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