fabro/apps/fabro-web/app/lib/run-events.ts
Bryan Helmkamp bc31b8d23a
Serve the run stream as the only run event API
Step 4 of the legacy executor deletion, third commit: the legacy event
API and every reader of it go, so that the next commits can delete the
event log, its reducer and the types beneath them.

The API:
- `GET /runs/{id}/events` pages the run stream only
  (`PaginatedRunStreamList` by `after`); the legacy `since_seq`,
  `before_seq` and `order` cursors, the `oneOf` envelope, the legacy
  `EventEnvelope`, `PaginatedEventList`, `RunEvent`, `EventSeq`,
  `AppendEventResponse` and `RunEventDetailResponse` schemas,
  `POST /runs/{id}/events`, `GET /runs/{id}/events/{seq}` and
  `GET /runs/{id}/stages/{stageId}/events` are deleted. `GET
  /runs/{id}/attach` and `GET /attach` frame `RunStreamItem`s only.
- The Rust and TypeScript clients regenerate; the removed models leave
  the TypeScript package.

The readers:
- `fabro-client` drops the legacy run event listing, tail and attach
  methods and `RunEventStream`; `list_run_stream_until` bounds a stream
  read.
- `fabro-tool`'s `fabro_run_events` lists, searches and details the run
  stream: `after` is the exclusive `stream_seq` cursor, `event_id` the
  item's id, filters match the item's name and `recorded_at`.
- `fabro-dump` writes the stream to `events.jsonl`; `fabro dump` reads
  it.
- The CLI's progress renderer keeps only what the run stream drives:
  the legacy event conversion, the sandbox and setup displays and their
  styles go. `fabro system events` prints stream items.
- The server's demo mode folds its agent fixture straight into the
  session projection and answers the attach stub with a stream item;
  the demo stage events endpoint is gone.
- The web app: every run is a Petri run. The legacy event hooks,
  renderer props, stage popover summary, run phases derivation and
  live-event payload handling are deleted or ported to `RunStreamItem`;
  toasts and board refreshes read the stream's platform records.
- Tests: the legacy API round trips and pagination tests are deleted;
  the CLI's MCP, attach and system event mocks serve stream pages; the
  CLI test helpers read stream items.

Still failing until the later commits: the CLI tests seeded through
`POST /runs/{id}/events`, the server tests over the legacy store, and
the legacy type tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:36:00 -04:00

264 lines
7.6 KiB
TypeScript

import { useEffect } from "react";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import { useSWRConfig, type Key } from "swr";
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import {
isStreamItemPayload,
isTerminalLifecycleItem,
petriEventName,
petriParsed,
petriStageLabel,
platformRecordKind,
} from "./petri-stream";
import { queryKeys } from "./query-keys";
import { getString } from "./unknown";
import {
createBrowserEventSource,
subscribeToSharedEventSource,
type EventPayload,
type EventSourceLike,
type MutateFn,
type SharedEventSubscription,
} from "./sse";
/**
* A frame of a run's attach stream as parsed JSON: a `RunStreamItem`, its
* fields optional until `isStreamItemPayload` has checked the shape.
*/
export interface RunEventPayload extends EventPayload {
run_id?: string;
stream_seq?: number;
kind?: string;
id?: string;
recorded_at?: number;
item?: unknown;
}
interface RunEventOptions {
debounceMs?: number;
coordinator?: CrossTabSseCoordinator;
/** Called with every well-formed stream item of the run, before invalidation. */
onItem?: (item: RunStreamItem) => void;
}
const subscriptions = new Map<string, SharedEventSubscription>();
/**
* The SWR keys a run stream item invalidates. Petri's events are
* named `<subject>.<verb>`; a platform record by its `kind`. The stage
* keys use the subject's `node@visit` label, which is the stage id the
* projection keys stages by.
*/
export function queryKeysForStreamItem(
runId: string,
item: RunStreamItem,
): { keys: Key[]; immediate: boolean } {
const stageId = petriStageLabel(item);
const stageKeys: Key[] = stageId
? [queryKeys.runs.stageEvents(runId, stageId), queryKeys.runs.stageContextWindow(runId, stageId)]
: [];
const stream = queryKeys.runs.stream(runId);
if (item.kind === "platform") {
const kind = platformRecordKind(item);
if (isTerminalLifecycleItem(item)) {
return { keys: terminalKeys(runId, stream), immediate: true };
}
switch (kind) {
case "checkpoint":
return {
keys: [
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.state(runId),
stream,
],
immediate: false,
};
case "interview.answered":
return {
keys: [queryKeys.runs.questions(runId, 25, 0), queryKeys.runs.detail(runId), stream],
immediate: false,
};
default:
return { keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), stream], immediate: false };
}
}
const name = petriEventName(item);
switch (name) {
case "run.finished":
return { keys: terminalKeys(runId, stream), immediate: false };
case "run.started":
case "run.paused":
case "run.unpaused":
case "invocation.finished":
case "invocation.cancel.requested":
case "run.stalled":
return { keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), stream], immediate: false };
case "visit.started":
case "visit.completed":
case "retry.scheduled":
case "wait.state.changed":
case "admission.decided":
return {
keys: [
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
queryKeys.runs.detail(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
...stageKeys,
],
immediate: false,
};
case "step.progress.recorded": {
const parsed = getString(petriParsed(item), "kind");
if (parsed === "question" || parsed === "question_expired") {
return {
keys: [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
stream,
...stageKeys,
],
immediate: false,
};
}
return { keys: [queryKeys.runs.state(runId), stream, ...stageKeys], immediate: false };
}
case "control.requested":
return {
keys: [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
stream,
...stageKeys,
],
immediate: false,
};
case "step.finished":
return {
keys: [
queryKeys.runs.state(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.detail(runId),
stream,
...stageKeys,
],
immediate: false,
};
case "fork.started":
case "branch.completed":
case "fork.completed":
case "node.expanded":
return {
keys: [
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
],
immediate: false,
};
case "routing.resolved":
case "route.applied":
return { keys: [stream, ...stageKeys], immediate: false };
default:
return { keys: [stream], immediate: false };
}
}
function terminalKeys(runId: string, stream: Key): Key[] {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
}
export function subscribeToRunEvents(
runId: string,
mutate: MutateFn,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 300, coordinator, onItem }: RunEventOptions = {},
): () => void {
return subscribeToCrossTabSse<RunEventPayload>({
coordinator,
subscriptionKey: `run:${runId}`,
mutate,
debounceMs,
resyncKeys: () => resyncKeysForRun(runId),
resolveInvalidation: (payload) => {
if (payload.run_id !== runId) return { keys: [] };
return runInvalidation(runId, payload, onItem);
},
fallbackSubscribe: () =>
subscribeToSharedEventSource<RunEventPayload>({
subscriptions,
subscriptionKey: runId,
url: queryKeys.runs.attachUrl(runId),
mutate,
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => {
const result = runInvalidation(runId, payload, onItem);
return { ...result, close: result.immediate };
},
}),
});
}
/** A frame that is not a stream item (a malformed frame) invalidates nothing. */
function runInvalidation(
runId: string,
payload: RunEventPayload,
onItem: RunEventOptions["onItem"],
) {
if (!isStreamItemPayload(payload)) return { keys: [], immediate: false };
onItem?.(payload);
return queryKeysForStreamItem(runId, payload);
}
function resyncKeysForRun(runId: string) {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.stream(runId),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.questions(runId, 25, 0),
];
}
/**
* 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]);
}