Serve the run stream as the only run event API

Step 4 of the legacy executor deletion, third commit: the legacy event
API and every reader of it go, so that the next commits can delete the
event log, its reducer and the types beneath them.

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-18 13:36:00 -04:00
parent 34996d630f
commit bc31b8d23a
No known key found for this signature in database
95 changed files with 1337 additions and 8830 deletions

View file

@ -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 `<subject>.<verb>` 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({

View file

@ -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 <PlatformRecordsPanelView records={records} projection={runStateQuery.data} />;
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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>): 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> = {}): Stage {
return baseMakeStage({
status: "succeeded",
@ -32,112 +18,11 @@ function makeStage(overrides: Partial<Stage> = {}): 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(
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
{node}
</SWRConfig>,
);
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<T>(
events: EventEnvelope[],
body: () => Promise<T>,
): Promise<T> {
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(<StagePopover runId="run-1" stage={stage} duration="1m 30s" />);
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(<StagePopover runId="run-1" stage={stage} duration="1m 30s" />);
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(<StagePopover runId="run-1" stage={stage} duration="12s" />);
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(<StagePopover runId="run-1" stage={stage} duration="12s" />);
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(<StagePopover runId="run-1" stage={stage} duration="--" />);
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(<StagePopover runId="run-1" stage={stage} duration="--" />);
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(<StagePopover runId="run-1" stage={stage} duration="3s" />);
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(<StagePopover runId="run-1" stage={stage} duration="3s" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Exit code");
expect(text).toContain("2");
},
);
});
});

View file

@ -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 (
<span
className="line-clamp-3 break-words text-fg-2"
title={truncated ? text : undefined}
>
{display}
</span>
);
}
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 (
<PopoverRow label="Attempt">
{max && max > 1 ? `${summary.attempt} of ${max}` : `${summary.attempt}`}
</PopoverRow>
);
}
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 (
<PopoverRow label="Tokens">
<span className="font-mono tabular-nums">
@ -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 ? (
<PopoverRow label="Cancelled by">{summary.systemActor}</PopoverRow>
) : null;
case "running":
return (
<>
<AttemptRow summary={summary} />
<ModelRow providerUsed={stage.providerUsed} />
</>
);
case "retrying":
return (
<>
<AttemptRow summary={summary} />
{summary.failureMessage && (
<PopoverRow label="Previous failure">
<TruncatedReason text={summary.failureMessage} />
</PopoverRow>
)}
</>
);
case "failed":
return <ModelRow providerUsed={stage.providerUsed} />;
case "succeeded":
return (
<>
<ModelRow providerUsed={stage.providerUsed} />
<TokensRow summary={summary} />
{summary.filesTouchedCount !== undefined && summary.filesTouchedCount > 0 && (
<PopoverRow label="Files touched">{summary.filesTouchedCount}</PopoverRow>
)}
</>
);
case "partially_succeeded":
return (
<>
{summary.notes && (
<PopoverRow label="Notes">
<TruncatedReason text={summary.notes} />
</PopoverRow>
)}
<ModelRow providerUsed={stage.providerUsed} />
<TokensRow summary={summary} />
<TokensRow usage={stage.usage} />
</>
);
case "failed": {
const isCommand = stage.handler === "command";
return (
<>
{summary.failureMessage ? (
<PopoverRow label="Reason">
<TruncatedReason text={summary.failureMessage} />
</PopoverRow>
) : loading ? (
<LoadingRow />
) : null}
<AttemptRow summary={summary} />
{isCommand && summary.exitCode !== undefined ? (
<PopoverRow label="Exit code">
<span className="font-mono tabular-nums">{summary.exitCode}</span>
</PopoverRow>
) : (
<ModelRow providerUsed={stage.providerUsed} />
)}
</>
);
}
case "skipped":
return summary.notes ? (
<PopoverRow label="Reason">
<TruncatedReason text={summary.notes} />
</PopoverRow>
) : loading ? (
<LoadingRow />
) : null;
default:
return null;
}
}
function LoadingRow() {
return (
<>
<dt className="text-fg-3" />
<dd className="text-fg-muted italic">Loading details…</dd>
</>
);
}
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 (
<div className="min-w-[14rem]">
<PopoverHeader>
@ -241,7 +118,7 @@ export function StagePopover({ runId, stage, duration }: StagePopoverProps) {
<span className="font-mono tabular-nums">{stage.graphVisit}</span>
</PopoverRow>
)}
<StatusTail stage={stage} summary={summary} loading={loading} />
<StatusTail stage={stage} />
</PopoverRows>
</div>
);

