slice 7: before/after benchmark runner
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m19s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m52s

Executes a benchmark harness inside the mission's team container
and records the resulting metrics as a benchmark_snapshots row keyed
on (phase_id, iteration). Baseline pass (iteration=0) captures
before_metrics; each post-iteration call captures after_metrics +
computes delta vs baseline.

Rust surface:
  - cm_db::repo::missions::upsert_benchmark_snapshot / benchmark_snapshots_for
  - cm_api::benchmark_runner::{baseline, after_iteration, run}
  - Harness enum: Auto | Criterion | CargoBench | VitestBench |
    PytestBench | Shell (each with a command() vector)
  - Auto detection peeks at the repo layout inside the container
    (Cargo.toml → CargoBench, package.json → VitestBench, pyproject
    → PytestBench). Falls back to a Shell echo when nothing
    identifiable.
  - Bencher-format line parser extracts (name, ns_per_iter,
    plusminus) so criterion + `cargo bench` output become structured
    samples the canvas can diff.
  - compute_delta pairs samples by name, emits {before_ns, after_ns,
    delta_pct, direction: improved|regressed}.

API:
  - POST /api/missions/{id}/benchmark { phase_id, slot, iteration? }
    triggers baseline or after run and returns the mission's full
    snapshot list.
  - GET /api/missions/{id} now includes `benchmarks[]` in the detail
    payload.

Frontend:
  - New Benchmarks tab on MissionCanvas with iteration + driver
    header, plus a 4-column grid (bench / before / after / Δ%) when
    delta samples are present. Improved deltas render green,
    regressions red.
  - TS types + triggerBenchmark() helper in lib/api/missions.ts.

Wiring notes:
  - team_container_for_mission reads teams.zeroclaw_container — that's
    populated by topology_worker::try_team_gateway_url on first run,
    so trigger baseline AFTER the mission's first phase spawns the
    container.
  - Not auto-fired yet by phase execution; that's the "template phase
    executor" work that spans Slices 4-8. Manual API trigger works
    today; automated hook is a follow-up.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 15:32:47 -07:00
co-authored by Claude Opus 4.7
parent 3ac3d53da7
commit f843c9ddb1
6 changed files with 705 additions and 5 deletions
@@ -8,7 +8,7 @@
//
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FileText, Play, RefreshCw } from "lucide-react";
import {
@@ -61,7 +61,7 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
custom: "Custom",
};
type Tab = "overview" | "phases" | "tasks" | "artifacts";
type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks";
export function MissionCanvas({
selectedId,
@@ -230,7 +230,7 @@ export function MissionCanvas({
</p>
)}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts"] as Tab[]).map((t) => {
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {
const active = tab === t;
const badge =
t === "tasks"
@@ -239,7 +239,9 @@ export function MissionCanvas({
? mission.artifacts.length
: t === "phases"
? mission.phases.length
: null;
: t === "benchmarks"
? mission.benchmarks.length
: null;
return (
<button
key={t}
@@ -493,6 +495,139 @@ export function MissionCanvas({
)}
</div>
)}
{tab === "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; }" />
) : (
mission.benchmarks.map((s) => {
const delta = s.delta as
| { kind?: string; samples?: Array<Record<string, unknown>> }
| null
| undefined;
return (
<div
key={s.id}
style={{
padding: 12,
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: s.iteration === 0 ? "#7cd6e0" : "#5fd08a",
letterSpacing: ".08em",
textTransform: "uppercase",
}}
>
{s.iteration === 0 ? "baseline" : `iter ${s.iteration}`}
</span>
{s.driver && (
<span
style={{
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
marginLeft: 4,
}}
>
{s.driver}
</span>
)}
<span
style={{
marginLeft: "auto",
fontFamily: mono,
fontSize: 10.5,
color: "#6a6a72",
}}
>
{new Date(s.created_at).toLocaleString()}
</span>
</div>
{delta?.samples && delta.samples.length > 0 && (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 100px 100px 100px",
gap: 6,
fontSize: 12,
alignItems: "center",
}}
>
<span style={{ color: "#8a8a92" }}>bench</span>
<span style={{ color: "#8a8a92", textAlign: "right" }}>before</span>
<span style={{ color: "#8a8a92", textAlign: "right" }}>after</span>
<span style={{ color: "#8a8a92", textAlign: "right" }}>Δ%</span>
{(delta.samples as Array<Record<string, unknown>>).map((row, i) => {
const pct = Number(row["delta_pct"] ?? 0);
const dir = String(row["direction"] ?? "");
const color = dir === "improved" ? "#5fd08a" : "#ff8a7a";
return (
<React.Fragment key={i}>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color: "#d7d7db",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{String(row["name"])}
</span>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color: "#a0a0a8",
textAlign: "right",
}}
>
{String(row["before_ns"])} ns
</span>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color: "#a0a0a8",
textAlign: "right",
}}
>
{String(row["after_ns"])} ns
</span>
<span
style={{
fontFamily: mono,
fontSize: 11.5,
color,
textAlign: "right",
}}
>
{pct > 0 ? "+" : ""}
{pct.toFixed(1)}%
</span>
</React.Fragment>
);
})}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
</div>
);