fabro/apps/fabro-web/app/lib/run-events.ts
Bryan Helmkamp 503e21fbc1
Merge origin/main into feat/parallel-branch-live-status
Conflicts were between this branch's parallel-branch identity work and
main's stage billing, review targets, and live stage timing.

- Stage fixtures: main added `billing` to each per-file `makeStage`; this
  branch had hoisted one builder into `lib/test-utils`. Kept the hoisted
  builder and gave it `billing: makeBilledTokenCounts()`, so both intents
  hold and the field list stays in one place. `stage-sidebar.test.ts` also
  builds raw `RunStage` wire payloads, so it keeps importing
  `makeBilledTokenCounts` directly.
- Import lists (`run_projection.rs`, `fabro-api/src/lib.rs`,
  `run_state.rs`, `stage_projection_round_trip.rs`): unioned both sides —
  `ParallelBranchId` alongside `timing`, `ReviewTarget`,
  `ReviewTargetKind`, `AttrValue`, `Node`, and
  `StageToolBatchProjection`.
- `fabro-server` tests: git interleaved two unrelated new tests into one
  body. Split them back into
  `list_run_stages_exposes_parallel_branch_identity` and
  `run_billing_includes_live_stage_timing_in_rows_and_totals`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:50:36 -04:00

382 lines
10 KiB
TypeScript

