pi-better-background-tasks 0.2.16 → 0.2.18
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 +40 -0
- package/package.json +1 -1
- package/src/conditions.ts +7 -0
- package/src/failures.ts +99 -0
- package/src/maintenance.ts +2 -0
- package/src/navigator-provider.ts +9 -3
- package/src/runtime.ts +76 -21
- package/src/sandbox.ts +74 -14
- package/src/shared-callback-batcher.ts +42 -16
- package/src/shared-failure-observations.ts +205 -0
- package/src/shared-sandbox-core.ts +309 -14
- package/src/tools.ts +30 -9
package/src/sandbox.ts
CHANGED
|
@@ -29,7 +29,15 @@ import { dirname } from "node:path";
|
|
|
29
29
|
|
|
30
30
|
import { commandExecution } from "./process.js";
|
|
31
31
|
import { baseDir } from "./registry.js";
|
|
32
|
-
import { maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
|
|
32
|
+
import { compileWritePolicy, maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
|
|
33
|
+
|
|
34
|
+
type SandboxPermissions = {
|
|
35
|
+
projectFiles: "off" | "read" | "read-write";
|
|
36
|
+
outsideProject: "off" | "read" | "read-write";
|
|
37
|
+
storedCredentials: "off" | "read" | "read-write";
|
|
38
|
+
commands: boolean;
|
|
39
|
+
network: boolean;
|
|
40
|
+
};
|
|
33
41
|
import type { CommandSpec } from "./types.js";
|
|
34
42
|
|
|
35
43
|
/**
|
|
@@ -70,6 +78,7 @@ export interface ForegroundSandboxPolicy {
|
|
|
70
78
|
readonly denyWrite: readonly string[];
|
|
71
79
|
/** Human-readable evidence for why `state` is what it is. */
|
|
72
80
|
readonly reason: string;
|
|
81
|
+
readonly permissions?: Readonly<SandboxPermissions & { enabled: boolean }>;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/** The minimum `pi.events` surface this module uses. */
|
|
@@ -98,6 +107,7 @@ export type ForegroundSandboxPlan =
|
|
|
98
107
|
readonly confined: true;
|
|
99
108
|
readonly writableRoot: string;
|
|
100
109
|
readonly denyWrite: readonly string[];
|
|
110
|
+
readonly permissions?: SandboxPermissions;
|
|
101
111
|
};
|
|
102
112
|
|
|
103
113
|
const UNCONFINED: ForegroundSandboxPlan = { confined: false };
|
|
@@ -111,7 +121,7 @@ const VALID_STATES = new Set<string>(["inactive", "enabled", "disabled", "unavai
|
|
|
111
121
|
* sessions inside one process (and separate tests) cannot read each other's
|
|
112
122
|
* policy.
|
|
113
123
|
*/
|
|
114
|
-
const mirrors = new WeakMap<PolicyEventBus, { policy: ForegroundSandboxPolicy | undefined }>();
|
|
124
|
+
const mirrors = new WeakMap<PolicyEventBus, { policy: ForegroundSandboxPolicy | undefined; error?: Error }>();
|
|
115
125
|
|
|
116
126
|
function eventBusOf(pi: unknown): PolicyEventBus | undefined {
|
|
117
127
|
const events = (pi as { events?: unknown } | undefined)?.events;
|
|
@@ -134,14 +144,34 @@ function readPolicy(data: unknown): ForegroundSandboxPolicy | undefined {
|
|
|
134
144
|
const denyWrite = Array.isArray(value.denyWrite)
|
|
135
145
|
? value.denyWrite.filter((entry): entry is string => typeof entry === "string")
|
|
136
146
|
: [];
|
|
147
|
+
const permissions = value.permissions === undefined ? undefined : readPermissions(value.permissions);
|
|
148
|
+
if (value.permissions !== undefined && !permissions) {
|
|
149
|
+
throw new Error("Invalid Main sandbox permission profile; update permissions in the sandbox UI.");
|
|
150
|
+
}
|
|
137
151
|
return {
|
|
138
152
|
state: value.state as ForegroundSandboxState,
|
|
139
153
|
writableRoot,
|
|
140
154
|
denyWrite: Object.freeze([...denyWrite]),
|
|
141
155
|
reason: typeof value.reason === "string" ? value.reason : "No reason was published.",
|
|
156
|
+
...(permissions ? { permissions } : {}),
|
|
142
157
|
};
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
function readPermissions(value: unknown): ForegroundSandboxPolicy["permissions"] | undefined {
|
|
161
|
+
if (!value || typeof value !== "object") return undefined;
|
|
162
|
+
const p = value as Record<string, unknown>;
|
|
163
|
+
const access = (v: unknown) => v === "off" || v === "read" || v === "read-write";
|
|
164
|
+
if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" ||
|
|
165
|
+
typeof p.network !== "boolean" || !access(p.projectFiles) ||
|
|
166
|
+
!access(p.outsideProject) || !access(p.storedCredentials)) return undefined;
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
enabled: p.enabled, commands: p.commands, network: p.network,
|
|
169
|
+
projectFiles: p.projectFiles as SandboxPermissions["projectFiles"],
|
|
170
|
+
outsideProject: p.outsideProject as SandboxPermissions["outsideProject"],
|
|
171
|
+
storedCredentials: p.storedCredentials as SandboxPermissions["storedCredentials"],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
145
175
|
/**
|
|
146
176
|
* Start mirroring foreground policy on this session's event bus.
|
|
147
177
|
*
|
|
@@ -154,11 +184,15 @@ export function observeForegroundSandboxPolicy(pi: unknown): void {
|
|
|
154
184
|
const events = eventBusOf(pi);
|
|
155
185
|
if (!events || mirrors.has(events)) return;
|
|
156
186
|
|
|
157
|
-
const mirror: { policy: ForegroundSandboxPolicy | undefined } = { policy: undefined };
|
|
187
|
+
const mirror: { policy: ForegroundSandboxPolicy | undefined; error?: Error } = { policy: undefined };
|
|
158
188
|
mirrors.set(events, mirror);
|
|
159
189
|
events.on(FOREGROUND_SANDBOX_POLICY_CHANNEL, (data) => {
|
|
160
|
-
|
|
161
|
-
|
|
190
|
+
try {
|
|
191
|
+
const policy = readPolicy(data);
|
|
192
|
+
if (policy) { mirror.policy = policy; mirror.error = undefined; }
|
|
193
|
+
} catch (error) {
|
|
194
|
+
mirror.error = error as Error;
|
|
195
|
+
}
|
|
162
196
|
});
|
|
163
197
|
events.emit(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
164
198
|
}
|
|
@@ -177,7 +211,9 @@ export function currentForegroundSandboxPolicy(pi: unknown): ForegroundSandboxPo
|
|
|
177
211
|
const events = eventBusOf(pi);
|
|
178
212
|
if (!events) return undefined;
|
|
179
213
|
events.emit(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
180
|
-
|
|
214
|
+
const mirror = mirrors.get(events);
|
|
215
|
+
if (mirror?.error) throw mirror.error;
|
|
216
|
+
return mirror?.policy;
|
|
181
217
|
}
|
|
182
218
|
|
|
183
219
|
/**
|
|
@@ -187,8 +223,16 @@ export function currentForegroundSandboxPolicy(pi: unknown): ForegroundSandboxPo
|
|
|
187
223
|
* Throws for every state that is neither confinable nor intentionally
|
|
188
224
|
* unconfined, which keeps a blocked launch from leaving task state behind.
|
|
189
225
|
*/
|
|
190
|
-
export function resolveForegroundSandboxPlan(pi: unknown): ForegroundSandboxPlan {
|
|
191
|
-
|
|
226
|
+
export function resolveForegroundSandboxPlan(pi: unknown, remote = false): ForegroundSandboxPlan {
|
|
227
|
+
const policy = currentForegroundSandboxPolicy(pi);
|
|
228
|
+
if (remote && !policy?.permissions) return UNCONFINED; // Preserve legacy SSH behavior.
|
|
229
|
+
const plan = planFor(policy);
|
|
230
|
+
if (remote && plan.confined) {
|
|
231
|
+
throw new ForegroundSandboxBlockedError(policy!, !plan.permissions?.network
|
|
232
|
+
? "Main profile disables network. SSH launches are blocked."
|
|
233
|
+
: "Structured SSH cannot apply local file and credential permissions yet. Use SSH through confined bash, or change Main permissions in /sandbox.");
|
|
234
|
+
}
|
|
235
|
+
return plan;
|
|
192
236
|
}
|
|
193
237
|
|
|
194
238
|
/** The plan for one already-read policy. Exposed for tests and reuse. */
|
|
@@ -196,11 +240,22 @@ export function planFor(policy: ForegroundSandboxPolicy | undefined): Foreground
|
|
|
196
240
|
// No sandbox extension is publishing: this package is installed on its own and
|
|
197
241
|
// keeps its historical unsandboxed behaviour.
|
|
198
242
|
if (!policy) return UNCONFINED;
|
|
243
|
+
if (policy.permissions?.enabled && !policy.permissions.commands) {
|
|
244
|
+
throw new ForegroundSandboxBlockedError(policy, "Main profile disables commands. Change permissions in the sandbox UI before launching background tasks.");
|
|
245
|
+
}
|
|
199
246
|
if (policy.state === "inactive" || policy.state === "disabled") return UNCONFINED;
|
|
200
247
|
if (policy.state !== "enabled" || !policy.writableRoot) {
|
|
201
248
|
throw new ForegroundSandboxBlockedError(policy);
|
|
202
249
|
}
|
|
203
|
-
|
|
250
|
+
const permissions = policy.permissions?.enabled ? {
|
|
251
|
+
projectFiles: policy.permissions.projectFiles,
|
|
252
|
+
outsideProject: policy.permissions.outsideProject,
|
|
253
|
+
storedCredentials: policy.permissions.storedCredentials,
|
|
254
|
+
commands: policy.permissions.commands,
|
|
255
|
+
network: policy.permissions.network,
|
|
256
|
+
} : undefined;
|
|
257
|
+
return { confined: true, writableRoot: policy.writableRoot, denyWrite: policy.denyWrite,
|
|
258
|
+
...(permissions ? { permissions } : {}) };
|
|
204
259
|
}
|
|
205
260
|
|
|
206
261
|
/**
|
|
@@ -245,16 +300,21 @@ export function confineCommandSpec(
|
|
|
245
300
|
const controlPlane = baseDir();
|
|
246
301
|
mkdirSync(controlPlane, { recursive: true });
|
|
247
302
|
const denyWrite = [...plan.denyWrite, controlPlane];
|
|
303
|
+
const policy = {
|
|
304
|
+
writableRoot: plan.writableRoot,
|
|
305
|
+
denyWrite,
|
|
306
|
+
home: homedir(),
|
|
307
|
+
...(plan.permissions ? { permissions: plan.permissions } : {}),
|
|
308
|
+
};
|
|
248
309
|
let command;
|
|
249
310
|
try {
|
|
311
|
+
if (plan.permissions && !("permissions" in compileWritePolicy(policy))) {
|
|
312
|
+
throw new Error("permission-aware sandbox core is unavailable; update the sandbox packages before launching");
|
|
313
|
+
}
|
|
250
314
|
command = maybeBuildSandboxCommand(
|
|
251
315
|
{
|
|
252
316
|
profilePath,
|
|
253
|
-
policy
|
|
254
|
-
writableRoot: plan.writableRoot,
|
|
255
|
-
denyWrite,
|
|
256
|
-
home: homedir(),
|
|
257
|
-
},
|
|
317
|
+
policy,
|
|
258
318
|
execPath,
|
|
259
319
|
execArgs,
|
|
260
320
|
},
|
|
@@ -105,6 +105,7 @@ export function createCallbackBatcher(
|
|
|
105
105
|
const pending = new Map<string, PendingEvent>();
|
|
106
106
|
const inFlight = new Set<string>();
|
|
107
107
|
const urgentInFlight = new Set<string>();
|
|
108
|
+
const handedOff = new Map<string, number>();
|
|
108
109
|
let sequence = 0;
|
|
109
110
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
111
|
let flushPromise: Promise<boolean> | undefined;
|
|
@@ -140,9 +141,25 @@ export function createCallbackBatcher(
|
|
|
140
141
|
for (const [key] of snapshot) inFlight.add(key);
|
|
141
142
|
|
|
142
143
|
const deliverable: Array<[string, PendingEvent]> = [];
|
|
144
|
+
let deferred = false;
|
|
143
145
|
for (const item of snapshot) {
|
|
144
146
|
const [key, pendingEvent] = item;
|
|
147
|
+
const priorHandoff = handedOff.get(key);
|
|
148
|
+
if (priorHandoff !== undefined) {
|
|
149
|
+
if (!invokeDelivered(pendingEvent.event, priorHandoff)) {
|
|
150
|
+
deferred = true;
|
|
151
|
+
pending.set(key, pendingEvent);
|
|
152
|
+
}
|
|
153
|
+
inFlight.delete(key);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
145
156
|
const disposition = eventDisposition(pendingEvent.event);
|
|
157
|
+
if (disposition.kind === "deferred") {
|
|
158
|
+
deferred = true;
|
|
159
|
+
pending.set(key, pendingEvent);
|
|
160
|
+
inFlight.delete(key);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
146
163
|
if (disposition.kind === "delivered") {
|
|
147
164
|
inFlight.delete(key);
|
|
148
165
|
continue;
|
|
@@ -156,8 +173,8 @@ export function createCallbackBatcher(
|
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
if (deliverable.length === 0) {
|
|
159
|
-
if (pending.size > 0) schedule(windowMs);
|
|
160
|
-
return
|
|
176
|
+
if (pending.size > 0) schedule(deferred ? retryMs : windowMs);
|
|
177
|
+
return !deferred;
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
try {
|
|
@@ -183,11 +200,15 @@ export function createCallbackBatcher(
|
|
|
183
200
|
|
|
184
201
|
const deliveredAt = Date.now();
|
|
185
202
|
for (const [key, item] of deliverable) {
|
|
186
|
-
|
|
203
|
+
handedOff.set(key, deliveredAt);
|
|
204
|
+
if (!invokeDelivered(item.event, deliveredAt)) {
|
|
205
|
+
deferred = true;
|
|
206
|
+
pending.set(key, item);
|
|
207
|
+
}
|
|
187
208
|
inFlight.delete(key);
|
|
188
209
|
}
|
|
189
|
-
if (pending.size > 0) schedule(windowMs);
|
|
190
|
-
return
|
|
210
|
+
if (pending.size > 0) schedule(deferred ? retryMs : windowMs);
|
|
211
|
+
return !deferred;
|
|
191
212
|
};
|
|
192
213
|
|
|
193
214
|
const flush = (): Promise<boolean> => {
|
|
@@ -201,7 +222,15 @@ export function createCallbackBatcher(
|
|
|
201
222
|
const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
|
|
202
223
|
const key = eventKey(event);
|
|
203
224
|
if (urgentInFlight.has(key)) return false;
|
|
225
|
+
const priorHandoff = handedOff.get(key);
|
|
226
|
+
if (priorHandoff !== undefined) return invokeDelivered(event, priorHandoff);
|
|
227
|
+
const acknowledge = (): boolean => {
|
|
228
|
+
const at = Date.now();
|
|
229
|
+
handedOff.set(key, at);
|
|
230
|
+
return invokeDelivered(event, at);
|
|
231
|
+
};
|
|
204
232
|
const disposition = eventDisposition(event);
|
|
233
|
+
if (disposition.kind === "deferred") return false;
|
|
205
234
|
if (disposition.kind === "delivered") return true;
|
|
206
235
|
if (disposition.kind === "suppressed") {
|
|
207
236
|
invokeSuppressed(event, disposition.reason, Date.now());
|
|
@@ -216,16 +245,13 @@ export function createCallbackBatcher(
|
|
|
216
245
|
);
|
|
217
246
|
if (isPromiseLike(handoff)) {
|
|
218
247
|
return Promise.resolve(handoff).then(
|
|
219
|
-
() =>
|
|
220
|
-
invokeDelivered(event, Date.now());
|
|
221
|
-
return true;
|
|
222
|
-
},
|
|
248
|
+
() => acknowledge(),
|
|
223
249
|
() => false,
|
|
224
250
|
).finally(() => urgentInFlight.delete(key));
|
|
225
251
|
}
|
|
226
|
-
|
|
252
|
+
const acknowledged = acknowledge();
|
|
227
253
|
urgentInFlight.delete(key);
|
|
228
|
-
return
|
|
254
|
+
return acknowledged;
|
|
229
255
|
} catch {
|
|
230
256
|
urgentInFlight.delete(key);
|
|
231
257
|
return false;
|
|
@@ -278,25 +304,25 @@ function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">):
|
|
|
278
304
|
|
|
279
305
|
function eventDisposition(
|
|
280
306
|
event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
|
|
281
|
-
): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
|
|
307
|
+
): | { kind: "deliver" } | { kind: "delivered" } | { kind: "deferred" } | { kind: "suppressed"; reason: string } {
|
|
282
308
|
try {
|
|
283
309
|
if (event.isDelivered?.()) return { kind: "delivered" };
|
|
284
310
|
} catch {
|
|
285
|
-
return { kind: "
|
|
311
|
+
return { kind: "deferred" };
|
|
286
312
|
}
|
|
287
313
|
try {
|
|
288
314
|
const reason = event.getSuppressionReason?.();
|
|
289
315
|
return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
|
|
290
316
|
} catch {
|
|
291
|
-
return { kind: "
|
|
317
|
+
return { kind: "deferred" };
|
|
292
318
|
}
|
|
293
319
|
}
|
|
294
320
|
|
|
295
321
|
function invokeDelivered(
|
|
296
322
|
event: Pick<CallbackBatchEvent, "onDelivered">,
|
|
297
323
|
at: number,
|
|
298
|
-
):
|
|
299
|
-
try { event.onDelivered?.(at); } catch {
|
|
324
|
+
): boolean {
|
|
325
|
+
try { event.onDelivered?.(at); return true; } catch { return false; }
|
|
300
326
|
}
|
|
301
327
|
|
|
302
328
|
function invokeSuppressed(
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Generated from packages/failure-observations/index.ts. Do not edit directly.
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export interface FailureEvent {
|
|
7
|
+
/** Stable source event identity: replay must reuse this id. */
|
|
8
|
+
id: string;
|
|
9
|
+
operation: string;
|
|
10
|
+
kind: "failure" | "recovered" | "incomplete" | "delivered";
|
|
11
|
+
/** Event time when known; never fabricate it from log mtime. */
|
|
12
|
+
at?: number;
|
|
13
|
+
summary?: string;
|
|
14
|
+
category?: string;
|
|
15
|
+
evidence?: string;
|
|
16
|
+
expected?: boolean;
|
|
17
|
+
/** Recovery/delivery must name the incidents it resolves/delivers. */
|
|
18
|
+
incidents?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface FailureObservation {
|
|
21
|
+
id: string;
|
|
22
|
+
operation: string;
|
|
23
|
+
status: "unresolved" | "expected" | "resolved";
|
|
24
|
+
category: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
evidence?: string;
|
|
27
|
+
firstObservedAt: number;
|
|
28
|
+
lastObservedAt: number;
|
|
29
|
+
/** Journal order breaks timestamp ties without inventing an event time. */
|
|
30
|
+
lastSequence?: number;
|
|
31
|
+
at?: number;
|
|
32
|
+
count: number;
|
|
33
|
+
resolvedAt?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface FailureState {
|
|
36
|
+
version: 1;
|
|
37
|
+
seen: string[];
|
|
38
|
+
observations: Record<string, FailureObservation>;
|
|
39
|
+
delivered: Record<string, number>;
|
|
40
|
+
resolved?: Record<string, number>;
|
|
41
|
+
}
|
|
42
|
+
export function emptyFailureState(): FailureState {
|
|
43
|
+
return { version: 1, seen: [], observations: {}, delivered: {} };
|
|
44
|
+
}
|
|
45
|
+
export function failureIdentity(...parts: unknown[]): string {
|
|
46
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
|
|
47
|
+
}
|
|
48
|
+
function text(value: string | undefined, fallback: string): string {
|
|
49
|
+
return (value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400);
|
|
50
|
+
}
|
|
51
|
+
/** Pure transition. Lifecycle is deliberately not an input or an output. */
|
|
52
|
+
export function reduceFailure(state: FailureState, event: FailureEvent, observedAt: number): FailureState {
|
|
53
|
+
if (state.seen.includes(event.id)) return state;
|
|
54
|
+
const next: FailureState = { ...state, seen: [...state.seen, event.id],
|
|
55
|
+
observations: { ...state.observations }, delivered: { ...state.delivered } };
|
|
56
|
+
if (event.kind === "delivered") {
|
|
57
|
+
for (const id of event.incidents ?? []) next.delivered = { ...next.delivered, [id]: observedAt };
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
const key = failureIdentity(event.operation);
|
|
61
|
+
const previous = state.observations[key];
|
|
62
|
+
if (event.kind === "recovered") {
|
|
63
|
+
if (previous && event.incidents?.includes(previous.id)) {
|
|
64
|
+
next.observations[key] = { ...previous, status: "resolved", resolvedAt: event.at ?? observedAt };
|
|
65
|
+
next.resolved = { ...state.resolved, [previous.id]: event.at ?? observedAt };
|
|
66
|
+
}
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
const active = previous && previous.status !== "resolved" &&
|
|
70
|
+
!(previous.status === "expected" && !event.expected);
|
|
71
|
+
next.observations[key] = {
|
|
72
|
+
id: active ? previous.id : event.id, operation: event.operation,
|
|
73
|
+
status: active ? previous.status : event.expected ? "expected" : "unresolved",
|
|
74
|
+
category: event.kind === "incomplete" ? "observation-incomplete" : event.category ?? "operation",
|
|
75
|
+
summary: text(event.summary, "Operation failed"), evidence: event.evidence,
|
|
76
|
+
firstObservedAt: active ? previous.firstObservedAt : observedAt,
|
|
77
|
+
lastObservedAt: observedAt, lastSequence: next.seen.length, at: event.at,
|
|
78
|
+
count: active ? previous.count + 1 : 1,
|
|
79
|
+
};
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
export function activeFailures(state: FailureState): FailureObservation[] {
|
|
83
|
+
const priority = (x: FailureObservation) => x.status === "expected" ? 2 : x.category === "observation-incomplete" ? 0 : 1;
|
|
84
|
+
return Object.values(state.observations).filter((x) => x.status !== "resolved")
|
|
85
|
+
.sort((a, b) => priority(a) - priority(b) || (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt);
|
|
86
|
+
}
|
|
87
|
+
/** Shared priority text, placed BEFORE assistant progress on every consumer surface. */
|
|
88
|
+
export function formatFailureSummary(state: FailureState): string {
|
|
89
|
+
const failures = activeFailures(state);
|
|
90
|
+
if (!failures.length) return "";
|
|
91
|
+
const rows = failures.slice(0, 5).map((x) => {
|
|
92
|
+
const label = x.status === "expected" ? "Expected failure" :
|
|
93
|
+
x.category === "observation-incomplete" ? "Observation incomplete" : "Unresolved failure";
|
|
94
|
+
const time = x.at === undefined ? `observed ${new Date(x.firstObservedAt).toISOString()}` : new Date(x.at).toISOString();
|
|
95
|
+
return `${label} · ${time} · ${x.summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${x.evidence ? ` · evidence: ${text(x.evidence, "")}` : ""}`;
|
|
96
|
+
});
|
|
97
|
+
if (failures.length > 5) rows.push(`${failures.length - 5} additional active failure observations retained in the failure journal.`);
|
|
98
|
+
return rows.join("\n");
|
|
99
|
+
}
|
|
100
|
+
export function pendingFailureAttention(state: FailureState, now: number, options: { terminal?: boolean; graceMs?: number } = {}): { key: string; incidents: string[]; summary: string } | undefined {
|
|
101
|
+
const due = activeFailures(state).filter((x) => x.status === "unresolved" && !Object.hasOwn(state.delivered, x.id) &&
|
|
102
|
+
(options.terminal || x.category === "observation-incomplete" || now - x.firstObservedAt >= (options.graceMs ?? 60_000)));
|
|
103
|
+
if (!due.length) return undefined;
|
|
104
|
+
const incidents = due.map((x) => x.id).sort();
|
|
105
|
+
return { key: failureIdentity(incidents), incidents,
|
|
106
|
+
summary: due.map((x) => x.summary).join("; ").slice(0, 800) };
|
|
107
|
+
}
|
|
108
|
+
function storageProblem(state: FailureState, summary: string): FailureState {
|
|
109
|
+
return reduceFailure(state, { id: failureIdentity("storage", summary), operation: "failure-observation-storage",
|
|
110
|
+
kind: "incomplete", summary }, Date.now());
|
|
111
|
+
}
|
|
112
|
+
/** Append-only journal: individual bounded writes avoid lost read/modify/write snapshots. */
|
|
113
|
+
interface PendingRecord { event: FailureEvent; observedAt: number }
|
|
114
|
+
const pendingWrites = new Map<string, PendingRecord[]>();
|
|
115
|
+
const knownJournals = new Map<string, number>();
|
|
116
|
+
function appendRecord(path: string, record: PendingRecord): void {
|
|
117
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
118
|
+
// A durable existence marker distinguishes a lost journal from a run that has
|
|
119
|
+
// never observed a failure, including after a process restart.
|
|
120
|
+
try { closeSync(openSync(`${path}.observed`, "wx", 0o600)); }
|
|
121
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
122
|
+
appendFileSync(path, "\n" + JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
123
|
+
}
|
|
124
|
+
/** Retry unpersisted evidence and receipts on every observation/read. Pending
|
|
125
|
+
* receipts count as handed off in this process, preventing notification storms. */
|
|
126
|
+
export function readFailureState(path: string): FailureState {
|
|
127
|
+
const pending = pendingWrites.get(path);
|
|
128
|
+
while (pending?.length) {
|
|
129
|
+
try { appendRecord(path, pending[0]!); pending.shift(); }
|
|
130
|
+
catch { break; }
|
|
131
|
+
}
|
|
132
|
+
if (!pending?.length) pendingWrites.delete(path);
|
|
133
|
+
let state = readStoredState(path);
|
|
134
|
+
const remaining = pendingWrites.get(path);
|
|
135
|
+
for (const record of remaining ?? []) state = reduceFailure(state, record.event, record.observedAt);
|
|
136
|
+
return remaining?.length ? storageProblem(state, "Failure evidence could not be persisted") : state;
|
|
137
|
+
}
|
|
138
|
+
function readStoredState(path: string): FailureState {
|
|
139
|
+
let source: string;
|
|
140
|
+
try { source = readFileSync(path, "utf8"); }
|
|
141
|
+
catch (error) {
|
|
142
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && !knownJournals.has(path) && !existsSync(`${path}.observed`)) return emptyFailureState();
|
|
143
|
+
return storageProblem(emptyFailureState(), "Failure journal could not be read");
|
|
144
|
+
}
|
|
145
|
+
const bytes = Buffer.byteLength(source);
|
|
146
|
+
const truncated = bytes < (knownJournals.get(path) ?? 0);
|
|
147
|
+
if (bytes || knownJournals.has(path)) knownJournals.set(path, bytes);
|
|
148
|
+
let state = truncated ? storageProblem(emptyFailureState(), "Failure journal was truncated; observations may be incomplete") : emptyFailureState();
|
|
149
|
+
for (const line of source.split("\n")) {
|
|
150
|
+
if (!line) continue;
|
|
151
|
+
try {
|
|
152
|
+
const row = JSON.parse(line);
|
|
153
|
+
const e = row.event;
|
|
154
|
+
if (!e || typeof e.id !== "string" || !e.id || typeof e.operation !== "string" || !e.operation ||
|
|
155
|
+
(e.expected !== undefined && typeof e.expected !== "boolean") ||
|
|
156
|
+
!["failure", "incomplete", "recovered", "delivered"].includes(e.kind) ||
|
|
157
|
+
!Number.isFinite(row.observedAt) || Math.abs(row.observedAt) > 8.64e15 ||
|
|
158
|
+
(e.at !== undefined && (!Number.isFinite(e.at) || Math.abs(e.at) > 8.64e15)) ||
|
|
159
|
+
(e.incidents !== undefined && (!Array.isArray(e.incidents) || !e.incidents.every((id: unknown) => typeof id === "string"))) ||
|
|
160
|
+
[e.summary, e.category, e.evidence].some((v) => v !== undefined && typeof v !== "string")) throw new Error("invalid record");
|
|
161
|
+
state = reduceFailure(state, e, row.observedAt);
|
|
162
|
+
} catch { state = storageProblem(state, "Failure journal contains unreadable records; observations may be incomplete"); }
|
|
163
|
+
}
|
|
164
|
+
if (truncated) {
|
|
165
|
+
const summary = "Failure journal was truncated; observations may be incomplete";
|
|
166
|
+
const record: PendingRecord = { event: { id: failureIdentity("storage", summary), operation: "failure-observation-storage", kind: "incomplete", summary }, observedAt: Date.now() };
|
|
167
|
+
try { appendRecord(path, record); }
|
|
168
|
+
catch { pendingWrites.set(path, [...(pendingWrites.get(path) ?? []), record]); }
|
|
169
|
+
}
|
|
170
|
+
return state;
|
|
171
|
+
}
|
|
172
|
+
export function observeFailures(path: string, events: readonly FailureEvent[], now = Date.now()): FailureState {
|
|
173
|
+
let state = readFailureState(path);
|
|
174
|
+
for (const raw of events) {
|
|
175
|
+
if (state.seen.includes(raw.id)) continue;
|
|
176
|
+
if (raw.kind === "recovered") {
|
|
177
|
+
const prior = state.observations[failureIdentity(raw.operation)];
|
|
178
|
+
if (!prior || prior.status === "resolved" || !raw.incidents?.includes(prior.id)) continue;
|
|
179
|
+
}
|
|
180
|
+
const event = { ...raw, ...(raw.summary ? { summary: text(raw.summary, "") } : {}),
|
|
181
|
+
...(raw.evidence ? { evidence: text(raw.evidence, "") } : {}) };
|
|
182
|
+
try {
|
|
183
|
+
if (pendingWrites.has(path)) throw new Error("Earlier evidence is awaiting persistence");
|
|
184
|
+
appendRecord(path, { event, observedAt: now });
|
|
185
|
+
state = reduceFailure(state, event, now);
|
|
186
|
+
} catch {
|
|
187
|
+
const pending = pendingWrites.get(path) ?? [];
|
|
188
|
+
pending.push({ event, observedAt: now });
|
|
189
|
+
pendingWrites.set(path, pending);
|
|
190
|
+
state = storageProblem(reduceFailure(state, event, now), "Failure evidence could not be persisted");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return state;
|
|
194
|
+
}
|
|
195
|
+
export function failureAttentionHandled(state: FailureState, incidents: readonly string[]): boolean {
|
|
196
|
+
return incidents.every((id) => {
|
|
197
|
+
if (Object.hasOwn(state.delivered, id) || Object.hasOwn(state.resolved ?? {}, id)) return true;
|
|
198
|
+
const observation = Object.values(state.observations).find((item) => item.id === id);
|
|
199
|
+
if (!observation) throw new Error("Failure incident evidence is unavailable; defer notification delivery");
|
|
200
|
+
return observation.status !== "unresolved";
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
export function markFailureAttentionDelivered(path: string, pending: { key: string; incidents: string[] }, at = Date.now()): FailureState {
|
|
204
|
+
return observeFailures(path, [{ id: `delivered:${pending.key}`, operation: "attention-delivery", kind: "delivered", incidents: pending.incidents }], at);
|
|
205
|
+
}
|