@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.
- package/CHANGELOG.md +9 -0
- package/RELEASE_NOTES.md +10 -0
- package/bin/xflow.js +75 -2
- package/docs/launch-dossier.md +1 -1
- package/docs/workflow-manual.md +105 -2
- package/package.json +1 -1
- package/scripts/demo-agent-stream.mjs +119 -0
- package/src/core/agent-stream.js +440 -0
- package/src/core/atoms/agent-invoke.js +82 -31
- package/src/core/audit.js +27 -1
- package/src/core/execution-log.js +178 -18
- package/src/core/log-tail.js +176 -0
- package/src/server.js +118 -11
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
2
|
-
import { appendFileSync, mkdirSync,
|
|
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
|
|
36
|
+
const tail = await readTailState(file);
|
|
29
37
|
const payload = {
|
|
30
38
|
timestamp: nowIso(),
|
|
31
39
|
...entry,
|
|
32
|
-
prev_hash:
|
|
40
|
+
prev_hash: tail.hash,
|
|
33
41
|
};
|
|
34
42
|
payload.entry_hash = buildLogEntryHash(payload);
|
|
35
|
-
|
|
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
|
|
54
|
+
const tail = readTailStateSync(file);
|
|
43
55
|
const payload = {
|
|
44
56
|
timestamp: nowIso(),
|
|
45
57
|
...entry,
|
|
46
|
-
prev_hash:
|
|
58
|
+
prev_hash: tail.hash,
|
|
47
59
|
};
|
|
48
60
|
payload.entry_hash = buildLogEntryHash(payload);
|
|
49
|
-
|
|
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
|
|
82
|
-
|
|
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
|
|
178
|
+
function readHashWindowSync(file, start, size) {
|
|
179
|
+
const fd = openSync(file, 'r');
|
|
89
180
|
try {
|
|
90
|
-
const
|
|
91
|
-
|
|
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
|
|
98
|
-
|
|
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(
|
|
261
|
+
const entry = JSON.parse(line);
|
|
102
262
|
if (entry.entry_hash) return entry.entry_hash;
|
|
103
263
|
} catch {
|
|
104
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
]
|
|
541
|
-
|
|
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
|
}
|