From dc31e406af9103c954e3d02231e454c098dcacc7 Mon Sep 17 00:00:00 2001 From: Sarath Soman Date: Tue, 26 May 2026 01:21:32 +0100 Subject: [PATCH] =?UTF-8?q?feat(ship):=20observe-a-run=20V-3=20(part)=20?= =?UTF-8?q?=E2=80=94=20cost=20column=20on=20Home=20recent-runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per V-3 acceptance ("per-run cost is visible") and design.md sub-flow 1 ("History zone: columns include cost · run-id · rerun. Cost column is new (V-3)"). Server side (`src/lib/run-dir.ts`): - New `sumCostFromTelemetry(runDir)` helper sums per-stage `cost_usd` from `telemetry.json`. Returns `null` when the file is missing, empty, or has no rows with a numeric cost — null is "no signal" (distinct from a real zero-cost run). - `scanRuns` calls it once per run and sets `RunSummary.cost_usd` when defined. No change for runs without telemetry (field stays absent). - `RunSummary` (and wire-shape mirror in `src/web/types.ts`) gains a new optional `cost_usd?: number` field. Client side (`src/web/pages/Home.tsx`): - New `Cost` column in the recent-runs table, between `Duration` and `Run ID`. - `formatCost(c)` → `$X.YZ` (2 decimals) or `—` when undefined / non-finite. - `data-testid="cost-cell"` on the latest-run cost cell for click-through tests in follow-up PRs. - `colSpan` on conflict/error banner rows bumped 7 → 8. Tests: 4 new in `src/lib/run-dir.test.ts` (claim 1 — multi-stage sums; 2 — missing telemetry leaves field absent; 3 — empty/malformed file leaves field absent; 4 — non-numeric cost rows ignored, valid rows still summed). Partial discharge of #430 — surfaces per-run cost on Home (one of the three surfaces design.md specs). OutcomeHeader aggregate (depends on V-2 #453) and UnifiedTimeline per-phase cost (depends on U-1 #432) remain. Co-Authored-By: Claude Opus 4.7 (1M context) --- ship/src/lib/run-dir.test.ts | 56 ++++++++++++++++++++++++++++++++++++ ship/src/lib/run-dir.ts | 36 +++++++++++++++++++++++ ship/src/web/pages/Home.tsx | 14 +++++++-- ship/src/web/types.ts | 3 ++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/ship/src/lib/run-dir.test.ts b/ship/src/lib/run-dir.test.ts index 3c3a2b0..2c27593 100644 --- a/ship/src/lib/run-dir.test.ts +++ b/ship/src/lib/run-dir.test.ts @@ -263,3 +263,59 @@ describe("issue #427 — scanRuns surfaces seconds_since_last_event", () => { expect(got?.seconds_since_last_event).toBeGreaterThan(STUCK_THRESHOLD_SECONDS); }); }); + +describe("issue #430 — scanRuns aggregates cost_usd from telemetry.json", () => { + function seedTelemetry(runId: string, rows: Array<{ stage: string; cost_usd: number }>): void { + const lines = rows.map((r) => + JSON.stringify({ + stage: r.stage, + cost_usd: r.cost_usd, + duration_ms: 1000, + prompt_tokens: 100, + output_tokens: 10, + }), + ); + writeFileSync(join(runsRoot, runId, "telemetry.json"), `${lines.join("\n")}\n`); + } + + it("sums per-stage cost_usd into RunSummary.cost_usd", () => { + const runId = "2026-01-01-000010-issue-430"; + seedRun(makeProjectDir(), runId, { state: "complete" }); + seedTelemetry(runId, [ + { stage: "validate", cost_usd: 1.5 }, + { stage: "impl", cost_usd: 6.25 }, + { stage: "review", cost_usd: 0.8 }, + ]); + const got = scanRuns().find((s) => s.run_id === runId); + expect(got).not.toBeUndefined(); + expect(got?.cost_usd).toBeCloseTo(8.55, 5); + }); + + it("leaves cost_usd undefined when telemetry.json is absent", () => { + const runId = "2026-01-01-000011-issue-430"; + seedRun(makeProjectDir(), runId, { state: "complete" }); + const got = scanRuns().find((s) => s.run_id === runId); + expect(got).not.toBeUndefined(); + expect(got?.cost_usd).toBeUndefined(); + }); + + it("leaves cost_usd undefined when telemetry.json has no parseable rows", () => { + const runId = "2026-01-01-000012-issue-430"; + seedRun(makeProjectDir(), runId, { state: "complete" }); + writeFileSync(join(runsRoot, runId, "telemetry.json"), "\n \nnot-json\n"); + const got = scanRuns().find((s) => s.run_id === runId); + expect(got).not.toBeUndefined(); + expect(got?.cost_usd).toBeUndefined(); + }); + + it("ignores rows with non-numeric cost_usd values", () => { + const runId = "2026-01-01-000013-issue-430"; + seedRun(makeProjectDir(), runId, { state: "complete" }); + writeFileSync( + join(runsRoot, runId, "telemetry.json"), + `${JSON.stringify({ stage: "v", cost_usd: "nope" })}\n${JSON.stringify({ stage: "i", cost_usd: 2.5 })}\n`, + ); + const got = scanRuns().find((s) => s.run_id === runId); + expect(got?.cost_usd).toBeCloseTo(2.5, 5); + }); +}); diff --git a/ship/src/lib/run-dir.ts b/ship/src/lib/run-dir.ts index 707bce9..1c86195 100644 --- a/ship/src/lib/run-dir.ts +++ b/ship/src/lib/run-dir.ts @@ -32,6 +32,10 @@ export interface RunSummary { /// for running runs (#427). Server-derived to avoid client clock skew; /// `> STUCK_THRESHOLD_SECONDS` is the NowBar anomaly signal. seconds_since_last_event?: number; + /// Aggregate cost across all phases (USD), summed from telemetry.json + /// per-stage `cost_usd` rows. Absent when telemetry.json is missing or + /// reports zero phases. Per V-3 (#430): the operator's primary kill signal. + cost_usd?: number; } export interface StageEntry { @@ -236,6 +240,36 @@ export function computeSecondsSinceLastEvent( } } +// Sum per-stage `cost_usd` across telemetry.json rows. Returns null when the +// file is missing, empty, or contains no parseable rows with a numeric cost +// — null is the "no signal" value, distinct from a real zero-cost run. +function sumCostFromTelemetry(runDir: string): number | null { + const path = join(runDir, "telemetry.json"); + if (!existsSync(path)) return null; + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch { + return null; + } + let total = 0; + let rows = 0; + for (const line of text.split("\n")) { + if (line.trim() === "") continue; + try { + const r = JSON.parse(line) as Record; + const cost = r["cost_usd"]; + if (typeof cost === "number" && Number.isFinite(cost)) { + total += cost; + rows++; + } + } catch { + // skip malformed + } + } + return rows > 0 ? total : null; +} + function latestJsonMtime(runDir: string): string | null { try { const entries = readdirSync(runDir); @@ -371,6 +405,8 @@ export function scanRuns(opts?: BuildOpts): RunSummary[] { const seconds = computeSecondsSinceLastEvent(fullPath); if (seconds !== null) summary.seconds_since_last_event = seconds; } + const cost = sumCostFromTelemetry(fullPath); + if (cost !== null) summary.cost_usd = cost; out.push(summary); } // Newest first by run_id (which is timestamp-prefixed). diff --git a/ship/src/web/pages/Home.tsx b/ship/src/web/pages/Home.tsx index ac862e2..31a1517 100644 --- a/ship/src/web/pages/Home.tsx +++ b/ship/src/web/pages/Home.tsx @@ -48,6 +48,11 @@ function durationOf(startedAt: string): string { return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; } +function formatCost(cost: number | undefined): string { + if (cost === undefined || !Number.isFinite(cost)) return "—"; + return `$${cost.toFixed(2)}`; +} + export function Home() { const navigate = useNavigate(); const qc = useQueryClient(); @@ -220,6 +225,7 @@ function RunsTable({ runs }: { runs: RunSummary[] }) { State Started Duration + Cost Run ID @@ -316,6 +322,9 @@ function RunGroupRows({ {relativeTime(latest.started_at)} {durationOf(latest.started_at)} + + {formatCost(latest.cost_usd)} + {latest.run_id} @@ -358,7 +367,7 @@ function RunGroupRows({ {conflict ? ( - + run already in flight:{" "} - + rerun failed: {error} @@ -393,6 +402,7 @@ function RunGroupRows({ {relativeTime(r.started_at)} {durationOf(r.started_at)} + {formatCost(r.cost_usd)} {r.run_id} diff --git a/ship/src/web/types.ts b/ship/src/web/types.ts index 47c2dfa..326099d 100644 --- a/ship/src/web/types.ts +++ b/ship/src/web/types.ts @@ -21,6 +21,9 @@ export interface RunSummary { /// Drives NowBar's `no progress >5m` anomaly (compared against /// `STUCK_THRESHOLD_SECONDS`). seconds_since_last_event?: number; + /// Aggregate cost across all phases (USD), summed from telemetry.json + /// per-stage rows. Absent when no telemetry. Per V-3 (#430). + cost_usd?: number; } export interface StageEntry {