@surlymochan/evalid 0.5.20 → 0.5.21

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.
@@ -1,8 +1,16 @@
1
- import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
- import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
1
+ import { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises';
2
+ import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { buildLogEntryHash, validateExecutionLogChain } from './workflow-integrity.js';
5
5
 
6
+ const TAIL_WINDOW_BYTES = 16_384;
7
+
8
+ // Tail anchors are cached per log file: an append only needs the previous entry_hash, and
9
+ // re-reading a multi-megabyte log for every event made appends O(n^2). The cache is keyed by
10
+ // (size, mtimeMs) so an external writer (another xflow process, a rotated or truncated log that
11
+ // lands on the same size) invalidates it instead of serving a stale anchor.
12
+ const tailStateCache = new Map();
13
+
6
14
  function nowIso() {
7
15
  return new Date().toISOString();
8
16
  }
@@ -25,28 +33,34 @@ export async function appendExecutionLog(projectRoot, entry = {}) {
25
33
  async function appendExecutionLogUnlocked(projectRoot, entry = {}) {
26
34
  const file = resolveExecutionLogPath(projectRoot);
27
35
  await mkdir(dirname(file), { recursive: true });
28
- const previousHash = await readLastEntryHash(file);
36
+ const tail = await readTailState(file);
29
37
  const payload = {
30
38
  timestamp: nowIso(),
31
39
  ...entry,
32
- prev_hash: previousHash,
40
+ prev_hash: tail.hash,
33
41
  };
34
42
  payload.entry_hash = buildLogEntryHash(payload);
35
- await appendFile(file, `${JSON.stringify(payload)}\n`, 'utf8');
43
+ // A crash can leave a partial trailing line; start a fresh line so the new entry stays
44
+ // parseable instead of being glued onto the fragment.
45
+ const line = `${tail.size > 0 && !tail.endsWithNewline ? '\n' : ''}${JSON.stringify(payload)}\n`;
46
+ await appendFile(file, line, 'utf8');
47
+ await rememberAppendedTail(file, payload.entry_hash);
36
48
  return file;
37
49
  }
38
50
 
39
51
  export function appendExecutionLogSync(projectRoot, entry = {}) {
40
52
  const file = resolveExecutionLogPath(projectRoot);
41
53
  mkdirSync(dirname(file), { recursive: true });
42
- const previousHash = readLastEntryHashSync(file);
54
+ const tail = readTailStateSync(file);
43
55
  const payload = {
44
56
  timestamp: nowIso(),
45
57
  ...entry,
46
- prev_hash: previousHash,
58
+ prev_hash: tail.hash,
47
59
  };
48
60
  payload.entry_hash = buildLogEntryHash(payload);
49
- appendFileSync(file, `${JSON.stringify(payload)}\n`, 'utf8');
61
+ const line = `${tail.size > 0 && !tail.endsWithNewline ? '\n' : ''}${JSON.stringify(payload)}\n`;
62
+ appendFileSync(file, line, 'utf8');
63
+ rememberAppendedTailSync(file, payload.entry_hash);
50
64
  return file;
51
65
  }
52
66
 
@@ -76,32 +90,178 @@ export async function readExecutionLog(projectRoot, options = {}) {
76
90
  }
77
91
  }
78
92
 
79
- async function readLastEntryHash(file) {
93
+ export async function readLastEntryHash(file) {
94
+ const tail = await readTailState(file);
95
+ return tail.hash;
96
+ }
97
+
98
+ async function readTailState(file) {
80
99
  try {
81
- const raw = await readFile(file, 'utf8');
82
- return lastEntryHashFromRaw(raw);
100
+ const info = await stat(file);
101
+ const cached = tailStateCache.get(file);
102
+ if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
103
+ return cached;
104
+ }
105
+ const state = {
106
+ size: info.size,
107
+ mtimeMs: info.mtimeMs,
108
+ hash: await readTailHash(file, info.size),
109
+ endsWithNewline: await tailEndsWithNewline(file, info.size),
110
+ };
111
+ tailStateCache.set(file, state);
112
+ return state;
113
+ } catch {
114
+ return { size: 0, mtimeMs: null, hash: null, endsWithNewline: true };
115
+ }
116
+ }
117
+
118
+ function readTailStateSync(file) {
119
+ try {
120
+ const info = statSync(file);
121
+ const cached = tailStateCache.get(file);
122
+ if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
123
+ return cached;
124
+ }
125
+ const state = {
126
+ size: info.size,
127
+ mtimeMs: info.mtimeMs,
128
+ hash: readTailHashSync(file, info.size),
129
+ endsWithNewline: tailEndsWithNewlineSync(file, info.size),
130
+ };
131
+ tailStateCache.set(file, state);
132
+ return state;
133
+ } catch {
134
+ return { size: 0, mtimeMs: null, hash: null, endsWithNewline: true };
135
+ }
136
+ }
137
+
138
+ async function readTailHash(file, size) {
139
+ if (!size) return null;
140
+ // The window must contain at least one complete line to anchor the chain. A single entry
141
+ // larger than the window (an error or detail field is unbounded) leaves no complete line, so
142
+ // the window grows until the previous entry comes into view. Returning null instead made the
143
+ // next append pick up prev_hash: null, which permanently breaks the chain after a restart or
144
+ // from a second process.
145
+ let windowBytes = TAIL_WINDOW_BYTES;
146
+ for (;;) {
147
+ const start = Math.max(0, size - windowBytes);
148
+ const hash = await readHashWindow(file, start, size);
149
+ if (hash !== null || start === 0) return hash;
150
+ windowBytes *= 4;
151
+ }
152
+ }
153
+
154
+ function readTailHashSync(file, size) {
155
+ if (!size) return null;
156
+ let windowBytes = TAIL_WINDOW_BYTES;
157
+ for (;;) {
158
+ const start = Math.max(0, size - windowBytes);
159
+ const hash = readHashWindowSync(file, start, size);
160
+ if (hash !== null || start === 0) return hash;
161
+ windowBytes *= 4;
162
+ }
163
+ }
164
+
165
+ async function readHashWindow(file, start, size) {
166
+ const handle = await open(file, 'r');
167
+ try {
168
+ const buffer = Buffer.alloc(size - start);
169
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
170
+ return lastEntryHashFromRaw(buffer.subarray(0, bytesRead).toString('utf8'), start > 0);
83
171
  } catch {
84
172
  return null;
173
+ } finally {
174
+ await handle.close();
85
175
  }
86
176
  }
