diff --git a/.changeset/trace-spool-single-owner.md b/.changeset/trace-spool-single-owner.md new file mode 100644 index 000000000..cc77dbccc --- /dev/null +++ b/.changeset/trace-spool-single-owner.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Give the local trace spool's on-disk layout a single owner: the shared trace reader now exposes the listing and segment-read primitives that `eve traces` and the `/traces` viewer both use, and payload formatting is shared between the detail panel and the conversation view. diff --git a/packages/eve/src/cli/commands/trace.ts b/packages/eve/src/cli/commands/trace.ts index b85484af7..55e9a5d72 100644 --- a/packages/eve/src/cli/commands/trace.ts +++ b/packages/eve/src/cli/commands/trace.ts @@ -16,8 +16,6 @@ interface CliTraceLogger { log(message: string): void; } -export type { LocalTrace, LocalTraceSpan }; - interface SpanExtent { readonly endTimeNs: bigint; readonly startTimeNs: bigint; diff --git a/packages/eve/src/cli/dev/tui/traces/trace-content.ts b/packages/eve/src/cli/dev/tui/traces/trace-content.ts index 90e3c90df..520436410 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-content.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-content.ts @@ -40,13 +40,26 @@ export function formatAttributeContent( const transcript = messageTranscript(value, theme, width); if (transcript !== undefined) return transcript; } - const parsed = parseJson(value); + return formatPayloadContent(value, width); +} + +/** + * Wraps text to `width`, splitting on embedded newlines so a multi-line payload + * keeps its shape, and stripping control bytes the frame must not carry. + */ +export function wrapPlainText(text: string, width: number): string[] { + return splitEmbeddedNewlines([stripTerminalControls(text)]).flatMap((line) => + wrapVisibleLine(line, width), + ); +} + +/** Formats a payload: JSON structure pretty-prints, anything else stays text. */ +export function formatPayloadContent(text: string, width: number): string[] { + const parsed = parseJson(text); if (parsed !== undefined && (Array.isArray(parsed) || isRecord(parsed))) { return prettyJson(parsed, width); } - return splitEmbeddedNewlines([stripTerminalControls(value)]).flatMap((line) => - wrapVisibleLine(line, width), - ); + return wrapPlainText(text, width); } function messageTranscript(raw: string, theme: Theme, width: number): string[] | undefined { diff --git a/packages/eve/src/cli/dev/tui/traces/trace-conversation.ts b/packages/eve/src/cli/dev/tui/traces/trace-conversation.ts index 41897adcc..d4f36bc72 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-conversation.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-conversation.ts @@ -8,18 +8,13 @@ */ import { formatElapsed } from "#cli/format-elapsed.js"; -import { - clipVisible, - stripTerminalControls, - visibleLength, - wrapVisibleLine, -} from "#cli/ui/terminal-text.js"; +import { clipVisible, stripTerminalControls, visibleLength } from "#cli/ui/terminal-text.js"; import type { LocalTrace, LocalTraceSpan } from "#tracing/local-trace-reader.js"; import { compareLocalTraceSpans } from "#tracing/local-trace-reader.js"; import { formatCompactTokenCount } from "../stream-format.js"; import type { Theme } from "../theme.js"; -import { prettyJson, splitEmbeddedNewlines } from "./trace-content.js"; +import { formatPayloadContent, wrapPlainText } from "./trace-content.js"; export interface ConversationItem { readonly kind: "system" | "user" | "assistant" | "tool"; @@ -242,8 +237,8 @@ export function renderConversationItem( const name = colors.bold(colors.white(item.name ?? "tool")); leftTitle = `${foldMarker(expanded, expandable, theme)}${name}`; rightTitle = item.durationMs > 0 ? colors.dim(formatElapsed(item.durationMs)) : ""; - const argsLines = item.args === undefined ? [] : payloadJsonLines(item.args, inner); - const resultLines = item.result === undefined ? [] : payloadJsonLines(item.result, inner); + const argsLines = item.args === undefined ? [] : formatPayloadContent(item.args, inner); + const resultLines = item.result === undefined ? [] : formatPayloadContent(item.result, inner); const cappedArgs = expanded ? argsLines : capLines(argsLines, inner); const cappedResult = expanded ? resultLines : capLines(resultLines, inner); if (cappedArgs.length > 0) { @@ -255,7 +250,7 @@ export function renderConversationItem( } else if (item.kind === "system") { leftTitle = `${foldMarker(expanded, expandable, theme)}${colors.bold(colors.white("system"))}`; rightTitle = colors.dim(`~${formatCompactTokenCount(estimateTokens(item.text ?? ""))} tokens`); - const uncapped = wrapText(item.text ?? "", inner); + const uncapped = wrapPlainText(item.text ?? "", inner); body.push(...(expanded ? uncapped : capLines(uncapped, inner))); } else if (item.kind === "assistant") { leftTitle = `${foldMarker(expanded, expandable, theme)}${colors.bold(colors.white("assistant"))}${ @@ -265,11 +260,11 @@ export function renderConversationItem( const reasoningUncapped = item.reasoning === undefined || item.reasoning.trim().length === 0 ? [] - : payloadLines(item.reasoning, inner); + : wrapPlainText(item.reasoning, inner); const textUncapped = item.text === undefined || item.text.trim().length === 0 ? [] - : payloadLines(item.text, inner); + : wrapPlainText(item.text, inner); const reasoningLines = (expanded ? reasoningUncapped : capLines(reasoningUncapped, inner)).map( (line) => colors.dim(line), ); @@ -301,7 +296,7 @@ export function renderConversationItem( const textUncapped = item.text === undefined || item.text.trim().length === 0 ? [] - : payloadLines(item.text, inner); + : wrapPlainText(item.text, inner); body.push(...(expanded ? textUncapped : capLines(textUncapped, inner))); } // Delegated work is called out on every card of the subagent's turn so it @@ -433,27 +428,6 @@ function foldMarker(expanded: boolean, expandable: boolean, theme: Theme): strin return expandable ? `${theme.colors.dim("▸")} ` : ""; } -function wrapText(text: string, width: number): string[] { - return splitEmbeddedNewlines([stripTerminalControls(text)]).flatMap((line) => - wrapVisibleLine(line, width), - ); -} - -function payloadLines(text: string, width: number): string[] { - return wrapText(text, width); -} - -/** Tool payloads pretty-print when they're JSON; text stays text. */ -function payloadJsonLines(text: string, width: number): string[] { - try { - const parsed: unknown = JSON.parse(text); - if (typeof parsed === "object" && parsed !== null) return prettyJson(parsed, width); - } catch { - // Not JSON — fall through to plain text. - } - return payloadLines(text, width); -} - /** Caps payload lines — the standalone `…` + click hint marks the cut. */ function capLines(lines: readonly string[], width: number): string[] { if (lines.length <= CARD_PAYLOAD_LINES) return [...lines]; @@ -477,27 +451,29 @@ export function conversationItemLineCount( export function conversationItemExpandable(item: ConversationItem, width: number): boolean { const inner = Math.max(8, width - 8); if (item.kind === "tool") { - const args = item.args === undefined ? 0 : payloadJsonLines(item.args, inner - 2).length; - const result = item.result === undefined ? 0 : payloadJsonLines(item.result, inner - 2).length; + const args = item.args === undefined ? 0 : formatPayloadContent(item.args, inner - 2).length; + const result = + item.result === undefined ? 0 : formatPayloadContent(item.result, inner - 2).length; return args > CARD_PAYLOAD_LINES || result > CARD_PAYLOAD_LINES; } - if (item.kind === "system") return wrapText(item.text ?? "", inner).length > CARD_PAYLOAD_LINES; + if (item.kind === "system") + return wrapPlainText(item.text ?? "", inner).length > CARD_PAYLOAD_LINES; if (item.kind === "user") { const text = item.text === undefined || item.text.trim().length === 0 ? 0 - : payloadLines(item.text, inner).length; + : wrapPlainText(item.text, inner).length; return text > CARD_PAYLOAD_LINES; } // assistant const reasoning = item.reasoning === undefined || item.reasoning.trim().length === 0 ? 0 - : payloadLines(item.reasoning, inner).length; + : wrapPlainText(item.reasoning, inner).length; const text = item.text === undefined || item.text.trim().length === 0 ? 0 - : payloadLines(item.text, inner).length; + : wrapPlainText(item.text, inner).length; return reasoning > CARD_PAYLOAD_LINES || text > CARD_PAYLOAD_LINES; } diff --git a/packages/eve/src/cli/dev/tui/traces/trace-store.ts b/packages/eve/src/cli/dev/tui/traces/trace-store.ts index f61c4ed91..0586e4e64 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-store.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-store.ts @@ -7,19 +7,14 @@ * of parsing spans, keeping a 1s poll cheap even with a large spool. */ -import { readdir, readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; - import type { LocalTrace, LocalTraceSpan } from "#tracing/local-trace-reader.js"; -import { assembleLocalTrace, parseLocalTraceSegment } from "#tracing/local-trace-reader.js"; import { - resolveLocalTraceSchemaDirectory, - resolveLocalTraceSegmentsDirectory, -} from "#tracing/local-trace-span-processor.js"; - -const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/u; -const SPAN_FILE_PATTERN = /^[0-9a-f]{16}\.otlp\.json$/u; -const MAX_SEGMENT_BYTES = 8 * 1024 * 1024; + assembleLocalTrace, + listLocalTraceIds, + listLocalTraceSegments, + readLocalTraceActivityMs, + readLocalTraceSegment, +} from "#tracing/local-trace-reader.js"; export interface TraceStoreEntry { readonly traceId: string; @@ -39,7 +34,6 @@ export interface TraceStore { } export function createTraceStore(options: { readonly appRoot: string }): TraceStore { - const schemaRoot = resolveLocalTraceSchemaDirectory(options.appRoot); const caches = new Map< string, { seen: Set; spans: Map; assembled?: LocalTrace } @@ -47,24 +41,10 @@ export function createTraceStore(options: { readonly appRoot: string }): TraceSt return { async list() { - let entries; - try { - entries = await readdir(schemaRoot, { withFileTypes: true }); - } catch (error) { - if (isMissing(error)) return []; - throw error; - } const traces: TraceStoreEntry[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || !TRACE_ID_PATTERN.test(entry.name)) continue; - try { - const segments = await stat( - resolveLocalTraceSegmentsDirectory(options.appRoot, entry.name), - ); - traces.push({ traceId: entry.name, lastActivityMs: segments.mtimeMs }); - } catch (error) { - if (!isMissing(error)) throw error; - } + for (const traceId of await listLocalTraceIds(options.appRoot)) { + const lastActivityMs = await readLocalTraceActivityMs(options.appRoot, traceId); + if (lastActivityMs !== undefined) traces.push({ traceId, lastActivityMs }); } return traces.sort((left, right) => right.lastActivityMs === left.lastActivityMs @@ -74,16 +54,10 @@ export function createTraceStore(options: { readonly appRoot: string }): TraceSt }, async read(traceId) { - const segmentsRoot = resolveLocalTraceSegmentsDirectory(options.appRoot, traceId); - let entries; - try { - entries = await readdir(segmentsRoot, { withFileTypes: true }); - } catch (error) { - if (isMissing(error)) { - caches.delete(traceId); - return undefined; - } - throw error; + const fileNames = await listLocalTraceSegments(options.appRoot, traceId); + if (fileNames === undefined) { + caches.delete(traceId); + return undefined; } let cache = caches.get(traceId); @@ -92,19 +66,10 @@ export function createTraceStore(options: { readonly appRoot: string }): TraceSt caches.set(traceId, cache); } let changed = false; - for (const entry of entries) { - if (!entry.isFile() || !SPAN_FILE_PATTERN.test(entry.name)) continue; - if (cache.seen.has(entry.name)) continue; - cache.seen.add(entry.name); - let content: string; - try { - const path = join(segmentsRoot, entry.name); - if ((await stat(path)).size > MAX_SEGMENT_BYTES) continue; - content = await readFile(path, "utf8"); - } catch { - continue; - } - for (const span of parseLocalTraceSegment(content, traceId)) { + for (const fileName of fileNames) { + if (cache.seen.has(fileName)) continue; + cache.seen.add(fileName); + for (const span of await readLocalTraceSegment(options.appRoot, traceId, fileName)) { if (!cache.spans.has(span.spanId)) { cache.spans.set(span.spanId, span); changed = true; @@ -119,7 +84,3 @@ export function createTraceStore(options: { readonly appRoot: string }): TraceSt }, }; } - -function isMissing(error: unknown): boolean { - return (error as NodeJS.ErrnoException).code === "ENOENT"; -} diff --git a/packages/eve/src/tracing/local-trace-reader.ts b/packages/eve/src/tracing/local-trace-reader.ts index bfc775a7b..8dbcd8e4f 100644 --- a/packages/eve/src/tracing/local-trace-reader.ts +++ b/packages/eve/src/tracing/local-trace-reader.ts @@ -11,10 +11,13 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { resolveLocalTraceSchemaDirectory } from "#tracing/local-trace-span-processor.js"; +import { + resolveLocalTraceSchemaDirectory, + resolveLocalTraceSegmentsDirectory, +} from "#tracing/local-trace-span-processor.js"; const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/u; -const SPAN_FILE_PATTERN = /^([0-9a-f]{16})\.otlp\.json$/u; +const SPAN_FILE_PATTERN = /^[0-9a-f]{16}\.otlp\.json$/u; const MAX_SEGMENT_BYTES = 8 * 1024 * 1024; const MAX_UINT64 = 18_446_744_073_709_551_615n; @@ -49,21 +52,89 @@ export interface LocalTrace { readonly window?: number; } -/** Reads valid local traces, newest first, while ignoring malformed segments. */ -export async function listLocalTraces(appRoot: string): Promise { - const root = resolveLocalTraceSchemaDirectory(appRoot); +/** + * Trace ids held by the spool, unordered. Empty when nothing has been written + * yet; any other read fault propagates. + */ +export async function listLocalTraceIds(appRoot: string): Promise { let entries; try { - entries = await readdir(root, { withFileTypes: true }); + entries = await readdir(resolveLocalTraceSchemaDirectory(appRoot), { withFileTypes: true }); } catch (error) { if (isMissing(error)) return []; throw error; } + return entries + .filter((entry) => entry.isDirectory() && TRACE_ID_PATTERN.test(entry.name)) + .map((entry) => entry.name); +} +/** + * Instant one trace last received a span, or `undefined` once retention has + * pruned it. Reads the segments directory's mtime, so it stays cheap enough to + * poll a large spool without parsing any span. + */ +export async function readLocalTraceActivityMs( + appRoot: string, + traceId: string, +): Promise { + try { + return (await stat(resolveLocalTraceSegmentsDirectory(appRoot, traceId))).mtimeMs; + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } +} + +/** + * Segment file names of one trace in read order, or `undefined` when the trace + * is gone. Segments are immutable and named for their span, so the order is + * stable across reads and a caller can treat names it has seen as parsed. + */ +export async function listLocalTraceSegments( + appRoot: string, + traceId: string, +): Promise { + let entries; + try { + entries = await readdir(resolveLocalTraceSegmentsDirectory(appRoot, traceId), { + withFileTypes: true, + }); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + return entries + .filter((entry) => entry.isFile() && SPAN_FILE_PATTERN.test(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); +} + +/** + * Spans held by one segment file. An oversized, unreadable, or malformed + * segment yields none, so a partial write never breaks a view. + */ +export async function readLocalTraceSegment( + appRoot: string, + traceId: string, + fileName: string, +): Promise { + const path = join(resolveLocalTraceSegmentsDirectory(appRoot, traceId), fileName); + let content: string; + try { + if ((await stat(path)).size > MAX_SEGMENT_BYTES) return []; + content = await readFile(path, "utf8"); + } catch { + return []; + } + return parseLocalTraceSegment(content, traceId); +} + +/** Reads valid local traces, newest first, while ignoring malformed segments. */ +export async function listLocalTraces(appRoot: string): Promise { const traces: LocalTrace[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || !TRACE_ID_PATTERN.test(entry.name)) continue; - const trace = await readLocalTrace(root, entry.name).catch(() => undefined); + for (const traceId of await listLocalTraceIds(appRoot)) { + const trace = await readLocalTrace(appRoot, traceId).catch(() => undefined); if (trace !== undefined) traces.push(trace); } return traces.sort((left, right) => @@ -76,33 +147,15 @@ export async function listLocalTraces(appRoot: string): Promise { } /** - * Reads one trace from its segments directory, or `undefined` when the trace - * has no valid spans. Spans come back ordered by start time. + * Reads one whole trace, or `undefined` when it holds no valid spans. Spans + * come back ordered by start time. */ -export async function readLocalTrace( - root: string, - traceId: string, -): Promise { - const segmentsRoot = join(root, traceId, "segments"); - let entries; - try { - entries = await readdir(segmentsRoot, { withFileTypes: true }); - } catch (error) { - if (isMissing(error)) return undefined; - throw error; - } +async function readLocalTrace(appRoot: string, traceId: string): Promise { + const fileNames = await listLocalTraceSegments(appRoot, traceId); + if (fileNames === undefined) return undefined; const spans = new Map(); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!entry.isFile() || !SPAN_FILE_PATTERN.test(entry.name)) continue; - let content: string; - try { - const path = join(segmentsRoot, entry.name); - if ((await stat(path)).size > MAX_SEGMENT_BYTES) continue; - content = await readFile(path, "utf8"); - } catch { - continue; - } - for (const span of parseLocalTraceSegment(content, traceId)) { + for (const fileName of fileNames) { + for (const span of await readLocalTraceSegment(appRoot, traceId, fileName)) { if (!spans.has(span.spanId)) spans.set(span.spanId, span); } }