2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 a78f308eea fix(deploy): move registry :latest by manifest PUT — prod follows a 60s rolling timer
ci / rust (push) Failing after 10s
ci / gates (push) Successful in 6s
ci / frontend (push) Failing after 30s
ci / e2e (push) Skipped
ci / publish (push) Skipped
Deploys were verifying green and then silently reverting minutes later.
Cause: gw-04 does not deploy from this script's recreate at all.
`clawmates-deploy.timer` runs every 60s, pulls
`$REGISTRY/clawmates/<svc>:latest`, and rolls the stack onto it whenever
the running image differs — so the local `docker tag` + `--force-recreate`
this script did was reverted within the minute. Its own log shows it:

  server drift: running=<the new image> target=<the old :latest>
  rolling: server frontend

The registry's `:latest` is therefore the only thing that decides what
prod runs — and `docker push …:latest` does NOT reliably move it here.
When the manifest already exists under another tag (the `main-<sha>` we
push immediately before), the push reports a digest but `:latest` keeps
resolving to the old image. Pushing a brand-new tag works, so it is
specific to overwriting an existing one.

Writing the manifest to the tag over the registry HTTP API does move it
(GET the main-<sha> manifest, PUT that body to :latest → 201), after
which the timer converges prod on its own. So:

- repoint :latest via manifest PUT from the build host, failing loudly on
  a non-2xx instead of assuming the push landed
- roll gw-04 immediately rather than waiting up to 60s for the timer
- verify against the resolved :latest (what compose and the timer both
  deploy from) instead of a main-<sha> tag that is never pulled there

Note for future debugging: image IDs differ per host for the same tag
(buildx OCI index — tank holds the index digest, gw-04 the resolved
platform image), so the trustworthy check is grepping the deployed binary
for a string only the new code contains.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 11:33:35 +02:00
Omar SobhandClaude Opus 5 0785ac9c79 feat(missions): document reader + three-tab IA for the mission page
The mission page made its own output unreadable. Reviewing a research
brief meant scrolling a 300px <pre> nested inside a 260px run box nested
inside the page scroller (plus a 4th scroll region for the description) —
and the text was capped at 6,000 chars server-side with no way to fetch
the rest, so a 53kB brief showed ~11% of itself and silently dropped the
remainder. Eight flat tabs (overview/phases/tasks/team/live/artifacts/
benchmarks/pane) mixed lifecycle, work items, people, telemetry, outputs
and infra at one level, so nothing indicated where the deliverable lived.

Reader:
- GET /api/missions/{id}/documents lists every agent output (titles +
  sizes, no bodies); GET .../documents/{run_id}/{index} returns one in
  full. Scoped to the mission so a run id from elsewhere can't be read.
- MissionOutputReader: rail (documents grouped by phase) · document ·
  outline (headings, click to jump). Exactly one scroll container per
  column, never nested. Copy + download .md.
