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:
@@ -11,11 +11,13 @@
|
||||
// research phase can leave dozens of documents.
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, GitMerge } from "lucide-react";
|
||||
|
||||
import {
|
||||
getArtifactContent,
|
||||
mergeMissionBranch,
|
||||
type ArtifactContent,
|
||||
type MergeResult,
|
||||
type MissionDetail,
|
||||
} from "@/lib/api/missions";
|
||||
import { MarkdownBlock } from "./MarkdownBlock";
|
||||
@@ -32,6 +34,8 @@ export function MissionArtifacts({
|
||||
}) {
|
||||
const missionId = mission.id;
|
||||
const artifacts = mission.artifacts;
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [merge, setMerge] = useState<MergeResult | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [text, setText] = useState<ArtifactContent | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -65,6 +69,32 @@ export function MissionArtifacts({
|
||||
[missionId, openId],
|
||||
);
|
||||
|
||||
// The branch delivery actually pushed, read from the artifact that recorded
|
||||
// it — not rebuilt from the mission id, so a phase that never pushed shows no
|
||||
// button rather than a button that cannot work.
|
||||
const branch = artifacts
|
||||
.map((a) => (a.metadata ?? {}) as Record<string, unknown>)
|
||||
.filter((m) => m.pushed === true)
|
||||
.map((m) => (typeof m.branch === "string" ? m.branch : null))
|
||||
.filter(Boolean)
|
||||
.pop() as string | null;
|
||||
|
||||
const doMerge = async () => {
|
||||
if (merging) return;
|
||||
setMerging(true);
|
||||
setMerge(null);
|
||||
try {
|
||||
setMerge(await mergeMissionBranch(mission.id));
|
||||
} catch (e) {
|
||||
setMerge({
|
||||
merged: false,
|
||||
reason: e instanceof Error ? e.message : "merge request failed",
|
||||
});
|
||||
} finally {
|
||||
setMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
return (
|
||||
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92", padding: 12 }}>
|
||||
@@ -75,6 +105,42 @@ export function MissionArtifacts({
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{branch && (
|
||||
<div
|
||||
style={{
|
||||
padding: 11,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(124,214,224,.22)",
|
||||
background: "rgba(124,214,224,.05)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<GitMerge size={16} style={{ color: "#7cd6e0", flex: "none" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
|
||||
Merge this mission’s work
|
||||
</div>
|
||||
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
|
||||
{merge ? merge.reason : `${branch} → default branch`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={doMerge}
|
||||
disabled={merging || merge?.merged === true}
|
||||
style={{
|
||||
...secondaryBtn,
|
||||
padding: "5px 10px",
|
||||
fontSize: 11,
|
||||
opacity: merging || merge?.merged ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{merging ? "Merging…" : merge?.merged ? "Merged" : "Merge to main"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{artifacts.map((a) => {
|
||||
const isOpen = openId === a.id;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user