glassbox-trace 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tail.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // Glassbox live tail — follow a session transcript (and its subagents) as it grows, and serve
2
+ // the viewer with Server-Sent Events on 127.0.0.1. No dependencies. Nothing leaves the machine.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import http from 'node:http';
6
+ import { subagentFiles } from './cli.mjs';
7
+
8
+ // Follow one session's files by byte offset. Emits:
9
+ // { type: 'snapshot', files: [{name, text}] } once, at start()
10
+ // { type: 'append', name, text } new complete lines
11
+ // { type: 'replace', name, text } file shrank or was rewritten → whole file again
12
+ // A trailing partial line (no newline yet) is held back until it completes.
13
+ export class Tailer {
14
+ constructor(session, opts = {}) {
15
+ this.session = session;
16
+ this.pollMs = opts.pollMs || 1000;
17
+ this.files = new Map(); // name -> { path, offset, pending }
18
+ this.listeners = [];
19
+ this.timer = null;
20
+ this.watchers = [];
21
+ }
22
+ on(fn) { this.listeners.push(fn); return this; }
23
+ emit(ev) { for (const fn of this.listeners) fn(ev); }
24
+
25
+ // All files that belong to the session right now: main + subagents dir (Workflow agents included;
26
+ // fs.watch below is not recursive, so their folders are picked up by the poll).
27
+ discover() {
28
+ const s = this.session;
29
+ return [{ name: s.id + '.jsonl', path: s.file }, ...subagentFiles(s)];
30
+ }
31
+
32
+ // Read everything new. Returns the events it emitted (also emits them).
33
+ tick() {
34
+ const events = [];
35
+ for (const { name, path: p } of this.discover()) {
36
+ let st; try { st = fs.statSync(p); } catch (e) { continue; }
37
+ let f = this.files.get(name);
38
+ if (!f) { f = { path: p, offset: 0, pending: Buffer.alloc(0) }; this.files.set(name, f); }
39
+ if (st.size < f.offset) { // truncated or rewritten
40
+ f.offset = 0; f.pending = Buffer.alloc(0);
41
+ const text = this.readFrom(f, st.size, true);
42
+ events.push({ type: 'replace', name, text });
43
+ continue;
44
+ }
45
+ if (st.size === f.offset) continue;
46
+ const text = this.readFrom(f, st.size, false);
47
+ if (text) events.push({ type: 'append', name, text });
48
+ }
49
+ for (const ev of events) this.emit(ev);
50
+ return events;
51
+ }
52
+
53
+ // Read [offset, size) from the file; split at the last newline byte and keep the tail pending
54
+ // as bytes, so a multi-byte UTF-8 character cut across two appends is reassembled intact.
55
+ readFrom(f, size, whole) {
56
+ const len = size - f.offset; if (len <= 0) return '';
57
+ const fd = fs.openSync(f.path, 'r');
58
+ let chunk;
59
+ try { const buf = Buffer.alloc(len); const n = fs.readSync(fd, buf, 0, len, f.offset); chunk = buf.subarray(0, n); } finally { fs.closeSync(fd); }
60
+ f.offset = size;
61
+ const all = f.pending && f.pending.length ? Buffer.concat([f.pending, chunk]) : chunk;
62
+ const cut = all.lastIndexOf(0x0a);
63
+ if (cut < 0) { if (whole) { f.pending = Buffer.alloc(0); return all.toString('utf8'); } f.pending = Buffer.from(all); return ''; }
64
+ f.pending = Buffer.from(all.subarray(cut + 1));
65
+ return all.subarray(0, cut + 1).toString('utf8');
66
+ }
67
+
68
+ // Full current content of every file (complete lines only), as the viewer expects them.
69
+ snapshot() {
70
+ const files = [];
71
+ for (const { name, path: p } of this.discover()) {
72
+ let buf; try { buf = fs.readFileSync(p); } catch (e) { continue; }
73
+ const cut = buf.lastIndexOf(0x0a);
74
+ this.files.set(name, { path: p, offset: buf.length, pending: Buffer.from(cut < 0 ? buf : buf.subarray(cut + 1)) });
75
+ files.push({ name, text: cut < 0 ? '' : buf.subarray(0, cut + 1).toString('utf8') });
76
+ }
77
+ return files;
78
+ }
79
+
80
+ // Everything emitted so far, per file (complete lines only): [0, offset − pending). Drains first so
81
+ // a page that loads from this and then subscribes to /events cannot miss or duplicate a line.
82
+ current() {
83
+ this.tick();
84
+ const out = [];
85
+ for (const [name, f] of this.files) {
86
+ const len = f.offset - f.pending.length; let text = '';
87
+ if (len > 0) { try { const fd = fs.openSync(f.path, 'r'); try { const buf = Buffer.alloc(len); const n = fs.readSync(fd, buf, 0, len, 0); text = buf.toString('utf8', 0, n); } finally { fs.closeSync(fd); } } catch (e) { } }
88
+ out.push({ name, text });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ start() {
94
+ const files = this.snapshot();
95
+ this.emit({ type: 'snapshot', files });
96
+ const kick = () => { try { this.tick(); } catch (e) { /* transient read errors: next tick */ } };
97
+ // fs.watch where it works (fast), plus a poll (fs.watch misses events on some filesystems).
98
+ const dirs = new Set([path.dirname(this.session.file), path.join(this.session.projectDir, this.session.id, 'subagents')]);
99
+ for (const d of dirs) { try { if (fs.existsSync(d)) { const w = fs.watch(d, { persistent: false }, () => setTimeout(kick, 30)); w.on('error', () => { }); this.watchers.push(w); } } catch (e) { /* fall back to polling */ } }
100
+ this.timer = setInterval(kick, this.pollMs); if (this.timer.unref) this.timer.unref();
101
+ return files;
102
+ }
103
+ stop() { if (this.timer) clearInterval(this.timer); this.timer = null; for (const w of this.watchers) { try { w.close(); } catch (e) { } } this.watchers = []; }
104
+ }
105
+
106
+ // Serve the viewer for one session on loopback and stream tail events to it.
107
+ // opts: { port (0 = random), template (dist/glassbox.html path), embed (fn files→html), pollMs }
108
+ export function serveLive(session, opts = {}) {
109
+ const tailer = new Tailer(session, { pollMs: opts.pollMs });
110
+ const clients = new Set();
111
+ const send = (res, type, data) => { try { res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); } catch (e) { clients.delete(res); } };
112
+ tailer.on((ev) => { if (ev.type === 'snapshot') return; for (const res of clients) send(res, ev.type, ev); });
113
+ const server = http.createServer((req, res) => {
114
+ const url = (req.url || '/').split('?')[0];
115
+ if (url === '/' || url === '/index.html') {
116
+ let html = opts.embed(tailer.current());
117
+ html = html.replace('/*__LIVE__*/null', () => JSON.stringify({ url: '/events', session: session.id, file: session.file }));
118
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
119
+ res.end(html); return;
120
+ }
121
+ if (url === '/events') {
122
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' });
123
+ res.write(': glassbox live\n\n');
124
+ clients.add(res);
125
+ send(res, 'hello', { session: session.id, file: session.file, files: tailer.current() });
126
+ req.on('close', () => clients.delete(res));
127
+ return;
128
+ }
129
+ if (url === '/health') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true, session: session.id, clients: clients.size, files: Array.from(tailer.files.keys()) })); return; }
130
+ res.writeHead(404); res.end('not found');
131
+ });
132
+ const ping = setInterval(() => { for (const res of clients) send(res, 'ping', { t: Date.now() }); }, 15000); if (ping.unref) ping.unref();
133
+ return new Promise((resolve, reject) => {
134
+ server.on('error', reject);
135
+ server.listen(opts.port || 0, '127.0.0.1', () => {
136
+ tailer.start();
137
+ const { port } = server.address();
138
+ resolve({ url: `http://127.0.0.1:${port}/`, port, tailer, server, clients, close: () => new Promise((r) => { clearInterval(ping); tailer.stop(); for (const c of clients) { try { c.end(); } catch (e) { } } clients.clear(); server.close(() => r()); }) });
139
+ });
140
+ });
141
+ }