Continue pet recordings with bounded Runtime history and safe segments

Reuse the existing Runtime lifecycle importer incrementally, retaining unfinished
requests and a 16-second recurrence window with metadata/event caps. Keep late
error receipt times and snapshot identities stable when earlier history retires.

Rotate the live JSONL pathname into exclusive, replayable segments before its
24-hour or 64 MiB limit. Sync history before atomic replacement; refuse existing
archives and changed output files. Add macOS/Windows pet recorder CI.

Validation: npm test: 66 package, 12 SDK, 446 web passed; 0 failed.
npm run check:web: 0 errors, 2 existing image warnings.
Pet check: 56 passed, 0 failed. Whalesong check: 310 passed, 0 failed.
TUI Watch: 12 passed, 0 failed. Apple: 11 workflows passed; macOS/iOS builds pass.
Android: all 11 device tests passed in one run; assembleDebug and lintDebug pass.
Real loopback SSE fixture: 260001 records; under 512 retained events and 256 KiB.
Old 43e6f02 stops at record 155859 on the same fixture. No provider calls.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-12 13:11:40 -07:00
1 parent 43e6f02ea3
commit e71be2c36e
14 files changed
+1179 -642

No files matched your search

+17
View File
@@ -38,6 +38,23 @@ jobs:
- run: cargo fetch --locked --manifest-path pet/tui/Cargo.toml
- name: TypeScript and Rust parity, including reduced motion
run: ./pet/verify.sh --no-swift
recorder:
name: recorder (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: pet/package-lock.json
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
android:
runs-on: ubuntu-latest
timeout-minutes: 25
+291 -213
View File
@@ -1780,6 +1780,7 @@ exports.PetEngineTelemetry = PetEngineTelemetry;
factories["codewhale"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CodewhaleRuntimeTrace = void 0;
exports.isCodewhaleSession = isCodewhaleSession;
exports.isCodewhaleRuntimeRecord = isCodewhaleRuntimeRecord;
exports.isCodewhaleRuntimeDocument = isCodewhaleRuntimeDocument;
@@ -2126,222 +2127,299 @@ function itemCategory(kind, tool) {
return 'tool';
return classify(kind);
}
/** Incremental form of the existing Runtime importer. File imports and live
* recording share this exact lifecycle parser; only a live driver retires old
* completed events after it has recorded their projection. */
class CodewhaleRuntimeTrace {
filename;
maxEvents;
project;
maxBytes;
events = [];
open = new Map();
requests = new Map();
sizes = new Map();
bytes = 0;
recordCount = 0;
skippedDeltas = 0;
origin;
model;
threadId;
threadName;
constructor(filename = 'Codewhale runtime', maxEvents = 250_000, project = event => event, maxBytes = Infinity) {
this.filename = filename;
this.maxEvents = maxEvents;
this.project = project;
this.maxBytes = maxBytes;
}
get retainedEvents() { return this.events.length; }
get retainedBytes() { return this.bytes; }
measure(event, proposed = event) {
const safe = this.project(proposed);
if (this.maxBytes !== Infinity) {
const size = new TextEncoder().encode(JSON.stringify(safe)).length;
const total = this.bytes - (this.sizes.get(event) ?? 0) + size;
if (total > this.maxBytes)
throw new Error('Runtime observation exceeds its retained input limit.');
this.bytes = total;
this.sizes.set(event, size);
}
for (const key of Object.keys(event))
if (!Object.hasOwn(safe, key))
delete event[key];
Object.assign(event, safe);
}
push(event) {
if (this.events.length >= this.maxEvents)
throw new Error(`Import exceeds the ${this.maxEvents.toLocaleString()} event limit.`);
this.measure(event);
pushEvent(this.events, event);
}
/** Keep unfinished lifetimes plus the recent window needed by the bucketer's
* 12-second recurrence measure. A completion may still arrive for any open item. */
prune(beforeWall) {
if (!Number.isFinite(beforeWall))
throw new Error('Invalid Runtime retention horizon.');
if (this.origin === undefined)
return;
const cutoff = beforeWall - this.origin;
let keep = 0;
for (const event of this.events) {
if (event.openEnded || Math.max(event.endTime, (0, model_js_1.errorOnsetOf)(event)) >= cutoff)
this.events[keep++] = event;
else {
this.bytes -= this.sizes.get(event) ?? 0;
this.sizes.delete(event);
if (this.open.get(event.id) === event)
this.open.delete(event.id);
}
}
this.events.length = keep;
}
append(records) {
if (!records.length)
return;
const { events, open, requests } = this;
const threadId = this.threadId ?? str(obj(records[0]).thread_id) ?? this.filename;
this.threadId = threadId;
let { origin, model, skippedDeltas } = this;
let threadName = this.threadName ?? threadId;
const stamp = (rec) => {
const t = parseTime(rec.timestamp);
if (t === undefined)
throw new Error(`Runtime event seq ${rec.seq} is missing a usable timestamp.`);
if (origin === undefined)
origin = t;
return t - origin;
};
for (const raw of records) {
if (!isCodewhaleRuntimeRecord(raw))
throw new Error('Runtime import cancelled: a line is not a Codewhale runtime event record. No rows were skipped.');
this.recordCount++;
const rec = obj(raw);
if (rec.thread_id !== threadId)
throw new Error('Runtime import contains multiple threads. Export one thread before importing.');
const eventName = rec.event;
if (eventName === 'item.delta') {
skippedDeltas++;
continue;
}
const payload = obj(rec.payload);
const item = obj(payload.item);
const turn = obj(payload.turn);
const thread = obj(payload.thread);
const relative = stamp(rec);
const turnId = str(rec.turn_id) ?? str(payload.turn_id);
const itemId = str(rec.item_id) ?? str(item.id);
const agentId = 'parent';
if (str(thread.model))
model = str(thread.model);
if (str(turn.model))
model = str(turn.model) ?? model;
if (eventName === 'thread.started') {
model = str(thread.model) ?? model;
threadName = str(thread.id) ?? threadId;
this.push({
schemaVersion: 1, id: `thread:${threadId}`, traceId: threadId,
startTime: relative, endTime: relative, openEnded: true,
agentId, name: 'thread', category: 'orchestration', model, status: 'running',
attributes: { 'codewhale.seq': rec.seq, 'whalesong.container': true }, raw: rec,
});
continue;
}
if (eventName === 'turn.started' || eventName === 'turn.completed') {
const id = `turn:${turnId ?? rec.seq}`;
if (eventName === 'turn.completed')
for (const [key, request] of requests) {
if (request.parentId !== id)
continue;
this.measure(request, { ...request, endTime: Math.max(request.startTime, relative), openEnded: false, status: 'unknown' });
requests.delete(key);
}
const startWall = parseTime(turn.started_at) ?? parseTime(turn.created_at);
const endWall = parseTime(turn.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'turn.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const usage = obj(turn.usage);
const existing = events.findIndex(e => e.id === id);
const next = {
schemaVersion: 1, id, traceId: threadId, parentId: `thread:${threadId}`,
startTime: start, endTime: Math.max(start, end), openEnded: eventName !== 'turn.completed',
agentId, name: 'turn', category: 'orchestration', model,
inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
status: statusOf(turn.status ?? payload.status), latency: num(turn.duration_ms),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'whalesong.container': true,
...(statusOf(turn.status ?? payload.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { input_summary: clip(turn.input_summary) }, raw: rec,
};
if (existing >= 0) {
const prior = events[existing];
this.measure(prior, { ...next, startTime: prior.startTime });
}
else
this.push(next);
continue;
}
if (eventName === 'turn.lifecycle')
continue;
if (['approval.required', 'approval.decided', 'approval.timeout', 'user_input.required', 'user_input.answered', 'user_input.canceled'].includes(eventName)) {
const kind = eventName.startsWith('approval.') ? 'approval' : 'user_input';
const requestId = str(payload[kind === 'approval' ? 'approval_id' : 'input_id']) ?? str(payload.id);
if (!requestId)
throw new Error(`Runtime ${eventName} is missing its request identity.`);
const key = JSON.stringify([turnId ?? '', kind, requestId]);
const prior = requests.get(key), required = eventName.endsWith('.required');
if (required && prior)
continue;
if (!required && prior) {
const next = { ...prior, attributes: { ...prior.attributes },
endTime: Math.max(prior.startTime, relative), openEnded: false,
status: eventName === 'approval.decided' || eventName === 'user_input.answered' ? 'success' : 'unknown' };
if (payload.auto === true) {
// Automatic consent has a receipt, but never asked the human to wait.
next.category = 'orchestration';
delete next.attributes['whalesong.waiting'];
next.attributes['whalesong.container'] = true;
}
this.measure(prior, next);
requests.delete(key);
continue;
}
const automatic = payload.auto === true;
const event = {
schemaVersion: 1, id: `request:${key}:${rec.seq}`, traceId: threadId,
parentId: turnId ? `turn:${turnId}` : undefined, startTime: relative, endTime: relative,
openEnded: required, agentId, name: eventName, category: automatic ? 'orchestration' : 'human',
status: required ? 'pending' : 'success', model,
attributes: { 'codewhale.seq': rec.seq, 'whalesong.waiting': required, 'whalesong.container': automatic }, raw: rec,
};
this.push(event);
if (required)
requests.set(key, event);
continue;
}
if (eventName === 'tool_call.requested' || eventName === 'tool_call.canceled') {
const callId = str(payload.call_id) ?? `call:${rec.seq}`;
const tool = str(payload.tool);
const canceled = eventName === 'tool_call.canceled';
this.push({
schemaVersion: 1, id: `${eventName}:${callId}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: tool ?? eventName, tool,
category: tool ? toolCategory(tool) : 'tool', model, status: canceled ? 'error' : 'pending',
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.call_id': callId, reason: payload.reason },
payload: { arguments: clip(payload.arguments) }, raw: rec,
});
continue;
}
if (eventName === 'item.started' || eventName === 'item.completed') {
const kind = str(item.kind) ?? 'item';
const tool = itemToolName(item, payload);
const id = itemId ?? `item:${rec.seq}`;
const startWall = parseTime(item.started_at);
const endWall = parseTime(item.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'item.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const openEnded = eventName === 'item.started' && endWall === undefined;
const existing = open.get(id);
if (existing && eventName === 'item.completed') {
const prior = existing;
const status = statusOf(item.status);
this.measure(prior, { ...prior, endTime: Math.max(prior.startTime, end), openEnded: false, status,
attributes: { ...prior.attributes, ...(status === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) } });
open.delete(id);
continue;
}
const event = {
schemaVersion: 1, id, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: start, endTime: Math.max(start, end), openEnded,
agentId, name: tool ?? kind, tool, category: itemCategory(kind, tool), model,
status: statusOf(item.status ?? (eventName === 'item.started' ? 'running' : undefined)),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.item_kind': kind,
...(statusOf(item.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) }, raw: rec,
};
this.push(event);
if (eventName === 'item.started')
open.set(id, event);
continue;
}
this.push({
schemaVersion: 1, id: `${eventName}:${rec.seq}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: eventName, category: classify(eventName),
model, status: 'unknown', attributes: { 'codewhale.seq': rec.seq }, raw: rec,
});
}
this.origin = origin;
this.model = model;
this.threadName = threadName;
this.skippedDeltas = skippedDeltas;
}
snapshot() {
const { events, open, requests, origin, model, skippedDeltas, filename } = this;
const threadId = this.threadId ?? filename, threadName = this.threadName ?? threadId;
const warnings = [];
if (skippedDeltas)
warnings.push(`Dropped ${skippedDeltas.toLocaleString()} item.delta records; they are token stream fragments, not spans. Item start/end remain the source of duration.`);
for (const [id] of open)
warnings.push(`Item ${id} started and never completed in this file; duration remains unknown.`);
for (const request of requests.values())
warnings.push(`Request ${request.id} has no terminal receipt; its duration remains unknown in this file.`);
if (!events.length)
throw new Error('Codewhale runtime file contained only stream deltas or unreadable records.');
const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0].startTime);
const normalized = events.map(event => ({ ...event, startTime: event.startTime - base, endTime: event.endTime - base,
attributes: { ...event.attributes, ...(event.attributes['whalesong.error_onset_ms'] !== undefined
? { 'whalesong.error_onset_ms': (0, model_js_1.errorOnsetOf)(event) - base } : {}) } }));
return {
id: threadId,
name: `Codewhale runtime · ${threadName}`,
events: normalized,
duration: Math.max(1, normalized.reduce((m, e) => Math.max(m, e.endTime, e.startTime, e.status === 'error' ? (0, model_js_1.errorOnsetOf)(e) : 0), 0)),
originTime: origin !== undefined ? new Date(origin + base).toISOString() : '0 ms',
source: 'codewhale',
privacy: 'redact',
warnings: [...new Set(warnings)],
metadata: {
sourceFormat: 'codewhale.runtime-events/v2',
timeBasis: 'wall-clock',
sourceFilename: filename,
threadId,
model,
skippedDeltas,
recordCount: this.recordCount,
timeUnit: 'ms',
},
};
}
}
exports.CodewhaleRuntimeTrace = CodewhaleRuntimeTrace;
function fromCodewhaleRuntime(records, filename = 'Codewhale runtime', maxEvents = 250_000) {
if (!records.length)
throw new Error('Codewhale runtime event file is empty.');
const first = obj(records[0]);
const threadId = str(first.thread_id) ?? filename;
const warnings = [];
let skippedDeltas = 0;
const open = new Map();
const requests = new Map();
const events = [];
let origin;
let model;
let threadName = threadId;
const stamp = (rec) => {
const t = parseTime(rec.timestamp);
if (t === undefined)
throw new Error(`Runtime event seq ${rec.seq} is missing a usable timestamp.`);
if (origin === undefined)
origin = t;
return t - origin;
};
for (const raw of records) {
if (!isCodewhaleRuntimeRecord(raw))
throw new Error('Runtime import cancelled: a line is not a Codewhale runtime event record. No rows were skipped.');
const rec = obj(raw);
if (rec.thread_id !== threadId)
throw new Error('Runtime import contains multiple threads. Export one thread before importing.');
const eventName = rec.event;
if (eventName === 'item.delta') {
skippedDeltas++;
continue;
}
if (events.length >= maxEvents)
throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
const payload = obj(rec.payload);
const item = obj(payload.item);
const turn = obj(payload.turn);
const thread = obj(payload.thread);
const relative = stamp(rec);
const turnId = str(rec.turn_id) ?? str(payload.turn_id);
const itemId = str(rec.item_id) ?? str(item.id);
const agentId = 'parent';
if (str(thread.model))
model = str(thread.model);
if (str(turn.model))
model = str(turn.model) ?? model;
if (eventName === 'thread.started') {
model = str(thread.model) ?? model;
threadName = str(thread.id) ?? threadId;
pushEvent(events, {
schemaVersion: 1, id: `thread:${threadId}`, traceId: threadId,
startTime: relative, endTime: relative, openEnded: true,
agentId, name: 'thread', category: 'orchestration', model, status: 'running',
attributes: { 'codewhale.seq': rec.seq, 'whalesong.container': true }, raw: rec,
});
continue;
}
if (eventName === 'turn.started' || eventName === 'turn.completed') {
const id = `turn:${turnId ?? rec.seq}`;
if (eventName === 'turn.completed')
for (const [key, request] of requests) {
if (request.parentId !== id)
continue;
request.endTime = Math.max(request.startTime, relative);
request.openEnded = false;
request.status = 'unknown';
requests.delete(key);
}
const startWall = parseTime(turn.started_at) ?? parseTime(turn.created_at);
const endWall = parseTime(turn.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'turn.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const usage = obj(turn.usage);
const existing = events.findIndex(e => e.id === id);
const next = {
schemaVersion: 1, id, traceId: threadId, parentId: `thread:${threadId}`,
startTime: start, endTime: Math.max(start, end), openEnded: eventName !== 'turn.completed',
agentId, name: 'turn', category: 'orchestration', model,
inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
status: statusOf(turn.status ?? payload.status), latency: num(turn.duration_ms),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'whalesong.container': true,
...(statusOf(turn.status ?? payload.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { input_summary: clip(turn.input_summary) }, raw: rec,
};
if (existing >= 0)
events[existing] = { ...events[existing], ...next, startTime: events[existing].startTime };
else
pushEvent(events, next);
continue;
}
if (eventName === 'turn.lifecycle')
continue;
if (['approval.required', 'approval.decided', 'approval.timeout', 'user_input.required', 'user_input.answered', 'user_input.canceled'].includes(eventName)) {
const kind = eventName.startsWith('approval.') ? 'approval' : 'user_input';
const requestId = str(payload[kind === 'approval' ? 'approval_id' : 'input_id']) ?? str(payload.id);
if (!requestId)
throw new Error(`Runtime ${eventName} is missing its request identity.`);
const key = JSON.stringify([turnId ?? '', kind, requestId]);
const prior = requests.get(key), required = eventName.endsWith('.required');
if (required && prior)
continue;
if (!required && prior) {
prior.endTime = Math.max(prior.startTime, relative);
prior.openEnded = false;
prior.status = eventName === 'approval.decided' || eventName === 'user_input.answered' ? 'success' : 'unknown';
if (payload.auto === true) {
// Automatic consent has a receipt, but never asked the human to wait.
prior.category = 'orchestration';
delete prior.attributes['whalesong.waiting'];
prior.attributes['whalesong.container'] = true;
}
requests.delete(key);
continue;
}
const automatic = payload.auto === true;
const event = {
schemaVersion: 1, id: `request:${key}:${rec.seq}`, traceId: threadId,
parentId: turnId ? `turn:${turnId}` : undefined, startTime: relative, endTime: relative,
openEnded: required, agentId, name: eventName, category: automatic ? 'orchestration' : 'human',
status: required ? 'pending' : 'success', model,
attributes: { 'codewhale.seq': rec.seq, 'whalesong.waiting': required, 'whalesong.container': automatic }, raw: rec,
};
if (required)
requests.set(key, event);
pushEvent(events, event);
continue;
}
if (eventName === 'tool_call.requested' || eventName === 'tool_call.canceled') {
const callId = str(payload.call_id) ?? `call:${rec.seq}`;
const tool = str(payload.tool);
const canceled = eventName === 'tool_call.canceled';
pushEvent(events, {
schemaVersion: 1, id: `${eventName}:${callId}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: tool ?? eventName, tool,
category: tool ? toolCategory(tool) : 'tool', model, status: canceled ? 'error' : 'pending',
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.call_id': callId, reason: payload.reason },
payload: { arguments: clip(payload.arguments) }, raw: rec,
});
continue;
}
if (eventName === 'item.started' || eventName === 'item.completed') {
const kind = str(item.kind) ?? 'item';
const tool = itemToolName(item, payload);
const id = itemId ?? `item:${rec.seq}`;
const startWall = parseTime(item.started_at);
const endWall = parseTime(item.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'item.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const openEnded = eventName === 'item.started' && endWall === undefined;
const existing = open.get(id);
if (existing && eventName === 'item.completed') {
const prior = events[existing.index];
prior.endTime = Math.max(prior.startTime, end);
prior.openEnded = false;
prior.status = statusOf(item.status);
if (prior.status === 'error')
prior.attributes['whalesong.error_onset_ms'] = relative;
prior.payload = { summary: clip(item.summary), detail: clip(item.detail) };
open.delete(id);
continue;
}
const event = {
schemaVersion: 1, id, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: start, endTime: Math.max(start, end), openEnded,
agentId, name: tool ?? kind, tool, category: itemCategory(kind, tool), model,
status: statusOf(item.status ?? (eventName === 'item.started' ? 'running' : undefined)),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.item_kind': kind,
...(statusOf(item.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) }, raw: rec,
};
if (eventName === 'item.started')
open.set(id, { index: events.length, startWall: (origin ?? 0) + start });
pushEvent(events, event);
continue;
}
pushEvent(events, {
schemaVersion: 1, id: `${eventName}:${rec.seq}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: eventName, category: classify(eventName),
model, status: 'unknown', attributes: { 'codewhale.seq': rec.seq }, raw: rec,
});
}
if (skippedDeltas)
warnings.push(`Dropped ${skippedDeltas.toLocaleString()} item.delta records; they are token stream fragments, not spans. Item start/end remain the source of duration.`);
for (const [id] of open)
warnings.push(`Item ${id} started and never completed in this file; duration remains unknown.`);
for (const request of requests.values())
warnings.push(`Request ${request.id} has no terminal receipt; its duration remains unknown in this file.`);
if (!events.length)
throw new Error('Codewhale runtime file contained only stream deltas or unreadable records.');
const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0].startTime);
for (const event of events) {
if (event.attributes['whalesong.error_onset_ms'] !== undefined)
event.attributes['whalesong.error_onset_ms'] = (0, model_js_1.errorOnsetOf)(event) - base;
event.startTime -= base;
event.endTime -= base;
}
return {
id: threadId,
name: `Codewhale runtime · ${threadName}`,
events,
duration: Math.max(1, events.reduce((m, e) => Math.max(m, e.endTime, e.startTime, e.status === 'error' ? (0, model_js_1.errorOnsetOf)(e) : 0), 0)),
originTime: origin !== undefined ? new Date(origin + base).toISOString() : '0 ms',
source: 'codewhale',
privacy: 'redact',
warnings: [...new Set(warnings)],
metadata: {
sourceFormat: 'codewhale.runtime-events/v2',
timeBasis: 'wall-clock',
sourceFilename: filename,
threadId,
model,
skippedDeltas,
recordCount: records.length,
timeUnit: 'ms',
},
};
const trace = new CodewhaleRuntimeTrace(filename, maxEvents);
trace.append(records);
return trace.snapshot();
}
/** The journal owns request state until a matching terminal receipt. A live
* driver may confirm that state only while its cursor-checked stream is healthy.
+6 -2
View File
@@ -115,8 +115,12 @@ advisory writer lock. A content revision rejects stale writers and external
edits. Invalid files, changed locks, unavailable storage and the 8 MiB native
habitat limit stop persistence with a localized warning; existing files remain
intact and the current in-memory recording can still be exported within that
limit. Full-tape retention is still bounded by the 64 MiB worker and the core's
24-hour recording limit; history rotation remains future work.
limit. The host archives consumed history after 1,024 buckets or 4,096 applied
inputs before replacing the active habitat. Each archive starts with an exact
checkpoint, and the running world retires history only after storage succeeds.
The active QuickJS worker remains limited to 64 MiB; retained archives grow on
disk. The standalone live recorder separately rotates bounded JSONL segments at
the same watched pathname; see `pet/README.md` for its limits and recovery.
`/workbar watch export` writes a new immutable `artifacts/pet/replay-<id>.json`
and reports its path through the existing toast system and transcript. Import it in the web
+16 -2
View File
@@ -93,8 +93,9 @@ open junctions; the same particle identities return to the whale at rest.
## Local evidence before publication (2026-09-12)
- Packaged pet: 48 tests passed; existing Whalesong consumers: 306 passed after
canonical source relocation. These are overlapping suites, not additive coverage.
- Packaged pet: 56 tests passed; existing Whalesong consumers: 310 passed after
the incremental Runtime importer change. These are overlapping suites, not
additive coverage.
- Product Node gate: 66 package, 12 SDK and 446 web tests passed; production web
check subsequently passed with the GitHub release fetch available.
- TypeScript/Rust/Swift/Kotlin: 380 identical checkpoints across both expression
@@ -148,6 +149,19 @@ open junctions; the same particle identities return to the whale at rest.
coverage before answering, and for two sealed unknown bins before stopping.
Its former fixed sleep occasionally stopped the child before those final bins
on a busy runner. The receipt assertions and privacy checks remain unchanged.
- Continuous recording: the actual watch CLI rotates three replayable files and
continues at the original live pathname. File tests cross the 24-hour sequence,
count UTF-8 bytes, follow replacement with the real cursor, and preserve
existing files on an archive collision or external replacement. History is
retained on disk; this does not resume a recorder after process restart.
- Bounded Runtime input: a real local HTTP/SSE fixture supplies 260,001 records
spanning nearly 29 hours of event timestamps. The importer retains fewer than
512 events and 256 KiB of metadata, including a still-open human request. The
published `43e6f02` transport fails the same fixture at record 155,859 when its
cumulative raw journal reaches the old limit. These are synthetic timestamps,
not a 29-hour provider or native companion run. Smaller fixtures compare recent
buckets with full imports through pruning, late failures, shifted origins,
request answers, automatic consent and turn completion.
- Authenticated read-only attachment to an existing local Runtime 0.9.13 session
journal passed. An 18-second recorder run produced 45 contiguous unknown buckets
and stopped cleanly in 41 ms after Ctrl+C, without treating historical work as
+15 -2
View File
@@ -105,8 +105,21 @@ holes are rejected; reconnects resume from the last accepted Runtime cursor.
The recorder seals the preceding observation interval against a fixed clock.
It retains request lifetimes and counts a delayed error once at receipt time.
Raw prompts, arguments, results and tokens do not enter the pet recording.
The standalone recorder currently stops after 24 hours per JSONL file; its
continuous native-companion handoff remains part of the live-attachment work.
Live recording continues in segments. At 216,000 buckets (24 hours) or 64 MiB,
the recorder syncs the completed file, preserves it as
`OUTPUT.segment-000001.jsonl` (then `000002`, etc.), and atomically replaces the
same live pathname. Each segment starts at sequence zero and replays independently.
Use `--segment-buckets=N` to rotate sooner. Followers establish a new baseline
after replacement, then accept subsequent appends as current observations.
The live importer retains unfinished lifetimes and 16 seconds of completed
events for the bucketer's recurrence window. It removes raw payloads immediately;
250,000 events and 64 MiB bound retained metadata, not total session history.
Completed output segments remain on disk, so disk use grows with recorded history.
Rotation requires same-directory hard links and atomic replacement. Unsupported
storage, an archive-name collision or an external replacement stops recording
without overwriting the existing files. Restart the recorder with a new unused
output path; it does not resume a previous process's recording.
The thin wire is JSONL, one flat version-1 `PetBucket` per line: PetState plus
`sequence`, `simTimeMs`, `durationMs`, thirteen-element `onsets` and `activeMs`,
+291 -213
View File
@@ -1780,6 +1780,7 @@ exports.PetEngineTelemetry = PetEngineTelemetry;
factories["codewhale"]=function(exports,require){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CodewhaleRuntimeTrace = void 0;
exports.isCodewhaleSession = isCodewhaleSession;
exports.isCodewhaleRuntimeRecord = isCodewhaleRuntimeRecord;
exports.isCodewhaleRuntimeDocument = isCodewhaleRuntimeDocument;
@@ -2126,222 +2127,299 @@ function itemCategory(kind, tool) {
return 'tool';
return classify(kind);
}
/** Incremental form of the existing Runtime importer. File imports and live
* recording share this exact lifecycle parser; only a live driver retires old
* completed events after it has recorded their projection. */
class CodewhaleRuntimeTrace {
filename;
maxEvents;
project;
maxBytes;
events = [];
open = new Map();
requests = new Map();
sizes = new Map();
bytes = 0;
recordCount = 0;
skippedDeltas = 0;
origin;
model;
threadId;
threadName;
constructor(filename = 'Codewhale runtime', maxEvents = 250_000, project = event => event, maxBytes = Infinity) {
this.filename = filename;
this.maxEvents = maxEvents;
this.project = project;
this.maxBytes = maxBytes;
}
get retainedEvents() { return this.events.length; }
get retainedBytes() { return this.bytes; }
measure(event, proposed = event) {
const safe = this.project(proposed);
if (this.maxBytes !== Infinity) {
const size = new TextEncoder().encode(JSON.stringify(safe)).length;
const total = this.bytes - (this.sizes.get(event) ?? 0) + size;
if (total > this.maxBytes)
throw new Error('Runtime observation exceeds its retained input limit.');
this.bytes = total;
this.sizes.set(event, size);
}
for (const key of Object.keys(event))
if (!Object.hasOwn(safe, key))
delete event[key];
Object.assign(event, safe);
}
push(event) {
if (this.events.length >= this.maxEvents)
throw new Error(`Import exceeds the ${this.maxEvents.toLocaleString()} event limit.`);
this.measure(event);
pushEvent(this.events, event);
}
/** Keep unfinished lifetimes plus the recent window needed by the bucketer's
* 12-second recurrence measure. A completion may still arrive for any open item. */
prune(beforeWall) {
if (!Number.isFinite(beforeWall))
throw new Error('Invalid Runtime retention horizon.');
if (this.origin === undefined)
return;
const cutoff = beforeWall - this.origin;
let keep = 0;
for (const event of this.events) {
if (event.openEnded || Math.max(event.endTime, (0, model_js_1.errorOnsetOf)(event)) >= cutoff)
this.events[keep++] = event;
else {
this.bytes -= this.sizes.get(event) ?? 0;
this.sizes.delete(event);
if (this.open.get(event.id) === event)
this.open.delete(event.id);
}
}
this.events.length = keep;
}
append(records) {
if (!records.length)
return;
const { events, open, requests } = this;
const threadId = this.threadId ?? str(obj(records[0]).thread_id) ?? this.filename;
this.threadId = threadId;
let { origin, model, skippedDeltas } = this;
let threadName = this.threadName ?? threadId;
const stamp = (rec) => {
const t = parseTime(rec.timestamp);
if (t === undefined)
throw new Error(`Runtime event seq ${rec.seq} is missing a usable timestamp.`);
if (origin === undefined)
origin = t;
return t - origin;
};
for (const raw of records) {
if (!isCodewhaleRuntimeRecord(raw))
throw new Error('Runtime import cancelled: a line is not a Codewhale runtime event record. No rows were skipped.');
this.recordCount++;
const rec = obj(raw);
if (rec.thread_id !== threadId)
throw new Error('Runtime import contains multiple threads. Export one thread before importing.');
const eventName = rec.event;
if (eventName === 'item.delta') {
skippedDeltas++;
continue;
}
const payload = obj(rec.payload);
const item = obj(payload.item);
const turn = obj(payload.turn);
const thread = obj(payload.thread);
const relative = stamp(rec);
const turnId = str(rec.turn_id) ?? str(payload.turn_id);
const itemId = str(rec.item_id) ?? str(item.id);
const agentId = 'parent';
if (str(thread.model))
model = str(thread.model);
if (str(turn.model))
model = str(turn.model) ?? model;
if (eventName === 'thread.started') {
model = str(thread.model) ?? model;
threadName = str(thread.id) ?? threadId;
this.push({
schemaVersion: 1, id: `thread:${threadId}`, traceId: threadId,
startTime: relative, endTime: relative, openEnded: true,
agentId, name: 'thread', category: 'orchestration', model, status: 'running',
attributes: { 'codewhale.seq': rec.seq, 'whalesong.container': true }, raw: rec,
});
continue;
}
if (eventName === 'turn.started' || eventName === 'turn.completed') {
const id = `turn:${turnId ?? rec.seq}`;
if (eventName === 'turn.completed')
for (const [key, request] of requests) {
if (request.parentId !== id)
continue;
this.measure(request, { ...request, endTime: Math.max(request.startTime, relative), openEnded: false, status: 'unknown' });
requests.delete(key);
}
const startWall = parseTime(turn.started_at) ?? parseTime(turn.created_at);
const endWall = parseTime(turn.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'turn.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const usage = obj(turn.usage);
const existing = events.findIndex(e => e.id === id);
const next = {
schemaVersion: 1, id, traceId: threadId, parentId: `thread:${threadId}`,
startTime: start, endTime: Math.max(start, end), openEnded: eventName !== 'turn.completed',
agentId, name: 'turn', category: 'orchestration', model,
inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
status: statusOf(turn.status ?? payload.status), latency: num(turn.duration_ms),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'whalesong.container': true,
...(statusOf(turn.status ?? payload.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { input_summary: clip(turn.input_summary) }, raw: rec,
};
if (existing >= 0) {
const prior = events[existing];
this.measure(prior, { ...next, startTime: prior.startTime });
}
else
this.push(next);
continue;
}
if (eventName === 'turn.lifecycle')
continue;
if (['approval.required', 'approval.decided', 'approval.timeout', 'user_input.required', 'user_input.answered', 'user_input.canceled'].includes(eventName)) {
const kind = eventName.startsWith('approval.') ? 'approval' : 'user_input';
const requestId = str(payload[kind === 'approval' ? 'approval_id' : 'input_id']) ?? str(payload.id);
if (!requestId)
throw new Error(`Runtime ${eventName} is missing its request identity.`);
const key = JSON.stringify([turnId ?? '', kind, requestId]);
const prior = requests.get(key), required = eventName.endsWith('.required');
if (required && prior)
continue;
if (!required && prior) {
const next = { ...prior, attributes: { ...prior.attributes },
endTime: Math.max(prior.startTime, relative), openEnded: false,
status: eventName === 'approval.decided' || eventName === 'user_input.answered' ? 'success' : 'unknown' };
if (payload.auto === true) {
// Automatic consent has a receipt, but never asked the human to wait.
next.category = 'orchestration';
delete next.attributes['whalesong.waiting'];
next.attributes['whalesong.container'] = true;
}
this.measure(prior, next);
requests.delete(key);
continue;
}
const automatic = payload.auto === true;
const event = {
schemaVersion: 1, id: `request:${key}:${rec.seq}`, traceId: threadId,
parentId: turnId ? `turn:${turnId}` : undefined, startTime: relative, endTime: relative,
openEnded: required, agentId, name: eventName, category: automatic ? 'orchestration' : 'human',
status: required ? 'pending' : 'success', model,
attributes: { 'codewhale.seq': rec.seq, 'whalesong.waiting': required, 'whalesong.container': automatic }, raw: rec,
};
this.push(event);
if (required)
requests.set(key, event);
continue;
}
if (eventName === 'tool_call.requested' || eventName === 'tool_call.canceled') {
const callId = str(payload.call_id) ?? `call:${rec.seq}`;
const tool = str(payload.tool);
const canceled = eventName === 'tool_call.canceled';
this.push({
schemaVersion: 1, id: `${eventName}:${callId}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: tool ?? eventName, tool,
category: tool ? toolCategory(tool) : 'tool', model, status: canceled ? 'error' : 'pending',
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.call_id': callId, reason: payload.reason },
payload: { arguments: clip(payload.arguments) }, raw: rec,
});
continue;
}
if (eventName === 'item.started' || eventName === 'item.completed') {
const kind = str(item.kind) ?? 'item';
const tool = itemToolName(item, payload);
const id = itemId ?? `item:${rec.seq}`;
const startWall = parseTime(item.started_at);
const endWall = parseTime(item.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'item.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const openEnded = eventName === 'item.started' && endWall === undefined;
const existing = open.get(id);
if (existing && eventName === 'item.completed') {
const prior = existing;
const status = statusOf(item.status);
this.measure(prior, { ...prior, endTime: Math.max(prior.startTime, end), openEnded: false, status,
attributes: { ...prior.attributes, ...(status === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) } });
open.delete(id);
continue;
}
const event = {
schemaVersion: 1, id, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: start, endTime: Math.max(start, end), openEnded,
agentId, name: tool ?? kind, tool, category: itemCategory(kind, tool), model,
status: statusOf(item.status ?? (eventName === 'item.started' ? 'running' : undefined)),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.item_kind': kind,
...(statusOf(item.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) }, raw: rec,
};
this.push(event);
if (eventName === 'item.started')
open.set(id, event);
continue;
}
this.push({
schemaVersion: 1, id: `${eventName}:${rec.seq}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: eventName, category: classify(eventName),
model, status: 'unknown', attributes: { 'codewhale.seq': rec.seq }, raw: rec,
});
}
this.origin = origin;
this.model = model;
this.threadName = threadName;
this.skippedDeltas = skippedDeltas;
}
snapshot() {
const { events, open, requests, origin, model, skippedDeltas, filename } = this;
const threadId = this.threadId ?? filename, threadName = this.threadName ?? threadId;
const warnings = [];
if (skippedDeltas)
warnings.push(`Dropped ${skippedDeltas.toLocaleString()} item.delta records; they are token stream fragments, not spans. Item start/end remain the source of duration.`);
for (const [id] of open)
warnings.push(`Item ${id} started and never completed in this file; duration remains unknown.`);
for (const request of requests.values())
warnings.push(`Request ${request.id} has no terminal receipt; its duration remains unknown in this file.`);
if (!events.length)
throw new Error('Codewhale runtime file contained only stream deltas or unreadable records.');
const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0].startTime);
const normalized = events.map(event => ({ ...event, startTime: event.startTime - base, endTime: event.endTime - base,
attributes: { ...event.attributes, ...(event.attributes['whalesong.error_onset_ms'] !== undefined
? { 'whalesong.error_onset_ms': (0, model_js_1.errorOnsetOf)(event) - base } : {}) } }));
return {
id: threadId,
name: `Codewhale runtime · ${threadName}`,
events: normalized,
duration: Math.max(1, normalized.reduce((m, e) => Math.max(m, e.endTime, e.startTime, e.status === 'error' ? (0, model_js_1.errorOnsetOf)(e) : 0), 0)),
originTime: origin !== undefined ? new Date(origin + base).toISOString() : '0 ms',
source: 'codewhale',
privacy: 'redact',
warnings: [...new Set(warnings)],
metadata: {
sourceFormat: 'codewhale.runtime-events/v2',
timeBasis: 'wall-clock',
sourceFilename: filename,
threadId,
model,
skippedDeltas,
recordCount: this.recordCount,
timeUnit: 'ms',
},
};
}
}
exports.CodewhaleRuntimeTrace = CodewhaleRuntimeTrace;
function fromCodewhaleRuntime(records, filename = 'Codewhale runtime', maxEvents = 250_000) {
if (!records.length)
throw new Error('Codewhale runtime event file is empty.');
const first = obj(records[0]);
const threadId = str(first.thread_id) ?? filename;
const warnings = [];
let skippedDeltas = 0;
const open = new Map();
const requests = new Map();
const events = [];
let origin;
let model;
let threadName = threadId;
const stamp = (rec) => {
const t = parseTime(rec.timestamp);
if (t === undefined)
throw new Error(`Runtime event seq ${rec.seq} is missing a usable timestamp.`);
if (origin === undefined)
origin = t;
return t - origin;
};
for (const raw of records) {
if (!isCodewhaleRuntimeRecord(raw))
throw new Error('Runtime import cancelled: a line is not a Codewhale runtime event record. No rows were skipped.');
const rec = obj(raw);
if (rec.thread_id !== threadId)
throw new Error('Runtime import contains multiple threads. Export one thread before importing.');
const eventName = rec.event;
if (eventName === 'item.delta') {
skippedDeltas++;
continue;
}
if (events.length >= maxEvents)
throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
const payload = obj(rec.payload);
const item = obj(payload.item);
const turn = obj(payload.turn);
const thread = obj(payload.thread);
const relative = stamp(rec);
const turnId = str(rec.turn_id) ?? str(payload.turn_id);
const itemId = str(rec.item_id) ?? str(item.id);
const agentId = 'parent';
if (str(thread.model))
model = str(thread.model);
if (str(turn.model))
model = str(turn.model) ?? model;
if (eventName === 'thread.started') {
model = str(thread.model) ?? model;
threadName = str(thread.id) ?? threadId;
pushEvent(events, {
schemaVersion: 1, id: `thread:${threadId}`, traceId: threadId,
startTime: relative, endTime: relative, openEnded: true,
agentId, name: 'thread', category: 'orchestration', model, status: 'running',
attributes: { 'codewhale.seq': rec.seq, 'whalesong.container': true }, raw: rec,
});
continue;
}
if (eventName === 'turn.started' || eventName === 'turn.completed') {
const id = `turn:${turnId ?? rec.seq}`;
if (eventName === 'turn.completed')
for (const [key, request] of requests) {
if (request.parentId !== id)
continue;
request.endTime = Math.max(request.startTime, relative);
request.openEnded = false;
request.status = 'unknown';
requests.delete(key);
}
const startWall = parseTime(turn.started_at) ?? parseTime(turn.created_at);
const endWall = parseTime(turn.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'turn.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const usage = obj(turn.usage);
const existing = events.findIndex(e => e.id === id);
const next = {
schemaVersion: 1, id, traceId: threadId, parentId: `thread:${threadId}`,
startTime: start, endTime: Math.max(start, end), openEnded: eventName !== 'turn.completed',
agentId, name: 'turn', category: 'orchestration', model,
inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
status: statusOf(turn.status ?? payload.status), latency: num(turn.duration_ms),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'whalesong.container': true,
...(statusOf(turn.status ?? payload.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { input_summary: clip(turn.input_summary) }, raw: rec,
};
if (existing >= 0)
events[existing] = { ...events[existing], ...next, startTime: events[existing].startTime };
else
pushEvent(events, next);
continue;
}
if (eventName === 'turn.lifecycle')
continue;
if (['approval.required', 'approval.decided', 'approval.timeout', 'user_input.required', 'user_input.answered', 'user_input.canceled'].includes(eventName)) {
const kind = eventName.startsWith('approval.') ? 'approval' : 'user_input';
const requestId = str(payload[kind === 'approval' ? 'approval_id' : 'input_id']) ?? str(payload.id);
if (!requestId)
throw new Error(`Runtime ${eventName} is missing its request identity.`);
const key = JSON.stringify([turnId ?? '', kind, requestId]);
const prior = requests.get(key), required = eventName.endsWith('.required');
if (required && prior)
continue;
if (!required && prior) {
prior.endTime = Math.max(prior.startTime, relative);
prior.openEnded = false;
prior.status = eventName === 'approval.decided' || eventName === 'user_input.answered' ? 'success' : 'unknown';
if (payload.auto === true) {
// Automatic consent has a receipt, but never asked the human to wait.
prior.category = 'orchestration';
delete prior.attributes['whalesong.waiting'];
prior.attributes['whalesong.container'] = true;
}
requests.delete(key);
continue;
}
const automatic = payload.auto === true;
const event = {
schemaVersion: 1, id: `request:${key}:${rec.seq}`, traceId: threadId,
parentId: turnId ? `turn:${turnId}` : undefined, startTime: relative, endTime: relative,
openEnded: required, agentId, name: eventName, category: automatic ? 'orchestration' : 'human',
status: required ? 'pending' : 'success', model,
attributes: { 'codewhale.seq': rec.seq, 'whalesong.waiting': required, 'whalesong.container': automatic }, raw: rec,
};
if (required)
requests.set(key, event);
pushEvent(events, event);
continue;
}
if (eventName === 'tool_call.requested' || eventName === 'tool_call.canceled') {
const callId = str(payload.call_id) ?? `call:${rec.seq}`;
const tool = str(payload.tool);
const canceled = eventName === 'tool_call.canceled';
pushEvent(events, {
schemaVersion: 1, id: `${eventName}:${callId}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: tool ?? eventName, tool,
category: tool ? toolCategory(tool) : 'tool', model, status: canceled ? 'error' : 'pending',
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.call_id': callId, reason: payload.reason },
payload: { arguments: clip(payload.arguments) }, raw: rec,
});
continue;
}
if (eventName === 'item.started' || eventName === 'item.completed') {
const kind = str(item.kind) ?? 'item';
const tool = itemToolName(item, payload);
const id = itemId ?? `item:${rec.seq}`;
const startWall = parseTime(item.started_at);
const endWall = parseTime(item.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'item.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const openEnded = eventName === 'item.started' && endWall === undefined;
const existing = open.get(id);
if (existing && eventName === 'item.completed') {
const prior = events[existing.index];
prior.endTime = Math.max(prior.startTime, end);
prior.openEnded = false;
prior.status = statusOf(item.status);
if (prior.status === 'error')
prior.attributes['whalesong.error_onset_ms'] = relative;
prior.payload = { summary: clip(item.summary), detail: clip(item.detail) };
open.delete(id);
continue;
}
const event = {
schemaVersion: 1, id, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: start, endTime: Math.max(start, end), openEnded,
agentId, name: tool ?? kind, tool, category: itemCategory(kind, tool), model,
status: statusOf(item.status ?? (eventName === 'item.started' ? 'running' : undefined)),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.item_kind': kind,
...(statusOf(item.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) }, raw: rec,
};
if (eventName === 'item.started')
open.set(id, { index: events.length, startWall: (origin ?? 0) + start });
pushEvent(events, event);
continue;
}
pushEvent(events, {
schemaVersion: 1, id: `${eventName}:${rec.seq}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: eventName, category: classify(eventName),
model, status: 'unknown', attributes: { 'codewhale.seq': rec.seq }, raw: rec,
});
}
if (skippedDeltas)
warnings.push(`Dropped ${skippedDeltas.toLocaleString()} item.delta records; they are token stream fragments, not spans. Item start/end remain the source of duration.`);
for (const [id] of open)
warnings.push(`Item ${id} started and never completed in this file; duration remains unknown.`);
for (const request of requests.values())
warnings.push(`Request ${request.id} has no terminal receipt; its duration remains unknown in this file.`);
if (!events.length)
throw new Error('Codewhale runtime file contained only stream deltas or unreadable records.');
const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0].startTime);
for (const event of events) {
if (event.attributes['whalesong.error_onset_ms'] !== undefined)
event.attributes['whalesong.error_onset_ms'] = (0, model_js_1.errorOnsetOf)(event) - base;
event.startTime -= base;
event.endTime -= base;
}
return {
id: threadId,
name: `Codewhale runtime · ${threadName}`,
events,
duration: Math.max(1, events.reduce((m, e) => Math.max(m, e.endTime, e.startTime, e.status === 'error' ? (0, model_js_1.errorOnsetOf)(e) : 0), 0)),
originTime: origin !== undefined ? new Date(origin + base).toISOString() : '0 ms',
source: 'codewhale',
privacy: 'redact',
warnings: [...new Set(warnings)],
metadata: {
sourceFormat: 'codewhale.runtime-events/v2',
timeBasis: 'wall-clock',
sourceFilename: filename,
threadId,
model,
skippedDeltas,
recordCount: records.length,
timeUnit: 'ms',
},
};
const trace = new CodewhaleRuntimeTrace(filename, maxEvents);
trace.append(records);
return trace.snapshot();
}
/** The journal owns request state until a matching terminal receipt. A live
* driver may confirm that state only while its cursor-checked stream is healthy.
+69
View File
@@ -0,0 +1,69 @@
import { open, link, rename, unlink, lstat } from 'node:fs/promises';
import { dirname, basename, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import { PET_BIN_MS, validatePetBucket, encodePetJSONL } from '../../dist/core/pet-telemetry.js';
async function syncDirectory(path) {
let directory;
try { directory = await open(path, 'r'); await directory.sync(); }
catch (error) {
// Node cannot open/sync directory handles on Windows. File data is still
// synced before its atomic replacement on that platform.
if (process.platform !== 'win32' || !['EPERM', 'EISDIR', 'EINVAL', 'ENOTSUP'].includes(error.code)) throw error;
} finally { await directory?.close(); }
}
/** Replaces the CLI's unbounded append-only output. Each complete segment is
* replayable on its own; the same live pathname always holds the newest one. */
export async function createPetRecorder(path, { maxBuckets = 216_000, maxBytes = 64 * 1024 * 1024, report = () => {} } = {}) {
if (!Number.isSafeInteger(maxBuckets) || maxBuckets < 1 || maxBuckets > 216_000
|| !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024)
throw new Error('Invalid pet recording segment limit.');
path = resolve(path);
let output = await open(path, 'wx', 0o600), sequence = 0, bytes = 0, segment = 0, busy = false;
return {
async append(bucket) {
if (!output || busy) throw new Error('Pet recorder is closed or already writing.');
validatePetBucket(bucket);
busy = true;
try {
const held = await output.stat({ bigint: true }), current = await lstat(path, { bigint: true });
if (!current.isFile() || held.dev !== current.dev || held.ino !== current.ino || held.size !== BigInt(bytes))
throw new Error('The live pet recording was changed or replaced externally; existing files were preserved.');
const encode = seq => encodePetJSONL([{ ...bucket, sequence: seq, simTimeMs: seq * PET_BIN_MS }]);
let row = encode(sequence), size = Buffer.byteLength(row);
if (sequence >= maxBuckets || bytes + size > maxBytes) {
row = encode(0); size = Buffer.byteLength(row);
if (size > maxBytes) throw new Error('Pet bucket exceeds the recording segment byte limit.');
const temporary = resolve(dirname(path), `.${basename(path)}.next-${randomUUID()}`);
const archive = `${path}.segment-${String(segment + 1).padStart(6, '0')}.jsonl`;
let next, installed = false, created = false;
try {
next = await open(temporary, 'wx', 0o600); created = true;
await next.writeFile(row); await next.sync(); await output.sync();
// link is exclusive: a collision never replaces someone else's
// archive. Persist this name before replacing the live pathname.
await link(path, archive); await syncDirectory(dirname(path));
await rename(temporary, path); installed = true;
const previous = output; output = next; next = undefined;
sequence = 0; bytes = 0; segment++;
await previous.close(); await syncDirectory(dirname(path));
report(`Archived pet recording: ${archive}`);
} finally {
await next?.close();
if (created && !installed) await unlink(temporary);
}
} else {
await output.writeFile(row);
}
bytes += size; sequence++;
} finally { busy = false; }
},
async close() {
if (busy) throw new Error('Wait for the pet recorder write before closing.');
if (!output) return;
const current = output; output = undefined;
try { await current.sync(); } finally { await current.close(); }
},
};
}
+18 -18
View File
@@ -1,6 +1,6 @@
import { setTimeout as delay } from 'node:timers/promises';
import { importTrace } from '../../dist/core/ingest.js';
import { isCodewhaleRuntimeRecord, observeRuntimeRequests } from '../../dist/core/codewhale.js';
import { privacyEvent, redact } from '../../dist/core/ingest.js';
import { CodewhaleRuntimeTrace, isCodewhaleRuntimeRecord, observeRuntimeRequests } from '../../dist/core/codewhale.js';
/** A read-only transport for the existing Runtime journal. All event meaning
* remains in Whalesong's importer and canonical pet bucketer. No raw journal,
@@ -19,9 +19,9 @@ export async function followRuntime({ baseUrl, threadId, token, report = () => {
throw new Error('The local Runtime SDK needs threadEvents support.');
const client = new sdk.CodeWhaleRuntimeClient({ baseUrl: url.href, token });
const shutdown = new AbortController();
const records = [];
let cursor = 0, bytes = 0, revision = 0, connected = false, fatal = false;
let cachedRevision = -1, cachedTrace;
const trace = new CodewhaleRuntimeTrace('Codewhale Runtime', 250_000,
event => privacyEvent(event, 'metadata'), 64 * 1024 * 1024);
let cursor = 0, revision = 0, connected = false, fatal = false;
const done = (async () => {
let backoff = 250;
while (!shutdown.signal.aborted && !fatal) {
@@ -51,18 +51,19 @@ export async function followRuntime({ baseUrl, threadId, token, report = () => {
if (record.previous_seq !== undefined && record.previous_seq !== cursor)
throw new Error('Runtime predecessor cursor does not match.');
if (record.event !== 'item.delta') {
const size = Buffer.byteLength(JSON.stringify(record));
if (records.length >= 250_000 || bytes + size > 64 * 1024 * 1024) {
fatal = true; throw new Error('Runtime recording reached its input limit.');
}
records.push(record); bytes += size; revision++;
// The existing importer retains unfinished lifetimes and a recent
// recurrence window, not a second copy of the entire raw journal.
if (revision % 256 === 0) trace.prune(Date.now() - 16_000);
try { trace.append([redact(record)]); }
catch (error) { fatal = true; throw error; }
revision++;
}
cursor = record.seq; connected = true; backoff = 250;
}
} catch (error) {
if ([400, 401, 403, 404, 405, 501].includes(error.status)) fatal = true;
if (!shutdown.signal.aborted) report(fatal
? 'Runtime input stopped: check the thread, authentication, SDK support, or recording size. Recording remains unobserved.'
? 'Runtime input stopped: check the thread, authentication, SDK support, or retained input limit. Recording remains unobserved.'
: 'Runtime input interrupted; recording unobserved gaps while reconnecting from the last cursor.');
} finally {
connected = false; clearTimeout(idleTimer); client.fetchImpl = fetchImpl;
@@ -77,14 +78,13 @@ export async function followRuntime({ baseUrl, threadId, token, report = () => {
get connected() { return connected; },
get revision() { return revision; },
get cursor() { return cursor; },
get retainedEvents() { return trace.retainedEvents; },
get retainedBytes() { return trace.retainedBytes; },
snapshot(observedThrough = Date.now()) {
if (!connected || !records.length) return undefined;
if (cachedRevision !== revision) {
// Reuse the same validated, metadata-only import path as file replay.
cachedTrace = importTrace(JSON.stringify(records), 'Codewhale Runtime', { privacy: 'metadata' })[0];
cachedRevision = revision;
}
return observeRuntimeRequests(cachedTrace, observedThrough);
if (!connected) return undefined;
trace.prune(observedThrough - 16_000);
if (!trace.retainedEvents) return undefined;
return observeRuntimeRequests({ ...trace.snapshot(), privacy: 'metadata' }, observedThrough);
},
async close() { shutdown.abort(); await done; },
};
+13 -11
View File
@@ -7,20 +7,21 @@ import { importTrace } from '../dist/core/ingest.js';
import { compilePetTelemetry, encodePetJSONL, encodePetTSV } from '../dist/core/pet-telemetry.js';
import { petDemoEvents } from '../dist/core/pet-demo.js';
import { followRuntime } from './lib/pet-runtime.mjs';
import { createPetRecorder } from './lib/pet-recorder.mjs';
const args = process.argv.slice(2);
const option = name => args.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
if (args.includes('--help')) {
console.log('node scripts/pet.mjs --input=trace.jsonl --output=pet.jsonl [--trace=ID] [--format=jsonl|tsv] [--watch]\nnode scripts/pet.mjs --runtime=http://127.0.0.1:7878 --thread=ID --output=pet.jsonl\nUse --demo instead of --input for synthetic telemetry. Output must not already exist.\nRuntime reads only the existing local event journal. Optional authentication comes from CODEWHALE_RUNTIME_TOKEN; never put a token in the URL. No agent or provider is started.');
console.log('node scripts/pet.mjs --input=trace.jsonl --output=pet.jsonl [--trace=ID] [--format=jsonl|tsv] [--watch]\nnode scripts/pet.mjs --runtime=http://127.0.0.1:7878 --thread=ID --output=pet.jsonl [--segment-buckets=216000]\nUse --demo instead of --input for synthetic telemetry. Output must not already exist.\nLive recording rotates at 216000 buckets or 64 MiB into OUTPUT.segment-NNNNNN.jsonl and continues at the same live path. All archives are retained.\nRuntime reads only the existing local event journal. Optional authentication comes from CODEWHALE_RUNTIME_TOKEN; never put a token in the URL. No agent or provider is started.');
process.exit(0);
}
let output, monitor, timer, runtime;
let output, recorder, monitor, timer, runtime;
try {
for (const a of args) if (!['--demo', '--watch'].includes(a) && !/^--(input|output|trace|format|runtime|thread)=.+/.test(a)) throw new Error('Unknown or empty option. Use --help.');
for (const a of args) if (!['--demo', '--watch'].includes(a) && !/^--(input|output|trace|format|runtime|thread|segment-buckets)=.+/.test(a)) throw new Error('Unknown or empty option. Use --help.');
const input = option('input'), runtimeURL = option('runtime'), path = option('output'), format = option('format') ?? 'jsonl', live = args.includes('--watch') || !!runtimeURL;
if (!path || [!!input, args.includes('--demo'), !!runtimeURL].filter(Boolean).length !== 1
|| !['jsonl', 'tsv'].includes(format) || live && format !== 'jsonl' || args.includes('--watch') && !input
|| !!runtimeURL !== !!option('thread') || option('trace') && !input)
|| !!runtimeURL !== !!option('thread') || option('trace') && !input || option('segment-buckets') && !live)
throw new Error('Choose one input source, an unused --output path, and JSONL for live recording. Runtime requires --thread.');
const load = async () => {
if (!input) return { events: petDemoEvents(), duration: 80_000 };
@@ -31,7 +32,8 @@ try {
return trace;
};
let trace = runtimeURL ? undefined : await load(), buckets = compilePetTelemetry(trace?.events ?? [], trace?.duration ?? 0);
output = await open(path, 'wx', 0o600);
if (live) recorder = await createPetRecorder(path, { maxBuckets: option('segment-buckets') === undefined ? 216_000 : Number(option('segment-buckets')), report: text => console.error(text) });
else output = await open(path, 'wx', 0o600);
if (runtimeURL) runtime = await followRuntime({ baseUrl: runtimeURL, threadId: option('thread'),
token: process.env.CODEWHALE_RUNTIME_TOKEN, report: text => console.error(text) });
if (!live) {
@@ -55,7 +57,6 @@ try {
try {
const elapsed = performance.now() - started, target = Math.floor(elapsed / 400);
if (sequence > target) return;
if (target >= 216_000) throw new Error('Start a new pet recording after 24 hours.');
if (runtime) {
failed = !runtime.connected;
if (!failed) {
@@ -71,9 +72,10 @@ try {
}
// A stalled host records skipped intervals as unknown instead of silently
// compressing time. Never repeat onsets when timer jitter hits a source bin twice.
while (sequence < target) {
await output.write(encodePetJSONL([{ ...empty, sequence, simTimeMs: sequence * 400 }])); sequence++;
while (sequence < target && !stopping) {
await recorder.append({ ...empty, sequence, simTimeMs: sequence * 400 }); sequence++;
}
if (stopping) return;
let state = empty;
if (runtime) {
// Seal the preceding observation interval before recording its state.
@@ -90,7 +92,7 @@ try {
if (bin === lastBin) state = { ...state, onsets: Array(13).fill(0), errors: 0 };
lastBin = bin;
}
await output.write(encodePetJSONL([{ ...state, sequence, simTimeMs: sequence * 400 }]));
await recorder.append({ ...state, sequence, simTimeMs: sequence * 400 });
sequence++;
} finally { running = false; }
};
@@ -99,12 +101,12 @@ try {
const stop = async () => {
stopping = true; clearInterval(timer); monitor?.close(); await runtime?.close();
while (running) await new Promise(resolve => setTimeout(resolve, 5));
if (output) { await output.sync(); await output.close(); output = undefined; }
await recorder.close();
};
process.once('SIGINT', stop); process.once('SIGTERM', stop);
console.log('Recording local live pet states. Ctrl+C to stop.');
}
} catch (error) {
console.error(error.message); process.exitCode = 1;
clearInterval(timer); monitor?.close(); await runtime?.close(); if (output) await output.close();
clearInterval(timer); monitor?.close(); await runtime?.close(); await recorder?.close(); if (output) await output.close();
}
+238 -179
View File
@@ -301,193 +301,252 @@ function itemCategory(kind: string, tool?: string): Category {
return classify(kind);
}
export function fromCodewhaleRuntime(records: unknown[], filename = 'Codewhale runtime', maxEvents = 250_000): Trace {
if (!records.length) throw new Error('Codewhale runtime event file is empty.');
const first = obj(records[0]);
const threadId = str(first.thread_id) ?? filename;
const warnings: string[] = [];
let skippedDeltas = 0;
const open = new Map<string, { index: number; startWall: number }>();
const requests = new Map<string, WhaleEvent>();
const events: WhaleEvent[] = [];
let origin: number | undefined;
let model: string | undefined;
let threadName = threadId;
const stamp = (rec: Obj): number => {
const t = parseTime(rec.timestamp);
if (t === undefined) throw new Error(`Runtime event seq ${rec.seq} is missing a usable timestamp.`);
if (origin === undefined) origin = t;
return t - origin;
};
for (const raw of records) {
if (!isCodewhaleRuntimeRecord(raw)) throw new Error('Runtime import cancelled: a line is not a Codewhale runtime event record. No rows were skipped.');
const rec = obj(raw);
if (rec.thread_id !== threadId) throw new Error('Runtime import contains multiple threads. Export one thread before importing.');
const eventName = rec.event as string;
if (eventName === 'item.delta') { skippedDeltas++; continue; }
if (events.length >= maxEvents) throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
const payload = obj(rec.payload);
const item = obj(payload.item);
const turn = obj(payload.turn);
const thread = obj(payload.thread);
const relative = stamp(rec);
const turnId = str(rec.turn_id) ?? str(payload.turn_id);
const itemId = str(rec.item_id) ?? str(item.id);
const agentId = 'parent';
if (str(thread.model)) model = str(thread.model);
if (str(turn.model)) model = str(turn.model) ?? model;
if (eventName === 'thread.started') {
model = str(thread.model) ?? model;
threadName = str(thread.id) ?? threadId;
pushEvent(events, {
schemaVersion: 1, id: `thread:${threadId}`, traceId: threadId,
startTime: relative, endTime: relative, openEnded: true,
agentId, name: 'thread', category: 'orchestration', model, status: 'running',
attributes: { 'codewhale.seq': rec.seq, 'whalesong.container': true }, raw: rec,
});
continue;
/** Incremental form of the existing Runtime importer. File imports and live
* recording share this exact lifecycle parser; only a live driver retires old
* completed events after it has recorded their projection. */
export class CodewhaleRuntimeTrace {
private readonly events: WhaleEvent[] = [];
private readonly open = new Map<string, WhaleEvent>();
private readonly requests = new Map<string, WhaleEvent>();
private readonly sizes = new Map<WhaleEvent, number>();
private bytes = 0;
private recordCount = 0;
private skippedDeltas = 0;
private origin: number | undefined;
private model: string | undefined;
private threadId: string | undefined;
private threadName: string | undefined;
constructor(private readonly filename = 'Codewhale runtime', private readonly maxEvents = 250_000,
private readonly project: (event: WhaleEvent) => WhaleEvent = event => event,
private readonly maxBytes = Infinity) {}
get retainedEvents(): number { return this.events.length; }
get retainedBytes(): number { return this.bytes; }
private measure(event: WhaleEvent, proposed = event): void {
const safe = this.project(proposed);
if (this.maxBytes !== Infinity) {
const size = new TextEncoder().encode(JSON.stringify(safe)).length;
const total = this.bytes - (this.sizes.get(event) ?? 0) + size;
if (total > this.maxBytes) throw new Error('Runtime observation exceeds its retained input limit.');
this.bytes = total; this.sizes.set(event, size);
}
if (eventName === 'turn.started' || eventName === 'turn.completed') {
const id = `turn:${turnId ?? rec.seq}`;
if (eventName === 'turn.completed') for (const [key, request] of requests) {
if (request.parentId !== id) continue;
request.endTime = Math.max(request.startTime, relative);
request.openEnded = false; request.status = 'unknown'; requests.delete(key);
for (const key of Object.keys(event)) if (!Object.hasOwn(safe, key)) delete (event as unknown as Obj)[key];
Object.assign(event, safe);
}
private push(event: WhaleEvent): void {
if (this.events.length >= this.maxEvents) throw new Error(`Import exceeds the ${this.maxEvents.toLocaleString()} event limit.`);
this.measure(event); pushEvent(this.events, event);
}
/** Keep unfinished lifetimes plus the recent window needed by the bucketer's
* 12-second recurrence measure. A completion may still arrive for any open item. */
prune(beforeWall: number): void {
if (!Number.isFinite(beforeWall)) throw new Error('Invalid Runtime retention horizon.');
if (this.origin === undefined) return;
const cutoff = beforeWall - this.origin;
let keep = 0;
for (const event of this.events) {
if (event.openEnded || Math.max(event.endTime, errorOnsetOf(event)) >= cutoff) this.events[keep++] = event;
else {
this.bytes -= this.sizes.get(event) ?? 0; this.sizes.delete(event);
if (this.open.get(event.id) === event) this.open.delete(event.id);
}
const startWall = parseTime(turn.started_at) ?? parseTime(turn.created_at);
const endWall = parseTime(turn.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'turn.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const usage = obj(turn.usage);
const existing = events.findIndex(e => e.id === id);
const next: WhaleEvent = {
schemaVersion: 1, id, traceId: threadId, parentId: `thread:${threadId}`,
startTime: start, endTime: Math.max(start, end), openEnded: eventName !== 'turn.completed',
agentId, name: 'turn', category: 'orchestration', model,
inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
status: statusOf(turn.status ?? payload.status), latency: num(turn.duration_ms),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'whalesong.container': true,
...(statusOf(turn.status ?? payload.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { input_summary: clip(turn.input_summary) }, raw: rec,
};
if (existing >= 0) events[existing] = { ...events[existing]!, ...next, startTime: events[existing]!.startTime };
else pushEvent(events, next);
continue;
}
if (eventName === 'turn.lifecycle') continue;
if (['approval.required', 'approval.decided', 'approval.timeout', 'user_input.required', 'user_input.answered', 'user_input.canceled'].includes(eventName)) {
const kind = eventName.startsWith('approval.') ? 'approval' : 'user_input';
const requestId = str(payload[kind === 'approval' ? 'approval_id' : 'input_id']) ?? str(payload.id);
if (!requestId) throw new Error(`Runtime ${eventName} is missing its request identity.`);
const key = JSON.stringify([turnId ?? '', kind, requestId]);
const prior = requests.get(key), required = eventName.endsWith('.required');
if (required && prior) continue;
if (!required && prior) {
prior.endTime = Math.max(prior.startTime, relative); prior.openEnded = false;
prior.status = eventName === 'approval.decided' || eventName === 'user_input.answered' ? 'success' : 'unknown';
if (payload.auto === true) {
// Automatic consent has a receipt, but never asked the human to wait.
prior.category = 'orchestration'; delete prior.attributes['whalesong.waiting'];
prior.attributes['whalesong.container'] = true;
}
requests.delete(key); continue;
}
const automatic = payload.auto === true;
const event: WhaleEvent = {
schemaVersion: 1, id: `request:${key}:${rec.seq}`, traceId: threadId,
parentId: turnId ? `turn:${turnId}` : undefined, startTime: relative, endTime: relative,
openEnded: required, agentId, name: eventName, category: automatic ? 'orchestration' : 'human',
status: required ? 'pending' : 'success', model,
attributes: { 'codewhale.seq': rec.seq, 'whalesong.waiting': required, 'whalesong.container': automatic }, raw: rec,
};
if (required) requests.set(key, event);
pushEvent(events, event); continue;
}
if (eventName === 'tool_call.requested' || eventName === 'tool_call.canceled') {
const callId = str(payload.call_id) ?? `call:${rec.seq}`;
const tool = str(payload.tool);
const canceled = eventName === 'tool_call.canceled';
pushEvent(events, {
schemaVersion: 1, id: `${eventName}:${callId}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: tool ?? eventName, tool,
category: tool ? toolCategory(tool) : 'tool', model, status: canceled ? 'error' : 'pending',
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.call_id': callId, reason: payload.reason },
payload: { arguments: clip(payload.arguments) }, raw: rec,
});
continue;
}
if (eventName === 'item.started' || eventName === 'item.completed') {
const kind = str(item.kind) ?? 'item';
const tool = itemToolName(item, payload);
const id = itemId ?? `item:${rec.seq}`;
const startWall = parseTime(item.started_at);
const endWall = parseTime(item.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'item.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const openEnded = eventName === 'item.started' && endWall === undefined;
const existing = open.get(id);
if (existing && eventName === 'item.completed') {
const prior = events[existing.index]!;
prior.endTime = Math.max(prior.startTime, end);
prior.openEnded = false;
prior.status = statusOf(item.status);
if (prior.status === 'error') prior.attributes['whalesong.error_onset_ms'] = relative;
prior.payload = { summary: clip(item.summary), detail: clip(item.detail) };
open.delete(id);
this.events.length = keep;
}
append(records: unknown[]): void {
if (!records.length) return;
const { events, open, requests } = this;
const threadId = this.threadId ?? str(obj(records[0]).thread_id) ?? this.filename;
this.threadId = threadId;
let { origin, model, skippedDeltas } = this;
let threadName = this.threadName ?? threadId;
const stamp = (rec: Obj): number => {
const t = parseTime(rec.timestamp);
if (t === undefined) throw new Error(`Runtime event seq ${rec.seq} is missing a usable timestamp.`);
if (origin === undefined) origin = t;
return t - origin;
};
for (const raw of records) {
if (!isCodewhaleRuntimeRecord(raw)) throw new Error('Runtime import cancelled: a line is not a Codewhale runtime event record. No rows were skipped.');
this.recordCount++;
const rec = obj(raw);
if (rec.thread_id !== threadId) throw new Error('Runtime import contains multiple threads. Export one thread before importing.');
const eventName = rec.event as string;
if (eventName === 'item.delta') { skippedDeltas++; continue; }
const payload = obj(rec.payload);
const item = obj(payload.item);
const turn = obj(payload.turn);
const thread = obj(payload.thread);
const relative = stamp(rec);
const turnId = str(rec.turn_id) ?? str(payload.turn_id);
const itemId = str(rec.item_id) ?? str(item.id);
const agentId = 'parent';
if (str(thread.model)) model = str(thread.model);
if (str(turn.model)) model = str(turn.model) ?? model;
if (eventName === 'thread.started') {
model = str(thread.model) ?? model;
threadName = str(thread.id) ?? threadId;
this.push({
schemaVersion: 1, id: `thread:${threadId}`, traceId: threadId,
startTime: relative, endTime: relative, openEnded: true,
agentId, name: 'thread', category: 'orchestration', model, status: 'running',
attributes: { 'codewhale.seq': rec.seq, 'whalesong.container': true }, raw: rec,
});
continue;
}
const event: WhaleEvent = {
schemaVersion: 1, id, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: start, endTime: Math.max(start, end), openEnded,
agentId, name: tool ?? kind, tool, category: itemCategory(kind, tool), model,
status: statusOf(item.status ?? (eventName === 'item.started' ? 'running' : undefined)),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.item_kind': kind,
...(statusOf(item.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) }, raw: rec,
};
if (eventName === 'item.started') open.set(id, { index: events.length, startWall: (origin ?? 0) + start });
pushEvent(events, event);
continue;
if (eventName === 'turn.started' || eventName === 'turn.completed') {
const id = `turn:${turnId ?? rec.seq}`;
if (eventName === 'turn.completed') for (const [key, request] of requests) {
if (request.parentId !== id) continue;
this.measure(request, { ...request, endTime: Math.max(request.startTime, relative), openEnded: false, status: 'unknown' });
requests.delete(key);
}
const startWall = parseTime(turn.started_at) ?? parseTime(turn.created_at);
const endWall = parseTime(turn.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'turn.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const usage = obj(turn.usage);
const existing = events.findIndex(e => e.id === id);
const next: WhaleEvent = {
schemaVersion: 1, id, traceId: threadId, parentId: `thread:${threadId}`,
startTime: start, endTime: Math.max(start, end), openEnded: eventName !== 'turn.completed',
agentId, name: 'turn', category: 'orchestration', model,
inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
status: statusOf(turn.status ?? payload.status), latency: num(turn.duration_ms),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'whalesong.container': true,
...(statusOf(turn.status ?? payload.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { input_summary: clip(turn.input_summary) }, raw: rec,
};
if (existing >= 0) {
const prior = events[existing]!; this.measure(prior, { ...next, startTime: prior.startTime });
}
else this.push(next);
continue;
}
if (eventName === 'turn.lifecycle') continue;
if (['approval.required', 'approval.decided', 'approval.timeout', 'user_input.required', 'user_input.answered', 'user_input.canceled'].includes(eventName)) {
const kind = eventName.startsWith('approval.') ? 'approval' : 'user_input';
const requestId = str(payload[kind === 'approval' ? 'approval_id' : 'input_id']) ?? str(payload.id);
if (!requestId) throw new Error(`Runtime ${eventName} is missing its request identity.`);
const key = JSON.stringify([turnId ?? '', kind, requestId]);
const prior = requests.get(key), required = eventName.endsWith('.required');
if (required && prior) continue;
if (!required && prior) {
const next: WhaleEvent = { ...prior, attributes: { ...prior.attributes },
endTime: Math.max(prior.startTime, relative), openEnded: false,
status: eventName === 'approval.decided' || eventName === 'user_input.answered' ? 'success' : 'unknown' };
if (payload.auto === true) {
// Automatic consent has a receipt, but never asked the human to wait.
next.category = 'orchestration'; delete next.attributes['whalesong.waiting'];
next.attributes['whalesong.container'] = true;
}
this.measure(prior, next); requests.delete(key); continue;
}
const automatic = payload.auto === true;
const event: WhaleEvent = {
schemaVersion: 1, id: `request:${key}:${rec.seq}`, traceId: threadId,
parentId: turnId ? `turn:${turnId}` : undefined, startTime: relative, endTime: relative,
openEnded: required, agentId, name: eventName, category: automatic ? 'orchestration' : 'human',
status: required ? 'pending' : 'success', model,
attributes: { 'codewhale.seq': rec.seq, 'whalesong.waiting': required, 'whalesong.container': automatic }, raw: rec,
};
this.push(event);
if (required) requests.set(key, event);
continue;
}
if (eventName === 'tool_call.requested' || eventName === 'tool_call.canceled') {
const callId = str(payload.call_id) ?? `call:${rec.seq}`;
const tool = str(payload.tool);
const canceled = eventName === 'tool_call.canceled';
this.push({
schemaVersion: 1, id: `${eventName}:${callId}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: tool ?? eventName, tool,
category: tool ? toolCategory(tool) : 'tool', model, status: canceled ? 'error' : 'pending',
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.call_id': callId, reason: payload.reason },
payload: { arguments: clip(payload.arguments) }, raw: rec,
});
continue;
}
if (eventName === 'item.started' || eventName === 'item.completed') {
const kind = str(item.kind) ?? 'item';
const tool = itemToolName(item, payload);
const id = itemId ?? `item:${rec.seq}`;
const startWall = parseTime(item.started_at);
const endWall = parseTime(item.ended_at);
const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
const end = eventName === 'item.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
const openEnded = eventName === 'item.started' && endWall === undefined;
const existing = open.get(id);
if (existing && eventName === 'item.completed') {
const prior = existing;
const status = statusOf(item.status);
this.measure(prior, { ...prior, endTime: Math.max(prior.startTime, end), openEnded: false, status,
attributes: { ...prior.attributes, ...(status === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) } });
open.delete(id);
continue;
}
const event: WhaleEvent = {
schemaVersion: 1, id, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: start, endTime: Math.max(start, end), openEnded,
agentId, name: tool ?? kind, tool, category: itemCategory(kind, tool), model,
status: statusOf(item.status ?? (eventName === 'item.started' ? 'running' : undefined)),
attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.item_kind': kind,
...(statusOf(item.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
payload: { summary: clip(item.summary), detail: clip(item.detail) }, raw: rec,
};
this.push(event);
if (eventName === 'item.started') open.set(id, event);
continue;
}
this.push({
schemaVersion: 1, id: `${eventName}:${rec.seq}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: eventName, category: classify(eventName),
model, status: 'unknown', attributes: { 'codewhale.seq': rec.seq }, raw: rec,
});
}
pushEvent(events, {
schemaVersion: 1, id: `${eventName}:${rec.seq}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
startTime: relative, endTime: relative, agentId, name: eventName, category: classify(eventName),
model, status: 'unknown', attributes: { 'codewhale.seq': rec.seq }, raw: rec,
});
}
if (skippedDeltas) warnings.push(`Dropped ${skippedDeltas.toLocaleString()} item.delta records; they are token stream fragments, not spans. Item start/end remain the source of duration.`);
for (const [id] of open) warnings.push(`Item ${id} started and never completed in this file; duration remains unknown.`);
for (const request of requests.values()) warnings.push(`Request ${request.id} has no terminal receipt; its duration remains unknown in this file.`);
if (!events.length) throw new Error('Codewhale runtime file contained only stream deltas or unreadable records.');
const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0]!.startTime);
for (const event of events) {
if (event.attributes['whalesong.error_onset_ms'] !== undefined) event.attributes['whalesong.error_onset_ms'] = errorOnsetOf(event) - base;
event.startTime -= base; event.endTime -= base;
this.origin = origin; this.model = model; this.threadName = threadName; this.skippedDeltas = skippedDeltas;
}
return {
id: threadId,
name: `Codewhale runtime · ${threadName}`,
events,
duration: Math.max(1, events.reduce((m, e) => Math.max(m, e.endTime, e.startTime, e.status === 'error' ? errorOnsetOf(e) : 0), 0)),
originTime: origin !== undefined ? new Date(origin + base).toISOString() : '0 ms',
source: 'codewhale',
privacy: 'redact',
warnings: [...new Set(warnings)],
metadata: {
sourceFormat: 'codewhale.runtime-events/v2',
timeBasis: 'wall-clock',
sourceFilename: filename,
threadId,
model,
skippedDeltas,
recordCount: records.length,
timeUnit: 'ms',
},
};
snapshot(): Trace {
const { events, open, requests, origin, model, skippedDeltas, filename } = this;
const threadId = this.threadId ?? filename, threadName = this.threadName ?? threadId;
const warnings: string[] = [];
if (skippedDeltas) warnings.push(`Dropped ${skippedDeltas.toLocaleString()} item.delta records; they are token stream fragments, not spans. Item start/end remain the source of duration.`);
for (const [id] of open) warnings.push(`Item ${id} started and never completed in this file; duration remains unknown.`);
for (const request of requests.values()) warnings.push(`Request ${request.id} has no terminal receipt; its duration remains unknown in this file.`);
if (!events.length) throw new Error('Codewhale runtime file contained only stream deltas or unreadable records.');
const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0]!.startTime);
const normalized = events.map(event => ({ ...event, startTime: event.startTime - base, endTime: event.endTime - base,
attributes: { ...event.attributes, ...(event.attributes['whalesong.error_onset_ms'] !== undefined
? { 'whalesong.error_onset_ms': errorOnsetOf(event) - base } : {}) } }));
return {
id: threadId,
name: `Codewhale runtime · ${threadName}`,
events: normalized,
duration: Math.max(1, normalized.reduce((m, e) => Math.max(m, e.endTime, e.startTime, e.status === 'error' ? errorOnsetOf(e) : 0), 0)),
originTime: origin !== undefined ? new Date(origin + base).toISOString() : '0 ms',
source: 'codewhale',
privacy: 'redact',
warnings: [...new Set(warnings)],
metadata: {
sourceFormat: 'codewhale.runtime-events/v2',
timeBasis: 'wall-clock',
sourceFilename: filename,
threadId,
model,
skippedDeltas,
recordCount: this.recordCount,
timeUnit: 'ms',
},
};
}
}
export function fromCodewhaleRuntime(records: unknown[], filename = 'Codewhale runtime', maxEvents = 250_000): Trace {
if (!records.length) throw new Error('Codewhale runtime event file is empty.');
const trace = new CodewhaleRuntimeTrace(filename, maxEvents);
trace.append(records); return trace.snapshot();
}
/** The journal owns request state until a matching terminal receipt. A live
+3 -2
View File
@@ -49,13 +49,14 @@ export function redact(value: unknown, path = '', hook?: ImportOptions['redactor
if (value && typeof value === 'object') seen.delete(value);
return hook ? hook(out, path) : out;
}
function privacyEvent(e: WhaleEvent, mode: PrivacyMode): WhaleEvent {
export function privacyEvent(e: WhaleEvent, mode: PrivacyMode): WhaleEvent {
if (mode === 'metadata') {
const { payload: _payload, raw: _raw, ...rest } = e;
return { ...rest, links: e.links?.map(link=>({traceId:link.traceId,spanId:link.spanId})), attributes: Object.fromEntries(Object.entries(e.attributes).filter(([k, v]) =>
SAFE_META.test(k) && (typeof v !== 'object' || v === null)
|| ['whalesong.container', 'codewhale.container', 'whalesong.waiting'].includes(k) && typeof v === 'boolean'
|| k === 'whalesong.error_onset_ms' && typeof v === 'number' && Number.isFinite(v) && v >= 0)) };
// Relative timestamps can be negative before the importer rebases them.
|| k === 'whalesong.error_onset_ms' && typeof v === 'number' && Number.isFinite(v))) };
}
return e;
}
+96
View File
@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, writeFile, stat, readdir, rename } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createPetRecorder } from '../scripts/lib/pet-recorder.mjs';
import { compilePetTelemetry, encodePetJSONL, decodePetJSONL, PetLiveTape } from '../dist/core/pet-telemetry.js';
const empty = compilePetTelemetry([])[0];
const destination = async () => join(await mkdtemp(join(tmpdir(), 'pet-segment-')), 'pet-state');
const part = (path, i) => `${path}.segment-${String(i).padStart(6, '0')}.jsonl`;
test('continuous recorder crosses a day, archives every bucket once, and the live reader resumes after each replacement', async () => {
const path = await destination(), writer = await createPetRecorder(path, { maxBuckets: 2 });
const live = new PetLiveTape(), inputs = [];
try {
for (let i = 0; i < 7; i++) {
const state = { ...empty, sequence: 215_999 + i, simTimeMs: (215_999 + i) * 400, activity: i / 10 };
inputs.push(state); await writer.append(state);
const accepted = live.readTail(await readFile(path, 'utf8'));
if (i % 2 === 0) assert.equal(accepted, undefined, 'New segment establishes a baseline');
else assert.equal(accepted.activity, state.activity, 'Next append is a new observation');
}
} finally { await writer.close(); }
const paths = [part(path, 1), part(path, 2), part(path, 3), path], replay = [];
for (const file of paths) {
const rows = decodePetJSONL(await readFile(file, 'utf8'));
assert.ok(rows.length <= 2); assert.equal(rows[0].sequence, 0);
replay.push(...rows);
if (process.platform !== 'win32') assert.equal((await stat(file)).mode & 0o777, 0o600);
}
assert.deepEqual(replay.map(b => b.activity), inputs.map(b => b.activity));
await assert.rejects(createPetRecorder(path), { code: 'EEXIST' });
await assert.rejects(writer.append(empty), /closed/);
});
test('segment byte limit counts UTF-8 data and rejects an oversized bucket before changing saved history', async () => {
const path = await destination(), state = { ...empty, agentIds: ['鯨'.repeat(20)] };
const bytes = Buffer.byteLength(encodePetJSONL([state, { ...state, sequence: 1, simTimeMs: 400 }]));
const writer = await createPetRecorder(path, { maxBytes: bytes });
try {
for (let i = 0; i < 3; i++) await writer.append(state);
const saved = await readFile(path);
await assert.rejects(writer.append({ ...empty, agentIds: ['鯨'.repeat(1000)] }), /byte limit/);
assert.deepEqual(await readFile(path), saved);
} finally { await writer.close(); }
assert.equal(decodePetJSONL(await readFile(part(path, 1), 'utf8')).length, 2);
assert.equal(decodePetJSONL(await readFile(path, 'utf8')).length, 1);
assert.equal((await stat(part(path, 1))).size, bytes);
});
test('an archive collision preserves both existing files and removes only the new unpublished temporary file', async () => {
const path = await destination(), writer = await createPetRecorder(path, { maxBuckets: 1 });
await writer.append(empty);
await writeFile(part(path, 1), 'existing archive');
const before = await readFile(path);
try { await assert.rejects(writer.append({ ...empty, channel: 'code' }), { code: 'EEXIST' }); }
finally { await writer.close(); }
assert.deepEqual(await readFile(path), before);
assert.equal(await readFile(part(path, 1), 'utf8'), 'existing archive');
assert.deepEqual((await readdir(join(path, '..'))).sort(), ['pet-state', 'pet-state.segment-000001.jsonl']);
});
test('ordinary appends refuse an externally replaced live path without overwriting either recording', async () => {
const path = await destination(), writer = await createPetRecorder(path, { maxBuckets: 2 });
await writer.append(empty);
const before = await readFile(path);
await rename(path, `${path}.held`); await writeFile(path, 'external replacement');
try { await assert.rejects(writer.append(empty), /replaced externally/); }
finally { await writer.close(); }
assert.equal(await readFile(path, 'utf8'), 'external replacement');
assert.deepEqual(await readFile(`${path}.held`), before);
assert.deepEqual((await readdir(join(path, '..'))).sort(), ['pet-state', 'pet-state.held']);
});
test('the actual watch CLI keeps recording at one pathname through several rotations and exits cleanly', { timeout: 15_000 }, async t => {
const { spawn } = await import('node:child_process');
const { once } = await import('node:events');
const { setTimeout: delay } = await import('node:timers/promises');
const path = await destination(), input = `${path}.source.json`;
await writeFile(input, JSON.stringify({ schemaVersion: 1, id: 'old', traceId: 'fixture', name: 'bash', category: 'code', startTime: 0, endTime: 1, attributes: {} }));
const child = spawn(process.execPath, ['scripts/pet.mjs', `--input=${input}`, `--output=${path}`, '--watch', '--segment-buckets=2'],
{ cwd: new URL('../', import.meta.url), stdio: ['ignore', 'pipe', 'pipe'] });
let log = ''; child.stdout.on('data', b => log += b); child.stderr.on('data', b => log += b);
const exited = once(child, 'exit');
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
const deadline = Date.now() + 10_000;
while (Date.now() < deadline && child.exitCode === null) {
try { if (decodePetJSONL(await readFile(path, 'utf8')).length >= 1 && (await stat(part(path, 3))).size) break; } catch { /* Wait for the next complete segment. */ }
await delay(25);
}
child.kill('SIGINT'); const [code] = await exited; assert.equal(code, 0, log);
for (let i = 1; i <= 3; i++) assert.equal(decodePetJSONL(await readFile(part(path, i), 'utf8')).length, 2);
assert.ok(decodePetJSONL(await readFile(path, 'utf8')).length >= 1);
assert.match(log, /Archived pet recording:/);
});
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { setTimeout as delay } from 'node:timers/promises';
import { followRuntime } from '../scripts/lib/pet-runtime.mjs';
import { compilePetTelemetry } from '../dist/core/pet-telemetry.js';
// Real HTTP/SSE transport, virtual event timestamps: no provider calls or
// 29-hour wall-clock sleep. This exceeded the old raw-journal lifetime limit.
test('Runtime consumes more than 250000 records across a day while retaining current state and unfinished requests', { timeout: 60_000 }, async t => {
const total = 260_001, began = Date.now() - total * 400, reports = [];
let response, transport, serverError;
const server = createServer(async (_req, res) => {
response = res;
const closed = new AbortController(); res.once('close', () => closed.abort());
res.writeHead(200, { 'content-type': 'text/event-stream' });
try {
for (let first = 1; first <= total; first += 128) {
let block = '';
for (let seq = first; seq < first + 128 && seq <= total; seq++) {
block += `data: ${JSON.stringify({ seq, previous_seq: seq - 1, event: seq === 1 ? 'user_input.required' : 'thread.updated',
thread_id: 'long-fixture', timestamp: new Date(began + seq * 400).toISOString(),
payload: seq === 1 ? { id: 'still-waiting' } : { description: 'fixture-private-journal'.repeat(12) } })}\n\n`;
}
if (!res.write(block)) await once(res, 'drain', { signal: closed.signal });
}
} catch (error) { if (!closed.signal.aborted) serverError = error; }
// Keep the final cursor healthy, including the still-open human request.
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
await transport?.close(); response?.destroy(); server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
});
transport = await followRuntime({ baseUrl: `http://127.0.0.1:${server.address().port}`, threadId: 'long-fixture', report: text => reports.push(text) });
const deadline = Date.now() + 50_000;
while (transport.cursor < total && Date.now() < deadline && !reports.some(text => text.includes('stopped'))) await delay(20);
assert.equal(serverError, undefined);
assert.equal(transport.cursor, total, reports.join('\n'));
assert.equal(transport.connected, true);
const now = Date.now(), snapshot = transport.snapshot(now);
assert.ok(snapshot); assert.doesNotMatch(JSON.stringify(snapshot), /fixture-private-journal/);
assert.ok(transport.retainedEvents < 512); assert.ok(transport.retainedBytes < 256 * 1024);
const live = compilePetTelemetry(snapshot.events, snapshot.duration, 0, now - 800 - Date.parse(snapshot.originTime));
assert.equal(live[0].waiting, true); assert.equal(live[0].activeMs[11], 400);
assert.deepEqual(reports, []);
});
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { importTrace, privacyEvent, redact } from '../dist/core/ingest.js';
import { CodewhaleRuntimeTrace, observeRuntimeRequests } from '../dist/core/codewhale.js';
import { compilePetTelemetry } from '../dist/core/pet-telemetry.js';
const epoch = Date.parse('2026-09-12T00:00:00Z');
const stamp = ms => new Date(epoch + ms).toISOString();
const row = (seq, event, ms, payload, turn_id = 'turn-a') => ({ seq, event, thread_id: 'fixture', turn_id, timestamp: stamp(ms), payload });
const read = rows => importTrace(JSON.stringify(rows), 'fixture', { privacy: 'metadata' })[0];
const incremental = (maxEvents = 250_000, maxBytes = 64 * 1024 * 1024) => new CodewhaleRuntimeTrace('fixture', maxEvents, event => privacyEvent(event, 'metadata'), maxBytes);
const recent = (trace, now) => {
const observed = observeRuntimeRequests(trace, epoch + now);
return compilePetTelemetry(observed.events, observed.duration, Math.floor(now / 400) - 6, epoch - Date.parse(observed.originTime));
};
test('pruned incremental Runtime input matches full imports through origin changes, open requests, automatic consent and late failures', () => {
const source = incremental(), all = [];
const steps = [
[12_000, [row(1, 'thread.updated', 10_000, {}), row(2, 'item.completed', 5000, { item: { id: 'old', kind: 'tool_call', started_at: stamp(1000), ended_at: stamp(3000), status: 'failed' }, tool: 'bash' })]],
[24_000, [row(3, 'user_input.required', 20_000, { id: 'human', request: { questions: ['fixture-private-question'] } }), row(4, 'item.started', 22_000, { item: { id: 'long', kind: 'tool_call', started_at: stamp(0), status: 'running' }, tool: 'bash' })]],
[40_000, [row(5, 'thread.updated', 39_000, {})]],
[60_000, [row(6, 'item.completed', 59_000, { item: { id: 'long', status: 'failed', ended_at: stamp(10_000), detail: 'fixture-private-result' } })]],
[72_000, [row(7, 'user_input.answered', 70_000, { input_id: 'human', answers: ['fixture-private-answer'] }), row(8, 'approval.required', 71_000, { approval_id: 'automatic' }), row(9, 'approval.decided', 71_100, { approval_id: 'automatic', auto: true })]],
[96_000, [row(10, 'user_input.required', 94_000, { id: 'turn-close' }), row(11, 'turn.completed', 95_000, { turn: { status: 'completed' } })]],
];
let prior, priorText;
for (const [now, rows] of steps) {
all.push(...rows); source.append(redact(rows)); source.prune(epoch + now - 16_000);
if (prior) assert.equal(JSON.stringify(prior), priorText, 'A later receipt cannot mutate an already captured snapshot');
const snapshot = source.snapshot();
assert.deepEqual(recent(snapshot, now), recent(read(all), now));
assert.doesNotMatch(JSON.stringify(snapshot), /fixture-private/);
if (now === 12_000) assert.equal(snapshot.events.find(e => e.id === 'old').attributes['whalesong.error_onset_ms'], 4000, 'An older negative relative error timestamp is normalized, not dropped');
if (now === 40_000) assert.ok(snapshot.events.some(e => e.id === 'long' && e.openEnded));
prior = snapshot; priorText = JSON.stringify(snapshot);
}
source.prune(epoch + 120_000);
assert.equal(source.retainedEvents, 0); assert.equal(source.retainedBytes, 0);
});
test('retained Runtime limits reject excess unfinished work and count terminal mutations without retaining payloads', () => {
const source = incremental(2, 8192);
source.append([row(1, 'user_input.required', 0, { id: 'a' }), row(2, 'user_input.required', 1000, { id: 'b' })]);
source.prune(epoch + 100_000); assert.equal(source.retainedEvents, 2);
const before = source.retainedBytes;
// Completion must remain possible at the event limit; it updates the existing
// lifetime rather than consuming another event slot.
source.append([row(3, 'user_input.answered', 100_000, { input_id: 'a', answers: ['private'.repeat(10_000)] })]);
source.prune(epoch + 120_000); assert.equal(source.retainedEvents, 1);
assert.ok(source.retainedBytes > 0 && source.retainedBytes < before);
source.append([row(4, 'user_input.required', 120_000, { id: 'c' })]);
assert.throws(() => source.append([row(5, 'user_input.required', 120_001, { id: 'd' })]), /event limit/);
assert.equal(source.retainedEvents, 2);
const bounded = incremental(100, 128);
assert.throws(() => bounded.append([row(1, 'thread.updated', 0, {})]), /retained input limit/);
assert.equal(bounded.retainedEvents, 0); assert.equal(bounded.retainedBytes, 0);
});