@felan-ai/felan 0.21.16 → 0.22.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.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/dist/dependencies.js +1 -1
  3. package/dist/dependencies.js.map +1 -1
  4. package/dist/extensions.d.ts +1 -1
  5. package/dist/extensions.d.ts.map +1 -1
  6. package/dist/extensions.js +2 -2
  7. package/dist/extensions.js.map +1 -1
  8. package/dist/herdr.d.ts +22 -0
  9. package/dist/herdr.d.ts.map +1 -0
  10. package/dist/herdr.js +208 -0
  11. package/dist/herdr.js.map +1 -0
  12. package/dist/insights.d.ts +1 -1
  13. package/dist/insights.d.ts.map +1 -1
  14. package/dist/insights.js +11 -8
  15. package/dist/insights.js.map +1 -1
  16. package/dist/memory/control.d.ts +1 -1
  17. package/dist/memory/control.d.ts.map +1 -1
  18. package/dist/memory/control.js +90 -34
  19. package/dist/memory/control.js.map +1 -1
  20. package/dist/memory/coordinator.d.ts +13 -2
  21. package/dist/memory/coordinator.d.ts.map +1 -1
  22. package/dist/memory/coordinator.js +416 -67
  23. package/dist/memory/coordinator.js.map +1 -1
  24. package/dist/memory/dreamer.d.ts +7 -1
  25. package/dist/memory/dreamer.d.ts.map +1 -1
  26. package/dist/memory/dreamer.js +145 -59
  27. package/dist/memory/dreamer.js.map +1 -1
  28. package/dist/memory/history-view.d.ts +26 -0
  29. package/dist/memory/history-view.d.ts.map +1 -0
  30. package/dist/memory/history-view.js +192 -0
  31. package/dist/memory/history-view.js.map +1 -0
  32. package/dist/memory/history.d.ts +33 -0
  33. package/dist/memory/history.d.ts.map +1 -0
  34. package/dist/memory/history.js +314 -0
  35. package/dist/memory/history.js.map +1 -0
  36. package/dist/memory/run-retention.d.ts +12 -0
  37. package/dist/memory/run-retention.d.ts.map +1 -0
  38. package/dist/memory/run-retention.js +294 -0
  39. package/dist/memory/run-retention.js.map +1 -0
  40. package/dist/memory/run.d.ts +67 -0
  41. package/dist/memory/run.d.ts.map +1 -0
  42. package/dist/memory/run.js +417 -0
  43. package/dist/memory/run.js.map +1 -0
  44. package/dist/memory/store.d.ts +67 -3
  45. package/dist/memory/store.d.ts.map +1 -1
  46. package/dist/memory/store.js +517 -110
  47. package/dist/memory/store.js.map +1 -1
  48. package/dist/resume.d.ts.map +1 -1
  49. package/dist/resume.js +12 -7
  50. package/dist/resume.js.map +1 -1
  51. package/dist/runtime.d.ts +1 -0
  52. package/dist/runtime.d.ts.map +1 -1
  53. package/dist/runtime.js +27 -18
  54. package/dist/runtime.js.map +1 -1
  55. package/dist/session-picker.d.ts +8 -1
  56. package/dist/session-picker.d.ts.map +1 -1
  57. package/dist/session-picker.js +7 -4
  58. package/dist/session-picker.js.map +1 -1
  59. package/dist/settings.d.ts +0 -3
  60. package/dist/settings.d.ts.map +1 -1
  61. package/dist/settings.js +0 -22
  62. package/dist/settings.js.map +1 -1
  63. package/dist/subagents/agent-transcript.d.ts +4 -1
  64. package/dist/subagents/agent-transcript.d.ts.map +1 -1
  65. package/dist/subagents/agent-transcript.js +16 -4
  66. package/dist/subagents/agent-transcript.js.map +1 -1
  67. package/package.json +24 -23
@@ -1,11 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, rm, writeFile, lstat } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { createEmptyMemoryArtifact, createMemoryProjectionSnapshot, createMemorySnapshot, hydrateMemoryDirectory, memoryArtifactFingerprint, readMemoryDirectory, } from '@felan-ai/ext-memory';
5
6
  import { localMemoryProjectDirectory } from './project.js';
6
7
  import { acquireLocalMemoryLease } from './lease.js';
7
8
  import { withLocalFileLock } from '../lock.js';
8
9
  const STATE_VERSION = 1;
