feat(missions): an operator button to merge a mission's branch into main
`MergePolicy::Never` — the default for anything touching code — has always meant
"do not merge on your own", deferring to a human. There was no way for that human
to say yes: `auto_merge` was reachable only from the paper-harvest path, no
workflow template declares `merge_policy`, and every mission ended at a branch.
`POST /api/missions/{id}/merge` is that yes, with a button on the artifacts tab.
The additive-only gate does NOT apply here, deliberately: an operator reading a
code change is exactly the judgement the policy was holding out for.
What is not waived:
- the branch comes from the artifact delivery RECORDED, not rebuilt from the
mission id, and must have `pushed: true`. A phase that never pushed shows no
button instead of one that cannot work.
- an empty branch is refused. A button reporting success for merging nothing
is worse than no button.
- a conflict refuses, aborts, and leaves the repo clean rather than forcing.
It works in a FRESH CLONE under `_merge/<mission>`, never the mission checkout:
that directory is reaped on a timer after a mission ends, so a merge using it
would succeed right after a run and fail inexplicably an hour later. The clone is
made by the server process, so nothing runs as root and ordinary cleanup works —
unlike the copies in `root_copy`.
`merge_and_push` is split out so the operator path and the automatic path run the
SAME git commands; only the gates differ. A test asserts both call it, that the
operator path does not re-apply the additive gate it exists to bypass, and that
it still refuses an empty branch.
Harness 43/43 across all five recipes before this change, with `_gate`, `_bench`
and `_verify` all at zero.
246 lib tests, 20 binaries, 89 frontend tests, clean build.
This commit is contained in:
@@ -397,6 +397,107 @@ pub async fn artifact_content(
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/missions/{id}/merge — merge this mission's branch into the base.
|
||||
///
|
||||
/// The operator's button. `MergePolicy::Never` — the default for anything that
|
||||
/// touches code — means "do not merge on your own", deferring to a human; this
|
||||
/// endpoint is that human saying yes. So the additive-only test does not apply
|
||||
/// here, and deliberately so.
|
||||
///
|
||||
/// It works in a FRESH CLONE under `_merge/<mission>`, never the mission
|
||||
/// checkout: that directory is reaped on a timer after a mission ends, so a
|
||||
/// merge that used it would succeed right after a run and fail inexplicably an
|
||||
/// hour later. The clone is made by the server process, so nothing here runs as
|
||||
/// root and the ordinary cleanup works — unlike the copies in `root_copy`.
|
||||
pub async fn merge_branch(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let repo_id = mission.repo_id.ok_or(ApiError::BadRequest)?;
|
||||
let repo = cm_db::repo::repos::get(&state.pool, repo_id, user.workspace_id)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound)?;
|
||||
let clone_url = repo.clone_url.as_deref().ok_or(ApiError::BadRequest)?;
|
||||
let base = repo.default_branch.as_deref().unwrap_or("main");
|
||||
|
||||
// The branch is whatever delivery actually pushed — read from the artifact
|
||||
// it recorded, not reconstructed from the mission id. A phase that never
|
||||
// pushed has no branch, and that must be a refusal rather than a guess.
|
||||
let artifacts = cm_db::repo::missions::artifacts_for(&state.pool, id).await?;
|
||||
let delivered = artifacts.iter().rev().find_map(|a| {
|
||||
let m = a.metadata.as_object()?;
|
||||
let branch = m.get("branch")?.as_str()?.to_string();
|
||||
(m.get("pushed").and_then(|v| v.as_bool()) == Some(true)).then_some(branch)
|
||||
});
|
||||
let Some(branch) = delivered else {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"merged": false,
|
||||
"reason": "this mission has no pushed branch to merge",
|
||||
})));
|
||||
};
|
||||
|
||||
let auth = crate::mission_workspace::with_ambient_auth(clone_url);
|
||||
let workdir = crate::mission_workspace::missions_root()
|
||||
.join("_merge")
|
||||
.join(id.to_string());
|
||||
let _ = tokio::fs::remove_dir_all(&workdir).await;
|
||||
if let Some(parent) = workdir.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
let clone = tokio::process::Command::new("git")
|
||||
.args(["clone", "--quiet", &auth.url])
|
||||
.arg(&workdir)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)?;
|
||||
if !clone.status.success() {
|
||||
eprintln!(
|
||||
"missions::merge_branch: clone for {id} failed: {}",
|
||||
String::from_utf8_lossy(&clone.stderr)
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect::<String>()
|
||||
);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"merged": false,
|
||||
"reason": "could not clone the repository to merge",
|
||||
})));
|
||||
}
|
||||
|
||||
let outcome =
|
||||
crate::auto_merge::merge_on_operator_approval(&workdir, &auth.url, &branch, base).await;
|
||||
let _ = tokio::fs::remove_dir_all(&workdir).await;
|
||||
|
||||
match outcome {
|
||||
Ok(o) => {
|
||||
eprintln!(
|
||||
"missions::merge_branch: mission {id} branch {branch} -> {base}: {}",
|
||||
o.reason
|
||||
);
|
||||
Ok(Json(serde_json::json!({
|
||||
"merged": o.merged,
|
||||
"reason": o.reason,
|
||||
"branch": branch,
|
||||
"base": base,
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("missions::merge_branch: mission {id} failed: {e}");
|
||||
Ok(Json(serde_json::json!({
|
||||
"merged": false,
|
||||
"reason": format!("merge failed: {e}"),
|
||||
"branch": branch,
|
||||
"base": base,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/missions/{id}/benchmark — run the benchmark harness
|
||||
/// against a phase. Slot='baseline' records iteration 0's
|
||||
/// before_metrics; slot='after' with iteration=N records the
|
||||
|
||||
Reference in New Issue
Block a user