diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index d804d3c4b..a13c92817 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -162,6 +162,18 @@ export function fetchRunCommandLog( ); } +export function useRunStageLog( + id: string | undefined, + stageId: string | undefined, + stream: CommandOutputStream, + enabled: boolean, +) { + return useSWR( + enabled && id && stageId ? queryKeys.runs.stageLog(id, stageId, stream) : null, + apiFetcher, + ); +} + export function useWorkflows() { return useSWR( queryKeys.workflows.list(), diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index e8639b0ee..d64b47972 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; -import { eventsToActivity, extractStageModel } from "./run-stages"; +import { eventsToActivity, extractStageModel, turnsToStageKind } from "./run-stages"; function envelope(seq: number, partial: Partial): EventEnvelope { return { @@ -79,8 +79,10 @@ describe("eventsToActivity", () => { event: "command.completed", node_id: "fmt", properties: { - stdout: "ok", - stderr: "", + stdout: "blob://sha256/abc", + stderr: "blob://sha256/def", + stdout_bytes: 42, + stderr_bytes: 0, exit_code: 0, duration_ms: 12, termination: "exited", @@ -94,6 +96,8 @@ describe("eventsToActivity", () => { kind: "command", script: "cargo fmt", running: false, + stdoutBytes: 42, + stderrBytes: 0, }); }); @@ -251,3 +255,49 @@ describe("eventsToActivity", () => { } }); }); + +describe("turnsToStageKind", () => { + test("classifies a stage with only command events as command", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "stage.prompt", + node_id: "fmt", + properties: { text: "run formatter" }, + }), + envelope(2, { + event: "command.started", + node_id: "fmt", + properties: { script: "cargo fmt", language: "shell" }, + }), + envelope(3, { + event: "command.completed", + node_id: "fmt", + properties: { + stdout: "blob://sha256/abc", + stderr: "blob://sha256/def", + exit_code: 0, + duration_ms: 5, + termination: "exited", + }, + }), + ]; + + expect(turnsToStageKind(eventsToActivity(events, "fmt"))).toBe("command"); + }); + + test("classifies a stage with agent events as agent", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "agent.message", + node_id: "simplify", + properties: { text: "thinking…" }, + }), + ]; + + expect(turnsToStageKind(eventsToActivity(events, "simplify"))).toBe("agent"); + }); + + test("defaults to agent for empty turns", () => { + expect(turnsToStageKind([])).toBe("agent"); + }); +}); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 887bc1d62..d9c14c9ac 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -20,11 +20,17 @@ import { Marked } from "marked"; import { StageSidebar } from "../components/stage-sidebar"; import type { Stage } from "../components/stage-sidebar"; import { EmptyState } from "../components/state"; -import { useRun, useRunStageEvents, useRunStages } from "../lib/queries"; +import { formatBytes } from "../lib/format"; +import { + useRun, + useRunStageEvents, + useRunStageLog, + useRunStages, +} from "../lib/queries"; import { STAGE_ACTIVITY_EVENT_TYPES, type StageActivityEventType } from "../lib/run-events"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { getNumber, getString, type UnknownRecord } from "../lib/unknown"; -import type { EventEnvelope } from "@qltysh/fabro-api-client"; +import type { CommandOutputStream, EventEnvelope } from "@qltysh/fabro-api-client"; export const handle = { wide: true, fullHeight: true }; @@ -32,7 +38,19 @@ type TurnType = | { kind: "system"; 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 } - | { kind: "command"; ts: string; script: string; running: boolean; exitCode: number | null; durationMs: number }; + | { + kind: "command"; + ts: string; + script: string; + running: boolean; + exitCode: number | null; + durationMs: number; + stdoutBytes: number; + stderrBytes: number; + }; + +type CommandTurn = Extract; +type StageKind = "agent" | "command"; const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); @@ -72,10 +90,10 @@ function debugCategoryTone(category: string): string { const EVENTS_TABS = ["transcript", "debug"] as const; type EventsTab = (typeof EVENTS_TABS)[number]; -const EVENTS_TAB_LABEL: Record = { - transcript: "Transcript", - debug: "Debug", -}; +function eventsTabLabel(tab: EventsTab, stageKind: StageKind): string { + if (tab === "debug") return "Debug"; + return stageKind === "command" ? "Logs" : "Transcript"; +} function assertNever(value: never): never { throw new Error(`Unhandled stage activity event type: ${value}`); @@ -174,6 +192,8 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn running: false, exitCode: getNumber(props, "exit_code") ?? null, durationMs: getNumber(props, "duration_ms") ?? 0, + stdoutBytes: getNumber(props, "stdout_bytes") ?? 0, + stderrBytes: getNumber(props, "stderr_bytes") ?? 0, }); pendingCommand = undefined; break; @@ -191,12 +211,23 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn running: true, exitCode: null, durationMs: 0, + stdoutBytes: 0, + stderrBytes: 0, }); } return turns; } +export function turnsToStageKind(turns: TurnType[]): StageKind { + let hasCommand = false; + for (const t of turns) { + if (t.kind === "assistant" || t.kind === "tool") return "agent"; + if (t.kind === "command") hasCommand = true; + } + return hasCommand ? "command" : "agent"; +} + const STAGE_MODEL_EVENT_NAMES = new Set([ "stage.prompt", "agent.session.activated", @@ -614,6 +645,157 @@ function EventDetails({ turn, runStart }: { turn: TurnType; runStart: string | u ); } +function decodeBase64Utf8(b64: string): string { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); +} + +function LogStream({ + runId, + stageId, + stream, + label, + byteCount, + enabled, + tone, +}: { + runId: string; + stageId: string; + stream: CommandOutputStream; + label: string; + byteCount: number; + enabled: boolean; + tone?: "stderr"; +}) { + const { data, error, isLoading } = useRunStageLog(runId, stageId, stream, enabled && byteCount > 0); + const text = useMemo(() => { + if (!data?.bytes_base64) return ""; + try { + return decodeBase64Utf8(data.bytes_base64); + } catch { + return ""; + } + }, [data]); + const truncated = + data && data.total_bytes > data.next_offset ? data.total_bytes - data.next_offset : 0; + + return ( +
+
+

+ {label} +

+ {byteCount > 0 && ( + + {formatBytes(byteCount)} + + )} +
+
+        {byteCount === 0 ? (
+          empty
+        ) : isLoading && !data ? (
+          loading…
+        ) : error ? (
+          Failed to load {stream}.
+        ) : (
+          text || empty
+        )}
+      
+ {truncated > 0 && ( +

+ Showing first {formatBytes(data!.next_offset)} of {formatBytes(data!.total_bytes)}. +

+ )} +
+ ); +} + +function CommandStatus({ turn }: { turn: CommandTurn }) { + const exitTone = + turn.exitCode == null + ? "text-fg-muted" + : turn.exitCode === 0 + ? "text-mint" + : "text-coral"; + return ( + + {turn.running ? ( + + + Running… + + ) : ( + + exit {turn.exitCode ?? "?"} + + )} + {turn.durationMs > 0 && ( + + {formatDurationMs(turn.durationMs)} + + )} + + ); +} + +function CommandScript({ script }: { script: string }) { + return ( +
+

+ Command +

+
+        {script || empty}
+      
+
+ ); +} + +function CommandLogs({ + runId, + stageId, + turn, +}: { + runId: string; + stageId: string; + turn: CommandTurn | null; +}) { + if (!turn) { + return ( +
No command output yet.
+ ); + } + return ( +
+ + + +
+ ); +} + function DetailsPanel({ title, isOpen, @@ -711,9 +893,11 @@ function DebugEventDetailsPanel({ function EventsTabToggle({ tab, + stageKind, onTabChange, }: { tab: EventsTab; + stageKind: StageKind; onTabChange: (tab: EventsTab) => void; }) { return ( @@ -736,7 +920,7 @@ function EventsTabToggle({ : "text-fg-muted hover:text-fg-2" }`} > - {EVENTS_TAB_LABEL[value]} + {eventsTabLabel(value, stageKind)} ); })} @@ -832,6 +1016,8 @@ function SearchInput({ function EventsToolbar({ tab, + stageKind, + commandTurn, onTabChange, selectedKinds, onKindsChange, @@ -845,6 +1031,8 @@ function EventsToolbar({ model, }: { tab: EventsTab; + stageKind: StageKind; + commandTurn: CommandTurn | null; onTabChange: (tab: EventsTab) => void; selectedKinds: EventKind[]; onKindsChange: (kinds: EventKind[]) => void; @@ -857,13 +1045,16 @@ function EventsToolbar({ totalCount: number; model: string | null; }) { + const showFilters = !(tab === "transcript" && stageKind === "command"); const transcriptAllSelected = selectedKinds.length === EVENT_KINDS.length; const debugAllSelected = selectedDebugCategories.length === 0 || selectedDebugCategories.length === availableDebugCategories.length; - const isFiltering = tab === "transcript" - ? !transcriptAllSelected || search.length > 0 - : !debugAllSelected || search.length > 0; + const isFiltering = + showFilters && + (tab === "transcript" + ? !transcriptAllSelected || search.length > 0 + : !debugAllSelected || search.length > 0); function clearFilters() { if (tab === "transcript") onKindsChange([...EVENT_KINDS]); @@ -873,35 +1064,37 @@ function EventsToolbar({ return (
- -
- {tab === "transcript" ? ( - - selected={selectedKinds} - options={EVENT_KINDS} - labelOf={(k) => EVENT_KIND_LABEL[k]} - onChange={onKindsChange} - /> - ) : ( - - selected={selectedDebugCategories} - options={availableDebugCategories} - labelOf={debugCategoryLabel} - onChange={onDebugCategoriesChange} - emptyMeansAll - /> - )} - - {isFiltering && ( - - )} -
+ + {showFilters && ( +
+ {tab === "transcript" ? ( + + selected={selectedKinds} + options={EVENT_KINDS} + labelOf={(k) => EVENT_KIND_LABEL[k]} + onChange={onKindsChange} + /> + ) : ( + + selected={selectedDebugCategories} + options={availableDebugCategories} + labelOf={debugCategoryLabel} + onChange={onDebugCategoriesChange} + emptyMeansAll + /> + )} + + {isFiltering && ( + + )} +
+ )} {isFiltering && totalCount > 0 && ( {filteredCount.toLocaleString()} of {totalCount.toLocaleString()} events @@ -909,13 +1102,16 @@ function EventsToolbar({ )} {model && ( )} + {!showFilters && commandTurn && }
); } @@ -939,6 +1135,14 @@ export default function RunStages() { : [], [stageEventsQuery.data, selectedStageId], ); + const stageKind = useMemo(() => turnsToStageKind(turns), [turns]); + const commandTurn = useMemo(() => { + for (let i = turns.length - 1; i >= 0; i -= 1) { + const t = turns[i]; + if (t.kind === "command") return t; + } + return null; + }, [turns]); const [openIndex, setOpenIndex] = useState(null); const [openDebugSeq, setOpenDebugSeq] = useState(null); @@ -1043,6 +1247,8 @@ export default function RunStages() {
{tab === "transcript" ? ( - turns.length > 0 && filteredTurns.length === 0 ? ( + stageKind === "command" ? ( + + ) : turns.length > 0 && filteredTurns.length === 0 ? (
No events match these filters.
@@ -1093,11 +1301,13 @@ export default function RunStages() {
{tab === "transcript" ? ( - setOpenIndex(null)} - /> + stageKind === "command" ? null : ( + setOpenIndex(null)} + /> + ) ) : (