pi-better-subagents 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,8 @@
51
51
  "docs/agent-catalog-operations.md",
52
52
  "docs/agent-model-resolution.md",
53
53
  "docs/agent-catalog-lifecycle.md",
54
- "docs/agent-catalog-acceptance.md"
54
+ "docs/agent-catalog-acceptance.md",
55
+ "docs/failure-observations.md"
55
56
  ],
56
57
  "peerDependencies": {
57
58
  "@earendil-works/pi-ai": "*",
@@ -0,0 +1,106 @@
1
+ /** Optional sandbox-extension policy, mirrored per Pi event bus at launch time. */
2
+ export const SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
3
+ export const SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
4
+
5
+ type Access = "off" | "read" | "read-write";
6
+ export type PermissionProfile = Readonly<{
7
+ enabled: boolean;
8
+ projectFiles: Access;
9
+ outsideProject: Access;
10
+ storedCredentials: Access;
11
+ commands: boolean;
12
+ network: boolean;
13
+ }>;
14
+ export type PermissionSnapshot = Readonly<{
15
+ permissions?: PermissionProfile;
16
+ subagentPermissions?: PermissionProfile;
17
+ }>;
18
+
19
+ type EventBus = {
20
+ on(channel: string, handler: (data: unknown) => void): unknown;
21
+ emit(channel: string, data: unknown): void;
22
+ };
23
+ const mirrors = new WeakMap<EventBus, { policy?: PermissionSnapshot; error?: Error }>();
24
+
25
+ function busOf(pi: unknown): EventBus | undefined {
26
+ const bus = (pi as { events?: unknown } | undefined)?.events;
27
+ if (!bus || typeof bus !== "object") return undefined;
28
+ const candidate = bus as Partial<EventBus>;
29
+ return typeof candidate.on === "function" && typeof candidate.emit === "function" ? candidate as EventBus : undefined;
30
+ }
31
+
32
+ function profile(value: unknown): PermissionProfile {
33
+ if (!value || typeof value !== "object") throw new Error("Invalid sandbox permission profile.");
34
+ const p = value as Record<string, unknown>;
35
+ const access = (v: unknown): v is Access => v === "off" || v === "read" || v === "read-write";
36
+ if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" || typeof p.network !== "boolean" ||
37
+ !access(p.projectFiles) || !access(p.outsideProject) || !access(p.storedCredentials)) {
38
+ throw new Error("Invalid sandbox permission profile; update permissions in the sandbox UI.");
39
+ }
40
+ return Object.freeze({
41
+ enabled: p.enabled, commands: p.commands, network: p.network,
42
+ projectFiles: p.projectFiles, outsideProject: p.outsideProject, storedCredentials: p.storedCredentials,
43
+ });
44
+ }
45
+
46
+ function readPolicy(data: unknown): PermissionSnapshot | undefined {
47
+ if (!data || typeof data !== "object") return undefined;
48
+ const p = data as Record<string, unknown>;
49
+ if (!["inactive", "enabled", "disabled", "unavailable", "failed"].includes(String(p.state))) return undefined;
50
+ return Object.freeze({
51
+ ...(p.permissions === undefined ? {} : { permissions: profile(p.permissions) }),
52
+ ...(p.subagentPermissions === undefined ? {} : { subagentPermissions: profile(p.subagentPermissions) }),
53
+ });
54
+ }
55
+
56
+ /** Subscribe before launching; request a replay for either extension load order. */
57
+ export function observeSandboxPermissions(pi: unknown): void {
58
+ const bus = busOf(pi);
59
+ if (!bus || mirrors.has(bus)) return;
60
+ const mirror: { policy?: PermissionSnapshot; error?: Error } = {};
61
+ mirrors.set(bus, mirror);
62
+ bus.on(SANDBOX_POLICY_CHANNEL, (data) => {
63
+ try {
64
+ const next = readPolicy(data);
65
+ if (next) { mirror.policy = next; mirror.error = undefined; }
66
+ } catch (error) {
67
+ mirror.error = error as Error;
68
+ }
69
+ });
70
+ bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
71
+ }
72
+
73
+ export function currentSandboxPermissions(pi: unknown): PermissionSnapshot | undefined {
74
+ observeSandboxPermissions(pi);
75
+ const bus = busOf(pi);
76
+ if (!bus) return undefined;
77
+ bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
78
+ const mirror = mirrors.get(bus);
79
+ if (mirror?.error) throw mirror.error;
80
+ return mirror?.policy;
81
+ }
82
+
83
+ /** Resolve before allocating a run. Absent settings retain the legacy default-on policy. */
84
+ export function resolveSubagentPermissions(pi: unknown, requestedSandbox: boolean | undefined): {
85
+ sandboxEnabled: boolean;
86
+ enforced: boolean;
87
+ permissions?: Omit<PermissionProfile, "enabled">;
88
+ } {
89
+ const snapshot = currentSandboxPermissions(pi);
90
+ if (snapshot?.permissions?.enabled && !snapshot.permissions.commands) {
91
+ throw new Error("Main sandbox profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
92
+ }
93
+ const child = snapshot?.subagentPermissions;
94
+ if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false };
95
+ if (child.enabled && requestedSandbox === false) {
96
+ throw new Error("Subagent sandbox is enforced by the human-enabled profile; sandbox:false cannot bypass it. Change Subagents permissions in the sandbox UI.");
97
+ }
98
+ const sandboxEnabled = child.enabled || requestedSandbox === true;
99
+ if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false };
100
+ if (!child.commands) throw new Error("Subagents profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
101
+ if (!child.network) {
102
+ throw new Error("Subagents profile disables network, including model requests. Child provider isolation is not available; enable network in the sandbox UI before launching a subagent.");
103
+ }
104
+ const { enabled: _enabled, ...permissions } = child;
105
+ return { sandboxEnabled: true, enforced: true, permissions };
106
+ }
package/sandbox.ts CHANGED
@@ -10,8 +10,10 @@
10
10
  * is passed through unchanged.
