diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx new file mode 100644 index 000000000..1b0bd94a5 --- /dev/null +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo } from "react"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { + CheckIcon, + ChevronUpDownIcon, + FunnelIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/16/solid"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; + +import { Tooltip } from "./ui"; +import { formatAbsoluteTs } from "../lib/format"; + +const DEBUG_CATEGORY_TONE: Record = { + agent: "bg-teal-500/15 text-teal-500", + command: "bg-mint/15 text-mint", + interview: "bg-coral/15 text-coral", + run: "bg-overlay-strong text-fg-2", + stage: "bg-amber/15 text-amber", + tool: "bg-mint/15 text-mint", +}; + +export function debugCategory(eventName: string): string { + const dot = eventName.indexOf("."); + return dot < 0 ? eventName : eventName.slice(0, dot); +} + +export function debugCategoryLabel(category: string): string { + if (!category) return "Other"; + return category.charAt(0).toUpperCase() + category.slice(1); +} + +export function debugCategoryTone(category: string): string { + return DEBUG_CATEGORY_TONE[category] ?? "bg-overlay text-fg-muted"; +} + +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): React.ReactNode[] { + const parts: React.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( + + {token} + , + ); + lastIndex = JSON_TOKEN_RE.lastIndex; + } + if (lastIndex < text.length) parts.push(text.slice(lastIndex)); + return parts; +} + +export function DebugEventRow({ + event, + runStart, + selected, + onSelect, +}: { + event: EventEnvelope; + runStart: string | undefined; + selected: boolean; + onSelect: () => void; +}) { + const eventName = event.event ?? ""; + const category = debugCategory(eventName); + return ( + + ); +} + +export function DetailsPanel({ + title, + isOpen, + onClose, + children, +}: { + title: string; + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +}) { + useEffect(() => { + if (!isOpen) return; + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + return ( +
+
+
+

{title}

+ +
+
+ {isOpen ? children : null} +
+
+
+ ); +} + +export function DebugEventDetailsPanel({ + event, + onClose, +}: { + event: EventEnvelope | null; + onClose: () => void; +}) { + return ( + + {event ? : null} + + ); +} + +function DebugEventDetails({ event }: { event: EventEnvelope }) { + const text = useMemo(() => JSON.stringify(event, null, 2), [event]); + const tokens = useMemo(() => highlightJson(text), [text]); + return ( +
+      {tokens}
+    
+ ); +} + +export function MultiSelectFilter({ + selected, + options, + labelOf, + onChange, + emptyMeansAll = false, +}: { + selected: T[]; + options: readonly T[]; + labelOf: (item: T) => string; + onChange: (next: T[]) => void; + emptyMeansAll?: boolean; +}) { + const allSelected = selected.length === options.length; + const summary = useMemo(() => { + if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types"; + if (selected.length === 0) return "No types"; + if (selected.length <= 2) { + return options + .filter((o) => selected.includes(o)) + .map(labelOf) + .join(", "); + } + return `${selected.length} types`; + }, [allSelected, emptyMeansAll, selected, options, labelOf]); + + return ( + + + + + {options.map((option) => ( + + + + {labelOf(option)} + + ))} + + + ); +} + +export function EventSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( +
+
+ ); +} diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index a5f1be26c..8afb88667 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -11,6 +11,7 @@ import { } from "@heroicons/react/24/solid"; import { Bars3BottomLeftIcon, + BoltIcon, DocumentTextIcon, MapIcon, PaperClipIcon, @@ -44,7 +45,7 @@ interface StageSidebarProps { stages: Stage[]; runId: string; selectedStageId?: string; - activeLink?: "settings" | "source" | "logs" | "artifacts"; + activeLink?: "settings" | "source" | "logs" | "artifacts" | "events"; } export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) { @@ -117,19 +118,6 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta

Workflow

    -
  • - - - Run Settings - -
  • +
  • + + + Run Events + +
  • +
  • + + + Run Settings + +
diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 3804febb6..552c0ae44 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -174,6 +174,16 @@ export function useRunStageEvents(id: string | undefined, stageId: string | unde ); } +export function useRunEventsList(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.events(id, 1000) : null, + () => + fetchAllStageEvents(`run ${id} events`, (sinceSeq, limit) => + apiData(() => runInternalsApi.listRunEvents(id!, sinceSeq, limit)), + ), + ); +} + export function fetchRunCommandLog( id: string, stageId: string, diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index eaaea8979..5d1cfce62 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -18,6 +18,7 @@ import * as RunStages from "./routes/run-stages"; import * as RunSettings from "./routes/run-settings"; import * as RunSource from "./routes/run-source"; import * as RunLogs from "./routes/run-logs"; +import * as RunEvents from "./routes/run-events"; import * as RunArtifacts from "./routes/run-artifacts"; import * as RunFiles from "./routes/run-files"; import * as RunBilling from "./routes/run-billing"; @@ -95,6 +96,7 @@ export const routes: RouteObject[] = [ route("settings", RunSettings), route("source", RunSource), route("logs", RunLogs), + route("events", RunEvents), route("artifacts", RunArtifacts), route("files", RunFiles), route("billing", RunBilling), diff --git a/apps/fabro-web/app/routes/run-events.tsx b/apps/fabro-web/app/routes/run-events.tsx new file mode 100644 index 000000000..16ced72c7 --- /dev/null +++ b/apps/fabro-web/app/routes/run-events.tsx @@ -0,0 +1,195 @@ +import { useMemo, useState } from "react"; +import { useParams } from "react-router"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; + +import { + DebugEventDetailsPanel, + DebugEventRow, + EventSearchInput, + MultiSelectFilter, + debugCategory, + debugCategoryLabel, +} from "../components/event-debug"; +import { StageSidebar } from "../components/stage-sidebar"; +import { EmptyState, ErrorState, LoadingState } from "../components/state"; +import { useRun, useRunEventsList, useRunStages } from "../lib/queries"; +import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; + +export const handle = { wide: true, fullHeight: true }; + +export default function RunEvents() { + const { id } = useParams(); + const runQuery = useRun(id); + const stagesQuery = useRunStages(id); + const eventsQuery = useRunEventsList(id); + const stages = useMemo( + () => mapRunStagesToSidebarStages(stagesQuery.data), + [stagesQuery.data], + ); + + return ( +
+
+ +
+ +
+ + + void eventsQuery.mutate()} + runStart={runQuery.data?.start_time ?? runQuery.data?.created_at} + /> +
+ ); +} + +function EventsView({ + events, + error, + onRetry, + runStart, +}: { + events: EventEnvelope[] | undefined; + error: unknown; + onRetry: () => void; + runStart: string | undefined; +}) { + const [openSeq, setOpenSeq] = useState(null); + const [selectedCategories, setSelectedCategories] = useState([]); + const [search, setSearch] = useState(""); + + const all = events ?? []; + + const availableCategories = useMemo(() => { + const set = new Set(); + for (const event of all) { + if (event.event) set.add(debugCategory(event.event)); + } + return Array.from(set).sort(); + }, [all]); + + const filtered = useMemo(() => { + const useCategoryFilter = selectedCategories.length > 0; + const cats = new Set(selectedCategories); + const needle = search.toLowerCase(); + return all.filter((event) => { + const name = event.event ?? ""; + if (useCategoryFilter && !cats.has(debugCategory(name))) return false; + if (needle) { + const blob = `${name} ${JSON.stringify(event.properties ?? {})}`.toLowerCase(); + if (!blob.includes(needle)) return false; + } + return true; + }); + }, [all, selectedCategories, search]); + + const openEvent = useMemo( + () => (openSeq != null ? all.find((e) => e.seq === openSeq) ?? null : null), + [all, openSeq], + ); + + const allCategoriesSelected = + selectedCategories.length === 0 || + selectedCategories.length === availableCategories.length; + const isFiltering = !allCategoriesSelected || search.length > 0; + + function clearFilters() { + setSelectedCategories([]); + setSearch(""); + } + + if (error) { + return ( +
+ +
+ ); + } + if (events === undefined) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+
+
+
+ + selected={selectedCategories} + options={availableCategories} + labelOf={debugCategoryLabel} + onChange={setSelectedCategories} + emptyMeansAll + /> + + {isFiltering && ( + + )} +
+ {all.length > 0 && ( + + {isFiltering + ? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} events` + : `${all.length.toLocaleString()} events`} + + )} +
+
+
+
+ {all.length === 0 ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ No events match these filters. +
+ ) : ( + filtered.map((event) => ( + setOpenSeq(event.seq)} + /> + )) + )} +
+
+ + setOpenSeq(null)} /> + + ); +} + +function errorMessage(error: unknown): string | undefined { + return error instanceof Error ? error.message : undefined; +} diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 421a7444d..8224b8ffd 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -1,24 +1,24 @@ import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router"; import { - Listbox, - ListboxButton, - ListboxOption, - ListboxOptions, -} from "@headlessui/react"; -import { XMarkIcon } from "@heroicons/react/24/outline"; -import { - CheckIcon, ChevronDownIcon, ChevronRightIcon, - ChevronUpDownIcon, CpuChipIcon, - FunnelIcon, - MagnifyingGlassIcon, } from "@heroicons/react/16/solid"; import { CircleStackIcon, ClockIcon } from "@heroicons/react/20/solid"; import { Marked } from "marked"; +import { + DebugEventDetailsPanel, + DebugEventRow, + DetailsPanel, + EventSearchInput, + MultiSelectFilter, + debugCategory, + debugCategoryLabel, + formatElapsed, + highlightJson, +} from "../components/event-debug"; import { StageSidebar } from "../components/stage-sidebar"; import type { Stage } from "../components/stage-sidebar"; import { EmptyState } from "../components/state"; @@ -39,6 +39,8 @@ export const handle = { wide: true, fullHeight: true }; type TurnType = | { kind: "system"; ts: string; content: string } + | { kind: "steer"; ts: string; content: string } + | { kind: "interrupt"; ts: string; content: string } | { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number } | { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean; durationMs: number } | { @@ -60,39 +62,18 @@ type PanelSelection = const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); -const EVENT_KINDS = ["system", "assistant", "tool", "command"] as const; +const EVENT_KINDS = ["system", "steer", "interrupt", "assistant", "tool", "command"] as const; type EventKind = (typeof EVENT_KINDS)[number]; const EVENT_KIND_LABEL: Record = { system: "System", + steer: "Steer", + interrupt: "Interrupt", assistant: "Agent", tool: "Tool", command: "Command", }; -const DEBUG_CATEGORY_TONE: Record = { - agent: "bg-teal-500/15 text-teal-500", - command: "bg-mint/15 text-mint", - interview: "bg-coral/15 text-coral", - run: "bg-overlay-strong text-fg-2", - stage: "bg-amber/15 text-amber", - tool: "bg-mint/15 text-mint", -}; - -function debugCategory(eventName: string): string { - const dot = eventName.indexOf("."); - return dot < 0 ? eventName : eventName.slice(0, dot); -} - -function debugCategoryLabel(category: string): string { - if (!category) return "Other"; - return category.charAt(0).toUpperCase() + category.slice(1); -} - -function debugCategoryTone(category: string): string { - return DEBUG_CATEGORY_TONE[category] ?? "bg-overlay text-fg-muted"; -} - const EVENTS_TABS = ["transcript", "debug"] as const; type EventsTab = (typeof EVENTS_TABS)[number]; @@ -156,6 +137,16 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn } break; } + case "agent.steering.injected": { + const text = getString(props, "text") ?? e.text ?? ""; + if (text) { + turns.push({ kind: "steer", ts: e.ts, content: text }); + } + break; + } + case "agent.interrupt.injected": + turns.push({ kind: "interrupt", ts: e.ts, content: "Agent interrupted" }); + break; case "agent.tool.started": { const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; const args = props.arguments ?? e.arguments; @@ -309,6 +300,10 @@ function turnLabel(turn: TurnType): string { switch (turn.kind) { case "system": return "System"; + case "steer": + return "Steer"; + case "interrupt": + return "Interrupt"; case "assistant": return "Agent"; case "tool": @@ -322,6 +317,10 @@ function turnTone(turn: TurnType): string { switch (turn.kind) { case "system": return "bg-amber/15 text-amber"; + case "steer": + return "bg-overlay-strong text-fg-2"; + case "interrupt": + return "bg-coral/15 text-coral"; case "assistant": return "bg-teal-500/15 text-teal-500"; case "tool": @@ -410,6 +409,8 @@ export function humanizeToolName(raw: string): string { export function turnSummary(turn: TurnType): string { switch (turn.kind) { case "system": + case "steer": + case "interrupt": case "assistant": return oneLine(turn.content); case "tool": @@ -447,6 +448,8 @@ export function turnMetric(turn: TurnType): string | null { case "tool": case "command": return turn.durationMs > 0 ? formatDurationMs(turn.durationMs) : null; + case "steer": + case "interrupt": case "system": return null; } @@ -455,6 +458,8 @@ export function turnMetric(turn: TurnType): string | null { export function searchableText(turn: TurnType): string { switch (turn.kind) { case "system": + case "steer": + case "interrupt": case "assistant": return turn.content; case "tool": @@ -464,18 +469,6 @@ export function searchableText(turn: TurnType): string { } } -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")}`; -} - function EventRow({ turn, runStart, @@ -595,45 +588,6 @@ function ToolGroupRow({ ); } -function DebugRow({ - event, - runStart, - selected, - onSelect, -}: { - event: EventEnvelope; - runStart: string | undefined; - selected: boolean; - onSelect: () => void; -}) { - const eventName = event.event ?? ""; - const category = debugCategory(eventName); - return ( - - ); -} - function DetailField({ label, children, @@ -672,42 +626,6 @@ function prettyJson(raw: string): { text: string; isJson: boolean } { } } -const JSON_TOKEN_RE = - /"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g; - -function highlightJson(text: string): React.ReactNode[] { - const parts: React.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( - - {token} - , - ); - lastIndex = JSON_TOKEN_RE.lastIndex; - } - if (lastIndex < text.length) parts.push(text.slice(lastIndex)); - return parts; -} - function JsonBlock({ value }: { value: string }) { const pretty = useMemo(() => prettyJson(value), [value]); const tokens = useMemo( @@ -749,7 +667,10 @@ function EventDetails({ )} - {(turn.kind === "system" || turn.kind === "assistant") && ( + {(turn.kind === "system" || + turn.kind === "steer" || + turn.kind === "interrupt" || + turn.kind === "assistant") && ( @@ -925,53 +846,6 @@ function CommandLogs({ ); } -function DetailsPanel({ - title, - isOpen, - onClose, - children, -}: { - title: string; - isOpen: boolean; - onClose: () => void; - children: React.ReactNode; -}) { - useEffect(() => { - if (!isOpen) return; - function handleKey(event: KeyboardEvent) { - if (event.key === "Escape") onClose(); - } - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [isOpen, onClose]); - - return ( -
-
-
-

{title}

- -
-
- {isOpen ? children : null} -
-
-
- ); -} - function EventDetailsPanel({ turn, runStart, @@ -1128,34 +1002,6 @@ function ToolGroupDetailsPanel({ ); } -function DebugEventDetails({ event }: { event: EventEnvelope }) { - const text = useMemo(() => JSON.stringify(event, null, 2), [event]); - const tokens = useMemo(() => highlightJson(text), [text]); - return ( -
-      {tokens}
-    
- ); -} - -function DebugEventDetailsPanel({ - event, - onClose, -}: { - event: EventEnvelope | null; - onClose: () => void; -}) { - return ( - - {event ? : null} - - ); -} - function EventsTabToggle({ tab, stageKind, @@ -1193,92 +1039,6 @@ function EventsTabToggle({ ); } -function MultiSelectFilter({ - selected, - options, - labelOf, - onChange, - emptyMeansAll = false, -}: { - selected: T[]; - options: readonly T[]; - labelOf: (item: T) => string; - onChange: (next: T[]) => void; - emptyMeansAll?: boolean; -}) { - const allSelected = selected.length === options.length; - const summary = useMemo(() => { - if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types"; - if (selected.length === 0) return "No types"; - if (selected.length <= 2) { - return options - .filter((o) => selected.includes(o)) - .map(labelOf) - .join(", "); - } - return `${selected.length} types`; - }, [allSelected, emptyMeansAll, selected, options, labelOf]); - - return ( - - - - - {options.map((option) => ( - - - - {labelOf(option)} - - ))} - - - ); -} - -function SearchInput({ - value, - onChange, -}: { - value: string; - onChange: (value: string) => void; -}) { - return ( -
-
- ); -} - function EventsToolbar({ tab, stageKind, @@ -1348,7 +1108,7 @@ function EventsToolbar({ emptyMeansAll /> )} - + {isFiltering && (