| 1 | #!/usr/bin/env node |
| 2 | /** Read-only adapter: existing Whalesong ingestion is the only event parser. */ |
| 3 | import { readFile, stat, open } from 'node:fs/promises'; |
| 4 | import { watch } from 'node:fs'; |
| 5 | import { basename, dirname } from 'node:path'; |
| 6 | import { importTrace } from '../dist/core/ingest.js'; |
| 7 | import { compilePetTelemetry, encodePetJSONL, encodePetTSV } from '../dist/core/pet-telemetry.js'; |
| 8 | import { petDemoEvents } from '../dist/core/pet-demo.js'; |
| 9 | import { followRuntime } from './lib/pet-runtime.mjs'; |
| 10 | import { createPetRecorder } from './lib/pet-recorder.mjs'; |
| 11 | |
| 12 | const args = process.argv.slice(2); |
| 13 | const option = name => args.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3); |
| 14 | if (args.includes('--help')) { |
| 15 | 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] [--resume]\nUse --demo instead of --input for synthetic telemetry. Output must not already exist unless --resume is used for live recording. A resumed recorder preserves the previous segment and starts unknown at the same path.\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.'); |
| 16 | process.exit(0); |
| 17 | } |
| 18 | let output, recorder, monitor, timer, runtime; |
| 19 | try { |
| 20 | for (const a of args) if (!['--demo', '--watch', '--resume'].includes(a) && !/^--(input|output|trace|format|runtime|thread|segment-buckets)=.+/.test(a)) throw new Error('Unknown or empty option. Use --help.'); |
| 21 | const input = option('input'), runtimeURL = option('runtime'), path = option('output'), format = option('format') ?? 'jsonl', live = args.includes('--watch') || !!runtimeURL; |
| 22 | if (!path || [!!input, args.includes('--demo'), !!runtimeURL].filter(Boolean).length !== 1 |
| 23 | || !['jsonl', 'tsv'].includes(format) || live && format !== 'jsonl' || args.includes('--watch') && !input |
| 24 | || !!runtimeURL !== !!option('thread') || option('trace') && !input || option('segment-buckets') && !live || args.includes('--resume') && !live) |
| 25 | throw new Error('Choose one input source, an unused --output path (or --resume), and JSONL for live recording. Runtime requires --thread.'); |
| 26 | const load = async () => { |
| 27 | if (!input) return { events: petDemoEvents(), duration: 80_000 }; |
| 28 | if ((await stat(input)).size > 64 * 1024 * 1024) throw new Error('Input exceeds 64 MiB.'); |
| 29 | const traces = importTrace(await readFile(input, 'utf8'), input, { privacy: 'metadata' }); |
| 30 | const trace = option('trace') ? traces.find(t => t.id === option('trace')) : traces.length === 1 ? traces[0] : undefined; |
| 31 | if (!trace) throw new Error('Select an existing --trace ID when input contains multiple traces.'); |
| 32 | return trace; |
| 33 | }; |
| 34 | let trace = runtimeURL ? undefined : await load(), buckets = compilePetTelemetry(trace?.events ?? [], trace?.duration ?? 0); |
| 35 | if (live) recorder = await createPetRecorder(path, { resume: args.includes('--resume'), maxBuckets: option('segment-buckets') === undefined ? 216_000 : Number(option('segment-buckets')), report: text => console.error(text) }); |
| 36 | else output = await open(path, 'wx', 0o600); |
| 37 | if (runtimeURL) runtime = await followRuntime({ baseUrl: runtimeURL, threadId: option('thread'), |
| 38 | token: process.env.CODEWHALE_RUNTIME_TOKEN, report: text => console.error(text) }); |
| 39 | if (!live) { |
| 40 | await output.writeFile(format === 'tsv' ? encodePetTSV(buckets) : encodePetJSONL(buckets)); |
| 41 | await output.close(); output = undefined; |
| 42 | console.log(`Wrote ${buckets.length} pet buckets (${args.includes('--demo') ? 'demo' : 'trace replay'}).`); |
| 43 | } else { |
| 44 | // The driver owns wall time. The core only sees recorded relative timestamps. |
| 45 | const started = performance.now(), startedWall = Date.now(); |
| 46 | const origin = trace && 'originTime' in trace && trace.originTime ? Date.parse(trace.originTime) : NaN; |
| 47 | const offset = Number.isFinite(origin) ? Math.max(0, Date.now() - origin) : trace?.duration ?? 0; |
| 48 | let dirty = false, running = false, sequence = 0, failed = false, stopping = false, lastBin = -1; |
| 49 | const empty = compilePetTelemetry([])[0]; |
| 50 | if (input) { |
| 51 | monitor = watch(dirname(input), (_event, filename) => { if (!filename || String(filename) === basename(input)) dirty = true; }); |
| 52 | monitor.on('error', () => { failed = true; dirty = true; }); |
| 53 | } |
| 54 | const tick = async () => { |
| 55 | if (running || stopping) return; |
| 56 | running = true; |
| 57 | try { |
| 58 | const elapsed = performance.now() - started, target = Math.floor(elapsed / 400); |
| 59 | if (sequence > target) return; |
| 60 | if (runtime) { |
| 61 | failed = !runtime.connected; |
| 62 | if (!failed) { |
| 63 | try { |
| 64 | trace = runtime.snapshot(startedWall + elapsed); |
| 65 | } catch { failed = true; console.error('Runtime snapshot is invalid; recording an unobserved gap.'); } |
| 66 | } |
| 67 | } |
| 68 | if (dirty) { |
| 69 | dirty = false; |
| 70 | try { trace = await load(); buckets = compilePetTelemetry(trace.events, trace.duration); failed = false; } |
| 71 | catch { failed = true; console.error('Source unavailable or invalid; recording an unobserved gap.'); } |
| 72 | } |
| 73 | // A stalled host records skipped intervals as unknown instead of silently |
| 74 | // compressing time. Never repeat onsets when timer jitter hits a source bin twice. |
| 75 | while (sequence < target && !stopping) { |
| 76 | await recorder.append({ ...empty, sequence, simTimeMs: sequence * 400 }); sequence++; |
| 77 | } |
| 78 | if (stopping) return; |
| 79 | let state = empty; |
| 80 | if (runtime) { |
| 81 | // Seal the preceding observation interval before recording its state. |
| 82 | // A fixed recorder origin survives imports discovering older starts. |
| 83 | // Accepting this state one bucket later matches the foreground host. |
| 84 | if (!failed && trace && sequence > 0) { |
| 85 | try { state = compilePetTelemetry(trace.events, trace.duration, |
| 86 | sequence - 1, startedWall - Date.parse(trace.originTime))[0] ?? empty; } |
| 87 | catch { console.error('Runtime snapshot is invalid; recording an unobserved gap.'); } |
| 88 | } |
| 89 | } else { |
| 90 | const bin = Math.floor((offset + elapsed) / 400); |
| 91 | state = failed || sequence === 0 ? empty : buckets[bin] ?? empty; |
| 92 | if (bin === lastBin) state = { ...state, onsets: Array(13).fill(0), errors: 0 }; |
| 93 | lastBin = bin; |
| 94 | } |
| 95 | await recorder.append({ ...state, sequence, simTimeMs: sequence * 400 }); |
| 96 | sequence++; |
| 97 | } finally { running = false; } |
| 98 | }; |
| 99 | await tick(); |
| 100 | timer = setInterval(() => { tick().catch(async error => { console.error(error.message); process.exitCode = 1; await stop(); }); }, 400); |
| 101 | const stop = async () => { |
| 102 | stopping = true; clearInterval(timer); monitor?.close(); await runtime?.close(); |
| 103 | while (running) await new Promise(resolve => setTimeout(resolve, 5)); |
| 104 | await recorder.close(); |
| 105 | }; |
| 106 | process.once('SIGINT', stop); process.once('SIGTERM', stop); |
| 107 | console.log('Recording local live pet states. Ctrl+C to stop.'); |
| 108 | } |
| 109 | } catch (error) { |
| 110 | console.error(error.message); process.exitCode = 1; |
| 111 | clearInterval(timer); monitor?.close(); await runtime?.close(); await recorder?.close(); if (output) await output.close(); |
| 112 | } |
| 113 |