View file

@ -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<string, string> = {
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;

View file

@ -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>): 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(<FanInResults stage={fanInStage} events={events} />);
renderer = TestRenderer.create(<FanInResults stage={fanInStage} reducer={reducer} />);
});
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.");

View file

@ -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 (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage}>

View file

@ -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();
});
});

View file

@ -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<string> = 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<string, HumanInterviewPair>();
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<string, unknown>;
}
// 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<string, unknown> = {};
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 };

View file

@ -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;

View file

@ -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>): 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(
<MemoryRouter>
<ParallelChildren
stage={stage}
events={events}
overview={forkOverview}
runId="run-1"
allStages={allStages}
/>
@ -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", () => {
<MemoryRouter>
<ParallelChildren
stage={parallelStage}
events={[
event({
properties: {
duration_ms: 100,
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: {},
},
],
},
}),
]}
overview={{
branchCount: 2,
results: [
{ id: "reviewer", index: 0, itemLabel: "auth", status: StageOutcome.SUCCEEDED },
{ id: "reviewer", index: 1, itemLabel: "api", status: StageOutcome.SUCCEEDED },
],
}}
runId="run-1"
allStages={[
{

View file

@ -2,12 +2,10 @@ import { useMemo } from "react";
import { Link } from "react-router";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import { StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview } from "./helpers";
import type { ParallelBranchSummary, ParallelOverview } from "./helpers";
/** Branch row view state sourced from a live branch stage or completed result. */
@ -98,23 +96,16 @@ function ChildRow({
export function ParallelChildren({
stage,
events,
overview: givenOverview,
overview,
runId,
allStages,
}: {
stage: Stage;
events: EventEnvelope[];
/** The overview when the caller derived it (a Petri run's projection). */
overview?: ParallelOverview;
/** The fork's branch count and results from the projection. */
overview: ParallelOverview;
runId: string;
allStages: Stage[];
}) {
const overview = useMemo(
() => givenOverview ?? parseParallelOverview(events),
[givenOverview, events],
);
const stagesByBranchIndex = useMemo(() => {
const byIndex = new Map<number, Stage>();
for (const candidate of allStages) {

View file

@ -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<DebugCategory, number>();
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 }))

View file

@ -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<string, unknown>, 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();
});
});

View file

@ -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<string>());
const seenItemKeysRef = useRef(new Set<string>());
useEffect(() => {
if (!runId) return;
const seen = new Set<string>();
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;
}
}

View file

@ -360,50 +360,6 @@ export async function fetchAllPages<TItem, TExtra extends object = {}>(
}
}
export async function fetchAllStageEvents<TItem extends { seq: number }>(
label: string,
loadPage: (sinceSeq: number, limit: number) => Promise<PaginatedEnvelope<TItem>>,
): Promise<TItem[]> {
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 } : {};
}

View file

@ -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);

View file

@ -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 `<subject>.<verb>`.
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<string, SharedEventSubscription>();
@ -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()
: [],
};
}

View file

@ -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) {

View file

@ -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;
}

View file

@ -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"]);

View file

@ -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<string, unknown>;
}
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<LiveEventPayload>({
const forward = (payload: EventPayload) => {
if (isStreamItemPayload(payload)) onItem(payload);
return { keys: NO_KEYS };
};
return subscribeToCrossTabSse<EventPayload>({
coordinator,
subscriptionKey: SUBSCRIPTION_KEY,
mutate: NOOP_MUTATE,
debounceMs: 0,
resyncKeys: () => NO_KEYS,
resolveInvalidation: (payload) => {
onEvent(payload);
return { keys: NO_KEYS };
},
resolveInvalidation: forward,
fallbackSubscribe: () =>
subscribeToSharedEventSource<LiveEventPayload>({
subscribeToSharedEventSource<EventPayload>({
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));
}, []);
}

View file

@ -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);
});

View file

@ -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",

View file

@ -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<EventEnvelope[]>(
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<ListRunEvents200Response, { event_contract_version: number }> {
return "event_contract_version" in page;
}
export function useRunEventsList(id: string | undefined) {
return useSWR<EventEnvelope[]>(
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<RunStreamItem[]> {
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<RunStreamItem[]> {
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<RunStreamItem[]>(
id ? queryKeys.runs.stream(id) : null,

View file

@ -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) =>

View file

@ -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"),
]);
}
});
});

View file

