Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/trace-spool-single-owner.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 0 additions & 2 deletions packages/eve/src/cli/commands/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ interface CliTraceLogger {
log(message: string): void;
}

export type { LocalTrace, LocalTraceSpan };

interface SpanExtent {
readonly endTimeNs: bigint;
readonly startTimeNs: bigint;
Expand Down
21 changes: 17 additions & 4 deletions packages/eve/src/cli/dev/tui/traces/trace-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 16 additions & 40 deletions packages/eve/src/cli/dev/tui/traces/trace-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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"))}${
Expand All @@ -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),
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand All @@ -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;
}

Expand Down
73 changes: 17 additions & 56 deletions packages/eve/src/cli/dev/tui/traces/trace-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,32 +34,17 @@ export interface TraceStore {
}

export function createTraceStore(options: { readonly appRoot: string }): TraceStore {
const schemaRoot = resolveLocalTraceSchemaDirectory(options.appRoot);
const caches = new Map<
string,
{ seen: Set<string>; spans: Map<string, LocalTraceSpan>; assembled?: LocalTrace }
>();

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
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -119,7 +84,3 @@ export function createTraceStore(options: { readonly appRoot: string }): TraceSt
},
};
}

function isMissing(error: unknown): boolean {
return (error as NodeJS.ErrnoException).code === "ENOENT";
}
Loading
Loading