- MarkdownBlock gains fenced code blocks (agent output is full of ```rust,
  previously mangled into paragraphs), h4-h6, heading anchors, and an
  outlineOf() helper.

Information architecture:
- Three primary tabs with shallow sub-views: RUN (phases/tasks/live) ·
  OUTPUT (documents/artifacts/benchmarks) · SETUP (overview/team/pane).
- PhaseRunsList shows a short excerpt with no inner scrollbar and points
  at the reader for the full text.
- The header description is clipped, not scrollable; its full text now
  has a home in Setup → Overview.

Missions list:
- /api/missions returns MissionListItem — Mission flattened plus
  phases_total/phases_done/current_phase, so the JSON stays a strict
  superset. Cards render a progress bar and "Coding · 1/2" instead of a
  bare status dot.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:16:14 +02:00
11 changed files with 1217 additions and 89 deletions
+8
View File
@@ -463,6 +463,14 @@ pub fn router(state: AppState) -> Router {
patch(routes::missions::set_description),
)
.route("/api/missions/{id}/runs", get(routes::missions::list_runs))
.route(
"/api/missions/{id}/documents",
get(routes::missions::list_documents),
)
.route(
"/api/missions/{id}/documents/{run_id}/{index}",
get(routes::missions::get_document),
)
.route(
"/api/missions/{id}/phases/{phase_id}/retry",
post(routes::missions::retry_phase),
+272 -2
View File
@@ -101,18 +101,51 @@ pub struct SecurityScanResponse {
// ── Handlers ─────────────────────────────────────────────────────
/// A mission plus the phase progress the list card needs. `mission` is
/// flattened, so the JSON is a strict SUPERSET of `Mission` — existing
/// consumers keep working and simply gain fields.
#[derive(Debug, Serialize)]
pub struct MissionListItem {
#[serde(flatten)]
pub mission: Mission,
pub phases_total: i64,
pub phases_done: i64,
/// Kind of the phase currently running, if any.
pub current_phase: Option<String>,
}
pub async fn list(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<Mission>>, ApiError> {
) -> Result<Json<Vec<MissionListItem>>, ApiError> {
let rows = cm_db::repo::missions::list_by_workspace(
&state.pool,
user.workspace_id.as_uuid(),
q.limit.clamp(1, 500),
)
.await?;
Ok(Json(rows))
// One extra grouped query for the whole page, not one per mission.
let ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
let progress = cm_db::repo::missions::phase_progress(&state.pool, &ids).await?;
let by_id: std::collections::HashMap<Uuid, (i64, i64, Option<String>)> = progress
.into_iter()
.map(|(id, total, done, running)| (id, (total, done, running)))
.collect();
Ok(Json(
rows.into_iter()
.map(|m| {
let (phases_total, phases_done, current_phase) =
by_id.get(&m.id).cloned().unwrap_or((0, 0, None));
MissionListItem {
mission: m,
phases_total,
phases_done,
current_phase,
}
})
.collect(),
))
}
pub async fn create(
@@ -697,3 +730,240 @@ pub async fn set_status(
.ok_or(ApiError::NotFound)?;
Ok(Json(mission))
}
// ── Output reader ────────────────────────────────────────────────
//
// The mission Output tab is a document reader, not a log tail. The
// phase-card preview endpoint (`routes::topology::get_run_output`) caps
// every turn at 6,000 chars, which shows only ~11% of a typical research
// brief (they run 40–55kB) with no way to read the rest. These two routes
// are the reader's data source: one lists every document in the mission
// for the outline rail, the other returns one document in full.
/// One agent turn's output, as a readable document.
#[derive(Debug, Serialize)]
pub struct MissionDocument {
pub run_id: Uuid,
pub phase_id: Option<Uuid>,
/// Index into the run's `checkpoint.outputs` array.
pub index: usize,
/// Topology node id (`n0`) — stable within the run's graph.
pub node_id: String,
/// The node's role (`code_archeologist`), i.e. what this agent was.
pub role: String,
/// Human title: the document's first markdown heading when it has
/// one, else its first non-empty line.
pub title: String,
pub chars: usize,
pub run_status: String,
}
#[derive(Debug, Serialize)]
pub struct MissionDocumentsResponse {
pub documents: Vec<MissionDocument>,
}
/// Derive a display title from a document's own text: prefer the first
/// markdown ATX heading, else the first non-empty line. Both are trimmed
/// to keep the rail readable.
fn document_title(body: &str, fallback: &str) -> String {
const MAX: usize = 90;
let heading = body
.lines()
.map(str::trim)
.find(|l| l.starts_with('#'))
.map(|l| l.trim_start_matches('#').trim());
let line = heading.or_else(|| body.lines().map(str::trim).find(|l| !l.is_empty()));
match line {
Some(l) if !l.is_empty() => {
if l.chars().count() > MAX {
format!("{}…", l.chars().take(MAX).collect::<String>())
} else {
l.to_string()
}
}
_ => fallback.to_string(),
}
}
/// Map a run's graph node index → (node_id, role). The reader labels each
/// document by the agent that produced it; `checkpoint.outputs[i]`
/// corresponds to `graph.nodes[i]` (the worker appends one output per
/// step, in node order).
fn nodes_of(graph: Option<&Value>) -> Vec<(String, String)> {
graph
.and_then(|g| g.get("nodes"))
.and_then(|n| n.as_array())
.map(|arr| {
arr.iter()
.map(|n| {
(
n.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
n.get("role")
.and_then(|v| v.as_str())
.unwrap_or("agent")
.to_string(),
)
})
.collect()
})
.unwrap_or_default()
}
fn outputs_of(checkpoint: Option<&Value>) -> Vec<String> {
checkpoint
.and_then(|c| c.get("outputs"))
.and_then(|o| o.as_array())
.map(|arr| {
arr.iter()
.map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect()
})
.unwrap_or_default()
}
/// `GET /api/missions/{id}/documents` — every agent output in the mission,
/// oldest run first, as a flat list the reader groups by phase. Bodies are
/// NOT included; the rail only needs titles and sizes.
pub async fn list_documents(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<MissionDocumentsResponse>, ApiError> {
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let source =
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
let mut documents = Vec::new();
for (run_id, phase_id, run_status, graph, checkpoint) in source {
let nodes = nodes_of(graph.as_ref());
for (index, body) in outputs_of(checkpoint.as_ref()).into_iter().enumerate() {
let (node_id, role) = nodes
.get(index)
.cloned()
.unwrap_or_else(|| (format!("n{index}"), "agent".to_string()));
let fallback = format!("Turn {}", index + 1);
documents.push(MissionDocument {
run_id,
phase_id,
index,
node_id,
title: document_title(&body, &fallback),
role,
chars: body.chars().count(),
run_status: run_status.clone(),
});
}
}
Ok(Json(MissionDocumentsResponse { documents }))
}
#[derive(Debug, Serialize)]
pub struct MissionDocumentBody {
pub run_id: Uuid,
pub index: usize,
pub role: String,
pub title: String,
/// The complete output text — untruncated, which is the whole point.
pub body: String,
pub chars: usize,
}
/// `GET /api/missions/{id}/documents/{run_id}/{index}` — one document in
/// full. Separate from the list so opening the Output tab doesn't pull
/// every brief in the mission over the wire at once.
pub async fn get_document(
State(state): State<AppState>,
Authed(user): Authed,
Path((id, run_id, index)): Path<(Uuid, Uuid, usize)>,
) -> Result<Json<MissionDocumentBody>, ApiError> {
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
// Scope the run to the mission as well, so a valid run id from another
// mission (or workspace) can't be read through this path.
let source =
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
let (_, _, _, graph, checkpoint) = source
.into_iter()
.find(|(rid, _, _, _, _)| *rid == run_id)
.ok_or(ApiError::NotFound)?;
let body = outputs_of(checkpoint.as_ref())
.into_iter()
.nth(index)
.ok_or(ApiError::NotFound)?;
let role = nodes_of(graph.as_ref())
.get(index)
.map(|(_, r)| r.clone())
.unwrap_or_else(|| "agent".to_string());
let fallback = format!("Turn {}", index + 1);
Ok(Json(MissionDocumentBody {
run_id,
index,
title: document_title(&body, &fallback),
role,
chars: body.chars().count(),
body,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn title_prefers_first_markdown_heading() {
let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext";
assert_eq!(document_title(body, "Turn 1"), "ClawHDF5 Research Report");
}
#[test]
fn title_falls_back_to_first_nonempty_line() {
let body = "\n\n Architecture notes for the io crate\nmore\n";
assert_eq!(
document_title(body, "Turn 1"),
"Architecture notes for the io crate"
);
}
#[test]
fn title_falls_back_to_label_when_empty() {
assert_eq!(document_title(" \n\n", "Turn 3"), "Turn 3");
}
#[test]
fn title_is_truncated() {
let body = format!("# {}", "x".repeat(200));
let t = document_title(&body, "Turn 1");
assert!(t.ends_with('…'));
assert_eq!(t.chars().count(), 91);
}
#[test]
fn nodes_and_outputs_are_positionally_aligned() {
let graph = serde_json::json!({
"nodes": [
{"id": "n0", "role": "code_archeologist"},
{"id": "n1", "role": "architecture_mapper"}
]
});
let cp = serde_json::json!({ "outputs": ["first brief", "second brief"] });
let nodes = nodes_of(Some(&graph));
let outs = outputs_of(Some(&cp));
assert_eq!(nodes[1], ("n1".into(), "architecture_mapper".into()));
assert_eq!(outs[1], "second brief");
}
#[test]
fn missing_graph_or_checkpoint_yields_no_documents() {
assert!(nodes_of(None).is_empty());
assert!(outputs_of(None).is_empty());
assert!(outputs_of(Some(&serde_json::json!({}))).is_empty());
}
}
+39
View File
@@ -743,3 +743,42 @@ pub async fn benchmark_snapshots_for(
})
.collect())
}
/// Phase progress for a set of missions, for the missions list cards.
/// Returns `(mission_id, total, done, running_phase_kind)`.
///
/// A status dot alone doesn't tell you where a mission actually is; this
/// is what lets a card say "Coding · 1/2" instead of just "running".
pub async fn phase_progress(
pool: &PgPool,
mission_ids: &[Uuid],
) -> Result<Vec<(Uuid, i64, i64, Option<String>)>, DbError> {
use sqlx::Row;
if mission_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query(
"SELECT mission_id,
count(*) AS total,
count(*) FILTER (WHERE status IN ('completed','skipped')) AS done,
(array_agg(kind ORDER BY order_idx)
FILTER (WHERE status = 'running'))[1] AS running_kind
FROM mission_phases
WHERE mission_id = ANY($1)
GROUP BY mission_id",
)
.bind(mission_ids)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.get("mission_id"),
r.get("total"),
r.get("done"),
r.get("running_kind"),
)
})
.collect())
}
+35
View File
@@ -476,3 +476,38 @@ pub async fn status(
updated_at: row.updated_at,
})
}
/// Graph + checkpoint for every run of a mission, oldest first — the raw
/// material the mission Output reader turns into a document list.
///
/// Deliberately returns the FULL checkpoint: the reader exists precisely
/// because the 6kB preview in `routes::topology::get_run_output` throws
/// away ~90% of a research brief. This is fetched on demand when the
/// operator opens the Output tab, never on a poll loop.
pub async fn documents_source_for_mission(
pool: &PgPool,
mission_id: Uuid,
) -> Result<Vec<(Uuid, Option<Uuid>, String, Option<Value>, Option<Value>)>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, mission_phase_id, status, graph, checkpoint
FROM topology_runs
WHERE mission_id = $1
ORDER BY created_at ASC",
)
.bind(mission_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.get("id"),
r.get("mission_phase_id"),
r.get("status"),
r.get("graph"),
r.get("checkpoint"),
)
})
.collect())
}
@@ -12,10 +12,38 @@ const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type Block =
| { kind: "h1" | "h2" | "h3"; text: string }
| { kind: "h1" | "h2" | "h3"; text: string; id?: string }
| { kind: "p"; text: string }
| { kind: "ul"; items: string[] }
| { kind: "ol"; items: string[] };
| { kind: "ol"; items: string[] }
| { kind: "code"; lang: string; text: string };
/** Stable slug for a heading, so the reader's outline can scroll to it. */
export function headingId(text: string, ordinal: number): string {
const slug = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
return `h-${ordinal}-${slug || "section"}`;
}
/** The headings of a document, for an outline rail. */
export function outlineOf(
md: string,
): Array<{ id: string; text: string; level: 1 | 2 | 3 }> {
return parse(md).flatMap((b, idx) =>
b.kind === "h1" || b.kind === "h2" || b.kind === "h3"
? [
{
id: b.id ?? headingId(b.text, idx),
text: b.text,
level: Number(b.kind.slice(1)) as 1 | 2 | 3,
},
]
: [],
);
}
function parse(md: string): Block[] {
const lines = md.replace(/\r\n/g, "\n").split("\n");
@@ -28,13 +56,32 @@ function parse(md: string): Block[] {
i++;
continue;
}
// Fenced code block. Agent output is full of ```rust / ```toml
// blocks; without this they render as mangled paragraphs.
const fence = /^```([A-Za-z0-9_+-]*)\s*$/.exec(trimmed);
if (fence) {
const lang = fence[1] ?? "";
const body: string[] = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
body.push(lines[i]);
i++;
}
i++; // consume the closing fence (or run off the end on an unclosed block)
blocks.push({ kind: "code", lang, text: body.join("\n") });
continue;
}
// Headings
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
const h = /^(#{1,6})\s+(.*)$/.exec(trimmed);
if (h) {
const level = h[1].length as 1 | 2 | 3;
// h4-h6 are rare in agent output; render them as h3 rather than
// dropping the text into a paragraph.
const level = Math.min(h[1].length, 3) as 1 | 2 | 3;
const text = h[2];
blocks.push({
kind: (`h${level}` as "h1" | "h2" | "h3"),
text: h[2],
text,
id: headingId(text, blocks.length),
});
i++;
continue;
@@ -64,7 +111,8 @@ function parse(md: string): Block[] {
while (
i < lines.length &&
lines[i].trim() &&
!/^(#{1,3})\s+/.test(lines[i].trim()) &&
!/^(#{1,6})\s+/.test(lines[i].trim()) &&
!/^```/.test(lines[i].trim()) &&
!/^[-*]\s+/.test(lines[i].trim()) &&
!/^\d+\.\s+/.test(lines[i].trim())
) {
@@ -133,6 +181,7 @@ export function MarkdownBlock({ source }: { source: string }) {
return (
<h1
key={idx}
id={b.id}
style={{
margin: "8px 0 2px",
fontSize: 17,
@@ -148,6 +197,7 @@ export function MarkdownBlock({ source }: { source: string }) {
return (
<h2
key={idx}
id={b.id}
style={{
margin: "10px 0 -2px",
fontSize: 12,
@@ -165,6 +215,7 @@ export function MarkdownBlock({ source }: { source: string }) {
return (
<h3
key={idx}
id={b.id}
style={{
margin: "6px 0 -4px",
fontSize: 11.5,
@@ -178,6 +229,43 @@ export function MarkdownBlock({ source }: { source: string }) {
{renderInline(b.text)}
</h3>
);
if (b.kind === "code")
return (
<pre
key={idx}
style={{
margin: 0,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.07)",
background: "rgba(0,0,0,.45)",
color: "#e0e0e5",
fontFamily: mono,
fontSize: 11.5,
lineHeight: 1.5,
// Code is the one thing that may scroll sideways; the
// page itself must never scroll horizontally.
overflowX: "auto",
whiteSpace: "pre",
}}
>
{b.lang && (
<span
style={{
display: "block",
marginBottom: 6,
fontSize: 9.5,
letterSpacing: ".12em",
textTransform: "uppercase",
color: "#6a6a72",
}}
>
{b.lang}
</span>
)}
<code>{b.text}</code>
</pre>
);
if (b.kind === "p")
return (
<p key={idx} style={{ margin: 0 }}>
@@ -44,6 +44,7 @@ import { EditMissionModal } from "./EditMissionModal";
import { MarkdownBlock } from "./MarkdownBlock";
import { MissionLiveEvents } from "./MissionLiveEvents";
import { MissionLivePane } from "./MissionLivePane";
import { MissionOutputReader } from "./MissionOutputReader";
import { MissionTeamTab } from "./MissionTeamTab";
import { MissionWizard } from "./MissionWizard";
import { PhaseRunsList } from "./PhaseRunsList";
@@ -89,15 +90,19 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
custom: "Custom",
};
type Tab =
| "overview"
| "phases"
| "tasks"
| "team"
| "live"
| "artifacts"
| "benchmarks"
| "pane";
// Three primary tabs, each with a shallow segmented sub-view. The old
// shape was eight flat tabs (overview/phases/tasks/team/live/artifacts/
// benchmarks/pane) that mixed lifecycle, work items, people, telemetry,
// outputs and infra at one level — so nothing told you where the actual
// deliverable lived (it was buried under phases → run → turn).
//
// RUN what is happening · phases · tasks · live
// OUTPUT what came out of it · documents · artifacts · benchmarks
// SETUP how it is configured · overview · team · pane
type Tab = "run" | "output" | "setup";
type RunSub = "phases" | "tasks" | "live";
type OutputSub = "documents" | "artifacts" | "benchmarks";
type SetupSub = "overview" | "team" | "pane";
export function MissionCanvas({
selectedId,
@@ -118,7 +123,10 @@ export function MissionCanvas({
const [mission, setMission] = useState<MissionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const [tab, setTab] = useState<Tab>("run");
const [runSub, setRunSub] = useState<RunSub>("phases");
const [outputSub, setOutputSub] = useState<OutputSub>("documents");
const [setupSub, setSetupSub] = useState<SetupSub>("overview");
const [launching, setLaunching] = useState(false);
const [refining, setRefining] = useState(false);
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
@@ -540,12 +548,19 @@ export function MissionCanvas({
)}
</div>
{mission.description && !headerCollapsed && (
// Clipped, NOT scrollable — a scroll container here was a fourth
// nested scrollbar above the content area. The full text lives in
// Setup → Overview.
<div
style={{
marginTop: 4,
maxHeight: "38vh",
overflowY: "auto",
maxHeight: 92,
overflow: "hidden",
paddingRight: 8,
maskImage:
"linear-gradient(to bottom, #000 60%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, #000 60%, transparent 100%)",
}}
>
<MarkdownBlock source={mission.description} />
@@ -582,52 +597,97 @@ export function MissionCanvas({
}}
/>
)}
{/* Primary tabs — three, not eight. */}
<div
style={{
display: "flex",
gap: 4,
marginTop: 4,
marginTop: 6,
overflowX: "auto",
paddingBottom: 2,
scrollbarWidth: "thin",
}}
>
{(
[
"overview",
"phases",
"tasks",
"team",
"live",
"artifacts",
"benchmarks",
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
] as Tab[]
).map((t) => {
{(["run", "output", "setup"] as Tab[]).map((t) => {
const active = tab === t;
const badge =
t === "tasks"
? mission.tasks.length
: t === "artifacts"
? mission.artifacts.length
: t === "phases"
? mission.phases.length
: t === "benchmarks"
? mission.benchmarks.length
: null;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
style={{
padding: "5px 12px",
padding: "6px 16px",
borderRadius: 8,
border: `1px solid ${active ? "rgba(255,138,122,.5)" : "rgba(255,255,255,.08)"}`,
background: active ? "rgba(255,138,122,.08)" : "transparent",
color: active ? "#ff8a7a" : "#a0a0a8",
fontFamily: mono,
fontSize: 11,
letterSpacing: ".10em",
textTransform: "uppercase",
fontWeight: active ? 600 : 400,
cursor: "pointer",
flex: "none",
whiteSpace: "nowrap",
}}
>
{t}
</button>
);
})}
</div>
{/* Sub-view for the active tab. */}
<div
style={{
display: "flex",
gap: 3,
marginTop: 6,
overflowX: "auto",
paddingBottom: 2,
scrollbarWidth: "thin",
}}
>
{(tab === "run"
? ([
["phases", mission.phases.length],
["tasks", mission.tasks.length],
["live", null],
] as Array<[string, number | null]>)
: tab === "output"
? ([
["documents", null],
["artifacts", mission.artifacts.length],
["benchmarks", mission.benchmarks.length],
] as Array<[string, number | null]>)
: ([
["overview", null],
["team", null],
...(mission.runtime_kind === "local_herdr"
? ([["pane", null]] as Array<[string, number | null]>)
: []),
] as Array<[string, number | null]>)
).map(([sub, badge]) => {
const current =
tab === "run" ? runSub : tab === "output" ? outputSub : setupSub;
const active = current === sub;
return (
<button
key={sub}
type="button"
onClick={() => {
if (tab === "run") setRunSub(sub as RunSub);
else if (tab === "output") setOutputSub(sub as OutputSub);
else setSetupSub(sub as SetupSub);
}}
style={{
padding: "3px 10px",
borderRadius: 999,
border: "1px solid transparent",
background: active ? "rgba(255,255,255,.07)" : "transparent",
color: active ? "#e0e0e5" : "#8a8a92",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".08em",
textTransform: "uppercase",
cursor: "pointer",
@@ -638,16 +698,9 @@ export function MissionCanvas({
whiteSpace: "nowrap",
}}
>
{t}
{sub}
{badge !== null && (
<span
style={{
fontSize: 10,
color: active ? "#ff8a7a" : "#8a8a92",
}}
>
{badge}
</span>
<span style={{ fontSize: 9.5, color: "#6a6a72" }}>{badge}</span>
)}
</button>
);
@@ -655,9 +708,44 @@ export function MissionCanvas({
</div>
</div>
{/* The documents reader manages its own columns + scrolling, so it
renders full-bleed. Everything else lives in one padded scroller
— never a scroll container inside a scroll container. */}
{tab === "output" && outputSub === "documents" ? (
<MissionOutputReader
missionId={mission.id}
phases={mission.phases}
visible
/>
) : (
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
{tab === "overview" && (
{tab === "setup" && setupSub === "overview" && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{mission.description && (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
marginBottom: 4,
}}
>
<div
style={{
fontFamily: mono,
fontSize: 9.5,
letterSpacing: ".14em",
textTransform: "uppercase",
color: "#6a6a72",
marginBottom: 8,
}}
>
Brief
</div>
<MarkdownBlock source={mission.description} />
</div>
)}
<FieldRow k="Template" v={TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} />
<FieldRow k="Status" v={mission.status} />
<FieldRow k="Team" v={mission.team_id ?? "(auto-provision on launch)"} />
@@ -679,7 +767,7 @@ export function MissionCanvas({
</div>
)}
{tab === "phases" && (
{tab === "run" && runSub === "phases" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{orderedPhases.length === 0 ? (
<Empty label="no phases" />
@@ -841,7 +929,7 @@ export function MissionCanvas({
</div>
)}
{tab === "tasks" && (
{tab === "run" && runSub === "tasks" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.tasks.length === 0 ? (
<Empty label="no tasks yet — task-card parser lands in Slice 5" />
@@ -910,7 +998,7 @@ export function MissionCanvas({
</div>
)}
{tab === "team" && (
{tab === "setup" && setupSub === "team" && (
<MissionTeamTab
missionId={mission.id}
teamId={mission.team_id}
@@ -918,11 +1006,11 @@ export function MissionCanvas({
/>
)}
{tab === "live" && (
<MissionLiveEvents missionId={mission.id} visible={tab === "live"} />
{tab === "run" && runSub === "live" && (
<MissionLiveEvents missionId={mission.id} visible={tab === "run" && runSub === "live"} />
)}
{tab === "artifacts" && (
{tab === "output" && outputSub === "artifacts" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.artifacts.length === 0 ? (
<Empty label="no artifacts yet — phases produce them as they run" />
@@ -1018,7 +1106,7 @@ export function MissionCanvas({
</div>
)}
{tab === "benchmarks" && (
{tab === "output" && outputSub === "benchmarks" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.benchmarks.length === 0 ? (
<Empty label="no benchmark snapshots yet — trigger a baseline via /api/missions/{id}/benchmark or a workflow with benchmark = { mode = &quot;before_after&quot; }" />
@@ -1151,13 +1239,14 @@ export function MissionCanvas({
</div>
)}
{tab === "pane" && mission.runtime_kind === "local_herdr" && (
{tab === "setup" && setupSub === "pane" && mission.runtime_kind === "local_herdr" && (
<MissionLivePane
nodeId={mission.target_node_id}
visible={tab === "pane"}
visible={tab === "setup" && setupSub === "pane"}
/>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,457 @@
"use client";
// MissionOutputReader — the mission's reading surface.
//
// Agent phases produce 40–55kB markdown briefs per turn. Before this,
// the only way to see them was a 300px-tall <pre> nested inside a 260px
// run box inside the page scroller, showing the first 6,000 chars with
// no way to reach the rest. This replaces that with a document reader:
//
// left rail every document in the mission, grouped by phase
// right pane the selected document IN FULL, rendered as markdown
// outline that document's headings, click to jump
//
// Exactly ONE scroll container per column — no nesting. The rail and the
// document scroll independently; the page body never scrolls.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Copy, Download, FileText, Loader2 } from "lucide-react";
import {
getMissionDocument,
listMissionDocuments,
type MissionDocument,
type MissionPhase,
type PhaseKind,
} from "@/lib/api/missions";
import { MarkdownBlock, outlineOf } from "./MarkdownBlock";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const PHASE_LABEL: Record<PhaseKind, string> = {
research: "Research",
coding: "Coding",
benchmark: "Benchmark",
security_scan: "Security scan",
};
/** `code_archeologist` → `Code Archeologist`. */
function humanRole(role: string): string {
return role
.split(/[_\s]+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
function sizeLabel(chars: number): string {
return chars >= 1000 ? `${Math.round(chars / 1000)}k` : `${chars}`;
}
type DocKey = string;
const keyOf = (d: Pick<MissionDocument, "run_id" | "index">): DocKey =>
`${d.run_id}:${d.index}`;
export function MissionOutputReader({
missionId,
phases,
visible,
}: {
missionId: string;
phases: MissionPhase[];
visible: boolean;
}) {
const [docs, setDocs] = useState<MissionDocument[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<DocKey | null>(null);
const [body, setBody] = useState<string>("");
const [bodyLoading, setBodyLoading] = useState(false);
const [copied, setCopied] = useState(false);
const docScroll = useRef<HTMLDivElement | null>(null);
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await listMissionDocuments(missionId);
setDocs(res.documents);
// Default to the newest document so the pane is never empty.
setSelected((prev) => {
if (prev && res.documents.some((d) => keyOf(d) === prev)) return prev;
const last = res.documents[res.documents.length - 1];
return last ? keyOf(last) : null;
});
} catch (e) {
setError(e instanceof Error ? e.message : "failed to load documents");
} finally {
setLoading(false);
}
}, [missionId]);
useEffect(() => {
if (!visible) return;
void loadList();
}, [visible, loadList]);
const selectedDoc = useMemo(
() => docs.find((d) => keyOf(d) === selected) ?? null,
[docs, selected],
);
// Fetch the selected document's full text.
useEffect(() => {
if (!visible || !selectedDoc) {
setBody("");
return;
}
let alive = true;
setBodyLoading(true);
getMissionDocument(missionId, selectedDoc.run_id, selectedDoc.index)
.then((d) => {
if (!alive) return;
setBody(d.body);
setError(null);
// A new document starts at the top, not wherever the last one sat.
docScroll.current?.scrollTo({ top: 0 });
})
.catch((e) => {
if (!alive) return;
setError(e instanceof Error ? e.message : "failed to load document");
setBody("");
})
.finally(() => {
if (alive) setBodyLoading(false);
});
return () => {
alive = false;
};
}, [missionId, selectedDoc, visible]);
const outline = useMemo(() => (body ? outlineOf(body) : []), [body]);
// Group documents under their phase, in phase order. Documents whose
// phase is unknown (ad-hoc runs) collect under a trailing bucket.
const groups = useMemo(() => {
const byPhase = new Map<string, MissionDocument[]>();
for (const d of docs) {
const k = d.phase_id ?? "__unphased";
const list = byPhase.get(k);
if (list) list.push(d);
else byPhase.set(k, [d]);
}
const ordered = [...phases]
.sort((a, b) => a.order_idx - b.order_idx)
.filter((p) => byPhase.has(p.id))
.map((p) => ({
id: p.id,
label: `${PHASE_LABEL[p.kind] ?? p.kind}`,
docs: byPhase.get(p.id) ?? [],
}));
const loose = byPhase.get("__unphased");
if (loose?.length) {
ordered.push({
id: "__unphased",
label: "Other runs",
docs: loose,
});
}
return ordered;
}, [docs, phases]);
const jumpTo = useCallback((id: string) => {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
const copyBody = useCallback(async () => {
try {
await navigator.clipboard.writeText(body);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard can be blocked; the download button is the fallback.
}
}, [body]);
const downloadBody = useCallback(() => {
if (!selectedDoc) return;
const blob = new Blob([body], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${selectedDoc.role}-${selectedDoc.index + 1}.md`;
a.click();
URL.revokeObjectURL(url);
}, [body, selectedDoc]);
if (loading && docs.length === 0) {
return (
<div style={{ padding: 22, fontFamily: mono, fontSize: 11, color: "#5ec8d8" }}>
Loading documents…
</div>
);
}
if (!loading && docs.length === 0) {
return (
<div style={{ padding: 22, display: "flex", flexDirection: "column", gap: 8 }}>
<span style={{ fontSize: 13, color: "#cfcfd5" }}>
No agent output yet.
</span>
<span style={{ fontSize: 12, color: "#8a8a92", lineHeight: 1.5 }}>
Documents appear here as each phase&apos;s agents finish their turns.
</span>
{error && (
<span style={{ fontSize: 12, color: "#ff8a7a" }}>{error}</span>
)}
</div>
);
}
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "grid",
// rail · document · outline. The outline collapses away on
// narrow viewports so the document keeps its reading width.
gridTemplateColumns: "230px minmax(0, 1fr) 200px",
alignItems: "stretch",
}}
>
{/* ── rail: every document, grouped by phase ── */}
<div
style={{
minHeight: 0,
overflowY: "auto",
borderRight: "1px solid rgba(255,255,255,.07)",
padding: "12px 8px",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{groups.map((g) => (
<div key={g.id} style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div
style={{
fontFamily: mono,
fontSize: 9.5,
letterSpacing: ".14em",
textTransform: "uppercase",
color: "#6a6a72",
padding: "0 6px 2px",
}}
>
{g.label} · {g.docs.length}
</div>
{g.docs.map((d) => {
const k = keyOf(d);
const active = k === selected;
return (
<button
key={k}
type="button"
onClick={() => setSelected(k)}
title={d.title}
style={{
textAlign: "left",
padding: "6px 8px",
borderRadius: 8,
border: `1px solid ${active ? "rgba(255,138,122,.45)" : "transparent"}`,
background: active ? "rgba(255,138,122,.08)" : "transparent",
cursor: "pointer",
display: "flex",
flexDirection: "column",
gap: 2,
minWidth: 0,
}}
>
<span
style={{
fontSize: 11.5,
color: active ? "#ff8a7a" : "#cfcfd5",
fontWeight: active ? 600 : 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{humanRole(d.role)}
</span>
<span
style={{
fontFamily: mono,
fontSize: 9.5,
color: "#6a6a72",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{d.node_id} · {sizeLabel(d.chars)} chars
</span>
</button>
);
})}
</div>
))}
</div>
{/* ── document: the only place long-form content is read ── */}
<div
ref={docScroll}
style={{
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
padding: "18px 26px 60px",
}}
>
{error && (
<div style={{ marginBottom: 12, fontSize: 12, color: "#ff8a7a" }}>
{error}
</div>
)}
{selectedDoc && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
marginBottom: 14,
paddingBottom: 12,
borderBottom: "1px solid rgba(255,255,255,.07)",
}}
>
<FileText size={15} style={{ color: "#7cd6e0", flex: "none", marginTop: 3 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, color: "#f3f3f5", fontWeight: 600 }}>
{selectedDoc.title}
</div>
<div
style={{
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
marginTop: 3,
}}
>
{humanRole(selectedDoc.role)} · {selectedDoc.node_id} ·{" "}
{selectedDoc.chars.toLocaleString()} chars
</div>
</div>
<button
type="button"
onClick={copyBody}
disabled={!body}
title="Copy the full document"
style={readerBtn}
>
<Copy size={12} /> {copied ? "Copied" : "Copy"}
</button>
<button
type="button"
onClick={downloadBody}
disabled={!body}
title="Download as .md"
style={readerBtn}
>
<Download size={12} /> .md
</button>
</div>
)}
{bodyLoading ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: mono,
fontSize: 11,
color: "#5ec8d8",
}}
>
<Loader2 size={13} className="animate-spin" /> Loading document…
</div>
) : body ? (
<MarkdownBlock source={body} />
) : null}
</div>
{/* ── outline: headings of the open document ── */}
<div
style={{
minHeight: 0,
overflowY: "auto",
borderLeft: "1px solid rgba(255,255,255,.07)",
padding: "16px 10px",
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<div
style={{
fontFamily: mono,
fontSize: 9.5,
letterSpacing: ".14em",
textTransform: "uppercase",
color: "#6a6a72",
padding: "0 6px 6px",
}}
>
Outline
</div>
{outline.length === 0 ? (
<span style={{ padding: "0 6px", fontSize: 11, color: "#6a6a72" }}>
No headings
</span>
) : (
outline.map((h) => (
<button
key={h.id}
type="button"
onClick={() => jumpTo(h.id)}
title={h.text}
style={{
textAlign: "left",
padding: "3px 6px",
paddingLeft: 6 + (h.level - 1) * 10,
borderRadius: 6,
border: "1px solid transparent",
background: "transparent",
cursor: "pointer",
color: h.level === 1 ? "#cfcfd5" : "#8a8a92",
fontSize: h.level === 1 ? 11.5 : 11,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{h.text}
</button>
))
)}
</div>
</div>
);
}
const readerBtn: React.CSSProperties = {
flex: "none",
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "4px 9px",
borderRadius: 7,
border: "1px solid rgba(255,255,255,.10)",
background: "transparent",
color: "#a0a0a8",
fontFamily: mono,
fontSize: 10,
cursor: "pointer",
};
@@ -11,8 +11,9 @@ import { Plus, RotateCw, Trash2, Wrench } from "lucide-react";
import {
deleteMission,
listMissions,
type Mission,
type MissionListItem,
type MissionStatus,
type PhaseKind,
type TemplateKind,
} from "@/lib/api/missions";
import { MissionWizard } from "./MissionWizard";
@@ -52,7 +53,7 @@ export function MissionsList({
* currently-open mission was among the deleted rows. */
onDeleted?: (deletedIds: string[]) => void;
}) {
const [missions, setMissions] = useState<Mission[]>([]);
const [missions, setMissions] = useState<MissionListItem[]>([]);
const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -351,6 +352,49 @@ export function MissionsList({
>
{m.title}
</div>
{/* Where the mission actually IS. A status dot alone
doesn't distinguish "just launched" from "nearly done". */}
{m.phases_total > 0 && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 2,
}}
>
<div
style={{
flex: 1,
height: 3,
borderRadius: 2,
background: "rgba(255,255,255,.08)",
overflow: "hidden",
}}
>
<div
style={{
width: `${Math.round((m.phases_done / m.phases_total) * 100)}%`,
height: "100%",
background: STATUS_COLOR[m.status],
}}
/>
</div>
<span
style={{
fontFamily: mono,
fontSize: 9,
color: "#6a6a72",
flex: "none",
}}
>
{m.current_phase
? `${PHASE_LABEL[m.current_phase] ?? m.current_phase} · `
: ""}
{m.phases_done}/{m.phases_total}
</span>
</div>
)}
</button>
);
})
@@ -371,6 +415,13 @@ export function MissionsList({
);
}
const PHASE_LABEL: Record<PhaseKind, string> = {
research: "Research",
coding: "Coding",
benchmark: "Benchmark",
security_scan: "Security",
};
const iconBtn: React.CSSProperties = {
width: 26,
height: 26,
@@ -141,6 +141,14 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
);
}
/** First few lines of a turn — enough to recognize it in the timeline,
* short enough not to need its own scrollbar. */
function excerpt(text: string, lines = 12): string {
const parts = text.split("\n");
if (parts.length <= lines) return text;
return `${parts.slice(0, lines).join("\n")}\n…`;
}
function RunOutputPanel({ runId }: { runId: string }) {
const [data, setData] = useState<RunOutput | null>(null);
const [err, setErr] = useState<string | null>(null);
@@ -238,6 +246,10 @@ function RunOutputPanel({ runId }: { runId: string }) {
</span>
)}
</summary>
{/* A SHORT excerpt only — no inner scrollbar. This used to be
a 300px scroll box nested inside the 260px run box inside
the page scroller, which made long output unreadable. The
full document lives in the Output tab's reader. */}
<pre
style={{
margin: "4px 0 0",
@@ -248,12 +260,24 @@ function RunOutputPanel({ runId }: { runId: string }) {
fontSize: 10.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 300,
overflow: "auto",
}}
>
{o.preview}
{excerpt(o.preview)}
</pre>
<span
style={{
display: "block",
marginTop: 4,
fontSize: 10,
color: "#6a6a72",
}}
>
{o.full_len.toLocaleString()} chars · open the{" "}
<strong style={{ color: "#7cd6e0", fontWeight: 600 }}>
Output
</strong>{" "}
tab to read this in full
</span>
</details>
);
})
+49 -1
View File
@@ -196,8 +196,16 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
return (await r.json()) as T;
}
/** A list row: `Mission` plus phase progress for the card. */
export interface MissionListItem extends Mission {
phases_total: number;
phases_done: number;
/** Kind of the phase currently running, if any. */
current_phase: PhaseKind | null;
}
export const listMissions = (limit = 50) =>
api<Mission[]>(`/api/missions?limit=${limit}`);
api<MissionListItem[]>(`/api/missions?limit=${limit}`);
export const getMission = (id: string) =>
api<MissionDetail>(`/api/missions/${id}`);
@@ -250,6 +258,46 @@ export interface RunOutput {
export const getRunOutput = (runId: string) =>
api<RunOutput>(`/api/topology-runs/${runId}/output`);
// ── Output reader ────────────────────────────────────────────────
// `getRunOutput` above caps each turn at 6,000 chars server-side, which
// is only ~11% of a typical research brief. These two power the Output
// tab's reader, which shows documents in full.
export interface MissionDocument {
run_id: string;
phase_id: string | null;
index: number;
node_id: string;
role: string;
title: string;
chars: number;
run_status: string;
}
export interface MissionDocumentBody {
run_id: string;
index: number;
role: string;
title: string;
/** Complete, untruncated output text. */
body: string;
chars: number;
}
/** Every agent output in the mission — titles + sizes only, no bodies. */
export const listMissionDocuments = (id: string) =>
api<{ documents: MissionDocument[] }>(`/api/missions/${id}/documents`);
/** One document, in full. Fetched on selection, not with the list. */
export const getMissionDocument = (
id: string,
runId: string,
index: number,
) =>
api<MissionDocumentBody>(
`/api/missions/${id}/documents/${runId}/${index}`,
);
export interface PhaseSummarySource {
title?: string;
note?: string;
+39 -20
View File
@@ -79,25 +79,43 @@ ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
done'
if [ -z "${IMAGES_ONLY:-}" ]; then
echo "→ pull + recreate server + frontend on $GW ($GW_DIR)"
# Snapshot the currently-deployed images as :rollback (a repoint, cheap) so a
# bad deploy can be reverted without a rebuild, then pull the freshly-pushed
# images and recreate.
# Pull the IMMUTABLE main-<sha> tag and retag it to :latest locally, then
# recreate WITHOUT a compose pull. Pulling `:latest` here is not reliable —
# the registry has served a stale manifest for that mutable tag (a deploy
# pushed main-9bc5f6a fine, but `pull :latest` reported "up to date" and
# left the OLD image running). Immutable tags always resolve correctly, so
# the sha tag is the source of truth and `:latest` is just a local alias
# for the compose file's image reference.
echo "→ repoint registry :latest → main-$SHA"
# gw-04 does NOT deploy from this script's push alone. A systemd timer
# (clawmates-deploy.timer, every 60s, /usr/local/bin/clawmates-deploy.sh)
# pulls `$REGISTRY/clawmates/<svc>:latest` and rolls the stack onto it
# whenever the running image differs. So ANY local `docker tag`/recreate on
# the gateway is reverted within a minute — the registry's `:latest` is the
# single source of truth for what prod runs.
#
# And `docker push …:latest` does NOT reliably move that tag here: when the
# manifest already exists in the registry under another tag (which it does,
# we just pushed main-$SHA), the push reports a digest but `:latest` keeps
# resolving to the old image. Writing the manifest to the tag directly over
# the HTTP API is what actually moves it. Verified: PUT → 201, and the
# timer then rolls prod on its own.
ssh "$BUILD_HOST" "set -e
for svc in server frontend; do
ct=\$(curl -s -o /tmp/cm-manifest.json -D- \
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
http://$REGISTRY/v2/clawmates/\$svc/manifests/main-$SHA \
| awk -F': ' '/^[Cc]ontent-[Tt]ype/{print \$2}' | tr -d '\r')
code=\$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
-H \"Content-Type: \$ct\" --data-binary @/tmp/cm-manifest.json \
http://$REGISTRY/v2/clawmates/\$svc/manifests/latest)
echo \" \$svc :latest → main-$SHA (HTTP \$code)\"
case \"\$code\" in 20*) ;; *) echo \" ✗ tag write failed\"; exit 1 ;; esac
done"
echo "→ roll $GW onto main-$SHA"
# Roll immediately rather than waiting up to 60s for the timer. Snapshot the
# outgoing image as :rollback first so a revert is a repoint, not a rebuild.
ssh "$GW" "set -e
for svc in server frontend; do
docker tag $REGISTRY/clawmates/\$svc:$TAG $REGISTRY/clawmates/\$svc:rollback 2>/dev/null || true
docker pull $REGISTRY/clawmates/\$svc:main-$SHA
docker tag $REGISTRY/clawmates/\$svc:main-$SHA $REGISTRY/clawmates/\$svc:$TAG
docker pull -q $REGISTRY/clawmates/\$svc:$TAG >/dev/null
done
cd $GW_DIR
docker-compose -p clawmates up -d --force-recreate --no-deps server frontend"
docker-compose -p clawmates up -d --no-deps server frontend"
fi
echo "→ load agent runtime images onto $GW + every fleet node"
@@ -110,16 +128,17 @@ done
if [ -z "${IMAGES_ONLY:-}" ]; then
echo "→ verify"
# Verify the RUNNING image matches what we just pushed — not just that the
# edge is up. A green edge on the OLD image is the silent-revert failure mode
# this check exists to catch.
want=$(ssh "$GW" "docker image inspect -f '{{.Id}}' $REGISTRY/clawmates/server:main-$SHA 2>/dev/null" || true)
# Verify the RUNNING image is the one we just published — not just that the
# edge is up. A green edge on the OLD image is the silent-revert failure
# mode this check exists to catch. Compare against the resolved :latest,
# which is what both compose and the rolling timer deploy from.
want=$(ssh "$GW" "docker image inspect -f '{{.Id}}' $REGISTRY/clawmates/server:$TAG 2>/dev/null" || true)
got=$(ssh "$GW" "docker inspect -f '{{.Image}}' clawmates_server_1 2>/dev/null" || true)
if [ -n "$want" ] && [ "$want" = "$got" ]; then
echo " server running expected image ($SHA): ${got:7:12}"
else
echo " ✗ server image MISMATCH — running ${got:7:12}, expected main-$SHA (${want:7:12})"
echo " the deploy did NOT take effect; check the registry pull on $GW"
echo " ✗ server image MISMATCH — running ${got:7:12}, expected ${want:7:12}"
echo " check that :latest was repointed and the roll succeeded on $GW"
exit 1
fi
ssh "$GW" 'curl -s -o /dev/null -w " edge HTTP %{http_code}\n" -m 10 https://clawmates.work/ || true'