@sublang/playbook 9.0.0 → 10.0.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/docs/cli.md +51 -8
- package/docs/embedding.md +38 -12
- package/package.json +7 -3
- package/reference/sdlc/captain.md +14 -10
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
- package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
- package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
- package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
- package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
- package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
- package/reference/sdlc/code.md +0 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
- package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
- package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
- package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
- package/reference/sdlc/code.playbook/bin/run.js +365 -63
- package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
- package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
- package/reference/sdlc/code.playbook/code.fsm.js +74 -25
- package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
- package/reference/sdlc/code.playbook/code.gears.md +0 -2
- package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
- package/reference/sdlc/code.playbook/code.playbook.js +54 -2
- package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
- package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
- package/reference/sdlc/code.playbook/code.registry.js +10 -3
- package/reference/sdlc/code.playbook/code.registry.ts +23 -5
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
- package/reference/sdlc/code.playbook/playbook-captain.js +1850 -72
- package/reference/sdlc/code.playbook/playbook-captain.ts +2759 -96
- package/reference/sdlc/decide.md +0 -1
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
- package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
- package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
- package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
- package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
- package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
- package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
- package/reference/sdlc/review.playbook/review.fsm.js +133 -12
- package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
- package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
- package/reference/sdlc/review.playbook/review.playbook.js +65 -2
- package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
- package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
- package/reference/sdlc/review.playbook/review.registry.js +10 -3
- package/reference/sdlc/review.playbook/review.registry.ts +23 -5
- package/slc/gears2fsm.md +6 -5
- package/slc/link.md +544 -41
- package/src/accepted-outcome.d.ts +18 -0
- package/src/accepted-outcome.js +94 -0
- package/src/accepted-outcome.ts +140 -0
- package/src/runtime.d.ts +164 -3
- package/src/runtime.ts +213 -2
- package/src/xstate-playbook-runtime.d.ts +149 -10
- package/src/xstate-playbook-runtime.js +2569 -270
- package/src/xstate-playbook-runtime.ts +4133 -490
- package/src/xstate-runtime.d.ts +59 -1
- package/src/xstate-runtime.js +866 -7
- package/src/xstate-runtime.ts +1397 -7
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
// PBCLI-23: durable headless Captain sessions use one exact settled/uncertain
|
|
5
5
|
// record union and one exclusive, crash-recoverable lease per logical session.
|
|
6
|
+
// PBCLI-53: a fresh lease may move its newest same-directory predecessor's
|
|
7
|
+
// complete retained-generation map under guarded source-first publication.
|
|
6
8
|
|
|
7
9
|
import { randomUUID } from 'node:crypto';
|
|
8
10
|
import { constants } from 'node:fs';
|
|
@@ -23,11 +25,30 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
23
25
|
import { isDeepStrictEqual } from 'node:util';
|
|
24
26
|
import { assertSupportedEffort } from '@sublang/cligent';
|
|
25
27
|
import { KNOWN_PLAYER_ADAPTERS } from '@sublang/cligent/tmux-play';
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
+
import {
|
|
29
|
+
assertPlaybookEffectLedger,
|
|
30
|
+
assertPlaybookRuntimeSnapshot,
|
|
31
|
+
emptyPlaybookEffectLedger,
|
|
32
|
+
isPlaybookEffectLedgerMonotonicExtension,
|
|
33
|
+
snapshotJsonValue,
|
|
34
|
+
} from '../../../../src/xstate-runtime.js';
|
|
35
|
+
import {
|
|
36
|
+
assertPlaybookCaptainShellSnapshot,
|
|
37
|
+
assertPlaybookCaptainUnresolvedEffects,
|
|
38
|
+
} from '../playbook-captain.js';
|
|
28
39
|
|
|
29
|
-
export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION =
|
|
40
|
+
export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 6;
|
|
30
41
|
export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
|
|
42
|
+
const LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 3;
|
|
43
|
+
// The interrupted retention change emitted this required-member shape before
|
|
44
|
+
// canonical writes returned to additive schema 3. Both pre-effect shapes and
|
|
45
|
+
// the pre-unresolved-effects schema-5 shape are projected in memory only far
|
|
46
|
+
// enough to validate malformed data before the effect-authority cutover
|
|
47
|
+
// classifies them as nonresumable.
|
|
48
|
+
const COMPATIBLE_RETENTION_RECORD_SCHEMA_VERSION = 4;
|
|
49
|
+
const PRE_UNRESOLVED_EFFECTS_RECORD_SCHEMA_VERSION = 5;
|
|
50
|
+
const CURRENT_ARTIFACT_SCHEMAS = new Set([3]);
|
|
51
|
+
const PRE_EFFECT_ARTIFACT_SCHEMAS = new Set([2, 3]);
|
|
31
52
|
export const SESSION_ID_PATTERN =
|
|
32
53
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
33
54
|
export const CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION = 1;
|
|
@@ -36,6 +57,7 @@ export const CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION = 2;
|
|
|
36
57
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
37
58
|
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
38
59
|
const RESERVED_ID = 'captain';
|
|
60
|
+
const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
|
|
39
61
|
const KNOWN_ADAPTERS = new Set(KNOWN_PLAYER_ADAPTERS);
|
|
40
62
|
|
|
41
63
|
const COMMON_RECORD_KEYS = [
|
|
@@ -49,7 +71,12 @@ const COMMON_RECORD_KEYS = [
|
|
|
49
71
|
'structuralProjection',
|
|
50
72
|
'lastAppliedExecutionProjection',
|
|
51
73
|
'snapshot',
|
|
74
|
+
'effectLedger',
|
|
75
|
+
'unresolvedEffects',
|
|
52
76
|
];
|
|
77
|
+
const LEGACY_COMMON_RECORD_KEYS = COMMON_RECORD_KEYS.filter(
|
|
78
|
+
(key) => key !== 'effectLedger' && key !== 'unresolvedEffects',
|
|
79
|
+
);
|
|
53
80
|
const UNCERTAIN_KEYS = [
|
|
54
81
|
'baseUpdatedAt',
|
|
55
82
|
'input',
|
|
@@ -58,6 +85,11 @@ const UNCERTAIN_KEYS = [
|
|
|
58
85
|
'markedAt',
|
|
59
86
|
'attemptedExecutionProjection',
|
|
60
87
|
];
|
|
88
|
+
const UNCERTAIN_ABANDONMENT_KEYS = [
|
|
89
|
+
'phase',
|
|
90
|
+
'rootPlaybookId',
|
|
91
|
+
'unresolvedEffects',
|
|
92
|
+
];
|
|
61
93
|
const RELEASED_SCHEMA_2_COMMON_RECORD_KEYS = [
|
|
62
94
|
'schemaVersion',
|
|
63
95
|
'kind',
|
|
@@ -109,6 +141,26 @@ class CaptainSessionRecordSchemaError extends Error {
|
|
|
109
141
|
}
|
|
110
142
|
}
|
|
111
143
|
|
|
144
|
+
class CaptainSessionRecordNonresumableError extends
|
|
145
|
+
CaptainSessionRecordSchemaError {
|
|
146
|
+
constructor(schemaVersion, message, orderingBoundary, cause) {
|
|
147
|
+
super(schemaVersion, message, cause);
|
|
148
|
+
this.name = 'CaptainSessionRecordNonresumableError';
|
|
149
|
+
this.orderingBoundary = orderingBoundary;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class CaptainSessionLeaseActiveError extends Error {
|
|
154
|
+
constructor(sessionId, pid) {
|
|
155
|
+
super(
|
|
156
|
+
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease: Captain session lease is active in process ${pid}`,
|
|
157
|
+
);
|
|
158
|
+
this.name = 'CaptainSessionLeaseActiveError';
|
|
159
|
+
this.sessionId = sessionId;
|
|
160
|
+
this.pid = pid;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
112
164
|
export function defaultCaptainSessionsDir(
|
|
113
165
|
env = process.env,
|
|
114
166
|
home = env.HOME ?? homedir(),
|
|
@@ -194,12 +246,25 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
194
246
|
`Captain session ${JSON.stringify(sessionId)} at ` +
|
|
195
247
|
`${JSON.stringify(path)}`;
|
|
196
248
|
if (cause instanceof CaptainSessionRecordSchemaError) {
|
|
197
|
-
|
|
249
|
+
const nonresumable =
|
|
250
|
+
cause instanceof CaptainSessionRecordNonresumableError;
|
|
251
|
+
if (
|
|
252
|
+
nonresumable &&
|
|
253
|
+
value.sessionId !== sessionId
|
|
254
|
+
) {
|
|
198
255
|
throw new Error(
|
|
199
256
|
`Captain session file ${JSON.stringify(path)} contains record ` +
|
|
200
257
|
JSON.stringify(value.sessionId),
|
|
201
258
|
);
|
|
202
259
|
}
|
|
260
|
+
if (nonresumable) {
|
|
261
|
+
throw new CaptainSessionRecordNonresumableError(
|
|
262
|
+
cause.schemaVersion,
|
|
263
|
+
`${context}: ${cause.message}`,
|
|
264
|
+
cause.orderingBoundary,
|
|
265
|
+
cause,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
203
268
|
throw new CaptainSessionRecordSchemaError(
|
|
204
269
|
cause.schemaVersion,
|
|
205
270
|
`${context}: ${cause.message}`,
|
|
@@ -221,7 +286,12 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
221
286
|
|
|
222
287
|
const read = (sessionId) => readRecord(sessionId);
|
|
223
288
|
|
|
224
|
-
const
|
|
289
|
+
const listRecords = async ({
|
|
290
|
+
onLegacyRecord,
|
|
291
|
+
onLegacyOrderingBoundary,
|
|
292
|
+
onInvalidRecord,
|
|
293
|
+
skipInvalidRecords = false,
|
|
294
|
+
} = {}) => {
|
|
225
295
|
if (
|
|
226
296
|
onLegacyRecord !== undefined &&
|
|
227
297
|
typeof onLegacyRecord !== 'function'
|
|
@@ -230,13 +300,34 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
230
300
|
'Captain session legacy-record observer must be a function',
|
|
231
301
|
);
|
|
232
302
|
}
|
|
303
|
+
if (
|
|
304
|
+
onLegacyOrderingBoundary !== undefined &&
|
|
305
|
+
typeof onLegacyOrderingBoundary !== 'function'
|
|
306
|
+
) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
'Captain session legacy ordering-boundary observer must be a function',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (
|
|
312
|
+
onInvalidRecord !== undefined &&
|
|
313
|
+
typeof onInvalidRecord !== 'function'
|
|
314
|
+
) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
'Captain session invalid-record observer must be a function',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (typeof skipInvalidRecords !== 'boolean') {
|
|
320
|
+
throw new Error(
|
|
321
|
+
'Captain session invalid-record skip option must be a boolean',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
233
324
|
let names;
|
|
234
325
|
try {
|
|
235
326
|
await assertPrivateDirectory(sessionsDir, fs);
|
|
236
327
|
names = await fs.readdir(sessionsDir);
|
|
237
328
|
} catch (cause) {
|
|
238
329
|
if (cause?.code === 'ENOENT') {
|
|
239
|
-
|
|
330
|
+
return [];
|
|
240
331
|
}
|
|
241
332
|
throw new Error(`cannot list Captain sessions: ${errorMessage(cause)}`);
|
|
242
333
|
}
|
|
@@ -250,15 +341,23 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
250
341
|
try {
|
|
251
342
|
candidates.push(await readRecord(sessionId));
|
|
252
343
|
} catch (error) {
|
|
253
|
-
if (
|
|
254
|
-
error
|
|
255
|
-
error.schemaVersion === 2
|
|
256
|
-
) {
|
|
344
|
+
if (error instanceof CaptainSessionRecordNonresumableError) {
|
|
345
|
+
await onLegacyOrderingBoundary?.(error.orderingBoundary);
|
|
257
346
|
await onLegacyRecord?.(
|
|
258
347
|
Object.freeze({
|
|
259
348
|
sessionId,
|
|
260
349
|
path: recordPathFor(sessionId),
|
|
261
|
-
schemaVersion:
|
|
350
|
+
schemaVersion: error.schemaVersion,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (skipInvalidRecords) {
|
|
356
|
+
await onInvalidRecord?.(
|
|
357
|
+
Object.freeze({
|
|
358
|
+
sessionId,
|
|
359
|
+
path: recordPathFor(sessionId),
|
|
360
|
+
reason: errorMessage(error),
|
|
262
361
|
}),
|
|
263
362
|
);
|
|
264
363
|
continue;
|
|
@@ -266,19 +365,80 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
266
365
|
throw error;
|
|
267
366
|
}
|
|
268
367
|
}
|
|
269
|
-
candidates
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
368
|
+
return candidates;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const latest = async ({ onLegacyRecord, preferredCwd } = {}) => {
|
|
372
|
+
const candidates = sortCaptainSessionRecords(
|
|
373
|
+
await listRecords({ onLegacyRecord }),
|
|
374
|
+
);
|
|
275
375
|
if (candidates.length === 0) {
|
|
276
376
|
throw new Error('no resumable Captain session exists');
|
|
277
377
|
}
|
|
278
|
-
return
|
|
378
|
+
return (
|
|
379
|
+
candidates.find((candidate) => candidate.cwd === preferredCwd) ??
|
|
380
|
+
candidates[0]
|
|
381
|
+
);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const scanAdoptionPredecessor = async (
|
|
385
|
+
target,
|
|
386
|
+
{ onLegacyRecord, onInvalidRecord } = {},
|
|
387
|
+
) => {
|
|
388
|
+
let orderingUnproved = false;
|
|
389
|
+
const legacyOrderingBoundaries = [];
|
|
390
|
+
const observeInvalid = async (record) => {
|
|
391
|
+
orderingUnproved = true;
|
|
392
|
+
await onInvalidRecord?.(record);
|
|
393
|
+
};
|
|
394
|
+
const records = await listRecords({
|
|
395
|
+
onLegacyRecord,
|
|
396
|
+
onLegacyOrderingBoundary: (record) => {
|
|
397
|
+
legacyOrderingBoundaries.push(record);
|
|
398
|
+
},
|
|
399
|
+
onInvalidRecord: observeInvalid,
|
|
400
|
+
skipInvalidRecords: true,
|
|
401
|
+
});
|
|
402
|
+
const resumableCandidates = records.filter(
|
|
403
|
+
(candidate) =>
|
|
404
|
+
candidate.sessionId !== target.sessionId &&
|
|
405
|
+
candidate.state === 'settled' &&
|
|
406
|
+
candidate.cwd === target.cwd,
|
|
407
|
+
);
|
|
408
|
+
const resumableById = new Map(
|
|
409
|
+
resumableCandidates.map((candidate) => [candidate.sessionId, candidate]),
|
|
410
|
+
);
|
|
411
|
+
const orderedBoundaries = sortCaptainSessionRecords([
|
|
412
|
+
...resumableCandidates,
|
|
413
|
+
...legacyOrderingBoundaries.filter(
|
|
414
|
+
(candidate) =>
|
|
415
|
+
candidate.sessionId !== target.sessionId &&
|
|
416
|
+
candidate.state === 'settled' &&
|
|
417
|
+
candidate.cwd === target.cwd,
|
|
418
|
+
),
|
|
419
|
+
]);
|
|
420
|
+
const newestBoundary = orderedBoundaries[0];
|
|
421
|
+
const candidate =
|
|
422
|
+
orderingUnproved || newestBoundary === undefined
|
|
423
|
+
? undefined
|
|
424
|
+
: resumableById.get(newestBoundary.sessionId);
|
|
425
|
+
const adoptionDeclined =
|
|
426
|
+
orderingUnproved ||
|
|
427
|
+
(newestBoundary !== undefined && candidate === undefined);
|
|
428
|
+
// A fully validated legacy record has a trustworthy place in the
|
|
429
|
+
// same-cwd predecessor order even though it cannot resume. Only a record
|
|
430
|
+
// whose contents cannot be validated leaves that order globally unproved.
|
|
431
|
+
return {
|
|
432
|
+
candidate,
|
|
433
|
+
newestBoundaryUpdatedAt: newestBoundary?.updatedAt,
|
|
434
|
+
adoptionDeclined,
|
|
435
|
+
};
|
|
279
436
|
};
|
|
280
437
|
|
|
281
|
-
const writeRecord = async (
|
|
438
|
+
const writeRecord = async (
|
|
439
|
+
recordValue,
|
|
440
|
+
{ noReplace, onPublished },
|
|
441
|
+
) => {
|
|
282
442
|
const record = validateCaptainSessionRecord(recordValue);
|
|
283
443
|
const destination = recordPathFor(record.sessionId);
|
|
284
444
|
await ensurePrivateDirectory(sessionsDir, fs);
|
|
@@ -319,6 +479,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
319
479
|
try {
|
|
320
480
|
await fs.link(temporary, destination);
|
|
321
481
|
published = true;
|
|
482
|
+
onPublished?.();
|
|
322
483
|
} catch (cause) {
|
|
323
484
|
if (cause?.code === 'EEXIST') {
|
|
324
485
|
throw new Error(
|
|
@@ -339,6 +500,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
339
500
|
await fs.rename(temporary, destination);
|
|
340
501
|
ownsTemporary = false;
|
|
341
502
|
published = true;
|
|
503
|
+
onPublished?.();
|
|
342
504
|
}
|
|
343
505
|
await syncDirectory(sessionsDir, fs);
|
|
344
506
|
return record;
|
|
@@ -619,18 +781,13 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
619
781
|
}
|
|
620
782
|
try {
|
|
621
783
|
await probeProcess(existing.pid);
|
|
622
|
-
throw new
|
|
623
|
-
|
|
784
|
+
throw new CaptainSessionLeaseActiveError(
|
|
785
|
+
sessionId,
|
|
786
|
+
existing.pid,
|
|
624
787
|
);
|
|
625
788
|
} catch (cause) {
|
|
626
789
|
if (cause?.code !== 'ESRCH') {
|
|
627
|
-
if (
|
|
628
|
-
cause instanceof Error &&
|
|
629
|
-
cause.message ===
|
|
630
|
-
`Captain session lease is active in process ${existing.pid}`
|
|
631
|
-
) {
|
|
632
|
-
throw cause;
|
|
633
|
-
}
|
|
790
|
+
if (cause instanceof CaptainSessionLeaseActiveError) throw cause;
|
|
634
791
|
throw new Error(
|
|
635
792
|
`Captain session lease owner process cannot be ruled dead: ${errorMessage(cause)}`,
|
|
636
793
|
);
|
|
@@ -651,7 +808,10 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
651
808
|
owner: stage.owner,
|
|
652
809
|
readRecord,
|
|
653
810
|
writeRecord,
|
|
811
|
+
syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
|
|
654
812
|
deleteRecord,
|
|
813
|
+
scanAdoptionPredecessor,
|
|
814
|
+
acquireSession: acquire,
|
|
655
815
|
readLeaseOwner,
|
|
656
816
|
retireObservedLease,
|
|
657
817
|
validateRetiredLeases,
|
|
@@ -677,6 +837,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
677
837
|
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease without leaving ownership uncertain`,
|
|
678
838
|
);
|
|
679
839
|
}
|
|
840
|
+
if (cause instanceof CaptainSessionLeaseActiveError) throw cause;
|
|
680
841
|
throw new Error(
|
|
681
842
|
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease: ${errorMessage(cause)}`,
|
|
682
843
|
);
|
|
@@ -691,7 +852,10 @@ function createLease({
|
|
|
691
852
|
owner,
|
|
692
853
|
readRecord,
|
|
693
854
|
writeRecord,
|
|
855
|
+
syncRecordDirectory,
|
|
694
856
|
deleteRecord,
|
|
857
|
+
scanAdoptionPredecessor,
|
|
858
|
+
acquireSession,
|
|
695
859
|
readLeaseOwner,
|
|
696
860
|
retireObservedLease,
|
|
697
861
|
validateRetiredLeases,
|
|
@@ -699,6 +863,7 @@ function createLease({
|
|
|
699
863
|
}) {
|
|
700
864
|
let released = false;
|
|
701
865
|
let operationActive = false;
|
|
866
|
+
let indeterminateEffectLedgerWrite;
|
|
702
867
|
|
|
703
868
|
const requireActive = () => {
|
|
704
869
|
if (released) throw new Error('Captain session lease was already released');
|
|
@@ -736,47 +901,333 @@ function createLease({
|
|
|
736
901
|
return record;
|
|
737
902
|
});
|
|
738
903
|
|
|
739
|
-
const
|
|
904
|
+
const freshSettledRecord = ({
|
|
740
905
|
cwd,
|
|
741
906
|
structuralProjection,
|
|
742
907
|
executionProjection,
|
|
743
908
|
snapshot,
|
|
744
|
-
} = {}) =>
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
909
|
+
} = {}) => {
|
|
910
|
+
const initial = validateFreshBoundary({
|
|
911
|
+
cwd,
|
|
912
|
+
structuralProjection,
|
|
913
|
+
snapshot,
|
|
914
|
+
});
|
|
915
|
+
const applied = assertCaptainSessionExecutionCompatible(
|
|
916
|
+
initial.structuralProjection,
|
|
917
|
+
executionProjection,
|
|
918
|
+
);
|
|
919
|
+
const createdAt = timestampFrom(now(), 'session timestamp');
|
|
920
|
+
const updatedAt = nextTimestamp(now(), createdAt);
|
|
921
|
+
return validateCaptainSessionRecord({
|
|
922
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
923
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
924
|
+
state: 'settled',
|
|
925
|
+
sessionId,
|
|
926
|
+
createdAt,
|
|
927
|
+
updatedAt,
|
|
928
|
+
cwd: initial.cwd,
|
|
929
|
+
structuralProjection: initial.structuralProjection,
|
|
930
|
+
lastAppliedExecutionProjection: applied,
|
|
931
|
+
snapshot: initial.snapshot,
|
|
932
|
+
effectLedger: emptyPlaybookEffectLedger(),
|
|
933
|
+
unresolvedEffects: [],
|
|
934
|
+
retainedGenerations: {},
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
const laterTimestamp = (left, right) =>
|
|
939
|
+
right !== undefined && Date.parse(right) > Date.parse(left) ? right : left;
|
|
940
|
+
|
|
941
|
+
const emptyFreshTargetAfter = (target, predecessorUpdatedAt) => {
|
|
942
|
+
if (predecessorUpdatedAt === undefined) return target;
|
|
943
|
+
return settledRecordWithRetainedGenerations(
|
|
944
|
+
target,
|
|
945
|
+
{},
|
|
946
|
+
nextTimestamp(
|
|
947
|
+
now(),
|
|
948
|
+
laterTimestamp(target.updatedAt, predecessorUpdatedAt),
|
|
949
|
+
),
|
|
950
|
+
);
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const publishEmptyFreshTarget = async (target) => {
|
|
954
|
+
try {
|
|
955
|
+
await assertOwnerUnchecked();
|
|
956
|
+
await writeRecord(target, {
|
|
957
|
+
noReplace: true,
|
|
750
958
|
});
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
959
|
+
} catch (cause) {
|
|
960
|
+
throw new Error(
|
|
961
|
+
`cannot publish empty Captain session adoption target: ${errorMessage(cause)}`,
|
|
962
|
+
{ cause },
|
|
754
963
|
);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
const useAcquiredSession = async (sourceSessionId, operation) => {
|
|
968
|
+
const sourceLease = await acquireSession(sourceSessionId);
|
|
969
|
+
let result;
|
|
970
|
+
let operationFailed = false;
|
|
971
|
+
let operationError;
|
|
972
|
+
try {
|
|
973
|
+
result = await operation(sourceLease);
|
|
974
|
+
} catch (cause) {
|
|
975
|
+
operationFailed = true;
|
|
976
|
+
operationError = cause;
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
await sourceLease.release();
|
|
980
|
+
} catch (releaseError) {
|
|
981
|
+
if (operationFailed) {
|
|
982
|
+
throw new AggregateError(
|
|
983
|
+
[operationError, releaseError],
|
|
984
|
+
'Captain session adoption predecessor operation failed and its lease could not be released',
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
throw releaseError;
|
|
988
|
+
}
|
|
989
|
+
if (operationFailed) throw operationError;
|
|
990
|
+
return result;
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const useAvailableSession = async (sourceSessionId, operation) => {
|
|
994
|
+
try {
|
|
995
|
+
return await useAcquiredSession(sourceSessionId, operation);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
if (error instanceof CaptainSessionLeaseActiveError) return undefined;
|
|
998
|
+
throw error;
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const initializeSettledWithPredecessor = (options = {}) =>
|
|
1003
|
+
runExclusive(async () => {
|
|
1004
|
+
const target = freshSettledRecord(options);
|
|
1005
|
+
await assertOwnerUnchecked();
|
|
1006
|
+
if (
|
|
1007
|
+
(await readRecord(sessionId, { missing: 'undefined' })) !== undefined
|
|
1008
|
+
) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
`Captain session ${JSON.stringify(sessionId)} already exists`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
const reportedSkippedRecords = new Set();
|
|
1014
|
+
const reportOnce = (observer, label) => {
|
|
1015
|
+
if (observer === undefined) return undefined;
|
|
1016
|
+
if (typeof observer !== 'function') {
|
|
1017
|
+
throw new Error(`Captain session ${label} observer must be a function`);
|
|
1018
|
+
}
|
|
1019
|
+
return async (record) => {
|
|
1020
|
+
const key = `${record.sessionId}\u0000${record.path}`;
|
|
1021
|
+
if (reportedSkippedRecords.has(key)) return;
|
|
1022
|
+
reportedSkippedRecords.add(key);
|
|
1023
|
+
await observer(record);
|
|
1024
|
+
};
|
|
1025
|
+
};
|
|
1026
|
+
const predecessorScanOptions = {
|
|
1027
|
+
onLegacyRecord: reportOnce(
|
|
1028
|
+
options.onLegacyRecord,
|
|
1029
|
+
'legacy-record',
|
|
1030
|
+
),
|
|
1031
|
+
onInvalidRecord: reportOnce(
|
|
1032
|
+
options.onInvalidRecord,
|
|
1033
|
+
'invalid-record',
|
|
1034
|
+
),
|
|
1035
|
+
};
|
|
1036
|
+
const initialScan = await scanAdoptionPredecessor(
|
|
1037
|
+
target,
|
|
1038
|
+
predecessorScanOptions,
|
|
1039
|
+
);
|
|
1040
|
+
if (initialScan.adoptionDeclined) {
|
|
1041
|
+
const emptyTarget = emptyFreshTargetAfter(
|
|
1042
|
+
target,
|
|
1043
|
+
initialScan.newestBoundaryUpdatedAt,
|
|
1044
|
+
);
|
|
1045
|
+
await publishEmptyFreshTarget(emptyTarget);
|
|
1046
|
+
await assertOwnerUnchecked();
|
|
1047
|
+
return emptyTarget;
|
|
1048
|
+
}
|
|
1049
|
+
const selected = initialScan.candidate;
|
|
1050
|
+
if (selected === undefined) {
|
|
1051
|
+
await publishEmptyFreshTarget(target);
|
|
1052
|
+
await assertOwnerUnchecked();
|
|
1053
|
+
return target;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
let result = await useAvailableSession(
|
|
1057
|
+
selected.sessionId,
|
|
1058
|
+
async (sourceLease) => {
|
|
1059
|
+
const source = await sourceLease.read();
|
|
1060
|
+
if (source === undefined) {
|
|
1061
|
+
const currentScan = await scanAdoptionPredecessor(
|
|
1062
|
+
target,
|
|
1063
|
+
predecessorScanOptions,
|
|
1064
|
+
);
|
|
1065
|
+
return {
|
|
1066
|
+
kind: 'declined',
|
|
1067
|
+
predecessorUpdatedAt: laterTimestamp(
|
|
1068
|
+
selected.updatedAt,
|
|
1069
|
+
currentScan.newestBoundaryUpdatedAt,
|
|
1070
|
+
),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
await assertOwnerUnchecked();
|
|
1074
|
+
if (
|
|
1075
|
+
(await readRecord(sessionId, { missing: 'undefined' })) !==
|
|
1076
|
+
undefined
|
|
1077
|
+
) {
|
|
1078
|
+
throw new Error('Captain session adoption target changed');
|
|
1079
|
+
}
|
|
1080
|
+
const currentScan = await scanAdoptionPredecessor(
|
|
1081
|
+
target,
|
|
1082
|
+
predecessorScanOptions,
|
|
1083
|
+
);
|
|
1084
|
+
if (currentScan.candidate?.sessionId !== source.sessionId) {
|
|
1085
|
+
return {
|
|
1086
|
+
kind: 'declined',
|
|
1087
|
+
predecessorUpdatedAt: laterTimestamp(
|
|
1088
|
+
source.updatedAt,
|
|
1089
|
+
currentScan.newestBoundaryUpdatedAt,
|
|
1090
|
+
),
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const retainedGenerations = source.retainedGenerations ?? {};
|
|
1095
|
+
try {
|
|
1096
|
+
assertAdoptionTransferEnvelope(
|
|
1097
|
+
source.structuralProjection,
|
|
1098
|
+
target.structuralProjection,
|
|
1099
|
+
retainedGenerations,
|
|
1100
|
+
source.effectLedger,
|
|
1101
|
+
);
|
|
1102
|
+
} catch {
|
|
1103
|
+
return {
|
|
1104
|
+
kind: 'declined',
|
|
1105
|
+
predecessorUpdatedAt: source.updatedAt,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
if (Object.keys(retainedGenerations).length === 0) {
|
|
1109
|
+
return {
|
|
1110
|
+
kind: 'empty',
|
|
1111
|
+
predecessorUpdatedAt: source.updatedAt,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const sourceNext = settledRecordWithRetainedGenerations(
|
|
1116
|
+
source,
|
|
1117
|
+
{},
|
|
1118
|
+
nextTimestamp(now(), source.updatedAt),
|
|
1119
|
+
);
|
|
1120
|
+
const targetTimestampFloor =
|
|
1121
|
+
Date.parse(target.updatedAt) > Date.parse(sourceNext.updatedAt)
|
|
1122
|
+
? target.updatedAt
|
|
1123
|
+
: sourceNext.updatedAt;
|
|
1124
|
+
const targetNext = settledRecordWithRetainedGenerations(
|
|
1125
|
+
target,
|
|
1126
|
+
retainedGenerations,
|
|
1127
|
+
nextTimestamp(now(), targetTimestampFloor),
|
|
1128
|
+
source.effectLedger,
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
await sourceLease.assertOwner();
|
|
1132
|
+
await assertOwnerUnchecked();
|
|
1133
|
+
let sourcePublished = false;
|
|
1134
|
+
try {
|
|
1135
|
+
await writeRecord(sourceNext, {
|
|
1136
|
+
noReplace: false,
|
|
1137
|
+
onPublished: () => {
|
|
1138
|
+
sourcePublished = true;
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
1141
|
+
} catch (cause) {
|
|
1142
|
+
throw new Error(
|
|
1143
|
+
`cannot clear Captain session adoption predecessor: ${errorMessage(cause)}`,
|
|
1144
|
+
{ cause },
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
if (!sourcePublished) {
|
|
1148
|
+
throw new Error(
|
|
1149
|
+
'Captain session adoption predecessor clear did not publish',
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
await sourceLease.assertOwner();
|
|
1153
|
+
await assertOwnerUnchecked();
|
|
1154
|
+
|
|
1155
|
+
let targetPublished = false;
|
|
1156
|
+
try {
|
|
1157
|
+
await writeRecord(targetNext, {
|
|
1158
|
+
noReplace: true,
|
|
1159
|
+
onPublished: () => {
|
|
1160
|
+
targetPublished = true;
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
} catch (cause) {
|
|
1164
|
+
if (!targetPublished) {
|
|
1165
|
+
try {
|
|
1166
|
+
await sourceLease.assertOwner();
|
|
1167
|
+
await assertOwnerUnchecked();
|
|
1168
|
+
await writeRecord(source, { noReplace: false });
|
|
1169
|
+
await sourceLease.assertOwner();
|
|
1170
|
+
await assertOwnerUnchecked();
|
|
1171
|
+
} catch (rollbackError) {
|
|
1172
|
+
throw new AggregateError(
|
|
1173
|
+
[cause, rollbackError],
|
|
1174
|
+
'Captain session adoption transfer failed and its predecessor could not be restored',
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
`cannot install Captain session adoption generations: ${errorMessage(cause)}`,
|
|
1180
|
+
{ cause },
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
await sourceLease.assertOwner();
|
|
1184
|
+
await assertOwnerUnchecked();
|
|
1185
|
+
return { kind: 'transferred', target: targetNext };
|
|
1186
|
+
},
|
|
1187
|
+
);
|
|
1188
|
+
if (result === undefined) {
|
|
1189
|
+
result = {
|
|
1190
|
+
kind: 'declined',
|
|
1191
|
+
predecessorUpdatedAt: selected.updatedAt,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (result.kind === 'transferred') return result.target;
|
|
1196
|
+
await assertOwnerUnchecked();
|
|
1197
|
+
if (
|
|
1198
|
+
(await readRecord(sessionId, { missing: 'undefined' })) !== undefined
|
|
1199
|
+
) {
|
|
1200
|
+
throw new Error('Captain session adoption target changed');
|
|
1201
|
+
}
|
|
1202
|
+
const emptyTarget = emptyFreshTargetAfter(
|
|
1203
|
+
target,
|
|
1204
|
+
result.predecessorUpdatedAt,
|
|
1205
|
+
);
|
|
1206
|
+
await publishEmptyFreshTarget(emptyTarget);
|
|
1207
|
+
await assertOwnerUnchecked();
|
|
1208
|
+
return emptyTarget;
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
const abandonFreshSettled = ({ expected: value } = {}) =>
|
|
1212
|
+
runExclusive(async () => {
|
|
1213
|
+
const expected = validateCaptainSessionRecord(value);
|
|
1214
|
+
if (expected.sessionId !== sessionId) {
|
|
1215
|
+
throw new Error('fresh Captain session abandonment id is mismatched');
|
|
1216
|
+
}
|
|
1217
|
+
assertFreshAdoptionTarget(expected);
|
|
769
1218
|
await assertOwnerUnchecked();
|
|
770
|
-
await
|
|
1219
|
+
const current = await readRecord(sessionId, { missing: 'undefined' });
|
|
1220
|
+
if (!isDeepStrictEqual(current, expected)) return false;
|
|
771
1221
|
await assertOwnerUnchecked();
|
|
772
|
-
|
|
1222
|
+
await deleteRecord(sessionId);
|
|
1223
|
+
await assertOwnerUnchecked();
|
|
1224
|
+
return true;
|
|
773
1225
|
});
|
|
774
1226
|
|
|
775
1227
|
const beginTurn = ({
|
|
776
1228
|
input,
|
|
777
1229
|
attemptId,
|
|
778
1230
|
attemptedExecutionProjection,
|
|
779
|
-
fresh,
|
|
780
1231
|
} = {}) =>
|
|
781
1232
|
runExclusive(async () => {
|
|
782
1233
|
assertAcceptedInput(input);
|
|
@@ -787,67 +1238,40 @@ function createLease({
|
|
|
787
1238
|
);
|
|
788
1239
|
await assertOwnerUnchecked();
|
|
789
1240
|
const prior = await readRecord(sessionId, { missing: 'undefined' });
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const initial = validateFreshBoundary(fresh);
|
|
796
|
-
const timestamp = timestampFrom(now(), 'session timestamp');
|
|
797
|
-
record = validateCaptainSessionRecord({
|
|
798
|
-
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
799
|
-
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
800
|
-
state: 'uncertain',
|
|
801
|
-
sessionId,
|
|
802
|
-
createdAt: timestamp,
|
|
803
|
-
updatedAt: timestamp,
|
|
804
|
-
cwd: initial.cwd,
|
|
805
|
-
structuralProjection: initial.structuralProjection,
|
|
806
|
-
lastAppliedExecutionProjection: attempted,
|
|
807
|
-
snapshot: initial.snapshot,
|
|
808
|
-
uncertain: {
|
|
809
|
-
baseUpdatedAt: null,
|
|
810
|
-
input,
|
|
811
|
-
attemptId,
|
|
812
|
-
attemptNumber: 1,
|
|
813
|
-
markedAt: timestamp,
|
|
814
|
-
attemptedExecutionProjection: attempted,
|
|
815
|
-
},
|
|
816
|
-
});
|
|
817
|
-
await assertOwnerUnchecked();
|
|
818
|
-
await writeRecord(record, { noReplace: true });
|
|
819
|
-
} else {
|
|
820
|
-
if (prior === undefined) {
|
|
821
|
-
throw new Error('Captain session does not exist for continuation');
|
|
822
|
-
}
|
|
823
|
-
if (prior.state !== 'settled') {
|
|
824
|
-
throw new Error('Captain session already has an uncertain turn');
|
|
825
|
-
}
|
|
826
|
-
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
827
|
-
record = validateCaptainSessionRecord({
|
|
828
|
-
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
829
|
-
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
830
|
-
state: 'uncertain',
|
|
831
|
-
sessionId,
|
|
832
|
-
createdAt: prior.createdAt,
|
|
833
|
-
updatedAt: timestamp,
|
|
834
|
-
cwd: prior.cwd,
|
|
835
|
-
structuralProjection: prior.structuralProjection,
|
|
836
|
-
lastAppliedExecutionProjection:
|
|
837
|
-
prior.lastAppliedExecutionProjection,
|
|
838
|
-
snapshot: prior.snapshot,
|
|
839
|
-
uncertain: {
|
|
840
|
-
baseUpdatedAt: prior.updatedAt,
|
|
841
|
-
input,
|
|
842
|
-
attemptId,
|
|
843
|
-
attemptNumber: 1,
|
|
844
|
-
markedAt: timestamp,
|
|
845
|
-
attemptedExecutionProjection: attempted,
|
|
846
|
-
},
|
|
847
|
-
});
|
|
848
|
-
await assertOwnerUnchecked();
|
|
849
|
-
await writeRecord(record, { noReplace: false });
|
|
1241
|
+
if (prior === undefined) {
|
|
1242
|
+
throw new Error('Captain session does not exist for continuation');
|
|
1243
|
+
}
|
|
1244
|
+
if (prior.state !== 'settled') {
|
|
1245
|
+
throw new Error('Captain session already has an uncertain turn');
|
|
850
1246
|
}
|
|
1247
|
+
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
1248
|
+
const record = validateCaptainSessionRecord({
|
|
1249
|
+
schemaVersion: prior.schemaVersion,
|
|
1250
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
1251
|
+
state: 'uncertain',
|
|
1252
|
+
sessionId,
|
|
1253
|
+
createdAt: prior.createdAt,
|
|
1254
|
+
updatedAt: timestamp,
|
|
1255
|
+
cwd: prior.cwd,
|
|
1256
|
+
structuralProjection: prior.structuralProjection,
|
|
1257
|
+
lastAppliedExecutionProjection:
|
|
1258
|
+
prior.lastAppliedExecutionProjection,
|
|
1259
|
+
snapshot: prior.snapshot,
|
|
1260
|
+
effectLedger: prior.effectLedger,
|
|
1261
|
+
unresolvedEffects: prior.unresolvedEffects,
|
|
1262
|
+
...retainedGenerationsMember(prior),
|
|
1263
|
+
...settledAbandonmentMember(prior),
|
|
1264
|
+
uncertain: {
|
|
1265
|
+
baseUpdatedAt: prior.updatedAt,
|
|
1266
|
+
input,
|
|
1267
|
+
attemptId,
|
|
1268
|
+
attemptNumber: 1,
|
|
1269
|
+
markedAt: timestamp,
|
|
1270
|
+
attemptedExecutionProjection: attempted,
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
await assertOwnerUnchecked();
|
|
1274
|
+
await writeRecord(record, { noReplace: false });
|
|
851
1275
|
await assertOwnerUnchecked();
|
|
852
1276
|
return record;
|
|
853
1277
|
});
|
|
@@ -864,12 +1288,17 @@ function createLease({
|
|
|
864
1288
|
await readRecord(sessionId, { missing: 'undefined' }),
|
|
865
1289
|
expectedAttemptId,
|
|
866
1290
|
);
|
|
1291
|
+
if (Object.hasOwn(prior.uncertain, 'abandonment')) {
|
|
1292
|
+
throw new Error(
|
|
1293
|
+
'Captain session unresolved-effect abandonment must recover before retry',
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
867
1296
|
if (!Number.isSafeInteger(prior.uncertain.attemptNumber + 1)) {
|
|
868
1297
|
throw new Error('Captain session attempt number cannot be incremented');
|
|
869
1298
|
}
|
|
870
1299
|
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
871
1300
|
const record = validateCaptainSessionRecord({
|
|
872
|
-
schemaVersion:
|
|
1301
|
+
schemaVersion: prior.schemaVersion,
|
|
873
1302
|
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
874
1303
|
state: 'uncertain',
|
|
875
1304
|
sessionId,
|
|
@@ -880,6 +1309,10 @@ function createLease({
|
|
|
880
1309
|
lastAppliedExecutionProjection:
|
|
881
1310
|
prior.lastAppliedExecutionProjection,
|
|
882
1311
|
snapshot: prior.snapshot,
|
|
1312
|
+
effectLedger: prior.effectLedger,
|
|
1313
|
+
unresolvedEffects: prior.unresolvedEffects,
|
|
1314
|
+
...retainedGenerationsMember(prior),
|
|
1315
|
+
...settledAbandonmentMember(prior),
|
|
883
1316
|
uncertain: {
|
|
884
1317
|
baseUpdatedAt: prior.uncertain.baseUpdatedAt,
|
|
885
1318
|
input: prior.uncertain.input,
|
|
@@ -896,27 +1329,117 @@ function createLease({
|
|
|
896
1329
|
return record;
|
|
897
1330
|
});
|
|
898
1331
|
|
|
899
|
-
const
|
|
1332
|
+
const requireAbandonmentSettlement = (
|
|
1333
|
+
abandonment,
|
|
1334
|
+
unresolvedEffects,
|
|
1335
|
+
retentionUpdates,
|
|
1336
|
+
snapshot,
|
|
1337
|
+
retainedGenerations,
|
|
1338
|
+
) => {
|
|
1339
|
+
if (
|
|
1340
|
+
!isDeepStrictEqual(
|
|
1341
|
+
abandonment.unresolvedEffects,
|
|
1342
|
+
unresolvedEffects,
|
|
1343
|
+
)
|
|
1344
|
+
) {
|
|
1345
|
+
throw new Error(
|
|
1346
|
+
'Captain session abandonment settlement differs from its durable unresolved effects',
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
if (
|
|
1350
|
+
!retentionUpdates.some(
|
|
1351
|
+
(update) =>
|
|
1352
|
+
update.kind === 'clear' &&
|
|
1353
|
+
update.rootPlaybookId === abandonment.rootPlaybookId,
|
|
1354
|
+
) ||
|
|
1355
|
+
retentionUpdates.some(
|
|
1356
|
+
(update) =>
|
|
1357
|
+
update.kind !== 'clear' ||
|
|
1358
|
+
(update.rootPlaybookId !== abandonment.rootPlaybookId &&
|
|
1359
|
+
Object.hasOwn(retainedGenerations, update.rootPlaybookId)),
|
|
1360
|
+
)
|
|
1361
|
+
) {
|
|
1362
|
+
throw new Error(
|
|
1363
|
+
'Captain session abandonment settlement must clear its durable root without changing another retained generation',
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
if (
|
|
1367
|
+
snapshot.mode !== 'chat' ||
|
|
1368
|
+
snapshot.lastAction !== 'runtime' ||
|
|
1369
|
+
snapshot.lastSettlementStatus !== 'ok'
|
|
1370
|
+
) {
|
|
1371
|
+
throw new Error(
|
|
1372
|
+
'Captain session abandonment settlement requires a successful host-disposal snapshot',
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
const unresolvedEffectAbandonmentInput = (value) => {
|
|
1378
|
+
const input = requireRecord(
|
|
1379
|
+
snapshotJsonValue(value, 'Captain unresolved-effect abandonment'),
|
|
1380
|
+
'Captain unresolved-effect abandonment',
|
|
1381
|
+
);
|
|
1382
|
+
rejectUnknownOrMissingKeys(
|
|
1383
|
+
input,
|
|
1384
|
+
['rootPlaybookId', 'unresolvedEffects'],
|
|
1385
|
+
'Captain unresolved-effect abandonment',
|
|
1386
|
+
);
|
|
1387
|
+
const rootPlaybookId = requireCanonicalNonblank(
|
|
1388
|
+
input.rootPlaybookId,
|
|
1389
|
+
'Captain unresolved-effect abandonment.rootPlaybookId',
|
|
1390
|
+
);
|
|
1391
|
+
const unresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
|
|
1392
|
+
input.unresolvedEffects,
|
|
1393
|
+
);
|
|
1394
|
+
if (unresolvedEffects.length === 0) {
|
|
1395
|
+
throw new Error(
|
|
1396
|
+
'Captain unresolved-effect abandonment requires nonempty unresolved effects',
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
return { rootPlaybookId, unresolvedEffects };
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
const beginUnresolvedEffectAbandonment = (value) =>
|
|
900
1403
|
runExclusive(async () => {
|
|
901
|
-
|
|
1404
|
+
const input = unresolvedEffectAbandonmentInput(value);
|
|
902
1405
|
await assertOwnerUnchecked();
|
|
903
1406
|
const prior = await requireUncertainRecord(
|
|
904
1407
|
await readRecord(sessionId, { missing: 'undefined' }),
|
|
905
|
-
attemptId,
|
|
906
1408
|
);
|
|
907
|
-
|
|
1409
|
+
if (Object.hasOwn(prior.uncertain, 'abandonment')) {
|
|
1410
|
+
const existing = prior.uncertain.abandonment;
|
|
1411
|
+
if (
|
|
1412
|
+
existing.phase === 'started' &&
|
|
1413
|
+
existing.rootPlaybookId === input.rootPlaybookId &&
|
|
1414
|
+
isDeepStrictEqual(existing.unresolvedEffects, input.unresolvedEffects)
|
|
1415
|
+
) {
|
|
1416
|
+
await assertOwnerUnchecked();
|
|
1417
|
+
await syncRecordDirectory();
|
|
1418
|
+
await assertOwnerUnchecked();
|
|
1419
|
+
return prior;
|
|
1420
|
+
}
|
|
1421
|
+
throw new Error(
|
|
1422
|
+
'Captain session unresolved-effect abandonment is already in progress',
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
if (
|
|
1426
|
+
prior.snapshot.mode !== 'engaged.parked' ||
|
|
1427
|
+
prior.snapshot.frames[0]?.playbookId !== input.rootPlaybookId
|
|
1428
|
+
) {
|
|
1429
|
+
throw new Error(
|
|
1430
|
+
'Captain session unresolved-effect abandonment root does not match its durable engagement',
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
908
1433
|
const record = validateCaptainSessionRecord({
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
prior.uncertain.attemptedExecutionProjection,
|
|
919
|
-
snapshot,
|
|
1434
|
+
...prior,
|
|
1435
|
+
uncertain: {
|
|
1436
|
+
...prior.uncertain,
|
|
1437
|
+
abandonment: {
|
|
1438
|
+
phase: 'started',
|
|
1439
|
+
rootPlaybookId: input.rootPlaybookId,
|
|
1440
|
+
unresolvedEffects: input.unresolvedEffects,
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
920
1443
|
});
|
|
921
1444
|
await assertOwnerUnchecked();
|
|
922
1445
|
await writeRecord(record, { noReplace: false });
|
|
@@ -924,24 +1447,384 @@ function createLease({
|
|
|
924
1447
|
return record;
|
|
925
1448
|
});
|
|
926
1449
|
|
|
927
|
-
const
|
|
1450
|
+
const completeUnresolvedEffectAbandonment = (value) =>
|
|
928
1451
|
runExclusive(async () => {
|
|
929
|
-
|
|
1452
|
+
const input = unresolvedEffectAbandonmentInput(value);
|
|
930
1453
|
await assertOwnerUnchecked();
|
|
931
1454
|
const prior = await requireUncertainRecord(
|
|
932
1455
|
await readRecord(sessionId, { missing: 'undefined' }),
|
|
933
|
-
attemptId,
|
|
934
1456
|
);
|
|
935
|
-
|
|
936
|
-
if (
|
|
937
|
-
|
|
1457
|
+
const abandonment = prior.uncertain.abandonment;
|
|
1458
|
+
if (
|
|
1459
|
+
abandonment === undefined ||
|
|
1460
|
+
abandonment.rootPlaybookId !== input.rootPlaybookId ||
|
|
1461
|
+
!isDeepStrictEqual(
|
|
1462
|
+
abandonment.unresolvedEffects,
|
|
1463
|
+
input.unresolvedEffects,
|
|
1464
|
+
)
|
|
1465
|
+
) {
|
|
1466
|
+
throw new Error(
|
|
1467
|
+
'Captain session unresolved-effect abandonment completion differs from its durable start',
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
if (abandonment.phase === 'disposed') {
|
|
1471
|
+
await assertOwnerUnchecked();
|
|
1472
|
+
await syncRecordDirectory();
|
|
1473
|
+
await assertOwnerUnchecked();
|
|
1474
|
+
return prior;
|
|
1475
|
+
}
|
|
1476
|
+
const record = validateCaptainSessionRecord({
|
|
1477
|
+
...prior,
|
|
1478
|
+
unresolvedEffects: input.unresolvedEffects,
|
|
1479
|
+
retainedGenerations: applyRetainedGenerationUpdates(
|
|
1480
|
+
prior.retainedGenerations ?? {},
|
|
1481
|
+
[{ kind: 'clear', rootPlaybookId: input.rootPlaybookId }],
|
|
1482
|
+
prior.structuralProjection,
|
|
1483
|
+
),
|
|
1484
|
+
uncertain: {
|
|
1485
|
+
...prior.uncertain,
|
|
1486
|
+
abandonment: {
|
|
1487
|
+
phase: 'disposed',
|
|
1488
|
+
rootPlaybookId: input.rootPlaybookId,
|
|
1489
|
+
unresolvedEffects: input.unresolvedEffects,
|
|
1490
|
+
},
|
|
1491
|
+
},
|
|
1492
|
+
});
|
|
1493
|
+
await assertOwnerUnchecked();
|
|
1494
|
+
await writeRecord(record, { noReplace: false });
|
|
1495
|
+
await assertOwnerUnchecked();
|
|
1496
|
+
return record;
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
const recoverUnresolvedEffectAbandonment = () =>
|
|
1500
|
+
runExclusive(async () => {
|
|
1501
|
+
await assertOwnerUnchecked();
|
|
1502
|
+
const prior = await readRecord(sessionId, { missing: 'undefined' });
|
|
1503
|
+
if (
|
|
1504
|
+
prior === undefined ||
|
|
1505
|
+
prior.state !== 'uncertain' ||
|
|
1506
|
+
!Object.hasOwn(prior.uncertain, 'abandonment')
|
|
1507
|
+
) {
|
|
1508
|
+
await assertOwnerUnchecked();
|
|
1509
|
+
if (
|
|
1510
|
+
prior?.state === 'settled' &&
|
|
1511
|
+
Object.hasOwn(prior, 'settledAbandonment')
|
|
1512
|
+
) {
|
|
1513
|
+
await syncRecordDirectory();
|
|
1514
|
+
await assertOwnerUnchecked();
|
|
1515
|
+
}
|
|
1516
|
+
return prior;
|
|
1517
|
+
}
|
|
1518
|
+
const abandonment = prior.uncertain.abandonment;
|
|
1519
|
+
const {
|
|
1520
|
+
frames: _frames,
|
|
1521
|
+
retainedEffectReconciliation: _retainedEffectReconciliation,
|
|
1522
|
+
pendingBossQuestions: _pendingBossQuestions,
|
|
1523
|
+
lastError: _lastError,
|
|
1524
|
+
...snapshotCommon
|
|
1525
|
+
} = prior.snapshot;
|
|
1526
|
+
const snapshot = assertPlaybookCaptainShellSnapshot({
|
|
1527
|
+
...snapshotCommon,
|
|
1528
|
+
effectLedger: prior.effectLedger,
|
|
1529
|
+
mode: 'chat',
|
|
1530
|
+
lastAction: 'runtime',
|
|
1531
|
+
lastSettlementStatus: 'ok',
|
|
1532
|
+
});
|
|
1533
|
+
const retainedGenerations = applyRetainedGenerationUpdates(
|
|
1534
|
+
prior.retainedGenerations ?? {},
|
|
1535
|
+
[{ kind: 'clear', rootPlaybookId: abandonment.rootPlaybookId }],
|
|
1536
|
+
prior.structuralProjection,
|
|
1537
|
+
);
|
|
1538
|
+
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
1539
|
+
const record = validateCaptainSessionRecord({
|
|
1540
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
1541
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
1542
|
+
state: 'settled',
|
|
1543
|
+
sessionId,
|
|
1544
|
+
createdAt: prior.createdAt,
|
|
1545
|
+
updatedAt: timestamp,
|
|
1546
|
+
cwd: prior.cwd,
|
|
1547
|
+
structuralProjection: prior.structuralProjection,
|
|
1548
|
+
lastAppliedExecutionProjection:
|
|
1549
|
+
prior.uncertain.attemptedExecutionProjection,
|
|
1550
|
+
snapshot,
|
|
1551
|
+
effectLedger: prior.effectLedger,
|
|
1552
|
+
unresolvedEffects: abandonment.unresolvedEffects,
|
|
1553
|
+
retainedGenerations,
|
|
1554
|
+
settledAbandonment: {
|
|
1555
|
+
phase: 'recovered',
|
|
1556
|
+
attemptId: prior.uncertain.attemptId,
|
|
1557
|
+
rootPlaybookId: abandonment.rootPlaybookId,
|
|
1558
|
+
unresolvedEffects: abandonment.unresolvedEffects,
|
|
1559
|
+
},
|
|
1560
|
+
});
|
|
1561
|
+
await assertOwnerUnchecked();
|
|
1562
|
+
await writeRecord(record, { noReplace: false });
|
|
1563
|
+
await assertOwnerUnchecked();
|
|
1564
|
+
return record;
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
const settle = ({
|
|
1568
|
+
attemptId,
|
|
1569
|
+
snapshot,
|
|
1570
|
+
unresolvedEffects,
|
|
1571
|
+
retentionUpdates = [],
|
|
1572
|
+
} = {}) =>
|
|
1573
|
+
runExclusive(async () => {
|
|
1574
|
+
assertUuid(attemptId, 'Captain session attempt id');
|
|
1575
|
+
const updates = validateRetainedGenerationUpdates(retentionUpdates);
|
|
1576
|
+
const settledUnresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
|
|
1577
|
+
unresolvedEffects,
|
|
1578
|
+
);
|
|
1579
|
+
await assertOwnerUnchecked();
|
|
1580
|
+
const current = await readRecord(sessionId, { missing: 'undefined' });
|
|
1581
|
+
const settledAbandonment =
|
|
1582
|
+
current?.state === 'settled' &&
|
|
1583
|
+
Object.hasOwn(current, 'settledAbandonment')
|
|
1584
|
+
? current.settledAbandonment
|
|
1585
|
+
: undefined;
|
|
1586
|
+
const prior =
|
|
1587
|
+
settledAbandonment === undefined
|
|
1588
|
+
? await requireUncertainRecord(current, attemptId)
|
|
1589
|
+
: undefined;
|
|
1590
|
+
if (
|
|
1591
|
+
settledAbandonment !== undefined &&
|
|
1592
|
+
settledAbandonment.attemptId !== attemptId
|
|
1593
|
+
) {
|
|
1594
|
+
throw new Error(
|
|
1595
|
+
'Captain session abandonment settlement attempt differs from its durable marker',
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
const settledSnapshot = assertPlaybookCaptainShellSnapshot(snapshot);
|
|
1599
|
+
if (settledAbandonment !== undefined) {
|
|
1600
|
+
requireAbandonmentSettlement(
|
|
1601
|
+
settledAbandonment,
|
|
1602
|
+
settledUnresolvedEffects,
|
|
1603
|
+
updates,
|
|
1604
|
+
settledSnapshot,
|
|
1605
|
+
current.retainedGenerations ?? {},
|
|
1606
|
+
);
|
|
1607
|
+
if (
|
|
1608
|
+
!isDeepStrictEqual(
|
|
1609
|
+
settledSnapshot.effectLedger,
|
|
1610
|
+
current.effectLedger,
|
|
1611
|
+
)
|
|
1612
|
+
) {
|
|
1613
|
+
throw new Error(
|
|
1614
|
+
'Captain session settlement snapshot effect ledger differs from its durable ledger',
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
const retainedGenerations = applyRetainedGenerationUpdates(
|
|
1618
|
+
current.retainedGenerations ?? {},
|
|
1619
|
+
updates,
|
|
1620
|
+
current.structuralProjection,
|
|
1621
|
+
);
|
|
1622
|
+
const settlementIsExact =
|
|
1623
|
+
isDeepStrictEqual(current.snapshot, settledSnapshot) &&
|
|
1624
|
+
isDeepStrictEqual(
|
|
1625
|
+
current.unresolvedEffects,
|
|
1626
|
+
settledUnresolvedEffects,
|
|
1627
|
+
) &&
|
|
1628
|
+
isDeepStrictEqual(
|
|
1629
|
+
current.retainedGenerations ?? {},
|
|
1630
|
+
retainedGenerations,
|
|
1631
|
+
);
|
|
1632
|
+
if (
|
|
1633
|
+
settledAbandonment.phase === 'final' &&
|
|
1634
|
+
settlementIsExact
|
|
1635
|
+
) {
|
|
1636
|
+
await assertOwnerUnchecked();
|
|
1637
|
+
await syncRecordDirectory();
|
|
1638
|
+
await assertOwnerUnchecked();
|
|
1639
|
+
return current;
|
|
1640
|
+
}
|
|
1641
|
+
if (settledAbandonment.phase === 'final') {
|
|
1642
|
+
throw new Error(
|
|
1643
|
+
'Captain session abandonment settlement differs from its finalized durable record',
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
const record = validateCaptainSessionRecord({
|
|
1647
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
1648
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
1649
|
+
state: 'settled',
|
|
1650
|
+
sessionId,
|
|
1651
|
+
createdAt: current.createdAt,
|
|
1652
|
+
updatedAt: nextTimestamp(now(), current.updatedAt),
|
|
1653
|
+
cwd: current.cwd,
|
|
1654
|
+
structuralProjection: current.structuralProjection,
|
|
1655
|
+
lastAppliedExecutionProjection:
|
|
1656
|
+
current.lastAppliedExecutionProjection,
|
|
1657
|
+
snapshot: settledSnapshot,
|
|
1658
|
+
effectLedger: current.effectLedger,
|
|
1659
|
+
unresolvedEffects: settledUnresolvedEffects,
|
|
1660
|
+
retainedGenerations,
|
|
1661
|
+
settledAbandonment: {
|
|
1662
|
+
...settledAbandonment,
|
|
1663
|
+
phase: 'final',
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
await assertOwnerUnchecked();
|
|
1667
|
+
await writeRecord(record, { noReplace: false });
|
|
1668
|
+
await assertOwnerUnchecked();
|
|
1669
|
+
return record;
|
|
1670
|
+
}
|
|
1671
|
+
if (Object.hasOwn(prior.uncertain, 'abandonment')) {
|
|
1672
|
+
const abandonment = prior.uncertain.abandonment;
|
|
1673
|
+
if (abandonment.phase !== 'disposed') {
|
|
1674
|
+
throw new Error(
|
|
1675
|
+
'Captain session unresolved-effect abandonment has not completed disposal',
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
requireAbandonmentSettlement(
|
|
1679
|
+
abandonment,
|
|
1680
|
+
settledUnresolvedEffects,
|
|
1681
|
+
updates,
|
|
1682
|
+
settledSnapshot,
|
|
1683
|
+
prior.retainedGenerations ?? {},
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
if (!isDeepStrictEqual(settledSnapshot.effectLedger, prior.effectLedger)) {
|
|
1687
|
+
throw new Error(
|
|
1688
|
+
'Captain session settlement snapshot effect ledger differs from its durable ledger',
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
1692
|
+
const record = validateCaptainSessionRecord({
|
|
1693
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
1694
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
1695
|
+
state: 'settled',
|
|
1696
|
+
sessionId,
|
|
1697
|
+
createdAt: prior.createdAt,
|
|
1698
|
+
updatedAt: timestamp,
|
|
1699
|
+
cwd: prior.cwd,
|
|
1700
|
+
structuralProjection: prior.structuralProjection,
|
|
1701
|
+
lastAppliedExecutionProjection:
|
|
1702
|
+
prior.uncertain.attemptedExecutionProjection,
|
|
1703
|
+
snapshot: settledSnapshot,
|
|
1704
|
+
effectLedger: prior.effectLedger,
|
|
1705
|
+
unresolvedEffects: settledUnresolvedEffects,
|
|
1706
|
+
retainedGenerations: applyRetainedGenerationUpdates(
|
|
1707
|
+
prior.retainedGenerations ?? {},
|
|
1708
|
+
updates,
|
|
1709
|
+
prior.structuralProjection,
|
|
1710
|
+
),
|
|
1711
|
+
...(Object.hasOwn(prior.uncertain, 'abandonment')
|
|
1712
|
+
? {
|
|
1713
|
+
settledAbandonment: {
|
|
1714
|
+
phase: 'final',
|
|
1715
|
+
attemptId: prior.uncertain.attemptId,
|
|
1716
|
+
rootPlaybookId:
|
|
1717
|
+
prior.uncertain.abandonment.rootPlaybookId,
|
|
1718
|
+
unresolvedEffects:
|
|
1719
|
+
prior.uncertain.abandonment.unresolvedEffects,
|
|
1720
|
+
},
|
|
1721
|
+
}
|
|
1722
|
+
: {}),
|
|
1723
|
+
});
|
|
1724
|
+
await assertOwnerUnchecked();
|
|
1725
|
+
await writeRecord(record, { noReplace: false });
|
|
1726
|
+
await assertOwnerUnchecked();
|
|
1727
|
+
return record;
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
const writeEffectLedger = (authorityValue, commandsValue) =>
|
|
1731
|
+
runExclusive(async () => {
|
|
1732
|
+
await assertOwnerUnchecked();
|
|
1733
|
+
const prior = await readRecord(sessionId, { missing: 'undefined' });
|
|
1734
|
+
if (prior === undefined) {
|
|
1735
|
+
throw new Error('Captain session does not exist for effect-ledger write-ahead');
|
|
1736
|
+
}
|
|
1737
|
+
const settledRecovery =
|
|
1738
|
+
prior.state === 'settled' &&
|
|
1739
|
+
prior.snapshot.mode === 'chat' &&
|
|
1740
|
+
(Object.keys(prior.retainedGenerations ?? {}).length > 0 ||
|
|
1741
|
+
prior.unresolvedEffects.length > 0);
|
|
1742
|
+
if (prior.state !== 'uncertain' && !settledRecovery) {
|
|
1743
|
+
throw new Error(
|
|
1744
|
+
'Captain session effect-ledger write-ahead requires an uncertain turn or a settled chat recovery boundary',
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
const authority = validateEffectLedgerAuthority(
|
|
1748
|
+
authorityValue,
|
|
1749
|
+
prior,
|
|
1750
|
+
owner,
|
|
1751
|
+
);
|
|
1752
|
+
const commands = snapshotJsonValue(
|
|
1753
|
+
commandsValue,
|
|
1754
|
+
'effect-ledger write commands',
|
|
1755
|
+
);
|
|
1756
|
+
if (settledRecovery) assertSettledEffectRecoveryCommands(commands);
|
|
1757
|
+
if (
|
|
1758
|
+
indeterminateEffectLedgerWrite !== undefined &&
|
|
1759
|
+
isDeepStrictEqual(commands, indeterminateEffectLedgerWrite.commands) &&
|
|
1760
|
+
isDeepStrictEqual(
|
|
1761
|
+
prior.effectLedger,
|
|
1762
|
+
indeterminateEffectLedgerWrite.ledger,
|
|
1763
|
+
)
|
|
1764
|
+
) {
|
|
1765
|
+
await assertOwnerUnchecked();
|
|
1766
|
+
await syncRecordDirectory();
|
|
1767
|
+
await assertOwnerUnchecked();
|
|
1768
|
+
indeterminateEffectLedgerWrite = undefined;
|
|
1769
|
+
return prior.effectLedger;
|
|
1770
|
+
}
|
|
1771
|
+
indeterminateEffectLedgerWrite = undefined;
|
|
1772
|
+
const nextLedger = applyEffectLedgerCommands(
|
|
1773
|
+
prior.effectLedger,
|
|
1774
|
+
authority,
|
|
1775
|
+
prior.uncertain,
|
|
1776
|
+
commands,
|
|
1777
|
+
);
|
|
1778
|
+
if (isDeepStrictEqual(nextLedger, prior.effectLedger)) {
|
|
1779
|
+
await assertOwnerUnchecked();
|
|
1780
|
+
return prior.effectLedger;
|
|
1781
|
+
}
|
|
1782
|
+
const record = validateCaptainSessionRecord({
|
|
1783
|
+
...prior,
|
|
1784
|
+
effectLedger: nextLedger,
|
|
1785
|
+
...(settledRecovery
|
|
1786
|
+
? { snapshot: { ...prior.snapshot, effectLedger: nextLedger } }
|
|
1787
|
+
: {}),
|
|
1788
|
+
});
|
|
1789
|
+
indeterminateEffectLedgerWrite = {
|
|
1790
|
+
commands,
|
|
1791
|
+
ledger: record.effectLedger,
|
|
1792
|
+
};
|
|
1793
|
+
await assertOwnerUnchecked();
|
|
1794
|
+
await writeRecord(record, { noReplace: false });
|
|
1795
|
+
await assertOwnerUnchecked();
|
|
1796
|
+
indeterminateEffectLedgerWrite = undefined;
|
|
1797
|
+
return record.effectLedger;
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
const discard = ({ attemptId } = {}) =>
|
|
1801
|
+
runExclusive(async () => {
|
|
1802
|
+
assertUuid(attemptId, 'Captain session attempt id');
|
|
1803
|
+
await assertOwnerUnchecked();
|
|
1804
|
+
const prior = await requireUncertainRecord(
|
|
1805
|
+
await readRecord(sessionId, { missing: 'undefined' }),
|
|
1806
|
+
attemptId,
|
|
1807
|
+
);
|
|
1808
|
+
if (Object.hasOwn(prior.uncertain, 'abandonment')) {
|
|
1809
|
+
throw new Error(
|
|
1810
|
+
'Captain session unresolved-effect abandonment must recover before discard',
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
if (!isDeepStrictEqual(prior.effectLedger, prior.snapshot.effectLedger)) {
|
|
1814
|
+
throw new Error(
|
|
1815
|
+
'Captain session uncertain effect ledger differs from its pre-turn checkpoint and cannot be discarded',
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
await assertOwnerUnchecked();
|
|
1819
|
+
if (prior.uncertain.baseUpdatedAt === null) {
|
|
1820
|
+
await deleteRecord(sessionId);
|
|
938
1821
|
await assertOwnerUnchecked();
|
|
939
1822
|
return undefined;
|
|
940
1823
|
}
|
|
941
1824
|
// writeRecord's stable key order reconstructs the exact prior settled
|
|
942
1825
|
// bytes from the baseline carried by the uncertain record.
|
|
943
1826
|
const record = validateCaptainSessionRecord({
|
|
944
|
-
schemaVersion:
|
|
1827
|
+
schemaVersion: prior.schemaVersion,
|
|
945
1828
|
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
946
1829
|
state: 'settled',
|
|
947
1830
|
sessionId,
|
|
@@ -952,6 +1835,10 @@ function createLease({
|
|
|
952
1835
|
lastAppliedExecutionProjection:
|
|
953
1836
|
prior.lastAppliedExecutionProjection,
|
|
954
1837
|
snapshot: prior.snapshot,
|
|
1838
|
+
effectLedger: prior.effectLedger,
|
|
1839
|
+
unresolvedEffects: prior.unresolvedEffects,
|
|
1840
|
+
...retainedGenerationsMember(prior),
|
|
1841
|
+
...settledAbandonmentMember(prior),
|
|
955
1842
|
});
|
|
956
1843
|
await writeRecord(record, { noReplace: false });
|
|
957
1844
|
await assertOwnerUnchecked();
|
|
@@ -969,9 +1856,14 @@ function createLease({
|
|
|
969
1856
|
sessionId,
|
|
970
1857
|
ownerToken: owner.ownerToken,
|
|
971
1858
|
read,
|
|
972
|
-
|
|
1859
|
+
initializeSettledWithPredecessor,
|
|
1860
|
+
abandonFreshSettled,
|
|
973
1861
|
beginTurn,
|
|
974
1862
|
beginRetry,
|
|
1863
|
+
beginUnresolvedEffectAbandonment,
|
|
1864
|
+
completeUnresolvedEffectAbandonment,
|
|
1865
|
+
recoverUnresolvedEffectAbandonment,
|
|
1866
|
+
writeEffectLedger,
|
|
975
1867
|
settle,
|
|
976
1868
|
discard,
|
|
977
1869
|
assertOwner,
|
|
@@ -982,23 +1874,66 @@ function createLease({
|
|
|
982
1874
|
export function validateCaptainSessionExecutionProjection(
|
|
983
1875
|
value,
|
|
984
1876
|
path = 'Captain session execution projection',
|
|
1877
|
+
) {
|
|
1878
|
+
return validateCaptainSessionExecutionProjectionWithSchemas(
|
|
1879
|
+
value,
|
|
1880
|
+
path,
|
|
1881
|
+
CURRENT_ARTIFACT_SCHEMAS,
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function validateCaptainSessionExecutionProjectionWithSchemas(
|
|
1886
|
+
value,
|
|
1887
|
+
path,
|
|
1888
|
+
artifactSchemas,
|
|
985
1889
|
) {
|
|
986
1890
|
const projection = requireRecord(snapshotJsonValue(value, path), path);
|
|
987
|
-
validateCaptainSessionProjection(projection, {
|
|
1891
|
+
validateCaptainSessionProjection(projection, {
|
|
1892
|
+
path,
|
|
1893
|
+
structural: false,
|
|
1894
|
+
artifactSchemas,
|
|
1895
|
+
});
|
|
988
1896
|
return projection;
|
|
989
1897
|
}
|
|
990
1898
|
|
|
991
1899
|
export function validateCaptainSessionStructuralProjection(
|
|
992
1900
|
value,
|
|
993
1901
|
path = 'Captain session structural projection',
|
|
1902
|
+
) {
|
|
1903
|
+
return validateCaptainSessionStructuralProjectionWithSchemas(
|
|
1904
|
+
value,
|
|
1905
|
+
path,
|
|
1906
|
+
CURRENT_ARTIFACT_SCHEMAS,
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function validateCaptainSessionStructuralProjectionWithSchemas(
|
|
1911
|
+
value,
|
|
1912
|
+
path,
|
|
1913
|
+
artifactSchemas,
|
|
994
1914
|
) {
|
|
995
1915
|
const projection = requireRecord(snapshotJsonValue(value, path), path);
|
|
996
|
-
validateCaptainSessionProjection(projection, {
|
|
1916
|
+
validateCaptainSessionProjection(projection, {
|
|
1917
|
+
path,
|
|
1918
|
+
structural: true,
|
|
1919
|
+
artifactSchemas,
|
|
1920
|
+
});
|
|
997
1921
|
return projection;
|
|
998
1922
|
}
|
|
999
1923
|
|
|
1000
1924
|
export function projectCaptainSessionStructure(value) {
|
|
1001
|
-
|
|
1925
|
+
return projectCaptainSessionStructureWithSchemas(
|
|
1926
|
+
value,
|
|
1927
|
+
CURRENT_ARTIFACT_SCHEMAS,
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function projectCaptainSessionStructureWithSchemas(value, artifactSchemas) {
|
|
1932
|
+
const execution = validateCaptainSessionExecutionProjectionWithSchemas(
|
|
1933
|
+
value,
|
|
1934
|
+
'Captain session execution projection',
|
|
1935
|
+
artifactSchemas,
|
|
1936
|
+
);
|
|
1002
1937
|
const fixedAgent = (agent) => ({
|
|
1003
1938
|
adapter: agent.adapter,
|
|
1004
1939
|
...(agent.instruction === undefined
|
|
@@ -1008,49 +1943,72 @@ export function projectCaptainSessionStructure(value) {
|
|
|
1008
1943
|
? {}
|
|
1009
1944
|
: { permissions: agent.permissions }),
|
|
1010
1945
|
});
|
|
1011
|
-
return
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
id,
|
|
1016
|
-
...fixedAgent(agent),
|
|
1017
|
-
})),
|
|
1018
|
-
catalog: Object.fromEntries(
|
|
1019
|
-
Object.entries(execution.catalog).map(([id, item]) => [
|
|
1946
|
+
return validateCaptainSessionStructuralProjectionWithSchemas(
|
|
1947
|
+
{
|
|
1948
|
+
schemaVersion: CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION,
|
|
1949
|
+
captain: fixedAgent(execution.captain),
|
|
1950
|
+
players: execution.players.map(({ id, ...agent }) => ({
|
|
1020
1951
|
id,
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1952
|
+
...fixedAgent(agent),
|
|
1953
|
+
})),
|
|
1954
|
+
catalog: Object.fromEntries(
|
|
1955
|
+
Object.entries(execution.catalog).map(([id, item]) => [
|
|
1956
|
+
id,
|
|
1957
|
+
{
|
|
1958
|
+
id: item.id,
|
|
1959
|
+
from: item.from,
|
|
1960
|
+
manifestCommand: item.manifestCommand,
|
|
1961
|
+
command: item.command,
|
|
1962
|
+
intent: item.intent,
|
|
1963
|
+
artifactSchema: item.artifactSchema,
|
|
1964
|
+
requiredRoleIds: item.requiredRoleIds,
|
|
1965
|
+
concurrentRoleSets: item.concurrentRoleSets,
|
|
1966
|
+
roles: Object.fromEntries(
|
|
1967
|
+
Object.entries(item.roles).map(([roleId, binding]) => [
|
|
1968
|
+
roleId,
|
|
1969
|
+
{ playerId: binding.playerId },
|
|
1970
|
+
]),
|
|
1971
|
+
),
|
|
1972
|
+
options: item.options,
|
|
1973
|
+
},
|
|
1974
|
+
]),
|
|
1975
|
+
),
|
|
1976
|
+
},
|
|
1977
|
+
'Captain session structural projection',
|
|
1978
|
+
artifactSchemas,
|
|
1979
|
+
);
|
|
1041
1980
|
}
|
|
1042
1981
|
|
|
1043
1982
|
export function assertCaptainSessionExecutionCompatible(
|
|
1044
1983
|
structuralProjection,
|
|
1045
1984
|
executionProjection,
|
|
1046
1985
|
) {
|
|
1047
|
-
|
|
1986
|
+
return assertCaptainSessionExecutionCompatibleWithSchemas(
|
|
1987
|
+
structuralProjection,
|
|
1988
|
+
executionProjection,
|
|
1989
|
+
CURRENT_ARTIFACT_SCHEMAS,
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function assertCaptainSessionExecutionCompatibleWithSchemas(
|
|
1994
|
+
structuralProjection,
|
|
1995
|
+
executionProjection,
|
|
1996
|
+
artifactSchemas,
|
|
1997
|
+
) {
|
|
1998
|
+
const structural = validateCaptainSessionStructuralProjectionWithSchemas(
|
|
1048
1999
|
structuralProjection,
|
|
2000
|
+
'Captain session structural projection',
|
|
2001
|
+
artifactSchemas,
|
|
1049
2002
|
);
|
|
1050
|
-
const execution =
|
|
2003
|
+
const execution = validateCaptainSessionExecutionProjectionWithSchemas(
|
|
1051
2004
|
executionProjection,
|
|
2005
|
+
'Captain session execution projection',
|
|
2006
|
+
artifactSchemas,
|
|
2007
|
+
);
|
|
2008
|
+
const projected = projectCaptainSessionStructureWithSchemas(
|
|
2009
|
+
execution,
|
|
2010
|
+
artifactSchemas,
|
|
1052
2011
|
);
|
|
1053
|
-
const projected = projectCaptainSessionStructure(execution);
|
|
1054
2012
|
if (!isDeepStrictEqual(projected, structural)) {
|
|
1055
2013
|
throw new Error(
|
|
1056
2014
|
'Captain session execution projection does not reproduce the stored structural projection',
|
|
@@ -1075,29 +2033,101 @@ export function validateCaptainSessionRecord(value) {
|
|
|
1075
2033
|
snapshotJsonValue(value, 'Captain session record'),
|
|
1076
2034
|
'Captain session record',
|
|
1077
2035
|
);
|
|
1078
|
-
if (record.schemaVersion
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
2036
|
+
if (record.schemaVersion === 2) {
|
|
2037
|
+
assertReleasedSchema2CaptainSessionRecord(record);
|
|
2038
|
+
throw new CaptainSessionRecordNonresumableError(
|
|
2039
|
+
record.schemaVersion,
|
|
2040
|
+
'Captain session record schema 2 has incompatible root-owned player identity; schema 6 is required',
|
|
2041
|
+
captainSessionOrderingBoundary(record),
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
if (
|
|
2045
|
+
record.schemaVersion === LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION ||
|
|
2046
|
+
record.schemaVersion === COMPATIBLE_RETENTION_RECORD_SCHEMA_VERSION
|
|
2047
|
+
) {
|
|
2048
|
+
const projectedRecord = validateCanonicalCaptainSessionRecord(
|
|
2049
|
+
projectPreEffectCaptainSessionRecordForValidation(record),
|
|
2050
|
+
PRE_EFFECT_ARTIFACT_SCHEMAS,
|
|
2051
|
+
);
|
|
2052
|
+
throw new CaptainSessionRecordNonresumableError(
|
|
2053
|
+
record.schemaVersion,
|
|
2054
|
+
`Captain session record schema ${record.schemaVersion} predates the artifact-schema-3 effect-authority cutover and is not resumable`,
|
|
2055
|
+
captainSessionOrderingBoundary(projectedRecord),
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
if (
|
|
2059
|
+
record.schemaVersion === PRE_UNRESOLVED_EFFECTS_RECORD_SCHEMA_VERSION
|
|
2060
|
+
) {
|
|
2061
|
+
const hasUnresolvedEffects = Object.hasOwn(record, 'unresolvedEffects');
|
|
2062
|
+
if (
|
|
2063
|
+
!hasUnresolvedEffects &&
|
|
2064
|
+
(Object.hasOwn(record, 'settledAbandonment') ||
|
|
2065
|
+
(typeof record.uncertain === 'object' &&
|
|
2066
|
+
record.uncertain !== null &&
|
|
2067
|
+
Object.hasOwn(record.uncertain, 'abandonment')))
|
|
2068
|
+
) {
|
|
2069
|
+
throw new Error(
|
|
2070
|
+
'Captain session record schema 5 pre-unresolved-effects shape has an unknown abandonment field',
|
|
1084
2071
|
);
|
|
1085
2072
|
}
|
|
2073
|
+
const projectedRecord = validateCanonicalCaptainSessionRecord(
|
|
2074
|
+
{
|
|
2075
|
+
...record,
|
|
2076
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
2077
|
+
...(hasUnresolvedEffects ? {} : { unresolvedEffects: [] }),
|
|
2078
|
+
},
|
|
2079
|
+
hasUnresolvedEffects
|
|
2080
|
+
? CURRENT_ARTIFACT_SCHEMAS
|
|
2081
|
+
: PRE_EFFECT_ARTIFACT_SCHEMAS,
|
|
2082
|
+
);
|
|
2083
|
+
throw new CaptainSessionRecordNonresumableError(
|
|
2084
|
+
record.schemaVersion,
|
|
2085
|
+
'Captain session record schema 5 predates the canonical schema-6 unresolved-effect settlement boundary for the artifact-schema-3 effect-authority cutover and is not resumable',
|
|
2086
|
+
captainSessionOrderingBoundary(projectedRecord),
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
if (record.schemaVersion !== CAPTAIN_SESSION_RECORD_SCHEMA_VERSION) {
|
|
1086
2090
|
throw new CaptainSessionRecordSchemaError(
|
|
1087
2091
|
record.schemaVersion,
|
|
1088
2092
|
`Captain session record schema ${JSON.stringify(record.schemaVersion)} is not supported`,
|
|
1089
2093
|
);
|
|
1090
2094
|
}
|
|
2095
|
+
return validateCanonicalCaptainSessionRecord(record);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function captainSessionOrderingBoundary(record) {
|
|
2099
|
+
return Object.freeze({
|
|
2100
|
+
sessionId: record.sessionId,
|
|
2101
|
+
state: record.state,
|
|
2102
|
+
cwd: record.cwd,
|
|
2103
|
+
updatedAt: record.updatedAt,
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function validateCanonicalCaptainSessionRecord(
|
|
2108
|
+
record,
|
|
2109
|
+
artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
|
|
2110
|
+
) {
|
|
2111
|
+
record = requireRecord(
|
|
2112
|
+
snapshotJsonValue(record, 'Captain session record'),
|
|
2113
|
+
'Captain session record',
|
|
2114
|
+
);
|
|
1091
2115
|
if (record.state !== 'settled' && record.state !== 'uncertain') {
|
|
1092
2116
|
throw new Error('Captain session record state is not supported');
|
|
1093
2117
|
}
|
|
1094
|
-
|
|
2118
|
+
exactOptionalKeys(
|
|
1095
2119
|
record,
|
|
1096
2120
|
record.state === 'uncertain'
|
|
1097
2121
|
? [...COMMON_RECORD_KEYS, 'uncertain']
|
|
1098
2122
|
: COMMON_RECORD_KEYS,
|
|
2123
|
+
['retainedGenerations', 'settledAbandonment'],
|
|
1099
2124
|
'Captain session record',
|
|
1100
2125
|
);
|
|
2126
|
+
if (record.schemaVersion !== CAPTAIN_SESSION_RECORD_SCHEMA_VERSION) {
|
|
2127
|
+
throw new Error(
|
|
2128
|
+
`Captain session record schemaVersion must be ${CAPTAIN_SESSION_RECORD_SCHEMA_VERSION}`,
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
1101
2131
|
if (record.kind !== CAPTAIN_SESSION_RECORD_KIND) {
|
|
1102
2132
|
throw new Error('Captain session record kind is not supported');
|
|
1103
2133
|
}
|
|
@@ -1118,17 +2148,52 @@ export function validateCaptainSessionRecord(value) {
|
|
|
1118
2148
|
if (resolve(record.cwd) !== record.cwd) {
|
|
1119
2149
|
throw new Error('Captain session record cwd must be normalized');
|
|
1120
2150
|
}
|
|
1121
|
-
const structural =
|
|
2151
|
+
const structural = validateCaptainSessionStructuralProjectionWithSchemas(
|
|
1122
2152
|
record.structuralProjection,
|
|
1123
2153
|
'Captain session record structuralProjection',
|
|
2154
|
+
artifactSchemas,
|
|
1124
2155
|
);
|
|
1125
|
-
const lastApplied =
|
|
2156
|
+
const lastApplied = assertCaptainSessionExecutionCompatibleWithSchemas(
|
|
1126
2157
|
structural,
|
|
1127
2158
|
record.lastAppliedExecutionProjection,
|
|
2159
|
+
artifactSchemas,
|
|
1128
2160
|
);
|
|
1129
2161
|
void lastApplied;
|
|
1130
2162
|
const snapshot = assertPlaybookCaptainShellSnapshot(record.snapshot);
|
|
1131
|
-
assertSnapshotMatchesStructure(snapshot, structural);
|
|
2163
|
+
assertSnapshotMatchesStructure(snapshot, structural, artifactSchemas);
|
|
2164
|
+
const effectLedger = assertPlaybookEffectLedger(
|
|
2165
|
+
record.effectLedger,
|
|
2166
|
+
'Captain session record effectLedger',
|
|
2167
|
+
);
|
|
2168
|
+
const unresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
|
|
2169
|
+
record.unresolvedEffects,
|
|
2170
|
+
);
|
|
2171
|
+
void unresolvedEffects;
|
|
2172
|
+
assertEffectLedgerMatchesCatalog(effectLedger, structural.catalog);
|
|
2173
|
+
if (record.state === 'settled') {
|
|
2174
|
+
if (!isDeepStrictEqual(effectLedger, snapshot.effectLedger)) {
|
|
2175
|
+
throw new Error(
|
|
2176
|
+
'settled Captain session effect ledger differs from its shell snapshot mirror',
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
} else if (
|
|
2180
|
+
!isPlaybookEffectLedgerMonotonicExtension(
|
|
2181
|
+
snapshot.effectLedger,
|
|
2182
|
+
effectLedger,
|
|
2183
|
+
)
|
|
2184
|
+
) {
|
|
2185
|
+
throw new Error(
|
|
2186
|
+
'uncertain Captain session effect ledger is not a monotonic extension of its pre-turn shell snapshot mirror',
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
if (Object.hasOwn(record, 'retainedGenerations')) {
|
|
2190
|
+
validateRetainedGenerations(
|
|
2191
|
+
record.retainedGenerations,
|
|
2192
|
+
structural,
|
|
2193
|
+
effectLedger,
|
|
2194
|
+
artifactSchemas,
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
1132
2197
|
if (
|
|
1133
2198
|
snapshot.captain.sessionId === record.sessionId ||
|
|
1134
2199
|
snapshot.issuedSessionIds.includes(record.sessionId)
|
|
@@ -1143,9 +2208,10 @@ export function validateCaptainSessionRecord(value) {
|
|
|
1143
2208
|
record.uncertain,
|
|
1144
2209
|
'Captain session record uncertain',
|
|
1145
2210
|
);
|
|
1146
|
-
|
|
2211
|
+
exactOptionalKeys(
|
|
1147
2212
|
uncertain,
|
|
1148
2213
|
UNCERTAIN_KEYS,
|
|
2214
|
+
['abandonment'],
|
|
1149
2215
|
'Captain session record uncertain',
|
|
1150
2216
|
);
|
|
1151
2217
|
if (uncertain.baseUpdatedAt !== null) {
|
|
@@ -1174,33 +2240,1077 @@ export function validateCaptainSessionRecord(value) {
|
|
|
1174
2240
|
) {
|
|
1175
2241
|
throw new Error('Captain session attempt number must be a positive integer');
|
|
1176
2242
|
}
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
'uncertain.markedAt',
|
|
2243
|
+
const newBoundaries = effectLedger.boundaries.slice(
|
|
2244
|
+
snapshot.effectLedger.boundaries.length,
|
|
1180
2245
|
);
|
|
1181
|
-
|
|
2246
|
+
const attemptIds = new Map();
|
|
2247
|
+
const attemptNumbersById = new Map();
|
|
2248
|
+
for (const boundary of newBoundaries) {
|
|
2249
|
+
if (boundary.attemptNumber > uncertain.attemptNumber) {
|
|
2250
|
+
throw new Error(
|
|
2251
|
+
'Captain session post-checkpoint effect boundary names a future uncertain attempt',
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
const priorId = attemptIds.get(boundary.attemptNumber);
|
|
2255
|
+
if (priorId !== undefined && priorId !== boundary.attemptId) {
|
|
2256
|
+
throw new Error(
|
|
2257
|
+
'Captain session post-checkpoint effect boundaries use inconsistent ids for one attempt number',
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
const priorNumber = attemptNumbersById.get(boundary.attemptId);
|
|
2261
|
+
if (priorNumber !== undefined && priorNumber !== boundary.attemptNumber) {
|
|
2262
|
+
throw new Error(
|
|
2263
|
+
'Captain session post-checkpoint effect attempts reuse one id across attempt numbers',
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
attemptIds.set(boundary.attemptNumber, boundary.attemptId);
|
|
2267
|
+
attemptNumbersById.set(boundary.attemptId, boundary.attemptNumber);
|
|
2268
|
+
if (
|
|
2269
|
+
boundary.attemptNumber === uncertain.attemptNumber
|
|
2270
|
+
? boundary.attemptId !== uncertain.attemptId
|
|
2271
|
+
: boundary.attemptId === uncertain.attemptId
|
|
2272
|
+
) {
|
|
2273
|
+
throw new Error(
|
|
2274
|
+
'Captain session post-checkpoint effect boundary attempt identity conflicts with the current uncertain marker',
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
const markedAt = canonicalTimestamp(
|
|
2279
|
+
uncertain.markedAt,
|
|
2280
|
+
'uncertain.markedAt',
|
|
2281
|
+
);
|
|
2282
|
+
if (markedAt !== updatedAt) {
|
|
2283
|
+
throw new Error(
|
|
2284
|
+
'Captain session uncertain markedAt must equal updatedAt',
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
const attempted = assertCaptainSessionExecutionCompatibleWithSchemas(
|
|
2288
|
+
structural,
|
|
2289
|
+
uncertain.attemptedExecutionProjection,
|
|
2290
|
+
artifactSchemas,
|
|
2291
|
+
);
|
|
2292
|
+
if (uncertain.baseUpdatedAt === null) {
|
|
2293
|
+
if (!isDeepStrictEqual(lastApplied, attempted)) {
|
|
2294
|
+
throw new Error(
|
|
2295
|
+
'fresh Captain session baseline and attempted execution projections must match',
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
assertTurnZeroSnapshot(
|
|
2299
|
+
snapshot,
|
|
2300
|
+
'fresh Captain session record snapshot',
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
if (Object.hasOwn(uncertain, 'abandonment')) {
|
|
2304
|
+
const abandonment = requireRecord(
|
|
2305
|
+
uncertain.abandonment,
|
|
2306
|
+
'Captain session record uncertain.abandonment',
|
|
2307
|
+
);
|
|
2308
|
+
rejectUnknownOrMissingKeys(
|
|
2309
|
+
abandonment,
|
|
2310
|
+
UNCERTAIN_ABANDONMENT_KEYS,
|
|
2311
|
+
'Captain session record uncertain.abandonment',
|
|
2312
|
+
);
|
|
2313
|
+
if (abandonment.phase !== 'started' && abandonment.phase !== 'disposed') {
|
|
2314
|
+
throw new Error(
|
|
2315
|
+
'Captain session record uncertain.abandonment.phase must be "started" or "disposed"',
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
const rootPlaybookId = requireCanonicalNonblank(
|
|
2319
|
+
abandonment.rootPlaybookId,
|
|
2320
|
+
'Captain session record uncertain.abandonment.rootPlaybookId',
|
|
2321
|
+
);
|
|
2322
|
+
if (!Object.hasOwn(structural.catalog, rootPlaybookId)) {
|
|
2323
|
+
throw new Error(
|
|
2324
|
+
`Captain session unresolved-effect abandonment names unknown stored playbook ${JSON.stringify(rootPlaybookId)}`,
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
if (
|
|
2328
|
+
snapshot.mode !== 'engaged.parked' ||
|
|
2329
|
+
snapshot.frames[0]?.playbookId !== rootPlaybookId
|
|
2330
|
+
) {
|
|
2331
|
+
throw new Error(
|
|
2332
|
+
'Captain session unresolved-effect abandonment root differs from its durable engagement',
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
const abandonmentEffects = assertPlaybookCaptainUnresolvedEffects(
|
|
2336
|
+
abandonment.unresolvedEffects,
|
|
2337
|
+
);
|
|
2338
|
+
if (abandonmentEffects.length === 0) {
|
|
2339
|
+
throw new Error(
|
|
2340
|
+
'Captain session unresolved-effect abandonment requires nonempty unresolved effects',
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
2343
|
+
if (abandonment.phase === 'disposed') {
|
|
2344
|
+
if (!isDeepStrictEqual(unresolvedEffects, abandonmentEffects)) {
|
|
2345
|
+
throw new Error(
|
|
2346
|
+
'Captain session disposed abandonment unresolved effects differ from the record settlement evidence',
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
if (Object.hasOwn(record.retainedGenerations ?? {}, rootPlaybookId)) {
|
|
2350
|
+
throw new Error(
|
|
2351
|
+
'Captain session disposed abandonment retained its cleared root generation',
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
if (Object.hasOwn(record, 'settledAbandonment')) {
|
|
2358
|
+
const settledAbandonment = requireRecord(
|
|
2359
|
+
record.settledAbandonment,
|
|
2360
|
+
'Captain session record settledAbandonment',
|
|
2361
|
+
);
|
|
2362
|
+
rejectUnknownOrMissingKeys(
|
|
2363
|
+
settledAbandonment,
|
|
2364
|
+
['phase', 'attemptId', 'rootPlaybookId', 'unresolvedEffects'],
|
|
2365
|
+
'Captain session record settledAbandonment',
|
|
2366
|
+
);
|
|
2367
|
+
if (
|
|
2368
|
+
settledAbandonment.phase !== 'recovered' &&
|
|
2369
|
+
settledAbandonment.phase !== 'final'
|
|
2370
|
+
) {
|
|
2371
|
+
throw new Error(
|
|
2372
|
+
'Captain session record settledAbandonment.phase must be "recovered" or "final"',
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
assertUuid(
|
|
2376
|
+
settledAbandonment.attemptId,
|
|
2377
|
+
'Captain session record settledAbandonment.attemptId',
|
|
2378
|
+
);
|
|
2379
|
+
const rootPlaybookId = requireCanonicalNonblank(
|
|
2380
|
+
settledAbandonment.rootPlaybookId,
|
|
2381
|
+
'Captain session record settledAbandonment.rootPlaybookId',
|
|
2382
|
+
);
|
|
2383
|
+
if (!Object.hasOwn(structural.catalog, rootPlaybookId)) {
|
|
2384
|
+
throw new Error(
|
|
2385
|
+
`Captain session recovered unresolved-effect abandonment names unknown stored playbook ${JSON.stringify(rootPlaybookId)}`,
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2388
|
+
const settledEffects = assertPlaybookCaptainUnresolvedEffects(
|
|
2389
|
+
settledAbandonment.unresolvedEffects,
|
|
2390
|
+
);
|
|
2391
|
+
if (
|
|
2392
|
+
settledEffects.length === 0 ||
|
|
2393
|
+
!isDeepStrictEqual(unresolvedEffects, settledEffects)
|
|
2394
|
+
) {
|
|
2395
|
+
throw new Error(
|
|
2396
|
+
'Captain session settled unresolved-effect abandonment must preserve its nonempty settlement evidence',
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
if (
|
|
2400
|
+
snapshot.mode !== 'chat' ||
|
|
2401
|
+
snapshot.lastAction !== 'runtime' ||
|
|
2402
|
+
snapshot.lastSettlementStatus !== 'ok' ||
|
|
2403
|
+
Object.hasOwn(record.retainedGenerations ?? {}, rootPlaybookId)
|
|
2404
|
+
) {
|
|
2405
|
+
throw new Error(
|
|
2406
|
+
'Captain session settled unresolved-effect abandonment must carry a successful host-disposal chat snapshot with its root generation cleared',
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
return record;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function projectPreEffectCaptainSessionRecordForValidation(record) {
|
|
2415
|
+
if (record.state !== 'settled' && record.state !== 'uncertain') {
|
|
2416
|
+
throw new Error('Captain session record state is not supported');
|
|
2417
|
+
}
|
|
2418
|
+
const recordKeys =
|
|
2419
|
+
record.schemaVersion === COMPATIBLE_RETENTION_RECORD_SCHEMA_VERSION
|
|
2420
|
+
? [...LEGACY_COMMON_RECORD_KEYS, 'retainedGenerations']
|
|
2421
|
+
: LEGACY_COMMON_RECORD_KEYS;
|
|
2422
|
+
exactOptionalKeys(
|
|
2423
|
+
record,
|
|
2424
|
+
record.state === 'uncertain'
|
|
2425
|
+
? [...recordKeys, 'uncertain']
|
|
2426
|
+
: recordKeys,
|
|
2427
|
+
record.schemaVersion === LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION
|
|
2428
|
+
? ['retainedGenerations']
|
|
2429
|
+
: [],
|
|
2430
|
+
'Captain session record',
|
|
2431
|
+
);
|
|
2432
|
+
validateCaptainSessionStructuralProjectionWithSchemas(
|
|
2433
|
+
record.structuralProjection,
|
|
2434
|
+
'Captain session record structuralProjection',
|
|
2435
|
+
PRE_EFFECT_ARTIFACT_SCHEMAS,
|
|
2436
|
+
);
|
|
2437
|
+
const effectLedger = emptyPlaybookEffectLedger();
|
|
2438
|
+
const migrated = {
|
|
2439
|
+
...record,
|
|
2440
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
2441
|
+
snapshot: migratePreEffectShellSnapshot(record.snapshot, effectLedger),
|
|
2442
|
+
effectLedger,
|
|
2443
|
+
unresolvedEffects: [],
|
|
2444
|
+
...(Object.hasOwn(record, 'retainedGenerations')
|
|
2445
|
+
? {
|
|
2446
|
+
retainedGenerations: migratePreEffectRetainedGenerations(
|
|
2447
|
+
record.retainedGenerations,
|
|
2448
|
+
),
|
|
2449
|
+
}
|
|
2450
|
+
: {}),
|
|
2451
|
+
};
|
|
2452
|
+
return migrated;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function migratePreEffectShellSnapshot(value, effectLedger) {
|
|
2456
|
+
const snapshot = requireRecord(value, 'legacy Captain shell snapshot');
|
|
2457
|
+
if (snapshot.schemaVersion !== 3) {
|
|
2458
|
+
throw new CaptainSessionRecordSchemaError(
|
|
2459
|
+
snapshot.schemaVersion,
|
|
2460
|
+
`legacy Captain shell snapshot schema ${JSON.stringify(snapshot.schemaVersion)} cannot migrate to schema 4`,
|
|
2461
|
+
);
|
|
2462
|
+
}
|
|
2463
|
+
if (Object.hasOwn(snapshot, 'effectLedger')) {
|
|
2464
|
+
throw new CaptainSessionRecordSchemaError(
|
|
2465
|
+
snapshot.schemaVersion,
|
|
2466
|
+
'legacy Captain shell snapshot must not contain an effect ledger',
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
const captain = requireRecord(
|
|
2470
|
+
snapshot.captain,
|
|
2471
|
+
'legacy Captain shell snapshot.captain',
|
|
2472
|
+
);
|
|
2473
|
+
const frames =
|
|
2474
|
+
snapshot.frames === undefined
|
|
2475
|
+
? undefined
|
|
2476
|
+
: Array.isArray(snapshot.frames)
|
|
2477
|
+
? snapshot.frames.map((frame, index) => {
|
|
2478
|
+
const captured = requireRecord(
|
|
2479
|
+
frame,
|
|
2480
|
+
`legacy Captain shell snapshot.frames[${index}]`,
|
|
2481
|
+
);
|
|
2482
|
+
return {
|
|
2483
|
+
...captured,
|
|
2484
|
+
runtime: migratePreEffectRuntimeSnapshot(
|
|
2485
|
+
captured.runtime,
|
|
2486
|
+
`legacy Captain shell snapshot.frames[${index}].runtime`,
|
|
2487
|
+
),
|
|
2488
|
+
};
|
|
2489
|
+
})
|
|
2490
|
+
: snapshot.frames;
|
|
2491
|
+
return {
|
|
2492
|
+
...snapshot,
|
|
2493
|
+
schemaVersion: 4,
|
|
2494
|
+
captain: {
|
|
2495
|
+
...captain,
|
|
2496
|
+
runtime: migratePreEffectRuntimeSnapshot(
|
|
2497
|
+
captain.runtime,
|
|
2498
|
+
'legacy Captain shell snapshot.captain.runtime',
|
|
2499
|
+
),
|
|
2500
|
+
},
|
|
2501
|
+
effectLedger,
|
|
2502
|
+
...(frames === undefined ? {} : { frames }),
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
function migratePreEffectRuntimeSnapshot(value, path) {
|
|
2507
|
+
const snapshot = requireRecord(value, path);
|
|
2508
|
+
if (snapshot.schemaVersion !== 3) {
|
|
2509
|
+
throw new CaptainSessionRecordSchemaError(
|
|
2510
|
+
snapshot.schemaVersion,
|
|
2511
|
+
`${path} schema ${JSON.stringify(snapshot.schemaVersion)} cannot migrate to schema 4`,
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
if (Object.hasOwn(snapshot, 'effectLedger')) {
|
|
2515
|
+
throw new CaptainSessionRecordSchemaError(
|
|
2516
|
+
snapshot.schemaVersion,
|
|
2517
|
+
`${path} must not contain an effect ledger`,
|
|
2518
|
+
);
|
|
2519
|
+
}
|
|
2520
|
+
return {
|
|
2521
|
+
...snapshot,
|
|
2522
|
+
schemaVersion: 4,
|
|
2523
|
+
effectLedger: emptyPlaybookEffectLedger(),
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
function migratePreEffectRetainedGenerations(value) {
|
|
2528
|
+
const retained = requireRecord(
|
|
2529
|
+
value,
|
|
2530
|
+
'legacy Captain session retainedGenerations',
|
|
2531
|
+
);
|
|
2532
|
+
return Object.fromEntries(
|
|
2533
|
+
Object.entries(retained).map(([rootPlaybookId, rawGeneration]) => {
|
|
2534
|
+
const path =
|
|
2535
|
+
'legacy Captain session retainedGenerations' +
|
|
2536
|
+
`[${JSON.stringify(rootPlaybookId)}]`;
|
|
2537
|
+
const generation = requireRecord(rawGeneration, path);
|
|
2538
|
+
if (
|
|
2539
|
+
Object.hasOwn(generation, 'effectLedger') ||
|
|
2540
|
+
Object.hasOwn(generation, 'retainedEffectReconciliation')
|
|
2541
|
+
) {
|
|
2542
|
+
throw new Error(
|
|
2543
|
+
`${path} must not contain pre-migration effect evidence`,
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
if (!Array.isArray(generation.frames)) {
|
|
2547
|
+
return [
|
|
2548
|
+
rootPlaybookId,
|
|
2549
|
+
{ ...generation, effectLedger: emptyPlaybookEffectLedger() },
|
|
2550
|
+
];
|
|
2551
|
+
}
|
|
2552
|
+
return [
|
|
2553
|
+
rootPlaybookId,
|
|
2554
|
+
{
|
|
2555
|
+
...generation,
|
|
2556
|
+
effectLedger: emptyPlaybookEffectLedger(),
|
|
2557
|
+
frames: generation.frames.map((rawFrame, index) => {
|
|
2558
|
+
const frame = requireRecord(rawFrame, `${path}.frames[${index}]`);
|
|
2559
|
+
return {
|
|
2560
|
+
...frame,
|
|
2561
|
+
runtime: migratePreEffectRuntimeSnapshot(
|
|
2562
|
+
frame.runtime,
|
|
2563
|
+
`${path}.frames[${index}].runtime`,
|
|
2564
|
+
),
|
|
2565
|
+
};
|
|
2566
|
+
}),
|
|
2567
|
+
},
|
|
2568
|
+
];
|
|
2569
|
+
}),
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
const EFFECT_AUTHORITY_KEYS = [
|
|
2574
|
+
'playbookId',
|
|
2575
|
+
'artifactSchema',
|
|
2576
|
+
'cwd',
|
|
2577
|
+
'sessionId',
|
|
2578
|
+
'leaseOwnerToken',
|
|
2579
|
+
'canonicalWorktree',
|
|
2580
|
+
'requiredRoleIds',
|
|
2581
|
+
'concurrentRoleSets',
|
|
2582
|
+
];
|
|
2583
|
+
const EFFECT_BOUNDARY_START_KEYS = [
|
|
2584
|
+
'boundaryId',
|
|
2585
|
+
'playbookId',
|
|
2586
|
+
'runtimeSessionId',
|
|
2587
|
+
'turnId',
|
|
2588
|
+
'callId',
|
|
2589
|
+
'roleId',
|
|
2590
|
+
'sourceStateId',
|
|
2591
|
+
'sourceOutcomeSchema',
|
|
2592
|
+
'dispositions',
|
|
2593
|
+
'canonicalWorktree',
|
|
2594
|
+
'baseline',
|
|
2595
|
+
'correctionBudget',
|
|
2596
|
+
];
|
|
2597
|
+
const EFFECT_BOUNDARY_IDENTITY_KEYS = [
|
|
2598
|
+
'sequence',
|
|
2599
|
+
'boundaryId',
|
|
2600
|
+
'attemptId',
|
|
2601
|
+
'attemptNumber',
|
|
2602
|
+
'playbookId',
|
|
2603
|
+
'runtimeSessionId',
|
|
2604
|
+
'turnId',
|
|
2605
|
+
'callId',
|
|
2606
|
+
'roleId',
|
|
2607
|
+
'sourceStateId',
|
|
2608
|
+
'sourceOutcomeSchema',
|
|
2609
|
+
'dispositions',
|
|
2610
|
+
'canonicalWorktree',
|
|
2611
|
+
'baseline',
|
|
2612
|
+
];
|
|
2613
|
+
const EFFECT_BOUNDARY_OPTIONAL_KEYS = [
|
|
2614
|
+
'after',
|
|
2615
|
+
'physicalReceipt',
|
|
2616
|
+
'finalText',
|
|
2617
|
+
'semanticCandidate',
|
|
2618
|
+
'initialSemanticCandidate',
|
|
2619
|
+
'cohortId',
|
|
2620
|
+
'logicalOperationId',
|
|
2621
|
+
];
|
|
2622
|
+
const EFFECT_LOGICAL_START_KEYS = [
|
|
2623
|
+
'operationId',
|
|
2624
|
+
'playbookId',
|
|
2625
|
+
'runtimeSessionId',
|
|
2626
|
+
'boundaryIds',
|
|
2627
|
+
'originalBaseline',
|
|
2628
|
+
'checkpointRestorationEligible',
|
|
2629
|
+
];
|
|
2630
|
+
const EFFECT_LOGICAL_OPTIONAL_KEYS = [
|
|
2631
|
+
'checkpoint',
|
|
2632
|
+
'pendingQuestion',
|
|
2633
|
+
'playerContinuation',
|
|
2634
|
+
'logicalReceipt',
|
|
2635
|
+
];
|
|
2636
|
+
|
|
2637
|
+
function assertEffectLedgerMatchesCatalog(ledger, catalog) {
|
|
2638
|
+
const cohorts = new Map();
|
|
2639
|
+
for (const boundary of ledger.boundaries) {
|
|
2640
|
+
const entry = catalog[boundary.playbookId];
|
|
2641
|
+
if (
|
|
2642
|
+
entry?.artifactSchema !== 3 ||
|
|
2643
|
+
!entry.requiredRoleIds.includes(boundary.roleId)
|
|
2644
|
+
) {
|
|
2645
|
+
throw new Error(
|
|
2646
|
+
`Captain session effect boundary ${JSON.stringify(boundary.boundaryId)} does not match its stored schema-3 catalog role`,
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
if (boundary.cohortId === undefined) continue;
|
|
2650
|
+
const members = cohorts.get(boundary.cohortId) ?? [];
|
|
2651
|
+
members.push(boundary);
|
|
2652
|
+
cohorts.set(boundary.cohortId, members);
|
|
2653
|
+
}
|
|
2654
|
+
for (const [cohortId, members] of cohorts) {
|
|
2655
|
+
const entry = catalog[members[0].playbookId];
|
|
2656
|
+
const roles = members.map((boundary) => boundary.roleId);
|
|
2657
|
+
if (
|
|
2658
|
+
!members.every(
|
|
2659
|
+
(boundary) => boundary.playbookId === members[0].playbookId,
|
|
2660
|
+
) ||
|
|
2661
|
+
!entry.concurrentRoleSets.some((candidate) =>
|
|
2662
|
+
isDeepStrictEqual(candidate, roles),
|
|
2663
|
+
)
|
|
2664
|
+
) {
|
|
2665
|
+
throw new Error(
|
|
2666
|
+
`Captain session effect cohort ${JSON.stringify(cohortId)} is not one stored declared concurrent role set`,
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
function validateEffectLedgerAuthority(value, record, owner) {
|
|
2673
|
+
const authority = requireRecord(
|
|
2674
|
+
snapshotJsonValue(value, 'effect-ledger write authority'),
|
|
2675
|
+
'effect-ledger write authority',
|
|
2676
|
+
);
|
|
2677
|
+
rejectUnknownOrMissingKeys(
|
|
2678
|
+
authority,
|
|
2679
|
+
EFFECT_AUTHORITY_KEYS,
|
|
2680
|
+
'effect-ledger write authority',
|
|
2681
|
+
);
|
|
2682
|
+
const identity = requireRecord(
|
|
2683
|
+
authority.canonicalWorktree,
|
|
2684
|
+
'effect-ledger write authority.canonicalWorktree',
|
|
2685
|
+
);
|
|
2686
|
+
rejectUnknownOrMissingKeys(
|
|
2687
|
+
identity,
|
|
2688
|
+
['worktree', 'gitDir'],
|
|
2689
|
+
'effect-ledger write authority.canonicalWorktree',
|
|
2690
|
+
);
|
|
2691
|
+
const entry = record.structuralProjection.catalog[authority.playbookId];
|
|
2692
|
+
if (
|
|
2693
|
+
authority.artifactSchema !== 3 ||
|
|
2694
|
+
authority.sessionId !== record.sessionId ||
|
|
2695
|
+
authority.sessionId !== owner.sessionId ||
|
|
2696
|
+
authority.leaseOwnerToken !== owner.ownerToken ||
|
|
2697
|
+
authority.cwd !== record.cwd ||
|
|
2698
|
+
entry?.artifactSchema !== 3 ||
|
|
2699
|
+
entry.id !== authority.playbookId ||
|
|
2700
|
+
!isDeepStrictEqual(authority.requiredRoleIds, entry.requiredRoleIds) ||
|
|
2701
|
+
!isDeepStrictEqual(
|
|
2702
|
+
authority.concurrentRoleSets,
|
|
2703
|
+
entry.concurrentRoleSets,
|
|
2704
|
+
)
|
|
2705
|
+
) {
|
|
2706
|
+
throw new Error(
|
|
2707
|
+
'effect-ledger write authority does not match the current Captain session lease and stored schema-3 playbook',
|
|
2708
|
+
);
|
|
2709
|
+
}
|
|
2710
|
+
for (const [key, path] of [
|
|
2711
|
+
['worktree', 'effect-ledger write authority canonical worktree'],
|
|
2712
|
+
['gitDir', 'effect-ledger write authority canonical Git directory'],
|
|
2713
|
+
]) {
|
|
2714
|
+
if (
|
|
2715
|
+
typeof identity[key] !== 'string' ||
|
|
2716
|
+
!isAbsolute(identity[key]) ||
|
|
2717
|
+
resolve(identity[key]) !== identity[key]
|
|
2718
|
+
) {
|
|
2719
|
+
throw new Error(`${path} must be a normalized absolute path`);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
return authority;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
function applyEffectLedgerCommands(
|
|
2726
|
+
ledgerValue,
|
|
2727
|
+
authority,
|
|
2728
|
+
uncertain,
|
|
2729
|
+
commandsValue,
|
|
2730
|
+
) {
|
|
2731
|
+
const previous = assertPlaybookEffectLedger(
|
|
2732
|
+
ledgerValue,
|
|
2733
|
+
'Captain session effect ledger',
|
|
2734
|
+
);
|
|
2735
|
+
const commands = requireNonemptyBatch(
|
|
2736
|
+
snapshotJsonValue(commandsValue, 'effect-ledger write commands'),
|
|
2737
|
+
'effect-ledger write commands',
|
|
2738
|
+
);
|
|
2739
|
+
let candidate = previous;
|
|
2740
|
+
for (const [index, commandValue] of commands.entries()) {
|
|
2741
|
+
candidate = applyEffectLedgerCommand(
|
|
2742
|
+
candidate,
|
|
2743
|
+
authority,
|
|
2744
|
+
uncertain,
|
|
2745
|
+
commandValue,
|
|
2746
|
+
`effect-ledger write commands[${index}]`,
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
if (isDeepStrictEqual(candidate, previous)) return previous;
|
|
2750
|
+
return validateNextEffectLedger(previous, {
|
|
2751
|
+
...candidate,
|
|
2752
|
+
revision: previous.revision + 1,
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function assertSettledEffectRecoveryCommands(commands) {
|
|
2757
|
+
if (!Array.isArray(commands) || commands.length === 0) {
|
|
2758
|
+
throw new Error(
|
|
2759
|
+
'settled Captain session effect-ledger recovery requires boundary completion evidence',
|
|
2760
|
+
);
|
|
2761
|
+
}
|
|
2762
|
+
for (const command of commands) {
|
|
2763
|
+
if (
|
|
2764
|
+
command === null ||
|
|
2765
|
+
typeof command !== 'object' ||
|
|
2766
|
+
Array.isArray(command) ||
|
|
2767
|
+
command.kind !== 'replace-boundaries' ||
|
|
2768
|
+
!Array.isArray(command.replacements) ||
|
|
2769
|
+
command.replacements.length === 0
|
|
2770
|
+
) {
|
|
2771
|
+
throw new Error(
|
|
2772
|
+
'settled Captain session effect-ledger recovery may only complete existing physical boundaries',
|
|
2773
|
+
);
|
|
2774
|
+
}
|
|
2775
|
+
for (const replacement of command.replacements) {
|
|
2776
|
+
const expected = replacement?.expected;
|
|
2777
|
+
const next = replacement?.next;
|
|
2778
|
+
if (
|
|
2779
|
+
expected === null ||
|
|
2780
|
+
typeof expected !== 'object' ||
|
|
2781
|
+
Array.isArray(expected) ||
|
|
2782
|
+
next === null ||
|
|
2783
|
+
typeof next !== 'object' ||
|
|
2784
|
+
Array.isArray(next) ||
|
|
2785
|
+
expected.physicalReceipt !== undefined ||
|
|
2786
|
+
next.physicalReceipt === undefined
|
|
2787
|
+
) {
|
|
2788
|
+
throw new Error(
|
|
2789
|
+
'settled Captain session effect-ledger recovery requires one new physical receipt for each incomplete boundary',
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
2792
|
+
const preserved = { ...next };
|
|
2793
|
+
delete preserved.after;
|
|
2794
|
+
delete preserved.physicalReceipt;
|
|
2795
|
+
if (!isDeepStrictEqual(preserved, expected)) {
|
|
2796
|
+
throw new Error(
|
|
2797
|
+
'settled Captain session effect-ledger recovery cannot change boundary semantics or identity',
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
function applyEffectLedgerCommand(
|
|
2805
|
+
ledger,
|
|
2806
|
+
authority,
|
|
2807
|
+
uncertain,
|
|
2808
|
+
commandValue,
|
|
2809
|
+
path,
|
|
2810
|
+
) {
|
|
2811
|
+
const command = requireRecord(
|
|
2812
|
+
commandValue,
|
|
2813
|
+
path,
|
|
2814
|
+
);
|
|
2815
|
+
if (command.kind === 'start-boundaries') {
|
|
2816
|
+
rejectUnknownOrMissingKeys(
|
|
2817
|
+
command,
|
|
2818
|
+
['kind', 'boundaries'],
|
|
2819
|
+
path,
|
|
2820
|
+
);
|
|
2821
|
+
return startEffectBoundaries(ledger, authority, uncertain, command.boundaries);
|
|
2822
|
+
}
|
|
2823
|
+
if (command.kind === 'replace-boundaries') {
|
|
2824
|
+
rejectUnknownOrMissingKeys(
|
|
2825
|
+
command,
|
|
2826
|
+
['kind', 'replacements'],
|
|
2827
|
+
path,
|
|
2828
|
+
);
|
|
2829
|
+
return replaceEffectBoundaries(ledger, authority, command.replacements);
|
|
2830
|
+
}
|
|
2831
|
+
if (command.kind === 'append-logical-operations') {
|
|
2832
|
+
rejectUnknownOrMissingKeys(
|
|
2833
|
+
command,
|
|
2834
|
+
['kind', 'operations'],
|
|
2835
|
+
path,
|
|
2836
|
+
);
|
|
2837
|
+
return appendEffectLogicalOperations(
|
|
2838
|
+
ledger,
|
|
2839
|
+
authority,
|
|
2840
|
+
command.operations,
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
if (command.kind === 'replace-logical-operations') {
|
|
2844
|
+
rejectUnknownOrMissingKeys(
|
|
2845
|
+
command,
|
|
2846
|
+
['kind', 'replacements'],
|
|
2847
|
+
path,
|
|
2848
|
+
);
|
|
2849
|
+
return replaceEffectLogicalOperations(
|
|
2850
|
+
ledger,
|
|
2851
|
+
authority,
|
|
2852
|
+
command.replacements,
|
|
2853
|
+
);
|
|
2854
|
+
}
|
|
2855
|
+
throw new Error(
|
|
2856
|
+
`${path}.kind ${JSON.stringify(command.kind)} is not supported`,
|
|
2857
|
+
);
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function requireNonemptyBatch(value, path) {
|
|
2861
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
2862
|
+
throw new Error(`${path} must be a nonempty array`);
|
|
2863
|
+
}
|
|
2864
|
+
return value;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
function startEffectBoundaries(ledger, authority, uncertain, value) {
|
|
2868
|
+
const inputs = requireNonemptyBatch(
|
|
2869
|
+
value,
|
|
2870
|
+
'effect-ledger start command boundaries',
|
|
2871
|
+
).map((raw, index) => {
|
|
2872
|
+
const path = `effect-ledger start command boundaries[${index}]`;
|
|
2873
|
+
const input = requireRecord(raw, path);
|
|
2874
|
+
exactOptionalKeys(
|
|
2875
|
+
input,
|
|
2876
|
+
EFFECT_BOUNDARY_START_KEYS,
|
|
2877
|
+
['cohortId', 'logicalOperationId'],
|
|
2878
|
+
path,
|
|
2879
|
+
);
|
|
2880
|
+
assertBoundaryAuthority(input, authority, path);
|
|
2881
|
+
return input;
|
|
2882
|
+
});
|
|
2883
|
+
assertDistinctCommandIdentities(inputs, 'boundaryId', 'boundary start');
|
|
2884
|
+
const existingById = new Map(
|
|
2885
|
+
ledger.boundaries.map((boundary) => [boundary.boundaryId, boundary]),
|
|
2886
|
+
);
|
|
2887
|
+
const existing = inputs.map((input) => existingById.get(input.boundaryId));
|
|
2888
|
+
if (existing.some((boundary) => boundary !== undefined)) {
|
|
2889
|
+
if (
|
|
2890
|
+
existing.every((boundary) => boundary !== undefined) &&
|
|
2891
|
+
existing.every((boundary, index) => {
|
|
2892
|
+
const payload = { ...boundary };
|
|
2893
|
+
const sequence = payload.sequence;
|
|
2894
|
+
delete payload.sequence;
|
|
2895
|
+
delete payload.attemptId;
|
|
2896
|
+
delete payload.attemptNumber;
|
|
2897
|
+
return (
|
|
2898
|
+
sequence === ledger.boundaries.length - inputs.length + index + 1 &&
|
|
2899
|
+
isDeepStrictEqual(payload, inputs[index])
|
|
2900
|
+
);
|
|
2901
|
+
})
|
|
2902
|
+
) {
|
|
2903
|
+
return ledger;
|
|
2904
|
+
}
|
|
2905
|
+
throw new Error(
|
|
2906
|
+
'effect-ledger boundary start reuses an existing boundary id with different data',
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
const cohorts = new Map();
|
|
2910
|
+
for (const input of inputs) {
|
|
2911
|
+
if (input.cohortId === undefined) continue;
|
|
2912
|
+
const members = cohorts.get(input.cohortId) ?? [];
|
|
2913
|
+
members.push(input);
|
|
2914
|
+
cohorts.set(input.cohortId, members);
|
|
2915
|
+
}
|
|
2916
|
+
for (const [cohortId, members] of cohorts) {
|
|
2917
|
+
if (
|
|
2918
|
+
members.length < 2 ||
|
|
2919
|
+
!authority.concurrentRoleSets.some((roles) =>
|
|
2920
|
+
isDeepStrictEqual(
|
|
2921
|
+
roles,
|
|
2922
|
+
members.map((boundary) => boundary.roleId),
|
|
2923
|
+
),
|
|
2924
|
+
) ||
|
|
2925
|
+
ledger.boundaries.some((boundary) => boundary.cohortId === cohortId)
|
|
2926
|
+
) {
|
|
2927
|
+
throw new Error(
|
|
2928
|
+
`effect-ledger boundary cohort ${JSON.stringify(cohortId)} is not one new declared concurrent role set`,
|
|
2929
|
+
);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
let sequence = ledger.boundaries.at(-1)?.sequence ?? 0;
|
|
2933
|
+
const additions = inputs.map((input) => ({
|
|
2934
|
+
sequence: ++sequence,
|
|
2935
|
+
boundaryId: input.boundaryId,
|
|
2936
|
+
attemptId: uncertain.attemptId,
|
|
2937
|
+
attemptNumber: uncertain.attemptNumber,
|
|
2938
|
+
playbookId: input.playbookId,
|
|
2939
|
+
runtimeSessionId: input.runtimeSessionId,
|
|
2940
|
+
turnId: input.turnId,
|
|
2941
|
+
callId: input.callId,
|
|
2942
|
+
roleId: input.roleId,
|
|
2943
|
+
sourceStateId: input.sourceStateId,
|
|
2944
|
+
sourceOutcomeSchema: input.sourceOutcomeSchema,
|
|
2945
|
+
dispositions: input.dispositions,
|
|
2946
|
+
canonicalWorktree: input.canonicalWorktree,
|
|
2947
|
+
baseline: input.baseline,
|
|
2948
|
+
correctionBudget: input.correctionBudget,
|
|
2949
|
+
...(input.cohortId === undefined ? {} : { cohortId: input.cohortId }),
|
|
2950
|
+
...(input.logicalOperationId === undefined
|
|
2951
|
+
? {}
|
|
2952
|
+
: { logicalOperationId: input.logicalOperationId }),
|
|
2953
|
+
}));
|
|
2954
|
+
return {
|
|
2955
|
+
...ledger,
|
|
2956
|
+
boundaries: [...ledger.boundaries, ...additions],
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
function replaceEffectBoundaries(ledger, authority, value) {
|
|
2961
|
+
const replacements = captureReplacements(
|
|
2962
|
+
value,
|
|
2963
|
+
'boundaryId',
|
|
2964
|
+
'effect-ledger boundary replacement command',
|
|
2965
|
+
);
|
|
2966
|
+
const byId = new Map(
|
|
2967
|
+
ledger.boundaries.map((boundary, index) => [
|
|
2968
|
+
boundary.boundaryId,
|
|
2969
|
+
{ boundary, index },
|
|
2970
|
+
]),
|
|
2971
|
+
);
|
|
2972
|
+
let alreadyApplied = true;
|
|
2973
|
+
const nextBoundaries = [...ledger.boundaries];
|
|
2974
|
+
for (const [index, replacement] of replacements.entries()) {
|
|
2975
|
+
const path = `effect-ledger boundary replacement command.replacements[${index}]`;
|
|
2976
|
+
assertBoundaryCommandShape(replacement.expected, `${path}.expected`);
|
|
2977
|
+
assertBoundaryCommandShape(replacement.next, `${path}.next`);
|
|
2978
|
+
assertBoundaryAuthority(replacement.expected, authority, `${path}.expected`);
|
|
2979
|
+
assertBoundaryAuthority(replacement.next, authority, `${path}.next`);
|
|
2980
|
+
assertMonotonicBoundaryReplacement(
|
|
2981
|
+
replacement.expected,
|
|
2982
|
+
replacement.next,
|
|
2983
|
+
path,
|
|
2984
|
+
);
|
|
2985
|
+
const current = byId.get(replacement.expected.boundaryId);
|
|
2986
|
+
if (current === undefined) {
|
|
2987
|
+
throw new Error(`${path} names an absent boundary`);
|
|
2988
|
+
}
|
|
2989
|
+
if (!isDeepStrictEqual(current.boundary, replacement.expected)) {
|
|
2990
|
+
throw new Error(`${path}.expected does not match the durable boundary`);
|
|
2991
|
+
}
|
|
2992
|
+
if (isDeepStrictEqual(current.boundary, replacement.next)) continue;
|
|
2993
|
+
alreadyApplied = false;
|
|
2994
|
+
nextBoundaries[current.index] = replacement.next;
|
|
2995
|
+
}
|
|
2996
|
+
if (alreadyApplied) return ledger;
|
|
2997
|
+
return {
|
|
2998
|
+
...ledger,
|
|
2999
|
+
boundaries: nextBoundaries,
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function appendEffectLogicalOperations(ledger, authority, value) {
|
|
3004
|
+
const inputs = requireNonemptyBatch(
|
|
3005
|
+
value,
|
|
3006
|
+
'effect-ledger logical-operation append command operations',
|
|
3007
|
+
).map((raw, index) => {
|
|
3008
|
+
const path = `effect-ledger logical-operation append command operations[${index}]`;
|
|
3009
|
+
const input = requireRecord(raw, path);
|
|
3010
|
+
exactOptionalKeys(
|
|
3011
|
+
input,
|
|
3012
|
+
EFFECT_LOGICAL_START_KEYS,
|
|
3013
|
+
EFFECT_LOGICAL_OPTIONAL_KEYS,
|
|
3014
|
+
path,
|
|
3015
|
+
);
|
|
3016
|
+
assertLogicalOperationAuthority(input, authority, path);
|
|
3017
|
+
return input;
|
|
3018
|
+
});
|
|
3019
|
+
assertDistinctCommandIdentities(
|
|
3020
|
+
inputs,
|
|
3021
|
+
'operationId',
|
|
3022
|
+
'logical-operation append',
|
|
3023
|
+
);
|
|
3024
|
+
const existingById = new Map(
|
|
3025
|
+
ledger.logicalOperations.map((operation) => [
|
|
3026
|
+
operation.operationId,
|
|
3027
|
+
operation,
|
|
3028
|
+
]),
|
|
3029
|
+
);
|
|
3030
|
+
const existing = inputs.map((input) => existingById.get(input.operationId));
|
|
3031
|
+
if (existing.some((operation) => operation !== undefined)) {
|
|
3032
|
+
if (
|
|
3033
|
+
existing.every((operation) => operation !== undefined) &&
|
|
3034
|
+
existing.every((operation, index) => {
|
|
3035
|
+
const { sequence, ...payload } = operation;
|
|
3036
|
+
return (
|
|
3037
|
+
sequence ===
|
|
3038
|
+
ledger.logicalOperations.length - inputs.length + index + 1 &&
|
|
3039
|
+
isDeepStrictEqual(payload, inputs[index])
|
|
3040
|
+
);
|
|
3041
|
+
})
|
|
3042
|
+
) {
|
|
3043
|
+
return ledger;
|
|
3044
|
+
}
|
|
3045
|
+
throw new Error(
|
|
3046
|
+
'effect-ledger logical-operation append reuses an existing operation id with different data',
|
|
3047
|
+
);
|
|
3048
|
+
}
|
|
3049
|
+
let sequence = ledger.logicalOperations.at(-1)?.sequence ?? 0;
|
|
3050
|
+
const additions = inputs.map((input) => ({ sequence: ++sequence, ...input }));
|
|
3051
|
+
return {
|
|
3052
|
+
...ledger,
|
|
3053
|
+
logicalOperations: [...ledger.logicalOperations, ...additions],
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function replaceEffectLogicalOperations(ledger, authority, value) {
|
|
3058
|
+
const replacements = captureReplacements(
|
|
3059
|
+
value,
|
|
3060
|
+
'operationId',
|
|
3061
|
+
'effect-ledger logical-operation replacement command',
|
|
3062
|
+
);
|
|
3063
|
+
const byId = new Map(
|
|
3064
|
+
ledger.logicalOperations.map((operation, index) => [
|
|
3065
|
+
operation.operationId,
|
|
3066
|
+
{ operation, index },
|
|
3067
|
+
]),
|
|
3068
|
+
);
|
|
3069
|
+
let alreadyApplied = true;
|
|
3070
|
+
const nextOperations = [...ledger.logicalOperations];
|
|
3071
|
+
for (const [index, replacement] of replacements.entries()) {
|
|
3072
|
+
const path = `effect-ledger logical-operation replacement command.replacements[${index}]`;
|
|
3073
|
+
assertLogicalOperationCommandShape(
|
|
3074
|
+
replacement.expected,
|
|
3075
|
+
`${path}.expected`,
|
|
3076
|
+
);
|
|
3077
|
+
assertLogicalOperationCommandShape(replacement.next, `${path}.next`);
|
|
3078
|
+
assertLogicalOperationAuthority(
|
|
3079
|
+
replacement.expected,
|
|
3080
|
+
authority,
|
|
3081
|
+
`${path}.expected`,
|
|
3082
|
+
);
|
|
3083
|
+
assertLogicalOperationAuthority(
|
|
3084
|
+
replacement.next,
|
|
3085
|
+
authority,
|
|
3086
|
+
`${path}.next`,
|
|
3087
|
+
);
|
|
3088
|
+
assertMonotonicLogicalReplacement(
|
|
3089
|
+
replacement.expected,
|
|
3090
|
+
replacement.next,
|
|
3091
|
+
path,
|
|
3092
|
+
);
|
|
3093
|
+
const current = byId.get(replacement.expected.operationId);
|
|
3094
|
+
if (current === undefined) {
|
|
3095
|
+
throw new Error(`${path} names an absent logical operation`);
|
|
3096
|
+
}
|
|
3097
|
+
if (!isDeepStrictEqual(current.operation, replacement.expected)) {
|
|
3098
|
+
throw new Error(`${path}.expected does not match the durable logical operation`);
|
|
3099
|
+
}
|
|
3100
|
+
if (isDeepStrictEqual(current.operation, replacement.next)) continue;
|
|
3101
|
+
alreadyApplied = false;
|
|
3102
|
+
nextOperations[current.index] = replacement.next;
|
|
3103
|
+
}
|
|
3104
|
+
if (alreadyApplied) return ledger;
|
|
3105
|
+
return {
|
|
3106
|
+
...ledger,
|
|
3107
|
+
logicalOperations: nextOperations,
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function captureReplacements(value, identityKey, path) {
|
|
3112
|
+
const replacements = requireNonemptyBatch(value, `${path}.replacements`).map(
|
|
3113
|
+
(raw, index) => {
|
|
3114
|
+
const replacementPath = `${path}.replacements[${index}]`;
|
|
3115
|
+
const replacement = requireRecord(raw, replacementPath);
|
|
3116
|
+
rejectUnknownOrMissingKeys(
|
|
3117
|
+
replacement,
|
|
3118
|
+
['expected', 'next'],
|
|
3119
|
+
replacementPath,
|
|
3120
|
+
);
|
|
3121
|
+
const expected = requireRecord(
|
|
3122
|
+
replacement.expected,
|
|
3123
|
+
`${replacementPath}.expected`,
|
|
3124
|
+
);
|
|
3125
|
+
const next = requireRecord(replacement.next, `${replacementPath}.next`);
|
|
3126
|
+
if (expected[identityKey] !== next[identityKey]) {
|
|
3127
|
+
throw new Error(
|
|
3128
|
+
`${replacementPath} cannot change ${identityKey}`,
|
|
3129
|
+
);
|
|
3130
|
+
}
|
|
3131
|
+
return { expected, next };
|
|
3132
|
+
},
|
|
3133
|
+
);
|
|
3134
|
+
assertDistinctCommandIdentities(
|
|
3135
|
+
replacements.map(({ expected }) => expected),
|
|
3136
|
+
identityKey,
|
|
3137
|
+
path,
|
|
3138
|
+
);
|
|
3139
|
+
return replacements;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
function assertBoundaryAuthority(boundary, authority, path) {
|
|
3143
|
+
if (
|
|
3144
|
+
boundary.playbookId !== authority.playbookId ||
|
|
3145
|
+
!isDeepStrictEqual(
|
|
3146
|
+
boundary.canonicalWorktree,
|
|
3147
|
+
authority.canonicalWorktree,
|
|
3148
|
+
) ||
|
|
3149
|
+
!authority.requiredRoleIds.includes(boundary.roleId)
|
|
3150
|
+
) {
|
|
3151
|
+
throw new Error(`${path} does not match its schema-3 host authority`);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
function assertBoundaryCommandShape(boundary, path) {
|
|
3156
|
+
exactOptionalKeys(
|
|
3157
|
+
boundary,
|
|
3158
|
+
[...EFFECT_BOUNDARY_IDENTITY_KEYS, 'correctionBudget'],
|
|
3159
|
+
EFFECT_BOUNDARY_OPTIONAL_KEYS,
|
|
3160
|
+
path,
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
function assertLogicalOperationAuthority(operation, authority, path) {
|
|
3165
|
+
if (operation.playbookId !== authority.playbookId) {
|
|
3166
|
+
throw new Error(`${path} does not match its schema-3 host authority`);
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
function assertLogicalOperationCommandShape(operation, path) {
|
|
3171
|
+
exactOptionalKeys(
|
|
3172
|
+
operation,
|
|
3173
|
+
['sequence', ...EFFECT_LOGICAL_START_KEYS],
|
|
3174
|
+
EFFECT_LOGICAL_OPTIONAL_KEYS,
|
|
3175
|
+
path,
|
|
3176
|
+
);
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
function assertDistinctCommandIdentities(values, key, label) {
|
|
3180
|
+
const identities = values.map((value) => value[key]);
|
|
3181
|
+
if (new Set(identities).size !== identities.length) {
|
|
3182
|
+
throw new Error(`effect-ledger ${label} contains duplicate ${key}`);
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
function assertMonotonicBoundaryReplacement(expected, next, path) {
|
|
3187
|
+
for (const key of EFFECT_BOUNDARY_IDENTITY_KEYS) {
|
|
3188
|
+
if (!isDeepStrictEqual(expected[key], next[key])) {
|
|
3189
|
+
throw new Error(`${path}.next cannot change boundary field ${key}`);
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
for (const key of [
|
|
3193
|
+
'after',
|
|
3194
|
+
'physicalReceipt',
|
|
3195
|
+
'finalText',
|
|
3196
|
+
'initialSemanticCandidate',
|
|
3197
|
+
]) {
|
|
3198
|
+
if (
|
|
3199
|
+
expected[key] !== undefined &&
|
|
3200
|
+
!isDeepStrictEqual(expected[key], next[key])
|
|
3201
|
+
) {
|
|
3202
|
+
throw new Error(`${path}.next cannot remove or replace boundary field ${key}`);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
if (
|
|
3206
|
+
expected.cohortId !== next.cohortId
|
|
3207
|
+
) {
|
|
3208
|
+
throw new Error(`${path}.next cannot change boundary field cohortId`);
|
|
3209
|
+
}
|
|
3210
|
+
if (
|
|
3211
|
+
expected.logicalOperationId !== undefined &&
|
|
3212
|
+
expected.logicalOperationId !== next.logicalOperationId
|
|
3213
|
+
) {
|
|
3214
|
+
throw new Error(
|
|
3215
|
+
`${path}.next cannot remove or replace boundary field logicalOperationId`,
|
|
3216
|
+
);
|
|
3217
|
+
}
|
|
3218
|
+
const expectedBudget = requireRecord(
|
|
3219
|
+
expected.correctionBudget,
|
|
3220
|
+
`${path}.expected.correctionBudget`,
|
|
3221
|
+
);
|
|
3222
|
+
const nextBudget = requireRecord(
|
|
3223
|
+
next.correctionBudget,
|
|
3224
|
+
`${path}.next.correctionBudget`,
|
|
3225
|
+
);
|
|
3226
|
+
if (
|
|
3227
|
+
expectedBudget.limit !== nextBudget.limit ||
|
|
3228
|
+
(expectedBudget.spent === true && nextBudget.spent !== true)
|
|
3229
|
+
) {
|
|
3230
|
+
throw new Error(`${path}.next cannot replenish its correction budget`);
|
|
3231
|
+
}
|
|
3232
|
+
const candidateChanged =
|
|
3233
|
+
expected.semanticCandidate !== undefined &&
|
|
3234
|
+
!isDeepStrictEqual(
|
|
3235
|
+
expected.semanticCandidate,
|
|
3236
|
+
next.semanticCandidate,
|
|
3237
|
+
);
|
|
3238
|
+
if (expected.semanticCandidate === undefined) {
|
|
3239
|
+
if (next.initialSemanticCandidate !== undefined) {
|
|
3240
|
+
throw new Error(
|
|
3241
|
+
`${path}.next cannot add initialSemanticCandidate without replacing a prior candidate`,
|
|
3242
|
+
);
|
|
3243
|
+
}
|
|
3244
|
+
} else if (!candidateChanged) {
|
|
3245
|
+
if (
|
|
3246
|
+
expected.initialSemanticCandidate === undefined &&
|
|
3247
|
+
next.initialSemanticCandidate !== undefined
|
|
3248
|
+
) {
|
|
1182
3249
|
throw new Error(
|
|
1183
|
-
|
|
3250
|
+
`${path}.next cannot add initialSemanticCandidate without replacing semanticCandidate`,
|
|
1184
3251
|
);
|
|
1185
3252
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
3253
|
+
} else if (
|
|
3254
|
+
next.semanticCandidate === undefined ||
|
|
3255
|
+
expected.initialSemanticCandidate !== undefined ||
|
|
3256
|
+
expectedBudget.spent !== true ||
|
|
3257
|
+
nextBudget.spent !== true ||
|
|
3258
|
+
!isDeepStrictEqual(
|
|
3259
|
+
next.initialSemanticCandidate,
|
|
3260
|
+
expected.semanticCandidate,
|
|
3261
|
+
)
|
|
3262
|
+
) {
|
|
3263
|
+
throw new Error(
|
|
3264
|
+
`${path}.next cannot replace semanticCandidate without its spent one-way correction provenance`,
|
|
1189
3265
|
);
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
function assertMonotonicLogicalReplacement(expected, next, path) {
|
|
3270
|
+
for (const key of [
|
|
3271
|
+
'sequence',
|
|
3272
|
+
'operationId',
|
|
3273
|
+
'playbookId',
|
|
3274
|
+
'runtimeSessionId',
|
|
3275
|
+
'originalBaseline',
|
|
3276
|
+
]) {
|
|
3277
|
+
if (!isDeepStrictEqual(expected[key], next[key])) {
|
|
3278
|
+
throw new Error(`${path}.next cannot change logical-operation field ${key}`);
|
|
1200
3279
|
}
|
|
1201
3280
|
}
|
|
3281
|
+
if (
|
|
3282
|
+
!Array.isArray(expected.boundaryIds) ||
|
|
3283
|
+
!Array.isArray(next.boundaryIds) ||
|
|
3284
|
+
expected.boundaryIds.some(
|
|
3285
|
+
(boundaryId, index) => next.boundaryIds[index] !== boundaryId,
|
|
3286
|
+
)
|
|
3287
|
+
) {
|
|
3288
|
+
throw new Error(
|
|
3289
|
+
`${path}.next boundaryIds must preserve the existing ordered prefix`,
|
|
3290
|
+
);
|
|
3291
|
+
}
|
|
3292
|
+
if (
|
|
3293
|
+
expected.logicalReceipt !== undefined &&
|
|
3294
|
+
!isDeepStrictEqual(expected.logicalReceipt, next.logicalReceipt)
|
|
3295
|
+
) {
|
|
3296
|
+
throw new Error(`${path}.next cannot remove or replace logicalReceipt`);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
1202
3299
|
|
|
1203
|
-
|
|
3300
|
+
function validateNextEffectLedger(previous, candidate) {
|
|
3301
|
+
if (!Number.isSafeInteger(previous.revision + 1)) {
|
|
3302
|
+
throw new Error('Captain session effect-ledger revision cannot advance');
|
|
3303
|
+
}
|
|
3304
|
+
const next = assertPlaybookEffectLedger(
|
|
3305
|
+
candidate,
|
|
3306
|
+
'Captain session next effect ledger',
|
|
3307
|
+
);
|
|
3308
|
+
if (!isPlaybookEffectLedgerMonotonicExtension(previous, next)) {
|
|
3309
|
+
throw new Error(
|
|
3310
|
+
'Captain session next effect ledger is not a monotonic extension',
|
|
3311
|
+
);
|
|
3312
|
+
}
|
|
3313
|
+
return next;
|
|
1204
3314
|
}
|
|
1205
3315
|
|
|
1206
3316
|
function assertReleasedSchema2CaptainSessionRecord(record) {
|
|
@@ -1323,7 +3433,7 @@ function assertTurnZeroSnapshot(snapshot, path) {
|
|
|
1323
3433
|
|
|
1324
3434
|
function validateCaptainSessionProjection(
|
|
1325
3435
|
projection,
|
|
1326
|
-
{ path, structural },
|
|
3436
|
+
{ path, structural, artifactSchemas },
|
|
1327
3437
|
) {
|
|
1328
3438
|
rejectUnknownOrMissingKeys(
|
|
1329
3439
|
projection,
|
|
@@ -1414,8 +3524,24 @@ function validateCaptainSessionProjection(
|
|
|
1414
3524
|
if (typeof item.intent !== 'string') {
|
|
1415
3525
|
throw new Error(`${itemPath}.intent must be a string`);
|
|
1416
3526
|
}
|
|
1417
|
-
if (item.artifactSchema
|
|
1418
|
-
|
|
3527
|
+
if (!artifactSchemas.has(item.artifactSchema)) {
|
|
3528
|
+
const expected = artifactSchemas === CURRENT_ARTIFACT_SCHEMAS
|
|
3529
|
+
? '3'
|
|
3530
|
+
: '2 or 3';
|
|
3531
|
+
throw new Error(`${itemPath}.artifactSchema must be ${expected}`);
|
|
3532
|
+
}
|
|
3533
|
+
if (
|
|
3534
|
+
item.options !== null &&
|
|
3535
|
+
typeof item.options === 'object' &&
|
|
3536
|
+
!Array.isArray(item.options) &&
|
|
3537
|
+
Object.prototype.hasOwnProperty.call(
|
|
3538
|
+
item.options,
|
|
3539
|
+
HOST_CAPABILITIES_OPTION_KEY,
|
|
3540
|
+
)
|
|
3541
|
+
) {
|
|
3542
|
+
throw new Error(
|
|
3543
|
+
`${itemPath}.options.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be persisted`,
|
|
3544
|
+
);
|
|
1419
3545
|
}
|
|
1420
3546
|
const requiredRoleIds = validateRoleIds(
|
|
1421
3547
|
item.requiredRoleIds,
|
|
@@ -1628,7 +3754,11 @@ function referencedPlayerIds(catalog) {
|
|
|
1628
3754
|
return ids;
|
|
1629
3755
|
}
|
|
1630
3756
|
|
|
1631
|
-
function assertSnapshotMatchesStructure(
|
|
3757
|
+
function assertSnapshotMatchesStructure(
|
|
3758
|
+
snapshot,
|
|
3759
|
+
structural,
|
|
3760
|
+
artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
|
|
3761
|
+
) {
|
|
1632
3762
|
if (!isDeepStrictEqual(snapshot.captain.agent, structural.captain)) {
|
|
1633
3763
|
throw new Error(
|
|
1634
3764
|
'Captain session snapshot Captain envelope differs from structuralProjection',
|
|
@@ -1675,6 +3805,541 @@ function assertSnapshotMatchesStructure(snapshot, structural) {
|
|
|
1675
3805
|
`Captain session snapshot frame ${JSON.stringify(frame.playbookId)} role bindings differ from structuralProjection`,
|
|
1676
3806
|
);
|
|
1677
3807
|
}
|
|
3808
|
+
const expectedLedger =
|
|
3809
|
+
artifactSchemas === PRE_EFFECT_ARTIFACT_SCHEMAS &&
|
|
3810
|
+
item.artifactSchema === 2
|
|
3811
|
+
? emptyPlaybookEffectLedger()
|
|
3812
|
+
: snapshot.effectLedger;
|
|
3813
|
+
if (!isDeepStrictEqual(frame.runtime.effectLedger, expectedLedger)) {
|
|
3814
|
+
throw new Error(
|
|
3815
|
+
`Captain session snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger differs from its structural schema authority`,
|
|
3816
|
+
);
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
function validateRetainedGenerationUpdates(value) {
|
|
3822
|
+
const updates = snapshotJsonValue(
|
|
3823
|
+
value,
|
|
3824
|
+
'Captain session retained-generation updates',
|
|
3825
|
+
);
|
|
3826
|
+
if (!Array.isArray(updates)) {
|
|
3827
|
+
throw new Error(
|
|
3828
|
+
'Captain session retained-generation updates must be an array',
|
|
3829
|
+
);
|
|
3830
|
+
}
|
|
3831
|
+
const roots = new Set();
|
|
3832
|
+
for (const [index, value] of updates.entries()) {
|
|
3833
|
+
const path = `Captain session retained-generation updates[${index}]`;
|
|
3834
|
+
const update = requireRecord(value, path);
|
|
3835
|
+
if (update.kind === 'retain') {
|
|
3836
|
+
rejectUnknownOrMissingKeys(
|
|
3837
|
+
update,
|
|
3838
|
+
['kind', 'rootPlaybookId', 'generation'],
|
|
3839
|
+
path,
|
|
3840
|
+
);
|
|
3841
|
+
requireRecord(update.generation, `${path}.generation`);
|
|
3842
|
+
} else if (update.kind === 'clear') {
|
|
3843
|
+
rejectUnknownOrMissingKeys(
|
|
3844
|
+
update,
|
|
3845
|
+
['kind', 'rootPlaybookId'],
|
|
3846
|
+
path,
|
|
3847
|
+
);
|
|
3848
|
+
} else {
|
|
3849
|
+
throw new Error(`${path}.kind must be "retain" or "clear"`);
|
|
3850
|
+
}
|
|
3851
|
+
const rootPlaybookId = requireCanonicalNonblank(
|
|
3852
|
+
update.rootPlaybookId,
|
|
3853
|
+
`${path}.rootPlaybookId`,
|
|
3854
|
+
);
|
|
3855
|
+
if (roots.has(rootPlaybookId)) {
|
|
3856
|
+
throw new Error(
|
|
3857
|
+
'Captain session retained-generation updates contain a duplicate root playbook id',
|
|
3858
|
+
);
|
|
3859
|
+
}
|
|
3860
|
+
roots.add(rootPlaybookId);
|
|
3861
|
+
}
|
|
3862
|
+
return updates;
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
function applyRetainedGenerationUpdates(
|
|
3866
|
+
retainedGenerations,
|
|
3867
|
+
updates,
|
|
3868
|
+
structural,
|
|
3869
|
+
) {
|
|
3870
|
+
const next = new Map(Object.entries(retainedGenerations));
|
|
3871
|
+
for (const update of updates) {
|
|
3872
|
+
if (!Object.hasOwn(structural.catalog, update.rootPlaybookId)) {
|
|
3873
|
+
throw new Error(
|
|
3874
|
+
`Captain session retained-generation update names unknown stored playbook ${JSON.stringify(update.rootPlaybookId)}`,
|
|
3875
|
+
);
|
|
3876
|
+
}
|
|
3877
|
+
if (update.kind === 'clear') {
|
|
3878
|
+
next.delete(update.rootPlaybookId);
|
|
3879
|
+
} else {
|
|
3880
|
+
next.set(update.rootPlaybookId, update.generation);
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
return Object.fromEntries(next);
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
function retainedGenerationsMember(record) {
|
|
3887
|
+
return Object.hasOwn(record, 'retainedGenerations')
|
|
3888
|
+
? { retainedGenerations: record.retainedGenerations }
|
|
3889
|
+
: {};
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
function settledAbandonmentMember(record) {
|
|
3893
|
+
return Object.hasOwn(record, 'settledAbandonment')
|
|
3894
|
+
? { settledAbandonment: record.settledAbandonment }
|
|
3895
|
+
: {};
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
function sortCaptainSessionRecords(records) {
|
|
3899
|
+
return [...records].sort((left, right) => {
|
|
3900
|
+
const byUpdated = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
|
|
3901
|
+
if (byUpdated !== 0) return byUpdated;
|
|
3902
|
+
if (right.sessionId === left.sessionId) return 0;
|
|
3903
|
+
return right.sessionId < left.sessionId ? -1 : 1;
|
|
3904
|
+
});
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
function assertFreshAdoptionTarget(record) {
|
|
3908
|
+
if (record === undefined) {
|
|
3909
|
+
throw new Error('Captain session adoption target does not exist');
|
|
3910
|
+
}
|
|
3911
|
+
if (Object.keys(record.retainedGenerations ?? {}).length !== 0) {
|
|
3912
|
+
throw new Error(
|
|
3913
|
+
'Captain session adoption target already retains generations',
|
|
3914
|
+
);
|
|
3915
|
+
}
|
|
3916
|
+
if (record.snapshot.mode !== 'chat') {
|
|
3917
|
+
throw new Error(
|
|
3918
|
+
'Captain session adoption target must have no live engagement',
|
|
3919
|
+
);
|
|
3920
|
+
}
|
|
3921
|
+
if (record.state !== 'settled') {
|
|
3922
|
+
throw new Error(
|
|
3923
|
+
'Captain session adoption target must be settled at turn zero',
|
|
3924
|
+
);
|
|
3925
|
+
}
|
|
3926
|
+
assertTurnZeroSnapshot(
|
|
3927
|
+
record.snapshot,
|
|
3928
|
+
'Captain session adoption target snapshot',
|
|
3929
|
+
);
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
function assertAdoptionTransferEnvelope(
|
|
3933
|
+
sourceStructural,
|
|
3934
|
+
targetStructural,
|
|
3935
|
+
retainedGenerations,
|
|
3936
|
+
effectLedger,
|
|
3937
|
+
) {
|
|
3938
|
+
assertEffectLedgerMatchesCatalog(effectLedger, targetStructural.catalog);
|
|
3939
|
+
for (const [rootPlaybookId, generation] of Object.entries(
|
|
3940
|
+
retainedGenerations,
|
|
3941
|
+
)) {
|
|
3942
|
+
const sourceRoot = sourceStructural.catalog[rootPlaybookId];
|
|
3943
|
+
const targetRoot = targetStructural.catalog[rootPlaybookId];
|
|
3944
|
+
if (targetRoot === undefined) {
|
|
3945
|
+
throw new Error(
|
|
3946
|
+
`Captain session adoption target has no root playbook ${JSON.stringify(rootPlaybookId)}`,
|
|
3947
|
+
);
|
|
3948
|
+
}
|
|
3949
|
+
const rootEnvelope = (item) => ({
|
|
3950
|
+
from: item.from,
|
|
3951
|
+
manifestCommand: item.manifestCommand,
|
|
3952
|
+
options: item.options,
|
|
3953
|
+
requiredRoleIds: item.requiredRoleIds,
|
|
3954
|
+
concurrentRoleSets: item.concurrentRoleSets,
|
|
3955
|
+
});
|
|
3956
|
+
if (
|
|
3957
|
+
!isDeepStrictEqual(
|
|
3958
|
+
rootEnvelope(sourceRoot),
|
|
3959
|
+
rootEnvelope(targetRoot),
|
|
3960
|
+
)
|
|
3961
|
+
) {
|
|
3962
|
+
throw new Error(
|
|
3963
|
+
`Captain session adoption target root envelope differs for ${JSON.stringify(rootPlaybookId)}`,
|
|
3964
|
+
);
|
|
3965
|
+
}
|
|
3966
|
+
for (const frame of generation.frames) {
|
|
3967
|
+
const sourceFrame = sourceStructural.catalog[frame.playbookId];
|
|
3968
|
+
const targetFrame = targetStructural.catalog[frame.playbookId];
|
|
3969
|
+
if (targetFrame === undefined) {
|
|
3970
|
+
throw new Error(
|
|
3971
|
+
`Captain session adoption target has no frame playbook ${JSON.stringify(frame.playbookId)}`,
|
|
3972
|
+
);
|
|
3973
|
+
}
|
|
3974
|
+
if (targetFrame.artifactSchema !== sourceFrame.artifactSchema) {
|
|
3975
|
+
throw new Error(
|
|
3976
|
+
`Captain session adoption target artifact schema differs for ${JSON.stringify(frame.playbookId)}`,
|
|
3977
|
+
);
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
function settledRecordWithRetainedGenerations(
|
|
3984
|
+
record,
|
|
3985
|
+
retainedGenerations,
|
|
3986
|
+
updatedAt,
|
|
3987
|
+
effectLedger = record.effectLedger,
|
|
3988
|
+
) {
|
|
3989
|
+
if (record.state !== 'settled') {
|
|
3990
|
+
throw new Error('Captain session adoption exchange requires settled records');
|
|
3991
|
+
}
|
|
3992
|
+
return validateCaptainSessionRecord({
|
|
3993
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
3994
|
+
kind: record.kind,
|
|
3995
|
+
state: record.state,
|
|
3996
|
+
sessionId: record.sessionId,
|
|
3997
|
+
createdAt: record.createdAt,
|
|
3998
|
+
updatedAt,
|
|
3999
|
+
cwd: record.cwd,
|
|
4000
|
+
structuralProjection: record.structuralProjection,
|
|
4001
|
+
lastAppliedExecutionProjection: record.lastAppliedExecutionProjection,
|
|
4002
|
+
snapshot: isDeepStrictEqual(record.snapshot.effectLedger, effectLedger)
|
|
4003
|
+
? record.snapshot
|
|
4004
|
+
: { ...record.snapshot, effectLedger },
|
|
4005
|
+
effectLedger,
|
|
4006
|
+
unresolvedEffects: record.unresolvedEffects,
|
|
4007
|
+
retainedGenerations,
|
|
4008
|
+
...(Object.hasOwn(record, 'settledAbandonment')
|
|
4009
|
+
? { settledAbandonment: record.settledAbandonment }
|
|
4010
|
+
: {}),
|
|
4011
|
+
});
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
function validateRetainedGenerations(
|
|
4015
|
+
value,
|
|
4016
|
+
structural,
|
|
4017
|
+
authoritativeLedger,
|
|
4018
|
+
artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
|
|
4019
|
+
) {
|
|
4020
|
+
const retained = requireRecord(
|
|
4021
|
+
value,
|
|
4022
|
+
'Captain session record retainedGenerations',
|
|
4023
|
+
);
|
|
4024
|
+
const sessionIds = new Set();
|
|
4025
|
+
for (const [rootPlaybookId, value] of Object.entries(retained)) {
|
|
4026
|
+
const path =
|
|
4027
|
+
`Captain session record retainedGenerations` +
|
|
4028
|
+
`[${JSON.stringify(rootPlaybookId)}]`;
|
|
4029
|
+
if (!Object.hasOwn(structural.catalog, rootPlaybookId)) {
|
|
4030
|
+
throw new Error(
|
|
4031
|
+
`Captain session retained generation names unknown stored playbook ${JSON.stringify(rootPlaybookId)}`,
|
|
4032
|
+
);
|
|
4033
|
+
}
|
|
4034
|
+
const generation = requireRecord(value, path);
|
|
4035
|
+
exactOptionalKeys(
|
|
4036
|
+
generation,
|
|
4037
|
+
['effectLedger', 'frames'],
|
|
4038
|
+
['retainedEffectReconciliation', 'rootStateDescription'],
|
|
4039
|
+
path,
|
|
4040
|
+
);
|
|
4041
|
+
const generationLedger = assertPlaybookEffectLedger(
|
|
4042
|
+
generation.effectLedger,
|
|
4043
|
+
`${path}.effectLedger`,
|
|
4044
|
+
);
|
|
4045
|
+
if (
|
|
4046
|
+
generationLedger.boundaries.some(
|
|
4047
|
+
({ physicalReceipt }) => physicalReceipt === undefined,
|
|
4048
|
+
)
|
|
4049
|
+
) {
|
|
4050
|
+
throw new Error(
|
|
4051
|
+
`${path}.effectLedger contains an incomplete physical boundary`,
|
|
4052
|
+
);
|
|
4053
|
+
}
|
|
4054
|
+
assertEffectLedgerMatchesCatalog(generationLedger, structural.catalog);
|
|
4055
|
+
if (
|
|
4056
|
+
!isPlaybookEffectLedgerMonotonicExtension(
|
|
4057
|
+
generationLedger,
|
|
4058
|
+
authoritativeLedger,
|
|
4059
|
+
)
|
|
4060
|
+
) {
|
|
4061
|
+
throw new Error(
|
|
4062
|
+
`${path}.effectLedger is not a monotonic checkpoint of the authoritative Captain session effect ledger`,
|
|
4063
|
+
);
|
|
4064
|
+
}
|
|
4065
|
+
const generationReconciliation =
|
|
4066
|
+
generation.retainedEffectReconciliation === undefined
|
|
4067
|
+
? undefined
|
|
4068
|
+
: requireRecord(
|
|
4069
|
+
generation.retainedEffectReconciliation,
|
|
4070
|
+
`${path}.retainedEffectReconciliation`,
|
|
4071
|
+
);
|
|
4072
|
+
if (generationReconciliation !== undefined) {
|
|
4073
|
+
rejectUnknownOrMissingKeys(
|
|
4074
|
+
generationReconciliation,
|
|
4075
|
+
['sourceGenerationId'],
|
|
4076
|
+
`${path}.retainedEffectReconciliation`,
|
|
4077
|
+
);
|
|
4078
|
+
assertUuid(
|
|
4079
|
+
generationReconciliation.sourceGenerationId,
|
|
4080
|
+
`${path}.retainedEffectReconciliation.sourceGenerationId`,
|
|
4081
|
+
);
|
|
4082
|
+
}
|
|
4083
|
+
if (generation.rootStateDescription !== undefined) {
|
|
4084
|
+
requireNonblank(
|
|
4085
|
+
generation.rootStateDescription,
|
|
4086
|
+
`${path}.rootStateDescription`,
|
|
4087
|
+
);
|
|
4088
|
+
}
|
|
4089
|
+
if (!Array.isArray(generation.frames) || generation.frames.length === 0) {
|
|
4090
|
+
throw new Error(`${path}.frames must be a non-empty array`);
|
|
4091
|
+
}
|
|
4092
|
+
const frames = generation.frames.map((value, index) =>
|
|
4093
|
+
validateRetainedGenerationFrame(
|
|
4094
|
+
value,
|
|
4095
|
+
index,
|
|
4096
|
+
rootPlaybookId,
|
|
4097
|
+
structural,
|
|
4098
|
+
generationLedger,
|
|
4099
|
+
authoritativeLedger,
|
|
4100
|
+
sessionIds,
|
|
4101
|
+
`${path}.frames[${index}]`,
|
|
4102
|
+
artifactSchemas,
|
|
4103
|
+
),
|
|
4104
|
+
);
|
|
4105
|
+
const schema3Frames = frames.filter(
|
|
4106
|
+
({ playbookId }) =>
|
|
4107
|
+
structural.catalog[playbookId].artifactSchema === 3,
|
|
4108
|
+
);
|
|
4109
|
+
const markedFrames = frames.filter(
|
|
4110
|
+
({ runtime }) =>
|
|
4111
|
+
runtime.retainedEffectReconciliation !== undefined,
|
|
4112
|
+
);
|
|
4113
|
+
const markedCaptureLedger = markedFrames[0]?.runtime.effectLedger;
|
|
4114
|
+
const sourceGenerationId =
|
|
4115
|
+
generationReconciliation?.sourceGenerationId;
|
|
4116
|
+
if (
|
|
4117
|
+
markedCaptureLedger !== undefined &&
|
|
4118
|
+
markedFrames.some(
|
|
4119
|
+
({ runtime }) =>
|
|
4120
|
+
!isDeepStrictEqual(runtime.effectLedger, markedCaptureLedger),
|
|
4121
|
+
)
|
|
4122
|
+
) {
|
|
4123
|
+
throw new Error(
|
|
4124
|
+
`${path} schema-3 frames differ from the marked generation capture mirror`,
|
|
4125
|
+
);
|
|
4126
|
+
}
|
|
4127
|
+
if (
|
|
4128
|
+
(sourceGenerationId === undefined && markedFrames.length !== 0) ||
|
|
4129
|
+
(sourceGenerationId !== undefined &&
|
|
4130
|
+
markedFrames.length !== schema3Frames.length) ||
|
|
4131
|
+
(sourceGenerationId !== undefined &&
|
|
4132
|
+
structural.catalog[frames[0].playbookId].artifactSchema === 3 &&
|
|
4133
|
+
frames[0].runtime.retainedEffectReconciliation?.sourceSessionId !==
|
|
4134
|
+
sourceGenerationId)
|
|
4135
|
+
) {
|
|
4136
|
+
throw new Error(
|
|
4137
|
+
`${path}.retainedEffectReconciliation is inconsistent with its schema-3 frame markers`,
|
|
4138
|
+
);
|
|
4139
|
+
}
|
|
4140
|
+
validateRetainedGenerationPath(frames, rootPlaybookId, path);
|
|
4141
|
+
}
|
|
4142
|
+
return retained;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
function validateRetainedGenerationFrame(
|
|
4146
|
+
value,
|
|
4147
|
+
index,
|
|
4148
|
+
rootPlaybookId,
|
|
4149
|
+
structural,
|
|
4150
|
+
generationLedger,
|
|
4151
|
+
authoritativeLedger,
|
|
4152
|
+
sessionIds,
|
|
4153
|
+
path,
|
|
4154
|
+
artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
|
|
4155
|
+
) {
|
|
4156
|
+
const frame = requireRecord(value, path);
|
|
4157
|
+
exactOptionalKeys(
|
|
4158
|
+
frame,
|
|
4159
|
+
[
|
|
4160
|
+
'playbookId',
|
|
4161
|
+
'sessionId',
|
|
4162
|
+
'rootSessionId',
|
|
4163
|
+
'depth',
|
|
4164
|
+
'options',
|
|
4165
|
+
'roleBindings',
|
|
4166
|
+
'runtime',
|
|
4167
|
+
],
|
|
4168
|
+
['parentSessionId', 'parentCallId'],
|
|
4169
|
+
path,
|
|
4170
|
+
);
|
|
4171
|
+
const playbookId = requireCanonicalNonblank(
|
|
4172
|
+
frame.playbookId,
|
|
4173
|
+
`${path}.playbookId`,
|
|
4174
|
+
);
|
|
4175
|
+
const catalogItem = structural.catalog[playbookId];
|
|
4176
|
+
if (catalogItem === undefined) {
|
|
4177
|
+
throw new Error(
|
|
4178
|
+
`${path}.playbookId names unknown stored playbook ${JSON.stringify(playbookId)}`,
|
|
4179
|
+
);
|
|
4180
|
+
}
|
|
4181
|
+
assertUuid(frame.sessionId, `${path}.sessionId`);
|
|
4182
|
+
assertUuid(frame.rootSessionId, `${path}.rootSessionId`);
|
|
4183
|
+
if (sessionIds.has(frame.sessionId)) {
|
|
4184
|
+
throw new Error(
|
|
4185
|
+
'Captain session retained generation frame session ids must be unique',
|
|
4186
|
+
);
|
|
4187
|
+
}
|
|
4188
|
+
sessionIds.add(frame.sessionId);
|
|
4189
|
+
if (!Number.isSafeInteger(frame.depth) || frame.depth !== index) {
|
|
4190
|
+
throw new Error(`${path}.depth must equal its root-to-leaf index`);
|
|
4191
|
+
}
|
|
4192
|
+
if (frame.parentSessionId !== undefined) {
|
|
4193
|
+
assertUuid(frame.parentSessionId, `${path}.parentSessionId`);
|
|
4194
|
+
}
|
|
4195
|
+
if (frame.parentCallId !== undefined) {
|
|
4196
|
+
requireNonblank(frame.parentCallId, `${path}.parentCallId`);
|
|
4197
|
+
}
|
|
4198
|
+
const roleBindings = requireRecord(
|
|
4199
|
+
frame.roleBindings,
|
|
4200
|
+
`${path}.roleBindings`,
|
|
4201
|
+
);
|
|
4202
|
+
rejectUnknownOrMissingKeys(
|
|
4203
|
+
roleBindings,
|
|
4204
|
+
catalogItem.requiredRoleIds,
|
|
4205
|
+
`${path}.roleBindings`,
|
|
4206
|
+
);
|
|
4207
|
+
for (const roleId of catalogItem.requiredRoleIds) {
|
|
4208
|
+
assertPlayerId(roleBindings[roleId], `${path}.roleBindings.${roleId}`);
|
|
4209
|
+
}
|
|
4210
|
+
let runtime;
|
|
4211
|
+
try {
|
|
4212
|
+
runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, {
|
|
4213
|
+
allowSuspendedCall: true,
|
|
4214
|
+
});
|
|
4215
|
+
} catch (cause) {
|
|
4216
|
+
throw new Error(`${path}.runtime is invalid: ${errorMessage(cause)}`, {
|
|
4217
|
+
cause,
|
|
4218
|
+
});
|
|
4219
|
+
}
|
|
4220
|
+
const runtimeReconciliation = runtime.retainedEffectReconciliation;
|
|
4221
|
+
if (
|
|
4222
|
+
artifactSchemas === PRE_EFFECT_ARTIFACT_SCHEMAS &&
|
|
4223
|
+
catalogItem.artifactSchema === 2 &&
|
|
4224
|
+
(!isDeepStrictEqual(runtime.effectLedger, emptyPlaybookEffectLedger()) ||
|
|
4225
|
+
runtimeReconciliation !== undefined ||
|
|
4226
|
+
runtime.retainedEffectSourceSessionId !== undefined)
|
|
4227
|
+
) {
|
|
4228
|
+
throw new Error(
|
|
4229
|
+
`${path}.runtime schema-2 effect state must be empty`,
|
|
4230
|
+
);
|
|
4231
|
+
} else if (
|
|
4232
|
+
runtimeReconciliation === undefined &&
|
|
4233
|
+
!isDeepStrictEqual(runtime.effectLedger, generationLedger)
|
|
4234
|
+
) {
|
|
4235
|
+
throw new Error(
|
|
4236
|
+
`${path}.runtime effect ledger differs from its retained-generation checkpoint`,
|
|
4237
|
+
);
|
|
4238
|
+
} else if (
|
|
4239
|
+
runtimeReconciliation !== undefined &&
|
|
4240
|
+
(!isDeepStrictEqual(runtimeReconciliation.checkpoint, generationLedger) ||
|
|
4241
|
+
isDeepStrictEqual(runtime.effectLedger, generationLedger) ||
|
|
4242
|
+
!isPlaybookEffectLedgerMonotonicExtension(
|
|
4243
|
+
runtime.effectLedger,
|
|
4244
|
+
authoritativeLedger,
|
|
4245
|
+
))
|
|
4246
|
+
) {
|
|
4247
|
+
throw new Error(
|
|
4248
|
+
`${path}.runtime retained-effect evidence differs from its generation checkpoint or capture-time authority`,
|
|
4249
|
+
);
|
|
4250
|
+
}
|
|
4251
|
+
if (
|
|
4252
|
+
runtime.state.status !== 'active' ||
|
|
4253
|
+
!runtime.state.quiescent ||
|
|
4254
|
+
runtime.state.stateId === undefined
|
|
4255
|
+
) {
|
|
4256
|
+
throw new Error(
|
|
4257
|
+
`${path}.runtime must capture one active quiescent pre-terminal state`,
|
|
4258
|
+
);
|
|
4259
|
+
}
|
|
4260
|
+
for (const roleId of Object.keys(runtime.roleResumeTokens)) {
|
|
4261
|
+
if (!Object.hasOwn(roleBindings, roleId)) {
|
|
4262
|
+
throw new Error(
|
|
4263
|
+
`${path}.runtime role token names an unbound role ${JSON.stringify(roleId)}`,
|
|
4264
|
+
);
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
for (const question of runtime.pendingBossQuestions) {
|
|
4268
|
+
if (
|
|
4269
|
+
question.asker.kind === 'role' &&
|
|
4270
|
+
!Object.hasOwn(roleBindings, question.asker.roleId)
|
|
4271
|
+
) {
|
|
4272
|
+
throw new Error(
|
|
4273
|
+
`${path}.runtime pending question names an unbound role ${JSON.stringify(question.asker.roleId)}`,
|
|
4274
|
+
);
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
if (index === 0 && playbookId !== rootPlaybookId) {
|
|
4278
|
+
throw new Error(
|
|
4279
|
+
`${path}.playbookId must equal retained root ${JSON.stringify(rootPlaybookId)}`,
|
|
4280
|
+
);
|
|
4281
|
+
}
|
|
4282
|
+
return {
|
|
4283
|
+
...frame,
|
|
4284
|
+
playbookId,
|
|
4285
|
+
runtime,
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
function validateRetainedGenerationPath(frames, rootPlaybookId, path) {
|
|
4290
|
+
const rootSessionId = frames[0].sessionId;
|
|
4291
|
+
const playbookIds = new Set();
|
|
4292
|
+
for (const [index, frame] of frames.entries()) {
|
|
4293
|
+
if (playbookIds.has(frame.playbookId)) {
|
|
4294
|
+
throw new Error(`${path}.frames must not contain a playbook cycle`);
|
|
4295
|
+
}
|
|
4296
|
+
playbookIds.add(frame.playbookId);
|
|
4297
|
+
if (frame.rootSessionId !== rootSessionId) {
|
|
4298
|
+
throw new Error(
|
|
4299
|
+
`${path}.frames[${index}].rootSessionId must equal the root session id`,
|
|
4300
|
+
);
|
|
4301
|
+
}
|
|
4302
|
+
if (index === 0) {
|
|
4303
|
+
if (
|
|
4304
|
+
frame.sessionId !== frame.rootSessionId ||
|
|
4305
|
+
frame.parentSessionId !== undefined ||
|
|
4306
|
+
frame.parentCallId !== undefined
|
|
4307
|
+
) {
|
|
4308
|
+
throw new Error(
|
|
4309
|
+
`${path}.frames[0] has child-only or inconsistent root identity`,
|
|
4310
|
+
);
|
|
4311
|
+
}
|
|
4312
|
+
continue;
|
|
4313
|
+
}
|
|
4314
|
+
const parent = frames[index - 1];
|
|
4315
|
+
const suspendedCall = parent.runtime.suspendedCall;
|
|
4316
|
+
if (
|
|
4317
|
+
frame.parentSessionId !== parent.sessionId ||
|
|
4318
|
+
frame.parentCallId === undefined
|
|
4319
|
+
) {
|
|
4320
|
+
throw new Error(
|
|
4321
|
+
`${path}.frames[${index}] does not identify its immediate parent`,
|
|
4322
|
+
);
|
|
4323
|
+
}
|
|
4324
|
+
if (
|
|
4325
|
+
suspendedCall === undefined ||
|
|
4326
|
+
suspendedCall.callId !== frame.parentCallId ||
|
|
4327
|
+
suspendedCall.playbookId !== frame.playbookId ||
|
|
4328
|
+
suspendedCall.childSessionId !== frame.sessionId
|
|
4329
|
+
) {
|
|
4330
|
+
throw new Error(
|
|
4331
|
+
`${path}.frames[${index - 1}] suspended call does not match its child edge`,
|
|
4332
|
+
);
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
const leaf = frames.at(-1).runtime;
|
|
4336
|
+
if (
|
|
4337
|
+
leaf.suspendedCall !== undefined ||
|
|
4338
|
+
!leaf.state.tags.includes('playbook.parked')
|
|
4339
|
+
) {
|
|
4340
|
+
throw new Error(
|
|
4341
|
+
`Captain session retained generation ${JSON.stringify(rootPlaybookId)} leaf must be parked without a suspended child call`,
|
|
4342
|
+
);
|
|
1678
4343
|
}
|
|
1679
4344
|
}
|
|
1680
4345
|
|
|
@@ -1711,7 +4376,10 @@ async function requireUncertainRecord(record, attemptId) {
|
|
|
1711
4376
|
if (record.state !== 'uncertain') {
|
|
1712
4377
|
throw new Error('Captain session has no uncertain turn');
|
|
1713
4378
|
}
|
|
1714
|
-
if (
|
|
4379
|
+
if (
|
|
4380
|
+
attemptId !== undefined &&
|
|
4381
|
+
record.uncertain.attemptId !== attemptId
|
|
4382
|
+
) {
|
|
1715
4383
|
throw new Error('Captain session uncertain attempt id changed');
|
|
1716
4384
|
}
|
|
1717
4385
|
return record;
|