Five fixed steps for every mission type, and getting a research document out of it meant naming a team, choosing a runtime, and writing per-phase completion conditions under a paragraph explaining what a model checker can and cannot prove. Two of those steps asked for things the mission does not use, and one of them blocked outright. 1 What do you want to do? 2 Title, a description with a Polish button, repo ONLY if the type needs one 3 Review -> Launch, plus one collapsed Advanced section Two hard defects fixed on the way: - The microVM runtime could not be selected AT ALL. Step 4 gated Next on `targetNodeId`, which microVM deliberately never sets because placement picks the node per phase. Everything shipped today, the local-GPU backend included, was unreachable from the UI. - Step 3 required a team while every workflow TOML already names one in `default_team_template` — which this file ignored. The answer was always available and the question was always asked. It is now resolved by key, with a category fallback, and shown under Advanced so an operator can see WHICH default rather than having to supply one. A failed `/api/team-templates` request and a genuinely empty list rendered the identical red banner, which sends the reader looking for missing template files when the request had 401'd. They now say different things. `phases[]` is no longer sent unless someone set a completion condition. `recipeToPreset` strips each phase's `config`, so posting the stripped list overrode the recipe's real settings — tools, commit policy, loop mode — with nothing. Omitting it lets `phases_for_create` use the recipe, which is both simpler and more correct. Launch keeps its own gate, since Advanced can still produce an unlaunchable combination — but it names what is missing instead of greying out in silence. Artifacts get a Download link. Deliberately a plain link to the streaming route rather than a Blob built from what "Read" already fetched: that content is capped at 2 MiB and UTF-8-decoded, so reusing it would silently produce a truncated or undownloadable file for exactly the artifacts worth downloading. Co-Authored-By: Claude Opus 5 <[email protected]>
233 lines
7.8 KiB
TypeScript
233 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
// MissionArtifacts — the mission's captured FILES, and a reader for them.
|
|
//
|
|
// Distinct from MissionOutputReader, which shows agent turn output: that is the
|
|
// agent's ACCOUNT of the work, this is the work. For a repo-less research
|
|
// mission these artifacts are the only durable result — see
|
|
// `cm-api/src/mission_outputs.rs`.
|
|
//
|
|
// Bodies are fetched on demand. The mission detail carries paths only, and a
|
|
// research phase can leave dozens of documents.
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { Download, FileText, GitMerge } from "lucide-react";
|
|
|
|
import {
|
|
getArtifactContent,
|
|
mergeMissionBranch,
|
|
type ArtifactContent,
|
|
type MergeResult,
|
|
type MissionDetail,
|
|
} from "@/lib/api/missions";
|
|
import { MarkdownBlock } from "./MarkdownBlock";
|
|
|
|
const mono =
|
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
|
|
|
export function MissionArtifacts({
|
|
mission,
|
|
secondaryBtn,
|
|
}: {
|
|
mission: MissionDetail;
|
|
secondaryBtn: React.CSSProperties;
|
|
}) {
|
|
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);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
/**
|
|
* Open one artifact, fetching its text. Clicking the open one closes it.
|
|
*
|
|
* Errors surface in place rather than being swallowed: a file the server
|
|
* refuses to read (outside `_outputs`, or reaped with its mission) has to say
|
|
* so, or the panel sits empty and reads as a slow load that never finishes.
|
|
*/
|
|
const open = useCallback(
|
|
async (artifactId: string) => {
|
|
if (openId === artifactId) {
|
|
setOpenId(null);
|
|
return;
|
|
}
|
|
setOpenId(artifactId);
|
|
setText(null);
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
setText(await getArtifactContent(missionId, artifactId));
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "could not read this artifact");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[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 }}>
|
|
no artifacts yet — phases produce them as they run
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div key={a.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<div
|
|
style={{
|
|
padding: 11,
|
|
borderRadius: 10,
|
|
border: "1px solid rgba(255,255,255,.07)",
|
|
background: "#101014",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
|
|
{a.title ?? a.path.split("/").pop() ?? a.path}
|
|
</div>
|
|
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
|
|
{a.kind} · {a.path}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => open(a.id)}
|
|
style={{ ...secondaryBtn, padding: "5px 10px", fontSize: 11 }}
|
|
>
|
|
{isOpen ? "Hide" : "Read"}
|
|
</button>
|
|
{/* A plain link, not a fetch-and-Blob.
|
|
The read endpoint caps at 2 MiB and decodes as UTF-8, so
|
|
building the download from what "Read" already fetched would
|
|
inherit both limits and silently produce a truncated or
|
|
undownloadable file for exactly the artifacts worth
|
|
downloading. This hits the streaming route instead. */}
|
|
<a
|
|
href={`/api/missions/${missionId}/artifacts/${a.id}/download`}
|
|
download
|
|
style={{
|
|
...secondaryBtn,
|
|
padding: "5px 10px",
|
|
fontSize: 11,
|
|
textDecoration: "none",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
}}
|
|
>
|
|
<Download aria-hidden size={12} />
|
|
Download
|
|
</a>
|
|
</div>
|
|
{isOpen && (
|
|
<div
|
|
style={{
|
|
border: "1px solid rgba(255,255,255,.06)",
|
|
borderRadius: 8,
|
|
background: "#0a0a0d",
|
|
padding: 16,
|
|
maxHeight: 640,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}>
|
|
loading…
|
|
</div>
|
|
) : error ? (
|
|
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#ff8a7a" }}>
|
|
{error}
|
|
</div>
|
|
) : text?.truncated ? (
|
|
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}>
|
|
too large to display inline ({text.bytes.toLocaleString()}{" "}
|
|
bytes) — use Download
|
|
</div>
|
|
) : text ? (
|
|
<MarkdownBlock source={text.content} />
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|