pi-better-background-tasks 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/package.json +1 -1
- package/src/failures.ts +85 -3
- package/src/index.ts +5 -0
- package/src/navigator-provider.ts +11 -7
- package/src/output.ts +83 -49
- package/src/runtime.ts +189 -17
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +34 -3
- package/src/shared-failure-observations.ts +414 -47
- package/src/shared-log-utils.ts +108 -0
- package/src/shared-navigator.ts +180 -134
- package/src/shared-sandbox-core.ts +724 -17
- package/src/tools.ts +107 -32
- package/src/types.ts +7 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Generated from packages/failure-observations/index.ts. Do not edit directly.
|
|
2
|
-
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
|
|
@@ -85,6 +85,102 @@ export const REJECTED_INTENT_CATEGORY = "rejected-intent";
|
|
|
85
85
|
/** The same unresolved agent tool operation failing this many times is treated as stuck. */
|
|
86
86
|
export const REPEATED_FAILURE_THRESHOLD = 3;
|
|
87
87
|
|
|
88
|
+
/** Caller-chosen intent identifiers: short, printable, no whitespace. */
|
|
89
|
+
export const INTENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,63}$";
|
|
90
|
+
const INTENT_ID = new RegExp(INTENT_ID_PATTERN);
|
|
91
|
+
export const MAX_EXPECTED_EXIT_CODES = 16;
|
|
92
|
+
/**
|
|
93
|
+
* Structured command intent (#315), declared by the caller before a command runs. Shared by the
|
|
94
|
+
* subagent task runtime's bash and background tasks (#325) so both validate identically.
|
|
95
|
+
*/
|
|
96
|
+
export interface CommandIntent {
|
|
97
|
+
/** Stable across caller-declared modified retries of one logical operation. */
|
|
98
|
+
operationId?: string;
|
|
99
|
+
/** Names one concrete execution; evidence identity, never an operation match. */
|
|
100
|
+
attemptId?: string;
|
|
101
|
+
/** Non-zero exit codes declared intentional before execution. */
|
|
102
|
+
expectedExitCodes?: number[];
|
|
103
|
+
}
|
|
104
|
+
export type CommandIntentField = keyof CommandIntent;
|
|
105
|
+
const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
|
|
106
|
+
/**
|
|
107
|
+
* Pi's own argument coercion for the intent fields (pi-ai `validateToolArguments` against the confined
|
|
108
|
+
* bash schema, where each field is `anyOf: [field schema, null]`). Pi applies it before execute, but the
|
|
109
|
+
* process log and session record keep the raw arguments; mirroring it lets the parent's replay read
|
|
110
|
+
* the declaration the child executed with (#332).
|
|
111
|
+
* - ids: a number or boolean becomes its string; `""` fails the pattern and falls through to null.
|
|
112
|
+
* - exit codes: array items that read as integers become integers (null -> 0, true -> 1);
|
|
113
|
+
* a scalar `0`, `""`, or `false` falls through to null.
|
|
114
|
+
*/
|
|
115
|
+
function coerceInteger(value: unknown): unknown {
|
|
116
|
+
if (value === null) return 0;
|
|
117
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isInteger(Number(value))) return Number(value);
|
|
118
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
function normalizeIntentField(key: CommandIntentField, value: unknown): unknown {
|
|
122
|
+
if (value == null) return undefined;
|
|
123
|
+
if (key === "expectedExitCodes") {
|
|
124
|
+
if (value === 0 || value === "" || value === false) return undefined;
|
|
125
|
+
if (!Array.isArray(value)) return value;
|
|
126
|
+
// 0 is a no-op declaration: exit 0 is already success. A list of only zeros declares nothing.
|
|
127
|
+
const codes = value.map(coerceInteger).filter((code) => code !== 0);
|
|
128
|
+
if (codes.length === 0 && value.length > 0) return undefined;
|
|
129
|
+
return codes.length === value.length && codes.every((code, index) => code === value[index]) ? value : codes;
|
|
130
|
+
}
|
|
131
|
+
if (value === "") return undefined;
|
|
132
|
+
return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The one normalization of intent-bearing arguments. An explicit `null` intent field means "not
|
|
136
|
+
* declared", exactly like an omitted one: models routinely send optional fields as null, and Pi's
|
|
137
|
+
* argument validation drops or nulls them before a tool runs, while the process log and session
|
|
138
|
+
* record keep the raw arguments. Values Pi would coerce are coerced the same way, and `0` is dropped
|
|
139
|
+
* from `expectedExitCodes`. Every reader of intent, the executing tool and the replay alike, and the
|
|
140
|
+
* exact operation identity go through this, so all see the same declaration from the same input.
|
|
141
|
+
* Malformed values are kept as they are for the validator to refuse.
|
|
142
|
+
*/
|
|
143
|
+
export function withoutAbsentIntent<T>(args: T): T {
|
|
144
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
145
|
+
const input = args as Record<string, unknown>;
|
|
146
|
+
let out: Record<string, unknown> | undefined;
|
|
147
|
+
for (const key of INTENT_FIELDS) {
|
|
148
|
+
if (!(key in input)) continue;
|
|
149
|
+
const value = normalizeIntentField(key, input[key]);
|
|
150
|
+
if (value === input[key]) continue;
|
|
151
|
+
out ??= { ...input };
|
|
152
|
+
if (value === undefined) delete out[key];
|
|
153
|
+
else out[key] = value;
|
|
154
|
+
}
|
|
155
|
+
return (out ?? args) as T;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Validate structured intent fields after `withoutAbsentIntent`. Absent fields are fine; malformed
|
|
159
|
+
* ones are an error, and the caller must not run the command. `names` renames fields in the error
|
|
160
|
+
* (e.g. snake_case parameters). `0` in `expectedExitCodes` is accepted and dropped (#332).
|
|
161
|
+
*/
|
|
162
|
+
export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
|
|
163
|
+
const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
|
|
164
|
+
const intent: CommandIntent = {};
|
|
165
|
+
for (const key of ["operationId", "attemptId"] as const) {
|
|
166
|
+
const value = input[key];
|
|
167
|
+
if (value === undefined) continue;
|
|
168
|
+
if (typeof value !== "string" || !INTENT_ID.test(value)) {
|
|
169
|
+
return { intent: {}, error: `${names[key] ?? key} must match ${INTENT_ID_PATTERN}` };
|
|
170
|
+
}
|
|
171
|
+
intent[key] = value;
|
|
172
|
+
}
|
|
173
|
+
const codes = input.expectedExitCodes;
|
|
174
|
+
if (codes !== undefined) {
|
|
175
|
+
if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
|
|
176
|
+
!codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
|
|
177
|
+
return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255 (0 is allowed and ignored)` };
|
|
178
|
+
}
|
|
179
|
+
intent.expectedExitCodes = [...codes] as number[];
|
|
180
|
+
}
|
|
181
|
+
return { intent };
|
|
182
|
+
}
|
|
183
|
+
|
|
88
184
|
/** The one current incident with this id, if it is still in the reduced state. */
|
|
89
185
|
export function findIncident(state: FailureState, id: string): FailureObservation | undefined {
|
|
90
186
|
return Object.values(state.observations).find((item) => item.id === id);
|
|
@@ -109,6 +205,25 @@ export function failureLabel(x: FailureObservation): string {
|
|
|
109
205
|
return requiresAction(x) ? "Action required" : "Unclassified failure observation";
|
|
110
206
|
}
|
|
111
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Whether an incident needs someone's action now: an unresolved `Action required` incident
|
|
210
|
+
* (non-tool failure, repeated tool failure, or disposed `open`) or an observation gap. Unclassified
|
|
211
|
+
* and expected failures are history: counted on every surface, listed only on explicit request.
|
|
212
|
+
*/
|
|
213
|
+
export function needsAction(x: FailureObservation): boolean {
|
|
214
|
+
return x.status === "unresolved" && (x.category === "observation-incomplete" || requiresAction(x));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Which incidents a surface lists. `actionable` (the default everywhere) is what needs action;
|
|
219
|
+
* `all` is the explicit history view: every incident ever reduced, actionable first, then unclassified,
|
|
220
|
+
* expected, and closed (recovered/superseded) incidents.
|
|
221
|
+
*/
|
|
222
|
+
export type IncidentScope = "actionable" | "all";
|
|
223
|
+
/** `compact` rows (the default) unwrap tool-result JSON, cap the excerpt, and shorten evidence paths; `full` keeps them. */
|
|
224
|
+
export type IncidentDetail = "compact" | "full";
|
|
225
|
+
export interface IncidentRowOptions { scope?: IncidentScope; detail?: IncidentDetail }
|
|
226
|
+
|
|
112
227
|
/** Validate a disposition against the reduced state. Returns the rejection reason, or undefined. */
|
|
113
228
|
export function validateDisposition(state: FailureState, event: FailureEvent): string | undefined {
|
|
114
229
|
if (event.kind !== "disposition") return "Not a disposition event";
|
|
@@ -195,6 +310,20 @@ export function activeFailures(state: FailureState): FailureObservation[] {
|
|
|
195
310
|
return Object.values(state.observations).filter((x) => x.status !== "resolved" && x.status !== "superseded")
|
|
196
311
|
.sort((a, b) => priority(a) - priority(b) || (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt);
|
|
197
312
|
}
|
|
313
|
+
/** Incidents that need action now, priority order. */
|
|
314
|
+
export function actionableFailures(state: FailureState): FailureObservation[] {
|
|
315
|
+
return activeFailures(state).filter(needsAction);
|
|
316
|
+
}
|
|
317
|
+
function closedOrder(a: FailureObservation, b: FailureObservation): number {
|
|
318
|
+
return (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt;
|
|
319
|
+
}
|
|
320
|
+
/** The incidents a scope lists, in the order rows render. */
|
|
321
|
+
export function scopedFailures(state: FailureState, scope: IncidentScope = "actionable"): FailureObservation[] {
|
|
322
|
+
if (scope !== "all") return actionableFailures(state);
|
|
323
|
+
const active = activeFailures(state);
|
|
324
|
+
const closed = failureHistory(state).filter((x) => x.status === "resolved" || x.status === "superseded").sort(closedOrder);
|
|
325
|
+
return [...active, ...closed];
|
|
326
|
+
}
|
|
198
327
|
/** Every incident ever reduced, current and closed. Nothing is removed. */
|
|
199
328
|
export function failureHistory(state: FailureState): FailureObservation[] {
|
|
200
329
|
return [...Object.values(state.history ?? {}), ...Object.values(state.observations)];
|
|
@@ -220,18 +349,120 @@ export function failureCounts(state: FailureState): FailureCounts {
|
|
|
220
349
|
}
|
|
221
350
|
return counts;
|
|
222
351
|
}
|
|
352
|
+
function plural(count: number, one: string, many = `${one}s`): string {
|
|
353
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Counts of incidents that do not need action, as one short fragment, or undefined when there are
|
|
357
|
+
* none: e.g. `8 unclassified tool errors · 2 expected · 1 recovered`.
|
|
358
|
+
*/
|
|
359
|
+
export function historyCountsText(state: FailureState): string | undefined {
|
|
360
|
+
const counts = failureCounts(state);
|
|
361
|
+
const parts = [
|
|
362
|
+
counts.unclassified ? plural(counts.unclassified, "unclassified tool error") : "",
|
|
363
|
+
counts.expected ? `${counts.expected} expected` : "",
|
|
364
|
+
counts.recovered ? `${counts.recovered} recovered` : "",
|
|
365
|
+
counts.superseded ? `${counts.superseded} superseded` : "",
|
|
366
|
+
].filter(Boolean);
|
|
367
|
+
return parts.length ? parts.join(" · ") : undefined;
|
|
368
|
+
}
|
|
369
|
+
/** One line for a run where nothing needs action but history exists. No cursor: history is explicit. */
|
|
370
|
+
export function quietFailureLine(state: FailureState): string | undefined {
|
|
371
|
+
if (actionableFailures(state).length) return undefined;
|
|
372
|
+
const counts = failureCounts(state);
|
|
373
|
+
// Closed incidents alone are not worth a line on every surface; they ride along with current ones.
|
|
374
|
+
if (!counts.unclassified && !counts.expected) return undefined;
|
|
375
|
+
const history = historyCountsText(state);
|
|
376
|
+
return history ? `No failures need action · ${history} (history)` : undefined;
|
|
377
|
+
}
|
|
378
|
+
/** Trailing history count after actionable rows, or undefined when there is none. */
|
|
379
|
+
export function historyTailLine(state: FailureState): string | undefined {
|
|
380
|
+
const history = historyCountsText(state);
|
|
381
|
+
return history ? `Also in history: ${history}` : undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
223
384
|
const INCIDENT_CURSOR_PREFIX = "i1.";
|
|
224
385
|
const encoder = new TextEncoder();
|
|
225
386
|
|
|
226
|
-
|
|
387
|
+
/** Longest summary or disposition-reason excerpt a compact row shows, in UTF-8 bytes. */
|
|
388
|
+
export const COMPACT_EXCERPT_BYTES = 120;
|
|
389
|
+
const TOOL_RESULT_WRAPPER = /\{"content":\[\{"type":"text","text":"/;
|
|
390
|
+
const JSON_ESCAPES: Record<string, string> = { '"': '"', "\\": "\\", "/": "/", b: " ", f: " ", n: " ", r: " ", t: " " };
|
|
391
|
+
|
|
392
|
+
/** Remove unpaired UTF-16 surrogates, which would otherwise encode as U+FFFD. */
|
|
393
|
+
function dropLoneSurrogates(value: string): string {
|
|
394
|
+
return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A tool error recorded as its raw result JSON (`{"content":[{"type":"text","text":"…"}]}`, often
|
|
399
|
+
* already cut mid-string) rendered as the text it carries. Decoding is tolerant of the cut; text
|
|
400
|
+
* before the wrapper (e.g. `read failed: `) is kept.
|
|
401
|
+
*/
|
|
402
|
+
export function unwrapToolResultText(value: string): string {
|
|
403
|
+
const match = TOOL_RESULT_WRAPPER.exec(value);
|
|
404
|
+
if (!match) return value;
|
|
405
|
+
let out = "";
|
|
406
|
+
let i = match.index + match[0].length;
|
|
407
|
+
while (i < value.length) {
|
|
408
|
+
const ch = value[i]!;
|
|
409
|
+
if (ch === '"') break;
|
|
410
|
+
if (ch !== "\\") { out += ch; i += 1; continue; }
|
|
411
|
+
const next = value[i + 1];
|
|
412
|
+
if (next === "u") {
|
|
413
|
+
const hex = value.slice(i + 2, i + 6);
|
|
414
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) break;
|
|
415
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
416
|
+
i += 6;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (next === undefined || !(next in JSON_ESCAPES)) break;
|
|
420
|
+
out += JSON_ESCAPES[next];
|
|
421
|
+
i += 2;
|
|
422
|
+
}
|
|
423
|
+
// A wrapper cut inside a surrogate pair, or an escape of one half, leaves a lone surrogate
|
|
424
|
+
// (rendered as U+FFFD); decoded control characters (e.g. an escaped NUL) are not shown either.
|
|
425
|
+
const decoded = dropLoneSurrogates(out).replace(/[\x00-\x1f\x7f]/g, " ");
|
|
426
|
+
return `${value.slice(0, match.index)}${decoded}`.replace(/\s+/g, " ").trim();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** At most `max` UTF-8 bytes of `value`, whole code points, ending in `…` when cut. */
|
|
430
|
+
export function capUtf8(value: string, max: number): string {
|
|
431
|
+
const bytes = encoder.encode(value);
|
|
432
|
+
if (bytes.length <= max) return value;
|
|
433
|
+
const ellipsis = "…";
|
|
434
|
+
const cut = utf8Prefix(bytes, Math.max(0, max - utf8Length(ellipsis)));
|
|
435
|
+
return `${Buffer.from(bytes.subarray(0, cut)).toString("utf8").trimEnd()}${ellipsis}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Evidence reference as a compact row shows it: an absolute path loses its directories
|
|
440
|
+
* (`/private/var/…/runs/<id>/output.log#byte=N` → `output.log#byte=N`); the run or task id already
|
|
441
|
+
* names the log. Full rows and the journal keep the whole reference.
|
|
442
|
+
*/
|
|
443
|
+
export function shortEvidence(evidence: string): string {
|
|
444
|
+
const match = /^(?:[A-Za-z]:)?[\\/](?:[^#]*[\\/])?([^\\/#]+)(#.*)?$/.exec(evidence);
|
|
445
|
+
return match ? `${match[1]}${match[2] ?? ""}` : evidence;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function failureRow(x: FailureObservation, detail: IncidentDetail = "compact"): string {
|
|
227
449
|
const time = x.at === undefined ? `observed ${new Date(x.firstObservedAt).toISOString()}` : new Date(x.at).toISOString();
|
|
228
|
-
const
|
|
229
|
-
|
|
450
|
+
const compact = detail !== "full";
|
|
451
|
+
// Full rows (raw evidence) show the journal summary verbatim; compact rows unwrap and cap it.
|
|
452
|
+
const summary = compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
|
|
453
|
+
const reason = x.disposition ? text(x.disposition.reason, "") : "";
|
|
454
|
+
const disposition = x.disposition ? ` · ${x.disposition.disposition}: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}` : "";
|
|
455
|
+
const evidence = x.evidence ? text(x.evidence, "") : "";
|
|
456
|
+
return `${failureLabel(x)} · ${time} · ${summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${evidence ? ` · evidence: ${compact ? shortEvidence(evidence) : evidence}` : ""}`;
|
|
230
457
|
}
|
|
231
458
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
459
|
+
/**
|
|
460
|
+
* Incident rows in priority order. By default, only incidents that need action, as compact rows;
|
|
461
|
+
* `scope: "all"` lists history too and `detail: "full"` keeps whole excerpts and evidence paths.
|
|
462
|
+
* Consumers page these; they are not a lossy summary.
|
|
463
|
+
*/
|
|
464
|
+
export function formatFailureLines(state: FailureState, options: IncidentRowOptions = {}): string[] {
|
|
465
|
+
return scopedFailures(state, options.scope).map((x) => failureRow(x, options.detail));
|
|
235
466
|
}
|
|
236
467
|
|
|
237
468
|
function closedHistoryLine(state: FailureState): string | undefined {
|
|
@@ -244,10 +475,10 @@ function closedHistoryLine(state: FailureState): string | undefined {
|
|
|
244
475
|
/** Shared priority text, placed BEFORE assistant progress on every consumer surface. */
|
|
245
476
|
export function formatFailureSummary(state: FailureState): string {
|
|
246
477
|
const lines = formatFailureLines(state);
|
|
247
|
-
if (!lines.length) return "";
|
|
478
|
+
if (!lines.length) return quietFailureLine(state) ?? "";
|
|
248
479
|
const rows = lines.slice(0, 5);
|
|
249
480
|
if (lines.length > 5) rows.push(`${lines.length - 5} additional active failure observations retained in the failure journal.`);
|
|
250
|
-
const history =
|
|
481
|
+
const history = historyTailLine(state);
|
|
251
482
|
if (history) rows.push(history);
|
|
252
483
|
return rows.join("\n");
|
|
253
484
|
}
|
|
@@ -257,7 +488,7 @@ export function pendingAttentionRows(state: FailureState, incidents: readonly st
|
|
|
257
488
|
const wanted = new Set(incidents);
|
|
258
489
|
const rows = activeFailures(state).filter((x) => wanted.has(x.id));
|
|
259
490
|
if (rows.length !== wanted.size) throw new Error("Failure incident evidence is unavailable; defer notification delivery");
|
|
260
|
-
return rows.map(failureRow);
|
|
491
|
+
return rows.map((x) => failureRow(x));
|
|
261
492
|
}
|
|
262
493
|
/** Count of other active incidents, which a notification references but never re-lists. */
|
|
263
494
|
export function pendingAttentionNote(state: FailureState, incidents: readonly string[]): string | undefined {
|
|
@@ -279,6 +510,8 @@ export function formatPendingAttention(state: FailureState, incidents: readonly
|
|
|
279
510
|
return lines.join("\n");
|
|
280
511
|
}
|
|
281
512
|
|
|
513
|
+
/** The terminal fact that lifecycle success is not evidence of correct work. Kept whenever it fits and never cut mid-sentence (#325). */
|
|
514
|
+
export const CORRECTNESS_NOTE: string = "Work correctness was not inferred from lifecycle alone.";
|
|
282
515
|
export interface TerminalFailureParts {
|
|
283
516
|
/** Actionable and observation-incomplete incidents among `incidents`, one row each. */
|
|
284
517
|
rows: string[];
|
|
@@ -292,7 +525,7 @@ export interface TerminalFailureParts {
|
|
|
292
525
|
export function terminalFailureParts(state: FailureState, incidents: readonly string[] = []): TerminalFailureParts {
|
|
293
526
|
const wanted = new Set(incidents);
|
|
294
527
|
const reportable = (x: FailureObservation) => requiresAction(x) || x.category === "observation-incomplete";
|
|
295
|
-
const rows = activeFailures(state).filter((x) => wanted.has(x.id) && reportable(x)).map(failureRow);
|
|
528
|
+
const rows = activeFailures(state).filter((x) => wanted.has(x.id) && reportable(x)).map((x) => failureRow(x));
|
|
296
529
|
const notes: string[] = [];
|
|
297
530
|
const earlier = activeFailures(state).filter((x) => !wanted.has(x.id) && reportable(x)).length;
|
|
298
531
|
if (earlier) notes.push(`${earlier} actionable incident${earlier === 1 ? " was" : "s were"} reported earlier; not repeated here.`);
|
|
@@ -301,7 +534,7 @@ export function terminalFailureParts(state: FailureState, incidents: readonly st
|
|
|
301
534
|
if (counts.expected) notes.push(`${counts.expected} expected failure${counts.expected === 1 ? "" : "s"} recorded.`);
|
|
302
535
|
const history = closedHistoryLine(state);
|
|
303
536
|
if (history) notes.push(history);
|
|
304
|
-
if (counts.unclassified || counts.actionRequired) notes.push(
|
|
537
|
+
if (counts.unclassified || counts.actionRequired) notes.push(CORRECTNESS_NOTE);
|
|
305
538
|
return { rows, notes };
|
|
306
539
|
}
|
|
307
540
|
export function formatTerminalFailureFacts(state: FailureState, incidents: readonly string[] = []): string {
|
|
@@ -339,8 +572,9 @@ export function failureRevision(state: FailureState): string {
|
|
|
339
572
|
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
|
|
340
573
|
}
|
|
341
574
|
|
|
342
|
-
|
|
343
|
-
|
|
575
|
+
/** Digest of the rows a cursor pages. Scope and detail are part of it: the same incidents render different bytes. */
|
|
576
|
+
function incidentRevision(state: FailureState, scope: IncidentScope = "actionable", detail: IncidentDetail = "compact"): string {
|
|
577
|
+
return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? ""])).slice(0, 16);
|
|
344
578
|
}
|
|
345
579
|
|
|
346
580
|
/** Cursors carry a digest of their resource/scope, not the scope text. */
|
|
@@ -348,20 +582,28 @@ function resourceTag(resource: string | undefined): string | undefined {
|
|
|
348
582
|
return resource === undefined ? undefined : createHash("sha256").update(resource).digest("base64url").slice(0, 16);
|
|
349
583
|
}
|
|
350
584
|
|
|
351
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Incident cursor format. `x` is the format version: cursors minted before scope and row detail
|
|
587
|
+
* existed (no `x`) carry byte offsets into full-detail rows of every active incident, so they reset
|
|
588
|
+
* as stale instead of resuming inside a compact row. `h`: the explicit history view; `f`: full rows.
|
|
589
|
+
*/
|
|
590
|
+
const INCIDENT_CURSOR_FORMAT = 2;
|
|
591
|
+
interface IncidentCursor { o: number; b: number; v: string; n: number; r?: string; h?: 1; f?: 1 }
|
|
352
592
|
|
|
353
593
|
function encodeIncidentCursor(cursor: IncidentCursor): string {
|
|
354
|
-
return INCIDENT_CURSOR_PREFIX + Buffer.from(JSON.stringify({ k: "i", o: cursor.o, ...(cursor.b ? { b: cursor.b } : {}),
|
|
355
|
-
v: cursor.v, n: cursor.n, ...(cursor.r !== undefined ? { r: cursor.r } : {}) }),
|
|
594
|
+
return INCIDENT_CURSOR_PREFIX + Buffer.from(JSON.stringify({ k: "i", x: INCIDENT_CURSOR_FORMAT, o: cursor.o, ...(cursor.b ? { b: cursor.b } : {}),
|
|
595
|
+
v: cursor.v, n: cursor.n, ...(cursor.r !== undefined ? { r: cursor.r } : {}), ...(cursor.h ? { h: 1 } : {}),
|
|
596
|
+
...(cursor.f ? { f: 1 } : {}) }), "utf8").toString("base64url");
|
|
356
597
|
}
|
|
357
598
|
|
|
358
599
|
function decodeIncidentCursor(cursor: string | undefined): IncidentCursor | undefined {
|
|
359
600
|
if (!cursor || !cursor.startsWith(INCIDENT_CURSOR_PREFIX)) return undefined;
|
|
360
601
|
try {
|
|
361
|
-
const parsed = JSON.parse(Buffer.from(cursor.slice(INCIDENT_CURSOR_PREFIX.length), "base64url").toString("utf8")) as { k?: string; o?: number; b?: number; v?: string; n?: number; r?: string };
|
|
362
|
-
if (parsed?.k === "i" && typeof parsed.v === "string") {
|
|
602
|
+
const parsed = JSON.parse(Buffer.from(cursor.slice(INCIDENT_CURSOR_PREFIX.length), "base64url").toString("utf8")) as { k?: string; x?: number; o?: number; b?: number; v?: string; n?: number; r?: string; h?: number; f?: number };
|
|
603
|
+
if (parsed?.k === "i" && parsed.x === INCIDENT_CURSOR_FORMAT && typeof parsed.v === "string") {
|
|
363
604
|
return { o: Math.max(0, Math.floor(parsed.o ?? 0)), b: Math.max(0, Math.floor(parsed.b ?? 0)), v: parsed.v,
|
|
364
|
-
n: Math.max(0, Math.floor(parsed.n ?? 0)), ...(typeof parsed.r === "string" ? { r: parsed.r } : {})
|
|
605
|
+
n: Math.max(0, Math.floor(parsed.n ?? 0)), ...(typeof parsed.r === "string" ? { r: parsed.r } : {}),
|
|
606
|
+
...(parsed.h === 1 ? { h: 1 as const } : {}), ...(parsed.f === 1 ? { f: 1 as const } : {}) };
|
|
365
607
|
}
|
|
366
608
|
} catch { /* stale */ }
|
|
367
609
|
return undefined;
|
|
@@ -371,6 +613,12 @@ export function isIncidentCursor(cursor: string | undefined): boolean {
|
|
|
371
613
|
return Boolean(cursor?.startsWith(INCIDENT_CURSOR_PREFIX));
|
|
372
614
|
}
|
|
373
615
|
|
|
616
|
+
/** The scope an incident cursor pages, or undefined for anything that is not an incident cursor. */
|
|
617
|
+
export function incidentCursorScope(cursor: string | undefined): IncidentScope | undefined {
|
|
618
|
+
const parsed = decodeIncidentCursor(cursor);
|
|
619
|
+
return parsed ? (parsed.h ? "all" : "actionable") : undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
374
622
|
function utf8Length(value: string): number {
|
|
375
623
|
return encoder.encode(value).byteLength;
|
|
376
624
|
}
|
|
@@ -384,7 +632,7 @@ function utf8Prefix(bytes: Uint8Array, room: number): number {
|
|
|
384
632
|
return end;
|
|
385
633
|
}
|
|
386
634
|
|
|
387
|
-
export interface IncidentPageRequest {
|
|
635
|
+
export interface IncidentPageRequest extends IncidentRowOptions {
|
|
388
636
|
cursor?: string;
|
|
389
637
|
maxBytes?: number;
|
|
390
638
|
/** Resource/scope bound into cursors; a cursor minted for another scope resets. */
|
|
@@ -398,23 +646,27 @@ export interface IncidentPageRequest {
|
|
|
398
646
|
* that `endsPartial`). A page never exceeds `maxBytes`.
|
|
399
647
|
*/
|
|
400
648
|
export function pageFailureIncidents(state: FailureState, request: IncidentPageRequest = {}): FailureIncidentPage {
|
|
401
|
-
|
|
402
|
-
const
|
|
649
|
+
// A cursor carries its view: following a history cursor stays in history without the flag.
|
|
650
|
+
const parsed = request.cursor ? decodeIncidentCursor(request.cursor) : undefined;
|
|
651
|
+
const scope: IncidentScope = request.scope ?? (parsed?.h ? "all" : "actionable");
|
|
652
|
+
const detail: IncidentDetail = request.detail ?? (parsed?.f ? "full" : "compact");
|
|
653
|
+
const lines = formatFailureLines(state, { scope, detail });
|
|
654
|
+
const revision = incidentRevision(state, scope, detail);
|
|
403
655
|
const total = lines.length;
|
|
404
656
|
const resource = resourceTag(request.resource);
|
|
405
657
|
let offset = 0;
|
|
406
658
|
let byte = 0;
|
|
407
659
|
let reset: FailureIncidentPage["reset"];
|
|
408
660
|
if (request.cursor) {
|
|
409
|
-
|
|
410
|
-
if (!parsed || parsed.r !== resource) reset = "stale-cursor";
|
|
661
|
+
if (!parsed || parsed.r !== resource || Boolean(parsed.h) !== (scope === "all") || Boolean(parsed.f) !== (detail === "full")) reset = "stale-cursor";
|
|
411
662
|
else if (parsed.v !== revision) reset = "source-replaced";
|
|
412
663
|
else { offset = Math.min(total, parsed.o); byte = offset < total ? parsed.b : 0; }
|
|
413
664
|
}
|
|
414
665
|
const maxBytes = Number.isFinite(request.maxBytes) && (request.maxBytes ?? -1) >= 0
|
|
415
666
|
? Math.floor(request.maxBytes as number)
|
|
416
667
|
: 2 * 1024;
|
|
417
|
-
const mint = (o: number, b: number) => encodeIncidentCursor({ o, b, v: revision, n: total, ...(resource !== undefined ? { r: resource } : {})
|
|
668
|
+
const mint = (o: number, b: number) => encodeIncidentCursor({ o, b, v: revision, n: total, ...(resource !== undefined ? { r: resource } : {}),
|
|
669
|
+
...(scope === "all" ? { h: 1 as const } : {}), ...(detail === "full" ? { f: 1 as const } : {}) });
|
|
418
670
|
const parts: string[] = [];
|
|
419
671
|
let used = 0;
|
|
420
672
|
let nextRow = offset;
|
|
@@ -460,11 +712,72 @@ export function pageFailureIncidents(state: FailureState, request: IncidentPageR
|
|
|
460
712
|
};
|
|
461
713
|
}
|
|
462
714
|
|
|
463
|
-
|
|
464
|
-
|
|
715
|
+
/** Cursor resource for one task/run's incidents in one session scope. */
|
|
716
|
+
export function incidentResource(scopeKey: string, id: string): string {
|
|
717
|
+
return `incidents:${scopeKey}:${id}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Heading for an explicit incident page. */
|
|
721
|
+
export function incidentPageHeading(total: number, scope: IncidentScope = "actionable"): string {
|
|
722
|
+
return scope === "all"
|
|
723
|
+
? `History page of ${total} failure observation${total === 1 ? "" : "s"} (all, including those that need no action).`
|
|
724
|
+
: `Incident page of ${total} active failure observation${total === 1 ? "" : "s"}.`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* One explicit incident page shaped as an envelope verbatim page (log-utils
|
|
729
|
+
* `VerbatimPage`): the shared body of every consumer's `incidentCursor`
|
|
730
|
+
* response. Omitted counts are rows, never bytes.
|
|
731
|
+
*/
|
|
732
|
+
export function incidentVerbatimPage(state: FailureState, request: IncidentPageRequest = {}): {
|
|
733
|
+
text: string;
|
|
734
|
+
hasMore: boolean;
|
|
735
|
+
cursor: string;
|
|
736
|
+
nextCursor: string;
|
|
737
|
+
omittedBytes: number;
|
|
738
|
+
omittedRows: number;
|
|
739
|
+
reset?: "stale-cursor" | "source-replaced";
|
|
740
|
+
} {
|
|
741
|
+
const page = pageFailureIncidents(state, request);
|
|
742
|
+
const scope = request.scope ?? incidentCursorScope(request.cursor) ?? "actionable";
|
|
743
|
+
const empty = scope === "all" ? "No failure observations recorded."
|
|
744
|
+
: [quietFailureLine(state) ? "No failures need action." : "No active failure observations.", historyCountsText(state) ? "Pass history:true to list history." : ""].filter(Boolean).join(" ");
|
|
745
|
+
return {
|
|
746
|
+
text: page.text || (page.total === 0 ? empty : ""),
|
|
747
|
+
hasMore: page.hasMore,
|
|
748
|
+
cursor: page.cursor,
|
|
749
|
+
nextCursor: page.nextCursor,
|
|
750
|
+
omittedBytes: 0,
|
|
751
|
+
omittedRows: page.omitted,
|
|
752
|
+
...(page.reset ? { reset: page.reset } : {}),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Cheap change fingerprint of a failure journal: the file's identity and size
|
|
758
|
+
* (or its read error), whether the durable existence marker is present, and
|
|
759
|
+
* how many records are still awaiting persistence in this process. Every
|
|
760
|
+
* journal append, truncation, deletion, or pending in-memory record changes
|
|
761
|
+
* it, so a cache keyed by it never serves stale incident counts.
|
|
762
|
+
*/
|
|
763
|
+
export function failureJournalFingerprint(path: string): string {
|
|
764
|
+
let file: string;
|
|
765
|
+
try {
|
|
766
|
+
const stats = statSync(path, { bigint: true });
|
|
767
|
+
file = `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
|
|
768
|
+
} catch (error) {
|
|
769
|
+
file = `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
|
|
770
|
+
}
|
|
771
|
+
return `${file}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export function incidentCursorAt(state: FailureState, offset: number, resource?: string, options: IncidentRowOptions = {}): string {
|
|
775
|
+
const scope = options.scope ?? "actionable";
|
|
776
|
+
const detail = options.detail ?? "compact";
|
|
777
|
+
const lines = formatFailureLines(state, { scope, detail });
|
|
465
778
|
const tag = resourceTag(resource);
|
|
466
|
-
return encodeIncidentCursor({ o: Math.max(0, Math.floor(offset)), b: 0, v: incidentRevision(state), n: lines.length,
|
|
467
|
-
...(tag !== undefined ? { r: tag } : {}) });
|
|
779
|
+
return encodeIncidentCursor({ o: Math.max(0, Math.floor(offset)), b: 0, v: incidentRevision(state, scope, detail), n: lines.length,
|
|
780
|
+
...(tag !== undefined ? { r: tag } : {}), ...(scope === "all" ? { h: 1 as const } : {}), ...(options.detail === "full" ? { f: 1 as const } : {}) });
|
|
468
781
|
}
|
|
469
782
|
|
|
470
783
|
const MIN_PARTIAL_ROW_BYTES = 96;
|
|
@@ -485,9 +798,28 @@ export interface IncidentSummary {
|
|
|
485
798
|
* total, fully shown, omitted, and the incident cursor that resumes at the
|
|
486
799
|
* first byte not shown. Counts survive any budget that fits the count line.
|
|
487
800
|
*/
|
|
488
|
-
export
|
|
801
|
+
export interface IncidentSummaryOptions {
|
|
802
|
+
maxBytes: number;
|
|
803
|
+
resource?: string;
|
|
804
|
+
retrieval?: string;
|
|
805
|
+
/** Row detail; cursors minted here page the same detail. Default compact. */
|
|
806
|
+
detail?: IncidentDetail;
|
|
807
|
+
}
|
|
808
|
+
export function formatIncidentSummary(state: FailureState, options: IncidentSummaryOptions): IncidentSummary {
|
|
489
809
|
const maxBytes = Math.max(0, Math.floor(options.maxBytes));
|
|
490
|
-
const
|
|
810
|
+
const summary = actionableIncidentSummary(state, options);
|
|
811
|
+
// Nothing needs action: one short line, no cursor. History is listed only on request.
|
|
812
|
+
if (summary.total === 0) {
|
|
813
|
+
const quiet = quietFailureLine(state);
|
|
814
|
+
return { text: quiet && utf8Length(quiet) <= maxBytes ? quiet : "", total: 0, represented: 0, omitted: 0 };
|
|
815
|
+
}
|
|
816
|
+
const tail = historyTailLine(state);
|
|
817
|
+
if (tail && summary.text && utf8Length(summary.text) + 1 + utf8Length(tail) <= maxBytes) return { ...summary, text: `${summary.text}\n${tail}` };
|
|
818
|
+
return summary;
|
|
819
|
+
}
|
|
820
|
+
function actionableIncidentSummary(state: FailureState, options: IncidentSummaryOptions): IncidentSummary {
|
|
821
|
+
const maxBytes = Math.max(0, Math.floor(options.maxBytes));
|
|
822
|
+
const whole = pageFailureIncidents(state, { maxBytes, resource: options.resource, detail: options.detail });
|
|
491
823
|
if (whole.total === 0) return { text: "", total: 0, represented: 0, omitted: 0 };
|
|
492
824
|
if (!whole.hasMore) return { text: whole.text, total: whole.total, represented: whole.represented, omitted: 0 };
|
|
493
825
|
const header = (page: FailureIncidentPage): string =>
|
|
@@ -495,11 +827,11 @@ export function formatIncidentSummary(state: FailureState, options: { maxBytes:
|
|
|
495
827
|
` · incidentCursor=${page.nextCursor}${options.retrieval ? ` (${options.retrieval})` : ""}`;
|
|
496
828
|
let rowBudget = maxBytes - utf8Length(header(whole)) - 1;
|
|
497
829
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
498
|
-
let page = pageFailureIncidents(state, { maxBytes: Math.max(0, rowBudget), resource: options.resource });
|
|
830
|
+
let page = pageFailureIncidents(state, { maxBytes: Math.max(0, rowBudget), resource: options.resource, detail: options.detail });
|
|
499
831
|
// A few bytes of a clipped row are noise; show the count line alone and
|
|
500
832
|
// let the cursor start at that row.
|
|
501
833
|
if (page.represented === 0 && page.endsPartial && utf8Length(page.text) < MIN_PARTIAL_ROW_BYTES) {
|
|
502
|
-
page = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource });
|
|
834
|
+
page = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource, detail: options.detail });
|
|
503
835
|
}
|
|
504
836
|
const line = header(page);
|
|
505
837
|
const text = page.text ? `${line}\n${page.text}` : line;
|
|
@@ -512,7 +844,7 @@ export function formatIncidentSummary(state: FailureState, options: { maxBytes:
|
|
|
512
844
|
}
|
|
513
845
|
// Not even the count line with its cursor fits. Never emit a clipped cursor:
|
|
514
846
|
// keep the exact counts and drop the cursor token whole, saying so.
|
|
515
|
-
const empty = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource });
|
|
847
|
+
const empty = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource, detail: options.detail });
|
|
516
848
|
const withCursor = header(empty);
|
|
517
849
|
if (utf8Length(withCursor) <= maxBytes) {
|
|
518
850
|
return { text: withCursor, total: empty.total, represented: 0, omitted: empty.total, nextCursor: empty.nextCursor };
|
|
@@ -532,23 +864,58 @@ export function formatIncidentSummary(state: FailureState, options: { maxBytes:
|
|
|
532
864
|
* observation-incomplete incidents (which lead the priority order) are shown as whole rows;
|
|
533
865
|
* unclassified, expected, and closed incidents are counts. When any active row is not shown, a
|
|
534
866
|
* count line gives the exact total, shown, omitted, and an incident cursor at the first unshown row.
|
|
867
|
+
*
|
|
868
|
+
* The result never exceeds `maxBytes` and is made of whole lines (#325). Under a tight budget it
|
|
869
|
+
* drops, in order: incident rows; the lower-priority notes (closed history, expected, earlier
|
|
870
|
+
* reported); the cursor's retrieval hint; the unclassified count; the cursor; the count line.
|
|
871
|
+
* The correctness note goes last, and only when it alone does not fit: it is never cut mid-sentence.
|
|
535
872
|
*/
|
|
536
|
-
export function formatTerminalIncidentSummary(state: FailureState, options:
|
|
873
|
+
export function formatTerminalIncidentSummary(state: FailureState, options: IncidentSummaryOptions): IncidentSummary {
|
|
537
874
|
const active = activeFailures(state);
|
|
538
|
-
|
|
539
|
-
const
|
|
875
|
+
// Counts, rows, and the cursor cover only incidents that need action; the rest is history.
|
|
876
|
+
const lines = formatFailureLines(state, { detail: options.detail });
|
|
877
|
+
const total = lines.length;
|
|
878
|
+
const parts = terminalFailureParts(state, active.map((x) => x.id));
|
|
879
|
+
const quiet = quietFailureLine(state);
|
|
880
|
+
// Nothing needs action: one short history line (no cursor) and, when tool failures stay
|
|
881
|
+
// unclassified, the correctness note.
|
|
882
|
+
const notes = total === 0 && quiet ? [quiet, ...parts.notes.filter((x) => x === CORRECTNESS_NOTE)] : parts.notes;
|
|
540
883
|
if (total === 0 && notes.length === 0) return { text: "", total: 0, represented: 0, omitted: 0 };
|
|
541
|
-
const reportable = active.filter((x) => requiresAction(x) || x.category === "observation-incomplete").length;
|
|
542
|
-
const lines = formatFailureLines(state).slice(0, reportable);
|
|
543
884
|
const maxBytes = Math.max(0, Math.floor(options.maxBytes));
|
|
544
|
-
const
|
|
885
|
+
const fits = (parts: readonly (string | undefined)[]) => utf8Length(parts.filter(Boolean).join("\n")) <= maxBytes;
|
|
886
|
+
// "full": cursor and retrieval hint; "bare": cursor only; "none": exact counts, no cursor.
|
|
887
|
+
type Header = "full" | "bare" | "none";
|
|
888
|
+
const header = (shown: number, mode: Header): string | undefined => shown >= total ? undefined :
|
|
545
889
|
`${total} active failure observation${total === 1 ? "" : "s"} · ${shown} shown · ${total - shown} omitted` +
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
890
|
+
(mode === "none" ? " · incident cursor not shown (page too small)"
|
|
891
|
+
: ` · incidentCursor=${incidentCursorAt(state, shown, options.resource, { detail: options.detail })}${mode === "full" && options.retrieval ? ` (${options.retrieval})` : ""}`);
|
|
892
|
+
const done = (parts: readonly (string | undefined)[], shown: number, mode: Header): IncidentSummary => {
|
|
893
|
+
const omitted = total - shown;
|
|
894
|
+
return { text: parts.filter((x): x is string => Boolean(x)).join("\n"), total, represented: shown, omitted,
|
|
895
|
+
...(omitted > 0 && mode !== "none" ? { nextCursor: incidentCursorAt(state, shown, options.resource, { detail: options.detail }) } : {}) };
|
|
896
|
+
};
|
|
897
|
+
// 1. Drop incident rows, lowest priority first, keeping every note and the count line with its cursor.
|
|
898
|
+
for (let shown = lines.length; shown >= 0; shown -= 1) {
|
|
899
|
+
const parts = [header(shown, "full"), ...lines.slice(0, shown), ...notes];
|
|
900
|
+
if (fits(parts)) return done(parts, shown, "full");
|
|
901
|
+
}
|
|
902
|
+
// 2. No rows. Then, in order: the lower-priority notes (history, expected, earlier reported), the
|
|
903
|
+
// cursor's retrieval hint, the unclassified count, the cursor, and the count line. The
|
|
904
|
+
// correctness note goes last, and only when it alone does not fit.
|
|
905
|
+
const correctness: string[] = notes.filter((x) => x === CORRECTNESS_NOTE);
|
|
906
|
+
const unclassified = notes.filter((x) => x !== CORRECTNESS_NOTE && /remains? unclassified\.$/.test(x));
|
|
907
|
+
const low = notes.filter((x) => !correctness.includes(x) && !unclassified.includes(x));
|
|
908
|
+
const ladder: Array<{ mode: Header | undefined; notes: string[] }> = [];
|
|
909
|
+
for (let keep = low.length - 1; keep >= 0; keep -= 1) ladder.push({ mode: "full", notes: [...low.slice(0, keep), ...unclassified, ...correctness] });
|
|
910
|
+
ladder.push({ mode: "bare", notes: [...unclassified, ...correctness] });
|
|
911
|
+
ladder.push({ mode: "bare", notes: correctness });
|
|
912
|
+
ladder.push({ mode: "none", notes: correctness });
|
|
913
|
+
ladder.push({ mode: undefined, notes: correctness });
|
|
914
|
+
for (const step of ladder) {
|
|
915
|
+
const parts = [step.mode ? header(0, step.mode) : undefined, ...step.notes];
|
|
916
|
+
if (parts.some(Boolean) && fits(parts)) return done(parts, 0, step.mode ?? "none");
|
|
917
|
+
}
|
|
918
|
+
return done([], 0, "none");
|
|
552
919
|
}
|
|
553
920
|
/**
|
|
554
921
|
* Incidents due for a notification. Terminal: every current unresolved incident not yet delivered
|