@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.
- package/README.md +4 -0
- package/dist/dependencies.js +1 -1
- package/dist/dependencies.js.map +1 -1
- package/dist/extensions.d.ts +1 -1
- package/dist/extensions.d.ts.map +1 -1
- package/dist/extensions.js +2 -2
- package/dist/extensions.js.map +1 -1
- package/dist/herdr.d.ts +22 -0
- package/dist/herdr.d.ts.map +1 -0
- package/dist/herdr.js +208 -0
- package/dist/herdr.js.map +1 -0
- package/dist/insights.d.ts +1 -1
- package/dist/insights.d.ts.map +1 -1
- package/dist/insights.js +11 -8
- package/dist/insights.js.map +1 -1
- package/dist/memory/control.d.ts +1 -1
- package/dist/memory/control.d.ts.map +1 -1
- package/dist/memory/control.js +90 -34
- package/dist/memory/control.js.map +1 -1
- package/dist/memory/coordinator.d.ts +13 -2
- package/dist/memory/coordinator.d.ts.map +1 -1
- package/dist/memory/coordinator.js +416 -67
- package/dist/memory/coordinator.js.map +1 -1
- package/dist/memory/dreamer.d.ts +7 -1
- package/dist/memory/dreamer.d.ts.map +1 -1
- package/dist/memory/dreamer.js +145 -59
- package/dist/memory/dreamer.js.map +1 -1
- package/dist/memory/history-view.d.ts +26 -0
- package/dist/memory/history-view.d.ts.map +1 -0
- package/dist/memory/history-view.js +192 -0
- package/dist/memory/history-view.js.map +1 -0
- package/dist/memory/history.d.ts +33 -0
- package/dist/memory/history.d.ts.map +1 -0
- package/dist/memory/history.js +314 -0
- package/dist/memory/history.js.map +1 -0
- package/dist/memory/run-retention.d.ts +12 -0
- package/dist/memory/run-retention.d.ts.map +1 -0
- package/dist/memory/run-retention.js +294 -0
- package/dist/memory/run-retention.js.map +1 -0
- package/dist/memory/run.d.ts +67 -0
- package/dist/memory/run.d.ts.map +1 -0
- package/dist/memory/run.js +417 -0
- package/dist/memory/run.js.map +1 -0
- package/dist/memory/store.d.ts +67 -3
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.js +517 -110
- package/dist/memory/store.js.map +1 -1
- package/dist/resume.d.ts.map +1 -1
- package/dist/resume.js +12 -7
- package/dist/resume.js.map +1 -1
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +27 -18
- package/dist/runtime.js.map +1 -1
- package/dist/session-picker.d.ts +8 -1
- package/dist/session-picker.d.ts.map +1 -1
- package/dist/session-picker.js +7 -4
- package/dist/session-picker.js.map +1 -1
- package/dist/settings.d.ts +0 -3
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +0 -22
- package/dist/settings.js.map +1 -1
- package/dist/subagents/agent-transcript.d.ts +4 -1
- package/dist/subagents/agent-transcript.d.ts.map +1 -1
- package/dist/subagents/agent-transcript.js +16 -4
- package/dist/subagents/agent-transcript.js.map +1 -1
- package/package.json +24 -23
package/dist/memory/store.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
|
106
|
+
recordedAt,
|
|
107
|
+
acceptedUpdates: previousPending === undefined ? 1 : acceptedCheckpointUpdates(previousPending) + 1,
|
|
85
108
|
},
|
|
86
109
|
};
|
|
87
|
-
await writeState(this.statePath, { ...state, pending, updatedAt:
|
|
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
|
|
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
|
-
|
|
121
|
-
version:
|
|
251
|
+
const journal = {
|
|
252
|
+
version: 2,
|
|
253
|
+
phase: 'prepared',
|
|
122
254
|
baseFingerprint,
|
|
123
255
|
fingerprint,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
392
|
+
return { ...state, memoryFingerprint: fingerprint, pending, processed, updatedAt: processedAt };
|
|
243
393
|
}
|
|
244
|
-
async function
|
|
245
|
-
await
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
|
465
|
+
async function transactionDirectories(path) {
|
|
301
466
|
try {
|
|
302
|
-
const
|
|
303
|
-
|
|
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
|
|
308
|
-
|
|
481
|
+
return [];
|
|
482
|
+
throw error;
|
|
309
483
|
}
|
|
310
484
|
}
|
|
311
|
-
async function
|
|
485
|
+
async function readBoundedRecord(path, maxBytes, unsafeMessage) {
|
|
486
|
+
let file;
|
|
312
487
|
try {
|
|
313
|
-
|
|
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
|
}
|