diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx index e23fd59d3..d563bb1c2 100644 --- a/apps/fabro-web/app/components/event-debug.tsx +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -16,7 +16,6 @@ import { import { Tooltip } from "./ui"; import { formatAbsoluteTs } from "../lib/format"; import { - debugCategory, debugCategoryColor, debugCategoryLabel, debugCategoryTone, @@ -28,20 +27,19 @@ import { FloatingTooltip } from "./floating-tooltip"; import { useWindowEvent } from "../hooks/effects"; /** - * What a debug row needs of an event: a legacy `EventEnvelope`, or a Petri - * run stream item as `debugRowsFromStream` shapes it (with its own - * category, since Petri's `.` names map to none of the - * legacy prefixes). + * What a debug row needs of a run stream item, as `debugRowsFromStream` + * shapes it: the `stream_seq`, the item's name, when it was recorded, and + * its category (`petri` or `platform`). */ export interface DebugRowLike { seq: number; event?: string | null; ts: string; - category?: DebugCategory; + category: DebugCategory; } export function debugRowCategory(row: DebugRowLike): DebugCategory { - return row.category ?? debugCategory(row.event); + return row.category; } export function DebugEventRow({ diff --git a/apps/fabro-web/app/components/platform-records-panel.tsx b/apps/fabro-web/app/components/platform-records-panel.tsx index e3e5ddf38..fe20ceb57 100644 --- a/apps/fabro-web/app/components/platform-records-panel.tsx +++ b/apps/fabro-web/app/components/platform-records-panel.tsx @@ -3,7 +3,6 @@ import type { RunProjection } from "@qltysh/fabro-api-client"; import { formatAbsoluteTs } from "../lib/format"; import { - isPetriRun, platformRecordsOf, type PlatformRecordEntry, } from "../lib/petri-stream"; @@ -82,15 +81,13 @@ export function PlatformRecordsPanelView({ ); } -/** The panel for a run, shown only when the run executes on Petri. */ +/** The panel for a run: the platform records on its stream. */ export function PlatformRecordsPanel({ runId }: { runId: string }) { const runStateQuery = useRunState(runId); - const petri = isPetriRun(runStateQuery.data); - const streamQuery = useRunStream(petri ? runId : undefined); + const streamQuery = useRunStream(runId); const records = useMemo( () => (streamQuery.data ? platformRecordsOf(streamQuery.data) : []), [streamQuery.data], ); - if (!petri) return null; return ; } diff --git a/apps/fabro-web/app/components/run-waterfall.tsx b/apps/fabro-web/app/components/run-waterfall.tsx index 79b2035df..3e4959c72 100644 --- a/apps/fabro-web/app/components/run-waterfall.tsx +++ b/apps/fabro-web/app/components/run-waterfall.tsx @@ -10,19 +10,13 @@ import { stageStatusLabel, stageStatusTone, } from "../lib/stage-sidebar"; -import { deriveRunPhases, type RunPhase } from "../lib/run-phases"; +import type { RunPhase } from "../lib/run-phases"; import { useTickingNow } from "../lib/time"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; interface WaterfallProps { runId: string; - events: EventEnvelope[]; - /** - * The run's phases when the caller derives them itself: a Petri run's - * come from its platform lifecycle records (`deriveRunPhasesFromStream`), - * not from legacy events. - */ - phases?: RunPhase[]; + /** The run's phases from its platform lifecycle records (`deriveRunPhasesFromStream`). */ + phases: RunPhase[]; stages: RunStage[]; createdAtIso: string; completedAtIso: string | null; @@ -163,22 +157,16 @@ function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null { function buildRows({ runId, - events, phases: givenPhases, stages, - createdAtIso, nowMs, }: { runId: string; - events: EventEnvelope[]; - phases?: RunPhase[]; + phases: RunPhase[]; stages: RunStage[]; - createdAtIso: string; nowMs: number; }): Row[] { - const phases = (givenPhases ?? deriveRunPhases(events, createdAtIso)).map((p) => - phaseRow(p, nowMs), - ); + const phases = givenPhases.map((p) => phaseRow(p, nowMs)); const stageRows: Row[] = []; for (const stage of stages) { if (!isVisibleStage(stage.node_id)) continue; @@ -191,7 +179,6 @@ function buildRows({ export function RunWaterfall({ runId, - events, phases, stages, createdAtIso, @@ -199,8 +186,8 @@ export function RunWaterfall({ }: WaterfallProps) { const nowMs = useTickingNow(true, 1000); const rows = useMemo( - () => buildRows({ runId, events, phases, stages, createdAtIso, nowMs }), - [runId, events, phases, stages, createdAtIso, nowMs], + () => buildRows({ runId, phases, stages, nowMs }), + [runId, phases, stages, nowMs], ); const createdMs = Date.parse(createdAtIso); diff --git a/apps/fabro-web/app/components/stage-popover-summary.ts b/apps/fabro-web/app/components/stage-popover-summary.ts deleted file mode 100644 index 48aace6c1..000000000 --- a/apps/fabro-web/app/components/stage-popover-summary.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { EventEnvelope } from "@qltysh/fabro-api-client"; - -import { - getArray, - getNumber, - getObject, - getString, -} from "../lib/unknown"; - -export interface StageSummary { - attempt?: number; - maxAttempts?: number; - failureMessage?: string; - notes?: string; - inputTokens?: number; - outputTokens?: number; - filesTouchedCount?: number; - systemActor?: string; - exitCode?: number; -} - -export function deriveStageSummary(events: EventEnvelope[]): StageSummary { - const summary: StageSummary = {}; - for (const e of events) { - const props = e.properties ?? {}; - switch (e.event) { - case "stage.started": { - const attempt = getNumber(props, "attempt"); - const max = getNumber(props, "max_attempts"); - if (attempt !== undefined) summary.attempt = attempt; - if (max !== undefined) summary.maxAttempts = max; - break; - } - case "stage.completed": { - readFailure(summary, getObject(props, "failure")); - readUsage(summary, getObject(props, "usage")); - readTermination(summary, getObject(props, "termination")); - const notes = getString(props, "notes"); - if (notes !== undefined) summary.notes = notes; - const files = getArray(props, "files_touched"); - if (files !== undefined) summary.filesTouchedCount = files.length; - break; - } - case "stage.failed": { - readFailure(summary, getObject(props, "failure")); - readUsage(summary, getObject(props, "usage")); - break; - } - } - } - return summary; -} - -function readFailure(summary: StageSummary, failure: unknown) { - if (!failure) return; - const message = getString(failure, "message"); - if (message !== undefined) summary.failureMessage = message; - const actor = getString(failure, "system_actor"); - if (actor !== undefined) summary.systemActor = actor; -} - -/** `stage.completed.usage` is a `ModelUsage`: the model, then the usage. */ -function readUsage(summary: StageSummary, modelUsage: unknown) { - if (!modelUsage) return; - const tokens = getObject(getObject(modelUsage, "usage"), "tokens"); - if (!tokens) return; - const input = getNumber(tokens, "input"); - const output = getNumber(tokens, "output"); - if (input !== undefined) summary.inputTokens = input; - if (output !== undefined) summary.outputTokens = output; -} - -function readTermination(summary: StageSummary, termination: unknown) { - if (!termination) return; - const exitCode = getNumber(termination, "exit_code"); - if (exitCode !== undefined) summary.exitCode = exitCode; -} diff --git a/apps/fabro-web/app/components/stage-popover.test.tsx b/apps/fabro-web/app/components/stage-popover.test.tsx index 2d456dd65..8d9f07c67 100644 --- a/apps/fabro-web/app/components/stage-popover.test.tsx +++ b/apps/fabro-web/app/components/stage-popover.test.tsx @@ -2,26 +2,12 @@ import { describe, expect, test } from "bun:test"; import type { ReactNode } from "react"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; -import { SWRConfig } from "swr"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; import { StagePopover } from "./stage-popover"; -import { deriveStageSummary } from "./stage-popover-summary"; import type { Stage } from "../lib/stage-sidebar"; -import { generatedAxios } from "../lib/api-client"; +import { makeUsage } from "../lib/test-fixtures"; import { makeStage as baseMakeStage } from "../lib/test-utils"; -function makeEvent(overrides: Partial): EventEnvelope { - return { - id: "evt-1", - ts: "2026-05-24T12:00:00Z", - run_id: "run-1", - event: "stage.started", - seq: 1, - ...overrides, - } as EventEnvelope; -} - function makeStage(overrides: Partial = {}): Stage { return baseMakeStage({ status: "succeeded", @@ -32,112 +18,11 @@ function makeStage(overrides: Partial = {}): Stage { }); } -describe("deriveStageSummary", () => { - test("returns empty summary for no events", () => { - expect(deriveStageSummary([])).toEqual({}); - }); - - test("captures attempt and max_attempts from latest stage.started", () => { - const summary = deriveStageSummary([ - makeEvent({ event: "stage.started", seq: 1, properties: { attempt: 1, max_attempts: 3 } }), - makeEvent({ event: "stage.failed", seq: 2, properties: { failure: { message: "boom" } } }), - makeEvent({ event: "stage.started", seq: 3, properties: { attempt: 2, max_attempts: 3 } }), - ]); - expect(summary.attempt).toBe(2); - expect(summary.maxAttempts).toBe(3); - }); - - test("captures failure message from stage.failed", () => { - const summary = deriveStageSummary([ - makeEvent({ - event: "stage.failed", - properties: { failure: { message: "verify failed: 3 tests failing", system_actor: "agent" } }, - }), - ]); - expect(summary.failureMessage).toBe("verify failed: 3 tests failing"); - expect(summary.systemActor).toBe("agent"); - }); - - test("captures usage tokens from stage.completed", () => { - const summary = deriveStageSummary([ - makeEvent({ - event: "stage.completed", - properties: { - usage: { - model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, - usage: { tokens: { input: 12400, output: 3120 } }, - }, - }, - }), - ]); - expect(summary.inputTokens).toBe(12400); - expect(summary.outputTokens).toBe(3120); - }); - - test("captures notes from stage.completed", () => { - const summary = deriveStageSummary([ - makeEvent({ - event: "stage.completed", - properties: { notes: "skipped because input was empty" }, - }), - ]); - expect(summary.notes).toBe("skipped because input was empty"); - }); - - test("captures files_touched count from stage.completed", () => { - const summary = deriveStageSummary([ - makeEvent({ - event: "stage.completed", - properties: { files_touched: ["a.rs", "b.rs", "c.rs"] }, - }), - ]); - expect(summary.filesTouchedCount).toBe(3); - }); - - test("captures termination exit_code from stage.completed", () => { - const summary = deriveStageSummary([ - makeEvent({ - event: "stage.completed", - properties: { termination: { exit_code: 137 } }, - }), - ]); - expect(summary.exitCode).toBe(137); - }); - - test("later events overwrite earlier ones (latest attempt wins)", () => { - const summary = deriveStageSummary([ - makeEvent({ event: "stage.failed", seq: 1, properties: { failure: { message: "old" } } }), - makeEvent({ event: "stage.failed", seq: 2, properties: { failure: { message: "newer" } } }), - ]); - expect(summary.failureMessage).toBe("newer"); - }); - - test("ignores non-lifecycle events", () => { - const summary = deriveStageSummary([ - makeEvent({ event: "agent.tool.completed", properties: { tool_name: "Bash" } }), - makeEvent({ event: "stage.started", properties: { attempt: 1, max_attempts: 1 } }), - ]); - expect(summary.attempt).toBe(1); - }); - - test("tolerates missing or non-numeric properties", () => { - const summary = deriveStageSummary([ - makeEvent({ event: "stage.started", properties: {} }), - makeEvent({ event: "stage.completed", properties: { usage: null } }), - ]); - expect(summary).toEqual({}); - }); -}); - function render(node: ReactNode): TestRenderer.ReactTestRenderer { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let tree!: TestRenderer.ReactTestRenderer; act(() => { - tree = TestRenderer.create( - new Map(), dedupingInterval: 0 }}> - {node} - , - ); + tree = TestRenderer.create(node); }); return tree; } @@ -152,122 +37,40 @@ function textOf(tree: TestRenderer.ReactTestRenderer): string { return collect(tree.toJSON()); } -interface MockEventsResponse { - data: EventEnvelope[]; - meta: { has_more: boolean }; -} - -function withMockedStageEvents( - events: EventEnvelope[], - body: () => Promise, -): Promise { - const response: MockEventsResponse = { data: events, meta: { has_more: false } }; - const originalAdapter = generatedAxios.defaults.adapter; - generatedAxios.defaults.adapter = async (config) => ({ - data: response, - status: 200, - statusText: "OK", - headers: {}, - config, - }); - return body().finally(() => { - generatedAxios.defaults.adapter = originalAdapter; - }); -} - describe("StagePopover rendering", () => { - test("succeeded stage shows model, tokens, and files touched", async () => { - await withMockedStageEvents( - [ - makeEvent({ - event: "stage.completed", - properties: { - usage: { - model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, - usage: { tokens: { input: 12400, output: 3120 } }, - }, - files_touched: ["a.rs", "b.rs"], - }, - }), - ], - async () => { - const stage = makeStage({ status: "succeeded" }); - const tree = render(); - await act(async () => { - await Promise.resolve(); - }); - const text = textOf(tree); - expect(text).toContain("implement"); - expect(text).toContain("Succeeded"); - expect(text).toContain("agent"); - expect(text).toContain("claude-opus-4-7"); - expect(text).toContain("12.4k in"); - expect(text).toContain("3.1k out"); - expect(text).toContain("Files touched"); - }, - ); + test("succeeded stage shows the model and the projection's tokens", () => { + const stage = makeStage({ + status: "succeeded", + usage: makeUsage({ input: 12400, output: 3120 }), + }); + const tree = render(); + const text = textOf(tree); + expect(text).toContain("implement"); + expect(text).toContain("Succeeded"); + expect(text).toContain("agent"); + expect(text).toContain("claude-opus-4-7"); + expect(text).toContain("12.4k in"); + expect(text).toContain("3.1k out"); }); - test("failed stage shows truncated reason", async () => { - const longMessage = "x".repeat(500); - await withMockedStageEvents( - [makeEvent({ event: "stage.failed", properties: { failure: { message: longMessage } } })], - async () => { - const stage = makeStage({ status: "failed", duration: "12s" }); - const tree = render(); - await act(async () => { - await Promise.resolve(); - }); - const text = textOf(tree); - expect(text).toContain("Reason"); - expect(text).toContain("…"); - // Truncated to ≤240 chars (plus the ellipsis we appended). - const reasonMatch = text.match(/x+/); - expect(reasonMatch).not.toBeNull(); - expect(reasonMatch![0].length).toBeLessThanOrEqual(240); - }, - ); + test("failed stage shows the model and nothing it must load", () => { + const stage = makeStage({ status: "failed", duration: "12s" }); + const tree = render(); + const text = textOf(tree); + expect(text).toContain("Failed"); + expect(text).toContain("claude-opus-4-7"); + expect(text).not.toContain("Reason"); + expect(text).not.toContain("Loading"); }); - test("retrying stage shows attempt and previous failure", async () => { - await withMockedStageEvents( - [ - makeEvent({ event: "stage.started", seq: 1, properties: { attempt: 1, max_attempts: 3 } }), - makeEvent({ - event: "stage.failed", - seq: 2, - properties: { failure: { message: "transient infra error" }, will_retry: true }, - }), - ], - async () => { - const stage = makeStage({ status: "retrying" }); - const tree = render(); - await act(async () => { - await Promise.resolve(); - }); - const text = textOf(tree); - expect(text).toContain("Attempt"); - expect(text).toContain("1 of 3"); - expect(text).toContain("Previous failure"); - expect(text).toContain("transient infra error"); - }, - ); - }); - - test("skipped stage shows skip reason from notes", async () => { - await withMockedStageEvents( - [makeEvent({ event: "stage.completed", properties: { notes: "no work to do" } })], - async () => { - const stage = makeStage({ status: "skipped", duration: "--" }); - const tree = render(); - await act(async () => { - await Promise.resolve(); - }); - const text = textOf(tree); - expect(text).toContain("Reason"); - expect(text).toContain("no work to do"); - }, - ); + test("failed command stage shows neither a model nor an exit code", () => { + const stage = makeStage({ status: "failed", handler: "command", providerUsed: null }); + const tree = render(); + const text = textOf(tree); + expect(text).toContain("Failed"); + expect(text).toContain("command"); + expect(text).not.toContain("Model"); + expect(text).not.toContain("Exit code"); }); test("pending stage renders minimal shell without status tail", () => { @@ -309,33 +112,4 @@ describe("StagePopover rendering", () => { expect(text).not.toContain("Resumed from"); expect(text).not.toContain("Graph visit"); }); - - test("failed command stage shows exit code instead of model", async () => { - await withMockedStageEvents( - [ - makeEvent({ - event: "stage.failed", - properties: { failure: { message: "exit 2" } }, - }), - makeEvent({ - event: "stage.completed", - properties: { termination: { exit_code: 2 } }, - }), - ], - async () => { - const stage = makeStage({ - status: "failed", - handler: "command", - providerUsed: null, - }); - const tree = render(); - await act(async () => { - await Promise.resolve(); - }); - const text = textOf(tree); - expect(text).toContain("Exit code"); - expect(text).toContain("2"); - }, - ); - }); }); diff --git a/apps/fabro-web/app/components/stage-popover.tsx b/apps/fabro-web/app/components/stage-popover.tsx index 8dce68fc3..0ea060770 100644 --- a/apps/fabro-web/app/components/stage-popover.tsx +++ b/apps/fabro-web/app/components/stage-popover.tsx @@ -1,9 +1,7 @@ -import { useMemo } from "react"; import { Link } from "react-router"; import type { StageState } from "@qltysh/fabro-api-client"; import { formatTokenCount } from "../lib/format"; -import { useRunStageEvents } from "../lib/queries"; import { formatStageLabel, stageStatusLabel, @@ -12,39 +10,6 @@ import { } from "../lib/stage-sidebar"; import { timeAgo } from "../lib/time"; import { PopoverHeader, PopoverRow, PopoverRows } from "./ui"; -import { - deriveStageSummary, - type StageSummary, -} from "./stage-popover-summary"; - -const REASON_MAX_CHARS = 240; - -/** Trim, collapse blank-line runs, and cap to ~240 chars with an ellipsis. */ -function truncateReason(text: string): { display: string; truncated: boolean } { - const collapsed = text - .replace(/\r\n/g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); - if (collapsed.length <= REASON_MAX_CHARS) { - return { display: collapsed, truncated: false }; - } - return { - display: `${collapsed.slice(0, REASON_MAX_CHARS - 1).trimEnd()}…`, - truncated: true, - }; -} - -function TruncatedReason({ text }: { text: string }) { - const { display, truncated } = truncateReason(text); - return ( - - {display} - - ); -} function StatusPill({ status }: { status: StageState }) { return ( @@ -68,20 +33,12 @@ function ModelRow({ providerUsed }: { providerUsed: Stage["providerUsed"] }) { ); } -function AttemptRow({ summary }: { summary: StageSummary }) { - if (summary.attempt === undefined) return null; - const max = summary.maxAttempts; - return ( - - {max && max > 1 ? `${summary.attempt} of ${max}` : `${summary.attempt}`} - - ); -} - -function TokensRow({ summary }: { summary: StageSummary }) { - if (summary.inputTokens === undefined && summary.outputTokens === undefined) return null; - const inLabel = formatTokenCount(summary.inputTokens ?? 0, { compactDecimal: true }); - const outLabel = formatTokenCount(summary.outputTokens ?? 0, { compactDecimal: true }); +/** The stage's tokens from its projection usage, once any were counted. */ +function TokensRow({ usage }: { usage: Stage["usage"] }) { + const { input, output } = usage.tokens; + if (input === undefined && output === undefined) return null; + const inLabel = formatTokenCount(input ?? 0, { compactDecimal: true }); + const outLabel = formatTokenCount(output ?? 0, { compactDecimal: true }); return ( @@ -91,105 +48,29 @@ function TokensRow({ summary }: { summary: StageSummary }) { ); } -function StatusTail({ - stage, - summary, - loading, -}: { - stage: Stage; - summary: StageSummary; - loading: boolean; -}) { +/** + * What the popover shows below the timing rows, by state: the model while + * the stage runs, the model and tokens once it finished. The projection + * carries no failure reason, attempt count or exit code for the popover. + */ +function StatusTail({ stage }: { stage: Stage }) { switch (stage.status) { - case "pending": - case "cancelled": - return summary.systemActor ? ( - {summary.systemActor} - ) : null; case "running": - return ( - <> - - - - ); - case "retrying": - return ( - <> - - {summary.failureMessage && ( - - - - )} - - ); + case "failed": + return ; case "succeeded": - return ( - <> - - - {summary.filesTouchedCount !== undefined && summary.filesTouchedCount > 0 && ( - {summary.filesTouchedCount} - )} - - ); case "partially_succeeded": return ( <> - {summary.notes && ( - - - - )} - + ); - case "failed": { - const isCommand = stage.handler === "command"; - return ( - <> - {summary.failureMessage ? ( - - - - ) : loading ? ( - - ) : null} - - {isCommand && summary.exitCode !== undefined ? ( - - {summary.exitCode} - - ) : ( - - )} - - ); - } - case "skipped": - return summary.notes ? ( - - - - ) : loading ? ( - - ) : null; default: return null; } } -function LoadingRow() { - return ( - <> -
-
Loading details…
- - ); -} - interface StagePopoverProps { runId: string; stage: Stage; @@ -198,10 +79,6 @@ interface StagePopoverProps { } export function StagePopover({ runId, stage, duration }: StagePopoverProps) { - const { data: events } = useRunStageEvents(runId, stage.id); - const summary = useMemo(() => deriveStageSummary(events ?? []), [events]); - const loading = events === undefined && stage.status !== "pending"; - return (
@@ -241,7 +118,7 @@ export function StagePopover({ runId, stage, duration }: StagePopoverProps) { {stage.graphVisit} )} - +
); diff --git a/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx b/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx index 9d67c632c..ff1fb4b9a 100644 --- a/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx +++ b/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx @@ -4,11 +4,10 @@ import { ArrowLongRightIcon, ArrowsRightLeftIcon, } from "@heroicons/react/20/solid"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; import type { Stage } from "../stage-sidebar"; import { StageMetaBar } from "./meta-bar"; -import { findEdgeForNode, type EdgeSelection } from "./helpers"; +import type { EdgeSelection } from "./helpers"; const REASON_LABEL: Record = { condition: "Matched condition", @@ -23,22 +22,16 @@ function reasonLabel(reason: string): string { export function ConditionalDecision({ stage, - runEvents, - edge: givenEdge, + edge, allStages, runId, }: { stage: Stage; - runEvents: EventEnvelope[]; - /** The edge when the caller derived it (a Petri run's `route.applied`). */ - edge?: EdgeSelection | null; + /** The edge the stage took (its `route.applied`), or null while undecided. */ + edge: EdgeSelection | null; allStages: Stage[]; runId: string; }) { - const edge = useMemo( - () => (givenEdge !== undefined ? givenEdge : findEdgeForNode(runEvents, stage.nodeId)), - [givenEdge, runEvents, stage.nodeId], - ); const targetStage = useMemo(() => { if (!edge) return null; let pick: Stage | null = null; diff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx index d75410137..9471acca7 100644 --- a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; -import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; +import { setupReactTestEnv } from "../../lib/test-utils"; import { makeUsage } from "../../lib/test-fixtures"; import type { Stage } from "../stage-sidebar"; import { FanInResults } from "./fan-in-results"; +import type { ReducerTranscript } from "./helpers"; let teardown: () => void; beforeEach(() => { @@ -26,22 +26,18 @@ const fanInStage: Stage = { usage: makeUsage(), }; -function event(seq: number, partial: Partial): EventEnvelope { - return makeEventEnvelope(seq, { stage_id: "join@1", ...partial }); -} - -function renderFanIn(events: EventEnvelope[]): string { +function renderFanIn(reducer: ReducerTranscript | null): string { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let renderer!: TestRenderer.ReactTestRenderer; act(() => { - renderer = TestRenderer.create(); + renderer = TestRenderer.create(); }); return JSON.stringify(renderer.toJSON()); } describe("FanInResults", () => { test("renders a neutral joined state without best-branch selection UI", () => { - const rendered = renderFanIn([]); + const rendered = renderFanIn(null); expect(rendered).toContain("Joined"); expect(rendered).not.toContain("Selected branch"); @@ -50,23 +46,13 @@ describe("FanInResults", () => { }); test("optionally renders the standard reducer transcript", () => { - const rendered = renderFanIn([ - event(1, { - event: "stage.prompt", - properties: { - mode: "prompt", - text: "Combine the useful findings.", - model: "claude-sonnet-4-6", - }, - }), - event(2, { - event: "prompt.completed", - properties: { - response: "All branch findings are now available.", - usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } }, - }, - }), - ]); + const rendered = renderFanIn({ + prompt: "Combine the useful findings.", + response: "All branch findings are now available.", + model: "claude-sonnet-4-6", + inputTokens: 1200, + outputTokens: 340, + }); expect(rendered).toContain("Reducer transcript"); expect(rendered).toContain("Combine the useful findings."); diff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx index 2df743873..a98830b4d 100644 --- a/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx +++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx @@ -1,31 +1,22 @@ -import { useMemo } from "react"; import { CheckCircleIcon, CpuChipIcon, } from "@heroicons/react/20/solid"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; import type { Stage } from "../stage-sidebar"; import { formatTokenCount } from "../../lib/format"; import { Markdown } from "./primitives"; import { StageMetaBar } from "./meta-bar"; -import { parseReducerTranscript, type ReducerTranscript } from "./helpers"; +import type { ReducerTranscript } from "./helpers"; export function FanInResults({ stage, - events, - reducer: givenReducer, + reducer, }: { stage: Stage; - events: EventEnvelope[]; - /** The transcript when the caller derived it (a Petri run's projection). */ - reducer?: ReducerTranscript | null; + /** The reducer's transcript from the projection, or null when none ran. */ + reducer: ReducerTranscript | null; }) { - const reducer = useMemo( - () => (givenReducer !== undefined ? givenReducer : parseReducerTranscript(events)), - [givenReducer, events], - ); - return (
diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts deleted file mode 100644 index 1537d40a6..000000000 --- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; - -import { makeEventEnvelope } from "../../lib/test-utils"; -import { - extractStageContext, - parseHumanInterviewPairs, - parseParallelOverview, - parseReducerTranscript, -} from "./helpers"; - -describe("parseHumanInterviewPairs", () => { - test("pairs interview.started with interview.completed by question_id", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "interview.started", - properties: { - question_id: "q-1", - question: "Approve PR?", - question_type: "yes_no", - options: [ - { key: "y", label: "Yes" }, - { key: "n", label: "No" }, - ], - allow_freeform: false, - }, - }), - makeEventEnvelope(2, { - event: "interview.completed", - properties: { - question_id: "q-1", - question: "Approve PR?", - answer: "y", - duration_ms: 4200, - actor: { kind: "user", email: "alice@example.com" }, - }, - }), - ]; - - const pairs = parseHumanInterviewPairs(events); - expect(pairs).toHaveLength(1); - expect(pairs[0].question.questionType).toBe("yes_no"); - expect(pairs[0].question.options).toEqual([ - { key: "y", label: "Yes" }, - { key: "n", label: "No" }, - ]); - const resolution = pairs[0].resolution; - expect(resolution).not.toBeNull(); - if (resolution?.kind === "answered") { - expect(resolution.answer).toBe("y"); - expect(resolution.actor).toBe("alice@example.com"); - expect(resolution.durationMs).toBe(4200); - } - }); - - test("leaves resolution null for unanswered (still pending) questions", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "interview.started", - properties: { - question_id: "q-1", - question: "Pick a branch", - question_type: "multiple_choice", - }, - }), - ]; - const pairs = parseHumanInterviewPairs(events); - expect(pairs[0].resolution).toBeNull(); - }); - - test("preserves option description and preview metadata from started events", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "interview.started", - properties: { - question_id: "q-1", - question: "Pick a path", - question_type: "multiple_choice", - options: [ - { - key: "ship", - label: "Ship", - description: "Deploy the current patch", - preview: "diff preview", - }, - ], - }, - }), - ]; - - const pairs = parseHumanInterviewPairs(events); - - expect(pairs[0].question.options[0]).toEqual({ - key: "ship", - label: "Ship", - description: "Deploy the current patch", - preview: "diff preview", - }); - }); - - test("preserves a typed review target from started events", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "interview.started", - properties: { - question_id: "q-1", - question: - "Review the Quarry review exercise document, then choose the next action.", - question_type: "multiple_choice", - review_target: { - label: "Quarry review exercise", - url: "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef", - kind: "document", - }, - }, - }), - ]; - - const pairs = parseHumanInterviewPairs(events); - - expect(pairs[0].question.reviewTarget).toEqual({ - label: "Quarry review exercise", - url: "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef", - kind: "document", - }); - }); - - test("captures timeout and interrupted resolutions", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "interview.started", - properties: { question_id: "q-1", question: "?", question_type: "freeform" }, - }), - makeEventEnvelope(2, { - event: "interview.timeout", - properties: { question_id: "q-1", duration_ms: 30000 }, - }), - makeEventEnvelope(3, { - event: "interview.started", - properties: { question_id: "q-2", question: "?", question_type: "freeform" }, - }), - makeEventEnvelope(4, { - event: "interview.interrupted", - properties: { - question_id: "q-2", - reason: "user cancelled", - duration_ms: 1200, - actor: { kind: "user", email: "bob@example.com" }, - }, - }), - ]; - const pairs = parseHumanInterviewPairs(events); - expect(pairs[0].resolution?.kind).toBe("timeout"); - expect(pairs[1].resolution?.kind).toBe("interrupted"); - if (pairs[1].resolution?.kind === "interrupted") { - expect(pairs[1].resolution.reason).toBe("user cancelled"); - expect(pairs[1].resolution.actor).toBe("bob@example.com"); - } - }); -}); - -describe("parseParallelOverview", () => { - test("rolls up branch_count and status-only results", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "parallel.started", - properties: { branch_count: 3 }, - }), - makeEventEnvelope(2, { - event: "parallel.completed", - properties: { - duration_ms: 12000, - success_count: 2, - failure_count: 1, - results: [ - { - id: "branch-a", - status: "succeeded", - context_updates: { "response.branch-a": "A" }, - }, - { - id: "branch-b", - status: "succeeded", - context_updates: { "command.output": { stdout: "B" } }, - }, - { - id: "branch-c", - status: "failed", - context_updates: { "response.branch-c": "C" }, - }, - ], - }, - }), - ]; - const overview = parseParallelOverview(events); - expect(overview).toEqual({ - branchCount: 3, - results: [ - { id: "branch-a", index: null, itemLabel: null, status: "succeeded" }, - { id: "branch-b", index: null, itemLabel: null, status: "succeeded" }, - { id: "branch-c", index: null, itemLabel: null, status: "failed" }, - ], - }); - }); - - test("parses dynamic item identity from results", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "parallel.completed", - properties: { - duration_ms: 20, - success_count: 2, - failure_count: 0, - results: [ - { - id: "reviewer", - index: 0, - item_label: "auth", - status: "succeeded", - context_updates: {}, - }, - { - id: "reviewer", - index: 1, - item_label: "api", - status: "succeeded", - context_updates: {}, - }, - ], - }, - }), - ]; - - expect(parseParallelOverview(events).results).toEqual([ - { id: "reviewer", index: 0, itemLabel: "auth", status: "succeeded" }, - { id: "reviewer", index: 1, itemLabel: "api", status: "succeeded" }, - ]); - }); - - test("reports in-flight when only the started event is present", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "parallel.started", - properties: { branch_count: 4 }, - }), - ]; - const overview = parseParallelOverview(events); - expect(overview.branchCount).toBe(4); - expect(overview.results).toEqual([]); - }); -}); - -describe("parseReducerTranscript", () => { - test("returns null when fan-in joins without a reducer", () => { - expect(parseReducerTranscript([])).toBeNull(); - }); - - test("parses the standard prompt transcript when a reducer ran", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "stage.prompt", - properties: { - mode: "prompt", - text: "Combine the branch results.", - model: "claude-sonnet-4-6", - }, - }), - makeEventEnvelope(2, { - event: "prompt.completed", - properties: { - response: "The branch results are joined.", - usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } }, - }, - }), - ]; - - expect(parseReducerTranscript(events)).toEqual({ - prompt: "Combine the branch results.", - response: "The branch results are joined.", - model: "claude-sonnet-4-6", - inputTokens: 1200, - outputTokens: 340, - }); - }); - - test("uses normal prompt mode for the reducer transcript", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "stage.prompt", - properties: { mode: "prompt", text: "Standard reducer" }, - }), - makeEventEnvelope(2, { - event: "prompt.completed", - properties: { response: "Standard response" }, - }), - ]; - - expect(parseReducerTranscript(events)?.response).toBe("Standard response"); - }); -}); - -describe("extractStageContext", () => { - test("keeps author-set keys and drops engine bookkeeping keys", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "stage.completed", - properties: { - context_updates: { - "plan.summary": "ship the thing", - review_score: 8, - last_stage: "implement", - last_response: "done", - "response.implement": "full text", - "internal.run_id": "run-1", - "current.preamble": "...", - "command.output": "blob:abc", - "human.gate.selected": "A", - "parallel.results": [], - }, - }, - }), - ]; - const ctx = extractStageContext(events); - expect(ctx).not.toBeNull(); - expect(ctx?.updates).toEqual({ - "plan.summary": "ship the thing", - review_score: 8, - }); - }); - - test("extracts routing hints from preferred_label and suggested_next_ids", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "stage.completed", - properties: { - preferred_label: "approve", - suggested_next_ids: ["review", "merge", 7], - }, - }), - ]; - const ctx = extractStageContext(events); - expect(ctx?.routing.preferredLabel).toBe("approve"); - expect(ctx?.routing.suggestedNextIds).toEqual(["review", "merge"]); - expect(ctx?.updates).toEqual({}); - }); - - test("returns null when the stage only wrote engine keys", () => { - const events: EventEnvelope[] = [ - makeEventEnvelope(1, { - event: "stage.completed", - properties: { - context_updates: { last_stage: "implement", "command.output": "blob:x" }, - }, - }), - ]; - expect(extractStageContext(events)).toBeNull(); - }); - - test("returns null when the stage has not completed", () => { - expect(extractStageContext([])).toBeNull(); - }); -}); diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts index d7eb56821..9380463e2 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts @@ -1,20 +1,10 @@ -import { ReviewTargetKind, StageOutcome } from "@qltysh/fabro-api-client"; -import type { EventEnvelope, ReviewTarget } from "@qltysh/fabro-api-client"; +/** + * The shapes the stage renderers show, and the helpers they share. Each is + * derived from a run's stream or projection in `lib/petri-stream.ts`. + */ +import type { ReviewTarget, StageOutcome } from "@qltysh/fabro-api-client"; -import { - getArray, - getNumber, - getObject, - getString, - isRecord, - type UnknownRecord, -} from "../../lib/unknown"; - -const STAGE_OUTCOMES: ReadonlySet = new Set(Object.values(StageOutcome)); - -function asStageOutcome(value: string | undefined): StageOutcome | null { - return value !== undefined && STAGE_OUTCOMES.has(value) ? (value as StageOutcome) : null; -} +import { getString, type UnknownRecord } from "../../lib/unknown"; export interface InterviewOption { key: string; @@ -63,106 +53,7 @@ export function principalLabel(actor: unknown): string | null { return kind || null; } -function parseInterviewOptions(value: unknown): InterviewOption[] { - if (!Array.isArray(value)) return []; - const out: InterviewOption[] = []; - for (const item of value) { - if (!item || typeof item !== "object") continue; - const record = item as UnknownRecord; - const key = getString(record, "key"); - const label = getString(record, "label"); - if (key && label) { - const option: InterviewOption = { key, label }; - const description = getString(record, "description"); - const preview = getString(record, "preview"); - if (description !== null) option.description = description; - if (preview !== null) option.preview = preview; - out.push(option); - } - } - return out; -} - -function parseReviewTarget(value: unknown): ReviewTarget | null { - if (!isRecord(value)) return null; - const label = getString(value, "label"); - const url = getString(value, "url"); - const kind = getString(value, "kind"); - if (!label || !url || kind !== ReviewTargetKind.DOCUMENT) return null; - return { label, url, kind }; -} - -/** - * Pair `interview.started` events with the matching `interview.completed`, - * `.timeout`, or `.interrupted` resolution by `question_id`. Unanswered - * questions return with `resolution: null` so the UI can show pending state. - */ -export function parseHumanInterviewPairs(events: EventEnvelope[]): HumanInterviewPair[] { - const pairs = new Map(); - - for (const event of events) { - const props: UnknownRecord = event.properties ?? {}; - if (event.event === "interview.started") { - const questionId = getString(props, "question_id"); - if (!questionId) continue; - pairs.set(questionId, { - question: { - ts: event.ts, - questionId, - question: getString(props, "question") ?? "", - questionType: getString(props, "question_type") ?? "freeform", - options: parseInterviewOptions(props.options), - allowFreeform: props.allow_freeform === true, - timeoutSeconds: getNumber(props, "timeout_seconds") ?? null, - contextDisplay: getString(props, "context_display") ?? null, - reviewTarget: parseReviewTarget(props.review_target), - }, - resolution: null, - }); - continue; - } - if (event.event === "interview.completed") { - const questionId = getString(props, "question_id"); - const pair = questionId ? pairs.get(questionId) : undefined; - if (!pair) continue; - pair.resolution = { - kind: "answered", - ts: event.ts, - answer: getString(props, "answer") ?? "", - durationMs: getNumber(props, "duration_ms") ?? 0, - actor: principalLabel(props.actor), - }; - continue; - } - if (event.event === "interview.timeout") { - const questionId = getString(props, "question_id"); - const pair = questionId ? pairs.get(questionId) : undefined; - if (!pair) continue; - pair.resolution = { - kind: "timeout", - ts: event.ts, - durationMs: getNumber(props, "duration_ms") ?? 0, - }; - continue; - } - if (event.event === "interview.interrupted") { - const questionId = getString(props, "question_id"); - const pair = questionId ? pairs.get(questionId) : undefined; - if (!pair) continue; - pair.resolution = { - kind: "interrupted", - ts: event.ts, - reason: getString(props, "reason") ?? "interrupted", - durationMs: getNumber(props, "duration_ms") ?? 0, - actor: principalLabel(props.actor), - }; - } - } - - return Array.from(pairs.values()).sort((a, b) => a.question.ts.localeCompare(b.question.ts)); -} - -/** Identity and outcome of one branch, parsed from `parallel.completed`. */ +/** Identity and outcome of one branch of a parallel stage. */ export interface ParallelBranchSummary { id: string; index: number | null; @@ -175,45 +66,6 @@ export interface ParallelOverview { results: ParallelBranchSummary[]; } -/** - * Roll up the `parallel.started` (announces branch count) and - * `parallel.completed` (carries the per-branch results) events for a parallel - * stage. Pre-completion, only the announce data is available. - * - * Only branch identity is parsed. The event's own `success_count`, - * `failure_count` and `duration_ms` rollups are deliberately ignored: the - * renderer counts the branch rows it actually draws, and duration comes from - * the stage record via `StageMetaBar`. - */ -export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview { - let branchCount: number | null = null; - let results: ParallelBranchSummary[] = []; - - for (const event of events) { - const props: UnknownRecord = event.properties ?? {}; - if (event.event === "parallel.started") { - branchCount = getNumber(props, "branch_count") ?? branchCount; - } else if (event.event === "parallel.completed") { - const rawResults = getArray(props, "results") ?? []; - results = rawResults - .map((entry) => { - const record = entry && typeof entry === "object" ? (entry as UnknownRecord) : null; - if (!record) return null; - const id = getString(record, "id"); - const index = getNumber(record, "index") ?? null; - const itemLabel = getString(record, "item_label") ?? null; - const status = asStageOutcome(getString(record, "status")); - if (!id || !status) return null; - return { id, index, itemLabel, status } satisfies ParallelBranchSummary; - }) - .filter((r): r is ParallelBranchSummary => r != null); - if (branchCount == null) branchCount = results.length; - } - } - - return { branchCount, results }; -} - export interface ReducerTranscript { prompt: string; response: string; @@ -222,91 +74,12 @@ export interface ReducerTranscript { outputTokens: number; } -/** Extract the standard prompt/response transcript emitted by an optional fan-in reducer. */ -export function parseReducerTranscript(events: EventEnvelope[]): ReducerTranscript | null { - let prompt = ""; - let response = ""; - let model: string | null = null; - let inputTokens = 0; - let outputTokens = 0; - let hasReducer = false; - - for (const event of events) { - const props: UnknownRecord = event.properties ?? {}; - if (event.event === "stage.prompt") { - prompt = getString(props, "text") ?? prompt; - model = getString(props, "model") ?? model; - hasReducer = true; - } else if (event.event === "prompt.completed" && hasReducer) { - response = getString(props, "response") ?? response; - model = getString(props, "model") ?? model; - // `prompt.completed.usage` is a `ModelUsage`: the model, then the usage. - const tokens = getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {}; - inputTokens = getNumber(tokens, "input") ?? inputTokens; - outputTokens = getNumber(tokens, "output") ?? outputTokens; - } - } - - return hasReducer ? { prompt, response, model, inputTokens, outputTokens } : null; -} - export interface StageContextData { routing: { preferredLabel: string | null; suggestedNextIds: string[] }; /** `context_updates` keys the workflow deliberately set (engine keys removed). */ updates: Record; } -// Engine/auto-populated context keys. These are bookkeeping or already shown in -// a stage's primary tab (command output, human answers, fan-in results), so the -// Context tab hides them and surfaces only what the workflow deliberately wrote. -const ENGINE_CONTEXT_KEYS = new Set(["last_stage", "last_response", "command.output"]); -const ENGINE_CONTEXT_PREFIXES = [ - "response.", - "internal.", - "current.", - "human.gate.", - "parallel.", -]; - -function isEngineContextKey(key: string): boolean { - if (ENGINE_CONTEXT_KEYS.has(key)) return true; - return ENGINE_CONTEXT_PREFIXES.some((prefix) => key.startsWith(prefix)); -} - -/** - * Extract the workflow's deliberate outputs from the `stage.completed` event: - * author-set `context_updates` (minus engine keys) plus the routing hints - * (`preferred_label`, `suggested_next_ids`). Returns null when the stage hasn't - * finished or produced nothing worth showing — which hides the Context tab. - */ -export function extractStageContext(events: EventEnvelope[]): StageContextData | null { - for (const event of events) { - if (event.event !== "stage.completed") continue; - const props: UnknownRecord = event.properties ?? {}; - - const rawUpdates = getObject(props, "context_updates") ?? {}; - const updates: Record = {}; - for (const [key, value] of Object.entries(rawUpdates)) { - if (!isEngineContextKey(key)) updates[key] = value; - } - - const preferredLabel = getString(props, "preferred_label") ?? null; - const suggestedNextIds = (getArray(props, "suggested_next_ids") ?? []).filter( - (v): v is string => typeof v === "string", - ); - - if ( - Object.keys(updates).length === 0 && - !preferredLabel && - suggestedNextIds.length === 0 - ) { - return null; - } - return { routing: { preferredLabel, suggestedNextIds }, updates }; - } - return null; -} - export interface EdgeSelection { fromNode: string; toNode: string; @@ -315,35 +88,5 @@ export interface EdgeSelection { isJump: boolean; } -/** - * Find the `edge.selected` event whose `from_node` matches this conditional - * stage's node. Edge events are run-scoped (no stage_id) so callers must pass - * the full run events list, not the per-stage events. - * - * When the stage runs multiple times, the most recent matching event wins — - * for now we just take the last one. Sufficient until we surface visit data. - */ -export function findEdgeForNode( - runEvents: EventEnvelope[], - nodeId: string, -): EdgeSelection | null { - let latest: EdgeSelection | null = null; - for (const event of runEvents) { - if (event.event !== "edge.selected") continue; - const props = event.properties ?? {}; - const fromNode = getString(props, "from_node"); - if (fromNode !== nodeId) continue; - const toNode = getString(props, "to_node") ?? ""; - latest = { - fromNode: nodeId, - toNode, - reason: getString(props, "reason") ?? "", - condition: getString(props, "condition") ?? null, - isJump: props.is_jump === true, - }; - } - return latest; -} - // Re-export helper used by renderers that need to read nested properties. export { getString }; diff --git a/apps/fabro-web/app/components/stage-renderers/human-qa.tsx b/apps/fabro-web/app/components/stage-renderers/human-qa.tsx index 3e37e4c51..a5e1f854f 100644 --- a/apps/fabro-web/app/components/stage-renderers/human-qa.tsx +++ b/apps/fabro-web/app/components/stage-renderers/human-qa.tsx @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { ArrowPathIcon, ChatBubbleLeftEllipsisIcon, @@ -7,7 +6,6 @@ import { ExclamationTriangleIcon, NoSymbolIcon, } from "@heroicons/react/20/solid"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; import type { Stage } from "../stage-sidebar"; import { @@ -19,11 +17,10 @@ import { formatAbsoluteTs, formatDurationMs } from "../../lib/format"; import { ACTIVE_STAGE_STATES } from "../../lib/stage-sidebar"; import { Markdown } from "./primitives"; import { StageMetaBar } from "./meta-bar"; -import { - parseHumanInterviewPairs, - type HumanInterviewPair, - type HumanResolution, - type InterviewOption, +import type { + HumanInterviewPair, + HumanResolution, + InterviewOption, } from "./helpers"; function questionTypeLabel(type: string): string { @@ -224,18 +221,12 @@ function QuestionBlock({ export function HumanQA({ stage, - events, - pairs: givenPairs, + pairs, }: { stage: Stage; - events: EventEnvelope[]; - /** The pairs when the caller derived them (a Petri run's stream). */ - pairs?: HumanInterviewPair[]; + /** The stage's questions and their answers (`parsePetriInterviewPairs`). */ + pairs: HumanInterviewPair[]; }) { - const pairs = useMemo( - () => givenPairs ?? parseHumanInterviewPairs(events), - [givenPairs, events], - ); const stageActive = ACTIVE_STAGE_STATES.has(stage.status); const pendingCount = pairs.filter((p) => p.resolution == null).length; diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx index 2ebc5c822..a36305025 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx @@ -1,16 +1,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { StageOutcome, StageState } from "@qltysh/fabro-api-client"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; import { - makeEventEnvelope, makeStage as baseMakeStage, setupReactTestEnv, textContent, } from "../../lib/test-utils"; import type { Stage } from "../stage-sidebar"; +import type { ParallelBranchSummary, ParallelOverview } from "./helpers"; import { ParallelChildren } from "./parallel-children"; let teardown: () => void; @@ -57,38 +56,21 @@ function branchStage( }); } -function event(partial: Partial): EventEnvelope { - return makeEventEnvelope(partial.seq ?? 1, { - event: "parallel.completed", - stage_id: "fork@1", - ...partial, - }); -} - -function startedEvent(branchCount: number): EventEnvelope { - return event({ - event: "parallel.started", - properties: { branch_count: branchCount }, - }); -} - -function completedEvent(results: Array<{ id: string; status: StageOutcome }>): EventEnvelope { - const countOf = (status: StageOutcome) => - results.filter((result) => result.status === status).length; - return event({ - seq: 2, - event: "parallel.completed", - properties: { - duration_ms: 12000, - success_count: countOf(StageOutcome.SUCCEEDED), - failure_count: countOf(StageOutcome.FAILED), - results: results.map((result) => ({ ...result, context_updates: {} })), - }, - }); +/** The fork's overview: the announced branch count and the results so far. */ +function overview( + branchCount: number | null, + results: Array<{ id: string; status: StageOutcome }> = [], +): ParallelOverview { + return { + branchCount, + results: results.map( + (result): ParallelBranchSummary => ({ ...result, index: null, itemLabel: null }), + ), + }; } function renderParallel( - events: EventEnvelope[], + forkOverview: ParallelOverview, allStages: Stage[], stage = parallelStage, ): TestRenderer.ReactTestRenderer { @@ -98,7 +80,7 @@ function renderParallel( @@ -123,7 +105,7 @@ function statValue(renderer: TestRenderer.ReactTestRenderer, label: string): str describe("ParallelChildren", () => { test("renders live branch names, statuses, counts, and stage links", () => { const renderer = renderParallel( - [startedEvent(2)], + overview(2), [ branchStage("review_glm", 0, StageState.SUCCEEDED), branchStage("review_opus", 1, StageState.RUNNING), @@ -146,7 +128,7 @@ describe("ParallelChildren", () => { test("shows the recorded stage duration when cancellation interrupts the fan-out", () => { const renderer = renderParallel( - [startedEvent(2)], + overview(2), [], makeStage({ id: "fork@1", @@ -165,7 +147,7 @@ describe("ParallelChildren", () => { test("keeps looped fork links scoped to the selected fork visit", () => { const renderer = renderParallel( - [startedEvent(1)], + overview(1), [ branchStage("review_glm", 0, StageState.SUCCEEDED, "fork@1", 1), branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2), @@ -179,7 +161,7 @@ describe("ParallelChildren", () => { // Branches queued behind `max_parallel` reserve no stage identity, so the // observed indexes are sparse. Sizing the list by entry count would drop // the only running branch. - const renderer = renderParallel([], [branchStage("review_opus", 2, StageState.RUNNING)]); + const renderer = renderParallel(overview(null), [branchStage("review_opus", 2, StageState.RUNNING)]); expect(branchRowText(renderer)).toEqual([ "PendingBranch 1", @@ -191,7 +173,7 @@ describe("ParallelChildren", () => { }); test("renders branches with no stage or result yet as pending placeholders", () => { - const renderer = renderParallel([startedEvent(3)], [branchStage("review_glm", 0, StageState.RUNNING)]); + const renderer = renderParallel(overview(3), [branchStage("review_glm", 0, StageState.RUNNING)]); expect(branchRowText(renderer)).toEqual([ "Runningreview_glm", @@ -204,7 +186,7 @@ describe("ParallelChildren", () => { test("labels a re-entered branch with its visit, matching the sidebar", () => { const renderer = renderParallel( - [startedEvent(1)], + overview(1), [branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2)], makeStage({ id: "fork@2", name: "fork", handler: "parallel", visit: 2 }), ); @@ -215,13 +197,10 @@ describe("ParallelChildren", () => { test("keeps duplicate branch targets in index order and only links recorded stages", () => { const renderer = renderParallel( - [ - startedEvent(2), - completedEvent([ + overview(2, [ { id: "review", status: StageOutcome.FAILED }, { id: "review", status: StageOutcome.FAILED }, ]), - ], [branchStage("review", 0, StageState.SUCCEEDED)], ); @@ -234,10 +213,7 @@ describe("ParallelChildren", () => { test("renders a completed result without a matching stage as an unlinked row", () => { const renderer = renderParallel( - [ - startedEvent(1), - completedEvent([{ id: "legacy_branch", status: StageOutcome.SUCCEEDED }]), - ], + overview(1, [{ id: "legacy_branch", status: StageOutcome.SUCCEEDED }]), [], ); @@ -250,15 +226,12 @@ describe("ParallelChildren", () => { branchStage("partial", 0, StageState.PARTIALLY_SUCCEEDED), branchStage("skipped", 1, StageState.SKIPPED), ]; - const running = renderParallel([startedEvent(2)], allStages); + const running = renderParallel(overview(2), allStages); const completed = renderParallel( - [ - startedEvent(2), - completedEvent([ + overview(2, [ { id: "partial", status: StageOutcome.PARTIALLY_SUCCEEDED }, { id: "skipped", status: StageOutcome.SKIPPED }, ]), - ], allStages, ); @@ -279,31 +252,13 @@ describe("ParallelChildren", () => { givenOverview ?? parseParallelOverview(events), - [givenOverview, events], - ); - const stagesByBranchIndex = useMemo(() => { const byIndex = new Map(); for (const candidate of allStages) { diff --git a/apps/fabro-web/app/components/stage-renderers/stage-summary.tsx b/apps/fabro-web/app/components/stage-renderers/stage-summary.tsx index 1a21ce35e..159a16c0b 100644 --- a/apps/fabro-web/app/components/stage-renderers/stage-summary.tsx +++ b/apps/fabro-web/app/components/stage-renderers/stage-summary.tsx @@ -1,6 +1,5 @@ import type { Stage } from "../stage-sidebar"; import { - debugCategory, debugCategoryLabel, debugCategoryTone, type DebugCategory, @@ -12,18 +11,15 @@ export interface CategoryCount { count: number; } -/** What the summary counts: a legacy event, or a Petri stream row with its own category. */ +/** What the summary counts: a stream row with its category. */ export interface CategorizedEvent { - event?: string | null; - category?: DebugCategory; + category: DebugCategory; } export function summarizeEventCategories(events: CategorizedEvent[]): CategoryCount[] { const counts = new Map(); for (const event of events) { - const cat = event.category ?? (event.event ? debugCategory(event.event) : null); - if (!cat) continue; - counts.set(cat, (counts.get(cat) ?? 0) + 1); + counts.set(event.category, (counts.get(event.category) ?? 0) + 1); } return Array.from(counts.entries()) .map(([category, count]) => ({ category, count })) diff --git a/apps/fabro-web/app/hooks/use-run-toasts.test.ts b/apps/fabro-web/app/hooks/use-run-toasts.test.ts new file mode 100644 index 000000000..53004889b --- /dev/null +++ b/apps/fabro-web/app/hooks/use-run-toasts.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; + +import { makePetriItem, makePlatformItem } from "../lib/test-utils"; +import { steeringToastMessage } from "./use-run-toasts"; + +const stage = { name: "code" }; + +function control(seq: number, ctl: Record, deliverable?: boolean) { + return makePetriItem( + seq, + { event: "control.requested", firing: 1, ctl }, + { stage, derived: deliverable === undefined ? undefined : { deliverable } }, + ); +} + +describe("steeringToastMessage", () => { + test("a delivered steer, a queued steer and an interrupt each earn a toast", () => { + expect(steeringToastMessage(control(1, { deliver: { $steer: "try again" } }, true))).toBe( + "Steer delivered.", + ); + expect(steeringToastMessage(control(2, { deliver: { $steer: "try again" } }, false))).toBe( + "Steer queued — will apply when an agent stage runs.", + ); + expect(steeringToastMessage(control(3, { cancel: { reason: "interrupt" } }))).toBe( + "Agent interrupted.", + ); + }); + + test("a refused steer shows the worker's notice", () => { + expect( + steeringToastMessage( + makePlatformItem(4, { + kind: "run.notice", + code: "steer_refused", + message: "No agent stage is running.", + }), + ), + ).toBe("No agent stage is running."); + }); + + test("an answer delivery and every other item earn none", () => { + expect( + steeringToastMessage(control(5, { deliver: { $answer: { question: "gate#2", choice: "N" } } }, true)), + ).toBeNull(); + expect(steeringToastMessage(makePetriItem(6, { event: "step.finished", firing: 1 }, { stage }))).toBeNull(); + expect( + steeringToastMessage(makePlatformItem(7, { kind: "run.notice", code: "other", message: "x" })), + ).toBeNull(); + expect(steeringToastMessage(makePlatformItem(8, { kind: "run.lifecycle", transition: "running" }))).toBeNull(); + }); +}); diff --git a/apps/fabro-web/app/hooks/use-run-toasts.ts b/apps/fabro-web/app/hooks/use-run-toasts.ts index 0badcdb87..98bf668a4 100644 --- a/apps/fabro-web/app/hooks/use-run-toasts.ts +++ b/apps/fabro-web/app/hooks/use-run-toasts.ts @@ -1,8 +1,16 @@ import { useEffect, useRef } from "react"; +import type { RunStreamItem } from "@qltysh/fabro-api-client"; import { useToast } from "../components/toast"; -import { subscribeToRunEvents, type RunEventPayload } from "../lib/run-events"; +import { eventDedupeKey } from "../lib/cross-tab-sse"; +import { + petriBody, + petriEventName, + platformRecordKind, +} from "../lib/petri-stream"; +import { subscribeToRunEvents } from "../lib/run-events"; import type { MutateFn } from "../lib/sse"; +import { getBool, getObject, getString } from "../lib/unknown"; const NOOP_MUTATE = (() => undefined) as MutateFn; const DEDUPE_WINDOW = 256; @@ -13,19 +21,19 @@ const DEDUPE_WINDOW = 256; */ export function useRunToasts(runId: string | undefined) { const { push } = useToast(); - const seenEventIdsRef = useRef(new Set()); + const seenItemKeysRef = useRef(new Set()); useEffect(() => { if (!runId) return; const seen = new Set(); - seenEventIdsRef.current = seen; + seenItemKeysRef.current = seen; return subscribeToRunEvents(runId, NOOP_MUTATE, undefined, { - onEvent: (payload) => { - const dedupeId = eventDedupeId(payload); - if (dedupeId) { - if (seen.has(dedupeId)) return; - seen.add(dedupeId); + onItem: (item) => { + const dedupeKey = eventDedupeKey(item); + if (dedupeKey) { + if (seen.has(dedupeKey)) return; + seen.add(dedupeKey); if (seen.size > DEDUPE_WINDOW) { // Set iteration order is insertion order; drop the oldest. const oldest = seen.values().next().value; @@ -33,7 +41,7 @@ export function useRunToasts(runId: string | undefined) { } } - const message = steeringToastMessage(payload); + const message = steeringToastMessage(item); if (message) { push({ message }); } @@ -42,35 +50,27 @@ export function useRunToasts(runId: string | undefined) { }, [push, runId]); } -function eventDedupeId(payload: RunEventPayload): string | null { - if (typeof payload.id === "string") return payload.id; - if (typeof payload.seq === "number") return `seq:${payload.seq}`; +/** + * The toast a steering item earns: a `control.requested` that delivers a + * `$steer` (queued until an agent stage runs when Petri says it is not + * deliverable) or cancels the firing (an interrupt), and the `run.notice` + * the worker records when it refuses a steer. + */ +export function steeringToastMessage(item: RunStreamItem): string | null { + if (platformRecordKind(item) === "run.notice") { + const record = getObject(item.item, "record"); + if (getString(record, "code") !== "steer_refused") return null; + return getString(record, "message") ?? "Steer refused: no agent stage is running."; + } + if (petriEventName(item) !== "control.requested") return null; + const ctl = getObject(petriBody(item), "ctl"); + if (!ctl) return null; + if (getObject(ctl, "deliver")?.$steer !== undefined) { + const deliverable = getBool(getObject(item.item, "derived"), "deliverable"); + return deliverable === false + ? "Steer queued — will apply when an agent stage runs." + : "Steer delivered."; + } + if (ctl.cancel !== undefined) return "Agent interrupted."; return null; } - -function steeringToastMessage(payload: RunEventPayload): string | null { - const props = payload.properties ?? {}; - - switch (payload.event) { - case "run.interrupt": - return "Agent interrupted."; - case "run.steer": - return "Steer accepted."; - case "agent.steering.injected": - return "Steer delivered."; - case "agent.steer.buffered": - return "Steer queued — will apply when an agent stage runs."; - case "agent.steer.dropped": { - const reason = props.reason; - if (reason === "queue_full") { - return "Steer rate limit reached; oldest queued steer dropped."; - } - if (reason === "run_ended") { - return "Run ended before queued steer(s) could apply."; - } - return null; - } - default: - return null; - } -} diff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts index bb59e3607..87499c86a 100644 --- a/apps/fabro-web/app/lib/api-client.ts +++ b/apps/fabro-web/app/lib/api-client.ts @@ -360,50 +360,6 @@ export async function fetchAllPages( } } -export async function fetchAllStageEvents( - label: string, - loadPage: (sinceSeq: number, limit: number) => Promise>, -): Promise { - const PAGE_LIMIT = 1000; - const MAX_PAGES = 50; - const data: TItem[] = []; - let sinceSeq = 1; - let pagesLoaded = 0; - - while (true) { - const page = await loadPage(sinceSeq, PAGE_LIMIT); - pagesLoaded += 1; - - if (page.data.length === 0) { - if (page.meta.has_more) { - console.warn( - `Stage events fetch for ${label} returned an empty page with has_more=true; stopping at ${data.length} items to avoid spinning.`, - ); - } - return data; - } - - data.push(...page.data); - if (!page.meta.has_more) return data; - - if (pagesLoaded >= MAX_PAGES) { - console.warn( - `Stopped stage events fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`, - ); - return data; - } - - const highestSeq = page.data.reduce((max, event) => Math.max(max, event.seq), sinceSeq - 1); - if (highestSeq < sinceSeq) { - console.warn( - `Stage events fetch for ${label} returned a non-advancing page at since_seq=${sinceSeq}; stopping at ${data.length} items to avoid spinning.`, - ); - return data; - } - sinceSeq = highestSeq + 1; - } -} - export function requestSignalOptions(request?: Request): RawAxiosRequestConfig { return request?.signal ? { signal: request.signal } : {}; } diff --git a/apps/fabro-web/app/lib/board-events.test.tsx b/apps/fabro-web/app/lib/board-events.test.tsx index 7d3a9e7f8..8b7a38588 100644 --- a/apps/fabro-web/app/lib/board-events.test.tsx +++ b/apps/fabro-web/app/lib/board-events.test.tsx @@ -4,6 +4,7 @@ import { shouldRefreshBoardForEvent, subscribeToBoardEvents, } from "./board-events"; +import { makePlatformItem } from "./test-utils"; import { createCrossTabSseCoordinator, type BroadcastChannelLike, @@ -35,12 +36,20 @@ class FakeBroadcastChannel implements BroadcastChannelLike { } describe("shouldRefreshBoardForEvent", () => { - test("refreshes board for run and interview status changes only", () => { - expect(shouldRefreshBoardForEvent("run.running")).toBe(true); - expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true); - expect(shouldRefreshBoardForEvent("run.cancel.requested")).toBe(true); - expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true); - expect(shouldRefreshBoardForEvent("checkpoint.completed")).toBe(false); + test("refreshes the board for the stream items that change a run's row", () => { + // Fabro's lifecycle records, before and after Petri runs the run. + expect(shouldRefreshBoardForEvent("run.lifecycle")).toBe(true); + expect(shouldRefreshBoardForEvent("run.title")).toBe(true); + expect(shouldRefreshBoardForEvent("run.archived")).toBe(true); + expect(shouldRefreshBoardForEvent("pull_request.created")).toBe(true); + // Petri's run events while it does, and the question that blocks it. + expect(shouldRefreshBoardForEvent("run.started")).toBe(true); + expect(shouldRefreshBoardForEvent("run.finished")).toBe(true); + expect(shouldRefreshBoardForEvent("wait.state.changed")).toBe(true); + expect(shouldRefreshBoardForEvent("control.requested")).toBe(true); + // A stage's own progress changes no row. + expect(shouldRefreshBoardForEvent("step.finished")).toBe(false); + expect(shouldRefreshBoardForEvent("checkpoint")).toBe(false); }); }); @@ -68,7 +77,7 @@ describe("subscribeToBoardEvents", () => { await waitFor(() => created.length === 1); keys.length = 0; - source.emit({ event: "run.running" }); + source.emit(makePlatformItem(1, { kind: "run.lifecycle", transition: "running" })); expect(created).toEqual(["/api/v1/attach"]); expect(keys).toHaveLength(1); @@ -104,7 +113,7 @@ describe("subscribeToBoardEvents", () => { throw new Error("source should be reused"); }, { debounceMs: 0, coordinator }); - source.emit({ event: "run.running" }); + source.emit(makePlatformItem(1, { kind: "run.lifecycle", transition: "running" })); expect(created).toEqual(["/api/v1/attach"]); expect(keys).toHaveLength(1); diff --git a/apps/fabro-web/app/lib/board-events.ts b/apps/fabro-web/app/lib/board-events.ts index 08b84f1a2..f080c5688 100644 --- a/apps/fabro-web/app/lib/board-events.ts +++ b/apps/fabro-web/app/lib/board-events.ts @@ -6,6 +6,7 @@ import { type CrossTabSseCoordinator, } from "./cross-tab-sse"; import { runListCacheMatchers } from "./board-cache"; +import { isStreamItemPayload, streamItemName } from "./petri-stream"; import { queryKeys } from "./query-keys"; import { createBrowserEventSource, @@ -21,35 +22,31 @@ interface BoardEventOptions { coordinator?: CrossTabSseCoordinator; } +// The stream items that change what the board shows of a run: its status +// (Fabro's `run.lifecycle` records before and after Petri runs it, Petri's +// own run events while it does), its title, its archive state, its parent, +// its pull request, and the questions that block it (`wait.state.changed` +// turns the status to blocked, `control.requested` delivers the answer). +// Named as `streamItemName` names them: a platform record by its `kind`, a +// Petri event by its `.`. const BOARD_STATUS_EVENTS = new Set([ - "run.submitted", - "run.start_requested", - "run.pending", - "run.approved", - "run.denied", - "run.runnable", - "run.starting", - "run.running", - "run.removing", - "run.paused", - "run.unpaused", - "run.blocked", - "run.unblocked", - "run.cancel.requested", - "run.pause.requested", - "run.unpause.requested", - "run.completed", - "run.failed", + "run.created", + "run.lifecycle", + "run.title", + "run.parent", "run.archived", "run.unarchived", - "run.title.updated", - "interview.started", - "interview.completed", - "interview.timeout", - "interview.interrupted", + "run.superseded", "pull_request.created", - "pull_request.linked", - "pull_request.unlinked", + "interview.answered", + "run.started", + "run.finished", + "run.paused", + "run.unpaused", + "run.stalled", + "invocation.cancel.requested", + "wait.state.changed", + "control.requested", ]); const subscriptions = new Map(); @@ -86,9 +83,10 @@ export function subscribeToBoardEvents( function boardInvalidation(payload: EventPayload) { return { - keys: payload.event && shouldRefreshBoardForEvent(payload.event) - ? boardRunKeys() - : [], + keys: + isStreamItemPayload(payload) && shouldRefreshBoardForEvent(streamItemName(payload)) + ? boardRunKeys() + : [], }; } diff --git a/apps/fabro-web/app/lib/cross-tab-sse.test.ts b/apps/fabro-web/app/lib/cross-tab-sse.test.ts index 76fc0b16e..d4709b2d6 100644 --- a/apps/fabro-web/app/lib/cross-tab-sse.test.ts +++ b/apps/fabro-web/app/lib/cross-tab-sse.test.ts @@ -8,6 +8,8 @@ import { type CrossTabSseCoordinator, type CrossTabSseMessage, } from "./cross-tab-sse"; +import { isStreamItemPayload, platformRecordKind } from "./petri-stream"; +import { makePlatformItem } from "./test-utils"; import type { EventPayload, MutateFn } from "./sse"; type MessageHandler = ((event: { data: string }) => void) | null; @@ -190,7 +192,7 @@ describe("subscribeToCrossTabSse", () => { subscriptionKey: "board", keys: boardKeys, resolveInvalidation: (payload) => ({ - keys: payload.event === "run.running" ? ["board"] : [], + keys: isLifecycleItem(payload) ? ["board"] : [], }), resyncKeys: () => ["board-resync"], }); @@ -198,7 +200,7 @@ describe("subscribeToCrossTabSse", () => { subscriptionKey: "run:run-1", keys: runKeys, resolveInvalidation: (payload) => ({ - keys: payload.event === "run.running" && payload.run_id === "run-1" ? ["run"] : [], + keys: isLifecycleItem(payload) && payload.run_id === "run-1" ? ["run"] : [], }), resyncKeys: () => ["run-resync"], }); @@ -634,7 +636,7 @@ function subscribeForRunEvent(coordinator: CrossTabSseCoordinator, keys: string[ subscriptionKey: "run-feed", keys, resolveInvalidation: (payload) => ({ - keys: payload.event === "run.running" ? ["event"] : [], + keys: isLifecycleItem(payload) ? ["event"] : [], }), resyncKeys: () => ["resync"], }); @@ -698,6 +700,7 @@ function candidateGenerations(coordinator: CrossTabSseCoordinator): number[] { return [...inspectable.candidates.values()].map((candidate) => candidate.candidateGeneration); } +/** A run's `run.lifecycle` stream item: `seq` is its delivery sequence. */ function runEvent({ id, runId, @@ -707,13 +710,15 @@ function runEvent({ runId: string; seq: number; }) { - return { - id, + return makePlatformItem( seq, - run_id: runId, - event: "run.running", - ts: "2026-05-04T12:00:00.000Z", - }; + { kind: "run.lifecycle", transition: "running" }, + { run_id: runId, id }, + ); +} + +function isLifecycleItem(payload: EventPayload): boolean { + return isStreamItemPayload(payload) && platformRecordKind(payload) === "run.lifecycle"; } async function waitFor(condition: () => boolean, timeoutMs = 500) { diff --git a/apps/fabro-web/app/lib/cross-tab-sse.ts b/apps/fabro-web/app/lib/cross-tab-sse.ts index b3df138df..31f0fc6a3 100644 --- a/apps/fabro-web/app/lib/cross-tab-sse.ts +++ b/apps/fabro-web/app/lib/cross-tab-sse.ts @@ -1162,21 +1162,16 @@ function candidateKey(candidate: CandidateMessage): string { return `${candidate.candidateGeneration}:${candidate.candidateId}`; } -export function eventDedupeKey(payload: EventPayload): string | undefined { +/** The key that dedupes a stream item across tabs and sources, from a parsed frame or a typed item. */ +export function eventDedupeKey(payload: { + run_id?: unknown; + stream_seq?: unknown; +}): string | undefined { // A run stream item's `id` is the item's own identity within its run (a - // Petri `EventId` or a platform record seq), so two runs share ids. + // Petri `EventId` or a platform record seq), so two runs share ids; the + // run and the delivery sequence together are unique. if (typeof payload.stream_seq === "number" && typeof payload.run_id === "string") { return `${payload.run_id}:stream:${payload.stream_seq}`; } - if (typeof payload.id === "string" && payload.id.length > 0) { - return payload.id; - } - - const runId = typeof payload.run_id === "string" ? payload.run_id : undefined; - const seq = typeof payload.seq === "number" ? payload.seq : undefined; - const event = typeof payload.event === "string" ? payload.event : undefined; - if (runId && seq != null && event) { - return `${runId}:${seq}:${event}`; - } return undefined; } diff --git a/apps/fabro-web/app/lib/live-events.test.tsx b/apps/fabro-web/app/lib/live-events.test.tsx index 433718c1f..1a6a61615 100644 --- a/apps/fabro-web/app/lib/live-events.test.tsx +++ b/apps/fabro-web/app/lib/live-events.test.tsx @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; +import type { RunStreamItem } from "@qltysh/fabro-api-client"; import { createCrossTabSseCoordinator, type BroadcastChannelLike, } from "./cross-tab-sse"; -import { subscribeToLiveEvents, type LiveEventPayload } from "./live-events"; +import { subscribeToLiveEvents } from "./live-events"; +import { makePetriItem, makePlatformItem } from "./test-utils"; import type { EventSourceLike } from "./sse"; type MessageHandler = ((event: { data: string }) => void) | null; @@ -34,7 +36,7 @@ describe("subscribeToLiveEvents", () => { test("coordinated mode opens /api/v1/attach and forwards every payload", async () => { const source = new FakeEventSource(); const created: string[] = []; - const seen: LiveEventPayload[] = []; + const seen: RunStreamItem[] = []; const coordinator = createCoordinator((url) => { created.push(url); return source; @@ -50,8 +52,10 @@ describe("subscribeToLiveEvents", () => { await waitFor(() => created.length === 1); - source.emit({ id: "evt-1", event: "stage.started", run_id: "run-a" }); - source.emit({ id: "evt-2", event: "agent.message", run_id: "run-b" }); + source.emit(makePetriItem(1, { event: "step.started", firing: 1 }, { run_id: "run-a" })); + source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "run-b" })); + // A frame that is not a stream item is dropped. + source.emit({ event: "stage.started", run_id: "run-c" }); expect(created).toEqual(["/api/v1/attach"]); expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]); @@ -63,7 +67,7 @@ describe("subscribeToLiveEvents", () => { test("fallback mode opens /api/v1/attach (not a per-run URL) and forwards payloads", () => { const source = new FakeEventSource(); const created: string[] = []; - const seen: LiveEventPayload[] = []; + const seen: RunStreamItem[] = []; const coordinator = createFallbackCoordinator(); const cleanup = subscribeToLiveEvents( @@ -75,8 +79,12 @@ describe("subscribeToLiveEvents", () => { { coordinator }, ); - source.emit({ id: "evt-1", event: "run.completed", run_id: "run-a" }); - source.emit({ id: "evt-2", event: "run.failed", run_id: "run-b" }); + source.emit( + makePlatformItem(9, { kind: "run.lifecycle", transition: "succeeded" }, { run_id: "run-a" }), + ); + source.emit( + makePlatformItem(9, { kind: "run.lifecycle", transition: "failed" }, { run_id: "run-b" }), + ); expect(created).toEqual(["/api/v1/attach"]); expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]); diff --git a/apps/fabro-web/app/lib/live-events.ts b/apps/fabro-web/app/lib/live-events.ts index b80717125..82ec3b4b5 100644 --- a/apps/fabro-web/app/lib/live-events.ts +++ b/apps/fabro-web/app/lib/live-events.ts @@ -1,10 +1,12 @@ import { useEffect, useRef } from "react"; +import type { RunStreamItem } from "@qltysh/fabro-api-client"; import type { Key } from "swr"; import { subscribeToCrossTabSse, type CrossTabSseCoordinator, } from "./cross-tab-sse"; +import { isStreamItemPayload } from "./petri-stream"; import { queryKeys } from "./query-keys"; import { createBrowserEventSource, @@ -14,17 +16,6 @@ import { type SharedEventSubscription, } from "./sse"; -export interface LiveEventPayload extends EventPayload { - id?: string; - seq?: number; - event?: string; - ts?: string; - run_id?: string; - node_id?: string; - stage_id?: string; - properties?: Record; -} - interface LiveEventOptions { coordinator?: CrossTabSseCoordinator; } @@ -34,48 +25,50 @@ const SUBSCRIPTION_KEY = "live-events"; const NO_KEYS: Key[] = []; const NOOP_MUTATE = () => Promise.resolve(); +/** + * Every run stream item the global attach stream (`GET /api/v1/attach`) + * delivers, across all runs. A frame that is not a stream item is dropped. + */ export function subscribeToLiveEvents( - onEvent: (payload: LiveEventPayload) => void, + onItem: (item: RunStreamItem) => void, eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource, { coordinator }: LiveEventOptions = {}, ): () => void { - return subscribeToCrossTabSse({ + const forward = (payload: EventPayload) => { + if (isStreamItemPayload(payload)) onItem(payload); + return { keys: NO_KEYS }; + }; + return subscribeToCrossTabSse({ coordinator, subscriptionKey: SUBSCRIPTION_KEY, mutate: NOOP_MUTATE, debounceMs: 0, resyncKeys: () => NO_KEYS, - resolveInvalidation: (payload) => { - onEvent(payload); - return { keys: NO_KEYS }; - }, + resolveInvalidation: forward, fallbackSubscribe: () => - subscribeToSharedEventSource({ + subscribeToSharedEventSource({ subscriptions, subscriptionKey: SUBSCRIPTION_KEY, url: queryKeys.system.attachUrl(), mutate: NOOP_MUTATE, eventSourceFactory, debounceMs: 0, - resolveInvalidation: (payload) => { - onEvent(payload); - return { keys: NO_KEYS }; - }, + resolveInvalidation: forward, }), }); } /** * Synchronizes React with the shared live-events SSE stream. The subscription is - * closed before resubscribe and on unmount; `onEvent` sees the latest render. + * closed before resubscribe and on unmount; `onItem` sees the latest render. */ export function useLiveEventsSubscription( - onEvent: (payload: LiveEventPayload) => void, + onItem: (item: RunStreamItem) => void, ) { - const onEventRef = useRef(onEvent); - onEventRef.current = onEvent; + const onItemRef = useRef(onItem); + onItemRef.current = onItem; useEffect(() => { - return subscribeToLiveEvents((payload) => onEventRef.current(payload)); + return subscribeToLiveEvents((item) => onItemRef.current(item)); }, []); } diff --git a/apps/fabro-web/app/lib/petri-stream.test.ts b/apps/fabro-web/app/lib/petri-stream.test.ts index 985f1d454..dbe7d9e37 100644 --- a/apps/fabro-web/app/lib/petri-stream.test.ts +++ b/apps/fabro-web/app/lib/petri-stream.test.ts @@ -8,7 +8,6 @@ import { deriveRunPhasesFromStream, extractPetriStageContext, findPetriEdgeForStage, - isPetriRun, isStreamItemPayload, isTerminalLifecycleItem, itemsForStage, @@ -31,7 +30,6 @@ const gate = loadPetriFixture("gate"); describe("stream items", () => { test("a fixture run executes on Petri and its stream is dense", () => { for (const fixture of [hello, command, parallel, gate]) { - expect(isPetriRun(fixture.projection)).toBe(true); const seqs = fixture.stream.map((item) => item.stream_seq); expect(seqs).toEqual(seqs.map((_, index) => index + 1)); for (const item of fixture.stream) { @@ -39,7 +37,6 @@ describe("stream items", () => { expect(item.run_id).toBe(fixture.run_id); } } - expect(isPetriRun({ spec: {} } as never)).toBe(false); expect(isStreamItemPayload({ event: "run.completed", seq: 3 })).toBe(false); }); diff --git a/apps/fabro-web/app/lib/petri-stream.ts b/apps/fabro-web/app/lib/petri-stream.ts index ed060081c..6721488c8 100644 --- a/apps/fabro-web/app/lib/petri-stream.ts +++ b/apps/fabro-web/app/lib/petri-stream.ts @@ -47,17 +47,7 @@ export function isPlatformItem(item: RunStreamItem): boolean { return item.kind === "platform"; } -/** - * Whether the projection is of a run that executes on Petri: every run - * does, so this is whether the projection carries a spec at all. - */ -export function isPetriRun( - projection: RunProjection | null | undefined, -): boolean { - return isRecord(projection?.spec?.admission); -} - -/** Whether an SSE payload is a run stream item rather than a legacy event. */ +/** Whether an SSE frame, parsed, is a run stream item. */ export function isStreamItemPayload( payload: unknown, ): payload is RunStreamItem { @@ -329,8 +319,7 @@ export function isTerminalLifecycleItem(item: RunStreamItem): boolean { /** * The run's phases before its stages own the timeline, from the platform - * `run.lifecycle` records: the same slices `deriveRunPhases` cuts from the - * legacy lifecycle events. + * `run.lifecycle` records. */ export function deriveRunPhasesFromStream( stream: PetriStream, @@ -559,7 +548,7 @@ export function reducerTranscriptFromProjection( } // The command step's own bookkeeping (`command.output`, `failure_class`) -// joins the engine keys the legacy Context tab hides. +// joins the engine keys the Context tab hides. const ENGINE_CONTEXT_KEYS = new Set([ "last_stage", "last_response", diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 6de55ea4e..fc51316c4 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -11,8 +11,6 @@ import type { CommandLogResponse, Environment, EnvironmentListResponse, - EventEnvelope, - ListRunEvents200Response, ListRunsDirectionEnum, ListRunsSortEnum, McpServer, @@ -54,7 +52,6 @@ import { automationsApi, environmentsApi, fetchAllPages, - fetchAllStageEvents, generatedAxios, humanInTheLoopApi, insightsApi, @@ -369,16 +366,6 @@ export function useRunPullRequest(id: string | undefined) { ); } -export function useRunStageEvents(id: string | undefined, stageId: string | undefined) { - return useSWR( - id && stageId ? queryKeys.runs.stageEvents(id, stageId) : null, - () => - fetchAllStageEvents(`run ${id} stage ${stageId}`, (sinceSeq, limit) => - apiData(() => runInternalsApi.listStageEvents(id!, stageId!, sinceSeq, limit)), - ), - ); -} - export function useRunStageContextWindow( id: string | undefined, stageId: string | undefined, @@ -389,59 +376,20 @@ export function useRunStageContextWindow( ); } -/** - * `GET /runs/{id}/events` answers in the run engine's envelope: a legacy run - * pages `EventEnvelope`s by `since_seq`, a Petri run pages `RunStreamItem`s - * by `after`. The stream page names the Petri event contract version it - * follows; the legacy page never does. - */ -function isRunStreamPage( - page: ListRunEvents200Response, -): page is Extract { - return "event_contract_version" in page; -} - -export function useRunEventsList(id: string | undefined) { - return useSWR( - id ? queryKeys.runs.events(id, 1000) : null, - () => - fetchAllStageEvents(`run ${id} events`, async (sinceSeq, limit) => { - const page = await apiData(() => - runInternalsApi.listRunEvents(id!, sinceSeq, limit), - ); - // A Petri run's events are a stream, read by `useRunStream`; the - // legacy list of such a run is empty. - if (isRunStreamPage(page)) { - return { data: [], meta: { has_more: false } }; - } - return page; - }), - ); -} - const STREAM_PAGE_LIMIT = 1000; const STREAM_MAX_PAGES = 50; /** - * Every item of a Petri run's stream, paged by `after` (the last - * `stream_seq` seen). A legacy run has no stream: its page comes back in - * the legacy envelope and reads as empty here. + * Every item of a run's stream (`GET /runs/{id}/events`), paged by `after` + * (the last `stream_seq` seen). */ async function fetchRunStream(id: string): Promise { const items: RunStreamItem[] = []; let after = 0; for (let pages = 0; pages < STREAM_MAX_PAGES; pages += 1) { const page = await apiData(() => - runInternalsApi.listRunEvents( - id, - undefined, - STREAM_PAGE_LIMIT, - undefined, - undefined, - after, - ), + runInternalsApi.listRunEvents(id, STREAM_PAGE_LIMIT, after), ); - if (!isRunStreamPage(page)) return items; if (page.data.length === 0) return items; items.push(...page.data); const last = page.data[page.data.length - 1]; @@ -454,7 +402,7 @@ async function fetchRunStream(id: string): Promise { return items; } -/** A Petri run's stream: Petri's events and Fabro's platform records, in order. */ +/** A run's stream: Petri's events and Fabro's platform records, in order. */ export function useRunStream(id: string | undefined) { return useSWR( id ? queryKeys.runs.stream(id) : null, diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 2e53d9ef5..8c6ac585a 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -60,7 +60,6 @@ export const queryKeys = { usage: (id: string) => ["runs", "usage", id] as const, questions: (id: string, limit = 1, offset = 0) => ["runs", "questions", id, limit, offset] as const, - events: (id: string, limit = 1000) => ["runs", "events", id, limit] as const, /** A Petri run's stream: every `RunStreamItem` in `stream_seq` order. */ stream: (id: string) => ["runs", "stream", id] as const, stageEvents: (id: string, stageId: string) => diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index 356d8cc35..21d4a2f4f 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -2,11 +2,9 @@ import { describe, expect, test } from "bun:test"; import type { Key } from "swr"; import { loadPetriFixture } from "./petri-fixtures"; -import { - queryKeysForRunEvent, - queryKeysForStreamItem, - subscribeToRunEvents, -} from "./run-events"; +import { streamItemName } from "./petri-stream"; +import { queryKeysForStreamItem, subscribeToRunEvents } from "./run-events"; +import { makePetriItem, makePlatformItem } from "./test-utils"; import { createCrossTabSseCoordinator, type BroadcastChannelLike, @@ -41,194 +39,6 @@ class FakeBroadcastChannel implements BroadcastChannelLike { close() {} } -describe("queryKeysForRunEvent", () => { - test("terminal events invalidate run-scoped resources", () => { - expect(queryKeysForRunEvent("run-1", "run.completed")).toEqual([ - queryKeys.runs.detail("run-1"), - queryKeys.runs.state("run-1"), - ...queryKeys.runs.filesAllScopes("run-1"), - queryKeys.runs.commits("run-1"), - queryKeys.runs.usage("run-1"), - queryKeys.runs.stages("run-1"), - queryKeys.runs.graph("run-1", "LR"), - queryKeys.runs.graph("run-1", "TB"), - ]); - }); - - test("stage.retrying invalidates stage-scoped and run-scoped resources", () => { - expect(queryKeysForRunEvent("run-1", "stage.retrying", "verify@2")).toEqual([ - queryKeys.runs.stages("run-1"), - queryKeys.runs.usage("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.state("run-1"), - queryKeys.runs.stageEvents("run-1", "verify@2"), - queryKeys.runs.stageContextWindow("run-1", "verify@2"), - ]); - }); - - test("stage-scoped steering events invalidate run events and stage-scoped resources", () => { - expect(queryKeysForRunEvent("run-1", "agent.session.activated", "agent@1")).toEqual([ - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.stageEvents("run-1", "agent@1"), - queryKeys.runs.stageContextWindow("run-1", "agent@1"), - ]); - }); - - test("stage-scoped interrupt injection invalidates run events and stage-scoped resources", () => { - expect(queryKeysForRunEvent("run-1", "agent.interrupt.injected", "nap@1")).toEqual([ - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.stageEvents("run-1", "nap@1"), - queryKeys.runs.stageContextWindow("run-1", "nap@1"), - ]); - }); - - test("interrupt settlement invalidates projected control state and stage activity", () => { - expect(queryKeysForRunEvent("run-1", "agent.round.interrupted", "nap@1")).toEqual([ - queryKeys.runs.detail("run-1"), - queryKeys.runs.usage("run-1"), - queryKeys.runs.state("run-1"), - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.stageEvents("run-1", "nap@1"), - queryKeys.runs.stageContextWindow("run-1", "nap@1"), - ]); - }); - - test("parallel branch lifecycle invalidates the stages list backing live branch rows", () => { - // Branches bypass stage.started/stage.completed, so these events are the - // only signal that a branch row's status changed. - expect(queryKeysForRunEvent("run-1", "parallel.branch.started", "review_glm@1")).toEqual([ - queryKeys.runs.stages("run-1"), - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.graph("run-1", "LR"), - queryKeys.runs.graph("run-1", "TB"), - queryKeys.runs.stageEvents("run-1", "review_glm@1"), - ]); - expect(queryKeysForRunEvent("run-1", "parallel.branch.completed", "review_glm@1")).toEqual([ - queryKeys.runs.stages("run-1"), - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.graph("run-1", "LR"), - queryKeys.runs.graph("run-1", "TB"), - queryKeys.runs.stageEvents("run-1", "review_glm@1"), - ]); - }); - - test("fork lifecycle invalidates run-scoped resources without a stage id", () => { - for (const event of ["parallel.started", "parallel.completed"]) { - expect(queryKeysForRunEvent("run-1", event)).toEqual([ - queryKeys.runs.stages("run-1"), - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.graph("run-1", "LR"), - queryKeys.runs.graph("run-1", "TB"), - ]); - } - }); - - test("cancel requests invalidate the durable run summary", () => { - expect(queryKeysForRunEvent("run-1", "run.cancel.requested")).toEqual([ - queryKeys.runs.detail("run-1"), - ]); - }); - - test("pair messages invalidate stage-scoped resources", () => { - expect(queryKeysForRunEvent("run-1", "agent.pair.user_message", "nap@1")).toEqual([ - queryKeys.runs.stageEvents("run-1", "nap@1"), - queryKeys.runs.stageContextWindow("run-1", "nap@1"), - ]); - expect(queryKeysForRunEvent("run-1", "agent.pair.system_message", "nap@1")).toEqual([ - queryKeys.runs.stageEvents("run-1", "nap@1"), - queryKeys.runs.stageContextWindow("run-1", "nap@1"), - ]); - }); - - test("todo events invalidate run state and run events", () => { - for (const event of ["todo.created", "todo.updated", "todo.deleted"]) { - expect(queryKeysForRunEvent("run-1", event)).toEqual([ - queryKeys.runs.state("run-1"), - queryKeys.runs.events("run-1", 1000), - ]); - } - }); - - test("todo events with a stage id also invalidate that stage's events", () => { - expect(queryKeysForRunEvent("run-1", "todo.created", "code@1")).toEqual([ - queryKeys.runs.state("run-1"), - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.stageEvents("run-1", "code@1"), - ]); - }); - - test("every inference projection transition invalidates live run state", () => { - for (const event of [ - "agent.llm.started", - "agent.error", - ]) { - expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ - queryKeys.runs.detail("run-1"), - queryKeys.runs.state("run-1"), - queryKeys.runs.usage("run-1"), - queryKeys.runs.stageEvents("run-1", "code@1"), - ]); - } - for (const event of ["agent.llm.first_output", "agent.llm.retry"]) { - expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ - queryKeys.runs.state("run-1"), - queryKeys.runs.stageEvents("run-1", "code@1"), - ]); - } - expect( - queryKeysForRunEvent("run-1", "agent.message", "code@1"), - ).toEqual([ - queryKeys.runs.detail("run-1"), - queryKeys.runs.state("run-1"), - queryKeys.runs.usage("run-1"), - queryKeys.runs.stageEvents("run-1", "code@1"), - queryKeys.runs.stageContextWindow("run-1", "code@1"), - ]); - expect(queryKeysForRunEvent("run-1", "agent.session.ended")).toEqual([ - queryKeys.runs.detail("run-1"), - queryKeys.runs.state("run-1"), - queryKeys.runs.usage("run-1"), - ]); - }); - - test("ACP timing events invalidate live summaries and stage events", () => { - for (const event of [ - "agent.acp.started", - "agent.acp.completed", - "agent.acp.cancelled", - "agent.acp.timed_out", - ]) { - expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ - queryKeys.runs.detail("run-1"), - queryKeys.runs.state("run-1"), - queryKeys.runs.usage("run-1"), - queryKeys.runs.stageEvents("run-1", "code@1"), - ]); - } - }); - - test("tool timing events invalidate live summaries and stage resources", () => { - for (const event of ["agent.tool.started", "agent.tool.completed"]) { - expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ - queryKeys.runs.detail("run-1"), - queryKeys.runs.state("run-1"), - queryKeys.runs.usage("run-1"), - queryKeys.runs.stageEvents("run-1", "code@1"), - queryKeys.runs.stageContextWindow("run-1", "code@1"), - ]); - } - }); - - test("watchdog timeout refreshes the stage events for that stage", () => { - expect( - queryKeysForRunEvent("run-1", "watchdog.timeout", "code@1"), - ).toEqual([queryKeys.runs.stageEvents("run-1", "code@1")]); - }); -}); - describe("queryKeysForStreamItem", () => { const parallel = loadPetriFixture("parallel"); const gate = loadPetriFixture("gate"); @@ -351,13 +161,15 @@ describe("subscribeToRunEvents", () => { await waitFor(() => created.length === 1); keys.length = 0; - source.emit({ event: "checkpoint.completed", run_id: "other-run" }); - source.emit({ event: "checkpoint.completed", run_id: "run-coordinated" }); + source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "other-run" })); + source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "run-coordinated" })); expect(created).toEqual(["/api/v1/attach"]); expect(keys).toEqual([ ...queryKeys.runs.filesAllScopes("run-coordinated"), queryKeys.runs.commits("run-coordinated"), + queryKeys.runs.state("run-coordinated"), + queryKeys.runs.stream("run-coordinated"), ]); cleanup(); @@ -381,15 +193,25 @@ describe("subscribeToRunEvents", () => { await waitFor(() => source.onmessage !== null); keys.length = 0; - source.emit({ event: "run.failed", run_id: "run-terminal" }); + source.emit( + makePlatformItem( + 1, + { kind: "run.lifecycle", transition: "failed", status: { kind: "failed", reason: "error" } }, + { run_id: "run-terminal" }, + ), + ); expect(source.closed).toBe(false); expect(keys).toContainEqual(queryKeys.runs.files("run-terminal")); expect(keys).toContainEqual(queryKeys.runs.usage("run-terminal")); keys.length = 0; - source.emit({ event: "run.archived", run_id: "run-terminal" }); + source.emit(makePlatformItem(2, { kind: "run.archived" }, { run_id: "run-terminal" })); expect(source.closed).toBe(false); - expect(keys).toEqual([queryKeys.runs.detail("run-terminal")]); + expect(keys).toEqual([ + queryKeys.runs.detail("run-terminal"), + queryKeys.runs.state("run-terminal"), + queryKeys.runs.stream("run-terminal"), + ]); cleanup(); coordinator.close(); @@ -416,12 +238,14 @@ describe("subscribeToRunEvents", () => { expect(created).toEqual(["/api/v1/runs/run-refcount/attach"]); firstCleanup(); - source.emit({ event: "checkpoint.completed" }); + source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "run-refcount" })); expect(source.closed).toBe(false); expect(keys).toEqual([ ...queryKeys.runs.filesAllScopes("run-refcount"), queryKeys.runs.commits("run-refcount"), + queryKeys.runs.state("run-refcount"), + queryKeys.runs.stream("run-refcount"), ]); secondCleanup(); @@ -449,15 +273,21 @@ describe("subscribeToRunEvents", () => { }, { debounceMs: 0, coordinator, - onEvent: (payload) => { - if (payload.event) seen.push(payload.event); + onItem: (item) => { + seen.push(streamItemName(item)); }, }); - source.emit({ id: "evt-1", event: "agent.steer.buffered", properties: {} }); + source.emit( + makePlatformItem(1, { kind: "run.notice", code: "steer_refused" }, { run_id: "run-shared-payload" }), + ); - expect(seen).toEqual(["agent.steer.buffered"]); - expect(keys).toEqual([queryKeys.runs.events("run-shared-payload", 1000)]); + expect(seen).toEqual(["run.notice"]); + expect(keys).toEqual([ + queryKeys.runs.detail("run-shared-payload"), + queryKeys.runs.state("run-shared-payload"), + queryKeys.runs.stream("run-shared-payload"), + ]); firstCleanup(); secondCleanup(); @@ -478,7 +308,19 @@ describe("subscribeToRunEvents", () => { { debounceMs: 0, coordinator }, ); + // A frame that is not a stream item invalidates nothing and keeps the + // source open. source.emit({ event: "run.failed" }); + expect(source.closed).toBe(false); + expect(keys).toEqual([]); + + source.emit( + makePlatformItem( + 1, + { kind: "run.lifecycle", transition: "dead", status: { kind: "dead", reason: "lease_lost" } }, + { run_id: "run-terminal" }, + ), + ); expect(source.closed).toBe(true); expect(keys).toContainEqual(queryKeys.runs.files("run-terminal")); @@ -488,7 +330,7 @@ describe("subscribeToRunEvents", () => { coordinator.close(); }); - test("envelope with suffixed stage_id invalidates stageEvents(runId, stageId)", async () => { + test("a stage's step.finished on the run's own stream invalidates its stage keys", async () => { const source = new FakeEventSource(); const keys: Key[] = []; const coordinator = createCoordinator(() => source); @@ -503,43 +345,19 @@ describe("subscribeToRunEvents", () => { ); await waitFor(() => source.onmessage !== null); - source.emit({ - event: "stage.retrying", - run_id: "run-stage", - stage_id: "verify@2", - node_id: "verify", - }); - - expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@2")); - expect(keys).toContainEqual(queryKeys.runs.stages("run-stage")); - expect(keys).toContainEqual(queryKeys.runs.events("run-stage", 1000)); - expect(keys).toContainEqual(queryKeys.runs.graph("run-stage", "LR")); - expect(keys).toContainEqual(queryKeys.runs.detail("run-stage")); - expect(keys).not.toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify")); - - cleanup(); - coordinator.close(); - }); - - test("falls back to node_id when an event has no stage_id", async () => { - const source = new FakeEventSource(); - const keys: Key[] = []; - const coordinator = createCoordinator(() => source); - const cleanup = subscribeToRunEvents( - "run-stage-node", - (key) => { - keys.push(key); - return Promise.resolve(); - }, - () => source, - { debounceMs: 0, coordinator }, + source.emit( + makePetriItem( + 1, + { event: "step.finished", firing: 2 }, + { stage: { name: "verify", visit: 2 }, run_id: "run-stage" }, + ), ); - await waitFor(() => source.onmessage !== null); - source.emit({ event: "stage.started", run_id: "run-stage-node", node_id: "verify" }); - - expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage-node", "verify")); - expect(keys).toContainEqual(queryKeys.runs.stages("run-stage-node")); + expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@2")); + expect(keys).toContainEqual(queryKeys.runs.stageContextWindow("run-stage", "verify@2")); + expect(keys).toContainEqual(queryKeys.runs.stages("run-stage")); + expect(keys).toContainEqual(queryKeys.runs.stream("run-stage")); + expect(keys).not.toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@1")); cleanup(); coordinator.close(); @@ -613,27 +431,3 @@ async function waitFor(condition: () => boolean, timeoutMs = 200) { } throw new Error("condition did not become true before timeout"); } - -describe("agent session events", () => { - test("refresh the run state the stage sidebar reads its agent facts from", () => { - for (const event of [ - "agent.route.failover", - "agent.route.failover.stopped", - "agent.mcp.server.ready", - "agent.mcp.server.failed", - "agent.mcp.server.disconnected", - "agent.skills.discovered", - "agent.skill.activated", - "agent.sub.spawned", - "agent.sub.completed", - "agent.sub.failed", - "agent.compaction.completed", - ]) { - expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ - queryKeys.runs.state("run-1"), - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.stageEvents("run-1", "code@1"), - ]); - } - }); -}); diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 98c9a6891..0a356c091 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -25,319 +25,30 @@ import { 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 { - id?: string; - seq?: number; - event?: string; run_id?: string; - node_id?: string; - stage_id?: string; - properties?: Record; - /** Set on a Petri run stream item, which is invalidated by its own rules. */ stream_seq?: number; kind?: string; + id?: string; + recorded_at?: number; item?: unknown; } interface RunEventOptions { debounceMs?: number; coordinator?: CrossTabSseCoordinator; - onEvent?: (payload: RunEventPayload) => void; + /** Called with every well-formed stream item of the run, before invalidation. */ + onItem?: (item: RunStreamItem) => void; } const subscriptions = new Map(); -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(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", -]); -// The coding agent's own events whose facts the stage sidebar reads from -// `StageProjection.agent`: route moves, MCP server outcomes, skills, -// subagent lifecycle, and compactions. Each refreshes the run state the -// sidebar renders from, like a todo mutation does. Files the tree wrote -// arrive with `agent.tool.completed`, which already refreshes it. -const AGENT_SESSION_EVENTS = new Set([ - "agent.route.failover", - "agent.route.failover.stopped", - "agent.mcp.server.ready", - "agent.mcp.server.failed", - "agent.mcp.server.disconnected", - "agent.skills.discovered", - "agent.skill.activated", - "agent.sub.spawned", - "agent.sub.turn.started", - "agent.sub.completed", - "agent.sub.failed", - "agent.sub.closed", - "agent.compaction.completed", -]); - -function liveTimingKeys(runId: string): Key[] { - return [ - queryKeys.runs.detail(runId), - queryKeys.runs.state(runId), - queryKeys.runs.usage(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.usage(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.usage(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.usage(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) || AGENT_SESSION_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 []; -} - /** - * The SWR keys a Petri run stream item invalidates. Petri's events are + * The SWR keys a run stream item invalidates. Petri's events are * named `.`; 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. @@ -485,7 +196,7 @@ export function subscribeToRunEvents( runId: string, mutate: MutateFn, eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource, - { debounceMs = 300, coordinator, onEvent }: RunEventOptions = {}, + { debounceMs = 300, coordinator, onItem }: RunEventOptions = {}, ): () => void { return subscribeToCrossTabSse({ coordinator, @@ -495,8 +206,7 @@ export function subscribeToRunEvents( resyncKeys: () => resyncKeysForRun(runId), resolveInvalidation: (payload) => { if (payload.run_id !== runId) return { keys: [] }; - onEvent?.(payload); - return runInvalidation(runId, payload); + return runInvalidation(runId, payload, onItem); }, fallbackSubscribe: () => subscribeToSharedEventSource({ @@ -507,25 +217,22 @@ export function subscribeToRunEvents( eventSourceFactory, debounceMs, resolveInvalidation: (payload) => { - onEvent?.(payload); - const result = runInvalidation(runId, payload); + const result = runInvalidation(runId, payload, onItem); return { ...result, close: result.immediate }; }, }), }); } -function runInvalidation(runId: string, payload: RunEventPayload) { - if (isStreamItemPayload(payload)) { - return queryKeysForStreamItem(runId, payload); - } - 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 }; +/** 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) { @@ -536,7 +243,6 @@ function resyncKeysForRun(runId: string) { queryKeys.runs.commits(runId), queryKeys.runs.usage(runId), queryKeys.runs.stages(runId), - queryKeys.runs.events(runId, 1000), queryKeys.runs.stream(runId), queryKeys.runs.graph(runId, "LR"), queryKeys.runs.graph(runId, "TB"), @@ -544,13 +250,6 @@ function resyncKeysForRun(runId: string) { ]; } -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. diff --git a/apps/fabro-web/app/lib/run-phases.test.ts b/apps/fabro-web/app/lib/run-phases.test.ts deleted file mode 100644 index 7c2dab22d..000000000 --- a/apps/fabro-web/app/lib/run-phases.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; - -import { deriveRunPhases } from "./run-phases"; - -const CREATED = "2026-05-23T12:00:00.000Z"; -const T_REQUESTED = "2026-05-23T12:00:01.000Z"; -const T_PENDING = "2026-05-23T12:00:02.000Z"; -const T_RUNNABLE = "2026-05-23T12:00:03.000Z"; -const T_STARTING = "2026-05-23T12:00:04.000Z"; -const T_RUNNING = "2026-05-23T12:00:10.000Z"; -const T_FAILED = "2026-05-23T12:00:12.000Z"; - -function makeEvent(name: string, ts: string, seq: number): EventEnvelope { - return { - id: `evt-${seq}`, - seq, - ts, - run_id: "run-1", - event: name, - } as EventEnvelope; -} - -describe("deriveRunPhases", () => { - test("returns empty for an unparseable created_at", () => { - expect(deriveRunPhases([], "not-a-date")).toEqual([]); - }); - - test("submitted phase is open-ended when no transitions have fired", () => { - const phases = deriveRunPhases([], CREATED); - expect(phases).toEqual([ - { - kind: "submitted", - label: "Submitted", - startMs: Date.parse(CREATED), - endMs: null, - }, - ]); - }); - - test("closes submitted at run.start_requested and opens pending when approval is required", () => { - const phases = deriveRunPhases( - [ - makeEvent("run.start_requested", T_REQUESTED, 1), - makeEvent("run.pending", T_PENDING, 2), - ], - CREATED, - ); - expect(phases).toEqual([ - { - kind: "submitted", - label: "Submitted", - startMs: Date.parse(CREATED), - endMs: Date.parse(T_REQUESTED), - }, - { - kind: "pending", - label: "Pending", - startMs: Date.parse(T_PENDING), - endMs: null, - }, - ]); - }); - - test("emits submitted, pending, runnable, and initializing through run.running", () => { - const phases = deriveRunPhases( - [ - makeEvent("run.start_requested", T_REQUESTED, 1), - makeEvent("run.pending", T_PENDING, 2), - makeEvent("run.runnable", T_RUNNABLE, 3), - makeEvent("run.starting", T_STARTING, 4), - makeEvent("run.running", T_RUNNING, 5), - ], - CREATED, - ); - expect(phases).toEqual([ - { - kind: "submitted", - label: "Submitted", - startMs: Date.parse(CREATED), - endMs: Date.parse(T_REQUESTED), - }, - { - kind: "pending", - label: "Pending", - startMs: Date.parse(T_PENDING), - endMs: Date.parse(T_RUNNABLE), - }, - { - kind: "runnable", - label: "Runnable", - startMs: Date.parse(T_RUNNABLE), - endMs: Date.parse(T_STARTING), - }, - { - kind: "initializing", - label: "Initializing", - startMs: Date.parse(T_STARTING), - endMs: Date.parse(T_RUNNING), - }, - ]); - }); - - test("skips pending and runnable phases when those events are missing", () => { - const phases = deriveRunPhases( - [ - makeEvent("run.starting", T_STARTING, 1), - makeEvent("run.running", T_RUNNING, 2), - ], - CREATED, - ); - expect(phases.map((p) => p.kind)).toEqual(["submitted", "initializing"]); - expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING)); - expect(phases[1]!.startMs).toBe(Date.parse(T_STARTING)); - expect(phases[1]!.endMs).toBe(Date.parse(T_RUNNING)); - }); - - test("closes initializing at run.failed when the run never reaches running", () => { - const phases = deriveRunPhases( - [ - makeEvent("run.start_requested", T_REQUESTED, 1), - makeEvent("run.runnable", T_RUNNABLE, 2), - makeEvent("run.starting", T_STARTING, 3), - makeEvent("run.failed", T_FAILED, 4), - ], - CREATED, - ); - expect(phases).toEqual([ - { - kind: "submitted", - label: "Submitted", - startMs: Date.parse(CREATED), - endMs: Date.parse(T_REQUESTED), - }, - { - kind: "runnable", - label: "Runnable", - startMs: Date.parse(T_RUNNABLE), - endMs: Date.parse(T_STARTING), - }, - { - kind: "initializing", - label: "Initializing", - startMs: Date.parse(T_STARTING), - endMs: Date.parse(T_FAILED), - }, - ]); - }); - - test("uses run.starting as fallback end for submitted when pre-execution events are missing", () => { - const phases = deriveRunPhases( - [makeEvent("run.starting", T_STARTING, 1)], - CREATED, - ); - expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING)); - }); - - test("ignores unrelated events", () => { - const phases = deriveRunPhases( - [ - makeEvent("agent.message", T_REQUESTED, 1), - makeEvent("stage.started", T_STARTING, 2), - ], - CREATED, - ); - expect(phases).toEqual([ - { - kind: "submitted", - label: "Submitted", - startMs: Date.parse(CREATED), - endMs: null, - }, - ]); - }); -}); diff --git a/apps/fabro-web/app/lib/run-phases.ts b/apps/fabro-web/app/lib/run-phases.ts index bc847eec6..c4063285c 100644 --- a/apps/fabro-web/app/lib/run-phases.ts +++ b/apps/fabro-web/app/lib/run-phases.ts @@ -1,110 +1,12 @@ -import type { EventEnvelope } from "@qltysh/fabro-api-client"; - export type RunPhaseKind = "submitted" | "pending" | "runnable" | "initializing"; +/** + * A slice of the run's timeline before its stages own it, from the platform + * `run.lifecycle` records (`deriveRunPhasesFromStream`). + */ export interface RunPhase { kind: RunPhaseKind; label: string; startMs: number; endMs: number | null; } - -const PHASE_LABEL: Record = { - submitted: "Submitted", - pending: "Pending", - runnable: "Runnable", - initializing: "Initializing", -}; - -// Stages own the timeline once `run.running` fires, so we stop slicing there. -export function deriveRunPhases( - events: ReadonlyArray | undefined, - createdAtIso: string, -): RunPhase[] { - const createdMs = Date.parse(createdAtIso); - if (Number.isNaN(createdMs)) return []; - - let startRequestedMs: number | null = null; - let pendingMs: number | null = null; - let runnableMs: number | null = null; - let startingMs: number | null = null; - let runningMs: number | null = null; - let terminalMs: number | null = null; - let remaining = 6; - - for (const event of events ?? []) { - if (remaining === 0) break; - let target: "startRequested" | "pending" | "runnable" | "starting" | "running" | "terminal" | null = null; - switch (event.event) { - case "run.start_requested": - if (startRequestedMs == null) target = "startRequested"; - break; - case "run.pending": - if (pendingMs == null) target = "pending"; - break; - case "run.runnable": - if (runnableMs == null) target = "runnable"; - break; - case "run.starting": - if (startingMs == null) target = "starting"; - break; - case "run.running": - if (runningMs == null) target = "running"; - break; - case "run.completed": - case "run.failed": - if (terminalMs == null) target = "terminal"; - break; - } - if (target == null) continue; - const ms = Date.parse(event.ts); - if (Number.isNaN(ms)) continue; - switch (target) { - case "startRequested": startRequestedMs = ms; break; - case "pending": pendingMs = ms; break; - case "runnable": runnableMs = ms; break; - case "starting": startingMs = ms; break; - case "running": runningMs = ms; break; - case "terminal": terminalMs = ms; break; - } - remaining -= 1; - } - - const phases: RunPhase[] = []; - - phases.push({ - kind: "submitted", - label: PHASE_LABEL.submitted, - startMs: createdMs, - endMs: startRequestedMs ?? pendingMs ?? runnableMs ?? startingMs ?? runningMs ?? terminalMs, - }); - - if (pendingMs != null) { - phases.push({ - kind: "pending", - label: PHASE_LABEL.pending, - startMs: pendingMs, - endMs: runnableMs ?? startingMs ?? runningMs ?? terminalMs, - }); - } - - if (runnableMs != null) { - phases.push({ - kind: "runnable", - label: PHASE_LABEL.runnable, - startMs: runnableMs, - endMs: startingMs ?? runningMs ?? terminalMs, - }); - } - - if (startingMs != null) { - phases.push({ - kind: "initializing", - label: PHASE_LABEL.initializing, - startMs: startingMs, - endMs: runningMs ?? terminalMs, - }); - } - - return phases; -} diff --git a/apps/fabro-web/app/lib/sse.ts b/apps/fabro-web/app/lib/sse.ts index c67c9ef7b..7ee4161af 100644 --- a/apps/fabro-web/app/lib/sse.ts +++ b/apps/fabro-web/app/lib/sse.ts @@ -4,8 +4,8 @@ export type KeyMatcher = (key: Key) => boolean; export type KeyOrMatcher = Key | KeyMatcher; export type MutateFn = (key: KeyOrMatcher) => ReturnType; +/** A parsed SSE frame: a run stream item, read through `isStreamItemPayload`. */ export interface EventPayload { - event?: string; [key: string]: unknown; } diff --git a/apps/fabro-web/app/lib/test-utils.tsx b/apps/fabro-web/app/lib/test-utils.tsx index 88b676954..23641e362 100644 --- a/apps/fabro-web/app/lib/test-utils.tsx +++ b/apps/fabro-web/app/lib/test-utils.tsx @@ -1,9 +1,10 @@ import { createElement, type ReactNode } from "react"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; +import type { RunStreamItem } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import type { Stage } from "./stage-sidebar"; import { makeUsage } from "./test-fixtures"; +import type { UnknownRecord } from "./unknown"; const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const; @@ -43,19 +44,113 @@ export function setupReactTestEnv(): () => void { }; } -/** Build an event envelope fixture; override any field via `partial`. */ -export function makeEventEnvelope( +const STREAM_EPOCH_MS = Date.parse("2026-04-09T12:00:00Z"); + +/** The stage a Petri item's subject names: a node, its visit and its firing. */ +export interface StreamStage { + name: string; + visit?: number; + firing?: number; + /** The node's `meta.kind`; `agent` unless given. */ + kind?: string; +} + +/** + * A platform record item of a run's stream, as `GET /runs/{id}/events` + * serves it: `record.kind` names the fact. Recorded one second per `seq` + * after the stream epoch unless `recorded_at` is overridden. + */ +export function makePlatformItem( seq: number, - partial: Partial, -): EventEnvelope { + record: UnknownRecord & { kind: string }, + overrides: Partial = {}, +): RunStreamItem { + const recordedAt = overrides.recorded_at ?? STREAM_EPOCH_MS + seq * 1000; return { - seq, - id: `evt-${seq}`, - ts: `2026-04-09T12:00:0${seq}Z`, run_id: "run-1", - event: "stage.prompt", - ...partial, - } as EventEnvelope; + stream_seq: seq, + kind: "platform", + id: String(seq), + recorded_at: recordedAt, + item: { seq, recorded_at: recordedAt, record }, + ...overrides, + }; +} + +/** + * A Petri event item of a run's stream: the recorded `body` (named by its + * `event`) under the stage `subject`, with Petri's `derived` view beside it. + */ +export function makePetriItem( + seq: number, + body: UnknownRecord & { event: string }, + { + stage, + derived, + ...overrides + }: { stage?: StreamStage; derived?: UnknownRecord } & Partial = {}, +): RunStreamItem { + const recordedAt = overrides.recorded_at ?? STREAM_EPOCH_MS + seq * 1000; + const subject = stage + ? { + node: { id: 0, name: stage.name, kind: "attractor/stage", meta: { kind: stage.kind ?? "agent" } }, + firing: stage.firing ?? stage.visit ?? 1, + visit: stage.visit ?? 1, + attempt: 1, + } + : undefined; + return { + run_id: "run-1", + stream_seq: seq, + kind: "petri", + id: `execution 0/${seq}/0`, + recorded_at: recordedAt, + item: { + id: { log: "execution", execution: 0, seq, index: 0 }, + origin: "external", + context: { invocation: 0, execution: 0 }, + ...(subject ? { subject } : {}), + recorded_at: recordedAt, + record: { seq, origin: "external", recorded_at: recordedAt, body }, + ...(derived ? { derived } : {}), + }, + ...overrides, + }; +} + +/** + * A stage's `step.progress.recorded` carrying one Pebble `CodingAgentEvent` + * envelope: the `variant` (`AssistantMessage`, `ToolCallStarted`, ...) with + * its `payload`, stamped `ts`. + */ +export function makePebbleItem( + seq: number, + ts: string, + stage: StreamStage, + variant: string, + payload: UnknownRecord, +): RunStreamItem { + return makePetriItem( + seq, + { + event: "step.progress.recorded", + firing: stage.firing ?? stage.visit ?? 1, + ev: { + custom: { + kind: "pebble", + node: stage.name, + event: { + seq, + stream_id: "ses_1", + session_id: "ses_1", + timestamp: ts, + event: { [variant]: payload }, + }, + }, + }, + }, + { stage, recorded_at: Date.parse(ts) }, + ); } /** Flatten a rendered subtree to its visible text. */ diff --git a/apps/fabro-web/app/routes/run-events.tsx b/apps/fabro-web/app/routes/run-events.tsx index 965d69d24..f4dc289ae 100644 --- a/apps/fabro-web/app/routes/run-events.tsx +++ b/apps/fabro-web/app/routes/run-events.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from "react"; import { useParams, useSearchParams } from "react-router"; -import type { EventEnvelope, RunStreamItem } from "@qltysh/fabro-api-client"; import { DebugEventDetailsPanel, @@ -8,10 +7,7 @@ import { EventSearchInput, MultiSelectFilter, } from "../components/event-debug"; -import { - debugCategory, - debugCategoryLabel, -} from "../components/event-debug-helpers"; +import { debugCategoryLabel } from "../components/event-debug-helpers"; import { RunWaterfall } from "../components/run-waterfall"; import type { RunPhase } from "../lib/run-phases"; import { StageSidebar } from "../components/stage-sidebar"; @@ -20,50 +16,34 @@ import { debugRowSearchText, debugRowsFromStream, deriveRunPhasesFromStream, - isPetriRun, type DebugRow, } from "../lib/petri-stream"; -import { - useRun, - useRunEventsList, - useRunStages, - useRunState, - useRunStream, -} from "../lib/queries"; +import { useRun, useRunStages, useRunStream } from "../lib/queries"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; export const handle = { wide: true, fullHeight: true }; type ViewMode = "waterfall" | "events"; -const EMPTY_EVENTS: EventEnvelope[] = []; -const EMPTY_STREAM: RunStreamItem[] = []; const EMPTY_ROWS: DebugRow[] = []; export default function RunEvents() { const { id } = useParams(); const runQuery = useRun(id); - const runStateQuery = useRunState(id); const stagesQuery = useRunStages(id); - // A Petri run's events are its stream; a legacy run's the stored events. - // Which one is known from the run's spec, so the other query stays idle. - // A state that cannot be read leaves the engine unknown; the legacy list - // then loads as it did before the engine existed. - const engineKnown = - runStateQuery.data !== undefined || runStateQuery.error !== undefined; - const petri = isPetriRun(runStateQuery.data); - const eventsQuery = useRunEventsList(engineKnown && !petri ? id : undefined); - const streamQuery = useRunStream(engineKnown && petri ? id : undefined); + // The run's events are its stream: Petri's events and the platform + // records, which the waterfall's phases and the events list derive from. + const streamQuery = useRunStream(id); const streamPhases = useMemo( () => - petri && streamQuery.data && runQuery.data + streamQuery.data && runQuery.data ? deriveRunPhasesFromStream(streamQuery.data, runQuery.data.timestamps.created_at) : undefined, - [petri, streamQuery.data, runQuery.data], + [streamQuery.data, runQuery.data], ); const streamRows = useMemo( - () => (petri && streamQuery.data ? debugRowsFromStream(streamQuery.data) : undefined), - [petri, streamQuery.data], + () => (streamQuery.data ? debugRowsFromStream(streamQuery.data) : undefined), + [streamQuery.data], ); const [searchParams, setSearchParams] = useSearchParams(); const view: ViewMode = searchParams.get("view") === "events" ? "events" : "waterfall"; @@ -99,21 +79,20 @@ export default function RunEvents() { {view === "waterfall" ? ( { - void (petri ? streamQuery.mutate() : eventsQuery.mutate()); + void streamQuery.mutate(); void stagesQuery.mutate(); }} view={view} onChangeView={setView} /> - ) : petri ? ( + ) : ( - ) : ( - void eventsQuery.mutate()} - runStart={ - runQuery.data?.timestamps.started_at ?? - runQuery.data?.timestamps.created_at - } - view={view} - onChangeView={setView} - /> )}
); @@ -179,7 +146,6 @@ function ViewToggle({ function WaterfallPane({ runId, - events, phases, eventsError, stagesData, @@ -191,8 +157,8 @@ function WaterfallPane({ onChangeView, }: { runId: string; - events: EventEnvelope[] | undefined; - phases?: RunPhase[]; + /** The run's phases once its stream has loaded. */ + phases: RunPhase[] | undefined; eventsError: unknown; stagesData: ReturnType["data"]; stagesError: unknown; @@ -204,7 +170,7 @@ function WaterfallPane({ }) { const error = eventsError ?? stagesError; const ready = - events !== undefined && stagesData !== undefined && createdAt !== undefined; + phases !== undefined && stagesData !== undefined && createdAt !== undefined; return (
@@ -228,8 +194,7 @@ function WaterfallPane({ ) : ( void; - runStart: string | undefined; - view: ViewMode; - onChangeView: (v: ViewMode) => void; -}) { - const [openSeq, setOpenSeq] = useState(null); - const [selectedCategories, setSelectedCategories] = useState([]); - const [search, setSearch] = useState(""); - - const all = events ?? EMPTY_EVENTS; - - const availableCategories = useMemo(() => { - const set = new Set(); - for (const event of all) { - if (event.event) set.add(debugCategory(event.event)); - } - return Array.from(set).sort(); - }, [all]); - - const filtered = useMemo(() => { - const useCategoryFilter = selectedCategories.length > 0; - const cats = new Set(selectedCategories); - const needle = search.toLowerCase(); - return all.filter((event) => { - const name = event.event ?? ""; - if (useCategoryFilter && !cats.has(debugCategory(name))) return false; - if (needle) { - const blob = `${name} ${JSON.stringify(event.properties ?? {})}`.toLowerCase(); - if (!blob.includes(needle)) return false; - } - return true; - }); - }, [all, selectedCategories, search]); - - const openEvent = useMemo( - () => (openSeq != null ? all.find((e) => e.seq === openSeq) ?? null : null), - [all, openSeq], - ); - - const allCategoriesSelected = - selectedCategories.length === 0 || - selectedCategories.length === availableCategories.length; - const isFiltering = !allCategoriesSelected || search.length > 0; - - function clearFilters() { - setSelectedCategories([]); - setSearch(""); - } - - if (error) { - return ( -
- -
- ); - } - if (events === undefined) { - return ( -
- -
- ); - } - - return ( - <> -
-
-
-
-
- - - selected={selectedCategories} - options={availableCategories} - labelOf={debugCategoryLabel} - onChange={setSelectedCategories} - emptyMeansAll - /> - - {isFiltering && ( - - )} -
- {all.length > 0 && ( - - {isFiltering - ? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} events` - : `${all.length.toLocaleString()} events`} - - )} -
-
-
-
- {all.length === 0 ? ( -
- -
- ) : filtered.length === 0 ? ( -
- No events match these filters. -
- ) : ( - filtered.map((event) => ( - setOpenSeq(event.seq)} - /> - )) - )} -
-
- - setOpenSeq(null)} /> - - ); -} - /** - * The events list of a Petri run: one row per stream item, named by the + * The events list of a run: one row per stream item, named by the * Petri event (`.`) or the platform record kind, with the * raw item in the details panel. */ diff --git a/apps/fabro-web/app/routes/run-overview.test.tsx b/apps/fabro-web/app/routes/run-overview.test.tsx index cda806718..fdcc427f2 100644 --- a/apps/fabro-web/app/routes/run-overview.test.tsx +++ b/apps/fabro-web/app/routes/run-overview.test.tsx @@ -21,7 +21,6 @@ mock.module("../lib/queries", () => ({ mutate: graphMutateMock, }), useRunGraphSource: () => ({ data: undefined }), - useRunStageEvents: () => ({ data: [] }), useRunState: () => ({ data: undefined }), useRunStream: () => ({ data: undefined }), })); diff --git a/apps/fabro-web/app/routes/run-petri.render.test.tsx b/apps/fabro-web/app/routes/run-petri.render.test.tsx index 5a98439d1..0da4e46a2 100644 --- a/apps/fabro-web/app/routes/run-petri.render.test.tsx +++ b/apps/fabro-web/app/routes/run-petri.render.test.tsx @@ -108,7 +108,6 @@ describe("a command-only run", () => { const html = render( ): EventEnvelope { - return { - seq, - id: `evt-${seq}`, - ts: "2026-04-09T12:00:00Z", - run_id: "run-1", - event: "stage.prompt", - ...partial, - } as EventEnvelope; +const CODE_STAGE = { name: "code", visit: 1 }; +const TS = "2026-04-09T12:00:00Z"; + +/** One Pebble envelope the `code@1` stage recorded. */ +function pebble(seq: number, variant: string, payload: UnknownRecord, ts = TS): RunStreamItem { + return makePebbleItem(seq, ts, CODE_STAGE, variant, payload); +} + +function agentTurns(items: RunStreamItem[]) { + return buildPetriStageActivity(items, undefined, "agent").turns; } function toolTurn(opts: { @@ -71,461 +74,8 @@ function expectSingleItem( return item; } -describe("eventsToActivity", () => { - test("filters events by stage_id (verify@1 vs verify@2 do not cross-contaminate)", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "stage.prompt", - stage_id: "verify@1", - node_id: "verify", - properties: { text: "first visit prompt" }, - }), - envelope(2, { - event: "stage.prompt", - stage_id: "verify@2", - node_id: "verify", - properties: { text: "second visit prompt" }, - }), - envelope(3, { - event: "agent.message", - stage_id: "verify@1", - node_id: "verify", - properties: { - event: { AssistantMessage: { text: "first visit reply" } }, - }, - }), - envelope(4, { - event: "agent.message", - stage_id: "verify@2", - node_id: "verify", - properties: { - event: { AssistantMessage: { text: "second visit reply" } }, - }, - }), - ]; - - const firstVisit = eventsToActivity(events, "verify@1"); - expect(firstVisit).toEqual([ - { - kind: "system", - ts: "2026-04-09T12:00:00Z", - content: "first visit prompt", - }, - { - kind: "assistant", - ts: "2026-04-09T12:00:00Z", - content: "first visit reply", - inputTokens: 0, - outputTokens: 0, - toolCallCount: null, - reasoning: null, - }, - ]); - - const secondVisit = eventsToActivity(events, "verify@2"); - expect(secondVisit).toEqual([ - { - kind: "system", - ts: "2026-04-09T12:00:00Z", - content: "second visit prompt", - }, - { - kind: "assistant", - ts: "2026-04-09T12:00:00Z", - content: "second visit reply", - inputTokens: 0, - outputTokens: 0, - toolCallCount: null, - reasoning: null, - }, - ]); - }); - - test("pairs command.started + command.completed into a single command turn", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "command.started", - node_id: "fmt", - properties: { script: "cargo fmt", language: "shell" }, - }), - envelope(2, { - event: "command.completed", - node_id: "fmt", - properties: { - output: "blob://sha256/abc", - output_bytes: 42, - exit_code: 0, - duration_ms: 12, - termination: "exited", - }, - }), - ]; - - const turns = eventsToActivity(events, "fmt"); - expect(turns).toHaveLength(1); - expect(turns[0]).toMatchObject({ - kind: "command", - script: "cargo fmt", - running: false, - outputBytes: 42, - }); - }); - - test("command turn carries the requested stage_id, no @1 fallback", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "command.started", - stage_id: "verify@2", - node_id: "verify", - properties: { script: "echo hi", language: "shell" }, - }), - envelope(2, { - event: "command.completed", - stage_id: "verify@2", - node_id: "verify", - properties: { - output: "hi", - exit_code: 0, - duration_ms: 5, - termination: "exited", - }, - }), - ]; - - const turns = eventsToActivity(events, "verify@2"); - expect(turns).toHaveLength(1); - const turn = turns[0]; - expect(turn.kind).toBe("command"); - if (turn.kind === "command") { - expect(turn.script).toBe("echo hi"); - expect(turn.running).toBe(false); - } - }); - - test("pairs agent.tool.started + agent.tool.completed into a single tool turn", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.tool.started", - node_id: "detect-drift", - properties: { - event: { ToolCallStarted: { tool_call_id: "call-1", - tool_name: "read_file", - arguments: { path: "config.toml" } } }, - }, - }), - envelope(2, { - event: "agent.tool.completed", - node_id: "detect-drift", - properties: { - event: { ToolCallCompleted: { tool_call_id: "call-1", - tool_name: "read_file", - output: "[redis]", - is_error: false } }, - }, - }), - ]; - - const turns = eventsToActivity(events, "detect-drift"); - expect(turns).toHaveLength(1); - expect(turns[0].kind).toBe("tool"); - if (turns[0].kind === "tool") { - expect(turns[0]).toMatchObject({ - toolName: "read_file", - isError: false, - }); - } - }); - - test("renders injected steering as a transcript turn for the matching stage", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "run.steer", - properties: { text: "say hello" }, - }), - envelope(2, { - event: "agent.steering.injected", - stage_id: "nap@1", - node_id: "nap", - properties: { - event: { SteeringInjected: { text: "say hello" } }, - }, - }), - envelope(3, { - event: "agent.steering.injected", - stage_id: "other@1", - node_id: "other", - properties: { - event: { SteeringInjected: { text: "wrong stage" } }, - }, - }), - ]; - - expect(eventsToActivity(events, "nap@1")).toEqual([ - { - kind: "steer", - ts: "2026-04-09T12:00:00Z", - content: "say hello", - }, - ]); - }); - - test("renders injected interrupt as a transcript turn for the matching stage", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "run.interrupt", - properties: {}, - }), - envelope(2, { - event: "agent.interrupt.injected", - stage_id: "nap@1", - node_id: "nap", - properties: { visit: 1 }, - }), - envelope(3, { - event: "agent.interrupt.injected", - stage_id: "other@1", - node_id: "other", - properties: { visit: 1 }, - }), - ]; - - expect(eventsToActivity(events, "nap@1")).toEqual([ - { - kind: "interrupt", - ts: "2026-04-09T12:00:00Z", - content: "Agent interrupted", - }, - ]); - }); - - test("renders settled interrupt as waiting for steering", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.round.interrupted", - stage_id: "nap@1", - node_id: "nap", - properties: { generation: 1, visit: 1 }, - }), - envelope(2, { - event: "agent.round.interrupted", - stage_id: "other@1", - node_id: "other", - properties: { generation: 1, visit: 1 }, - }), - ]; - - expect(eventsToActivity(events, "nap@1")).toEqual([ - { - kind: "interrupt", - ts: "2026-04-09T12:00:00Z", - content: "Interrupted — waiting for steering", - }, - ]); - }); - - test("renders pair messages as transcript turns for the matching stage", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.pair.system_message", - ts: "2026-04-09T12:00:00Z", - stage_id: "nap@1", - node_id: "nap", - properties: { - text: "A human has joined this workflow run for live pairing.", - kind: "human_joined", - visit: 1, - }, - }), - envelope(2, { - event: "agent.pair.user_message", - ts: "2026-04-09T12:00:05Z", - stage_id: "nap@1", - node_id: "nap", - properties: { text: "try a smaller diff", visit: 1 }, - }), - envelope(3, { - event: "agent.pair.user_message", - ts: "2026-04-09T12:00:06Z", - stage_id: "other@1", - node_id: "other", - properties: { text: "wrong stage", visit: 1 }, - }), - ]; - - expect(eventsToActivity(events, "nap@1")).toEqual([ - { - kind: "pair_system", - ts: "2026-04-09T12:00:00Z", - content: "A human has joined this workflow run for live pairing.", - }, - { - kind: "pair_user", - ts: "2026-04-09T12:00:05Z", - content: "try a smaller diff", - }, - ]); - }); - - test("renders prompt.completed as an assistant turn for prompt-shape stages", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "stage.prompt", - stage_id: "summarize@1", - node_id: "summarize", - properties: { text: "summarize the diff" }, - }), - envelope(2, { - event: "prompt.completed", - stage_id: "summarize@1", - node_id: "summarize", - properties: { - response: "Refactored auth module", - model: "claude-sonnet-4-6", - provider: "anthropic", - usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 120, output: 30 } } }, - }, - }), - ]; - - expect(eventsToActivity(events, "summarize@1")).toEqual([ - { - kind: "system", - ts: "2026-04-09T12:00:00Z", - content: "summarize the diff", - }, - { - kind: "assistant", - ts: "2026-04-09T12:00:00Z", - content: "Refactored auth module", - inputTokens: 120, - outputTokens: 30, - toolCallCount: null, - reasoning: null, - }, - ]); - }); - - test("does not duplicate the assistant turn when prompt.completed follows agent.message", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "stage.prompt", - stage_id: "simplify@1", - node_id: "simplify", - properties: { text: "simplify" }, - }), - envelope(2, { - event: "agent.message", - stage_id: "simplify@1", - node_id: "simplify", - properties: { - event: { AssistantMessage: { text: "Done.", - usage: { input: 10, output: 5 } } }, - }, - }), - envelope(3, { - event: "prompt.completed", - stage_id: "simplify@1", - node_id: "simplify", - properties: { - response: "Done.", - model: "claude-sonnet-4-6", - provider: "anthropic", - usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 10, output: 5 } } }, - }, - }), - ]; - - const turns = eventsToActivity(events, "simplify@1"); - expect(turns).toEqual([ - { - kind: "system", - ts: "2026-04-09T12:00:00Z", - content: "simplify", - }, - { - kind: "assistant", - ts: "2026-04-09T12:00:00Z", - content: "Done.", - inputTokens: 10, - outputTokens: 5, - toolCallCount: null, - reasoning: null, - }, - ]); - }); - - test("renders prompt.completed even with no preceding stage.prompt", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "prompt.completed", - stage_id: "summarize@1", - node_id: "summarize", - properties: { - response: "All clear.", - model: "claude-sonnet-4-6", - provider: "anthropic", - usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 0, output: 4 } } }, - }, - }), - ]; - - expect(eventsToActivity(events, "summarize@1")).toEqual([ - { - kind: "assistant", - ts: "2026-04-09T12:00:00Z", - content: "All clear.", - inputTokens: 0, - outputTokens: 4, - toolCallCount: null, - reasoning: null, - }, - ]); - }); - - test("reads disclosed reasoning off agent.message", () => { - function reasoningOf(properties: Record) { - const turns = eventsToActivity( - [ - envelope(1, { - event: "agent.message", - stage_id: "plan@1", - node_id: "plan", - properties: { event: { AssistantMessage: properties } }, - }), - ], - "plan@1", - ); - expect(turns[0].kind).toBe("assistant"); - return turns[0].kind === "assistant" ? turns[0].reasoning : undefined; - } - - expect( - reasoningOf({ - text: "Done.", - reasoning: { summary: "Checked the config", trace: "step one…" }, - }), - ).toEqual({ summary: "Checked the config", trace: "step one…" }); - - // Anthropic thinking arrives as a trace with no summary. - expect( - reasoningOf({ text: "Done.", reasoning: { trace: "step one…" } }), - ).toEqual({ trace: "step one…" }); - expect( - reasoningOf({ - text: "Done.", - reasoning: { summary: "Checked the config" }, - }), - ).toEqual({ summary: "Checked the config" }); - - expect(reasoningOf({ text: "Done." })).toBe(null); - // A provider that sends the key but nothing usable reads as "none". - expect(reasoningOf({ text: "Done.", reasoning: {} })).toBe(null); - expect( - reasoningOf({ text: "Done.", reasoning: { summary: "", trace: "" } }), - ).toBe(null); - }); - - test("formatStageModelUsageLabel includes reasoning effort when present", () => { +describe("formatStageModelUsageLabel", () => { + test("includes reasoning effort when present", () => { expect( formatStageModelUsageLabel({ mode: "agent", @@ -537,7 +87,7 @@ describe("eventsToActivity", () => { ).toBe("gpt-5.5[high]"); }); - test("formatStageModelUsageLabel returns null when the projection has no model", () => { + test("returns null when the projection has no model", () => { expect( formatStageModelUsageLabel({ mode: "acp", @@ -546,41 +96,6 @@ describe("eventsToActivity", () => { }), ).toBe(null); }); - - test("ignores unknown event types and events for other stages", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "stage.started", - node_id: "detect-drift", - properties: {}, - }), - envelope(2, { - event: "agent.message", - node_id: "detect-drift", - properties: { - event: { AssistantMessage: { text: "signal" } }, - }, - }), - envelope(3, { - event: "run.running", - node_id: "detect-drift", - properties: {}, - }), - envelope(4, { - event: "agent.message", - node_id: "other-stage", - properties: { - event: { AssistantMessage: { text: "wrong stage" } }, - }, - }), - ]; - - const turns = eventsToActivity(events, "detect-drift"); - expect(turns).toHaveLength(1); - if (turns[0].kind === "assistant") { - expect(turns[0].content).toBe("signal"); - } - }); }); describe("groupConsecutiveTools", () => { @@ -946,39 +461,22 @@ describe("buildChatItems", () => { }); }); -describe("buildStageActivity pending tools", () => { +describe("buildPetriStageActivity pending tools", () => { test("returns started-but-not-completed calls for the stage", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.tool.started", - stage_id: "plan@1", - node_id: "plan", - properties: { - event: { ToolCallStarted: { tool_call_id: "call-1", - tool_name: "shell", - arguments: { command: "cargo build" } } }, - }, + const items = [ + pebble(1, "ToolCallStarted", { + tool_call_id: "call-1", + tool_name: "shell", + arguments: { command: "cargo build" }, }), - envelope(2, { - event: "agent.tool.started", - stage_id: "plan@1", - node_id: "plan", - properties: { - event: { ToolCallStarted: { tool_call_id: "call-2", - tool_name: "read_file", - arguments: { file_path: "/tmp/x" } } }, - }, - }), - envelope(3, { - event: "agent.tool.completed", - stage_id: "plan@1", - node_id: "plan", - properties: { - event: { ToolCallCompleted: { tool_call_id: "call-1", output: "ok" } }, - }, + pebble(2, "ToolCallStarted", { + tool_call_id: "call-2", + tool_name: "read_file", + arguments: { file_path: "/tmp/x" }, }), + pebble(3, "ToolCallCompleted", { tool_call_id: "call-1", output: "ok" }), ]; - expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([ + expect(buildPetriStageActivity(items, undefined, "agent").pendingTools).toEqual([ { toolCallId: "call-2", toolName: "read_file", @@ -987,45 +485,21 @@ describe("buildStageActivity pending tools", () => { ]); }); - test("ignores events from other stage visits", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.tool.started", - stage_id: "plan@2", - node_id: "plan", - properties: { - event: { ToolCallStarted: { tool_call_id: "call-1", - tool_name: "shell", - arguments: {} } }, - }, - }), - ]; - expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([]); - }); - test("keeps stable identities for simultaneous calls with the same tool name", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.tool.started", - stage_id: "plan@1", - properties: { - event: { ToolCallStarted: { tool_call_id: "call-1", - tool_name: "shell", - arguments: { command: "cargo build" } } }, - }, + const items = [ + pebble(1, "ToolCallStarted", { + tool_call_id: "call-1", + tool_name: "shell", + arguments: { command: "cargo build" }, }), - envelope(2, { - event: "agent.tool.started", - stage_id: "plan@1", - properties: { - event: { ToolCallStarted: { tool_call_id: "call-2", - tool_name: "shell", - arguments: { command: "cargo test" } } }, - }, + pebble(2, "ToolCallStarted", { + tool_call_id: "call-2", + tool_name: "shell", + arguments: { command: "cargo test" }, }), ]; - expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([ + expect(buildPetriStageActivity(items, undefined, "agent").pendingTools).toEqual([ { toolCallId: "call-1", toolName: "shell", @@ -1039,34 +513,18 @@ describe("buildStageActivity pending tools", () => { ]); }); - test("ignores malformed tool events without a call id", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.tool.started", - stage_id: "plan@1", - properties: { - event: { ToolCallStarted: { tool_name: "shell", arguments: { command: "ignored" } } }, - }, - }), - envelope(2, { - event: "agent.tool.started", - stage_id: "plan@1", - properties: { - event: { ToolCallStarted: { tool_call_id: "call-1", - tool_name: "shell", - arguments: { command: "kept" } } }, - }, - }), - envelope(3, { - event: "agent.tool.completed", - stage_id: "plan@1", - properties: { - event: { ToolCallCompleted: { output: "must not clear call-1" } }, - }, + test("ignores malformed tool envelopes without a call id", () => { + const items = [ + pebble(1, "ToolCallStarted", { tool_name: "shell", arguments: { command: "ignored" } }), + pebble(2, "ToolCallStarted", { + tool_call_id: "call-1", + tool_name: "shell", + arguments: { command: "kept" }, }), + pebble(3, "ToolCallCompleted", { output: "must not clear call-1" }), ]; - const activity = buildStageActivity(events, "plan@1"); + const activity = buildPetriStageActivity(items, undefined, "agent"); expect(activity.turns).toEqual([]); expect(activity.pendingTools).toEqual([ { @@ -1263,22 +721,17 @@ describe("buildThreadDnaItems", () => { }); describe("tool-call-only agent responses", () => { - test("retains an empty agent.message with its timestamp, usage, and tool-call count", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.message", - ts: "2026-04-09T12:00:42Z", - stage_id: "code@1", - node_id: "code", - properties: { - event: { AssistantMessage: { text: "", - usage: { input: 4200, output: 96 }, - tool_call_count: 2 } }, - }, - }), + test("retains an empty assistant message with its timestamp, usage, and tool-call count", () => { + const items = [ + pebble( + 1, + "AssistantMessage", + { text: "", usage: { input: 4200, output: 96 }, tool_call_count: 2 }, + "2026-04-09T12:00:42Z", + ), ]; - expect(eventsToActivity(events, "code@1")).toEqual([ + expect(agentTurns(items)).toEqual([ { kind: "assistant", ts: "2026-04-09T12:00:42Z", @@ -1291,28 +744,16 @@ describe("tool-call-only agent responses", () => { ]); }); - test("does not synthesize a prompt.completed turn after an empty agent.message", () => { - const events: EventEnvelope[] = [ - envelope(1, { - event: "agent.message", - stage_id: "code@1", - node_id: "code", - properties: { - event: { AssistantMessage: { text: "", tool_call_count: 1 } }, - }, - }), - envelope(2, { - event: "prompt.completed", - stage_id: "code@1", - node_id: "code", - properties: { - response: "", - usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1, output: 2 } } }, - }, - }), - ]; + test("does not add the projection's response after an empty assistant message", () => { + const items = [pebble(1, "AssistantMessage", { text: "", tool_call_count: 1 })]; + const stage: StageProjection = { + first_event_seq: 1, + state: "succeeded", + usage: makeUsage({ input: 1, output: 2 }), + response: "", + }; - const turns = eventsToActivity(events, "code@1"); + const turns = buildPetriStageActivity(items, stage, "agent").turns; expect(turns).toHaveLength(1); expect(turns[0]).toMatchObject({ kind: "assistant", toolCallCount: 1 }); }); @@ -1346,7 +787,6 @@ describe("tool-call-only agent responses", () => { }); describe("tool batch boundaries", () => { - const STAGE = "code@1"; const RUN_START = "2026-04-09T12:00:00Z"; function modelResponse( @@ -1354,18 +794,13 @@ describe("tool batch boundaries", () => { ts: string, toolCallCount: number, text = "", - ): EventEnvelope { - return envelope(seq, { - event: "agent.message", + ): RunStreamItem { + return pebble( + seq, + "AssistantMessage", + { text, usage: { input: 1000, output: 20 }, tool_call_count: toolCallCount }, ts, - stage_id: STAGE, - node_id: "code", - properties: { - event: { AssistantMessage: { text, - usage: { input: 1000, output: 20 }, - tool_call_count: toolCallCount } }, - }, - }); + ); } function shellCall( @@ -1374,42 +809,28 @@ describe("tool batch boundaries", () => { startTs: string, endTs: string, command: string, - ): EventEnvelope[] { + ): RunStreamItem[] { return [ - envelope(seq, { - event: "agent.tool.started", - ts: startTs, - stage_id: STAGE, - node_id: "code", - properties: { - event: { ToolCallStarted: { tool_call_id: callId, - tool_name: "shell", - arguments: { command } } }, - }, - }), - envelope(seq + 1, { - event: "agent.tool.completed", - ts: endTs, - stage_id: STAGE, - node_id: "code", - properties: { - event: { ToolCallCompleted: { tool_call_id: callId, tool_name: "shell", output: "ok" } }, - }, - }), + pebble( + seq, + "ToolCallStarted", + { tool_call_id: callId, tool_name: "shell", arguments: { command } }, + startTs, + ), + pebble( + seq + 1, + "ToolCallCompleted", + { tool_call_id: callId, tool_name: "shell", output: "ok" }, + endTs, + ), ]; } // Anonymized reproduction: eight sub-100ms shell calls issued across five // model responses, each response separated by a minute or more of model // time and carrying no text of its own. - const REPRO_EVENTS: EventEnvelope[] = [ - envelope(1, { - event: "stage.prompt", - ts: RUN_START, - stage_id: STAGE, - node_id: "code", - properties: { text: "investigate the failure" }, - }), + const REPRO_EVENTS: RunStreamItem[] = [ + pebble(1, "UserInput", { text: "investigate the failure" }, RUN_START), modelResponse(2, "2026-04-09T12:00:30Z", 2), ...shellCall( 3, @@ -1475,7 +896,7 @@ describe("tool batch boundaries", () => { ]; function reproItems(): DisplayItem[] { - const turns = eventsToActivity(REPRO_EVENTS, STAGE); + const turns = agentTurns(REPRO_EVENTS); return groupConsecutiveTools(turns.map((turn, index) => ({ turn, index }))); } diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 06c802053..68ee296b0 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -53,12 +53,12 @@ import { } from "../components/ui"; import { ConditionalDecision } from "../components/stage-renderers/conditional-decision"; import { FanInResults } from "../components/stage-renderers/fan-in-results"; -import { - extractStageContext, - type EdgeSelection, - type HumanInterviewPair, - type ParallelOverview, - type ReducerTranscript, +import type { + EdgeSelection, + HumanInterviewPair, + ParallelOverview, + ReducerTranscript, + StageContextData, } from "../components/stage-renderers/helpers"; import { HumanQA } from "../components/stage-renderers/human-qa"; import { ParallelChildren } from "../components/stage-renderers/parallel-children"; @@ -87,7 +87,6 @@ import { debugRowsFromStream, extractPetriStageContext, findPetriEdgeForStage, - isPetriRun, itemsForStage, parallelOverviewFromProjection, parsePetriInterviewPairs, @@ -96,18 +95,12 @@ import { } from "../lib/petri-stream"; import { useRun, - useRunEventsList, useRunStageContextWindow, - useRunStageEvents, useRunStageLog, useRunStages, useRunState, useRunStream, } from "../lib/queries"; -import { - STAGE_ACTIVITY_EVENT_TYPES, - type StageActivityEventType, -} from "../lib/run-events"; import { ACTIVE_STAGE_STATES, mapRunStagesToSidebarStages, @@ -120,7 +113,6 @@ import { type UnknownRecord, } from "../lib/unknown"; import type { - EventEnvelope, ReasoningOutput, RunProjection, RunStreamItem, @@ -180,8 +172,6 @@ export type StageRenderer = type PanelSelection = ThreadDnaSelection; -const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); - export const EVENT_KINDS = [ "system", "steer", @@ -267,10 +257,6 @@ export function eventsTabLabel( return PRIMARY_TAB_LABEL[renderer]; } -function assertNever(value: never): never { - throw new Error(`Unhandled stage activity event type: ${value}`); -} - export function selectStageRenderer(handler: StageHandler): StageRenderer { switch (handler) { case "agent": @@ -293,12 +279,6 @@ export function selectStageRenderer(handler: StageHandler): StageRenderer { } } -function activityEventStageId(event: EventEnvelope): string | undefined { - if (typeof event.stage_id === "string") return event.stage_id; - if (typeof event.node_id === "string") return event.node_id; - return getString(event.properties ?? {}, "node_id"); -} - interface PendingTool { ts: string; toolName: string; @@ -321,20 +301,6 @@ interface PendingCommand { script: string; } -/** - * The coding agent's own payload inside an `agent.*` event: `properties.event` - * is externally tagged, `{ AssistantMessage: {...} }`, so the variant's fields - * live one level down. An event with no such payload reads as empty. - */ -function agentEventPayload(props: UnknownRecord): UnknownRecord { - const event = getObject(props, "event"); - if (!event) return {}; - for (const value of Object.values(event)) { - if (isRecord(value)) return value; - } - return {}; -} - function readTurnReasoning(props: UnknownRecord): ReasoningOutput | null { const reasoning = getObject(props, "reasoning"); if (!reasoning) return null; @@ -346,194 +312,6 @@ function readTurnReasoning(props: UnknownRecord): ReasoningOutput | null { return trace ? { trace } : null; } -export function buildStageActivity( - events: EventEnvelope[], - stageId: string, -): StageActivity { - const turns: TurnType[] = []; - const pendingTools = new Map(); - let pendingCommand: PendingCommand | undefined; - let sawAssistantMessage = false; - - for (const e of events) { - const eventName = e.event; - if (activityEventStageId(e) !== stageId) { - continue; - } - if ( - !eventName || - !STAGE_ACTIVITY_EVENT_SET.has(eventName) - ) { - continue; - } - const eventType = eventName as StageActivityEventType; - const props: UnknownRecord = e.properties ?? {}; - switch (eventType) { - case "stage.prompt": - turns.push({ - kind: "system", - ts: e.ts, - content: getString(props, "text") ?? e.text ?? "", - }); - break; - case "agent.message": { - sawAssistantMessage = true; - // A text-free message still marks the end of a model response — it is - // the boundary between two batches of tool calls. Dropping it would - // splice unrelated batches into one tool group. - const message = agentEventPayload(props); - const usage = getObject(message, "usage") ?? {}; - turns.push({ - kind: "assistant", - ts: e.ts, - content: getString(message, "text") ?? "", - inputTokens: getNumber(usage, "input") ?? 0, - outputTokens: - (getNumber(usage, "output") ?? 0) + (getNumber(usage, "reasoning") ?? 0), - toolCallCount: getNumber(message, "tool_call_count") ?? null, - reasoning: readTurnReasoning(message), - }); - break; - } - case "prompt.completed": { - if (!sawAssistantMessage) { - // `prompt.completed.usage` is a `ModelUsage`: the model, then the usage. - const tokens = - getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {}; - turns.push({ - kind: "assistant", - ts: e.ts, - content: getString(props, "response") ?? "", - inputTokens: getNumber(tokens, "input") ?? 0, - outputTokens: getNumber(tokens, "output") ?? 0, - toolCallCount: null, - // Only agent.message carries reasoning; prompt stages have none. - reasoning: null, - }); - } - break; - } - case "agent.steering.injected": { - const text = getString(agentEventPayload(props), "text") ?? ""; - if (text) { - turns.push({ kind: "steer", ts: e.ts, content: text }); - } - break; - } - case "agent.interrupt.injected": - turns.push({ - kind: "interrupt", - ts: e.ts, - content: "Agent interrupted", - }); - break; - case "agent.round.interrupted": - turns.push({ - kind: "interrupt", - ts: e.ts, - content: "Interrupted — waiting for steering", - }); - break; - case "agent.pair.user_message": { - const text = getString(props, "text") ?? e.text ?? ""; - if (text) { - turns.push({ kind: "pair_user", ts: e.ts, content: text }); - } - break; - } - case "agent.pair.system_message": { - const text = getString(props, "text") ?? e.text ?? ""; - if (text) { - turns.push({ kind: "pair_system", ts: e.ts, content: text }); - } - break; - } - case "agent.tool.started": { - const call = agentEventPayload(props); - const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? ""; - if (!callId) break; - const args = call.arguments; - pendingTools.set(callId, { - ts: e.ts, - toolName: getString(call, "tool_name") ?? "", - input: typeof args === "string" ? args : JSON.stringify(args ?? ""), - }); - break; - } - case "agent.tool.completed": { - const call = agentEventPayload(props); - const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? ""; - if (!callId) break; - const started = pendingTools.get(callId); - pendingTools.delete(callId); - const output = call.output ?? ""; - const result = - typeof output === "string" ? output : JSON.stringify(output, null, 2); - turns.push({ - kind: "tool", - ts: started?.ts ?? e.ts, - toolName: started?.toolName ?? getString(call, "tool_name") ?? "", - input: started?.input ?? "", - result, - isError: call.is_error === true, - durationMs: durationBetween(started?.ts, e.ts), - }); - break; - } - case "command.started": { - pendingCommand = { - ts: e.ts, - script: getString(props, "script") ?? "", - }; - break; - } - case "command.completed": { - turns.push({ - kind: "command", - ts: pendingCommand?.ts ?? e.ts, - script: pendingCommand?.script ?? "", - running: false, - exitCode: getNumber(props, "exit_code") ?? null, - durationMs: getNumber(props, "duration_ms") ?? 0, - outputBytes: getNumber(props, "output_bytes") ?? 0, - }); - pendingCommand = undefined; - break; - } - default: - assertNever(eventType); - } - } - - if (pendingCommand) { - turns.push({ - kind: "command", - ts: pendingCommand.ts, - script: pendingCommand.script, - running: true, - exitCode: null, - durationMs: 0, - outputBytes: 0, - }); - } - - return { - turns, - pendingTools: Array.from(pendingTools, ([toolCallId, tool]) => ({ - toolCallId, - toolName: tool.toolName, - input: tool.input, - })), - }; -} - -export function eventsToActivity( - events: EventEnvelope[], - stageId: string, -): TurnType[] { - return buildStageActivity(events, stageId).turns; -} - /** The stream and projection of a Petri run, threaded into the stage views. */ export interface PetriRunData { stream: RunStreamItem[]; @@ -672,21 +450,10 @@ export function buildPetriStageActivity( }; } -/** A debug list item: a legacy event, or a Petri stream row. */ -type DebugListItem = EventEnvelope | DebugRow; +const EMPTY_STREAM: RunStreamItem[] = []; -function isDebugRow(item: DebugListItem): item is DebugRow { - return "item" in item && "category" in item; -} - -function debugItemSearchText(item: DebugListItem): string { - if (isDebugRow(item)) return debugRowSearchText(item); - return `${item.event ?? ""} ${JSON.stringify(item.properties ?? {})}`.toLowerCase(); -} - -/** What the details panel shows for a debug item. */ -function debugItemPayload(item: DebugListItem): EventDisplayPayload { - if (!isDebugRow(item)) return item; +/** What the details panel shows for a debug row. */ +function debugItemPayload(item: DebugRow): EventDisplayPayload { return { event: item.event, stream_seq: item.seq, @@ -2085,7 +1852,7 @@ function EventExportActions({ stageId, className, }: { - events: EventEnvelope[]; + events: RunStreamItem[]; runId: string; stageId: string; className?: string; @@ -2178,7 +1945,7 @@ function EventsToolbar({ totalCount: number; providerUsed: StageModelUsage | null; usage: Usage; - events: EventEnvelope[]; + events: RunStreamItem[]; runId: string; stageId: string; }) { @@ -2292,7 +2059,6 @@ function StageActivityBody({ openDebugSeq, onDebugSeqChange, contextData, - runEvents, stages, petri, }: { @@ -2306,18 +2072,16 @@ function StageActivityBody({ runId: string; selectedStage: Stage; commandTurn: CommandTurn | null; - debugEvents: DebugListItem[]; - filteredDebugEvents: DebugListItem[]; + debugEvents: DebugRow[]; + filteredDebugEvents: DebugRow[]; openDebugSeq: number | null; onDebugSeqChange: (seq: number | null) => void; - contextData: ReturnType; - runEvents: EventEnvelope[]; + contextData: StageContextData | null; stages: Stage[]; - /** Set for a stage of a Petri run: the renderers read these, not events. */ - petri?: PetriStageViews; + /** What the stage's renderers show, derived from the stream and projection. */ + petri: PetriStageViews; }) { const { turns, pendingTools } = activity; - const legacyEvents = petri ? [] : (debugEvents as EventEnvelope[]); return (
{effectiveTab === "chat" ? ( @@ -2371,29 +2135,23 @@ function StageActivityBody({ turn={commandTurn} /> ) : renderer === "human" ? ( - + ) : renderer === "conditional" ? ( ) : renderer === "parallel" ? ( ) : renderer === "fan_in" ? ( - + ) : renderer === "wait" ? ( ) : ( @@ -2454,49 +2212,38 @@ function RunStageActivityStage({ }) { const selectedStageId = selectedStage.id; const renderer: StageRenderer = selectStageRenderer(selectedStage.handler); - // A Petri run has no legacy stage events: its views read the stream and - // the projection, so the query stays idle. - const stageEventsQuery = useRunStageEvents(petri ? undefined : runId, selectedStageId); + // The stage's views read the run's stream and projection; until those + // load, the stage shows as empty. + const stream = petri?.stream ?? EMPTY_STREAM; const stageItems = useMemo( - () => (petri ? itemsForStage(petri.stream, selectedStageId) : []), - [petri, selectedStageId], + () => itemsForStage(stream, selectedStageId), + [stream, selectedStageId], ); const stageProjection: StageProjection | undefined = petri?.projection.stages[selectedStageId]; const activity = useMemo( - () => - petri - ? buildPetriStageActivity(stageItems, stageProjection, renderer) - : buildStageActivity(stageEventsQuery.data ?? [], selectedStageId), - [petri, stageItems, stageProjection, renderer, stageEventsQuery.data, selectedStageId], + () => buildPetriStageActivity(stageItems, stageProjection, renderer), + [stageItems, stageProjection, renderer], ); const { turns } = activity; - const debugEvents = useMemo(() => { - if (petri) return debugRowsFromStream(stageItems); - return (stageEventsQuery.data ?? []).filter( - (event) => activityEventStageId(event) === selectedStageId, - ); - }, [petri, stageItems, stageEventsQuery.data, selectedStageId]); - const petriViews = useMemo( - () => - petri - ? { - pairs: parsePetriInterviewPairs(stageItems), - edge: findPetriEdgeForStage(petri.stream, selectedStageId), - overview: parallelOverviewFromProjection(stageProjection), - reducer: reducerTranscriptFromProjection(stageProjection), - } - : undefined, - [petri, stageItems, stageProjection, selectedStageId], + const debugEvents = useMemo( + () => debugRowsFromStream(stageItems), + [stageItems], + ); + const petriViews = useMemo( + () => ({ + pairs: parsePetriInterviewPairs(stageItems), + edge: findPetriEdgeForStage(stream, selectedStageId), + overview: parallelOverviewFromProjection(stageProjection), + reducer: reducerTranscriptFromProjection(stageProjection), + }), + [stream, stageItems, stageProjection, selectedStageId], ); // The Context tab surfaces the workflow's deliberate per-visit outputs. It // only exists when the stage completed and actually wrote something. const contextData = useMemo( - () => - petri - ? extractPetriStageContext(stageItems) - : extractStageContext(debugEvents as EventEnvelope[]), - [petri, stageItems, debugEvents], + () => extractPetriStageContext(stageItems), + [stageItems], ); const availableTabs = useMemo( () => @@ -2511,11 +2258,6 @@ function RunStageActivityStage({ const isPrimaryAgent = effectiveTab === "primary" && renderer === "agent"; const isDebug = effectiveTab === "debug"; - // Some renderers need run-scoped events (e.g. conditional renders the - // engine-level edge.selected event, which has no stage_id). Only fetch when - // the active renderer actually needs it to keep this off the hot path. - const needsRunEvents = renderer === "conditional" && !petri; - const runEventsQuery = useRunEventsList(needsRunEvents ? runId : undefined); const commandTurn = useMemo(() => { if (effectiveTab !== "primary" || renderer !== "command") return null; for (let i = turns.length - 1; i >= 0; i -= 1) { @@ -2598,14 +2340,14 @@ function RunStageActivityStage({ } return Array.from(set).sort(); }, [isDebug, debugEvents]); - const filteredDebugEvents = useMemo(() => { + const filteredDebugEvents = useMemo(() => { if (!isDebug) return []; const useCategoryFilter = selectedDebugCategories.length > 0; const cats = new Set(selectedDebugCategories); const needle = search.toLowerCase(); return debugEvents.filter((event) => { if (useCategoryFilter && !cats.has(debugRowCategory(event))) return false; - if (needle && !debugItemSearchText(event).includes(needle)) return false; + if (needle && !debugRowSearchText(event).includes(needle)) return false; return true; }); }, [isDebug, debugEvents, selectedDebugCategories, search]); @@ -2649,7 +2391,7 @@ function RunStageActivityStage({ } providerUsed={selectedStage.providerUsed} usage={selectedStage.usage} - events={stageEventsQuery.data ?? []} + events={stageItems} runId={runId} stageId={selectedStageId} /> @@ -2690,7 +2432,6 @@ function RunStageActivityStage({ openDebugSeq={openDebugSeq} onDebugSeqChange={setOpenDebugSeq} contextData={contextData} - runEvents={runEventsQuery.data ?? []} stages={stages} petri={petriViews} /> @@ -2787,19 +2528,18 @@ export default function RunStages() { selectedStage?.startedAt ?? runQuery.data?.timestamps.started_at ?? runQuery.data?.timestamps.created_at; - // The projection says which engine ran the run (a Petri run's stage views - // read it and the run's stream) and feeds the insights sidebar of an - // agent stage; the context window is fetched only for one. + // The stage views read the projection and the run's stream; the + // projection also feeds the insights sidebar of an agent stage, and the + // context window is fetched only for one. const isAgentStage = selectedStage?.handler === "agent"; const runStateQuery = useRunState(id); - const petri = isPetriRun(runStateQuery.data); - const streamQuery = useRunStream(petri ? id : undefined); + const streamQuery = useRunStream(id); const petriData = useMemo( () => - petri && runStateQuery.data && streamQuery.data + runStateQuery.data && streamQuery.data ? { stream: streamQuery.data, projection: runStateQuery.data } : undefined, - [petri, runStateQuery.data, streamQuery.data], + [runStateQuery.data, streamQuery.data], ); const contextWindowQuery = useRunStageContextWindow( isAgentStage ? id : undefined, diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx index 683a1ef50..f40bc61a1 100644 --- a/apps/fabro-web/app/routes/runs.test.tsx +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -148,20 +148,16 @@ describe("runs route board mapping", () => { ]); }); - test("refreshes for blocked status and interview events", () => { - expect(shouldRefreshBoardForEvent("run.pending")).toBe(true); - expect(shouldRefreshBoardForEvent("run.runnable")).toBe(true); - expect(shouldRefreshBoardForEvent("run.approved")).toBe(true); - expect(shouldRefreshBoardForEvent("run.denied")).toBe(true); - expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true); - expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true); - expect(shouldRefreshBoardForEvent("run.cancel.requested")).toBe(true); + test("refreshes for the lifecycle, the blocking question and the answer", () => { + expect(shouldRefreshBoardForEvent("run.created")).toBe(true); + expect(shouldRefreshBoardForEvent("run.lifecycle")).toBe(true); + expect(shouldRefreshBoardForEvent("invocation.cancel.requested")).toBe(true); expect(shouldRefreshBoardForEvent("run.archived")).toBe(true); expect(shouldRefreshBoardForEvent("run.unarchived")).toBe(true); - expect(shouldRefreshBoardForEvent("run.title.updated")).toBe(true); - expect(shouldRefreshBoardForEvent("interview.started")).toBe(true); - expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true); - expect(shouldRefreshBoardForEvent("run.created")).toBe(false); + expect(shouldRefreshBoardForEvent("run.title")).toBe(true); + expect(shouldRefreshBoardForEvent("wait.state.changed")).toBe(true); + expect(shouldRefreshBoardForEvent("interview.answered")).toBe(true); + expect(shouldRefreshBoardForEvent("step.progress.recorded")).toBe(false); }); test("includes the configured server argument for GitHub-auth quick starts", () => { diff --git a/apps/fabro-web/app/routes/settings-live-events.test.tsx b/apps/fabro-web/app/routes/settings-live-events.test.tsx index 1a284cb2e..d2f6dcb81 100644 --- a/apps/fabro-web/app/routes/settings-live-events.test.tsx +++ b/apps/fabro-web/app/routes/settings-live-events.test.tsx @@ -2,20 +2,22 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter, Route, Routes } from "react-router"; -import type { LiveEventPayload } from "../lib/live-events"; +import type { RunStreamItem } from "@qltysh/fabro-api-client"; -let capturedOnEvent: ((payload: LiveEventPayload) => void) | null = null; +import { makePetriItem, makePlatformItem } from "../lib/test-utils"; + +let capturedOnEvent: ((payload: RunStreamItem) => void) | null = null; mock.module("../lib/live-events", () => ({ subscribeToLiveEvents: ( - onEvent: (payload: LiveEventPayload) => void, + onEvent: (payload: RunStreamItem) => void, ) => { capturedOnEvent = onEvent; return () => { if (capturedOnEvent === onEvent) capturedOnEvent = null; }; }, - useLiveEventsSubscription: (onEvent: (payload: LiveEventPayload) => void) => { + useLiveEventsSubscription: (onEvent: (payload: RunStreamItem) => void) => { capturedOnEvent = onEvent; }, })); @@ -43,7 +45,7 @@ function renderSettingsLiveEvents() { return renderer!; } -function pushEvent(payload: LiveEventPayload) { +function pushEvent(payload: RunStreamItem) { act(() => { capturedOnEvent?.(payload); }); @@ -63,46 +65,30 @@ function rowsByEventName(renderer: TestRenderer.ReactTestRenderer): string[] { } describe("appendLiveEvent", () => { - test("prepends new events newest-first", () => { - const a: LiveEventPayload = { id: "a", event: "x" }; - const b: LiveEventPayload = { id: "b", event: "y" }; - const result = appendLiveEvent(appendLiveEvent([], a), b); - expect(result.map((e) => e.id)).toEqual(["b", "a"]); + const lifecycle = (seq: number, runId = "run-1") => + makePlatformItem(seq, { kind: "run.lifecycle", transition: "running" }, { run_id: runId }); + + test("prepends new items newest-first", () => { + const result = appendLiveEvent(appendLiveEvent([], lifecycle(1)), lifecycle(2)); + expect(result.map((item) => item.stream_seq)).toEqual([2, 1]); }); - test("dedupes by id when present", () => { - const a: LiveEventPayload = { id: "a", event: "x" }; - const result = appendLiveEvent([a], { id: "a", event: "x" }); + test("dedupes an item by its run and delivery sequence", () => { + const result = appendLiveEvent([lifecycle(7)], lifecycle(7)); expect(result).toHaveLength(1); }); - test("dedupes by run_id:seq:event when id is missing", () => { - const a: LiveEventPayload = { run_id: "run-1", seq: 7, event: "x" }; - const result = appendLiveEvent([a], { run_id: "run-1", seq: 7, event: "x" }); - expect(result).toHaveLength(1); - }); - - test("keeps different event names with the same run_id and seq", () => { - const a: LiveEventPayload = { run_id: "run-1", seq: 7, event: "x" }; - const result = appendLiveEvent([a], { run_id: "run-1", seq: 7, event: "y" }); - expect(result).toHaveLength(2); - }); - - test("treats events with neither id nor seq as distinct", () => { - const a: LiveEventPayload = { event: "x" }; - const result = appendLiveEvent([a], { event: "x" }); + test("keeps the same delivery sequence of two runs apart", () => { + const result = appendLiveEvent([lifecycle(7, "run-1")], lifecycle(7, "run-2")); expect(result).toHaveLength(2); }); test("caps the buffer at MAX_EVENTS", () => { - const seed = Array.from({ length: MAX_EVENTS }, (_, i) => ({ - id: `seed-${i}`, - event: "x", - })); - const result = appendLiveEvent(seed, { id: "fresh", event: "x" }); + const seed = Array.from({ length: MAX_EVENTS }, (_, i) => lifecycle(i + 1)); + const result = appendLiveEvent(seed, lifecycle(MAX_EVENTS + 1)); expect(result).toHaveLength(MAX_EVENTS); - expect(result[0]?.id).toBe("fresh"); - expect(result[result.length - 1]?.id).toBe(`seed-${MAX_EVENTS - 2}`); + expect(result[0]?.stream_seq).toBe(MAX_EVENTS + 1); + expect(result[result.length - 1]?.stream_seq).toBe(MAX_EVENTS - 1); }); }); @@ -124,49 +110,57 @@ describe("SettingsLiveEvents route", () => { expect(text).toContain("only shows events that arrive after it's opened"); }); - test("appends incoming events newest first", () => { - const renderer = renderSettingsLiveEvents(); - pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); - pushEvent({ id: "b", event: "agent.message", run_id: "run-2", ts: "2026-05-10T10:00:01Z" }); + const stage = { name: "code" }; + const started = (seq: number, runId: string) => + makePetriItem(seq, { event: "step.started", firing: 1 }, { stage, run_id: runId }); + const finished = (seq: number, runId: string) => + makePetriItem(seq, { event: "step.finished", firing: 1 }, { stage, run_id: runId }); - expect(rowsByEventName(renderer)).toEqual(["agent.message", "stage.started"]); + test("appends incoming items newest first", () => { + const renderer = renderSettingsLiveEvents(); + pushEvent(started(1, "run-1")); + pushEvent(finished(1, "run-2")); + + expect(rowsByEventName(renderer)).toEqual(["step.finished", "step.started"]); }); - test("ignores duplicate event ids", () => { + test("ignores an item delivered twice", () => { const renderer = renderSettingsLiveEvents(); - pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); - pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); + pushEvent(started(1, "run-1")); + pushEvent(started(1, "run-1")); - expect(rowsByEventName(renderer)).toEqual(["stage.started"]); + expect(rowsByEventName(renderer)).toEqual(["step.started"]); }); - test("links the run_id cell to the run detail page", () => { + test("links the run cell to the run detail page", () => { const renderer = renderSettingsLiveEvents(); - pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); + pushEvent(started(1, "run-1")); const links = renderer.root.findAllByProps({ to: "/runs/run-1" }); expect(links.length).toBeGreaterThan(0); }); - test("filters events by category and search", () => { + test("names a platform record by its kind and filters items by search", () => { const renderer = renderSettingsLiveEvents(); - pushEvent({ id: "1", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); - pushEvent({ id: "2", event: "agent.message", run_id: "run-2", ts: "2026-05-10T10:00:01Z" }); - pushEvent({ id: "3", event: "command.started", run_id: "run-3", ts: "2026-05-10T10:00:02Z" }); + pushEvent(started(1, "run-1")); + pushEvent(finished(2, "run-2")); + pushEvent( + makePlatformItem(3, { kind: "run.lifecycle", transition: "succeeded" }, { run_id: "run-3" }), + ); expect(rowsByEventName(renderer)).toEqual([ - "command.started", - "agent.message", - "stage.started", + "run.lifecycle", + "step.finished", + "step.started", ]); const searchInput = renderer.root.findByProps({ name: "event-search" }); act(() => { (searchInput.props.onChange as (e: { target: { value: string } }) => void)({ - target: { value: "agent" }, + target: { value: "finished" }, }); }); - expect(rowsByEventName(renderer)).toEqual(["agent.message"]); + expect(rowsByEventName(renderer)).toEqual(["step.finished"]); }); }); diff --git a/apps/fabro-web/app/routes/settings-live-events.tsx b/apps/fabro-web/app/routes/settings-live-events.tsx index 622046c82..0c7d79d91 100644 --- a/apps/fabro-web/app/routes/settings-live-events.tsx +++ b/apps/fabro-web/app/routes/settings-live-events.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { Link } from "react-router"; +import type { RunStreamItem } from "@qltysh/fabro-api-client"; import { DebugEventDetailsPanel, @@ -8,7 +9,6 @@ import { } from "../components/event-debug"; import { DEBUG_CATEGORIES, - debugCategory, debugCategoryLabel, debugCategoryTone, type DebugCategory, @@ -17,10 +17,12 @@ import { EmptyState } from "../components/state"; import { Tooltip } from "../components/ui"; import { eventDedupeKey } from "../lib/cross-tab-sse"; import { formatAbsoluteTs } from "../lib/format"; +import { useLiveEventsSubscription } from "../lib/live-events"; import { - useLiveEventsSubscription, - type LiveEventPayload, -} from "../lib/live-events"; + debugRowSearchText, + debugRowsFromStream, + type DebugRow, +} from "../lib/petri-stream"; export function meta() { return [{ title: "Live Events — Fabro" }]; @@ -31,46 +33,62 @@ export const handle = { wide: true, fullHeight: true }; export const MAX_EVENTS = 1000; export function appendLiveEvent( - buffer: LiveEventPayload[], - payload: LiveEventPayload, -): LiveEventPayload[] { - const key = eventDedupeKey(payload); - if (key != null && buffer.some((event) => eventDedupeKey(event) === key)) { + buffer: RunStreamItem[], + item: RunStreamItem, +): RunStreamItem[] { + const key = rowKey(item); + if (buffer.some((event) => rowKey(event) === key)) { return buffer; } - const next = [payload, ...buffer]; + const next = [item, ...buffer]; if (next.length > MAX_EVENTS) next.length = MAX_EVENTS; return next; } export default function SettingsLiveEvents() { - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); const [openKey, setOpenKey] = useState(null); const [selectedCategories, setSelectedCategories] = useState([]); const [search, setSearch] = useState(""); - useLiveEventsSubscription((payload) => { - setEvents((prev) => appendLiveEvent(prev, payload)); + useLiveEventsSubscription((item) => { + setEvents((prev) => appendLiveEvent(prev, item)); }); - const filtered = useMemo(() => { + const rows = useMemo(() => debugRowsFromStream(events), [events]); + + const filtered = useMemo(() => { const useCategoryFilter = selectedCategories.length > 0; const cats = new Set(selectedCategories); const needle = search.toLowerCase(); - return events.filter((event) => { - const name = event.event ?? ""; - if (useCategoryFilter && !cats.has(debugCategory(name))) return false; + return rows.filter((row) => { + if (useCategoryFilter && !cats.has(row.category)) return false; if (needle) { - const blob = `${name} ${event.run_id ?? ""} ${event.stage_id ?? ""} ${event.node_id ?? ""} ${JSON.stringify(event.properties ?? {})}`.toLowerCase(); + const blob = `${debugRowSearchText(row)} ${row.item.run_id.toLowerCase()}`; if (!blob.includes(needle)) return false; } return true; }); - }, [events, selectedCategories, search]); + }, [rows, selectedCategories, search]); - const openEvent = useMemo( - () => (openKey != null ? events.find((e) => rowKey(e) === openKey) ?? null : null), - [events, openKey], + const openRow = useMemo( + () => (openKey != null ? rows.find((row) => rowKey(row.item) === openKey) ?? null : null), + [rows, openKey], + ); + const openPayload = useMemo( + () => + openRow + ? { + event: openRow.event, + run_id: openRow.item.run_id, + stream_seq: openRow.seq, + kind: openRow.item.kind, + stage: openRow.stageLabel, + recorded_at: openRow.ts, + item: openRow.item.item, + } + : null, + [openRow], ); const isFiltering = selectedCategories.length > 0 || search.length > 0; @@ -131,42 +149,41 @@ export default function SettingsLiveEvents() { No events match these filters.
) : ( - filtered.map((event) => ( + filtered.map((row) => ( setOpenKey(rowKey(event))} + key={rowKey(row.item)} + row={row} + selected={openKey === rowKey(row.item)} + onSelect={() => setOpenKey(rowKey(row.item))} /> )) )}
- setOpenKey(null)} /> + setOpenKey(null)} /> ); } -function rowKey(event: LiveEventPayload): string { - return ( - eventDedupeKey(event) ?? - `${event.run_id ?? "?"}:${event.event ?? ""}:${event.ts ?? ""}` - ); +/** The run and the delivery sequence identify an item across every run. */ +function rowKey(item: RunStreamItem): string { + return eventDedupeKey(item) ?? `${item.run_id}:stream:${item.stream_seq}`; } function LiveEventRow({ - event, + row, selected, onSelect, }: { - event: LiveEventPayload; + row: DebugRow; selected: boolean; onSelect: () => void; }) { - const eventName = event.event ?? ""; - const category = debugCategory(eventName); - const stage = event.stage_id ?? event.node_id ?? null; + const eventName = row.event; + const { category } = row; + const runId = row.item.run_id; + const stage = row.stageLabel; function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" || e.key === " ") { @@ -196,30 +213,22 @@ function LiveEventRow({ {eventName}
- {event.run_id ? ( - e.stopPropagation()} - className="text-fg-3 hover:text-fg hover:underline" - > - {event.run_id} - - ) : ( - No run - )} + e.stopPropagation()} + className="text-fg-3 hover:text-fg hover:underline" + > + {runId} + {stage ?? ""} - {event.ts ? ( - - - {formatAbsoluteTs(event.ts)} - - - ) : ( - No time - )} + + + {formatAbsoluteTs(row.ts)} + + ); } diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 62e5c45ca..df7a9cd20 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -2776,49 +2776,24 @@ paths: tags: [Run Internals] summary: List Run Events description: | - Returns a paginated JSON list of the run's events. The shape depends - on the engine the run was created for (`RunSpec.engine`). - - For a legacy run (`engine.kind = legacy`): stored run events in the - legacy envelope (`PaginatedEventList`). Ascending order uses - `since_seq` as an inclusive cursor. Descending order uses - `before_seq` as an exclusive cursor and starts at the newest event - when `before_seq` is omitted. - - For a Petri run (`engine.kind = petri`): the run stream - (`PaginatedRunStreamList`), one ordered delivery of Petri's own - `RunEvent`s and Fabro's platform records in the `RunStreamItem` - envelope, in `stream_seq` order. The cursor is `after`: the last - `stream_seq` the client saw, exclusive; the first page is `after=0`. - `since_seq`, `before_seq` and `order` are not accepted for a Petri - run. A client that reconnects resumes from its last `stream_seq` and - deduplicates by each item's `id`; every item is delivered once, in - order, with no gap. + Returns one page of the run's stream (`PaginatedRunStreamList`): + one ordered delivery of Petri's own `RunEvent`s and Fabro's platform + records in the `RunStreamItem` envelope, in `stream_seq` order. The + cursor is `after`: the last `stream_seq` the client saw, exclusive; + the first page is `after=0`. A client that reconnects resumes from + its last `stream_seq` and deduplicates by each item's `id`; every + item is delivered once, in order, with no gap. parameters: - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SinceSeq" - $ref: "#/components/parameters/EventLimit" - - $ref: "#/components/parameters/BeforeSeq" - - $ref: "#/components/parameters/EventOrder" - $ref: "#/components/parameters/StreamAfter" responses: "200": - description: Paginated list of run events, in the run engine's envelope + description: One page of the run's stream content: application/json: schema: - oneOf: - - $ref: "#/components/schemas/PaginatedEventList" - - $ref: "#/components/schemas/PaginatedRunStreamList" - "400": - description: Invalid cursor and order combination - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/schemas/PaginatedRunStreamList" "404": description: Run not found headers: @@ -2828,92 +2803,6 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - post: - operationId: appendRunEvent - tags: [Run Internals] - summary: Append Run Event - description: Appends a validated event to the run event log. Intended for trusted internal callers. - parameters: - - $ref: "#/components/parameters/RunId" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RunEvent" - responses: - "200": - description: Event appended - content: - application/json: - schema: - $ref: "#/components/schemas/AppendEventResponse" - "400": - description: Invalid event payload - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - "404": - description: Run not found - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /api/v1/runs/{id}/events/{seq}: - get: - operationId: getRunEventDetail - tags: [Run Internals] - summary: Get Run Event Detail - description: Returns one stored run event by source event sequence with content fields separated and truncated. - parameters: - - $ref: "#/components/parameters/RunId" - - name: seq - in: path - required: true - schema: - type: integer - minimum: 1 - - name: max_content_length - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 200000 - default: 20000 - responses: - "200": - description: Run event detail - content: - application/json: - schema: - $ref: "#/components/schemas/RunEventDetailResponse" - "400": - description: Invalid query parameter - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - "404": - description: Run or event not found - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" /api/v1/runs/{id}/attach: get: @@ -2921,23 +2810,16 @@ paths: tags: [Run Internals] summary: Attach Run Events description: | - Opens an ordered server-sent event stream, replaying persisted items - and continuing with live updates while the run remains active. Each - `data:` frame is one JSON object in the run engine's envelope. - - For a legacy run the frames are `EventEnvelope`s and the stream - starts at `since_seq` (inclusive; the next unseen event when omitted). - It ends after `run.completed` or `run.failed`. - - For a Petri run the frames are `RunStreamItem`s and the stream - starts after `after` (the last `stream_seq` the client saw; `0` - replays the whole run; the next unseen item when omitted). It ends - once the run is no longer active and every committed item has been - sent. A reconnecting client passes its last `stream_seq` as `after` - and deduplicates by `id`. + Opens an ordered server-sent event stream of the run's stream, + replaying committed items and continuing with live ones while the + run remains active. Each `data:` frame is one `RunStreamItem`. The + stream starts after `after` (the last `stream_seq` the client saw; + `0` replays the whole run; the next unseen item when omitted). It + ends once the run is no longer active and every committed item has + been sent. A reconnecting client passes its last `stream_seq` as + `after` and deduplicates by `id`. parameters: - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SinceSeq" - $ref: "#/components/parameters/StreamAfter" responses: "200": @@ -3446,34 +3328,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/stages/{stageId}/events: - get: - operationId: listStageEvents - tags: [Run Internals] - summary: List Stage Events - description: Returns a paginated JSON list of stored run events scoped to a single stage visit. - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/StageId" - - $ref: "#/components/parameters/SinceSeq" - - $ref: "#/components/parameters/EventLimit" - responses: - "200": - description: Paginated list of stage events - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedEventList" - "404": - description: Run not found. - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/stages/{stageId}/context-window: get: operationId: getRunStageContextWindow @@ -5361,7 +5215,10 @@ paths: operationId: attachEvents tags: [System] summary: Attach Global Events - description: Opens a server-sent event stream for live run events across the server. + description: >- + Opens a server-sent event stream of every run's stream across the + server: each `data:` frame is one `RunStreamItem`, as it is + committed. parameters: - name: run_id in: query @@ -6155,31 +6012,6 @@ components: default: 1 example: 42 - BeforeSeq: - name: before_seq - in: query - required: false - description: | - Exclusive upper event sequence cursor for descending order. Omit on - the first descending request to start from the newest event. - schema: - type: integer - minimum: 1 - example: 42 - - EventOrder: - name: order - in: query - required: false - description: | - Event sequence order. `since_seq` is valid only with `asc`; - `before_seq` is valid only with `desc`. - schema: - type: string - enum: [asc, desc] - default: asc - example: desc - EventLimit: name: limit in: query @@ -8099,72 +7931,6 @@ components: tool_call_id: type: string - RunEventDetailResponse: - type: object - required: - - event - - properties - - truncated - - redacted - - max_content_length - properties: - event: - type: object - required: - - seq - - id - - ts - - run_id - - event - properties: - seq: - type: integer - minimum: 1 - id: - type: string - ts: - type: string - format: date-time - run_id: - type: string - event: - type: string - actor: - $ref: "#/components/schemas/Principal" - session_id: - type: string - node_id: - type: string - node_label: - type: string - stage_id: - type: string - tool_call_id: - type: string - properties: - type: object - additionalProperties: true - content: - type: object - required: [kind, value] - properties: - kind: - type: string - enum: - - text - - tool_output - - tool_arguments - - error - - details - value: - type: string - truncated: - type: boolean - redacted: - type: boolean - max_content_length: - type: integer - SessionId: description: Durable session identifier. type: string @@ -10609,63 +10375,6 @@ components: system_kind: $ref: "#/components/schemas/SystemActorKind" - RunEvent: - description: > - Internal RunEvent-compatible JSON payload. The server validates this - body by deserializing into the typed RunEvent struct. - type: object - required: - - id - - ts - - run_id - - event - properties: - id: - type: string - ts: - type: string - format: date-time - run_id: - type: string - node_id: - type: ["string", "null"] - node_label: - type: ["string", "null"] - stage_id: - type: ["string", "null"] - description: Stage execution identity, formatted as "{node_id}@{visit}". - parallel_group_id: - type: ["string", "null"] - description: > - Durable identity of one execution of a parallel node, formatted as - "{node_id}@{visit}". - parallel_branch_id: - oneOf: - - $ref: "#/components/schemas/ParallelBranchId" - - type: "null" - session_id: - type: ["string", "null"] - parent_session_id: - type: ["string", "null"] - tool_call_id: - type: ["string", "null"] - description: > - Stable identifier for a tool call, present on agent.tool.* events - and other durable events that directly describe the same tool - call. - actor: - oneOf: - - $ref: "#/components/schemas/Principal" - - type: "null" - event: - type: string - description: Event type discriminator. - example: stage.started - properties: - type: object - additionalProperties: true - additionalProperties: true - AgentSessionActivatedProps: description: Properties for the `agent.session.activated` event. type: object @@ -10812,40 +10521,6 @@ components: type: integer minimum: 1 - EventSeq: - description: Assigned sequence number component of a stored event envelope. - type: object - required: - - seq - properties: - seq: - type: integer - description: Assigned event sequence number. - example: 42 - - EventEnvelope: - description: > - Stored event envelope with assigned sequence number. On the wire the - envelope is flattened: seq sits alongside the RunEvent payload fields - at the top level of the JSON object. - allOf: - - $ref: "#/components/schemas/EventSeq" - - $ref: "#/components/schemas/RunEvent" - - PaginatedEventList: - description: Paginated list of stored run events. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/EventEnvelope" - meta: - $ref: "#/components/schemas/PaginationMeta" - RunStreamItemKind: description: Which item shape a run stream item carries. type: string @@ -10939,17 +10614,6 @@ components: minimum: 0 example: 3 - AppendEventResponse: - description: Assigned sequence number for an appended event. - type: object - required: - - seq - properties: - seq: - type: integer - description: Assigned event sequence number. - example: 42 - BlobHash: description: >- Content-addressed SHA-256 hash of a stored blob. Hex input is case-insensitive; diff --git a/lib/apps/fabro-cli/src/commands/dump.rs b/lib/apps/fabro-cli/src/commands/dump.rs index 9c0454da8..336c8bb79 100644 --- a/lib/apps/fabro-cli/src/commands/dump.rs +++ b/lib/apps/fabro-cli/src/commands/dump.rs @@ -79,8 +79,8 @@ async fn write_run_dump( state: &RunProjection, output_dir: &Path, ) -> Result { - let events = client.list_run_events(run_id, None, None).await?; - let mut dump = RunDump::from_store_state_and_events(state, &events)?; + let items = client.list_run_stream(run_id, 0).await?; + let mut dump = RunDump::from_store_state_and_stream(state, &items)?; if let Some(log) = client.get_run_logs(run_id).await? { dump.add_file_bytes("run.log", log); diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs index 37bb18d81..135808c18 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/event.rs @@ -1,6 +1,4 @@ use chrono::{DateTime, Utc}; -use fabro_types::{EventBody, ModelUsage, RunEvent}; -use fabro_util::{error, text}; use fabro_workflow::event::RunNoticeLevel; use pebble_coding_agent::events::{CodingEvent, ErrorKind as AgentErrorKind, LlmOutputKind}; use serde_json::Value; @@ -13,18 +11,6 @@ pub(super) struct ProgressUsage { } impl ProgressUsage { - pub(super) fn from_stage_usage(usage: &ModelUsage) -> Self { - let tokens = usage.usage.tokens; - Self { - input_tokens: tokens.input, - output_tokens: tokens.billable_output(), - cost: usage - .usage - .cost - .map(|cost| cost.usd_micros as f64 / 1_000_000.0), - } - } - pub(super) fn total_tokens(&self) -> u64 { self.input_tokens.saturating_add(self.output_tokens) } @@ -44,62 +30,6 @@ pub(super) enum ProgressEvent { base_branch: Option, base_sha: Option, }, - WorkingDirectorySet { - working_directory: String, - }, - SandboxInitializing { - provider: String, - }, - SandboxReady { - provider: String, - duration_ms: u64, - name: Option, - url: Option, - }, - SandboxFailed { - provider: String, - error: String, - }, - SnapshotPulling { - name: String, - }, - SnapshotCreating { - name: String, - }, - SnapshotReady { - name: String, - duration_ms: u64, - }, - SnapshotFailed { - name: String, - error: String, - }, - SshAccessReady { - ssh_command: String, - }, - SetupStarted { - command_count: u64, - }, - SetupCompleted { - duration_ms: u64, - }, - SetupCommandCompleted { - command: String, - command_index: u64, - exit_code: i64, - duration_ms: u64, - }, - CliEnsureStarted { - cli_name: String, - }, - CliEnsureCompleted { - cli_name: String, - already_installed: bool, - duration_ms: u64, - }, - CliEnsureFailed { - cli_name: String, - }, StageStarted { node_id: String, name: String, @@ -212,11 +142,6 @@ pub(super) enum ProgressEvent { from_node: String, to_node: String, }, - MetadataSnapshotFailed { - phase: String, - failure_kind: String, - error: String, - }, RunNotice { level: RunNoticeLevel, code: String, @@ -226,175 +151,6 @@ pub(super) enum ProgressEvent { pr_url: String, draft: bool, }, - PullRequestFailed { - error: String, - }, -} - -pub(super) fn from_run_event(stored: &RunEvent) -> Option { - let node_id = stored.node_id.clone().unwrap_or_else(|| "?".to_string()); - let node_label = stored.node_label.clone().unwrap_or_else(|| node_id.clone()); - - match &stored.body { - EventBody::RunCreated(props) => Some(ProgressEvent::RunCreated { - web_url: props.web_url.clone(), - }), - EventBody::RunStarted(props) => Some(ProgressEvent::WorkflowStarted { - worktree_dir: props.worktree_dir.clone(), - base_branch: props.base_branch.clone(), - base_sha: props.base_sha.clone(), - }), - EventBody::SandboxInitialized(props) => Some(ProgressEvent::WorkingDirectorySet { - working_directory: props.working_directory.clone(), - }), - EventBody::SandboxInitializing(props) => Some(ProgressEvent::SandboxInitializing { - provider: props.provider.clone(), - }), - EventBody::SandboxReady(props) => Some(ProgressEvent::SandboxReady { - provider: props.provider.clone(), - duration_ms: props.duration_ms, - name: props.name.clone(), - url: props.url.clone(), - }), - EventBody::SandboxFailed(props) => Some(ProgressEvent::SandboxFailed { - provider: props.provider.clone(), - error: props.error.clone(), - }), - EventBody::SandboxDriver { event, .. } => driver_progress_event(event), - EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady { - ssh_command: props.ssh_command.clone(), - }), - EventBody::SetupStarted(props) => Some(ProgressEvent::SetupStarted { - command_count: props.command_count as u64, - }), - EventBody::SetupCompleted(props) => Some(ProgressEvent::SetupCompleted { - duration_ms: props.duration_ms, - }), - EventBody::SetupCommandCompleted(props) => Some(ProgressEvent::SetupCommandCompleted { - command: props.command.clone(), - command_index: props.index as u64, - exit_code: i64::from(props.exit_code), - duration_ms: props.duration_ms, - }), - EventBody::CliEnsureStarted(props) => Some(ProgressEvent::CliEnsureStarted { - cli_name: props.cli_name.clone(), - }), - EventBody::CliEnsureCompleted(props) => Some(ProgressEvent::CliEnsureCompleted { - cli_name: props.cli_name.clone(), - already_installed: props.already_installed, - duration_ms: props.duration_ms, - }), - EventBody::CliEnsureFailed(props) => Some(ProgressEvent::CliEnsureFailed { - cli_name: props.cli_name.clone(), - }), - EventBody::StageStarted(_) => Some(ProgressEvent::StageStarted { - node_id, - name: node_label, - script: None, - }), - EventBody::StageCompleted(props) => Some(ProgressEvent::StageCompleted { - node_id, - name: node_label, - timing: props.timing, - status: props.status.to_string(), - usage: props.usage.as_ref().map(ProgressUsage::from_stage_usage), - }), - EventBody::StageFailed(props) => Some(ProgressEvent::StageFailed { - node_id, - name: node_label, - error: props.failure.as_ref().map_or_else( - || "unknown error".to_string(), - |failure| error::render_compact_with_causes(&failure.message, &failure.causes), - ), - }), - EventBody::StageRetrying(props) => Some(ProgressEvent::StageRetrying { - name: node_label, - attempt: props.attempt as u64, - max_attempts: props.max_attempts as u64, - delay_ms: props.delay_ms, - }), - EventBody::ParallelStarted(_) => Some(ProgressEvent::ParallelStarted), - EventBody::ParallelBranchStarted(props) => Some(ProgressEvent::ParallelBranchStarted { - branch: parallel_branch_display(&node_id, props.index, props.item_label.as_deref()), - }), - EventBody::ParallelBranchCompleted(props) => Some(ProgressEvent::ParallelBranchCompleted { - branch: parallel_branch_display( - &node_id, - props.index, - props.item_label.as_deref(), - ), - duration_ms: props.duration_ms, - status: props.status, - }), - EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted), - EventBody::Agent(props) => agent_progress_event(node_id, stored, props.coding_event()), - EventBody::EdgeSelected(props) => Some(ProgressEvent::EdgeSelected { - from_node: props.from_node.clone(), - to_node: props.to_node.clone(), - label: props.label.clone(), - condition: props.condition.clone(), - }), - EventBody::LoopRestart(props) => Some(ProgressEvent::LoopRestart { - from_node: props.from_node.clone(), - to_node: props.to_node.clone(), - }), - EventBody::MetadataSnapshotFailed(props) => Some(ProgressEvent::MetadataSnapshotFailed { - phase: props.phase.to_string(), - failure_kind: props.failure_kind.to_string(), - error: props.error.clone(), - }), - EventBody::RunNotice(props) => Some(ProgressEvent::RunNotice { - level: props.level, - code: props.code.clone(), - message: props.message.clone(), - }), - EventBody::PullRequestCreated(props) => Some(ProgressEvent::PullRequestCreated { - pr_url: props.pr_url.clone(), - draft: props.draft, - }), - EventBody::PullRequestFailed(props) => Some(ProgressEvent::PullRequestFailed { - error: props.error.clone(), - }), - _ => None, - } -} - -/// Name a parallel branch for the terminal. -/// -/// `item_label` is sanitized where it is created, but events replayed from a -/// run recorded before that are not, and this string goes straight to the -/// terminal. Sanitizing again is cheap and keeps the guarantee local. -fn parallel_branch_display(node_id: &str, index: usize, item_label: Option<&str>) -> String { - item_label - .map(text::sanitize_display_label) - .filter(|label| !label.is_empty()) - .map_or_else( - || node_id.to_string(), - |label| format!("{label} ({node_id} #{index})"), - ) -} - -#[cfg(test)] -pub(super) fn from_json_line(line: &str) -> Option { - let stored = RunEvent::from_json_str(line).ok()?; - from_run_event(&stored) -} - -/// The progress line for one coding agent event, if the terminal shows it. -/// -/// Inference brackets and interrupts are tracked for the root session only: -/// a subagent's rounds must not move the stage's live line. -fn agent_progress_event( - node_id: String, - stored: &RunEvent, - event: &CodingEvent, -) -> Option { - coding_progress_event( - node_id, - stored.parent_session_id.is_none(), - Some(stored.ts), - event, - ) } /// The progress line for one coding agent event, given whether it came @@ -543,481 +299,3 @@ pub(super) fn coding_progress_event( _ => None, } } - -/// The setup progress a sandbox driver event stands for: the image pull -/// inside the sandbox's create, or a snapshot build. Every other driver -/// event is stored on the run but renders nothing here. -fn driver_progress_event(event: &sandbox_driver::Event) -> Option { - use sandbox_driver::{Action, EventBody as Body, EventSubject, ProgressCode}; - - match (&event.subject, &event.body) { - ( - EventSubject::Sandbox { .. }, - Body::OperationProgress { - action: Action::Create, - progress, - }, - ) if progress.code.as_str() == ProgressCode::IMAGE_PULL => { - Some(ProgressEvent::SnapshotPulling { - name: pulled_image_name(progress.message.as_deref()), - }) - } - (EventSubject::Snapshot { id, name }, body) => { - let name = name - .clone() - .or_else(|| id.as_ref().map(ToString::to_string)) - .unwrap_or_default(); - match body { - Body::OperationStarted { - action: Action::Create, - } => Some(ProgressEvent::SnapshotCreating { name }), - Body::OperationCompleted { - action: Action::Create, - duration, - } => Some(ProgressEvent::SnapshotReady { - name, - duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX), - }), - Body::OperationFailed { - action: Action::Create, - error, - .. - } => Some(ProgressEvent::SnapshotFailed { - name, - error: error.message.clone(), - }), - _ => None, - } - } - _ => None, - } -} - -/// The image an image pull progress report names. The Docker provider -/// says `pulling image `; the reference alone reads better. -fn pulled_image_name(message: Option<&str>) -> String { - let message = message.unwrap_or("image"); - message - .strip_prefix("pulling image ") - .unwrap_or(message) - .to_owned() -} - -#[cfg(test)] -mod tests { - use fabro_types::{MetadataSnapshotFailureKind, MetadataSnapshotPhase, fixtures}; - use fabro_workflow::event::{Event, RunNoticeCode, SandboxLifecycle, to_run_event}; - use pebble_coding_agent::events::CodingAgentEvent; - - use super::*; - - #[test] - fn parallel_branch_display_neutralizes_runtime_labels() { - assert_eq!( - parallel_branch_display("reviewer", 0, Some("auth")), - "auth (reviewer #0)" - ); - assert_eq!(parallel_branch_display("reviewer", 0, None), "reviewer"); - // A label recorded before sanitizing moved to the source must not - // reach the terminal with escapes or newlines intact. - assert_eq!( - parallel_branch_display("reviewer", 1, Some("\u{1b}[31mauth\u{1b}[0m")), - "auth (reviewer #1)" - ); - assert_eq!( - parallel_branch_display("reviewer", 2, Some("auth\nSUCCESS")), - "authSUCCESS (reviewer #2)" - ); - // Nothing printable left, so fall back to the node id we control. - assert_eq!( - parallel_branch_display("reviewer", 3, Some("\u{1b}[0m ")), - "reviewer" - ); - } - - #[test] - fn parse_edge_selected() { - let stored = to_run_event(&fixtures::RUN_1, &Event::EdgeSelected { - from_node: "a".into(), - to_node: "b".into(), - label: Some("yes".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "succeeded".into(), - is_jump: false, - }); - - let event = from_run_event(&stored).unwrap(); - assert!(matches!( - event, - ProgressEvent::EdgeSelected { - from_node, - to_node, - label, - .. - } if from_node == "a" && to_node == "b" && label.as_deref() == Some("yes") - )); - } - - #[test] - fn round_trip_stage_completed() { - let event = Event::StageCompleted { - node_id: "plan".into(), - name: "Plan".into(), - index: 0, - timing: fabro_types::StageTiming::wall_only(5000), - status: "succeeded".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - usage: None, - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 1, - max_attempts: 1, - }; - - let stored = to_run_event(&fixtures::RUN_1, &event); - let parsed = from_run_event(&stored).unwrap(); - assert!(matches!( - parsed, - ProgressEvent::StageCompleted { - node_id, - name, - timing, - .. - } if node_id == "plan" && name == "Plan" && timing.wall_time_ms == 5000 - )); - } - - #[test] - fn round_trip_agent_tool_call() { - let event = Event::Agent { - stage: "code".into(), - visit: 1, - event: CodingAgentEvent::new( - "ses_root", - CodingEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }, - std::time::SystemTime::UNIX_EPOCH, - ), - }; - - let stored = to_run_event(&fixtures::RUN_1, &event); - let parsed = from_run_event(&stored).unwrap(); - assert!(matches!( - parsed, - ProgressEvent::ToolCallStarted { - stage_node_id, - tool_name, - tool_call_id, - .. - } if stage_node_id == "code" && tool_name == "read_file" && tool_call_id == "tc1" - )); - } - - #[test] - fn round_trip_pull_request_created_without_stage_scope() { - let event = Event::PullRequestCreated { - pr_url: "https://github.com/acme/widgets/pull/42".into(), - pr_number: 42, - owner: "acme".into(), - repo: "widgets".into(), - base_branch: "main".into(), - head_branch: "fabro/run/42".into(), - head_sha: Some("final-sha".to_string()), - title: "Ship the server-side PR".into(), - draft: true, - }; - - let stored = to_run_event(&fixtures::RUN_1, &event); - assert!(stored.node_id.is_none()); - assert!(stored.stage_id.is_none()); - - let parsed = from_run_event(&stored).unwrap(); - assert!(matches!( - parsed, - ProgressEvent::PullRequestCreated { pr_url, draft } - if pr_url == "https://github.com/acme/widgets/pull/42" && draft - )); - } - - #[test] - fn parse_tool_call_timestamps_from_jsonl() { - let started = from_json_line( - &serde_json::json!({ - "id": "evt_1", - "ts": "2026-03-30T12:00:00.000Z", - "run_id": fixtures::RUN_1.to_string(), - "event": "agent.tool.started", - "node_id": "code", - "node_label": "code", - "properties": { - "stage": "code", - "visit": 1, - "session_id": "ses_root", - "timestamp": "2026-03-30T12:00:00.000Z", - "event": { - "ToolCallStarted": { - "tool_name": "read_file", - "tool_call_id": "tc1", - "arguments": {"path": "src/main.rs"} - } - } - } - }) - .to_string(), - ) - .unwrap(); - let completed = from_json_line( - &serde_json::json!({ - "id": "evt_2", - "ts": "2026-03-30T12:00:00.500Z", - "run_id": fixtures::RUN_1.to_string(), - "event": "agent.tool.completed", - "node_id": "code", - "node_label": "code", - "properties": { - "stage": "code", - "visit": 1, - "session_id": "ses_root", - "timestamp": "2026-03-30T12:00:00.500Z", - "event": { - "ToolCallCompleted": { - "tool_name": "read_file", - "tool_call_id": "tc1", - "output": {"ok": true}, - "is_error": false, - "output_bytes_observed": 11, - "output_bytes_retained": 11, - "output_bytes_omitted": 0 - } - } - } - }) - .to_string(), - ) - .unwrap(); - - assert!(matches!( - started, - ProgressEvent::ToolCallStarted { - timestamp: Some(timestamp), - .. - } if timestamp == DateTime::parse_from_rfc3339("2026-03-30T12:00:00.000Z") - .unwrap() - .with_timezone(&Utc) - )); - assert!(matches!( - completed, - ProgressEvent::ToolCallCompleted { - duration_ms: None, - timestamp: Some(timestamp), - .. - } if timestamp == DateTime::parse_from_rfc3339("2026-03-30T12:00:00.500Z") - .unwrap() - .with_timezone(&Utc) - )); - } - - #[test] - fn round_trip_sandbox_ready() { - let event = Event::Sandbox { - event: SandboxLifecycle::Ready { - provider: "daytona".into(), - duration_ms: 2500, - name: Some("sandbox-1".into()), - url: Some("https://example.test".into()), - }, - }; - - let stored = to_run_event(&fixtures::RUN_1, &event); - let parsed = from_run_event(&stored).unwrap(); - assert!(matches!( - parsed, - ProgressEvent::SandboxReady { - provider, - duration_ms, - name, - .. - } if provider == "daytona" && duration_ms == 2500 && name.as_deref() == Some("sandbox-1") - )); - } - - #[test] - fn round_trip_sandbox_failed() { - let event = Event::Sandbox { - event: SandboxLifecycle::InitializeFailed { - provider: "docker".into(), - error: "pull failed".into(), - causes: Vec::new(), - duration_ms: 900, - }, - }; - - let stored = to_run_event(&fixtures::RUN_1, &event); - let parsed = from_run_event(&stored).unwrap(); - assert!(matches!( - parsed, - ProgressEvent::SandboxFailed { provider, error } - if provider == "docker" && error == "pull failed" - )); - } - - fn driver_event(value: serde_json::Value) -> Event { - Event::SandboxDriver { - event: serde_json::from_value(value).expect("a driver event"), - } - } - - #[test] - fn round_trip_driver_events_that_render_setup_progress() { - let pulling = to_run_event( - &fixtures::RUN_1, - &driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 1}, - "occurred_at": "2026-01-01T00:00:00Z", - "provider": "docker", - "subject": {"type": "sandbox"}, - "type": "operation_progress", - "action": "create", - "progress": {"code": "image.pull", "message": "pulling image buildpack-deps:noble"} - })), - ); - let creating = to_run_event( - &fixtures::RUN_1, - &driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 2}, - "occurred_at": "2026-01-01T00:00:00Z", - "provider": "daytona", - "subject": {"type": "snapshot", "name": "fabro-v9"}, - "type": "operation_started", - "action": "create" - })), - ); - let ready = to_run_event( - &fixtures::RUN_1, - &driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 3}, - "occurred_at": "2026-01-01T00:00:01Z", - "provider": "daytona", - "subject": {"type": "snapshot", "name": "fabro-v9"}, - "type": "operation_completed", - "action": "create", - "duration": {"secs": 1, "nanos": 200_000_000} - })), - ); - let failed = to_run_event( - &fixtures::RUN_1, - &driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 4}, - "occurred_at": "2026-01-01T00:00:02Z", - "provider": "daytona", - "subject": {"type": "snapshot", "name": "fabro-v9"}, - "type": "operation_failed", - "action": "create", - "duration": {"secs": 2, "nanos": 0}, - "error": {"kind": "provider", "message": "build failed", "retryable": false, "causes": []} - })), - ); - let stopped = to_run_event( - &fixtures::RUN_1, - &driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 5}, - "occurred_at": "2026-01-01T00:00:03Z", - "provider": "docker", - "subject": {"type": "sandbox", "id": "c1"}, - "type": "operation_completed", - "action": "stop", - "duration": {"secs": 0, "nanos": 0} - })), - ); - - assert_eq!(pulling.event_name(), "sandbox.create.progress"); - assert!(matches!( - from_run_event(&pulling).unwrap(), - ProgressEvent::SnapshotPulling { name } if name == "buildpack-deps:noble" - )); - assert!(matches!( - from_run_event(&creating).unwrap(), - ProgressEvent::SnapshotCreating { name } if name == "fabro-v9" - )); - assert!(matches!( - from_run_event(&ready).unwrap(), - ProgressEvent::SnapshotReady { name, duration_ms } - if name == "fabro-v9" && duration_ms == 1200 - )); - assert!(matches!( - from_run_event(&failed).unwrap(), - ProgressEvent::SnapshotFailed { name, error } - if name == "fabro-v9" && error == "build failed" - )); - assert_eq!(stopped.event_name(), "sandbox.stop.completed"); - assert!( - from_run_event(&stopped).is_none(), - "a stop is stored on the run but renders no setup progress" - ); - } - - #[test] - fn round_trip_run_notice() { - let event = Event::RunNotice { - level: RunNoticeLevel::Warn, - code: RunNoticeCode::SandboxCleanupFailed.to_string(), - message: "sandbox cleanup failed".into(), - exec_output_tail: None, - }; - let expected_code = RunNoticeCode::SandboxCleanupFailed.to_string(); - - let stored = to_run_event(&fixtures::RUN_1, &event); - let parsed = from_run_event(&stored).unwrap(); - assert!(matches!( - parsed, - ProgressEvent::RunNotice { - level: RunNoticeLevel::Warn, - code, - message, - } if code == expected_code && message == "sandbox cleanup failed" - )); - } - - #[test] - fn round_trip_metadata_snapshot_failed() { - let event = Event::MetadataSnapshotFailed { - phase: MetadataSnapshotPhase::Finalize, - branch: "fabro/meta".into(), - duration_ms: 900, - failure_kind: MetadataSnapshotFailureKind::Push, - error: "push rejected".into(), - causes: vec!["remote rejected".into()], - commit_sha: Some("abc123".into()), - entry_count: Some(2), - bytes: Some(42), - exec_output_tail: None, - }; - - let stored = to_run_event(&fixtures::RUN_1, &event); - let parsed = from_run_event(&stored).unwrap(); - assert!(matches!( - parsed, - ProgressEvent::MetadataSnapshotFailed { - phase, - failure_kind, - error, - } if phase == "finalize" && failure_kind == "push" && error == "push rejected" - )); - } -} diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs index c081deefe..a19f3440e 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/info_display.rs @@ -60,34 +60,6 @@ impl InfoDisplay { ); } - pub(super) fn on_pull_request_failed(renderer: &ProgressRenderer, error: &str) { - Self::insert_info_line( - renderer, - &format!("{} {error}", renderer.styles().red.apply_to("PR failed:")), - ); - } - - pub(super) fn on_metadata_snapshot_failed( - renderer: &ProgressRenderer, - phase: &str, - failure_kind: &str, - error: &str, - ) { - let styles = renderer.styles(); - let kind_suffix = if failure_kind.is_empty() { - String::new() - } else { - format!(" {}", styles.dim.apply_to(format!("[{failure_kind}]"))) - }; - Self::insert_info_line( - renderer, - &format!( - "{} Metadata {phase} failed: {error}{kind_suffix}", - styles.yellow.apply_to("Warning:") - ), - ); - } - pub(super) fn on_edge_selected( &self, renderer: &ProgressRenderer, diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs index 0c27c994c..85436006d 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs @@ -3,29 +3,24 @@ reason = "sync CLI run-progress renderer: writes to std::io::stderr directly" )] -use fabro_types::{RunEvent, RunNoticeCode, RunStreamItem}; +use fabro_types::{RunNoticeCode, RunStreamItem}; mod event; mod info_display; mod petri; mod renderer; -mod setup_display; mod stage_display; mod styles; -#[cfg(test)] -use event::from_json_line; -use event::{ProgressEvent, from_run_event}; +use event::ProgressEvent; use info_display::InfoDisplay; use petri::PetriProgressState; use renderer::ProgressRenderer; -use setup_display::SetupDisplay; use stage_display::StageDisplay; pub(crate) struct ProgressUI { renderer: ProgressRenderer, stage: StageDisplay, - setup: SetupDisplay, info: InfoDisplay, saw_metadata_snapshot_failure: bool, petri: PetriProgressState, @@ -48,26 +43,12 @@ impl ProgressUI { Self { renderer, stage: StageDisplay::new(verbose), - setup: SetupDisplay::new(verbose), info: InfoDisplay::new(verbose), saw_metadata_snapshot_failure: false, petri: PetriProgressState::default(), } } - #[cfg(test)] - #[expect( - clippy::disallowed_types, - reason = "test helper accepts a sync blocking writer to capture rendered output" - )] - fn new_plain_test(out: Box, verbose: bool, colors: bool) -> Self { - Self::with_renderer(ProgressRenderer::new_plain(out, colors), verbose) - } - - pub(crate) fn set_working_directory(&mut self, dir: String) { - self.stage.set_working_directory(dir); - } - pub(crate) fn hide_bars(&self) { self.renderer.hide(); } @@ -78,32 +59,11 @@ impl ProgressUI { pub(crate) fn finish(&mut self) { self.stage.finish(); - self.setup.finish(); self.renderer.finish(); } - #[cfg_attr( - not(test), - allow( - dead_code, - reason = "Production code drives this via JSON lines; tests call it directly." - ) - )] - pub(crate) fn handle_event(&mut self, event: &RunEvent) { - if let Some(progress_event) = from_run_event(event) { - self.dispatch(progress_event); - } - } - - #[cfg(test)] - pub(crate) fn handle_json_line(&mut self, line: &str) { - if let Some(progress_event) = from_json_line(line) { - self.dispatch(progress_event); - } - } - /// One item of a Petri run's stream: the progress lines it means, if - /// any, rendered as a legacy event's would be. + /// any. pub(crate) fn handle_stream_item(&mut self, item: &RunStreamItem) { for progress_event in petri::progress_events(item, &mut self.petri) { self.dispatch(progress_event); @@ -130,82 +90,6 @@ impl ProgressUI { InfoDisplay::show_base_info(renderer, base_branch.as_deref(), &base_sha); } } - ProgressEvent::WorkingDirectorySet { working_directory } => { - self.set_working_directory(working_directory); - } - ProgressEvent::SandboxInitializing { provider } => { - self.setup.on_sandbox_initializing(renderer, &provider); - } - ProgressEvent::SandboxFailed { provider, error } => { - self.setup.on_sandbox_failed(renderer, &provider, &error); - } - ProgressEvent::SnapshotPulling { name } => { - self.setup.on_snapshot_pulling(renderer, &name); - } - ProgressEvent::SnapshotCreating { name } => { - self.setup.on_snapshot_creating(renderer, &name); - } - ProgressEvent::SnapshotReady { name, duration_ms } => { - self.setup.on_snapshot_ready(renderer, &name, duration_ms); - } - ProgressEvent::SnapshotFailed { name, error } => { - self.setup.on_snapshot_failed(renderer, &name, &error); - } - ProgressEvent::SandboxReady { - provider, - duration_ms, - name, - url, - } => { - self.setup.on_sandbox_ready( - renderer, - &provider, - duration_ms, - name.as_deref(), - url.as_deref(), - ); - } - ProgressEvent::SshAccessReady { ssh_command } => { - SetupDisplay::on_ssh_access_ready(renderer, &ssh_command); - } - ProgressEvent::SetupStarted { command_count } => { - self.setup.on_setup_started(renderer, command_count); - } - ProgressEvent::SetupCompleted { duration_ms } => { - self.setup.on_setup_completed(renderer, duration_ms); - } - ProgressEvent::SetupCommandCompleted { - command, - command_index, - exit_code, - duration_ms, - } => { - self.setup.on_setup_command_completed( - renderer, - &command, - command_index, - exit_code, - duration_ms, - ); - } - ProgressEvent::CliEnsureStarted { cli_name } => { - self.setup.on_cli_ensure_started(renderer, &cli_name); - } - ProgressEvent::CliEnsureCompleted { - cli_name, - already_installed, - duration_ms, - } => { - self.setup.on_cli_ensure_completed( - renderer, - &cli_name, - already_installed, - duration_ms, - ); - } - ProgressEvent::CliEnsureFailed { cli_name } => { - self.setup.on_cli_ensure_failed(renderer, &cli_name); - } ProgressEvent::StageStarted { node_id, name, @@ -419,14 +303,6 @@ impl ProgressUI { ProgressEvent::LoopRestart { from_node, to_node } => { self.info.on_loop_restart(renderer, &from_node, &to_node); } - ProgressEvent::MetadataSnapshotFailed { - phase, - failure_kind, - error, - } => { - self.saw_metadata_snapshot_failure = true; - InfoDisplay::on_metadata_snapshot_failed(renderer, &phase, &failure_kind, &error); - } ProgressEvent::RunNotice { level, code, @@ -445,1178 +321,6 @@ impl ProgressUI { ProgressEvent::PullRequestCreated { pr_url, draft } => { InfoDisplay::on_pull_request_created(renderer, &pr_url, draft); } - ProgressEvent::PullRequestFailed { error } => { - InfoDisplay::on_pull_request_failed(renderer, &error); - } } } } - -#[cfg(test)] -mod tests { - #![allow( - clippy::absolute_paths, - clippy::needless_pass_by_value, - reason = "These run-progress tests prefer explicit fixtures over pedantic style lints." - )] - #![expect( - clippy::disallowed_types, - reason = "These tests capture rendered output in Vec buffers." - )] - - use std::io::{self, Write}; - use std::sync::{Arc, Mutex}; - - use chrono::{DateTime, Utc}; - use fabro_types::run_event::CliEnsureCompletedProps; - use fabro_types::{ - MetadataSnapshotFailureKind, MetadataSnapshotPhase, ModelRef, ParallelBranchId, - SandboxProviderKind, StageId, fixtures, - }; - use fabro_workflow::event::{ - Event, RunNoticeLevel, SandboxLifecycle, to_run_event, to_run_event_at, - }; - use fabro_workflow::outcome::ModelUsage; - use lithos_llm::catalog::{ModelId, builtin}; - use lithos_llm::types::{Cost, CostSource, TokenCounts, Usage}; - use pebble_coding_agent::events::{ - CodingAgentEvent, CodingEvent, CompactionReason, ErrorData as AgentErrorData, - ErrorKind as AgentErrorKind, - }; - - use super::*; - use crate::commands::run::run_progress::stage_display::ToolCallStatus; - - struct SharedBuffer { - inner: Arc>>, - } - - impl Write for SharedBuffer { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.inner - .lock() - .expect("buffer lock poisoned") - .extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - fn capture_ui(verbose: bool) -> (ProgressUI, Arc>>) { - let buffer = Arc::new(Mutex::new(Vec::new())); - let ui = ProgressUI::new_plain_test( - Box::new(SharedBuffer { - inner: Arc::clone(&buffer), - }), - verbose, - false, - ); - (ui, buffer) - } - - fn rendered(buffer: &Arc>>) -> String { - String::from_utf8(buffer.lock().expect("buffer lock poisoned").clone()) - .expect("valid utf-8") - } - - fn driver_event(value: serde_json::Value) -> Event { - Event::SandboxDriver { - event: serde_json::from_value(value).expect("a driver event"), - } - } - - /// A snapshot build reported by the driver: started, or completed after - /// `secs`. - fn snapshot_build_event(name: &str, kind: &str, secs: Option) -> Event { - let mut value = serde_json::json!({ - "id": {"source_id": "test", "sequence": 1}, - "occurred_at": "2026-01-01T00:00:00Z", - "provider": "daytona", - "subject": {"type": "snapshot", "name": name}, - "type": kind, - "action": "create" - }); - if let Some(secs) = secs { - value["duration"] = serde_json::json!({"secs": secs, "nanos": 0}); - } - driver_event(value) - } - - fn snapshot_build_failed_event(name: &str, error: &str) -> Event { - driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 1}, - "occurred_at": "2026-01-01T00:00:00Z", - "provider": "docker", - "subject": {"type": "snapshot", "name": name}, - "type": "operation_failed", - "action": "create", - "duration": {"secs": 1, "nanos": 0}, - "error": {"kind": "provider", "message": error, "retryable": false, "causes": []} - })) - } - - /// The Docker provider pulling the sandbox's image inside its create. - fn image_pull_event(image: &str) -> Event { - driver_event(serde_json::json!({ - "id": {"source_id": "test", "sequence": 1}, - "occurred_at": "2026-01-01T00:00:00Z", - "provider": "docker", - "subject": {"type": "sandbox"}, - "type": "operation_progress", - "action": "create", - "progress": {"code": "image.pull", "message": format!("pulling image {image}")} - })) - } - - fn emit(ui: &mut ProgressUI, event: Event) { - let stored = to_run_event(&fixtures::RUN_1, &event); - ui.handle_event(&stored); - } - - fn emit_ref(ui: &mut ProgressUI, event: &Event) { - let stored = to_run_event(&fixtures::RUN_1, event); - ui.handle_event(&stored); - } - - fn emit_body(ui: &mut ProgressUI, body: fabro_types::EventBody) { - ui.handle_event(&RunEvent { - id: "evt_legacy".to_string(), - ts: Utc::now(), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body, - }); - } - - fn agent_event(stage: &str, event: CodingEvent) -> Event { - Event::Agent { - stage: stage.into(), - visit: 1, - event: CodingAgentEvent::new("ses_root", event, std::time::SystemTime::UNIX_EPOCH), - } - } - - fn child_agent_event(stage: &str, event: CodingEvent) -> Event { - Event::Agent { - stage: stage.into(), - visit: 1, - event: CodingAgentEvent::new("ses_child", event, std::time::SystemTime::UNIX_EPOCH) - .with_parent_session_id("ses_root"), - } - } - - fn stage_started(node_id: &str, name: &str) -> Event { - Event::StageStarted { - graph_visit: None, - resumed_from_stage_id: None, - node_id: node_id.into(), - name: name.into(), - index: 0, - handler_type: String::new(), - attempt: 1, - max_attempts: 1, - } - } - - fn assistant_event(model: &str, text: &str) -> CodingEvent { - CodingEvent::AssistantMessage { - text: text.into(), - model: model.into(), - usage: Usage::default(), - tool_call_count: 0, - context_window: None, - reasoning: None, - } - } - - fn assistant_message(stage: &str, model: &str) -> Event { - agent_event(stage, assistant_event(model, "done")) - } - - fn child_assistant_message(stage: &str, model: &str) -> Event { - child_agent_event(stage, assistant_event(model, "child done")) - } - - fn llm_request_started(stage: &str, model: &str) -> Event { - agent_event(stage, CodingEvent::LlmRequestStarted { - requested_model: model.into(), - }) - } - - fn stage_completed(node_id: &str, name: &str) -> Event { - Event::StageCompleted { - node_id: node_id.into(), - name: name.into(), - index: 0, - timing: fabro_types::StageTiming::wall_only(5000), - status: "succeeded".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - usage_by_model: Vec::new(), - // Priced as lithos-llm prices gpt-5.4: 1200 input at $2.50/M and - // 300 output at $15/M. - usage: Some(ModelUsage::new( - ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")), - Usage { - tokens: TokenCounts { - input: 1200, - output: 300, - ..TokenCounts::default() - }, - cost: Some(Cost { - usd_micros: 7_500, - source: CostSource::Catalog, - }), - }, - )), - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 1, - max_attempts: 1, - } - } - - #[test] - fn parallel_branches_tracked_as_tool_calls() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("fork1", "Fork Analysis")); - assert!(ui.stage.active_stages.contains_key("fork1")); - assert!(ui.stage.parallel_parent.is_none()); - - emit(&mut ui, Event::ParallelStarted { - node_id: "fork1".into(), - visit: 1, - branch_count: 2, - }); - assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1")); - - emit(&mut ui, Event::ParallelBranchStarted { - graph_visit: None, - resumed_from_stage_id: None, - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - item_label: Some("auth".into()), - }); - let stage = &ui.stage.active_stages["fork1"]; - assert_eq!(stage.tool_calls.len(), 1); - assert_eq!(stage.tool_calls[0].tool_call_id, "auth (security #0)"); - assert!(matches!( - stage.tool_calls[0].status, - ToolCallStatus::Running - )); - - emit(&mut ui, Event::ParallelBranchCompleted { - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - item_label: Some("auth".into()), - duration_ms: 2000, - status: fabro_workflow::outcome::StageOutcome::Succeeded, - }); - let stage = &ui.stage.active_stages["fork1"]; - assert!(matches!( - stage.tool_calls[0].status, - ToolCallStatus::Succeeded - )); - } - - #[test] - fn parallel_branch_running_shows_triangle_glyph() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("fork1", "Fork")); - emit(&mut ui, Event::ParallelStarted { - node_id: "fork1".into(), - visit: 1, - branch_count: 1, - }); - emit(&mut ui, Event::ParallelBranchStarted { - graph_visit: None, - resumed_from_stage_id: None, - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - item_label: None, - }); - - let stage = &ui.stage.active_stages["fork1"]; - let message = stage.tool_calls[0].bar.message(); - assert!( - message.contains('\u{25b8}'), - "expected branch message to contain ▸, got: {message:?}" - ); - } - - #[test] - fn compaction_sets_and_clears_bar() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("s1", "Build")); - assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); - - emit( - &mut ui, - agent_event("s1", CodingEvent::CompactionStarted { - estimated_tokens: 5000, - context_window_size: 8000, - reason: CompactionReason::Threshold, - }), - ); - assert!(ui.stage.active_stages["s1"].compaction_bar.is_some()); - emit(&mut ui, llm_request_started("s1", "claude-fable-5")); - assert!(ui.stage.active_stages["s1"].inference_bar.is_some()); - - emit( - &mut ui, - agent_event("s1", CodingEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, - summary_token_estimate: 500, - tracked_file_count: 3, - reason: CompactionReason::Threshold, - usage: Usage::default(), - }), - ); - assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); - } - - #[test] - fn compaction_failure_clears_bar() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("s1", "Build")); - emit( - &mut ui, - agent_event("s1", CodingEvent::CompactionStarted { - estimated_tokens: 5000, - context_window_size: 8000, - reason: CompactionReason::Threshold, - }), - ); - assert!(ui.stage.active_stages["s1"].compaction_bar.is_some()); - - emit( - &mut ui, - agent_event("s1", CodingEvent::Error { - error: AgentErrorData::new( - AgentErrorKind::Compaction, - "generated summary was empty after trimming; refused to replace 14 turns and \ - left history intact", - ), - }), - ); - - assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); - assert!(ui.stage.active_stages["s1"].inference_bar.is_none()); - } - - #[test] - fn plain_compaction_failure_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit( - &mut ui, - agent_event("s1", CodingEvent::Error { - error: AgentErrorData::new( - AgentErrorKind::Compaction, - "generated summary was empty after trimming; refused to replace 14 turns and \ - left history intact", - ), - }), - ); - - insta::assert_snapshot!(rendered(&buffer), @" ✗ compaction failed: generated summary was empty after trimming; refused to replace 14 turns and left history intact"); - } - - #[test] - fn inference_bracket_sets_updates_and_clears_bar() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("s1", "Build")); - assert!(ui.stage.active_stages["s1"].inference_bar.is_none()); - - emit(&mut ui, llm_request_started("s1", "claude-fable-5")); - let message = ui.stage.active_stages["s1"] - .inference_bar - .as_ref() - .expect("bracket should open a live line") - .message(); - assert!( - message.contains("waiting on claude-fable-5"), - "expected the requested model, got: {message:?}" - ); - - emit( - &mut ui, - agent_event("s1", CodingEvent::LlmFirstOutput { - kind: fabro_types::LlmOutputKind::ToolCall, - }), - ); - let message = ui.stage.active_stages["s1"] - .inference_bar - .as_ref() - .expect("the line stays open until the round ends") - .message(); - assert!( - message.contains("calling tools"), - "expected the observed output kind, got: {message:?}" - ); - - emit(&mut ui, assistant_message("s1", "claude-fable-5")); - assert!(ui.stage.active_stages["s1"].inference_bar.is_none()); - } - - #[test] - fn inference_retry_resets_the_live_line_before_verbose_output() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("s1", "Build")); - emit(&mut ui, llm_request_started("s1", "claude-fable-5")); - emit( - &mut ui, - agent_event("s1", CodingEvent::LlmFirstOutput { - kind: fabro_types::LlmOutputKind::Text, - }), - ); - emit( - &mut ui, - agent_event("s1", CodingEvent::LlmRetry { - provider: "anthropic".into(), - model: "claude-fable-5".into(), - attempt: 1, - delay_secs: 0.1, - phase: fabro_types::LlmRetryPhase::Consume, - error: AgentErrorData::new(AgentErrorKind::Llm, "retry"), - }), - ); - - let message = ui.stage.active_stages["s1"] - .inference_bar - .as_ref() - .expect("retry keeps the bracket open") - .message(); - assert!(message.contains("waiting on claude-fable-5")); - } - - #[test] - fn inference_interrupt_clears_the_live_line() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("s1", "Build")); - emit(&mut ui, llm_request_started("s1", "claude-fable-5")); - emit( - &mut ui, - agent_event("s1", CodingEvent::RoundInterrupted { generation: 1 }), - ); - - assert!(ui.stage.active_stages["s1"].inference_bar.is_none()); - } - - #[test] - fn child_session_events_do_not_mutate_the_root_inference_line() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("s1", "Build")); - emit(&mut ui, llm_request_started("s1", "claude-fable-5")); - emit( - &mut ui, - child_agent_event("s1", CodingEvent::LlmFirstOutput { - kind: fabro_types::LlmOutputKind::ToolCall, - }), - ); - emit(&mut ui, child_assistant_message("s1", "child-model")); - - let message = ui.stage.active_stages["s1"] - .inference_bar - .as_ref() - .expect("child output must not close the root bracket") - .message(); - assert!(message.contains("waiting on claude-fable-5")); - - emit(&mut ui, assistant_message("s1", "claude-fable-5")); - assert!(ui.stage.active_stages["s1"].inference_bar.is_none()); - } - - #[test] - fn handle_json_line_ignores_invalid_json() { - let (mut ui, buffer) = capture_ui(false); - ui.handle_json_line("not valid json"); - ui.handle_json_line(""); - ui.handle_json_line("{}"); - assert!(rendered(&buffer).is_empty()); - } - - #[test] - fn handle_json_line_matches_handle_event_for_verbose_events() { - let events = vec![ - stage_started("code", "Code"), - Event::SandboxInitialized { - working_directory: "/home/daytona/workspace".into(), - provider: SandboxProviderKind::DAYTONA, - id: "daytona:sandbox-id".into(), - repo_cloned: None, - clone_origin_url: None, - clone_branch: None, - workspace_root: None, - repos_root: None, - primary_repo_path: None, - primary_repo_link: None, - image: None, - snapshot: None, - }, - agent_event("code", CodingEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({ - "file_path": "/home/daytona/workspace/src/main.rs" - }), - }), - assistant_message("code", "gpt-5-mini"), - Event::EdgeSelected { - from_node: "code".into(), - to_node: "review".into(), - label: Some("ship".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "succeeded".into(), - is_jump: false, - }, - Event::StageRetrying { - node_id: "code".into(), - name: "Code".into(), - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 1500, - }, - agent_event("code", CodingEvent::Warning { - kind: "context_window".into(), - message: "high usage".into(), - details: serde_json::json!({"usage_percent": 92}), - }), - agent_event("code", CodingEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-5-mini".into(), - attempt: 2, - delay_secs: 1.5, - phase: fabro_types::LlmRetryPhase::Open, - error: AgentErrorData::new(AgentErrorKind::Llm, "busy"), - }), - agent_event("code", CodingEvent::SubAgentSpawned { - agent_id: "a1".into(), - depth: 1, - task: "review recent changes".into(), - generation: 1, - }), - agent_event("code", CodingEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - generation: 1, - success: true, - turns_used: 3, - }), - Event::SetupStarted { command_count: 1 }, - Event::SetupCommandCompleted { - command: "bun install".into(), - index: 0, - exit_code: 0, - duration_ms: 2200, - }, - Event::SetupCompleted { duration_ms: 2200 }, - ]; - - let (mut event_ui, event_buffer) = capture_ui(true); - for event in &events { - emit_ref(&mut event_ui, event); - } - - let (mut json_ui, json_buffer) = capture_ui(true); - for event in &events { - let line = serde_json::to_string(&to_run_event(&fixtures::RUN_1, event)).unwrap(); - json_ui.handle_json_line(&line); - } - - assert_eq!(rendered(&event_buffer), rendered(&json_buffer)); - } - - #[test] - fn plain_default_stage_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, stage_started("plan", "Plan")); - emit(&mut ui, assistant_message("plan", "gpt-5-mini")); - emit( - &mut ui, - agent_event("plan", CodingEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }), - ); - emit( - &mut ui, - agent_event("plan", CodingEvent::ToolCallCompleted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - output: serde_json::json!({"ok": true}), - metadata: pebble_agent::ToolOutputMetadata::default(), - error_kind: None, - is_error: false, - output_bytes_observed: 11, - output_bytes_retained: 11, - output_bytes_omitted: 0, - }), - ); - emit(&mut ui, stage_completed("plan", "Plan")); - - insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan $0.01 5s"); - } - - #[test] - fn plain_default_setup_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Initializing { - provider: "daytona".into(), - }, - }); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Ready { - provider: "daytona".into(), - duration_ms: 2500, - name: Some("sandbox-1".into()), - url: None, - }, - }); - emit(&mut ui, Event::SshAccessReady { - ssh_command: "ssh daytona@example".into(), - }); - emit(&mut ui, Event::SetupStarted { command_count: 2 }); - emit(&mut ui, Event::SetupCompleted { duration_ms: 8200 }); - emit_body( - &mut ui, - fabro_types::EventBody::CliEnsureCompleted(CliEnsureCompletedProps { - cli_name: "gh".into(), - provider: "github".into(), - already_installed: false, - node_installed: false, - duration_ms: 600, - }), - ); - insta::assert_snapshot!(rendered(&buffer), @" - Sandbox: daytona (ready in 2s) - sandbox-1 - ssh daytona@example - Setup: 2 commands (8s) - CLI: gh (installed, 600ms) - "); - } - - #[test] - fn plain_daytona_snapshot_creation_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Initializing { - provider: "daytona".into(), - }, - }); - emit( - &mut ui, - snapshot_build_event("fabro-v9-test", "operation_started", None), - ); - emit( - &mut ui, - snapshot_build_event("fabro-v9-test", "operation_completed", Some(210)), - ); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Ready { - provider: "daytona".into(), - duration_ms: 212_000, - name: Some("sandbox-1".into()), - url: None, - }, - }); - - insta::assert_snapshot!(rendered(&buffer), @" - Sandbox: building fabro-v9-test... - Sandbox: daytona (ready in 3m32s) - sandbox-1 - "); - } - - #[test] - fn plain_docker_snapshot_pull_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Initializing { - provider: "docker".into(), - }, - }); - emit(&mut ui, image_pull_event("buildpack-deps:noble")); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Ready { - provider: "docker".into(), - duration_ms: 9_000, - name: None, - url: None, - }, - }); - - insta::assert_snapshot!(rendered(&buffer), @r" - Sandbox: pulling buildpack-deps:noble... - Sandbox: docker (ready in 9s) - "); - } - - #[test] - fn plain_docker_skipped_snapshot_phase_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Initializing { - provider: "docker".into(), - }, - }); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Ready { - provider: "docker".into(), - duration_ms: 20, - name: None, - url: None, - }, - }); - - insta::assert_snapshot!(rendered(&buffer), @" Sandbox: docker (ready in 20ms)"); - } - - #[test] - fn plain_snapshot_failure_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Initializing { - provider: "docker".into(), - }, - }); - emit( - &mut ui, - snapshot_build_failed_event("buildpack-deps:noble", "pull failed"), - ); - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::InitializeFailed { - provider: "docker".into(), - error: "pull failed".into(), - causes: Vec::new(), - duration_ms: 900, - }, - }); - - insta::assert_snapshot!(rendered(&buffer), @r" - Sandbox: Snapshot buildpack-deps:noble failed: pull failed - Sandbox: docker failed: pull failed - "); - } - - #[test] - fn tty_snapshot_ready_keeps_sandbox_bar_until_sandbox_ready() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Initializing { - provider: "docker".into(), - }, - }); - assert!(ui.setup.sandbox_bar.is_some()); - - emit( - &mut ui, - snapshot_build_event("buildpack-deps:noble", "operation_completed", Some(0)), - ); - assert!(ui.setup.sandbox_bar.is_some()); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Ready { - provider: "docker".into(), - duration_ms: 20, - name: None, - url: None, - }, - }); - assert!(ui.setup.sandbox_bar.is_none()); - } - - #[test] - fn tty_sandbox_failed_finishes_sandbox_bar() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::Initializing { - provider: "docker".into(), - }, - }); - assert!(ui.setup.sandbox_bar.is_some()); - - emit(&mut ui, Event::Sandbox { - event: SandboxLifecycle::InitializeFailed { - provider: "docker".into(), - error: "pull failed".into(), - causes: Vec::new(), - duration_ms: 900, - }, - }); - assert!(ui.setup.sandbox_bar.is_none()); - } - - #[test] - fn plain_verbose_snapshot() { - let (mut ui, buffer) = capture_ui(true); - - emit(&mut ui, stage_started("code", "Code")); - emit(&mut ui, Event::SandboxInitialized { - working_directory: "/home/daytona/workspace".into(), - provider: SandboxProviderKind::DAYTONA, - id: "daytona:sandbox-id".into(), - repo_cloned: None, - clone_origin_url: None, - clone_branch: None, - workspace_root: None, - repos_root: None, - primary_repo_path: None, - primary_repo_link: None, - image: None, - snapshot: None, - }); - emit( - &mut ui, - agent_event("code", CodingEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({ - "file_path": "/home/daytona/workspace/src/main.rs" - }), - }), - ); - emit(&mut ui, assistant_message("code", "gpt-5-mini")); - emit(&mut ui, Event::EdgeSelected { - from_node: "code".into(), - to_node: "review".into(), - label: Some("ship".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "succeeded".into(), - is_jump: false, - }); - emit(&mut ui, Event::StageRetrying { - node_id: "code".into(), - name: "Code".into(), - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 1500, - }); - emit( - &mut ui, - agent_event("code", CodingEvent::Warning { - kind: "context_window".into(), - message: "high usage".into(), - details: serde_json::json!({"usage_percent": 92}), - }), - ); - emit( - &mut ui, - agent_event("code", CodingEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-5-mini".into(), - attempt: 2, - delay_secs: 1.5, - phase: fabro_types::LlmRetryPhase::Open, - error: AgentErrorData::new(AgentErrorKind::Llm, "busy"), - }), - ); - emit( - &mut ui, - agent_event("code", CodingEvent::SubAgentSpawned { - agent_id: "a1".into(), - depth: 1, - task: "review recent changes".into(), - generation: 1, - }), - ); - emit( - &mut ui, - agent_event("code", CodingEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - generation: 1, - success: true, - turns_used: 3, - }), - ); - emit( - &mut ui, - agent_event("code", CodingEvent::SubAgentTurnStarted { - agent_id: "a1".into(), - depth: 1, - task: "fix the review findings".into(), - generation: 2, - }), - ); - emit( - &mut ui, - agent_event("code", CodingEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - generation: 2, - success: true, - turns_used: 2, - }), - ); - emit(&mut ui, Event::SetupStarted { command_count: 1 }); - emit(&mut ui, Event::SetupCommandCompleted { - command: "bun install".into(), - index: 0, - exit_code: 0, - duration_ms: 2200, - }); - emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 }); - emit(&mut ui, stage_completed("code", "Code")); - - insta::assert_snapshot!(rendered(&buffer), @r#" - → code → review "ship" - ↻ Code: retrying (attempt 2/3, delay 1s) - ⚠ context window: 92% used - ⚠ retry: gpt-5-mini attempt 2 (busy, delay 1s) - ▸ subagent[a1] "review recent changes" - ✓ subagent[a1] (3 turns) - ↻ subagent[a1] turn 2 "fix the review findings" - ✓ subagent[a1] (2 turns) - ✓ [1/1] bun install 2s - Setup: 1 command (2s) - ✓ Code $0.01 5s (1 turns, 0 tools, 1.5k toks) - "#); - } - - #[test] - fn plain_notice_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::RunNotice { - level: RunNoticeLevel::Warn, - code: RunNoticeCode::SandboxCleanupFailed.to_string(), - message: "sandbox cleanup failed".into(), - exec_output_tail: None, - }); - emit(&mut ui, Event::PullRequestCreated { - pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(), - pr_number: 42, - owner: "fabro-sh".into(), - repo: "fabro".into(), - base_branch: "main".into(), - head_branch: "fabro/run/42".into(), - head_sha: Some("final-sha".to_string()), - title: "Ship the change".into(), - draft: true, - }); - emit(&mut ui, Event::PullRequestFailed { - creation_id: None, - error: "auth token expired".into(), - }); - - insta::assert_snapshot!(rendered(&buffer), @r" - Warning: sandbox cleanup failed [sandbox_cleanup_failed] - Draft PR: https://github.com/fabro-sh/fabro/pull/42 - PR failed: auth token expired - "); - } - - #[test] - fn plain_metadata_snapshot_snapshot() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::MetadataSnapshotCompleted { - phase: MetadataSnapshotPhase::Checkpoint, - branch: "fabro/meta".into(), - duration_ms: 2000, - entry_count: 2, - bytes: 42, - commit_sha: "abc123".into(), - }); - emit(&mut ui, Event::MetadataSnapshotFailed { - phase: MetadataSnapshotPhase::Finalize, - branch: "fabro/meta".into(), - duration_ms: 900, - failure_kind: MetadataSnapshotFailureKind::Push, - error: "push rejected".into(), - causes: Vec::new(), - commit_sha: Some("abc123".into()), - entry_count: Some(2), - bytes: Some(42), - exec_output_tail: None, - }); - - insta::assert_snapshot!(rendered(&buffer), @"Warning: Metadata finalize failed: push rejected [push]"); - } - - #[test] - fn metadata_snapshot_failure_suppresses_compat_notice_only() { - let (mut ui, buffer) = capture_ui(false); - - emit(&mut ui, Event::MetadataSnapshotFailed { - phase: MetadataSnapshotPhase::Checkpoint, - branch: "fabro/meta".into(), - duration_ms: 900, - failure_kind: MetadataSnapshotFailureKind::Write, - error: "write failed".into(), - causes: Vec::new(), - commit_sha: None, - entry_count: None, - bytes: None, - exec_output_tail: None, - }); - emit(&mut ui, Event::RunNotice { - level: RunNoticeLevel::Warn, - code: RunNoticeCode::CheckpointMetadataWriteFailed.to_string(), - message: "legacy metadata warning".into(), - exec_output_tail: None, - }); - emit(&mut ui, Event::RunNotice { - level: RunNoticeLevel::Warn, - code: RunNoticeCode::CheckpointMetadataDegraded.to_string(), - message: "metadata snapshots are disabled for this run".into(), - exec_output_tail: None, - }); - - insta::assert_snapshot!(rendered(&buffer), @r" - Warning: Metadata checkpoint failed: write failed [write] - Warning: metadata snapshots are disabled for this run [checkpoint_metadata_degraded] - "); - } - - #[test] - fn tty_parallel_branch_completion_uses_recorded_duration() { - let mut ui = ProgressUI::new(true, false); - - emit(&mut ui, stage_started("fork1", "Fork")); - emit(&mut ui, Event::ParallelStarted { - node_id: "fork1".into(), - visit: 1, - branch_count: 1, - }); - emit(&mut ui, Event::ParallelBranchStarted { - graph_visit: None, - resumed_from_stage_id: None, - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - item_label: None, - }); - emit(&mut ui, Event::ParallelBranchCompleted { - parallel_group_id: StageId::new("fork1", 1), - parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), - branch: "security".into(), - index: 0, - item_label: None, - duration_ms: 500, - status: fabro_workflow::outcome::StageOutcome::Succeeded, - }); - - let stage = &ui.stage.active_stages["fork1"]; - assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms"); - } - - #[test] - fn tty_tool_call_completion_uses_jsonl_timestamps() { - let mut ui = ProgressUI::new(true, false); - - let started_ts = DateTime::parse_from_rfc3339("2026-03-30T12:00:00.000Z") - .unwrap() - .with_timezone(&Utc); - let completed_ts = DateTime::parse_from_rfc3339("2026-03-30T12:00:00.500Z") - .unwrap() - .with_timezone(&Utc); - - let stage_started = serde_json::to_string(&to_run_event_at( - &fixtures::RUN_1, - &Event::StageStarted { - graph_visit: None, - resumed_from_stage_id: None, - node_id: "code".into(), - name: "Code".into(), - index: 0, - handler_type: "agent".into(), - attempt: 1, - max_attempts: 1, - }, - started_ts, - None, - )) - .unwrap(); - let tool_started = serde_json::to_string(&to_run_event_at( - &fixtures::RUN_1, - &agent_event("code", CodingEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }), - started_ts, - None, - )) - .unwrap(); - let tool_completed = serde_json::to_string(&to_run_event_at( - &fixtures::RUN_1, - &agent_event("code", CodingEvent::ToolCallCompleted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - output: serde_json::json!({"ok": true}), - metadata: pebble_agent::ToolOutputMetadata::default(), - error_kind: None, - is_error: false, - output_bytes_observed: 11, - output_bytes_retained: 11, - output_bytes_omitted: 0, - }), - completed_ts, - None, - )) - .unwrap(); - - ui.handle_json_line(&stage_started); - ui.handle_json_line(&tool_started); - ui.handle_json_line(&tool_completed); - - let stage = &ui.stage.active_stages["code"]; - assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms"); - } -} diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/renderer.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/renderer.rs index 516e3a854..4e4bc6654 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/renderer.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/renderer.rs @@ -55,13 +55,6 @@ impl ProgressRenderer { } } - pub(super) fn insert_before(&self, before: &ProgressBar) -> ProgressBar { - match &self.inner { - RendererInner::Tty { multi } => multi.insert_before(before, ProgressBar::new_spinner()), - RendererInner::Plain { .. } => ProgressBar::hidden(), - } - } - pub(super) fn print_line(&self, indent: usize, message: &str) { if let RendererInner::Plain { out } = &self.inner { let mut out = out.lock().expect("plain renderer lock poisoned"); diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/setup_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/setup_display.rs deleted file mode 100644 index 03000bbe4..000000000 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/setup_display.rs +++ /dev/null @@ -1,299 +0,0 @@ -use std::time::Duration; - -use indicatif::ProgressBar; - -use super::renderer::ProgressRenderer; -use super::styles; -use crate::shared::format_duration_ms; - -pub(super) struct SetupDisplay { - verbose: bool, - current_provider: Option, - pub(super) sandbox_bar: Option, - pub(super) setup_bar: Option, - pub(super) setup_command_count: u64, - pub(super) cli_ensure_bar: Option, -} - -impl SetupDisplay { - pub(super) fn new(verbose: bool) -> Self { - Self { - verbose, - current_provider: None, - sandbox_bar: None, - setup_bar: None, - setup_command_count: 0, - cli_ensure_bar: None, - } - } - - pub(super) fn finish(&mut self) { - if let Some(bar) = self.sandbox_bar.take() { - bar.finish_and_clear(); - } - if let Some(bar) = self.setup_bar.take() { - bar.finish_and_clear(); - } - if let Some(bar) = self.cli_ensure_bar.take() { - bar.finish_and_clear(); - } - } - - pub(super) fn on_sandbox_initializing(&mut self, renderer: &ProgressRenderer, provider: &str) { - self.current_provider = Some(provider.to_string()); - if renderer.is_tty() { - let bar = renderer.add_spinner(); - bar.set_style(styles::style_header_running()); - bar.set_message(initializing_message(provider)); - bar.enable_steady_tick(Duration::from_millis(100)); - self.sandbox_bar = Some(bar); - } - } - - pub(super) fn on_sandbox_ready( - &mut self, - renderer: &ProgressRenderer, - provider: &str, - duration_ms: u64, - name: Option<&str>, - url: Option<&str>, - ) { - let dur = format_duration_ms(duration_ms); - let detail = name.map(str::to_string); - - if renderer.is_tty() { - let display_provider = match url { - Some(url) => styles::terminal_hyperlink(url, provider), - None => provider.to_string(), - }; - - if let Some(bar) = self.sandbox_bar.take() { - bar.set_style(styles::style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!("Sandbox: {display_provider}")); - if let Some(detail) = detail { - let detail_bar = renderer.insert_after(&bar); - detail_bar.set_style(styles::style_sandbox_detail()); - detail_bar.finish_with_message(detail); - } - } - } else { - renderer.print_line(4, &format!("Sandbox: {provider} (ready in {dur})")); - if let Some(detail) = detail { - renderer.print_line(13, &detail); - } - } - } - - pub(super) fn on_sandbox_failed( - &mut self, - renderer: &ProgressRenderer, - provider: &str, - error: &str, - ) { - if renderer.is_tty() { - let bar = self - .sandbox_bar - .take() - .unwrap_or_else(|| renderer.add_spinner()); - bar.set_style(styles::style_header_failed()); - bar.finish_with_message(format!("Sandbox: {provider} failed: {error}")); - } else { - renderer.print_line(4, &format!("Sandbox: {provider} failed: {error}")); - } - } - - pub(super) fn on_snapshot_pulling(&self, renderer: &ProgressRenderer, name: &str) { - if renderer.is_tty() { - if let Some(bar) = self.sandbox_bar.as_ref() { - bar.set_message(format!("Pulling {name}...")); - } - } else { - renderer.print_line(4, &format!("Sandbox: pulling {name}...")); - } - } - - pub(super) fn on_snapshot_creating(&self, renderer: &ProgressRenderer, name: &str) { - if renderer.is_tty() { - if let Some(bar) = self.sandbox_bar.as_ref() { - bar.set_message(format!("Building {name}...")); - } - } else { - renderer.print_line(4, &format!("Sandbox: building {name}...")); - } - } - - pub(super) fn on_snapshot_ready( - &self, - renderer: &ProgressRenderer, - _name: &str, - _duration_ms: u64, - ) { - if renderer.is_tty() { - if let Some(bar) = self.sandbox_bar.as_ref() { - let provider = self.current_provider.as_deref().unwrap_or("sandbox"); - bar.set_style(styles::style_header_running()); - bar.set_message(initializing_message(provider)); - } - } - } - - pub(super) fn on_snapshot_failed(&self, renderer: &ProgressRenderer, name: &str, error: &str) { - let message = format!("Snapshot {name} failed: {error}"); - if renderer.is_tty() { - if let Some(bar) = self.sandbox_bar.as_ref() { - bar.set_message(message); - } - } else { - renderer.print_line(4, &format!("Sandbox: {message}")); - } - } - - pub(super) fn on_ssh_access_ready(renderer: &ProgressRenderer, ssh_command: &str) { - if renderer.is_tty() { - let bar = renderer.add_spinner(); - bar.set_style(styles::style_sandbox_detail()); - bar.finish_with_message(ssh_command.to_string()); - } else { - renderer.print_line(13, ssh_command); - } - } - - pub(super) fn on_setup_started(&mut self, renderer: &ProgressRenderer, command_count: u64) { - self.setup_command_count = command_count; - if renderer.is_tty() { - let bar = renderer.add_spinner(); - bar.set_style(styles::style_header_running()); - bar.set_message(format!( - "Setup: {command_count} command{}...", - if command_count == 1 { "" } else { "s" } - )); - bar.enable_steady_tick(Duration::from_millis(100)); - self.setup_bar = Some(bar); - } - } - - pub(super) fn on_setup_completed(&mut self, renderer: &ProgressRenderer, duration_ms: u64) { - let dur = format_duration_ms(duration_ms); - let suffix = if self.setup_command_count == 1 { - "" - } else { - "s" - }; - - if renderer.is_tty() { - if let Some(bar) = self.setup_bar.take() { - bar.set_style(styles::style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!( - "Setup: {} command{suffix}", - self.setup_command_count - )); - } - } else { - renderer.print_line( - 4, - &format!( - "Setup: {} command{suffix} ({dur})", - self.setup_command_count - ), - ); - } - } - - pub(super) fn on_setup_command_completed( - &self, - renderer: &ProgressRenderer, - command: &str, - command_index: u64, - exit_code: i64, - duration_ms: u64, - ) { - if !self.verbose { - return; - } - - let glyph = if exit_code == 0 { - styles::green_check(renderer.styles()) - } else { - styles::red_cross(renderer.styles()) - }; - let msg = format!( - "{glyph} [{}/{}] {}", - command_index + 1, - self.setup_command_count, - styles::truncate(command, 60) - ); - let dur = format_duration_ms(duration_ms); - - if renderer.is_tty() { - let bar = match &self.setup_bar { - Some(setup_bar) => renderer.insert_before(setup_bar), - None => renderer.add_spinner(), - }; - bar.set_style(styles::style_tool_done()); - bar.set_prefix(dur); - bar.finish_with_message(msg); - } else { - renderer.print_line(6, &format!("{msg} {dur}")); - } - } - - pub(super) fn on_cli_ensure_started(&mut self, renderer: &ProgressRenderer, cli_name: &str) { - if renderer.is_tty() { - let bar = renderer.add_spinner(); - bar.set_style(styles::style_header_running()); - bar.set_message(format!("CLI: ensuring {cli_name}...")); - bar.enable_steady_tick(Duration::from_millis(100)); - self.cli_ensure_bar = Some(bar); - } - } - - pub(super) fn on_cli_ensure_completed( - &mut self, - renderer: &ProgressRenderer, - cli_name: &str, - already_installed: bool, - duration_ms: u64, - ) { - let status = if already_installed { - "found" - } else { - "installed" - }; - let dur = format_duration_ms(duration_ms); - - if renderer.is_tty() { - if let Some(bar) = self.cli_ensure_bar.take() { - bar.set_style(styles::style_header_done()); - bar.set_prefix(dur); - bar.finish_with_message(format!("CLI: {cli_name} ({status})")); - } - } else { - renderer.print_line(4, &format!("CLI: {cli_name} ({status}, {dur})")); - } - } - - pub(super) fn on_cli_ensure_failed(&mut self, renderer: &ProgressRenderer, cli_name: &str) { - let message = format!( - "{} CLI: {cli_name} install failed", - styles::red_cross(renderer.styles()) - ); - if renderer.is_tty() { - if let Some(bar) = self.cli_ensure_bar.take() { - bar.set_style(styles::style_header_done()); - bar.finish_with_message(message); - } - } else { - renderer.print_line(4, &message); - } - } -} - -fn initializing_message(provider: &str) -> String { - if provider == "sandbox" { - "Initializing sandbox...".to_string() - } else { - format!("Initializing {provider} sandbox...") - } -} diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs index cf34bb473..2a5c863ac 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/stage_display.rs @@ -72,10 +72,6 @@ impl StageDisplay { } } - pub(super) fn set_working_directory(&mut self, dir: String) { - self.working_directory = Some(dir); - } - pub(super) fn finish(&mut self) { for (_node_id, stage) in self.active_stages.drain() { if let Some(bar) = stage.compaction_bar { @@ -819,24 +815,3 @@ fn set_duration_prefix(bar: &ProgressBar, duration_ms: Option) { ); bar.set_prefix(prefix); } - -#[cfg(test)] -mod tests { - use super::*; - use crate::commands::run::run_progress::renderer::ProgressRenderer; - - #[test] - fn tool_display_name_shortens_paths_relative_to_working_directory() { - let renderer = ProgressRenderer::new_plain(Box::new(std::io::sink()), false); - let mut stage = StageDisplay::new(false); - stage.set_working_directory("/workspace".into()); - - let display_name = stage.tool_display_name( - &renderer, - "read_file", - &serde_json::json!({"file_path": "/workspace/src/main.rs"}), - ); - - assert_eq!(display_name, "read_file(src/main.rs)"); - } -} diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/styles.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/styles.rs index d0f379f0f..27f389170 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/styles.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/styles.rs @@ -19,12 +19,6 @@ macro_rules! cached_style { }; } -cached_style!( - style_header_running, - " {spinner:.dim} {wide_msg} {elapsed:.dim}" -); -cached_style!(style_header_done, " {wide_msg:.dim} {prefix:.dim}"); -cached_style!(style_header_failed, " {wide_msg:.red} {prefix:.dim}"); cached_style!( style_stage_running, " {spinner:.cyan} {wide_msg} {elapsed:.dim}" @@ -38,7 +32,6 @@ cached_style!(style_tool_done, " {wide_msg} {prefix:.dim}"); cached_style!(style_subagent_info, " {wide_msg}"); cached_style!(style_branch_done, " {wide_msg} {prefix:.dim}"); cached_style!(style_static_dim, " {wide_msg:.dim}"); -cached_style!(style_sandbox_detail, " {wide_msg:.dim}"); cached_style!(style_empty, " "); pub(super) fn green_check(styles: &Styles) -> String { @@ -64,10 +57,6 @@ pub(crate) fn format_duration_short(d: Duration) -> String { } } -pub(super) fn terminal_hyperlink(url: &str, text: &str) -> String { - format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") -} - pub(super) fn truncate(s: &str, max: usize) -> String { let single_line = s.split_whitespace().collect::>().join(" "); if single_line.len() > max { diff --git a/lib/apps/fabro-cli/src/commands/system/events.rs b/lib/apps/fabro-cli/src/commands/system/events.rs index 8b9a30050..98527b69e 100644 --- a/lib/apps/fabro-cli/src/commands/system/events.rs +++ b/lib/apps/fabro-cli/src/commands/system/events.rs @@ -1,5 +1,6 @@ use anyhow::Result; use fabro_client::sse; +use fabro_types::RunStreamItem; use futures::StreamExt; use crate::args::SystemEventsArgs; @@ -42,31 +43,22 @@ fn render_sse_payload(data: &str, json_output: bool) -> Result<()> { return Ok(()); } - let value: serde_json::Value = serde_json::from_str(data)?; - let payload = value - .get("payload") - .and_then(serde_json::Value::as_object) - .cloned() - .unwrap_or_default(); - let ts = payload - .get("ts") - .and_then(serde_json::Value::as_str) - .unwrap_or("-"); - let run_id = payload - .get("run_id") - .and_then(serde_json::Value::as_str) - .unwrap_or("-"); - let event = payload - .get("event") - .and_then(serde_json::Value::as_str) - .unwrap_or("-"); + // Each frame is one `RunStreamItem`: the run, when its record was + // appended, and the event's name. + let item: RunStreamItem = serde_json::from_str(data)?; + let recorded_at = i64::try_from(item.recorded_at) + .ok() + .and_then(chrono::DateTime::::from_timestamp_millis) + .map_or_else(|| "-".to_string(), |at| at.to_rfc3339()); + let run_id = item.run_id.to_string(); + let event = item.name().unwrap_or("-"); #[allow( clippy::print_stdout, reason = "Rendered event lines belong on stdout for piping." )] { - println!("{ts} {} {event}", short_run_id(run_id)); + println!("{recorded_at} {} {event}", short_run_id(&run_id)); } Ok(()) } diff --git a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs index 98d57079e..650cf3e19 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs @@ -2109,15 +2109,7 @@ async fn mcp_events_filters_find_matches_beyond_first_page() { "goal": format!("ordinary event {sequence}") }) }; - serde_json::json!({ - "seq": sequence, - "id": format!("evt-{sequence}"), - "ts": "2026-04-05T12:00:00Z", - "run_id": run_id, - "event": event_name, - "properties": properties, - "actor": null - }) + stream_item(&run_id, sequence, event_name, &properties) }) .collect::>(); let first_event = events[0].clone(); @@ -2127,21 +2119,16 @@ async fn mcp_events_filters_find_matches_beyond_first_page() { .query_param("limit", "1"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": [first_event], - "meta": { "has_more": true } - })); + .json_body(stream_page(&[first_event], true)); }); let list_events = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/events")) + .query_param("after", "0") .query_param_missing("limit"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": events, - "meta": { "has_more": false } - })); + .json_body(stream_page(&events, false)); }); let client = spawn_mcp_client(&context, &["--server", &target_url]).await; @@ -2203,31 +2190,36 @@ async fn mcp_events_decodes_run_created_with_model_keyed_fallbacks() { "speed": null } }); + // The platform record of the run's creation, as the stream carries it. let event = serde_json::json!({ - "seq": 1, - "id": "evt-created", - "ts": "2026-04-05T12:00:00Z", "run_id": run_id, - "event": "run.created", - "properties": { - "settings": settings, - "graph": Graph::new("Remote Workflow"), - "labels": {}, - "source_directory": "/srv/repo", - "provenance": test_support::test_run_provenance() - }, - "actor": null + "stream_seq": 1, + "kind": "platform", + "id": "1", + "recorded_at": 1_775_390_400_000_u64, + "item": { + "seq": 1, + "recorded_at": 1_775_390_400_000_u64, + "record": { + "kind": "run.created", + "spec": { + "settings": settings, + "graph": Graph::new("Remote Workflow"), + "labels": {}, + "source_directory": "/srv/repo", + "provenance": test_support::test_run_provenance() + } + } + } }); let events = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/events")) + .query_param("after", "0") .query_param_missing("limit"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": [event], - "meta": { "has_more": false } - })); + .json_body(stream_page(&[event], false)); }); let client = spawn_mcp_client(&context, &["--server", &target_url]).await; @@ -2244,8 +2236,8 @@ async fn mcp_events_decodes_run_created_with_model_keyed_fallbacks() { .await; assert_eq!( - result["events"][0]["event"]["properties"]["settings"]["run"]["model"]["fallbacks"]["gpt-5.6-sol"] - [0], + result["events"][0]["event"]["item"]["record"]["spec"]["settings"]["run"]["model"]["fallbacks"] + ["gpt-5.6-sol"][0], "gpt-5.6-terra" ); resolve.assert(); @@ -2305,80 +2297,42 @@ async fn mcp_events_desc_after_offset_and_limit_page_over_requested_order() { let resolve = mock_resolved_run(&server, "nightly", &run_id); let events = (1..=5) .map(|sequence| { - serde_json::json!({ - "seq": sequence, - "id": format!("evt-{sequence}"), - "ts": format!("2026-04-05T12:00:0{sequence}Z"), - "run_id": run_id, - "event": "run.started", - "properties": { "name": format!("event {sequence}") }, - "actor": null - }) + stream_item( + &run_id, + sequence, + "run.started", + &serde_json::json!({ "name": format!("event {sequence}") }), + ) }) .collect::>(); let first_event = events[0].clone(); + let after_two = events[2..].to_vec(); let limited_events = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/events")) .query_param("limit", "1"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": [first_event], - "meta": { "has_more": true } - })); + .json_body(stream_page(&[first_event], true)); }); let full_events = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/events")) - .query_param_missing("limit") - .query_param_missing("since_seq"); + .query_param("after", "0") + .query_param_missing("limit"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": events, - "meta": { "has_more": false } - })); + .json_body(stream_page(&events, false)); }); + // `after` is exclusive: the page after item 2 starts at item 3. let after_events = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/events")) - .query_param("since_seq", "2") + .query_param("after", "2") .query_param("limit", "3"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": [ - { - "seq": 2, - "id": "evt-2", - "ts": "2026-04-05T12:00:02Z", - "run_id": run_id, - "event": "run.started", - "properties": { "name": "event 2" }, - "actor": null - }, - { - "seq": 3, - "id": "evt-3", - "ts": "2026-04-05T12:00:03Z", - "run_id": run_id, - "event": "run.started", - "properties": { "name": "event 3" }, - "actor": null - }, - { - "seq": 4, - "id": "evt-4", - "ts": "2026-04-05T12:00:04Z", - "run_id": run_id, - "event": "run.started", - "properties": { "name": "event 4" }, - "actor": null - } - ], - "meta": { "has_more": true } - })); + .json_body(stream_page(&after_two, false)); }); let client = spawn_mcp_client(&context, &["--server", &target_url]).await; @@ -2408,8 +2362,8 @@ async fn mcp_events_desc_after_offset_and_limit_page_over_requested_order() { assert_eq!(desc["events"][0]["event_id"], "evt-5"); assert_eq!(desc["next_cursor"], 5); - assert_eq!(paged["events"][0]["event_id"], "evt-3"); - assert_eq!(paged["events"][1]["event_id"], "evt-4"); + assert_eq!(paged["events"][0]["event_id"], "evt-4"); + assert_eq!(paged["events"][1]["event_id"], "evt-5"); assert_eq!(paged["next_cursor"], 5); resolve.assert_calls(2); limited_events.assert_calls(0); @@ -2432,28 +2386,22 @@ async fn mcp_events_desc_cursor_continues_to_older_events() { let resolve = mock_resolved_run(&server, "nightly", &run_id); let events = (1..=5) .map(|sequence| { - serde_json::json!({ - "seq": sequence, - "id": format!("evt-{sequence}"), - "ts": format!("2026-04-05T12:00:0{sequence}Z"), - "run_id": run_id, - "event": "run.started", - "properties": { "name": format!("event {sequence}") }, - "actor": null - }) + stream_item( + &run_id, + sequence, + "run.started", + &serde_json::json!({ "name": format!("event {sequence}") }), + ) }) .collect::>(); let full_events = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/events")) - .query_param_missing("limit") - .query_param_missing("since_seq"); + .query_param("after", "0") + .query_param_missing("limit"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": events, - "meta": { "has_more": false } - })); + .json_body(stream_page(&events, false)); }); let client = spawn_mcp_client(&context, &["--server", &target_url]).await; @@ -2506,28 +2454,23 @@ async fn mcp_events_offset_beyond_fetch_cap_reaches_later_pages() { let resolve = mock_resolved_run(&server, "nightly", &run_id); let events = (1..=300) .map(|sequence| { - serde_json::json!({ - "seq": sequence, - "id": format!("evt-{sequence}"), - "ts": "2026-04-05T12:00:00Z", - "run_id": run_id, - "event": "run.started", - "properties": { "name": format!("event {sequence}") }, - "actor": null - }) + stream_item( + &run_id, + sequence, + "run.started", + &serde_json::json!({ "name": format!("event {sequence}") }), + ) }) .collect::>(); let first_251_events = events.iter().take(251).cloned().collect::>(); let bounded_events = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/events")) + .query_param("after", "0") .query_param("limit", "251"); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "data": first_251_events, - "meta": { "has_more": true } - })); + .json_body(stream_page(&first_251_events, true)); }); let client = spawn_mcp_client(&context, &["--server", &target_url]).await; @@ -2544,7 +2487,7 @@ async fn mcp_events_offset_beyond_fetch_cap_reaches_later_pages() { .await; assert_eq!(paged["events"][0]["event_id"], "evt-251"); - assert_eq!(paged["next_cursor"], 252); + assert_eq!(paged["next_cursor"], 251); resolve.assert(); bounded_events.assert(); client @@ -2933,6 +2876,37 @@ fn run_id_with_timestamp(timestamp: &str, sequence: u128) -> String { RunId::with_timestamp(timestamp, sequence).to_string() } +/// One Petri item of a run's stream, as `GET /runs/{id}/events` pages it: +/// the recorded body's `event` names it. +fn stream_item( + run_id: &str, + stream_seq: u64, + event_name: &str, + properties: &serde_json::Value, +) -> serde_json::Value { + let mut body = serde_json::json!({ "event": event_name }); + if let (Some(body), Some(properties)) = (body.as_object_mut(), properties.as_object()) { + body.extend(properties.clone()); + } + serde_json::json!({ + "run_id": run_id, + "stream_seq": stream_seq, + "kind": "petri", + "id": format!("evt-{stream_seq}"), + "recorded_at": 1_775_390_400_000_u64 + stream_seq, + "item": { "record": { "body": body } } + }) +} + +/// One page of a run's stream. +fn stream_page(items: &[serde_json::Value], has_more: bool) -> serde_json::Value { + serde_json::json!({ + "data": items, + "meta": { "has_more": has_more }, + "event_contract_version": 3 + }) +} + fn mock_resolved_run_json<'a>( server: &'a MockServer, selector: &str, diff --git a/lib/apps/fabro-cli/tests/it/cmd/pr_view.rs b/lib/apps/fabro-cli/tests/it/cmd/pr_view.rs index 35a68e4d9..6baa50105 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/pr_view.rs @@ -4,8 +4,7 @@ )] use fabro_test::{fabro_snapshot, test_context}; -use fabro_types::run_event::PullRequestCreatedProps; -use fabro_types::{EventBody, RunEvent, RunId}; +use fabro_types::RunId; use httpmock::MockServer; use super::support::{mock_resolved_run, server_endpoint, setup_seeded_completed_dry_run}; @@ -65,34 +64,11 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() { runtime.block_on(async { let (client, base_url) = server_endpoint(&context.storage_dir).expect("server endpoint should exist"); - let event = RunEvent { - id: ulid::Ulid::new().to_string(), - ts: chrono::Utc::now(), - run_id, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::PullRequestCreated(PullRequestCreatedProps { - pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), - pr_number: 123, - owner: "fabro-sh".to_string(), - repo: "fabro".to_string(), - base_branch: "main".to_string(), - head_branch: "fabro/run/demo".to_string(), - head_sha: Some("final-sha".to_string()), - title: "Map the constellations".to_string(), - draft: false, - }), - }; client - .post(format!("{base_url}/api/v1/runs/{run_id}/events")) - .json(&event) + .put(format!("{base_url}/api/v1/runs/{run_id}/pull_request")) + .json(&serde_json::json!({ + "html_url": "https://github.com/fabro-sh/fabro/pull/123" + })) .send() .await .unwrap() diff --git a/lib/apps/fabro-cli/tests/it/cmd/runner.rs b/lib/apps/fabro-cli/tests/it/cmd/runner.rs index ccb4bf4c3..dfc3b11fd 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/runner.rs @@ -12,11 +12,10 @@ use std::process::{Child, ExitStatus, Output, Stdio}; use std::time::{Duration, Instant}; use fabro_client::ServerTarget; -use fabro_store::EventEnvelope; use fabro_test::{ assert_reqwest_status, expect_reqwest_json, fabro_json_snapshot, fabro_snapshot, test_context, }; -use fabro_types::{EventBody, FailureReason, RunEvent, StageId}; +use fabro_types::{FailureReason, RunStreamItem, StageId}; use httpmock::MockServer; use super::support::{ @@ -36,12 +35,24 @@ fn auth_context() -> fabro_test::TestContext { context } -fn stored_worker_events(run_dir: &std::path::Path) -> Vec { - run_events(run_dir).iter().map(run_event).collect() +fn stored_worker_events(run_dir: &std::path::Path) -> Vec { + run_events(run_dir) } -fn run_event(event: &EventEnvelope) -> RunEvent { - event.event.clone() +/// The platform record of `item`, when it carries one. +fn platform_record(item: &RunStreamItem) -> Option<&serde_json::Value> { + item.item.get("record") +} + +/// Whether `item` is the `run.lifecycle` record that moved the run to +/// `status` (`succeeded`, `failed`, ...) for `reason`. +fn is_lifecycle(item: &RunStreamItem, status: &str, reason: &str) -> bool { + let Some(record) = platform_record(item) else { + return false; + }; + record["kind"] == "run.lifecycle" + && record["status"]["kind"] == status + && record["status"]["reason"] == reason } fn assert_worker_succeeded(run_dir: &std::path::Path, stdout: &[u8]) { @@ -50,10 +61,11 @@ fn assert_worker_succeeded(run_dir: &std::path::Path, stdout: &[u8]) { "worker should not emit event transport on stdout" ); let events = stored_worker_events(run_dir); - assert!(events.iter().any(|event| matches!( - &event.body, - EventBody::RunCompleted(props) if props.status == "succeeded" - ))); + assert!( + events + .iter() + .any(|item| is_lifecycle(item, "succeeded", "completed")) + ); } fn spawn_worker_process( @@ -777,11 +789,13 @@ fn detached_run_answers_pending_question_without_interview_scratch_files() { .success(); let events = stored_worker_events(&run_dir); - assert!(events.iter().any(|event| matches!( - &event.body, - EventBody::InterviewCompleted(props) - if props.question_id == question_id && props.answer == "A" - ))); + assert!(events.iter().any(|item| { + platform_record(item).is_some_and(|record| { + record["kind"] == "interview.answered" + && record["question"] == question_id + && record["answer"] == "A" + }) + })); } #[test] @@ -836,10 +850,11 @@ fn detached_run_cancel_reaches_worker_over_control_websocket() { wait_for_status(&run_dir, &["failed"]); let events = stored_worker_events(&run_dir); - assert!(events.iter().any(|event| matches!( - &event.body, - EventBody::RunFailed(props) if props.failure.reason == FailureReason::Cancelled - ))); + assert!( + events + .iter() + .any(|item| is_lifecycle(item, "failed", "cancelled")) + ); } #[cfg(unix)] diff --git a/lib/apps/fabro-cli/tests/it/cmd/support.rs b/lib/apps/fabro-cli/tests/it/cmd/support.rs index 8826d70f0..27c4cde88 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/support.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/support.rs @@ -22,11 +22,10 @@ use fabro_client::Client; use fabro_config::bind::Bind; use fabro_config::daemon::ServerDaemon; use fabro_config::{Storage, envfile}; -use fabro_store::EventEnvelope; use fabro_test::{TestContext, expect_reqwest_status}; use fabro_types::test_support::test_principal; use fabro_types::{ - GitRunTarget, RunId, RunIntent, RunIntentArgs, RunTarget, StageId, WorkflowPath, + GitRunTarget, RunId, RunIntent, RunIntentArgs, RunStreamItem, RunTarget, StageId, WorkflowPath, WorkflowVersion, }; use httpmock::{HttpMockResponse, Mock, MockServer}; @@ -881,13 +880,13 @@ pub(crate) fn run_state(run_dir: &Path) -> RunProjection { )) } -pub(crate) fn run_events(run_dir: &Path) -> Vec { +pub(crate) fn run_events(run_dir: &Path) -> Vec { let run_id = infer_run_id(run_dir); let response: serde_json::Value = block_on(get_server_json( run_dir, - &format!("/api/v1/runs/{run_id}/events"), + &format!("/api/v1/runs/{run_id}/events?after=0&limit=1000"), )); - crate::support::parse_event_envelopes(&response) + crate::support::parse_stream_items(&response) } pub(crate) fn command_log_text(run_dir: &Path, stage_id: &StageId) -> String { @@ -912,7 +911,7 @@ pub(crate) fn wait_for_event_names(run_dir: &Path, expected: &[&str]) { loop { let event_names = run_events(run_dir) .into_iter() - .map(|event| event.event.event_name().to_string()) + .filter_map(|item| item.name().map(str::to_string)) .collect::>(); if expected diff --git a/lib/apps/fabro-cli/tests/it/cmd/system_events.rs b/lib/apps/fabro-cli/tests/it/cmd/system_events.rs index 2950aeaae..5d9a07717 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/system_events.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/system_events.rs @@ -33,11 +33,21 @@ fn system_events_renders_text_lines_from_sse_payloads() { let context = test_context!(); let server = MockServer::start(); let run_id = crate::support::unique_run_id(); + // One `RunStreamItem`: the platform record that ended the run. let payload = serde_json::json!({ - "payload": { - "ts": "2026-04-05T12:00:00Z", - "run_id": run_id, - "event": "run.completed", + "run_id": run_id, + "stream_seq": 7, + "kind": "platform", + "id": "7", + "recorded_at": 1_775_390_400_000_u64, + "item": { + "seq": 7, + "recorded_at": 1_775_390_400_000_u64, + "record": { + "kind": "run.lifecycle", + "transition": "succeeded", + "status": { "kind": "succeeded", "reason": "completed" } + } } }); let attach_mock = server.mock(|when, then| { @@ -66,7 +76,7 @@ fn system_events_renders_text_lines_from_sse_payloads() { let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); assert_eq!( stdout.trim(), - format!("2026-04-05T12:00:00Z {} run.completed", &run_id[..12]) + format!("2026-04-05T12:00:00+00:00 {} run.lifecycle", &run_id[..12]) ); attach_mock.assert(); } diff --git a/lib/apps/fabro-cli/tests/it/cmd/worker_auth.rs b/lib/apps/fabro-cli/tests/it/cmd/worker_auth.rs index 1cfcbff72..1d70e45f5 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/worker_auth.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/worker_auth.rs @@ -19,14 +19,14 @@ use std::time::{Duration, Instant}; use chrono::{Duration as ChronoDuration, Utc}; use fabro_client::{AuthEntry, AuthStore, OAuthEntry, ServerTarget, StoredSubject}; use fabro_config::{Storage, envfile}; -use fabro_store::EventEnvelope; use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context}; +use fabro_types::RunStreamItem; use fabro_vault::{SecretType, Vault}; use super::support::{created_run_id, find_run_dir, output_stderr}; use crate::support::{ - TEST_SESSION_SECRET, issue_test_github_jwt, issue_test_worker_jwt, parse_event_envelopes, - unique_run_id, + TEST_SESSION_SECRET, is_terminal_lifecycle, issue_test_github_jwt, issue_test_worker_jwt, + parse_stream_items, unique_run_id, }; const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); @@ -249,9 +249,11 @@ async fn wait_for_http_ready(base_url: &str, child: &mut Child) { } } -async fn run_events(api_base_url: &str, run_id: &str, access_token: &str) -> Vec { +async fn run_events(api_base_url: &str, run_id: &str, access_token: &str) -> Vec { let response = fabro_test::test_http_client() - .get(format!("{api_base_url}/api/v1/runs/{run_id}/events")) + .get(format!( + "{api_base_url}/api/v1/runs/{run_id}/events?after=0&limit=1000" + )) .bearer_auth(access_token) .send() .await @@ -262,21 +264,18 @@ async fn run_events(api_base_url: &str, run_id: &str, access_token: &str) -> Vec format!("GET /api/v1/runs/{run_id}/events"), ) .await; - parse_event_envelopes(&body) + parse_stream_items(&body) } async fn wait_for_completed_events( api_base_url: &str, run_id: &str, access_token: &str, -) -> Vec { +) -> Vec { let deadline = Instant::now() + COMMAND_TIMEOUT; loop { let events = run_events(api_base_url, run_id, access_token).await; - if events - .iter() - .any(|event| event.event.event_name() == "run.completed") - { + if events.iter().any(is_terminal_lifecycle) { return events; } assert!( @@ -325,13 +324,14 @@ async fn github_only_server_dispatched_worker_succeeds_without_worker_auth_store let _run_dir = wait_for_run_dir(&server.storage_dir, &run_id); let events = wait_for_completed_events(&server.api_base_url, &run_id, &access_token).await; - assert!(events.iter().any(|event| { - matches!( - event.event.actor.as_ref(), - Some(fabro_api::types::Principal::Worker { run_id: actor_run_id }) - if actor_run_id.to_string() == run_id - ) - })); + // The worker wrote the run's Petri records over its own token: the + // stream holds them beside the platform records. + assert!( + events + .iter() + .any(|item| item.kind == fabro_types::RunStreamItemKind::Petri), + "the worker's records reached the store: {events:#?}" + ); assert!(!server.worker_home.join("auth.json").exists()); assert!(!server.worker_home.join("auth.lock").exists()); diff --git a/lib/apps/fabro-cli/tests/it/scenario/smoke.rs b/lib/apps/fabro-cli/tests/it/scenario/smoke.rs index aac6eb33f..b622e2616 100644 --- a/lib/apps/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/apps/fabro-cli/tests/it/scenario/smoke.rs @@ -13,21 +13,34 @@ fn live_run_state_response(run_id: &str) -> serde_json::Value { ) } -fn run_sse_body(run_id: &str) -> String { - let completed = serde_json::json!({ - "seq": 2, - "event": "run.completed", - "id": "evt-run-completed", +/// The platform record that moves the run to `status` (`running`, +/// `succeeded`, ...), as one item of the run's stream. +fn lifecycle_item( + run_id: &str, + stream_seq: u64, + transition: &str, + status: &str, +) -> serde_json::Value { + serde_json::json!({ "run_id": run_id, - "ts": "2026-04-05T12:00:01Z", - "properties": { - "timing": {"wall_time_ms": 12, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - "artifact_count": 0, - "status": "succeeded", - "reason": "completed" + "stream_seq": stream_seq, + "kind": "platform", + "id": stream_seq.to_string(), + "recorded_at": 1_775_390_400_000_u64 + stream_seq, + "item": { + "seq": stream_seq, + "recorded_at": 1_775_390_400_000_u64 + stream_seq, + "record": { + "kind": "run.lifecycle", + "transition": transition, + "status": { "kind": status, "reason": "completed" } + } } - }); + }) +} +fn run_sse_body(run_id: &str) -> String { + let completed = lifecycle_item(run_id, 2, "succeeded", "succeeded"); format!("data: {completed}\n\n") } @@ -241,20 +254,15 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { }); success_server.mock(|when, then| { when.method("GET") - .path(format!("/api/v1/runs/{success_run_id}/events")); + .path(format!("/api/v1/runs/{success_run_id}/events")) + .query_param("after", "0"); then.status(200) .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [{ - "seq": 1, - "event": "run.running", - "id": "evt-run-running", - "run_id": success_run_id, - "ts": "2026-04-05T12:00:00Z", - "properties": {} - }], - "meta": { "has_more": false } + "data": [lifecycle_item(&success_run_id, 1, "running", "running")], + "meta": { "has_more": false }, + "event_contract_version": 3 }) .to_string(), ); @@ -278,7 +286,7 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { let attach_mock = success_server.mock(|when, then| { when.method("GET") .path(format!("/api/v1/runs/{success_run_id}/attach")) - .query_param("since_seq", "2"); + .query_param("after", "1"); then.status(200) .header("Content-Type", "text/event-stream") .body(run_sse_body(success_run_id.as_str())); @@ -301,92 +309,7 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { attach_mock.assert(); let success_stdout = String::from_utf8(success_output.stdout).expect("stdout should be UTF-8"); assert!( - success_stdout.contains("\"event\":\"run.completed\""), + success_stdout.contains("\"transition\":\"succeeded\""), "{success_stdout}" ); - - let eof_server = MockServer::start(); - let eof_run_id = unique_run_id(); - - eof_server.mock(|when, then| { - when.method("GET") - .path("/api/v1/runs/resolve") - .query_param("selector", eof_run_id.as_str()); - then.status(200) - .header("Content-Type", "application/json") - .body( - remote_run_summary_json( - &eof_run_id, - "Remote Workflow", - "remote-workflow", - "Remote output", - &serde_json::json!({ - "kind": "running" - }), - "2026-04-05T12:00:00Z", - ) - .to_string(), - ); - }); - eof_server.mock(|when, then| { - when.method("GET") - .path(format!("/api/v1/runs/{eof_run_id}/events")); - then.status(200) - .header("Content-Type", "application/json") - .body( - serde_json::json!({ - "data": [{ - "seq": 1, - "event": "run.running", - "id": "evt-run-running", - "run_id": eof_run_id, - "ts": "2026-04-05T12:00:00Z", - "properties": {} - }], - "meta": { "has_more": false } - }) - .to_string(), - ); - }); - eof_server.mock(|when, then| { - when.method("GET") - .path(format!("/api/v1/runs/{eof_run_id}/state")); - then.status(200) - .header("Content-Type", "application/json") - .body(live_run_state_response(eof_run_id.as_str()).to_string()); - }); - eof_server.mock(|when, then| { - when.method("GET") - .path(format!("/api/v1/runs/{eof_run_id}/questions")) - .query_param("page[limit]", "100") - .query_param("page[offset]", "0"); - then.status(200) - .header("Content-Type", "application/json") - .body(r#"{"data":[],"meta":{"has_more":false}}"#); - }); - eof_server.mock(|when, then| { - when.method("GET") - .path(format!("/api/v1/runs/{eof_run_id}/attach")) - .query_param("since_seq", "2"); - then.status(200) - .header("Content-Type", "text/event-stream") - .body(""); - }); - context.set_http_target(&eof_server.base_url()); - - let eof_output = context - .command() - .args(["attach", &eof_run_id]) - .output() - .expect("attach should execute"); - - assert!( - !eof_output.status.success(), - "attach should fail on premature EOF" - ); - let eof_stderr = String::from_utf8(eof_output.stderr).expect("stderr should be UTF-8"); - assert!( - eof_stderr.contains("terminal run event"), - "expected a protocol error, got:\n{eof_stderr}" - ); } diff --git a/lib/apps/fabro-cli/tests/it/support/mod.rs b/lib/apps/fabro-cli/tests/it/support/mod.rs index 663dc17c1..df937ad5b 100644 --- a/lib/apps/fabro-cli/tests/it/support/mod.rs +++ b/lib/apps/fabro-cli/tests/it/support/mod.rs @@ -9,9 +9,8 @@ pub(crate) use auth_harness::{ no_redirect_browser_client, run_detached, saved_auth_entry, seed_dev_token_auth, }; pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt, issue_test_worker_jwt}; -use fabro_store::EventEnvelope; use fabro_test::{EnvVars, TestContext, preserve_coverage_env}; -use fabro_types::{Graph, RunId, RunSpec, WorkflowSettings}; +use fabro_types::{Graph, RunId, RunSpec, RunStreamItem, WorkflowSettings}; pub(crate) use mcp_client::McpStdioTestClient; pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> { @@ -79,15 +78,31 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s }) } -pub(crate) fn parse_event_envelopes(response: &serde_json::Value) -> Vec { +/// Whether the item is the platform record that ends a run: a +/// `run.lifecycle` record whose transition is terminal. +pub(crate) fn is_terminal_lifecycle(item: &RunStreamItem) -> bool { + let record = item.item.get("record"); + record + .and_then(|record| record.get("kind")) + .and_then(serde_json::Value::as_str) + == Some("run.lifecycle") + && matches!( + record + .and_then(|record| record.get("transition")) + .and_then(serde_json::Value::as_str), + Some("succeeded" | "failed" | "dead") + ) +} + +pub(crate) fn parse_stream_items(response: &serde_json::Value) -> Vec { response["data"] .as_array() - .expect("event list response should contain a data array") + .expect("run stream page should contain a data array") .iter() .cloned() .map(serde_json::from_value) .collect::, _>>() - .expect("wire event envelope list should parse") + .expect("run stream items should parse") } pub(crate) struct LightweightCli { diff --git a/lib/apps/fabro-cli/tests/it/workflow/mod.rs b/lib/apps/fabro-cli/tests/it/workflow/mod.rs index c494defbd..0b716a7ad 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/mod.rs @@ -18,8 +18,8 @@ pub(super) mod plugin; use std::path::{Path, PathBuf}; use std::time::Duration; -use fabro_store::EventEnvelope; use fabro_test::{TestContext, expect_reqwest_status}; +use fabro_types::RunStreamItem; use serde_json::Value; use crate::cmd::support::{RunProjection, server_endpoint}; @@ -54,7 +54,7 @@ pub(super) fn completed_nodes(run_dir: &Path) -> Vec { pub(super) fn has_event(run_dir: &Path, event_name: &str) -> bool { run_events(run_dir) .into_iter() - .any(|event| event.event.event_name() == event_name) + .any(|item| item.name() == Some(event_name)) } pub(super) fn dump_export(context: &TestContext, run_id: &str) -> PathBuf { @@ -156,15 +156,15 @@ fn run_state(run_dir: &Path) -> RunProjection { )) } -fn run_events(run_dir: &Path) -> Vec { +fn run_events(run_dir: &Path) -> Vec { let run_id = infer_run_id(run_dir); let runs_dir = run_dir.parent().expect("run dir should have parent"); let storage_dir = runs_dir.parent().expect("runs dir should have parent"); let response: serde_json::Value = block_on(get_server_json_for_storage( storage_dir, - &format!("/api/v1/runs/{run_id}/events"), + &format!("/api/v1/runs/{run_id}/events?after=0&limit=1000"), )); - crate::support::parse_event_envelopes(&response) + crate::support::parse_stream_items(&response) } /// Runs a scenario against every sandbox provider fabro supports: diff --git a/lib/apps/fabro-server/src/demo/mod.rs b/lib/apps/fabro-server/src/demo/mod.rs index b6a0f352f..395588acd 100644 --- a/lib/apps/fabro-server/src/demo/mod.rs +++ b/lib/apps/fabro-server/src/demo/mod.rs @@ -15,20 +15,19 @@ use axum::http::StatusCode; use axum::response::sse::{Event, Sse}; use axum::response::{IntoResponse, Response}; use fabro_api::types::{ - CreateSecretRequest, DeleteSecretRequest, DiffFile, DiffStats, EventEnvelope, FileDiff, - FileDiffChangeKind, PaginatedEventList, PaginatedRunCommitList, PaginatedRunFileList, - PaginationMeta, RunArtifactListResponse, RunCommit, RunCommitParent, RunCommitParentSha, - RunCommitParentShortSha, RunCommitPerson, RunCommitSha, RunCommitShortSha, RunCommitTreeSha, - RunCommitsMeta, RunCommitsMetaBaseSha, RunCommitsMetaHeadSha, RunCommitsMetaSource, - RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource, SandboxService, - SandboxServiceListResponse, + CreateSecretRequest, DeleteSecretRequest, DiffFile, DiffStats, FileDiff, FileDiffChangeKind, + PaginatedRunCommitList, PaginatedRunFileList, RunArtifactListResponse, RunCommit, + RunCommitParent, RunCommitParentSha, RunCommitParentShortSha, RunCommitPerson, RunCommitSha, + RunCommitShortSha, RunCommitTreeSha, RunCommitsMeta, RunCommitsMetaBaseSha, + RunCommitsMetaHeadSha, RunCommitsMetaSource, RunFilesMeta, RunFilesMetaScope, + RunFilesMetaSource, SandboxService, SandboxServiceListResponse, }; use serde_json::json; use crate::error::ApiError; use crate::principal_middleware::RequiredUser; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; -use crate::server::{AppState, EventListParams, PaginationParams, parse_stage_id_path}; +use crate::server::{AppState, PaginationParams}; fn paginated_response( items: Vec, @@ -120,44 +119,6 @@ pub(crate) async fn get_run_stages( paginated_response(runs::stages(), &pagination) } -pub(crate) async fn get_stage_events( - _auth: RequiredUser, - State(_state): State>, - Path((_id, stage_id)): Path<(String, String)>, - Query(params): Query, -) -> Response { - let stage_id = match parse_stage_id_path(&stage_id) { - Ok(stage_id) => stage_id, - Err(response) => return response, - }; - let since_seq = params.since_seq(); - let limit = params.limit(); - let mut matches: Vec = runs::stage_events() - .into_iter() - .filter(|envelope| { - envelope.seq >= since_seq - && (envelope.event.stage_id.as_ref() == Some(&stage_id) - || (envelope.event.stage_id.is_none() - && stage_id.visit() == 1 - && envelope.event.node_id.as_deref() == Some(stage_id.node_id()))) - }) - .take(limit + 1) - .collect(); - let has_more = matches.len() > limit; - matches.truncate(limit); - ( - StatusCode::OK, - Json(PaginatedEventList { - data: matches, - meta: PaginationMeta { - has_more, - total: None, - }, - }), - ) - .into_response() -} - pub(crate) async fn list_run_artifacts_stub( _auth: RequiredUser, State(_state): State>, @@ -488,18 +449,23 @@ pub(crate) async fn run_events_stub( State(_state): State>, Path(_id): Path, ) -> Response { + // One `RunStreamItem`: the platform record that ends the demo run. let events = vec![Ok::<_, std::convert::Infallible>( Event::default().data( json!({ - "seq": 2, - "id": "evt_demo_attach_completed", - "ts": "2026-04-06T15:00:02Z", "run_id": "01JQ0000000000000000000001", - "event": "run.completed", - "properties": { - "duration_ms": 42, - "artifact_count": 0, - "status": "succeeded" + "stream_seq": 2, + "kind": "platform", + "id": "2", + "recorded_at": 1_775_487_602_000_u64, + "item": { + "seq": 2, + "recorded_at": 1_775_487_602_000_u64, + "record": { + "kind": "run.lifecycle", + "transition": "succeeded", + "status": { "kind": "succeeded", "reason": "completed" } + } } }) .to_string(), @@ -1474,55 +1440,23 @@ mod runs { ] } - /// The agent stage's stored events: what pebble reports for one prompt - /// that finds MCP servers, activates a skill, reads and writes files, - /// delegates to a subagent, moves to a fallback route, and compacts, - /// plus fabro's own `stage.prompt`. - pub(super) fn stage_events() -> Vec { - use fabro_types::run_event::stage::StagePromptProps; - use fabro_types::{AgentEventProps, EventBody, EventEnvelope, RunEvent}; + /// The agent stage's coding agent events: what pebble reports for one + /// prompt that finds MCP servers, activates a skill, reads and writes + /// files, delegates to a subagent, moves to a fallback route, and + /// compacts. + pub(super) fn agent_events() -> Vec { use pebble_coding_agent::events::{ CodingAgentEvent, CodingEvent, CompactionReason, ErrorData, ErrorKind, FailoverContinuation, InputSource, McpToolSummary, SkillActivationSource, SkillSummary, }; - let run_id = demo_run_id(1); - let node_id = "detect-drift"; - let stage_id = fabro_types::StageId::new(node_id, 1); let ts = ts("2026-03-06T14:30:00Z"); - let make_envelope = |seq: u32, id: &str, body: EventBody| EventEnvelope { - seq, - event: RunEvent { - id: id.into(), - ts, - run_id, - node_id: Some(node_id.into()), - node_label: Some("Detect Drift".into()), - stage_id: Some(stage_id.clone()), - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body, - }, - }; - let agent = |event: CodingEvent| { - EventBody::Agent(AgentEventProps::new( - node_id, - 1, - CodingAgentEvent::new("ses_demo_detect_drift", event, ts.into()), - )) - }; + let agent = + |event: CodingEvent| CodingAgentEvent::new("ses_demo_detect_drift", event, ts.into()); let subagent = |event: CodingEvent| { - EventBody::Agent(AgentEventProps::new( - node_id, - 1, - CodingAgentEvent::new("ses_demo_sub_1", event, ts.into()) - .with_parent_session_id("ses_demo_detect_drift"), - )) + CodingAgentEvent::new("ses_demo_sub_1", event, ts.into()) + .with_parent_session_id("ses_demo_detect_drift") }; let answer = |model: &str, text: &str, input: u64, output: u64| CodingEvent::AssistantMessage { @@ -1574,16 +1508,7 @@ mod runs { let prompt = "You are a drift detection agent. Compare the production and staging environments and identify any configuration or code drift."; let report = "# Drift report\n\n- redis.max_connections: 200 (production) vs 100 (staging)\n- redis.tls: enabled vs disabled\n- iam.session_duration: 3600s vs 1800s\n"; - let events = vec![ - EventBody::StagePrompt(StagePromptProps { - visit: 1, - text: prompt.into(), - mode: None, - provider: None, - model: None, - reasoning_effort: None, - speed: None, - }), + vec![ agent(started("anthropic", "claude-opus-4.6")), agent(CodingEvent::McpServerReady { server: "github".into(), @@ -1706,22 +1631,14 @@ mod runs { 260, )), agent(CodingEvent::ProcessingEnd), - ]; - events - .into_iter() - .enumerate() - .map(|(index, body)| { - let seq = u32::try_from(index + 1).expect("the demo stream is short"); - make_envelope(seq, &format!("evt-detect-drift-{seq}"), body) - }) - .collect() + ] } /// The demo run's projection: each stage as `stages()` lists it, and the - /// agent stage carrying the coding agent's fold of `stage_events()`. + /// agent stage carrying the coding agent's fold of `agent_events()`. pub(super) fn run_state() -> fabro_types::RunProjection { use fabro_types::{ - EventBody, Graph, RunProjection, RunProvenance, RunSpec, StageTiming, WorkflowSettings, + Graph, RunProjection, RunProvenance, RunSpec, StageTiming, WorkflowSettings, first_event_seq, }; use pebble_coding_agent::projection::SessionProjection; @@ -1770,10 +1687,8 @@ mod runs { entry.timing = stage.wall_time_ms.map(StageTiming::wall_only); } let mut agent = SessionProjection::new(); - for envelope in stage_events() { - if let EventBody::Agent(props) = &envelope.event.body { - agent.apply(&props.event); - } + for event in agent_events() { + agent.apply(&event); } let detect = projection.stage_entry("detect-drift", 1, first_event_seq(1)); detect.agent = Some(agent); diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 73c875001..f7bf3c1b6 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -23,8 +23,8 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use bytes::Bytes; use chrono::{DateTime, Utc}; pub use fabro_api::types::{ - AggregateUsage, AggregateUsageTotals, ApiQuestion, AppendEventResponse, ArtifactEntry, - ArtifactListResponse, BatchDeleteRunsRequest, BatchDeleteRunsResponse, BatchDeleteRunsResult, + AggregateUsage, AggregateUsageTotals, ApiQuestion, ArtifactEntry, ArtifactListResponse, + BatchDeleteRunsRequest, BatchDeleteRunsResponse, BatchDeleteRunsResult, BatchDeleteRunsResultOutcome, BatchDeleteRunsSummary, BatchRunLifecycleRequest, BatchRunLifecycleResponse, BatchRunLifecycleResult, BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary, CloseRunPullRequestResponse, CompletionResponse, @@ -33,8 +33,8 @@ pub use fabro_api::types::{ DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ErrorResponseEntry, IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, LinkRunPullRequestRequest, MergeRunPullRequestRequest, - MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList, - PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider, + MergeRunPullRequestResponse, ModelReference, PaginatedRunList, PaginationMeta, + PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider, ProviderCredentialTestRequest, ProviderCredentialTestResponse, ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, Run, RunArtifactEntry, RunArtifactListResponse, RunError, RunManifest, RunStage, RunUsage, @@ -170,7 +170,6 @@ mod session_runtime; pub(crate) mod stream_follower; pub(crate) use automation_scheduler::spawn_automation_scheduler; -pub(crate) use handler::events::EventListParams; pub(crate) use handler::graph::render_graph_bytes; #[cfg(test)] pub(in crate::server) use handler::graph::{ diff --git a/lib/apps/fabro-server/src/server/handler/events.rs b/lib/apps/fabro-server/src/server/handler/events.rs index 9b0cb4972..2a452d63d 100644 --- a/lib/apps/fabro-server/src/server/handler/events.rs +++ b/lib/apps/fabro-server/src/server/handler/events.rs @@ -26,25 +26,6 @@ pub(super) fn routes() -> Router> { .route("/runs/{id}/attach", get(attach_run_events)) } -/// Query parameters shared by the paged event listings: a page size. -#[derive(serde::Deserialize)] -pub(crate) struct EventListParams { - #[serde(default)] - since_seq: Option, - #[serde(default)] - limit: Option, -} - -impl EventListParams { - pub(crate) fn since_seq(&self) -> u32 { - self.since_seq.unwrap_or(1).max(1) - } - - pub(crate) fn limit(&self) -> usize { - self.limit.unwrap_or(100).clamp(1, 1000) - } -} - /// Query parameters for `/runs/{id}/events`: the stream cursor and a page /// size. #[derive(serde::Deserialize)] diff --git a/lib/apps/fabro-server/src/server/handler/mod.rs b/lib/apps/fabro-server/src/server/handler/mod.rs index d22bb8d29..7f91e67ad 100644 --- a/lib/apps/fabro-server/src/server/handler/mod.rs +++ b/lib/apps/fabro-server/src/server/handler/mod.rs @@ -120,10 +120,6 @@ pub(super) fn demo_routes() -> Router> { .route("/runs/{id}/artifacts/download", get(not_implemented)) .route("/runs/{id}/files", get(demo::list_run_files_stub)) .route("/runs/{id}/commits", get(demo::list_run_commits_stub)) - .route( - "/runs/{id}/stages/{stageId}/events", - get(demo::get_stage_events), - ) .route( "/runs/{id}/stages/{stageId}/context-window", get(not_implemented), diff --git a/lib/apps/fabro-server/tests/it/event_pagination.rs b/lib/apps/fabro-server/tests/it/event_pagination.rs deleted file mode 100644 index 20191c0bc..000000000 --- a/lib/apps/fabro-server/tests/it/event_pagination.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! Cursor-pagination tests for the per-stage events endpoint (demo mode). -//! -//! The stage-events route uses `since_seq=` + `limit=` (cursor-based) instead -//! of the offset-based `page[limit]/page[offset]` pagination used by other -//! list endpoints, so it gets its own test rather than living in the generic -//! offset-shape matrix. - -#![allow( - clippy::absolute_paths, - reason = "This test module prefers explicit type paths over extra imports." -)] - -use axum::body::Body; -use axum::http::{Request, StatusCode}; -use tower::ServiceExt; - -use super::helpers::{response_json, test_app_state}; - -async fn get_json(app: &axum::Router, uri: &str) -> serde_json::Value { - let req = Request::builder() - .method("GET") - .uri(uri) - .header("x-fabro-demo", "1") - .body(Body::empty()) - .expect("event pagination request should build"); - let response = app.clone().oneshot(req).await.unwrap(); - response_json(response, StatusCode::OK, format!("GET {uri}")).await -} - -#[tokio::test] -async fn demo_stage_events_default_returns_all_fixture_events_with_no_more() { - let app = fabro_server::test_support::build_test_router(test_app_state()); - - let body = get_json(&app, "/api/v1/runs/run-1/stages/detect-drift@1/events").await; - let data = body["data"].as_array().expect("data is an array"); - - assert_eq!(data.len(), 24, "every fixture event should be returned"); - assert_eq!(body["meta"]["has_more"], false); -} - -#[tokio::test] -async fn demo_stage_events_limit_one_signals_has_more() { - let app = fabro_server::test_support::build_test_router(test_app_state()); - - let body = get_json( - &app, - "/api/v1/runs/run-1/stages/detect-drift@1/events?limit=1", - ) - .await; - let data = body["data"].as_array().expect("data is an array"); - - assert_eq!(data.len(), 1); - assert_eq!(body["meta"]["has_more"], true); -} - -#[tokio::test] -async fn demo_stage_events_since_seq_filters_out_earlier_events() { - let app = fabro_server::test_support::build_test_router(test_app_state()); - - // The fixture seqs are 1..=24. since_seq=4 should skip the first three. - let body = get_json( - &app, - "/api/v1/runs/run-1/stages/detect-drift@1/events?since_seq=4", - ) - .await; - let data = body["data"].as_array().expect("data is an array"); - - assert_eq!(data.len(), 21); - let seqs: Vec = data - .iter() - .map(|envelope| envelope["seq"].as_u64().expect("seq is a number")) - .collect(); - assert_eq!(seqs, (4..=24).collect::>()); - assert_eq!(body["meta"]["has_more"], false); -} - -#[tokio::test] -async fn demo_run_state_carries_the_agent_stages_fold() { - let app = fabro_server::test_support::build_test_router(test_app_state()); - - let body = get_json(&app, "/api/v1/runs/run-1/state").await; - let stage = &body["stages"]["detect-drift@1"]; - assert_eq!(stage["handler"], "agent"); - let agent = &stage["agent"]; - assert_eq!(agent["root_session_id"], "ses_demo_detect_drift"); - assert_eq!( - agent["route"]["model"], "gpt-5.4", - "the route after the failover" - ); - assert_eq!(agent["activity"], "idle"); - assert_eq!( - agent["mcp_servers"]["github"]["tools"] - .as_array() - .unwrap() - .len(), - 2 - ); - assert_eq!(agent["mcp_servers"]["github"]["invoked"], true); - assert_eq!( - agent["mcp_servers"]["atlassian"]["error"], - "auth failed: the API token has expired" - ); - assert_eq!(agent["skills"]["activated"][0]["name"], "drift-triage"); - assert_eq!(agent["subagents"][0]["status"]["status"], "completed"); - assert_eq!(agent["failovers"][0]["to"], "openai/gpt-5.4"); - assert_eq!(agent["compactions"].as_array().unwrap().len(), 1); - assert_eq!( - agent["files_touched"], - serde_json::json!(["reports/drift.md"]) - ); - assert!(agent.get("pending_writes").is_none()); - assert!(body["stages"]["apply-changes@2"]["agent"].is_null()); -} diff --git a/lib/apps/fabro-server/tests/it/main.rs b/lib/apps/fabro-server/tests/it/main.rs index 865bb8132..9e5ddab9f 100644 --- a/lib/apps/fabro-server/tests/it/main.rs +++ b/lib/apps/fabro-server/tests/it/main.rs @@ -4,7 +4,6 @@ )] mod api; -mod event_pagination; mod helpers; mod openapi_conformance; mod pagination; diff --git a/lib/apps/fabro-server/tests/it/scenario/archive.rs b/lib/apps/fabro-server/tests/it/scenario/archive.rs index fb51d1f7b..9844a1c29 100644 --- a/lib/apps/fabro-server/tests/it/scenario/archive.rs +++ b/lib/apps/fabro-server/tests/it/scenario/archive.rs @@ -57,34 +57,6 @@ async fn archived_runs_reject_mutations_with_actionable_body() { ); } - let req = Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/events"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({ - "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", - "ts": "2026-04-19T12:00:00.000Z", - "run_id": run_id, - "event": "agent.message", - "properties": {} - })) - .unwrap(), - )) - .unwrap(); - let response = app.clone().oneshot(req).await.unwrap(); - let body = response_json( - response, - StatusCode::CONFLICT, - format!("POST /api/v1/runs/{run_id}/events"), - ) - .await; - let detail = body["errors"][0]["detail"].as_str().unwrap_or_default(); - assert!( - detail.contains("is archived") && detail.contains("fabro unarchive"), - "expected archived-rejection body on /events, got: {body}" - ); - // The archive guard runs before each endpoint's state-specific lookups, so // synthetic stage/question/filename values are enough to drive these // write surfaces into the guard. @@ -150,50 +122,6 @@ async fn archived_runs_reject_mutations_with_actionable_body() { assert_eq!(body["lifecycle"]["status"]["kind"], "succeeded"); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn appending_run_archived_event_directly_is_rejected() { - let workspace = tempfile::tempdir().unwrap(); - // Regression: archive/unarchive events must not be injectable via - // `append_run_event` — clients must use the operation endpoints. - let state = test_app_state_with_options(test_settings(), 5); - let app = test_app_with_scheduler(state); - - let run_id = create_and_start_run_from_intent( - &app, - minimal_intent_json_with_dry_run(&app, MINIMAL_DOT, workspace.path()).await, - ) - .await; - wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await; - - let req = Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/events"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::to_string(&serde_json::json!({ - "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", - "ts": "2026-04-19T12:00:00.000Z", - "run_id": run_id, - "event": "run.archived", - "properties": {} - })) - .unwrap(), - )) - .unwrap(); - let response = app.clone().oneshot(req).await.unwrap(); - let body = crate::helpers::response_json( - response, - StatusCode::BAD_REQUEST, - format!("{}:{}", file!(), line!()), - ) - .await; - let detail = body["errors"][0]["detail"].as_str().unwrap_or_default(); - assert!( - detail.contains("run.archived must be performed through its dedicated operation endpoint"), - "expected dedicated-operation rejection, got: {body}" - ); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn archive_returns_404_for_unknown_run() { let state = test_app_state_with_options(test_settings(), 5); diff --git a/lib/components/fabro-dump/src/lib.rs b/lib/components/fabro-dump/src/lib.rs index 2b3e2e50b..c0b3fa77e 100644 --- a/lib/components/fabro-dump/src/lib.rs +++ b/lib/components/fabro-dump/src/lib.rs @@ -14,10 +14,8 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, bail}; use bytes::Bytes; -use fabro_store::{ - EventEnvelope, RunProjection, SerializableProjection, StageId, retry_storage_segment, -}; -use fabro_types::{BlobHash, parse_blob_ref}; +use fabro_store::{RunProjection, SerializableProjection, StageId, retry_storage_segment}; +use fabro_types::{BlobHash, RunStreamItem, parse_blob_ref}; use futures::future::BoxFuture; pub type BlobReader = Box BoxFuture<'static, Result>> + Send>; @@ -146,15 +144,17 @@ impl RunDump { }) } - pub fn from_store_state_and_events( + /// The dump of a run's projection with its stream: one `RunStreamItem` + /// per line of `events.jsonl`, in `stream_seq` order. + pub fn from_store_state_and_stream( state: &RunProjection, - events: &[EventEnvelope], + items: &[RunStreamItem], ) -> Result { let mut dump = Self::from_projection(state)?; let mut events_jsonl = Vec::new(); - for event in events { - serde_json::to_writer(&mut events_jsonl, event)?; + for item in items { + serde_json::to_writer(&mut events_jsonl, item)?; events_jsonl.write_all(b"\n")?; } dump.entries diff --git a/lib/components/fabro-tool/src/common.rs b/lib/components/fabro-tool/src/common.rs index b24128ff9..e986935b0 100644 --- a/lib/components/fabro-tool/src/common.rs +++ b/lib/components/fabro-tool/src/common.rs @@ -72,18 +72,14 @@ pub trait FabroToolBackend: Send + Sync { async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> anyhow::Result; async fn unlink_run_parent(&self, child_id: &RunId) -> anyhow::Result; async fn get_run_state(&self, run_id: &RunId) -> anyhow::Result; - async fn list_run_events( + /// The run's stream past `after` (the last `stream_seq` seen; `0` from + /// the start), at most `limit` items when a limit is given. + async fn list_run_stream( &self, run_id: &RunId, - after: Option, + after: u64, limit: Option, - ) -> anyhow::Result>; - async fn list_run_events_until( - &self, - run_id: &RunId, - after: Option, - limit: usize, - ) -> anyhow::Result>; + ) -> anyhow::Result>; async fn list_run_questions(&self, run_id: &RunId) -> anyhow::Result>; async fn submit_run_answer( &self, diff --git a/lib/components/fabro-tool/src/events.rs b/lib/components/fabro-tool/src/events.rs index 130dc40ca..9a794410c 100644 --- a/lib/components/fabro-tool/src/events.rs +++ b/lib/components/fabro-tool/src/events.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; -use fabro_types::EventEnvelope; +use fabro_types::RunStreamItem; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -17,6 +17,9 @@ pub enum RunEventsAction { Search, } +/// The `fabro_run_events` tool's parameters: which page of a run's stream +/// to read (`after` is the last `stream_seq` seen, exclusive) and how to +/// narrow it. #[derive(Debug, Deserialize, JsonSchema)] pub struct FabroRunEventsParams { pub action: RunEventsAction, @@ -27,7 +30,7 @@ pub struct FabroRunEventsParams { pub created_after: Option, pub created_before: Option, pub first: Option, - pub after: Option, + pub after: Option, pub event_ids: Option>, pub offset: Option, pub limit: Option, @@ -100,13 +103,15 @@ pub struct RunEventsResult { pub run_id: String, pub action: RunEventsAction, pub events: Vec, - pub next_cursor: Option, + /// The `after` cursor for the next page: the last `stream_seq` returned. + pub next_cursor: Option, } +/// One item of the run's stream, as the tool returns it. #[derive(Debug, Serialize, JsonSchema)] pub struct RunEventResult { pub event_id: String, - pub sequence: u32, + pub sequence: u64, pub event: Value, pub truncated: bool, } @@ -125,26 +130,26 @@ pub async fn run_events( .await .map_err(|err| ToolError::from_anyhow(&err))? .id; - let fetch_after = if descending { None } else { raw.after }; - let mut events = if let Some(limit) = event_fetch_limit(&raw, first) { - backend - .list_run_events_until(&run_id, fetch_after, limit) - .await + let fetch_after = if descending { + 0 } else { - backend.list_run_events(&run_id, fetch_after, None).await - } - .map_err(|err| ToolError::from_anyhow(&err))?; + raw.after.unwrap_or(0) + }; + let mut items = backend + .list_run_stream(&run_id, fetch_after, event_fetch_limit(&raw, first)) + .await + .map_err(|err| ToolError::from_anyhow(&err))?; if descending { if let Some(after) = raw.after { - events.retain(|event| event.seq < after); + items.retain(|item| item.stream_seq < after); } } - filter_events(&mut events, &raw, created_after, created_before); + filter_items(&mut items, &raw, created_after, created_before); if descending { - events.reverse(); + items.reverse(); } let offset = raw.offset.unwrap_or(0); - let page = events + let page = items .into_iter() .skip(offset) .take(first) @@ -152,15 +157,9 @@ pub async fn run_events( let max_content_length = raw.max_content_length.unwrap_or(20_000); let results = page .iter() - .map(|event| run_event_result(event, max_content_length)) + .map(|item| run_event_result(item, max_content_length)) .collect::>>()?; - let next_cursor = page.last().map(|event| { - if descending { - event.seq - } else { - event.seq.saturating_add(1) - } - }); + let next_cursor = page.last().map(|item| item.stream_seq); Ok(RunEventsResult { run_id: run_id.to_string(), @@ -174,6 +173,9 @@ pub fn run_events_text(result: &RunEventsResult) -> String { format!("returned {} Fabro event(s)", result.events.len()) } +/// How many items to read from the server: the page plus its offset when +/// the request is a plain ascending page, the whole stream when a filter, +/// a search or descending order needs every item. fn event_fetch_limit(params: &FabroRunEventsParams, first: usize) -> Option { let needs_full_scan = params.event_ids.is_some() || params.event_types.is_some() @@ -193,65 +195,68 @@ fn event_fetch_limit(params: &FabroRunEventsParams, first: usize) -> Option, +fn filter_items( + items: &mut Vec, params: &FabroRunEventsParams, created_after: Option>, created_before: Option>, ) { if let Some(event_ids) = params.event_ids.as_ref() { - events.retain(|event| event_ids.contains(&event.event.id)); + items.retain(|item| event_ids.contains(&item.id)); } if let Some(event_types) = params.event_types.as_ref() { - events.retain(|event| { - event_types - .iter() - .any(|event_type| event_type == event.event.event_name()) + items.retain(|item| { + item.name() + .is_some_and(|name| event_types.iter().any(|event_type| event_type == name)) }); } if let Some(categories) = params.categories.as_ref() { - events.retain(|event| { - let category = event - .event - .event_name() - .split('.') - .next() + items.retain(|item| { + let category = item + .name() + .and_then(|name| name.split('.').next()) .unwrap_or_default(); categories.iter().any(|candidate| candidate == category) }); } if let Some(cutoff) = created_after { - events.retain(|event| event.event.ts >= cutoff); + items.retain(|item| recorded_at(item) >= cutoff); } if let Some(cutoff) = created_before { - events.retain(|event| event.event.ts <= cutoff); + items.retain(|item| recorded_at(item) <= cutoff); } if matches!(params.action, RunEventsAction::Search) { if let Some(query) = params.query.as_deref() { - events.retain(|event| { - serde_json::to_string(event).is_ok_and(|serialized| serialized.contains(query)) + items.retain(|item| { + serde_json::to_string(item).is_ok_and(|serialized| serialized.contains(query)) }); } } } -fn run_event_result( - event: &EventEnvelope, - max_content_length: usize, -) -> ToolResult { - let mut serialized = serde_json::to_string(event) +/// When the item's record was appended, from its epoch milliseconds; the +/// epoch itself for a timestamp outside `DateTime`'s range. +fn recorded_at(item: &RunStreamItem) -> DateTime { + i64::try_from(item.recorded_at) + .ok() + .and_then(DateTime::from_timestamp_millis) + .unwrap_or_default() +} + +fn run_event_result(item: &RunStreamItem, max_content_length: usize) -> ToolResult { + let mut serialized = serde_json::to_string(item) .map_err(|err| ToolError::message(format!("failed to serialize event: {err}")))?; let truncated = serialized.len() > max_content_length; let event_value = if truncated { serialized.truncate(floor_char_boundary(&serialized, max_content_length)); Value::String(serialized) } else { - serde_json::to_value(event) + serde_json::to_value(item) .map_err(|err| ToolError::message(format!("failed to serialize event: {err}")))? }; Ok(RunEventResult { - event_id: event.event.id.clone(), - sequence: event.seq, + event_id: item.id.clone(), + sequence: item.stream_seq, event: event_value, truncated, }) @@ -267,41 +272,33 @@ fn floor_char_boundary(value: &str, max_len: usize) -> usize { #[cfg(test)] mod tests { - use chrono::Utc; - use fabro_types::{EventBody, EventEnvelope, RunEvent, fixtures}; + use fabro_types::{RunStreamItemKind, fixtures}; use serde_json::{Value, json}; use super::*; + fn item(stream_seq: u64, name: &str, recorded_at: u64) -> RunStreamItem { + RunStreamItem { + run_id: fixtures::RUN_1, + stream_seq, + kind: RunStreamItemKind::Petri, + id: format!("coordinator/{stream_seq}/0"), + recorded_at, + item: json!({ + "record": { "body": { "event": name, "message": "éééé" } } + }), + } + } + #[test] fn run_event_result_truncates_at_utf8_boundary() { - let event = EventEnvelope { - seq: 1, - event: RunEvent { - id: "evt_utf8".to_string(), - ts: Utc::now(), - run_id: fixtures::RUN_1, - node_id: None, - node_label: None, - stage_id: None, - parallel_group_id: None, - parallel_branch_id: None, - session_id: None, - parent_session_id: None, - tool_call_id: None, - actor: None, - body: EventBody::Unknown { - name: "test.utf8".to_string(), - properties: json!({ "message": "éééé" }), - }, - }, - }; - let serialized = serde_json::to_string(&event).unwrap(); + let item = item(1, "test.utf8", 1_789_323_217_366); + let serialized = serde_json::to_string(&item).unwrap(); let first_multibyte = serialized .find('é') .expect("serialized event should contain é"); - let result = run_event_result(&event, first_multibyte + 1).unwrap(); + let result = run_event_result(&item, first_multibyte + 1).unwrap(); assert!(result.truncated); let Value::String(event_json) = result.event else { @@ -309,4 +306,51 @@ mod tests { }; assert!(event_json.is_char_boundary(event_json.len())); } + + #[test] + fn filters_narrow_by_name_category_and_time() { + let params = FabroRunEventsParams { + action: RunEventsAction::List, + run_id: fixtures::RUN_1.to_string(), + event_types: Some(vec!["stage.started".to_string()]), + categories: None, + direction: None, + created_after: None, + created_before: None, + first: None, + after: None, + event_ids: None, + offset: None, + limit: None, + max_content_length: None, + query: None, + }; + let mut items = vec![ + item(1, "run.started", 1_000), + item(2, "stage.started", 2_000), + item(3, "stage.finished", 3_000), + ]; + filter_items(&mut items, ¶ms, None, None); + assert_eq!(items.len(), 1); + assert_eq!(items[0].stream_seq, 2); + + let params = FabroRunEventsParams { + event_types: None, + categories: Some(vec!["stage".to_string()]), + ..params + }; + let mut items = vec![ + item(1, "run.started", 1_000), + item(2, "stage.started", 2_000), + item(3, "stage.finished", 3_000), + ]; + filter_items( + &mut items, + ¶ms, + DateTime::from_timestamp_millis(2_500), + None, + ); + assert_eq!(items.len(), 1); + assert_eq!(items[0].stream_seq, 3); + } } diff --git a/lib/components/fabro-tool/src/fabro_client.rs b/lib/components/fabro-tool/src/fabro_client.rs index f21e99bc5..7103f7d31 100644 --- a/lib/components/fabro-tool/src/fabro_client.rs +++ b/lib/components/fabro-tool/src/fabro_client.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_api::types; use fabro_types::{ - EventEnvelope, PairId, PairMessageRecord, PairMessageRequest, PairRecord, - PairTranscriptResponse, Run, RunId, RunIntent, RunPairStatusResponse, RunProjection, StageId, + PairId, PairMessageRecord, PairMessageRequest, PairRecord, PairTranscriptResponse, Run, RunId, + RunIntent, RunPairStatusResponse, RunProjection, RunStreamItem, StageId, }; use crate::{FabroToolBackend, common}; @@ -174,26 +174,21 @@ impl FabroToolBackend for ClientBackend { self.client.get_run_state(run_id).await } - async fn list_run_events( + async fn list_run_stream( &self, run_id: &RunId, - after: Option, + after: u64, limit: Option, - ) -> anyhow::Result> { + ) -> anyhow::Result> { self.ensure_run_scope(run_id)?; - self.client.list_run_events(run_id, after, limit).await - } - - async fn list_run_events_until( - &self, - run_id: &RunId, - after: Option, - limit: usize, - ) -> anyhow::Result> { - self.ensure_run_scope(run_id)?; - self.client - .list_run_events_until(run_id, after, limit) - .await + match limit { + Some(limit) => { + self.client + .list_run_stream_until(run_id, after, limit) + .await + } + None => self.client.list_run_stream(run_id, after).await, + } } async fn list_run_questions(&self, run_id: &RunId) -> anyhow::Result> { diff --git a/lib/components/fabro-tool/src/interact.rs b/lib/components/fabro-tool/src/interact.rs index 2a780361c..545bb5cee 100644 --- a/lib/components/fabro-tool/src/interact.rs +++ b/lib/components/fabro-tool/src/interact.rs @@ -452,8 +452,8 @@ mod tests { use chrono::{TimeZone, Utc}; use fabro_api::types::Usage; use fabro_types::{ - EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection, - RunStatus, RunTimestamps, WorkflowRef, test_support, + FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection, RunStatus, + RunTimestamps, WorkflowRef, test_support, }; use serde_json::json; @@ -825,21 +825,12 @@ mod tests { unreachable!() } - async fn list_run_events( + async fn list_run_stream( &self, _run_id: &RunId, - _after: Option, + _after: u64, _limit: Option, - ) -> anyhow::Result> { - unreachable!() - } - - async fn list_run_events_until( - &self, - _run_id: &RunId, - _after: Option, - _limit: usize, - ) -> anyhow::Result> { + ) -> anyhow::Result> { unreachable!() } diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index aa0d94802..8ac10961b 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -628,7 +628,6 @@ fn main() { ("ModelCosts", "fabro_types::ModelCosts", &[]), ("ModelTestMode", "fabro_types::ModelTestMode", &[]), ("RunProjection", "fabro_types::RunProjection", &[]), - ("RunEvent", "fabro_types::RunEvent", &[]), ("PairId", "fabro_types::PairId", &[]), ("PairMessageId", "fabro_types::PairMessageId", &[]), ("PairStatus", "fabro_types::PairStatus", &[]), @@ -652,12 +651,6 @@ fn main() { "fabro_types::PairTranscriptEntry", &[], ), - ( - "RunEventDetailResponse", - "fabro_types::RunEventDetailResponse", - &[], - ), - ("EventEnvelope", "fabro_types::EventEnvelope", &[]), ("SessionEvent", "fabro_types::SessionEvent", &[]), ("RunStreamItem", "fabro_types::RunStreamItem", &[]), ("RunStreamItemKind", "fabro_types::RunStreamItemKind", &[]), diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 2ea56ac32..f353fd93b 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -42,27 +42,26 @@ pub mod types { CommandTermination, Conclusion, ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness, ContextWindowWarning, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, - EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, - GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, - IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, - IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord, - LlmOutputKind, McpServerDraft as CreateMcpServerRequest, - McpServerReplace as ReplaceMcpServerRequest, McpServerView as McpServer, McpTransportView, - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ModelRef as UsageModelRef, - ModelTestMode, ModelUsage, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, - PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry, - PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord, - PermissionLevel, PetriAdmission, PetriGraphRef, Principal, Provider, PullRequest, - PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails, + ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, GitContext, GitRunTarget, + GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, IntegrationConnectionKind, + IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, + IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind, + McpServerDraft as CreateMcpServerRequest, McpServerReplace as ReplaceMcpServerRequest, + McpServerView as McpServer, McpTransportView, Model, ModelControls, ModelCosts, + ModelFeatures, ModelLimits, ModelRef as UsageModelRef, ModelTestMode, ModelUsage, PairId, + PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, + PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, + ParallelBranchResult, PendingInterviewRecord, PermissionLevel, PetriAdmission, + PetriGraphRef, Principal, Provider, PullRequest, PullRequestCreation, + PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails, PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, ReviewTarget, - ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent, - RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs, - RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox, - RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, - RunServerProvenance, RunSessionMetadata, RunSize, RunStreamItem, RunStreamItemKind, - RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, - SandboxProviderKind, SandboxProviderLookupError, SandboxService, + ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunFailure, + RunIntent, RunIntentArgs, RunPairStatusResponse, RunProjection, RunProvenance, + RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, + RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSessionMetadata, RunSize, + RunStreamItem, RunStreamItemKind, RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta, + SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError, SandboxService, SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionEvent, SessionEventBody, SessionId, SessionStatus, SessionSummary, SessionTurn, SkillActivationSource, SkillSummary, StageCompletion, StageContextWindow, diff --git a/lib/foundation/fabro-api/tests/event_envelope_round_trip.rs b/lib/foundation/fabro-api/tests/event_envelope_round_trip.rs deleted file mode 100644 index 0a8f044a9..000000000 --- a/lib/foundation/fabro-api/tests/event_envelope_round_trip.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::any::{TypeId, type_name}; - -use fabro_api::types::EventEnvelope as ApiEventEnvelope; -use fabro_types::{EventEnvelope, fixtures}; -use serde_json::json; - -#[test] -fn event_envelope_reuses_canonical_type() { - assert_same_type::(); -} - -#[test] -fn event_envelope_round_trips_flattened_run_event_json() { - let value = json!({ - "seq": 42, - "id": "evt_envelope", - "ts": "2026-04-29T12:03:00Z", - "run_id": fixtures::RUN_1, - "event": "stage.started", - "node_id": "code", - "node_label": "Code", - "stage_id": "code@2", - "properties": { - "index": 1, - "handler_type": "agent", - "attempt": 2, - "max_attempts": 3 - } - }); - - let envelope: EventEnvelope = serde_json::from_value(value.clone()).unwrap(); - assert_eq!(serde_json::to_value(envelope).unwrap(), value); -} - -fn assert_same_type() { - assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() - ); -} diff --git a/lib/foundation/fabro-api/tests/pair_round_trip.rs b/lib/foundation/fabro-api/tests/pair_round_trip.rs index edb6e54dc..0b27118ea 100644 --- a/lib/foundation/fabro-api/tests/pair_round_trip.rs +++ b/lib/foundation/fabro-api/tests/pair_round_trip.rs @@ -7,13 +7,12 @@ use fabro_api::types::{ PairStatus as ApiPairStatus, PairTarget as ApiPairTarget, PairTranscriptEntry as ApiPairTranscriptEntry, PairTranscriptResponse as ApiPairTranscriptResponse, - RunEventDetailResponse as ApiRunEventDetailResponse, RunPairStatusResponse as ApiRunPairStatusResponse, }; use fabro_types::{ PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, - PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, RunEventDetailResponse, - RunPairStatusResponse, fixtures, + PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, RunPairStatusResponse, + fixtures, }; use serde_json::{Value, json}; @@ -30,7 +29,6 @@ fn pair_api_reuses_canonical_types() { assert_same_type::(); assert_same_type::(); assert_same_type::(); - assert_same_type::(); } #[test] @@ -139,37 +137,6 @@ fn pair_transcript_response_round_trips_json() { })); } -#[test] -fn run_event_detail_response_round_trips_json() { - assert_round_trip::(json!({ - "event": { - "seq": 45, - "id": "evt_3", - "ts": "2026-05-18T12:01:20Z", - "run_id": fixtures::RUN_1, - "event": "agent.tool.completed", - "session_id": "ses_01", - "node_id": "code", - "node_label": "Code", - "stage_id": "code@1", - "tool_call_id": "call_7" - }, - "properties": { - "tool_name": "shell", - "tool_call_id": "call_7", - "is_error": false, - "visit": 1 - }, - "content": { - "kind": "tool_output", - "value": "..." - }, - "truncated": false, - "redacted": false, - "max_content_length": 20000 - })); -} - fn pair_target_json() -> Value { json!({ "stage_id": "code@1", diff --git a/lib/foundation/fabro-api/tests/run_event_round_trip.rs b/lib/foundation/fabro-api/tests/run_event_round_trip.rs deleted file mode 100644 index ce26a8aed..000000000 --- a/lib/foundation/fabro-api/tests/run_event_round_trip.rs +++ /dev/null @@ -1,366 +0,0 @@ -use std::any::{TypeId, type_name}; - -use fabro_api::types::RunEvent as ApiRunEvent; -use fabro_types::{Graph, RunEvent, WorkflowSettings, fixtures, test_support}; -use serde_json::{Value, json}; - -#[test] -fn run_event_reuses_canonical_type() { - assert_same_type::(); -} - -#[test] -fn run_event_round_trips_run_created() { - let value = json!({ - "id": "evt_run_created", - "ts": "2026-04-29T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.created", - "properties": { - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "source_directory": "/tmp/fabro/run-1", - "provenance": test_support::test_run_provenance() - } - }); - - assert_run_event_round_trip(value); -} - -#[test] -fn run_event_round_trips_run_created_with_web_url() { - let value = json!({ - "id": "evt_run_created_web", - "ts": "2026-04-29T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.created", - "properties": { - "settings": WorkflowSettings::default(), - "graph": Graph::new("test"), - "source_directory": "/tmp/fabro/run-1", - "web_url": format!("http://localhost:3000/runs/{}", fixtures::RUN_1), - "provenance": test_support::test_run_provenance() - } - }); - - assert_run_event_round_trip(value); -} - -#[test] -fn run_event_round_trips_sandbox_initialized_image_and_snapshot() { - assert_run_event_round_trip(json!({ - "id": "evt_sandbox_initialized", - "ts": "2026-04-29T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "sandbox.initialized", - "properties": { - "provider": "daytona", - "id": "fabro-run-sandbox", - "working_directory": "/home/daytona/workspace", - "snapshot": "fabro-11111111-2222-8333-8444-555555555555" - } - })); - - assert_run_event_round_trip(json!({ - "id": "evt_sandbox_initialized_docker", - "ts": "2026-04-29T12:01:00Z", - "run_id": fixtures::RUN_1, - "event": "sandbox.initialized", - "properties": { - "provider": "docker", - "id": "container-id", - "working_directory": "/workspace", - "image": "ubuntu:24.04" - } - })); -} - -#[test] -fn run_event_round_trips_run_interrupt() { - let value = json!({ - "id": "evt_run_interrupt", - "ts": "2026-04-29T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.interrupt", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": {} - }); - - assert_run_event_round_trip(value); -} - -#[test] -fn run_event_round_trips_run_steer() { - let value = json!({ - "id": "evt_run_steer", - "ts": "2026-04-29T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.steer", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": { - "text": "try another approach" - } - }); - - assert_run_event_round_trip(value); -} - -#[test] -fn run_event_round_trips_pair_lifecycle_events() { - let value = json!({ - "id": "evt_pair_started", - "ts": "2026-05-18T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.pair.started", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": { - "pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ", - "target": { - "stage_id": "code@1", - "node_label": "Code" - } - } - }); - - let event: RunEvent = serde_json::from_value(value.clone()).unwrap(); - let serialized = serde_json::to_value(&event).unwrap(); - assert_eq!(serialized, value); - - let body = &serialized["properties"]; - let body_text = body.to_string(); - assert!(body_text.contains("stage_id")); - assert!(!body_text.contains("agent_session_id")); - assert!(!body_text.contains("session_id")); - assert!(!body_text.contains("provider")); - assert!(!body_text.contains("model")); - assert!(!body_text.contains("\"node_id\"")); - assert!(!body_text.contains("\"visit\"")); - - assert_run_event_round_trip(json!({ - "id": "evt_pair_ended", - "ts": "2026-05-18T12:05:00Z", - "run_id": fixtures::RUN_1, - "event": "run.pair.ended", - "properties": { - "pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ", - "reason": "user_requested" - } - })); -} - -#[test] -fn run_event_round_trips_agent_pair_messages() { - assert_run_event_round_trip(json!({ - "id": "evt_pair_user", - "ts": "2026-05-18T12:01:00Z", - "run_id": fixtures::RUN_1, - "event": "agent.pair.user_message", - "node_id": "code", - "node_label": "Code", - "stage_id": "code@1", - "session_id": "ses_01", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": { - "pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ", - "message_id": "01HZX6M4D7Y1QW0Q0P6V8Z4DR5", - "client_message_id": "client-1", - "text": "Can you inspect the failing test?", - "visit": 1 - } - })); - - assert_run_event_round_trip(json!({ - "id": "evt_pair_system", - "ts": "2026-05-18T12:01:01Z", - "run_id": fixtures::RUN_1, - "event": "agent.pair.system_message", - "node_id": "code", - "node_label": "Code", - "stage_id": "code@1", - "session_id": "ses_01", - "properties": { - "pair_id": "01HZX6M29F1CD5YYMHT1F5D7WQ", - "kind": "human_joined", - "text": "A human has joined this workflow run for live pairing. Wait for their next message before continuing.", - "visit": 1 - } - })); -} - -#[test] -fn run_event_round_trips_agent_interrupt_injected() { - let value = json!({ - "id": "evt_interrupt_injected", - "ts": "2026-04-29T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "agent.interrupt.injected", - "node_id": "code", - "node_label": "code", - "stage_id": "code@2", - "session_id": "ses_1", - "actor": { "kind": "system", "system_kind": "engine" }, - "properties": { - "visit": 2 - } - }); - - assert_run_event_round_trip(value); -} - -#[test] -fn run_event_turn_failed_defaults_code_for_legacy_payloads() { - let value = json!({ - "id": "evt_session_failed", - "ts": "2026-05-20T12:00:00Z", - "run_id": fixtures::RUN_1, - "event": "run.session.turn.failed", - "session_id": "01HZX6M0P7SE4VJ9Y3X2B8E9QF", - "properties": { - "turn_id": "01HZX6M29F1CD5YYMHT1F5D7WQ", - "error": "provider unavailable" - } - }); - - let event: ApiRunEvent = serde_json::from_value(value).unwrap(); - let round_trip = serde_json::to_value(event).unwrap(); - assert_eq!(round_trip["properties"]["code"], "agent_error"); - assert_eq!(round_trip["properties"]["retryable"], false); -} - -#[test] -fn run_event_round_trips_stage_started() { - let value = json!({ - "id": "evt_stage_started", - "ts": "2026-04-29T12:01:00Z", - "run_id": fixtures::RUN_1, - "event": "stage.started", - "node_id": "code", - "node_label": "Code", - "stage_id": "code@2", - "properties": { - "index": 1, - "handler_type": "agent", - "attempt": 2, - "max_attempts": 3 - } - }); - - assert_run_event_round_trip(value); -} - -#[test] -fn run_event_round_trips_parallel_public_contracts() { - assert_run_event_round_trip(json!({ - "id": "evt_parallel_started", - "ts": "2026-04-29T12:02:00Z", - "run_id": fixtures::RUN_1, - "event": "parallel.started", - "node_id": "fanout", - "node_label": "Fanout", - "parallel_group_id": "fanout@2", - "properties": { - "visit": 2, - "branch_count": 2 - } - })); - assert_run_event_round_trip(json!({ - "id": "evt_parallel_branch_completed", - "ts": "2026-04-29T12:02:01Z", - "run_id": fixtures::RUN_1, - "event": "parallel.branch.completed", - "node_id": "review_api", - "node_label": "Review API", - "parallel_group_id": "fanout@2", - "parallel_branch_id": "fanout@2:0", - "properties": { - "index": 0, - "duration_ms": 1000, - "status": "succeeded" - } - })); - assert_run_event_round_trip(json!({ - "id": "evt_parallel_completed", - "ts": "2026-04-29T12:02:02Z", - "run_id": fixtures::RUN_1, - "event": "parallel.completed", - "node_id": "fanout", - "node_label": "Fanout", - "parallel_group_id": "fanout@2", - "properties": { - "visit": 2, - "duration_ms": 2000, - "success_count": 1, - "failure_count": 1, - "results": [ - { - "id": "review_api", - "status": "succeeded", - "context_updates": {"response.review_api": "looks good"} - }, - { - "id": "review_ux", - "status": "failed", - "context_updates": {} - } - ] - } - })); -} - -#[test] -fn run_event_round_trips_agent_tool_started() { - let value = json!({ - "id": "evt_tool_started", - "ts": "2026-04-29T12:02:00Z", - "run_id": fixtures::RUN_1, - "event": "agent.tool.started", - "node_id": "code", - "node_label": "Code", - "stage_id": "code@2", - "parallel_group_id": "code@2", - "parallel_branch_id": "code@2:1", - "session_id": "ses_child", - "parent_session_id": "ses_parent", - "tool_call_id": "call_1", - "actor": { - "kind": "agent", - "session_id": "ses_child", - "parent_session_id": "ses_parent", - "model": "claude-sonnet" - }, - "properties": { - "stage": "code", - "visit": 2, - "seq": 12, - "stream_id": "ses_parent", - "event": { - "ToolCallStarted": { - "tool_name": "Bash", - "tool_call_id": "call_1", - "arguments": { "cmd": "cargo test" } - } - }, - "timestamp": "2026-04-29T12:02:00.000Z", - "session_id": "ses_child", - "parent_session_id": "ses_parent", - "tool_call_id": "call_1" - } - }); - - assert_run_event_round_trip(value); -} - -fn assert_run_event_round_trip(value: Value) { - let event: RunEvent = serde_json::from_value(value.clone()).unwrap(); - assert_eq!(serde_json::to_value(event).unwrap(), value); -} - -fn assert_same_type() { - assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() - ); -} diff --git a/lib/foundation/fabro-client/src/client.rs b/lib/foundation/fabro-client/src/client.rs index 09a0ffcf0..241b09431 100644 --- a/lib/foundation/fabro-client/src/client.rs +++ b/lib/foundation/fabro-client/src/client.rs @@ -12,10 +12,10 @@ use fabro_http::header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE}; use fabro_http::multipart::{Form, Part}; use fabro_types::settings::run::MergeStrategy; use fabro_types::{ - ArtifactUpload, BlobHash, EventEnvelope, Model, ModelTestMode, PairId, PairMessageRecord, - PairMessageRequest, PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent, - RunEventDetailResponse, RunId, RunPairStatusResponse, RunProjection, RunSessionMetadata, - RunStreamItem, SessionEvent, SessionId, StageId, WorkflowVersion, WorkflowVersionId, + ArtifactUpload, BlobHash, Model, ModelTestMode, PairId, PairMessageRecord, PairMessageRequest, + PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunId, RunPairStatusResponse, + RunProjection, RunSessionMetadata, RunStreamItem, SessionEvent, SessionId, StageId, + WorkflowVersion, WorkflowVersionId, }; use fabro_util::exit::{ErrorExt, ExitClass}; use futures::future::BoxFuture; @@ -50,12 +50,6 @@ const PULL_REQUEST_CREATION_POLL_DEADLINE: std::time::Duration = std::time::Dura type TransportFuture = BoxFuture<'static, Result<(fabro_http::HttpClient, String)>>; -pub struct RunEventStream { - stream: progenitor_client::ByteStream, - pending_bytes: Vec, - buffered_events: VecDeque, -} - /// The live stream of a Petri run, as `GET /runs/{id}/attach` serves it: /// one `RunStreamItem` per `data:` frame, in `stream_seq` order. pub struct RunStreamItemStream { @@ -165,42 +159,6 @@ struct ArtifactBatchUploadEntry { content_type: Option, } -impl RunEventStream { - #[must_use] - pub fn new(stream: progenitor_client::ByteStream) -> Self { - Self { - stream, - pending_bytes: Vec::new(), - buffered_events: VecDeque::new(), - } - } - - pub async fn next_event(&mut self) -> Result> { - loop { - if let Some(event) = self.buffered_events.pop_front() { - return Ok(Some(event)); - } - - if let Some(chunk) = self.stream.next().await { - let chunk = chunk.map_err(anyhow::Error::new)?; - self.pending_bytes.extend_from_slice(&chunk); - self.buffer_sse_events(false)?; - } else { - self.buffer_sse_events(true)?; - return Ok(self.buffered_events.pop_front()); - } - } - } - - fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> { - for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) { - self.buffered_events - .push_back(serde_json::from_str(&payload)?); - } - Ok(()) - } -} - impl RunStreamItemStream { #[must_use] pub fn new(stream: progenitor_client::ByteStream) -> Self { @@ -1307,29 +1265,6 @@ impl Client { convert_type(response.into_inner()) } - pub async fn get_run_event_detail( - &self, - run_id: &RunId, - seq: u32, - max_content_length: Option, - ) -> Result { - let seq = non_zero_u64_from_u32(seq).context("event seq must be non-zero")?; - let max_content_length = max_content_length.and_then(non_zero_u64_from_u32); - let response = self - .send_api(|client| async move { - let mut builder = client - .get_run_event_detail() - .id(run_id.to_string()) - .seq(seq); - if let Some(max_content_length) = max_content_length { - builder = builder.max_content_length(max_content_length); - } - builder.send().await - }) - .await?; - convert_type(response.into_inner()) - } - pub async fn archive_run(&self, run_id: &RunId) -> Result { let response = self .send_api( @@ -1674,160 +1609,6 @@ impl Client { convert_type(response.into_inner()) } - pub async fn list_run_events( - &self, - run_id: &RunId, - since_seq: Option, - limit: Option, - ) -> Result> { - let mut next_since_seq = since_seq; - let mut all_events = Vec::new(); - - loop { - let page = EventPageCursor::Ascending { - since_seq: next_since_seq, - }; - let (page_events, has_more) = self.fetch_run_events_page(run_id, page, limit).await?; - let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1)); - all_events.extend(page_events); - - if limit.is_some() || !has_more || next_page_since_seq.is_none() { - break; - } - next_since_seq = next_page_since_seq; - } - - Ok(all_events) - } - - /// Returns the newest `max_events` in ascending sequence order. - pub async fn list_run_events_tail( - &self, - run_id: &RunId, - max_events: usize, - ) -> Result> { - if max_events == 0 { - return Ok(Vec::new()); - } - - // Fetch two events when the caller asks for one so an older server - // that silently ignores the new order parameter can be detected. - let fetch_target = max_events.max(2); - let mut before_seq = None; - let mut descending_events: Vec = Vec::new(); - loop { - let remaining = fetch_target - descending_events.len(); - let (page_events, has_more) = self - .fetch_run_events_page( - run_id, - EventPageCursor::Descending { before_seq }, - Some(remaining), - ) - .await?; - - let keeps_descending = descending_events - .last() - .into_iter() - .chain(&page_events) - .is_sorted_by(|previous, next| previous.seq > next.seq); - if !keeps_descending { - // An older server ignored the order parameter and returned - // ascending history; fetch everything and slice the tail. - let mut events = self.list_run_events(run_id, None, None).await?; - let tail_start = events.len().saturating_sub(max_events); - return Ok(events.split_off(tail_start)); - } - - before_seq = page_events.last().map(|event| event.seq); - descending_events.extend(page_events); - if descending_events.len() >= fetch_target || !has_more || before_seq.is_none() { - break; - } - } - - descending_events.reverse(); - let tail_start = descending_events.len().saturating_sub(max_events); - Ok(descending_events.split_off(tail_start)) - } - - pub async fn list_run_events_until( - &self, - run_id: &RunId, - since_seq: Option, - max_events: usize, - ) -> Result> { - if max_events == 0 { - return Ok(Vec::new()); - } - - let mut next_since_seq = since_seq; - let mut all_events = Vec::new(); - while all_events.len() < max_events { - let remaining = max_events - all_events.len(); - let page = EventPageCursor::Ascending { - since_seq: next_since_seq, - }; - let (page_events, has_more) = self - .fetch_run_events_page(run_id, page, Some(remaining)) - .await?; - let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1)); - all_events.extend(page_events); - - if !has_more || next_page_since_seq.is_none() { - break; - } - next_since_seq = next_page_since_seq; - } - - Ok(all_events) - } - - async fn fetch_run_events_page( - &self, - run_id: &RunId, - cursor: EventPageCursor, - limit: Option, - ) -> Result<(Vec, bool)> { - let response = self - .send_api(|client| async move { - let mut request = client.list_run_events().id(run_id.to_string()); - match cursor { - EventPageCursor::Ascending { since_seq } => { - if let Some(seq) = since_seq.and_then(non_zero_u64_from_u32) { - request = request.since_seq(seq); - } - } - EventPageCursor::Descending { before_seq } => { - request = request.order(types::ListRunEventsOrder::Desc); - if let Some(seq) = before_seq.and_then(non_zero_u64_from_u32) { - request = request.before_seq(seq); - } - } - } - let page_limit = limit.map(|limit| limit.min(1000)); - if let Some(limit) = page_limit.and_then(non_zero_u64_from_usize) { - request = request.limit(limit); - } - request.send().await - }) - .await?; - let parsed = match response.into_inner() { - types::ListRunEventsResponse::EventList(page) => page, - types::ListRunEventsResponse::RunStreamList(_) => { - bail!( - "run {run_id} executes on Petri; its events are served as a run stream \ - (list_run_stream)" - ); - } - }; - let events = parsed - .data - .into_iter() - .map(convert_type::<_, EventEnvelope>) - .collect::>>()?; - Ok((events, parsed.meta.has_more)) - } - /// One page of a Petri run's stream: up to `limit` items with /// `stream_seq > after`, in order. pub async fn list_run_stream_page( @@ -1846,25 +1627,41 @@ impl Client { request.send().await }) .await?; - match response.into_inner() { - types::ListRunEventsResponse::RunStreamList(page) => Ok(RunStreamPage { - items: page.data, - has_more: page.meta.has_more, - event_contract_version: Some(page.event_contract_version), - }), - // An empty page decodes as either list; a legacy page with - // items is a run that does not execute on Petri. - types::ListRunEventsResponse::EventList(page) if page.data.is_empty() => { - Ok(RunStreamPage { - items: Vec::new(), - has_more: page.meta.has_more, - event_contract_version: None, - }) - } - types::ListRunEventsResponse::EventList(_) => { - bail!("run {run_id} executes on the legacy engine; its events are not a run stream") + let page = response.into_inner(); + Ok(RunStreamPage { + items: page.data, + has_more: page.meta.has_more, + event_contract_version: Some(page.event_contract_version), + }) + } + + /// At most `max_items` items of a Petri run's stream past `after`, in + /// order, page by page. + pub async fn list_run_stream_until( + &self, + run_id: &RunId, + after: u64, + max_items: usize, + ) -> Result> { + let mut cursor = after; + let mut all = Vec::new(); + while all.len() < max_items { + let remaining = max_items - all.len(); + let page = self + .list_run_stream_page(run_id, cursor, Some(remaining)) + .await?; + let Some(last) = page.items.last() else { + break; + }; + cursor = last.stream_seq; + let has_more = page.has_more; + all.extend(page.items); + if !has_more { + break; } } + all.truncate(max_items); + Ok(all) } /// Every item of a Petri run's stream past `after`, page by page. @@ -1906,23 +1703,6 @@ impl Client { Ok(RunStreamItemStream::new(response.into_inner())) } - pub async fn attach_run_events( - &self, - run_id: &RunId, - since_seq: Option, - ) -> Result { - let response = self - .send_api(|client| async move { - let mut request = client.attach_run_events().id(run_id.to_string()); - if let Some(seq) = since_seq.and_then(non_zero_u64_from_u32) { - request = request.since_seq(seq); - } - request.send().await - }) - .await?; - Ok(RunEventStream::new(response.into_inner())) - } - pub async fn list_run_questions(&self, run_id: &RunId) -> Result> { let response = self .send_api(|client| async move { @@ -1957,21 +1737,6 @@ impl Client { Ok(()) } - pub async fn append_run_event(&self, run_id: &RunId, event: &RunEvent) -> Result { - let body: types::RunEvent = convert_type(event)?; - let response = self - .send_api(|client| async move { - client - .append_run_event() - .id(run_id.to_string()) - .body(body.clone()) - .send() - .await - }) - .await?; - u32::try_from(response.into_inner().seq).context("append_run_event returned invalid seq") - } - pub async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result { let response = self .send_api(|client| async move { @@ -2555,12 +2320,6 @@ pub fn apply_bearer_token_auth( Ok(builder.default_headers(headers)) } -#[derive(Clone, Copy)] -enum EventPageCursor { - Ascending { since_seq: Option }, - Descending { before_seq: Option }, -} - fn non_zero_u64_from_u32(value: u32) -> Option { NonZeroU64::new(u64::from(value)) } @@ -2627,17 +2386,6 @@ mod tests { } } - fn run_event_json(run_id: &RunId, seq: u32) -> serde_json::Value { - json!({ - "seq": seq, - "event": "run.running", - "id": format!("evt-{seq}"), - "run_id": run_id, - "ts": "2026-07-24T12:00:00Z", - "properties": {}, - }) - } - fn test_workflow_version( name: &str, workflow_dependencies: BTreeMap, @@ -3074,116 +2822,6 @@ mod tests { assert!(models.is_empty()); } - #[tokio::test] - async fn list_run_events_tail_pages_backward_and_returns_ascending() { - let server = MockServer::start_async().await; - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - let newest_page = server - .mock_async(|when, then| { - when.method(GET) - .path(format!("/api/v1/runs/{run_id}/events")) - .query_param("order", "desc") - .query_param("limit", "5"); - then.status(200) - .header("Content-Type", "application/json") - .json_body(json!({ - "data": [ - run_event_json(&run_id, 6), - run_event_json(&run_id, 5), - run_event_json(&run_id, 4), - ], - "meta": { "has_more": true }, - })); - }) - .await; - let older_page = server - .mock_async(|when, then| { - when.method(GET) - .path(format!("/api/v1/runs/{run_id}/events")) - .query_param("order", "desc") - .query_param("before_seq", "4") - .query_param("limit", "2"); - then.status(200) - .header("Content-Type", "application/json") - .json_body(json!({ - "data": [ - run_event_json(&run_id, 3), - run_event_json(&run_id, 2), - ], - "meta": { "has_more": true }, - })); - }) - .await; - - let client = Client::new_no_proxy(&server.url("")).unwrap(); - let events = client.list_run_events_tail(&run_id, 5).await.unwrap(); - - newest_page.assert_async().await; - older_page.assert_async().await; - let seqs = events - .into_iter() - .map(|event| event.seq) - .collect::>(); - assert_eq!(seqs, vec![2, 3, 4, 5, 6]); - } - - #[tokio::test] - async fn list_run_events_tail_falls_back_when_server_ignores_descending_order() { - let server = MockServer::start_async().await; - let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - let unsupported_descending_page = server - .mock_async(|when, then| { - when.method(GET) - .path(format!("/api/v1/runs/{run_id}/events")) - .query_param("order", "desc") - .query_param("limit", "3"); - then.status(200) - .header("Content-Type", "application/json") - .json_body(json!({ - "data": [ - run_event_json(&run_id, 1), - run_event_json(&run_id, 2), - run_event_json(&run_id, 3), - ], - "meta": { "has_more": true }, - })); - }) - .await; - let full_history = server - .mock_async(|when, then| { - when.method(GET) - .path(format!("/api/v1/runs/{run_id}/events")) - .query_param_missing("order") - .query_param_missing("before_seq") - .query_param_missing("since_seq") - .query_param_missing("limit"); - then.status(200) - .header("Content-Type", "application/json") - .json_body(json!({ - "data": [ - run_event_json(&run_id, 1), - run_event_json(&run_id, 2), - run_event_json(&run_id, 3), - run_event_json(&run_id, 4), - run_event_json(&run_id, 5), - ], - "meta": { "has_more": false }, - })); - }) - .await; - - let client = Client::new_no_proxy(&server.url("")).unwrap(); - let events = client.list_run_events_tail(&run_id, 3).await.unwrap(); - - unsupported_descending_page.assert_async().await; - full_history.assert_async().await; - let seqs = events - .into_iter() - .map(|event| event.seq) - .collect::>(); - assert_eq!(seqs, vec![3, 4, 5]); - } - #[tokio::test] async fn test_provider_credentials_posts_api_key() { let server = MockServer::start_async().await; diff --git a/lib/foundation/fabro-client/src/lib.rs b/lib/foundation/fabro-client/src/lib.rs index d85fb6c47..ec0e71231 100644 --- a/lib/foundation/fabro-client/src/lib.rs +++ b/lib/foundation/fabro-client/src/lib.rs @@ -12,8 +12,8 @@ pub use auth_store::{ AuthEntry, AuthStore, AuthStoreError, DevTokenEntry, LockError, OAuthEntry, StoredSubject, }; pub use client::{ - Client, RunEventStream, RunStreamItemStream, RunStreamPage, SessionEventStream, - TransportConnector, apply_bearer_token_auth, + Client, RunStreamItemStream, RunStreamPage, SessionEventStream, TransportConnector, + apply_bearer_token_auth, }; pub use credential::{Credential, CredentialFallback}; pub use error::{ diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index 55367f476..28e1ebbc8 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -107,8 +107,7 @@ pub use pair::{ PairTranscriptAssistantMessage, PairTranscriptDetailRef, PairTranscriptEntry, PairTranscriptError, PairTranscriptMeta, PairTranscriptResponse, PairTranscriptSystemMessage, PairTranscriptToolCall, PairTranscriptToolStatus, PairTranscriptUserMessage, - PairTranscriptWarning, RunEventDetailContent, RunEventDetailContentKind, - RunEventDetailEnvelope, RunEventDetailResponse, RunPairStatusResponse, + PairTranscriptWarning, RunPairStatusResponse, }; pub use parallel::ParallelBranchResult; pub use pebble_coding_agent::events::{ diff --git a/lib/foundation/fabro-types/src/pair.rs b/lib/foundation/fabro-types/src/pair.rs index 72d8e60d5..9cb895df5 100644 --- a/lib/foundation/fabro-types/src/pair.rs +++ b/lib/foundation/fabro-types/src/pair.rs @@ -1,6 +1,5 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; use strum::{Display, EnumString, IntoStaticStr}; use crate::id::ulid_id; @@ -210,54 +209,3 @@ pub enum PairSystemMessageKind { HumanJoined, HumanLeft, } - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunEventDetailResponse { - pub event: RunEventDetailEnvelope, - pub properties: Map, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub content: Option, - pub truncated: bool, - pub redacted: bool, - pub max_content_length: usize, -} - -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum RunEventDetailContentKind { - Text, - ToolOutput, - ToolArguments, - Error, - Details, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunEventDetailEnvelope { - pub seq: u32, - pub id: String, - pub ts: DateTime, - pub run_id: RunId, - pub event: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub actor: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_label: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stage_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RunEventDetailContent { - pub kind: RunEventDetailContentKind, - pub value: String, -} diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 0d57f88d0..23e9fb7e9 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -54,7 +54,6 @@ models/agent-tools-available-props.ts models/aggregate-usage-totals.ts models/aggregate-usage.ts models/api-question.ts -models/append-event-response.ts models/approval-mode.ts models/artifact-batch-upload-entry.ts models/artifact-batch-upload-manifest.ts @@ -153,8 +152,6 @@ models/environment-settings.ts models/environment.ts models/error-response-entry.ts models/error-response.ts -models/event-envelope.ts -models/event-seq.ts models/exec-output-tail.ts models/execute-query-request.ts models/execute-query-response-rows-inner-inner.ts @@ -214,7 +211,6 @@ models/interview-option.ts models/interview-provider-settings.ts models/interview-question-record.ts models/link-run-pull-request-request.ts -models/list-run-events200-response.ts models/llm-output-kind.ts models/llm-retry-classification-after.ts models/llm-retry-classification-never.ts @@ -263,7 +259,6 @@ models/object-store-local-settings.ts models/object-store-s3-settings.ts models/object-store-settings.ts models/paginated-api-question-list.ts -models/paginated-event-list.ts models/paginated-history-entry-list.ts models/paginated-model-list.ts models/paginated-run-commit-list.ts @@ -389,10 +384,6 @@ models/run-control-action.ts models/run-diff.ts models/run-environment-settings.ts models/run-error.ts -models/run-event-detail-response-content.ts -models/run-event-detail-response-event.ts -models/run-event-detail-response.ts -models/run-event.ts models/run-execution-settings.ts models/run-failure.ts models/run-files-meta.ts diff --git a/lib/packages/fabro-api-client/src/api/run-internals-api.ts b/lib/packages/fabro-api-client/src/api/run-internals-api.ts index f9afb1097..e99a16e04 100644 --- a/lib/packages/fabro-api-client/src/api/run-internals-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-internals-api.ts @@ -22,20 +22,16 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; // @ts-ignore -import type { AppendEventResponse } from '../models'; -// @ts-ignore import type { ArtifactListResponse } from '../models'; // @ts-ignore import type { CommandLogResponse } from '../models'; // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore -import type { ListRunEvents200Response } from '../models'; -// @ts-ignore -import type { PaginatedEventList } from '../models'; -// @ts-ignore import type { PaginatedRunStageList } from '../models'; // @ts-ignore +import type { PaginatedRunStreamList } from '../models'; +// @ts-ignore import type { PetriAppendRequest } from '../models'; // @ts-ignore import type { PetriOpenRequest } from '../models'; @@ -56,10 +52,6 @@ import type { RunArtifactListResponse } from '../models'; // @ts-ignore import type { RunCheckpoint } from '../models'; // @ts-ignore -import type { RunEvent } from '../models'; -// @ts-ignore -import type { RunEventDetailResponse } from '../models'; -// @ts-ignore import type { RunProjection } from '../models'; // @ts-ignore import type { StageContextWindow } from '../models'; @@ -169,60 +161,14 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Appends a validated event to the run event log. Intended for trusted internal callers. - * @summary Append Run Event - * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - appendRunEvent: async (id: string, runEvent: RunEvent, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('appendRunEvent', 'id', id) - // verify required parameter 'runEvent' is not null or undefined - assertParamExists('appendRunEvent', 'runEvent', runEvent) - const localVarPath = `/api/v1/runs/{id}/events` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - localVarHeaderParameter['Content-Type'] = 'application/json'; - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(runEvent, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * Opens an ordered server-sent event stream, replaying persisted items and continuing with live updates while the run remains active. Each `data:` frame is one JSON object in the run engine\'s envelope. For a legacy run the frames are `EventEnvelope`s and the stream starts at `since_seq` (inclusive; the next unseen event when omitted). It ends after `run.completed` or `run.failed`. For a Petri run the frames are `RunStreamItem`s and the stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. + * Opens an ordered server-sent event stream of the run\'s stream, replaying committed items and continuing with live ones while the run remains active. Each `data:` frame is one `RunStreamItem`. The stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. * @summary Attach Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - attachRunEvents: async (id: string, sinceSeq?: number, after?: number, options: RawAxiosRequestConfig = {}): Promise => { + attachRunEvents: async (id: string, after?: number, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('attachRunEvents', 'id', id) const localVarPath = `/api/v1/runs/{id}/attach` @@ -244,10 +190,6 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (sinceSeq !== undefined) { - localVarQueryParameter['since_seq'] = sinceSeq; - } - if (after !== undefined) { localVarQueryParameter['after'] = after; } @@ -303,55 +245,6 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config options: localVarRequestOptions, }; }, - /** - * Returns one stored run event by source event sequence with content fields separated and truncated. - * @summary Get Run Event Detail - * @param {string} id Unique run identifier (ULID). - * @param {number} seq - * @param {number} [maxContentLength] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getRunEventDetail: async (id: string, seq: number, maxContentLength?: number, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('getRunEventDetail', 'id', id) - // verify required parameter 'seq' is not null or undefined - assertParamExists('getRunEventDetail', 'seq', seq) - const localVarPath = `/api/v1/runs/{id}/events/{seq}` - .replace(`{${"id"}}`, encodeURIComponent(String(id))) - .replace(`{${"seq"}}`, encodeURIComponent(String(seq))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (maxContentLength !== undefined) { - localVarQueryParameter['max_content_length'] = maxContentLength; - } - - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -718,18 +611,15 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Returns a paginated JSON list of the run\'s events. The shape depends on the engine the run was created for (`RunSpec.engine`). For a legacy run (`engine.kind = legacy`): stored run events in the legacy envelope (`PaginatedEventList`). Ascending order uses `since_seq` as an inclusive cursor. Descending order uses `before_seq` as an exclusive cursor and starts at the newest event when `before_seq` is omitted. For a Petri run (`engine.kind = petri`): the run stream (`PaginatedRunStreamList`), one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. `since_seq`, `before_seq` and `order` are not accepted for a Petri run. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. + * Returns one page of the run\'s stream (`PaginatedRunStreamList`): one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. * @summary List Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [limit] Maximum number of events to return. - * @param {number} [beforeSeq] Exclusive upper event sequence cursor for descending order. Omit on the first descending request to start from the newest event. - * @param {ListRunEventsOrderEnum} [order] Event sequence order. `since_seq` is valid only with `asc`; `before_seq` is valid only with `desc`. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRunEvents: async (id: string, sinceSeq?: number, limit?: number, beforeSeq?: number, order?: ListRunEventsOrderEnum, after?: number, options: RawAxiosRequestConfig = {}): Promise => { + listRunEvents: async (id: string, limit?: number, after?: number, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('listRunEvents', 'id', id) const localVarPath = `/api/v1/runs/{id}/events` @@ -751,22 +641,10 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (sinceSeq !== undefined) { - localVarQueryParameter['since_seq'] = sinceSeq; - } - if (limit !== undefined) { localVarQueryParameter['limit'] = limit; } - if (beforeSeq !== undefined) { - localVarQueryParameter['before_seq'] = beforeSeq; - } - - if (order !== undefined) { - localVarQueryParameter['order'] = order; - } - if (after !== undefined) { localVarQueryParameter['after'] = after; } @@ -876,60 +754,6 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config options: localVarRequestOptions, }; }, - /** - * Returns a paginated JSON list of stored run events scoped to a single stage visit. - * @summary List Stage Events - * @param {string} id Unique run identifier (ULID). - * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {number} [sinceSeq] First event sequence number to include. - * @param {number} [limit] Maximum number of events to return. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listStageEvents: async (id: string, stageId: string, sinceSeq?: number, limit?: number, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('listStageEvents', 'id', id) - // verify required parameter 'stageId' is not null or undefined - assertParamExists('listStageEvents', 'stageId', stageId) - const localVarPath = `/api/v1/runs/{id}/stages/{stageId}/events` - .replace(`{${"id"}}`, encodeURIComponent(String(id))) - .replace(`{${"stageId"}}`, encodeURIComponent(String(stageId))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (sinceSeq !== undefined) { - localVarQueryParameter['since_seq'] = sinceSeq; - } - - if (limit !== undefined) { - localVarQueryParameter['limit'] = limit; - } - - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * Opens the run in the Petri run store for the worker. `create` inserts the run and takes its writer lease for `owner`; `write` takes the lease of an existing run; `read` takes no lease. The lease is idempotent per owner: a retry by the owner that holds it gets the same lease. Another live owner is refused with `petri_run_leased`. The lease ends when the worker releases it, when the server observes the worker exit, or by operator release, never by timeout. * @summary Open Petri Run @@ -1384,30 +1208,15 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Appends a validated event to the run event log. Intended for trusted internal callers. - * @summary Append Run Event - * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async appendRunEvent(id: string, runEvent: RunEvent, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.appendRunEvent(id, runEvent, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.appendRunEvent']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * Opens an ordered server-sent event stream, replaying persisted items and continuing with live updates while the run remains active. Each `data:` frame is one JSON object in the run engine\'s envelope. For a legacy run the frames are `EventEnvelope`s and the stream starts at `since_seq` (inclusive; the next unseen event when omitted). It ends after `run.completed` or `run.failed`. For a Petri run the frames are `RunStreamItem`s and the stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. + * Opens an ordered server-sent event stream of the run\'s stream, replaying committed items and continuing with live ones while the run remains active. Each `data:` frame is one `RunStreamItem`. The stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. * @summary Attach Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async attachRunEvents(id: string, sinceSeq?: number, after?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.attachRunEvents(id, sinceSeq, after, options); + async attachRunEvents(id: string, after?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.attachRunEvents(id, after, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.attachRunEvents']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -1425,21 +1234,6 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.downloadRunArtifacts']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * Returns one stored run event by source event sequence with content fields separated and truncated. - * @summary Get Run Event Detail - * @param {string} id Unique run identifier (ULID). - * @param {number} seq - * @param {number} [maxContentLength] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async getRunEventDetail(id: string, seq: number, maxContentLength?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getRunEventDetail(id, seq, maxContentLength, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.getRunEventDetail']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -1554,19 +1348,16 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Returns a paginated JSON list of the run\'s events. The shape depends on the engine the run was created for (`RunSpec.engine`). For a legacy run (`engine.kind = legacy`): stored run events in the legacy envelope (`PaginatedEventList`). Ascending order uses `since_seq` as an inclusive cursor. Descending order uses `before_seq` as an exclusive cursor and starts at the newest event when `before_seq` is omitted. For a Petri run (`engine.kind = petri`): the run stream (`PaginatedRunStreamList`), one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. `since_seq`, `before_seq` and `order` are not accepted for a Petri run. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. + * Returns one page of the run\'s stream (`PaginatedRunStreamList`): one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. * @summary List Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [limit] Maximum number of events to return. - * @param {number} [beforeSeq] Exclusive upper event sequence cursor for descending order. Omit on the first descending request to start from the newest event. - * @param {ListRunEventsOrderEnum} [order] Event sequence order. `since_seq` is valid only with `asc`; `before_seq` is valid only with `desc`. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listRunEvents(id: string, sinceSeq?: number, limit?: number, beforeSeq?: number, order?: ListRunEventsOrderEnum, after?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listRunEvents(id, sinceSeq, limit, beforeSeq, order, after, options); + async listRunEvents(id: string, limit?: number, after?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRunEvents(id, limit, after, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.listRunEvents']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -1600,22 +1391,6 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.listStageArtifacts']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * Returns a paginated JSON list of stored run events scoped to a single stage visit. - * @summary List Stage Events - * @param {string} id Unique run identifier (ULID). - * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {number} [sinceSeq] First event sequence number to include. - * @param {number} [limit] Maximum number of events to return. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listStageEvents(id: string, stageId: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listStageEvents(id, stageId, sinceSeq, limit, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.listStageEvents']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, /** * Opens the run in the Petri run store for the worker. `create` inserts the run and takes its writer lease for `owner`; `write` takes the lease of an existing run; `read` takes no lease. The lease is idempotent per owner: a retry by the owner that holds it gets the same lease. Another live owner is refused with `petri_run_leased`. The lease ends when the worker releases it, when the server observes the worker exit, or by operator release, never by timeout. * @summary Open Petri Run @@ -1777,27 +1552,15 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b return localVarFp.appendPetriRecords(id, log, petriAppendRequest, options).then((request) => request(axios, basePath)); }, /** - * Appends a validated event to the run event log. Intended for trusted internal callers. - * @summary Append Run Event - * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - appendRunEvent(id: string, runEvent: RunEvent, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.appendRunEvent(id, runEvent, options).then((request) => request(axios, basePath)); - }, - /** - * Opens an ordered server-sent event stream, replaying persisted items and continuing with live updates while the run remains active. Each `data:` frame is one JSON object in the run engine\'s envelope. For a legacy run the frames are `EventEnvelope`s and the stream starts at `since_seq` (inclusive; the next unseen event when omitted). It ends after `run.completed` or `run.failed`. For a Petri run the frames are `RunStreamItem`s and the stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. + * Opens an ordered server-sent event stream of the run\'s stream, replaying committed items and continuing with live ones while the run remains active. Each `data:` frame is one `RunStreamItem`. The stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. * @summary Attach Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - attachRunEvents(id: string, sinceSeq?: number, after?: number, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.attachRunEvents(id, sinceSeq, after, options).then((request) => request(axios, basePath)); + attachRunEvents(id: string, after?: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.attachRunEvents(id, after, options).then((request) => request(axios, basePath)); }, /** * Streams a ZIP archive with the latest captured version of each artifact path. Stage order, retry number, and then stage ID determine the latest version, matching the artifacts page. Captures from the graph\'s boundary nodes are excluded, identified by their `start` and `exit` handler type rather than by node name. The archive streams, so the response status is sent before the first artifact is read. A failure after that point aborts the transfer rather than returning `500`. The ZIP central directory is written last, so a truncated download does not open as a valid archive. @@ -1809,18 +1572,6 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b downloadRunArtifacts(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.downloadRunArtifacts(id, options).then((request) => request(axios, basePath)); }, - /** - * Returns one stored run event by source event sequence with content fields separated and truncated. - * @summary Get Run Event Detail - * @param {string} id Unique run identifier (ULID). - * @param {number} seq - * @param {number} [maxContentLength] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getRunEventDetail(id: string, seq: number, maxContentLength?: number, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.getRunEventDetail(id, seq, maxContentLength, options).then((request) => request(axios, basePath)); - }, /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -1911,19 +1662,16 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b return localVarFp.listRunArtifacts(id, options).then((request) => request(axios, basePath)); }, /** - * Returns a paginated JSON list of the run\'s events. The shape depends on the engine the run was created for (`RunSpec.engine`). For a legacy run (`engine.kind = legacy`): stored run events in the legacy envelope (`PaginatedEventList`). Ascending order uses `since_seq` as an inclusive cursor. Descending order uses `before_seq` as an exclusive cursor and starts at the newest event when `before_seq` is omitted. For a Petri run (`engine.kind = petri`): the run stream (`PaginatedRunStreamList`), one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. `since_seq`, `before_seq` and `order` are not accepted for a Petri run. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. + * Returns one page of the run\'s stream (`PaginatedRunStreamList`): one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. * @summary List Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [limit] Maximum number of events to return. - * @param {number} [beforeSeq] Exclusive upper event sequence cursor for descending order. Omit on the first descending request to start from the newest event. - * @param {ListRunEventsOrderEnum} [order] Event sequence order. `since_seq` is valid only with `asc`; `before_seq` is valid only with `desc`. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRunEvents(id: string, sinceSeq?: number, limit?: number, beforeSeq?: number, order?: ListRunEventsOrderEnum, after?: number, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.listRunEvents(id, sinceSeq, limit, beforeSeq, order, after, options).then((request) => request(axios, basePath)); + listRunEvents(id: string, limit?: number, after?: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listRunEvents(id, limit, after, options).then((request) => request(axios, basePath)); }, /** * Returns the ordered list of stages in a run\'s workflow graph with their current status and timing. Stages are bounded by the workflow graph size, typically fewer than 20. @@ -1948,19 +1696,6 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b listStageArtifacts(id: string, stageId: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listStageArtifacts(id, stageId, options).then((request) => request(axios, basePath)); }, - /** - * Returns a paginated JSON list of stored run events scoped to a single stage visit. - * @summary List Stage Events - * @param {string} id Unique run identifier (ULID). - * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {number} [sinceSeq] First event sequence number to include. - * @param {number} [limit] Maximum number of events to return. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listStageEvents(id: string, stageId: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.listStageEvents(id, stageId, sinceSeq, limit, options).then((request) => request(axios, basePath)); - }, /** * Opens the run in the Petri run store for the worker. `create` inserts the run and takes its writer lease for `owner`; `write` takes the lease of an existing run; `read` takes no lease. The lease is idempotent per owner: a retry by the owner that holds it gets the same lease. Another live owner is refused with `petri_run_leased`. The lease ends when the worker releases it, when the server observes the worker exit, or by operator release, never by timeout. * @summary Open Petri Run @@ -2095,28 +1830,15 @@ export class RunInternalsApi extends BaseAPI { } /** - * Appends a validated event to the run event log. Intended for trusted internal callers. - * @summary Append Run Event - * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public appendRunEvent(id: string, runEvent: RunEvent, options?: RawAxiosRequestConfig) { - return RunInternalsApiFp(this.configuration).appendRunEvent(id, runEvent, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * Opens an ordered server-sent event stream, replaying persisted items and continuing with live updates while the run remains active. Each `data:` frame is one JSON object in the run engine\'s envelope. For a legacy run the frames are `EventEnvelope`s and the stream starts at `since_seq` (inclusive; the next unseen event when omitted). It ends after `run.completed` or `run.failed`. For a Petri run the frames are `RunStreamItem`s and the stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. + * Opens an ordered server-sent event stream of the run\'s stream, replaying committed items and continuing with live ones while the run remains active. Each `data:` frame is one `RunStreamItem`. The stream starts after `after` (the last `stream_seq` the client saw; `0` replays the whole run; the next unseen item when omitted). It ends once the run is no longer active and every committed item has been sent. A reconnecting client passes its last `stream_seq` as `after` and deduplicates by `id`. * @summary Attach Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public attachRunEvents(id: string, sinceSeq?: number, after?: number, options?: RawAxiosRequestConfig) { - return RunInternalsApiFp(this.configuration).attachRunEvents(id, sinceSeq, after, options).then((request) => request(this.axios, this.basePath)); + public attachRunEvents(id: string, after?: number, options?: RawAxiosRequestConfig) { + return RunInternalsApiFp(this.configuration).attachRunEvents(id, after, options).then((request) => request(this.axios, this.basePath)); } /** @@ -2130,19 +1852,6 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).downloadRunArtifacts(id, options).then((request) => request(this.axios, this.basePath)); } - /** - * Returns one stored run event by source event sequence with content fields separated and truncated. - * @summary Get Run Event Detail - * @param {string} id Unique run identifier (ULID). - * @param {number} seq - * @param {number} [maxContentLength] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public getRunEventDetail(id: string, seq: number, maxContentLength?: number, options?: RawAxiosRequestConfig) { - return RunInternalsApiFp(this.configuration).getRunEventDetail(id, seq, maxContentLength, options).then((request) => request(this.axios, this.basePath)); - } - /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -2241,19 +1950,16 @@ export class RunInternalsApi extends BaseAPI { } /** - * Returns a paginated JSON list of the run\'s events. The shape depends on the engine the run was created for (`RunSpec.engine`). For a legacy run (`engine.kind = legacy`): stored run events in the legacy envelope (`PaginatedEventList`). Ascending order uses `since_seq` as an inclusive cursor. Descending order uses `before_seq` as an exclusive cursor and starts at the newest event when `before_seq` is omitted. For a Petri run (`engine.kind = petri`): the run stream (`PaginatedRunStreamList`), one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. `since_seq`, `before_seq` and `order` are not accepted for a Petri run. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. + * Returns one page of the run\'s stream (`PaginatedRunStreamList`): one ordered delivery of Petri\'s own `RunEvent`s and Fabro\'s platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item\'s `id`; every item is delivered once, in order, with no gap. * @summary List Run Events * @param {string} id Unique run identifier (ULID). - * @param {number} [sinceSeq] First event sequence number to include. * @param {number} [limit] Maximum number of events to return. - * @param {number} [beforeSeq] Exclusive upper event sequence cursor for descending order. Omit on the first descending request to start from the newest event. - * @param {ListRunEventsOrderEnum} [order] Event sequence order. `since_seq` is valid only with `asc`; `before_seq` is valid only with `desc`. * @param {number} [after] Run stream cursor for a Petri run: the last `stream_seq` the client saw, exclusive. `0` starts at the first item. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public listRunEvents(id: string, sinceSeq?: number, limit?: number, beforeSeq?: number, order?: ListRunEventsOrderEnum, after?: number, options?: RawAxiosRequestConfig) { - return RunInternalsApiFp(this.configuration).listRunEvents(id, sinceSeq, limit, beforeSeq, order, after, options).then((request) => request(this.axios, this.basePath)); + public listRunEvents(id: string, limit?: number, after?: number, options?: RawAxiosRequestConfig) { + return RunInternalsApiFp(this.configuration).listRunEvents(id, limit, after, options).then((request) => request(this.axios, this.basePath)); } /** @@ -2281,20 +1987,6 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).listStageArtifacts(id, stageId, options).then((request) => request(this.axios, this.basePath)); } - /** - * Returns a paginated JSON list of stored run events scoped to a single stage visit. - * @summary List Stage Events - * @param {string} id Unique run identifier (ULID). - * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {number} [sinceSeq] First event sequence number to include. - * @param {number} [limit] Maximum number of events to return. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public listStageEvents(id: string, stageId: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig) { - return RunInternalsApiFp(this.configuration).listStageEvents(id, stageId, sinceSeq, limit, options).then((request) => request(this.axios, this.basePath)); - } - /** * Opens the run in the Petri run store for the worker. `create` inserts the run and takes its writer lease for `owner`; `write` takes the lease of an existing run; `read` takes no lease. The lease is idempotent per owner: a retry by the owner that holds it gets the same lease. Another live owner is refused with `petri_run_leased`. The lease ends when the worker releases it, when the server observes the worker exit, or by operator release, never by timeout. * @summary Open Petri Run @@ -2405,9 +2097,3 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).writeRunBlob(id, body, options).then((request) => request(this.axios, this.basePath)); } } - -export const ListRunEventsOrderEnum = { - ASC: 'asc', - DESC: 'desc' -} as const; -export type ListRunEventsOrderEnum = typeof ListRunEventsOrderEnum[keyof typeof ListRunEventsOrderEnum]; diff --git a/lib/packages/fabro-api-client/src/api/system-api.ts b/lib/packages/fabro-api-client/src/api/system-api.ts index e6a51a4a1..855888625 100644 --- a/lib/packages/fabro-api-client/src/api/system-api.ts +++ b/lib/packages/fabro-api-client/src/api/system-api.ts @@ -43,7 +43,7 @@ import type { SystemResourcesResponse } from '../models'; export const SystemApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * Opens a server-sent event stream for live run events across the server. + * Opens a server-sent event stream of every run\'s stream across the server: each `data:` frame is one `RunStreamItem`, as it is committed. * @summary Attach Global Events * @param {string} [runId] Optional comma-separated list of run IDs to include. * @param {*} [options] Override http request option. @@ -319,7 +319,7 @@ export const SystemApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = SystemApiAxiosParamCreator(configuration) return { /** - * Opens a server-sent event stream for live run events across the server. + * Opens a server-sent event stream of every run\'s stream across the server: each `data:` frame is one `RunStreamItem`, as it is committed. * @summary Attach Global Events * @param {string} [runId] Optional comma-separated list of run IDs to include. * @param {*} [options] Override http request option. @@ -415,7 +415,7 @@ export const SystemApiFactory = function (configuration?: Configuration, basePat const localVarFp = SystemApiFp(configuration) return { /** - * Opens a server-sent event stream for live run events across the server. + * Opens a server-sent event stream of every run\'s stream across the server: each `data:` frame is one `RunStreamItem`, as it is committed. * @summary Attach Global Events * @param {string} [runId] Optional comma-separated list of run IDs to include. * @param {*} [options] Override http request option. @@ -488,7 +488,7 @@ export const SystemApiFactory = function (configuration?: Configuration, basePat */ export class SystemApi extends BaseAPI { /** - * Opens a server-sent event stream for live run events across the server. + * Opens a server-sent event stream of every run\'s stream across the server: each `data:` frame is one `RunStreamItem`, as it is committed. * @summary Attach Global Events * @param {string} [runId] Optional comma-separated list of run IDs to include. * @param {*} [options] Override http request option. diff --git a/lib/packages/fabro-api-client/src/models/append-event-response.ts b/lib/packages/fabro-api-client/src/models/append-event-response.ts deleted file mode 100644 index dc5f95688..000000000 --- a/lib/packages/fabro-api-client/src/models/append-event-response.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Assigned sequence number for an appended event. - */ -export interface AppendEventResponse { - /** - * Assigned event sequence number. - */ - 'seq': number; -} diff --git a/lib/packages/fabro-api-client/src/models/event-envelope.ts b/lib/packages/fabro-api-client/src/models/event-envelope.ts deleted file mode 100644 index 1ff9fe51d..000000000 --- a/lib/packages/fabro-api-client/src/models/event-envelope.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { EventSeq } from './event-seq'; -// May contain unused imports in some cases -// @ts-ignore -import type { Principal } from './principal'; -// May contain unused imports in some cases -// @ts-ignore -import type { RunEvent } from './run-event'; - -/** - * @type EventEnvelope - * Stored event envelope with assigned sequence number. On the wire the envelope is flattened: seq sits alongside the RunEvent payload fields at the top level of the JSON object. - */ -export type EventEnvelope = EventSeq & RunEvent; diff --git a/lib/packages/fabro-api-client/src/models/event-seq.ts b/lib/packages/fabro-api-client/src/models/event-seq.ts deleted file mode 100644 index 948345b27..000000000 --- a/lib/packages/fabro-api-client/src/models/event-seq.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Assigned sequence number component of a stored event envelope. - */ -export interface EventSeq { - /** - * Assigned event sequence number. - */ - 'seq': number; -} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 876c38994..3bbc82543 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -25,7 +25,6 @@ export * from './agent-tools-available-props'; export * from './aggregate-usage'; export * from './aggregate-usage-totals'; export * from './api-question'; -export * from './append-event-response'; export * from './approval-mode'; export * from './artifact-batch-upload-entry'; export * from './artifact-batch-upload-manifest'; @@ -124,8 +123,6 @@ export * from './environment-resources-settings'; export * from './environment-settings'; export * from './error-response'; export * from './error-response-entry'; -export * from './event-envelope'; -export * from './event-seq'; export * from './exec-output-tail'; export * from './execute-query-request'; export * from './execute-query-response'; @@ -184,7 +181,6 @@ export * from './interview-option'; export * from './interview-provider-settings'; export * from './interview-question-record'; export * from './link-run-pull-request-request'; -export * from './list-run-events200-response'; export * from './llm-output-kind'; export * from './llm-retry-classification'; export * from './llm-retry-classification-after'; @@ -233,7 +229,6 @@ export * from './object-store-local-settings'; export * from './object-store-s3-settings'; export * from './object-store-settings'; export * from './paginated-api-question-list'; -export * from './paginated-event-list'; export * from './paginated-history-entry-list'; export * from './paginated-model-list'; export * from './paginated-run-commit-list'; @@ -360,10 +355,6 @@ export * from './run-control-action'; export * from './run-diff'; export * from './run-environment-settings'; export * from './run-error'; -export * from './run-event'; -export * from './run-event-detail-response'; -export * from './run-event-detail-response-content'; -export * from './run-event-detail-response-event'; export * from './run-execution-settings'; export * from './run-failure'; export * from './run-files-meta'; diff --git a/lib/packages/fabro-api-client/src/models/list-run-events200-response.ts b/lib/packages/fabro-api-client/src/models/list-run-events200-response.ts deleted file mode 100644 index ed35f86ed..000000000 --- a/lib/packages/fabro-api-client/src/models/list-run-events200-response.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { PaginatedEventList } from './paginated-event-list'; -// May contain unused imports in some cases -// @ts-ignore -import type { PaginatedRunStreamList } from './paginated-run-stream-list'; -// May contain unused imports in some cases -// @ts-ignore -import type { PaginationMeta } from './pagination-meta'; -// May contain unused imports in some cases -// @ts-ignore -import type { RunStreamItem } from './run-stream-item'; - -/** - * @type ListRunEvents200Response - */ -export type ListRunEvents200Response = PaginatedEventList | PaginatedRunStreamList; diff --git a/lib/packages/fabro-api-client/src/models/paginated-event-list.ts b/lib/packages/fabro-api-client/src/models/paginated-event-list.ts deleted file mode 100644 index 1d1dddd14..000000000 --- a/lib/packages/fabro-api-client/src/models/paginated-event-list.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { EventEnvelope } from './event-envelope'; -// May contain unused imports in some cases -// @ts-ignore -import type { PaginationMeta } from './pagination-meta'; - -/** - * Paginated list of stored run events. - */ -export interface PaginatedEventList { - 'data': Array; - 'meta': PaginationMeta; -} diff --git a/lib/packages/fabro-api-client/src/models/run-event-detail-response-content.ts b/lib/packages/fabro-api-client/src/models/run-event-detail-response-content.ts deleted file mode 100644 index cdbf7a8a3..000000000 --- a/lib/packages/fabro-api-client/src/models/run-event-detail-response-content.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface RunEventDetailResponseContent { - 'kind': RunEventDetailResponseContentKindEnum; - 'value': string; -} - -export const RunEventDetailResponseContentKindEnum = { - TEXT: 'text', - TOOL_OUTPUT: 'tool_output', - TOOL_ARGUMENTS: 'tool_arguments', - ERROR: 'error', - DETAILS: 'details' -} as const; - -export type RunEventDetailResponseContentKindEnum = typeof RunEventDetailResponseContentKindEnum[keyof typeof RunEventDetailResponseContentKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/run-event-detail-response-event.ts b/lib/packages/fabro-api-client/src/models/run-event-detail-response-event.ts deleted file mode 100644 index 0214b752b..000000000 --- a/lib/packages/fabro-api-client/src/models/run-event-detail-response-event.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { Principal } from './principal'; - -export interface RunEventDetailResponseEvent { - 'seq': number; - 'id': string; - 'ts': string; - 'run_id': string; - 'event': string; - 'actor'?: Principal; - 'session_id'?: string; - 'node_id'?: string; - 'node_label'?: string; - 'stage_id'?: string; - 'tool_call_id'?: string; -} diff --git a/lib/packages/fabro-api-client/src/models/run-event-detail-response.ts b/lib/packages/fabro-api-client/src/models/run-event-detail-response.ts deleted file mode 100644 index 800c08039..000000000 --- a/lib/packages/fabro-api-client/src/models/run-event-detail-response.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { RunEventDetailResponseContent } from './run-event-detail-response-content'; -// May contain unused imports in some cases -// @ts-ignore -import type { RunEventDetailResponseEvent } from './run-event-detail-response-event'; - -export interface RunEventDetailResponse { - 'event': RunEventDetailResponseEvent; - 'properties': { [key: string]: any; }; - 'content'?: RunEventDetailResponseContent; - 'truncated': boolean; - 'redacted': boolean; - 'max_content_length': number; -} diff --git a/lib/packages/fabro-api-client/src/models/run-event.ts b/lib/packages/fabro-api-client/src/models/run-event.ts deleted file mode 100644 index a27df1702..000000000 --- a/lib/packages/fabro-api-client/src/models/run-event.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.2.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { Principal } from './principal'; - -/** - * Internal RunEvent-compatible JSON payload. The server validates this body by deserializing into the typed RunEvent struct. - */ -export interface RunEvent { - [key: string]: any; - - 'id': string; - 'ts': string; - 'run_id': string; - 'node_id'?: string | null; - 'node_label'?: string | null; - /** - * Stage execution identity, formatted as \"{node_id}@{visit}\". - */ - 'stage_id'?: string | null; - /** - * Durable identity of one execution of a parallel node, formatted as \"{node_id}@{visit}\". - */ - 'parallel_group_id'?: string | null; - /** - * Durable identity of one branch within a parallel execution, in `{parallel_group_id}:{index}` form. - */ - 'parallel_branch_id'?: string | null; - 'session_id'?: string | null; - 'parent_session_id'?: string | null; - /** - * Stable identifier for a tool call, present on agent.tool.* events and other durable events that directly describe the same tool call. - */ - 'tool_call_id'?: string | null; - 'actor'?: Principal | null; - /** - * Event type discriminator. - */ - 'event': string; - 'properties'?: { [key: string]: any; }; -}