@ -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<string, unknown>;
/** 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<string, SharedEventSubscription>();
const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]);
const RUN_SUMMARY_EVENTS = new Set([
"run.submitted",
"run.start_requested",
"run.pending",
"run.approved",
"run.denied",
"run.runnable",
"run.starting",
"run.running",
"run.paused",
"run.unpaused",
"run.blocked",
"run.unblocked",
"run.cancel.requested",
"run.pause.requested",
"run.unpause.requested",
"run.archived",
"run.unarchived",
"run.title.updated",
"pull_request.created",
"pull_request.linked",
"pull_request.unlinked",
]);
const STAGE_EVENTS = new Set([
"stage.started",
"stage.completed",
"stage.failed",
"stage.retrying",
]);
// Single source of truth: every event type the `eventsToActivity` reducer in
// `routes/run-stages.tsx` consumes. When any of these arrive for a stage we
// currently view, the stage-events SWR key for that stage must be invalidated
// so the panel refetches. The reducer imports this list so the switch stays
// in sync with the invalidation set; if the reducer grows a new case, this
// list is the single edit point.
//
// The lifecycle `STAGE_EVENTS` set is kept separate because it also fans out
// to run-scoped invalidations (stages list, graph, detail).
export const STAGE_ACTIVITY_EVENT_TYPES = [
"stage.prompt",
"prompt.completed",
"agent.message",
"agent.tool.started",
"agent.tool.completed",
"agent.steering.injected",
"agent.interrupt.injected",
"agent.round.interrupted",
"agent.pair.user_message",
"agent.pair.system_message",
"command.started",
"command.completed",
] as const;
export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number];
const STAGE_ACTIVITY_EVENTS = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
// Parallel branches bypass the engine's `stage.started` / `stage.completed`
// lifecycle (the parallel handler dispatches each branch directly), so
// `STAGE_EVENTS` never fires for them. Without this set the stages list never
// refetches while a fork runs and branch rows stay frozen at their first
// observed state.
const PARALLEL_EVENTS = new Set([
"parallel.started",
"parallel.branch.started",
"parallel.branch.completed",
"parallel.completed",
]);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
"interview.timeout",
"interview.interrupted",
]);
const STEERING_EVENTS = new Set([
"run.interrupt",
"run.steer",
"agent.steering.injected",
"agent.interrupt.injected",
"agent.round.interrupted",
"agent.session.activated",
"agent.session.deactivated",
"agent.steer.buffered",
"agent.steer.dropped",
]);
const AGENT_CONTROL_STATE_EVENTS = new Set([
"agent.round.interrupted",
"agent.steering.injected",
"agent.session.deactivated",
]);
const INFERENCE_EVENTS = new Set([
"agent.llm.started",
"agent.llm.first_output",
"agent.llm.retry",
"agent.message",
"agent.error",
"agent.session.ended",
]);
const INFERENCE_TIMING_EVENTS = new Set([
"agent.llm.started",
"agent.message",
"agent.error",
"agent.session.ended",
]);
const TOOL_TIMING_EVENTS = new Set([
"agent.tool.started",
"agent.tool.completed",
]);
const ACP_TIMING_EVENTS = new Set([
"agent.acp.started",
"agent.acp.completed",
"agent.acp.cancelled",
"agent.acp.timed_out",
]);
// Todo / task mutation events refresh `getRunState` consumers (so per-stage
// todo projections update live) and the run events list.
const TODO_EVENTS = new Set([
"todo.created",
"todo.updated",
"todo.deleted",
]);
// 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 `<subject>.<verb>`; a platform record by its `kind`. The stage
* keys use the subject's `node@visit` label, which is the stage id the
* projection keys stages by.
@ -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<RunEventPayload>({
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<RunEventPayload>({
@ -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.

View file

@ -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,
},
]);
});
});

View file

@ -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<RunPhaseKind, string> = {
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<EventEnvelope> | 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;
}

View file

@ -4,8 +4,8 @@ export type KeyMatcher = (key: Key) => boolean;
export type KeyOrMatcher = Key | KeyMatcher;
export type MutateFn = (key: KeyOrMatcher) => ReturnType<MutatorCallback>;
/** A parsed SSE frame: a run stream item, read through `isStreamItemPayload`. */
export interface EventPayload {
event?: string;
[key: string]: unknown;
}

View file

@ -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>,
): EventEnvelope {
record: UnknownRecord & { kind: string },
overrides: Partial<RunStreamItem> = {},
): 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> = {},
): 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. */

View file

