@vincemakes/kiso-runtime 0.1.38 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * E1 (1.2.0) — the Request Trace record schema (proposal §1.1 as adopted
3
+ * by the R1 ruling: "adopt as-is, keep ts, per-round additive"). This is
4
+ * the 1.2.0 field-set lock: every field below is spec'd, and the
5
+ * closed-field-set gate (ruling R1a) pins the shape bidirectionally —
6
+ * `TRACE_RECORD_FIELDS`/`TRACE_SEGMENT_FIELDS` must stay in exact
7
+ * agreement with the interfaces, or trace-schema.test.ts goes red.
8
+ *
9
+ * R1a: `schemaVersion` pins BOTH the record shape AND the hash /
10
+ * fingerprint algorithms (`HASH_SPEC_BY_VERSION`). A version bump must
11
+ * pin a spec for the new version before any writer can use it
12
+ * (`hashSpecFor` throws otherwise).
13
+ *
14
+ * The ledger is OUT-side (ADR-0051 §6): versionable, never part of the
15
+ * correctness ABI. These types are runtime-internal.
16
+ *
17
+ * E2 (1.3.0) — schemaVersion 2: the record gains the `canonical` block
18
+ * (E2 proposal §1.5 Case A — the nested block, ruled 2026-08-13; raw
19
+ * quartet stays as provider observation above it). The validators accept
20
+ * BOTH generations (R1d-1): a v1 sidecar has no canonical block and reads
21
+ * as defaults at every consumer — never a crash.
22
+ *
23
+ * E3 (0.2.1) — schemaVersion 3: the record gains the `rent` block — the
24
+ * static rent ledger, one line per surface (trace/rent.ts). The v3
25
+ * writers record it; v1/v2 sidecars keep reading as defaults (R1d-1,
26
+ * R2-1): no rent block = no rent lines = the zero-rent reading, never a
27
+ * crash.
28
+ */
29
+ /** schemaVersion: 3 for 0.2.1 (the rent block). Version 1 = the 1.2.0
30
+ * shape, version 2 = the 1.3.0 shape; both kept for generation-compat
31
+ * reads (R1d-1, R2-1). Algorithm and shape changes bump it (ADR-0051 §6
32
+ * OUT-side versioning). */
33
+ export const TRACE_SCHEMA_VERSION = 3;
34
+ /** The versions a reader may meet in a ledger. v1 and v2 records are
35
+ * accepted (generation-compat) and read as defaults — no canonical
36
+ * block (v1), no rent block (v1, v2). */
37
+ export const TRACE_SCHEMA_VERSIONS = new Set([1, 2, TRACE_SCHEMA_VERSION]);
38
+ import { PRICING_TABLE_V1, priceFor, pricingTableFor, validateCanonicalUsage } from "../usage/canonical.js";
39
+ import { validateRentLine } from "./rent.js";
40
+ export const HASH_SPEC_BY_VERSION = {
41
+ 1: { algorithm: "sha-256", output: "full-hex" },
42
+ 2: { algorithm: "sha-256", output: "full-hex" }, // E2 — the algorithms do not change
43
+ 3: { algorithm: "sha-256", output: "full-hex" }, // E3 — same algorithms, re-pinned (the E2 ritual)
44
+ };
45
+ export function hashSpecFor(version) {
46
+ const spec = HASH_SPEC_BY_VERSION[version];
47
+ if (spec === undefined)
48
+ throw new Error(`no hash spec pinned for trace schemaVersion ${version}`);
49
+ return spec;
50
+ }
51
+ // ── The closed-field-set gate (R1a) ───────────────────────────────────────
52
+ // Trace-schema.test.ts asserts Object.keys of a fully populated record is
53
+ // EXACTLY this set (both directions).
54
+ /** The 1.2.0 field set (schemaVersion 1) — kept verbatim for
55
+ * generation-compat reads of old sidecars (R1d-1): a v1 record has no
56
+ * canonical block and reads as defaults at every consumer. */
57
+ export const TRACE_RECORD_FIELDS_V1 = [
58
+ "schemaVersion",
59
+ "kind",
60
+ "requestId",
61
+ "runId",
62
+ "requestIndex",
63
+ "retryAttempt",
64
+ "provider",
65
+ "model",
66
+ "adapterVersion",
67
+ "systemPromptHash",
68
+ "toolSchemaHash",
69
+ "contextHash",
70
+ "contextManifest",
71
+ "segmentHashes",
72
+ "stablePrefixFingerprint",
73
+ "freshInput",
74
+ "cacheRead",
75
+ "cacheWrite",
76
+ "output",
77
+ "latencyMs",
78
+ "ttftMs",
79
+ "toolCalls",
80
+ "outcome",
81
+ "lineageLink",
82
+ "ts",
83
+ ];
84
+ /** The 1.3.0 field set (schemaVersion 2) = the v1 set + `canonical`. */
85
+ export const TRACE_RECORD_FIELDS_V2 = [...TRACE_RECORD_FIELDS_V1, "canonical"];
86
+ /** The 0.2.1 field set (schemaVersion 3) = the v2 set + `rent`. */
87
+ export const TRACE_RECORD_FIELDS = [...TRACE_RECORD_FIELDS_V2, "rent"];
88
+ export const TRACE_SEGMENT_FIELDS = ["role", "seqRange", "estTokens", "freshness"];
89
+ // ── Validators ────────────────────────────────────────────────────────────
90
+ // Strict by design: extra keys are rejected (the closed set), so a
91
+ // misspelled field can never silently enter the ledger.
92
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
93
+ const keysOf = (v) => (isRecord(v) ? Object.keys(v) : []);
94
+ const isNonNegInt = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
95
+ const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
96
+ const isHex64 = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
97
+ /** The closed set, both directions: no key outside the spec, and every
98
+ * spec'd key present except the explicit optional ones. */
99
+ const hasClosedKeys = (v, spec, optional = []) => {
100
+ const keys = keysOf(v);
101
+ if (keys.some((k) => !spec.includes(k)))
102
+ return false;
103
+ const optionalSet = new Set(optional);
104
+ return spec.every((k) => optionalSet.has(k) || keys.includes(k));
105
+ };
106
+ const VALID_SEGMENT_ROLES = new Set(["system", "tools", "turn", "current_turn"]);
107
+ const VALID_FRESHNESS = new Set(["fresh", "cache_read", "cache_write"]);
108
+ const VALID_OUTCOMES = new Set(["ok", "provider_error", "aborted"]);
109
+ function isValidSeqRange(v) {
110
+ if (v === null)
111
+ return true;
112
+ if (!Array.isArray(v) || v.length !== 2)
113
+ return false;
114
+ const [a, b] = v;
115
+ return isNonNegInt(a) && isNonNegInt(b) && a <= b;
116
+ }
117
+ export function validateTraceSegment(v) {
118
+ if (!isRecord(v) || !hasClosedKeys(v, TRACE_SEGMENT_FIELDS))
119
+ return false;
120
+ if (typeof v.role !== "string" || !VALID_SEGMENT_ROLES.has(v.role))
121
+ return false;
122
+ if (!isValidSeqRange(v.seqRange))
123
+ return false;
124
+ if (!isNonNegInt(v.estTokens))
125
+ return false;
126
+ if (!VALID_FRESHNESS.has(v.freshness))
127
+ return false;
128
+ return true;
129
+ }
130
+ export function validateTraceRecord(v) {
131
+ if (!isRecord(v))
132
+ return false;
133
+ const version = v.schemaVersion;
134
+ // generation-compat (R1d-1, R2-1): a v1 sidecar has no canonical
135
+ // block, a v2 sidecar has no rent block — both read as defaults,
136
+ // accepted, never a crash; the current version is fully checked
137
+ // (shape + the canonical block + the rent ledger).
138
+ if (version !== 1 && version !== 2 && version !== TRACE_SCHEMA_VERSION)
139
+ return false;
140
+ const fields = version === 1 ? TRACE_RECORD_FIELDS_V1 : version === 2 ? TRACE_RECORD_FIELDS_V2 : TRACE_RECORD_FIELDS;
141
+ if (!hasClosedKeys(v, fields, ["lineageLink"]))
142
+ return false;
143
+ if (v.kind !== "request")
144
+ return false;
145
+ if (typeof v.requestId !== "string" || typeof v.runId !== "string")
146
+ return false;
147
+ if (!isNonNegInt(v.requestIndex) || !isNonNegInt(v.retryAttempt))
148
+ return false;
149
+ if (typeof v.provider !== "string" || typeof v.model !== "string")
150
+ return false;
151
+ if (v.adapterVersion !== null && typeof v.adapterVersion !== "string")
152
+ return false;
153
+ if (!isHex64(v.systemPromptHash) || !isHex64(v.toolSchemaHash) || !isHex64(v.contextHash))
154
+ return false;
155
+ if (!isHex64(v.stablePrefixFingerprint))
156
+ return false;
157
+ if (!Array.isArray(v.contextManifest) || !v.contextManifest.every(validateTraceSegment))
158
+ return false;
159
+ // segmentHashes must mirror the manifest 1:1 — a misaligned list
160
+ // would silently corrupt the break derivation (R4b)
161
+ if (!Array.isArray(v.segmentHashes) ||
162
+ v.segmentHashes.length !== v.contextManifest.length ||
163
+ !v.segmentHashes.every(isHex64))
164
+ return false;
165
+ if (!isNumber(v.freshInput) || !isNumber(v.cacheRead))
166
+ return false;
167
+ if (v.cacheWrite !== null && !isNumber(v.cacheWrite))
168
+ return false;
169
+ if (!isNumber(v.output) || !isNumber(v.latencyMs))
170
+ return false;
171
+ if (!isNumber(v.ttftMs))
172
+ return false; // 0 = unknown, never null (locked set)
173
+ if (!Array.isArray(v.toolCalls) || !v.toolCalls.every((t) => typeof t === "string"))
174
+ return false;
175
+ if (typeof v.outcome !== "string" || !VALID_OUTCOMES.has(v.outcome))
176
+ return false;
177
+ if (v.lineageLink !== undefined) {
178
+ const l = v.lineageLink;
179
+ if (!isRecord(l))
180
+ return false;
181
+ if (typeof l.parentSessionId !== "string" || typeof l.parentRunId !== "string")
182
+ return false;
183
+ if (!isNonNegInt(l.parentInvocationSeq))
184
+ return false;
185
+ if (typeof l.role !== "string")
186
+ return false;
187
+ }
188
+ if (version !== 1) {
189
+ // the canonical block: the schema's invariants machine-checked
190
+ if (!validateCanonicalUsage(v.canonical))
191
+ return false;
192
+ const c = v.canonical;
193
+ // the block formalizes the raw quartet — a divergence means the
194
+ // derivation drifted (a future bug, caught at the ledger)
195
+ if (c.input !== v.freshInput || c.cacheRead !== v.cacheRead || c.output !== v.output || c.cacheWrite !== v.cacheWrite)
196
+ return false;
197
+ // cost consistency: recomputed from the components × the version's
198
+ // pinned table, at the record's own route (the route context lives
199
+ // in the record; the standalone schema cannot check this). A null
200
+ // costUsd is the R5b-④c absent stamp (the table has no rate for
201
+ // this route) — nothing to recompute against, accepted. The
202
+ // cross-check runs only for builtin-id records: a foreign table's
203
+ // consistency is the billing layer's accounting, not the ledger's.
204
+ if (c.costUsd !== null && c.pricingTableId === PRICING_TABLE_V1.id) {
205
+ const expected = priceFor(v.provider, { input: c.input, output: c.output, cacheRead: c.cacheRead, cacheWrite: c.cacheWrite }, pricingTableFor(c.pricingTableVersion));
206
+ // non-null for the builtin table by construction (both real
207
+ // routes + the mirror fallback) — a null here is a code bug,
208
+ // and an epsilon check has nothing to compare
209
+ if (expected !== null && Math.abs(c.costUsd - expected) > 1e-6)
210
+ return false;
211
+ }
212
+ }
213
+ if (version === TRACE_SCHEMA_VERSION) {
214
+ // the rent block: every line validates (closed fields, non-empty
215
+ // surface, non-negative integer chars, the estTokens == ceil(chars/4)
216
+ // cross-check — R6). v1/v2 sidecars have no block (R2-1).
217
+ if (!Array.isArray(v.rent) || !v.rent.every(validateRentLine))
218
+ return false;
219
+ }
220
+ if (!isNumber(v.ts))
221
+ return false;
222
+ return true;
223
+ }
224
+ export function validateTraceLine(v) {
225
+ if (!isRecord(v))
226
+ return false;
227
+ // both ledger generations are readable (R1d-1); the per-kind shapes are
228
+ // identical across 1 → 2 — only the request line's field set differs
229
+ // (v1 lacks the canonical block), handled by validateTraceRecord's
230
+ // version dispatch
231
+ if (!TRACE_SCHEMA_VERSIONS.has(v.schemaVersion))
232
+ return false;
233
+ switch (v.kind) {
234
+ case "header":
235
+ return (hasClosedKeys(v, ["schemaVersion", "kind", "sessionId", "kisoVersion", "createdAt"]) &&
236
+ typeof v.sessionId === "string" &&
237
+ typeof v.kisoVersion === "string" &&
238
+ isNumber(v.createdAt));
239
+ case "request":
240
+ return validateTraceRecord(v);
241
+ case "run_end":
242
+ return (hasClosedKeys(v, ["schemaVersion", "kind", "runId", "ts", "lastRequestIndex"]) &&
243
+ typeof v.runId === "string" &&
244
+ isNumber(v.ts) &&
245
+ // -1 = the run made no adapter calls (an empty run still
246
+ // settles cleanly); anything below that is not a request index
247
+ typeof v.lastRequestIndex === "number" &&
248
+ Number.isInteger(v.lastRequestIndex) &&
249
+ v.lastRequestIndex >= -1);
250
+ case "crash":
251
+ return (hasClosedKeys(v, ["schemaVersion", "kind", "ts", "note"]) &&
252
+ isNumber(v.ts) &&
253
+ typeof v.note === "string");
254
+ default:
255
+ return false;
256
+ }
257
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * E3 (0.2.1) — the rent ledger: every request's static model-side rent,
3
+ * one line per surface.
4
+ *
5
+ * The static rent is the surface the model pays REGARDLESS of the turn's
6
+ * content: the base system prompt, each extension's append, every tool's
7
+ * serialized spec, and the per-request envelope. One line per surface,
8
+ * four classes, measured in chars + the rounds' est-token convention
9
+ * (chars/4, ceil — the E1 script's formula, the work order's own words,
10
+ * R6):
11
+ *
12
+ * - system:base the session's own prompt as the CLI handed it
13
+ * (built-in constant + project instructions — the
14
+ * runtime cannot split those, R3). The runtime's
15
+ * generated tool table is machinery BETWEEN base and
16
+ * appends; it stays out of the ledger (counts live
17
+ * in tool:<name> lines and the base's own line).
18
+ * - system:ext:<name> one line per extension with a non-empty
19
+ * systemPrompt.append, measured on the append
20
+ * string, in load order.
21
+ * - tool:<name> one line per tool, measured on the exact
22
+ * serialized ToolSpec projection the adapters
23
+ * receive ({name, description, inputSchema} — the
24
+ * registry.toSpecs() shape, protocol/messages.ts).
25
+ * - envelope the per-request fixed overhead OUTSIDE the
26
+ * conversation payloads: the R5 skeleton
27
+ * JSON.stringify({model, messages: [], tools: []})
28
+ * — a function of the model string only, never of
29
+ * the payloads (the skeleton is the definition).
30
+ *
31
+ * R9: an absent surface is an absent line — an unconfigured mcp, a
32
+ * session with no project instructions: no line. The absence IS the
33
+ * ledger statement (the 0.1.45 diet-A precedent: not paid = no rent).
34
+ *
35
+ * Determinism (the reviewer-facing property): every line is derivable
36
+ * from components the reviewer can recompute — the exported prompt
37
+ * constant, the appends, the ToolSpec array, the request skeleton. The
38
+ * ledger stores COUNTS, never payloads (the seqRange thin-pointer
39
+ * discipline applied to rent).
40
+ *
41
+ * buildRentLedger is the SINGLE source: the R7 star gate drives a real
42
+ * session with the script's predicted composition and asserts the
43
+ * recorded ledger equals the prediction line for line.
44
+ */
45
+ import type { ToolSpec } from "@vincemakes/kiso-core";
46
+ export interface RentLine {
47
+ /** "system:base" | "system:ext:<name>" | "tool:<name>" | "envelope" */
48
+ surface: string;
49
+ /** length of the serialized surface, measured, never a copy */
50
+ chars: number;
51
+ /** Math.ceil(chars / 4) — the E1 script convention, pinned (R6) */
52
+ estTokens: number;
53
+ }
54
+ /** The closed line set (the same gate discipline as the record fields). */
55
+ export declare const RENT_LINE_FIELDS: readonly ["surface", "chars", "estTokens"];
56
+ /** What the runtime knows about the static surface beyond what the
57
+ * adapter call itself carries: the base prompt as configured, and the
58
+ * per-extension appends in load order (the adapter's composed
59
+ * systemPrompt is the result — the parts are the ledger's inputs). */
60
+ export interface RentParts {
61
+ base?: string;
62
+ appends?: readonly {
63
+ name: string;
64
+ text: string;
65
+ }[];
66
+ }
67
+ export interface RentInput extends RentParts {
68
+ model: string;
69
+ tools?: readonly ToolSpec[];
70
+ }
71
+ /** The one ledger: system:base, then system:ext:* (load order), then
72
+ * tool:* (the array's order — the registry's toSpecs() order), then the
73
+ * envelope. Absent surfaces contribute no lines (R9). */
74
+ export declare function buildRentLedger(input: RentInput): RentLine[];
75
+ /** The line validator — the same strict discipline as the record's other
76
+ * closed sets: no key outside the spec, every spec'd key present,
77
+ * non-empty surface (R9: a line exists only for a surface that exists),
78
+ * non-negative integer chars, and the estTokens cross-check (R6). The
79
+ * schema pins the LINE, not the multiset — duplicate surfaces are the
80
+ * writer's business, never a schema crash. */
81
+ export declare function validateRentLine(v: unknown): v is RentLine;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * E3 (0.2.1) — the rent ledger: every request's static model-side rent,
3
+ * one line per surface.
4
+ *
5
+ * The static rent is the surface the model pays REGARDLESS of the turn's
6
+ * content: the base system prompt, each extension's append, every tool's
7
+ * serialized spec, and the per-request envelope. One line per surface,
8
+ * four classes, measured in chars + the rounds' est-token convention
9
+ * (chars/4, ceil — the E1 script's formula, the work order's own words,
10
+ * R6):
11
+ *
12
+ * - system:base the session's own prompt as the CLI handed it
13
+ * (built-in constant + project instructions — the
14
+ * runtime cannot split those, R3). The runtime's
15
+ * generated tool table is machinery BETWEEN base and
16
+ * appends; it stays out of the ledger (counts live
17
+ * in tool:<name> lines and the base's own line).
18
+ * - system:ext:<name> one line per extension with a non-empty
19
+ * systemPrompt.append, measured on the append
20
+ * string, in load order.
21
+ * - tool:<name> one line per tool, measured on the exact
22
+ * serialized ToolSpec projection the adapters
23
+ * receive ({name, description, inputSchema} — the
24
+ * registry.toSpecs() shape, protocol/messages.ts).
25
+ * - envelope the per-request fixed overhead OUTSIDE the
26
+ * conversation payloads: the R5 skeleton
27
+ * JSON.stringify({model, messages: [], tools: []})
28
+ * — a function of the model string only, never of
29
+ * the payloads (the skeleton is the definition).
30
+ *
31
+ * R9: an absent surface is an absent line — an unconfigured mcp, a
32
+ * session with no project instructions: no line. The absence IS the
33
+ * ledger statement (the 0.1.45 diet-A precedent: not paid = no rent).
34
+ *
35
+ * Determinism (the reviewer-facing property): every line is derivable
36
+ * from components the reviewer can recompute — the exported prompt
37
+ * constant, the appends, the ToolSpec array, the request skeleton. The
38
+ * ledger stores COUNTS, never payloads (the seqRange thin-pointer
39
+ * discipline applied to rent).
40
+ *
41
+ * buildRentLedger is the SINGLE source: the R7 star gate drives a real
42
+ * session with the script's predicted composition and asserts the
43
+ * recorded ledger equals the prediction line for line.
44
+ */
45
+ /** The closed line set (the same gate discipline as the record fields). */
46
+ export const RENT_LINE_FIELDS = ["surface", "chars", "estTokens"];
47
+ const line = (surface, text) => ({
48
+ surface,
49
+ chars: text.length,
50
+ estTokens: Math.ceil(text.length / 4),
51
+ });
52
+ /** The one ledger: system:base, then system:ext:* (load order), then
53
+ * tool:* (the array's order — the registry's toSpecs() order), then the
54
+ * envelope. Absent surfaces contribute no lines (R9). */
55
+ export function buildRentLedger(input) {
56
+ const lines = [];
57
+ if (input.base !== undefined)
58
+ lines.push(line("system:base", input.base));
59
+ for (const append of input.appends ?? [])
60
+ lines.push(line(`system:ext:${append.name}`, append.text));
61
+ for (const tool of input.tools ?? []) {
62
+ lines.push(line(`tool:${tool.name}`, JSON.stringify({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })));
63
+ }
64
+ // R5: the envelope is the request skeleton — a model-string function,
65
+ // never the payloads; the empty arrays are literal (the definition).
66
+ lines.push(line("envelope", JSON.stringify({ model: input.model, messages: [], tools: [] })));
67
+ return lines;
68
+ }
69
+ /** The line validator — the same strict discipline as the record's other
70
+ * closed sets: no key outside the spec, every spec'd key present,
71
+ * non-empty surface (R9: a line exists only for a surface that exists),
72
+ * non-negative integer chars, and the estTokens cross-check (R6). The
73
+ * schema pins the LINE, not the multiset — duplicate surfaces are the
74
+ * writer's business, never a schema crash. */
75
+ export function validateRentLine(v) {
76
+ if (typeof v !== "object" || v === null || Array.isArray(v))
77
+ return false;
78
+ const o = v;
79
+ const keys = Object.keys(o);
80
+ const fields = RENT_LINE_FIELDS;
81
+ if (keys.some((k) => !fields.includes(k)))
82
+ return false;
83
+ if (!fields.every((k) => k in o))
84
+ return false;
85
+ if (typeof o.surface !== "string" || o.surface.length === 0)
86
+ return false;
87
+ const chars = o.chars;
88
+ const estTokens = o.estTokens;
89
+ if (typeof chars !== "number" || !Number.isInteger(chars) || chars < 0)
90
+ return false;
91
+ if (typeof estTokens !== "number" || !Number.isInteger(estTokens) || estTokens < 0)
92
+ return false;
93
+ if (estTokens !== Math.ceil(chars / 4))
94
+ return false;
95
+ return true;
96
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
3
+ *
4
+ * The ledger is OUT-side (ADR-0051 §6): a versionable observation
5
+ * artifact, never part of the correctness ABI. The soft-fail law
6
+ * (work-order): trace loss costs ONE stderr line, then the session runs
7
+ * on — the writer degrades to a no-op on its first I/O failure and
8
+ * never throws into the caller.
9
+ *
10
+ * Async discipline (work-order §9): request lines are ENQUEUED (a sync
11
+ * push, sub-microsecond) and flushed on setImmediate — the model hot
12
+ * path never blocks on the ledger. The run_end line is flushed
13
+ * SYNCHRONOUSLY from the run's finally block: clean-settle marking is
14
+ * where the ledger's story (every hole reconciles as a crash, never as
15
+ * a writer bug) needs the write to have happened before the run ends.
16
+ */
17
+ import { type TraceLine } from "./record.js";
18
+ /** The runtime's own version, read from the package.json next to the
19
+ * build; null on failure (soft-fail — the header still ships). */
20
+ export declare function runtimeVersion(): string | null;
21
+ export interface TraceWriterDeps {
22
+ /** The sessions dir (store.root). The ledger lives in <root>/traces/ —
23
+ * NOT flat in root: a flat `<sid>.trace.jsonl` would be enumerated as
24
+ * a phantom session by SessionStore.list() and corrupt the bench
25
+ * sessions glob (proposal §3.2; the R3a gate pins it). */
26
+ root: string;
27
+ sessionId: string;
28
+ }
29
+ export declare class TraceWriter {
30
+ #private;
31
+ constructor(deps: TraceWriterDeps);
32
+ /** One-time setup: mkdir the ledger dir, mark a previous run that left
33
+ * no run_end (crash), write the header — the header once per ledger
34
+ * file (session-level, §1.2), so a resume appends markers, never a
35
+ * second header. Never throws. */
36
+ init(): void;
37
+ /** Enqueue a line for the next flush — the hot path's only cost.
38
+ * Never throws; after a degradation the line is dropped. */
39
+ enqueue(line: TraceLine): void;
40
+ /** Clean-settle marking: the run_end line lands NOW (synchronous),
41
+ * together with any still-pending request lines. */
42
+ finishRun(runId: string, lastRequestIndex: number): void;
43
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
3
+ *
4
+ * The ledger is OUT-side (ADR-0051 §6): a versionable observation
5
+ * artifact, never part of the correctness ABI. The soft-fail law
6
+ * (work-order): trace loss costs ONE stderr line, then the session runs
7
+ * on — the writer degrades to a no-op on its first I/O failure and
8
+ * never throws into the caller.
9
+ *
10
+ * Async discipline (work-order §9): request lines are ENQUEUED (a sync
11
+ * push, sub-microsecond) and flushed on setImmediate — the model hot
12
+ * path never blocks on the ledger. The run_end line is flushed
13
+ * SYNCHRONOUSLY from the run's finally block: clean-settle marking is
14
+ * where the ledger's story (every hole reconciles as a crash, never as
15
+ * a writer bug) needs the write to have happened before the run ends.
16
+ */
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { TRACE_SCHEMA_VERSION } from "./record.js";
21
+ let cachedRuntimeVersion;
22
+ /** The runtime's own version, read from the package.json next to the
23
+ * build; null on failure (soft-fail — the header still ships). */
24
+ export function runtimeVersion() {
25
+ if (cachedRuntimeVersion === undefined) {
26
+ cachedRuntimeVersion = (() => {
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"));
29
+ return pkg.version ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ })();
35
+ }
36
+ return cachedRuntimeVersion;
37
+ }
38
+ export class TraceWriter {
39
+ #root;
40
+ #sessionId;
41
+ #path;
42
+ #pending = [];
43
+ #flushScheduled = false;
44
+ #degraded = false;
45
+ constructor(deps) {
46
+ this.#root = deps.root;
47
+ this.#sessionId = deps.sessionId;
48
+ this.#path = join(deps.root, "traces", `${deps.sessionId}.jsonl`);
49
+ }
50
+ /** One-time setup: mkdir the ledger dir, mark a previous run that left
51
+ * no run_end (crash), write the header — the header once per ledger
52
+ * file (session-level, §1.2), so a resume appends markers, never a
53
+ * second header. Never throws. */
54
+ init() {
55
+ if (this.#degraded)
56
+ return;
57
+ try {
58
+ mkdirSync(join(this.#root, "traces"), { recursive: true });
59
+ const fresh = !existsSync(this.#path);
60
+ if (this.#crashDetect()) {
61
+ this.#writeSync([
62
+ { schemaVersion: TRACE_SCHEMA_VERSION, kind: "crash", ts: Date.now(), note: "previous run left no run_end" },
63
+ ]);
64
+ }
65
+ if (fresh) {
66
+ this.#writeSync([
67
+ {
68
+ schemaVersion: TRACE_SCHEMA_VERSION,
69
+ kind: "header",
70
+ sessionId: this.#sessionId,
71
+ kisoVersion: runtimeVersion() ?? "?",
72
+ createdAt: Date.now(),
73
+ },
74
+ ]);
75
+ }
76
+ }
77
+ catch (err) {
78
+ this.#degrade(err);
79
+ }
80
+ }
81
+ /** Enqueue a line for the next flush — the hot path's only cost.
82
+ * Never throws; after a degradation the line is dropped. */
83
+ enqueue(line) {
84
+ if (this.#degraded)
85
+ return;
86
+ this.#pending.push(line);
87
+ if (!this.#flushScheduled) {
88
+ this.#flushScheduled = true;
89
+ setImmediate(() => {
90
+ this.#flushScheduled = false;
91
+ this.#flushSync();
92
+ });
93
+ }
94
+ }
95
+ /** Clean-settle marking: the run_end line lands NOW (synchronous),
96
+ * together with any still-pending request lines. */
97
+ finishRun(runId, lastRequestIndex) {
98
+ this.enqueue({
99
+ schemaVersion: TRACE_SCHEMA_VERSION,
100
+ kind: "run_end",
101
+ runId,
102
+ ts: Date.now(),
103
+ lastRequestIndex,
104
+ });
105
+ this.#flushSync();
106
+ }
107
+ /** True when the ledger already exists and its LAST line is neither
108
+ * run_end nor crash — an un-terminated run (killed mid-run), to be
109
+ * marked. A crash-marked ledger is not re-marked. */
110
+ #crashDetect() {
111
+ if (!existsSync(this.#path))
112
+ return false;
113
+ let last;
114
+ try {
115
+ for (const line of readFileSync(this.#path, "utf8").split("\n")) {
116
+ if (line.trim() === "")
117
+ continue;
118
+ last = JSON.parse(line);
119
+ }
120
+ }
121
+ catch {
122
+ return false; // unreadable ledger — do not compound the failure
123
+ }
124
+ if (last === undefined)
125
+ return false;
126
+ return last.kind !== "run_end" && last.kind !== "crash";
127
+ }
128
+ #writeSync(lines) {
129
+ if (lines.length === 0)
130
+ return;
131
+ try {
132
+ appendFileSync(this.#path, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
133
+ }
134
+ catch (err) {
135
+ this.#degrade(err);
136
+ }
137
+ }
138
+ #flushSync() {
139
+ if (this.#pending.length === 0)
140
+ return;
141
+ const lines = this.#pending;
142
+ this.#pending = [];
143
+ this.#writeSync(lines);
144
+ }
145
+ /** The soft-fail law: one stderr line, then the ledger is dropped and
146
+ * the session runs on. Idempotent. */
147
+ #degrade(err) {
148
+ if (this.#degraded)
149
+ return;
150
+ this.#degraded = true;
151
+ this.#pending = [];
152
+ console.error(`[kiso] trace writer degraded (${err instanceof Error ? err.message : String(err)}); request traces dropped`);
153
+ }
154
+ }