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