返回 CodeWhale
codewhale.ts
根目录 / pet / src / core / codewhale.ts
1 /** Read-only Codewhale session/runtime adapter. Does not record, mutate, or own receipts. */
2 import { CATEGORIES, errorOnsetOf, type Category, type Status, type Trace, type WhaleEvent } from './model.js';
3
4 const PAYLOAD_LIMIT = 2000;
5 const COLLAPSED_SPAN_MS = 1000;
6 const ENVELOPE_MIN_MS = 60_000;
7
8 type Obj = Record<string, any>;
9 const obj = (v: unknown): Obj => v !== null && typeof v === 'object' && !Array.isArray(v) ? v as Obj : {};
10 const str = (v: unknown): string | undefined => typeof v === 'string' && v.length ? v : undefined;
11 const num = (v: unknown): number | undefined => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
12
13 export function isCodewhaleSession(value: unknown): boolean {
14 const root = obj(value);
15 const metadata = obj(root.metadata);
16 if (!str(metadata.id)) return false;
17 if (root.format === 'whalesong.evidence/v1' || Array.isArray(root.resourceSpans) || root.schemaVersion === 1) return false;
18 const journal = obj(root.journal);
19 return Array.isArray(root.messages) || Array.isArray(journal.entries);
20 }
21
22 export function isCodewhaleRuntimeRecord(value: unknown): boolean {
23 const rec = obj(value);
24 return Number.isSafeInteger(rec.seq) && rec.seq >= 0 && typeof rec.event === 'string' && !!rec.event
25 && typeof rec.thread_id === 'string' && !!rec.thread_id && rec.timestamp != null;
26 }
27
28 export function isCodewhaleRuntimeDocument(value: unknown): boolean {
29 if (!Array.isArray(value) || !value.length) return false;
30 const n = Math.min(value.length, 8);
31 let hits = 0;
32 for (let i = 0; i < n; i++) if (isCodewhaleRuntimeRecord(value[i])) hits++;
33 return hits === n;
34 }
35
36 function clip(value: unknown): unknown {
37 if (value == null) return value;
38 const text = typeof value === 'string' ? value : JSON.stringify(value);
39 if (text.length <= PAYLOAD_LIMIT) return typeof value === 'string' ? value : JSON.parse(text);
40 return `${text.slice(0, PAYLOAD_LIMIT)}…[truncated ${text.length - PAYLOAD_LIMIT} source bytes]`;
41 }
42
43 function parseTime(value: unknown): number | undefined {
44 if (typeof value === 'number' && Number.isFinite(value)) return value;
45 if (typeof value !== 'string' || !value) return undefined;
46 const ms = Date.parse(value);
47 return Number.isFinite(ms) ? ms : undefined;
48 }
49
50 function statusOf(value: unknown, isError?: boolean): Status {
51 if (isError === true) return 'error';
52 if (isError === false) return 'success';
53 const s = String(value ?? '').toLowerCase();
54 if (s === 'completed' || s === 'success' || s === 'ok') return 'success';
55 if (s === 'failed' || s === 'error' || s === 'errored') return 'error';
56 if (s === 'canceled' || s === 'cancelled' || s === 'interrupted') return 'error';
57 if (s === 'in_progress' || s === 'running') return 'running';
58 if (s === 'pending') return 'pending';
59 return 'unknown';
60 }
61
62 function classify(name: string): Category {
63 const n = name.toLowerCase();
64 if (/exception|^error\b/.test(n)) return 'error';
65 if (/spawn|fork|subagent|^agent$/.test(n)) return 'agent';
66 if (/message\.send|handoff|agent\.message|assistant_message/.test(n)) return 'communication';
67 if (/retrieve|retrieval|context|embedding|vector|memory|rag/.test(n)) return 'memory';
68 if (/browser|navigate|screenshot|click|playwright/.test(n)) return 'browser';
69 if (/read_file|write_file|list_dir|^read$|^write$|^edit$|glob|grep|file\.|filesystem/.test(n)) return 'filesystem';
70 if (/bash|exec|shell|run_test|cargo|pytest|compile/.test(n)) return 'code';
71 if (/reason|thinking|completion|generate|chat|llm/.test(n)) return 'reasoning';
72 if (/http|request|api|fetch|network|mcp_/.test(n)) return 'network';
73 if (/user_message|human|approval/.test(n)) return 'human';
74 if (/orchestrat|workflow|phase|join|session|thread|turn|todo|plan|operate_contract|status/.test(n)) return 'orchestration';
75 if (/tool/.test(n)) return 'tool';
76 return CATEGORIES.includes(n as Category) ? n as Category : 'other';
77 }
78 /** Presentation vocabulary beside the canonical category classifier. Only the
79 * witnessed tool name is used; command contents are never inferred. */
80 export function toolActivity(name: string): { kind: string; label: string } {
81 const n = name.toLowerCase().replace(/-/g, '_');
82 if (/search|grep|glob|find_file/.test(n)) return { kind: 'searching', label: 'Searching' };
83 if (/read_file|list_dir|read_text|open_file/.test(n)) return { kind: 'reading', label: 'Reading files' };
84 if (/apply_patch|write_file|edit_file|replace_text/.test(n)) return { kind: 'editing', label: 'Editing files' };
85 if (/run_test|pytest|test_suite/.test(n)) return { kind: 'testing', label: 'Running tests' };
86 const category = toolCategory(name);
87 return ({ browser: { kind: 'browsing', label: 'Using the browser' },
88 filesystem: { kind: 'files', label: 'Working with files' },
89 code: { kind: 'executing', label: 'Running a command' },
90 network: { kind: 'network', label: 'Calling a service' },
91 agent: { kind: 'delegating', label: 'Coordinating agents' },
92 memory: { kind: 'memory', label: 'Retrieving context' },
93 reasoning: { kind: 'thinking', label: 'Thinking' },
94 communication: { kind: 'communicating', label: 'Communicating' },
95 } as Record<string, {kind: string; label: string}>)[category] ?? { kind: 'tool', label: 'Using a tool' };
96 }
97
98 export function toolCategory(name: string): Category {
99 const category = classify(name);
100 return category === 'other' ? 'tool' : category;
101 }
102
103 function pointer(source: string, ids: Obj): Obj {
104 return { format: source, ...ids };
105 }
106
107 function titleOfSession(metadata: Obj, filename: string): string {
108 const title = str(metadata.title)?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
109 if (title && !title.startsWith('codewhale:runtime_event')) return title.slice(0, 120);
110 return `Codewhale session · ${(str(metadata.id) ?? filename).slice(0, 8)}`;
111 }
112
113 function activeJournalEntries(journal: Obj): { entries: Obj[]; warnings: string[] } {
114 const entries = Array.isArray(journal.entries) ? journal.entries.map(obj) : [];
115 const leaf = str(journal.leaf_id);
116 if (!leaf || !entries.length) return { entries, warnings: [] };
117 const byId = new Map(entries.filter(e => str(e.id)).map(e => [e.id as string, e]));
118 const chain: Obj[] = [];
119 const seen = new Set<string>();
120 let id: string | undefined = leaf;
121 while (id && !seen.has(id)) {
122 seen.add(id);
123 const entry = byId.get(id);
124 if (!entry) break;
125 chain.push(entry);
126 id = str(entry.parent_id);
127 }
128 if (!chain.length) return { entries, warnings: ['Journal leaf_id did not resolve; using append order instead of the active branch.'] };
129 if (chain.length < entries.length) {
130 return {
131 entries: chain.reverse(),
132 warnings: [`Active journal branch has ${chain.length} of ${entries.length} entries. Forked history was not invented into the timeline.`],
133 };
134 }
135 return { entries: chain.reverse(), warnings: [] };
136 }
137
138 function collapsedTimestamps(entries: Obj[], created?: number, updated?: number): boolean {
139 const times = entries.map(e => parseTime(e.created_at)).filter((n): n is number => n !== undefined);
140 if (times.length < 2) return false;
141 const span = Math.max(...times) - Math.min(...times);
142 const envelope = created !== undefined && updated !== undefined ? updated - created : 0;
143 return envelope >= ENVELOPE_MIN_MS && span < COLLAPSED_SPAN_MS;
144 }
145
146 function pushEvent(events: WhaleEvent[], event: WhaleEvent): void {
147 events.push(event);
148 }
149
150 export function fromCodewhaleSession(document: unknown, filename = 'Codewhale session', maxEvents = 250_000): Trace {
151 const root = obj(document);
152 const metadata = obj(root.metadata);
153 const sessionId = str(metadata.id) ?? filename;
154 const journal = obj(root.journal);
155 const { entries, warnings } = activeJournalEntries(journal);
156 const sourceEntries: Obj[] = entries.length ? entries : (Array.isArray(root.messages) ? root.messages.map((message: unknown, i: number) => ({ id: `${sessionId}/message/${i}`, kind: 'message', message })) : []);
157 if (!sourceEntries.length) throw new Error('Codewhale session contains no journal entries or messages.');
158 const created = parseTime(metadata.created_at);
159 const updated = parseTime(metadata.updated_at);
160 const orderOnly = collapsedTimestamps(sourceEntries, created, updated);
161 if (orderOnly) {
162 warnings.push('Journal created_at values are collapsed to last-save time, not execution time. The time axis is journal order (1 ms per emitted event), not wall-clock duration. Gap, burst, and cycle-period findings are not execution-time claims.');
163 } else {
164 const times = sourceEntries.map(e => parseTime(e.created_at)).filter((n): n is number => n !== undefined);
165 if (!times.length) warnings.push('Journal entries have no usable timestamps. The time axis is journal order.');
166 }
167
168 const events: WhaleEvent[] = [];
169 const pending = new Map<string, number>();
170 let seq = 0;
171 const originWall = orderOnly ? undefined : sourceEntries.map(e => parseTime(e.created_at)).find((n): n is number => n !== undefined);
172 const agentId = 'parent';
173 const model = str(metadata.model);
174 const provider = str(metadata.model_provider);
175
176 const when = (entry: Obj, fallback: number): { start: number; open: boolean } => {
177 if (orderOnly || originWall === undefined) return { start: fallback, open: false };
178 const t = parseTime(entry.created_at);
179 if (t === undefined) return { start: fallback, open: true };
180 return { start: t - originWall, open: false };
181 };
182
183 for (const entry of sourceEntries) {
184 if (events.length >= maxEvents) throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
185 const entryId = str(entry.id) ?? `${sessionId}/entry/${seq}`;
186 const message = obj(entry.message ?? (entry.kind === 'message' ? entry : {}));
187 const role = str(message.role) ?? (str(entry.kind) === 'user' ? 'user' : str(entry.kind) === 'assistant' ? 'assistant' : undefined);
188 const blocks: Obj[] = Array.isArray(message.content) ? message.content.map(obj) : [];
189 if (!blocks.length) {
190 const text = str(entry.text) ?? str(message.text);
191 if (text) blocks.push({ type: role === 'user' ? 'text' : 'text', text });
192 }
193 if (!blocks.length) continue;
194 const parentEventId = events.length ? events[events.length - 1]!.id : undefined;
195 for (const block of blocks) {
196 const t = when(entry, seq);
197 const idBase = `${entryId}/${seq}`;
198 const type = str(block.type) ?? 'text';
199 const raw = pointer('codewhale.session/v1', { sessionId, entryId, seq, blockType: type, toolUseId: block.id ?? block.tool_use_id });
200 if (type === 'tool_use' || type === 'server_tool_use') {
201 const tool = str(block.name) ?? 'tool';
202 const callId = str(block.id) ?? idBase;
203 const started = tool === 'agent' && obj(block.input).action === 'start';
204 const event: WhaleEvent = {
205 schemaVersion: 1, id: callId, traceId: sessionId, parentId: parentEventId,
206 startTime: t.start, endTime: t.start, openEnded: true,
207 agentId, name: started ? 'agent.spawn' : tool, tool, category: toolCategory(tool),
208 subtype: started ? 'fork' : undefined, model, provider,
209 status: 'running', attributes: { 'codewhale.entry_id': entryId, 'codewhale.seq': seq, 'tool.name': tool },
210 payload: { arguments: clip(block.input) }, raw,
211 };
212 pending.set(callId, events.length);
213 pushEvent(events, event);
214 } else if (type === 'tool_result') {
215 const callId = str(block.tool_use_id);
216 const isError = block.is_error === true;
217 const target = callId !== undefined ? pending.get(callId) : undefined;
218 if (target !== undefined) {
219 const prior = events[target]!;
220 prior.endTime = t.start;
221 prior.openEnded = false;
222 prior.status = statusOf('completed', isError);
223 prior.payload = { ...(obj(prior.payload)), result: clip(block.content) };
224 prior.attributes = { ...prior.attributes, 'codewhale.result_entry_id': entryId };
225 pending.delete(callId as string);
226 } else {
227 pushEvent(events, {
228 schemaVersion: 1, id: idBase, traceId: sessionId, parentId: callId ?? parentEventId,
229 startTime: t.start, endTime: t.start, agentId,
230 name: 'tool_result', category: 'tool', model, provider,
231 status: statusOf(undefined, isError),
232 attributes: { 'codewhale.entry_id': entryId, 'codewhale.seq': seq, tool_use_id: callId },
233 payload: { result: clip(block.content) }, raw,
234 });
235 }
236 } else if (type === 'thinking') {
237 pushEvent(events, {
238 schemaVersion: 1, id: idBase, traceId: sessionId, parentId: parentEventId,
239 startTime: t.start, endTime: t.start, agentId, name: 'thinking', category: 'reasoning',
240 model, provider, status: 'success',
241 attributes: { 'codewhale.entry_id': entryId, 'codewhale.seq': seq },
242 payload: { thinking: clip(block.thinking ?? block.text) }, raw,
243 });
244 } else {
245 const text = str(block.text) ?? '';
246 const operate = text.includes('codewhale:runtime_event');
247 const user = role === 'user' || role === 'User';
248 pushEvent(events, {
249 schemaVersion: 1, id: idBase, traceId: sessionId, parentId: parentEventId,
250 startTime: t.start, endTime: t.start, agentId,
251 name: operate ? 'operate_contract' : user ? 'user_message' : 'assistant_message',
252 category: operate ? 'orchestration' : user ? 'human' : 'communication',
253 model, provider, status: 'success',
254 attributes: { 'codewhale.entry_id': entryId, 'codewhale.seq': seq, role: role ?? 'unknown' },
255 payload: { text: clip(text) }, raw,
256 });
257 }
258 seq += 1;
259 }
260 }
261
262 if (!events.length) throw new Error('Codewhale session produced no inspectable events.');
263 for (const event of events) {
264 if (event.openEnded && event.tool) warnings.push(`Tool ${event.id} has no matching tool_result in this snapshot; duration remains unknown.`);
265 }
266 const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0]!.startTime);
267 for (const event of events) { event.startTime -= base; event.endTime -= base; }
268 const cost = obj(metadata.cost);
269 const sessionCost = num(cost.session_cost_usd);
270 const duration = Math.max(1, events.reduce((m, e) => Math.max(m, e.endTime, e.startTime), 0));
271 const uniqueWarnings = [...new Set(warnings)];
272 return {
273 id: sessionId,
274 name: titleOfSession(metadata, filename),
275 events,
276 duration,
277 originTime: orderOnly ? 'journal-order' : (str(metadata.created_at) ?? `${base} ms`),
278 source: 'codewhale',
279 privacy: 'redact',
280 warnings: uniqueWarnings,
281 metadata: {
282 sourceFormat: 'codewhale.session/v1',
283 timeBasis: orderOnly || originWall === undefined ? 'journal-order' : 'wall-clock',
284 sourceFilename: filename,
285 sessionId,
286 model,
287 provider,
288 workspace: metadata.workspace,
289 mode: metadata.mode,
290 envelopeCreatedAt: metadata.created_at,
291 envelopeUpdatedAt: metadata.updated_at,
292 cumulativeTurnSecs: metadata.cumulative_turn_secs,
293 messageCount: metadata.message_count,
294 journalEntries: sourceEntries.length,
295 totalTokens: metadata.total_tokens,
296 sessionCostUsd: sessionCost,
297 pricedTurns: cost.priced_turns,
298 unpricedTurns: cost.unpriced_turns,
299 runtimeStore: metadata.runtime_store,
300 timeUnit: 'ms',
301 },
302 };
303 }
304
305 function itemToolName(item: Obj, payload: Obj): string | undefined {
306 const named = str(payload.tool) ?? str(item.tool) ?? str(item.name);
307 if (named) return named;
308 if (str(item.kind) !== 'tool_call') return undefined;
309 const head = str(item.summary)?.split(':')[0]?.trim();
310 if (head && head.length < 80 && !/\s/.test(head)) return head;
311 return undefined;
312 }
313
314 function itemCategory(kind: string, tool?: string): Category {
315 if (kind === 'user_message') return 'human';
316 if (kind === 'agent_reasoning') return 'reasoning';
317 if (kind === 'agent_message') return 'communication';
318 if (kind === 'status') return 'orchestration';
319 if (kind === 'tool_call' && tool) return toolCategory(tool);
320 if (kind === 'tool_call') return 'tool';
321 return classify(kind);
322 }
323
324 /** Incremental form of the existing Runtime importer. File imports and live
325 * recording share this exact lifecycle parser; only a live driver retires old
326 * completed events after it has recorded their projection. */
327 export class CodewhaleRuntimeTrace {
328 private readonly events: WhaleEvent[] = [];
329 private readonly open = new Map<string, WhaleEvent>();
330 private readonly requests = new Map<string, WhaleEvent>();
331 private readonly sizes = new Map<WhaleEvent, number>();
332 private bytes = 0;
333 private recordCount = 0;
334 private skippedDeltas = 0;
335 private origin: number | undefined;
336 private model: string | undefined;
337 private threadId: string | undefined;
338 private threadName: string | undefined;
339 constructor(private readonly filename = 'Codewhale runtime', private readonly maxEvents = 250_000,
340 private readonly project: (event: WhaleEvent) => WhaleEvent = event => event,
341 private readonly maxBytes = Infinity) {}
342 get retainedEvents(): number { return this.events.length; }
343 get retainedBytes(): number { return this.bytes; }
344 private measure(event: WhaleEvent, proposed = event): void {
345 const safe = this.project(proposed);
346 if (this.maxBytes !== Infinity) {
347 const size = new TextEncoder().encode(JSON.stringify(safe)).length;
348 const total = this.bytes - (this.sizes.get(event) ?? 0) + size;
349 if (total > this.maxBytes) throw new Error('Runtime observation exceeds its retained input limit.');
350 this.bytes = total; this.sizes.set(event, size);
351 }
352 for (const key of Object.keys(event)) if (!Object.hasOwn(safe, key)) delete (event as unknown as Obj)[key];
353 Object.assign(event, safe);
354 }
355 private push(event: WhaleEvent): void {
356 if (this.events.length >= this.maxEvents) throw new Error(`Import exceeds the ${this.maxEvents.toLocaleString()} event limit.`);
357 this.measure(event); pushEvent(this.events, event);
358 }
359 /** Keep unfinished lifetimes plus the recent window needed by the bucketer's
360 * 12-second recurrence measure. A completion may still arrive for any open item. */
361 prune(beforeWall: number): void {
362 if (!Number.isFinite(beforeWall)) throw new Error('Invalid Runtime retention horizon.');
363 if (this.origin === undefined) return;
364 const cutoff = beforeWall - this.origin;
365 let keep = 0;
366 for (const event of this.events) {
367 if (event.openEnded || Math.max(event.endTime, errorOnsetOf(event)) >= cutoff) this.events[keep++] = event;
368 else {
369 this.bytes -= this.sizes.get(event) ?? 0; this.sizes.delete(event);
370 if (this.open.get(event.id) === event) this.open.delete(event.id);
371 }
372 }
373 this.events.length = keep;
374 }
375 append(records: unknown[]): void {
376 if (!records.length) return;
377 const { events, open, requests } = this;
378 const threadId = this.threadId ?? str(obj(records[0]).thread_id) ?? this.filename;
379 this.threadId = threadId;
380 let { origin, model, skippedDeltas } = this;
381 let threadName = this.threadName ?? threadId;
382 const stamp = (rec: Obj): number => {
383 const t = parseTime(rec.timestamp);
384 if (t === undefined) throw new Error(`Runtime event seq ${rec.seq} is missing a usable timestamp.`);
385 if (origin === undefined) origin = t;
386 return t - origin;
387 };
388 for (const raw of records) {
389 if (!isCodewhaleRuntimeRecord(raw)) throw new Error('Runtime import cancelled: a line is not a Codewhale runtime event record. No rows were skipped.');
390 this.recordCount++;
391 const rec = obj(raw);
392 if (rec.thread_id !== threadId) throw new Error('Runtime import contains multiple threads. Export one thread before importing.');
393 const eventName = rec.event as string;
394 if (eventName === 'item.delta') { skippedDeltas++; continue; }
395 const payload = obj(rec.payload);
396 const item = obj(payload.item);
397 const turn = obj(payload.turn);
398 const thread = obj(payload.thread);
399 const relative = stamp(rec);
400 const turnId = str(rec.turn_id) ?? str(payload.turn_id);
401 const itemId = str(rec.item_id) ?? str(item.id);
402 const agentId = 'parent';
403 if (str(thread.model)) model = str(thread.model);
404 if (str(turn.model)) model = str(turn.model) ?? model;
405
406 if (eventName === 'thread.started') {
407 model = str(thread.model) ?? model;
408 threadName = str(thread.id) ?? threadId;
409 this.push({
410 schemaVersion: 1, id: `thread:${threadId}`, traceId: threadId,
411 startTime: relative, endTime: relative, openEnded: true,
412 agentId, name: 'thread', category: 'orchestration', model, status: 'running',
413 attributes: { 'codewhale.seq': rec.seq, 'whalesong.container': true }, raw: rec,
414 });
415 continue;
416 }
417 if (eventName === 'turn.started' || eventName === 'turn.completed') {
418 const id = `turn:${turnId ?? rec.seq}`;
419 if (eventName === 'turn.completed') for (const [key, request] of requests) {
420 if (request.parentId !== id) continue;
421 this.measure(request, { ...request, endTime: Math.max(request.startTime, relative), openEnded: false, status: 'unknown' });
422 requests.delete(key);
423 }
424 const startWall = parseTime(turn.started_at) ?? parseTime(turn.created_at);
425 const endWall = parseTime(turn.ended_at);
426 const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
427 const end = eventName === 'turn.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
428 const usage = obj(turn.usage);
429 const existing = events.findIndex(e => e.id === id);
430 const next: WhaleEvent = {
431 schemaVersion: 1, id, traceId: threadId, parentId: `thread:${threadId}`,
432 startTime: start, endTime: Math.max(start, end), openEnded: eventName !== 'turn.completed',
433 agentId, name: 'turn', category: 'orchestration', model,
434 inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
435 status: statusOf(turn.status ?? payload.status), latency: num(turn.duration_ms),
436 attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'whalesong.container': true,
437 ...(statusOf(turn.status ?? payload.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
438 payload: { input_summary: clip(turn.input_summary) }, raw: rec,
439 };
440 if (existing >= 0) {
441 const prior = events[existing]!; this.measure(prior, { ...next, startTime: prior.startTime });
442 }
443 else this.push(next);
444 continue;
445 }
446 if (eventName === 'turn.lifecycle') continue;
447 if (['approval.required', 'approval.decided', 'approval.timeout', 'user_input.required', 'user_input.answered', 'user_input.canceled'].includes(eventName)) {
448 const kind = eventName.startsWith('approval.') ? 'approval' : 'user_input';
449 const requestId = str(payload[kind === 'approval' ? 'approval_id' : 'input_id']) ?? str(payload.id);
450 if (!requestId) throw new Error(`Runtime ${eventName} is missing its request identity.`);
451 const key = JSON.stringify([turnId ?? '', kind, requestId]);
452 const prior = requests.get(key), required = eventName.endsWith('.required');
453 if (required && prior) continue;
454 if (!required && prior) {
455 const next: WhaleEvent = { ...prior, attributes: { ...prior.attributes },
456 endTime: Math.max(prior.startTime, relative), openEnded: false,
457 status: eventName === 'approval.decided' || eventName === 'user_input.answered' ? 'success' : 'unknown' };
458 if (payload.auto === true) {
459 // Automatic consent has a receipt, but never asked the human to wait.
460 next.category = 'orchestration'; delete next.attributes['whalesong.waiting'];
461 next.attributes['whalesong.container'] = true;
462 }
463 this.measure(prior, next); requests.delete(key); continue;
464 }
465 const automatic = payload.auto === true;
466 const event: WhaleEvent = {
467 schemaVersion: 1, id: `request:${key}:${rec.seq}`, traceId: threadId,
468 parentId: turnId ? `turn:${turnId}` : undefined, startTime: relative, endTime: relative,
469 openEnded: required, agentId, name: eventName, category: automatic ? 'orchestration' : 'human',
470 status: required ? 'pending' : 'success', model,
471 attributes: { 'codewhale.seq': rec.seq, 'whalesong.waiting': required, 'whalesong.container': automatic }, raw: rec,
472 };
473 this.push(event);
474 if (required) requests.set(key, event);
475 continue;
476 }
477 if (eventName === 'tool_call.requested' || eventName === 'tool_call.canceled') {
478 const callId = str(payload.call_id) ?? `call:${rec.seq}`;
479 const tool = str(payload.tool);
480 const canceled = eventName === 'tool_call.canceled';
481 this.push({
482 schemaVersion: 1, id: `${eventName}:${callId}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
483 startTime: relative, endTime: relative, agentId, name: tool ?? eventName, tool,
484 category: tool ? toolCategory(tool) : 'tool', model, status: canceled ? 'error' : 'pending',
485 attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.call_id': callId, reason: payload.reason },
486 payload: { arguments: clip(payload.arguments) }, raw: rec,
487 });
488 continue;
489 }
490 if (eventName === 'item.started' || eventName === 'item.completed') {
491 const kind = str(item.kind) ?? 'item';
492 const tool = itemToolName(item, payload);
493 const id = itemId ?? `item:${rec.seq}`;
494 const startWall = parseTime(item.started_at);
495 const endWall = parseTime(item.ended_at);
496 const start = startWall !== undefined && origin !== undefined ? startWall - origin : relative;
497 const end = eventName === 'item.completed' && endWall !== undefined && origin !== undefined ? endWall - origin : relative;
498 const openEnded = eventName === 'item.started' && endWall === undefined;
499 const existing = open.get(id);
500 if (existing && eventName === 'item.completed') {
501 const prior = existing;
502 const status = statusOf(item.status);
503 this.measure(prior, { ...prior, endTime: Math.max(prior.startTime, end), openEnded: false, status,
504 attributes: { ...prior.attributes, ...(status === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
505 payload: { summary: clip(item.summary), detail: clip(item.detail) } });
506 open.delete(id);
507 continue;
508 }
509 const event: WhaleEvent = {
510 schemaVersion: 1, id, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
511 startTime: start, endTime: Math.max(start, end), openEnded,
512 agentId, name: tool ?? kind, tool, category: itemCategory(kind, tool), model,
513 status: statusOf(item.status ?? (eventName === 'item.started' ? 'running' : undefined)),
514 attributes: { 'codewhale.seq': rec.seq, 'codewhale.turn_id': turnId, 'codewhale.item_kind': kind,
515 ...(statusOf(item.status) === 'error' ? { 'whalesong.error_onset_ms': relative } : {}) },
516 payload: { summary: clip(item.summary), detail: clip(item.detail) }, raw: rec,
517 };
518 this.push(event);
519 if (eventName === 'item.started') open.set(id, event);
520 continue;
521 }
522 this.push({
523 schemaVersion: 1, id: `${eventName}:${rec.seq}`, traceId: threadId, parentId: turnId ? `turn:${turnId}` : undefined,
524 startTime: relative, endTime: relative, agentId, name: eventName, category: classify(eventName),
525 model, status: 'unknown', attributes: { 'codewhale.seq': rec.seq }, raw: rec,
526 });
527 }
528
529 this.origin = origin; this.model = model; this.threadName = threadName; this.skippedDeltas = skippedDeltas;
530 }
531 snapshot(): Trace {
532 const { events, open, requests, origin, model, skippedDeltas, filename } = this;
533 const threadId = this.threadId ?? filename, threadName = this.threadName ?? threadId;
534 const warnings: string[] = [];
535 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.`);
536 for (const [id] of open) warnings.push(`Item ${id} started and never completed in this file; duration remains unknown.`);
537 for (const request of requests.values()) warnings.push(`Request ${request.id} has no terminal receipt; its duration remains unknown in this file.`);
538 if (!events.length) throw new Error('Codewhale runtime file contained only stream deltas or unreadable records.');
539 const base = events.reduce((m, e) => Math.min(m, e.startTime), events[0]!.startTime);
540 const normalized = events.map(event => ({ ...event, startTime: event.startTime - base, endTime: event.endTime - base,
541 attributes: { ...event.attributes, ...(event.attributes['whalesong.error_onset_ms'] !== undefined
542 ? { 'whalesong.error_onset_ms': errorOnsetOf(event) - base } : {}) } }));
543 return {
544 id: threadId,
545 name: `Codewhale runtime · ${threadName}`,
546 events: normalized,
547 duration: Math.max(1, normalized.reduce((m, e) => Math.max(m, e.endTime, e.startTime, e.status === 'error' ? errorOnsetOf(e) : 0), 0)),
548 originTime: origin !== undefined ? new Date(origin + base).toISOString() : '0 ms',
549 source: 'codewhale',
550 privacy: 'redact',
551 warnings: [...new Set(warnings)],
552 metadata: {
553 sourceFormat: 'codewhale.runtime-events/v2',
554 timeBasis: 'wall-clock',
555 sourceFilename: filename,
556 threadId,
557 model,
558 skippedDeltas,
559 recordCount: this.recordCount,
560 timeUnit: 'ms',
561 },
562 };
563 }
564 }
565
566 export function fromCodewhaleRuntime(records: unknown[], filename = 'Codewhale runtime', maxEvents = 250_000): Trace {
567 if (!records.length) throw new Error('Codewhale runtime event file is empty.');
568 const trace = new CodewhaleRuntimeTrace(filename, maxEvents);
569 trace.append(records); return trace.snapshot();
570 }
571
572 /** The journal owns request state until a matching terminal receipt. A live
573 * driver may confirm that state only while its cursor-checked stream is healthy.
574 * Ordinary open tool spans remain unknown-duration; no execution is inferred. */
575 export function observeRuntimeRequests(trace: Trace, observedThrough: number): Trace {
576 const origin = Date.parse(trace.originTime ?? '');
577 if (trace.metadata.sourceFormat !== 'codewhale.runtime-events/v2' || !Number.isFinite(origin)
578 || !Number.isFinite(observedThrough)) throw new Error('Invalid Runtime observation horizon.');
579 const at = observedThrough - origin;
580 const events = trace.events.map(e => e.openEnded && e.attributes['whalesong.waiting'] === true && at >= e.startTime
581 ? { ...e, endTime: at, openEnded: false } : e);
582 return { ...trace, events, duration: Math.max(trace.duration, at) };
583 }
584
584 lines TYPESCRIPT