4a899a265f
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
199 lines
9.0 KiB
TypeScript
199 lines
9.0 KiB
TypeScript
/**
|
|
* Trace writer — append-only JSON Lines.
|
|
*
|
|
* Docs: packages/docs/content/sessions-and-traces.{zh,en}.md (site path
|
|
* /docs/sessions-and-traces) documents the file layout and recording rules.
|
|
*
|
|
* Design points:
|
|
* - Every observable action is appended to Trace; historical events are never modified in place
|
|
* (append-only).
|
|
* - One Trace file corresponds to one complete model context; when the context is compacted
|
|
* and a new segment is produced, `rotate()` starts a new, separately numbered file.
|
|
* - Only "recordable" messages are written: `session_meta`, complete `model_msg`, and all
|
|
* `event_msg`; streaming `partial_*` messages are skipped (the producer appends the
|
|
* corresponding complete message once the segment ends); nested child-session messages are
|
|
* never written (their spawn location is recorded via the `subagent` pointer event written by
|
|
* context_engine).
|
|
* - Path convention: `<tracesDir>/<yyyy-mm-dd>/<sessionId>_<index3>.jsonl`.
|
|
*/
|
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import {
|
|
PartialAggregator,
|
|
isCompleteModelMessage,
|
|
isEventMessage,
|
|
isSessionMeta,
|
|
} from "../omnimessage/index.js";
|
|
import type { OmniMessage } from "../omnimessage/index.js";
|
|
import { formatLocalDate } from "../internal/dates.js";
|
|
|
|
export interface WriterOptions {
|
|
/** Trace root directory, typically `<agent>/traces`. */
|
|
tracesDir: string;
|
|
/** Current Session id, written into the file name. */
|
|
sessionId: string;
|
|
/** The time used to derive the date subdirectory; defaults to `new Date()`. */
|
|
date?: Date;
|
|
/**
|
|
* Directly specifies the date subdirectory name (used when Session resumption continues
|
|
* writing to the original file: the Trace file follows the context, not the date); takes
|
|
* priority over `date`.
|
|
*/
|
|
dateDir?: string;
|
|
/** Starting Trace index (used when Session resumption continues the original index); defaults to 1. */
|
|
startIndex?: number;
|
|
}
|
|
|
|
/** Zero-pads a Trace index to 3 digits, e.g. 1 -> "001". */
|
|
function formatIndex(index: number): string {
|
|
return index.toString().padStart(3, "0");
|
|
}
|
|
|
|
/**
|
|
* Determines whether an OmniMessage should be written to Trace (skips streaming partial_* and nested child-session messages).
|
|
*
|
|
* Child-session messages are never written to this Trace: the child Session has its own complete
|
|
* Trace, and recording it again would distort this Trace's statistics. The spawn location is
|
|
* recorded via the `subagent` pointer event (recording only the child Session id) that
|
|
* context_engine writes at the spawn site; when the session is reopened, the server uses this to
|
|
* re-attach the child session to its corresponding run_subagent tool card.
|
|
* Docs: /docs/sessions-and-traces § "Trace design".
|
|
*/
|
|
function isRecordable(msg: OmniMessage): boolean {
|
|
if (msg.origin && msg.origin.length > 0) return false;
|
|
return isCompleteModelMessage(msg) || isEventMessage(msg) || isSessionMeta(msg);
|
|
}
|
|
|
|
/**
|
|
* append-only JSONL Trace writer.
|
|
*
|
|
* Every write uses `appendFile` (O_APPEND) rather than caching a file handle and seeking to
|
|
* write, avoiding overwriting existing content; this also removes the need for an explicit
|
|
* close.
|
|
*
|
|
* Concurrency: one live Session has exactly one Writer instance, but that instance receives
|
|
* appends from **multiple async producers in the same process** — the engine's LLM stream
|
|
* driver and each parallel tool execution write independently. `appendFile` splits large
|
|
* payloads (multi-MB records such as base64 image Data URLs) into multiple underlying writes,
|
|
* so two overlapping appends can interleave mid-record and corrupt the JSONL (#215). All
|
|
* mutating operations (`write`, `rotate`) are therefore serialized through one per-instance
|
|
* promise chain: each record lands as one uninterrupted append, and a rotation cannot land in
|
|
* the middle of an append. Cross-instance/file concurrency does not occur by design **within a
|
|
* single server/CLI process**: a Session allows one active run at a time, child sessions write
|
|
* their own files, and server-side Trace import only ever creates brand-new files (`wx`). Two
|
|
* processes pointed at the same agent data directory are outside this contract (and outside
|
|
* supported usage) — the chain cannot cover them.
|
|
*
|
|
* Error semantics: a failed operation rejects **that caller's** returned promise only; the
|
|
* chain itself absorbs the failure so subsequent operations still run (a transient disk error
|
|
* must not wedge Trace recording for the rest of the session). A **hung** append is different:
|
|
* it blocks this instance's chain until it settles — deliberate head-of-line blocking, because
|
|
* ordering cannot be preserved around an append whose outcome is still unknown. Callers
|
|
* (context_engine, Session) already treat Trace writes as best-effort and log the surfaced
|
|
* error.
|
|
*/
|
|
export class Writer {
|
|
private readonly tracesDir: string;
|
|
private readonly sessionId: string;
|
|
private readonly dateDir: string;
|
|
/** Current Trace index, starting at 1; incremented by `rotate()`. */
|
|
private index = 1;
|
|
/** Set true once the date directory has been created for the current file, to avoid a redundant mkdir. */
|
|
private ensuredDirForIndex = -1;
|
|
/** Serialization chain: mutating operations run strictly in submission order (see class docs). */
|
|
private chain: Promise<void> = Promise.resolve();
|
|
|
|
constructor(opts: WriterOptions) {
|
|
this.tracesDir = opts.tracesDir;
|
|
this.sessionId = opts.sessionId;
|
|
this.dateDir = opts.dateDir ?? formatLocalDate(opts.date ?? new Date());
|
|
this.index = opts.startIndex ?? 1;
|
|
}
|
|
|
|
/** Absolute path of the current Trace file. */
|
|
currentPath(): string {
|
|
const fileName = `${this.sessionId}_${formatIndex(this.index)}.jsonl`;
|
|
return join(this.tracesDir, this.dateDir, fileName);
|
|
}
|
|
|
|
/**
|
|
* Enqueues one mutating operation on the serialization chain. The returned promise settles
|
|
* with that operation's own outcome (so a failure surfaces to its caller), while the chain
|
|
* swallows the failure and proceeds with whatever was enqueued next.
|
|
*/
|
|
private enqueue<T>(op: () => Promise<T>): Promise<T> {
|
|
const result = this.chain.then(op);
|
|
this.chain = result.then(
|
|
() => undefined,
|
|
() => undefined, // a failed operation must not wedge the chain
|
|
);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Appends one message. Only written if it's a recordable message; streaming `partial_*` is
|
|
* skipped. `mkdir -p`s the date directory on the first write to the current file.
|
|
*
|
|
* The append is serialized on the instance chain, so the record lands as one uninterrupted
|
|
* JSONL line even when other writes (or a rotation) are submitted concurrently; the target
|
|
* path is resolved when the operation runs, so a write submitted after `rotate()` goes to
|
|
* the new file. The returned promise resolves only after this record has been appended
|
|
* (callers may rely on write-then-send ordering), and rejects if this append failed.
|
|
*/
|
|
async write(msg: OmniMessage): Promise<void> {
|
|
if (!isRecordable(msg)) return;
|
|
return this.enqueue(async () => {
|
|
const path = this.currentPath();
|
|
if (this.ensuredDirForIndex !== this.index) {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
this.ensuredDirForIndex = this.index;
|
|
}
|
|
await appendFile(path, `${JSON.stringify(msg)}\n`, "utf8");
|
|
});
|
|
}
|
|
|
|
/** Writes multiple messages in sequence. */
|
|
async writeAll(msgs: OmniMessage[]): Promise<void> {
|
|
for (const msg of msgs) {
|
|
await this.write(msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aggregates a message stream mixed with streaming `partial_*` into complete messages first,
|
|
* then writes them per the `write` convention. A convenience helper: `write` skips partial_*
|
|
* by default (the producer will already append the complete message), so this method is only
|
|
* needed when reconstructing a complete context from raw streaming fragments.
|
|
*/
|
|
async aggregateAndWrite(msgs: OmniMessage[]): Promise<void> {
|
|
const agg = new PartialAggregator();
|
|
for (const msg of msgs) {
|
|
await this.writeAll(agg.push(msg));
|
|
}
|
|
await this.writeAll(agg.flush());
|
|
}
|
|
|
|
/**
|
|
* Starts a new Trace file: increments the index, so the next `write` goes to the new file.
|
|
* Used to split into a separate file when the context is compacted and a new context segment is produced.
|
|
* Serialized on the instance chain: appends submitted before the rotation land in the old
|
|
* file, appends submitted after it land in the new one — a rotation can never split a record.
|
|
* Docs: /docs/sessions-and-traces § "Trace design".
|
|
*/
|
|
async rotate(): Promise<void> {
|
|
return this.enqueue(async () => {
|
|
this.index += 1;
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Parses a Trace file line by line (ignoring blank lines), for testing and later reads. */
|
|
export async function readTrace(path: string): Promise<OmniMessage[]> {
|
|
const content = await readFile(path, "utf8");
|
|
return content
|
|
.split("\n")
|
|
.filter((line) => line.trim().length > 0)
|
|
.map((line) => JSON.parse(line) as OmniMessage);
|
|
}
|