Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions ship/src/lib/run-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
36 changes: 36 additions & 0 deletions ship/src/lib/run-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
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);
Expand Down Expand Up @@ -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).
Expand Down
14 changes: 12 additions & 2 deletions ship/src/web/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -220,6 +225,7 @@ function RunsTable({ runs }: { runs: RunSummary[] }) {
<th>State</th>
<th>Started</th>
<th>Duration</th>
<th>Cost</th>
<th>Run ID</th>
<th></th>
<th></th>
Expand Down Expand Up @@ -316,6 +322,9 @@ function RunGroupRows({
</td>
<td>{relativeTime(latest.started_at)}</td>
<td className="mono">{durationOf(latest.started_at)}</td>
<td className="mono" data-testid="cost-cell">
{formatCost(latest.cost_usd)}
</td>
<td className="mono" style={{ color: "var(--text-muted)", fontSize: "0.75rem" }}>
{latest.run_id}
</td>
Expand Down Expand Up @@ -358,7 +367,7 @@ function RunGroupRows({
</tr>
{conflict ? (
<tr style={{ background: "var(--bg-muted)" }}>
<td colSpan={7} style={{ fontSize: "0.85rem" }}>
<td colSpan={8} style={{ fontSize: "0.85rem" }}>
run already in flight:{" "}
<Link
to={`/runs/${conflict.runId}`}
Expand All @@ -372,7 +381,7 @@ function RunGroupRows({
) : null}
{error ? (
<tr style={{ background: "var(--bg-muted)" }}>
<td colSpan={7} style={{ fontSize: "0.85rem", color: "var(--danger, #c33)" }}>
<td colSpan={8} style={{ fontSize: "0.85rem", color: "var(--danger, #c33)" }}>
rerun failed: {error}
</td>
</tr>
Expand All @@ -393,6 +402,7 @@ function RunGroupRows({
</td>
<td>{relativeTime(r.started_at)}</td>
<td className="mono">{durationOf(r.started_at)}</td>
<td className="mono">{formatCost(r.cost_usd)}</td>
<td className="mono" style={{ color: "var(--text-muted)", fontSize: "0.75rem" }}>
{r.run_id}
</td>
Expand Down
3 changes: 3 additions & 0 deletions ship/src/web/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down