import { useEffect } from "react";
import { useSWRConfig, type Key } from "swr";
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import { queryKeys } from "./query-keys";
import {
createBrowserEventSource,
subscribeToSharedEventSource,
type EventPayload,
type EventSourceLike,
type MutateFn,
type SharedEventSubscription,
} from "./sse";
export interface RunEventPayload extends EventPayload {
id?: string;
seq?: number;
event?: string;
run_id?: string;
node_id?: string;
stage_id?: string;
properties?: Record<string, unknown>;
}
interface RunEventOptions {
debounceMs?: number;
coordinator?: CrossTabSseCoordinator;
onEvent?: (payload: RunEventPayload) => void;
}
const subscriptions = new Map<string, SharedEventSubscription>();
const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]);
const RUN_SUMMARY_EVENTS = new Set([
"run.submitted",
"run.start_requested",
"run.pending",
"run.approved",
"run.denied",
"run.runnable",
"run.starting",
"run.running",
"run.paused",
"run.unpaused",
"run.blocked",
"run.unblocked",
"run.cancel.requested",
"run.pause.requested",
"run.unpause.requested",
"run.archived",
"run.unarchived",
"run.title.updated",
"pull_request.created",
"pull_request.linked",
"pull_request.unlinked",
]);
const STAGE_EVENTS = new Set([
"stage.started",
"stage.completed",
"stage.failed",
"stage.retrying",
]);
// Single source of truth: every event type the `eventsToActivity` reducer in
// `routes/run-stages.tsx` consumes. When any of these arrive for a stage we
// currently view, the stage-events SWR key for that stage must be invalidated
// so the panel refetches. The reducer imports this list so the switch stays
// in sync with the invalidation set; if the reducer grows a new case, this
// list is the single edit point.
//
// The lifecycle `STAGE_EVENTS` set is kept separate because it also fans out
// to run-scoped invalidations (stages list, graph, detail).
export const STAGE_ACTIVITY_EVENT_TYPES = [
"stage.prompt",
"prompt.completed",
"agent.message",
"agent.tool.started",
"agent.tool.completed",
"agent.steering.injected",
"agent.interrupt.injected",
"agent.round.interrupted",
"agent.pair.user_message",
"agent.pair.system_message",
"command.started",
"command.completed",
] as const;
export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number];
const STAGE_ACTIVITY_EVENTS = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
// Parallel branches bypass the engine's `stage.started` / `stage.completed`
// lifecycle (the parallel handler dispatches each branch directly), so
// `STAGE_EVENTS` never fires for them. Without this set the stages list never
// refetches while a fork runs and branch rows stay frozen at their first
// observed state.
const PARALLEL_EVENTS = new Set([
"parallel.started",
"parallel.branch.started",
"parallel.branch.completed",
"parallel.completed",
]);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
"interview.timeout",
"interview.interrupted",
]);
const STEERING_EVENTS = new Set([
"run.interrupt",
"run.steer",
"agent.steering.injected",
"agent.interrupt.injected",
"agent.round.interrupted",
"agent.session.activated",
"agent.session.deactivated",
"agent.steer.buffered",
"agent.steer.dropped",
]);
const AGENT_CONTROL_STATE_EVENTS = new Set([
"agent.round.interrupted",
"agent.steering.injected",
"agent.session.deactivated",
]);
const INFERENCE_EVENTS = new Set([
"agent.llm.started",
"agent.llm.first_output",
"agent.llm.retry",
"agent.message",
"agent.error",
"agent.session.ended",
]);
const INFERENCE_TIMING_EVENTS = new Set([
"agent.llm.started",
"agent.message",
"agent.error",
"agent.session.ended",
]);
const TOOL_TIMING_EVENTS = new Set([
"agent.tool.started",
"agent.tool.completed",
]);
const ACP_TIMING_EVENTS = new Set([
"agent.acp.started",
"agent.acp.completed",
"agent.acp.cancelled",
"agent.acp.timed_out",
]);
// Todo / task mutation events refresh `getRunState` consumers (so per-stage
// todo projections update live) and the run events list.
const TODO_EVENTS = new Set([
"todo.created",
"todo.updated",
"todo.deleted",
]);
function liveTimingKeys(runId: string): Key[] {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
queryKeys.runs.billing(runId),
];
}
export function queryKeysForRunEvent(
runId: string,
event: string,
stageId?: string,
): Key[] {
if (event === "checkpoint.completed") {
return [
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
];
}
if (TERMINAL_EVENTS.has(event)) {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
}
if (RUN_SUMMARY_EVENTS.has(event)) {
return [queryKeys.runs.detail(runId)];
}
if (INTERVIEW_EVENTS.has(event)) {
return [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
];
}
if (STAGE_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
return keys;
}
if (PARALLEL_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (STEERING_EVENTS.has(event)) {
const keys: Key[] = [queryKeys.runs.events(runId, 1000)];
if (AGENT_CONTROL_STATE_EVENTS.has(event)) {
keys.unshift(queryKeys.runs.state(runId));
}
if (event === "agent.round.interrupted") {
keys.unshift(
queryKeys.runs.detail(runId),
queryKeys.runs.billing(runId),
);
}
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
return keys;
}
if (INFERENCE_EVENTS.has(event)) {
const keys = INFERENCE_TIMING_EVENTS.has(event)
? liveTimingKeys(runId)
: [queryKeys.runs.state(runId)];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
if (event === "agent.message") {
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
}
return keys;
}
if (TOOL_TIMING_EVENTS.has(event)) {
const keys = liveTimingKeys(runId);
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
return keys;
}
if (ACP_TIMING_EVENTS.has(event)) {
const keys = liveTimingKeys(runId);
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (event === "watchdog.timeout") {
return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : [];
}
if (STAGE_ACTIVITY_EVENTS.has(event)) {
return stageId
? [
queryKeys.runs.stageEvents(runId, stageId),
queryKeys.runs.stageContextWindow(runId, stageId),
]
: [];
}
if (TODO_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.state(runId),
queryKeys.runs.events(runId, 1000),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
return [];
}
export function subscribeToRunEvents(
runId: string,
mutate: MutateFn,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 300, coordinator, onEvent }: RunEventOptions = {},
): () => void {
return subscribeToCrossTabSse<RunEventPayload>({
coordinator,
subscriptionKey: `run:${runId}`,
mutate,
debounceMs,
resyncKeys: () => resyncKeysForRun(runId),
resolveInvalidation: (payload) => {
if (payload.run_id !== runId) return { keys: [] };
onEvent?.(payload);
return runInvalidation(runId, payload);
},
fallbackSubscribe: () =>
subscribeToSharedEventSource<RunEventPayload>({
subscriptions,
subscriptionKey: runId,
url: queryKeys.runs.attachUrl(runId),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => {
onEvent?.(payload);
const result = runInvalidation(runId, payload);
return { ...result, close: result.immediate };
},
}),
});
}
function runInvalidation(runId: string, payload: RunEventPayload) {
const event = payload.event;
if (!event) return { keys: [], immediate: false };
const stageId = stageIdFromPayload(payload);
const keys = queryKeysForRunEvent(runId, event, stageId);
const terminal = TERMINAL_EVENTS.has(event);
return { keys, immediate: terminal };
}
function resyncKeysForRun(runId: string) {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.questions(runId, 25, 0),
];
}
function stageIdFromPayload(payload: RunEventPayload): string | undefined {
if (typeof payload.stage_id === "string") return payload.stage_id;
if (typeof payload.node_id === "string") return payload.node_id;
const nodeId = payload.properties?.node_id;
return typeof nodeId === "string" ? nodeId : undefined;
}
/**
* Synchronizes React/SWR with a run-scoped SSE stream. Changing `runId`
* resubscribes, and the active subscription is closed on unmount.
*/
export function useRunEvents(runId: string | undefined) {
const { mutate } = useSWRConfig();
useEffect(() => {
if (!runId) return;
return subscribeToRunEvents(runId, mutate as MutateFn);
}, [mutate, runId]);
}