UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

147 lines 6.23 kB
import { watch } from 'node:fs'; import { open } from 'node:fs/promises'; /** * Tails an append-only JSONL log, calling `onLine` for each complete line as it * is written. Reads only the bytes appended since the last {@link pull}, * buffering a torn trailing line until its newline arrives. A file that shrinks * (a fresh agent truncated the log) resets to the start so the new content is * picked up. The generic base behind the daemon's event tail and the agent's * control tail (#344) — one tailer, two directions. */ export class JsonlTailer { path; onLine; offset = 0; partial = ''; lastMtimeMs = 0; adoptMtime = false; constructor(path, onLine) { this.path = path; this.onLine = onLine; } /** * Point the tailer at the journal's new home, keeping the read offset. For a log that is * *relocated with its content intact* — an agent's `events.jsonl` is copied verbatim into the * archive at teardown (and restored on a continuation) — the bytes already consumed are a * prefix of the new file, so the next {@link pull} delivers exactly the lines the move would * otherwise have swallowed, without replaying what was already delivered. * * The first pull after a retarget adopts the new home's mtime instead of running the * same-length-rewrite check: the copy is younger than the original by construction, and a * fully-consumed journal would otherwise read as "rewritten to the same length" and replay * every line it already delivered. */ retarget(path) { this.path = path; this.adoptMtime = true; } /** Read and dispatch any lines appended since the previous call. */ async pull() { let fd; try { fd = await open(this.path, 'r'); } catch { return; // not created yet (nothing has written) } try { const { size, mtimeMs } = await fd.stat(); // A fresh agent truncates the log in place (same inode). Detect it two ways: the // file shrank below what we consumed, or it was rewritten to the same length // (size unchanged but mtime advanced). Either way, re-read from the top. // Suspended for the first read after a retarget, whose newer mtime is the copy's, not a rewrite's. const rewritten = !this.adoptMtime && size === this.offset && this.offset > 0 && mtimeMs > this.lastMtimeMs; if (size < this.offset || rewritten) { this.offset = 0; this.partial = ''; } this.lastMtimeMs = mtimeMs; this.adoptMtime = false; if (size === this.offset) return; const buf = Buffer.alloc(size - this.offset); await fd.read(buf, 0, buf.length, this.offset); this.offset = size; this.partial += buf.toString('utf8'); const lines = this.partial.split('\n'); this.partial = lines.pop() ?? ''; // trailing fragment with no newline yet for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { this.onLine(JSON.parse(trimmed)); } catch { // a torn/half-written line — skip it; the log never rewrites history } } } finally { await fd.close(); } } } /** * Drive a {@link JsonlTailer} as the file grows: an `fs.watch` on `dir` for latency, plus a * poll backstop because `fs.watch` is unreliable across platforms. Pulls are serialized (a * pull already in flight swallows the next trigger) and stop for good once the returned * function is called. Shared by the agent's control tail and the dashboard's event tail, which * hand-rolled this separately and drifted apart. * * Nothing here may throw at the process: a failed pull and a watcher error are both survivable, * and the poll alone is a complete tail (#996). */ export function followFile(dir, pull, opts) { let pulling = false; let stopped = false; const pump = async () => { if (pulling || stopped) return; pulling = true; try { await pull(); } catch { // #996: every caller discards this promise, so a rejected read (EIO on a network mount, // EISDIR, a log grown past kMaxLength) would be an unhandled rejection and kill the // process. Swallowed rather than logged: the next tick retries, and a fault that persists // would otherwise print once per poll forever. } finally { pulling = false; } }; let watcher; try { watcher = watch(dir, () => void pump()); // #996: an 'error' with no listener throws out of the emitter, which is the same process // death. The watcher is spent once it errors (node closes the handle first), so drop it and // let the poll below carry the tail on its own. watcher.on('error', () => { watcher?.close(); watcher = undefined; }); // Unref the watcher, not just the poll below: an `fs.watch` handle refs the event loop on // its own, so unrefing only the timer left `unref: true` a half-measure that still pinned the // process open. That is what wedged an agent whose config check failed before its watcher had an // owner to close it — the CLI returned, and the process then sat there forever with the agent // recorded as `running`. if (opts.unref) watcher?.unref(); } catch { // dir may not be watchable everywhere; the poll backstop still covers it } const poll = setInterval(() => void pump(), opts.pollMs); if (opts.unref) poll.unref(); void pump(); // seed with whatever is already written return () => { stopped = true; clearInterval(poll); watcher?.close(); watcher = undefined; }; } //# sourceMappingURL=jsonl-tail.js.map