mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-03 02:24:33 +00:00
### 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>
52 lines
1.9 KiB
TypeScript
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([]);
|
|
});
|
|
});
|