fabro/apps/fabro-web/app/lib/query-keys.test.ts
fabro-sh-0530[bot] 786a6f67e1
Read billing and stages from RunProjection with live runtimes (#213)
### Summary
Billing and stage lists now use the event-sourced `RunProjection` as
their source of truth, so running and retrying stages appear immediately
and runtimes keep advancing in the UI. This removes the checkpoint
completed-node bypass that hid in-flight work and froze totals until the
next server response.

### Plan Summary
- Store stage `started_at`, terminal `duration_ms`, server-internal
`usage`, and lifecycle `state` on `StageProjection`.
- Populate those fields from stage lifecycle events, including retry
transitions and per-attempt reset on new starts.
- Render `/runs/{id}/stages` and `/runs/{id}/billing` from
`RunProjection.iter_stages()`.
- Expose the new API/client fields and tick in-flight billing runtimes
on the web UI.

```mermaid
flowchart TB
  Events["Stage lifecycle events"] --> Projection["RunProjection StageProjection"]
  Projection --> StagesAPI["GET /runs/{id}/stages"]
  Projection --> BillingAPI["GET /runs/{id}/billing"]
  StagesAPI --> StageUI["Stage sidebar/stages view"]
  BillingAPI --> BillingUI["Billing tab live totals"]
```

### Key decisions
Retry and revisit handling stays one row per node id: latest visit data
wins, while first-seen event sequence keeps ordering stable with
finalize output. `state` is stored rather than derived so `Retrying` is
representable, and old serialized projections still work through the
`effective_state()` fallback. Billing `usage` remains server-internal
and is skipped on the wire; public schemas only expose the fields needed
by `/stages`, `/billing`, and the frontend live timer.

Added focused reducer, server retry/revisit, API round-trip, billing UI,
and event invalidation coverage.

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-05-05 09:32:33 -04:00

52 lines
1.9 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { queryKeys } from "./query-keys";
import { queryKeysForRunEvent } from "./run-events";
describe("queryKeys", () => {
test("uses API path strings as stable SWR keys", () => {
expect(queryKeys.auth.me()).toBe("/api/v1/auth/me");
expect(queryKeys.runs.files("run 1")).toBe("/api/v1/runs/run%201/files");
expect(queryKeys.runs.graph("run-1", "TB")).toBe("/api/v1/runs/run-1/graph?direction=TB");
expect(queryKeys.runs.stageLog("run 1", "build step@2", "stderr", 12, 34)).toBe(
"/api/v1/runs/run%201/stages/build%20step%402/logs/stderr?offset=12&limit=34",
);
expect(queryKeys.runs.stageEvents("run 1", "build step", 7, 25)).toBe(
"/api/v1/runs/run%201/stages/build%20step/events?since_seq=7&limit=25",
);
});
test("event-mapped keys match query hook resources", () => {
expect(queryKeysForRunEvent("run-1", "checkpoint.completed")).toEqual([
queryKeys.runs.files("run-1"),
]);
expect(queryKeysForRunEvent("run-1", "stage.completed", "stage-1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.detail("run-1"),
queryKeys.runs.stageEvents("run-1", "stage-1"),
]);
});
test("agent activity events invalidate the per-stage events key", () => {
for (const event of [
"stage.prompt",
"agent.message",
"agent.tool.started",
"agent.tool.completed",
"command.started",
"command.completed",
]) {
expect(queryKeysForRunEvent("run-1", event, "stage-1")).toEqual([
queryKeys.runs.stageEvents("run-1", "stage-1"),
]);
}
});
test("agent activity events without a node_id invalidate nothing", () => {
expect(queryKeysForRunEvent("run-1", "agent.message")).toEqual([]);
});
});