| 1 | import test from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import { importTrace, privacyEvent, redact } from '../dist/core/ingest.js'; |
| 4 | import { CodewhaleRuntimeTrace, observeRuntimeRequests } from '../dist/core/codewhale.js'; |
| 5 | import { compilePetTelemetry } from '../dist/core/pet-telemetry.js'; |
| 6 | |
| 7 | const epoch = Date.parse('2026-09-12T00:00:00Z'); |
| 8 | const stamp = ms => new Date(epoch + ms).toISOString(); |
| 9 | const row = (seq, event, ms, payload, turn_id = 'turn-a') => ({ seq, event, thread_id: 'fixture', turn_id, timestamp: stamp(ms), payload }); |
| 10 | const read = rows => importTrace(JSON.stringify(rows), 'fixture', { privacy: 'metadata' })[0]; |
| 11 | const incremental = (maxEvents = 250_000, maxBytes = 64 * 1024 * 1024) => new CodewhaleRuntimeTrace('fixture', maxEvents, event => privacyEvent(event, 'metadata'), maxBytes); |
| 12 | const recent = (trace, now) => { |
| 13 | const observed = observeRuntimeRequests(trace, epoch + now); |
| 14 | return compilePetTelemetry(observed.events, observed.duration, Math.floor(now / 400) - 6, epoch - Date.parse(observed.originTime)); |
| 15 | }; |
| 16 | |
| 17 | test('pruned incremental Runtime input matches full imports through origin changes, open requests, automatic consent and late failures', () => { |
| 18 | const source = incremental(), all = []; |
| 19 | const steps = [ |
| 20 | [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' })]], |
| 21 | [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' })]], |
| 22 | [40_000, [row(5, 'thread.updated', 39_000, {})]], |
| 23 | [60_000, [row(6, 'item.completed', 59_000, { item: { id: 'long', status: 'failed', ended_at: stamp(10_000), detail: 'fixture-private-result' } })]], |
| 24 | [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 })]], |
| 25 | [96_000, [row(10, 'user_input.required', 94_000, { id: 'turn-close' }), row(11, 'turn.completed', 95_000, { turn: { status: 'completed' } })]], |
| 26 | ]; |
| 27 | let prior, priorText; |
| 28 | for (const [now, rows] of steps) { |
| 29 | all.push(...rows); source.append(redact(rows)); source.prune(epoch + now - 16_000); |
| 30 | if (prior) assert.equal(JSON.stringify(prior), priorText, 'A later receipt cannot mutate an already captured snapshot'); |
| 31 | const snapshot = source.snapshot(); |
| 32 | assert.deepEqual(recent(snapshot, now), recent(read(all), now)); |
| 33 | assert.doesNotMatch(JSON.stringify(snapshot), /fixture-private/); |
| 34 | 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'); |
| 35 | if (now === 40_000) assert.ok(snapshot.events.some(e => e.id === 'long' && e.openEnded)); |
| 36 | prior = snapshot; priorText = JSON.stringify(snapshot); |
| 37 | } |
| 38 | source.prune(epoch + 120_000); |
| 39 | assert.equal(source.retainedEvents, 0); assert.equal(source.retainedBytes, 0); |
| 40 | }); |
| 41 | |
| 42 | test('retained Runtime limits reject excess unfinished work and count terminal mutations without retaining payloads', () => { |
| 43 | const source = incremental(2, 8192); |
| 44 | source.append([row(1, 'user_input.required', 0, { id: 'a' }), row(2, 'user_input.required', 1000, { id: 'b' })]); |
| 45 | source.prune(epoch + 100_000); assert.equal(source.retainedEvents, 2); |
| 46 | const before = source.retainedBytes; |
| 47 | // Completion must remain possible at the event limit; it updates the existing |
| 48 | // lifetime rather than consuming another event slot. |
| 49 | source.append([row(3, 'user_input.answered', 100_000, { input_id: 'a', answers: ['private'.repeat(10_000)] })]); |
| 50 | source.prune(epoch + 120_000); assert.equal(source.retainedEvents, 1); |
| 51 | assert.ok(source.retainedBytes > 0 && source.retainedBytes < before); |
| 52 | source.append([row(4, 'user_input.required', 120_000, { id: 'c' })]); |
| 53 | assert.throws(() => source.append([row(5, 'user_input.required', 120_001, { id: 'd' })]), /event limit/); |
| 54 | assert.equal(source.retainedEvents, 2); |
| 55 | const bounded = incremental(100, 128); |
| 56 | assert.throws(() => bounded.append([row(1, 'thread.updated', 0, {})]), /retained input limit/); |
| 57 | assert.equal(bounded.retainedEvents, 0); assert.equal(bounded.retainedBytes, 0); |
| 58 | }); |
| 59 |