@ -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" ? (
<WaterfallPane
runId={id!}
events={petri ? (streamQuery.data ? EMPTY_EVENTS : undefined) : eventsQuery.data}
phases={streamPhases}
eventsError={petri ? streamQuery.error : eventsQuery.error}
eventsError={streamQuery.error}
stagesData={stagesQuery.data}
stagesError={stagesQuery.error}
createdAt={runQuery.data?.timestamps.created_at}
completedAt={runQuery.data?.timestamps.completed_at ?? null}
onRetry={() => {
void (petri ? streamQuery.mutate() : eventsQuery.mutate());
void streamQuery.mutate();
void stagesQuery.mutate();
}}
view={view}
onChangeView={setView}
/>
) : petri ? (
) : (
<StreamEventsView
rows={streamRows}
error={streamQuery.error}
@ -125,18 +104,6 @@ export default function RunEvents() {
view={view}
onChangeView={setView}
/>
) : (
<EventsView
events={eventsQuery.data}
error={eventsQuery.error}
onRetry={() => void eventsQuery.mutate()}
runStart={
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at
}
view={view}
onChangeView={setView}
/>
)}
</div>
);
@ -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<typeof useRunStages>["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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
@ -228,8 +194,7 @@ function WaterfallPane({
) : (
<RunWaterfall
runId={runId}
events={events!}
phases={phases}
phases={phases!}
stages={stagesData!.data ?? []}
createdAtIso={createdAt!}
completedAtIso={completedAt}
@ -239,153 +204,8 @@ function WaterfallPane({
);
}
function EventsView({
events,
error,
onRetry,
runStart,
view,
onChangeView,
}: {
events: EventEnvelope[] | undefined;
error: unknown;
onRetry: () => void;
runStart: string | undefined;
view: ViewMode;
onChangeView: (v: ViewMode) => void;
}) {
const [openSeq, setOpenSeq] = useState<number | null>(null);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [search, setSearch] = useState("");
const all = events ?? EMPTY_EVENTS;
const availableCategories = useMemo<string[]>(() => {
const set = new Set<string>();
for (const event of all) {
if (event.event) set.add(debugCategory(event.event));
}
return Array.from(set).sort();
}, [all]);
const filtered = useMemo<EventEnvelope[]>(() => {
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<EventEnvelope | null>(
() => (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 (
<div className="min-w-0 flex-1 pt-3">
<ErrorState
title="Couldn't load events"
description={errorMessage(error)}
onRetry={onRetry}
/>
</div>
);
}
if (events === undefined) {
return (
<div className="min-w-0 flex-1 pt-3">
<LoadingState label="Loading events…" />
</div>
);
}
return (
<>
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
<div className="shrink-0 border-b border-line">
<div className="pl-3 pr-4 sm:pr-6 lg:pr-8">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
<div className="flex flex-1 flex-wrap items-center gap-2">
<ViewToggle value={view} onChange={onChangeView} />
<MultiSelectFilter<string>
selected={selectedCategories}
options={availableCategories}
labelOf={debugCategoryLabel}
onChange={setSelectedCategories}
emptyMeansAll
/>
<EventSearchInput value={search} onChange={setSearch} />
{isFiltering && (
<button
type="button"
onClick={clearFilters}
className="rounded px-2 py-1 text-xs text-fg-muted transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
Clear
</button>
)}
</div>
{all.length > 0 && (
<span className="text-xs tabular-nums text-fg-muted">
{isFiltering
? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} events`
: `${all.length.toLocaleString()} events`}
</span>
)}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
{all.length === 0 ? (
<div className="px-2 py-12">
<EmptyState
title="No events yet"
description="Events will appear here as the run executes."
/>
</div>
) : filtered.length === 0 ? (
<div className="px-2 py-6 text-sm text-fg-muted">
No events match these filters.
</div>
) : (
filtered.map((event) => (
<DebugEventRow
key={`event-${event.seq}`}
event={event}
runStart={runStart}
selected={openSeq === event.seq}
onSelect={() => setOpenSeq(event.seq)}
/>
))
)}
</div>
</div>
<DebugEventDetailsPanel event={openEvent} onClose={() => 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 (`<subject>.<verb>`) or the platform record kind, with the
* raw item in the details panel.
*/

View file

@ -21,7 +21,6 @@ mock.module("../lib/queries", () => ({
mutate: graphMutateMock,
}),
useRunGraphSource: () => ({ data: undefined }),
useRunStageEvents: () => ({ data: [] }),
useRunState: () => ({ data: undefined }),
useRunStream: () => ({ data: undefined }),
}));

View file

@ -108,7 +108,6 @@ describe("a command-only run", () => {
const html = render(
<RunWaterfall
runId="run-1"
events={[]}
phases={deriveRunPhasesFromStream(fixture.stream, createdAt(fixture))}
stages={runStages(fixture)}
createdAtIso={createdAt(fixture)}

View file

@ -1,13 +1,12 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { RunStreamItem, StageProjection } from "@qltysh/fabro-api-client";
import {
buildChatItems,
buildStageActivity,
buildPetriStageActivity,
buildThreadDnaItems,
EVENT_KINDS,
eventsTabLabel,
eventsToActivity,
filterDisplayItems,
filterThreadDnaItems,
formatStageModelUsageLabel,
@ -20,16 +19,20 @@ import {
type EventKind,
} from "./run-stages";
import { threadSelectionId } from "../components/event-debug";
import { makeUsage } from "../lib/test-fixtures";
import { makePebbleItem } from "../lib/test-utils";
import type { UnknownRecord } from "../lib/unknown";
function envelope(seq: number, partial: Partial<EventEnvelope>): 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<string, unknown>) {
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 })));
}

View file

@ -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<string>(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<string, PendingTool>();
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<typeof extractStageContext>;
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 (
<div className="min-h-0 flex-1 overflow-y-auto pt-6 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
{effectiveTab === "chat" ? (
@ -2371,29 +2135,23 @@ function StageActivityBody({
turn={commandTurn}
/>
) : renderer === "human" ? (
<HumanQA stage={selectedStage} events={legacyEvents} pairs={petri?.pairs} />
<HumanQA stage={selectedStage} pairs={petri.pairs} />
) : renderer === "conditional" ? (
<ConditionalDecision
stage={selectedStage}
runEvents={runEvents}
edge={petri?.edge}
edge={petri.edge}
allStages={stages}
runId={runId}
/>
) : renderer === "parallel" ? (
<ParallelChildren
stage={selectedStage}
events={legacyEvents}
overview={petri?.overview}
overview={petri.overview}
runId={runId}
allStages={stages}
/>
) : renderer === "fan_in" ? (
<FanInResults
stage={selectedStage}
events={legacyEvents}
reducer={petri?.reducer}
/>
<FanInResults stage={selectedStage} reducer={petri.reducer} />
) : renderer === "wait" ? (
<WaitStatus stage={selectedStage} />
) : (
@ -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<RunStreamItem[]>(
() => (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<DebugListItem[]>(() => {
if (petri) return debugRowsFromStream(stageItems);
return (stageEventsQuery.data ?? []).filter(
(event) => activityEventStageId(event) === selectedStageId,
);
}, [petri, stageItems, stageEventsQuery.data, selectedStageId]);
const petriViews = useMemo<PetriStageViews | undefined>(
() =>
petri
? {
pairs: parsePetriInterviewPairs(stageItems),
edge: findPetriEdgeForStage(petri.stream, selectedStageId),
overview: parallelOverviewFromProjection(stageProjection),
reducer: reducerTranscriptFromProjection(stageProjection),
}
: undefined,
[petri, stageItems, stageProjection, selectedStageId],
const debugEvents = useMemo<DebugRow[]>(
() => debugRowsFromStream(stageItems),
[stageItems],
);
const petriViews = useMemo<PetriStageViews>(
() => ({
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<EventsTab[]>(
() =>
@ -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<CommandTurn | null>(() => {
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<DebugListItem[]>(() => {
const filteredDebugEvents = useMemo<DebugRow[]>(() => {
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<PetriRunData | undefined>(
() =>
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,

View file

@ -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", () => {

View file

@ -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"]);
});
});

View file

@ -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<LiveEventPayload[]>([]);
const [events, setEvents] = useState<RunStreamItem[]>([]);
const [openKey, setOpenKey] = useState<string | null>(null);
const [selectedCategories, setSelectedCategories] = useState<DebugCategory[]>([]);
const [search, setSearch] = useState("");
useLiveEventsSubscription((payload) => {
setEvents((prev) => appendLiveEvent(prev, payload));
useLiveEventsSubscription((item) => {
setEvents((prev) => appendLiveEvent(prev, item));
});
const filtered = useMemo<LiveEventPayload[]>(() => {
const rows = useMemo(() => debugRowsFromStream(events), [events]);
const filtered = useMemo<DebugRow[]>(() => {
const useCategoryFilter = selectedCategories.length > 0;
const cats = new Set<DebugCategory>(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<LiveEventPayload | null>(
() => (openKey != null ? events.find((e) => rowKey(e) === openKey) ?? null : null),
[events, openKey],
const openRow = useMemo<DebugRow | null>(
() => (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.
</div>
) : (
filtered.map((event) => (
filtered.map((row) => (
<LiveEventRow
key={rowKey(event)}
event={event}
selected={openKey === rowKey(event)}
onSelect={() => setOpenKey(rowKey(event))}
key={rowKey(row.item)}
row={row}
selected={openKey === rowKey(row.item)}
onSelect={() => setOpenKey(rowKey(row.item))}
/>
))
)}
</div>
</div>
<DebugEventDetailsPanel event={openEvent} onClose={() => setOpenKey(null)} />
<DebugEventDetailsPanel event={openPayload} onClose={() => setOpenKey(null)} />
</div>
);
}
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<HTMLDivElement>) {
if (e.key === "Enter" || e.key === " ") {
@ -196,30 +213,22 @@ function LiveEventRow({
{eventName}
</span>
<span className="min-w-0 truncate font-mono text-xs">
{event.run_id ? (
<Link
to={`/runs/${event.run_id}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-3 hover:text-fg hover:underline"
>
{event.run_id}
</Link>
) : (
<span className="text-fg-muted">No run</span>
)}
<Link
to={`/runs/${runId}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-3 hover:text-fg hover:underline"
>
{runId}
</Link>
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-muted">
{stage ?? ""}
</span>
{event.ts ? (
<Tooltip label={formatAbsoluteTs(event.ts)}>
<span className="font-mono text-xs tabular-nums text-fg-muted">
{formatAbsoluteTs(event.ts)}
</span>
</Tooltip>
) : (
<span className="font-mono text-xs text-fg-muted">No time</span>
)}
<Tooltip label={formatAbsoluteTs(row.ts)}>
<span className="font-mono text-xs tabular-nums text-fg-muted">
{formatAbsoluteTs(row.ts)}
</span>
</Tooltip>
</div>
);
}

View file

@ -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;

View file

@ -79,8 +79,8 @@ async fn write_run_dump(
state: &RunProjection,
output_dir: &Path,
) -> Result<usize> {
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);

View file

@ -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<String>,
base_sha: Option<String>,
},
WorkingDirectorySet {
working_directory: String,
},
SandboxInitializing {
provider: String,
},
SandboxReady {
provider: String,
duration_ms: u64,
name: Option<String>,
url: Option<String>,
},
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<ProgressEvent> {
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<ProgressEvent> {
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<ProgressEvent> {
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<ProgressEvent> {
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 <reference>`; 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"
));
}
}

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -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");

View file

@ -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<String>,
pub(super) sandbox_bar: Option<ProgressBar>,
pub(super) setup_bar: Option<ProgressBar>,
pub(super) setup_command_count: u64,
pub(super) cli_ensure_bar: Option<ProgressBar>,
}
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...")
}
}

View file

@ -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<u64>) {
);
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)");
}
}

