fabro/apps/fabro-web/app/components/event-debug-helpers.tsx
Bryan Helmkamp f22cf18c86
Render a Petri run's detail views from its projection and stream
The web app reads a Petri run through the run stream (`useRunStream`
pages `GET /runs/{id}/events` by `after`) and the projection, keyed on
`RunSpec.engine`, beside the legacy event path. `lib/petri-stream.ts`
holds the pure derivations `VIEWS.md` maps: the stage label of an item's
subject (lowering nodes skipped), the interview pairs from `parsed.question`
and the delivered `control.requested` with the `interview.answered`
principal, the run phases from the platform lifecycle records, the edge a
`route.applied` took, the fork's branches and the fan-in transcript from
the projection, the stage context from the final `step.finished`, the
Pebble envelopes of a step, and the debug rows the listings show.

The events route lists stream rows (named `<subject>.<verb>` or by the
platform kind, with the stage beside them and the raw item in the details
panel) and the waterfall takes its phases from the lifecycle records. The
stages route builds a Petri stage's turns from the projection's prompt and
response and the step's envelopes, its debug tab from the stage's items,
and hands the human, conditional, parallel and fan-in renderers the
derived data instead of events. The overview lists the platform records
(checkpoints with their commit, pull request, notices). The SSE
subscription invalidates SWR keys from a stream item's event name or
platform kind, ending on the terminal lifecycle record, and the cross-tab
dedupe keys a stream item by its run and `stream_seq`.

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

130 lines
3.6 KiB
TypeScript

import type { ReactNode } from "react";
export type DebugCategory =
| "agent"
| "command"
| "lifecycle"
| "human"
| "system"
| "petri"
| "platform";
export const DEBUG_CATEGORIES: readonly DebugCategory[] = [
"agent",
"command",
"lifecycle",
"human",
"system",
"petri",
"platform",
] as const;
const PREFIX_TO_CATEGORY: Record<string, DebugCategory> = {
agent: "agent",
command: "command",
run: "lifecycle",
stage: "lifecycle",
parallel: "lifecycle",
subgraph: "lifecycle",
edge: "lifecycle",
loop: "lifecycle",
prompt: "lifecycle",
interview: "human",
};
const CATEGORY_LABEL: Record<DebugCategory, string> = {
agent: "Agent",
command: "Command",
lifecycle: "Lifecycle",
human: "Human",
system: "System",
petri: "Petri",
platform: "Platform",
};
const CATEGORY_TONE: Record<DebugCategory, string> = {
agent: "bg-teal-500/15 text-teal-500",
command: "bg-mint/15 text-mint",
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> = {
agent: "var(--color-teal-500)",
command: "var(--color-mint)",
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 {
if (!eventName) return "system";
const dot = eventName.indexOf(".");
const prefix = dot < 0 ? eventName : eventName.slice(0, dot);
return PREFIX_TO_CATEGORY[prefix] ?? "system";
}
export function debugCategoryLabel(category: DebugCategory): string {
return CATEGORY_LABEL[category];
}
export function debugCategoryTone(category: DebugCategory): string {
return CATEGORY_TONE[category];
}
export function debugCategoryColor(category: DebugCategory): string {
return CATEGORY_COLOR[category];
}
export function formatElapsed(eventTs: string, runStart: string | undefined): string {
if (!runStart) return "";
const startMs = Date.parse(runStart);
const eventMs = Date.parse(eventTs);
if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return "";
const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000));
const hours = Math.floor(delta / 3600);
const minutes = Math.floor((delta % 3600) / 60);
const seconds = delta % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
const JSON_TOKEN_RE =
/"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g;
export function highlightJson(text: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let key = 0;
JSON_TOKEN_RE.lastIndex = 0;
while ((match = JSON_TOKEN_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const token = match[0];
let cls: string;
if (token.startsWith('"')) {
const after = text.slice(JSON_TOKEN_RE.lastIndex);
cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint";
} else if (token === "true" || token === "false") {
cls = "text-coral";
} else if (token === "null") {
cls = "text-fg-muted";
} else {
cls = "text-amber";
}
parts.push(
<span key={key++} className={cls}>
{token}
</span>,
);
lastIndex = JSON_TOKEN_RE.lastIndex;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts;
}