amicus 4.8.1 → 4.9.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +249 -0
- package/README.md +8 -3
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +54 -4
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +36 -15
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui.js +18 -8
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +1 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +1 -1
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +63 -15
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/template/render.js +16 -7
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +11 -3
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +2 -5
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +10 -2
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/result-schema.js +10 -0
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +33 -7
- package/src/workspace/seat-space.js +10 -6
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-log-parse
|
|
3
|
+
* Line-shape parsing for the engine log: level, session, message.
|
|
4
|
+
*
|
|
5
|
+
* Is this line an ERROR, is it about THIS session, and where does its human
|
|
6
|
+
* message begin.
|
|
7
|
+
*
|
|
8
|
+
* EXTRACTED from src/utils/engine-log.js (v4.9 W10 round 2). That module is the
|
|
9
|
+
* I/O half — candidate dirs, mtime order, the scan budget and its memo, with the
|
|
10
|
+
* bounded tail reads themselves in `./engine-log-tail.js` since PR #206;
|
|
11
|
+
* everything here is a pure function of ONE line, which is where every
|
|
12
|
+
* correlation and truncation finding has landed since. `engine-log.js`
|
|
13
|
+
* re-exports all of it, so a consumer still has one import site.
|
|
14
|
+
*
|
|
15
|
+
* TWO LINE FORMATS are in the wild and both are matched:
|
|
16
|
+
* logfmt (1.17.x): `… level=ERROR … session.id=ses_<id> error=<msg>`
|
|
17
|
+
* columnar (1.2.x): `ERROR <iso> +Nms service=… id=ses_<id> <msg>`
|
|
18
|
+
* A session-identity FIELD is the correlation key in both — never a bare
|
|
19
|
+
* mention (round-3 review A1; see lineIsAboutSession).
|
|
20
|
+
*
|
|
21
|
+
* WHAT IS NOT HERE: the excerpt sanitizer, which moved to `./text-sanitize.js`
|
|
22
|
+
* in round 3 when `engine-skew.js` became its second caller. The TOKENIZER
|
|
23
|
+
* stayed: logfmt/columnar token shapes are this module's domain, and all three
|
|
24
|
+
* of its consumers live in this file.
|
|
25
|
+
*
|
|
26
|
+
* PURE: no I/O, no throwing paths that a caller has to guard.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
// The excerpt sanitizer MOVED to ./text-sanitize.js (round 3) once `engine-skew`
|
|
32
|
+
// became its second caller — see that module's docblock. Re-exported below, as
|
|
33
|
+
// the same function object, so every existing import site stays valid.
|
|
34
|
+
const { collapseExcerpt, MAX_EXCERPT_CHARS } = require('./text-sanitize');
|
|
35
|
+
|
|
36
|
+
/** A `key=value` token — the structural shape in both formats. */
|
|
37
|
+
const PAIR_TOKEN = /^[\w.[\]-]+=/;
|
|
38
|
+
/**
|
|
39
|
+
* The columnar header run before the message: `ERROR <iso> +Nms`. A LEVEL word,
|
|
40
|
+
* an ISO date, or a SIGNED duration — and nothing else.
|
|
41
|
+
*
|
|
42
|
+
* A bare number is deliberately NOT header-shaped (round-2 review B5): the
|
|
43
|
+
* header run ends the prefix on a pairless line, so anything it accepts is
|
|
44
|
+
* something it EATS — the older `[+-]?\d[\w.:+-]*` would have swallowed the `3`
|
|
45
|
+
* of `3 retries exhausted`.
|
|
46
|
+
*/
|
|
47
|
+
const HEADER_TOKEN = /^(?:[A-Z]+|\d{4}-\d\d-\d\d[\w:.+-]*|[+-]\d[\w.]*)$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which format is this line? A columnar line OPENS with a bare level token; a
|
|
51
|
+
* logfmt line opens with a `key=value`.
|
|
52
|
+
*
|
|
53
|
+
* Round-2 review A2: the shape decides, NOT the presence of the substring
|
|
54
|
+
* `error=` somewhere in the text. Running the logfmt extractor first on every
|
|
55
|
+
* line cut columnar messages that merely CONTAINED `error=` — measured before
|
|
56
|
+
* the fix, `… id=ses_… retry limit reached, error=true was set in config` came
|
|
57
|
+
* back as `true was set in config`.
|
|
58
|
+
*/
|
|
59
|
+
function isColumnarLine(line) {
|
|
60
|
+
return /^\s*[A-Z]+(\s|$)/.test(line);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The line's tokens: whitespace-separated, except that a QUOTED value
|
|
65
|
+
* (`key="…"`) holds together however much whitespace it contains, backslash
|
|
66
|
+
* escapes honoured so an embedded `\"` does not end it (round-2 review B5).
|
|
67
|
+
*
|
|
68
|
+
* ONE DIALECT, THREE CONSUMERS (round-3 reviews B2 and C5). "Where does a field
|
|
69
|
+
* end and prose begin" had three substring answers, each wrong somewhere: a
|
|
70
|
+
* whitespace split read `error="lost session.id=<them> mid-write"` as two
|
|
71
|
+
* fields, the second a session identity; the `error=` extractor matched that
|
|
72
|
+
* substring anywhere, so `msg="db error=timeout"` became the message. One
|
|
73
|
+
* tokenizer, and all three agree by construction.
|
|
74
|
+
*
|
|
75
|
+
* `at` is the token's offset in `line` — what lets a caller slice the ORIGINAL
|
|
76
|
+
* text rather than re-join tokens and lose its spacing.
|
|
77
|
+
* @param {string} line
|
|
78
|
+
* @returns {{text: string, at: number}[]}
|
|
79
|
+
*/
|
|
80
|
+
function tokenize(line) {
|
|
81
|
+
const text = String(line);
|
|
82
|
+
const tokens = [];
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < text.length) {
|
|
85
|
+
if (/\s/.test(text[i])) { i += 1; continue; }
|
|
86
|
+
const at = i;
|
|
87
|
+
let quoted = false;
|
|
88
|
+
while (i < text.length && (quoted || !/\s/.test(text[i]))) {
|
|
89
|
+
if (quoted && text[i] === '\\') { i += 2; continue; }
|
|
90
|
+
if (text[i] === '"') { quoted = !quoted; }
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
tokens.push({ text: text.slice(at, i), at });
|
|
94
|
+
}
|
|
95
|
+
return tokens;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The index of the STRUCTURAL RUN's last token, or -1 when the line opens with
|
|
100
|
+
* prose. The run is `ERROR <iso> +Nms service=… id=ses_…` at a columnar line's
|
|
101
|
+
* start, and the whole `key=value` sequence of a logfmt line.
|
|
102
|
+
*
|
|
103
|
+
* Deliberately not "up to the LAST key=value on the line" (W10 round-1 review
|
|
104
|
+
* B2): an engine error naming a setting mid-sentence — `could not parse foo=bar
|
|
105
|
+
* in the config` — would lose everything before it. Once the run ends, the rest
|
|
106
|
+
* of the line is text, `=` and all.
|
|
107
|
+
*
|
|
108
|
+
* HEADER tokens count only until the first pair; after one, an all-caps or
|
|
109
|
+
* numeric word is message text (`… id=ses_x FATAL disk error`). But they do
|
|
110
|
+
* ADVANCE the run, letting a PAIRLESS columnar line shed its header (round-2 B5).
|
|
111
|
+
* @param {{text: string, at: number}[]} tokens
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
function structuralRunEnd(tokens) {
|
|
115
|
+
let runEnd = -1;
|
|
116
|
+
let seenPair = false;
|
|
117
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
118
|
+
if (PAIR_TOKEN.test(tokens[i].text)) { seenPair = true; runEnd = i; continue; }
|
|
119
|
+
if (!seenPair && HEADER_TOKEN.test(tokens[i].text)) { runEnd = i; continue; }
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
return runEnd;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A leading QUOTED run's content, escapes decoded by `tokenize`'s own rule. */
|
|
126
|
+
function unquote(raw) {
|
|
127
|
+
let out = '';
|
|
128
|
+
for (let i = 1; i < raw.length; i++) {
|
|
129
|
+
if (raw[i] === '\\' && i + 1 < raw.length) { out += raw[i + 1]; i += 1; continue; }
|
|
130
|
+
if (raw[i] === '"') { break; }
|
|
131
|
+
out += raw[i];
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The logfmt `error=` value: quoted content when quoted, else the rest of the
|
|
138
|
+
* line, since an unquoted engine error is a sentence, not one token, and is
|
|
139
|
+
* conventionally last. Null when the line has no such key.
|
|
140
|
+
*
|
|
141
|
+
* A TOP-LEVEL key only (round-3 review C5). The old match was a substring sweep,
|
|
142
|
+
* so an `error=` inside another field's quoted value was taken as the message —
|
|
143
|
+
* MEASURED before the fix, `… msg="db error=timeout" error=connection refused`
|
|
144
|
+
* returned `timeout" error=connection refused`. Tokenizing first makes the
|
|
145
|
+
* boundary structural instead of textual.
|
|
146
|
+
*/
|
|
147
|
+
function errorFieldValue(line) {
|
|
148
|
+
const text = String(line);
|
|
149
|
+
for (const token of tokenize(text)) {
|
|
150
|
+
if (!token.text.startsWith('error=')) { continue; }
|
|
151
|
+
const value = token.text.slice('error='.length);
|
|
152
|
+
return value.startsWith('"') ? unquote(value) : text.slice(token.at + 'error='.length);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Everything after the structural run — exactly where a columnar message
|
|
159
|
+
* begins. With no structural token at all the whole line comes back, so an
|
|
160
|
+
* unrecognized future format degrades to "slightly noisy" rather than silence.
|
|
161
|
+
*/
|
|
162
|
+
function afterStructuralRun(line) {
|
|
163
|
+
const text = String(line);
|
|
164
|
+
const tokens = tokenize(text);
|
|
165
|
+
const runEnd = structuralRunEnd(tokens);
|
|
166
|
+
if (runEnd === -1) { return text.trim(); }
|
|
167
|
+
const next = tokens[runEnd + 1];
|
|
168
|
+
return next ? text.slice(next.at).trim() : '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The human part of an ERROR line, chosen by the line's SHAPE. */
|
|
172
|
+
function extractMessage(line) {
|
|
173
|
+
if (!isColumnarLine(line)) {
|
|
174
|
+
const value = errorFieldValue(line);
|
|
175
|
+
if (value !== null) { return value; }
|
|
176
|
+
}
|
|
177
|
+
return afterStructuralRun(line);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A token character. MEASURED (2026-08-25) over this machine's own engine logs:
|
|
181
|
+
* 369 distinct ids, each exactly `ses_` + 26 characters, every one drawn from
|
|
182
|
+
* `[A-Za-z0-9]` — no `-`, `_`, or `.`. `_` counts here anyway: an id cannot
|
|
183
|
+
* contain one, so a `_` beside a match means the token is longer than an id. */
|
|
184
|
+
function isTokenCharCode(code) {
|
|
185
|
+
return (code >= 48 && code <= 57) || (code >= 65 && code <= 90)
|
|
186
|
+
|| (code >= 97 && code <= 122) || code === 95;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Does `text` mention session `needle` as a WHOLE id?
|
|
191
|
+
*
|
|
192
|
+
* `String.includes` is wrong here: ids share prefixes, so `ses_abc` matches
|
|
193
|
+
* `ses_abc123`'s line and a leg would quote a stranger's failure as its own
|
|
194
|
+
* (W10 round-1 review A1). BOTH edges are anchored — round-1 anchored only the
|
|
195
|
+
* right one, so `Xses_abc` and `prev_ses_abc` still matched (round-2 review B6).
|
|
196
|
+
* A char-code boundary test rather than a regex: this runs per candidate field
|
|
197
|
+
* of every ERROR line in a tail read, once per dead seat in a wave.
|
|
198
|
+
*/
|
|
199
|
+
function mentionsSession(line, needle) {
|
|
200
|
+
let from = 0;
|
|
201
|
+
for (;;) {
|
|
202
|
+
const at = line.indexOf(needle, from);
|
|
203
|
+
if (at === -1) { return false; }
|
|
204
|
+
const after = at + needle.length;
|
|
205
|
+
if ((at === 0 || !isTokenCharCode(line.charCodeAt(at - 1)))
|
|
206
|
+
&& (after >= line.length || !isTokenCharCode(line.charCodeAt(after)))) { return true; }
|
|
207
|
+
from = at + 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The field names that carry SESSION identity, in both formats. Exact
|
|
212
|
+
* spellings: `parent.id` and `message.id` also end in `id` and name something
|
|
213
|
+
* else entirely; treating them as identity hands us a foreign line. */
|
|
214
|
+
const SESSION_FIELDS = new Set(['id', 'session', 'sessions', 'sessionid', 'session.id']);
|
|
215
|
+
const FIELD_TOKEN = /^([\w.[\]-]+)=(.*)$/;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* ERROR level in either format: columnar's leading `ERROR` word, or logfmt's
|
|
219
|
+
* `level=ERROR` read as a TOP-LEVEL FIELD of the structural run.
|
|
220
|
+
*
|
|
221
|
+
* A FIELD, not a substring (#201 final-round tail C1). The old logfmt half was
|
|
222
|
+
* `/(^|\s)level=ERROR(\s|$)/` over the raw line — the last substring sweep left
|
|
223
|
+
* in this module, the shape rounds 2 and 3 replaced in the format test (A2),
|
|
224
|
+
* the `error=` extractor (C5) and the ownership scan (B2). A quoted value that
|
|
225
|
+
* merely CONTAINS the delimited text promoted the whole line, and an INFO line
|
|
226
|
+
* is ordinary prose about a retry: MEASURED before the fix, `… level=INFO …
|
|
227
|
+
* msg="upstream logged level=ERROR moments ago" fixture fell back to the cache`
|
|
228
|
+
* was reported as the leg's cause of death as `fixture fell back to the cache`.
|
|
229
|
+
* The same run bound applies as everywhere else: past the fields, `level=ERROR`
|
|
230
|
+
* is text a human wrote, `=` and all.
|
|
231
|
+
*
|
|
232
|
+
* The substring is still the FIRST test, just no longer the LAST word: it is a
|
|
233
|
+
* necessary condition, so a line without it rejects on one `indexOf` and never
|
|
234
|
+
* tokenizes — which is what keeps this the cheap half of the pair in
|
|
235
|
+
* `engine-log-tail.js :: newestExcerptInFile`.
|
|
236
|
+
*/
|
|
237
|
+
function isErrorLine(line) {
|
|
238
|
+
const text = String(line);
|
|
239
|
+
if (/^\s*ERROR\b/.test(text)) { return true; }
|
|
240
|
+
if (text.indexOf('level=ERROR') === -1) { return false; }
|
|
241
|
+
const tokens = tokenize(text);
|
|
242
|
+
const runEnd = structuralRunEnd(tokens);
|
|
243
|
+
for (let i = 0; i <= runEnd; i++) {
|
|
244
|
+
const field = FIELD_TOKEN.exec(tokens[i].text);
|
|
245
|
+
if (field && field[1].toLowerCase() === 'level' && field[2] === 'ERROR') { return true; }
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Is this line ABOUT session `needle` — not merely one that mentions it?
|
|
252
|
+
*
|
|
253
|
+
* OWNERSHIP COMES ONLY FROM A SESSION-IDENTITY FIELD IN THE STRUCTURAL RUN. A
|
|
254
|
+
* line carrying no such field is NEVER attributed — no exception, no fallback.
|
|
255
|
+
*
|
|
256
|
+
* Round 2 established the first half (a whole-token MENTION is not ownership:
|
|
257
|
+
* `… session.id=ses_other parent.id=<us> error="…"` was returning another
|
|
258
|
+
* session's failure as this leg's own) but kept a bare-mention fallback for
|
|
259
|
+
* lines with no session field, and scanned fields by splitting on whitespace.
|
|
260
|
+
* Round-3 reviews A1+B2 measured both leaks: a foreign line's FREE TEXT still
|
|
261
|
+
* decided ownership whenever the line named no session in a field, and a
|
|
262
|
+
* `session.id=`-shaped token INSIDE free text — a storage path, an `error=`
|
|
263
|
+
* sentence, another field's quoted value — was read as a field and voted.
|
|
264
|
+
*
|
|
265
|
+
* PRECISION OVER RECALL is the lead's ruling, and the trade is cheap one way
|
|
266
|
+
* only: a missed attribution falls through to the byte-identical message this
|
|
267
|
+
* module's whole miss path is pinned on (and the skew clause is independent of
|
|
268
|
+
* it), while a wrong one states a stranger's failure as this leg's own.
|
|
269
|
+
*/
|
|
270
|
+
function lineIsAboutSession(line, needle) {
|
|
271
|
+
const tokens = tokenize(line);
|
|
272
|
+
const runEnd = structuralRunEnd(tokens);
|
|
273
|
+
let ours = false;
|
|
274
|
+
for (let i = 0; i <= runEnd; i++) {
|
|
275
|
+
const field = FIELD_TOKEN.exec(tokens[i].text);
|
|
276
|
+
if (!field || !SESSION_FIELDS.has(field[1].toLowerCase())) { continue; }
|
|
277
|
+
if (mentionsSession(field[2], needle)) { ours = true; }
|
|
278
|
+
}
|
|
279
|
+
return ours;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = {
|
|
283
|
+
isErrorLine,
|
|
284
|
+
extractMessage,
|
|
285
|
+
collapseExcerpt,
|
|
286
|
+
mentionsSession,
|
|
287
|
+
lineIsAboutSession,
|
|
288
|
+
MAX_EXCERPT_CHARS,
|
|
289
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-log-tail
|
|
3
|
+
* One engine-log FILE: read its tail, find the newest usable excerpt in it.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `./engine-log.js` in PR #206's fix round, for the reason that
|
|
6
|
+
* file's two earlier extractions happened (`./engine-log-parse.js` in W10 round
|
|
7
|
+
* 2, `./text-sanitize.js` in round 3): it sat at exactly 300/300 lines, and the
|
|
8
|
+
* A1+B2 fix — a MISS may not be served from the warm scan memo — needs both new
|
|
9
|
+
* code and the disclosure of its residual. A same-line-count swap would have
|
|
10
|
+
* had to pay for those by deleting prose three review rounds argued for, so the
|
|
11
|
+
* stable neighbour moved instead and moved UNCHANGED.
|
|
12
|
+
*
|
|
13
|
+
* THE SPLIT. `./engine-log.js` decides WHICH files to open and in what order,
|
|
14
|
+
* and holds the total byte budget one lookup may spend. This file spends it,
|
|
15
|
+
* one file at a time: how much of a file to read, how to decode it safely, and
|
|
16
|
+
* which line in it answers. The line SHAPE rules — what an ERROR line is, which
|
|
17
|
+
* line is about a session, where its message begins — are `./engine-log-parse.js`.
|
|
18
|
+
*
|
|
19
|
+
* BEST-EFFORT, like its caller: an unreadable file yields null and no bytes
|
|
20
|
+
* charged, never a throw. A log read must not become the failure it reports on.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
isErrorLine, extractMessage, collapseExcerpt, lineIsAboutSession,
|
|
27
|
+
} = require('./engine-log-parse');
|
|
28
|
+
|
|
29
|
+
/** Read at most the last 256 KiB of any candidate. The legacy single file is
|
|
30
|
+
* 2.4 MB on the reference machine, and this runs on a leg's death path (often
|
|
31
|
+
* once per dead seat in a wave) — whole-file reads are not an acceptable cost
|
|
32
|
+
* for a diagnostic nicety. */
|
|
33
|
+
const MAX_TAIL_BYTES = 256 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Read the last MAX_TAIL_BYTES of a file, or `budget` bytes if that is less.
|
|
36
|
+
* When the read started mid-file the first line is dropped: it is a fragment,
|
|
37
|
+
* and slicing at a byte offset can also land inside a multi-byte UTF-8
|
|
38
|
+
* sequence. `bytes` is what the read actually spent, so the caller can charge
|
|
39
|
+
* it against its own scan budget (`engine-log.js :: MAX_SCAN_BYTES`).
|
|
40
|
+
* @returns {{text: string, bytes: number}} */
|
|
41
|
+
function readTail(file, fsImpl, budget) {
|
|
42
|
+
const size = fsImpl.statSync(file).size;
|
|
43
|
+
const cap = Math.min(MAX_TAIL_BYTES, Math.max(0, budget));
|
|
44
|
+
const start = Math.max(0, size - cap);
|
|
45
|
+
const length = Math.min(size, cap);
|
|
46
|
+
if (!(length > 0)) { return { text: '', bytes: 0 }; }
|
|
47
|
+
const fd = fsImpl.openSync(file, 'r');
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
const buf = Buffer.alloc(length);
|
|
51
|
+
// Decode only what was actually read: a short read would otherwise decode
|
|
52
|
+
// the allocation's zero-fill as NUL characters, which no whitespace
|
|
53
|
+
// collapse strips and which would ride into the excerpt.
|
|
54
|
+
const bytesRead = fsImpl.readSync(fd, buf, 0, length, start);
|
|
55
|
+
text = buf.toString('utf-8', 0, Number.isFinite(bytesRead) ? bytesRead : length);
|
|
56
|
+
} finally {
|
|
57
|
+
try { fsImpl.closeSync(fd); } catch (_e) { /* best effort */ }
|
|
58
|
+
}
|
|
59
|
+
if (start > 0) {
|
|
60
|
+
const firstBreak = text.indexOf('\n');
|
|
61
|
+
text = firstBreak === -1 ? '' : text.slice(firstBreak + 1);
|
|
62
|
+
}
|
|
63
|
+
return { text, bytes: length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The NEWEST usable excerpt in this file for `needle`. Logs are append-ordered,
|
|
68
|
+
* so scanning backwards reaches the newest match first — and the newest is the
|
|
69
|
+
* one that killed the leg (a session can log several errors while it degrades).
|
|
70
|
+
*
|
|
71
|
+
* ABOUT, not merely mentioning (round-2 review A1, tightened in round 3): a line
|
|
72
|
+
* whose structural session field names someone else is skipped even when our id
|
|
73
|
+
* appears elsewhere on it, and the walk continues to an older line that is
|
|
74
|
+
* really ours.
|
|
75
|
+
*
|
|
76
|
+
* USABLE, not merely matching (round-3 review C4). A matching line whose message
|
|
77
|
+
* part is empty used to end the file's scan, so the fallthrough skipped to the
|
|
78
|
+
* next FILE and never reached the older line in THIS one that actually says what
|
|
79
|
+
* happened — and those two are typically neighbours, the real failure followed by
|
|
80
|
+
* a terse message-less line as the session tears down. The excerpt is therefore
|
|
81
|
+
* built here, inside the walk, and an empty one just keeps walking. It costs no
|
|
82
|
+
* extra I/O: the tail is already in memory, so the byte budget is unchanged.
|
|
83
|
+
*
|
|
84
|
+
* `isErrorLine` runs FIRST because it is the cheaper test — it rejects a
|
|
85
|
+
* non-ERROR line on one `indexOf` and tokenizes only when the level substring
|
|
86
|
+
* is actually present (#201 tail C1), while ownership tokenizes every line it
|
|
87
|
+
* is handed. Only ERROR lines can ever answer.
|
|
88
|
+
*
|
|
89
|
+
* `tails` is the caller's memo (#201 tail C2); a cached entry carries the bytes
|
|
90
|
+
* its ORIGINAL read spent, charged again here, so the budget accounts
|
|
91
|
+
* identically warm or cold and both walk the same files to the same stop. A
|
|
92
|
+
* FAILED read is deliberately not cached — an unreadable file is a transient.
|
|
93
|
+
* @returns {{excerpt: string|null, bytes: number}} `bytes` is the budget spent.
|
|
94
|
+
*/
|
|
95
|
+
function newestExcerptInFile(file, needle, fsImpl, budget, tails) {
|
|
96
|
+
let read = tails && tails.get(file);
|
|
97
|
+
if (!read) {
|
|
98
|
+
try { read = readTail(file, fsImpl, budget); } catch (_e) { return { excerpt: null, bytes: 0 }; }
|
|
99
|
+
if (tails) { tails.set(file, read); }
|
|
100
|
+
}
|
|
101
|
+
const lines = read.text.split(/\r?\n/);
|
|
102
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
103
|
+
const line = lines[i];
|
|
104
|
+
if (!isErrorLine(line) || !lineIsAboutSession(line, needle)) { continue; }
|
|
105
|
+
const excerpt = collapseExcerpt(extractMessage(line));
|
|
106
|
+
if (excerpt) { return { excerpt, bytes: read.bytes }; }
|
|
107
|
+
}
|
|
108
|
+
return { excerpt: null, bytes: read.bytes };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `MAX_TAIL_BYTES` and `readTail` stay internal: nothing outside this file
|
|
112
|
+
// consumes either, and a constant in a `Key Exports` cell reads as a function
|
|
113
|
+
// it is not (engine-log.js's rule).
|
|
114
|
+
module.exports = { newestExcerptInFile };
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-log
|
|
3
|
+
* Resolve the OpenCode engine's own error line for one session.
|
|
4
|
+
*
|
|
5
|
+
* A leg that dies silent can then quote the truth instead of a guess.
|
|
6
|
+
*
|
|
7
|
+
* WHY (#133). The NO_OUTPUT_BACKSTOP message reports only what the deadline
|
|
8
|
+
* observed — deliberately, since the guess it replaced ("likely a listed-but-
|
|
9
|
+
* not-serving model or a dead endpoint") sent 30 minutes of a real outage's
|
|
10
|
+
* debugging at model ids and API keys. But the engine had written its real
|
|
11
|
+
* failure to its own log at the exact timestamp of every dead session the
|
|
12
|
+
* whole time. Reporting silence while the cause sits on disk unread is the
|
|
13
|
+
* "correct but SILENT degrade" the product principle forbids as hard as a
|
|
14
|
+
* crash. This module reads it; src/headless.js appends it to the reason.
|
|
15
|
+
*
|
|
16
|
+
* MEASURED (2026-08-25). The long-quoted single-file premise —
|
|
17
|
+
* `~/.local/share/opencode/log/opencode.log` — is STALE. The current engine
|
|
18
|
+
* writes ONE timestamped file PER PROCESS (`2026-08-25T185532.log`), and the
|
|
19
|
+
* two schemes coexist across machines: the reference machine still carries a
|
|
20
|
+
* 2.4 MB legacy `opencode.log`, while this one holds 12 timestamped files and
|
|
21
|
+
* NO `opencode.log` at all. A resolver that opened only the legacy name would
|
|
22
|
+
* therefore return nothing on a current install — silently, forever.
|
|
23
|
+
*
|
|
24
|
+
* THE TWO LINE FORMATS (logfmt at 1.17.x, columnar at 1.2.x), which line is an
|
|
25
|
+
* ERROR, which line is about this session, and where its message begins all
|
|
26
|
+
* live in `./engine-log-parse.js` — extracted in W10 round 2 and re-exported
|
|
27
|
+
* below. Reading ONE file's tail and finding the newest usable line in it is
|
|
28
|
+
* `./engine-log-tail.js`, extracted in PR #206's fix round. This file is what
|
|
29
|
+
* is left of the I/O half: which files to open, in what order, the total bytes
|
|
30
|
+
* one lookup may spend — and how long one scan is reused (#201 tail C2).
|
|
31
|
+
*
|
|
32
|
+
* THAT REUSE SERVES HITS ONLY (PR #206 round-1 A1+B2). A MISS re-lists and
|
|
33
|
+
* re-reads before it is believed, because the engine writes a leg's error WHEN
|
|
34
|
+
* that leg dies — possibly after the cached scan, possibly into a file it has
|
|
35
|
+
* only just rolled over to — so an absence in a previous call's listing and
|
|
36
|
+
* tails is not an absence on disk. The accepted residual runs the other way,
|
|
37
|
+
* and round 3 states its REAL bound. What a warm HIT serves is the
|
|
38
|
+
* newest-for-THIS-SESSION line as of the moment the slot was built, so the
|
|
39
|
+
* TTL bounds the MISSED WINDOW — lines written in the last ≤10 seconds — and
|
|
40
|
+
* bounds nothing about the AGE of what is served: the GAP between the quoted
|
|
41
|
+
* line and the line it misses is unbounded, because the cached answer can be
|
|
42
|
+
* arbitrarily old and still have been newest when it was read. A minutes-old
|
|
43
|
+
* error can therefore be quoted while a fatal line written two seconds ago
|
|
44
|
+
* sits unread. What it quotes is still a genuine ERROR line for THIS leg,
|
|
45
|
+
* which is a true diagnostic; suppression — reporting silence while the cause
|
|
46
|
+
* sits on disk unread — is the failure this module exists to end.
|
|
47
|
+
*
|
|
48
|
+
* EVERYTHING HERE IS BEST-EFFORT: every path returns null rather than
|
|
49
|
+
* throwing. A log read must never break a leg's death report — the report is
|
|
50
|
+
* the product; the excerpt is a bonus on top of it.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
'use strict';
|
|
54
|
+
|
|
55
|
+
const os = require('os');
|
|
56
|
+
const path = require('path');
|
|
57
|
+
const {
|
|
58
|
+
isErrorLine, extractMessage, collapseExcerpt, mentionsSession, lineIsAboutSession,
|
|
59
|
+
} = require('./engine-log-parse');
|
|
60
|
+
const { newestExcerptInFile } = require('./engine-log-tail');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The TOTAL tail bytes one lookup may read across all candidates — 8 full tail
|
|
64
|
+
* reads' worth (`engine-log-tail.js :: MAX_TAIL_BYTES`, 256 KiB). The bound that
|
|
65
|
+
* used to sit here was "the 3 newest files by mtime", and it broke exactly where
|
|
66
|
+
* this module matters most (round-2 review
|
|
67
|
+
* B1): the engine writes one log PER PROCESS, so in a mass-death wave every
|
|
68
|
+
* seat has its own file and the SURVIVORS keep writing — the dead leg's own log
|
|
69
|
+
* gets pushed out of the top 3 by the legs that did NOT die. A byte budget
|
|
70
|
+
* bounds the same cost (I/O, not file handles) without letting the answer's
|
|
71
|
+
* position in the mtime order decide whether it is ever read.
|
|
72
|
+
*/
|
|
73
|
+
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
|
74
|
+
/**
|
|
75
|
+
* How long ONE scan (the listing plus the tails it read) is reused (#201 tail
|
|
76
|
+
* C2). MEASURED, not guessed: this runs on a leg's DEATH PATH and legs die in
|
|
77
|
+
* WAVES — a wave's seats launch together, so their backstop deadlines land
|
|
78
|
+
* together, each observed on a 2-second poll tick (`POLL_INTERVAL_MS`,
|
|
79
|
+
* src/headless.js:80). Ten seconds is five of those ticks: wide enough for a
|
|
80
|
+
* whole wave to share one scan, short enough that a LATER failure gets a
|
|
81
|
+
* genuinely fresh read instead of a stale answer about an older death. Not a
|
|
82
|
+
* knob — any second-granularity value trades the same way.
|
|
83
|
+
*/
|
|
84
|
+
const SCAN_CACHE_TTL_MS = 10 * 1000;
|
|
85
|
+
const scanCaches = new WeakMap();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The live scan slot — `{key, at, files, tails}` — freshly reset when absent,
|
|
89
|
+
* stale, or built for other dirs. Bounded BY CONSTRUCTION: ONE slot per fs
|
|
90
|
+
* impl, holding one listing and one scan's worth of tails (MAX_SCAN_BYTES);
|
|
91
|
+
* another dir set evicts rather than accumulating. Keyed by the fs IMPL:
|
|
92
|
+
* production always passes the same `require('fs')` object, so a wave of seats
|
|
93
|
+
* shares one slot, while a test seam (or a hostile one) is a fresh object with
|
|
94
|
+
* a slot of its own — the fs seam is this module's whole testability and a
|
|
95
|
+
* shared cache would quietly break it. `engineErrorForSession` also DROPS the
|
|
96
|
+
* live slot behind a MISS, so the pass after one builds a new one.
|
|
97
|
+
*/
|
|
98
|
+
function scanSlot(fsImpl, key) {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
let slot = scanCaches.get(fsImpl);
|
|
101
|
+
if (!slot || slot.key !== key || now - slot.at >= SCAN_CACHE_TTL_MS) {
|
|
102
|
+
slot = { key, at: now, files: null, tails: new Map() };
|
|
103
|
+
scanCaches.set(fsImpl, slot);
|
|
104
|
+
}
|
|
105
|
+
return slot;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ordered, de-duplicated candidate engine-log DIRECTORIES, most specific first
|
|
110
|
+
* — the same order as `src/utils/auth-json.js :: authJsonCandidates` (same
|
|
111
|
+
* engine, same data root), whose comment records the measurement that on
|
|
112
|
+
* Windows OpenCode still writes to `~/.local/share/opencode`, which is why the
|
|
113
|
+
* `.local/share` path stays FIRST after XDG rather than APPDATA. Order is a
|
|
114
|
+
* listing order, not a precedence: all of them are searched (see
|
|
115
|
+
* existingEngineLogDirs), and mtime decides which file answers.
|
|
116
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
function engineLogDirCandidates(env = process.env) {
|
|
120
|
+
const home = os.homedir();
|
|
121
|
+
const candidates = [];
|
|
122
|
+
if (env.XDG_DATA_HOME) { candidates.push(path.join(env.XDG_DATA_HOME, 'opencode', 'log')); }
|
|
123
|
+
candidates.push(path.join(home, '.local', 'share', 'opencode', 'log'));
|
|
124
|
+
if (process.platform === 'win32') {
|
|
125
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
126
|
+
candidates.push(path.join(appData, 'opencode', 'log'));
|
|
127
|
+
}
|
|
128
|
+
return [...new Set(candidates)];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* EVERY candidate directory that exists, in candidate order — NOT "the first
|
|
133
|
+
* one that exists" (W10 round-1 review A2). The dirs are alternative homes for
|
|
134
|
+
* the SAME engine's logs, and existence says nothing about which one the live
|
|
135
|
+
* engine writes to: a stale `$XDG_DATA_HOME`, or an empty dir left by a
|
|
136
|
+
* previous install, would otherwise shadow the dir holding the answer — this
|
|
137
|
+
* module's own silent-forever miss, one layer up from where it was fixed.
|
|
138
|
+
* @returns {string[]}
|
|
139
|
+
*/
|
|
140
|
+
function existingEngineLogDirs(env, fsImpl) {
|
|
141
|
+
const dirs = [];
|
|
142
|
+
for (const dir of engineLogDirCandidates(env)) {
|
|
143
|
+
try { if (fsImpl.existsSync(dir)) { dirs.push(dir); } } catch (_e) { /* try the next */ }
|
|
144
|
+
}
|
|
145
|
+
return dirs;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* EVERY `*.log` across all candidate dirs, newest first by mtime. The order is
|
|
150
|
+
* taken over the UNION, not per directory, so which dir a file sits in never
|
|
151
|
+
* outranks how recently the engine wrote to it.
|
|
152
|
+
*
|
|
153
|
+
* Not truncated here: the scan reads down this list until it finds a match or
|
|
154
|
+
* spends MAX_SCAN_BYTES (see engineErrorForSession). Listing is a stat per
|
|
155
|
+
* file; only the READS are the cost worth bounding. This also retires the
|
|
156
|
+
* legacy `opencode.log`'s reserved slot — on a single-file machine it is the
|
|
157
|
+
* newest file anyway (it is written continuously), and on a migrated machine it
|
|
158
|
+
* is stale and simply sorts last, which is the same answer the reserved slot
|
|
159
|
+
* was there to produce.
|
|
160
|
+
* @param {string[]} dirs
|
|
161
|
+
* @returns {string[]} absolute paths, newest first
|
|
162
|
+
*/
|
|
163
|
+
function candidateLogFiles(dirs, fsImpl) {
|
|
164
|
+
const found = [];
|
|
165
|
+
for (const dir of dirs) {
|
|
166
|
+
let names;
|
|
167
|
+
try { names = fsImpl.readdirSync(dir); } catch (_e) { continue; }
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
if (!String(name).endsWith('.log')) { continue; }
|
|
170
|
+
const full = path.join(dir, name);
|
|
171
|
+
try {
|
|
172
|
+
found.push({ full, mtimeMs: fsImpl.statSync(full).mtimeMs || 0 });
|
|
173
|
+
} catch (_e) { /* vanished or unreadable — skip it */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
177
|
+
return found.map((f) => f.full);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The engine's own error line for a session, as a one-line excerpt.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} sessionId - `ses_…` (createSession's return) or the bare id.
|
|
184
|
+
* @param {object} [options]
|
|
185
|
+
* @param {string} [options.dataDir] - XDG-style DATA dir override; the module
|
|
186
|
+
* appends `opencode/log` itself, exactly as `$XDG_DATA_HOME` behaves in
|
|
187
|
+
* src/utils/auth-json.js. Tests point this at a synthetic fixture tree. When
|
|
188
|
+
* given it is the ONLY dir searched — an explicit override is a statement,
|
|
189
|
+
* not one more candidate.
|
|
190
|
+
* @param {NodeJS.ProcessEnv} [options.env] - environment for dir resolution.
|
|
191
|
+
* @param {object} [options.fs] - fs seam (needs existsSync/readdirSync/
|
|
192
|
+
* statSync/openSync/readSync/closeSync); defaults to the real module.
|
|
193
|
+
* @returns {string|null} excerpt, or null on EVERY miss path (no dir, no file,
|
|
194
|
+
* no line whose own session field names us, no ERROR line, every match empty,
|
|
195
|
+
* unreadable file, hostile fs, budget spent).
|
|
196
|
+
*/
|
|
197
|
+
function engineErrorForSession(sessionId, options = {}) {
|
|
198
|
+
try {
|
|
199
|
+
if (!sessionId || typeof sessionId !== 'string') { return null; }
|
|
200
|
+
const fsImpl = options.fs || require('fs');
|
|
201
|
+
const dirs = options.dataDir
|
|
202
|
+
? [path.join(options.dataDir, 'opencode', 'log')]
|
|
203
|
+
: existingEngineLogDirs(options.env || process.env, fsImpl);
|
|
204
|
+
if (!dirs.length) { return null; }
|
|
205
|
+
const needle = sessionId.startsWith('ses_') ? sessionId : `ses_${sessionId}`;
|
|
206
|
+
const key = dirs.join('\0');
|
|
207
|
+
// TWO passes at most. Pass 1 may run on a WARM slot — the listing and tails
|
|
208
|
+
// another seat that died in the same window already read (#201 tail C2, see
|
|
209
|
+
// SCAN_CACHE_TTL_MS): the SCAN is memoized, never the ANSWER, so each seat
|
|
210
|
+
// still walks the lines for its OWN id. A HIT off that slot is served; a
|
|
211
|
+
// MISS is not (PR #206 round-1 A1+B2, see the module header) — pass 2 drops
|
|
212
|
+
// the slot and re-lists and re-reads before believing the absence.
|
|
213
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
214
|
+
const slot = scanSlot(fsImpl, key);
|
|
215
|
+
const cold = !slot.files; // a slot built just now carries no listing yet
|
|
216
|
+
if (cold) { slot.files = candidateLogFiles(dirs, fsImpl); }
|
|
217
|
+
// Newest first, reading until the answer turns up or the byte budget is
|
|
218
|
+
// spent — NOT "the N newest files" (round-2 review B1, see MAX_SCAN_BYTES).
|
|
219
|
+
let budget = MAX_SCAN_BYTES;
|
|
220
|
+
for (const file of slot.files) {
|
|
221
|
+
if (budget <= 0) { break; }
|
|
222
|
+
const found = newestExcerptInFile(file, needle, fsImpl, budget, slot.tails);
|
|
223
|
+
budget -= found.bytes;
|
|
224
|
+
if (found.excerpt) { return found.excerpt; }
|
|
225
|
+
}
|
|
226
|
+
if (cold) { break; } // this pass read the disk itself: nothing to distrust
|
|
227
|
+
scanCaches.delete(fsImpl);
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
} catch (_e) {
|
|
231
|
+
return null; // a diagnostic read must never become the failure it reports on
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The line-shape helpers are RE-EXPORTED from their new home in
|
|
236
|
+
// `engine-log-parse.js` so this module stays the single import site a consumer
|
|
237
|
+
// needs (tests/engine-log.test.js pins that they are the same function objects,
|
|
238
|
+
// not copies). The scan budget and the TTL beside it — like the tail bound now
|
|
239
|
+
// in `engine-log-tail.js` — are deliberately NOT exported: nothing outside
|
|
240
|
+
// consumes them, and a constant in a `Key Exports` cell reads as a function it
|
|
241
|
+
// is not.
|
|
242
|
+
module.exports = {
|
|
243
|
+
engineErrorForSession,
|
|
244
|
+
engineLogDirCandidates,
|
|
245
|
+
isErrorLine,
|
|
246
|
+
extractMessage,
|
|
247
|
+
collapseExcerpt,
|
|
248
|
+
mentionsSession,
|
|
249
|
+
lineIsAboutSession,
|
|
250
|
+
};
|