10
+ const MAX_CONTROL_BYTES = 64 * 1_024;
11
+ export const MEMORY_ATTEMPT_TIMEOUT_MS = 60 * 60 * 1_000;
12
+ export const MEMORY_RETRY_DELAYS_MS = [60_000, 300_000];
13
+ export class LocalMemoryControlError extends Error {
14
+ constructor(path) {
15
+ super(`Local memory control is malformed or unsupported; processing is blocked: ${path}`);
16
+ this.name = 'LocalMemoryControlError';
17
+ }
18
+ }
9
19
  export class LocalMemoryStore {
10
20
  agentDir;
11
21
  project;
@@ -13,8 +23,10 @@ export class LocalMemoryStore {
13
23
  currentDirectory;
14
24
  stagingDirectory;
15
25
  statePath;
26
+ controlPath;
16
27
  projectionName = '.memory';
17
28
  #memoryPath;
29
+ #retryDelaysMs;
18
30
  constructor(agentDir, project, options = {}) {
19
31
  this.agentDir = agentDir;
20
32
  this.project = project;
@@ -22,32 +34,41 @@ export class LocalMemoryStore {
22
34
  this.currentDirectory = join(this.projectDirectory, 'current');
23
35
  this.stagingDirectory = join(this.projectDirectory, 'staging');
24
36
  this.statePath = join(this.projectDirectory, 'state.json');
37
+ this.controlPath = join(this.projectDirectory, 'control.json');
25
38
  this.#memoryPath = options.memoryPath ?? '.memory';
39
+ this.#retryDelaysMs = options.retryDelaysMs ?? MEMORY_RETRY_DELAYS_MS;
40
+ if (this.#retryDelaysMs.length !== 2 || this.#retryDelaysMs.some((delay) => !Number.isFinite(delay) || delay < 0)) {
41
+ throw new Error('Memory retry delays must be two non-negative finite durations');
42
+ }
26
43
  }
27
- async initialize() {
44
+ async initialize(now = Date.now()) {
28
45
  await mkdir(this.projectDirectory, { recursive: true, mode: 0o700 });
29
46
  await mkdir(this.stagingDirectory, { recursive: true, mode: 0o700 });
30
47
  const recoveryLease = await acquireLocalMemoryLease(this.projectDirectory);
31
48
  try {
32
49
  await withStateLock(this, async () => {
50
+ if (!(await readControl(this.controlPath)))
51
+ await writeControl(this.controlPath, emptyControl(now));
33
52
  if (recoveryLease)
34
- await recoverTransaction(this);
53
+ await recoverTransaction(this, recoveryLease, now);
35
54
  const current = await safeLstat(this.currentDirectory);
36
55
  if (current?.isSymbolicLink())
37
56
  throw new Error('Canonical memory current directory cannot be a symlink');
38
57
  if (!current) {
58
+ await verifyInitializationLease(recoveryLease);
39
59
  await hydrateMemoryDirectory(createEmptyMemoryArtifact(this.#memoryPath), this.currentDirectory);
40
60
  }
41
61
  const artifact = await readMemoryDirectory(this.currentDirectory, { memoryPath: this.#memoryPath, mode: 'read' });
42
62
  const fingerprint = memoryArtifactFingerprint(artifact);
43
63
  const existing = await readState(this.statePath);
44
64
  if (!existing || existing.memoryFingerprint !== fingerprint) {
65
+ await verifyInitializationLease(recoveryLease);
45
66
  await writeState(this.statePath, {
46
67
  version: STATE_VERSION,
47
68
  memoryFingerprint: fingerprint,
48
69
  pending: existing?.pending ?? {},
49
70
  processed: existing?.processed ?? {},
50
- updatedAt: new Date().toISOString(),
71
+ updatedAt: new Date(now).toISOString(),
51
72
  });
52
73
  }
53
74
  });
@@ -77,27 +98,137 @@ export class LocalMemoryStore {
77
98
  if (sameCursor(previousPending?.checkpoint, checkpoint) || sameCursor(previousProcessed?.checkpoint, checkpoint)) {
78
99
  return false;
79
100
  }
101
+ const recordedAt = new Date().toISOString();
80
102
  const pending = {
81
103
  ...state.pending,
82
104
  [checkpoint.sessionId]: {
83
105
  checkpoint,
84
- recordedAt: new Date().toISOString(),
106
+ recordedAt,
107
+ acceptedUpdates: previousPending === undefined ? 1 : acceptedCheckpointUpdates(previousPending) + 1,
85
108
  },
86
109
  };
87
- await writeState(this.statePath, { ...state, pending, updatedAt: new Date().toISOString() });
110
+ await writeState(this.statePath, { ...state, pending, updatedAt: recordedAt });
88
111
  return true;
89
112
  });
90
113
  }
91
114
  async status() {
92
115
  return this.readState();
93
116
  }
94
- async processingSnapshot(maxSessions = 8) {
117
+ async pendingAcceptedUpdates(include = () => true) {
118
+ const state = await this.readState();
119
+ return acceptedCheckpointUpdatesFor(Object.values(state.pending).filter(({ checkpoint }) => include(checkpoint)));
120
+ }
121
+ async readControl() {
122
+ return await readControl(this.controlPath) ?? emptyControl(Date.now());
123
+ }
124
+ async beginAttempt(lease, input, now = Date.now()) {
125
+ return withStateLock(this, async () => {
126
+ await verifyLease(lease);
127
+ const control = await this.readControl();
128
+ if (control.autoDisabled || control.activeAttempt
129
+ || (!input.bypassRetryAt && control.nextRetryAt && Date.parse(control.nextRetryAt) > now)
130
+ || input.checkpoints.length === 0)
131
+ return undefined;
132
+ const attempt = {
133
+ runId: input.runId,
134
+ generation: control.generation,
135
+ leaseToken: lease.token,
136
+ startedAt: new Date(now).toISOString(),
137
+ deadlineAt: input.deadlineAt ?? new Date(now + MEMORY_ATTEMPT_TIMEOUT_MS).toISOString(),
138
+ baseFingerprint: input.baseFingerprint,
139
+ checkpoints: structuredClone(input.checkpoints),
140
+ };
141
+ if (!isMemoryAttempt(attempt))
142
+ throw new Error('Invalid memory attempt');
143
+ const state = await this.readState();
144
+ if (state.memoryFingerprint !== input.baseFingerprint
145
+ || await fingerprintAt(this.currentDirectory, this.memoryPath) !== input.baseFingerprint) {
146
+ throw new Error('Memory changed before the attempt started');
147
+ }
148
+ if (input.checkpoints.some((entry) => !sameStoredCheckpoint(state.pending[entry.checkpoint.sessionId], entry))) {
149
+ return undefined;
150
+ }
151
+ await verifyLease(lease);
152
+ await writeControl(this.controlPath, { ...control, activeAttempt: attempt, updatedAt: attempt.startedAt });
153
+ return attempt;
154
+ });
155
+ }
156
+ async finishAttempt(lease, attempt, outcome, now = Date.now()) {
157
+ return withStateLock(this, async () => {
158
+ if (outcome.status !== 'cancelled') {
159
+ await verifyLease(lease);
160
+ await recoverTransaction(this, lease, now);
161
+ }
162
+ const control = await withPublicationEvidence(this, await this.readControl());
163
+ if (!sameAttempt(control.activeAttempt, attempt))
164
+ return control;
165
+ if (outcome.status !== 'cancelled' && control.activeAttempt.leaseToken !== lease.token) {
166
+ throw new Error('Memory attempt belongs to a different writer lease');
167
+ }
168
+ if (outcome.status === 'success' && !control.activeAttempt.published) {
169
+ throw new Error('Memory attempt has not published successfully');
170
+ }
171
+ const next = finishControl(control, outcome, now, this.#retryDelaysMs);
172
+ if (outcome.status !== 'cancelled')
173
+ await verifyLease(lease);
174
+ await writeControl(this.controlPath, next);
175
+ return next;
176
+ });
177
+ }
178
+ async resetProjectControl(now = Date.now(), isLocallyEligible = () => true) {
179
+ return withStateLock(this, async () => {
180
+ const control = await withPublicationEvidence(this, await this.readControl());
181
+ const finished = finishControl(control, { status: 'cancelled' }, now, this.#retryDelaysMs);
182
+ const lastDisabledRunId = control.lastDisabledRunId ?? control.autoDisabled?.runId;
183
+ const next = {
184
+ ...emptyControl(now),
185
+ generation: control.generation + 1,
186
+ ...(finished.lastOutcome ? { lastOutcome: finished.lastOutcome } : {}),
187
+ ...(lastDisabledRunId ? { lastDisabledRunId } : {}),
188
+ };
189
+ if (!isLocallyEligible())
190
+ throw new Error('Memory retry was cancelled');
191
+ await writeControl(this.controlPath, next);
192
+ if (!isLocallyEligible()) {
193
+ await writeControl(this.controlPath, control);
194
+ throw new Error('Memory retry was cancelled');
195
+ }
196
+ return next;
197
+ });
198
+ }
199
+ async cancelActiveAttempt(now = Date.now()) {
200
+ return withStateLock(this, async () => {
201
+ const control = await withPublicationEvidence(this, await this.readControl());
202
+ if (!control.activeAttempt)
203
+ return control;
204
+ const next = finishControl(control, { status: 'cancelled' }, now, this.#retryDelaysMs);
205
+ await writeControl(this.controlPath, next);
206
+ return next;
207
+ });
208
+ }
209
+ async reconcileAbandonedAttempt(lease, now = Date.now()) {
210
+ return withStateLock(this, async () => {
211
+ await verifyLease(lease);
212
+ await recoverTransaction(this, lease, now);
213
+ const control = await withPublicationEvidence(this, await this.readControl());
214
+ const active = control.activeAttempt;
215
+ if (!active || (active.leaseToken === lease.token && !active.published && Date.parse(active.deadlineAt) > now)) {
216
+ return control;
217
+ }
218
+ const next = finishControl(control, { status: 'cancelled', reason: 'Memory writer was interrupted' }, now, this.#retryDelaysMs);
219
+ await verifyLease(lease);
220
+ await writeControl(this.controlPath, next);
221
+ return next;
222
+ });
223
+ }
224
+ async processingSnapshot(maxSessions = 8, include = () => true) {
95
225
  return withStateLock(this, async () => {
96
226
  const state = await this.readState();
97
227
  const artifact = await readMemoryDirectory(this.currentDirectory, { memoryPath: this.#memoryPath, mode: 'read' });
98
228
  const fingerprint = memoryArtifactFingerprint(artifact);
99
229
  const checkpoints = Object.values(state.pending)
100
230
  .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt))
231
+ .filter(({ checkpoint }) => include(checkpoint))
101
232
  .slice(0, maxSessions);
102
233
  return { artifact, fingerprint, checkpoints, state };
103
234
  });
@@ -108,7 +239,7 @@ export class LocalMemoryStore {
108
239
  await mkdir(path, { recursive: true, mode: 0o700 });
109
240
  return path;
110
241
  }
111
- async commit(lease, baseFingerprint, artifact, processed) {
242
+ async commit(lease, baseFingerprint, artifact, processed, attempt) {
112
243
  if (!(await lease.verify()))
113
244
  throw new Error('Memory writer lease was lost before commit');
114
245
  const fingerprint = memoryArtifactFingerprint(artifact);
@@ -117,67 +248,60 @@ export class LocalMemoryStore {
117
248
  const previousMemory = join(staging, 'previous');
118
249
  const journalPath = join(staging, 'commit.json');
119
250
  await hydrateMemoryDirectory(artifact, stagedMemory, { memoryPath: this.#memoryPath });
120
- await writeFile(journalPath, `${JSON.stringify({
121
- version: 1,
251
+ const journal = {
252
+ version: 2,
253
+ phase: 'prepared',
122
254
  baseFingerprint,
123
255
  fingerprint,
124
- processed: processed.map(({ checkpoint }) => checkpoint.sessionId),
125
- })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
126
- if (!(await lease.verify()))
127
- throw new Error('Memory writer lease was lost before replacement');
128
- try {
129
- await withStateLock(this, async () => {
130
- const current = await readMemoryDirectory(this.currentDirectory, { memoryPath: this.#memoryPath, mode: 'read' });
131
- if (memoryArtifactFingerprint(current) !== baseFingerprint) {
132
- throw new Error('Memory changed while the dream was running');
133
- }
134
- if (!(await lease.verify()))
135
- throw new Error('Memory writer lease was lost before replacement');
256
+ checkpoints: structuredClone(processed),
257
+ ...(attempt ? { attempt: { ...attempt, leaseToken: lease.token } } : {}),
258
+ };
259
+ await withStateLock(this, async () => {
260
+ const control = await this.readControl();
261
+ assertPublicationAllowed(control, attempt, lease.token);
262
+ if (attempt && (control.activeAttempt.baseFingerprint !== baseFingerprint
263
+ || !sameCheckpoints(control.activeAttempt.checkpoints, processed))) {
264
+ throw new Error('Memory publication does not match the active attempt input');
265
+ }
266
+ const current = await readMemoryDirectory(this.currentDirectory, { memoryPath: this.#memoryPath, mode: 'read' });
267
+ if (memoryArtifactFingerprint(current) !== baseFingerprint) {
268
+ throw new Error('Memory changed while the dream was running');
269
+ }
270
+ const state = await this.readState();
271
+ if (state.memoryFingerprint !== baseFingerprint)
272
+ throw new Error('Memory state changed while the dream was running');
273
+ await verifyLease(lease);
274
+ await writeJson(journalPath, journal);
275
+ await verifyLease(lease);
276
+ {
136
277
  await rename(this.currentDirectory, previousMemory);
278
+ const at = new Date().toISOString();
137
279
  try {
138
280
  await rename(stagedMemory, this.currentDirectory);
139
281
  if (!(await lease.verify()))
140
282
  throw new Error('Memory writer lease was lost after replacement');
141
- const state = await this.readState();
142
- if (!(await lease.verify()))
143
- throw new Error('Memory writer lease was lost before state commit');
144
- if (state.memoryFingerprint !== baseFingerprint)
145
- throw new Error('Memory state changed while the dream was running');
146
- const processedAt = new Date().toISOString();
147
- const pending = { ...state.pending };
148
- const nextProcessed = { ...state.processed };
149
- for (const entry of processed) {
150
- const currentPending = pending[entry.checkpoint.sessionId];
151
- if (!currentPending || !sameCursor(currentPending.checkpoint, entry.checkpoint))
152
- continue;
153
- delete pending[entry.checkpoint.sessionId];
154
- nextProcessed[entry.checkpoint.sessionId] = {
155
- ...entry,
156
- processedAt,
157
- memoryFingerprint: fingerprint,
158
- };
159
- }
160
- await writeState(this.statePath, {
161
- version: STATE_VERSION,
162
- memoryFingerprint: fingerprint,
163
- pending,
164
- processed: nextProcessed,
165
- updatedAt: processedAt,
166
- });
283
+ // The journal proves publication before any pending evidence is acknowledged.
284
+ await writeJson(journalPath, { ...journal, phase: 'published', publishedAt: at });
285
+ await verifyLease(lease);
286
+ await writeState(this.statePath, acknowledgedState(state, fingerprint, processed, at));
167
287
  }
168
288
  catch (error) {
169
- const replacement = await safeLstat(this.currentDirectory);
170
- if (replacement)
171
- await rm(this.currentDirectory, { recursive: true, force: true });
172
- await rename(previousMemory, this.currentDirectory).catch(() => { });
289
+ await writeJson(journalPath, { ...journal, phase: 'rolled_back' });
290
+ await restorePrevious(this, previousMemory, baseFingerprint);
291
+ await rm(staging, { recursive: true, force: true });
173
292
  throw error;
174
293
  }
175
- });
176
- }
177
- catch (error) {
178
- throw error;
179
- }
180
- await rm(previousMemory, { recursive: true, force: true });
294
+ // Once state is committed, a control-write failure must not roll canonical memory back.
295
+ if (attempt) {
296
+ await verifyLease(lease);
297
+ await writeControl(this.controlPath, {
298
+ ...control,
299
+ activeAttempt: { ...control.activeAttempt, published: { fingerprint, at } },
300
+ updatedAt: at,
301
+ });
302
+ }
303
+ }
304
+ });
181
305
  await rm(staging, { recursive: true, force: true });
182
306
  return fingerprint;
183
307
  }
@@ -195,61 +319,93 @@ export class LocalMemoryStore {
195
319
  return state;
196
320
  }
197
321
  }
198
- async function recoverTransaction(store) {
199
- const entries = await safeReadDir(store.stagingDirectory);
200
- for (const entry of entries) {
201
- const path = join(store.stagingDirectory, entry);
322
+ async function recoverTransaction(store, lease, now) {
323
+ await verifyLease(lease);
324
+ await store.readControl();
325
+ const staging = await realDirectory(store.stagingDirectory, 'Memory staging directory is unsafe; recovery is blocked');
326
+ for (const transactionDirectory of await transactionDirectories(store.stagingDirectory)) {
327
+ const path = transactionDirectory.path;
202
328
  const journalPath = join(path, 'commit.json');
203
- const journal = await readJson(journalPath);
329
+ const journal = await readBoundedRecord(journalPath, MAX_CONTROL_BYTES, 'Memory commit journal is unsafe; recovery is blocked');
204
330
  if (!journal) {
331
+ await verifyLease(lease);
332
+ await verifyDirectory(staging, store.stagingDirectory, 'Memory staging directory changed; recovery is blocked');
333
+ await verifyDirectory(transactionDirectory, path, 'Memory transaction directory changed; recovery is blocked');
205
334
  await rm(path, { recursive: true, force: true });
206
335
  continue;
207
336
  }
337
+ if (!isLegacyCommitJournal(journal) && !isCommitJournal(journal)) {
338
+ throw new Error('Memory commit journal is malformed or unsupported; recovery is blocked');
339
+ }
208
340
  const current = await safeLstat(store.currentDirectory);
341
+ if (current && (!current.isDirectory() || current.isSymbolicLink())) {
342
+ throw new Error('Canonical memory current directory is unsafe; recovery is blocked');
343
+ }
209
344
  const memory = join(path, 'memory');
210
345
  const previous = join(path, 'previous');
211
- if (current && typeof journal.fingerprint === 'string'
212
- && await fingerprintAt(store.currentDirectory, store.memoryPath) === journal.fingerprint) {
213
- await applyRecoveredStateUnlocked(store, journal.fingerprint, journal.processed);
214
- await rm(path, { recursive: true, force: true });
215
- continue;
346
+ await optionalRealDirectory(memory, 'Staged memory directory is unsafe; recovery is blocked');
347
+ await optionalRealDirectory(previous, 'Previous memory directory is unsafe; recovery is blocked');
348
+ await verifyLease(lease);
349
+ if (journal.version === 1) {
350
+ // Legacy journals have only session IDs and cannot prove which cursor was processed.
351
+ if (!current && await safeLstat(previous))
352
+ await rename(previous, store.currentDirectory);
216
353
  }
217
- if (!current && await safeLstat(previous)) {
218
- await rename(previous, store.currentDirectory);
354
+ else {
355
+ const transaction = journal;
356
+ const currentFingerprint = current
357
+ ? await fingerprintAt(store.currentDirectory, store.memoryPath)
358
+ : undefined;
359
+ const state = await readState(store.statePath);
360
+ if (!state)
361
+ throw new Error('Memory state is missing during commit recovery');
362
+ if (currentFingerprint === transaction.fingerprint
363
+ && hasAcknowledgedCommit(state, transaction.fingerprint, transaction.checkpoints)) {
364
+ // Durable state acknowledgement is the only proof that publication completed.
365
+ }
366
+ else if (!current || (currentFingerprint === transaction.fingerprint
367
+ && transaction.fingerprint !== transaction.baseFingerprint)) {
368
+ await restorePrevious(store, previous, transaction.baseFingerprint);
369
+ }
219
370
  }
371
+ await verifyLease(lease);
372
+ await verifyDirectory(staging, store.stagingDirectory, 'Memory staging directory changed; recovery is blocked');
373
+ await verifyDirectory(transactionDirectory, path, 'Memory transaction directory changed; recovery is blocked');
220
374
  await rm(path, { recursive: true, force: true });
221
- void memory;
222
375
  }
223
376
  }
224
- async function applyRecoveredStateUnlocked(store, fingerprint, sessionIds) {
225
- if (!Array.isArray(sessionIds))
226
- return;
227
- const state = await readState(store.statePath);
228
- if (!state)
229
- return;
377
+ function acknowledgedState(state, fingerprint, checkpoints, processedAt) {
230
378
  const pending = { ...state.pending };
231
379
  const processed = { ...state.processed };
232
- const processedAt = new Date().toISOString();
233
- for (const sessionId of sessionIds) {
234
- if (typeof sessionId !== 'string')
235
- continue;
236
- const entry = pending[sessionId];
237
- if (!entry)
238
- continue;
239
- delete pending[sessionId];
380
+ for (const entry of checkpoints) {
381
+ const sessionId = entry.checkpoint.sessionId;
382
+ const current = pending[sessionId];
383
+ if (current !== undefined) {
384
+ const remaining = acceptedCheckpointUpdates(current) - acceptedCheckpointUpdates(entry);
385
+ if (remaining > 0)
386
+ pending[sessionId] = { ...current, acceptedUpdates: remaining };
387
+ else
388
+ delete pending[sessionId];
389
+ }
240
390
  processed[sessionId] = { ...entry, processedAt, memoryFingerprint: fingerprint };
241
391
  }
242
- await writeState(store.statePath, { ...state, memoryFingerprint: fingerprint, pending, processed, updatedAt: processedAt });
392
+ return { ...state, memoryFingerprint: fingerprint, pending, processed, updatedAt: processedAt };
243
393
  }
244
- async function withStateLock(store, operation) {
245
- await mkdir(dirname(store.statePath), { recursive: true });
246
- try {
247
- await writeFile(store.statePath, '{}', { flag: 'wx', mode: 0o600 });
394
+ async function restorePrevious(store, previous, baseFingerprint) {
395
+ const previousDirectory = await realDirectory(previous, 'Previous canonical memory is unsafe; recovery is blocked');
396
+ if (await fingerprintAt(previous, store.memoryPath) !== baseFingerprint) {
397
+ throw new Error('Previous canonical memory is missing or changed; recovery is blocked');
248
398
  }
249
- catch (error) {
250
- if (!isAlreadyExists(error))
251
- throw error;
399
+ const current = await safeLstat(store.currentDirectory);
400
+ if (current && (!current.isDirectory() || current.isSymbolicLink())) {
401
+ throw new Error('Canonical memory current directory is unsafe; recovery is blocked');
252
402
  }
403
+ await verifyDirectory(previousDirectory, previous, 'Previous canonical memory changed; recovery is blocked');
404
+ await rm(store.currentDirectory, { recursive: true, force: true });
405
+ await rename(previous, store.currentDirectory);
406
+ }
407
+ async function withStateLock(store, operation) {
408
+ await mkdir(dirname(store.statePath), { recursive: true });
253
409
  return withLocalFileLock(store.statePath, {
254
410
  realpath: false,
255
411
  retries: { retries: 20, minTimeout: 10, maxTimeout: 100 },
@@ -263,16 +419,25 @@ function operationWithLock(operation) {
263
419
  };
264
420
  }
265
421
  async function writeState(path, state) {
422
+ await writeJson(path, state);
423
+ }
424
+ async function writeJson(path, value) {
266
425
  const temporary = `${path}.${randomUUID()}.tmp`;
267
- await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
268
- await rename(temporary, path);
426
+ try {
427
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
428
+ await rename(temporary, path);
429
+ }
430
+ finally {
431
+ await rm(temporary, { force: true });
432
+ }
269
433
  }
270
434
  async function readState(path) {
271
435
  try {
272
436
  const value = JSON.parse(await readFile(path, 'utf8'));
273
437
  if (!isRecord(value) || value.version !== STATE_VERSION || typeof value.memoryFingerprint !== 'string'
274
- || !isRecord(value.pending) || !isRecord(value.processed) || typeof value.updatedAt !== 'string')
275
- return undefined;
438
+ || !isRecord(value.pending) || !isRecord(value.processed) || typeof value.updatedAt !== 'string') {
439
+ throw new Error('Local memory state is malformed or unsupported; processing is blocked');
440
+ }
276
441
  return {
277
442
  version: STATE_VERSION,
278
443
  memoryFingerprint: value.memoryFingerprint,
@@ -297,28 +462,77 @@ async function fingerprintAt(path, memoryPath) {
297
462
  throw error;
298
463
  }
299
464
  }
300
- async function readJson(path) {
465
+ async function transactionDirectories(path) {
301
466
  try {
302
- const value = JSON.parse(await readFile(path, 'utf8'));
303
- return isRecord(value) ? value : undefined;
467
+ const entries = await readdir(path, { withFileTypes: true });
468
+ const directories = [];
469
+ for (const entry of entries) {
470
+ const entryPath = join(path, entry.name);
471
+ const stat = await lstat(entryPath);
472
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !stat.isDirectory() || stat.isSymbolicLink()) {
473
+ throw new Error('Memory staging contains an unsafe transaction entry; recovery is blocked');
474
+ }
475
+ directories.push({ path: entryPath, stat });
476
+ }
477
+ return directories;
304
478
  }
305
479
  catch (error) {
306
480
  if (isMissing(error))
307
- return undefined;
308
- return undefined;
481
+ return [];
482
+ throw error;
309
483
  }
310
484
  }
311
- async function safeReadDir(path) {
485
+ async function readBoundedRecord(path, maxBytes, unsafeMessage) {
486
+ let file;
312
487
  try {
313
- const { readdir } = await import('node:fs/promises');
314
- return await readdir(path);
488
+ file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
315
489
  }
316
490
  catch (error) {
317
491
  if (isMissing(error))
318
- return [];
319
- throw error;
492
+ return undefined;
493
+ throw new Error(unsafeMessage, { cause: error });
494
+ }
495
+ try {
496
+ const stat = await file.stat();
497
+ if (!stat.isFile() || stat.size > maxBytes)
498
+ throw new Error(unsafeMessage);
499
+ const bytes = Buffer.alloc(maxBytes + 1);
500
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
501
+ if (bytesRead > maxBytes)
502
+ throw new Error(unsafeMessage);
503
+ try {
504
+ const value = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'));
505
+ if (!isRecord(value))
506
+ throw new Error(unsafeMessage);
507
+ return value;
508
+ }
509
+ catch (error) {
510
+ if (error instanceof Error && error.message === unsafeMessage)
511
+ throw error;
512
+ throw new Error(unsafeMessage, { cause: error });
513
+ }
514
+ }
515
+ finally {
516
+ await file.close();
320
517
  }
321
518
  }
519
+ async function realDirectory(path, message) {
520
+ const stat = await safeLstat(path);
521
+ if (!stat?.isDirectory() || stat.isSymbolicLink())
522
+ throw new Error(message);
523
+ return { path, stat };
524
+ }
525
+ async function optionalRealDirectory(path, message) {
526
+ const stat = await safeLstat(path);
527
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
528
+ throw new Error(message);
529
+ }
530
+ async function verifyDirectory(expected, path, message) {
531
+ const actual = await safeLstat(path);
532
+ if (!actual?.isDirectory() || actual.isSymbolicLink()
533
+ || actual.dev !== expected.stat.dev || actual.ino !== expected.stat.ino)
534
+ throw new Error(message);
535
+ }
322
536
  async function safeLstat(path) {
323
537
  try {
324
538
  return await lstat(path);
@@ -332,12 +546,205 @@ async function safeLstat(path) {
332
546
  function sameCursor(left, right) {
333
547
  return left?.leafId === right.leafId && left?.transcriptDigest === right.transcriptDigest;
334
548
  }
549
+ function acceptedCheckpointUpdates(entry) {
550
+ return entry?.acceptedUpdates ?? 1;
551
+ }
552
+ function acceptedCheckpointUpdatesFor(entries) {
553
+ let total = 0;
554
+ for (const entry of entries)
555
+ total += acceptedCheckpointUpdates(entry);
556
+ return total;
557
+ }
558
+ function sameStoredCheckpoint(left, right) {
559
+ return left?.recordedAt === right.recordedAt && left.checkpoint.sessionId === right.checkpoint.sessionId
560
+ && left.checkpoint.sessionFile === right.checkpoint.sessionFile && sameCursor(left.checkpoint, right.checkpoint);
561
+ }
562
+ function sameCheckpoints(left, right) {
563
+ return left.length === right.length && left.every((entry, index) => sameStoredCheckpoint(right[index], entry));
564
+ }
565
+ function hasAcknowledgedCommit(state, fingerprint, checkpoints) {
566
+ return state.memoryFingerprint === fingerprint && checkpoints.every((entry) => {
567
+ const processed = state.processed[entry.checkpoint.sessionId];
568
+ return sameStoredCheckpoint(processed, entry) && processed.memoryFingerprint === fingerprint;
569
+ });
570
+ }
571
+ async function withPublicationEvidence(store, control) {
572
+ const active = control.activeAttempt;
573
+ if (!active || active.published)
574
+ return control;
575
+ const state = await readState(store.statePath);
576
+ if (!state || !hasAcknowledgedCommit(state, state.memoryFingerprint, active.checkpoints)
577
+ || await fingerprintAt(store.currentDirectory, store.memoryPath) !== state.memoryFingerprint)
578
+ return control;
579
+ return {
580
+ ...control,
581
+ activeAttempt: { ...active, published: { fingerprint: state.memoryFingerprint, at: state.updatedAt } },
582
+ };
583
+ }
584
+ async function verifyInitializationLease(lease) {
585
+ if (!lease)
586
+ throw new Error('Local memory initialization needs writer ownership for recovery; retry after the writer releases its lease');
587
+ await verifyLease(lease);
588
+ }
589
+ async function verifyLease(lease) {
590
+ if (!lease || !(await lease.verify()))
591
+ throw new Error('Memory writer lease was lost');
592
+ }
593
+ function publicationAllowed(control, attempt, leaseToken) {
594
+ if (control.autoDisabled)
595
+ return false;
596
+ if (!attempt)
597
+ return !control.activeAttempt;
598
+ return control.generation === attempt.generation && sameAttempt(control.activeAttempt, attempt)
599
+ && control.activeAttempt.leaseToken === leaseToken;
600
+ }
601
+ function assertPublicationAllowed(control, attempt, token) {
602
+ if (!publicationAllowed(control, attempt, token))
603
+ throw new Error('Memory publication is disabled or the attempt was fenced');
604
+ }
605
+ function emptyControl(now) {
606
+ return { version: 1, generation: 0, consecutiveFailures: 0, updatedAt: new Date(now).toISOString() };
607
+ }
608
+ async function readControl(path) {
609
+ try {
610
+ const info = await lstat(path);
611
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_CONTROL_BYTES)
612
+ throw new LocalMemoryControlError(path);
613
+ const value = JSON.parse(await readFile(path, 'utf8'));
614
+ if (!isLocalMemoryControl(value))
615
+ throw new LocalMemoryControlError(path);
616
+ return value;
617
+ }
618
+ catch (error) {
619
+ if (error instanceof SyntaxError)
620
+ throw new LocalMemoryControlError(path);
621
+ if (isMissing(error))
622
+ return undefined;
623
+ throw error;
624
+ }
625
+ }
626
+ async function writeControl(path, control) {
627
+ if (!isLocalMemoryControl(control) || Buffer.byteLength(JSON.stringify(control), 'utf8') > MAX_CONTROL_BYTES) {
628
+ throw new LocalMemoryControlError(path);
629
+ }
630
+ await writeJson(path, control);
631
+ }
632
+ function finishControl(control, outcome, now, retryDelaysMs) {
633
+ const { activeAttempt, ...rest } = control;
634
+ if (!activeAttempt)
635
+ return control;
636
+ const at = new Date(now).toISOString();
637
+ const status = activeAttempt.published ? 'success' : outcome.status;
638
+ const lastOutcome = { runId: activeAttempt.runId, generation: activeAttempt.generation, status, at };
639
+ if (status === 'cancelled') {
640
+ return { ...rest, generation: control.generation + 1, lastOutcome, updatedAt: at };
641
+ }
642
+ const { nextRetryAt: _retry, autoDisabled: _disabled, ...cleared } = rest;
643
+ if (status === 'success')
644
+ return {
645
+ ...cleared,
646
+ ...(outcome.status === 'cancelled' ? { generation: control.generation + 1 } : {}),
647
+ consecutiveFailures: 0,
648
+ lastOutcome,
649
+ updatedAt: at,
650
+ };
651
+ const consecutiveFailures = Math.min(control.consecutiveFailures + 1, 3);
652
+ return {
653
+ ...cleared,
654
+ consecutiveFailures,
655
+ ...(consecutiveFailures === 3 ? {
656
+ generation: control.generation + 1,
657
+ autoDisabled: { reason: 'Three consecutive memory processing attempts failed', runId: activeAttempt.runId, at },
658
+ lastDisabledRunId: activeAttempt.runId,
659
+ } : { nextRetryAt: new Date(now + retryDelaysMs[consecutiveFailures - 1]).toISOString() }),
660
+ lastOutcome,
661
+ updatedAt: at,
662
+ };
663
+ }
664
+ function sameAttempt(active, identity) {
665
+ return active?.runId === identity.runId && active.generation === identity.generation;
666
+ }
667
+ function isStoredCheckpoint(value) {
668
+ if (!isRecord(value) || !isTimestamp(value.recordedAt) || !isRecord(value.checkpoint))
669
+ return false;
670
+ if (value.acceptedUpdates !== undefined
671
+ && (!Number.isSafeInteger(value.acceptedUpdates) || Number(value.acceptedUpdates) < 1))
672
+ return false;
673
+ const checkpoint = value.checkpoint;
674
+ return isNonemptyString(checkpoint.sessionId) && isNonemptyString(checkpoint.sessionFile)
675
+ && (checkpoint.leafId === null || isNonemptyString(checkpoint.leafId))
676
+ && isNonemptyString(checkpoint.transcriptDigest);
677
+ }
678
+ function isMemoryAttempt(value) {
679
+ return isRecord(value) && isIdentity(value) && isNonemptyString(value.leaseToken)
680
+ && isTimestamp(value.startedAt) && isTimestamp(value.deadlineAt)
681
+ && Date.parse(value.deadlineAt) > Date.parse(value.startedAt)
682
+ && isFingerprint(value.baseFingerprint) && Array.isArray(value.checkpoints)
683
+ && value.checkpoints.length > 0 && value.checkpoints.every(isStoredCheckpoint)
684
+ && new Set(value.checkpoints.map((entry) => entry.checkpoint.sessionId)).size === value.checkpoints.length
685
+ && (value.published === undefined || (isRecord(value.published)
686
+ && isFingerprint(value.published.fingerprint) && isTimestamp(value.published.at)));
687
+ }
688
+ function isLocalMemoryControl(value) {
689
+ if (!isRecord(value) || value.version !== 1 || !isGeneration(value.generation)
690
+ || !Number.isInteger(value.consecutiveFailures) || Number(value.consecutiveFailures) < 0
691
+ || Number(value.consecutiveFailures) > 3 || !isTimestamp(value.updatedAt))
692
+ return false;
693
+ if (value.nextRetryAt !== undefined && !isTimestamp(value.nextRetryAt))
694
+ return false;
695
+ if (value.autoDisabled !== undefined && (!isRecord(value.autoDisabled)
696
+ || !isNonemptyString(value.autoDisabled.reason) || !isRunId(value.autoDisabled.runId)
697
+ || !isTimestamp(value.autoDisabled.at)))
698
+ return false;
699
+ if (value.lastDisabledRunId !== undefined && (!isRunId(value.lastDisabledRunId)
700
+ || (isRecord(value.autoDisabled) && value.autoDisabled.runId !== value.lastDisabledRunId)))
701
+ return false;
702
+ if (value.activeAttempt !== undefined && (!isMemoryAttempt(value.activeAttempt)
703
+ || value.activeAttempt.generation !== value.generation || value.autoDisabled !== undefined))
704
+ return false;
705
+ if (value.lastOutcome !== undefined && (!isRecord(value.lastOutcome) || !isIdentity(value.lastOutcome)
706
+ || !['success', 'failure', 'cancelled'].includes(String(value.lastOutcome.status))
707
+ || !isTimestamp(value.lastOutcome.at) || value.lastOutcome.generation > Number(value.generation)))
708
+ return false;
709
+ if (value.consecutiveFailures === 3)
710
+ return value.autoDisabled !== undefined && value.nextRetryAt === undefined;
711
+ return value.autoDisabled === undefined
712
+ && (value.consecutiveFailures === 0 ? value.nextRetryAt === undefined : value.nextRetryAt !== undefined);
713
+ }
714
+ function isCommitJournal(value) {
715
+ return value.version === 2 && ['prepared', 'published', 'rolled_back'].includes(String(value.phase))
716
+ && isFingerprint(value.baseFingerprint) && isFingerprint(value.fingerprint)
717
+ && Array.isArray(value.checkpoints) && value.checkpoints.every(isStoredCheckpoint)
718
+ && new Set(value.checkpoints.map((entry) => entry.checkpoint.sessionId)).size === value.checkpoints.length
719
+ && (value.attempt === undefined || (isRecord(value.attempt) && isIdentity(value.attempt)
720
+ && isNonemptyString(value.attempt.leaseToken)))
721
+ && (value.publishedAt === undefined || isTimestamp(value.publishedAt));
722
+ }
723
+ function isLegacyCommitJournal(value) {
724
+ return value.version === 1 && isFingerprint(value.baseFingerprint) && isFingerprint(value.fingerprint)
725
+ && Array.isArray(value.processed) && value.processed.every(isNonemptyString);
726
+ }
727
+ function isIdentity(value) {
728
+ return isRunId(value.runId) && isGeneration(value.generation);
729
+ }
730
+ function isGeneration(value) {
731
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value < Number.MAX_SAFE_INTEGER;
732
+ }
733
+ function isRunId(value) {
734
+ return typeof value === 'string' && /^[a-zA-Z0-9_-]+$/u.test(value);
735
+ }
736
+ function isFingerprint(value) {
737
+ return typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value);
738
+ }
739
+ function isTimestamp(value) {
740
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
741
+ }
742
+ function isNonemptyString(value) {
743
+ return typeof value === 'string' && value.length > 0;
744
+ }
335
745
  function isMissing(error) {
336
746
  return typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ENOENT';
337
747
  }
338
- function isAlreadyExists(error) {
339
- return typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'EEXIST';
340
- }
341
748
  function isRecord(value) {
342
749
  return typeof value === 'object' && value !== null && !Array.isArray(value);
343
750
  }