| 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 |