View file

@ -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::<Vec<_>>().join(" ");
if single_line.len() > max {

View file

@ -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::<chrono::Utc>::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(())
}

View file

@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
let first_251_events = events.iter().take(251).cloned().collect::<Vec<_>>();
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,

View file

@ -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()

View file

@ -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<RunEvent> {
run_events(run_dir).iter().map(run_event).collect()
fn stored_worker_events(run_dir: &std::path::Path) -> Vec<RunStreamItem> {
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)]

View file

@ -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<EventEnvelope> {
pub(crate) fn run_events(run_dir: &Path) -> Vec<RunStreamItem> {
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::<Vec<_>>();
if expected

View file

@ -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();
}

View file

@ -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<EventEnvelope> {
async fn run_events(api_base_url: &str, run_id: &str, access_token: &str) -> Vec<RunStreamItem> {
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<EventEnvelope> {
) -> Vec<RunStreamItem> {
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());

View file

@ -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}"
);
}

View file

@ -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<EventEnvelope> {
/// 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<RunStreamItem> {
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::<Result<Vec<_>, _>>()
.expect("wire event envelope list should parse")
.expect("run stream items should parse")
}
pub(crate) struct LightweightCli {

View file

@ -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<String> {
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<EventEnvelope> {
fn run_events(run_dir: &Path) -> Vec<RunStreamItem> {
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:

View file

@ -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<T: serde::Serialize>(
items: Vec<T>,
@ -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<Arc<AppState>>,
Path((_id, stage_id)): Path<(String, String)>,
Query(params): Query<EventListParams>,
) -> 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<EventEnvelope> = 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<Arc<AppState>>,
@ -488,18 +449,23 @@ pub(crate) async fn run_events_stub(
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> 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<fabro_types::EventEnvelope> {
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<pebble_coding_agent::events::CodingAgentEvent> {
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);

View file

@ -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::{

View file

@ -26,25 +26,6 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
.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<u32>,
#[serde(default)]
limit: Option<usize>,
}
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)]

View file

@ -120,10 +120,6 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
.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),

View file

@ -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<u64> = data
.iter()
.map(|envelope| envelope["seq"].as_u64().expect("seq is a number"))
.collect();
assert_eq!(seqs, (4..=24).collect::<Vec<u64>>());
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());
}

View file

@ -4,7 +4,6 @@
)]
mod api;
mod event_pagination;
mod helpers;
mod openapi_conformance;
mod pagination;

