From 68ddd4a3e93acf72e65cd24a90b209e098ed3083 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 12 Mar 2026 06:28:24 +0900 Subject: [PATCH 1/3] feat: add interactive log viewer TUI with delegation tree Add a full-featured log viewer for `perstack log` with: - Delegation tree builder that merges resume runs into logical nodes - Job list, run list, event list, and checkpoint screens - Drill-down navigation with keyboard shortcuts - Bottom panel component for info display - flattenTreeAll utility for unpruned tree traversal - Comprehensive tests for tree building including failed delegates Co-Authored-By: Claude Opus 4.6 --- .changeset/log-viewer-tui.md | 5 + .../src/components/bottom-panel.tsx | 55 ++ .../execution/hooks/use-delegation-tree.ts | 25 + .../tui-components/src/log-viewer/app.tsx | 320 ++++++++ .../src/log-viewer/build-run-tree.test.ts | 710 ++++++++++++++++++ .../src/log-viewer/build-run-tree.ts | 303 ++++++++ .../components/log-info-content.tsx | 260 +++++++ .../tui-components/src/log-viewer/index.ts | 2 + .../tui-components/src/log-viewer/render.tsx | 14 + .../log-viewer/screens/checkpoint-detail.tsx | 129 ++++ .../log-viewer/screens/checkpoint-list.tsx | 147 ++++ .../src/log-viewer/screens/event-detail.tsx | 191 +++++ .../src/log-viewer/screens/event-list.tsx | 170 +++++ .../src/log-viewer/screens/job-detail.tsx | 89 +++ .../src/log-viewer/screens/job-list.tsx | 137 ++++ .../src/log-viewer/screens/run-list.tsx | 116 +++ .../tui-components/src/log-viewer/types.ts | 36 + 17 files changed, 2709 insertions(+) create mode 100644 .changeset/log-viewer-tui.md create mode 100644 packages/tui-components/src/components/bottom-panel.tsx create mode 100644 packages/tui-components/src/log-viewer/app.tsx create mode 100644 packages/tui-components/src/log-viewer/build-run-tree.test.ts create mode 100644 packages/tui-components/src/log-viewer/build-run-tree.ts create mode 100644 packages/tui-components/src/log-viewer/components/log-info-content.tsx create mode 100644 packages/tui-components/src/log-viewer/index.ts create mode 100644 packages/tui-components/src/log-viewer/render.tsx create mode 100644 packages/tui-components/src/log-viewer/screens/checkpoint-detail.tsx create mode 100644 packages/tui-components/src/log-viewer/screens/checkpoint-list.tsx create mode 100644 packages/tui-components/src/log-viewer/screens/event-detail.tsx create mode 100644 packages/tui-components/src/log-viewer/screens/event-list.tsx create mode 100644 packages/tui-components/src/log-viewer/screens/job-detail.tsx create mode 100644 packages/tui-components/src/log-viewer/screens/job-list.tsx create mode 100644 packages/tui-components/src/log-viewer/screens/run-list.tsx create mode 100644 packages/tui-components/src/log-viewer/types.ts diff --git a/.changeset/log-viewer-tui.md b/.changeset/log-viewer-tui.md new file mode 100644 index 00000000..0a249175 --- /dev/null +++ b/.changeset/log-viewer-tui.md @@ -0,0 +1,5 @@ +--- +"@perstack/tui-components": patch +--- + +Add interactive log viewer TUI with delegation tree, run list, event/checkpoint drill-down screens diff --git a/packages/tui-components/src/components/bottom-panel.tsx b/packages/tui-components/src/components/bottom-panel.tsx new file mode 100644 index 00000000..e152248c --- /dev/null +++ b/packages/tui-components/src/components/bottom-panel.tsx @@ -0,0 +1,55 @@ +import { Box, Text, useInput } from "ink" +import type React from "react" +import { colors } from "../colors.js" +import { useTextInput } from "../hooks/use-text-input.js" + +type BottomPanelProps = { + children: React.ReactNode + onSubmit: (query: string) => void + canSubmit?: boolean + inputPlaceholder?: string +} + +export const BottomPanel = ({ + children, + onSubmit, + canSubmit = true, + inputPlaceholder, +}: BottomPanelProps) => { + const { input, handleInput } = useTextInput({ + onSubmit, + canSubmit, + }) + + useInput(handleInput) + + return ( + + {children} + + > + {input ? ( + <> + {input} + _ + + ) : inputPlaceholder ? ( + + {inputPlaceholder} + _ + + ) : ( + _ + )} + + + ) +} diff --git a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts index 5ff45038..5cfece8a 100644 --- a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts +++ b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts @@ -130,6 +130,31 @@ export function getStatusCounts(state: DelegationTreeState): { return { running, waiting } } +/** + * Flatten the entire tree without pruning — includes all nodes regardless of status. + * Useful for testing and debugging where the full tree structure matters. + */ +export function flattenTreeAll(state: DelegationTreeState): FlatTreeNode[] { + if (!state.rootRunId) return [] + const root = state.nodes.get(state.rootRunId) + if (!root) return [] + + const result: FlatTreeNode[] = [] + + function dfs(nodeId: string, depth: number, isLast: boolean, ancestorIsLast: boolean[]) { + const node = state.nodes.get(nodeId) + if (!node) return + result.push({ node, depth, isLast, ancestorIsLast: [...ancestorIsLast] }) + for (let i = 0; i < node.childRunIds.length; i++) { + const childIsLast = i === node.childRunIds.length - 1 + dfs(node.childRunIds[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast]) + } + } + + dfs(state.rootRunId, 0, true, []) + return result +} + export function flattenTree(state: DelegationTreeState): FlatTreeNode[] { if (!state.rootRunId) return [] const root = state.nodes.get(state.rootRunId) diff --git a/packages/tui-components/src/log-viewer/app.tsx b/packages/tui-components/src/log-viewer/app.tsx new file mode 100644 index 00000000..9c6fcba8 --- /dev/null +++ b/packages/tui-components/src/log-viewer/app.tsx @@ -0,0 +1,320 @@ +import type { Checkpoint, Job, RunEvent } from "@perstack/core" +import type { LogDataFetcher } from "@perstack/log" +import { Box, Text, useApp, useInput } from "ink" +import { useCallback, useEffect, useRef, useState } from "react" +import { colors } from "../colors.js" +import { BottomPanel } from "../components/bottom-panel.js" +import type { DelegationTreeNode } from "../execution/hooks/use-delegation-tree.js" +import { buildRunTreeFromEvents, extractQueryFromStartRun } from "./build-run-tree.js" +import { + CheckpointInfoContent, + EventInfoContent, + JobInfoContent, + RunInfoContent, +} from "./components/log-info-content.js" +import { CheckpointDetailScreen } from "./screens/checkpoint-detail.js" +import { CheckpointListScreen } from "./screens/checkpoint-list.js" +import { EventDetailScreen } from "./screens/event-detail.js" +import { EventListScreen } from "./screens/event-list.js" +import { JobDetailScreen } from "./screens/job-detail.js" +import { JobListScreen } from "./screens/job-list.js" +import { RunListScreen } from "./screens/run-list.js" +import type { JobListItem, LogViewerScreen, RunInfo } from "./types.js" + +type LogViewerAppProps = { + fetcher: LogDataFetcher + initialJobId?: string + initialRunId?: string +} + +async function extractJobQuery(fetcher: LogDataFetcher, job: Job): Promise { + try { + const runs = await fetcher.getRuns(job.id) + if (runs.length === 0) return undefined + const firstRun = runs[0] + const events = await fetcher.getEvents(job.id, firstRun.runId) + const startRunEvent = events.find((e) => e.type === "startRun") + if (!startRunEvent) return undefined + return extractQueryFromStartRun(startRunEvent) + } catch { + return undefined + } +} + +async function buildRunTree(fetcher: LogDataFetcher, jobId: string) { + const allEvents = await fetcher.getAllEventsForJob(jobId) + return buildRunTreeFromEvents(allEvents) +} + +export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerAppProps) => { + const { exit } = useApp() + const [screen, setScreen] = useState({ type: "jobList" }) + const [jobItems, setJobItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState() + + // Selected items from list screens + const [selectedJob, setSelectedJob] = useState() + const [selectedRun, setSelectedRun] = useState() + const [selectedTreeNode, setSelectedTreeNode] = useState() + const [selectedCheckpoint, setSelectedCheckpoint] = useState() + const [selectedEvent, setSelectedEvent] = useState() + + // Global quit handler + useInput((char) => { + if (char === "q") { + exit() + } + }) + + // Load initial data + useEffect(() => { + const loadInitial = async () => { + try { + setLoading(true) + + if (initialJobId && initialRunId) { + const job = await fetcher.getJob(initialJobId) + if (!job) { + setError(`Job ${initialJobId} not found`) + setLoading(false) + return + } + const checkpoints = await fetcher.getCheckpoints(initialJobId) + const runCheckpoints = checkpoints.filter((cp) => cp.runId === initialRunId) + const run: RunInfo = { jobId: initialJobId, runId: initialRunId } + setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints }) + setLoading(false) + return + } + + if (initialJobId) { + const job = await fetcher.getJob(initialJobId) + if (!job) { + setError(`Job ${initialJobId} not found`) + setLoading(false) + return + } + const { treeState, runQueries, runStats } = await buildRunTree(fetcher, initialJobId) + setScreen({ type: "runList", job, treeState, runQueries, runStats }) + setLoading(false) + return + } + + const latest = await fetcher.getLatestJob() + if (latest) { + const query = await extractJobQuery(fetcher, latest) + setJobItems([{ job: latest, query }]) + } else { + setJobItems([]) + } + setLoading(false) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } + loadInitial() + }, [fetcher, initialJobId, initialRunId]) + + const handleChatSubmit = useCallback((_query: string) => { + // TODO: AI chat integration + }, []) + + // Navigation handlers + const navigateToJobDetail = useCallback((job: Job) => { + setScreen({ type: "jobDetail", job }) + }, []) + + const navigateToRunList = useCallback( + async (job: Job) => { + setLoading(true) + try { + const { treeState, runQueries, runStats } = await buildRunTree(fetcher, job.id) + setScreen({ type: "runList", job, treeState, runQueries, runStats }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + setLoading(false) + }, + [fetcher], + ) + + const navigateToCheckpointList = useCallback( + async (job: Job, run: RunInfo) => { + setLoading(true) + try { + const checkpoints = await fetcher.getCheckpoints(job.id) + const runCheckpoints = checkpoints.filter((cp) => cp.runId === run.runId) + setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + setLoading(false) + }, + [fetcher], + ) + + const navigateToCheckpointDetail = useCallback( + (job: Job, run: RunInfo, checkpoint: Checkpoint) => { + setScreen({ type: "checkpointDetail", job, run, checkpoint }) + }, + [], + ) + + const navigateToEventList = useCallback( + async (job: Job, run: RunInfo) => { + setLoading(true) + try { + const events = await fetcher.getEvents(job.id, run.runId) + setScreen({ type: "eventList", job, run, events }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + setLoading(false) + }, + [fetcher], + ) + + const navigateToEventDetail = useCallback((job: Job, run: RunInfo, event: RunEvent) => { + setScreen({ type: "eventDetail", job, run, event }) + }, []) + + // Stable callback for run tree selection — uses ref to avoid re-render loop + const screenRef = useRef(screen) + screenRef.current = screen + const handleRunSelectedChange = useCallback((node: DelegationTreeNode | undefined) => { + const currentScreen = screenRef.current + if (currentScreen.type !== "runList") return + setSelectedTreeNode(node) + setSelectedRun( + node + ? { jobId: currentScreen.job.id, runId: node.runId, expertKey: node.expertKey } + : undefined, + ) + }, []) + + // Build info content based on current screen and selection + const renderInfoContent = () => { + switch (screen.type) { + case "jobList": + return selectedJob ? : null + case "jobDetail": + return + case "runList": { + if (!selectedTreeNode) return null + const stats = screen.runStats.get(selectedTreeNode.runId) + return ( + + ) + } + case "checkpointList": + return selectedCheckpoint ? : null + case "checkpointDetail": + return + case "eventList": + return selectedEvent ? : null + case "eventDetail": + return + default: + return null + } + } + + if (error) { + return ( + + Error: {error} + Press q to quit + + ) + } + + if (loading) { + return Loading... + } + + return ( + + + {screen.type === "jobList" && ( + navigateToRunList(job)} + onViewJobDetail={navigateToJobDetail} + onSelectedChange={setSelectedJob} + /> + )} + {screen.type === "jobDetail" && ( + setScreen({ type: "jobList" })} + onViewRuns={navigateToRunList} + /> + )} + {screen.type === "runList" && ( + { + const run: RunInfo = { + jobId: screen.job.id, + runId: node.runId, + expertKey: node.expertKey, + } + navigateToCheckpointList(screen.job, run) + }} + onBack={() => setScreen({ type: "jobList" })} + onSelectedChange={handleRunSelectedChange} + /> + )} + {screen.type === "checkpointList" && ( + navigateToCheckpointDetail(screen.job, screen.run, cp)} + onBack={() => navigateToRunList(screen.job)} + onSelectedChange={setSelectedCheckpoint} + /> + )} + {screen.type === "checkpointDetail" && ( + navigateToCheckpointList(screen.job, screen.run)} + /> + )} + {screen.type === "eventList" && ( + navigateToEventDetail(screen.job, screen.run, ev)} + onBack={() => navigateToRunList(screen.job)} + onSelectedChange={setSelectedEvent} + /> + )} + {screen.type === "eventDetail" && ( + navigateToEventList(screen.job, screen.run)} + /> + )} + + + {renderInfoContent() ?? Select an item to view details} + + + ) +} diff --git a/packages/tui-components/src/log-viewer/build-run-tree.test.ts b/packages/tui-components/src/log-viewer/build-run-tree.test.ts new file mode 100644 index 00000000..ece465de --- /dev/null +++ b/packages/tui-components/src/log-viewer/build-run-tree.test.ts @@ -0,0 +1,710 @@ +import { describe, expect, it } from "bun:test" +import type { Checkpoint, RunEvent, Usage } from "@perstack/core" +import { flattenTreeAll } from "../execution/hooks/use-delegation-tree.js" +import { buildRunTreeFromEvents } from "./build-run-tree.js" + +// --- Helpers --- + +const baseUsage: Usage = { + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 0, + totalTokens: 150, + cachedInputTokens: 0, +} + +let eventCounter = 0 +let timestamp = 1000000 + +function makeCheckpoint(overrides: Partial = {}): Checkpoint { + return { + id: `cp-${eventCounter}`, + jobId: "job-1", + runId: "run-1", + status: "proceeding", + stepNumber: 1, + messages: [], + expert: { key: "test", name: "test", version: "1.0.0" }, + usage: { ...baseUsage }, + ...overrides, + } +} + +function makeEvent( + type: string, + runId: string, + expertKey: string, + payload: Record, +): RunEvent { + eventCounter++ + timestamp += 100 + return { + id: `evt-${eventCounter}`, + type, + expertKey, + timestamp, + jobId: "job-1", + runId, + stepNumber: 1, + ...payload, + } as RunEvent +} + +function startRun( + runId: string, + expertKey: string, + parentRunId?: string, + model = "test-model", +): RunEvent { + return makeEvent("startRun", runId, expertKey, { + initialCheckpoint: makeCheckpoint({ + runId, + delegatedBy: parentRunId + ? { + expert: { key: "parent", name: "parent", version: "1.0.0" }, + toolCallId: "tc-1", + toolName: "delegate", + checkpointId: "cp-parent", + runId: parentRunId, + } + : undefined, + }), + inputMessages: [], + model, + }) +} + +function stopByDelegate( + runId: string, + expertKey: string, + delegateTo?: Array<{ key: string; query?: string }>, +): RunEvent { + return makeEvent("stopRunByDelegate", runId, expertKey, { + checkpoint: makeCheckpoint({ + runId, + status: "stoppedByDelegate", + delegateTo: delegateTo?.map((d, i) => ({ + expert: { key: d.key, name: d.key, version: "1.0.0" }, + toolCallId: `tc-${i}`, + toolName: d.key.split("/").pop() ?? d.key, + query: d.query ?? "", + })), + }), + step: {}, + }) +} + +function completeRun(runId: string, expertKey: string): RunEvent { + return makeEvent("completeRun", runId, expertKey, { + checkpoint: makeCheckpoint({ runId, status: "completed" }), + step: {}, + text: "done", + usage: baseUsage, + }) +} + +function errorRun(runId: string, expertKey: string, parentRunId: string): RunEvent { + return makeEvent("stopRunByError", runId, expertKey, { + checkpoint: makeCheckpoint({ + runId, + status: "stoppedByError", + error: { name: "Error", message: "Executable not found", isRetryable: false }, + }), + step: {}, + error: { name: "Error", message: "Executable not found", isRetryable: false }, + }) +} + +function resumeFromStop(newRunId: string, expertKey: string, model = "test-model"): RunEvent { + // In real data, checkpoint.runId === event.runId (always the new runId) + return makeEvent("resumeFromStop", newRunId, expertKey, { + checkpoint: makeCheckpoint({ runId: newRunId, status: "proceeding" }), + model, + }) +} + +// --- Tests --- + +describe("buildRunTreeFromEvents", () => { + it("builds a simple single-run tree", () => { + const events = [startRun("root", "coordinator"), completeRun("root", "coordinator")] + + const { treeState } = buildRunTreeFromEvents(events) + expect(treeState.rootRunId).toBe("root") + expect(treeState.nodes.size).toBe(1) + expect(treeState.nodes.get("root")?.status).toBe("completed") + }) + + it("builds parent-child with delegation", () => { + const events = [ + startRun("parent", "coordinator"), + stopByDelegate("parent", "coordinator"), + startRun("child-1", "worker-a", "parent"), + startRun("child-2", "worker-b", "parent"), + completeRun("child-1", "worker-a"), + completeRun("child-2", "worker-b"), + resumeFromStop("parent-resume", "coordinator"), + completeRun("parent-resume", "coordinator"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + // Resume should be merged into original parent + expect(treeState.nodes.size).toBe(3) // parent, child-1, child-2 + expect(treeState.rootRunId).toBe("parent") + + const parent = treeState.nodes.get("parent")! + expect(parent.status).toBe("completed") + expect(parent.childRunIds).toEqual(["child-1", "child-2"]) + + // Children should have correct parent + expect(treeState.nodes.get("child-1")?.parentRunId).toBe("parent") + expect(treeState.nodes.get("child-2")?.parentRunId).toBe("parent") + + // Resume runId should not create a separate node + expect(treeState.nodes.has("parent-resume")).toBe(false) + }) + + it("handles multi-level delegation (grandchildren)", () => { + const events = [ + // Root starts and delegates to child + startRun("root", "coordinator"), + stopByDelegate("root", "coordinator"), + // Child starts and delegates to grandchild + startRun("child", "worker", "root"), + stopByDelegate("child", "worker"), + // Grandchild runs and completes + startRun("grandchild", "sub-worker", "child"), + completeRun("grandchild", "sub-worker"), + // Child resumes and completes + resumeFromStop("child-resume", "worker"), + completeRun("child-resume", "worker"), + // Root resumes and completes + resumeFromStop("root-resume", "coordinator"), + completeRun("root-resume", "coordinator"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + expect(treeState.nodes.size).toBe(3) // root, child, grandchild + expect(treeState.rootRunId).toBe("root") + + const root = treeState.nodes.get("root")! + expect(root.childRunIds).toEqual(["child"]) + expect(root.status).toBe("completed") + + const child = treeState.nodes.get("child")! + expect(child.parentRunId).toBe("root") + expect(child.childRunIds).toEqual(["grandchild"]) + expect(child.status).toBe("completed") + + const grandchild = treeState.nodes.get("grandchild")! + expect(grandchild.parentRunId).toBe("child") + expect(grandchild.status).toBe("completed") + }) + + it("handles multiple delegation rounds from the same parent", () => { + // coordinator delegates to plan, resumes, delegates to build, resumes + const events = [ + startRun("root", "coordinator"), + stopByDelegate("root", "coordinator"), + startRun("plan", "@coordinator/plan", "root"), + completeRun("plan", "@coordinator/plan"), + resumeFromStop("root-r1", "coordinator"), + stopByDelegate("root-r1", "coordinator"), + startRun("build", "@coordinator/build", "root-r1"), + completeRun("build", "@coordinator/build"), + resumeFromStop("root-r2", "coordinator"), + completeRun("root-r2", "coordinator"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + // root-r1 and root-r2 merged into root + expect(treeState.nodes.size).toBe(3) // root, plan, build + const root = treeState.nodes.get("root")! + expect(root.childRunIds).toEqual(["plan", "build"]) + expect(root.status).toBe("completed") + + // build's parent should resolve to root (since root-r1 is alias of root) + expect(treeState.nodes.get("build")?.parentRunId).toBe("root") + }) + + it("handles parallel delegates with the same expertKey", () => { + // build delegates to 3 test-expert instances in parallel + const events = [ + startRun("build", "@coordinator/build"), + stopByDelegate("build", "@coordinator/build"), + startRun("test-1", "@coordinator/test-expert", "build"), + startRun("test-2", "@coordinator/test-expert", "build"), + startRun("test-3", "@coordinator/test-expert", "build"), + // test-3 completes first (no delegation) + completeRun("test-3", "@coordinator/test-expert"), + // test-1 delegates then resumes + stopByDelegate("test-1", "@coordinator/test-expert"), + startRun("game-1", "bash-gaming", "test-1"), + completeRun("game-1", "bash-gaming"), + resumeFromStop("test-1-resume", "@coordinator/test-expert"), + completeRun("test-1-resume", "@coordinator/test-expert"), + // test-2 delegates then resumes + stopByDelegate("test-2", "@coordinator/test-expert"), + startRun("game-2", "bash-gaming", "test-2"), + completeRun("game-2", "bash-gaming"), + resumeFromStop("test-2-resume", "@coordinator/test-expert"), + completeRun("test-2-resume", "@coordinator/test-expert"), + // build resumes + resumeFromStop("build-resume", "@coordinator/build"), + completeRun("build-resume", "@coordinator/build"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + // 6 logical nodes: build, test-1, test-2, test-3, game-1, game-2 + expect(treeState.nodes.size).toBe(6) + + const build = treeState.nodes.get("build")! + expect(build.childRunIds).toEqual(["test-1", "test-2", "test-3"]) + expect(build.status).toBe("completed") + + // test-1-resume merged into test-1 + expect(treeState.nodes.has("test-1-resume")).toBe(false) + const test1 = treeState.nodes.get("test-1")! + expect(test1.childRunIds).toEqual(["game-1"]) + expect(test1.status).toBe("completed") + + // test-2-resume merged into test-2 + expect(treeState.nodes.has("test-2-resume")).toBe(false) + const test2 = treeState.nodes.get("test-2")! + expect(test2.childRunIds).toEqual(["game-2"]) + expect(test2.status).toBe("completed") + }) + + it("produces correct flat tree ordering", () => { + const events = [ + startRun("root", "coordinator"), + stopByDelegate("root", "coordinator"), + startRun("child-a", "worker-a", "root"), + startRun("child-b", "worker-b", "root"), + completeRun("child-a", "worker-a"), + completeRun("child-b", "worker-b"), + resumeFromStop("root-resume", "coordinator"), + completeRun("root-resume", "coordinator"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + const flat = flattenTreeAll(treeState) + + expect(flat.map((f) => f.node.runId)).toEqual(["root", "child-a", "child-b"]) + expect(flat[0].depth).toBe(0) + expect(flat[1].depth).toBe(1) + expect(flat[2].depth).toBe(1) + }) + + it("accumulates tokens across resume segments", () => { + const events = [ + startRun("root", "coordinator"), + makeEvent("callTools", "root", "coordinator", { + newMessage: {}, + toolCalls: [], + usage: { ...baseUsage, inputTokens: 100, outputTokens: 50, totalTokens: 150 }, + }), + stopByDelegate("root", "coordinator"), + startRun("child", "worker", "root"), + completeRun("child", "worker"), + resumeFromStop("root-resume", "coordinator"), + makeEvent("callTools", "root-resume", "coordinator", { + newMessage: {}, + toolCalls: [], + usage: { ...baseUsage, inputTokens: 200, outputTokens: 100, totalTokens: 300 }, + }), + completeRun("root-resume", "coordinator"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + const root = treeState.nodes.get("root")! + + // Tokens from both segments should be accumulated + // callTools(100) + completeRun(100) + callTools(200) + completeRun(100) = 400 input + expect(root.inputTokens).toBe(400) + expect(root.outputTokens).toBe(200) + expect(root.totalTokens).toBe(600) + }) + + it("aggregates runStats across resume segments", () => { + const events = [ + startRun("root", "coordinator"), + stopByDelegate("root", "coordinator"), + startRun("child", "worker", "root"), + completeRun("child", "worker"), + resumeFromStop("root-resume", "coordinator"), + completeRun("root-resume", "coordinator"), + ] + + const { runStats } = buildRunTreeFromEvents(events) + + // root-resume stats should be merged into root + expect(runStats.has("root-resume")).toBe(false) + const rootStats = runStats.get("root")! + expect(rootStats.eventCount).toBeGreaterThan(0) + }) + + it("extracts query from startRun input messages", () => { + const events = [ + makeEvent("startRun", "root", "coordinator", { + initialCheckpoint: makeCheckpoint({ runId: "root" }), + inputMessages: [ + { + type: "userMessage", + contents: [{ text: "Build a snake\ngame" }], + }, + ], + model: "test-model", + }), + completeRun("root", "coordinator"), + ] + + const { runQueries } = buildRunTreeFromEvents(events) + expect(runQueries.get("root")).toBe("Build a snake game") // newlines replaced + }) + + it("handles the full bash-gaming-like scenario", () => { + // Simulates the real data structure: + // create-expert (4 segments: root -> r1 -> r2 -> r3) + // ├── plan (completes) + // ├── design-roles (2 segments: dr -> dr-r1) + // │ └── (inline find-skill, no separate runs) + // └── build (2 segments: build -> build-r1) + // ├── test-expert-1 (2 segments: te1 -> te1-r1) + // │ └── bash-gaming-1 (3 segments: bg1 -> bg1-r1 -> bg1-r2) + // │ └── game-designer-1 (completes) + // ├── test-expert-2 (2 segments: te2 -> te2-r1) + // │ └── bash-gaming-2 (3 segments: bg2 -> bg2-r1 -> bg2-r2) + // │ └── game-designer-2 (completes) + // └── test-expert-3 (completes without delegation) + const events = [ + // Root create-expert starts + startRun("root", "create-expert"), + stopByDelegate("root", "create-expert"), + + // plan child + startRun("plan", "@create-expert/plan", "root"), + completeRun("plan", "@create-expert/plan"), + + // Root resumes (r1), delegates to design-roles + resumeFromStop("root-r1", "create-expert"), + stopByDelegate("root-r1", "create-expert"), + + // design-roles starts, delegates to find-skill (inline, no separate runs), resumes + startRun("dr", "@create-expert/design-roles", "root-r1"), + stopByDelegate("dr", "@create-expert/design-roles"), + // (inline find-skill runs have no events in event store) + resumeFromStop("dr-r1", "@create-expert/design-roles"), + completeRun("dr-r1", "@create-expert/design-roles"), + + // Root resumes (r2), delegates to build + resumeFromStop("root-r2", "create-expert"), + stopByDelegate("root-r2", "create-expert"), + + // build starts, delegates to 3 test-experts + startRun("build", "@create-expert/build", "root-r2"), + stopByDelegate("build", "@create-expert/build"), + + // 3 parallel test-experts + startRun("te1", "@create-expert/test-expert", "build"), + startRun("te2", "@create-expert/test-expert", "build"), + startRun("te3", "@create-expert/test-expert", "build"), + + // te1 delegates to bash-gaming-1 + stopByDelegate("te1", "@create-expert/test-expert"), + startRun("bg1", "bash-gaming", "te1"), + // bg1 delegates to game-designer + stopByDelegate("bg1", "bash-gaming"), + startRun("gd1", "@bash-gaming/game-designer", "bg1"), + completeRun("gd1", "@bash-gaming/game-designer"), + // bg1 resumes, delegates again (inline), resumes again + resumeFromStop("bg1-r1", "bash-gaming"), + stopByDelegate("bg1-r1", "bash-gaming"), + resumeFromStop("bg1-r2", "bash-gaming"), + completeRun("bg1-r2", "bash-gaming"), + // te1 resumes + resumeFromStop("te1-r1", "@create-expert/test-expert"), + completeRun("te1-r1", "@create-expert/test-expert"), + + // te2 delegates to bash-gaming-2 + stopByDelegate("te2", "@create-expert/test-expert"), + startRun("bg2", "bash-gaming", "te2"), + stopByDelegate("bg2", "bash-gaming"), + startRun("gd2", "@bash-gaming/game-designer", "bg2"), + completeRun("gd2", "@bash-gaming/game-designer"), + resumeFromStop("bg2-r1", "bash-gaming"), + stopByDelegate("bg2-r1", "bash-gaming"), + resumeFromStop("bg2-r2", "bash-gaming"), + completeRun("bg2-r2", "bash-gaming"), + resumeFromStop("te2-r1", "@create-expert/test-expert"), + completeRun("te2-r1", "@create-expert/test-expert"), + + // te3 completes without delegation + completeRun("te3", "@create-expert/test-expert"), + + // build resumes + resumeFromStop("build-r1", "@create-expert/build"), + completeRun("build-r1", "@create-expert/build"), + + // Root resumes (r3) and completes + resumeFromStop("root-r3", "create-expert"), + completeRun("root-r3", "create-expert"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + // Expected logical nodes: 12 + // root, plan, dr, build, te1, te2, te3, bg1, bg2, gd1, gd2 + // (root-r1/r2/r3, dr-r1, build-r1, te1-r1, te2-r1, bg1-r1/r2, bg2-r1/r2 all merged) + expect(treeState.nodes.size).toBe(11) + expect(treeState.rootRunId).toBe("root") + + // Verify tree structure + const root = treeState.nodes.get("root")! + expect(root.status).toBe("completed") + expect(root.childRunIds.sort()).toEqual(["build", "dr", "plan"]) + + const dr = treeState.nodes.get("dr")! + expect(dr.status).toBe("completed") + expect(dr.parentRunId).toBe("root") + + const build = treeState.nodes.get("build")! + expect(build.status).toBe("completed") + expect(build.parentRunId).toBe("root") + expect(build.childRunIds.sort()).toEqual(["te1", "te2", "te3"]) + + const te1 = treeState.nodes.get("te1")! + expect(te1.status).toBe("completed") + expect(te1.childRunIds).toEqual(["bg1"]) + + const bg1 = treeState.nodes.get("bg1")! + expect(bg1.status).toBe("completed") + expect(bg1.childRunIds).toEqual(["gd1"]) + + const te3 = treeState.nodes.get("te3")! + expect(te3.status).toBe("completed") + expect(te3.childRunIds).toEqual([]) + + // Verify no resume runIds leaked as separate nodes + for (const key of treeState.nodes.keys()) { + expect(key).not.toContain("-r") + } + + // Verify flat tree has correct nesting + const flat = flattenTreeAll(treeState) + expect(flat.length).toBe(11) + const rootFlat = flat.find((f) => f.node.runId === "root")! + expect(rootFlat.depth).toBe(0) + const gd1Flat = flat.find((f) => f.node.runId === "gd1")! + expect(gd1Flat.depth).toBe(4) // root > build > te1 > bg1 > gd1 + }) + + it("shows failed delegates as error nodes (startRun + stopRunByError)", () => { + // With the runtime fix, failed delegates now emit startRun + stopRunByError events. + // design-roles delegates to 4x find-skill, all fail immediately. + const events = [ + startRun("root", "coordinator"), + stopByDelegate("root", "coordinator"), + startRun("dr", "@coordinator/design-roles", "root"), + stopByDelegate("dr", "@coordinator/design-roles"), + // Failed delegates: startRun + stopRunByError pairs + startRun("fs1", "@coordinator/find-skill", "dr"), + errorRun("fs1", "@coordinator/find-skill", "dr"), + startRun("fs2", "@coordinator/find-skill", "dr"), + errorRun("fs2", "@coordinator/find-skill", "dr"), + startRun("fs3", "@coordinator/find-skill", "dr"), + errorRun("fs3", "@coordinator/find-skill", "dr"), + startRun("fs4", "@coordinator/find-skill", "dr"), + errorRun("fs4", "@coordinator/find-skill", "dr"), + // design-roles resumes + resumeFromStop("dr-r1", "@coordinator/design-roles"), + completeRun("dr-r1", "@coordinator/design-roles"), + resumeFromStop("root-r1", "coordinator"), + completeRun("root-r1", "coordinator"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + const dr = treeState.nodes.get("dr")! + expect(dr.status).toBe("completed") + expect(dr.childRunIds.length).toBe(4) + + for (const childId of dr.childRunIds) { + const child = treeState.nodes.get(childId)! + expect(child.expertKey).toBe("@coordinator/find-skill") + expect(child.status).toBe("error") + expect(child.parentRunId).toBe("dr") + } + }) + + it("shows failed parallel delegates alongside successful ones", () => { + // bash-gaming delegates to game-designer (success) then logic/tui/cli (all fail) + const events = [ + startRun("bg", "bash-gaming"), + stopByDelegate("bg", "bash-gaming"), + startRun("gd", "@bash-gaming/game-designer", "bg"), + completeRun("gd", "@bash-gaming/game-designer"), + resumeFromStop("bg-r1", "bash-gaming"), + // Second delegation: 3 delegates that fail + stopByDelegate("bg-r1", "bash-gaming"), + startRun("le", "@bash-gaming/logic-engineer", "bg-r1"), + errorRun("le", "@bash-gaming/logic-engineer", "bg-r1"), + startRun("te", "@bash-gaming/tui-engineer", "bg-r1"), + errorRun("te", "@bash-gaming/tui-engineer", "bg-r1"), + startRun("ce", "@bash-gaming/cli-engineer", "bg-r1"), + errorRun("ce", "@bash-gaming/cli-engineer", "bg-r1"), + resumeFromStop("bg-r2", "bash-gaming"), + completeRun("bg-r2", "bash-gaming"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + const bg = treeState.nodes.get("bg")! + expect(bg.status).toBe("completed") + // game-designer + 3 failed delegates = 4 children + expect(bg.childRunIds.length).toBe(4) + + expect(treeState.nodes.get("gd")?.status).toBe("completed") + + const errorChildren = bg.childRunIds + .map((id) => treeState.nodes.get(id)!) + .filter((n) => n.status === "error") + expect(errorChildren.length).toBe(3) + const errorKeys = errorChildren.map((n) => n.expertKey).sort() + expect(errorKeys).toEqual([ + "@bash-gaming/cli-engineer", + "@bash-gaming/logic-engineer", + "@bash-gaming/tui-engineer", + ]) + }) + + it("handles full bash-gaming scenario with failed delegates", () => { + // Full realistic scenario with startRun + stopRunByError for failed delegates + const events = [ + startRun("root", "create-expert"), + stopByDelegate("root", "create-expert"), + + startRun("plan", "@create-expert/plan", "root"), + completeRun("plan", "@create-expert/plan"), + + resumeFromStop("root-r1", "create-expert"), + stopByDelegate("root-r1", "create-expert"), + + startRun("dr", "@create-expert/design-roles", "root-r1"), + stopByDelegate("dr", "@create-expert/design-roles"), + // 4 failed find-skill delegates + startRun("fs1", "@create-expert/find-skill", "dr"), + errorRun("fs1", "@create-expert/find-skill", "dr"), + startRun("fs2", "@create-expert/find-skill", "dr"), + errorRun("fs2", "@create-expert/find-skill", "dr"), + startRun("fs3", "@create-expert/find-skill", "dr"), + errorRun("fs3", "@create-expert/find-skill", "dr"), + startRun("fs4", "@create-expert/find-skill", "dr"), + errorRun("fs4", "@create-expert/find-skill", "dr"), + resumeFromStop("dr-r1", "@create-expert/design-roles"), + completeRun("dr-r1", "@create-expert/design-roles"), + + resumeFromStop("root-r2", "create-expert"), + stopByDelegate("root-r2", "create-expert"), + + startRun("build", "@create-expert/build", "root-r2"), + stopByDelegate("build", "@create-expert/build"), + + startRun("te1", "@create-expert/test-expert", "build"), + startRun("te2", "@create-expert/test-expert", "build"), + startRun("te3", "@create-expert/test-expert", "build"), + + // te1 -> bash-gaming(snake) -> game-designer + 3 failed + stopByDelegate("te1", "@create-expert/test-expert"), + startRun("bg1", "bash-gaming", "te1"), + stopByDelegate("bg1", "bash-gaming"), + startRun("gd1", "@bash-gaming/game-designer", "bg1"), + completeRun("gd1", "@bash-gaming/game-designer"), + resumeFromStop("bg1-r1", "bash-gaming"), + stopByDelegate("bg1-r1", "bash-gaming"), + startRun("bg1-le", "@bash-gaming/logic-engineer", "bg1-r1"), + errorRun("bg1-le", "@bash-gaming/logic-engineer", "bg1-r1"), + startRun("bg1-te", "@bash-gaming/tui-engineer", "bg1-r1"), + errorRun("bg1-te", "@bash-gaming/tui-engineer", "bg1-r1"), + startRun("bg1-ce", "@bash-gaming/cli-engineer", "bg1-r1"), + errorRun("bg1-ce", "@bash-gaming/cli-engineer", "bg1-r1"), + resumeFromStop("bg1-r2", "bash-gaming"), + completeRun("bg1-r2", "bash-gaming"), + resumeFromStop("te1-r1", "@create-expert/test-expert"), + completeRun("te1-r1", "@create-expert/test-expert"), + + // te2 -> bash-gaming(roguelike) -> game-designer + 3 failed + stopByDelegate("te2", "@create-expert/test-expert"), + startRun("bg2", "bash-gaming", "te2"), + stopByDelegate("bg2", "bash-gaming"), + startRun("gd2", "@bash-gaming/game-designer", "bg2"), + completeRun("gd2", "@bash-gaming/game-designer"), + resumeFromStop("bg2-r1", "bash-gaming"), + stopByDelegate("bg2-r1", "bash-gaming"), + startRun("bg2-le", "@bash-gaming/logic-engineer", "bg2-r1"), + errorRun("bg2-le", "@bash-gaming/logic-engineer", "bg2-r1"), + startRun("bg2-te", "@bash-gaming/tui-engineer", "bg2-r1"), + errorRun("bg2-te", "@bash-gaming/tui-engineer", "bg2-r1"), + startRun("bg2-ce", "@bash-gaming/cli-engineer", "bg2-r1"), + errorRun("bg2-ce", "@bash-gaming/cli-engineer", "bg2-r1"), + resumeFromStop("bg2-r2", "bash-gaming"), + completeRun("bg2-r2", "bash-gaming"), + resumeFromStop("te2-r1", "@create-expert/test-expert"), + completeRun("te2-r1", "@create-expert/test-expert"), + + // te3 completes without delegation + completeRun("te3", "@create-expert/test-expert"), + + // build resumes + resumeFromStop("build-r1", "@create-expert/build"), + completeRun("build-r1", "@create-expert/build"), + + // root completes + resumeFromStop("root-r3", "create-expert"), + completeRun("root-r3", "create-expert"), + ] + + const { treeState } = buildRunTreeFromEvents(events) + + expect(treeState.rootRunId).toBe("root") + + // Root children: plan, dr, build + const root = treeState.nodes.get("root")! + expect(root.status).toBe("completed") + expect(root.childRunIds.sort()).toEqual(["build", "dr", "plan"]) + + // design-roles has 4 failed find-skill children + const dr = treeState.nodes.get("dr")! + expect(dr.childRunIds.length).toBe(4) + for (const childId of dr.childRunIds) { + const child = treeState.nodes.get(childId)! + expect(child.expertKey).toBe("@create-expert/find-skill") + expect(child.status).toBe("error") + } + + // bg1 has game-designer + 3 failed inline delegates + const bg1 = treeState.nodes.get("bg1")! + expect(bg1.childRunIds.length).toBe(4) // gd1 + 3 failed + expect(treeState.nodes.get("gd1")?.status).toBe("completed") + const bg1Errors = bg1.childRunIds + .map((id) => treeState.nodes.get(id)!) + .filter((n) => n.status === "error") + expect(bg1Errors.length).toBe(3) + + // bg2 same structure + const bg2 = treeState.nodes.get("bg2")! + expect(bg2.childRunIds.length).toBe(4) + expect(treeState.nodes.get("gd2")?.status).toBe("completed") + const bg2Errors = bg2.childRunIds + .map((id) => treeState.nodes.get(id)!) + .filter((n) => n.status === "error") + expect(bg2Errors.length).toBe(3) + + // Total: 11 real + 4 find-skill + 6 logic/tui/cli = 21 + expect(treeState.nodes.size).toBe(21) + }) +}) diff --git a/packages/tui-components/src/log-viewer/build-run-tree.ts b/packages/tui-components/src/log-viewer/build-run-tree.ts new file mode 100644 index 00000000..0fab8f5d --- /dev/null +++ b/packages/tui-components/src/log-viewer/build-run-tree.ts @@ -0,0 +1,303 @@ +import type { RunEvent } from "@perstack/core" +import { parseExpertKey } from "@perstack/core" +import type { + DelegationTreeNode, + DelegationTreeState, +} from "../execution/hooks/use-delegation-tree.js" +import { createInitialDelegationTreeState } from "../execution/hooks/use-delegation-tree.js" + +export type RunStats = { eventCount: number; stepCount: number } + +export type RunTreeResult = { + treeState: DelegationTreeState + runQueries: Map + runStats: Map +} + +function parseExpertName(expertKey: string): string { + try { + return parseExpertKey(expertKey).name + } catch { + return expertKey + } +} + +function createTreeNode( + runId: string, + expertKey: string, + model: string | undefined, + parentRunId: string | undefined, +): DelegationTreeNode { + return { + runId, + expertName: parseExpertName(expertKey), + expertKey, + status: "running", + actionLabel: "", + actionFileArg: undefined, + contextWindowUsage: 0, + model, + parentRunId, + childRunIds: [], + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + } +} + +function addNodeToTree(state: DelegationTreeState, node: DelegationTreeNode) { + state.nodes.set(node.runId, node) + if (node.parentRunId) { + const parent = state.nodes.get(node.parentRunId) + if (parent && !parent.childRunIds.includes(node.runId)) { + parent.childRunIds.push(node.runId) + } + } else { + state.rootRunId = node.runId + } +} + +function accumulateTokens( + node: DelegationTreeNode, + usage: { + totalTokens: number + inputTokens: number + outputTokens: number + cachedInputTokens: number + }, +) { + node.totalTokens += usage.totalTokens + node.inputTokens += usage.inputTokens + node.outputTokens += usage.outputTokens + node.cachedInputTokens += usage.cachedInputTokens +} + +export function extractQueryFromStartRun(event: RunEvent): string | undefined { + if (event.type !== "startRun") return undefined + for (const msg of event.inputMessages) { + if (msg.type === "userMessage") { + for (const part of msg.contents) { + if ("text" in part && part.text) { + return part.text.replace(/\n/g, " ") + } + } + } + } + return undefined +} + +/** + * Build a delegation tree from a list of RunEvents. + * + * Parent-child relationships are established via `startRun.delegatedBy.runId`. + * + * Resume runs (`resumeFromStop`) are merged into the first run of the same + * expert that is waiting for delegates. The linkage works as follows: + * - `startRun` with `delegatedBy.runId` records child → parent + * - When a child completes, its parent runId is marked as "awaiting resume" + * - `resumeFromStop` matches the awaiting parent by expertKey + */ +export function buildRunTreeFromEvents(events: RunEvent[]): RunTreeResult { + const treeState = createInitialDelegationTreeState() + const runQueries = new Map() + const runStats = new Map() + + // Maps new resume runId → original first runId (for merging) + const runIdAliases = new Map() + + // Track parent→child relationships for resume matching + // parentRunId → Set of child runIds that were delegated from this parent + const parentToChildren = new Map>() + // parentRunId → number of children that have completed + const parentCompletedChildren = new Map() + // Set of runIds whose all children have completed (ready for resume) + const awaitingResume = new Set() + + function resolveRunId(runId: string): string { + let current = runId + const visited = new Set() + while (runIdAliases.has(current)) { + if (visited.has(current)) break + visited.add(current) + current = runIdAliases.get(current)! + } + return current + } + + function resolveNode(runId: string): DelegationTreeNode | undefined { + return treeState.nodes.get(resolveRunId(runId)) + } + + function mergeStats(fromRunId: string, toRunId: string) { + const fromStats = runStats.get(fromRunId) + if (fromStats) { + const toStats = runStats.get(toRunId) ?? { eventCount: 0, stepCount: 0 } + toStats.eventCount += fromStats.eventCount + if (fromStats.stepCount > toStats.stepCount) { + toStats.stepCount = fromStats.stepCount + } + runStats.set(toRunId, toStats) + runStats.delete(fromRunId) + } + } + + function recordStats(runId: string, stepNumber: number) { + const resolvedId = resolveRunId(runId) + const stats = runStats.get(resolvedId) ?? { eventCount: 0, stepCount: 0 } + stats.eventCount++ + if (stepNumber > stats.stepCount) { + stats.stepCount = stepNumber + } + runStats.set(resolvedId, stats) + } + + // When a child run completes, check if all siblings under the same parent + // have completed. If so, mark the parent as awaiting resume. + function onChildCompleted(childRunId: string) { + const childNode = treeState.nodes.get(childRunId) + if (!childNode?.parentRunId) return + + const parentRunId = childNode.parentRunId + const count = (parentCompletedChildren.get(parentRunId) ?? 0) + 1 + parentCompletedChildren.set(parentRunId, count) + + const totalChildren = parentToChildren.get(parentRunId)?.size ?? 0 + if (count >= totalChildren) { + awaitingResume.add(parentRunId) + } + } + + for (const event of events) { + recordStats(event.runId, event.stepNumber) + + switch (event.type) { + case "startRun": { + const parentRawRunId = event.initialCheckpoint.delegatedBy?.runId + const parentRunId = parentRawRunId ? resolveRunId(parentRawRunId) : undefined + const node = createTreeNode(event.runId, event.expertKey, event.model, parentRunId) + node.contextWindowUsage = event.initialCheckpoint.contextWindowUsage ?? 0 + addNodeToTree(treeState, node) + + // Track parent → child relationship + if (parentRunId) { + const children = parentToChildren.get(parentRunId) ?? new Set() + children.add(event.runId) + parentToChildren.set(parentRunId, children) + } + + const query = extractQueryFromStartRun(event) + if (query) { + runQueries.set(event.runId, query) + } + break + } + + case "resumeFromStop": { + // Find the original run to merge into. + // The resume run has the same expertKey as the original run that delegated. + // We look for a run that is awaiting resume with the same expertKey. + let originalRunId: string | undefined + + for (const candidateRunId of awaitingResume) { + const candidateNode = treeState.nodes.get(candidateRunId) + if (candidateNode && candidateNode.expertKey === event.expertKey) { + originalRunId = candidateRunId + break + } + } + + // Fallback: find any suspending node with same expertKey + if (!originalRunId) { + for (const [nodeId, node] of treeState.nodes) { + if (node.expertKey === event.expertKey && node.status === "suspending") { + originalRunId = nodeId + break + } + } + } + + if (originalRunId && originalRunId !== event.runId) { + runIdAliases.set(event.runId, originalRunId) + awaitingResume.delete(originalRunId) + mergeStats(event.runId, originalRunId) + } + + const node = resolveNode(event.runId) + if (node) { + node.status = "running" + node.model = event.model + if (event.checkpoint.contextWindowUsage !== undefined) { + node.contextWindowUsage = event.checkpoint.contextWindowUsage + } + } + break + } + + case "stopRunByDelegate": { + const node = resolveNode(event.runId) + if (node) { + node.status = "suspending" + if (event.checkpoint.contextWindowUsage !== undefined) { + node.contextWindowUsage = event.checkpoint.contextWindowUsage + } + } + break + } + + case "callTools": { + const node = resolveNode(event.runId) + if (node) { + accumulateTokens(node, event.usage) + } + break + } + + case "completeRun": { + const resolvedId = resolveRunId(event.runId) + const node = treeState.nodes.get(resolvedId) + if (node) { + node.status = "completed" + accumulateTokens(node, event.usage) + if (event.checkpoint.contextWindowUsage !== undefined) { + node.contextWindowUsage = event.checkpoint.contextWindowUsage + } + } + onChildCompleted(resolvedId) + break + } + + case "stopRunByError": { + const resolvedId = resolveRunId(event.runId) + const node = treeState.nodes.get(resolvedId) + if (node) { + node.status = "error" + if (event.checkpoint.contextWindowUsage !== undefined) { + node.contextWindowUsage = event.checkpoint.contextWindowUsage + } + } + // Error also counts as "done" for resume tracking + onChildCompleted(resolvedId) + break + } + + case "retry": { + const node = resolveNode(event.runId) + if (node) { + accumulateTokens(node, event.usage) + } + break + } + + case "continueToNextStep": { + const node = resolveNode(event.runId) + if (node && event.nextCheckpoint.contextWindowUsage !== undefined) { + node.contextWindowUsage = event.nextCheckpoint.contextWindowUsage + } + break + } + } + } + + return { treeState, runQueries, runStats } +} diff --git a/packages/tui-components/src/log-viewer/components/log-info-content.tsx b/packages/tui-components/src/log-viewer/components/log-info-content.tsx new file mode 100644 index 00000000..eeab8b6a --- /dev/null +++ b/packages/tui-components/src/log-viewer/components/log-info-content.tsx @@ -0,0 +1,260 @@ +import type { Checkpoint, Job, RunEvent } from "@perstack/core" +import { Text } from "ink" +import type React from "react" +import { colors } from "../../colors.js" +import { USAGE_INDICATORS } from "../../constants.js" +import type { DelegationTreeNode } from "../../execution/hooks/use-delegation-tree.js" +import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js" +import type { RunInfo } from "../types.js" + +function formatDuration(startedAt: number, finishedAt?: number): string { + const end = finishedAt ?? Date.now() + const seconds = Math.floor((end - startedAt) / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (minutes < 60) return `${minutes}m ${remainingSeconds}s` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m` +} + +function formatShortDate(timestamp: number): string { + const d = new Date(timestamp) + return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` +} + +function getUsageIcon(ratio: number): string { + const percent = ratio * 100 + if (percent >= 75) return USAGE_INDICATORS.FULL + if (percent >= 50) return USAGE_INDICATORS.HIGH + if (percent >= 25) return USAGE_INDICATORS.MEDIUM + if (percent >= 5) return USAGE_INDICATORS.LOW + return USAGE_INDICATORS.EMPTY +} + +function statusColor(status: string): string { + if (status === "completed") return colors.success + if (status === "running" || status === "proceeding" || status === "init") return colors.accent + return colors.destructive +} + +// --- Job --- + +export function JobInfoContent({ job }: { job: Job }): React.ReactNode { + const duration = formatDuration(job.startedAt, job.finishedAt) + const totalInput = job.usage.inputTokens + (job.usage.cachedInputTokens ?? 0) + const cacheRate = totalInput > 0 ? ((job.usage.cachedInputTokens ?? 0) / totalInput) * 100 : 0 + return ( + <> + + {job.coordinatorExpertKey} + · + {job.status} + · + {job.id} + + + {job.totalSteps} steps · {duration} · Started {formatShortDate(job.startedAt)} + {job.finishedAt ? ` · Finished ${formatShortDate(job.finishedAt)}` : ""} + + + Tokens: In {formatTokenCount(job.usage.inputTokens)} · Out{" "} + {formatTokenCount(job.usage.outputTokens)} + {(job.usage.cachedInputTokens ?? 0) > 0 ? ( + <> + {" · Cache "} + {formatTokenCount(job.usage.cachedInputTokens ?? 0)}/{cacheRate.toFixed(2)}% + + ) : null} + + + ) +} + +// --- Run --- + +type RunInfoContentProps = { + job: Job + run: RunInfo + treeNode?: DelegationTreeNode + providerName?: string + checkpointCount?: number + eventCount?: number +} + +export function RunInfoContent({ + run, + treeNode, + providerName, + checkpointCount, + eventCount, +}: RunInfoContentProps): React.ReactNode { + const parts: string[] = [] + if (providerName) parts.push(providerName) + if (treeNode?.model) parts.push(treeNode.model) + if (checkpointCount !== undefined) parts.push(`${checkpointCount} steps`) + if (eventCount !== undefined) parts.push(`${eventCount} events`) + return ( + <> + + {run.expertKey ? {run.expertKey} : null} + {run.expertKey ? · : null} + + {treeNode ? treeNode.status : "unknown"} + + + {parts.length > 0 ? ( + + {parts.join(" · ")} + + ) : null} + {treeNode ? ( + + Tokens: In {formatTokenCount(treeNode.inputTokens)} · Out{" "} + {formatTokenCount(treeNode.outputTokens)} + {treeNode.cachedInputTokens > 0 ? ( + <> + {" · Cache "} + {formatTokenCount(treeNode.cachedInputTokens)}/ + {( + (treeNode.cachedInputTokens / (treeNode.inputTokens + treeNode.cachedInputTokens)) * + 100 + ).toFixed(2)} + % + + ) : null} + + ) : null} + + ) +} + +// --- Checkpoint --- + +export function CheckpointInfoContent({ checkpoint }: { checkpoint: Checkpoint }): React.ReactNode { + const cp = checkpoint + const usageIcon = + cp.contextWindowUsage !== undefined ? ` ${getUsageIcon(cp.contextWindowUsage)}` : "" + const usagePercent = + cp.contextWindowUsage !== undefined ? ` ${(cp.contextWindowUsage * 100).toFixed(1)}%` : "" + + return ( + <> + + Step {cp.stepNumber} + · + {cp.status} + · + {cp.expert.key} + · + {cp.id} + + + {cp.messages.length} msgs + {usageIcon ? ( + <> + {" ·"} + {usageIcon} + {usagePercent} + + ) : null} + {" · "}Tokens: In {formatTokenCount(cp.usage.inputTokens)} · Out{" "} + {formatTokenCount(cp.usage.outputTokens)} + + {cp.error ? ( + + Error: {cp.error.name}: {cp.error.message} + + ) : null} + {cp.delegateTo && cp.delegateTo.length > 0 ? ( + + Delegates: {cp.delegateTo.map((d) => d.expert.key).join(", ")} + + ) : null} + + ) +} + +// --- Event --- + +export function EventInfoContent({ event }: { event: RunEvent }): React.ReactNode { + return ( + <> + + {event.type} + · + Step {event.stepNumber} + · + {event.expertKey} + · + {event.id} + + + {formatShortDate(event.timestamp)} · run:{event.runId} + + + + ) +} + +function EventSummaryLine({ event }: { event: RunEvent }): React.ReactNode { + switch (event.type) { + case "callTools": + return ( + <> + + Tools: {event.toolCalls.map((tc) => `${tc.skillName}/${tc.toolName}`).join(", ")} + + + Tokens: In {formatTokenCount(event.usage.inputTokens)} · Out{" "} + {formatTokenCount(event.usage.outputTokens)} + + + ) + case "completeRun": + return ( + <> + + Result: {event.text} + + + Tokens: In {formatTokenCount(event.usage.inputTokens)} · Out{" "} + {formatTokenCount(event.usage.outputTokens)} + + + ) + case "stopRunByError": + return ( + + Error: {event.error.name}: {event.error.message} + + ) + case "retry": + return ( + + Reason: {event.reason} + + ) + case "startRun": + return ( + + Model: {event.model} · {event.inputMessages.length} input messages + + ) + case "stopRunByDelegate": + return event.checkpoint.delegateTo ? ( + + Delegates: {event.checkpoint.delegateTo.map((d) => d.expert.key).join(", ")} + + ) : null + case "startGeneration": + return ( + + {event.messages.length} messages + + ) + default: + return null + } +} diff --git a/packages/tui-components/src/log-viewer/index.ts b/packages/tui-components/src/log-viewer/index.ts new file mode 100644 index 00000000..06d9a61f --- /dev/null +++ b/packages/tui-components/src/log-viewer/index.ts @@ -0,0 +1,2 @@ +export { renderLogViewer } from "./render.js" +export type { JobListItem, LogViewerParams, LogViewerScreen, RunInfo } from "./types.js" diff --git a/packages/tui-components/src/log-viewer/render.tsx b/packages/tui-components/src/log-viewer/render.tsx new file mode 100644 index 00000000..a62151c3 --- /dev/null +++ b/packages/tui-components/src/log-viewer/render.tsx @@ -0,0 +1,14 @@ +import { render } from "ink" +import { LogViewerApp } from "./app.js" +import type { LogViewerParams } from "./types.js" + +export async function renderLogViewer(params: LogViewerParams): Promise { + const { waitUntilExit } = render( + , + ) + await waitUntilExit() +} diff --git a/packages/tui-components/src/log-viewer/screens/checkpoint-detail.tsx b/packages/tui-components/src/log-viewer/screens/checkpoint-detail.tsx new file mode 100644 index 00000000..dbd07a1b --- /dev/null +++ b/packages/tui-components/src/log-viewer/screens/checkpoint-detail.tsx @@ -0,0 +1,129 @@ +import type { Checkpoint, Job } from "@perstack/core" +import { Box, Text, useInput } from "ink" +import { colors } from "../../colors.js" +import { truncateText } from "../../helpers.js" +import type { RunInfo } from "../types.js" + +type CheckpointDetailScreenProps = { + job: Job + run: RunInfo + checkpoint: Checkpoint + onBack: () => void +} + +export const CheckpointDetailScreen = ({ checkpoint, onBack }: CheckpointDetailScreenProps) => { + useInput((char, key) => { + if (key.escape || char === "b") { + onBack() + } + }) + + const cp = checkpoint + + return ( + + + + Checkpoint Detail + + b:Back q:Quit + + + + ID: + {cp.id} + + + Status: + + {cp.status} + + + + Step: + {cp.stepNumber} + + + Expert: + + {cp.expert.name} ({cp.expert.key}@{cp.expert.version}) + + + + Messages: + {cp.messages.length} + + + Input Tokens: + {cp.usage.inputTokens.toLocaleString()} + + + Output Tokens: + {cp.usage.outputTokens.toLocaleString()} + + {cp.contextWindow && ( + + Context Window: + {cp.contextWindow.toLocaleString()} + + )} + {cp.contextWindowUsage !== undefined && ( + + Context Usage: + {(cp.contextWindowUsage * 100).toFixed(1)}% + + )} + {cp.error && ( + + + Error: + + + {cp.error.name}: {truncateText(cp.error.message, 100)} + + {cp.error.statusCode && ( + Status Code: {cp.error.statusCode} + )} + Retryable: {cp.error.isRetryable ? "yes" : "no"} + + )} + {cp.delegateTo && cp.delegateTo.length > 0 && ( + + + Delegating to: + + {cp.delegateTo.map((d) => ( + + - {d.expert.key}: {truncateText(d.query, 60)} + + ))} + + )} + {cp.delegatedBy && ( + + + Delegated by: + + + {cp.delegatedBy.expert.key} via {cp.delegatedBy.toolName} + + + )} + {cp.pendingToolCalls && cp.pendingToolCalls.length > 0 && ( + + Pending Tool Calls: + {cp.pendingToolCalls.length} + + )} + + + ) +} diff --git a/packages/tui-components/src/log-viewer/screens/checkpoint-list.tsx b/packages/tui-components/src/log-viewer/screens/checkpoint-list.tsx new file mode 100644 index 00000000..bdea0190 --- /dev/null +++ b/packages/tui-components/src/log-viewer/screens/checkpoint-list.tsx @@ -0,0 +1,147 @@ +import type { Checkpoint, Job } from "@perstack/core" +import { Box, Text, useInput } from "ink" +import { useEffect, useMemo } from "react" +import { colors } from "../../colors.js" +import { USAGE_INDICATORS } from "../../constants.js" +import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js" +import { useListNavigation } from "../../hooks/use-list-navigation.js" +import type { RunInfo } from "../types.js" + +type CheckpointListScreenProps = { + job: Job + run: RunInfo + checkpoints: Checkpoint[] + onSelectCheckpoint: (checkpoint: Checkpoint) => void + onBack: () => void + onSelectedChange: (checkpoint: Checkpoint | undefined) => void +} + +const MAX_VISIBLE = 10 + +function statusColor(status: string): string { + if (status === "completed") return colors.success + if (status === "proceeding" || status === "init") return colors.accent + return colors.destructive +} + +function getUsageIcon(ratio: number): string { + const percent = ratio * 100 + if (percent >= 75) return USAGE_INDICATORS.FULL + if (percent >= 50) return USAGE_INDICATORS.HIGH + if (percent >= 25) return USAGE_INDICATORS.MEDIUM + if (percent >= 5) return USAGE_INDICATORS.LOW + return USAGE_INDICATORS.EMPTY +} + +export const CheckpointListScreen = ({ + run, + checkpoints, + onSelectCheckpoint, + onBack, + onSelectedChange, +}: CheckpointListScreenProps) => { + const { selectedIndex, handleNavigation } = useListNavigation({ + items: checkpoints, + onSelect: onSelectCheckpoint, + onBack, + }) + + useInput((char, key) => { + handleNavigation(char, key) + }) + + const selectedCp = checkpoints[selectedIndex] + + useEffect(() => { + onSelectedChange(selectedCp) + }, [selectedCp, onSelectedChange]) + + const { scrollOffset, displayItems } = useMemo(() => { + const offset = Math.max( + 0, + Math.min(selectedIndex - MAX_VISIBLE + 1, checkpoints.length - MAX_VISIBLE), + ) + return { + scrollOffset: offset, + displayItems: checkpoints.slice(offset, offset + MAX_VISIBLE), + } + }, [checkpoints, selectedIndex]) + + const shortRunId = run.runId.length > 12 ? `${run.runId.slice(0, 12)}...` : run.runId + + return ( + + + + Checkpoints + + ({shortRunId}) + {checkpoints.length > MAX_VISIBLE && ( + + {" "} + [{selectedIndex + 1}/{checkpoints.length}] + + )} + Enter:Select b:Back q:Quit + + {scrollOffset > 0 && ...} + {displayItems.length === 0 ? ( + No checkpoints found + ) : ( + displayItems.map((cp, i) => { + const actualIndex = scrollOffset + i + const isSelected = actualIndex === selectedIndex + const hasUsage = cp.contextWindowUsage !== undefined + const hasDelegates = cp.delegateTo && cp.delegateTo.length > 0 + const hasError = !!cp.error + return ( + + + + {isSelected ? " \u25B8 " : " "} + + + Step {cp.stepNumber} + · + {cp.status} + · + {cp.expert.key} + · + {cp.messages.length} msgs + {hasUsage ? ( + <> + · + + {getUsageIcon(cp.contextWindowUsage!)}{" "} + {(cp.contextWindowUsage! * 100).toFixed(0)}% + + + ) : null} + + + + + Tokens: In {formatTokenCount(cp.usage.inputTokens)} · Out{" "} + {formatTokenCount(cp.usage.outputTokens)} + {hasDelegates ? ( + <> + {" · Delegates: "} + {cp.delegateTo!.map((d) => d.expert.key).join(", ")} + + ) : null} + {hasError ? ( + + {" · "} + {cp.error!.name} + + ) : null} + + + + ) + }) + )} + {scrollOffset + MAX_VISIBLE < checkpoints.length && ...} + + ) +} diff --git a/packages/tui-components/src/log-viewer/screens/event-detail.tsx b/packages/tui-components/src/log-viewer/screens/event-detail.tsx new file mode 100644 index 00000000..2cc22504 --- /dev/null +++ b/packages/tui-components/src/log-viewer/screens/event-detail.tsx @@ -0,0 +1,191 @@ +import type { Job, RunEvent } from "@perstack/core" +import { Box, Text, useInput } from "ink" +import { colors } from "../../colors.js" +import { formatTimestamp, truncateText } from "../../helpers.js" +import type { RunInfo } from "../types.js" + +type EventDetailScreenProps = { + job: Job + run: RunInfo + event: RunEvent + onBack: () => void +} + +export const EventDetailScreen = ({ event, onBack }: EventDetailScreenProps) => { + useInput((char, key) => { + if (key.escape || char === "b") { + onBack() + } + }) + + return ( + + + + Event Detail + + b:Back q:Quit + + + + ID: + {event.id} + + + Type: + {event.type} + + + Step: + {event.stepNumber} + + + Expert: + {event.expertKey} + + + Run: + {event.runId} + + + Job: + {event.jobId} + + + Timestamp: + {formatTimestamp(event.timestamp)} + + + + Payload: + + + {renderEventPayload(event)} + + + ) +} + +function renderEventPayload(event: RunEvent) { + switch (event.type) { + case "startRun": + return ( + + + Model: + {event.model} + + + Input Messages: + {event.inputMessages.length} + + + Checkpoint Status: + {event.initialCheckpoint.status} + + + ) + case "callTools": + return ( + + + Tool Calls: + {event.toolCalls.length} + + {event.toolCalls.map((tc) => ( + + + {tc.skillName}/{tc.toolName} ({tc.id}) + + {truncateText(JSON.stringify(tc.args), 120)} + + ))} + + Input Tokens: + {event.usage.inputTokens.toLocaleString()} + + + Output Tokens: + {event.usage.outputTokens.toLocaleString()} + + + ) + case "completeRun": + return ( + + + Result: + {truncateText(event.text, 200)} + + + Input Tokens: + {event.usage.inputTokens.toLocaleString()} + + + Output Tokens: + {event.usage.outputTokens.toLocaleString()} + + + ) + case "stopRunByError": + return ( + + + Error: + + {event.error.name}: {event.error.message} + + + {event.error.statusCode && ( + + Status Code: + {event.error.statusCode} + + )} + + Retryable: + {event.error.isRetryable ? "yes" : "no"} + + + ) + case "retry": + return ( + + + Reason: + {event.reason} + + + ) + case "startGeneration": + return ( + + + Messages: + {event.messages.length} + + + ) + case "stopRunByDelegate": + return ( + + + Step: + {event.step.stepNumber} + + {event.checkpoint.delegateTo && ( + + Delegates: + {event.checkpoint.delegateTo.map((d) => ( + + - {d.expert.key}: {truncateText(d.query, 60)} + + ))} + + )} + + ) + default: + return {truncateText(JSON.stringify(event, null, 2), 500)} + } +} diff --git a/packages/tui-components/src/log-viewer/screens/event-list.tsx b/packages/tui-components/src/log-viewer/screens/event-list.tsx new file mode 100644 index 00000000..921328ee --- /dev/null +++ b/packages/tui-components/src/log-viewer/screens/event-list.tsx @@ -0,0 +1,170 @@ +import type { RunEvent } from "@perstack/core" +import { Box, Text, useInput } from "ink" +import { useEffect, useMemo } from "react" +import { colors } from "../../colors.js" +import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js" +import { useListNavigation } from "../../hooks/use-list-navigation.js" +import type { RunInfo } from "../types.js" + +type EventListScreenProps = { + run: RunInfo + events: RunEvent[] + onSelectEvent: (event: RunEvent) => void + onBack: () => void + onSelectedChange: (event: RunEvent | undefined) => void +} + +const MAX_VISIBLE = 10 + +function formatTime(timestamp: number): string { + const d = new Date(timestamp) + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}` +} + +function eventTypeColor(type: string): string { + switch (type) { + case "startRun": + return colors.accent + case "completeRun": + return colors.success + case "stopRunByError": + return colors.destructive + case "stopRunByDelegate": + case "stopRunByInteractiveTool": + return colors.warn + case "callTools": + return "magenta" + case "retry": + return colors.destructive + default: + return colors.muted + } +} + +function getEventSummary(event: RunEvent): string | undefined { + switch (event.type) { + case "callTools": + return `Tools: ${event.toolCalls.map((tc) => `${tc.skillName}/${tc.toolName}`).join(", ")}` + case "completeRun": + return `Result: ${event.text}` + case "stopRunByError": + return `${event.error.name}: ${event.error.message}` + case "retry": + return `Reason: ${event.reason}` + case "startRun": + return `Model: ${event.model}` + case "stopRunByDelegate": + return event.checkpoint.delegateTo + ? `Delegates: ${event.checkpoint.delegateTo.map((d) => d.expert.key).join(", ")}` + : undefined + default: + return undefined + } +} + +export const EventListScreen = ({ + run, + events, + onSelectEvent, + onBack, + onSelectedChange, +}: EventListScreenProps) => { + const { selectedIndex, handleNavigation } = useListNavigation({ + items: events, + onSelect: onSelectEvent, + onBack, + }) + + useInput((char, key) => { + handleNavigation(char, key) + }) + + const selectedEvent = events[selectedIndex] + + useEffect(() => { + onSelectedChange(selectedEvent) + }, [selectedEvent, onSelectedChange]) + + const { scrollOffset, displayItems } = useMemo(() => { + const offset = Math.max( + 0, + Math.min(selectedIndex - MAX_VISIBLE + 1, events.length - MAX_VISIBLE), + ) + return { + scrollOffset: offset, + displayItems: events.slice(offset, offset + MAX_VISIBLE), + } + }, [events, selectedIndex]) + + const shortRunId = run.runId.length > 12 ? `${run.runId.slice(0, 12)}...` : run.runId + + return ( + + + + Events + + ({shortRunId}) + {events.length > MAX_VISIBLE && ( + + {" "} + [{selectedIndex + 1}/{events.length}] + + )} + Enter:Select b:Back q:Quit + + {scrollOffset > 0 && ...} + {displayItems.length === 0 ? ( + No events found + ) : ( + displayItems.map((ev, i) => { + const actualIndex = scrollOffset + i + const isSelected = actualIndex === selectedIndex + const summary = getEventSummary(ev) + const hasUsage = "usage" in ev && ev.usage + return ( + + + + {isSelected ? " \u25B8 " : " "} + + + Step {ev.stepNumber} + · + {ev.type} + · + {ev.expertKey} + {hasUsage ? ( + <> + · + + In{" "} + {formatTokenCount( + (ev as { usage: { inputTokens: number } }).usage.inputTokens, + )}{" "} + · Out{" "} + {formatTokenCount( + (ev as { usage: { outputTokens: number } }).usage.outputTokens, + )} + + + ) : null} + {" "} + {formatTime(ev.timestamp)} + + + {summary ? ( + + + {summary} + + + ) : null} + + ) + }) + )} + {scrollOffset + MAX_VISIBLE < events.length && ...} + + ) +} diff --git a/packages/tui-components/src/log-viewer/screens/job-detail.tsx b/packages/tui-components/src/log-viewer/screens/job-detail.tsx new file mode 100644 index 00000000..ada14439 --- /dev/null +++ b/packages/tui-components/src/log-viewer/screens/job-detail.tsx @@ -0,0 +1,89 @@ +import type { Job } from "@perstack/core" +import { Box, Text, useInput } from "ink" +import { colors } from "../../colors.js" +import { formatTimestamp } from "../../helpers.js" + +type JobDetailScreenProps = { + job: Job + onBack: () => void + onViewRuns: (job: Job) => void +} + +export const JobDetailScreen = ({ job, onBack, onViewRuns }: JobDetailScreenProps) => { + useInput((char, key) => { + if (key.escape || char === "b") { + onBack() + return + } + if (key.return || char === "r") { + onViewRuns(job) + } + }) + + const statusColor = + job.status === "completed" + ? colors.success + : job.status === "running" + ? colors.accent + : colors.destructive + + const duration = + job.finishedAt && job.startedAt + ? `${((job.finishedAt - job.startedAt) / 1000).toFixed(1)}s` + : "running..." + + return ( + + + + Job Detail + + Enter/r:View Runs b:Back q:Quit + + + + ID: + {job.id} + + + Status: + {job.status} + + + Expert: + {job.coordinatorExpertKey} + + + Version: + {job.runtimeVersion} + + + Steps: + {job.totalSteps} + + + Duration: + {duration} + + + Started: + {formatTimestamp(job.startedAt)} + + {job.finishedAt && ( + + Finished: + {formatTimestamp(job.finishedAt)} + + )} + + Input Tokens: + {job.usage.inputTokens.toLocaleString()} + + + Output Tokens: + {job.usage.outputTokens.toLocaleString()} + + + + ) +} diff --git a/packages/tui-components/src/log-viewer/screens/job-list.tsx b/packages/tui-components/src/log-viewer/screens/job-list.tsx new file mode 100644 index 00000000..8940f3e1 --- /dev/null +++ b/packages/tui-components/src/log-viewer/screens/job-list.tsx @@ -0,0 +1,137 @@ +import type { Job } from "@perstack/core" +import { Box, Text, useInput } from "ink" +import { useEffect, useMemo } from "react" +import { colors } from "../../colors.js" +import { formatTokenCount } from "../../execution/hooks/use-delegation-tree.js" +import { useListNavigation } from "../../hooks/use-list-navigation.js" +import type { JobListItem } from "../types.js" + +type JobListScreenProps = { + items: JobListItem[] + onSelectJob: (job: Job) => void + onViewJobDetail: (job: Job) => void + onSelectedChange: (job: Job | undefined) => void +} + +const MAX_VISIBLE = 10 + +function formatDuration(startedAt: number, finishedAt?: number): string { + const end = finishedAt ?? Date.now() + const seconds = Math.floor((end - startedAt) / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (minutes < 60) return `${minutes}m ${remainingSeconds}s` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m` +} + +function formatShortDate(timestamp: number): string { + const d = new Date(timestamp) + return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` +} + +function statusColor(status: string): string { + if (status === "completed") return colors.success + if (status === "running" || status === "proceeding" || status === "init") return colors.accent + return colors.destructive +} + +export const JobListScreen = ({ + items, + onSelectJob, + onViewJobDetail, + onSelectedChange, +}: JobListScreenProps) => { + const { selectedIndex, handleNavigation } = useListNavigation({ + items, + onSelect: (item) => onSelectJob(item.job), + }) + + useInput((char, key) => { + if (char === "d" && items[selectedIndex]) { + onViewJobDetail(items[selectedIndex].job) + return + } + handleNavigation(char, key) + }) + + const selectedItem = items[selectedIndex] + + useEffect(() => { + onSelectedChange(selectedItem?.job) + }, [selectedItem, onSelectedChange]) + + const { scrollOffset, displayItems } = useMemo(() => { + const offset = Math.max( + 0, + Math.min(selectedIndex - MAX_VISIBLE + 1, items.length - MAX_VISIBLE), + ) + return { + scrollOffset: offset, + displayItems: items.slice(offset, offset + MAX_VISIBLE), + } + }, [items, selectedIndex]) + + return ( + + + + Jobs + + {items.length > MAX_VISIBLE && ( + + {" "} + ({selectedIndex + 1}/{items.length}) + + )} + Enter:Select d:Detail q:Quit + + {scrollOffset > 0 && ...} + {displayItems.length === 0 ? ( + No jobs found + ) : ( + displayItems.map((item, i) => { + const actualIndex = scrollOffset + i + const isSelected = actualIndex === selectedIndex + const { job, query } = item + return ( + + + + {isSelected ? " \u25B8 " : " "} + + + {job.coordinatorExpertKey} + · + {job.status} + · + {job.totalSteps} steps + · + {formatDuration(job.startedAt, job.finishedAt)} + {" "} + {formatShortDate(job.startedAt)} + + + {query ? ( + + + {">"} {query} + + + ) : null} + + + Tokens: In {formatTokenCount(job.usage.inputTokens)} · Out{" "} + {formatTokenCount(job.usage.outputTokens)} + + + + ) + }) + )} + {scrollOffset + MAX_VISIBLE < items.length && ...} + + ) +} diff --git a/packages/tui-components/src/log-viewer/screens/run-list.tsx b/packages/tui-components/src/log-viewer/screens/run-list.tsx new file mode 100644 index 00000000..02a8bc2f --- /dev/null +++ b/packages/tui-components/src/log-viewer/screens/run-list.tsx @@ -0,0 +1,116 @@ +import type { Job } from "@perstack/core" +import { Box, Text, useInput } from "ink" +import { useEffect, useMemo } from "react" +import { colors } from "../../colors.js" +import type { + DelegationTreeNode, + DelegationTreeState, +} from "../../execution/hooks/use-delegation-tree.js" +import { flattenTreeAll } from "../../execution/hooks/use-delegation-tree.js" +import { useListNavigation } from "../../hooks/use-list-navigation.js" + +type RunListScreenProps = { + job: Job + treeState: DelegationTreeState + runQueries: Map + onSelectRun: (node: DelegationTreeNode) => void + onBack: () => void + onSelectedChange: (node: DelegationTreeNode | undefined) => void +} + +const MAX_VISIBLE = 50 + +function statusColor(status: string): string | undefined { + switch (status) { + case "completed": + return colors.success + case "error": + return colors.destructive + case "running": + return colors.accent + default: + return undefined + } +} + +export const RunListScreen = ({ + job, + treeState, + runQueries, + onSelectRun, + onBack, + onSelectedChange, +}: RunListScreenProps) => { + const flatNodes = useMemo(() => flattenTreeAll(treeState), [treeState]) + + const { selectedIndex, handleNavigation } = useListNavigation({ + items: flatNodes, + onSelect: (flatNode) => onSelectRun(flatNode.node), + onBack, + }) + + useInput((char, key) => { + handleNavigation(char, key) + }) + + const selectedNode = flatNodes[selectedIndex]?.node + + useEffect(() => { + onSelectedChange(selectedNode) + }, [selectedNode, onSelectedChange]) + + const { scrollOffset, displayItems } = useMemo(() => { + const offset = Math.max( + 0, + Math.min(selectedIndex - MAX_VISIBLE + 1, flatNodes.length - MAX_VISIBLE), + ) + return { + scrollOffset: offset, + displayItems: flatNodes.slice(offset, offset + MAX_VISIBLE), + } + }, [flatNodes, selectedIndex]) + + return ( + + + + Runs + + ({job.coordinatorExpertKey}) + {flatNodes.length > MAX_VISIBLE && ( + + {" "} + [{selectedIndex + 1}/{flatNodes.length}] + + )} + Enter:Select b:Back q:Quit + + {scrollOffset > 0 && ...} + {displayItems.length === 0 ? ( + No runs found + ) : ( + displayItems.map((flatNode, i) => { + const actualIndex = scrollOffset + i + const isSelected = actualIndex === selectedIndex + const { node } = flatNode + const nameColor = statusColor(node.status) + const query = runQueries.get(node.runId) + const indent = " ".repeat(flatNode.depth * 2) + return ( + + + {isSelected ? " > " : " "} + + {indent} + + {node.expertName} + + {query ? {query} : null} + + ) + }) + )} + {scrollOffset + MAX_VISIBLE < flatNodes.length && ...} + + ) +} diff --git a/packages/tui-components/src/log-viewer/types.ts b/packages/tui-components/src/log-viewer/types.ts new file mode 100644 index 00000000..0390008d --- /dev/null +++ b/packages/tui-components/src/log-viewer/types.ts @@ -0,0 +1,36 @@ +import type { Checkpoint, Job, RunEvent } from "@perstack/core" +import type { LogDataFetcher } from "@perstack/log" +import type { DelegationTreeState } from "../execution/hooks/use-delegation-tree.js" + +export type LogViewerParams = { + fetcher: LogDataFetcher + initialJobId?: string + initialRunId?: string +} + +export type LogViewerScreen = + | { type: "jobList" } + | { type: "jobDetail"; job: Job } + | { + type: "runList" + job: Job + treeState: DelegationTreeState + runQueries: Map + runStats: Map + } + | { type: "checkpointList"; job: Job; run: RunInfo; checkpoints: Checkpoint[] } + | { type: "checkpointDetail"; job: Job; run: RunInfo; checkpoint: Checkpoint } + | { type: "eventList"; job: Job; run: RunInfo; events: RunEvent[] } + | { type: "eventDetail"; job: Job; run: RunInfo; event: RunEvent } + +export type RunInfo = { + jobId: string + runId: string + expertKey?: string + startedAt?: number +} + +export type JobListItem = { + job: Job + query?: string +} From 72a572ce4ebc38517fc170c3964a5c4eb101abb4 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 12 Mar 2026 06:33:20 +0900 Subject: [PATCH 2/3] fix: resolve CI failures for log viewer TUI - Add @perstack/log dependency to tui-components - Add inputTokens, outputTokens, cachedInputTokens to DelegationTreeNode type - Fix implicit any types in app.tsx - Export log-viewer from package entry point to satisfy knip - Fix lint: prefix unused parameter with underscore Co-Authored-By: Claude Opus 4.6 --- bun.lock | 5 +++-- packages/tui-components/package.json | 1 + .../src/execution/hooks/use-delegation-tree.ts | 6 ++++++ packages/tui-components/src/index.ts | 3 +++ packages/tui-components/src/log-viewer/app.tsx | 6 +++--- .../tui-components/src/log-viewer/build-run-tree.test.ts | 2 +- 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 9484c9a6..0477c403 100644 --- a/bun.lock +++ b/bun.lock @@ -49,7 +49,7 @@ }, "apps/perstack": { "name": "perstack", - "version": "0.0.123", + "version": "0.0.124", "dependencies": { "commander": "^14.0.3", }, @@ -304,7 +304,7 @@ }, "packages/runtime": { "name": "@perstack/runtime", - "version": "0.0.134", + "version": "0.0.136", "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.60", "@ai-sdk/anthropic": "^3.0.44", @@ -396,6 +396,7 @@ "version": "0.0.34", "dependencies": { "@perstack/core": "workspace:*", + "@perstack/log": "workspace:*", "@perstack/react": "workspace:*", "ink": "^6.7.0", "react": "^19.2.4", diff --git a/packages/tui-components/package.json b/packages/tui-components/package.json index 077be7ff..d8f7aed3 100644 --- a/packages/tui-components/package.json +++ b/packages/tui-components/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@perstack/core": "workspace:*", + "@perstack/log": "workspace:*", "@perstack/react": "workspace:*", "ink": "^6.7.0", "react": "^19.2.4" diff --git a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts index 5cfece8a..9fcfd503 100644 --- a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts +++ b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts @@ -20,6 +20,9 @@ export type DelegationTreeNode = { parentRunId: string | undefined childRunIds: string[] totalTokens: number + inputTokens: number + outputTokens: number + cachedInputTokens: number } export type DelegationTreeState = { @@ -261,6 +264,9 @@ export function processDelegationTreeEvent( parentRunId, childRunIds: [], totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, } state.nodes.set(event.runId, node) diff --git a/packages/tui-components/src/index.ts b/packages/tui-components/src/index.ts index f6553db2..9b2fd2fc 100644 --- a/packages/tui-components/src/index.ts +++ b/packages/tui-components/src/index.ts @@ -2,6 +2,9 @@ export type { ExecutionParams, ExecutionResult } from "./execution/index.js" export { renderExecution } from "./execution/index.js" +export type { JobListItem, LogViewerParams, LogViewerScreen, RunInfo } from "./log-viewer/index.js" +// Log Viewer +export { renderLogViewer } from "./log-viewer/index.js" export type { SelectionParams, SelectionResult } from "./selection/index.js" // Selection export { renderSelection } from "./selection/index.js" diff --git a/packages/tui-components/src/log-viewer/app.tsx b/packages/tui-components/src/log-viewer/app.tsx index 9c6fcba8..38ce000b 100644 --- a/packages/tui-components/src/log-viewer/app.tsx +++ b/packages/tui-components/src/log-viewer/app.tsx @@ -33,7 +33,7 @@ async function extractJobQuery(fetcher: LogDataFetcher, job: Job): Promise e.type === "startRun") + const startRunEvent = events.find((e: RunEvent) => e.type === "startRun") if (!startRunEvent) return undefined return extractQueryFromStartRun(startRunEvent) } catch { @@ -81,7 +81,7 @@ export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerA return } const checkpoints = await fetcher.getCheckpoints(initialJobId) - const runCheckpoints = checkpoints.filter((cp) => cp.runId === initialRunId) + const runCheckpoints = checkpoints.filter((cp: Checkpoint) => cp.runId === initialRunId) const run: RunInfo = { jobId: initialJobId, runId: initialRunId } setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints }) setLoading(false) @@ -145,7 +145,7 @@ export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerA setLoading(true) try { const checkpoints = await fetcher.getCheckpoints(job.id) - const runCheckpoints = checkpoints.filter((cp) => cp.runId === run.runId) + const runCheckpoints = checkpoints.filter((cp: Checkpoint) => cp.runId === run.runId) setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints }) } catch (err) { setError(err instanceof Error ? err.message : String(err)) diff --git a/packages/tui-components/src/log-viewer/build-run-tree.test.ts b/packages/tui-components/src/log-viewer/build-run-tree.test.ts index ece465de..4bdad4a5 100644 --- a/packages/tui-components/src/log-viewer/build-run-tree.test.ts +++ b/packages/tui-components/src/log-viewer/build-run-tree.test.ts @@ -103,7 +103,7 @@ function completeRun(runId: string, expertKey: string): RunEvent { }) } -function errorRun(runId: string, expertKey: string, parentRunId: string): RunEvent { +function errorRun(runId: string, expertKey: string, _parentRunId: string): RunEvent { return makeEvent("stopRunByError", runId, expertKey, { checkpoint: makeCheckpoint({ runId, From fbac84bd76697f499e458c7f914fdd2c9b90be27 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 12 Mar 2026 06:29:40 +0900 Subject: [PATCH 3/3] fix: handle resolveToolResults event in delegation tree builder Add explicit handling for the resolveToolResults event type in buildRunTreeFromEvents. This event fires after MCP tool execution and after resumeFromStop when all tool results are resolved. Without this handler, these events were silently ignored in the switch statement. Co-Authored-By: Claude Opus 4.6 --- .changeset/resolve-tool-results.md | 5 +++ .../src/log-viewer/build-run-tree.test.ts | 37 +++++++++++++++++++ .../src/log-viewer/build-run-tree.ts | 6 +++ 3 files changed, 48 insertions(+) create mode 100644 .changeset/resolve-tool-results.md diff --git a/.changeset/resolve-tool-results.md b/.changeset/resolve-tool-results.md new file mode 100644 index 00000000..33cde60c --- /dev/null +++ b/.changeset/resolve-tool-results.md @@ -0,0 +1,5 @@ +--- +"@perstack/tui-components": patch +--- + +Handle resolveToolResults event type in delegation tree builder diff --git a/packages/tui-components/src/log-viewer/build-run-tree.test.ts b/packages/tui-components/src/log-viewer/build-run-tree.test.ts index 4bdad4a5..eb5525c7 100644 --- a/packages/tui-components/src/log-viewer/build-run-tree.test.ts +++ b/packages/tui-components/src/log-viewer/build-run-tree.test.ts @@ -503,6 +503,43 @@ describe("buildRunTreeFromEvents", () => { expect(gd1Flat.depth).toBe(4) // root > build > te1 > bg1 > gd1 }) + it("handles resolveToolResults events without affecting tree structure", () => { + // resolveToolResults fires after MCP tool execution and after resumeFromStop. + // It should be handled gracefully without creating spurious nodes or breaking merges. + const events = [ + startRun("root", "coordinator"), + makeEvent("callTools", "root", "coordinator", { + newMessage: {}, + toolCalls: [], + usage: baseUsage, + }), + // resolveToolResults after MCP tool execution + makeEvent("resolveToolResults", "root", "coordinator", { + toolResults: [{ id: "tr-1", skillName: "read_file", toolName: "read_file", result: [] }], + }), + stopByDelegate("root", "coordinator"), + startRun("child", "@coordinator/worker", "root"), + completeRun("child", "@coordinator/worker"), + // Resume and immediately resolveToolResults (same timestamp pattern from real data) + resumeFromStop("root-resume", "coordinator"), + makeEvent("resolveToolResults", "root-resume", "coordinator", { + toolResults: [{ id: "tr-2", skillName: "delegate/worker", toolName: "worker", result: [] }], + }), + completeRun("root-resume", "coordinator"), + ] + + const { treeState, runStats } = buildRunTreeFromEvents(events) + + // Resume merged correctly + expect(treeState.nodes.size).toBe(2) // root, child + expect(treeState.nodes.get("root")?.status).toBe("completed") + expect(treeState.nodes.get("child")?.parentRunId).toBe("root") + + // resolveToolResults events counted in stats + const rootStats = runStats.get("root")! + expect(rootStats.eventCount).toBeGreaterThanOrEqual(6) // start, callTools, resolve, stop, resume, resolve, complete + }) + it("shows failed delegates as error nodes (startRun + stopRunByError)", () => { // With the runtime fix, failed delegates now emit startRun + stopRunByError events. // design-roles delegates to 4x find-skill, all fail immediately. diff --git a/packages/tui-components/src/log-viewer/build-run-tree.ts b/packages/tui-components/src/log-viewer/build-run-tree.ts index 0fab8f5d..1f582058 100644 --- a/packages/tui-components/src/log-viewer/build-run-tree.ts +++ b/packages/tui-components/src/log-viewer/build-run-tree.ts @@ -296,6 +296,12 @@ export function buildRunTreeFromEvents(events: RunEvent[]): RunTreeResult { } break } + + case "resolveToolResults": { + // Tool results resolved — no node state change needed. + // Stats are already recorded at the top of the loop. + break + } } }