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/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
- const policy = readPolicy(data);
161
- if (policy) mirror.policy = policy;
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
- return mirrors.get(events)?.policy;
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
- return planFor(currentForegroundSandboxPolicy(pi));
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
- return { confined: true, writableRoot: policy.writableRoot, denyWrite: policy.denyWrite };
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 true;
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
- invokeDelivered(item.event, deliveredAt);
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 true;
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
- invokeDelivered(event, Date.now());
252
+ const acknowledged = acknowledge();
227
253
  urgentInFlight.delete(key);
228
- return true;
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: "suppressed", reason: "durable delivery state could not be verified" };
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: "suppressed", reason: "callback ownership could not be verified" };
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
- ): void {
299
- try { event.onDelivered?.(at); } catch { /* handoff already succeeded */ }
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
+ }