87
177
 
88
- function readLastEntryHashSync(file) {
178
+ function readHashWindowSync(file, start, size) {
179
+ const fd = openSync(file, 'r');
89
180
  try {
90
- const raw = readFileSync(file, 'utf8');
91
- return lastEntryHashFromRaw(raw);
181
+ const buffer = Buffer.alloc(size - start);
182
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, start);
183
+ return lastEntryHashFromRaw(buffer.subarray(0, bytesRead).toString('utf8'), start > 0);
92
184
  } catch {
93
185
  return null;
186
+ } finally {
187
+ closeSync(fd);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Record the anchor that was just written. One cheap stat keeps the cache key (size, mtimeMs)
193
+ * honest, so a later writer that rewrites the file at the same size invalidates the entry.
194
+ */
195
+ async function rememberAppendedTail(file, entryHash) {
196
+ try {
197
+ const info = await stat(file);
198
+ tailStateCache.set(file, {
199
+ size: info.size,
200
+ mtimeMs: info.mtimeMs,
201
+ hash: entryHash,
202
+ endsWithNewline: true,
203
+ });
204
+ } catch {
205
+ tailStateCache.delete(file);
206
+ }
207
+ }
208
+
209
+ function rememberAppendedTailSync(file, entryHash) {
210
+ try {
211
+ const info = statSync(file);
212
+ tailStateCache.set(file, {
213
+ size: info.size,
214
+ mtimeMs: info.mtimeMs,
215
+ hash: entryHash,
216
+ endsWithNewline: true,
217
+ });
218
+ } catch {
219
+ tailStateCache.delete(file);
94
220
  }
95
221
  }
96
222
 
97
- function lastEntryHashFromRaw(raw) {
98
- const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
223
+ async function tailEndsWithNewline(file, size) {
224
+ if (!size) return true;
225
+ const handle = await open(file, 'r');
226
+ try {
227
+ const buffer = Buffer.alloc(1);
228
+ const { bytesRead } = await handle.read(buffer, 0, 1, size - 1);
229
+ return bytesRead === 0 || buffer[0] === 0x0a;
230
+ } catch {
231
+ return true;
232
+ } finally {
233
+ await handle.close();
234
+ }
235
+ }
236
+
237
+ function tailEndsWithNewlineSync(file, size) {
238
+ if (!size) return true;
239
+ const fd = openSync(file, 'r');
240
+ try {
241
+ const buffer = Buffer.alloc(1);
242
+ const bytesRead = readSync(fd, buffer, 0, 1, size - 1);
243
+ return bytesRead === 0 || buffer[0] === 0x0a;
244
+ } catch {
245
+ return true;
246
+ } finally {
247
+ closeSync(fd);
248
+ }
249
+ }
250
+
251
+ function lastEntryHashFromRaw(raw, startedMidLine = false) {
252
+ const lines = raw.split('\n');
253
+ if (startedMidLine) {
254
+ // The first line of a tail window is usually a fragment; it cannot anchor the chain.
255
+ lines.shift();
256
+ }
99
257
  for (let index = lines.length - 1; index >= 0; index -= 1) {
258
+ const line = lines[index].trim();
259
+ if (!line) continue;
100
260
  try {
101
- const entry = JSON.parse(lines[index]);
261
+ const entry = JSON.parse(line);
102
262
  if (entry.entry_hash) return entry.entry_hash;
103
263
  } catch {
104
- return null;
264
+ // A corrupt or truncated trailing line must not reset the chain; keep scanning upwards.
105
265
  }
106
266
  }
107
267
  return null;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * log-tail — byte-offset reader for the hash-chained execution log.
3
+ *
4
+ * The execution log (.as-xflow/logs/execution.ndjson) is the single source of truth for
5
+ * workflow progress: the CLI writes it, `xflow serve` reads it. Live consumers therefore tail
6
+ * it by byte offset instead of re-reading it, and resume from the `entry_hash` they last saw
7
+ * (SSE Last-Event-ID). Every offset returned here sits on a line boundary, so a consumer never
8
+ * observes half an entry.
9
+ */
10
+ import { open, stat } from 'node:fs/promises';
11
+ import { resolveExecutionLogPath } from './execution-log.js';
12
+
13
+ export const DEFAULT_TAIL_LIMIT = 200;
14
+ export const MAX_TAIL_READ_BYTES = 4 * 1024 * 1024;
15
+ const OVERSCAN_BYTES = 64 * 1024;
16
+ const MAX_OVERSCAN_BYTES = 64 * 1024 * 1024;
17
+
18
+ export async function readExecutionLogSize(projectRoot = process.cwd()) {
19
+ try {
20
+ return (await stat(resolveExecutionLogPath(projectRoot))).size;
21
+ } catch {
22
+ return 0;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Read the entries written after `offset`.
28
+ *
29
+ * Returns { entries, nextOffset, size, hasMore, partial, eof }. `nextOffset` only ever advances
30
+ * past a complete line, so an entry that is still being written is re-read (not skipped) on the
31
+ * next call. `limit` caps one call so a large backlog is drained over several calls instead of
32
+ * one unbounded read.
33
+ */
34
+ export async function readEntriesFromOffset(projectRoot = process.cwd(), offset = 0, options = {}) {
35
+ const file = resolveExecutionLogPath(projectRoot);
36
+ const limit = Math.max(1, Math.floor(Number(options.limit) || DEFAULT_TAIL_LIMIT));
37
+ const size = await readExecutionLogSize(projectRoot);
38
+ const requested = Math.floor(Number(offset) || 0);
39
+ const start = Math.min(Math.max(0, requested), size);
40
+ if (start >= size) {
41
+ return { file, entries: [], nextOffset: size, size, hasMore: false, partial: false, eof: true };
42
+ }
43
+
44
+ const end = Math.min(size, start + MAX_TAIL_READ_BYTES);
45
+ const window = await readRange(file, start, end);
46
+ const entries = [];
47
+ let cursor = 0;
48
+ let nextOffset = start;
49
+ let sawNewline = false;
50
+
51
+ while (entries.length < limit) {
52
+ const newlineAt = window.indexOf(0x0a, cursor);
53
+ if (newlineAt === -1) break;
54
+ sawNewline = true;
55
+ const line = window.subarray(cursor, newlineAt).toString('utf8').trim();
56
+ cursor = newlineAt + 1;
57
+ nextOffset = start + cursor;
58
+ if (!line) continue;
59
+ entries.push(parseEntryLine(line));
60
+ }
61
+
62
+ if (!sawNewline && end < size) {
63
+ // One entry larger than the read window would stall the tail forever (the same offset
64
+ // would be returned on every poll). Skip to the next line boundary and tell the caller.
65
+ const skippedTo = await skipOversizedLine(file, end, size);
66
+ entries.push({
67
+ kind: 'invalid_log_line',
68
+ raw: 'line_exceeds_read_window',
69
+ reason: 'entry_larger_than_read_window',
70
+ truncated: true,
71
+ });
72
+ return {
73
+ file,
74
+ entries,
75
+ nextOffset: skippedTo,
76
+ size,
77
+ hasMore: skippedTo < size,
78
+ partial: false,
79
+ eof: skippedTo >= size,
80
+ };
81
+ }
82
+
83
+ return {
84
+ file,
85
+ entries,
86
+ nextOffset,
87
+ size,
88
+ hasMore: nextOffset < size,
89
+ // Anything left in the window sits after the last newline, i.e. an entry still being written.
90
+ partial: cursor < window.length,
91
+ eof: nextOffset >= size,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Map an entry_hash to the byte offset just past that entry.
97
+ *
98
+ * Returns null when the hash is unknown (rotated or deleted log), which callers treat as
99
+ * "replay is impossible, fall back to a snapshot" rather than as an error.
100
+ */
101
+ export async function resolveEntryOffset(projectRoot = process.cwd(), entryHash) {
102
+ const wanted = String(entryHash || '').trim();
103
+ if (!wanted) return null;
104
+ const file = resolveExecutionLogPath(projectRoot);
105
+ let size;
106
+ try {
107
+ size = (await stat(file)).size;
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (!size) return null;
112
+
113
+ // A reconnect resolves a single hash, and the entry the client last saw is almost always
114
+ // among the most recent ones, so scan the tail first and widen the window until the log is
115
+ // covered. Every window ends at EOF on purpose: a window that stopped at an arbitrary offset
116
+ // would split an entry across two reads and never see it whole.
117
+ let windowBytes = OVERSCAN_BYTES;
118
+ for (;;) {
119
+ const start = Math.max(0, size - windowBytes);
120
+ const window = await readRange(file, start, size);
121
+ const offset = matchEntryOffset(window, start, wanted);
122
+ if (offset !== null) return offset;
123
+ if (start === 0) return null;
124
+ windowBytes *= 4;
125
+ }
126
+ }
127
+
128
+ function matchEntryOffset(window, base, wanted) {
129
+ let cursor = 0;
130
+ while (cursor < window.length) {
131
+ const newlineAt = window.indexOf(0x0a, cursor);
132
+ if (newlineAt === -1) break;
133
+ const line = window.subarray(cursor, newlineAt).toString('utf8').trim();
134
+ cursor = newlineAt + 1;
135
+ if (!line) continue;
136
+ try {
137
+ if (JSON.parse(line).entry_hash === wanted) return base + cursor;
138
+ } catch {
139
+ // Corrupt lines cannot match a hash; keep scanning.
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function parseEntryLine(line) {
146
+ try {
147
+ const entry = JSON.parse(line);
148
+ return entry && typeof entry === 'object' ? entry : { kind: 'invalid_log_line', raw: line.slice(0, 512) };
149
+ } catch {
150
+ return { kind: 'invalid_log_line', raw: line.slice(0, 512), reason: 'invalid_json' };
151
+ }
152
+ }
153
+
154
+ async function readRange(file, start, end) {
155
+ const handle = await open(file, 'r');
156
+ try {
157
+ const buffer = Buffer.alloc(end - start);
158
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
159
+ return buffer.subarray(0, bytesRead);
160
+ } finally {
161
+ await handle.close();
162
+ }
163
+ }
164
+
165
+ async function skipOversizedLine(file, from, size) {
166
+ let position = from;
167
+ while (position < size) {
168
+ const chunkEnd = Math.min(size, position + OVERSCAN_BYTES);
169
+ const chunk = await readRange(file, position, chunkEnd);
170
+ const newlineAt = chunk.indexOf(0x0a);
171
+ if (newlineAt !== -1) return position + newlineAt + 1;
172
+ position = chunkEnd;
173
+ if (position - from > MAX_OVERSCAN_BYTES) return position;
174
+ }
175
+ return size;
176
+ }
package/src/server.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from './core/mission-state.js';
24
24
  import { mutateStore, readStore, resolveDataFile } from './core/state-store.js';
25
25
  import { readExecutionLog, resolveExecutionLogPath } from './core/execution-log.js';
26
+ import { readEntriesFromOffset, readExecutionLogSize, resolveEntryOffset } from './core/log-tail.js';
26
27
  import { ackPendingGate, listPendingGates } from './core/pending-gates.js';
27
28
  import { prepareWorkflowRerun } from './core/workflow-rerun.js';
28
29
  import { readBinding } from './core/bindings.js';
@@ -140,7 +141,11 @@ function sendHtml(response, statusCode, html) {
140
141
  response.end(html);
141
142
  }
142
143
 
143
- function sendEvent(response, event, payload) {
144
+ function sendEvent(response, event, payload, id) {
145
+ if (response.writableEnded || response.destroyed) return;
146
+ // `id` is optional: entries written by older builds carry no entry_hash, and an empty id line
147
+ // would make the browser replay from a meaningless position.
148
+ if (id) response.write(`id: ${id}\n`);
144
149
  response.write(`event: ${event}\n`);
145
150
  response.write(`data: ${JSON.stringify(payload)}\n\n`);
146
151
  }
@@ -526,10 +531,22 @@ function renderDashboardHtml() {
526
531
  '</div>'
527
532
  )).join('') || '<div class="meta">No pending gates</div>';
528
533
  }
529
- function renderActivity(entries) {
530
- document.getElementById('activity').innerHTML = entries.map((entry) => (
531
- '<li><div><strong>' + (entry.atom_id || entry.kind || 'event') + '</strong></div>' +
532
- '<div class="meta">' + [
534
+ let activityEntries = [];
535
+ // Hashes already on screen. Seeded from the snapshot as well as from log events: an
536
+ // entry can arrive twice, once inside the snapshot and once as a stream event written
537
+ // between the snapshot read and the point where the stream started.
538
+ const seenHashes = new Set();
539
+ function describeEntry(entry) {
540
+ if (entry.kind === 'agent_event') {
541
+ const detail = entry.text || entry.args_preview || entry.reason || '';
542
+ return {
543
+ title: entry.event + (entry.tool ? ' · ' + entry.tool : ''),
544
+ meta: [entry.phase, entry.atom, detail ? String(detail).slice(0, 120) : '', entry.timestamp],
545
+ };
546
+ }
547
+ return {
548
+ title: entry.atom_id || entry.kind || 'event',
549
+ meta: [
533
550
  entry.phase_id,
534
551
  entry.status,
535
552
  entry.adapter,
@@ -537,8 +554,44 @@ function renderDashboardHtml() {
537
554
  entry.artifact_type,
538
555
  entry.parallel_weight ? 'weight ' + entry.parallel_weight : '',
539
556
  entry.timestamp,
540
- ].filter(Boolean).join(' · ') + '</div></li>'
541
- )).join('') || '<li class="meta">No execution activity yet</li>';
557
+ ],
558
+ };
559
+ }
560
+ function paintActivity() {
561
+ document.getElementById('activity').innerHTML = activityEntries.map((entry) => {
562
+ const view = describeEntry(entry);
563
+ return '<li><div><strong>' + view.title + '</strong></div>' +
564
+ '<div class="meta">' + view.meta.filter(Boolean).join(' · ') + '</div></li>';
565
+ }).join('') || '<li class="meta">No execution activity yet</li>';
566
+ }
567
+ // A snapshot carries entries a log event may already have painted. Merging by entry_hash
568
+ // instead of replacing the list keeps anything that arrived while the snapshot was in
569
+ // flight (replacing it made those rows disappear again for up to one snapshot interval).
570
+ // Entries already on screen stay first: they are strictly newer than anything the snapshot
571
+ // adds, so this preserves file order without trusting timestamps.
572
+ function renderActivity(entries, options) {
573
+ const incoming = entries.slice(0, 20);
574
+ for (const entry of incoming) {
575
+ if (entry.entry_hash) seenHashes.add(entry.entry_hash);
576
+ }
577
+ if (options && options.merge) {
578
+ const merged = activityEntries.slice();
579
+ const seen = new Set(merged.map((entry) => entry.entry_hash).filter(Boolean));
580
+ for (const entry of incoming) {
581
+ if (entry.entry_hash && seen.has(entry.entry_hash)) continue;
582
+ if (entry.entry_hash) seen.add(entry.entry_hash);
583
+ merged.push(entry);
584
+ }
585
+ activityEntries = merged.slice(0, 20);
586
+ } else {
587
+ activityEntries = incoming;
588
+ }
589
+ paintActivity();
590
+ }
591
+ function appendActivity(entry) {
592
+ activityEntries.unshift(entry);
593
+ if (activityEntries.length > 20) activityEntries.length = 20;
594
+ paintActivity();
542
595
  }
543
596
  async function refreshFallback() {
544
597
  const [summaryRes, gatesRes, logsRes] = await Promise.all([
@@ -617,7 +670,15 @@ function renderDashboardHtml() {
617
670
  const payload = JSON.parse(event.data);
618
671
  renderSummary(payload.summary);
619
672
  renderGates(payload.pending_gates || []);
620
- renderActivity(payload.execution_log?.entries || []);
673
+ renderActivity(payload.execution_log?.entries || [], { merge: true });
674
+ });
675
+ events.addEventListener('log', (event) => {
676
+ const entry = JSON.parse(event.data);
677
+ if (entry.entry_hash) {
678
+ if (seenHashes.has(entry.entry_hash)) return;
679
+ seenHashes.add(entry.entry_hash);
680
+ }
681
+ appendActivity(entry);
621
682
  });
622
683
  events.onerror = () => {
623
684
  events.close();
@@ -816,22 +877,68 @@ export default async function startServer(options = {}) {
816
877
  });
817
878
  response.write('\n');
818
879
 
880
+ let closed = false;
881
+ let offset = null;
882
+
819
883
  const sendSnapshot = async () => {
820
884
  const nextState = await readStore(dataFile);
821
885
  sendEvent(response, 'snapshot', await buildSnapshot(projectRoot, nextState));
822
886
  };
823
887
 
888
+ // Append-only tail of the execution log. Each entry is one `log` event whose SSE id is the
889
+ // entry hash, so a reconnecting client resumes from Last-Event-ID instead of re-reading.
890
+ const sendNewEntries = async () => {
891
+ const batch = await readEntriesFromOffset(projectRoot, offset, { limit: 100 });
892
+ offset = batch.nextOffset;
893
+ for (const entry of batch.entries) {
894
+ sendEvent(response, 'log', entry, entry.entry_hash);
895
+ }
896
+ };
897
+
898
+ const lastEventId = String(request.headers['last-event-id'] || '').trim();
899
+ if (lastEventId) {
900
+ const resumed = await resolveEntryOffset(projectRoot, lastEventId);
901
+ if (resumed !== null) offset = resumed;
902
+ }
903
+
904
+ if (offset === null) {
905
+ // A fresh client already has history in the snapshot, so follow only entries from here on.
906
+ // The offset is taken before the snapshot is written: anything that lands in between is
907
+ // tailed right after it and deduplicated client-side by entry_hash, so nothing is missed.
908
+ offset = await readExecutionLogSize(projectRoot);
909
+ }
910
+
911
+ // Snapshot stays the first frame: the dashboard relies on it for summary and gates.
912
+ await sendSnapshot();
913
+ await sendNewEntries().catch(() => {});
914
+
915
+ const tail = async () => {
916
+ if (closed) return;
917
+ try {
918
+ await sendNewEntries();
919
+ } catch {
920
+ // A transient read failure must not kill the stream; the next tick retries.
921
+ }
922
+ };
923
+
824
924
  const heartbeat = setInterval(() => {
825
- response.write(': ping\n\n');
925
+ if (!closed) response.write(': ping\n\n');
826
926
  }, 15000);
827
927
  const timer = setInterval(() => {
828
- sendSnapshot().catch(() => {});
928
+ if (!closed) sendSnapshot().catch(() => {});
829
929
  }, 3000);
930
+ // One tailer per connection: the read is a stat plus a short read from the last offset, so a
931
+ // dashboard tab costs a few hundred bytes per tick. If many clients ever watch the same run,
932
+ // the cheaper shape is one shared tailer broadcasting to all responses.
933
+ const tailer = setInterval(() => {
934
+ tail().catch(() => {});
935
+ }, 200);
830
936
 
831
- await sendSnapshot();
832
937
  request.on('close', () => {
938
+ closed = true;
833
939
  clearInterval(timer);
834
940
  clearInterval(heartbeat);
941
+ clearInterval(tailer);
835
942
  });
836
943
  return;
837
944
  }