@mono-agent/agent-app 0.10.0 → 0.11.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/dist/app.js +4 -2
- package/dist/app.js.map +1 -1
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +23 -9
- package/dist/channels.js.map +1 -1
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +16 -6
- package/dist/configured-agent.js.map +1 -1
- package/dist/continuation-service.d.ts.map +1 -1
- package/dist/continuation-service.js +453 -32
- package/dist/continuation-service.js.map +1 -1
- package/dist/continuation-store.d.ts +41 -2
- package/dist/continuation-store.d.ts.map +1 -1
- package/dist/continuation-store.js +643 -33
- package/dist/continuation-store.js.map +1 -1
- package/dist/continuations.d.ts +29 -4
- package/dist/continuations.d.ts.map +1 -1
- package/dist/continuations.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +99 -10
- package/dist/doctor.js.map +1 -1
- package/package.json +15 -15
- package/skills/mono-agent-composer/references/feature-coverage.md +2 -1
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
3
|
import { chmod, lstat, mkdir, open, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
+
import { AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_BYTES, AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGE_BYTES, AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGES, assertAgentContinuationOriginContext, } from "@mono-agent/agent-contracts";
|
|
6
|
+
import { canonicalContinuationJson, continuationDigest, isContinuationMode, isContinuationState, TERMINAL_CONTINUATION_STATES, } from "./continuations.js";
|
|
5
7
|
export const CONTINUATION_STORE_SCHEMA_VERSION = 1;
|
|
6
|
-
export const CONTINUATION_RECORD_STORE_SCHEMA_VERSION =
|
|
7
|
-
const RECORDS_DIRECTORY = "records-
|
|
8
|
-
const TRANSACTION_FILE = "continuation-transaction-
|
|
9
|
-
const MANIFEST_FILE = "continuation-store-
|
|
8
|
+
export const CONTINUATION_RECORD_STORE_SCHEMA_VERSION = 3;
|
|
9
|
+
const RECORDS_DIRECTORY = "records-v3";
|
|
10
|
+
const TRANSACTION_FILE = "continuation-transaction-v3.json";
|
|
11
|
+
const MANIFEST_FILE = "continuation-store-v3.json";
|
|
12
|
+
const LEGACY_RECORDS_DIRECTORY = "records-v2";
|
|
13
|
+
const LEGACY_TRANSACTION_FILE = "continuation-transaction-v2.json";
|
|
14
|
+
const V2_ROLLBACK_GUARD = "UPGRADED-TO-RECORDS-V3";
|
|
15
|
+
const ORIGIN_CONTEXT_GROUPS_DIRECTORY = "origin-context-groups-v1";
|
|
10
16
|
const OWNER_DATABASE_FILE = "continuations-owner.sqlite";
|
|
17
|
+
const ORIGIN_CONTEXTS_DIRECTORY = "origin-context-v1";
|
|
11
18
|
const MAX_RECORD_BYTES = 2 * 1024 * 1024;
|
|
12
19
|
const MAX_TRANSACTION_BYTES = 16 * 1024 * 1024;
|
|
13
20
|
const DEFAULT_TERMINAL_MAX_RECORDS = 50_000;
|
|
14
21
|
const DEFAULT_TERMINAL_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1_000;
|
|
15
22
|
const DEFAULT_CAPTURED_TEXT_MAX_RECORDS = 1_000;
|
|
16
23
|
const DEFAULT_CAPTURED_TEXT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
24
|
+
export const MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES = AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_BYTES;
|
|
25
|
+
export const MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES = AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGES;
|
|
26
|
+
export const MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGE_BYTES = AGENT_CONTINUATION_ORIGIN_CONTEXT_MAX_MESSAGE_BYTES;
|
|
27
|
+
export const MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES = 256 * 1024 * 1024;
|
|
17
28
|
/**
|
|
18
29
|
* Claim exclusive ownership of a continuation state directory for this process.
|
|
19
30
|
* SQLite holds an OS-backed exclusive transaction for the process lifetime.
|
|
@@ -88,37 +99,75 @@ export async function openContinuationStore(stateDir, options = {}) {
|
|
|
88
99
|
await ensureOwnerOnlyDirectory(stateDir);
|
|
89
100
|
const recordsDir = join(stateDir, RECORDS_DIRECTORY);
|
|
90
101
|
await ensureOwnerOnlyDirectory(recordsDir);
|
|
102
|
+
const legacyRecordsDir = join(stateDir, LEGACY_RECORDS_DIRECTORY);
|
|
103
|
+
await ensureOwnerOnlyDirectory(legacyRecordsDir);
|
|
104
|
+
const originContextsDir = join(stateDir, ORIGIN_CONTEXTS_DIRECTORY);
|
|
105
|
+
await ensureOwnerOnlyDirectory(originContextsDir);
|
|
106
|
+
const originContextGroupsDir = join(stateDir, ORIGIN_CONTEXT_GROUPS_DIRECTORY);
|
|
107
|
+
await ensureOwnerOnlyDirectory(originContextGroupsDir);
|
|
91
108
|
const transactionPath = join(stateDir, TRANSACTION_FILE);
|
|
92
109
|
const manifestPath = join(stateDir, MANIFEST_FILE);
|
|
93
110
|
const legacyPath = join(stateDir, "continuations-v1.json");
|
|
111
|
+
const legacyTransactionPath = join(stateDir, LEGACY_TRANSACTION_FILE);
|
|
112
|
+
const rollbackGuardPath = join(legacyRecordsDir, V2_ROLLBACK_GUARD);
|
|
94
113
|
const policy = resolveRetention(options.retention);
|
|
95
114
|
const now = options.now ?? (() => new Date());
|
|
96
|
-
const
|
|
115
|
+
const manifestExists = await continuationPathExists(manifestPath);
|
|
116
|
+
if (manifestExists)
|
|
117
|
+
await assertV3Manifest(manifestPath);
|
|
118
|
+
// Finish any v2 transaction before installing the rollback guard. Once the
|
|
119
|
+
// guard exists, v0.10 fails closed while v3 deliberately leaves the v2/v1
|
|
120
|
+
// evidence untouched for audit and manual recovery.
|
|
121
|
+
if (!await continuationPathExists(rollbackGuardPath)) {
|
|
122
|
+
await recoverRecordTransaction(legacyRecordsDir, legacyTransactionPath, 2);
|
|
123
|
+
}
|
|
124
|
+
const recoveredGeneration = await recoverRecordTransaction(recordsDir, transactionPath, CONTINUATION_RECORD_STORE_SCHEMA_VERSION);
|
|
97
125
|
const records = await loadRecordDirectory(recordsDir);
|
|
98
126
|
const beforeOpen = cloneRecords(records);
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
normalizeLegacyContinuationRecords(records);
|
|
128
|
+
if (!manifestExists) {
|
|
129
|
+
const migrationSource = await loadRecordDirectory(legacyRecordsDir, new Set([V2_ROLLBACK_GUARD]));
|
|
130
|
+
normalizeLegacyContinuationRecords(migrationSource);
|
|
131
|
+
if (await continuationPathExists(legacyPath)) {
|
|
132
|
+
const legacy = await loadLegacyStore(legacyPath);
|
|
133
|
+
normalizeLegacyContinuationRecords(legacy);
|
|
134
|
+
mergeMigrationRecords(migrationSource, legacy, "v1 and v2");
|
|
135
|
+
}
|
|
136
|
+
// Install the old-reader poison before the first v3 record becomes active.
|
|
137
|
+
// A crash on either side is restart-safe: v3 repeats a semantic merge, and
|
|
138
|
+
// v0.10 cannot start against a stale v2 snapshot.
|
|
139
|
+
if (!await continuationPathExists(rollbackGuardPath)) {
|
|
140
|
+
await writeTextAtomic(rollbackGuardPath, "This state directory uses continuation records v3. Older runtimes must not open records-v2.\n", 4 * 1024);
|
|
110
141
|
}
|
|
142
|
+
mergeMigrationRecords(records, migrationSource, "v2 and v3");
|
|
111
143
|
}
|
|
144
|
+
const committedOriginGroups = await applyOriginContextGroupCommits(originContextGroupsDir, records);
|
|
112
145
|
applyRetention(records, policy, now());
|
|
113
146
|
const migrationGeneration = await persistRecordChanges(recordsDir, transactionPath, beforeOpen, records);
|
|
114
|
-
if (legacyExists) {
|
|
115
|
-
await rm(legacyPath, { force: true });
|
|
116
|
-
await syncDirectory(stateDir);
|
|
117
|
-
}
|
|
118
147
|
let generation = migrationGeneration ?? recoveredGeneration ?? randomUUID();
|
|
119
148
|
await persistManifest(manifestPath, generation, continuationStoreStats(records, policy), now());
|
|
149
|
+
await removeOriginContextGroupCommits(originContextGroupsDir, committedOriginGroups);
|
|
150
|
+
await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set());
|
|
120
151
|
let tail = Promise.resolve();
|
|
152
|
+
let originTail = Promise.resolve();
|
|
153
|
+
// Multiple capabilities from one origin run intentionally stage the same
|
|
154
|
+
// content-addressed snapshot concurrently. Track leases, not just presence:
|
|
155
|
+
// one failed/finalized caller must not sweep the blob while another caller
|
|
156
|
+
// still owns an uncommitted pin for the same digest.
|
|
157
|
+
const pendingOriginPins = new Map();
|
|
121
158
|
let poisoned;
|
|
159
|
+
async function withOriginLock(operation) {
|
|
160
|
+
const previous = originTail;
|
|
161
|
+
let release;
|
|
162
|
+
originTail = new Promise((resolve) => { release = resolve; });
|
|
163
|
+
await previous;
|
|
164
|
+
try {
|
|
165
|
+
return await operation();
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
release();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
122
171
|
async function locked(operation) {
|
|
123
172
|
const previous = tail;
|
|
124
173
|
let release;
|
|
@@ -145,11 +194,14 @@ export async function openContinuationStore(stateDir, options = {}) {
|
|
|
145
194
|
generation = committedGeneration;
|
|
146
195
|
replaceRecords(records, draft);
|
|
147
196
|
await persistManifest(manifestPath, generation, continuationStoreStats(records, policy), now());
|
|
197
|
+
await withOriginLock(async () => {
|
|
198
|
+
await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set(pendingOriginPins.keys()));
|
|
199
|
+
});
|
|
148
200
|
return result;
|
|
149
201
|
}
|
|
150
202
|
catch (error) {
|
|
151
203
|
try {
|
|
152
|
-
const recovered = await recoverRecordTransaction(recordsDir, transactionPath);
|
|
204
|
+
const recovered = await recoverRecordTransaction(recordsDir, transactionPath, CONTINUATION_RECORD_STORE_SCHEMA_VERSION);
|
|
153
205
|
if (recovered !== undefined)
|
|
154
206
|
generation = recovered;
|
|
155
207
|
replaceRecords(records, await loadRecordDirectory(recordsDir));
|
|
@@ -165,6 +217,86 @@ export async function openContinuationStore(stateDir, options = {}) {
|
|
|
165
217
|
release();
|
|
166
218
|
}
|
|
167
219
|
}
|
|
220
|
+
async function activateOriginContextGroup(input) {
|
|
221
|
+
if (!requiredString(input.claimFingerprint) || !requiredDate(input.activatedAt)) {
|
|
222
|
+
throw new Error("Continuation origin-context activation has an invalid claim or timestamp.");
|
|
223
|
+
}
|
|
224
|
+
const previous = tail;
|
|
225
|
+
let release;
|
|
226
|
+
tail = new Promise((resolve) => { release = resolve; });
|
|
227
|
+
await previous;
|
|
228
|
+
if (poisoned !== undefined) {
|
|
229
|
+
release();
|
|
230
|
+
throw new Error("Continuation store requires restart after a failed durable transaction.", { cause: poisoned });
|
|
231
|
+
}
|
|
232
|
+
const before = cloneRecords(records);
|
|
233
|
+
const draft = cloneRecords(records);
|
|
234
|
+
let commit;
|
|
235
|
+
let markerPath;
|
|
236
|
+
let published = false;
|
|
237
|
+
try {
|
|
238
|
+
commit = prepareOriginContextGroupCommit(draft, input);
|
|
239
|
+
if (commit === undefined)
|
|
240
|
+
return;
|
|
241
|
+
markerPath = join(originContextGroupsDir, `${commit.groupKey}.json`);
|
|
242
|
+
if (await continuationPathExists(markerPath)) {
|
|
243
|
+
const existing = await loadOriginContextGroupCommit(markerPath);
|
|
244
|
+
if (canonicalContinuationJson(existing) !== canonicalContinuationJson(commit)) {
|
|
245
|
+
throw new Error("Continuation origin-context group activation conflicts with an existing commit marker.");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
// The marker is the semantic commit point. It is deliberately compact:
|
|
250
|
+
// the member-set digest makes one fsync atomic for groups whose record
|
|
251
|
+
// materialization spans arbitrarily many bounded transaction batches.
|
|
252
|
+
await writeJsonAtomic(markerPath, commit, true, 64 * 1024);
|
|
253
|
+
}
|
|
254
|
+
published = true;
|
|
255
|
+
applyOriginContextGroupCommit(draft, commit);
|
|
256
|
+
replaceRecords(records, draft);
|
|
257
|
+
try {
|
|
258
|
+
const committedGeneration = await persistRecordChanges(recordsDir, transactionPath, before, draft);
|
|
259
|
+
if (committedGeneration !== undefined)
|
|
260
|
+
generation = committedGeneration;
|
|
261
|
+
await persistManifest(manifestPath, generation, continuationStoreStats(records, policy), now());
|
|
262
|
+
await rm(markerPath, { force: true });
|
|
263
|
+
await syncDirectory(originContextGroupsDir);
|
|
264
|
+
}
|
|
265
|
+
catch (materializationError) {
|
|
266
|
+
// Publication already committed. Recover the bounded materialization if
|
|
267
|
+
// possible and keep the group marker as the restart-time source of
|
|
268
|
+
// truth. Never report an ambiguous activation failure to a caller that
|
|
269
|
+
// might then abandon an already-published group.
|
|
270
|
+
try {
|
|
271
|
+
const recovered = await recoverRecordTransaction(recordsDir, transactionPath, CONTINUATION_RECORD_STORE_SCHEMA_VERSION);
|
|
272
|
+
if (recovered !== undefined)
|
|
273
|
+
generation = recovered;
|
|
274
|
+
const recoveredRecords = await loadRecordDirectory(recordsDir);
|
|
275
|
+
normalizeLegacyContinuationRecords(recoveredRecords);
|
|
276
|
+
applyOriginContextGroupCommit(recoveredRecords, commit);
|
|
277
|
+
replaceRecords(records, recoveredRecords);
|
|
278
|
+
}
|
|
279
|
+
catch (recoveryError) {
|
|
280
|
+
poisoned = new AggregateError([materializationError, recoveryError], "Continuation origin-context activation committed but requires restart to recover.");
|
|
281
|
+
}
|
|
282
|
+
poisoned ??= materializationError;
|
|
283
|
+
}
|
|
284
|
+
await withOriginLock(async () => {
|
|
285
|
+
await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set(pendingOriginPins.keys()));
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (!published)
|
|
290
|
+
throw error;
|
|
291
|
+
// The fsynced marker means activation is no longer ambiguous. Preserve
|
|
292
|
+
// that success contract and force a restart for any unexpected local
|
|
293
|
+
// maintenance failure after the commit point.
|
|
294
|
+
poisoned ??= error;
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
release();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
168
300
|
return {
|
|
169
301
|
path: manifestPath,
|
|
170
302
|
async get(id) {
|
|
@@ -186,6 +318,74 @@ export async function openContinuationStore(stateDir, options = {}) {
|
|
|
186
318
|
await tail;
|
|
187
319
|
return continuationStoreStats(records, policy);
|
|
188
320
|
},
|
|
321
|
+
async stageOriginContext(snapshot) {
|
|
322
|
+
assertAgentContinuationOriginContext(snapshot);
|
|
323
|
+
const canonical = canonicalContinuationJson(snapshot);
|
|
324
|
+
const bytes = Buffer.byteLength(canonical, "utf8");
|
|
325
|
+
if (snapshot.messages.length > MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES) {
|
|
326
|
+
throw new Error(`Continuation origin context exceeds its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES)} message limit.`);
|
|
327
|
+
}
|
|
328
|
+
if (snapshot.messages.some((message) => Buffer.byteLength(message.content, "utf8") > MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGE_BYTES)) {
|
|
329
|
+
throw new Error(`Continuation origin context contains a message over its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGE_BYTES)} byte limit.`);
|
|
330
|
+
}
|
|
331
|
+
if (bytes > MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES) {
|
|
332
|
+
throw new Error(`Continuation origin context exceeds its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES)} byte limit.`);
|
|
333
|
+
}
|
|
334
|
+
const digest = originContextDigest(canonical);
|
|
335
|
+
const reference = { schemaVersion: 1, digest, bytes, messageCount: snapshot.messages.length };
|
|
336
|
+
pendingOriginPins.set(digest, (pendingOriginPins.get(digest) ?? 0) + 1);
|
|
337
|
+
try {
|
|
338
|
+
await withOriginLock(async () => {
|
|
339
|
+
const path = join(originContextsDir, `${digest}.json`);
|
|
340
|
+
if (await continuationPathExists(path)) {
|
|
341
|
+
const existing = await readOriginContextCanonical(path, reference);
|
|
342
|
+
if (existing !== canonical)
|
|
343
|
+
throw new Error("Continuation origin context digest collision or content conflict.");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const aggregate = await originContextStoreBytes(originContextsDir);
|
|
347
|
+
if (aggregate + bytes > MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES) {
|
|
348
|
+
throw new Error(`Continuation origin context store exceeds its ${String(MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES)} byte quota.`);
|
|
349
|
+
}
|
|
350
|
+
await writeTextAtomic(path, canonical, MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
releasePendingOriginPin(pendingOriginPins, digest);
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
let released = false;
|
|
358
|
+
return {
|
|
359
|
+
reference,
|
|
360
|
+
async release() {
|
|
361
|
+
if (released)
|
|
362
|
+
return;
|
|
363
|
+
released = true;
|
|
364
|
+
releasePendingOriginPin(pendingOriginPins, digest);
|
|
365
|
+
await withOriginLock(async () => {
|
|
366
|
+
await sweepOriginContextBlobs(originContextsDir, referencedOriginContextDigests(records), new Set(pendingOriginPins.keys()));
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
async loadOriginContext(reference) {
|
|
372
|
+
if (!isOriginContextReference(reference))
|
|
373
|
+
return undefined;
|
|
374
|
+
return await withOriginLock(async () => {
|
|
375
|
+
const path = join(originContextsDir, `${reference.digest}.json`);
|
|
376
|
+
try {
|
|
377
|
+
const canonical = await readOriginContextCanonical(path, reference);
|
|
378
|
+
return JSON.parse(canonical);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (isMissing(error) || error instanceof SyntaxError || error instanceof OriginContextCorruptionError) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
activateOriginContextGroup,
|
|
189
389
|
mutate: locked,
|
|
190
390
|
};
|
|
191
391
|
}
|
|
@@ -273,11 +473,15 @@ async function loadLegacyStore(path) {
|
|
|
273
473
|
}
|
|
274
474
|
return new Map(Object.entries(parsed.records).map(([id, record]) => [id, record]));
|
|
275
475
|
}
|
|
276
|
-
async function loadRecordDirectory(path) {
|
|
476
|
+
async function loadRecordDirectory(path, ignoredEntries = new Set()) {
|
|
277
477
|
const records = new Map();
|
|
278
478
|
let removedTemporary = false;
|
|
279
479
|
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
280
480
|
const filePath = join(path, entry.name);
|
|
481
|
+
if (ignoredEntries.has(entry.name)) {
|
|
482
|
+
await assertOwnerOnlyRegularFile(filePath, "Continuation migration guard");
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
281
485
|
if (entry.name.startsWith(".") && entry.name.endsWith(".tmp")) {
|
|
282
486
|
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
283
487
|
throw new Error(`Continuation temporary record is not a regular file: ${filePath}`);
|
|
@@ -314,6 +518,166 @@ async function loadRecordDirectory(path) {
|
|
|
314
518
|
await syncDirectory(path);
|
|
315
519
|
return records;
|
|
316
520
|
}
|
|
521
|
+
function mergeMigrationRecords(target, source, label) {
|
|
522
|
+
// Both sides must be normalized before the semantic comparison. Otherwise a
|
|
523
|
+
// crash after persisting defaults (for example synthesisDeferrals=0) makes a
|
|
524
|
+
// restart falsely report a migration conflict against the equivalent v1/v2
|
|
525
|
+
// representation that omitted those fields.
|
|
526
|
+
normalizeLegacyContinuationRecords(target);
|
|
527
|
+
normalizeLegacyContinuationRecords(source);
|
|
528
|
+
for (const [id, record] of source) {
|
|
529
|
+
const current = target.get(id);
|
|
530
|
+
if (current === undefined) {
|
|
531
|
+
target.set(id, structuredClone(record));
|
|
532
|
+
}
|
|
533
|
+
else if (canonicalContinuationJson(current) !== canonicalContinuationJson(record)) {
|
|
534
|
+
throw new Error(`${label} continuation records conflict for id ${id}; refusing lossy migration.`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function assertV3Manifest(path) {
|
|
539
|
+
const raw = await readBoundedOwnerOnlyFile(path, 1024 * 1024, "Continuation v3 manifest");
|
|
540
|
+
let value;
|
|
541
|
+
try {
|
|
542
|
+
value = JSON.parse(raw);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
throw new Error(`Continuation v3 manifest contains invalid JSON: ${path}`, { cause: error });
|
|
546
|
+
}
|
|
547
|
+
if (!isObject(value)
|
|
548
|
+
|| value.schemaVersion !== CONTINUATION_RECORD_STORE_SCHEMA_VERSION
|
|
549
|
+
|| !requiredString(value.generation)
|
|
550
|
+
|| !requiredDate(value.updatedAt)
|
|
551
|
+
|| !isObject(value.stats)) {
|
|
552
|
+
throw new Error(`Continuation v3 manifest has a malformed schema: ${path}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function prepareOriginContextGroupCommit(records, input) {
|
|
556
|
+
const seeds = [...records.values()].filter((record) => record.claimFingerprint === input.claimFingerprint);
|
|
557
|
+
if (seeds.length === 0)
|
|
558
|
+
return undefined;
|
|
559
|
+
const seed = seeds[0];
|
|
560
|
+
if (seed.originContextState === "detached_latest")
|
|
561
|
+
return undefined;
|
|
562
|
+
if (seed.historyBoundary === undefined) {
|
|
563
|
+
throw new Error("A pinned continuation origin group must have an immutable history boundary.");
|
|
564
|
+
}
|
|
565
|
+
const candidates = [...records.values()].filter((record) => record.originRunId === seed.originRunId
|
|
566
|
+
&& record.originConversationId === seed.originConversationId
|
|
567
|
+
&& record.historyBoundary === seed.historyBoundary
|
|
568
|
+
&& !TERMINAL_CONTINUATION_STATES.has(record.state));
|
|
569
|
+
if (candidates.length === 0)
|
|
570
|
+
return undefined;
|
|
571
|
+
const digests = new Set();
|
|
572
|
+
for (const record of candidates) {
|
|
573
|
+
if ((record.originContextState !== "pending" && record.originContextState !== "pinned")
|
|
574
|
+
|| record.originContextRef === undefined
|
|
575
|
+
|| record.originContextDigest !== record.originContextRef.digest
|
|
576
|
+
|| record.originContextBindingMac === undefined) {
|
|
577
|
+
throw new Error("Continuation origin context was not durably prepared for activation.");
|
|
578
|
+
}
|
|
579
|
+
digests.add(record.originContextRef.digest);
|
|
580
|
+
}
|
|
581
|
+
if (digests.size !== 1) {
|
|
582
|
+
throw new Error("Continuation origin claims were prepared with conflicting snapshots.");
|
|
583
|
+
}
|
|
584
|
+
if (candidates.every((record) => record.originContextState === "pinned"))
|
|
585
|
+
return undefined;
|
|
586
|
+
const snapshotDigest = [...digests][0];
|
|
587
|
+
if (snapshotDigest === undefined)
|
|
588
|
+
throw new Error("Continuation origin group has no snapshot digest.");
|
|
589
|
+
const memberIds = candidates.map((record) => record.continuationId).sort();
|
|
590
|
+
const groupIdentity = {
|
|
591
|
+
originRunId: seed.originRunId,
|
|
592
|
+
originConversationId: seed.originConversationId,
|
|
593
|
+
historyBoundary: seed.historyBoundary,
|
|
594
|
+
};
|
|
595
|
+
return {
|
|
596
|
+
schemaVersion: 1,
|
|
597
|
+
groupKey: continuationDigest(`mono-agent-origin-context-group-v1\0${canonicalContinuationJson(groupIdentity)}`),
|
|
598
|
+
...groupIdentity,
|
|
599
|
+
snapshotDigest,
|
|
600
|
+
memberCount: memberIds.length,
|
|
601
|
+
memberSetDigest: continuationDigest(`mono-agent-origin-context-members-v1\0${canonicalContinuationJson(memberIds)}`),
|
|
602
|
+
activatedAt: input.activatedAt,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function applyOriginContextGroupCommit(records, commit) {
|
|
606
|
+
const candidates = [...records.values()].filter((record) => record.originRunId === commit.originRunId
|
|
607
|
+
&& record.originConversationId === commit.originConversationId
|
|
608
|
+
&& record.historyBoundary === commit.historyBoundary
|
|
609
|
+
&& !TERMINAL_CONTINUATION_STATES.has(record.state));
|
|
610
|
+
const memberIds = candidates.map((record) => record.continuationId).sort();
|
|
611
|
+
const memberSetDigest = continuationDigest(`mono-agent-origin-context-members-v1\0${canonicalContinuationJson(memberIds)}`);
|
|
612
|
+
if (candidates.length !== commit.memberCount || memberSetDigest !== commit.memberSetDigest) {
|
|
613
|
+
throw new Error("Continuation origin-context group commit member set does not match durable records.");
|
|
614
|
+
}
|
|
615
|
+
for (const record of candidates) {
|
|
616
|
+
if ((record.originContextState !== "pending" && record.originContextState !== "pinned")
|
|
617
|
+
|| record.originContextRef?.digest !== commit.snapshotDigest
|
|
618
|
+
|| record.originContextDigest !== commit.snapshotDigest
|
|
619
|
+
|| record.originContextBindingMac === undefined) {
|
|
620
|
+
throw new Error("Continuation origin-context group commit does not match its prepared records.");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
for (const record of candidates) {
|
|
624
|
+
if (record.originContextState === "pinned")
|
|
625
|
+
continue;
|
|
626
|
+
record.originContextState = "pinned";
|
|
627
|
+
record.updatedAt = commit.activatedAt;
|
|
628
|
+
if (record.lastError?.code === "origin_context_pending")
|
|
629
|
+
delete record.lastError;
|
|
630
|
+
delete record.nextAttemptAt;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function applyOriginContextGroupCommits(directory, records) {
|
|
634
|
+
const applied = [];
|
|
635
|
+
let removedTemporary = false;
|
|
636
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
637
|
+
const path = join(directory, entry.name);
|
|
638
|
+
if (entry.name.startsWith(".") && entry.name.endsWith(".tmp")) {
|
|
639
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
640
|
+
throw new Error(`Continuation origin-context group temporary is not a regular file: ${path}`);
|
|
641
|
+
}
|
|
642
|
+
await rm(path, { force: true });
|
|
643
|
+
removedTemporary = true;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (!/^[a-f0-9]{64}\.json$/u.test(entry.name) || !entry.isFile() || entry.isSymbolicLink()) {
|
|
647
|
+
throw new Error(`Unexpected entry in continuation origin-context group directory: ${path}`);
|
|
648
|
+
}
|
|
649
|
+
const commit = await loadOriginContextGroupCommit(path);
|
|
650
|
+
if (`${commit.groupKey}.json` !== entry.name) {
|
|
651
|
+
throw new Error(`Continuation origin-context group filename does not match its key: ${path}`);
|
|
652
|
+
}
|
|
653
|
+
applyOriginContextGroupCommit(records, commit);
|
|
654
|
+
applied.push(path);
|
|
655
|
+
}
|
|
656
|
+
if (removedTemporary)
|
|
657
|
+
await syncDirectory(directory);
|
|
658
|
+
return applied;
|
|
659
|
+
}
|
|
660
|
+
async function loadOriginContextGroupCommit(path) {
|
|
661
|
+
const raw = await readBoundedOwnerOnlyFile(path, 64 * 1024, "Continuation origin-context group commit");
|
|
662
|
+
let value;
|
|
663
|
+
try {
|
|
664
|
+
value = JSON.parse(raw);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
throw new Error(`Continuation origin-context group commit contains invalid JSON: ${path}`, { cause: error });
|
|
668
|
+
}
|
|
669
|
+
if (!isOriginContextGroupCommit(value)) {
|
|
670
|
+
throw new Error(`Continuation origin-context group commit has a malformed schema: ${path}`);
|
|
671
|
+
}
|
|
672
|
+
return value;
|
|
673
|
+
}
|
|
674
|
+
async function removeOriginContextGroupCommits(directory, paths) {
|
|
675
|
+
if (paths.length === 0)
|
|
676
|
+
return;
|
|
677
|
+
for (const path of paths)
|
|
678
|
+
await rm(path, { force: true });
|
|
679
|
+
await syncDirectory(directory);
|
|
680
|
+
}
|
|
317
681
|
async function persistRecordChanges(recordsDir, transactionPath, before, after) {
|
|
318
682
|
const writes = [...after.values()].filter((record) => {
|
|
319
683
|
const prior = before.get(record.continuationId);
|
|
@@ -332,7 +696,7 @@ async function persistRecordChanges(recordsDir, transactionPath, before, after)
|
|
|
332
696
|
}
|
|
333
697
|
return generation;
|
|
334
698
|
}
|
|
335
|
-
async function recoverRecordTransaction(recordsDir, transactionPath) {
|
|
699
|
+
async function recoverRecordTransaction(recordsDir, transactionPath, expectedSchemaVersion) {
|
|
336
700
|
if (!await continuationPathExists(transactionPath))
|
|
337
701
|
return undefined;
|
|
338
702
|
const raw = await readBoundedOwnerOnlyFile(transactionPath, MAX_TRANSACTION_BYTES, "Continuation transaction");
|
|
@@ -343,7 +707,7 @@ async function recoverRecordTransaction(recordsDir, transactionPath) {
|
|
|
343
707
|
catch (error) {
|
|
344
708
|
throw new Error(`Continuation transaction contains invalid JSON: ${transactionPath}`, { cause: error });
|
|
345
709
|
}
|
|
346
|
-
if (!isRecordTransaction(value)) {
|
|
710
|
+
if (!isRecordTransaction(value, expectedSchemaVersion)) {
|
|
347
711
|
throw new Error(`Continuation transaction has a malformed schema: ${transactionPath}`);
|
|
348
712
|
}
|
|
349
713
|
await applyRecordTransaction(recordsDir, value);
|
|
@@ -392,6 +756,142 @@ async function writeJsonAtomic(path, value, syncParent = true, maxBytes = Number
|
|
|
392
756
|
await rm(temporary, { force: true }).catch(() => undefined);
|
|
393
757
|
}
|
|
394
758
|
}
|
|
759
|
+
async function writeTextAtomic(path, body, maxBytes) {
|
|
760
|
+
if (Buffer.byteLength(body, "utf8") > maxBytes) {
|
|
761
|
+
throw new Error(`Durable continuation file exceeds its ${String(maxBytes)} byte safety limit: ${path}`);
|
|
762
|
+
}
|
|
763
|
+
const temporary = join(dirname(path), `.${continuationDigest(path).slice(0, 12)}-${process.pid}-${randomUUID()}.tmp`);
|
|
764
|
+
let handle;
|
|
765
|
+
try {
|
|
766
|
+
handle = await open(temporary, "wx", 0o600);
|
|
767
|
+
await handle.writeFile(body, "utf8");
|
|
768
|
+
await handle.sync();
|
|
769
|
+
await handle.close();
|
|
770
|
+
handle = undefined;
|
|
771
|
+
await rename(temporary, path);
|
|
772
|
+
if (process.platform !== "win32")
|
|
773
|
+
await chmod(path, 0o600);
|
|
774
|
+
await syncDirectory(dirname(path));
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
await handle?.close().catch(() => undefined);
|
|
778
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
class OriginContextCorruptionError extends Error {
|
|
782
|
+
}
|
|
783
|
+
function originContextDigest(canonical) {
|
|
784
|
+
return continuationDigest(`mono-agent-origin-context-v1\0${canonical}`);
|
|
785
|
+
}
|
|
786
|
+
async function readOriginContextCanonical(path, reference) {
|
|
787
|
+
let canonical;
|
|
788
|
+
try {
|
|
789
|
+
const loaded = await readBoundedOwnerOnlyFileWithStats(path, MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES, "Continuation origin context");
|
|
790
|
+
if (loaded.bytes !== reference.bytes) {
|
|
791
|
+
throw new OriginContextCorruptionError("Continuation origin context size does not match its reference.");
|
|
792
|
+
}
|
|
793
|
+
canonical = loaded.text;
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
if (error instanceof OriginContextCorruptionError || isMissing(error))
|
|
797
|
+
throw error;
|
|
798
|
+
throw new OriginContextCorruptionError("Continuation origin context is not a safe owner-only file.", { cause: error });
|
|
799
|
+
}
|
|
800
|
+
if (Buffer.byteLength(canonical, "utf8") !== reference.bytes
|
|
801
|
+
|| originContextDigest(canonical) !== reference.digest) {
|
|
802
|
+
throw new OriginContextCorruptionError("Continuation origin context digest does not match its reference.");
|
|
803
|
+
}
|
|
804
|
+
let parsed;
|
|
805
|
+
try {
|
|
806
|
+
parsed = JSON.parse(canonical);
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
throw new OriginContextCorruptionError("Continuation origin context is not valid JSON.", { cause: error });
|
|
810
|
+
}
|
|
811
|
+
if (canonicalContinuationJson(parsed) !== canonical) {
|
|
812
|
+
throw new OriginContextCorruptionError("Continuation origin context is not canonically encoded.");
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
assertAgentContinuationOriginContext(parsed);
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
throw new OriginContextCorruptionError("Continuation origin context has an invalid schema.", { cause: error });
|
|
819
|
+
}
|
|
820
|
+
if (parsed.messages.length !== reference.messageCount) {
|
|
821
|
+
throw new OriginContextCorruptionError("Continuation origin context message count does not match its reference.");
|
|
822
|
+
}
|
|
823
|
+
return canonical;
|
|
824
|
+
}
|
|
825
|
+
function referencedOriginContextDigests(records) {
|
|
826
|
+
return new Set([...records.values()].flatMap((record) => record.originContextRef === undefined ? [] : [record.originContextRef.digest]));
|
|
827
|
+
}
|
|
828
|
+
function releasePendingOriginPin(pins, digest) {
|
|
829
|
+
const count = pins.get(digest);
|
|
830
|
+
if (count === undefined)
|
|
831
|
+
return;
|
|
832
|
+
if (count <= 1)
|
|
833
|
+
pins.delete(digest);
|
|
834
|
+
else
|
|
835
|
+
pins.set(digest, count - 1);
|
|
836
|
+
}
|
|
837
|
+
async function sweepOriginContextBlobs(directory, referenced, pending) {
|
|
838
|
+
let changed = false;
|
|
839
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
840
|
+
const path = join(directory, entry.name);
|
|
841
|
+
if (entry.name.startsWith(".") && entry.name.endsWith(".tmp")) {
|
|
842
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) {
|
|
843
|
+
throw new Error(`Continuation origin-context temporary is not a regular file: ${path}`);
|
|
844
|
+
}
|
|
845
|
+
await rm(path, { force: true });
|
|
846
|
+
changed = true;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const match = /^([a-f0-9]{64})\.json$/u.exec(entry.name);
|
|
850
|
+
if (match?.[1] !== undefined && !referenced.has(match[1]) && !pending.has(match[1])) {
|
|
851
|
+
// Once no durable record references a blob, unlink it without opening or
|
|
852
|
+
// following it. This also lets the safe-fallback path clean up a blob
|
|
853
|
+
// whose mode/identity was corrupted instead of poisoning record storage.
|
|
854
|
+
await rm(path, { force: true, recursive: true });
|
|
855
|
+
changed = true;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (match?.[1] === undefined) {
|
|
859
|
+
throw new Error(`Unexpected entry in continuation origin-context directory: ${path}`);
|
|
860
|
+
}
|
|
861
|
+
// A referenced blob is untrusted payload, not store metadata. Do not let a
|
|
862
|
+
// corrupt mode, hard link, symlink, or non-file poison startup; the
|
|
863
|
+
// descriptor-stable load path will classify it as unavailable and the
|
|
864
|
+
// service can emit its deterministic zero-model fallback.
|
|
865
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
866
|
+
continue;
|
|
867
|
+
try {
|
|
868
|
+
await assertOwnerOnlyRegularFile(path, "Continuation origin context");
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (changed)
|
|
875
|
+
await syncDirectory(directory);
|
|
876
|
+
}
|
|
877
|
+
async function originContextStoreBytes(directory) {
|
|
878
|
+
let total = 0;
|
|
879
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
880
|
+
if (entry.name.startsWith(".") && entry.name.endsWith(".tmp"))
|
|
881
|
+
continue;
|
|
882
|
+
if (!/^[a-f0-9]{64}\.json$/u.test(entry.name)) {
|
|
883
|
+
throw new Error(`Unexpected entry in continuation origin-context directory: ${join(directory, entry.name)}`);
|
|
884
|
+
}
|
|
885
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
886
|
+
continue;
|
|
887
|
+
const info = await lstat(join(directory, entry.name));
|
|
888
|
+
total += info.size;
|
|
889
|
+
if (total > MAX_CONTINUATION_ORIGIN_CONTEXT_STORE_BYTES) {
|
|
890
|
+
throw new Error("Continuation origin context store exceeds its aggregate byte quota.");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return total;
|
|
894
|
+
}
|
|
395
895
|
async function syncDirectory(path) {
|
|
396
896
|
let directory;
|
|
397
897
|
try {
|
|
@@ -493,6 +993,13 @@ const SETTLED_TERMINAL_STATES = new Set([
|
|
|
493
993
|
"cancelled",
|
|
494
994
|
"dead_lettered",
|
|
495
995
|
]);
|
|
996
|
+
const ORIGIN_CONTEXT_SCRUB_STATES = new Set([
|
|
997
|
+
"delivery_unknown",
|
|
998
|
+
"delivered",
|
|
999
|
+
"expired",
|
|
1000
|
+
"cancelled",
|
|
1001
|
+
"dead_lettered",
|
|
1002
|
+
]);
|
|
496
1003
|
function applyRetention(records, policy, now) {
|
|
497
1004
|
const nowMs = now.getTime();
|
|
498
1005
|
const captures = [...records.values()]
|
|
@@ -504,6 +1011,12 @@ function applyRetention(records, policy, now) {
|
|
|
504
1011
|
.slice(0, policy.capturedTextMaxRecords);
|
|
505
1012
|
const retainedCaptureText = new Set(captures.map((record) => record.continuationId));
|
|
506
1013
|
for (const record of records.values()) {
|
|
1014
|
+
if (ORIGIN_CONTEXT_SCRUB_STATES.has(record.state) && record.originContextRef !== undefined) {
|
|
1015
|
+
record.originContextDigest ??= record.originContextRef.digest;
|
|
1016
|
+
record.originContextMessageCount ??= record.originContextRef.messageCount;
|
|
1017
|
+
delete record.originContextRef;
|
|
1018
|
+
record.originContextState = "scrubbed";
|
|
1019
|
+
}
|
|
507
1020
|
if (!SETTLED_TERMINAL_STATES.has(record.state))
|
|
508
1021
|
continue;
|
|
509
1022
|
if (record.resultPayload !== undefined) {
|
|
@@ -534,7 +1047,7 @@ function newestFirst(left, right) {
|
|
|
534
1047
|
function continuationStoreStats(records, policy) {
|
|
535
1048
|
const values = [...records.values()];
|
|
536
1049
|
return {
|
|
537
|
-
format: "per-record-
|
|
1050
|
+
format: "per-record-v3",
|
|
538
1051
|
records: values.length,
|
|
539
1052
|
active: values.filter((record) => !SETTLED_TERMINAL_STATES.has(record.state) && record.state !== "delivery_unknown").length,
|
|
540
1053
|
unresolvedDelivery: values.filter((record) => record.state === "delivery_unknown").length,
|
|
@@ -570,8 +1083,13 @@ async function continuationPathExists(path) {
|
|
|
570
1083
|
}
|
|
571
1084
|
async function assertOwnerOnlyRegularFile(path, label) {
|
|
572
1085
|
const info = await lstat(path);
|
|
1086
|
+
assertOwnerOnlySingleLinkStats(info, path, label);
|
|
1087
|
+
}
|
|
1088
|
+
function assertOwnerOnlySingleLinkStats(info, path, label) {
|
|
573
1089
|
if (!info.isFile() || info.isSymbolicLink())
|
|
574
1090
|
throw new Error(`${label} is not a regular file: ${path}`);
|
|
1091
|
+
if (info.nlink !== 1)
|
|
1092
|
+
throw new Error(`${label} must have exactly one filesystem link: ${path}`);
|
|
575
1093
|
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
|
576
1094
|
throw new Error(`${label} is not owned by the current user: ${path}`);
|
|
577
1095
|
}
|
|
@@ -580,15 +1098,40 @@ async function assertOwnerOnlyRegularFile(path, label) {
|
|
|
580
1098
|
}
|
|
581
1099
|
}
|
|
582
1100
|
async function readBoundedOwnerOnlyFile(path, maxBytes, label) {
|
|
583
|
-
await
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1101
|
+
return (await readBoundedOwnerOnlyFileWithStats(path, maxBytes, label)).text;
|
|
1102
|
+
}
|
|
1103
|
+
async function readBoundedOwnerOnlyFileWithStats(path, maxBytes, label) {
|
|
1104
|
+
const pathInfo = await lstat(path);
|
|
1105
|
+
assertOwnerOnlySingleLinkStats(pathInfo, path, label);
|
|
1106
|
+
const flags = fsConstants.O_RDONLY
|
|
1107
|
+
| (process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW);
|
|
1108
|
+
let handle;
|
|
1109
|
+
try {
|
|
1110
|
+
handle = await open(path, flags);
|
|
1111
|
+
const before = await handle.stat();
|
|
1112
|
+
assertOwnerOnlySingleLinkStats(before, path, label);
|
|
1113
|
+
if (before.dev !== pathInfo.dev || before.ino !== pathInfo.ino) {
|
|
1114
|
+
throw new Error(`${label} changed identity while it was opened: ${path}`);
|
|
1115
|
+
}
|
|
1116
|
+
if (before.size > maxBytes) {
|
|
1117
|
+
throw new Error(`${label} exceeds its ${String(maxBytes)} byte safety limit: ${path}`);
|
|
1118
|
+
}
|
|
1119
|
+
const body = await handle.readFile();
|
|
1120
|
+
const after = await handle.stat();
|
|
1121
|
+
assertOwnerOnlySingleLinkStats(after, path, label);
|
|
1122
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size
|
|
1123
|
+
|| body.byteLength !== before.size) {
|
|
1124
|
+
throw new Error(`${label} changed while it was being read: ${path}`);
|
|
1125
|
+
}
|
|
1126
|
+
return { text: body.toString("utf8"), bytes: body.byteLength };
|
|
1127
|
+
}
|
|
1128
|
+
finally {
|
|
1129
|
+
await handle?.close().catch(() => undefined);
|
|
1130
|
+
}
|
|
588
1131
|
}
|
|
589
|
-
function isRecordTransaction(value) {
|
|
1132
|
+
function isRecordTransaction(value, expectedSchemaVersion) {
|
|
590
1133
|
if (!isObject(value)
|
|
591
|
-
|| value.schemaVersion !==
|
|
1134
|
+
|| value.schemaVersion !== expectedSchemaVersion
|
|
592
1135
|
|| !requiredString(value.generation)
|
|
593
1136
|
|| !requiredDate(value.createdAt)
|
|
594
1137
|
|| !Array.isArray(value.writes)
|
|
@@ -611,6 +1154,17 @@ function isStoreFile(value) {
|
|
|
611
1154
|
}
|
|
612
1155
|
return Object.entries(value.records).every(([id, record]) => id.length > 0 && isRecord(record, id));
|
|
613
1156
|
}
|
|
1157
|
+
function normalizeLegacyContinuationRecords(records) {
|
|
1158
|
+
for (const record of records.values()) {
|
|
1159
|
+
if (record.originContextState === undefined) {
|
|
1160
|
+
record.originContextState = record.historyBoundary === undefined
|
|
1161
|
+
? "detached_latest"
|
|
1162
|
+
: "legacy_missing";
|
|
1163
|
+
}
|
|
1164
|
+
if (record.synthesisDeferrals === undefined)
|
|
1165
|
+
record.synthesisDeferrals = 0;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
614
1168
|
function isRecord(value, id) {
|
|
615
1169
|
if (!isObject(value))
|
|
616
1170
|
return false;
|
|
@@ -620,6 +1174,18 @@ function isRecord(value, id) {
|
|
|
620
1174
|
&& requiredString(value.originConversationId)
|
|
621
1175
|
&& optionalString(value.replyToConversationId)
|
|
622
1176
|
&& optionalString(value.historyBoundary)
|
|
1177
|
+
&& (value.originContextState === undefined || isOriginContextState(value.originContextState))
|
|
1178
|
+
&& (value.originContextRef === undefined || isOriginContextReference(value.originContextRef))
|
|
1179
|
+
&& (value.originContextDigest === undefined || isSha256(value.originContextDigest))
|
|
1180
|
+
&& (value.originContextMessageCount === undefined
|
|
1181
|
+
|| (Number.isSafeInteger(value.originContextMessageCount)
|
|
1182
|
+
&& Number(value.originContextMessageCount) >= 0
|
|
1183
|
+
&& Number(value.originContextMessageCount) <= MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES))
|
|
1184
|
+
&& (value.originContextFingerprint === undefined || isSha256(value.originContextFingerprint))
|
|
1185
|
+
&& (value.originContextBindingMac === undefined || isSha256(value.originContextBindingMac))
|
|
1186
|
+
&& (value.completionKind === undefined
|
|
1187
|
+
|| value.completionKind === "synthesized"
|
|
1188
|
+
|| value.completionKind === "origin_context_unavailable")
|
|
623
1189
|
&& isContinuationMode(value.mode)
|
|
624
1190
|
&& optionalString(value.routeName)
|
|
625
1191
|
&& requiredString(value.taskKey)
|
|
@@ -632,6 +1198,8 @@ function isRecord(value, id) {
|
|
|
632
1198
|
&& isContinuationState(value.state)
|
|
633
1199
|
&& Number.isInteger(value.synthesisAttempts)
|
|
634
1200
|
&& Number(value.synthesisAttempts) >= 0
|
|
1201
|
+
&& (value.synthesisDeferrals === undefined
|
|
1202
|
+
|| (Number.isSafeInteger(value.synthesisDeferrals) && Number(value.synthesisDeferrals) >= 0))
|
|
635
1203
|
&& Number.isInteger(value.deliveryAttempts)
|
|
636
1204
|
&& Number(value.deliveryAttempts) >= 0
|
|
637
1205
|
&& optionalString(value.resultIdempotencyKey)
|
|
@@ -647,6 +1215,48 @@ function isRecord(value, id) {
|
|
|
647
1215
|
&& (value.lastError === undefined || isLastError(value.lastError))
|
|
648
1216
|
&& (value.receipt === undefined || isReceipt(value.receipt));
|
|
649
1217
|
}
|
|
1218
|
+
function isOriginContextState(value) {
|
|
1219
|
+
return value === "pending"
|
|
1220
|
+
|| value === "pinned"
|
|
1221
|
+
|| value === "abandoned"
|
|
1222
|
+
|| value === "detached_latest"
|
|
1223
|
+
|| value === "legacy_missing"
|
|
1224
|
+
|| value === "scrubbed";
|
|
1225
|
+
}
|
|
1226
|
+
function isOriginContextReference(value) {
|
|
1227
|
+
return isObject(value)
|
|
1228
|
+
&& value.schemaVersion === 1
|
|
1229
|
+
&& isSha256(value.digest)
|
|
1230
|
+
&& Number.isSafeInteger(value.bytes)
|
|
1231
|
+
&& Number(value.bytes) > 0
|
|
1232
|
+
&& Number(value.bytes) <= MAX_CONTINUATION_ORIGIN_CONTEXT_BYTES
|
|
1233
|
+
&& Number.isSafeInteger(value.messageCount)
|
|
1234
|
+
&& Number(value.messageCount) >= 2
|
|
1235
|
+
&& Number(value.messageCount) <= MAX_CONTINUATION_ORIGIN_CONTEXT_MESSAGES;
|
|
1236
|
+
}
|
|
1237
|
+
function isOriginContextGroupCommit(value) {
|
|
1238
|
+
if (!isObject(value)
|
|
1239
|
+
|| value.schemaVersion !== 1
|
|
1240
|
+
|| !isSha256(value.groupKey)
|
|
1241
|
+
|| !requiredString(value.originRunId)
|
|
1242
|
+
|| !requiredString(value.originConversationId)
|
|
1243
|
+
|| !requiredString(value.historyBoundary)
|
|
1244
|
+
|| !isSha256(value.snapshotDigest)
|
|
1245
|
+
|| !Number.isSafeInteger(value.memberCount)
|
|
1246
|
+
|| Number(value.memberCount) < 1
|
|
1247
|
+
|| !isSha256(value.memberSetDigest)
|
|
1248
|
+
|| !requiredDate(value.activatedAt))
|
|
1249
|
+
return false;
|
|
1250
|
+
const expectedKey = continuationDigest(`mono-agent-origin-context-group-v1\0${canonicalContinuationJson({
|
|
1251
|
+
originRunId: value.originRunId,
|
|
1252
|
+
originConversationId: value.originConversationId,
|
|
1253
|
+
historyBoundary: value.historyBoundary,
|
|
1254
|
+
})}`);
|
|
1255
|
+
return value.groupKey === expectedKey;
|
|
1256
|
+
}
|
|
1257
|
+
function isSha256(value) {
|
|
1258
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
1259
|
+
}
|
|
650
1260
|
function isLastError(value) {
|
|
651
1261
|
return isObject(value) && requiredString(value.code) && requiredString(value.reason) && requiredDate(value.at);
|
|
652
1262
|
}
|