@surlymochan/evalid 0.5.19 → 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 +21 -0
- package/RELEASE_NOTES.md +24 -0
- package/bin/xflow.js +102 -5
- package/docs/evidence/m4-slice4-auto-resume.md +96 -0
- 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/lite-track-auto.js +126 -0
- package/src/core/log-tail.js +176 -0
- package/src/core/workflow-executor.js +12 -3
- package/src/server.js +118 -11
- package/xflow/atoms/lite_track.py +46 -0
|
@@ -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,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M4 lite fast track, slice 3 (evalid #101, parent #97): auto-select the
|
|
3
|
+
* track for `xflow workflow run`.
|
|
4
|
+
*
|
|
5
|
+
* With no explicit flag the run classifies the change first (slice 1's
|
|
6
|
+
* `xflow/atoms/lite_track.py`, working-tree diff against the merge-base,
|
|
7
|
+
* `--uncommitted`): a small diff walks the lite track, a big diff stays
|
|
8
|
+
* full. Explicit `--lite` / `--full` always wins; both flags together
|
|
9
|
+
* resolve to full (conservative). Any classifier failure falls back to
|
|
10
|
+
* full and records the failure -- a run must never silently go lite.
|
|
11
|
+
*
|
|
12
|
+
* The returned decision object is JSON-serialisable on purpose: the CLI
|
|
13
|
+
* attaches it to the workflow input as `lite_track_decision` and the
|
|
14
|
+
* executor records it in the `workflow_started` / `workflow_completed`
|
|
15
|
+
* events, next to slice 1's `lite_fast_track` field.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { dirname, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
export const DEFAULT_CLASSIFIER_PATH = resolve(__dirname, '..', '..', 'xflow', 'atoms', 'lite_track.py');
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run the classifier against `projectRoot` and return the auto decision.
|
|
27
|
+
*
|
|
28
|
+
* `runClassifier` is injectable (defaults to `spawnSync`) so tests can
|
|
29
|
+
* stub the classifier invocation without touching git or python.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveLiteTrackAuto({
|
|
32
|
+
projectRoot,
|
|
33
|
+
classifierPath = DEFAULT_CLASSIFIER_PATH,
|
|
34
|
+
python = process.env.PYTHON || 'python3',
|
|
35
|
+
runClassifier = spawnSync,
|
|
36
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const fallback = (detail) => ({
|
|
39
|
+
source: 'auto-fallback-full',
|
|
40
|
+
track: 'full',
|
|
41
|
+
reason: `classifier failed (${detail}); falling back to full track`,
|
|
42
|
+
files: null,
|
|
43
|
+
added: null,
|
|
44
|
+
removed: null,
|
|
45
|
+
total: null,
|
|
46
|
+
base: null,
|
|
47
|
+
force_full: [],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
result = runClassifier(
|
|
53
|
+
python,
|
|
54
|
+
[classifierPath, '--repo', projectRoot, '--uncommitted', '--json'],
|
|
55
|
+
{ encoding: 'utf8', timeout: timeoutMs },
|
|
56
|
+
);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return fallback(`spawn error: ${err?.message || String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
if (!result || result.status !== 0) {
|
|
61
|
+
const detail = String(
|
|
62
|
+
result?.stderr?.trim() || result?.error?.message || `exit ${result?.status ?? 'unknown'}`,
|
|
63
|
+
).slice(0, 200);
|
|
64
|
+
return fallback(detail);
|
|
65
|
+
}
|
|
66
|
+
let verdict;
|
|
67
|
+
try {
|
|
68
|
+
verdict = JSON.parse(result.stdout);
|
|
69
|
+
} catch {
|
|
70
|
+
return fallback('unparseable classifier output');
|
|
71
|
+
}
|
|
72
|
+
if (verdict?.track !== 'lite' && verdict?.track !== 'full') {
|
|
73
|
+
return fallback(`unexpected verdict: ${JSON.stringify(verdict).slice(0, 200)}`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
source: 'auto',
|
|
77
|
+
track: verdict.track,
|
|
78
|
+
reason: verdict.reason || '',
|
|
79
|
+
files: verdict.files ?? null,
|
|
80
|
+
added: verdict.added ?? null,
|
|
81
|
+
removed: verdict.removed ?? null,
|
|
82
|
+
total: verdict.total ?? null,
|
|
83
|
+
base: verdict.base ?? null,
|
|
84
|
+
force_full: verdict.force_full || [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the effective track from explicit flags and (when neither flag
|
|
90
|
+
* is given) the auto decision. Returns `{ lite, decision }`.
|
|
91
|
+
*/
|
|
92
|
+
export function decideLiteTrack({ lite, full, autoDecision }) {
|
|
93
|
+
if (lite === true && full === true) {
|
|
94
|
+
return {
|
|
95
|
+
lite: false,
|
|
96
|
+
decision: {
|
|
97
|
+
source: 'explicit-full',
|
|
98
|
+
track: 'full',
|
|
99
|
+
reason: 'both --lite and --full given; --full wins (conservative)',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (lite === true) {
|
|
104
|
+
return {
|
|
105
|
+
lite: true,
|
|
106
|
+
decision: { source: 'explicit-lite', track: 'lite', reason: 'explicit --lite flag' },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (full === true) {
|
|
110
|
+
return {
|
|
111
|
+
lite: false,
|
|
112
|
+
decision: { source: 'explicit-full', track: 'full', reason: 'explicit --full flag' },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (!autoDecision) {
|
|
116
|
+
return {
|
|
117
|
+
lite: false,
|
|
118
|
+
decision: {
|
|
119
|
+
source: 'auto-fallback-full',
|
|
120
|
+
track: 'full',
|
|
121
|
+
reason: 'no classifier verdict available; falling back to full track',
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { lite: autoDecision.track === 'lite', decision: autoDecision };
|
|
126
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -75,9 +75,13 @@ export class WorkflowExecutor {
|
|
|
75
75
|
|
|
76
76
|
// ─── Main execution loop ─────────────────────────────────────────────────
|
|
77
77
|
|
|
78
|
-
// M4 lite fast track:
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// M4 lite fast track: `input.lite === true` engages it. Slice 1 set this
|
|
79
|
+
// only from the explicit `--lite` flag; slice 3 (evalid #101) lets the
|
|
80
|
+
// CLI resolve it first -- `decideLiteTrack` in lite-track-auto.js runs
|
|
81
|
+
// the classifier when no explicit flag is given and sets `input.lite`
|
|
82
|
+
// when the verdict is lite, so the executor itself never decides. The
|
|
83
|
+
// decision itself arrives as `input.lite_track_decision` and is recorded
|
|
84
|
+
// in the workflow_started / workflow_completed events.
|
|
81
85
|
isLiteFastTrack() {
|
|
82
86
|
return this.input?.lite === true;
|
|
83
87
|
}
|
|
@@ -137,6 +141,10 @@ export class WorkflowExecutor {
|
|
|
137
141
|
workflow: this.workflow.name,
|
|
138
142
|
track: this.workflow.track,
|
|
139
143
|
lite_fast_track: this.isLiteFastTrack(),
|
|
144
|
+
// M4 slice 3: how the track was chosen (auto / explicit-lite /
|
|
145
|
+
// explicit-full / auto-fallback-full); null when the run was started
|
|
146
|
+
// without the CLI decision step (e.g. direct executor use).
|
|
147
|
+
lite_track_decision: this.input?.lite_track_decision ?? null,
|
|
140
148
|
workflow_integrity: this.workflowManifest,
|
|
141
149
|
max_parallel_agents: this.maxParallelAgents,
|
|
142
150
|
parallel_policy: this.concurrencyPolicy.mode,
|
|
@@ -351,6 +359,7 @@ export class WorkflowExecutor {
|
|
|
351
359
|
workflow: this.workflow.name,
|
|
352
360
|
track: this.workflow.track,
|
|
353
361
|
lite_fast_track: this.isLiteFastTrack(),
|
|
362
|
+
lite_track_decision: this.input?.lite_track_decision ?? null,
|
|
354
363
|
workflow_integrity: this.workflowManifest,
|
|
355
364
|
change_id: this.changeId || null,
|
|
356
365
|
mission_id: this.runtime.missionId || null,
|