@sublang/playbook 12.3.0 → 13.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 +56 -52
- package/docs/configuration.md +2 -2
- package/docs/embedding.md +68 -32
- package/package.json +11 -3
- package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
- package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
- package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
- package/reference/sdlc/code.playbook/bin/run.js +62 -18
- package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +622 -65
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +53 -14
- package/reference/sdlc/code.playbook/playbook-captain.ts +68 -21
- package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
- package/reference/sdlc/code.playbook/session-host.js +18 -0
- package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
- package/reference/sdlc/code.playbook/session-store.js +22 -6
- package/src/xstate-playbook-runtime.js +12 -13
- package/src/xstate-playbook-runtime.ts +14 -21
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
// complete retained-generation map under guarded source-first publication.
|
|
8
8
|
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
+
import {
|
|
11
|
+
SESSION_MANIFEST_VERSION, EMPTY_REPLAY_SHA256, sha256,
|
|
12
|
+
validateSessionManifest, recoveryFromManifest, manifestFromRecovery,
|
|
13
|
+
contextFromRecovery, validateSessionContext, validateSessionHints,
|
|
14
|
+
projectRecovery, isRecordedAbsolutePath,
|
|
15
|
+
} from './portable-codec.js';
|
|
10
16
|
import { constants } from 'node:fs';
|
|
11
17
|
import {
|
|
12
18
|
access,
|
|
@@ -219,8 +225,7 @@ export function defaultCaptainSessionsDir(
|
|
|
219
225
|
env = process.env,
|
|
220
226
|
home = env.HOME ?? homedir(),
|
|
221
227
|
) {
|
|
222
|
-
|
|
223
|
-
return join(stateHome, 'playbook', 'sessions');
|
|
228
|
+
return join(typeof env.SPEX_HOME === 'string' && env.SPEX_HOME.trim() !== '' ? env.SPEX_HOME : join(home, '.spex'), 'sessions');
|
|
224
229
|
}
|
|
225
230
|
|
|
226
231
|
// PBCLI-78: front-end bootstrap checks the same private filesystem boundary
|
|
@@ -235,6 +240,7 @@ export async function assertCaptainSessionsDirectoryUsable(
|
|
|
235
240
|
}
|
|
236
241
|
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
237
242
|
try {
|
|
243
|
+
await prepareSessionPermissions(sessionsDir, fs);
|
|
238
244
|
await assertPrivateDirectory(sessionsDir, fs);
|
|
239
245
|
await fs.access(
|
|
240
246
|
sessionsDir,
|
|
@@ -630,6 +636,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
630
636
|
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
631
637
|
const replayReadCursors = new Map();
|
|
632
638
|
const replayReadQueues = new Map();
|
|
639
|
+
const portableWriters = new Map();
|
|
633
640
|
|
|
634
641
|
if (!isAbsolute(sessionsDir)) {
|
|
635
642
|
throw new Error('Captain session store path must be absolute');
|
|
@@ -665,7 +672,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
665
672
|
return join(sessionsDir, `.${sessionId}.lock.retired.${ownerToken}`);
|
|
666
673
|
};
|
|
667
674
|
|
|
668
|
-
const readRecord = async (sessionId, { missing = 'error' } = {}) => {
|
|
675
|
+
const readRecord = async (sessionId, { missing = 'error', requirePortable = false } = {}) => {
|
|
669
676
|
const path = recordPathFor(sessionId);
|
|
670
677
|
let text;
|
|
671
678
|
try {
|
|
@@ -690,7 +697,16 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
690
697
|
}
|
|
691
698
|
let record;
|
|
692
699
|
try {
|
|
693
|
-
|
|
700
|
+
if (value.schemaVersion === SESSION_MANIFEST_VERSION && value.state === 'history-only') {
|
|
701
|
+
const manifest = validateSessionManifest(value);
|
|
702
|
+
throw new CaptainSessionRecordNonresumableError(7, manifest.reason, captainSessionOrderingBoundary(manifest));
|
|
703
|
+
}
|
|
704
|
+
record = value.schemaVersion === SESSION_MANIFEST_VERSION
|
|
705
|
+
? recoveryFromManifest(value)
|
|
706
|
+
: validateCaptainSessionRecord(value);
|
|
707
|
+
if (requirePortable && value.schemaVersion !== SESSION_MANIFEST_VERSION) {
|
|
708
|
+
throw new CaptainSessionRecordNonresumableError(value.schemaVersion, `schema ${value.schemaVersion} requires explicit migration; run playbook migrate-session ${sessionId}`, captainSessionOrderingBoundary(record));
|
|
709
|
+
}
|
|
694
710
|
} catch (cause) {
|
|
695
711
|
const context =
|
|
696
712
|
`Captain session ${JSON.stringify(sessionId)} at ` +
|
|
@@ -734,6 +750,70 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
734
750
|
return record;
|
|
735
751
|
};
|
|
736
752
|
|
|
753
|
+
const readManifest = async (sessionId) => {
|
|
754
|
+
let bytes;
|
|
755
|
+
try { await assertPrivateDirectory(sessionsDir, fs); bytes = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record'); }
|
|
756
|
+
catch (cause) { if (cause?.code === 'ENOENT') throw new CaptainSessionNotFoundError(sessionId, recordPathFor(sessionId)); throw cause; }
|
|
757
|
+
const value = JSON.parse(bytes);
|
|
758
|
+
if (value.sessionId !== sessionId) throw new Error('session manifest identity does not match its filename');
|
|
759
|
+
return value.schemaVersion === 7 ? validateSessionManifest(value) : value;
|
|
760
|
+
};
|
|
761
|
+
const prepare = () => prepareSessionPermissions(sessionsDir, fs);
|
|
762
|
+
const readHistory = async (sessionId, options = {}) => {
|
|
763
|
+
assertSessionId(sessionId);
|
|
764
|
+
const history = await readSessionHistory({ sessionsDir, path: recordsPathFor(sessionId), fs, afterSeq: options.afterSeq ?? 0 });
|
|
765
|
+
if (!history.missing) return history;
|
|
766
|
+
let legacy;
|
|
767
|
+
try {
|
|
768
|
+
legacy = JSON.parse(await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'legacy record'));
|
|
769
|
+
if (![2, 3, 4, 5, 6].includes(legacy.schemaVersion)) return history;
|
|
770
|
+
if (legacy.kind !== CAPTAIN_SESSION_RECORD_KIND || legacy.sessionId !== sessionId || !Array.isArray(legacy.snapshot?.journal) || !Number.isFinite(Date.parse(legacy.updatedAt))) return history;
|
|
771
|
+
} catch { return history; }
|
|
772
|
+
return legacyJournalHistory(legacy, options.afterSeq ?? 0, history);
|
|
773
|
+
};
|
|
774
|
+
const validate = async (sessionId, context = {}) => {
|
|
775
|
+
const manifest = await readManifest(sessionId);
|
|
776
|
+
const history = await readHistory(sessionId);
|
|
777
|
+
const reasons = [];
|
|
778
|
+
let integrityValid = !history.missing && !history.incomplete;
|
|
779
|
+
if (history.missing) reasons.push('session replay file is missing');
|
|
780
|
+
if (history.pendingTail) reasons.push('session replay has an unfinished final record');
|
|
781
|
+
if (manifest.schemaVersion !== 7) {
|
|
782
|
+
if ([2, 3, 4, 5, 6].includes(manifest.schemaVersion)) {
|
|
783
|
+
try { validateCaptainSessionRecord(manifest); }
|
|
784
|
+
catch (cause) { if (!(cause instanceof CaptainSessionRecordNonresumableError)) throw cause; reasons.push(cause.message); }
|
|
785
|
+
reasons.push(`schema ${manifest.schemaVersion} requires explicit migration; run playbook migrate-session ${sessionId}`);
|
|
786
|
+
} else reasons.push(`unsupported session schema ${manifest.schemaVersion}`);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
if (history.entries.some(({ record }) => !isDeepStrictEqual(record, sanitizeReplayRecord(record)))) {
|
|
790
|
+
integrityValid = false;
|
|
791
|
+
reasons.push('session replay contains provider continuation fields');
|
|
792
|
+
}
|
|
793
|
+
if (manifest.state === 'history-only') reasons.push(manifest.reason);
|
|
794
|
+
if (manifest.replay.incomplete || history.incomplete) reasons.push('session replay is incomplete');
|
|
795
|
+
if (history.digests[manifest.replay.seq] !== manifest.replay.sha256) { integrityValid = false; reasons.push('session replay checkpoint digest does not match'); }
|
|
796
|
+
if (manifest.contextSeq !== null) {
|
|
797
|
+
const contextRecord = history.entries.find((entry) => entry.seq === manifest.contextSeq)?.record;
|
|
798
|
+
try {
|
|
799
|
+
const supported = validateSessionContext(contextRecord);
|
|
800
|
+
if (manifest.state !== 'history-only') {
|
|
801
|
+
const execution = manifest.state === 'uncertain'
|
|
802
|
+
? manifest.uncertain.attemptedExecutionProjection : manifest.lastAppliedExecutionProjection;
|
|
803
|
+
if (!isDeepStrictEqual(supported.configuration, execution) || supported.captainId !== manifest.snapshot.captain.sessionId) { integrityValid = false; reasons.push('required session context differs from checkpoint recovery'); }
|
|
804
|
+
}
|
|
805
|
+
} catch { reasons.push('required session context is absent or unsupported'); }
|
|
806
|
+
}
|
|
807
|
+
if (!isAbsolute(manifest.cwd) || resolve(manifest.cwd) !== manifest.cwd) reasons.push('recorded working directory is not native; checkpoint relocation is unsupported');
|
|
808
|
+
if (context.cwd !== undefined && context.cwd !== manifest.cwd) reasons.push('recorded working directory differs; checkpoint relocation is unsupported');
|
|
809
|
+
if (context.executionProjection !== undefined && manifest.state !== 'history-only') {
|
|
810
|
+
try { assertCaptainSessionExecutionCompatible(manifest.structuralProjection, context.executionProjection); }
|
|
811
|
+
catch (cause) { reasons.push(errorMessage(cause)); }
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return Object.freeze({ sessionId, integrityValid, resumable: integrityValid && reasons.length === 0, reasons: Object.freeze(reasons), manifest, history });
|
|
815
|
+
};
|
|
816
|
+
|
|
737
817
|
const read = (sessionId) => readRecord(sessionId);
|
|
738
818
|
|
|
739
819
|
const readStream = async (sessionId, options) => {
|
|
@@ -823,15 +903,16 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
823
903
|
// Canonically named records are store-owned. Corruption must not make
|
|
824
904
|
// --continue silently select an older logical session.
|
|
825
905
|
try {
|
|
826
|
-
candidates.push(await readRecord(sessionId));
|
|
906
|
+
candidates.push(await readRecord(sessionId, { requirePortable: true }));
|
|
827
907
|
} catch (error) {
|
|
828
908
|
if (error instanceof CaptainSessionRecordNonresumableError) {
|
|
829
|
-
await onLegacyOrderingBoundary?.(error.orderingBoundary);
|
|
909
|
+
if (error.orderingBoundary) await onLegacyOrderingBoundary?.(error.orderingBoundary);
|
|
830
910
|
await onLegacyRecord?.(
|
|
831
911
|
Object.freeze({
|
|
832
912
|
sessionId,
|
|
833
913
|
path: recordPathFor(sessionId),
|
|
834
914
|
schemaVersion: error.schemaVersion,
|
|
915
|
+
...(error.schemaVersion >= 6 ? { reason: error.cause?.message ?? error.message } : {}),
|
|
835
916
|
}),
|
|
836
917
|
);
|
|
837
918
|
continue;
|
|
@@ -865,37 +946,24 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
865
946
|
);
|
|
866
947
|
};
|
|
867
948
|
|
|
868
|
-
const readSummary = async (sessionId) =>
|
|
869
|
-
|
|
949
|
+
const readSummary = async (sessionId) => {
|
|
950
|
+
const manifest = await readManifest(sessionId);
|
|
951
|
+
if (manifest.schemaVersion !== 7) validateCaptainSessionRecord(manifest);
|
|
952
|
+
return projectPlaybookSessionSummary(manifest);
|
|
953
|
+
};
|
|
870
954
|
|
|
871
955
|
const listSummaries = async () => {
|
|
872
|
-
const skipped = [];
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
},
|
|
884
|
-
onInvalidRecord: ({ sessionId, reason }) => {
|
|
885
|
-
skipped.push(Object.freeze({ sessionId, reason }));
|
|
886
|
-
},
|
|
887
|
-
skipInvalidRecords: true,
|
|
888
|
-
});
|
|
889
|
-
return Object.freeze({
|
|
890
|
-
sessions: Object.freeze(
|
|
891
|
-
sortCaptainSessionRecords(records).map(projectPlaybookSessionSummary),
|
|
892
|
-
),
|
|
893
|
-
skipped: Object.freeze(
|
|
894
|
-
skipped.sort((left, right) =>
|
|
895
|
-
left.sessionId.localeCompare(right.sessionId),
|
|
896
|
-
),
|
|
897
|
-
),
|
|
898
|
-
});
|
|
956
|
+
const sessions = [], skipped = [];
|
|
957
|
+
let names;
|
|
958
|
+
try { await assertPrivateDirectory(sessionsDir, fs); names = await fs.readdir(sessionsDir); }
|
|
959
|
+
catch (cause) { if (cause?.code === 'ENOENT') return { sessions, skipped }; throw cause; }
|
|
960
|
+
for (const name of names.sort()) {
|
|
961
|
+
const sessionId = name.slice(0, -5);
|
|
962
|
+
if (!name.endsWith('.json') || !SESSION_ID_PATTERN.test(sessionId)) continue;
|
|
963
|
+
try { sessions.push(await readSummary(sessionId)); }
|
|
964
|
+
catch (cause) { skipped.push({ sessionId, reason: errorMessage(cause) }); }
|
|
965
|
+
}
|
|
966
|
+
return Object.freeze({ sessions: Object.freeze(sortCaptainSessionRecords(sessions)), skipped: Object.freeze(skipped) });
|
|
899
967
|
};
|
|
900
968
|
|
|
901
969
|
const scanAdoptionPredecessor = async (
|
|
@@ -930,7 +998,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
930
998
|
...legacyOrderingBoundaries.filter(
|
|
931
999
|
(candidate) =>
|
|
932
1000
|
candidate.sessionId !== target.sessionId &&
|
|
933
|
-
candidate.state
|
|
1001
|
+
candidate.state !== 'uncertain' &&
|
|
934
1002
|
candidate.cwd === target.cwd,
|
|
935
1003
|
),
|
|
936
1004
|
]);
|
|
@@ -954,9 +1022,12 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
954
1022
|
|
|
955
1023
|
const writeRecord = async (
|
|
956
1024
|
recordValue,
|
|
957
|
-
{ noReplace, onPublished },
|
|
1025
|
+
{ noReplace, onPublished, portable = false },
|
|
958
1026
|
) => {
|
|
959
|
-
const record =
|
|
1027
|
+
const record = portable
|
|
1028
|
+
? validateSessionManifest(recordValue)
|
|
1029
|
+
: await portableWriters.get(recordValue.sessionId)?.checkpoint(recordValue);
|
|
1030
|
+
if (record === undefined) throw new Error('session persistence requires its owning lifecycle');
|
|
960
1031
|
const destination = recordPathFor(record.sessionId);
|
|
961
1032
|
await ensurePrivateDirectory(sessionsDir, fs);
|
|
962
1033
|
|
|
@@ -1020,6 +1091,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1020
1091
|
onPublished?.();
|
|
1021
1092
|
}
|
|
1022
1093
|
await syncDirectory(sessionsDir, fs);
|
|
1094
|
+
portableWriters.get(record.sessionId)?.published(record);
|
|
1023
1095
|
return record;
|
|
1024
1096
|
} catch (cause) {
|
|
1025
1097
|
try {
|
|
@@ -1039,10 +1111,11 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1039
1111
|
};
|
|
1040
1112
|
|
|
1041
1113
|
const deleteRecord = async (sessionId) => {
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1114
|
+
for (const suffix of ['.records.jsonl', '.hints.json', '.spex.json', '.json']) {
|
|
1115
|
+
const path = join(sessionsDir, `${sessionId}${suffix}`);
|
|
1116
|
+
try { await assertPrivateRegularPath(path, 0o600, fs, 'session file'); await fs.unlink(path); await syncDirectory(sessionsDir, fs); }
|
|
1117
|
+
catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
|
|
1118
|
+
}
|
|
1046
1119
|
};
|
|
1047
1120
|
|
|
1048
1121
|
const readLeaseDirectory = async (
|
|
@@ -1097,6 +1170,26 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1097
1170
|
const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
|
|
1098
1171
|
readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
|
|
1099
1172
|
|
|
1173
|
+
const readLeaseState = async (sessionId) => {
|
|
1174
|
+
assertSessionId(sessionId);
|
|
1175
|
+
try {
|
|
1176
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1177
|
+
await fs.lstat(leasePathFor(sessionId));
|
|
1178
|
+
} catch (cause) { return cause?.code === 'ENOENT' ? 'idle' : 'unknown'; }
|
|
1179
|
+
try {
|
|
1180
|
+
const owner = await readLeaseOwner(sessionId);
|
|
1181
|
+
if (owner.hostname !== localHostname) return 'unknown';
|
|
1182
|
+
let state = 'active';
|
|
1183
|
+
try { await probeProcess(owner.pid); }
|
|
1184
|
+
catch (cause) {
|
|
1185
|
+
if (cause?.code !== 'ESRCH') return 'unknown';
|
|
1186
|
+
state = 'idle';
|
|
1187
|
+
}
|
|
1188
|
+
const current = await readLeaseOwner(sessionId);
|
|
1189
|
+
return current.ownerToken === owner.ownerToken ? state : 'unknown';
|
|
1190
|
+
} catch { return 'unknown'; }
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1100
1193
|
const activeLeaseErrorFor = async (sessionId, owner) => {
|
|
1101
1194
|
if (owner.hostname !== localHostname) {
|
|
1102
1195
|
return foreignCaptainSessionLeaseActiveError(
|
|
@@ -1300,8 +1393,9 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1300
1393
|
return publishedOwner;
|
|
1301
1394
|
};
|
|
1302
1395
|
|
|
1303
|
-
const acquire = async (sessionId) => {
|
|
1396
|
+
const acquire = async (sessionId, management = false) => {
|
|
1304
1397
|
assertSessionId(sessionId);
|
|
1398
|
+
await prepareSessionPermissions(sessionsDir, fs, sessionId);
|
|
1305
1399
|
let stage;
|
|
1306
1400
|
let stagePublished = false;
|
|
1307
1401
|
try {
|
|
@@ -1330,6 +1424,21 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1330
1424
|
await publishLeaseStage(sessionId, stage, () => {
|
|
1331
1425
|
stagePublished = true;
|
|
1332
1426
|
});
|
|
1427
|
+
if (management) {
|
|
1428
|
+
let released = false;
|
|
1429
|
+
const assertOwner = async () => {
|
|
1430
|
+
if (released) throw new Error('session management lease was released');
|
|
1431
|
+
const current = await readLeaseOwner(sessionId);
|
|
1432
|
+
if (current.ownerToken !== stage.owner.ownerToken) throw new Error('session management ownership changed');
|
|
1433
|
+
return current;
|
|
1434
|
+
};
|
|
1435
|
+
return Object.freeze({ sessionId, ownerToken: stage.owner.ownerToken, assertOwner, async release() {
|
|
1436
|
+
if (released) return;
|
|
1437
|
+
const current = await assertOwner();
|
|
1438
|
+
await retireObservedLease(sessionId, current);
|
|
1439
|
+
released = true;
|
|
1440
|
+
} });
|
|
1441
|
+
}
|
|
1333
1442
|
return await createLease({
|
|
1334
1443
|
sessionId,
|
|
1335
1444
|
owner: stage.owner,
|
|
@@ -1338,6 +1447,10 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1338
1447
|
replayFs: fs,
|
|
1339
1448
|
readReplayStream: (options) => readStream(sessionId, options),
|
|
1340
1449
|
readRecord,
|
|
1450
|
+
readManifest,
|
|
1451
|
+
readHistory,
|
|
1452
|
+
validateSession: validate,
|
|
1453
|
+
portableWriters,
|
|
1341
1454
|
writeRecord,
|
|
1342
1455
|
syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
|
|
1343
1456
|
deleteRecord,
|
|
@@ -1375,14 +1488,219 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1375
1488
|
}
|
|
1376
1489
|
};
|
|
1377
1490
|
|
|
1491
|
+
const migrate = async (sessionId, migration = {}) => {
|
|
1492
|
+
assertSessionId(sessionId);
|
|
1493
|
+
await prepareSessionPermissions(sessionsDir, fs, sessionId, true);
|
|
1494
|
+
const sourcePath = migration.sourcePath ?? recordPathFor(sessionId);
|
|
1495
|
+
const sidecar = sourcePath.endsWith('.spex.json');
|
|
1496
|
+
const sourceDir = dirname(sourcePath);
|
|
1497
|
+
const sourceReplayPath = join(sourceDir, `${sessionId}.records.jsonl`);
|
|
1498
|
+
const external = sourceDir !== sessionsDir;
|
|
1499
|
+
if (!isAbsolute(sourcePath) || sourcePath !== join(sourceDir, `${sessionId}${sidecar ? '.spex' : ''}.json`)) throw new Error('migration source must be the canonical session file');
|
|
1500
|
+
const backupDir = migration.backupDir ?? join(dirname(sessionsDir), 'local', 'migrations', sessionId);
|
|
1501
|
+
const inputsDir = join(backupDir, 'inputs');
|
|
1502
|
+
const receiptPath = join(backupDir, 'receipt.json');
|
|
1503
|
+
const decodeSource = (sourceBytes) => {
|
|
1504
|
+
const source = JSON.parse(sourceBytes);
|
|
1505
|
+
if (!sidecar && source.schemaVersion === 7) {
|
|
1506
|
+
if (external) throw new Error('source is already portable; select its complete bundle instead of legacy migration');
|
|
1507
|
+
return { source };
|
|
1508
|
+
}
|
|
1509
|
+
let recovery, metadata;
|
|
1510
|
+
if (sidecar) {
|
|
1511
|
+
const value = source.session ?? source;
|
|
1512
|
+
if (source.v !== 1 || value.id !== sessionId || !Number.isFinite(value.createdAt) || typeof migration.cwd !== 'string' || !isAbsolute(migration.cwd) || resolve(migration.cwd) !== migration.cwd) throw new Error('invalid legacy desktop sidecar or missing normalized cwd');
|
|
1513
|
+
metadata = { cwd: migration.cwd, createdAt: new Date(value.createdAt).toISOString(), updatedAt: new Date(value.endedAt ?? value.createdAt).toISOString(), reason: 'legacy desktop history lacks complete durable recovery', journal: value.snapshot?.shell?.journal ?? [] };
|
|
1514
|
+
} else {
|
|
1515
|
+
if (source.sessionId !== sessionId) throw new Error('migration session identity mismatch');
|
|
1516
|
+
try { recovery = validateCaptainSessionRecord(source); }
|
|
1517
|
+
catch (cause) {
|
|
1518
|
+
if (!(cause instanceof CaptainSessionRecordNonresumableError)) throw cause;
|
|
1519
|
+
metadata = { cwd: source.cwd, createdAt: source.createdAt, updatedAt: source.updatedAt, reason: `legacy schema ${source.schemaVersion} has no supported recovery`, journal: source.snapshot?.journal ?? [] };
|
|
1520
|
+
}
|
|
1521
|
+
if (recovery && source.schemaVersion !== 6) throw new Error('only schema 6 supports executable migration');
|
|
1522
|
+
}
|
|
1523
|
+
return { source, recovery, metadata };
|
|
1524
|
+
};
|
|
1525
|
+
if (external) await prepareSessionPermissions(sourceDir, fs, sessionId, true);
|
|
1526
|
+
// Refusal needs no ownership or persistent memo. Reread after acquiring
|
|
1527
|
+
// the lease below; a preflight read never authorizes migration writes.
|
|
1528
|
+
try { decodeSource(await readPrivateRegularFile(sourcePath, 0o600, fs, 'migration source')); }
|
|
1529
|
+
catch (cause) { if (cause?.code !== 'ENOENT' || !sidecar && !external) throw cause; }
|
|
1530
|
+
let sourceLease, lease;
|
|
1531
|
+
try {
|
|
1532
|
+
if (external) {
|
|
1533
|
+
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env, homeDir: home, fsOps: fs, hostname: localHostname, pid: localPid, probeProcess });
|
|
1534
|
+
sourceLease = await sourceStore.acquireManagement(sessionId);
|
|
1535
|
+
}
|
|
1536
|
+
lease = await acquire(sessionId, true);
|
|
1537
|
+
await lease.assertOwner();
|
|
1538
|
+
let sourceBytes;
|
|
1539
|
+
try { sourceBytes = await readPrivateRegularFile(sourcePath, 0o600, fs, 'migration source'); }
|
|
1540
|
+
catch (cause) {
|
|
1541
|
+
if ((!sidecar && !external) || cause?.code !== 'ENOENT') throw cause;
|
|
1542
|
+
let receipt;
|
|
1543
|
+
if (external) {
|
|
1544
|
+
receipt = JSON.parse(await readPrivateRegularFile(receiptPath, 0o600, fs, 'migration receipt'));
|
|
1545
|
+
if (receipt.v !== 1 || receipt.id !== sessionId || !Array.isArray(receipt.inputs) || receipt.inputs[0]?.path !== sourcePath || ![1, 2].includes(receipt.inputs.length)) throw new Error('missing migration source has no matching receipt');
|
|
1546
|
+
for (const [index, input] of receipt.inputs.entries()) {
|
|
1547
|
+
const bytes = await readPrivateRegularFile(join(inputsDir, String(index)), 0o600, fs, 'retained migration input', true);
|
|
1548
|
+
if (sha256(bytes) !== input.sha256) throw new Error('retained migration input differs');
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const current = await validate(sessionId);
|
|
1552
|
+
if (!current.integrityValid) throw new Error('completed migration destination failed validation');
|
|
1553
|
+
if (receipt?.complete === false) await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
|
|
1554
|
+
return { manifest: current.manifest, migrated: false, reasons: current.reasons };
|
|
1555
|
+
}
|
|
1556
|
+
let { source, recovery, metadata } = decodeSource(sourceBytes);
|
|
1557
|
+
if (!sidecar && source.schemaVersion === 7) {
|
|
1558
|
+
const current = await validate(sessionId);
|
|
1559
|
+
return { manifest: current.manifest, migrated: false, reasons: current.reasons };
|
|
1560
|
+
}
|
|
1561
|
+
const sourceSnapshot = await readReplaySnapshot({ sessionsDir: sourceDir, path: sourceReplayPath, fs, afterSeq: 0, forceFullRead: true });
|
|
1562
|
+
const snapshot = external ? await readReplaySnapshot({ sessionsDir, path: recordsPathFor(sessionId), fs, afterSeq: 0, forceFullRead: true }) : sourceSnapshot;
|
|
1563
|
+
const currentReplay = snapshot.absent ? undefined : snapshot.bytes;
|
|
1564
|
+
let originalReplay = sourceSnapshot.absent ? undefined : sourceSnapshot.bytes;
|
|
1565
|
+
let priorReceipt;
|
|
1566
|
+
try { priorReceipt = JSON.parse(await readPrivateRegularFile(receiptPath, 0o600, fs, 'migration receipt')); }
|
|
1567
|
+
catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
|
|
1568
|
+
if (priorReceipt !== undefined) {
|
|
1569
|
+
if (priorReceipt.v !== 1 || priorReceipt.id !== sessionId || !Array.isArray(priorReceipt.inputs) || priorReceipt.inputs[0]?.path !== sourcePath || priorReceipt.inputs[0]?.sha256 !== sha256(Buffer.from(sourceBytes))) throw new Error('migration source differs from retained input');
|
|
1570
|
+
const retainedSource = await readPrivateRegularFile(join(inputsDir, '0'), 0o600, fs, 'retained migration source');
|
|
1571
|
+
if (retainedSource !== sourceBytes) throw new Error('migration retained source differs');
|
|
1572
|
+
if (priorReceipt.inputs.length === 2) {
|
|
1573
|
+
originalReplay = await readPrivateRegularFile(join(inputsDir, '1'), 0o600, fs, 'retained migration replay', true);
|
|
1574
|
+
if (priorReceipt.inputs[1].path !== sourceReplayPath || sha256(originalReplay) !== priorReceipt.inputs[1].sha256) throw new Error('retained migration replay differs');
|
|
1575
|
+
} else if (priorReceipt.inputs.length === 1) originalReplay = undefined;
|
|
1576
|
+
else throw new Error('invalid migration inputs');
|
|
1577
|
+
}
|
|
1578
|
+
if (external && priorReceipt && !sourceSnapshot.absent && !sourceSnapshot.bytes.equals(originalReplay ?? Buffer.alloc(0))) throw new Error('migration source replay differs from retained input');
|
|
1579
|
+
let replayBytes = originalReplay ?? Buffer.alloc(0);
|
|
1580
|
+
if (originalReplay === undefined) {
|
|
1581
|
+
const journal = metadata?.journal ?? recovery?.snapshot?.journal ?? [];
|
|
1582
|
+
const updatedAt = source.updatedAt ?? metadata.updatedAt;
|
|
1583
|
+
const projected = legacyJournalHistory({ updatedAt, snapshot: { journal } }, 0, {}).entries;
|
|
1584
|
+
const records = [...projected.map(({ record }) => record), ...journal.map((entry) => ({ type: 'legacy_journal', timestamp: Date.parse(updatedAt), entry }))];
|
|
1585
|
+
replayBytes = Buffer.from(records.map((record, index) => `${JSON.stringify({ v: 1, seq: index + 1, record: sanitizeReplayRecord(record) })}\n`).join(''));
|
|
1586
|
+
}
|
|
1587
|
+
let history = parseSessionHistory(replayBytes);
|
|
1588
|
+
if (history.pendingTail) {
|
|
1589
|
+
try {
|
|
1590
|
+
parseReplayEnvelope(replayBytes.subarray(history.completeBytes), history.lastReadableSeq + 1);
|
|
1591
|
+
replayBytes = Buffer.concat([replayBytes, Buffer.from('\n')]);
|
|
1592
|
+
history = parseSessionHistory(replayBytes);
|
|
1593
|
+
} catch { /* Preserve a torn or invalid tail in the retained input only. */ }
|
|
1594
|
+
}
|
|
1595
|
+
const completeReplay = replayBytes.subarray(0, history.completeBytes);
|
|
1596
|
+
let offset = 0;
|
|
1597
|
+
replayBytes = Buffer.concat(history.entries.map((entry) => {
|
|
1598
|
+
const end = completeReplay.indexOf(0x0a, offset) + 1;
|
|
1599
|
+
const line = completeReplay.subarray(offset, end);
|
|
1600
|
+
offset = end;
|
|
1601
|
+
const record = sanitizeReplayRecord(entry.record);
|
|
1602
|
+
return isDeepStrictEqual(record, entry.record)
|
|
1603
|
+
? line : Buffer.from(`${JSON.stringify({ ...entry, record })}\n`);
|
|
1604
|
+
}));
|
|
1605
|
+
let manifest;
|
|
1606
|
+
if (recovery && !history.incomplete && !history.pendingTail) {
|
|
1607
|
+
const context = contextFromRecovery(recovery);
|
|
1608
|
+
context.timestamp = Date.parse(recovery.updatedAt);
|
|
1609
|
+
const contextSeq = history.lastReadableSeq + 1;
|
|
1610
|
+
replayBytes = Buffer.concat([replayBytes, Buffer.from(`${JSON.stringify({v:1,seq:contextSeq,record:context})}\n`)]);
|
|
1611
|
+
manifest = manifestFromRecovery(recovery, { seq: contextSeq, sha256: sha256(replayBytes), incomplete: false }, contextSeq);
|
|
1612
|
+
} else {
|
|
1613
|
+
if (recovery) metadata = { cwd: recovery.cwd, createdAt: recovery.createdAt, updatedAt: recovery.updatedAt, reason: 'legacy replay is incomplete' };
|
|
1614
|
+
manifest = validateSessionManifest({ schemaVersion: 7, kind: 'captain-session', sessionId, state: 'history-only', cwd: metadata.cwd, createdAt: metadata.createdAt, updatedAt: metadata.updatedAt, reason: metadata.reason, replay: { seq: history.lastReadableSeq, sha256: sha256(replayBytes), incomplete: history.incomplete || history.pendingTail }, contextSeq: null });
|
|
1615
|
+
}
|
|
1616
|
+
if (priorReceipt && currentReplay !== undefined && !currentReplay.equals(originalReplay ?? Buffer.alloc(0)) && !currentReplay.equals(replayBytes)) throw new Error('migration destination replay diverged');
|
|
1617
|
+
if (external && currentReplay !== undefined && !currentReplay.equals(replayBytes)) throw new Error('migration destination replay diverged');
|
|
1618
|
+
if (sidecar || external) {
|
|
1619
|
+
try {
|
|
1620
|
+
const existing = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'migration destination');
|
|
1621
|
+
if (existing !== `${JSON.stringify(manifest)}\n`) throw new Error('migration destination manifest diverged');
|
|
1622
|
+
} catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
|
|
1623
|
+
}
|
|
1624
|
+
await ensurePrivateDirectory(inputsDir, fs);
|
|
1625
|
+
const inputs = [{ path: sourcePath, bytes: Buffer.from(sourceBytes, 'utf8') }, ...(originalReplay === undefined ? [] : [{ path: sourceReplayPath, bytes: originalReplay }])];
|
|
1626
|
+
for (const [index, input] of inputs.entries()) {
|
|
1627
|
+
const path = join(inputsDir, String(index));
|
|
1628
|
+
try {
|
|
1629
|
+
const retained = await readPrivateRegularFile(path, 0o600, fs, 'retained migration source', true);
|
|
1630
|
+
if (!retained.equals(input.bytes)) throw new Error('migration retained source differs');
|
|
1631
|
+
} catch (cause) { if (cause?.code !== 'ENOENT') throw cause; await writePrivateBytes(path, input.bytes, fs); }
|
|
1632
|
+
}
|
|
1633
|
+
const receipt = { v: 1, id: sessionId, inputs: inputs.map(({ path, bytes }) => ({ path, sha256: sha256(bytes) })), complete: false };
|
|
1634
|
+
await writePrivateJson(receiptPath, receipt, fs);
|
|
1635
|
+
await lease.assertOwner();
|
|
1636
|
+
await writePrivateBytes(recordsPathFor(sessionId), replayBytes, fs);
|
|
1637
|
+
await writePrivateBytes(recordPathFor(sessionId), Buffer.from(`${JSON.stringify(manifest)}\n`), fs);
|
|
1638
|
+
const published = await validate(sessionId);
|
|
1639
|
+
if (!published.integrityValid) throw new Error('published migration bundle failed validation');
|
|
1640
|
+
if (external) {
|
|
1641
|
+
await sourceLease.assertOwner();
|
|
1642
|
+
try { await assertPrivateRegularPath(sourceReplayPath, 0o600, fs, 'migration source replay'); await fs.unlink(sourceReplayPath); await syncDirectory(sourceDir, fs); }
|
|
1643
|
+
catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
|
|
1644
|
+
}
|
|
1645
|
+
if (sidecar || external) { await fs.unlink(sourcePath); await syncDirectory(sourceDir, fs); }
|
|
1646
|
+
await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
|
|
1647
|
+
return { manifest, migrated: true, reasons: [] };
|
|
1648
|
+
} finally {
|
|
1649
|
+
try { await lease?.release(); } finally { await sourceLease?.release(); }
|
|
1650
|
+
}
|
|
1651
|
+
};
|
|
1652
|
+
|
|
1653
|
+
const migrateLegacyDefault = async (migration = {}) => {
|
|
1654
|
+
const sourceEnv = migration.env ?? env;
|
|
1655
|
+
const sourceHome = migration.homeDir ?? home;
|
|
1656
|
+
const sourceDir = join(sourceEnv.XDG_STATE_HOME || join(sourceHome, '.local', 'state'), 'playbook', 'sessions');
|
|
1657
|
+
const result = { sourceDir, migrated: [], skipped: [] };
|
|
1658
|
+
if (sourceDir === sessionsDir) return result;
|
|
1659
|
+
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, hostname: localHostname, pid: localPid, probeProcess });
|
|
1660
|
+
await sourceStore.prepare();
|
|
1661
|
+
try { await assertPrivateDirectory(sourceDir, fs); }
|
|
1662
|
+
catch (cause) { if (cause?.code === 'ENOENT') return result; throw cause; }
|
|
1663
|
+
const names = await fs.readdir(sourceDir);
|
|
1664
|
+
const ids = [...new Set(names.map((name) => name.endsWith('.records.jsonl') ? name.slice(0, -14) : name.endsWith('.json') ? name.slice(0, -5) : '').filter((id) => SESSION_ID_PATTERN.test(id)))].sort();
|
|
1665
|
+
for (const sessionId of ids) {
|
|
1666
|
+
const state = await sourceStore.readLeaseState(sessionId);
|
|
1667
|
+
if (state !== 'idle') throw new Error(`legacy session ${sessionId} ownership is ${state}; stop all old writers before migration`);
|
|
1668
|
+
const sourcePath = join(sourceDir, `${sessionId}.json`);
|
|
1669
|
+
try {
|
|
1670
|
+
const source = JSON.parse(await readPrivateRegularFile(sourcePath, 0o600, fs, 'legacy default manifest'));
|
|
1671
|
+
if (![2, 3, 4, 5, 6].includes(source.schemaVersion)) throw new Error(`unsupported legacy schema ${source.schemaVersion}`);
|
|
1672
|
+
try { validateCaptainSessionRecord(source); }
|
|
1673
|
+
catch (cause) { if (!(cause instanceof CaptainSessionRecordNonresumableError)) throw cause; }
|
|
1674
|
+
} catch (cause) { result.skipped.push({ sessionId, reason: errorMessage(cause) }); continue; }
|
|
1675
|
+
await migrate(sessionId, { sourcePath });
|
|
1676
|
+
result.migrated.push(sessionId);
|
|
1677
|
+
}
|
|
1678
|
+
return result;
|
|
1679
|
+
};
|
|
1680
|
+
|
|
1681
|
+
const remove = async (sessionId) => {
|
|
1682
|
+
const lease = await acquire(sessionId, true);
|
|
1683
|
+
try { await lease.assertOwner(); await deleteRecord(sessionId); }
|
|
1684
|
+
finally { await lease.release(); }
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1378
1687
|
return Object.freeze({
|
|
1379
1688
|
sessionsDir,
|
|
1689
|
+
prepare,
|
|
1690
|
+
migrate,
|
|
1691
|
+
migrateLegacyDefault,
|
|
1692
|
+
readManifest,
|
|
1693
|
+
readHistory,
|
|
1694
|
+
readLeaseState,
|
|
1695
|
+
validate,
|
|
1696
|
+
delete: remove,
|
|
1380
1697
|
listSummaries,
|
|
1381
1698
|
readSummary,
|
|
1382
1699
|
read,
|
|
1383
1700
|
readStream,
|
|
1384
1701
|
latest,
|
|
1385
1702
|
acquire,
|
|
1703
|
+
acquireManagement: (sessionId) => acquire(sessionId, true),
|
|
1386
1704
|
});
|
|
1387
1705
|
}
|
|
1388
1706
|
|
|
@@ -2077,7 +2395,7 @@ function canonicalReplayJson(value) {
|
|
|
2077
2395
|
return JSON.stringify(value);
|
|
2078
2396
|
}
|
|
2079
2397
|
|
|
2080
|
-
function sanitizeReplayValue(value, path, ancestors) {
|
|
2398
|
+
function sanitizeReplayValue(value, path, ancestors, scope = 'record') {
|
|
2081
2399
|
if (
|
|
2082
2400
|
value === null ||
|
|
2083
2401
|
typeof value === 'string' ||
|
|
@@ -2132,6 +2450,7 @@ function sanitizeReplayValue(value, path, ancestors) {
|
|
|
2132
2450
|
descriptor.value,
|
|
2133
2451
|
`${path}[${index}]`,
|
|
2134
2452
|
nextAncestors,
|
|
2453
|
+
scope,
|
|
2135
2454
|
),
|
|
2136
2455
|
);
|
|
2137
2456
|
}
|
|
@@ -2165,10 +2484,19 @@ function sanitizeReplayValue(value, path, ancestors) {
|
|
|
2165
2484
|
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
2166
2485
|
}
|
|
2167
2486
|
const nextAncestors = new Set(ancestors).add(value);
|
|
2487
|
+
// Cligent session IDs are provider continuations. Playbook trace and
|
|
2488
|
+
// checkpoint session IDs are logical identities and must survive.
|
|
2489
|
+
const type = descriptors.type?.value;
|
|
2490
|
+
const adapterEvent = scope !== 'content' &&
|
|
2491
|
+
typeof type === 'string' && typeof descriptors.agent?.value === 'string' &&
|
|
2492
|
+
Number.isFinite(descriptors.timestamp?.value) && Object.hasOwn(descriptors, 'payload');
|
|
2493
|
+
const provider = scope === 'provider' || scope === 'identity' || adapterEvent;
|
|
2494
|
+
const observed = type === 'captain_event' || type === 'player_event';
|
|
2168
2495
|
const copy = {};
|
|
2169
2496
|
for (const key of keys) {
|
|
2170
2497
|
if (typeof key !== 'string') continue;
|
|
2171
2498
|
if (key === 'resumeToken') continue;
|
|
2499
|
+
if (provider && (['sessionid', 'nativesessionid', 'threadid', 'conversationid'].includes(key.replaceAll('_', '').toLowerCase()) || (scope === 'identity' && key === 'id'))) continue;
|
|
2172
2500
|
const descriptor = descriptors[key];
|
|
2173
2501
|
if (
|
|
2174
2502
|
descriptor === undefined ||
|
|
@@ -2186,6 +2514,11 @@ function sanitizeReplayValue(value, path, ancestors) {
|
|
|
2186
2514
|
descriptor.value,
|
|
2187
2515
|
`${path}.${key}`,
|
|
2188
2516
|
nextAncestors,
|
|
2517
|
+
scope === 'content' || (provider && (key === 'input' || key === 'output'))
|
|
2518
|
+
? 'content'
|
|
2519
|
+
: provider
|
|
2520
|
+
? ['session', 'thread', 'conversation'].includes(key) ? 'identity' : 'provider'
|
|
2521
|
+
: observed && key === 'event' ? 'provider' : 'record',
|
|
2189
2522
|
),
|
|
2190
2523
|
enumerable: true,
|
|
2191
2524
|
configurable: true,
|
|
@@ -2203,6 +2536,10 @@ async function createLease({
|
|
|
2203
2536
|
replayFs,
|
|
2204
2537
|
readReplayStream,
|
|
2205
2538
|
readRecord,
|
|
2539
|
+
readManifest,
|
|
2540
|
+
readHistory,
|
|
2541
|
+
validateSession,
|
|
2542
|
+
portableWriters,
|
|
2206
2543
|
writeRecord,
|
|
2207
2544
|
syncRecordDirectory,
|
|
2208
2545
|
deleteRecord,
|
|
@@ -2253,9 +2590,102 @@ async function createLease({
|
|
|
2253
2590
|
readStream: readReplayStream,
|
|
2254
2591
|
});
|
|
2255
2592
|
|
|
2593
|
+
let contextSeq;
|
|
2594
|
+
let previousManifest;
|
|
2595
|
+
let acknowledgedHints = { players: {} };
|
|
2596
|
+
try { previousManifest = await readManifest(sessionId); contextSeq = previousManifest.contextSeq; }
|
|
2597
|
+
catch { /* Unsupported recovery still permits leased migration/deletion. */ }
|
|
2598
|
+
|
|
2599
|
+
const recordContext = async (value) => {
|
|
2600
|
+
const context = validateSessionContext(value);
|
|
2601
|
+
if (contextSeq !== undefined && contextSeq !== null) {
|
|
2602
|
+
const previous = (await readHistory(sessionId)).entries.find((entry) => entry.seq === contextSeq)?.record;
|
|
2603
|
+
if (previous) {
|
|
2604
|
+
const { timestamp: _previousTimestamp, ...oldContext } = previous;
|
|
2605
|
+
const { timestamp: _newTimestamp, ...newContext } = context;
|
|
2606
|
+
if (isDeepStrictEqual(oldContext, newContext)) return contextSeq;
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
await replayWriter.append(context);
|
|
2610
|
+
await replayWriter.checkpoint();
|
|
2611
|
+
const status = replayWriter.status();
|
|
2612
|
+
if (status.incomplete || status.lastDurableSeq === null) throw new Error('cannot persist session execution context');
|
|
2613
|
+
contextSeq = status.lastDurableSeq;
|
|
2614
|
+
return contextSeq;
|
|
2615
|
+
};
|
|
2616
|
+
const checkpoint = async (value) => {
|
|
2617
|
+
const recovery = validateCaptainSessionRecord(value);
|
|
2618
|
+
const required = contextFromRecovery(recovery);
|
|
2619
|
+
let contextHistory;
|
|
2620
|
+
try { contextHistory = await readHistory(sessionId); } catch { /* Preserve the last proven context on replay failure. */ }
|
|
2621
|
+
const applicable = contextHistory?.entries.findLast(({ record }) =>
|
|
2622
|
+
record.type === 'session_context' && record.contextVersion === 1 &&
|
|
2623
|
+
record.captainId === required.captainId && isDeepStrictEqual(record.configuration, required.configuration));
|
|
2624
|
+
if (applicable) contextSeq = applicable.seq;
|
|
2625
|
+
else if (contextSeq === undefined || contextSeq === null || !replayWriter.status().incomplete) await recordContext(required);
|
|
2626
|
+
await replayWriter.checkpoint();
|
|
2627
|
+
const status = replayWriter.status();
|
|
2628
|
+
let history;
|
|
2629
|
+
try { history = await readHistory(sessionId); }
|
|
2630
|
+
catch { history = { digests: [], incomplete: true }; }
|
|
2631
|
+
const durableSeq = status.lastDurableSeq ?? previousManifest?.replay?.seq ?? 0;
|
|
2632
|
+
const seq = history.digests[durableSeq] === undefined ? previousManifest?.replay?.seq ?? 0 : durableSeq;
|
|
2633
|
+
const digest = history.digests[seq] ?? previousManifest?.replay?.sha256 ?? EMPTY_REPLAY_SHA256;
|
|
2634
|
+
const replay = { seq, sha256: digest, incomplete: previousManifest?.replay?.incomplete === true || status.incomplete || history.incomplete || history.digests[seq] === undefined };
|
|
2635
|
+
const manifest = manifestFromRecovery(recovery, replay, contextSeq);
|
|
2636
|
+
return manifest;
|
|
2637
|
+
};
|
|
2638
|
+
portableWriters.set(sessionId, { checkpoint, sync: replayWriter.checkpoint, published: (manifest) => { previousManifest = manifest; } });
|
|
2639
|
+
|
|
2640
|
+
const hintsPath = join(sessionsDir, `${sessionId}.hints.json`);
|
|
2641
|
+
const readHints = async () => {
|
|
2642
|
+
try {
|
|
2643
|
+
const bytes = await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, replayFs, 'record');
|
|
2644
|
+
const manifest = validateSessionManifest(JSON.parse(bytes));
|
|
2645
|
+
const hints = JSON.parse(await readPrivateRegularFile(hintsPath, 0o600, replayFs, 'hints'));
|
|
2646
|
+
return validateSessionHints(hints, bytes, manifest);
|
|
2647
|
+
} catch { return { players: {} }; }
|
|
2648
|
+
};
|
|
2649
|
+
const writeHints = async (hints) => {
|
|
2650
|
+
const bytes = await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, replayFs, 'record');
|
|
2651
|
+
const manifest = validateSessionManifest(JSON.parse(bytes));
|
|
2652
|
+
const value = { v: 1, sessionId, checkpointSha256: sha256(bytes), players: hints.players, ...(hints.captain ? { captain: hints.captain } : {}) };
|
|
2653
|
+
validateSessionHints(value, bytes, manifest);
|
|
2654
|
+
await writePrivateJson(hintsPath, value, replayFs);
|
|
2655
|
+
};
|
|
2656
|
+
const consumeHints = () => runExclusive(async () => {
|
|
2657
|
+
await assertOwnerUnchecked();
|
|
2658
|
+
const hints = await readHints();
|
|
2659
|
+
await writeHints({ players: {} });
|
|
2660
|
+
// Unused conversations cannot advance. Retain that proof until the
|
|
2661
|
+
// participant's before-call hook clears it; crashes still lose the hints.
|
|
2662
|
+
acknowledgedHints = structuredClone(hints);
|
|
2663
|
+
return hints;
|
|
2664
|
+
});
|
|
2665
|
+
const acknowledgeHint = (participantId, token) => {
|
|
2666
|
+
if (typeof token !== 'string' || token.length === 0) return;
|
|
2667
|
+
if (participantId === 'captain') acknowledgedHints.captain = { kind: 'pinned', token };
|
|
2668
|
+
else acknowledgedHints.players[participantId] = token;
|
|
2669
|
+
};
|
|
2670
|
+
const clearHint = (participantId) => {
|
|
2671
|
+
if (participantId === 'captain') delete acknowledgedHints.captain;
|
|
2672
|
+
else delete acknowledgedHints.players[participantId];
|
|
2673
|
+
};
|
|
2674
|
+
const assertContinuable = async (context = {}) => {
|
|
2675
|
+
const result = await validateSession(sessionId, context);
|
|
2676
|
+
if (!result.resumable) throw new Error(result.reasons.join('; '));
|
|
2677
|
+
return result;
|
|
2678
|
+
};
|
|
2679
|
+
const append = (record, role) => replayWriter.append(
|
|
2680
|
+
contextSeq !== undefined && record?.type !== 'session_context' && typeof record?.type === 'string' && Number.isFinite(record?.timestamp)
|
|
2681
|
+
? { ...record, contextSeq } : record,
|
|
2682
|
+
role,
|
|
2683
|
+
);
|
|
2684
|
+
|
|
2256
2685
|
const finishSettlement = async (record) => {
|
|
2257
2686
|
await replayWriter.checkpoint();
|
|
2258
|
-
|
|
2687
|
+
try { await writeHints(acknowledgedHints); } catch { /* Hints are optional; missing hints start fresh. */ }
|
|
2688
|
+
return validateCaptainSessionRecord(projectRecovery(record));
|
|
2259
2689
|
};
|
|
2260
2690
|
|
|
2261
2691
|
const read = () =>
|
|
@@ -2367,6 +2797,7 @@ async function createLease({
|
|
|
2367
2797
|
const initializeSettledWithPredecessor = (options = {}) =>
|
|
2368
2798
|
runExclusive(async () => {
|
|
2369
2799
|
const target = freshSettledRecord(options);
|
|
2800
|
+
if (options.context !== undefined) await recordContext(options.context);
|
|
2370
2801
|
await assertOwnerUnchecked();
|
|
2371
2802
|
if (
|
|
2372
2803
|
(await readRecord(sessionId, { missing: 'undefined' })) !== undefined
|
|
@@ -2602,6 +3033,7 @@ async function createLease({
|
|
|
2602
3033
|
'Captain session attempted execution projection',
|
|
2603
3034
|
);
|
|
2604
3035
|
await assertOwnerUnchecked();
|
|
3036
|
+
await assertContinuable();
|
|
2605
3037
|
const prior = await readRecord(sessionId, { missing: 'undefined' });
|
|
2606
3038
|
if (prior === undefined) {
|
|
2607
3039
|
throw new Error('Captain session does not exist for continuation');
|
|
@@ -2649,6 +3081,7 @@ async function createLease({
|
|
|
2649
3081
|
throw new Error('Captain session retry requires a fresh attempt id');
|
|
2650
3082
|
}
|
|
2651
3083
|
await assertOwnerUnchecked();
|
|
3084
|
+
await assertContinuable();
|
|
2652
3085
|
const prior = await requireUncertainRecord(
|
|
2653
3086
|
await readRecord(sessionId, { missing: 'undefined' }),
|
|
2654
3087
|
expectedAttemptId,
|
|
@@ -2937,7 +3370,7 @@ async function createLease({
|
|
|
2937
3370
|
} = {}) =>
|
|
2938
3371
|
runExclusive(async () => {
|
|
2939
3372
|
assertUuid(attemptId, 'Captain session attempt id');
|
|
2940
|
-
const
|
|
3373
|
+
const rawUpdates = validateRetainedGenerationUpdates(retentionUpdates);
|
|
2941
3374
|
const settledUnresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
|
|
2942
3375
|
unresolvedEffects,
|
|
2943
3376
|
);
|
|
@@ -2960,7 +3393,19 @@ async function createLease({
|
|
|
2960
3393
|
'Captain session abandonment settlement attempt differs from its durable marker',
|
|
2961
3394
|
);
|
|
2962
3395
|
}
|
|
2963
|
-
|
|
3396
|
+
// Compare durable recovery forms, not provider-local continuation hints.
|
|
3397
|
+
// Validate before projection so removing a token cannot legalize bad input.
|
|
3398
|
+
const rawSnapshot = assertPlaybookCaptainShellSnapshot(snapshot);
|
|
3399
|
+
const rawRetained = applyRetainedGenerationUpdates(
|
|
3400
|
+
current.retainedGenerations ?? {}, rawUpdates, current.structuralProjection);
|
|
3401
|
+
validateRetainedGenerations(rawRetained, current.structuralProjection, current.effectLedger);
|
|
3402
|
+
const projected = projectRecovery({ ...current, snapshot: rawSnapshot,
|
|
3403
|
+
retainedGenerations: rawRetained,
|
|
3404
|
+
});
|
|
3405
|
+
const settledSnapshot = assertPlaybookCaptainShellSnapshot(projected.snapshot);
|
|
3406
|
+
const updates = rawUpdates.map((update) => update.kind === 'retain'
|
|
3407
|
+
? { ...update, generation: projected.retainedGenerations[update.rootPlaybookId] }
|
|
3408
|
+
: update);
|
|
2964
3409
|
if (settledAbandonment !== undefined) {
|
|
2965
3410
|
requireAbandonmentSettlement(
|
|
2966
3411
|
settledAbandonment,
|
|
@@ -3186,8 +3631,7 @@ async function createLease({
|
|
|
3186
3631
|
await assertOwnerUnchecked();
|
|
3187
3632
|
return undefined;
|
|
3188
3633
|
}
|
|
3189
|
-
//
|
|
3190
|
-
// bytes from the baseline carried by the uncertain record.
|
|
3634
|
+
// Restore the prior recovery baseline while retaining attempt history.
|
|
3191
3635
|
const record = validateCaptainSessionRecord({
|
|
3192
3636
|
schemaVersion: prior.schemaVersion,
|
|
3193
3637
|
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
@@ -3214,9 +3658,14 @@ async function createLease({
|
|
|
3214
3658
|
replayWriter.closeAppendAdmission();
|
|
3215
3659
|
return runExclusive(async () => {
|
|
3216
3660
|
await replayWriter.prepareRelease();
|
|
3661
|
+
if (replayWriter.status().incomplete && previousManifest?.replay?.incomplete !== true) {
|
|
3662
|
+
const record = await readRecord(sessionId, { missing: 'undefined' });
|
|
3663
|
+
if (record !== undefined) await writeRecord(record, { noReplace: false });
|
|
3664
|
+
}
|
|
3217
3665
|
const current = await assertOwnerUnchecked();
|
|
3218
3666
|
await retireObservedLease(sessionId, current);
|
|
3219
3667
|
released = true;
|
|
3668
|
+
portableWriters.delete(sessionId);
|
|
3220
3669
|
return replayWriter.status();
|
|
3221
3670
|
});
|
|
3222
3671
|
};
|
|
@@ -3224,7 +3673,13 @@ async function createLease({
|
|
|
3224
3673
|
return Object.freeze({
|
|
3225
3674
|
sessionId,
|
|
3226
3675
|
ownerToken: owner.ownerToken,
|
|
3227
|
-
append
|
|
3676
|
+
append,
|
|
3677
|
+
recordContext,
|
|
3678
|
+
consumeHints,
|
|
3679
|
+
acknowledgeHint,
|
|
3680
|
+
clearHint,
|
|
3681
|
+
assertContinuable,
|
|
3682
|
+
readManifest: () => readManifest(sessionId),
|
|
3228
3683
|
readStream: replayWriter.read,
|
|
3229
3684
|
streamStatus: replayWriter.status,
|
|
3230
3685
|
read,
|
|
@@ -3401,6 +3856,7 @@ export function captainSessionSelectedMembers(value) {
|
|
|
3401
3856
|
}
|
|
3402
3857
|
|
|
3403
3858
|
export function validateCaptainSessionRecord(value) {
|
|
3859
|
+
if (value?.schemaVersion === 7) return recoveryFromManifest(value);
|
|
3404
3860
|
const record = requireRecord(
|
|
3405
3861
|
snapshotJsonValue(value, 'Captain session record'),
|
|
3406
3862
|
'Captain session record',
|
|
@@ -3514,12 +3970,7 @@ function validateCanonicalCaptainSessionRecord(
|
|
|
3514
3970
|
'settled Captain session updatedAt must follow its creation marker',
|
|
3515
3971
|
);
|
|
3516
3972
|
}
|
|
3517
|
-
if (
|
|
3518
|
-
throw new Error('Captain session record cwd must be an absolute path');
|
|
3519
|
-
}
|
|
3520
|
-
if (resolve(record.cwd) !== record.cwd) {
|
|
3521
|
-
throw new Error('Captain session record cwd must be normalized');
|
|
3522
|
-
}
|
|
3973
|
+
if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
|
|
3523
3974
|
const structural = validateCaptainSessionStructuralProjectionWithSchemas(
|
|
3524
3975
|
record.structuralProjection,
|
|
3525
3976
|
'Captain session record structuralProjection',
|
|
@@ -4713,12 +5164,7 @@ function assertReleasedSchema2CaptainSessionRecord(record) {
|
|
|
4713
5164
|
'settled Captain session updatedAt must follow its creation marker',
|
|
4714
5165
|
);
|
|
4715
5166
|
}
|
|
4716
|
-
if (
|
|
4717
|
-
throw new Error('Captain session record cwd must be an absolute path');
|
|
4718
|
-
}
|
|
4719
|
-
if (resolve(record.cwd) !== record.cwd) {
|
|
4720
|
-
throw new Error('Captain session record cwd must be normalized');
|
|
4721
|
-
}
|
|
5167
|
+
if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
|
|
4722
5168
|
requireRecord(record.config, 'Captain session record config');
|
|
4723
5169
|
requireRecord(record.snapshot, 'Captain session record snapshot');
|
|
4724
5170
|
|
|
@@ -5900,8 +6346,8 @@ function nextTimestamp(value, previous) {
|
|
|
5900
6346
|
return new Date(Date.parse(previous) + 1).toISOString();
|
|
5901
6347
|
}
|
|
5902
6348
|
|
|
5903
|
-
async function readPrivateRegularFile(path, mode, fs, label) {
|
|
5904
|
-
await assertPrivateRegularPath(path, mode, fs, label);
|
|
6349
|
+
async function readPrivateRegularFile(path, mode, fs, label, raw = false) {
|
|
6350
|
+
const before = await assertPrivateRegularPath(path, mode, fs, label);
|
|
5905
6351
|
const handle = await fs.open(
|
|
5906
6352
|
path,
|
|
5907
6353
|
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
@@ -5914,7 +6360,12 @@ async function readPrivateRegularFile(path, mode, fs, label) {
|
|
|
5914
6360
|
if ((stat.mode & 0o7777) !== mode) {
|
|
5915
6361
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
5916
6362
|
}
|
|
5917
|
-
|
|
6363
|
+
if (!sameFileIdentity(before, stat)) throw new Error(`${label} changed during open`);
|
|
6364
|
+
const bytes = await handle.readFile();
|
|
6365
|
+
const after = await handle.stat();
|
|
6366
|
+
const current = await assertPrivateRegularPath(path, mode, fs, label);
|
|
6367
|
+
if (!sameFileIdentity(after, current) || after.size !== stat.size) throw new Error(`${label} changed during read`);
|
|
6368
|
+
return raw ? bytes : new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
5918
6369
|
} finally {
|
|
5919
6370
|
await handle.close();
|
|
5920
6371
|
}
|
|
@@ -6017,3 +6468,109 @@ async function assertDirectoryNotLink(path, fs) {
|
|
|
6017
6468
|
function errorMessage(error) {
|
|
6018
6469
|
return error instanceof Error ? error.message : String(error);
|
|
6019
6470
|
}
|
|
6471
|
+
|
|
6472
|
+
// Opening is the only permission preparation boundary. Strict readers never
|
|
6473
|
+
// change modes, and verified handles ensure tightening cannot follow links.
|
|
6474
|
+
async function prepareSessionPermissions(sessionsDir, fs, selectedSessionId, includeSidecars = false) {
|
|
6475
|
+
let initial;
|
|
6476
|
+
try { initial = await fs.lstat(sessionsDir); }
|
|
6477
|
+
catch (cause) { if (cause?.code === 'ENOENT') return; throw cause; }
|
|
6478
|
+
const uid = process.getuid?.();
|
|
6479
|
+
const verify = (stat, directory) => {
|
|
6480
|
+
const required = directory ? 0o700 : 0o600;
|
|
6481
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) || (uid !== undefined && stat.uid !== uid) || (stat.mode & required) !== required) throw new Error('session permission preparation refuses unsafe ownership, links, type, or owner access');
|
|
6482
|
+
};
|
|
6483
|
+
const tighten = async (path, before, directory) => {
|
|
6484
|
+
verify(before, directory);
|
|
6485
|
+
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (directory ? constants.O_DIRECTORY ?? 0 : 0));
|
|
6486
|
+
try {
|
|
6487
|
+
const opened = await handle.stat(); verify(opened, directory);
|
|
6488
|
+
if (!sameFileIdentity(before, opened)) throw new Error('session entry changed during permission preparation');
|
|
6489
|
+
const mode = directory ? 0o700 : 0o600;
|
|
6490
|
+
if ((opened.mode & 0o7777) !== mode) await handle.chmod(mode);
|
|
6491
|
+
const after = await handle.stat(); verify(after, directory);
|
|
6492
|
+
const current = await fs.lstat(path); verify(current, directory);
|
|
6493
|
+
if (!sameFileIdentity(after, current) || (after.mode & 0o7777) !== mode || (current.mode & 0o7777) !== mode) throw new Error('session permission tightening could not be verified');
|
|
6494
|
+
} finally { await handle.close(); }
|
|
6495
|
+
};
|
|
6496
|
+
await tighten(sessionsDir, initial, true);
|
|
6497
|
+
for (const name of await fs.readdir(sessionsDir)) {
|
|
6498
|
+
const eligible = /^[0-9a-f-]{36}\.(?:json|records\.jsonl|hints\.json)$/.test(name)
|
|
6499
|
+
|| (includeSidecars && /^[0-9a-f-]{36}\.spex\.json$/.test(name));
|
|
6500
|
+
if (!eligible || (selectedSessionId !== undefined && !name.startsWith(`${selectedSessionId}.`))) continue;
|
|
6501
|
+
const path = join(sessionsDir, name);
|
|
6502
|
+
await tighten(path, await fs.lstat(path), false);
|
|
6503
|
+
}
|
|
6504
|
+
}
|
|
6505
|
+
|
|
6506
|
+
async function readSessionHistory({ sessionsDir, path, fs, afterSeq = 0 }) {
|
|
6507
|
+
if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) throw new Error('history afterSeq must be a nonnegative safe integer');
|
|
6508
|
+
const snapshot = await readReplaySnapshot({ sessionsDir, path, fs, afterSeq: 0, forceFullRead: true });
|
|
6509
|
+
const bytes = snapshot.absent ? Buffer.alloc(0) : snapshot.bytes;
|
|
6510
|
+
return Object.freeze({ ...parseSessionHistory(bytes, afterSeq), missing: snapshot.absent === true });
|
|
6511
|
+
}
|
|
6512
|
+
|
|
6513
|
+
|
|
6514
|
+
function legacyJournalHistory(record, afterSeq, absentHistory) {
|
|
6515
|
+
const entries = [];
|
|
6516
|
+
const timestamp = Date.parse(record.updatedAt);
|
|
6517
|
+
let activeTurn;
|
|
6518
|
+
const emit = (event) => entries.push(Object.freeze({ v: 1, seq: entries.length + 1, record: Object.freeze(event) }));
|
|
6519
|
+
const finish = () => {
|
|
6520
|
+
if (activeTurn !== undefined) emit({ type: 'turn_finished', timestamp, turnId: activeTurn });
|
|
6521
|
+
activeTurn = undefined;
|
|
6522
|
+
};
|
|
6523
|
+
for (const entry of record.snapshot?.journal ?? []) {
|
|
6524
|
+
if (!Number.isSafeInteger(entry.turnId) || entry.turnId <= 0 || typeof entry.payload !== 'string') continue;
|
|
6525
|
+
if (entry.kind === 'boss') {
|
|
6526
|
+
finish(); activeTurn = entry.turnId;
|
|
6527
|
+
emit({ type: 'turn_started', timestamp, turnId: entry.turnId, turn: { id: entry.turnId, prompt: entry.payload } });
|
|
6528
|
+
} else if (entry.kind === 'reply' && entry.turnId === activeTurn) {
|
|
6529
|
+
emit({ type: 'captain_reply', timestamp, turnId: entry.turnId, text: entry.payload });
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
finish();
|
|
6533
|
+
return Object.freeze({ ...absentHistory, synthetic: true, lastReadableSeq: entries.length, entries: Object.freeze(entries.filter(({ seq }) => seq > afterSeq)) });
|
|
6534
|
+
}
|
|
6535
|
+
|
|
6536
|
+
function parseSessionHistory(bytes, afterSeq = 0) {
|
|
6537
|
+
const entries = [], digests = [EMPTY_REPLAY_SHA256];
|
|
6538
|
+
const hash = createHash('sha256');
|
|
6539
|
+
let offset = 0, seq = 0, damage;
|
|
6540
|
+
while (offset < bytes.length) {
|
|
6541
|
+
const newline = bytes.indexOf(10, offset);
|
|
6542
|
+
if (newline < 0) break;
|
|
6543
|
+
let entry;
|
|
6544
|
+
try { entry = parseReplayEnvelope(bytes.subarray(offset, newline), seq + 1); }
|
|
6545
|
+
catch (cause) { damage = { seq: seq + 1, offset, reason: errorMessage(cause) }; break; }
|
|
6546
|
+
hash.update(bytes.subarray(offset, newline + 1));
|
|
6547
|
+
seq += 1; digests.push(hash.copy().digest('hex'));
|
|
6548
|
+
if (seq > afterSeq) entries.push(entry);
|
|
6549
|
+
offset = newline + 1;
|
|
6550
|
+
}
|
|
6551
|
+
return Object.freeze({ entries: Object.freeze(entries), lastReadableSeq: seq, incomplete: damage !== undefined, ...(damage ? { damage } : {}), pendingTail: damage === undefined && offset < bytes.length, digests: Object.freeze(digests), completeBytes: offset });
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
async function writePrivateJson(path, value, fs) {
|
|
6555
|
+
return writePrivateBytes(path, Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'), fs);
|
|
6556
|
+
}
|
|
6557
|
+
|
|
6558
|
+
async function writePrivateBytes(path, bytes, fs) {
|
|
6559
|
+
const directory = dirname(path);
|
|
6560
|
+
await assertPrivateDirectory(directory, fs);
|
|
6561
|
+
try { await assertPrivateRegularPath(path, 0o600, fs, 'session data'); }
|
|
6562
|
+
catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
|
|
6563
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
6564
|
+
let handle;
|
|
6565
|
+
try {
|
|
6566
|
+
handle = await fs.open(temporary, 'wx', 0o600);
|
|
6567
|
+
await handle.chmod(0o600);
|
|
6568
|
+
await handle.writeFile(bytes);
|
|
6569
|
+
await handle.sync(); await handle.close(); handle = undefined;
|
|
6570
|
+
await fs.rename(temporary, path);
|
|
6571
|
+
await syncDirectory(directory, fs);
|
|
6572
|
+
} finally {
|
|
6573
|
+
await handle?.close();
|
|
6574
|
+
try { await fs.unlink(temporary); } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
|
|
6575
|
+
}
|
|
6576
|
+
}
|