返回 CodeWhale
pet-runtime.mjs
根目录 / pet / scripts / lib / pet-runtime.mjs
1 import { setTimeout as delay } from 'node:timers/promises';
2 import { privacyEvent, redact } from '../../dist/core/ingest.js';
3 import { CodewhaleRuntimeTrace, isCodewhaleRuntimeRecord, observeRuntimeRequests } from '../../dist/core/codewhale.js';
4
5 /** A read-only transport for the existing Runtime journal. All event meaning
6 * remains in Whalesong's importer and canonical pet bucketer. No raw journal,
7 * prompt, tool argument or bearer token is written into the pet recording. */
8 export async function followRuntime({ baseUrl, threadId, token, report = () => {} }) {
9 const url = new URL(baseUrl);
10 if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(url.hostname)
11 || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
12 throw new Error('Pet Runtime input requires a plain HTTP loopback IP origin, without credentials or a path.');
13 if (typeof threadId !== 'string' || !threadId.trim() || threadId.length > 512)
14 throw new Error('Choose one Runtime --thread ID.');
15 let sdk;
16 try { sdk = await import('@codewhale/runtime-sdk'); }
17 catch { sdk = await import('../../../npm/runtime-sdk/index.js'); }
18 if (typeof sdk.CodeWhaleRuntimeClient.prototype.threadEvents !== 'function')
19 throw new Error('The local Runtime SDK needs threadEvents support.');
20 const client = new sdk.CodeWhaleRuntimeClient({ baseUrl: url.href, token });
21 const shutdown = new AbortController();
22 const trace = new CodewhaleRuntimeTrace('Codewhale Runtime', 250_000,
23 event => privacyEvent(event, 'metadata'), 64 * 1024 * 1024);
24 let cursor = 0, revision = 0, connected = false, fatal = false;
25 const done = (async () => {
26 let backoff = 250;
27 while (!shutdown.signal.aborted && !fatal) {
28 // Fifteen-second server heartbeats make a silent, half-open connection
29 // distinguishable from an idle journal. The timeout is driver time only.
30 const attempt = new AbortController();
31 const signal = AbortSignal.any([shutdown.signal, attempt.signal]);
32 let idleTimer;
33 const refresh = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => attempt.abort(), 45_000); };
34 refresh();
35 const fetchImpl = client.fetchImpl;
36 client.fetchImpl = async (input, init) => {
37 const response = await fetchImpl(input, init);
38 if (!response.body || !response.ok) return response;
39 // Also reject an older installed SDK that silently omits the requested
40 // progress option; it must not turn historical packets into live state.
41 if (response.headers.get('x-codewhale-event-progress') !== '1') {
42 await response.body.cancel();
43 const error = new Error('Runtime replay progress is unavailable.'); error.status = 501; throw error;
44 }
45 // Cancel the wrapped pipeline too: the original Response can be collected
46 // while its idle body is still being read through the replacement below.
47 const body = response.body.pipeThrough(new TransformStream({ transform(chunk, controller) { refresh(); controller.enqueue(chunk); } }), { signal });
48 return new Response(body, { status: response.status, headers: response.headers });
49 };
50 try {
51 for await (const record of client.threadEvents(threadId, { sinceSeq: cursor, signal, includeProgress: true })) {
52 if (record?.event === 'stream.progress') {
53 if (record.thread_id !== threadId || record.seq !== cursor || !['live', 'replaying'].includes(record.state))
54 throw new Error('Invalid Runtime replay progress.');
55 connected = record.state === 'live';
56 continue;
57 }
58 if (!isCodewhaleRuntimeRecord(record) || record.thread_id !== threadId || !Number.isSafeInteger(record.seq) || record.seq < 0)
59 throw new Error('Invalid Runtime envelope.');
60 if (record.seq <= cursor) continue;
61 // Sequence numbers belong to Runtime, and need not be consecutive.
62 // Its predecessor cursor detects loss without inventing a new counter.
63 if (record.previous_seq !== undefined && record.previous_seq !== cursor)
64 throw new Error('Runtime predecessor cursor does not match.');
65 if (record.event !== 'item.delta') {
66 // The existing importer retains unfinished lifetimes and a recent
67 // recurrence window, not a second copy of the entire raw journal.
68 if (revision % 256 === 0) trace.prune(Date.now() - 16_000);
69 try { trace.append([redact(record)]); }
70 catch (error) { fatal = true; throw error; }
71 revision++;
72 }
73 cursor = record.seq; backoff = 250;
74 }
75 } catch (error) {
76 if ([400, 401, 403, 404, 405, 501].includes(error.status)) fatal = true;
77 if (!shutdown.signal.aborted) report(fatal
78 ? 'Runtime input stopped: check the thread, authentication, SDK/Runtime replay-progress support, or retained input limit. Recording remains unobserved.'
79 : 'Runtime input interrupted; recording unobserved gaps while reconnecting from the last cursor.');
80 } finally {
81 connected = false; clearTimeout(idleTimer); client.fetchImpl = fetchImpl;
82 }
83 if (!shutdown.signal.aborted && !fatal) {
84 await delay(backoff, undefined, { signal: shutdown.signal }).catch(() => {});
85 backoff = Math.min(8000, backoff * 2);
86 }
87 }
88 })();
89 return {
90 get connected() { return connected; },
91 get revision() { return revision; },
92 get cursor() { return cursor; },
93 get retainedEvents() { return trace.retainedEvents; },
94 get retainedBytes() { return trace.retainedBytes; },
95 snapshot(observedThrough = Date.now()) {
96 if (!connected) return undefined;
97 trace.prune(observedThrough - 16_000);
98 if (!trace.retainedEvents) return undefined;
99 return observeRuntimeRequests({ ...trace.snapshot(), privacy: 'metadata' }, observedThrough);
100 },
101 async close() { shutdown.abort(); await done; },
102 };
103 }
104
104 lines Plain Text