返回 CodeWhale
pet.mjs
根目录 / pet / scripts / pet.mjs
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
113 lines Plain Text