View file

@ -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);

View file

@ -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<dyn FnMut(BlobHash) -> BoxFuture<'static, Result<Option<Bytes>>> + 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<Self> {
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

View file

@ -72,18 +72,14 @@ pub trait FabroToolBackend: Send + Sync {
async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> anyhow::Result<Run>;
async fn unlink_run_parent(&self, child_id: &RunId) -> anyhow::Result<Run>;
async fn get_run_state(&self, run_id: &RunId) -> anyhow::Result<fabro_types::RunProjection>;
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<u32>,
after: u64,
limit: Option<usize>,
) -> anyhow::Result<Vec<fabro_types::EventEnvelope>>;
async fn list_run_events_until(
&self,
run_id: &RunId,
after: Option<u32>,
limit: usize,
) -> anyhow::Result<Vec<fabro_types::EventEnvelope>>;
) -> anyhow::Result<Vec<fabro_types::RunStreamItem>>;
async fn list_run_questions(&self, run_id: &RunId) -> anyhow::Result<Vec<types::ApiQuestion>>;
async fn submit_run_answer(
&self,

View file

@ -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<String>,
pub created_before: Option<String>,
pub first: Option<usize>,
pub after: Option<u32>,
pub after: Option<u64>,
pub event_ids: Option<Vec<String>>,
pub offset: Option<usize>,
pub limit: Option<usize>,
@ -100,13 +103,15 @@ pub struct RunEventsResult {
pub run_id: String,
pub action: RunEventsAction,
pub events: Vec<RunEventResult>,
pub next_cursor: Option<u32>,
/// The `after` cursor for the next page: the last `stream_seq` returned.
pub next_cursor: Option<u64>,
}
/// 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::<ToolResult<Vec<_>>>()?;
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<usize> {
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<usiz
Some(requested.max(1))
}
fn filter_events(
events: &mut Vec<EventEnvelope>,
fn filter_items(
items: &mut Vec<RunStreamItem>,
params: &FabroRunEventsParams,
created_after: Option<DateTime<Utc>>,
created_before: Option<DateTime<Utc>>,
) {
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<RunEventResult> {
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<Utc> {
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<RunEventResult> {
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, &params, 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,
&params,
DateTime::from_timestamp_millis(2_500),
None,
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].stream_seq, 3);
}
}

View file

@ -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<u32>,
after: u64,
limit: Option<usize>,
) -> anyhow::Result<Vec<EventEnvelope>> {
) -> anyhow::Result<Vec<RunStreamItem>> {
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<u32>,
limit: usize,
) -> anyhow::Result<Vec<EventEnvelope>> {
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<Vec<types::ApiQuestion>> {

View file

@ -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<u32>,
_after: u64,
_limit: Option<usize>,
) -> anyhow::Result<Vec<EventEnvelope>> {
unreachable!()
}
async fn list_run_events_until(
&self,
_run_id: &RunId,
_after: Option<u32>,
_limit: usize,
) -> anyhow::Result<Vec<EventEnvelope>> {
) -> anyhow::Result<Vec<fabro_types::RunStreamItem>> {
unreachable!()
}

View file

@ -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", &[]),

View file

@ -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,

View file

@ -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::<ApiEventEnvelope, EventEnvelope>();
}
#[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<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -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::<ApiPairMessageRecord, PairMessageRecord>();
assert_same_type::<ApiPairTranscriptEntry, PairTranscriptEntry>();
assert_same_type::<ApiPairTranscriptResponse, PairTranscriptResponse>();
assert_same_type::<ApiRunEventDetailResponse, RunEventDetailResponse>();
}
#[test]
@ -139,37 +137,6 @@ fn pair_transcript_response_round_trips_json() {
}));
}
#[test]
fn run_event_detail_response_round_trips_json() {
assert_round_trip::<RunEventDetailResponse>(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",

View file

@ -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::<ApiRunEvent, RunEvent>();
}
#[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<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -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<u8>,
buffered_events: VecDeque<EventEnvelope>,
}
/// 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<String>,
}
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<Option<EventEnvelope>> {
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<u32>,
) -> Result<RunEventDetailResponse> {
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<Run> {
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<u32>,
limit: Option<usize>,
) -> Result<Vec<EventEnvelope>> {
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<Vec<EventEnvelope>> {
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<EventEnvelope> = 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<u32>,
max_events: usize,
) -> Result<Vec<EventEnvelope>> {
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<usize>,
) -> Result<(Vec<EventEnvelope>, 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::<Result<Vec<EventEnvelope>>>()?;
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<Vec<RunStreamItem>> {
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<u32>,
) -> Result<RunEventStream> {
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<Vec<types::ApiQuestion>> {
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<u32> {
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<BlobHash> {
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<u32> },
Descending { before_seq: Option<u32> },
}
fn non_zero_u64_from_u32(value: u32) -> Option<NonZeroU64> {
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<WorkflowPath, WorkflowVersionId>,
@ -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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(seqs, vec![3, 4, 5]);
}
#[tokio::test]
async fn test_provider_credentials_posts_api_key() {
let server = MockServer::start_async().await;

View file

@ -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::{

View file

@ -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::{

View file

@ -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<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<RunEventDetailContent>,
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<Utc>,
pub run_id: RunId,
pub event: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<crate::Principal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage_id: Option<StageId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunEventDetailContent {
pub kind: RunEventDetailContentKind,
pub value: String,
}

View file

@ -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

View file

@ -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<RequestArgs> => {
// 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 &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; starts at the first item.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
attachRunEvents: async (id: string, sinceSeq?: number, after?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
attachRunEvents: async (id: string, after?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
// 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. &#x60;since_seq&#x60; is valid only with &#x60;asc&#x60;; &#x60;before_seq&#x60; is valid only with &#x60;desc&#x60;.
* @param {number} [after] Run stream cursor for a Petri run: the last &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; 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<RequestArgs> => {
listRunEvents: async (id: string, limit?: number, after?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @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<RequestArgs> => {
// 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<AppendEventResponse>> {
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 &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; 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<string>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.attachRunEvents(id, sinceSeq, after, options);
async attachRunEvents(id: string, after?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>> {
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<RunEventDetailResponse>> {
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. &#x60;since_seq&#x60; is valid only with &#x60;asc&#x60;; &#x60;before_seq&#x60; is valid only with &#x60;desc&#x60;.
* @param {number} [after] Run stream cursor for a Petri run: the last &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; 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<ListRunEvents200Response>> {
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<PaginatedRunStreamList>> {
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\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @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<PaginatedEventList>> {
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<AppendEventResponse> {
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 &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; starts at the first item.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
attachRunEvents(id: string, sinceSeq?: number, after?: number, options?: RawAxiosRequestConfig): AxiosPromise<string> {
return localVarFp.attachRunEvents(id, sinceSeq, after, options).then((request) => request(axios, basePath));
attachRunEvents(id: string, after?: number, options?: RawAxiosRequestConfig): AxiosPromise<string> {
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<File> {
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<RunEventDetailResponse> {
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. &#x60;since_seq&#x60; is valid only with &#x60;asc&#x60;; &#x60;before_seq&#x60; is valid only with &#x60;desc&#x60;.
* @param {number} [after] Run stream cursor for a Petri run: the last &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; 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<ListRunEvents200Response> {
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<PaginatedRunStreamList> {
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<ArtifactListResponse> {
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\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @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<PaginatedEventList> {
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 &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; 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. &#x60;since_seq&#x60; is valid only with &#x60;asc&#x60;; &#x60;before_seq&#x60; is valid only with &#x60;desc&#x60;.
* @param {number} [after] Run stream cursor for a Petri run: the last &#x60;stream_seq&#x60; the client saw, exclusive. &#x60;0&#x60; 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\&#39;s workflow graph, serialized as &#x60;node_id@visit&#x60;.
* @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];

View file

@ -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.

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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';

View file

@ -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;

View file

@ -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<EventEnvelope>;
'meta': PaginationMeta;
}

View file

@ -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];

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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; };
}