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/.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/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/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..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 = { @@ -130,6 +133,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) @@ -236,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 new file mode 100644 index 00000000..38ce000b --- /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: RunEvent) => 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: Checkpoint) => 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: Checkpoint) => 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..eb5525c7 --- /dev/null +++ b/packages/tui-components/src/log-viewer/build-run-tree.test.ts @@ -0,0 +1,747 @@ +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("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. + 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..1f582058 --- /dev/null +++ b/packages/tui-components/src/log-viewer/build-run-tree.ts @@ -0,0 +1,309 @@ +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 + } + + case "resolveToolResults": { + // Tool results resolved — no node state change needed. + // Stats are already recorded at the top of the loop. + 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 +}