mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
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:
1 parent
43e6f02ea3
commit
e71be2c36e
14 files changed
+1179
-642
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user