11
11
  */
12
12
 
13
+ import type { PermissionProfile } from "./permission-policy.ts";
13
14
  import {
14
15
  buildSandboxCommand as buildSharedSandboxCommand,
16
+ compileWritePolicy,
15
17
  maybeBuildSandboxCommand as maybeBuildSharedSandboxCommand,
16
18
  sandboxSupported as sharedSandboxSupported,
17
19
  type SandboxCommand,
@@ -25,19 +27,34 @@ type SandboxCommandArgs = {
25
27
  home: string;
26
28
  piBin: string;
27
29
  piArgs: string[];
30
+ permissions?: Omit<PermissionProfile, "enabled">;
31
+ denyWrite?: readonly string[];
32
+ runtimeDir?: string;
28
33
  };
29
34
 
30
35
  /** Map the subagent's single-writable-directory shape onto the shared policy. */
31
36
  function sharedArgs(args: SandboxCommandArgs): SharedSandboxCommandArgs {
32
37
  return {
33
38
  profilePath: args.profilePath,
34
- // Subagents have no write-deny list: the run directory is the policy.
35
- policy: { writableRoot: args.writableDir, home: args.home },
36
- execPath: args.piBin,
37
- execArgs: args.piArgs,
39
+ // Parent-owned run artifacts must never be writable by the child.
40
+ policy: { writableRoot: args.writableDir, home: args.home,
41
+ ...(args.permissions ? { permissions: args.permissions } : {}),
42
+ ...(args.denyWrite ? { denyWrite: args.denyWrite } : {}),
43
+ ...(args.runtimeDir ? { runtimeWrite: [args.runtimeDir] } : {}),
44
+ },
45
+ execPath: args.runtimeDir ? "/usr/bin/env" : args.piBin,
46
+ execArgs: args.runtimeDir
47
+ ? [`TMPDIR=${args.runtimeDir}`, `TMP=${args.runtimeDir}`, `TEMP=${args.runtimeDir}`, args.piBin, ...args.piArgs]
48
+ : args.piArgs,
38
49
  };
39
50
  }
40
51
 
52
+ function assertPermissionCore(args: SandboxCommandArgs): void {
53
+ if (args.permissions && !("permissions" in compileWritePolicy(sharedArgs(args).policy))) {
54
+ throw new Error("Permission-aware sandbox core is unavailable; update the sandbox packages before launching a subagent.");
55
+ }
56
+ }
57
+
41
58
  /** True when an OS write-sandbox backend can be applied on this platform. */
42
59
  export function sandboxSupported(): boolean {
43
60
  return sharedSandboxSupported();
@@ -55,9 +72,12 @@ export function maybeBuildSandboxCommand(
55
72
  // `sandbox:false` is this surface's opt-out, and the only one its operator
56
73
  // has: a subagent has no slash commands. A caller that states its own remedy
57
74
  // keeps it.
75
+ assertPermissionCore(args);
58
76
  return maybeBuildSharedSandboxCommand(sharedArgs(args), {
59
77
  ...request,
60
- remedy: request.remedy ?? "Pass sandbox:false to run this subagent unconfined.",
78
+ remedy: request.remedy ?? (args.permissions
79
+ ? "Change the Subagents permissions in /sandbox."
80
+ : "Pass sandbox:false to run this subagent unconfined."),
61
81
  });
62
82
  }
63
83
 
@@ -67,5 +87,6 @@ export function maybeBuildSandboxCommand(
67
87
  * bypass the request-policy helper above.
68
88
  */
69
89
  export function buildSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
90
+ assertPermissionCore(args);
70
91
  return buildSharedSandboxCommand(sharedArgs(args));
71
92
  }
@@ -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
+ }