singularity-forge/src/resources/traces.ts

235 lines
5.6 KiB
TypeScript

/**
* Structured Trace Data Model & Export
*
* Provides a hierarchical span model for tracing autonomous mode execution.
* Spans form a tree: root session span → unit spans (milestone/slice/task) → tool spans.
*
* Two export modes:
* exportTrace(path) — write to arbitrary path
* exportTraceToProject(dir) — write to .sf/traces/ in project dir
*/
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SpanKind = "session" | "unit" | "tool";
export type SpanStatus =
| "ok"
| "error"
| "cancelled"
| "timeout"
| "in_progress";
export interface TraceEvent {
name: string;
timestamp: number;
attributes?: Record<string, string | number | boolean | null>;
}
export interface SpanAttributes {
// Session-level
projectRoot?: string;
sessionId?: string;
cwd?: string;
command?: string;
model?: string;
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
costUsd?: number;
exitCode?: number;
// Unit-level
unitType?: "milestone" | "slice" | "task";
unitId?: string;
unitStatus?: SpanStatus;
unitErrorReason?: string;
// Tool-level
toolName?: string;
toolCallId?: string;
toolStatus?: SpanStatus;
toolError?: string;
toolDurationMs?: number;
}
export interface Span {
id: string;
name: string;
kind: SpanKind;
status: SpanStatus;
startTime: number;
endTime?: number;
attributes: SpanAttributes;
children: Span[];
events: TraceEvent[];
}
export interface Trace {
id: string;
version: number;
projectRoot: string;
sessionId?: string;
startedAt: string;
completedAt?: string;
rootSpan: Span;
}
// ---------------------------------------------------------------------------
// Span helpers
// ---------------------------------------------------------------------------
/** Create a new span with a random UUID and current timestamp. */
export function createSpan(
name: string,
kind: SpanKind,
attributes: SpanAttributes = {},
): Span {
return {
id: randomUUID(),
name,
kind,
status: "in_progress",
startTime: Date.now(),
attributes,
children: [],
events: [],
};
}
/** Mark a span as complete and record end time. */
export function endSpan(span: Span, status: SpanStatus = "ok"): Span {
span.status = status;
span.endTime = Date.now();
return span;
}
/** Append a named event to a span with optional attributes. */
export function addEvent(
span: Span,
name: string,
attributes?: Record<string, string | number | boolean | null>,
): void {
span.events.push({
name,
timestamp: Date.now(),
attributes,
});
}
/** Append an error event to a span with message and optional stack. */
export function addError(span: Span, message: string, stack?: string): void {
span.events.push({
name: "error",
timestamp: Date.now(),
attributes: {
message,
...(stack ? { stack } : {}),
},
});
span.status = "error";
if (!span.endTime) span.endTime = Date.now();
}
// ---------------------------------------------------------------------------
// Trace helpers
// ---------------------------------------------------------------------------
/** Create a new trace with a root session span. */
export function createTrace(
projectRoot: string,
sessionId?: string,
command?: string,
model?: string,
): Trace {
const rootSpan = createSpan(`session:${sessionId ?? "unknown"}`, "session", {
sessionId,
projectRoot,
command,
model,
});
return {
id: randomUUID(),
version: 1,
projectRoot,
sessionId,
startedAt: new Date().toISOString(),
rootSpan,
};
}
/** Finalize a trace: set completedAt timestamp. */
export function finalizeTrace(trace: Trace): Trace {
trace.completedAt = new Date().toISOString();
return trace;
}
/** Find a span in the tree by ID (linear walk). */
export function findSpan(span: Span, id: string): Span | undefined {
if (span.id === id) return span;
for (const child of span.children) {
const found = findSpan(child, id);
if (found) return found;
}
return undefined;
}
/** Add a child span to a parent. */
export function addChildSpan(parent: Span, child: Span): void {
parent.children.push(child);
}
/** Walk all spans in a trace (root first, depth-first). Yields each span. */
export function* walkSpans(span: Span): Generator<Span, void, unknown> {
yield span;
for (const child of span.children) {
yield* walkSpans(child);
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
/**
* Serialize and write a trace to an arbitrary path.
* Creates parent directories as needed.
*/
export function exportTrace(trace: Trace, path: string): void {
const dir = join(path, "..");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(path, JSON.stringify(trace, null, 2), "utf-8");
}
/**
* Serialize and write a trace to .sf/traces/ in the project root.
* Filename: trace-<timestamp>.json
*/
export function exportTraceToProject(
trace: Trace,
projectRoot: string,
): string {
const tracesDir = join(projectRoot, ".sf", "traces");
if (!existsSync(tracesDir)) {
mkdirSync(tracesDir, { recursive: true });
}
const filename = `trace-${Date.now()}.json`;
const path = join(tracesDir, filename);
writeFileSync(path, JSON.stringify(trace, null, 2), "utf-8");
return path;
}
/**
* Read a trace from disk.
*/
export function readTrace(path: string): Trace {
return JSON.parse(readFileSync(path, "utf-8")) as Trace;
}