Merge branch 'petri-integration-api' into petri-integration

# Conflicts:
#	lib/apps/fabro-cli/tests/it/scenario/petri.rs
This commit is contained in:
Bryan Helmkamp 2026-09-18 01:39:53 -04:00
commit 26429a7444
No known key found for this signature in database
65 changed files with 29139 additions and 224 deletions

View file

@ -5,7 +5,9 @@ export type DebugCategory =
| "command"
| "lifecycle"
| "human"
| "system";
| "system"
| "petri"
| "platform";
export const DEBUG_CATEGORIES: readonly DebugCategory[] = [
"agent",
@ -13,6 +15,8 @@ export const DEBUG_CATEGORIES: readonly DebugCategory[] = [
"lifecycle",
"human",
"system",
"petri",
"platform",
] as const;
const PREFIX_TO_CATEGORY: Record<string, DebugCategory> = {
@ -34,6 +38,8 @@ const CATEGORY_LABEL: Record<DebugCategory, string> = {
lifecycle: "Lifecycle",
human: "Human",
system: "System",
petri: "Petri",
platform: "Platform",
};
const CATEGORY_TONE: Record<DebugCategory, string> = {
@ -42,6 +48,8 @@ const CATEGORY_TONE: Record<DebugCategory, string> = {
lifecycle: "bg-amber/15 text-amber",
human: "bg-coral/15 text-coral",
system: "bg-overlay-strong text-fg-3",
petri: "bg-teal-500/15 text-teal-500",
platform: "bg-amber/15 text-amber",
};
const CATEGORY_COLOR: Record<DebugCategory, string> = {
@ -50,6 +58,8 @@ const CATEGORY_COLOR: Record<DebugCategory, string> = {
lifecycle: "var(--color-amber)",
human: "var(--color-coral)",
system: "var(--color-ice-300)",
petri: "var(--color-teal-500)",
platform: "var(--color-amber)",
};
export function debugCategory(eventName: string | null | undefined): DebugCategory {

View file

@ -12,7 +12,6 @@ import {
FunnelIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/16/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { Tooltip } from "./ui";
import { formatAbsoluteTs } from "../lib/format";
@ -28,19 +27,36 @@ import {
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).
*/
export interface DebugRowLike {
seq: number;
event?: string | null;
ts: string;
category?: DebugCategory;
}
export function debugRowCategory(row: DebugRowLike): DebugCategory {
return row.category ?? debugCategory(row.event);
}
export function DebugEventRow({
event,
runStart,
selected,
onSelect,
}: {
event: EventEnvelope;
event: DebugRowLike;
runStart: string | undefined;
selected: boolean;
onSelect: () => void;
}) {
const eventName = event.event ?? "";
const category = debugCategory(eventName);
const category = debugRowCategory(event);
return (
<button
type="button"
@ -291,7 +307,7 @@ export function DebugDnaStrip({
onSelect,
runStart,
}: {
events: EventEnvelope[];
events: DebugRowLike[];
selectedSeq: number | null;
onSelect: (seq: number) => void;
runStart: string | undefined;
@ -351,7 +367,7 @@ export function DebugDnaStrip({
const ms = Date.parse(event.ts);
if (Number.isNaN(ms)) return null;
const pct = ((ms - range.start) / range.duration) * 100;
const category = debugCategory(event.event);
const category = debugRowCategory(event);
const color = debugCategoryColor(category);
const isSelected = event.seq === selectedSeq;
const isHovered = hover?.seq === event.seq;
@ -411,14 +427,14 @@ function DnaPopover({
anchorRect,
runStart,
}: {
event: EventEnvelope;
event: DebugRowLike;
anchorRect: DOMRect;
runStart: string | undefined;
}) {
const category = debugCategory(event.event);
const category = debugRowCategory(event);
return (
<FloatingTooltip rect={anchorRect} placement="top">
{`${debugCategoryLabel(category)} · ${friendlyEventName(event.event)} · ${formatElapsed(event.ts, runStart)}`}
{`${debugCategoryLabel(category)} · ${friendlyEventName(event.event ?? "")} · ${formatElapsed(event.ts, runStart)}`}
</FloatingTooltip>
);
}

View file

@ -0,0 +1,96 @@
import { useMemo } from "react";
import type { RunProjection } from "@qltysh/fabro-api-client";
import { formatAbsoluteTs } from "../lib/format";
import {
isPetriRun,
platformRecordsOf,
type PlatformRecordEntry,
} from "../lib/petri-stream";
import { useRunState, useRunStream } from "../lib/queries";
const KIND_LABEL: Record<string, string> = {
"checkpoint": "Checkpoint",
"pull_request.created": "Pull request",
"run.notice": "Notice",
"run.title": "Title",
"run.branch": "Run branch",
};
function kindLabel(kind: string): string {
return KIND_LABEL[kind] ?? kind;
}
/**
* The platform records of a Petri run: Fabro's own facts beside the engine's
* events (a checkpoint with its commit, a pull request, a notice), each with
* the stage it belongs to when it belongs to one.
*/
export function PlatformRecordsPanelView({
records,
projection,
}: {
records: PlatformRecordEntry[];
projection: RunProjection | null | undefined;
}) {
const pullRequest = projection?.pull_request ?? null;
if (records.length === 0 && !pullRequest) return null;
return (
<section
aria-label="Platform records"
className="rounded-md border border-line bg-panel/60 px-6 py-4"
>
<h3 className="text-[10px] font-medium uppercase tracking-[0.08em] text-fg-muted">
Platform records
</h3>
<ul className="mt-2 space-y-1 text-sm">
{pullRequest && (
<li className="flex items-baseline gap-3">
<span className="w-28 shrink-0 text-fg-muted">Pull request</span>
<a
href={pullRequest.html_url}
target="_blank"
rel="noreferrer"
className="truncate font-mono text-teal-500 hover:text-teal-300"
>
#{pullRequest.number}
</a>
</li>
)}
{records.map((record) => (
<li
key={record.streamSeq}
data-kind={record.kind}
className="flex items-baseline gap-3"
>
<span className="w-28 shrink-0 text-fg-muted">{kindLabel(record.kind)}</span>
<span className="min-w-0 flex-1 truncate font-mono text-fg-2">
{record.detail ?? "—"}
</span>
{record.stageKey && (
<span className="shrink-0 font-mono text-xs text-fg-muted">
stage {record.stageKey}
</span>
)}
<span className="shrink-0 font-mono text-xs tabular-nums text-fg-muted">
{formatAbsoluteTs(record.ts)}
</span>
</li>
))}
</ul>
</section>
);
}
/** The panel for a run, shown only when the run executes on Petri. */
export function PlatformRecordsPanel({ runId }: { runId: string }) {
const runStateQuery = useRunState(runId);
const petri = isPetriRun(runStateQuery.data);
const streamQuery = useRunStream(petri ? runId : undefined);
const records = useMemo(
() => (streamQuery.data ? platformRecordsOf(streamQuery.data) : []),
[streamQuery.data],
);
if (!petri) return null;
return <PlatformRecordsPanelView records={records} projection={runStateQuery.data} />;
}

View file

@ -17,6 +17,12 @@ 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[];
stages: RunStage[];
createdAtIso: string;
completedAtIso: string | null;
@ -158,17 +164,21 @@ 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[];
stages: RunStage[];
createdAtIso: string;
nowMs: number;
}): Row[] {
const phases = deriveRunPhases(events, createdAtIso).map((p) => phaseRow(p, nowMs));
const phases = (givenPhases ?? deriveRunPhases(events, createdAtIso)).map((p) =>
phaseRow(p, nowMs),
);
const stageRows: Row[] = [];
for (const stage of stages) {
if (!isVisibleStage(stage.node_id)) continue;
@ -182,14 +192,15 @@ function buildRows({
export function RunWaterfall({
runId,
events,
phases,
stages,
createdAtIso,
completedAtIso,
}: WaterfallProps) {
const nowMs = useTickingNow(true, 1000);
const rows = useMemo(
() => buildRows({ runId, events, stages, createdAtIso, nowMs }),
[runId, events, stages, createdAtIso, nowMs],
() => buildRows({ runId, events, phases, stages, createdAtIso, nowMs }),
[runId, events, phases, stages, createdAtIso, nowMs],
);
const createdMs = Date.parse(createdAtIso);

View file

@ -8,7 +8,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import { findEdgeForNode } from "./helpers";
import { findEdgeForNode, type EdgeSelection } from "./helpers";
const REASON_LABEL: Record<string, string> = {
condition: "Matched condition",
@ -24,17 +24,20 @@ function reasonLabel(reason: string): string {
export function ConditionalDecision({
stage,
runEvents,
edge: givenEdge,
allStages,
runId,
}: {
stage: Stage;
runEvents: EventEnvelope[];
/** The edge when the caller derived it (a Petri run's `route.applied`). */
edge?: EdgeSelection | null;
allStages: Stage[];
runId: string;
}) {
const edge = useMemo(
() => findEdgeForNode(runEvents, stage.nodeId),
[runEvents, stage.nodeId],
() => (givenEdge !== undefined ? givenEdge : findEdgeForNode(runEvents, stage.nodeId)),
[givenEdge, runEvents, stage.nodeId],
);
const targetStage = useMemo(() => {
if (!edge) return null;

View file

@ -9,16 +9,22 @@ import type { Stage } from "../stage-sidebar";
import { formatTokenCount } from "../../lib/format";
import { Markdown } from "./primitives";
import { StageMetaBar } from "./meta-bar";
import { parseReducerTranscript } from "./helpers";
import { parseReducerTranscript, type ReducerTranscript } from "./helpers";
export function FanInResults({
stage,
events,
reducer: givenReducer,
}: {
stage: Stage;
events: EventEnvelope[];
/** The transcript when the caller derived it (a Petri run's projection). */
reducer?: ReducerTranscript | null;
}) {
const reducer = useMemo(() => parseReducerTranscript(events), [events]);
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">

View file

@ -45,7 +45,7 @@ export interface HumanInterviewPair {
resolution: HumanResolution | null;
}
function principalLabel(actor: unknown): string | null {
export function principalLabel(actor: unknown): string | null {
if (!actor || typeof actor !== "object") return null;
const record = actor as UnknownRecord;
const kind = getString(record, "kind") ?? "";

View file

@ -225,11 +225,17 @@ function QuestionBlock({
export function HumanQA({
stage,
events,
pairs: givenPairs,
}: {
stage: Stage;
events: EventEnvelope[];
/** The pairs when the caller derived them (a Petri run's stream). */
pairs?: HumanInterviewPair[];
}) {
const pairs = useMemo(() => parseHumanInterviewPairs(events), [events]);
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

@ -8,7 +8,7 @@ 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 } from "./helpers";
import type { ParallelBranchSummary, ParallelOverview } from "./helpers";
/** Branch row view state sourced from a live branch stage or completed result. */
interface BranchRow {
@ -99,15 +99,21 @@ function ChildRow({
export function ParallelChildren({
stage,
events,
overview: givenOverview,
runId,
allStages,
}: {
stage: Stage;
events: EventEnvelope[];
/** The overview when the caller derived it (a Petri run's projection). */
overview?: ParallelOverview;
runId: string;
allStages: Stage[];
}) {
const overview = useMemo(() => parseParallelOverview(events), [events]);
const overview = useMemo(
() => givenOverview ?? parseParallelOverview(events),
[givenOverview, events],
);
const stagesByBranchIndex = useMemo(() => {
const byIndex = new Map<number, Stage>();

View file

@ -1,5 +1,3 @@
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import {
debugCategory,
@ -9,16 +7,22 @@ import {
} from "../event-debug-helpers";
import { StageMetaBar } from "./meta-bar";
interface CategoryCount {
export interface CategoryCount {
category: DebugCategory;
count: number;
}
function summarizeEventCategories(events: EventEnvelope[]): CategoryCount[] {
/** What the summary counts: a legacy event, or a Petri stream row with its own category. */
export interface CategorizedEvent {
event?: string | null;
category?: DebugCategory;
}
export function summarizeEventCategories(events: CategorizedEvent[]): CategoryCount[] {
const counts = new Map<DebugCategory, number>();
for (const event of events) {
if (!event.event) continue;
const cat = debugCategory(event.event);
const cat = event.category ?? (event.event ? debugCategory(event.event) : null);
if (!cat) continue;
counts.set(cat, (counts.get(cat) ?? 0) + 1);
}
return Array.from(counts.entries())
@ -31,7 +35,7 @@ export function StageSummary({
events,
}: {
stage: Stage;
events: EventEnvelope[];
events: CategorizedEvent[];
}) {
const categories = summarizeEventCategories(events);

View file

@ -1163,6 +1163,11 @@ function candidateKey(candidate: CandidateMessage): string {
}
export function eventDedupeKey(payload: EventPayload): 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.
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;
}

View file

@ -0,0 +1,25 @@
/**
* The Petri scenario fixtures the server tests capture
* (`lib/apps/fabro-server/tests/it/scenario/petri_stream.rs` under
* `FABRO_CAPTURE_PETRI_FIXTURES`): a settled run's projection and its whole
* stream. Test-only; `tsc` excludes the tests that import this module.
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { RunProjection, RunStreamItem } from "@qltysh/fabro-api-client";
export type PetriFixtureName = "hello" | "command" | "parallel" | "gate";
export interface PetriFixture {
run_id: string;
projection: RunProjection;
stream: RunStreamItem[];
}
const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "test-fixtures", "petri");
export function loadPetriFixture(name: PetriFixtureName): PetriFixture {
const text = readFileSync(join(FIXTURES_DIR, `${name}.json`), "utf8");
return JSON.parse(text) as PetriFixture;
}

View file

@ -0,0 +1,220 @@
import { describe, expect, test } from "bun:test";
import { loadPetriFixture } from "./petri-fixtures";
import {
agentEnvelopesOf,
commandOutcomeOf,
debugRowsFromStream,
deriveRunPhasesFromStream,
extractPetriStageContext,
findPetriEdgeForStage,
isPetriRun,
isStreamItemPayload,
isTerminalLifecycleItem,
itemsForStage,
parallelOverviewFromProjection,
parsePetriInterviewPairs,
petriEventName,
petriStageLabel,
platformRecordKind,
platformRecordsOf,
reducerTranscriptFromProjection,
stagesFromProjection,
streamItemName,
} from "./petri-stream";
const hello = loadPetriFixture("hello");
const command = loadPetriFixture("command");
const parallel = loadPetriFixture("parallel");
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) {
expect(isStreamItemPayload(item)).toBe(true);
expect(item.run_id).toBe(fixture.run_id);
}
}
expect(isPetriRun({ spec: { engine: { kind: "legacy" } } } as never)).toBe(false);
expect(isStreamItemPayload({ event: "run.completed", seq: 3 })).toBe(false);
});
test("a Petri item is named by its recorded event and a platform item by its kind", () => {
const names = command.stream.map(streamItemName);
expect(names[0]).toBe("run.created");
expect(names).toContain("run.started");
expect(names).toContain("visit.started");
expect(names).toContain("step.finished");
expect(names[names.length - 2]).toBe("run.finished");
expect(names[names.length - 1]).toBe("run.lifecycle");
const created = command.stream[0];
expect(platformRecordKind(created)).toBe("run.created");
expect(petriEventName(created)).toBeUndefined();
});
test("the stage label is the subject's node@visit and skips the fork's delegates", () => {
const labels = new Set(
parallel.stream.map(petriStageLabel).filter((label): label is string => label != null),
);
expect(labels).toEqual(new Set(["start@1", "fork@1", "a@1", "b@1", "merge@1", "exit@1"]));
// The parent execution holds a `parallel.branch` delegate named after
// each branch; only the child execution's own node is the stage.
const starts = parallel.stream.filter(
(item) => petriEventName(item) === "visit.started" && petriStageLabel(item) === "a@1",
);
expect(starts).toHaveLength(1);
expect(itemsForStage(command.stream, "say@1").map(petriEventName)).toEqual([
"visit.started",
"wait.state.changed",
"admission.decided",
"step.started",
"wait.state.changed",
"step.progress.recorded",
// The command's log line, then the checkpoint hook's note.
"step.progress.recorded",
"step.finished",
"visit.completed",
"routing.resolved",
"route.applied",
"token.emitted",
]);
});
});
describe("questions", () => {
test("a gate's question pairs with its delivered answer and the answering principal", () => {
const pairs = parsePetriInterviewPairs(itemsForStage(gate.stream, "gate@1").concat(
gate.stream.filter((item) => platformRecordKind(item) === "interview.answered"),
));
expect(pairs).toHaveLength(1);
const [pair] = pairs;
expect(pair.question.questionId).toBe("gate#2");
expect(pair.question.question).toBe("Go?");
expect(pair.question.questionType).toBe("yes_no");
expect(pair.question.options.map((option) => option.key)).toEqual(["Y", "N"]);
expect(pair.question.allowFreeform).toBe(false);
expect(pair.resolution).toMatchObject({ kind: "answered", answer: "N", actor: "dev" });
expect(pair.resolution?.kind === "answered" && pair.resolution.durationMs).toBeGreaterThan(0);
});
test("a run without a gate asks nothing", () => {
expect(parsePetriInterviewPairs(command.stream)).toEqual([]);
});
});
describe("run phases", () => {
test("the phases come from the platform lifecycle records", () => {
const createdAt = parallel.projection.status_updated_at;
const created = parallel.stream[0];
const phases = deriveRunPhasesFromStream(
parallel.stream,
new Date(created.recorded_at).toISOString(),
);
expect(phases.map((phase) => phase.kind)).toEqual(["submitted", "runnable", "initializing"]);
for (const phase of phases) {
expect(phase.endMs).not.toBeNull();
expect(phase.startMs).toBeLessThanOrEqual(phase.endMs!);
}
expect(createdAt).toBeDefined();
const terminal = parallel.stream.filter(isTerminalLifecycleItem);
expect(terminal).toHaveLength(1);
expect(terminal[0]).toBe(parallel.stream[parallel.stream.length - 1]);
});
});
describe("platform records", () => {
test("a notice recorded between the branches lists with its message", () => {
const records = platformRecordsOf(parallel.stream);
const notices = records.filter((record) => record.kind === "run.notice");
expect(notices).toHaveLength(1);
expect(notices[0].detail).toBe("recorded while both branches ran");
expect(notices[0].stageKey).toBeNull();
});
test("each stage's checkpoint lists with its commit and its stage", () => {
const records = platformRecordsOf(parallel.stream);
const checkpoints = records.filter((record) => record.kind === "checkpoint");
// start, fork, a, b, merge, exit, and the two branch delegates.
expect(checkpoints.length).toBeGreaterThanOrEqual(6);
for (const checkpoint of checkpoints) {
expect(checkpoint.detail).toMatch(/^[0-9a-f]{12}$/);
expect(checkpoint.stageKey).not.toBeNull();
}
const order = records.map((record) => record.kind);
expect(order.indexOf("checkpoint")).toBeLessThan(order.indexOf("run.notice"));
});
test("the debug rows name every item and carry its stage", () => {
const rows = debugRowsFromStream(parallel.stream);
expect(rows).toHaveLength(parallel.stream.length);
const notice = rows.find((row) => row.event === "run.notice");
expect(notice?.category).toBe("platform");
const started = rows.find((row) => row.event === "visit.started" && row.stageLabel === "b@1");
expect(started?.category).toBe("petri");
expect(rows.every((row) => !Number.isNaN(Date.parse(row.ts)))).toBe(true);
});
});
describe("stage renderers", () => {
test("the fork's branches and results come from the projection", () => {
const fork = parallel.projection.stages["fork@1"];
const overview = parallelOverviewFromProjection(fork);
expect(overview.branchCount).toBe(2);
expect(overview.results.map((result) => [result.id, result.index, result.status])).toEqual([
["a", 0, "succeeded"],
["b", 1, "succeeded"],
]);
const stages = stagesFromProjection(parallel.projection);
const branches = stages.filter((stage) => stage.parallelGroupId === "fork@1");
expect(branches.map((stage) => [stage.id, stage.parallelBranchIndex])).toEqual([
["a@1", 0],
["b@1", 1],
]);
expect(stages.map((stage) => stage.id)).toEqual([
"start@1",
"fork@1",
"a@1",
"b@1",
"merge@1",
"exit@1",
]);
});
test("the fan-in with no reducer has no transcript", () => {
expect(reducerTranscriptFromProjection(parallel.projection.stages["merge@1"])).toBeNull();
const greet = reducerTranscriptFromProjection(hello.projection.stages["greet@1"]);
expect(greet?.response).toBe("A haiku, added.");
});
test("the edge a stage took is its route.applied target", () => {
expect(findPetriEdgeForStage(gate.stream, "gate@1")).toEqual({
fromNode: "gate",
toNode: "no",
reason: "condition",
condition: null,
isJump: false,
});
expect(findPetriEdgeForStage(gate.stream, "exit@1")).toBeNull();
});
test("a command stage's outcome is read from its final step.finished", () => {
const say = itemsForStage(command.stream, "say@1");
expect(commandOutcomeOf(say).exitCode).toBe(0);
expect(extractPetriStageContext(say)).toBeNull();
});
test("an agent stage's Pebble envelopes are read with their variant and session", () => {
const envelopes = agentEnvelopesOf(itemsForStage(hello.stream, "greet@1"));
expect(envelopes.length).toBeGreaterThan(0);
expect(envelopes[0].variant).toBe("SessionStarted");
expect(envelopes[0].payload).toEqual({ provider: "openai", model: "gpt-5.4" });
expect(envelopes.every((envelope) => envelope.sessionId?.startsWith("ses_"))).toBe(true);
const message = envelopes.find((envelope) => envelope.variant === "AssistantMessage");
expect(message?.payload.text).toBe("A haiku, added.");
expect(agentEnvelopesOf(itemsForStage(command.stream, "say@1"))).toEqual([]);
});
});

View file

@ -0,0 +1,737 @@
/**
* Pure helpers over a Petri run's stream: the `RunStreamItem`s
* `GET /runs/{id}/events` serves for a run that executes on Petri. Each item
* is a Petri `RunEvent` (Petri's event contract, passed through as JSON) or a
* stored platform record (Fabro's own fact about the run), in one envelope
* keyed by `stream_seq`. The mapping from items to views follows
* `lib/components/fabro-petri/VIEWS.md`.
*/
import { StageOutcome, StageState } from "@qltysh/fabro-api-client";
import type {
RunProjection,
RunStreamItem,
StageProjection,
} from "@qltysh/fabro-api-client";
import type {
EdgeSelection,
HumanInterviewPair,
HumanResolution,
InterviewOption,
ParallelOverview,
ReducerTranscript,
StageContextData,
} from "../components/stage-renderers/helpers";
import { principalLabel } from "../components/stage-renderers/helpers";
import { principalDisplay } from "./principal-display";
import type { Stage } from "./stage-sidebar";
import type { RunPhase, RunPhaseKind } from "./run-phases";
import { formatDurationMs } from "./format";
import {
getArray,
getBool,
getNumber,
getObject,
getString,
isRecord,
type UnknownRecord,
} from "./unknown";
export type PetriStream = ReadonlyArray<RunStreamItem>;
export function isPetriItem(item: RunStreamItem): boolean {
return item.kind === "petri";
}
export function isPlatformItem(item: RunStreamItem): boolean {
return item.kind === "platform";
}
/** Whether the projection is of a run that executes on Petri. */
export function isPetriRun(
projection: RunProjection | null | undefined,
): boolean {
const engine = projection?.spec?.engine;
return isRecord(engine) && getString(engine, "kind") === "petri";
}
/** Whether an SSE payload is a run stream item rather than a legacy event. */
export function isStreamItemPayload(
payload: unknown,
): payload is RunStreamItem {
return (
isRecord(payload) &&
typeof payload.stream_seq === "number" &&
(payload.kind === "petri" || payload.kind === "platform")
);
}
function record(item: RunStreamItem): UnknownRecord | undefined {
return getObject(item.item, "record");
}
function derived(item: RunStreamItem): UnknownRecord | undefined {
return getObject(item.item, "derived");
}
/** The stored platform record's `kind`, for a platform item. */
export function platformRecordKind(item: RunStreamItem): string | undefined {
if (!isPlatformItem(item)) return undefined;
return getString(record(item), "kind");
}
/**
* The `<subject>.<verb>` name of a Petri event: the recorded body's `event`
* tag, or a view event's tag under `derived`.
*/
export function petriEventName(item: RunStreamItem): string | undefined {
if (!isPetriItem(item)) return undefined;
return (
getString(getObject(record(item), "body"), "event") ??
getString(derived(item), "event")
);
}
/** The name a listing shows: the Petri event name or the platform kind. */
export function streamItemName(item: RunStreamItem): string {
return petriEventName(item) ?? platformRecordKind(item) ?? item.kind;
}
/** When the item's record was appended, as an ISO timestamp. */
export function streamItemTs(item: RunStreamItem): string {
return new Date(item.recorded_at).toISOString();
}
/** Petri's reading of a `step.progress.recorded` payload (`derived.parsed`). */
export function petriParsed(item: RunStreamItem): UnknownRecord | undefined {
return getObject(derived(item), "parsed");
}
/** The recorded event body of a Petri item (`record.body`). */
export function petriBody(item: RunStreamItem): UnknownRecord | undefined {
return getObject(record(item), "body");
}
function subject(item: RunStreamItem): UnknownRecord | undefined {
return getObject(item.item, "subject");
}
function subjectNode(item: RunStreamItem): UnknownRecord | undefined {
return getObject(subject(item), "node");
}
/**
* Whether the subject's node is a stage of its own. A lowering node (the
* `parallel.branch` delegate the fork's execution holds for each branch, a
* synthetic fan-in placeholder) shares a name with a real stage and is not
* one.
*/
function isShownNode(node: UnknownRecord | undefined): boolean {
if (!node) return false;
const meta = getObject(node, "meta");
if (getBool(meta, "synthetic") === true) return false;
return getString(meta, "kind") !== "parallel.branch";
}
/**
* The stage label (`node@visit`) of a Petri item's subject, or `undefined`
* for an item with no subject or one whose node is a lowering node.
*/
export function petriStageLabel(item: RunStreamItem): string | undefined {
const node = subjectNode(item);
if (!isShownNode(node)) return undefined;
const name = getString(node, "name");
if (!name) return undefined;
const visit = getNumber(subject(item), "visit") ?? 1;
return `${name}@${visit}`;
}
/** The subject's stage key, `(execution, firing)`, for an item under a firing. */
export function petriStageKey(item: RunStreamItem): string | undefined {
const firing = getNumber(subject(item), "firing");
const execution = getNumber(getObject(item.item, "context"), "execution");
if (firing === undefined || execution === undefined) return undefined;
return `${execution}:${firing}`;
}
/** The items whose subject is the stage with this label, in stream order. */
export function itemsForStage(
stream: PetriStream,
stageLabel: string,
): RunStreamItem[] {
return stream.filter((item) => petriStageLabel(item) === stageLabel);
}
// ── Questions ───────────────────────────────────────────────────────────
function parseOptions(value: unknown): InterviewOption[] {
if (!Array.isArray(value)) return [];
const out: InterviewOption[] = [];
for (const entry of value) {
const key = getString(entry, "key");
const label = getString(entry, "label");
if (!key || !label) continue;
const option: InterviewOption = { key, label };
const description = getString(entry, "description");
const preview = getString(entry, "preview");
if (description !== undefined) option.description = description;
if (preview !== undefined) option.preview = preview;
out.push(option);
}
return out;
}
/**
* Who answered, from the `interview.answered` record's `Principal`: the
* user's login, or the legacy label for an actor shaped as the old events
* carried it.
*/
function answeringPrincipalLabel(principal: unknown): string | null {
if (!isRecord(principal)) return null;
const kind = getString(principal, "kind");
if (kind === "user" && getString(principal, "login")) {
return principalDisplay(principal as unknown as Parameters<typeof principalDisplay>[0]).label;
}
return principalLabel(principal);
}
function answerText(answer: UnknownRecord): string {
const choice = getString(answer, "choice");
if (choice) return choice;
const text = getString(answer, "text");
if (text) return text;
const choices = getArray(answer, "choices");
if (choices) return choices.filter((c): c is string => typeof c === "string").join(", ");
if (getBool(answer, "cancelled") === true) return "";
if (getBool(answer, "confirmed") !== undefined) {
return getBool(answer, "confirmed") ? "yes" : "no";
}
for (const value of Object.values(answer)) {
if (typeof value === "string") return value;
}
return "";
}
/**
* Pair each question a stage asked (`step.progress.recorded` with
* `derived.parsed.kind === "question"`) with what resolved it: the delivered
* `control.requested` answer, a `question_expired` reading, or a cancelled
* answer. The answering principal comes from the `interview.answered`
* platform record keyed on Petri's question id.
*/
export function parsePetriInterviewPairs(stream: PetriStream): HumanInterviewPair[] {
const pairs = new Map<string, HumanInterviewPair>();
const actors = new Map<string, string | null>();
const askedAt = new Map<string, number>();
for (const item of stream) {
if (isPlatformItem(item)) {
const rec = record(item);
if (getString(rec, "kind") === "interview.answered") {
const question = getString(rec, "question");
if (question) actors.set(question, answeringPrincipalLabel(rec?.principal));
}
continue;
}
const name = petriEventName(item);
const parsed = petriParsed(item);
if (name === "step.progress.recorded" && getString(parsed, "kind") === "question") {
const question = getObject(parsed, "question");
const id = getString(question, "id");
if (!id) continue;
const timeoutMs = getNumber(question, "timeout_ms");
askedAt.set(id, item.recorded_at);
pairs.set(id, {
question: {
ts: streamItemTs(item),
questionId: id,
question: getString(question, "text") ?? "",
questionType: getString(question, "kind") ?? "freeform",
options: parseOptions(question?.options),
allowFreeform: getBool(question, "freeform") === true,
timeoutSeconds: timeoutMs !== undefined ? Math.round(timeoutMs / 1000) : null,
contextDisplay: getString(question, "context") ?? null,
reviewTarget: null,
},
resolution: null,
});
continue;
}
if (name === "step.progress.recorded" && getString(parsed, "kind") === "question_expired") {
const expired = getObject(parsed, "expired");
const id = getString(expired, "question");
const pair = id ? pairs.get(id) : undefined;
if (!pair || !id) continue;
pair.resolution = {
kind: "timeout",
ts: streamItemTs(item),
durationMs: getNumber(expired, "waited_ms") ?? item.recorded_at - (askedAt.get(id) ?? item.recorded_at),
};
continue;
}
if (name === "control.requested") {
const d = derived(item);
const answer = getObject(d, "answer");
const id = getString(answer, "question");
const pair = id ? pairs.get(id) : undefined;
if (!pair || !id || !answer) continue;
if (getBool(d, "deliverable") === false) continue;
const durationMs = item.recorded_at - (askedAt.get(id) ?? item.recorded_at);
const resolution: HumanResolution =
getBool(answer, "cancelled") === true
? {
kind: "interrupted",
ts: streamItemTs(item),
reason: "cancelled",
durationMs,
actor: null,
}
: {
kind: "answered",
ts: streamItemTs(item),
answer: answerText(answer),
durationMs,
actor: null,
};
pair.resolution = resolution;
}
}
for (const pair of pairs.values()) {
const resolution = pair.resolution;
if (resolution && resolution.kind !== "timeout") {
resolution.actor = actors.get(pair.question.questionId) ?? null;
}
}
return Array.from(pairs.values()).sort((a, b) => a.question.ts.localeCompare(b.question.ts));
}
// ── Run phases ──────────────────────────────────────────────────────────
const PHASE_LABEL: Record<RunPhaseKind, string> = {
submitted: "Submitted",
pending: "Pending",
runnable: "Runnable",
initializing: "Initializing",
};
const TERMINAL_TRANSITIONS: ReadonlySet<string> = new Set(["succeeded", "failed", "dead"]);
/** Whether a platform item is the run's terminal lifecycle record. */
export function isTerminalLifecycleItem(item: RunStreamItem): boolean {
if (platformRecordKind(item) !== "run.lifecycle") return false;
const transition = getString(record(item), "transition");
return transition !== undefined && TERMINAL_TRANSITIONS.has(transition);
}
/**
* 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.
*/
export function deriveRunPhasesFromStream(
stream: PetriStream,
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;
for (const item of stream) {
if (platformRecordKind(item) !== "run.lifecycle") continue;
const transition = getString(record(item), "transition");
const ms = item.recorded_at;
switch (transition) {
case "start_requested":
startRequestedMs ??= ms;
break;
case "pending":
pendingMs ??= ms;
break;
case "runnable":
runnableMs ??= ms;
break;
case "starting":
startingMs ??= ms;
break;
case "running":
runningMs ??= ms;
break;
case "succeeded":
case "failed":
case "dead":
terminalMs ??= ms;
break;
default:
break;
}
}
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;
}
// ── Platform records ────────────────────────────────────────────────────
export interface PlatformRecordEntry {
streamSeq: number;
kind: string;
ts: string;
/** The stage the record belongs to, as `execution:firing`, if any. */
stageKey: string | null;
/** A one-line summary: the commit sha, the pull request url, the notice. */
detail: string | null;
}
/** The platform records on the stream that name a Fabro fact worth a row. */
export function platformRecordsOf(stream: PetriStream): PlatformRecordEntry[] {
const out: PlatformRecordEntry[] = [];
for (const item of stream) {
const kind = platformRecordKind(item);
if (!kind) continue;
const rec = record(item) ?? {};
const position = getObject(item.item, "position");
const execution = getNumber(position, "execution") ?? getNumber(rec, "execution");
const firing = getNumber(position, "firing") ?? getNumber(rec, "firing");
const stageKey =
execution !== undefined && firing !== undefined ? `${execution}:${firing}` : null;
let detail: string | null = null;
switch (kind) {
case "checkpoint":
detail = getString(rec, "git_commit_sha")?.slice(0, 12) ?? null;
break;
case "pull_request.created":
detail = getString(rec, "html_url") ?? getString(rec, "url") ?? null;
break;
case "run.notice":
detail = getString(rec, "message") ?? getString(rec, "code") ?? null;
break;
case "run.title":
detail = getString(rec, "title") ?? null;
break;
case "run.branch":
detail = getString(rec, "run_branch") ?? null;
break;
default:
continue;
}
out.push({ streamSeq: item.stream_seq, kind, ts: streamItemTs(item), stageKey, detail });
}
return out;
}
// ── Debug rows ──────────────────────────────────────────────────────────
/** A stream item as the events listing and the stage debug tab show it. */
export interface DebugRow {
/** The `stream_seq`: the row's key and the cursor. */
seq: number;
/** The `<subject>.<verb>` name or the platform record kind. */
event: string;
ts: string;
category: "petri" | "platform";
stageLabel: string | null;
/** The raw item, for the details panel. */
item: RunStreamItem;
}
export function debugRowsFromStream(stream: PetriStream): DebugRow[] {
return stream.map((item) => ({
seq: item.stream_seq,
event: streamItemName(item),
ts: streamItemTs(item),
category: isPlatformItem(item) ? "platform" : "petri",
stageLabel: petriStageLabel(item) ?? null,
item,
}));
}
/** The text a search box matches a row against. */
export function debugRowSearchText(row: DebugRow): string {
const body = isPlatformItem(row.item)
? record(row.item)
: { ...(petriBody(row.item) ?? {}), derived: derived(row.item) ?? {} };
return `${row.event} ${row.stageLabel ?? ""} ${JSON.stringify(body ?? {})}`.toLowerCase();
}
// ── Stage renderers ─────────────────────────────────────────────────────
/**
* The edge a stage's firing took, from its `route.applied` record:
* `derived.target` is the node Petri resolved, `kind` says whether the edge
* was followed or jumped to.
*/
export function findPetriEdgeForStage(
stream: PetriStream,
stageLabel: string,
): EdgeSelection | null {
let latest: EdgeSelection | null = null;
for (const item of stream) {
if (petriEventName(item) !== "route.applied") continue;
if (petriStageLabel(item) !== stageLabel) continue;
const target = getString(getObject(derived(item), "target"), "name");
if (!target) continue;
const kind = getString(petriBody(item), "kind") ?? "edge";
latest = {
fromNode: getString(subjectNode(item), "name") ?? stageLabel,
toNode: target,
reason: kind === "jump" ? "jump" : "condition",
condition: null,
isJump: kind === "jump",
};
}
return latest;
}
const STAGE_OUTCOMES: ReadonlySet<string> = new Set(Object.values(StageOutcome));
/** The fork's branches as the projection carries them (`parallel_results`). */
export function parallelOverviewFromProjection(
stage: StageProjection | undefined,
): ParallelOverview {
const results = (stage?.parallel_results ?? [])
.map((result) => {
const status = STAGE_OUTCOMES.has(result.status) ? (result.status as StageOutcome) : null;
if (!status) return null;
return {
id: result.id,
index: result.index ?? null,
itemLabel: result.item_label ?? null,
status,
};
})
.filter((r): r is NonNullable<typeof r> => r != null);
return { branchCount: results.length > 0 ? results.length : null, results };
}
/** The fan-in's reducer prompt and response, from the projection. */
export function reducerTranscriptFromProjection(
stage: StageProjection | undefined,
): ReducerTranscript | null {
if (!stage?.prompt && !stage?.response) return null;
const tokens = stage.usage?.tokens;
return {
prompt: stage.prompt ?? "",
response: stage.response ?? "",
model: stage.provider_used?.model ?? stage.model?.model_id ?? null,
inputTokens: tokens?.input ?? 0,
outputTokens: tokens?.output ?? 0,
};
}
// The command step's own bookkeeping (`command.output`, `failure_class`)
// joins the engine keys the legacy Context tab hides.
const ENGINE_CONTEXT_KEYS = new Set([
"last_stage",
"last_response",
"command.output",
"failure_class",
]);
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));
}
/**
* The workflow's deliberate outputs from the stage's final `step.finished`:
* its `outcome.context_updates` minus the engine's keys.
*/
export function extractPetriStageContext(items: PetriStream): StageContextData | null {
let latest: StageContextData | null = null;
for (const item of items) {
if (petriEventName(item) !== "step.finished") continue;
if (getBool(derived(item), "final") === false) continue;
const outcome = getObject(petriBody(item), "outcome");
const rawUpdates = getObject(outcome, "context_updates") ?? {};
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawUpdates)) {
if (!isEngineContextKey(key)) updates[key] = value;
}
if (Object.keys(updates).length === 0) {
latest = null;
continue;
}
latest = { routing: { preferredLabel: null, suggestedNextIds: [] }, updates };
}
return latest;
}
/** A Pebble `CodingAgentEvent` envelope a stage's step recorded. */
export interface PetriAgentEnvelope {
ts: string;
streamSeq: number;
/** The Pebble variant name, e.g. `AssistantMessage`. */
variant: string;
/** The variant's fields. */
payload: UnknownRecord;
sessionId: string | null;
parentSessionId: string | null;
}
/**
* The backend envelopes among a stage's items: a `step.progress.recorded`
* whose custom payload carries a string `kind` (the backend) and an `event`
* object, Pebble's `CodingAgentEvent` as recorded: `{seq, stream_id,
* session_id, parent_session_id?, timestamp, event: {Variant: {...}}}`.
*/
export function agentEnvelopesOf(items: PetriStream): PetriAgentEnvelope[] {
const out: PetriAgentEnvelope[] = [];
for (const item of items) {
if (petriEventName(item) !== "step.progress.recorded") continue;
const custom = getObject(getObject(petriBody(item), "ev"), "custom");
const envelope = getObject(custom, "event");
if (!custom || !envelope || !getString(custom, "kind")) continue;
const event = getObject(envelope, "event");
if (!event) continue;
let variant: string | null = null;
let payload: UnknownRecord = {};
for (const [key, value] of Object.entries(event)) {
variant = key;
payload = isRecord(value) ? value : {};
break;
}
if (!variant) continue;
out.push({
ts: getString(envelope, "timestamp") ?? streamItemTs(item),
streamSeq: item.stream_seq,
variant,
payload,
sessionId: getString(envelope, "session_id") ?? null,
parentSessionId: getString(envelope, "parent_session_id") ?? null,
});
}
return out;
}
/** A command stage's script, from its `step.started` record, if recorded. */
export function commandScriptOf(items: PetriStream): string | null {
for (const item of items) {
if (petriEventName(item) !== "step.started") continue;
const script =
getString(getObject(petriBody(item), "config"), "script") ??
getString(getObject(getObject(subjectNode(item), "meta"), "config"), "script");
if (script) return script;
}
return null;
}
/**
* The exit code and duration of the stage's final `step.finished`: the
* command step's output carries `exit_status`, its metrics the duration.
*/
export function commandOutcomeOf(items: PetriStream): {
exitCode: number | null;
durationMs: number;
} {
let exitCode: number | null = null;
let durationMs = 0;
for (const item of items) {
if (petriEventName(item) !== "step.finished") continue;
const outcome = getObject(petriBody(item), "outcome");
const output = getObject(outcome, "output");
const metrics = getObject(outcome, "metrics");
exitCode =
getNumber(output, "exit_status") ?? getNumber(metrics, "exit_code") ?? exitCode;
durationMs = getNumber(metrics, "duration_ms") ?? durationMs;
}
return { exitCode, durationMs };
}
// ── Stages from the projection ──────────────────────────────────────────
const STAGE_STATES: ReadonlySet<string> = new Set(Object.values(StageState));
/**
* The sidebar stages a projection describes, sorted by their first event.
* The API serves the same rows through `/runs/{id}/stages`; this derivation
* lets a view (and a test) build them from the projection alone.
*/
export function stagesFromProjection(projection: RunProjection): Stage[] {
const stages: Stage[] = [];
for (const [id, stage] of Object.entries(projection.stages ?? {})) {
const at = id.lastIndexOf("@");
const name = at > 0 ? id.slice(0, at) : id;
const visit = at > 0 ? Number.parseInt(id.slice(at + 1), 10) || 1 : 1;
const branch = stage.parallel_branch_id ?? null;
const branchAt = branch ? branch.lastIndexOf(":") : -1;
const status = STAGE_STATES.has(stage.state) ? stage.state : StageState.PENDING;
stages.push({
id,
name,
handler: (stage as { handler?: Stage["handler"] }).handler ?? "agent",
nodeId: name,
visit,
graphVisit: (stage as { graph_visit?: number | null }).graph_visit ?? null,
resumedFromStageId: null,
parallelGroupId: branch && branchAt > 0 ? branch.slice(0, branchAt) : null,
parallelBranchIndex:
branch && branchAt > 0 ? Number.parseInt(branch.slice(branchAt + 1), 10) : null,
status,
duration:
stage.timing?.wall_time_ms != null ? formatDurationMs(stage.timing.wall_time_ms) : "--",
startedAt: stage.started_at ?? null,
providerUsed: stage.provider_used ?? null,
usage: stage.usage,
firstEventSeq: stage.first_event_seq,
} as Stage & { firstEventSeq: number });
}
// Stages list in the order they started. The branches of one fork start
// concurrently, so among themselves they list by branch index, anchored
// at the first of them to start.
const seqOf = (stage: Stage) => (stage as Stage & { firstEventSeq?: number }).firstEventSeq ?? 0;
const groupAnchor = new Map<string, number>();
for (const stage of stages) {
if (stage.parallelGroupId == null) continue;
const anchor = groupAnchor.get(stage.parallelGroupId);
if (anchor == null || seqOf(stage) < anchor) groupAnchor.set(stage.parallelGroupId, seqOf(stage));
}
const sortKey = (stage: Stage): [number, number] =>
stage.parallelGroupId == null
? [seqOf(stage), -1]
: [groupAnchor.get(stage.parallelGroupId) ?? seqOf(stage), stage.parallelBranchIndex ?? -1];
stages.sort((a, b) => {
const [aSeq, aIndex] = sortKey(a);
const [bSeq, bIndex] = sortKey(b);
return aSeq - bSeq || aIndex - bIndex;
});
return stages.map(({ firstEventSeq: _, ...stage }: Stage & { firstEventSeq?: number }) => stage);
}

View file

@ -12,6 +12,7 @@ import type {
Environment,
EnvironmentListResponse,
EventEnvelope,
ListRunEvents200Response,
ListRunsDirectionEnum,
ListRunsSortEnum,
McpServer,
@ -27,6 +28,7 @@ import type {
RunArtifactListResponse,
RunProjection,
Run,
RunStreamItem,
RunUsage,
SandboxDetails,
SecretListResponse,
@ -387,13 +389,76 @@ 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`, (sinceSeq, limit) =>
apiData(() => runInternalsApi.listRunEvents(id!, sinceSeq, limit)),
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.
*/
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,
),
);
if (!isRunStreamPage(page)) return items;
if (page.data.length === 0) return items;
items.push(...page.data);
const last = page.data[page.data.length - 1];
if (!page.meta.has_more || last.stream_seq <= after) return items;
after = last.stream_seq;
}
console.warn(
`Stopped run stream fetch for ${id} after ${STREAM_MAX_PAGES} pages and ${items.length} items because the safety cap was reached.`,
);
return items;
}
/** A Petri 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,
() => fetchRunStream(id!),
);
}

View file

@ -61,6 +61,8 @@ export const queryKeys = {
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) =>
["runs", "stage-events", id, stageId] as const,
stageContextWindow: (id: string, stageId: string) =>

View file

@ -1,8 +1,10 @@
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 {
@ -227,6 +229,103 @@ describe("queryKeysForRunEvent", () => {
});
});
describe("queryKeysForStreamItem", () => {
const parallel = loadPetriFixture("parallel");
const gate = loadPetriFixture("gate");
const runId = "run-petri";
const named = (name: string, stage?: string) =>
parallel.stream.find((item) => {
const body = (item.item as { record?: { body?: { event?: string } } }).record?.body;
const derived = (item.item as { derived?: { event?: string } }).derived;
const subject = (item.item as { subject?: { node?: { name?: string } } }).subject;
return (
(body?.event ?? derived?.event) === name &&
(stage === undefined || subject?.node?.name === stage)
);
})!;
test("a stage's visit invalidates the stage list, the state, the stream and its stage keys", () => {
const { keys, immediate } = queryKeysForStreamItem(runId, named("visit.started", "merge"));
expect(immediate).toBe(false);
expect(keys).toEqual([
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
queryKeys.runs.detail(runId),
queryKeys.runs.stream(runId),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.stageEvents(runId, "merge@1"),
queryKeys.runs.stageContextWindow(runId, "merge@1"),
]);
});
test("a platform notice refreshes the run summary; the terminal lifecycle record is immediate", () => {
const notice = parallel.stream.find(
(item) => item.kind === "platform" && (item.item as { record: { kind: string } }).record.kind === "run.notice",
)!;
expect(queryKeysForStreamItem(runId, notice)).toEqual({
keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), queryKeys.runs.stream(runId)],
immediate: false,
});
const terminal = parallel.stream[parallel.stream.length - 1];
const result = queryKeysForStreamItem(runId, terminal);
expect(result.immediate).toBe(true);
expect(result.keys).toContainEqual(queryKeys.runs.usage(runId));
expect(result.keys).toContainEqual(queryKeys.runs.stream(runId));
});
test("a question and its answer refresh the questions list", () => {
const question = gate.stream.find(
(item) => (item.item as { derived?: { parsed?: { kind?: string } } }).derived?.parsed?.kind === "question",
)!;
expect(queryKeysForStreamItem(runId, question).keys[0]).toEqual(
queryKeys.runs.questions(runId, 25, 0),
);
const answer = gate.stream.find(
(item) => (item.item as { record?: { body?: { event?: string } } }).record?.body?.event === "control.requested",
)!;
expect(queryKeysForStreamItem(runId, answer).keys).toContainEqual(
queryKeys.runs.stageEvents(runId, "gate@1"),
);
});
test("a run stream item on the attach stream is invalidated by its own rules", async () => {
const source = new FakeEventSource();
const keys: Key[] = [];
// The coordinated stream carries every run, so the item's `run_id` is
// what keeps another run's item from invalidating this one.
const coordinator = createCoordinator(() => source);
const cleanup = subscribeToRunEvents(
runId,
(key) => {
keys.push(key);
return Promise.resolve();
},
() => {
throw new Error("source should be created by coordinator");
},
{ debounceMs: 0, coordinator },
);
await waitFor(() => source.onmessage !== null);
keys.length = 0;
source.emit({ ...named("step.finished", "a"), run_id: runId });
expect(keys).toEqual([
queryKeys.runs.state(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.detail(runId),
queryKeys.runs.stream(runId),
queryKeys.runs.stageEvents(runId, "a@1"),
queryKeys.runs.stageContextWindow(runId, "a@1"),
]);
keys.length = 0;
source.emit({ ...named("step.finished", "a"), run_id: "another-run" });
expect(keys).toEqual([]);
cleanup();
coordinator.close();
});
});
describe("subscribeToRunEvents", () => {
test("coordinated mode uses the global attach stream and filters by run_id", async () => {
const source = new FakeEventSource();

View file

@ -1,11 +1,21 @@
import { useEffect } from "react";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import { useSWRConfig, type Key } from "swr";
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import {
isStreamItemPayload,
isTerminalLifecycleItem,
petriEventName,
petriParsed,
petriStageLabel,
platformRecordKind,
} from "./petri-stream";
import { queryKeys } from "./query-keys";
import { getString } from "./unknown";
import {
createBrowserEventSource,
subscribeToSharedEventSource,
@ -23,6 +33,10 @@ export interface RunEventPayload extends EventPayload {
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;
item?: unknown;
}
interface RunEventOptions {
@ -322,6 +336,151 @@ export function queryKeysForRunEvent(
return [];
}
/**
* The SWR keys a Petri run stream item invalidates. Petri's events are
* named `<subject>.<verb>`; a platform record by its `kind`. The stage
* keys use the subject's `node@visit` label, which is the stage id the
* projection keys stages by.
*/
export function queryKeysForStreamItem(
runId: string,
item: RunStreamItem,
): { keys: Key[]; immediate: boolean } {
const stageId = petriStageLabel(item);
const stageKeys: Key[] = stageId
? [queryKeys.runs.stageEvents(runId, stageId), queryKeys.runs.stageContextWindow(runId, stageId)]
: [];
const stream = queryKeys.runs.stream(runId);
if (item.kind === "platform") {
const kind = platformRecordKind(item);
if (isTerminalLifecycleItem(item)) {
return { keys: terminalKeys(runId, stream), immediate: true };
}
switch (kind) {
case "checkpoint":
return {
keys: [
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.state(runId),
stream,
],
immediate: false,
};
case "interview.answered":
return {
keys: [queryKeys.runs.questions(runId, 25, 0), queryKeys.runs.detail(runId), stream],
immediate: false,
};
default:
return { keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), stream], immediate: false };
}
}
const name = petriEventName(item);
switch (name) {
case "run.finished":
return { keys: terminalKeys(runId, stream), immediate: false };
case "run.started":
case "run.paused":
case "run.unpaused":
case "invocation.finished":
case "invocation.cancel.requested":
case "run.stalled":
return { keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), stream], immediate: false };
case "visit.started":
case "visit.completed":
case "retry.scheduled":
case "wait.state.changed":
case "admission.decided":
return {
keys: [
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
queryKeys.runs.detail(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
...stageKeys,
],
immediate: false,
};
case "step.progress.recorded": {
const parsed = getString(petriParsed(item), "kind");
if (parsed === "question" || parsed === "question_expired") {
return {
keys: [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
stream,
...stageKeys,
],
immediate: false,
};
}
return { keys: [queryKeys.runs.state(runId), stream, ...stageKeys], immediate: false };
}
case "control.requested":
return {
keys: [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
stream,
...stageKeys,
],
immediate: false,
};
case "step.finished":
return {
keys: [
queryKeys.runs.state(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.detail(runId),
stream,
...stageKeys,
],
immediate: false,
};
case "fork.started":
case "branch.completed":
case "fork.completed":
case "node.expanded":
return {
keys: [
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
],
immediate: false,
};
case "routing.resolved":
case "route.applied":
return { keys: [stream, ...stageKeys], immediate: false };
default:
return { keys: [stream], immediate: false };
}
}
function terminalKeys(runId: string, stream: Key): Key[] {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
}
export function subscribeToRunEvents(
runId: string,
mutate: MutateFn,
@ -357,6 +516,9 @@ export function subscribeToRunEvents(
}
function runInvalidation(runId: string, payload: RunEventPayload) {
if (isStreamItemPayload(payload)) {
return queryKeysForStreamItem(runId, payload);
}
const event = payload.event;
if (!event) return { keys: [], immediate: false };
@ -375,6 +537,7 @@ function resyncKeysForRun(runId: string) {
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"),
queryKeys.runs.questions(runId, 25, 0),

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { EventEnvelope, RunStreamItem } from "@qltysh/fabro-api-client";
import {
DebugEventDetailsPanel,
@ -13,9 +13,23 @@ 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";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { useRun, useRunEventsList, useRunStages } from "../lib/queries";
import {
debugRowSearchText,
debugRowsFromStream,
deriveRunPhasesFromStream,
isPetriRun,
type DebugRow,
} from "../lib/petri-stream";
import {
useRun,
useRunEventsList,
useRunStages,
useRunState,
useRunStream,
} from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true, fullHeight: true };
@ -23,12 +37,34 @@ 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);
const eventsQuery = useRunEventsList(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);
const streamPhases = useMemo(
() =>
petri && streamQuery.data && runQuery.data
? deriveRunPhasesFromStream(streamQuery.data, runQuery.data.timestamps.created_at)
: undefined,
[petri, streamQuery.data, runQuery.data],
);
const streamRows = useMemo(
() => (petri && streamQuery.data ? debugRowsFromStream(streamQuery.data) : undefined),
[petri, streamQuery.data],
);
const [searchParams, setSearchParams] = useSearchParams();
const view: ViewMode = searchParams.get("view") === "events" ? "events" : "waterfall";
const setView = (next: ViewMode) => {
@ -63,19 +99,32 @@ export default function RunEvents() {
{view === "waterfall" ? (
<WaterfallPane
runId={id!}
events={eventsQuery.data}
eventsError={eventsQuery.error}
events={petri ? (streamQuery.data ? EMPTY_EVENTS : undefined) : eventsQuery.data}
phases={streamPhases}
eventsError={petri ? streamQuery.error : eventsQuery.error}
stagesData={stagesQuery.data}
stagesError={stagesQuery.error}
createdAt={runQuery.data?.timestamps.created_at}
completedAt={runQuery.data?.timestamps.completed_at ?? null}
onRetry={() => {
void eventsQuery.mutate();
void (petri ? streamQuery.mutate() : eventsQuery.mutate());
void stagesQuery.mutate();
}}
view={view}
onChangeView={setView}
/>
) : petri ? (
<StreamEventsView
rows={streamRows}
error={streamQuery.error}
onRetry={() => void streamQuery.mutate()}
runStart={
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at
}
view={view}
onChangeView={setView}
/>
) : (
<EventsView
events={eventsQuery.data}
@ -131,6 +180,7 @@ function ViewToggle({
function WaterfallPane({
runId,
events,
phases,
eventsError,
stagesData,
stagesError,
@ -142,6 +192,7 @@ function WaterfallPane({
}: {
runId: string;
events: EventEnvelope[] | undefined;
phases?: RunPhase[];
eventsError: unknown;
stagesData: ReturnType<typeof useRunStages>["data"];
stagesError: unknown;
@ -178,6 +229,7 @@ function WaterfallPane({
<RunWaterfall
runId={runId}
events={events!}
phases={phases}
stages={stagesData!.data ?? []}
createdAtIso={createdAt!}
completedAtIso={completedAt}
@ -332,6 +384,196 @@ function EventsView({
);
}
/**
* The events list of a Petri 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.
*/
export function StreamEventsView({
rows,
error,
onRetry,
runStart,
view,
onChangeView,
}: {
rows: DebugRow[] | 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 = rows ?? EMPTY_ROWS;
const availableCategories = useMemo<string[]>(() => {
const set = new Set<string>();
for (const row of all) set.add(row.category);
return Array.from(set).sort();
}, [all]);
const filtered = useMemo<DebugRow[]>(() => {
const useCategoryFilter = selectedCategories.length > 0;
const cats = new Set(selectedCategories);
const needle = search.toLowerCase();
return all.filter((row) => {
if (useCategoryFilter && !cats.has(row.category)) return false;
if (needle && !debugRowSearchText(row).includes(needle)) return false;
return true;
});
}, [all, selectedCategories, search]);
const openRow = useMemo<DebugRow | null>(
() => (openSeq != null ? all.find((row) => row.seq === openSeq) ?? null : null),
[all, openSeq],
);
const openPayload = useMemo(
() =>
openRow
? {
event: openRow.event,
stream_seq: openRow.seq,
kind: openRow.item.kind,
stage: openRow.stageLabel,
recorded_at: openRow.ts,
item: openRow.item.item,
}
: null,
[openRow],
);
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 (rows === 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()} items`
: `${all.length.toLocaleString()} items`}
</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((row) => (
<StreamEventRow
key={`stream-${row.seq}`}
row={row}
runStart={runStart}
selected={openSeq === row.seq}
onSelect={() => setOpenSeq(row.seq)}
/>
))
)}
</div>
</div>
<DebugEventDetailsPanel event={openPayload} onClose={() => setOpenSeq(null)} />
</>
);
}
/** A debug row with the stage the item belongs to beside its name. */
function StreamEventRow({
row,
runStart,
selected,
onSelect,
}: {
row: DebugRow;
runStart: string | undefined;
selected: boolean;
onSelect: () => void;
}) {
return (
<div className="grid grid-cols-[1fr_auto] items-center">
<DebugEventRow
event={row}
runStart={runStart}
selected={selected}
onSelect={onSelect}
/>
{row.stageLabel && (
<span
data-stage={row.stageLabel}
className="pr-5 font-mono text-[11px] text-fg-muted"
>
{row.stageLabel}
</span>
)}
</div>
);
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}

View file

@ -22,6 +22,8 @@ mock.module("../lib/queries", () => ({
}),
useRunGraphSource: () => ({ data: undefined }),
useRunStageEvents: () => ({ data: [] }),
useRunState: () => ({ data: undefined }),
useRunStream: () => ({ data: undefined }),
}));
mock.module("../components/run-summary-panel", () => ({

View file

@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router";
import { ApiError } from "../lib/api-client";
import { useRun, useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries";
import { FloatingTooltip } from "../components/floating-tooltip";
import { PlatformRecordsPanel } from "../components/platform-records-panel";
import { RunSummaryPanel } from "../components/run-summary-panel";
import { StagePopover } from "../components/stage-popover";
import { StageSidebar } from "../components/stage-sidebar";
@ -150,8 +151,9 @@ export default function RunOverview() {
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 pb-[var(--fabro-interview-dock-clearance,0px)]">
<div className="shrink-0">
<div className="shrink-0 space-y-4">
<RunSummaryPanel runId={id!} />
<PlatformRecordsPanel runId={id!} />
</div>
{graphSvg === undefined && graphQuery.isLoading ? (
<div className="flex-1" />

View file

@ -0,0 +1,286 @@
/**
* The run detail's views over a Petri run, rendered from the projection and
* the stream the server tests captured (`test-fixtures/petri/*.json`): one
* scenario per fixture, every view `VIEWS.md` lists that the web app draws
* from those two sources.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { ReactElement } from "react";
import type { RunStage } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { PlatformRecordsPanelView } from "../components/platform-records-panel";
import { RunWaterfall } from "../components/run-waterfall";
import { StageSidebar } from "../components/stage-sidebar";
import { FanInResults } from "../components/stage-renderers/fan-in-results";
import { HumanQA } from "../components/stage-renderers/human-qa";
import { ParallelChildren } from "../components/stage-renderers/parallel-children";
import { ConditionalDecision } from "../components/stage-renderers/conditional-decision";
import { loadPetriFixture, type PetriFixture } from "../lib/petri-fixtures";
import {
debugRowsFromStream,
deriveRunPhasesFromStream,
findPetriEdgeForStage,
itemsForStage,
parallelOverviewFromProjection,
parsePetriInterviewPairs,
platformRecordsOf,
reducerTranscriptFromProjection,
stagesFromProjection,
} from "../lib/petri-stream";
import { setupReactTestEnv } from "../lib/test-utils";
import { StreamEventsView } from "./run-events";
import { StageChatView, buildPetriStageActivity } from "./run-stages";
let teardown: () => void;
beforeEach(() => {
teardown = setupReactTestEnv();
});
afterEach(() => teardown());
function render(element: ReactElement): string {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/runs/run-1"]}>{element}</MemoryRouter>,
);
});
const json = JSON.stringify(renderer.toJSON());
act(() => renderer.unmount());
return json;
}
function runStages(fixture: PetriFixture): RunStage[] {
return stagesFromProjection(fixture.projection).map((stage) => ({
id: stage.id,
name: stage.name,
handler: stage.handler,
status: stage.status,
node_id: stage.nodeId,
visit: stage.visit,
started_at: stage.startedAt,
wall_time_ms: fixture.projection.stages[stage.id]?.timing?.wall_time_ms,
usage: stage.usage,
parallel_group_id: stage.parallelGroupId ?? undefined,
parallel_branch_index: stage.parallelBranchIndex ?? undefined,
}));
}
function createdAt(fixture: PetriFixture): string {
return new Date(fixture.stream[0].recorded_at).toISOString();
}
describe("a command-only run", () => {
const fixture = loadPetriFixture("command");
const stages = stagesFromProjection(fixture.projection);
test("the stage list shows every stage with its state", () => {
expect(stages.map((stage) => [stage.id, stage.status])).toEqual([
["start@1", "succeeded"],
["say@1", "succeeded"],
["exit@1", "succeeded"],
]);
const html = render(<StageSidebar stages={stages} runId="run-1" />);
for (const name of ["start", "say", "exit"]) expect(html).toContain(name);
// The sidebar shows a stage's state as its icon's tone: mint is succeeded.
expect((html.match(/text-mint/g) ?? []).length).toBe(3);
expect(html).not.toContain("animate-pulse");
});
test("the command stage is one command turn with its exit status and output size", () => {
const say = fixture.projection.stages["say@1"];
const activity = buildPetriStageActivity(
itemsForStage(fixture.stream, "say@1"),
say,
"command",
);
expect(activity.turns).toHaveLength(1);
expect(activity.turns[0]).toMatchObject({
kind: "command",
running: false,
exitCode: 0,
outputBytes: say.output_bytes,
});
});
test("the waterfall's phases come from the lifecycle records", () => {
const html = render(
<RunWaterfall
runId="run-1"
events={[]}
phases={deriveRunPhasesFromStream(fixture.stream, createdAt(fixture))}
stages={runStages(fixture)}
createdAtIso={createdAt(fixture)}
completedAtIso={fixture.projection.conclusion?.timestamp ?? null}
/>,
);
for (const label of ["Submitted", "Runnable", "Initializing", "say"]) {
expect(html).toContain(label);
}
});
});
describe("the hello run on the twin", () => {
const fixture = loadPetriFixture("hello");
const stages = stagesFromProjection(fixture.projection);
test("the agent stage's chat shows the prompt and the agent's response", () => {
const greet = stages.find((stage) => stage.id === "greet@1")!;
expect(greet.handler).toBe("agent");
const activity = buildPetriStageActivity(
itemsForStage(fixture.stream, "greet@1"),
fixture.projection.stages["greet@1"],
"agent",
);
expect(activity.turns.map((turn) => turn.kind)).toEqual(["system", "assistant"]);
expect(activity.turns[0]).toMatchObject({ kind: "system" });
expect((activity.turns[0] as { content: string }).content).toContain("Add a haiku");
expect(activity.turns[1]).toMatchObject({
kind: "assistant",
content: "A haiku, added.",
inputTokens: 1,
outputTokens: 5,
});
const html = render(
<StageChatView
turns={activity.turns}
pendingTools={activity.pendingTools}
stage={greet}
/>,
);
expect(html).toContain("A haiku, added.");
});
test("the stage list shows the agent stage succeeded", () => {
const greet = stages.find((stage) => stage.id === "greet@1")!;
expect(greet.status).toBe("succeeded");
expect(greet.providerUsed?.model).toBe("gpt-5.4");
const html = render(<StageSidebar stages={stages} runId="run-1" />);
expect(html).toContain("greet");
expect(html).toContain("text-mint");
});
});
describe("a two-branch parallel run", () => {
const fixture = loadPetriFixture("parallel");
const stages = stagesFromProjection(fixture.projection);
test("the fork lists both branches under it with their outcomes", () => {
const fork = stages.find((stage) => stage.id === "fork@1")!;
const html = render(
<ParallelChildren
stage={fork}
events={[]}
overview={parallelOverviewFromProjection(fixture.projection.stages["fork@1"])}
runId="run-1"
allStages={stages}
/>,
);
expect(html).toContain("Branches");
expect(html).toContain("/runs/run-1/stages/a@1");
expect(html).toContain("/runs/run-1/stages/b@1");
expect((html.match(/Succeeded/g) ?? []).length).toBeGreaterThanOrEqual(2);
});
test("the fan-in joined the branches", () => {
const merge = stages.find((stage) => stage.id === "merge@1")!;
const html = render(
<FanInResults
stage={merge}
events={[]}
reducer={reducerTranscriptFromProjection(fixture.projection.stages["merge@1"])}
/>,
);
expect(html).toContain("Joined");
expect(html).not.toContain("Reducer transcript");
});
test("the events view lists Petri events by name and the platform notice", () => {
const html = render(
<StreamEventsView
rows={debugRowsFromStream(fixture.stream)}
error={undefined}
onRetry={() => {}}
runStart={createdAt(fixture)}
view="events"
onChangeView={() => {}}
/>,
);
for (const name of ["run.started", "visit.started", "fork.completed", "run.finished"]) {
expect(html).toContain(name);
}
expect(html).toContain("run.notice");
expect(html).toContain('"data-stage":"a@1"');
expect(html).toContain(`${fixture.stream.length} items`);
});
test("the overview lists the platform records", () => {
const html = render(
<PlatformRecordsPanelView
records={platformRecordsOf(fixture.stream)}
projection={fixture.projection}
/>,
);
expect(html).toContain("Platform records");
expect(html).toContain("Notice");
expect(html).toContain("recorded while both branches ran");
// The checkpoint hook wrote one record per stage, with its commit.
expect(html).toContain("Checkpoint");
const checkpoint = fixture.stream.find(
(item) => item.kind === "platform" && item.item.record?.kind === "checkpoint",
);
expect(checkpoint).toBeDefined();
expect(html).toContain(String(checkpoint!.item.record.git_commit_sha).slice(0, 12));
});
test("the sidebar groups the branches under the fork", () => {
const branches = stages.filter((stage) => stage.parallelGroupId === "fork@1");
expect(branches.map((stage) => stage.id)).toEqual(["a@1", "b@1"]);
const html = render(<StageSidebar stages={stages} runId="run-1" />);
for (const name of ["fork", "a", "b", "merge"]) expect(html).toContain(name);
});
});
describe("a human gate answered through the API", () => {
const fixture = loadPetriFixture("gate");
const stages = stagesFromProjection(fixture.projection);
test("the Q&A shows the question, its options and the answer with who gave it", () => {
const gate = stages.find((stage) => stage.id === "gate@1")!;
expect(gate.handler).toBe("human");
const html = render(
<HumanQA
stage={gate}
events={[]}
pairs={parsePetriInterviewPairs(fixture.stream)}
/>,
);
expect(html).toContain("Go?");
expect(html).toContain("[Y] Yes");
expect(html).toContain("[N] No");
expect(html).toContain("dev");
expect(html).not.toContain("pending");
});
test("the gate's decision took the no edge", () => {
const gate = stages.find((stage) => stage.id === "gate@1")!;
const edge = findPetriEdgeForStage(fixture.stream, "gate@1");
const html = render(
<ConditionalDecision
stage={gate}
runEvents={[]}
edge={edge}
allStages={stages}
runId="run-1"
/>,
);
expect(html).toContain("/runs/run-1/stages/no@1");
expect(html).not.toContain("No target");
});
test("no question is left pending in the projection", () => {
expect(Object.keys(fixture.projection.pending_interviews)).toEqual([]);
expect(stages.map((stage) => stage.id)).toEqual(["start@1", "gate@1", "no@1", "exit@1"]);
});
});

View file

@ -23,6 +23,7 @@ import {
EventSearchInput,
MultiSelectFilter,
ThreadDnaStrip,
debugRowCategory,
threadSelectionId,
threadSelectionsEqual,
} from "../components/event-debug";
@ -33,6 +34,7 @@ import {
type DebugCategory,
} from "../components/event-debug-helpers";
import type {
EventDisplayPayload,
ThreadDnaItem,
ThreadDnaSelection,
} from "../components/event-debug";
@ -51,7 +53,13 @@ import {
} from "../components/ui";
import { ConditionalDecision } from "../components/stage-renderers/conditional-decision";
import { FanInResults } from "../components/stage-renderers/fan-in-results";
import { extractStageContext } from "../components/stage-renderers/helpers";
import {
extractStageContext,
type EdgeSelection,
type HumanInterviewPair,
type ParallelOverview,
type ReducerTranscript,
} from "../components/stage-renderers/helpers";
import { HumanQA } from "../components/stage-renderers/human-qa";
import { ParallelChildren } from "../components/stage-renderers/parallel-children";
import {
@ -71,6 +79,21 @@ import {
} from "../lib/format";
import { costSourceTag, hasUsage, usageTokenBuckets } from "../lib/usage";
import { plural } from "../lib/plural";
import {
agentEnvelopesOf,
commandOutcomeOf,
commandScriptOf,
debugRowSearchText,
debugRowsFromStream,
extractPetriStageContext,
findPetriEdgeForStage,
isPetriRun,
itemsForStage,
parallelOverviewFromProjection,
parsePetriInterviewPairs,
reducerTranscriptFromProjection,
type DebugRow,
} from "../lib/petri-stream";
import {
useRun,
useRunEventsList,
@ -79,6 +102,7 @@ import {
useRunStageLog,
useRunStages,
useRunState,
useRunStream,
} from "../lib/queries";
import {
STAGE_ACTIVITY_EVENT_TYPES,
@ -98,6 +122,9 @@ import {
import type {
EventEnvelope,
ReasoningOutput,
RunProjection,
RunStreamItem,
StageProjection,
StageHandler,
StageModelUsage,
Usage,
@ -507,6 +534,177 @@ export function eventsToActivity(
return buildStageActivity(events, stageId).turns;
}
/** The stream and projection of a Petri run, threaded into the stage views. */
export interface PetriRunData {
stream: RunStreamItem[];
projection: RunProjection;
}
/**
* The turns of a stage that ran on Petri: the prompt the projection holds,
* the Pebble envelopes the stage's step recorded (assistant messages, tool
* calls, interrupts), and, when no envelope carried the answer, the
* projection's response as the one assistant turn. A command stage is one
* command turn from its `step.started` and final `step.finished`.
*/
export function buildPetriStageActivity(
items: RunStreamItem[],
stage: StageProjection | undefined,
renderer: StageRenderer,
): StageActivity {
const turns: TurnType[] = [];
const pendingTools = new Map<string, PendingTool>();
const firstTs = items[0]
? new Date(items[0].recorded_at).toISOString()
: (stage?.started_at ?? new Date(0).toISOString());
const startTs = stage?.started_at ?? firstTs;
if (renderer === "command") {
const started = items.some(
(item) => item.kind === "petri" && getString(getObject(getObject(item.item, "record"), "body"), "event") === "step.started",
);
if (started || stage) {
const outcome = commandOutcomeOf(items);
const running =
stage?.state === "running" || stage?.state === "retrying";
turns.push({
kind: "command",
ts: startTs,
script: commandScriptOf(items) ?? "",
running,
exitCode: outcome.exitCode,
durationMs: outcome.durationMs || (stage?.timing?.wall_time_ms ?? 0),
outputBytes: stage?.output_bytes ?? 0,
});
}
return { turns, pendingTools: [] };
}
const envelopes = agentEnvelopesOf(items);
// The prompt is the session's `UserInput`; the projection's `prompt` stands
// in for a stage whose session recorded none (a prompt node).
if (stage?.prompt && !envelopes.some((envelope) => envelope.variant === "UserInput")) {
turns.push({ kind: "system", ts: startTs, content: stage.prompt });
}
let sawAssistantMessage = false;
for (const envelope of envelopes) {
const { payload } = envelope;
switch (envelope.variant) {
case "UserInput": {
const text = getString(payload, "text") ?? "";
if (text) turns.push({ kind: "system", ts: envelope.ts, content: text });
break;
}
case "AssistantMessage": {
sawAssistantMessage = true;
const tokens = getObject(getObject(payload, "usage"), "tokens") ?? getObject(payload, "usage") ?? {};
turns.push({
kind: "assistant",
ts: envelope.ts,
content: getString(payload, "text") ?? "",
inputTokens: getNumber(tokens, "input") ?? 0,
outputTokens: (getNumber(tokens, "output") ?? 0) + (getNumber(tokens, "reasoning") ?? 0),
toolCallCount: getNumber(payload, "tool_call_count") ?? null,
reasoning: readTurnReasoning(payload),
});
break;
}
case "ToolCallStarted": {
const callId = getString(payload, "tool_call_id");
if (!callId) break;
const args = payload.arguments;
pendingTools.set(callId, {
ts: envelope.ts,
toolName: getString(payload, "tool_name") ?? "",
input: typeof args === "string" ? args : JSON.stringify(args ?? ""),
});
break;
}
case "ToolCallCompleted": {
const callId = getString(payload, "tool_call_id");
if (!callId) break;
const started = pendingTools.get(callId);
pendingTools.delete(callId);
const output = payload.output ?? "";
turns.push({
kind: "tool",
ts: started?.ts ?? envelope.ts,
toolName: started?.toolName ?? getString(payload, "tool_name") ?? "",
input: started?.input ?? "",
result: typeof output === "string" ? output : JSON.stringify(output, null, 2),
isError: payload.is_error === true,
durationMs: durationBetween(started?.ts, envelope.ts),
});
break;
}
case "SteeringInjected": {
const text = getString(payload, "text") ?? "";
if (text) turns.push({ kind: "steer", ts: envelope.ts, content: text });
break;
}
case "RoundInterrupted":
turns.push({ kind: "interrupt", ts: envelope.ts, content: "Interrupted — waiting for steering" });
break;
default:
break;
}
}
if (!sawAssistantMessage && stage?.response) {
const tokens = stage.usage?.tokens;
turns.push({
kind: "assistant",
ts: stage.completion?.timestamp ?? firstTs,
content: stage.response,
inputTokens: tokens?.input ?? 0,
outputTokens: tokens?.output ?? 0,
toolCallCount: null,
reasoning: null,
});
}
return {
turns,
pendingTools: Array.from(pendingTools, ([toolCallId, tool]) => ({
toolCallId,
toolName: tool.toolName,
input: tool.input,
})),
};
}
/** A debug list item: a legacy event, or a Petri stream row. */
type DebugListItem = EventEnvelope | DebugRow;
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;
return {
event: item.event,
stream_seq: item.seq,
kind: item.item.kind,
stage: item.stageLabel,
recorded_at: item.ts,
item: item.item.item,
};
}
/** What the Petri renderers show for one stage, derived once per stage. */
interface PetriStageViews {
pairs: HumanInterviewPair[];
edge: EdgeSelection | null;
overview: ParallelOverview;
reducer: ReducerTranscript | null;
}
type ToolTurn = Extract<TurnType, { kind: "tool" }>;
type ToolGroupChild = { turn: ToolTurn; turnIndex: number };
type ToolGroupChildren = readonly [
@ -2096,6 +2294,7 @@ function StageActivityBody({
contextData,
runEvents,
stages,
petri,
}: {
effectiveTab: EventsTab;
renderer: StageRenderer;
@ -2107,15 +2306,18 @@ function StageActivityBody({
runId: string;
selectedStage: Stage;
commandTurn: CommandTurn | null;
debugEvents: EventEnvelope[];
filteredDebugEvents: EventEnvelope[];
debugEvents: DebugListItem[];
filteredDebugEvents: DebugListItem[];
openDebugSeq: number | null;
onDebugSeqChange: (seq: number | null) => void;
contextData: ReturnType<typeof extractStageContext>;
runEvents: EventEnvelope[];
stages: Stage[];
/** Set for a stage of a Petri run: the renderers read these, not events. */
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" ? (
@ -2169,23 +2371,29 @@ function StageActivityBody({
turn={commandTurn}
/>
) : renderer === "human" ? (
<HumanQA stage={selectedStage} events={debugEvents} />
<HumanQA stage={selectedStage} events={legacyEvents} pairs={petri?.pairs} />
) : renderer === "conditional" ? (
<ConditionalDecision
stage={selectedStage}
runEvents={runEvents}
edge={petri?.edge}
allStages={stages}
runId={runId}
/>
) : renderer === "parallel" ? (
<ParallelChildren
stage={selectedStage}
events={debugEvents}
events={legacyEvents}
overview={petri?.overview}
runId={runId}
allStages={stages}
/>
) : renderer === "fan_in" ? (
<FanInResults stage={selectedStage} events={debugEvents} />
<FanInResults
stage={selectedStage}
events={legacyEvents}
reducer={petri?.reducer}
/>
) : renderer === "wait" ? (
<WaitStatus stage={selectedStage} />
) : (
@ -2227,6 +2435,7 @@ function RunStageActivityStage({
onKindsChange,
onDebugCategoriesChange,
onSearchChange,
petri,
}: {
runId: string;
selectedStage: Stage;
@ -2240,25 +2449,54 @@ function RunStageActivityStage({
onKindsChange: (kinds: EventKind[]) => void;
onDebugCategoriesChange: (categories: DebugCategory[]) => void;
onSearchChange: (search: string) => void;
/** The run's stream and projection when it executes on Petri. */
petri?: PetriRunData;
}) {
const selectedStageId = selectedStage.id;
const stageEventsQuery = useRunStageEvents(runId, selectedStageId);
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);
const stageItems = useMemo<RunStreamItem[]>(
() => (petri ? itemsForStage(petri.stream, selectedStageId) : []),
[petri, selectedStageId],
);
const stageProjection: StageProjection | undefined =
petri?.projection.stages[selectedStageId];
const activity = useMemo(
() => buildStageActivity(stageEventsQuery.data ?? [], selectedStageId),
[stageEventsQuery.data, selectedStageId],
() =>
petri
? buildPetriStageActivity(stageItems, stageProjection, renderer)
: buildStageActivity(stageEventsQuery.data ?? [], selectedStageId),
[petri, stageItems, stageProjection, renderer, stageEventsQuery.data, selectedStageId],
);
const { turns } = activity;
const renderer: StageRenderer = selectStageRenderer(selectedStage.handler);
const debugEvents = useMemo<EventEnvelope[]>(() => {
const debugEvents = useMemo<DebugListItem[]>(() => {
if (petri) return debugRowsFromStream(stageItems);
return (stageEventsQuery.data ?? []).filter(
(event) => activityEventStageId(event) === selectedStageId,
);
}, [stageEventsQuery.data, 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],
);
// 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(
() => extractStageContext(debugEvents),
[debugEvents],
() =>
petri
? extractPetriStageContext(stageItems)
: extractStageContext(debugEvents as EventEnvelope[]),
[petri, stageItems, debugEvents],
);
const availableTabs = useMemo<EventsTab[]>(
() =>
@ -2276,7 +2514,7 @@ function RunStageActivityStage({
// 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";
const needsRunEvents = renderer === "conditional" && !petri;
const runEventsQuery = useRunEventsList(needsRunEvents ? runId : undefined);
const commandTurn = useMemo<CommandTurn | null>(() => {
if (effectiveTab !== "primary" || renderer !== "command") return null;
@ -2347,34 +2585,27 @@ function RunStageActivityStage({
}
return null;
}, [isPrimaryAgent, displayItems, panelSelection]);
const openDebugEvent = useMemo<EventEnvelope | null>(
() =>
isDebug && openDebugSeq != null
? (debugEvents.find((e) => e.seq === openDebugSeq) ?? null)
: null,
[isDebug, debugEvents, openDebugSeq],
);
const openDebugEvent = useMemo<EventDisplayPayload | null>(() => {
if (!isDebug || openDebugSeq == null) return null;
const item = debugEvents.find((e) => e.seq === openDebugSeq);
return item ? debugItemPayload(item) : null;
}, [isDebug, debugEvents, openDebugSeq]);
const availableDebugCategories = useMemo<DebugCategory[]>(() => {
if (!isDebug) return [];
const set = new Set<DebugCategory>();
for (const event of debugEvents) {
if (event.event) set.add(debugCategory(event.event));
if (event.event) set.add(debugRowCategory(event));
}
return Array.from(set).sort();
}, [isDebug, debugEvents]);
const filteredDebugEvents = useMemo<EventEnvelope[]>(() => {
const filteredDebugEvents = useMemo<DebugListItem[]>(() => {
if (!isDebug) return [];
const useCategoryFilter = selectedDebugCategories.length > 0;
const cats = new Set(selectedDebugCategories);
const needle = search.toLowerCase();
return debugEvents.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;
}
if (useCategoryFilter && !cats.has(debugRowCategory(event))) return false;
if (needle && !debugItemSearchText(event).includes(needle)) return false;
return true;
});
}, [isDebug, debugEvents, selectedDebugCategories, search]);
@ -2461,6 +2692,7 @@ function RunStageActivityStage({
contextData={contextData}
runEvents={runEventsQuery.data ?? []}
stages={stages}
petri={petriViews}
/>
</div>
@ -2493,11 +2725,13 @@ function RunStageActivity({
selectedStage,
stages,
runStart,
petri,
}: {
runId: string;
selectedStage: Stage;
stages: Stage[];
runStart: string | undefined;
petri?: PetriRunData;
}) {
const [activityState, dispatchActivity] = useReducer(
stageActivityReducer,
@ -2532,6 +2766,7 @@ function RunStageActivity({
onSearchChange={(nextSearch) =>
dispatchActivity({ type: "searchChanged", search: nextSearch })
}
petri={petri}
/>
);
}
@ -2552,10 +2787,20 @@ export default function RunStages() {
selectedStage?.startedAt ??
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at;
// Insights sidebar only renders for agent stages; fetch projection + context
// window only when the user is on one to keep the hot path lean.
// 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.
const isAgentStage = selectedStage?.handler === "agent";
const runStateQuery = useRunState(isAgentStage ? id : undefined);
const runStateQuery = useRunState(id);
const petri = isPetriRun(runStateQuery.data);
const streamQuery = useRunStream(petri ? id : undefined);
const petriData = useMemo<PetriRunData | undefined>(
() =>
petri && runStateQuery.data && streamQuery.data
? { stream: streamQuery.data, projection: runStateQuery.data }
: undefined,
[petri, runStateQuery.data, streamQuery.data],
);
const contextWindowQuery = useRunStageContextWindow(
isAgentStage ? id : undefined,
isAgentStage ? selectedStageId : undefined,
@ -2615,6 +2860,7 @@ export default function RunStages() {
selectedStage={selectedStage}
stages={stages}
runStart={runStart}
petri={petriData}
/>
</div>
);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2970,23 +2970,40 @@ paths:
tags: [Run Internals]
summary: List Run Events
description: |
Returns a paginated JSON list of stored run events. Ascending order
uses `since_seq` as an inclusive cursor. Descending order uses
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.
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
description: Paginated list of run events, in the run engine's envelope
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedEventList"
oneOf:
- $ref: "#/components/schemas/PaginatedEventList"
- $ref: "#/components/schemas/PaginatedRunStreamList"
"400":
description: Invalid cursor and order combination
headers:
@ -3097,10 +3114,25 @@ paths:
operationId: attachRunEvents
tags: [Run Internals]
summary: Attach Run Events
description: Opens an ordered server-sent event stream starting at `since_seq`, replaying persisted events and continuing with live updates while the run remains active.
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`.
parameters:
- $ref: "#/components/parameters/RunId"
- $ref: "#/components/parameters/SinceSeq"
- $ref: "#/components/parameters/StreamAfter"
responses:
"200":
description: Server-sent event stream
@ -6354,6 +6386,20 @@ components:
default: 100
example: 100
StreamAfter:
name: after
in: query
required: false
description: |
Run stream cursor for a Petri run: the last `stream_seq` the client
saw, exclusive. `0` starts at the first item.
schema:
type: integer
format: uint64
minimum: 0
default: 0
example: 42
QuestionId:
name: qid
in: path
@ -10933,6 +10979,99 @@ components:
meta:
$ref: "#/components/schemas/PaginationMeta"
RunStreamItemKind:
description: Which item shape a run stream item carries.
type: string
enum: [petri, platform]
RunStreamItem:
description: |
One item of a Petri run's stream: a Petri `RunEvent` or a Fabro
platform record in Fabro's envelope.
`stream_seq` is the durable per-run delivery sequence the projector
assigned when the item's record was committed: dense, strictly
increasing within the run, and the cursor for `after`. `id` is the
item's own identity, kept beside the cursor so a client deduplicates
by it: for a Petri event the `EventId` as `<log>/<seq>/<index>`
(`coordinator/3/0`, `execution 1/23/0`); for a platform record its
`seq`. Petri's `EventId` is per log and has no platform variant, so
it is never the cursor.
A `petri` item is a Petri `RunEvent` passed through unchanged:
`{id: {log, execution?, seq, index}, origin, recorded_at,
observed_at?, context: {invocation, execution, parent?}, subject?,
record?, derived?}`. Its vocabulary is Petri's public event contract
(`crates/core/execution/EVENTS.md` in the Petri repository), not
Fabro's: the recorded event's name is `record.body.event`
(`<subject>.<verb>`, e.g. `visit.started`, `step.finished`,
`run.finished`), a derived view event's is `derived.event`, and the
stage a subject names is `(context.execution, subject.firing)` with
`subject.node.name` and `subject.visit` as its display label. The
server reports the contract version it serves in
`PaginatedRunStreamList.event_contract_version`.
A `platform` item is a stored platform record: `{seq, recorded_at,
record: {kind, ...}, position?: {execution, firing}}`. `record.kind`
is one of `run.created`, `run.lifecycle`, `run.title`, `run.parent`,
`run.archived`, `run.unarchived`, `run.superseded`, `run.notice`,
`interview.answered`, `run.branch`, `git.identity`, `checkpoint`,
`pull_request.created`, `notification.sent`, `run.paired`.
type: object
required:
- run_id
- stream_seq
- kind
- id
- recorded_at
- item
properties:
run_id:
type: string
stream_seq:
type: integer
format: uint64
minimum: 0
description: The delivery sequence; the cursor.
kind:
$ref: "#/components/schemas/RunStreamItemKind"
id:
type: string
description: The item's own identity, for deduplication.
recorded_at:
type: integer
format: uint64
minimum: 0
description: Milliseconds since the Unix epoch when the item's record was appended.
item:
type: object
additionalProperties: true
description: The Petri `RunEvent` or the stored platform record, unchanged.
PaginatedRunStreamList:
description: |
One page of a Petri run's stream, in `stream_seq` order.
`event_contract_version` is Petri's `EVENT_CONTRACT_VERSION` the
server was built against: the version of the event contract every
`petri` item follows.
type: object
required:
- data
- meta
- event_contract_version
properties:
data:
type: array
items:
$ref: "#/components/schemas/RunStreamItem"
meta:
$ref: "#/components/schemas/PaginationMeta"
event_contract_version:
type: integer
format: uint32
minimum: 0
example: 3
AppendEventResponse:
description: Assigned sequence number for an appended event.
type: object
@ -12841,6 +12980,58 @@ components:
oneOf:
- $ref: "#/components/schemas/ForkSourceRef"
- type: "null"
engine:
$ref: "#/components/schemas/RunEngine"
description: |
The engine the run was created for, with what it admitted.
Absent in a spec written before the field existed, which means
the legacy executor.
RunEngine:
description: |
The engine a run was created for. `legacy` is the in-process
executor; `petri` names the Petri workflow engine and carries what
Petri admitted at create time.
oneOf:
- type: object
required: [kind]
properties:
kind:
type: string
enum: [legacy]
- allOf:
- type: object
required: [kind]
properties:
kind:
type: string
enum: [petri]
- $ref: "#/components/schemas/PetriAdmission"
PetriAdmission:
description: |
What Petri admitted for a run at create time: the lowered root graph
and the pre-lowered child graphs, every one persisted in the blob
store before the run exists.
type: object
required: [graph]
properties:
graph:
$ref: "#/components/schemas/PetriGraphRef"
children:
type: array
items:
$ref: "#/components/schemas/PetriGraphRef"
PetriGraphRef:
description: An admitted graph in the blob store, verified by digest on load.
type: object
required: [blob, digest]
properties:
blob:
$ref: "#/components/schemas/BlobHash"
digest:
type: string
UpdateRunParentRequest:
type: object

View file

@ -31,7 +31,7 @@ use fabro_workflow::run_status::RunStatus;
use tokio::signal::ctrl_c;
use tokio::time::{Duration as TokioDuration, sleep};
use super::run_progress;
use super::{petri_stream, run_progress};
use crate::server_client;
const INTERVIEW_UNANSWERED_MESSAGE: &str =
@ -39,6 +39,9 @@ const INTERVIEW_UNANSWERED_MESSAGE: &str =
const JSON_INTERVIEW_MESSAGE: &str = "This run is waiting for human input, but --json is non-interactive. Reattach without --json to answer it.";
const ATTACH_PREMATURE_EOF_MESSAGE: &str = "Attach stream ended before terminal run event.";
const PROMPT_READ_POLL_INTERVAL: TokioDuration = TokioDuration::from_millis(50);
/// How long a Petri attach waits before it reconnects to the stream the
/// server ended while the run was still active.
const STREAM_RECONNECT_DELAY: TokioDuration = TokioDuration::from_millis(200);
enum PromptRead {
Line(String),
@ -182,6 +185,22 @@ pub(crate) async fn attach_run_with_client(
) -> Result<ExitCode> {
let state = client.get_run_state(run_id).await?;
let auto_approve = state.spec.settings.run.execution.approval == ApprovalMode::Auto;
if state.spec.engine.is_petri() {
return Box::pin(attach_petri_run_with_client(
client,
run_id,
&state,
styles,
AttachOptions {
auto_approve,
verbose: live_verbose,
kill_on_detach,
json_output,
},
printer,
))
.await;
}
let events = client.list_run_events(run_id, None, None).await?;
let replay_events = events.clone();
let next_seq = events.last().map_or(1, |event| event.seq.saturating_add(1));
@ -321,6 +340,197 @@ async fn attach_live_run_with_client(
}
}
/// Attach to a Petri run: replay its stream through the progress renderer,
/// then follow it live from the last `stream_seq` seen. A question on the
/// stream is asked at the terminal and answered through the questions API.
/// When the server ends the stream before the run's terminal record, the
/// attach reconnects from its cursor, so no item is missed or repeated.
async fn attach_petri_run_with_client(
client: &server_client::Client,
run_id: &RunId,
state: &server_client::RunProjection,
styles: &'static Styles,
opts: AttachOptions,
printer: Printer,
) -> Result<ExitCode> {
let is_tty = std::io::stderr().is_terminal();
let mut progress_ui = run_progress::ProgressUI::new(is_tty, opts.verbose);
let ctrl_c_signal = ctrl_c();
tokio::pin!(ctrl_c_signal);
let items = client.list_run_stream(run_id, 0).await?;
let mut cursor = items.last().map_or(0, |item| item.stream_seq);
let mut replayed_exit_code = None;
for item in &items {
emit_stream_item(&mut progress_ui, item, opts.json_output)?;
if let Some(code) = petri_stream::exit_code_of(item) {
replayed_exit_code = Some(ExitCode::from(code));
}
}
if let Some(exit_code) = replayed_exit_code.or_else(|| {
state_is_terminal(state).then(|| state_exit_code(state).unwrap_or(ExitCode::from(1)))
}) {
finish_progress(&mut progress_ui, opts.json_output);
return Ok(exit_code);
}
loop {
let mut stream = client.attach_run_stream(run_id, Some(cursor)).await?;
if let Some(exit_code) = Box::pin(handle_pending_petri_interview(
client,
run_id,
&mut stream,
&mut cursor,
&opts,
&mut progress_ui,
styles,
printer,
))
.await?
{
return Ok(exit_code);
}
loop {
let next_item = tokio::select! {
_ = &mut ctrl_c_signal => {
handle_detach_signal(client, run_id, opts.kill_on_detach, printer).await;
finish_progress(&mut progress_ui, opts.json_output);
return Ok(ExitCode::from(1));
}
result = stream.next_item() => result?,
};
let Some(item) = next_item else {
break;
};
cursor = item.stream_seq;
emit_stream_item(&mut progress_ui, &item, opts.json_output)?;
if let Some(code) = petri_stream::exit_code_of(&item) {
finish_progress(&mut progress_ui, opts.json_output);
return Ok(ExitCode::from(code));
}
if petri_stream::question_of(&item).is_some() {
if let Some(exit_code) = Box::pin(handle_pending_petri_interview(
client,
run_id,
&mut stream,
&mut cursor,
&opts,
&mut progress_ui,
styles,
printer,
))
.await?
{
return Ok(exit_code);
}
}
}
// The server ended the stream. A run that concluded has nothing
// more to send past what the grace let through; otherwise this is
// a lost connection, and the attach resumes from its cursor.
let state = client.get_run_state(run_id).await?;
if state_is_terminal(&state) {
for item in client.list_run_stream(run_id, cursor).await? {
emit_stream_item(&mut progress_ui, &item, opts.json_output)?;
}
finish_progress(&mut progress_ui, opts.json_output);
return Ok(state_exit_code(&state).unwrap_or(ExitCode::from(1)));
}
sleep(STREAM_RECONNECT_DELAY).await;
}
}
/// Ask the run's pending question, if one is listed, while the stream keeps
/// flowing: an answer given elsewhere, or the run ending, ends the prompt.
async fn handle_pending_petri_interview(
client: &server_client::Client,
run_id: &RunId,
stream: &mut server_client::RunStreamItemStream,
cursor: &mut u64,
opts: &AttachOptions,
progress_ui: &mut run_progress::ProgressUI,
styles: &'static Styles,
printer: Printer,
) -> Result<Option<ExitCode>> {
let Some(question) = client.list_run_questions(run_id).await?.into_iter().next() else {
return Ok(None);
};
if json_pending_interview_requires_manual_input(opts.json_output, opts.auto_approve) {
fabro_util::printerr!(printer, "{JSON_INTERVIEW_MESSAGE}");
return Ok(Some(ExitCode::from(1)));
}
if opts.json_output {
return Ok(None);
}
hide_progress(progress_ui, opts.json_output);
let ask = ask_attach_question(api_question_to_question(&question), styles);
tokio::pin!(ask);
let ctrl_c_signal = ctrl_c();
tokio::pin!(ctrl_c_signal);
let answer = loop {
let next_item = tokio::select! {
answer = &mut ask => {
break answer;
}
_ = &mut ctrl_c_signal => {
handle_detach_signal(client, run_id, opts.kill_on_detach, printer).await;
show_progress(progress_ui, opts.json_output);
return Ok(Some(ExitCode::from(1)));
}
result = stream.next_item() => result?,
};
// The stream ended under the prompt: the caller reconnects and asks
// again if the question is still pending.
let Some(item) = next_item else {
show_progress(progress_ui, opts.json_output);
return Ok(None);
};
*cursor = item.stream_seq;
emit_stream_item(progress_ui, &item, opts.json_output)?;
if let Some(code) = petri_stream::exit_code_of(&item) {
show_progress(progress_ui, opts.json_output);
return Ok(Some(ExitCode::from(code)));
}
if petri_stream::resolves_question(&item, &question.id) {
show_progress(progress_ui, opts.json_output);
return Ok(None);
}
};
show_progress(progress_ui, opts.json_output);
if answer_requires_reattach(&answer) {
fabro_util::printerr!(printer, "{INTERVIEW_UNANSWERED_MESSAGE}");
return Ok(Some(ExitCode::from(1)));
}
submit_server_interview_answer(client, run_id, &question.id, &answer).await?;
Ok(None)
}
fn emit_stream_item(
progress_ui: &mut run_progress::ProgressUI,
item: &fabro_types::RunStreamItem,
json_output: bool,
) -> Result<()> {
if json_output {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
writeln!(handle, "{}", petri_stream::raw_line(item)?)?;
} else {
progress_ui.handle_stream_item(item);
}
Ok(())
}
async fn handle_pending_server_interview(
client: &server_client::Client,
run_id: &RunId,

View file

@ -20,6 +20,7 @@ use fabro_util::terminal::Styles;
use tokio::time;
use tracing::{debug, info};
use super::petri_stream;
use crate::args::EventsArgs;
use crate::command_context::CommandContext;
use crate::server_client;
@ -42,6 +43,24 @@ pub(crate) async fn run(
None => None,
};
// A Petri run's events are its stream, in the stream envelope.
let state = client
.get_run_state(&run_id)
.await
.context("Failed to read run state from server")?;
if state.spec.engine.is_petri() {
let pretty = args.pretty && !ctx.json_output();
return Box::pin(petri_stream::print_events(
client.as_ref(),
&run_id,
args,
since_cutoff,
pretty,
styles,
))
.await;
}
let events = match (args.tail, since_cutoff.is_none()) {
(Some(tail), true) => {
// With --tail 0 --follow, fetch one event anyway so `last_seq`

View file

@ -20,6 +20,7 @@ pub(crate) mod fork;
pub(crate) mod logs;
pub(crate) mod output;
pub(crate) mod overrides;
pub(crate) mod petri_stream;
mod petri_worker;
pub(crate) mod preview;
mod remote_workflow;
@ -126,7 +127,7 @@ pub(crate) async fn dispatch(
RunCommands::Diff(args) => diff::run(args, base_ctx).await,
RunCommands::Events(args) => {
let styles = Styles::detect_stdout();
events::run(&args, &styles, base_ctx).await
Box::pin(events::run(&args, &styles, base_ctx)).await
}
RunCommands::Logs(args) => logs::run(&args, base_ctx).await,
RunCommands::Resume(args) => {

File diff suppressed because it is too large Load diff

View file

@ -388,7 +388,23 @@ fn agent_progress_event(
stored: &RunEvent,
event: &CodingEvent,
) -> Option<ProgressEvent> {
let root_session = stored.parent_session_id.is_none();
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
/// from the root session and when it was recorded: the mapping the legacy
/// envelope and a Petri stream envelope share.
pub(super) fn coding_progress_event(
node_id: String,
root_session: bool,
timestamp: Option<DateTime<Utc>>,
event: &CodingEvent,
) -> Option<ProgressEvent> {
match event {
CodingEvent::AssistantMessage { model, .. } => Some(ProgressEvent::AssistantMessage {
stage_node_id: node_id,
@ -401,10 +417,10 @@ fn agent_progress_event(
arguments,
} => Some(ProgressEvent::ToolCallStarted {
stage_node_id: node_id,
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: arguments.clone(),
timestamp: Some(stored.ts),
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: arguments.clone(),
timestamp,
}),
CodingEvent::ToolCallCompleted {
tool_call_id,
@ -412,10 +428,10 @@ fn agent_progress_event(
..
} => Some(ProgressEvent::ToolCallCompleted {
stage_node_id: node_id,
tool_call_id: tool_call_id.clone(),
is_error: *is_error,
duration_ms: None,
timestamp: Some(stored.ts),
tool_call_id: tool_call_id.clone(),
is_error: *is_error,
duration_ms: None,
timestamp,
}),
CodingEvent::Warning { kind, details, .. } if kind == "context_window" => {
let usage_percent = details

View file

@ -3,10 +3,11 @@
reason = "sync CLI run-progress renderer: writes to std::io::stderr directly"
)]
use fabro_types::{RunEvent, RunNoticeCode};
use fabro_types::{RunEvent, RunNoticeCode, RunStreamItem};
mod event;
mod info_display;
mod petri;
mod renderer;
mod setup_display;
mod stage_display;
@ -14,6 +15,7 @@ mod styles;
use event::{ProgressEvent, from_json_line, from_run_event};
use info_display::InfoDisplay;
use petri::PetriProgressState;
use renderer::ProgressRenderer;
use setup_display::SetupDisplay;
use stage_display::StageDisplay;
@ -24,6 +26,7 @@ pub(crate) struct ProgressUI {
setup: SetupDisplay,
info: InfoDisplay,
saw_metadata_snapshot_failure: bool,
petri: PetriProgressState,
}
impl ProgressUI {
@ -46,6 +49,7 @@ impl ProgressUI {
setup: SetupDisplay::new(verbose),
info: InfoDisplay::new(verbose),
saw_metadata_snapshot_failure: false,
petri: PetriProgressState::default(),
}
}
@ -95,6 +99,14 @@ impl ProgressUI {
}
}
/// One item of a Petri run's stream: the progress lines it means, if
/// any, rendered as a legacy event's would be.
pub(crate) fn handle_stream_item(&mut self, item: &RunStreamItem) {
for progress_event in petri::progress_events(item, &mut self.petri) {
self.dispatch(progress_event);
}
}
fn dispatch(&mut self, event: ProgressEvent) {
let renderer = &self.renderer;
match event {

View file

@ -0,0 +1,264 @@
//! The progress lines a Petri run's stream items mean: the mapping from
//! Petri's `<subject>.<verb>` events and Fabro's platform records onto the
//! [`ProgressEvent`]s the renderer already draws for a legacy run.
//!
//! A stage is a firing whose node is a logical stage (`VIEWS.md`); its
//! display key is the node's name, as a legacy stage's `node_id` is. A
//! fork's `parallel.branch` delegates are the branches of the parallel
//! group, never stages of their own.
use fabro_types::run_event::RunNoticeLevel;
use fabro_types::{CodingAgentEvent, RunStreamItem, StageOutcome, StageTiming};
use serde_json::Value;
use super::event::{ProgressEvent, ProgressUsage, coding_progress_event};
use crate::commands::run::petri_stream::{PetriItem, StageClock};
/// What the mapping remembers between items: when each firing started.
#[derive(Default)]
pub(super) struct PetriProgressState {
clock: StageClock,
}
/// The progress events one stream item means, in order.
pub(super) fn progress_events(
item: &RunStreamItem,
state: &mut PetriProgressState,
) -> Vec<ProgressEvent> {
let elapsed = state.clock.observe(item);
let view = PetriItem::new(item);
if let Some(record) = view.platform_record() {
return platform_progress_event(record).into_iter().collect();
}
let Some(name) = view.name() else {
return Vec::new();
};
let node_id = view.node_name().unwrap_or("?").to_string();
let label = view.node_label().unwrap_or("?").to_string();
match name {
"visit.started" => {
if view.is_shown_stage() {
vec![ProgressEvent::StageStarted {
node_id,
name: label,
script: None,
}]
} else if view.node_kind() == Some("parallel.branch") {
vec![ProgressEvent::ParallelBranchStarted { branch: node_id }]
} else {
Vec::new()
}
}
"visit.completed" if view.is_shown_stage() => {
let Some(derived) = view.derived() else {
return Vec::new();
};
let outcome = derived.get("outcome");
let executed = derived
.get("executed")
.and_then(Value::as_bool)
.unwrap_or(true);
let status = outcome
.and_then(|outcome| outcome.get("status"))
.and_then(Value::as_str)
.unwrap_or("?");
let timing = StageTiming {
wall_time_ms: elapsed.unwrap_or(0),
..StageTiming::default()
};
let completed =
|status: &str, usage: Option<ProgressUsage>| ProgressEvent::StageCompleted {
node_id: node_id.clone(),
name: label.clone(),
timing,
status: status.to_string(),
usage,
};
if !executed || status == "skipped" {
return vec![completed("skipped", None)];
}
match status {
"success" => vec![completed("succeeded", outcome.and_then(usage_of))],
"partial_success" => {
vec![completed("partially_succeeded", outcome.and_then(usage_of))]
}
"cancelled" => vec![completed("cancelled", None)],
other => {
let error = outcome
.and_then(|outcome| outcome.pointer("/failure/message"))
.and_then(Value::as_str)
.unwrap_or(other)
.to_string();
vec![ProgressEvent::StageFailed {
node_id,
name: label,
error,
}]
}
}
}
"retry.scheduled" => {
let Some(derived) = view.derived() else {
return Vec::new();
};
let attempt = derived
.get("next_attempt")
.and_then(Value::as_u64)
.unwrap_or(0);
let delay_ms = derived
.pointer("/base_delay/secs")
.and_then(Value::as_u64)
.map(|secs| secs.saturating_mul(1000))
.or_else(|| derived.get("base_delay").and_then(Value::as_u64))
.unwrap_or(0);
vec![ProgressEvent::StageRetrying {
name: label,
attempt,
max_attempts: attempt,
delay_ms,
}]
}
"fork.started" => vec![ProgressEvent::ParallelStarted],
"branch.completed" => {
let Some(result) = view.derived().and_then(|derived| derived.get("result")) else {
return Vec::new();
};
let branch = result
.pointer("/node/name")
.and_then(Value::as_str)
.unwrap_or(&node_id)
.to_string();
let status = match result.get("status").and_then(Value::as_str) {
Some("success") => StageOutcome::Succeeded,
Some("partial_success") => StageOutcome::PartiallySucceeded,
_ => StageOutcome::Failed {
retry_requested: false,
},
};
vec![ProgressEvent::ParallelBranchCompleted {
branch,
duration_ms: elapsed.unwrap_or(0),
status,
}]
}
"fork.completed" => vec![ProgressEvent::ParallelCompleted],
"route.applied" => {
let Some(derived) = view.derived() else {
return Vec::new();
};
let Some(to_node) = derived.pointer("/target/name").and_then(Value::as_str) else {
return Vec::new();
};
let back = derived
.get("back")
.and_then(Value::as_bool)
.unwrap_or(false);
if back {
vec![ProgressEvent::LoopRestart {
from_node: node_id,
to_node: to_node.to_string(),
}]
} else {
vec![ProgressEvent::EdgeSelected {
from_node: node_id,
to_node: to_node.to_string(),
label: None,
condition: derived
.get("transition")
.and_then(Value::as_str)
.filter(|transition| *transition != "Continue")
.map(str::to_lowercase),
}]
}
}
"step.progress.recorded" => envelope_progress_event(view, node_id).into_iter().collect(),
_ => Vec::new(),
}
}
/// The progress line of a Pebble coding-agent envelope on a stage's
/// stream, if the terminal shows it.
fn envelope_progress_event(view: PetriItem<'_>, node_id: String) -> Option<ProgressEvent> {
let custom = view.custom()?;
if custom.get("kind").and_then(Value::as_str) != Some("pebble") {
return None;
}
let envelope: CodingAgentEvent = serde_json::from_value(custom.get("event")?.clone()).ok()?;
let root_session = envelope.parent_session_id.is_none();
coding_progress_event(
node_id,
root_session,
Some(view.recorded_at()),
&envelope.event,
)
}
/// The stage's model usage from a finished visit's metrics, when the step
/// reported one.
fn usage_of(outcome: &Value) -> Option<ProgressUsage> {
let custom = outcome.pointer("/metrics/custom")?;
let usage = custom
.get("pebble.usage")
.or_else(|| custom.get("prompt.usage"))?;
let tokens = usage.get("tokens")?;
Some(ProgressUsage {
input_tokens: tokens.get("input").and_then(Value::as_u64).unwrap_or(0),
output_tokens: tokens.get("output").and_then(Value::as_u64).unwrap_or(0),
cost: usage
.pointer("/cost/usd_micros")
.and_then(Value::as_u64)
.map(|micros| micros as f64 / 1_000_000.0),
})
}
/// The progress line a platform record means, if the terminal shows it.
fn platform_progress_event(record: &Value) -> Option<ProgressEvent> {
match record.get("kind")?.as_str()? {
"run.created" => Some(ProgressEvent::RunCreated {
web_url: record
.get("web_url")
.and_then(Value::as_str)
.map(str::to_string),
}),
"run.branch" => Some(ProgressEvent::WorkflowStarted {
worktree_dir: None,
base_branch: record
.get("run_branch")
.and_then(Value::as_str)
.map(str::to_string),
base_sha: record
.get("base_sha")
.and_then(Value::as_str)
.map(str::to_string),
}),
"run.notice" => Some(ProgressEvent::RunNotice {
level: match record.get("level").and_then(Value::as_str) {
Some("warn") => RunNoticeLevel::Warn,
Some("error") => RunNoticeLevel::Error,
_ => RunNoticeLevel::Info,
},
code: record
.get("code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
message: record
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
}),
"pull_request.created" => Some(ProgressEvent::PullRequestCreated {
pr_url: record
.get("html_url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
draft: record
.get("draft")
.and_then(Value::as_bool)
.unwrap_or(false),
}),
_ => None,
}
}

View file

@ -7,7 +7,7 @@ use fabro_client::{
AuthEntry, AuthStore, Credential, OAuthSession, ServerTarget, TransportConnector,
apply_bearer_token_auth,
};
pub(crate) use fabro_client::{Client, RunEventStream};
pub(crate) use fabro_client::{Client, RunEventStream, RunStreamItemStream};
use fabro_config::Storage;
use fabro_config::bind::Bind;
pub(crate) use fabro_types::RunProjection;

View file

@ -22,8 +22,9 @@
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
use std::env;
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
use fabro_client::ServerTarget;
@ -33,14 +34,14 @@ use fabro_petri::checkpoint::CheckpointKey;
use fabro_petri::engine::{self, RunStatus};
use fabro_petri::petri::RunKey;
use fabro_static::EnvVars;
use fabro_store::{EventEnvelope, PlatformRecord, PlatformRecordKind, PlatformRecordStore};
use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context};
use fabro_types::{EventBody, RunId};
use fabro_store::{PlatformRecord, PlatformRecordKind, PlatformRecordStore};
use fabro_test::{
apply_test_isolation, expect_reqwest_json, fabro_snapshot, isolated_storage_dir, test_context,
};
use fabro_types::RunId;
use crate::cmd::support::created_run_id;
use crate::support::{
TEST_DEV_TOKEN, TEST_SESSION_SECRET, parse_event_envelopes, seed_dev_token_auth,
};
use crate::support::{TEST_DEV_TOKEN, TEST_SESSION_SECRET, seed_dev_token_auth};
const HOST_PLUGIN: &str = "sandbox-driver-host";
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
@ -471,39 +472,93 @@ async fn wait_for_status(server: &RunningServer, run_id: &str, expected: &[&str]
}
}
async fn run_events(server: &RunningServer, run_id: &str) -> Vec<EventEnvelope> {
parse_event_envelopes(&run_json(server, &format!("runs/{run_id}/events")).await)
/// The run's stream, as `GET /runs/{id}/events` serves a Petri run: every
/// item in `stream_seq` order, in the stream envelope.
async fn run_stream(server: &RunningServer, run_id: &str) -> Vec<serde_json::Value> {
let mut items = Vec::new();
let mut after = 0;
loop {
let page = run_json(
server,
&format!("runs/{run_id}/events?after={after}&limit=1000"),
)
.await;
let data = page["data"]
.as_array()
.cloned()
.expect("the stream page has a data array");
let Some(last) = data.last() else {
break;
};
after = last["stream_seq"].as_u64().expect("a stream_seq");
let has_more = page["meta"]["has_more"].as_bool().unwrap_or(false);
items.extend(data);
if !has_more {
break;
}
}
items
}
/// The run's events once `terminal` is among them: the events list is a
/// projection that settles after the run's status does.
async fn settled_events(
server: &RunningServer,
run_id: &str,
terminal: &str,
) -> Vec<EventEnvelope> {
/// What each stream item is, for an assertion: a Petri event by its
/// `<subject>.<verb>` name (`question` and `question_expired` for the parsed
/// progress payloads), a platform lifecycle record as
/// `lifecycle:<transition>`, another platform record by its kind.
fn stream_names(items: &[serde_json::Value]) -> Vec<String> {
items
.iter()
.map(|line| {
let item = &line["item"];
if line["kind"] == "platform" {
let record = &item["record"];
return match record["kind"].as_str().unwrap_or("?") {
"run.lifecycle" => {
format!("lifecycle:{}", record["transition"].as_str().unwrap_or("?"))
}
kind => kind.to_string(),
};
}
if let Some(kind) = item["derived"]["parsed"]["kind"].as_str() {
if matches!(kind, "question" | "question_expired") {
return kind.to_string();
}
}
item["record"]["body"]["event"]
.as_str()
.or_else(|| item["derived"]["event"].as_str())
.unwrap_or("?")
.to_string()
})
.collect()
}
fn count_of(names: &[String], expected: &str) -> usize {
names.iter().filter(|name| *name == expected).count()
}
/// The run's whole stream once it is settled: Fabro's terminal lifecycle
/// record lands a moment after the engine's finish (the worker exits, the
/// server records the status, the projector folds it), so a reader that
/// wants the end of the stream waits for that record.
async fn settled_stream(server: &RunningServer, run_id: &str) -> Vec<serde_json::Value> {
let deadline = Instant::now() + RUN_TIMEOUT;
loop {
let events = run_events(server, run_id).await;
if event_names(&events).contains(&terminal) {
return events;
let items = run_stream(server, run_id).await;
let names = stream_names(&items);
if names
.iter()
.any(|name| matches!(name.as_str(), "lifecycle:succeeded" | "lifecycle:failed"))
{
return items;
}
assert!(
Instant::now() < deadline,
"`{terminal}` was never projected for run {run_id}: {:?}",
event_names(&events)
"run {run_id} never recorded its terminal lifecycle transition: {names:?}"
);
tokio::time::sleep(POLL).await;
}
}
fn event_names(events: &[EventEnvelope]) -> Vec<&str> {
events
.iter()
.map(|envelope| envelope.event.event_name())
.collect()
}
/// The pid of the worker subprocess the server launched for the run: the
/// worker retitles itself `fabro <first 12 of the run id> <phase>`, so that
/// is what the process table shows.
@ -572,18 +627,12 @@ async fn a_petri_run_executes_in_the_server_launched_worker() {
let state = run_json(&server, &format!("runs/{run_id}/state")).await;
assert_eq!(state["spec"]["engine"]["kind"], "petri", "state: {state}");
let events = settled_events(&server, &run_id, "run.completed").await;
let names = event_names(&events);
assert_eq!(
names
.iter()
.filter(|name| **name == "run.completed")
.count(),
1,
"{names:?}"
);
let names = stream_names(&settled_stream(&server, &run_id).await);
assert_eq!(count_of(&names, "lifecycle:succeeded"), 1, "{names:?}");
assert_eq!(count_of(&names, "run.finished"), 1, "{names:?}");
assert!(
names.contains(&"run.starting") && names.contains(&"run.running"),
names.iter().any(|name| name == "lifecycle:starting")
&& names.iter().any(|name| name == "lifecycle:running"),
"{names:?}"
);
@ -612,7 +661,7 @@ async fn a_petri_run_executes_in_the_server_launched_worker() {
/// A Petri run whose worker and server both die mid-stage continues after
/// the server restarts: the new server releases the dead worker's lease,
/// asks the run to start again as a resume, and launches a worker in
/// resume mode, which finishes the run with one `run.completed`.
/// resume mode, which finishes the run with one terminal lifecycle record.
#[tokio::test(flavor = "multi_thread")]
async fn a_petri_run_resumes_in_a_new_worker_after_the_server_restarts() {
if host_plugin().is_none() {
@ -659,40 +708,35 @@ async fn a_petri_run_resumes_in_a_new_worker_after_the_server_restarts() {
std::fs::write(&gate, "go").expect("the gate opens");
let status = wait_for_status(&server, &run_id, &["succeeded", "failed"]).await;
let items = settled_stream(&server, &run_id).await;
let names = stream_names(&items);
assert_eq!(
status,
"succeeded",
"server stderr:\n{}",
"stream: {names:?}\nserver stderr:\n{}",
server.stderr_text()
);
let events = settled_events(&server, &run_id, "run.completed").await;
let names = event_names(&events);
assert_eq!(
names
.iter()
.filter(|name| **name == "run.completed")
.count(),
1,
"{names:?}"
);
assert_eq!(count_of(&names, "lifecycle:succeeded"), 1, "{names:?}");
assert_eq!(count_of(&names, "run.finished"), 1, "{names:?}");
// `fabro run` asked for the first start; the restart asked for a
// resume, after the run had been running.
let first_running = names
.iter()
.position(|name| *name == "run.running")
.position(|name| name == "lifecycle:running")
.expect("the run ran before the crash");
let resume_request = events
let resume_request = items
.iter()
.position(|envelope| {
matches!(
&envelope.event.body,
EventBody::RunStartRequested(props) if props.resume
)
.position(|line| {
let record = &line["item"]["record"];
line["kind"] == "platform"
&& record["kind"] == "run.lifecycle"
&& record["transition"] == "start_requested"
&& record["source"] == "resume"
})
.expect("the restart asked for a resume");
assert!(resume_request > first_running, "{names:?}");
assert_eq!(
names.iter().filter(|name| **name == "run.running").count(),
count_of(&names, "lifecycle:running"),
2,
"the run ran once before and once after the restart: {names:?}"
);
@ -846,11 +890,11 @@ async fn a_human_gate_in_the_worker_is_answered_through_the_api() {
markers.join("no").exists() && !markers.join("yes").exists(),
"the no branch ran"
);
let names = run_events(&server, &run_id).await;
let names = event_names(&names);
let names = stream_names(&run_stream(&server, &run_id).await);
assert!(
names.contains(&"interview.started") && names.contains(&"interview.completed"),
"{names:?}"
names.iter().any(|name| name == "question")
&& names.iter().any(|name| name == "interview.answered"),
"the question and who answered it are on the stream: {names:?}"
);
assert!(questions(&server, &run_id).await.is_empty());
let store = server.petri_store().await;
@ -945,13 +989,315 @@ async fn an_unanswered_gate_in_the_worker_expires_with_its_default() {
markers.join("no").exists() && !markers.join("yes").exists(),
"the default ran"
);
let events = run_events(&server, &run_id).await;
let names = event_names(&events);
assert!(names.contains(&"interview.timeout"), "{names:?}");
let names = stream_names(&run_stream(&server, &run_id).await);
assert!(
names.iter().any(|name| name == "question_expired"),
"{names:?}"
);
assert!(questions(&server, &run_id).await.is_empty());
server.shutdown();
}
/// A CLI command against the server, as `run_detached` seeds its auth.
fn cli(context: &fabro_test::TestContext, server: &RunningServer, args: &[&str]) -> Output {
let target = server.target();
let output = context
.command()
.args(args)
.args(["--server", &target])
.output()
.expect("the CLI command executes");
assert!(
output.status.success(),
"`fabro {}` failed\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn ndjson(output: &Output) -> Vec<serde_json::Value> {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("a JSON line"))
.collect()
}
/// The `<subject>.<verb>` name of a Petri item, or the kind of a platform
/// record, from a raw stream line.
fn stream_line_name(line: &serde_json::Value) -> String {
let item = &line["item"];
if line["kind"] == "platform" {
return format!(
"platform:{}",
item["record"]["kind"].as_str().unwrap_or("?")
);
}
item["record"]["body"]["event"]
.as_str()
.or_else(|| item["derived"]["event"].as_str())
.unwrap_or("?")
.to_string()
}
/// The snapshot filters for `events --pretty` over a Petri run: clocks,
/// durations and the run id vary per run.
fn pretty_filters(context: &fabro_test::TestContext) -> Vec<(String, String)> {
let mut filters = context.filters();
filters.push((r"\b\d{2}:\d{2}:\d{2}\b".to_string(), "[CLOCK]".to_string()));
filters.push((
r"\b\d+(\.\d+)?(ms|s)\b".to_string(),
"[DURATION]".to_string(),
));
filters.push((
r"Checkpoint [0-9a-f]{7}".to_string(),
"Checkpoint [SHA]".to_string(),
));
filters
}
/// A finished Petri run reads back through the CLI: `events` prints the
/// stream envelope raw, dense in `stream_seq`; `events --pretty` renders
/// the stages by `<subject>.<verb>` with their labels and the platform
/// records by kind; `attach` replays it and exits with the run's status;
/// `wait` and `runs inspect` read the projection.
#[tokio::test(flavor = "multi_thread")]
async fn a_finished_petri_run_reads_back_through_the_cli() {
if host_plugin().is_none() {
return;
}
let context = test_context!();
let server = RunningServer::start().await;
let workspace = write_petri_workspace(&context, "echo hello from petri");
let run_id = run_detached(&context, &server, &workspace);
let status = wait_for_status(&server, &run_id, &["succeeded", "failed"]).await;
assert_eq!(
status,
"succeeded",
"server stderr:\n{}",
server.stderr_text()
);
settled_stream(&server, &run_id).await;
let target = server.target();
// Raw: the envelope, one item per line, dense and in order.
let raw = cli(&context, &server, &["events", &run_id]);
let lines = ndjson(&raw);
let seqs: Vec<u64> = lines
.iter()
.map(|line| line["stream_seq"].as_u64().expect("a stream_seq"))
.collect();
let expected: Vec<u64> = (1..=seqs.len() as u64).collect();
assert_eq!(seqs, expected, "stream_seq is dense");
for line in &lines {
assert_eq!(line["run_id"], run_id, "{line}");
assert!(
line["id"].is_string() && line["recorded_at"].is_u64(),
"{line}"
);
}
let names: Vec<String> = lines.iter().map(stream_line_name).collect();
for expected in [
"platform:run.created",
"platform:run.lifecycle",
"run.started",
"visit.started",
"visit.completed",
"run.finished",
] {
assert!(
names.iter().any(|name| name == expected),
"{expected} is on the stream: {names:?}"
);
}
assert_eq!(
names.last().map(String::as_str),
Some("platform:run.lifecycle"),
"the terminal lifecycle record ends the stream: {names:?}"
);
// Tail: the last two items only.
let tail = cli(&context, &server, &["events", "--tail", "2", &run_id]);
assert_eq!(ndjson(&tail).len(), 2);
// Pretty: stages and platform records.
let mut cmd = context.command();
cmd.args(["events", "--pretty", "--server", &target, &run_id]);
fabro_snapshot!(pretty_filters(&context), cmd, @r"
success: true
exit_code: 0
----- stdout -----
[CLOCK] ▶ Run one command [ULID]
[CLOCK] · submitted
[CLOCK] · start_requested
[CLOCK] · runnable
[CLOCK] · starting
[CLOCK] · running
[CLOCK] Engine: petri run started
[CLOCK] ▶ start
[CLOCK] │ checkout: [TEMP_DIR]/petri-workspace is not a Git repository; the workspace starts empty
[CLOCK] ✓ start [DURATION]
[CLOCK] ⎘ Checkpoint [SHA]
[CLOCK] ▶ say
[CLOCK] start → say continue
[CLOCK] │ hello from petri
[CLOCK] ✓ say [DURATION]
[CLOCK] ⎘ Checkpoint [SHA]
[CLOCK] ▶ exit
[CLOCK] say → exit continue
[CLOCK] ✓ exit [DURATION]
[CLOCK] ⎘ Checkpoint [SHA]
[CLOCK] ✓ SUCCEEDED [DURATION]
[CLOCK] · succeeded
----- stderr -----
");
// Attach replays the finished run and exits with its status.
let attach = cli(&context, &server, &["attach", &run_id]);
let stderr = String::from_utf8_lossy(&attach.stderr);
assert!(stderr.contains("say"), "the stage is drawn: {stderr}");
// Wait reads the projection's status and conclusion.
let wait = cli(&context, &server, &["wait", &run_id]);
let stderr = String::from_utf8_lossy(&wait.stderr);
assert!(stderr.contains("Succeeded"), "{stderr}");
// Inspect reads the projection, whose spec names the engine.
let inspect = cli(&context, &server, &["inspect", &run_id]);
let inspected: serde_json::Value =
serde_json::from_slice(&inspect.stdout).expect("inspect prints JSON");
let entry = &inspected[0];
assert_eq!(entry["run_id"], run_id, "{entry}");
assert_eq!(entry["run_spec"]["engine"]["kind"], "petri", "{entry}");
assert_eq!(entry["conclusion"]["status"], "succeeded", "{entry}");
server.shutdown();
}
/// `attach` on a Petri run with a human gate asks the question at the
/// terminal and answers it through the questions API; the answer routes
/// the gate and the attach exits with the run's status.
#[tokio::test(flavor = "multi_thread")]
async fn attach_asks_a_petri_gate_at_the_terminal_and_answers_it() {
if host_plugin().is_none() {
return;
}
let context = test_context!();
let server = RunningServer::start().await;
let markers = context.temp_dir.join("markers");
std::fs::create_dir_all(&markers).expect("the marker dir creates");
let workspace = write_petri_workflow(&context, &gate_dot(&markers, ""));
let run_id = run_detached_with(&context, &server, &workspace, &[]);
wait_for_questions(&server, &run_id, 1).await;
let target = server.target();
let mut attach_cmd = Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut attach_cmd, &context.home_dir);
attach_cmd
.current_dir(&context.temp_dir)
.args(["attach", "--server", &target, &run_id])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = attach_cmd.spawn().expect("attach spawns");
{
let mut stdin = child.stdin.take().expect("attach stdin is piped");
stdin.write_all(b"N\n").expect("the answer writes");
}
let output = child
.wait_with_output()
.expect("attach exits once the run ends");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"attach failed\nstderr:\n{stderr}\nserver stderr:\n{}",
server.stderr_text()
);
assert!(stderr.contains("Go?"), "the question was asked: {stderr}");
assert!(
markers.join("no").exists() && !markers.join("yes").exists(),
"the no branch ran"
);
let status = wait_for_status(&server, &run_id, &["succeeded", "failed"]).await;
assert_eq!(status, "succeeded");
server.shutdown();
}
/// `events --follow` on a Petri run follows the stream live from its
/// cursor: the items already stored print first, the ones committed while
/// the run goes on follow, and the terminal lifecycle record ends it.
#[tokio::test(flavor = "multi_thread")]
async fn events_follow_streams_a_petri_run_live_to_its_end() {
if host_plugin().is_none() {
return;
}
let context = test_context!();
let server = RunningServer::start().await;
let gate = context.temp_dir.join("go");
let workspace = write_petri_workspace(
&context,
&format!(
"while [ ! -f {} ]; do sleep 0.05; done; echo released",
gate.display()
),
);
let run_id = run_detached(&context, &server, &workspace);
wait_for_status(&server, &run_id, &["running"]).await;
let target = server.target();
let mut follow_cmd = Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut follow_cmd, &context.home_dir);
follow_cmd
.current_dir(&context.temp_dir)
.args([
"events", "--follow", "--pretty", "--server", &target, &run_id,
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = follow_cmd.spawn().expect("events --follow spawns");
// Let the follower attach before the run is released.
tokio::time::sleep(Duration::from_millis(500)).await;
std::fs::write(&gate, b"").expect("the release marker writes");
let deadline = Instant::now() + RUN_TIMEOUT;
let status = loop {
if let Some(status) = child.try_wait().expect("the follower polls") {
break status;
}
assert!(
Instant::now() < deadline,
"events --follow did not end with the run; server stderr:\n{}",
server.stderr_text()
);
tokio::time::sleep(POLL).await;
};
let mut stdout = String::new();
child
.stdout
.take()
.expect("stdout is piped")
.read_to_string(&mut stdout)
.expect("stdout reads");
let mut stderr = String::new();
child
.stderr
.take()
.expect("stderr is piped")
.read_to_string(&mut stderr)
.expect("stderr reads");
assert!(status.success(), "events --follow failed: {stderr}");
assert!(stdout.contains("▶ say"), "the stage started: {stdout}");
assert!(stdout.contains("│ released"), "the live log line: {stdout}");
assert!(stdout.contains("✓ SUCCEEDED"), "the finish: {stdout}");
assert!(
stdout.trim_end().ends_with("· succeeded"),
"the terminal lifecycle record ends the follow: {stdout}"
);
server.shutdown();
}
/// A shell loop that waits for `gate` to exist.
fn wait_for(gate: &Path) -> String {
format!("while [ ! -f {} ]; do sleep 0.05; done", gate.display())
@ -1277,12 +1623,18 @@ async fn a_failed_checkpoint_fails_the_run_and_a_restart_leaves_it_failed() {
let status = wait_for_status(&server, &run_id, &["succeeded", "failed"]).await;
let run = run_json(&server, &format!("runs/{run_id}")).await;
assert_eq!(status, "failed", "run: {run}");
let failures: Vec<String> = settled_events(&server, &run_id, "run.failed")
// The run's failure travels on the stream as the platform record of
// its terminal lifecycle transition, with the failure's message as the
// reason.
let failures: Vec<String> = settled_stream(&server, &run_id)
.await
.iter()
.filter_map(|envelope| match &envelope.event.body {
EventBody::RunFailed(props) => Some(props.failure.detail.message.clone()),
_ => None,
.filter_map(|line| {
let record = &line["item"]["record"];
(line["kind"] == "platform"
&& record["kind"] == "run.lifecycle"
&& record["transition"] == "failed")
.then(|| record["reason"].as_str().unwrap_or_default().to_string())
})
.collect();
assert_eq!(failures.len(), 1, "{failures:?}");

View file

@ -1,12 +1,17 @@
use std::sync::Arc;
use std::time::Duration;
use axum::extract::DefaultBodyLimit;
use fabro_api::types::PaginatedRunStreamList;
use fabro_petri::petri::EVENT_CONTRACT_VERSION;
use fabro_types::run_event::MAX_RUN_EVENT_BODY_BYTES;
use fabro_types::{
RunEventDetailContent, RunEventDetailContentKind, RunEventDetailEnvelope,
RunEventDetailResponse,
RunEventDetailResponse, RunStreamItem,
};
use fabro_workflow::event::build_redacted_event_payload;
use tokio::sync::broadcast::error::RecvError;
use tokio::time::{self, Instant};
use super::super::{
ApiError, AppState, AppendEventResponse, BroadcastStream, Event, EventBody, EventEnvelope,
@ -70,9 +75,12 @@ struct RunEventListParams {
#[serde(default)]
before_seq: Option<u32>,
#[serde(default)]
order: EventSequenceOrder,
order: Option<EventSequenceOrder>,
#[serde(default)]
limit: Option<usize>,
/// The run stream cursor of a Petri run: the last `stream_seq` seen.
#[serde(default)]
after: Option<u64>,
}
impl RunEventListParams {
@ -80,12 +88,21 @@ impl RunEventListParams {
self.since_seq.unwrap_or(1).max(1)
}
fn order(&self) -> EventSequenceOrder {
self.order.unwrap_or_default()
}
fn limit(&self) -> usize {
self.limit.unwrap_or(100).clamp(1, 1000)
}
fn cursor_error(&self) -> Option<&'static str> {
match self.order {
if self.after.is_some() && (self.since_seq.is_some() || self.before_seq.is_some()) {
return Some(
"after is the run stream cursor and cannot be combined with since_seq or before_seq.",
);
}
match self.order() {
EventSequenceOrder::Asc if self.before_seq.is_some() => {
Some("before_seq requires order=desc.")
}
@ -95,12 +112,27 @@ impl RunEventListParams {
_ => None,
}
}
/// Why the parameters do not address a Petri run's stream, if they do
/// not: the legacy cursors have no meaning there.
fn stream_cursor_error(&self) -> Option<&'static str> {
if self.since_seq.is_some() || self.before_seq.is_some() || self.order.is_some() {
return Some(
"this run executes on Petri; its events are a run stream addressed by `after` \
(the last stream_seq seen), not by since_seq, before_seq or order.",
);
}
None
}
}
#[derive(serde::Deserialize)]
struct AttachParams {
#[serde(default)]
since_seq: Option<u32>,
/// The run stream cursor of a Petri run: the last `stream_seq` seen.
#[serde(default)]
after: Option<u64>,
}
#[derive(serde::Deserialize)]
@ -256,9 +288,25 @@ async fn list_run_events(
}
let limit = params.limit();
match run_is_petri(&state, &id).await {
Ok(true) => {
if let Some(detail) = params.stream_cursor_error() {
return ApiError::bad_request(detail).into_response();
}
return list_run_stream(&state, id, params.after.unwrap_or(0), limit).await;
}
Ok(false) => {}
Err(response) => return response,
}
if params.after.is_some() {
return ApiError::bad_request(
"after is the run stream cursor of a Petri run; this run's events use since_seq.",
)
.into_response();
}
match state.stores.runs.open_run_reader(&id).await {
Ok(run_store) => {
let events = match params.order {
let events = match params.order() {
EventSequenceOrder::Asc => {
run_store
.list_events_from_with_limit(params.since_seq(), limit)
@ -291,6 +339,171 @@ async fn list_run_events(
}
}
/// Whether the run executes on Petri, from its stored spec; the canonical
/// 404 when there is no such run.
async fn run_is_petri(state: &AppState, id: &RunId) -> Result<bool, Response> {
let projection = state
.load_run_projection(id)
.await
.map_err(IntoResponse::into_response)?;
Ok(projection.spec.engine.is_petri())
}
/// One page of a Petri run's stream past `after`.
async fn list_run_stream(state: &AppState, id: RunId, after: u64, limit: usize) -> Response {
match state
.petri_projector
.stream_after(id, after, limit.saturating_add(1))
.await
{
Ok(mut items) => {
let has_more = items.len() > limit;
items.truncate(limit);
Json(PaginatedRunStreamList {
data: items,
meta: PaginationMeta {
has_more,
total: None,
},
event_contract_version: EVENT_CONTRACT_VERSION,
})
.into_response()
}
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
fn sse_event_from_stream_item(item: &RunStreamItem) -> Option<Event> {
let data = serde_json::to_string(item).ok()?;
let data = redact_jsonl_line(&data);
Some(Event::default().data(data))
}
/// How many stream items one read takes while attached.
const STREAM_ATTACH_BATCH_LIMIT: usize = 256;
/// How long an attached reader waits for a commit signal before it re-reads
/// its cursor anyway: a signal is a wake-up, never the source of facts.
const STREAM_ATTACH_POLL: Duration = Duration::from_secs(1);
/// How long an attached reader keeps following a run whose projection is
/// already terminal, waiting for the platform record of the terminal
/// lifecycle transition that ends the stream; after that it ends anyway.
const STREAM_ATTACH_TERMINAL_GRACE: Duration = Duration::from_secs(15);
/// Whether the item ends an attached stream: the platform record of the
/// run's terminal lifecycle transition, which Fabro writes after the engine
/// recorded the run's finish. The analog of the legacy stream's
/// `run.completed` and `run.failed`.
fn stream_item_is_terminal(item: &RunStreamItem) -> bool {
if item.kind != fabro_types::RunStreamItemKind::Platform {
return false;
}
let record = &item.item["record"];
record["kind"].as_str() == Some("run.lifecycle")
&& matches!(
record["transition"].as_str(),
Some("succeeded" | "failed" | "dead")
)
}
/// The live stream of a Petri run from `after` (the last `stream_seq` the
/// client saw; `None` starts at the next unseen item), as server-sent
/// events. Every committed item past the cursor is sent once, in order,
/// and the stream ends once the run is no longer active and every
/// committed item is out.
async fn attach_run_stream(state: Arc<AppState>, id: RunId, after: Option<u64>) -> Response {
let cursor = match after {
Some(after) => after,
None => match state.petri_projector.stream_head(id).await {
Ok(head) => head.unwrap_or(0),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
},
};
let (sender, receiver) = mpsc::unbounded_channel();
let shutdown = state.shutdown_token();
tokio::spawn(async move {
// Subscribed before the first read, so a pass that commits between
// the read and the wait is not missed.
let mut committed = state.petri_projector.subscribe();
let mut cursor = cursor;
// Set once the projection is terminal: the stream then ends at the
// terminal lifecycle record, or when the grace runs out.
let mut terminal_deadline: Option<Instant> = None;
loop {
// Drain everything committed past the cursor.
let mut drained = false;
while !drained {
let Ok(items) = state
.petri_projector
.stream_after(id, cursor, STREAM_ATTACH_BATCH_LIMIT)
.await
else {
return;
};
drained = items.len() < STREAM_ATTACH_BATCH_LIMIT;
for item in items {
cursor = item.stream_seq;
let terminal = stream_item_is_terminal(&item);
if let Some(sse_event) = sse_event_from_stream_item(&item) {
if sender
.send(Ok::<Event, std::convert::Infallible>(sse_event))
.is_err()
{
return;
}
}
if terminal {
return;
}
}
}
// The run's status is read after the drain, so an item
// committed with the finish is already out. Once terminal, the
// stream keeps following for the terminal lifecycle record,
// which Fabro writes after the engine's finish, for a bounded
// time.
if terminal_deadline.is_none() {
let active = match state.stores.runs.load_run_projection(&id).await {
Ok(Some(projection)) => run_projection_is_active(&projection),
Ok(None) | Err(_) => false,
};
if !active {
terminal_deadline = Some(Instant::now() + STREAM_ATTACH_TERMINAL_GRACE);
}
}
if terminal_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return;
}
// Wait for the projector to commit more of this run, or poll.
loop {
tokio::select! {
biased;
() = shutdown.cancelled() => return,
signal = committed.recv() => match signal {
Ok(run_id) if run_id == id => break,
Ok(_) => {}
Err(RecvError::Lagged(_)) => break,
Err(RecvError::Closed) => return,
},
() = time::sleep(STREAM_ATTACH_POLL) => break,
}
}
}
});
Sse::new(UnboundedReceiverStream::new(receiver))
.keep_alive(KeepAlive::default())
.into_response()
}
async fn list_run_stage_events(
RequireRunStageScoped(id, stage_id): RequireRunStageScoped,
State(state): State<Arc<AppState>>,
@ -446,6 +659,11 @@ async fn attach_run_events(
Ok(id) => id,
Err(response) => return response,
};
match run_is_petri(&state, &id).await {
Ok(true) => return attach_run_stream(state, id, params.after).await,
Ok(false) => {}
Err(response) => return response,
}
let Ok(run_store) = state.stores.runs.open_run_reader(&id).await else {
return ApiError::not_found("Run not found.").into_response();
};

View file

@ -2,6 +2,7 @@ mod archive;
mod dry_run;
mod lifecycle;
mod petri;
mod petri_stream;
mod run_completion;
mod sse;
mod usage;

View file

@ -105,14 +105,14 @@ const PARALLEL_DOT: &str = r#"digraph Parallel {
merge -> exit
}"#;
const PLAIN_SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n";
pub(super) const PLAIN_SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n";
const PETRI_SETTINGS: &str =
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\nengine = \"petri\"\n";
/// The host plugin as Petri's lookup finds it: the override variable, else
/// the executable on `PATH`. `None`, after saying so, when the test should
/// skip; a panic when the environment forbids a skip.
fn host_plugin() -> Option<PathBuf> {
pub(super) fn host_plugin() -> Option<PathBuf> {
let found = env::var_os(HOST_PLUGIN_OVERRIDE)
.map(PathBuf::from)
.or_else(|| {
@ -132,7 +132,7 @@ fn host_plugin() -> Option<PathBuf> {
/// Register a version whose entrypoint is `workflow.fabro`, with the given
/// files beside it.
async fn register_version(app: &axum::Router, files: &[(&str, &str)]) -> String {
pub(super) async fn register_version(app: &axum::Router, files: &[(&str, &str)]) -> String {
let entrypoint = WorkflowPath::new("workflow.fabro").expect("entrypoint path is valid");
let files = files
.iter()
@ -150,7 +150,7 @@ async fn register_version(app: &axum::Router, files: &[(&str, &str)]) -> String
.to_string()
}
fn intent(version_id: &str, workspace: &std::path::Path) -> serde_json::Value {
pub(super) fn intent(version_id: &str, workspace: &std::path::Path) -> serde_json::Value {
serde_json::json!({
"workflow_version_id": version_id,
"target": {"kind": "folder", "path": workspace},
@ -187,7 +187,11 @@ async fn petri_outcome(state: &AppState, run_id: &str) -> engine::RunOutcome {
}
/// The run's projected state once its projector settled.
async fn settled_state(state: &AppState, app: &axum::Router, run_id: &str) -> serde_json::Value {
pub(super) async fn settled_state(
state: &AppState,
app: &axum::Router,
run_id: &str,
) -> serde_json::Value {
let id: RunId = run_id.parse().expect("the run id parses");
state.test_petri_projector().settle(id).await;
let req = Request::builder()
@ -335,6 +339,7 @@ async fn the_hello_bundle_runs_on_petri_when_the_version_names_the_engine() {
.any(|request| request["model"] == OPENAI_MODEL),
"the prompt stage should have called the twin, got {logs}"
);
super::petri_stream::capture_settled(&state, &app, &run_id, "hello").await;
}
/// A command-only bundle runs on Petri when the server's setting names the
@ -379,6 +384,7 @@ async fn a_command_bundle_runs_on_petri_under_the_server_setting() {
);
let stream = petri_stream_len(&state, &run_id).await;
assert!(stream > 0, "the run's stream holds its events");
super::petri_stream::capture_settled(&state, &app, &run_id, "command").await;
}
/// A parallel bundle with two command branches runs on Petri through the
@ -685,4 +691,5 @@ async fn a_human_gate_is_answered_through_the_questions_api() {
record.principal.is_some(),
"the answering principal: {record:?}"
);
super::petri_stream::capture_settled(&state, &app, &run_id, "gate").await;
}

View file

@ -0,0 +1,421 @@
//! The run stream of a Petri run through the server: `GET /runs/{id}/events`
//! pages it by `after`, `GET /runs/{id}/attach` follows it live, and a
//! client that disconnects mid-run and reconnects from its last
//! `stream_seq` receives every item once, in order, with no gap and no
//! duplicate, including a platform record Fabro recorded between two
//! concurrent child executions' events.
//!
//! The runs execute in the server process under the handler-registry test
//! override and take their host scope through the sandbox-driver host
//! plugin, so the tests skip, and say why, when the executable is not
//! found (see `petri.rs`).
//!
//! With `FABRO_CAPTURE_PETRI_FIXTURES` set, a scenario also writes its
//! settled projection and full stream as JSON under the web app's test
//! fixtures (`apps/fabro-web/app/test-fixtures/petri/`), which the web
//! app's rendering tests read.
#![expect(
clippy::disallowed_methods,
reason = "the tests locate the plugin executable and the capture switch through the process environment"
)]
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
use std::collections::BTreeSet;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::server::AppState;
use fabro_store::platform_records::{PlatformRecord, PlatformRecordStore, RunNoticeRecord};
use fabro_types::run_event::RunNoticeLevel;
use fabro_types::{RunId, RunStreamItem, RunStreamItemKind};
use http_body_util::BodyExt;
use tokio::time::timeout;
use tower::ServiceExt;
use super::petri::{PLAIN_SETTINGS, host_plugin, intent, register_version, settled_state};
use crate::helpers::{
api, create_and_start_run_from_intent, repo_root, response_json, run_json, settings_from_toml,
test_app_state_with_options, test_app_with_scheduler, wait_for_run_status,
};
const CAPTURE_ENV: &str = "FABRO_CAPTURE_PETRI_FIXTURES";
const FRAME_TIMEOUT: Duration = Duration::from_secs(20);
/// Two command branches that announce they started and wait for a release
/// marker, so a test can act between their events.
fn gated_parallel_dot(markers: &std::path::Path) -> String {
let dir = markers.display();
format!(
r#"digraph Parallel {{
graph [goal="Run two branches"]
start [shape=Mdiamond]
exit [shape=Msquare]
fork [shape=component]
a [shape=parallelogram, script="touch {dir}/a.started; while [ ! -f {dir}/go ]; do sleep 0.05; done; echo a"]
b [shape=parallelogram, script="touch {dir}/b.started; while [ ! -f {dir}/go ]; do sleep 0.05; done; echo b"]
merge [shape=tripleoctagon]
start -> fork
fork -> a
fork -> b
a -> merge
b -> merge
merge -> exit
}}"#
)
}
/// One page of the run's stream past `after`.
async fn stream_page(
app: &axum::Router,
run_id: &str,
after: u64,
limit: usize,
) -> serde_json::Value {
let req = Request::builder()
.method("GET")
.uri(api(&format!(
"/runs/{run_id}/events?after={after}&limit={limit}"
)))
.body(Body::empty())
.expect("events request should build");
let response = app
.clone()
.oneshot(req)
.await
.expect("events request routes");
response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events?after={after}&limit={limit}"),
)
.await
}
/// Every item of the run's stream, paged through the listing endpoint.
pub(super) async fn list_stream(
app: &axum::Router,
run_id: &str,
page_limit: usize,
) -> Vec<RunStreamItem> {
let mut after = 0;
let mut items = Vec::new();
loop {
let page = stream_page(app, run_id, after, page_limit).await;
let data: Vec<RunStreamItem> =
serde_json::from_value(page["data"].clone()).expect("stream items decode");
let has_more = page["meta"]["has_more"]
.as_bool()
.expect("has_more is a bool");
assert_eq!(
page["event_contract_version"].as_u64(),
Some(3),
"the server reports Petri's contract version: {page}"
);
let Some(last) = data.last() else {
assert!(!has_more, "an empty page is the last");
break;
};
after = last.stream_seq;
items.extend(data);
if !has_more {
break;
}
}
items
}
/// An attached reader of the run's stream that stops reading when `until`
/// says so, as a client that lost its connection would: the frames it saw
/// so far come back.
struct Attached {
body: Body,
pending: String,
}
impl Attached {
async fn open(app: &axum::Router, run_id: &str, after: u64) -> Self {
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/attach?after={after}")))
.body(Body::empty())
.expect("attach request should build");
let response = app
.clone()
.oneshot(req)
.await
.expect("attach request routes");
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("text/event-stream")),
"an SSE response"
);
Self {
body: response.into_body(),
pending: String::new(),
}
}
/// The next item on the stream, or `None` once the server ended it.
async fn next(&mut self) -> Option<RunStreamItem> {
loop {
if let Some(end) = self.pending.find("\n\n") {
let frame = self.pending[..end].to_string();
self.pending.drain(..end + 2);
let data = frame
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim)
.collect::<Vec<_>>()
.join("\n");
if data.is_empty() {
continue;
}
return Some(serde_json::from_str(&data).expect("a stream item frame decodes"));
}
let frame = timeout(FRAME_TIMEOUT, self.body.frame())
.await
.expect("the attached stream keeps sending or ends");
match frame {
Some(Ok(frame)) => {
if let Some(data) = frame.data_ref() {
self.pending.push_str(&String::from_utf8_lossy(data));
}
}
Some(Err(err)) => panic!("the attached stream failed: {err}"),
None => return None,
}
}
}
}
fn petri_name(item: &RunStreamItem) -> Option<&str> {
(item.kind == RunStreamItemKind::Petri)
.then(|| item.name())
.flatten()
}
/// The subject's node name, for a node that is a stage of its own: the
/// `parallel.branch` delegate the fork's execution holds for each branch
/// shares the branch's name and is not one.
fn subject_node(item: &RunStreamItem) -> Option<&str> {
let node = &item.item["subject"]["node"];
if node["meta"]["kind"].as_str() == Some("parallel.branch") {
return None;
}
node["name"].as_str()
}
fn wait_for_marker(path: &std::path::Path) {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while !path.exists() {
assert!(
std::time::Instant::now() < deadline,
"{} never appeared",
path.display()
);
std::thread::sleep(Duration::from_millis(20));
}
}
/// A client attached to a two-branch parallel run disconnects once both
/// branches have started, Fabro records a platform notice while they run,
/// the client reconnects from its last `stream_seq`, and the union of what
/// it saw is the whole stream: every item once, in `stream_seq` order, no
/// gap, no duplicate, with the notice between the branches' events.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_reconnecting_client_receives_every_stream_item_once_in_order() {
if host_plugin().is_none() {
return;
}
let workspace = tempfile::tempdir().expect("workspace tempdir");
let markers = tempfile::tempdir().expect("marker tempdir");
let settings = settings_from_toml(
"_version = 1\n\n[run.environment]\nid = \"local\"\n\n[server.execution]\nengine = \
\"petri\"\n",
);
let state = test_app_state_with_options(settings, 5);
let app = test_app_with_scheduler(Arc::clone(&state));
let dot = gated_parallel_dot(markers.path());
let version_id = register_version(&app, &[
("workflow.fabro", &dot),
("workflow.toml", PLAIN_SETTINGS),
])
.await;
let run_id =
create_and_start_run_from_intent(&app, intent(&version_id, workspace.path())).await;
let id: RunId = run_id.parse().expect("the run id parses");
// First connection: from the start of the run until both branches
// have a `visit.started` on the stream, then drop it.
let mut first = Attached::open(&app, &run_id, 0).await;
let mut seen_first: Vec<RunStreamItem> = Vec::new();
let mut started: BTreeSet<String> = BTreeSet::new();
while started.len() < 2 {
let item = first
.next()
.await
.expect("the stream runs until both branches started");
if petri_name(&item) == Some("visit.started") {
if let Some(node @ ("a" | "b")) = subject_node(&item) {
started.insert(node.to_string());
}
}
seen_first.push(item);
}
let last_seen = seen_first
.last()
.map(|item| item.stream_seq)
.expect("something was seen");
drop(first);
// Both branch scripts are running: record a platform fact between
// their events, as a checkpoint or a notice would be, then let them go.
wait_for_marker(&markers.path().join("a.started"));
wait_for_marker(&markers.path().join("b.started"));
let platform = PlatformRecordStore::new(state.test_petri_view_pool());
let notice = platform
.append(
&id,
&PlatformRecord::RunNotice(RunNoticeRecord {
level: RunNoticeLevel::Info,
code: "test.between_branches".to_string(),
message: "recorded while both branches ran".to_string(),
}),
None,
)
.await
.expect("the notice appends");
state.test_petri_projector().signal(id);
state.test_petri_projector().settle(id).await;
std::fs::write(markers.path().join("go"), b"").expect("the release marker writes");
// Second connection: resume from the last stream_seq seen and read to
// the end of the stream, which the server closes once the run is done.
let mut second = Attached::open(&app, &run_id, last_seen).await;
let mut seen_second: Vec<RunStreamItem> = Vec::new();
while let Some(item) = second.next().await {
seen_second.push(item);
}
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
let run = run_json(&app, &run_id).await;
assert_eq!(status, "succeeded", "run: {run}");
let projection = settled_state(&state, &app, &run_id).await;
assert_eq!(projection["status"]["kind"], "succeeded", "{projection}");
// The union is the whole stream, once each, in order, with no gap.
let mut union = seen_first;
union.extend(seen_second);
let seqs: Vec<u64> = union.iter().map(|item| item.stream_seq).collect();
let expected: Vec<u64> = (1..=seqs.len() as u64).collect();
assert_eq!(
seqs, expected,
"stream_seq is dense and strictly increasing"
);
let ids: BTreeSet<&str> = union.iter().map(|item| item.id.as_str()).collect();
assert_eq!(ids.len(), union.len(), "every item identity appears once");
for item in &union {
assert_eq!(item.run_id, id);
assert!(item.recorded_at > 0, "{item:?}");
}
// The same stream, paged through the listing endpoint with small
// pages, is item for item what the attached client saw.
let listed = list_stream(&app, &run_id, 7).await;
assert_eq!(listed, union, "the listing pages the same stream");
// The notice sits between the branches' events.
let notice_seq = union
.iter()
.find(|item| item.kind == RunStreamItemKind::Platform && item.id == notice.seq.to_string())
.map(|item| item.stream_seq)
.expect("the notice is on the stream");
let branch_seqs = |name: &str| -> Vec<u64> {
union
.iter()
.filter(|item| {
petri_name(item) == Some(name) && matches!(subject_node(item), Some("a" | "b"))
})
.map(|item| item.stream_seq)
.collect()
};
let starts = branch_seqs("visit.started");
let ends = branch_seqs("visit.completed");
assert_eq!(starts.len(), 2, "{starts:?}");
assert_eq!(ends.len(), 2, "{ends:?}");
assert!(
starts.iter().all(|seq| *seq < notice_seq) && ends.iter().all(|seq| *seq > notice_seq),
"the notice ({notice_seq}) is between the branch starts {starts:?} and ends {ends:?}"
);
let finished = union
.iter()
.filter(|item| petri_name(item) == Some("run.finished"))
.count();
assert_eq!(finished, 1, "the stream ends with the run's finish");
// The legacy cursors are refused for a Petri run; the stream cursor is
// refused for nothing else.
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/events?since_seq=1")))
.body(Body::empty())
.expect("events request should build");
let response = app
.clone()
.oneshot(req)
.await
.expect("events request routes");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
capture_fixture(&app, &run_id, "parallel", &projection).await;
}
/// Write the run's settled projection and its whole stream under the web
/// app's test fixtures, when the capture switch is set.
pub(super) async fn capture_fixture(
app: &axum::Router,
run_id: &str,
name: &str,
projection: &serde_json::Value,
) {
if env::var_os(CAPTURE_ENV).is_none() {
return;
}
let stream = list_stream(app, run_id, 1000).await;
let fixture = serde_json::json!({
"run_id": run_id,
"projection": projection,
"stream": stream,
});
let dir = repo_root().join("apps/fabro-web/app/test-fixtures/petri");
std::fs::create_dir_all(&dir).expect("the fixture directory creates");
let path = dir.join(format!("{name}.json"));
std::fs::write(
&path,
serde_json::to_string_pretty(&fixture).expect("the fixture serializes"),
)
.expect("the fixture writes");
eprintln!("captured {}", path.display());
}
/// Helpers the other Petri scenarios use to capture their fixtures.
pub(super) async fn capture_settled(
state: &AppState,
app: &axum::Router,
run_id: &str,
name: &str,
) {
if env::var_os(CAPTURE_ENV).is_none() {
return;
}
let projection = settled_state(state, app, run_id).await;
capture_fixture(app, run_id, name, &projection).await;
}

View file

@ -87,7 +87,14 @@ Every adapter the integration plan describes lands here.
every Petri run, at startup. A run that executes in the server process
goes through `Projector::observe_store`, which signals after each append.
A torn tail (a record Petri cannot read) holds the view where it stands
and reports the run incomplete with the reason.
and reports the run incomplete with the reason. The projector also serves
the stream back (`Projector::stream_after`, one `RunStreamItem` per row:
`run_id`, `stream_seq`, `kind`, the item's own `id`, `recorded_at`, the
item) and signals its readers after each committed pass
(`Projector::subscribe`), which is how `GET /runs/{id}/events` pages a
Petri run by `after` and `GET /runs/{id}/attach` follows it live. The
version of Petri's event contract the stream carries is
`petri::EVENT_CONTRACT_VERSION`.
- The platform adapters the plan adds after it: hooks and the run tools.
### What the projection leaves default
@ -181,13 +188,25 @@ serving the projection over Petri's records; a human gate is answered
through the questions API; and Petri's diagnostics refuse a run at create.
The server's `petri_runs` unit tests cover the lease ending at worker exit
and the restart reconcile that relaunches a worker in resume mode.
`lib/apps/fabro-server/tests/it/scenario/petri_stream.rs` covers the stream:
a client attached to a two-branch parallel run disconnects once both
branches started, a platform notice is recorded while both branch scripts
run, the client reconnects from its last `stream_seq`, and the union of
what it saw is the whole stream, every item once, in order, with the notice
between the branch events and the same as the paged listing. With
`FABRO_CAPTURE_PETRI_FIXTURES` set, the scenarios write their settled
projection and stream under `apps/fabro-web/app/test-fixtures/petri/`,
which the web app's rendering tests read.
The worker path is covered with the real binary in
`lib/apps/fabro-cli/tests/it/scenario/petri.rs`: a command-only Petri run
executes in the worker a foreground server launched, its records reach
`petri_records` over the HTTP store and its lease ends with the worker; and
a run whose server and worker are both killed mid-stage resumes in a new
worker after the server restarts, with one `run.completed`; a human gate in
the worker is answered through the questions API over the control channel;
two parallel gates each bind their own answer; and an unanswered gate
expires with its default.
worker after the server restarts, with one terminal lifecycle record; a
human gate in the worker is answered through the questions API over the
control channel; two parallel gates each bind their own answer; and an
unanswered gate expires with its default. The same file reads a finished
run back through the CLI (`events` raw, tail and `--pretty`, `attach`,
`wait`, `inspect`), answers a gate from an attached terminal, and follows
a run live with `events --follow` to its end.

View file

@ -3,6 +3,11 @@
//! depending on the Petri packages themselves. Only this crate names them in
//! its `Cargo.toml`.
use petri_execution::events;
pub use petri_store::{
Access, Digest, ExecutionId, LogId, OwnerId, Record, RunKey, RunLogs, RunStore, StoreError,
};
/// The version of Petri's public event contract this build serves on the
/// run stream: every `petri` item of `GET /runs/{id}/events` follows it.
pub const EVENT_CONTRACT_VERSION: u32 = events::EVENT_CONTRACT_VERSION;

View file

@ -46,13 +46,13 @@ use std::time::Duration;
use fabro_db::DbPool;
use fabro_store::platform_records::{PlatformRecordStore, StoredPlatformRecord, now_ms};
use fabro_store::{RunProjection, RunSummaryStore};
use fabro_types::RunId;
use fabro_types::{RunId, RunStreamItem, RunStreamItemKind};
use fabro_util::error::collect_chain;
use petri_execution::events::{self, EventId, EventSource, RunEvent};
use petri_execution::{Access, RunKey, RunStore as _, inspect};
use petri_store::StoreError;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::{Mutex as AsyncMutex, broadcast};
use tokio::time;
use tracing::{debug, info, warn};
@ -147,17 +147,21 @@ struct Slot {
/// the server both are the one database; a test may hand it the run
/// summary store's own pool for the views.
pub struct Projector {
records: DbPool,
pool: DbPool,
store: SqliteRunStore,
platform: PlatformRecordStore,
slots: Mutex<HashMap<RunId, Slot>>,
records: DbPool,
pool: DbPool,
store: SqliteRunStore,
platform: PlatformRecordStore,
slots: Mutex<HashMap<RunId, Slot>>,
/// One pass at a time per run: a signalled pass and the startup pass
/// over the same run never interleave their reads and writes.
passes: Mutex<HashMap<RunId, Arc<AsyncMutex<()>>>>,
passes: Mutex<HashMap<RunId, Arc<AsyncMutex<()>>>>,
/// Test-only: stop the next pass after its reads, before its view
/// transaction, as a crash there would.
fault: AtomicBool,
fault: AtomicBool,
/// Sent after each committed pass that wrote stream rows: the run whose
/// stream grew. A wake-up for the stream's readers, never a source of
/// facts; a reader that lags re-reads from its cursor.
committed: broadcast::Sender<RunId>,
}
impl std::fmt::Debug for Projector {
@ -180,9 +184,42 @@ impl Projector {
slots: Mutex::default(),
passes: Mutex::default(),
fault: AtomicBool::new(false),
committed: broadcast::channel(COMMIT_SIGNAL_CAPACITY).0,
})
}
/// A receiver that learns which run's stream grew after each committed
/// pass. A receiver that falls behind gets `Lagged` and treats it as a
/// wake-up for every run it follows.
#[must_use]
pub fn subscribe(&self) -> broadcast::Receiver<RunId> {
self.committed.subscribe()
}
/// The run's stream past the cursor: up to `limit` items with
/// `stream_seq > after`, in `stream_seq` order, each in Fabro's
/// envelope. `after = 0` reads from the first item.
pub async fn stream_after(
&self,
run_id: RunId,
after: u64,
limit: usize,
) -> Result<Vec<RunStreamItem>, ProjectError> {
stream_after(&self.pool, run_id, after, limit).await
}
/// The last delivery sequence the run's view holds, or `None` when no
/// pass has committed a view for it.
pub async fn stream_head(&self, run_id: RunId) -> Result<Option<u64>, ProjectError> {
let head: Option<i64> =
sqlx::query_scalar("SELECT stream_seq FROM petri_projection WHERE run_id = ?")
.bind(run_id.to_string())
.fetch_optional(&self.pool)
.await
.map_err(ProjectError::Database)?;
Ok(head.map(|head| u64::try_from(head).unwrap_or(0)))
}
/// Schedule a pass for the run. A pass already running for it runs once
/// more when it ends; any number of signals in between coalesce.
pub fn signal(self: &Arc<Self>, run_id: RunId) {
@ -471,6 +508,10 @@ impl Projector {
stream_seq,
"Petri projection pass committed"
);
if !rows.is_empty() {
// No receiver is not an error: nobody follows the stream.
let _ = self.committed.send(run_id);
}
Ok(PassReport {
run_id,
skipped: false,
@ -658,6 +699,52 @@ struct StreamRow {
event_json: String,
}
/// How many commit signals a slow reader may fall behind before it is told
/// it lagged and re-reads from its cursor.
const COMMIT_SIGNAL_CAPACITY: usize = 1024;
/// The run's stream past the cursor, read from the view tables: up to
/// `limit` rows with `stream_seq > after`, in order, in Fabro's envelope.
pub async fn stream_after(
views: &DbPool,
run_id: RunId,
after: u64,
limit: usize,
) -> Result<Vec<RunStreamItem>, ProjectError> {
let rows: Vec<(i64, String, String, String)> = sqlx::query_as(
"SELECT stream_seq, item_kind, item_id, event_json FROM petri_stream WHERE run_id = ? AND \
stream_seq > ? ORDER BY stream_seq LIMIT ?",
)
.bind(run_id.to_string())
.bind(column(after))
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
.fetch_all(views)
.await
.map_err(ProjectError::Database)?;
rows.into_iter()
.map(|(stream_seq, item_kind, item_id, event_json)| {
let item: serde_json::Value =
serde_json::from_str(&event_json).map_err(ProjectError::Encode)?;
let kind = match item_kind.as_str() {
"platform" => RunStreamItemKind::Platform,
_ => RunStreamItemKind::Petri,
};
let recorded_at = item
.get("recorded_at")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
Ok(RunStreamItem {
run_id,
stream_seq: u64::try_from(stream_seq).unwrap_or(0),
kind,
id: item_id,
recorded_at,
item,
})
})
.collect()
}
/// A Petri event id as the stream names it: `<log>/<seq>/<index>`.
#[must_use]
pub fn event_id_text(id: &EventId) -> String {

View file

@ -658,6 +658,11 @@ fn main() {
&[],
),
("EventEnvelope", "fabro_types::EventEnvelope", &[]),
("RunStreamItem", "fabro_types::RunStreamItem", &[]),
("RunStreamItemKind", "fabro_types::RunStreamItemKind", &[]),
("RunEngine", "fabro_types::RunEngine", &[]),
("PetriAdmission", "fabro_types::PetriAdmission", &[]),
("PetriGraphRef", "fabro_types::PetriGraphRef", &[]),
("PullRequest", "fabro_types::PullRequest", &[]),
("PullRequestLink", "fabro_types::PullRequestLink", &[]),
(

View file

@ -52,25 +52,25 @@ pub mod types {
ModelTestMode, ModelUsage, PairId, PairMessageId, PairMessageRecord, PairMessageRequest,
PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry,
PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord,
PermissionLevel, Principal, Provider, PullRequest, PullRequestCreation,
PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails,
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, RunTarget, SandboxDetails, SandboxInfo,
SandboxListMeta, SandboxListResponse, SandboxProviderKind, SandboxProviderLookupError,
SandboxService, SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings,
SessionDetail, SessionId, SessionStatus, SessionSummary, SessionTurn,
SkillActivationSource, SkillSummary, StageCompletion, StageContextWindow,
StageContextWindowUnavailableReason, StageHandler, StageId, StageInferenceProjection,
StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection,
SystemActorKind, SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection,
ToolCategory, ToolSource, ToolSummary, TurnId, UpdateVariableRequest, UserPrincipal,
Variable, VariableListResponse, WorkflowPath, WorkflowSettings, WorkflowVersion,
WorkflowVersionId,
ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEngine,
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,
SandboxServiceListResponse, SecretMetadata, SecretType, ServerSettings, SessionDetail,
SessionId, SessionStatus, SessionSummary, SessionTurn, SkillActivationSource, SkillSummary,
StageCompletion, StageContextWindow, StageContextWindowUnavailableReason, StageHandler,
StageId, StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection,
StageState, StageToolBatchProjection, SystemActorKind, SystemIntegrationStatus,
SystemIntegrationsResponse, TodoListProjection, ToolCategory, ToolSource, ToolSummary,
TurnId, UpdateVariableRequest, UserPrincipal, Variable, VariableListResponse, WorkflowPath,
WorkflowSettings, WorkflowVersion, WorkflowVersionId,
};
pub use lithos_llm::catalog::{ModelHandle, ProviderId};
pub use lithos_llm::types::{

View file

@ -0,0 +1,57 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{
PetriAdmission as ApiPetriAdmission, PetriGraphRef as ApiPetriGraphRef,
RunEngine as ApiRunEngine,
};
use fabro_types::{PetriAdmission, PetriGraphRef, RunEngine};
use serde_json::json;
#[test]
fn run_engine_reuses_canonical_types() {
assert_same_type::<ApiRunEngine, RunEngine>();
assert_same_type::<ApiPetriAdmission, PetriAdmission>();
assert_same_type::<ApiPetriGraphRef, PetriGraphRef>();
}
#[test]
fn the_legacy_engine_round_trips_as_its_kind_alone() {
let value = json!({ "kind": "legacy" });
let engine: RunEngine = serde_json::from_value(value.clone()).unwrap();
assert!(engine.is_legacy());
assert_eq!(serde_json::to_value(&engine).unwrap(), value);
}
#[test]
fn the_petri_engine_round_trips_with_its_admission_flattened() {
let value = json!({
"kind": "petri",
"graph": {
"blob": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"digest": "sha256:root"
},
"children": [
{
"blob": "3cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"digest": "sha256:child"
}
]
});
let engine: RunEngine = serde_json::from_value(value.clone()).unwrap();
let admission = engine
.petri()
.expect("a Petri engine carries its admission");
assert_eq!(admission.graph.digest, "sha256:root");
assert_eq!(admission.children.len(), 1);
assert_eq!(serde_json::to_value(&engine).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

@ -0,0 +1,74 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{
RunStreamItem as ApiRunStreamItem, RunStreamItemKind as ApiRunStreamItemKind,
};
use fabro_types::{RunStreamItem, RunStreamItemKind, fixtures};
use serde_json::json;
#[test]
fn run_stream_item_reuses_canonical_types() {
assert_same_type::<ApiRunStreamItem, RunStreamItem>();
assert_same_type::<ApiRunStreamItemKind, RunStreamItemKind>();
}
#[test]
fn a_petri_item_round_trips_with_its_event_unchanged() {
let value = json!({
"run_id": fixtures::RUN_1.to_string(),
"stream_seq": 12,
"kind": "petri",
"id": "execution 1/23/0",
"recorded_at": 1_789_323_217_459_u64,
"item": {
"id": { "log": "execution", "execution": 1, "seq": 23, "index": 0 },
"origin": "core",
"recorded_at": 1_789_323_217_459_u64,
"context": { "invocation": 0, "execution": 1 },
"subject": {
"node": { "id": 4, "name": "review", "kind": "attractor/agent", "meta": { "kind": "agent" } },
"firing": 3, "visit": 1, "attempt": 1, "generation": 0, "branch": { "role": "none" }
},
"record": {
"seq": 23, "origin": "core", "recorded_at": 1_789_323_217_459_u64,
"body": { "event": "route.applied", "kind": "jump", "firing": 3, "target": 7 }
},
"derived": { "target": { "id": 7, "name": "finalize", "kind": "attractor/command", "meta": { "kind": "command" } } }
}
});
let item: RunStreamItem = serde_json::from_value(value.clone()).unwrap();
assert_eq!(item.kind, RunStreamItemKind::Petri);
assert_eq!(item.name(), Some("route.applied"));
assert_eq!(serde_json::to_value(&item).unwrap(), value);
}
#[test]
fn a_platform_item_round_trips_with_its_record_unchanged() {
let value = json!({
"run_id": fixtures::RUN_1.to_string(),
"stream_seq": 13,
"kind": "platform",
"id": "4",
"recorded_at": 1_789_323_217_500_u64,
"item": {
"seq": 4,
"recorded_at": 1_789_323_217_500_u64,
"record": { "kind": "checkpoint", "execution": 1, "firing": 3, "git_commit_sha": "abc123" },
"position": { "execution": 1, "firing": 3 }
}
});
let item: RunStreamItem = serde_json::from_value(value.clone()).unwrap();
assert_eq!(item.kind, RunStreamItemKind::Platform);
assert_eq!(item.name(), Some("checkpoint"));
assert_eq!(serde_json::to_value(&item).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

@ -15,7 +15,7 @@ use fabro_types::{
ArtifactUpload, BlobHash, EventEnvelope, Model, ModelTestMode, PairId, PairMessageRecord,
PairMessageRequest, PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent,
RunEventDetailResponse, RunId, RunPairStatusResponse, RunProjection, RunSessionMetadata,
SessionId, StageId, WorkflowVersion, WorkflowVersionId,
RunStreamItem, SessionId, StageId, WorkflowVersion, WorkflowVersionId,
};
use fabro_util::exit::{ErrorExt, ExitClass};
use futures::future::BoxFuture;
@ -56,6 +56,24 @@ pub struct RunEventStream {
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 {
stream: progenitor_client::ByteStream,
pending_bytes: Vec<u8>,
buffered_items: VecDeque<RunStreamItem>,
}
/// One page of a Petri run's stream.
#[derive(Debug, Clone)]
pub struct RunStreamPage {
pub items: Vec<RunStreamItem>,
pub has_more: bool,
/// Petri's `EVENT_CONTRACT_VERSION` the server serves; `None` when the
/// page was empty and the server reported no version beside it.
pub event_contract_version: Option<u32>,
}
type HttpByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
pub struct SessionEventStream {
@ -186,6 +204,42 @@ impl RunEventStream {
}
}
impl RunStreamItemStream {
#[must_use]
pub fn new(stream: progenitor_client::ByteStream) -> Self {
Self {
stream,
pending_bytes: Vec::new(),
buffered_items: VecDeque::new(),
}
}
pub async fn next_item(&mut self) -> Result<Option<RunStreamItem>> {
loop {
if let Some(item) = self.buffered_items.pop_front() {
return Ok(Some(item));
}
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_items(false)?;
} else {
self.buffer_sse_items(true)?;
return Ok(self.buffered_items.pop_front());
}
}
}
fn buffer_sse_items(&mut self, finalize: bool) -> Result<()> {
for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) {
self.buffered_items
.push_back(serde_json::from_str(&payload)?);
}
Ok(())
}
}
impl SessionEventStream {
#[must_use]
pub fn new(stream: HttpByteStream) -> Self {
@ -1813,7 +1867,15 @@ impl Client {
request.send().await
})
.await?;
let parsed = response.into_inner();
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()
@ -1822,6 +1884,84 @@ impl Client {
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(
&self,
run_id: &RunId,
after: u64,
limit: Option<usize>,
) -> Result<RunStreamPage> {
let response = self
.send_api(|client| async move {
let mut request = client.list_run_events().id(run_id.to_string()).after(after);
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?;
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")
}
}
}
/// Every item of a Petri run's stream past `after`, page by page.
pub async fn list_run_stream(&self, run_id: &RunId, after: u64) -> Result<Vec<RunStreamItem>> {
let mut cursor = after;
let mut all = Vec::new();
loop {
let page = self.list_run_stream_page(run_id, cursor, None).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;
}
}
Ok(all)
}
/// The live stream of a Petri run from `after` (the last `stream_seq`
/// seen; `Some(0)` replays the whole run; `None` starts at the next
/// unseen item).
pub async fn attach_run_stream(
&self,
run_id: &RunId,
after: Option<u64>,
) -> Result<RunStreamItemStream> {
let response = self
.send_api(|client| async move {
let mut request = client.attach_run_events().id(run_id.to_string());
if let Some(after) = after {
request = request.after(after);
}
request.send().await
})
.await?;
Ok(RunStreamItemStream::new(response.into_inner()))
}
pub async fn attach_run_events(
&self,
run_id: &RunId,

View file

@ -12,7 +12,8 @@ pub use auth_store::{
AuthEntry, AuthStore, AuthStoreError, DevTokenEntry, LockError, OAuthEntry, StoredSubject,
};
pub use client::{
Client, RunEventStream, SessionEventStream, TransportConnector, apply_bearer_token_auth,
Client, RunEventStream, RunStreamItemStream, RunStreamPage, SessionEventStream,
TransportConnector, apply_bearer_token_auth,
};
pub use credential::{Credential, CredentialFallback};
pub use error::{

View file

@ -35,6 +35,7 @@ pub mod run_id;
pub mod run_intent;
pub mod run_projection;
pub mod run_sandbox;
pub mod run_stream;
pub mod run_summary;
pub mod run_title;
pub mod sandbox_details;
@ -152,6 +153,7 @@ pub use run_sandbox::{
RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan,
RunSandboxRuntime,
};
pub use run_stream::{RunStreamItem, RunStreamItemKind, petri_event_name};
pub use run_summary::{
AskFabro, AskFabroUnavailableReason, AutomationRef, ResolvedAutomationGitWorkflowSource, Run,
RunApproval, RunApprovalState, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin,

View file

@ -0,0 +1,168 @@
//! The run stream: one ordered delivery of a Petri run's public events and
//! Fabro's platform records, as `GET /runs/{id}/events` and the attach
//! stream serve them for a run that executes on Petri.
//!
//! Each item is a Petri `RunEvent` (Petri's event contract, passed through
//! as JSON) or a stored platform record (Fabro's own fact about the run:
//! its lifecycle before and after the engine, a checkpoint with its commit,
//! a pull request), in one Fabro envelope. The envelope carries:
//!
//! - `stream_seq`, the durable per-run delivery sequence the projector assigned
//! when the item's record was committed. It is the cursor: a client resumes
//! from the last `stream_seq` it saw. It is dense and strictly increasing
//! within a run.
//! - `id`, the item's own identity, kept beside the cursor so a client
//! deduplicates by it: for a Petri event the `EventId` as
//! `<log>/<seq>/<index>` (`coordinator/3/0`, `execution 1/23/0`), for a
//! platform record its `seq`. Petri's `EventId` is per log and has no
//! platform variant, so it is never the cursor.
//! - `kind`, which of the two the item is.
//! - `recorded_at`, when the item's record was appended, in milliseconds since
//! the Unix epoch; the same field both item shapes carry.
//! - `item`, the Petri `RunEvent` or the stored platform record, unchanged.
use serde::{Deserialize, Serialize};
use crate::RunId;
/// Which of the two item shapes a stream item carries.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum RunStreamItemKind {
/// A Petri `RunEvent`: `{id, origin, recorded_at, context, subject,
/// record, derived}` under Petri's event contract.
Petri,
/// A stored platform record: `{seq, recorded_at, record: {kind, ...},
/// position?}`.
Platform,
}
/// One item of a Petri run's stream, in Fabro's envelope.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStreamItem {
pub run_id: RunId,
/// The delivery sequence: the cursor.
pub stream_seq: u64,
pub kind: RunStreamItemKind,
/// The item's own identity, for deduplication.
pub id: String,
/// Milliseconds since the Unix epoch when the item's record was
/// appended.
pub recorded_at: u64,
/// The Petri `RunEvent` or the stored platform record, as JSON.
pub item: serde_json::Value,
}
impl RunStreamItem {
/// The `<subject>.<verb>` name of a Petri event, or the `kind` of a
/// platform record: what a listing shows and a filter matches on.
#[must_use]
pub fn name(&self) -> Option<&str> {
match self.kind {
RunStreamItemKind::Petri => petri_event_name(&self.item),
RunStreamItemKind::Platform => self
.item
.get("record")
.and_then(|record| record.get("kind"))
.and_then(serde_json::Value::as_str),
}
}
}
/// The `<subject>.<verb>` name of a Petri `RunEvent` value: the recorded
/// body's `event` tag, or a view event's tag under `derived`.
#[must_use]
pub fn petri_event_name(event: &serde_json::Value) -> Option<&str> {
event
.get("record")
.and_then(|record| record.get("body"))
.and_then(|body| body.get("event"))
.and_then(serde_json::Value::as_str)
.or_else(|| {
event
.get("derived")
.and_then(|derived| derived.get("event"))
.and_then(serde_json::Value::as_str)
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{RunStreamItem, RunStreamItemKind, petri_event_name};
use crate::fixtures;
#[test]
fn kind_names_are_lowercase_in_both_directions() {
assert_eq!(RunStreamItemKind::Petri.to_string(), "petri");
assert_eq!(
"platform".parse::<RunStreamItemKind>(),
Ok(RunStreamItemKind::Platform)
);
assert_eq!(
serde_json::to_value(RunStreamItemKind::Platform).expect("kind serializes"),
json!("platform")
);
}
#[test]
fn a_petri_item_is_named_by_its_recorded_event_tag() {
let item = RunStreamItem {
run_id: fixtures::RUN_1,
stream_seq: 4,
kind: RunStreamItemKind::Petri,
id: "coordinator/3/0".to_string(),
recorded_at: 1_789_323_217_366,
item: json!({
"id": {"log": "coordinator", "seq": 3, "index": 0},
"record": {"seq": 3, "body": {"event": "execution.declared"}}
}),
};
assert_eq!(item.name(), Some("execution.declared"));
assert_eq!(
petri_event_name(&json!({"origin": "derived", "derived": {"event": "visit.started"}})),
Some("visit.started")
);
}
#[test]
fn a_platform_item_is_named_by_its_record_kind() {
let item = RunStreamItem {
run_id: fixtures::RUN_1,
stream_seq: 5,
kind: RunStreamItemKind::Platform,
id: "2".to_string(),
recorded_at: 1_789_323_217_400,
item: json!({"seq": 2, "record": {"kind": "run.notice", "level": "info"}}),
};
assert_eq!(item.name(), Some("run.notice"));
}
#[test]
fn the_envelope_round_trips_as_json() {
let value = json!({
"run_id": fixtures::RUN_1.to_string(),
"stream_seq": 7,
"kind": "platform",
"id": "3",
"recorded_at": 1_789_323_217_400_u64,
"item": {"seq": 3, "recorded_at": 1_789_323_217_400_u64, "record": {"kind": "checkpoint", "execution": 1, "firing": 2}}
});
let item: RunStreamItem = serde_json::from_value(value.clone()).expect("item decodes");
assert_eq!(serde_json::to_value(&item).expect("item encodes"), value);
}
}

View file

@ -216,6 +216,7 @@ 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
@ -271,6 +272,7 @@ models/paginated-run-commit-list.ts
models/paginated-run-file-list.ts
models/paginated-run-list.ts
models/paginated-run-stage-list.ts
models/paginated-run-stream-list.ts
models/paginated-saved-query-list.ts
models/paginated-session-list.ts
models/paginated-workflow-list-response.ts
@ -296,7 +298,9 @@ models/pending-interview-record.ts
models/pending-reason.ts
models/permission-level.ts
models/petri-access.ts
models/petri-admission.ts
models/petri-append-request.ts
models/petri-graph-ref.ts
models/petri-open-request.ts
models/petri-open-response.ts
models/petri-platform-record-append-request.ts
@ -386,6 +390,9 @@ models/run-commit.ts
models/run-commits-meta.ts
models/run-control-action.ts
models/run-diff.ts
models/run-engine-one-of.ts
models/run-engine-one-of1.ts
models/run-engine.ts
models/run-environment-settings.ts
models/run-error.ts
models/run-event-detail-response-content.ts
@ -445,6 +452,8 @@ models/run-status-starting.ts
models/run-status-submitted.ts
models/run-status-succeeded.ts
models/run-status.ts
models/run-stream-item-kind.ts
models/run-stream-item.ts
models/run-superseded-by-props.ts
models/run-target.ts
models/run-timestamps.ts

View file

@ -30,6 +30,8 @@ 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';
@ -212,14 +214,15 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config
};
},
/**
* Opens an ordered server-sent event stream starting at `since_seq`, replaying persisted events and continuing with live updates while the run remains active.
* 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`.
* @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, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
attachRunEvents: async (id: string, sinceSeq?: number, 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`
@ -245,6 +248,10 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config
localVarQueryParameter['since_seq'] = sinceSeq;
}
if (after !== undefined) {
localVarQueryParameter['after'] = after;
}
localVarHeaderParameter['Accept'] = 'text/event-stream,application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -711,17 +718,18 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config
};
},
/**
* Returns a paginated JSON list of stored run events. 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.
* 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.
* @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, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listRunEvents: async (id: string, sinceSeq?: number, limit?: number, beforeSeq?: number, order?: ListRunEventsOrderEnum, 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`
@ -759,6 +767,10 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config
localVarQueryParameter['order'] = order;
}
if (after !== undefined) {
localVarQueryParameter['after'] = after;
}
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -1386,15 +1398,16 @@ export const RunInternalsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Opens an ordered server-sent event stream starting at `since_seq`, replaying persisted events and continuing with live updates while the run remains active.
* 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`.
* @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, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.attachRunEvents(id, sinceSeq, options);
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);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.attachRunEvents']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -1541,18 +1554,19 @@ export const RunInternalsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns a paginated JSON list of stored run events. 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.
* 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.
* @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, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedEventList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRunEvents(id, sinceSeq, limit, beforeSeq, order, options);
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);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.listRunEvents']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -1774,15 +1788,16 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b
return localVarFp.appendRunEvent(id, runEvent, options).then((request) => request(axios, basePath));
},
/**
* Opens an ordered server-sent event stream starting at `since_seq`, replaying persisted events and continuing with live updates while the run remains active.
* 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`.
* @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, options?: RawAxiosRequestConfig): AxiosPromise<string> {
return localVarFp.attachRunEvents(id, sinceSeq, options).then((request) => request(axios, basePath));
attachRunEvents(id: string, sinceSeq?: number, after?: number, options?: RawAxiosRequestConfig): AxiosPromise<string> {
return localVarFp.attachRunEvents(id, sinceSeq, 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.
@ -1896,18 +1911,19 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b
return localVarFp.listRunArtifacts(id, options).then((request) => request(axios, basePath));
},
/**
* Returns a paginated JSON list of stored run events. 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.
* 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.
* @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, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedEventList> {
return localVarFp.listRunEvents(id, sinceSeq, limit, beforeSeq, order, options).then((request) => request(axios, basePath));
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));
},
/**
* 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.
@ -2091,15 +2107,16 @@ export class RunInternalsApi extends BaseAPI {
}
/**
* Opens an ordered server-sent event stream starting at `since_seq`, replaying persisted events and continuing with live updates while the run remains active.
* 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`.
* @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, options?: RawAxiosRequestConfig) {
return RunInternalsApiFp(this.configuration).attachRunEvents(id, sinceSeq, options).then((request) => request(this.axios, this.basePath));
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));
}
/**
@ -2224,18 +2241,19 @@ export class RunInternalsApi extends BaseAPI {
}
/**
* Returns a paginated JSON list of stored run events. 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.
* 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.
* @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, options?: RawAxiosRequestConfig) {
return RunInternalsApiFp(this.configuration).listRunEvents(id, sinceSeq, limit, beforeSeq, order, options).then((request) => request(this.axios, this.basePath));
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));
}
/**

View file

@ -186,6 +186,7 @@ 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';
@ -241,6 +242,7 @@ export * from './paginated-run-commit-list';
export * from './paginated-run-file-list';
export * from './paginated-run-list';
export * from './paginated-run-stage-list';
export * from './paginated-run-stream-list';
export * from './paginated-saved-query-list';
export * from './paginated-session-list';
export * from './paginated-workflow-list-response';
@ -266,7 +268,9 @@ export * from './pending-interview-record';
export * from './pending-reason';
export * from './permission-level';
export * from './petri-access';
export * from './petri-admission';
export * from './petri-append-request';
export * from './petri-graph-ref';
export * from './petri-open-request';
export * from './petri-open-response';
export * from './petri-platform-record';
@ -357,6 +361,9 @@ export * from './run-commit-person';
export * from './run-commits-meta';
export * from './run-control-action';
export * from './run-diff';
export * from './run-engine';
export * from './run-engine-one-of';
export * from './run-engine-one-of1';
export * from './run-environment-settings';
export * from './run-error';
export * from './run-event';
@ -416,6 +423,8 @@ export * from './run-status-running';
export * from './run-status-starting';
export * from './run-status-submitted';
export * from './run-status-succeeded';
export * from './run-stream-item';
export * from './run-stream-item-kind';
export * from './run-superseded-by-props';
export * from './run-target';
export * from './run-timestamps';

View file

@ -0,0 +1,32 @@
/* 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

@ -0,0 +1,30 @@
/* 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 { PaginationMeta } from './pagination-meta';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStreamItem } from './run-stream-item';
/**
* One page of a Petri run\'s stream, in `stream_seq` order. `event_contract_version` is Petri\'s `EVENT_CONTRACT_VERSION` the server was built against: the version of the event contract every `petri` item follows.
*/
export interface PaginatedRunStreamList {
'data': Array<RunStreamItem>;
'meta': PaginationMeta;
'event_contract_version': number;
}

View file

@ -0,0 +1,26 @@
/* 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 { PetriGraphRef } from './petri-graph-ref';
/**
* What Petri admitted for a run at create time: the lowered root graph and the pre-lowered child graphs, every one persisted in the blob store before the run exists.
*/
export interface PetriAdmission {
'graph': PetriGraphRef;
'children'?: Array<PetriGraphRef>;
}

View file

@ -0,0 +1,26 @@
/* 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.
*/
/**
* An admitted graph in the blob store, verified by digest on load.
*/
export interface PetriGraphRef {
/**
* Content-addressed SHA-256 hash of a stored blob. Hex input is case-insensitive; Fabro emits the canonical lowercase form.
*/
'blob': string;
'digest': string;
}

View file

@ -0,0 +1,25 @@
/* 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 RunEngineOneOf {
'kind': RunEngineOneOfKindEnum;
}
export const RunEngineOneOfKindEnum = {
LEGACY: 'legacy'
} as const;
export type RunEngineOneOfKindEnum = typeof RunEngineOneOfKindEnum[keyof typeof RunEngineOneOfKindEnum];

View file

@ -0,0 +1,26 @@
/* 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 { PetriAdmission } from './petri-admission';
// May contain unused imports in some cases
// @ts-ignore
import type { PetriGraphRef } from './petri-graph-ref';
/**
* @type RunEngineOneOf1
*/
export type RunEngineOneOf1 = PetriAdmission;

View file

@ -0,0 +1,30 @@
/* 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 { PetriGraphRef } from './petri-graph-ref';
// May contain unused imports in some cases
// @ts-ignore
import type { RunEngineOneOf } from './run-engine-one-of';
// May contain unused imports in some cases
// @ts-ignore
import type { RunEngineOneOf1 } from './run-engine-one-of1';
/**
* @type RunEngine
* The engine a run was created for. `legacy` is the in-process executor; `petri` names the Petri workflow engine and carries what Petri admitted at create time.
*/
export type RunEngine = RunEngineOneOf | RunEngineOneOf1;

View file

@ -24,6 +24,9 @@ import type { ForkSourceRef } from './fork-source-ref';
import type { GitContext } from './git-context';
// May contain unused imports in some cases
// @ts-ignore
import type { RunEngine } from './run-engine';
// May contain unused imports in some cases
// @ts-ignore
import type { RunProvenance } from './run-provenance';
// May contain unused imports in some cases
// @ts-ignore
@ -54,4 +57,8 @@ export interface RunSpec {
'spec_blob'?: string | null;
'git'?: GitContext | null;
'fork_source_ref'?: ForkSourceRef | null;
/**
* The engine the run was created for, with what it admitted. Absent in a spec written before the field existed, which means the legacy executor.
*/
'engine'?: RunEngine;
}

View file

@ -0,0 +1,26 @@
/* 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.
*/
/**
* Which item shape a run stream item carries.
*/
export const RunStreamItemKind = {
PETRI: 'petri',
PLATFORM: 'platform'
} as const;
export type RunStreamItemKind = typeof RunStreamItemKind[keyof typeof RunStreamItemKind];

View file

@ -0,0 +1,42 @@
/* 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 { RunStreamItemKind } from './run-stream-item-kind';
/**
* One item of a Petri run\'s stream: a Petri `RunEvent` or a Fabro platform record in Fabro\'s envelope. `stream_seq` is the durable per-run delivery sequence the projector assigned when the item\'s record was committed: dense, strictly increasing within the run, and the cursor for `after`. `id` is the item\'s own identity, kept beside the cursor so a client deduplicates by it: for a Petri event the `EventId` as `<log>/<seq>/<index>` (`coordinator/3/0`, `execution 1/23/0`); for a platform record its `seq`. Petri\'s `EventId` is per log and has no platform variant, so it is never the cursor. A `petri` item is a Petri `RunEvent` passed through unchanged: `{id: {log, execution?, seq, index}, origin, recorded_at, observed_at?, context: {invocation, execution, parent?}, subject?, record?, derived?}`. Its vocabulary is Petri\'s public event contract (`crates/core/execution/EVENTS.md` in the Petri repository), not Fabro\'s: the recorded event\'s name is `record.body.event` (`<subject>.<verb>`, e.g. `visit.started`, `step.finished`, `run.finished`), a derived view event\'s is `derived.event`, and the stage a subject names is `(context.execution, subject.firing)` with `subject.node.name` and `subject.visit` as its display label. The server reports the contract version it serves in `PaginatedRunStreamList.event_contract_version`. A `platform` item is a stored platform record: `{seq, recorded_at, record: {kind, ...}, position?: {execution, firing}}`. `record.kind` is one of `run.created`, `run.lifecycle`, `run.title`, `run.parent`, `run.archived`, `run.unarchived`, `run.superseded`, `run.notice`, `interview.answered`, `run.branch`, `git.identity`, `checkpoint`, `pull_request.created`, `notification.sent`, `run.paired`.
*/
export interface RunStreamItem {
'run_id': string;
/**
* The delivery sequence; the cursor.
*/
'stream_seq': number;
'kind': RunStreamItemKind;
/**
* The item\'s own identity, for deduplication.
*/
'id': string;
/**
* Milliseconds since the Unix epoch when the item\'s record was appended.
*/
'recorded_at': number;
/**
* The Petri `RunEvent` or the stored platform record, unchanged.
*/
'item': { [key: string]: any; };
}