@sublang/playbook 12.2.2 → 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 +13 -7
- 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 +638 -70
- package/reference/sdlc/code.playbook/code.fsm.js +43 -5
- package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
- package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
- 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/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
- package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
- package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
- package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
- package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
- package/reference/sdlc/review.playbook/review.fsm.js +11 -1
- package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
- package/slc/gears2fsm.md +21 -3
- package/slc/link.md +21 -2
- package/src/runtime.d.ts +7 -0
- package/src/runtime.ts +12 -0
- package/src/xstate-playbook-runtime.d.ts +13 -0
- package/src/xstate-playbook-runtime.js +75 -13
- package/src/xstate-playbook-runtime.ts +87 -21
- package/src/xstate-runtime.js +35 -4
- package/src/xstate-runtime.ts +49 -4
|
@@ -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,
|
|
@@ -57,8 +63,22 @@ export const CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION = 1;
|
|
|
57
63
|
export const CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION = 2;
|
|
58
64
|
|
|
59
65
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
60
|
-
const
|
|
66
|
+
const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
|
|
61
67
|
const RESERVED_ID = 'captain';
|
|
68
|
+
|
|
69
|
+
// PBCLI-4: a local role id is its source role name lowercased by Unicode case
|
|
70
|
+
// mapping — nonempty, free of whitespace and control characters, and equal to
|
|
71
|
+
// its own lowercase form. The rule restricts neither script nor alphabet, so
|
|
72
|
+
// `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
|
|
73
|
+
// enforce the reserved `captain` name separately.
|
|
74
|
+
export function isCanonicalLocalRoleId(value) {
|
|
75
|
+
return (
|
|
76
|
+
typeof value === 'string' &&
|
|
77
|
+
value.length > 0 &&
|
|
78
|
+
value === value.toLowerCase() &&
|
|
79
|
+
!ROLE_ID_WHITESPACE_OR_CONTROL.test(value)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
62
82
|
const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
|
|
63
83
|
const KNOWN_ADAPTERS = new Set(KNOWN_PLAYER_ADAPTERS);
|
|
64
84
|
|
|
@@ -205,8 +225,7 @@ export function defaultCaptainSessionsDir(
|
|
|
205
225
|
env = process.env,
|
|
206
226
|
home = env.HOME ?? homedir(),
|
|
207
227
|
) {
|
|
208
|
-
|
|
209
|
-
return join(stateHome, 'playbook', 'sessions');
|
|
228
|
+
return join(typeof env.SPEX_HOME === 'string' && env.SPEX_HOME.trim() !== '' ? env.SPEX_HOME : join(home, '.spex'), 'sessions');
|
|
210
229
|
}
|
|
211
230
|
|
|
212
231
|
// PBCLI-78: front-end bootstrap checks the same private filesystem boundary
|
|
@@ -221,6 +240,7 @@ export async function assertCaptainSessionsDirectoryUsable(
|
|
|
221
240
|
}
|
|
222
241
|
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
223
242
|
try {
|
|
243
|
+
await prepareSessionPermissions(sessionsDir, fs);
|
|
224
244
|
await assertPrivateDirectory(sessionsDir, fs);
|
|
225
245
|
await fs.access(
|
|
226
246
|
sessionsDir,
|
|
@@ -616,6 +636,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
616
636
|
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
617
637
|
const replayReadCursors = new Map();
|
|
618
638
|
const replayReadQueues = new Map();
|
|
639
|
+
const portableWriters = new Map();
|
|
619
640
|
|
|
620
641
|
if (!isAbsolute(sessionsDir)) {
|
|
621
642
|
throw new Error('Captain session store path must be absolute');
|
|
@@ -651,7 +672,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
651
672
|
return join(sessionsDir, `.${sessionId}.lock.retired.${ownerToken}`);
|
|
652
673
|
};
|
|
653
674
|
|
|
654
|
-
const readRecord = async (sessionId, { missing = 'error' } = {}) => {
|
|
675
|
+
const readRecord = async (sessionId, { missing = 'error', requirePortable = false } = {}) => {
|
|
655
676
|
const path = recordPathFor(sessionId);
|
|
656
677
|
let text;
|
|
657
678
|
try {
|
|
@@ -676,7 +697,16 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
676
697
|
}
|
|
677
698
|
let record;
|
|
678
699
|
try {
|
|
679
|
-
|
|
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
|
+
}
|
|
680
710
|
} catch (cause) {
|
|
681
711
|
const context =
|
|
682
712
|
`Captain session ${JSON.stringify(sessionId)} at ` +
|
|
@@ -720,6 +750,70 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
720
750
|
return record;
|
|
721
751
|
};
|
|
722
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
|
+
|
|
723
817
|
const read = (sessionId) => readRecord(sessionId);
|
|
724
818
|
|
|
725
819
|
const readStream = async (sessionId, options) => {
|
|
@@ -809,15 +903,16 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
809
903
|
// Canonically named records are store-owned. Corruption must not make
|
|
810
904
|
// --continue silently select an older logical session.
|
|
811
905
|
try {
|
|
812
|
-
candidates.push(await readRecord(sessionId));
|
|
906
|
+
candidates.push(await readRecord(sessionId, { requirePortable: true }));
|
|
813
907
|
} catch (error) {
|
|
814
908
|
if (error instanceof CaptainSessionRecordNonresumableError) {
|
|
815
|
-
await onLegacyOrderingBoundary?.(error.orderingBoundary);
|
|
909
|
+
if (error.orderingBoundary) await onLegacyOrderingBoundary?.(error.orderingBoundary);
|
|
816
910
|
await onLegacyRecord?.(
|
|
817
911
|
Object.freeze({
|
|
818
912
|
sessionId,
|
|
819
913
|
path: recordPathFor(sessionId),
|
|
820
914
|
schemaVersion: error.schemaVersion,
|
|
915
|
+
...(error.schemaVersion >= 6 ? { reason: error.cause?.message ?? error.message } : {}),
|
|
821
916
|
}),
|
|
822
917
|
);
|
|
823
918
|
continue;
|
|
@@ -851,37 +946,24 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
851
946
|
);
|
|
852
947
|
};
|
|
853
948
|
|
|
854
|
-
const readSummary = async (sessionId) =>
|
|
855
|
-
|
|
949
|
+
const readSummary = async (sessionId) => {
|
|
950
|
+
const manifest = await readManifest(sessionId);
|
|
951
|
+
if (manifest.schemaVersion !== 7) validateCaptainSessionRecord(manifest);
|
|
952
|
+
return projectPlaybookSessionSummary(manifest);
|
|
953
|
+
};
|
|
856
954
|
|
|
857
955
|
const listSummaries = async () => {
|
|
858
|
-
const skipped = [];
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
},
|
|
870
|
-
onInvalidRecord: ({ sessionId, reason }) => {
|
|
871
|
-
skipped.push(Object.freeze({ sessionId, reason }));
|
|
872
|
-
},
|
|
873
|
-
skipInvalidRecords: true,
|
|
874
|
-
});
|
|
875
|
-
return Object.freeze({
|
|
876
|
-
sessions: Object.freeze(
|
|
877
|
-
sortCaptainSessionRecords(records).map(projectPlaybookSessionSummary),
|
|
878
|
-
),
|
|
879
|
-
skipped: Object.freeze(
|
|
880
|
-
skipped.sort((left, right) =>
|
|
881
|
-
left.sessionId.localeCompare(right.sessionId),
|
|
882
|
-
),
|
|
883
|
-
),
|
|
884
|
-
});
|
|
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) });
|
|
885
967
|
};
|
|
886
968
|
|
|
887
969
|
const scanAdoptionPredecessor = async (
|
|
@@ -916,7 +998,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
916
998
|
...legacyOrderingBoundaries.filter(
|
|
917
999
|
(candidate) =>
|
|
918
1000
|
candidate.sessionId !== target.sessionId &&
|
|
919
|
-
candidate.state
|
|
1001
|
+
candidate.state !== 'uncertain' &&
|
|
920
1002
|
candidate.cwd === target.cwd,
|
|
921
1003
|
),
|
|
922
1004
|
]);
|
|
@@ -940,9 +1022,12 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
940
1022
|
|
|
941
1023
|
const writeRecord = async (
|
|
942
1024
|
recordValue,
|
|
943
|
-
{ noReplace, onPublished },
|
|
1025
|
+
{ noReplace, onPublished, portable = false },
|
|
944
1026
|
) => {
|
|
945
|
-
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');
|
|
946
1031
|
const destination = recordPathFor(record.sessionId);
|
|
947
1032
|
await ensurePrivateDirectory(sessionsDir, fs);
|
|
948
1033
|
|
|
@@ -1006,6 +1091,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1006
1091
|
onPublished?.();
|
|
1007
1092
|
}
|
|
1008
1093
|
await syncDirectory(sessionsDir, fs);
|
|
1094
|
+
portableWriters.get(record.sessionId)?.published(record);
|
|
1009
1095
|
return record;
|
|
1010
1096
|
} catch (cause) {
|
|
1011
1097
|
try {
|
|
@@ -1025,10 +1111,11 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1025
1111
|
};
|
|
1026
1112
|
|
|
1027
1113
|
const deleteRecord = async (sessionId) => {
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
+
}
|
|
1032
1119
|
};
|
|
1033
1120
|
|
|
1034
1121
|
const readLeaseDirectory = async (
|
|
@@ -1083,6 +1170,26 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1083
1170
|
const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
|
|
1084
1171
|
readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
|
|
1085
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
|
+
|
|
1086
1193
|
const activeLeaseErrorFor = async (sessionId, owner) => {
|
|
1087
1194
|
if (owner.hostname !== localHostname) {
|
|
1088
1195
|
return foreignCaptainSessionLeaseActiveError(
|
|
@@ -1286,8 +1393,9 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1286
1393
|
return publishedOwner;
|
|
1287
1394
|
};
|
|
1288
1395
|
|
|
1289
|
-
const acquire = async (sessionId) => {
|
|
1396
|
+
const acquire = async (sessionId, management = false) => {
|
|
1290
1397
|
assertSessionId(sessionId);
|
|
1398
|
+
await prepareSessionPermissions(sessionsDir, fs, sessionId);
|
|
1291
1399
|
let stage;
|
|
1292
1400
|
let stagePublished = false;
|
|
1293
1401
|
try {
|
|
@@ -1316,6 +1424,21 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1316
1424
|
await publishLeaseStage(sessionId, stage, () => {
|
|
1317
1425
|
stagePublished = true;
|
|
1318
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
|
+
}
|
|
1319
1442
|
return await createLease({
|
|
1320
1443
|
sessionId,
|
|
1321
1444
|
owner: stage.owner,
|
|
@@ -1324,6 +1447,10 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1324
1447
|
replayFs: fs,
|
|
1325
1448
|
readReplayStream: (options) => readStream(sessionId, options),
|
|
1326
1449
|
readRecord,
|
|
1450
|
+
readManifest,
|
|
1451
|
+
readHistory,
|
|
1452
|
+
validateSession: validate,
|
|
1453
|
+
portableWriters,
|
|
1327
1454
|
writeRecord,
|
|
1328
1455
|
syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
|
|
1329
1456
|
deleteRecord,
|
|
@@ -1361,14 +1488,219 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1361
1488
|
}
|
|
1362
1489
|
};
|
|
1363
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
|
+
|
|
1364
1687
|
return Object.freeze({
|
|
1365
1688
|
sessionsDir,
|
|
1689
|
+
prepare,
|
|
1690
|
+
migrate,
|
|
1691
|
+
migrateLegacyDefault,
|
|
1692
|
+
readManifest,
|
|
1693
|
+
readHistory,
|
|
1694
|
+
readLeaseState,
|
|
1695
|
+
validate,
|
|
1696
|
+
delete: remove,
|
|
1366
1697
|
listSummaries,
|
|
1367
1698
|
readSummary,
|
|
1368
1699
|
read,
|
|
1369
1700
|
readStream,
|
|
1370
1701
|
latest,
|
|
1371
1702
|
acquire,
|
|
1703
|
+
acquireManagement: (sessionId) => acquire(sessionId, true),
|
|
1372
1704
|
});
|
|
1373
1705
|
}
|
|
1374
1706
|
|
|
@@ -2063,7 +2395,7 @@ function canonicalReplayJson(value) {
|
|
|
2063
2395
|
return JSON.stringify(value);
|
|
2064
2396
|
}
|
|
2065
2397
|
|
|
2066
|
-
function sanitizeReplayValue(value, path, ancestors) {
|
|
2398
|
+
function sanitizeReplayValue(value, path, ancestors, scope = 'record') {
|
|
2067
2399
|
if (
|
|
2068
2400
|
value === null ||
|
|
2069
2401
|
typeof value === 'string' ||
|
|
@@ -2118,6 +2450,7 @@ function sanitizeReplayValue(value, path, ancestors) {
|
|
|
2118
2450
|
descriptor.value,
|
|
2119
2451
|
`${path}[${index}]`,
|
|
2120
2452
|
nextAncestors,
|
|
2453
|
+
scope,
|
|
2121
2454
|
),
|
|
2122
2455
|
);
|
|
2123
2456
|
}
|
|
@@ -2151,10 +2484,19 @@ function sanitizeReplayValue(value, path, ancestors) {
|
|
|
2151
2484
|
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
2152
2485
|
}
|
|
2153
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';
|
|
2154
2495
|
const copy = {};
|
|
2155
2496
|
for (const key of keys) {
|
|
2156
2497
|
if (typeof key !== 'string') continue;
|
|
2157
2498
|
if (key === 'resumeToken') continue;
|
|
2499
|
+
if (provider && (['sessionid', 'nativesessionid', 'threadid', 'conversationid'].includes(key.replaceAll('_', '').toLowerCase()) || (scope === 'identity' && key === 'id'))) continue;
|
|
2158
2500
|
const descriptor = descriptors[key];
|
|
2159
2501
|
if (
|
|
2160
2502
|
descriptor === undefined ||
|
|
@@ -2172,6 +2514,11 @@ function sanitizeReplayValue(value, path, ancestors) {
|
|
|
2172
2514
|
descriptor.value,
|
|
2173
2515
|
`${path}.${key}`,
|
|
2174
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',
|
|
2175
2522
|
),
|
|
2176
2523
|
enumerable: true,
|
|
2177
2524
|
configurable: true,
|
|
@@ -2189,6 +2536,10 @@ async function createLease({
|
|
|
2189
2536
|
replayFs,
|
|
2190
2537
|
readReplayStream,
|
|
2191
2538
|
readRecord,
|
|
2539
|
+
readManifest,
|
|
2540
|
+
readHistory,
|
|
2541
|
+
validateSession,
|
|
2542
|
+
portableWriters,
|
|
2192
2543
|
writeRecord,
|
|
2193
2544
|
syncRecordDirectory,
|
|
2194
2545
|
deleteRecord,
|
|
@@ -2239,9 +2590,102 @@ async function createLease({
|
|
|
2239
2590
|
readStream: readReplayStream,
|
|
2240
2591
|
});
|
|
2241
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
|
+
|
|
2242
2685
|
const finishSettlement = async (record) => {
|
|
2243
2686
|
await replayWriter.checkpoint();
|
|
2244
|
-
|
|
2687
|
+
try { await writeHints(acknowledgedHints); } catch { /* Hints are optional; missing hints start fresh. */ }
|
|
2688
|
+
return validateCaptainSessionRecord(projectRecovery(record));
|
|
2245
2689
|
};
|
|
2246
2690
|
|
|
2247
2691
|
const read = () =>
|
|
@@ -2353,6 +2797,7 @@ async function createLease({
|
|
|
2353
2797
|
const initializeSettledWithPredecessor = (options = {}) =>
|
|
2354
2798
|
runExclusive(async () => {
|
|
2355
2799
|
const target = freshSettledRecord(options);
|
|
2800
|
+
if (options.context !== undefined) await recordContext(options.context);
|
|
2356
2801
|
await assertOwnerUnchecked();
|
|
2357
2802
|
if (
|
|
2358
2803
|
(await readRecord(sessionId, { missing: 'undefined' })) !== undefined
|
|
@@ -2588,6 +3033,7 @@ async function createLease({
|
|
|
2588
3033
|
'Captain session attempted execution projection',
|
|
2589
3034
|
);
|
|
2590
3035
|
await assertOwnerUnchecked();
|
|
3036
|
+
await assertContinuable();
|
|
2591
3037
|
const prior = await readRecord(sessionId, { missing: 'undefined' });
|
|
2592
3038
|
if (prior === undefined) {
|
|
2593
3039
|
throw new Error('Captain session does not exist for continuation');
|
|
@@ -2635,6 +3081,7 @@ async function createLease({
|
|
|
2635
3081
|
throw new Error('Captain session retry requires a fresh attempt id');
|
|
2636
3082
|
}
|
|
2637
3083
|
await assertOwnerUnchecked();
|
|
3084
|
+
await assertContinuable();
|
|
2638
3085
|
const prior = await requireUncertainRecord(
|
|
2639
3086
|
await readRecord(sessionId, { missing: 'undefined' }),
|
|
2640
3087
|
expectedAttemptId,
|
|
@@ -2923,7 +3370,7 @@ async function createLease({
|
|
|
2923
3370
|
} = {}) =>
|
|
2924
3371
|
runExclusive(async () => {
|
|
2925
3372
|
assertUuid(attemptId, 'Captain session attempt id');
|
|
2926
|
-
const
|
|
3373
|
+
const rawUpdates = validateRetainedGenerationUpdates(retentionUpdates);
|
|
2927
3374
|
const settledUnresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
|
|
2928
3375
|
unresolvedEffects,
|
|
2929
3376
|
);
|
|
@@ -2946,7 +3393,19 @@ async function createLease({
|
|
|
2946
3393
|
'Captain session abandonment settlement attempt differs from its durable marker',
|
|
2947
3394
|
);
|
|
2948
3395
|
}
|
|
2949
|
-
|
|
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);
|
|
2950
3409
|
if (settledAbandonment !== undefined) {
|
|
2951
3410
|
requireAbandonmentSettlement(
|
|
2952
3411
|
settledAbandonment,
|
|
@@ -3172,8 +3631,7 @@ async function createLease({
|
|
|
3172
3631
|
await assertOwnerUnchecked();
|
|
3173
3632
|
return undefined;
|
|
3174
3633
|
}
|
|
3175
|
-
//
|
|
3176
|
-
// bytes from the baseline carried by the uncertain record.
|
|
3634
|
+
// Restore the prior recovery baseline while retaining attempt history.
|
|
3177
3635
|
const record = validateCaptainSessionRecord({
|
|
3178
3636
|
schemaVersion: prior.schemaVersion,
|
|
3179
3637
|
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
@@ -3200,9 +3658,14 @@ async function createLease({
|
|
|
3200
3658
|
replayWriter.closeAppendAdmission();
|
|
3201
3659
|
return runExclusive(async () => {
|
|
3202
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
|
+
}
|
|
3203
3665
|
const current = await assertOwnerUnchecked();
|
|
3204
3666
|
await retireObservedLease(sessionId, current);
|
|
3205
3667
|
released = true;
|
|
3668
|
+
portableWriters.delete(sessionId);
|
|
3206
3669
|
return replayWriter.status();
|
|
3207
3670
|
});
|
|
3208
3671
|
};
|
|
@@ -3210,7 +3673,13 @@ async function createLease({
|
|
|
3210
3673
|
return Object.freeze({
|
|
3211
3674
|
sessionId,
|
|
3212
3675
|
ownerToken: owner.ownerToken,
|
|
3213
|
-
append
|
|
3676
|
+
append,
|
|
3677
|
+
recordContext,
|
|
3678
|
+
consumeHints,
|
|
3679
|
+
acknowledgeHint,
|
|
3680
|
+
clearHint,
|
|
3681
|
+
assertContinuable,
|
|
3682
|
+
readManifest: () => readManifest(sessionId),
|
|
3214
3683
|
readStream: replayWriter.read,
|
|
3215
3684
|
streamStatus: replayWriter.status,
|
|
3216
3685
|
read,
|
|
@@ -3387,6 +3856,7 @@ export function captainSessionSelectedMembers(value) {
|
|
|
3387
3856
|
}
|
|
3388
3857
|
|
|
3389
3858
|
export function validateCaptainSessionRecord(value) {
|
|
3859
|
+
if (value?.schemaVersion === 7) return recoveryFromManifest(value);
|
|
3390
3860
|
const record = requireRecord(
|
|
3391
3861
|
snapshotJsonValue(value, 'Captain session record'),
|
|
3392
3862
|
'Captain session record',
|
|
@@ -3500,12 +3970,7 @@ function validateCanonicalCaptainSessionRecord(
|
|
|
3500
3970
|
'settled Captain session updatedAt must follow its creation marker',
|
|
3501
3971
|
);
|
|
3502
3972
|
}
|
|
3503
|
-
if (
|
|
3504
|
-
throw new Error('Captain session record cwd must be an absolute path');
|
|
3505
|
-
}
|
|
3506
|
-
if (resolve(record.cwd) !== record.cwd) {
|
|
3507
|
-
throw new Error('Captain session record cwd must be normalized');
|
|
3508
|
-
}
|
|
3973
|
+
if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
|
|
3509
3974
|
const structural = validateCaptainSessionStructuralProjectionWithSchemas(
|
|
3510
3975
|
record.structuralProjection,
|
|
3511
3976
|
'Captain session record structuralProjection',
|
|
@@ -4699,12 +5164,7 @@ function assertReleasedSchema2CaptainSessionRecord(record) {
|
|
|
4699
5164
|
'settled Captain session updatedAt must follow its creation marker',
|
|
4700
5165
|
);
|
|
4701
5166
|
}
|
|
4702
|
-
if (
|
|
4703
|
-
throw new Error('Captain session record cwd must be an absolute path');
|
|
4704
|
-
}
|
|
4705
|
-
if (resolve(record.cwd) !== record.cwd) {
|
|
4706
|
-
throw new Error('Captain session record cwd must be normalized');
|
|
4707
|
-
}
|
|
5167
|
+
if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
|
|
4708
5168
|
requireRecord(record.config, 'Captain session record config');
|
|
4709
5169
|
requireRecord(record.snapshot, 'Captain session record snapshot');
|
|
4710
5170
|
|
|
@@ -5080,10 +5540,7 @@ function validateRoleIds(value, path) {
|
|
|
5080
5540
|
if (
|
|
5081
5541
|
!Array.isArray(value) ||
|
|
5082
5542
|
value.some(
|
|
5083
|
-
(roleId) =>
|
|
5084
|
-
typeof roleId !== 'string' ||
|
|
5085
|
-
!ROLE_ID_PATTERN.test(roleId) ||
|
|
5086
|
-
roleId === RESERVED_ID,
|
|
5543
|
+
(roleId) => !isCanonicalLocalRoleId(roleId) || roleId === RESERVED_ID,
|
|
5087
5544
|
) ||
|
|
5088
5545
|
new Set(value).size !== value.length
|
|
5089
5546
|
) {
|
|
@@ -5889,8 +6346,8 @@ function nextTimestamp(value, previous) {
|
|
|
5889
6346
|
return new Date(Date.parse(previous) + 1).toISOString();
|
|
5890
6347
|
}
|
|
5891
6348
|
|
|
5892
|
-
async function readPrivateRegularFile(path, mode, fs, label) {
|
|
5893
|
-
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);
|
|
5894
6351
|
const handle = await fs.open(
|
|
5895
6352
|
path,
|
|
5896
6353
|
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
@@ -5903,7 +6360,12 @@ async function readPrivateRegularFile(path, mode, fs, label) {
|
|
|
5903
6360
|
if ((stat.mode & 0o7777) !== mode) {
|
|
5904
6361
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
5905
6362
|
}
|
|
5906
|
-
|
|
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);
|
|
5907
6369
|
} finally {
|
|
5908
6370
|
await handle.close();
|
|
5909
6371
|
}
|
|
@@ -6006,3 +6468,109 @@ async function assertDirectoryNotLink(path, fs) {
|
|
|
6006
6468
|
function errorMessage(error) {
|
|
6007
6469
|
return error instanceof Error ? error.message : String(error);
|
|
6008
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
|
+
}
|