@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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { posix, win32 } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import {
|
|
8
|
+
validateCaptainSessionRecord,
|
|
9
|
+
validateCaptainSessionExecutionProjection,
|
|
10
|
+
sanitizeReplayRecord,
|
|
11
|
+
SESSION_ID_PATTERN,
|
|
12
|
+
} from './session-store.js';
|
|
13
|
+
|
|
14
|
+
export const SESSION_MANIFEST_VERSION = 7;
|
|
15
|
+
export const EMPTY_REPLAY_SHA256 = createHash('sha256').digest('hex');
|
|
16
|
+
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
const COMMON = ['schemaVersion', 'kind', 'sessionId', 'cwd', 'createdAt', 'updatedAt', 'state', 'replay', 'contextSeq'];
|
|
18
|
+
const RECOVERY = ['structuralProjection', 'lastAppliedExecutionProjection', 'snapshot', 'effectLedger', 'unresolvedEffects'];
|
|
19
|
+
const OPTIONAL = ['retainedGenerations', 'settledAbandonment'];
|
|
20
|
+
const HEX = /^[0-9a-f]{64}$/;
|
|
21
|
+
const clone = (value) => structuredClone(value);
|
|
22
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
const nonempty = (value) => typeof value === 'string' && value.length > 0;
|
|
24
|
+
function exact(value, required, optional = []) {
|
|
25
|
+
if (!object(value) || required.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))) throw new Error('invalid or unknown session format fields');
|
|
26
|
+
}
|
|
27
|
+
function iso(value) {
|
|
28
|
+
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) throw new Error('session timestamp must be canonical ISO UTC');
|
|
29
|
+
}
|
|
30
|
+
export function isRecordedAbsolutePath(value) {
|
|
31
|
+
return typeof value === 'string' && !value.includes('\0') && (
|
|
32
|
+
(posix.isAbsolute(value) && posix.resolve(value) === value) ||
|
|
33
|
+
(/^(?:[A-Za-z]:\\|\\\\[^\\]+\\[^\\]+(?:\\|$))/.test(value) && win32.resolve(value) === value)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
export function validateReplayCheckpoint(value) {
|
|
37
|
+
exact(value, ['seq', 'sha256', 'incomplete']);
|
|
38
|
+
if (!Number.isSafeInteger(value.seq) || value.seq < 0 || !HEX.test(value.sha256) || typeof value.incomplete !== 'boolean' || (value.seq === 0 && value.sha256 !== EMPTY_REPLAY_SHA256)) throw new Error('invalid replay checkpoint');
|
|
39
|
+
return clone(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The schema-6 validator remains the legacy decoder and the shared in-memory
|
|
43
|
+
// recovery validator. Portable metadata never enters the runtime's snapshots.
|
|
44
|
+
export function validateSessionManifest(value) {
|
|
45
|
+
if (!object(value) || value.schemaVersion !== 7) throw new Error(`unsupported session manifest schema ${value?.schemaVersion}`);
|
|
46
|
+
const history = value.state === 'history-only';
|
|
47
|
+
exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? [] : OPTIONAL);
|
|
48
|
+
if (value.kind !== 'captain-session' || !SESSION_ID_PATTERN.test(value.sessionId) || !isRecordedAbsolutePath(value.cwd)) throw new Error('invalid session manifest identity');
|
|
49
|
+
iso(value.createdAt); iso(value.updatedAt);
|
|
50
|
+
if (Date.parse(value.updatedAt) < Date.parse(value.createdAt)) throw new Error('session update precedes creation');
|
|
51
|
+
validateReplayCheckpoint(value.replay);
|
|
52
|
+
if (!(history && value.contextSeq === null) && (!Number.isSafeInteger(value.contextSeq) || value.contextSeq <= 0 || value.contextSeq > value.replay.seq)) throw new Error('invalid session context reference');
|
|
53
|
+
if (history) {
|
|
54
|
+
if (!nonempty(value.reason)) throw new Error('history-only session needs a reason');
|
|
55
|
+
} else {
|
|
56
|
+
const recovery = recoveryFromManifestUnchecked(value);
|
|
57
|
+
validateCaptainSessionRecord(recovery);
|
|
58
|
+
if (!isDeepStrictEqual(projectRecovery(recovery), recovery)) throw new Error('portable recovery contains provider continuation fields');
|
|
59
|
+
}
|
|
60
|
+
return clone(value);
|
|
61
|
+
}
|
|
62
|
+
function recoveryFromManifestUnchecked(value) {
|
|
63
|
+
const { replay, contextSeq, ...recovery } = value;
|
|
64
|
+
return { ...recovery, schemaVersion: 6 };
|
|
65
|
+
}
|
|
66
|
+
export function recoveryFromManifest(value) {
|
|
67
|
+
const manifest = validateSessionManifest(value);
|
|
68
|
+
if (manifest.state === 'history-only') throw new Error(manifest.reason);
|
|
69
|
+
return validateCaptainSessionRecord(recoveryFromManifestUnchecked(manifest));
|
|
70
|
+
}
|
|
71
|
+
export function manifestFromRecovery(value, replay, contextSeq) {
|
|
72
|
+
const recovery = projectRecovery(validateCaptainSessionRecord(value));
|
|
73
|
+
return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq });
|
|
74
|
+
}
|
|
75
|
+
export function projectRecovery(value) {
|
|
76
|
+
const source = clone(value);
|
|
77
|
+
for (const key of ['snapshot', 'effectLedger', 'retainedGenerations']) {
|
|
78
|
+
if (source[key] !== undefined) source[key] = clone(sanitizeReplayRecord(source[key]));
|
|
79
|
+
}
|
|
80
|
+
const catalog = source.structuralProjection.catalog;
|
|
81
|
+
const continuation = (operation, ledger) => {
|
|
82
|
+
if (!Object.hasOwn(operation, 'playerContinuation')) return;
|
|
83
|
+
const binding = operation.playerContinuation;
|
|
84
|
+
const entry = catalog[operation.playbookId];
|
|
85
|
+
const roleId = operation.pendingQuestion?.asker?.kind === 'role'
|
|
86
|
+
? operation.pendingQuestion.asker.roleId
|
|
87
|
+
: ledger.boundaries.find((item) => operation.boundaryIds.includes(item.boundaryId))?.roleId;
|
|
88
|
+
const playerId = entry?.roles?.[roleId]?.playerId;
|
|
89
|
+
if (!nonempty(playerId)) throw new Error('pending operation has ambiguous player identity');
|
|
90
|
+
if (object(binding) && (!isDeepStrictEqual(binding, { v: 1, playerId }))) throw new Error('unsupported deferred player continuation binding');
|
|
91
|
+
if (!object(binding) && binding !== false && !nonempty(binding)) throw new Error('unsupported deferred player continuation binding');
|
|
92
|
+
operation.playerContinuation = { v: 1, playerId };
|
|
93
|
+
};
|
|
94
|
+
const visit = (node) => {
|
|
95
|
+
if (!object(node)) return;
|
|
96
|
+
if (object(node.captain) && object(node.playerSessions) && Array.isArray(node.journal)) {
|
|
97
|
+
node.captain.conversation = { kind: node.journal.length === 0 ? 'unopened' : 'needsSeeding' };
|
|
98
|
+
for (const entry of Object.values(node.playerSessions)) delete entry.resumeToken;
|
|
99
|
+
}
|
|
100
|
+
if (object(node.roleResumeTokens)) node.roleResumeTokens = {};
|
|
101
|
+
if (node.schemaVersion === 1 && Array.isArray(node.boundaries) && Array.isArray(node.logicalOperations)) {
|
|
102
|
+
for (const operation of node.logicalOperations) continuation(operation, node);
|
|
103
|
+
}
|
|
104
|
+
for (const [key, child] of Object.entries(node)) {
|
|
105
|
+
if (key === 'journal' && Array.isArray(child)) {
|
|
106
|
+
node[key] = child.map((entry) => ({ ...entry, ...(object(entry.payload) ? { payload: sanitizeReplayRecord(entry.payload) } : {}) }));
|
|
107
|
+
} else if (key !== 'configuration' && key !== 'structuralProjection' && key !== 'lastAppliedExecutionProjection' && key !== 'attemptedExecutionProjection') {
|
|
108
|
+
if (Array.isArray(child)) child.forEach(visit); else visit(child);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
visit(source);
|
|
113
|
+
return source;
|
|
114
|
+
}
|
|
115
|
+
export function validateSessionContext(value) {
|
|
116
|
+
exact(value, ['type', 'timestamp', 'contextVersion', 'captainId', 'configuration', 'graphs', 'initialVisible']);
|
|
117
|
+
if (value.type !== 'session_context' || value.contextVersion !== 1 || !Number.isFinite(value.timestamp) || !SESSION_ID_PATTERN.test(value.captainId)) throw new Error('unsupported session context');
|
|
118
|
+
const configuration = validateCaptainSessionExecutionProjection(value.configuration);
|
|
119
|
+
const players = new Set(configuration.players.map(({ id }) => id));
|
|
120
|
+
if (!Array.isArray(value.initialVisible) || value.initialVisible.some((id) => !players.has(id)) || new Set(value.initialVisible).size !== value.initialVisible.length) throw new Error('invalid initially visible players');
|
|
121
|
+
if (!Array.isArray(value.graphs) || value.graphs.length !== Object.keys(configuration.catalog).length) throw new Error('context graphs must cover the catalog');
|
|
122
|
+
const ids = new Set();
|
|
123
|
+
for (const item of value.graphs) {
|
|
124
|
+
exact(item, ['playbookId', 'graph']);
|
|
125
|
+
if (!Object.hasOwn(configuration.catalog, item.playbookId) || ids.has(item.playbookId)) throw new Error('invalid context graph identity');
|
|
126
|
+
ids.add(item.playbookId);
|
|
127
|
+
if (item.graph !== null) validateGraph(item.graph);
|
|
128
|
+
}
|
|
129
|
+
return clone(value);
|
|
130
|
+
}
|
|
131
|
+
function validateGraph(graph) {
|
|
132
|
+
exact(graph, ['initial', 'nodes', 'edges']);
|
|
133
|
+
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) throw new Error('invalid graph arrays');
|
|
134
|
+
const nodes = new Map();
|
|
135
|
+
for (const node of graph.nodes) {
|
|
136
|
+
exact(node, ['id', 'kind', 'tags'], ['parent', 'role', 'description']);
|
|
137
|
+
if (!nonempty(node.id) || nodes.has(node.id) || !['state', 'final'].includes(node.kind) || !Array.isArray(node.tags) || node.tags.some((tag) => typeof tag !== 'string') || ['parent', 'role', 'description'].some((key) => Object.hasOwn(node, key) && typeof node[key] !== 'string')) throw new Error('invalid graph node');
|
|
138
|
+
nodes.set(node.id, node);
|
|
139
|
+
}
|
|
140
|
+
if (!nodes.has(graph.initial)) throw new Error('graph initial node is absent');
|
|
141
|
+
for (const node of nodes.values()) {
|
|
142
|
+
const visited = new Set([node.id]);
|
|
143
|
+
let parent = node.parent;
|
|
144
|
+
while (parent !== undefined) {
|
|
145
|
+
if (!nodes.has(parent) || visited.has(parent)) throw new Error('graph parent is absent or cyclic');
|
|
146
|
+
visited.add(parent); parent = nodes.get(parent).parent;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const edges = new Set();
|
|
150
|
+
for (const edge of graph.edges) {
|
|
151
|
+
exact(edge, ['id', 'from', 'to', 'event']);
|
|
152
|
+
if (!nonempty(edge.id) || edges.has(edge.id) || !nodes.has(edge.from) || !nodes.has(edge.to) || typeof edge.event !== 'string') throw new Error('invalid graph edge');
|
|
153
|
+
edges.add(edge.id);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export function contextFromRecovery(record, graphs = [], initialVisible = []) {
|
|
157
|
+
const configuration = record.state === 'uncertain' ? record.uncertain.attemptedExecutionProjection : record.lastAppliedExecutionProjection;
|
|
158
|
+
return validateSessionContext({ type: 'session_context', timestamp: Date.now(), contextVersion: 1, captainId: record.snapshot.captain.sessionId, configuration, graphs: Object.keys(configuration.catalog).map((playbookId) => ({ playbookId, graph: graphs.find((item) => item.playbookId === playbookId)?.graph ?? null })), initialVisible });
|
|
159
|
+
}
|
|
160
|
+
export function validateSessionHints(value, manifestBytes, manifest) {
|
|
161
|
+
exact(value, ['v', 'sessionId', 'checkpointSha256', 'players'], ['captain']);
|
|
162
|
+
if (value.v !== 1 || value.sessionId !== manifest.sessionId || value.checkpointSha256 !== sha256(manifestBytes) || !object(value.players)) throw new Error('provider hints do not match the checkpoint');
|
|
163
|
+
const players = new Set(manifest.structuralProjection?.players.map(({ id }) => id) ?? []);
|
|
164
|
+
for (const [id, token] of Object.entries(value.players)) if (!players.has(id) || !nonempty(token)) throw new Error('invalid player hint');
|
|
165
|
+
if (value.captain !== undefined) {
|
|
166
|
+
const captain = value.captain;
|
|
167
|
+
if (captain.kind === 'pinned') { exact(captain, ['kind', 'token']); if (!nonempty(captain.token)) throw new Error('invalid Captain hint'); }
|
|
168
|
+
else if (captain.kind === 'needsCatchUp') { exact(captain, ['kind', 'resume', 'afterJournalSeq']); if (!(captain.resume === false || nonempty(captain.resume)) || !Number.isSafeInteger(captain.afterJournalSeq) || captain.afterJournalSeq < 0 || captain.afterJournalSeq > manifest.snapshot.sequences.journal) throw new Error('invalid Captain catch-up hint'); }
|
|
169
|
+
else throw new Error('invalid Captain conversation hint');
|
|
170
|
+
}
|
|
171
|
+
return clone(value);
|
|
172
|
+
}
|
|
173
|
+
export function attachSessionHints(snapshot, hints) {
|
|
174
|
+
const result = clone(snapshot);
|
|
175
|
+
if (hints?.captain) result.captain.conversation = clone(hints.captain);
|
|
176
|
+
for (const [id, token] of Object.entries(hints?.players ?? {})) if (result.playerSessions[id]) result.playerSessions[id].resumeToken = token;
|
|
177
|
+
const visit = (node, inheritedBindings = {}) => {
|
|
178
|
+
if (!object(node)) return;
|
|
179
|
+
const bindings = node.roleBindings ?? inheritedBindings;
|
|
180
|
+
if (object(node.roleResumeTokens)) {
|
|
181
|
+
node.roleResumeTokens = Object.fromEntries(Object.entries(bindings).flatMap(([role, binding]) => {
|
|
182
|
+
const token = hints?.players?.[typeof binding === 'string' ? binding : binding.playerId];
|
|
183
|
+
return token === undefined ? [] : [[role, token]];
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
for (const child of Object.values(node)) if (Array.isArray(child)) child.forEach((item) => visit(item, bindings)); else visit(child, bindings);
|
|
187
|
+
};
|
|
188
|
+
visit(result);
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
@@ -7,10 +7,25 @@ const PLAYER_RECORD_TYPES = new Set([
|
|
|
7
7
|
'player_finished',
|
|
8
8
|
]);
|
|
9
9
|
|
|
10
|
-
export function createReplayRecordObserver({ lease, onIncomplete }) {
|
|
10
|
+
export function createReplayRecordObserver({ lease, onIncomplete, onStored }) {
|
|
11
11
|
const activeFrames = new Map();
|
|
12
12
|
let incompleteReported = false;
|
|
13
13
|
let lastIncomplete = readIncomplete(lease);
|
|
14
|
+
let lastDeliveredSeq = 0;
|
|
15
|
+
try { lastDeliveredSeq = lease.streamStatus().lastReadableSeq ?? 0; } catch { /* Unavailable replay has no delivery cursor. */ }
|
|
16
|
+
let deliveryQueue = Promise.resolve();
|
|
17
|
+
const flushStoredRecords = () => {
|
|
18
|
+
if (!onStored) return Promise.resolve();
|
|
19
|
+
const operation = deliveryQueue.then(async () => {
|
|
20
|
+
const result = await lease.readStream({ afterSeq: lastDeliveredSeq });
|
|
21
|
+
for (const entry of result.entries) {
|
|
22
|
+
await onStored(entry, lease.streamStatus());
|
|
23
|
+
lastDeliveredSeq = entry.seq;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
deliveryQueue = operation.catch(() => reportIfIncomplete());
|
|
27
|
+
return deliveryQueue;
|
|
28
|
+
};
|
|
14
29
|
|
|
15
30
|
const reportIfIncomplete = async (knownStatus) => {
|
|
16
31
|
if (incompleteReported) return;
|
|
@@ -36,6 +51,7 @@ export function createReplayRecordObserver({ lease, onIncomplete }) {
|
|
|
36
51
|
}
|
|
37
52
|
try {
|
|
38
53
|
await lease.append(record, ...(role === undefined ? [] : [role]));
|
|
54
|
+
await flushStoredRecords();
|
|
39
55
|
} catch {
|
|
40
56
|
// The replay writer records failure in its live latch. The observer
|
|
41
57
|
// remains installed so later host records and lifecycle work continue.
|
|
@@ -48,7 +64,7 @@ export function createReplayRecordObserver({ lease, onIncomplete }) {
|
|
|
48
64
|
},
|
|
49
65
|
});
|
|
50
66
|
|
|
51
|
-
return Object.freeze({ observer, reportIfIncomplete });
|
|
67
|
+
return Object.freeze({ observer, reportIfIncomplete, flushStoredRecords });
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
function readIncomplete(lease, knownStatus) {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// presenter; it does not construct a registry runtime or PlaybookPorts itself.
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { attachSessionHints, validateSessionContext } from "./portable-codec.js";
|
|
10
11
|
import { homedir } from "node:os";
|
|
11
12
|
import { resolve } from "node:path";
|
|
12
13
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
checkReadiness,
|
|
31
32
|
invalidRegistryEntryReason,
|
|
32
33
|
loadLaunchPlan,
|
|
34
|
+
loadSelectedLaunchPlanDataOnly,
|
|
33
35
|
projectHostAgent,
|
|
34
36
|
resolveLaunchSessionsDir,
|
|
35
37
|
relocateLegacyUserConfig,
|
|
@@ -50,7 +52,6 @@ import {
|
|
|
50
52
|
import {
|
|
51
53
|
assertCaptainSessionExecutionCompatible,
|
|
52
54
|
assertCaptainSessionsDirectoryUsable,
|
|
53
|
-
captainSessionSelectedMembers,
|
|
54
55
|
createCaptainSessionStore,
|
|
55
56
|
projectCaptainSessionStructure,
|
|
56
57
|
SESSION_ID_PATTERN,
|
|
@@ -142,6 +143,7 @@ export async function runPlaybookRun(options = {}) {
|
|
|
142
143
|
}
|
|
143
144
|
const bootstrapConfigNotices = [];
|
|
144
145
|
let resolvedSessionsDir;
|
|
146
|
+
let migrateDefaultStore = false;
|
|
145
147
|
if (options.sessionStore === undefined) {
|
|
146
148
|
try {
|
|
147
149
|
resolvedSessionsDir = resolveLaunchSessionsDir({
|
|
@@ -153,6 +155,7 @@ export async function runPlaybookRun(options = {}) {
|
|
|
153
155
|
? { sessionsDir: options.sessionsDir }
|
|
154
156
|
: {}),
|
|
155
157
|
preparePrimary: !continuing,
|
|
158
|
+
onDefault: () => { migrateDefaultStore = !(typeof env.SPEX_HOME === 'string' && env.SPEX_HOME.trim() !== ''); },
|
|
156
159
|
onNotice: (line) => bootstrapConfigNotices.push(line),
|
|
157
160
|
});
|
|
158
161
|
await assertCaptainSessionsDirectoryUsable(resolvedSessionsDir);
|
|
@@ -178,6 +181,11 @@ export async function runPlaybookRun(options = {}) {
|
|
|
178
181
|
? { createTempId: options.createSessionTempId }
|
|
179
182
|
: {}),
|
|
180
183
|
});
|
|
184
|
+
if (migrateDefaultStore) {
|
|
185
|
+
const migrated = await store.migrateLegacyDefault();
|
|
186
|
+
if (migrated.migrated.length > 0) await writeStream(stderr, `playbook run: migrated ${migrated.migrated.length} sessions from ${migrated.sourceDir}\n`);
|
|
187
|
+
for (const skipped of migrated.skipped) await writeStream(stderr, `playbook run: preserved legacy session ${skipped.sessionId} in ${migrated.sourceDir}: ${skipped.reason}\n`);
|
|
188
|
+
}
|
|
181
189
|
} catch (error) {
|
|
182
190
|
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
183
191
|
return { code: EXIT.argument };
|
|
@@ -226,6 +234,8 @@ export async function runPlaybookRun(options = {}) {
|
|
|
226
234
|
}
|
|
227
235
|
throwIfAborted(options.signal);
|
|
228
236
|
lease = await store.acquire(sessionId);
|
|
237
|
+
if (!args.discardUncertain) await lease.assertContinuable();
|
|
238
|
+
else if ((await lease.readManifest()).schemaVersion !== 7) throw new Error(`session requires explicit migration; run playbook migrate-session ${sessionId}`);
|
|
229
239
|
replayChannel = createHeadlessReplayChannel({
|
|
230
240
|
lease,
|
|
231
241
|
sessionId,
|
|
@@ -343,20 +353,20 @@ export async function runPlaybookRun(options = {}) {
|
|
|
343
353
|
const configNotices = [...bootstrapConfigNotices];
|
|
344
354
|
try {
|
|
345
355
|
throwIfAborted(options.signal);
|
|
346
|
-
plan =
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
356
|
+
plan = continuing
|
|
357
|
+
? await loadSelectedLaunchPlanDataOnly({
|
|
358
|
+
userConfigPath,
|
|
359
|
+
overlayPaths: args.withPaths,
|
|
360
|
+
structuralProjection: priorRecord.structuralProjection,
|
|
361
|
+
onNotice: (line) => configNotices.push(line),
|
|
362
|
+
})
|
|
363
|
+
: await loadLaunchPlan({
|
|
364
|
+
userConfigPath,
|
|
365
|
+
overlayPaths: args.withPaths,
|
|
366
|
+
loadModule,
|
|
367
|
+
prepareRegistryModule,
|
|
368
|
+
onNotice: (line) => configNotices.push(line),
|
|
369
|
+
});
|
|
360
370
|
throwIfAborted(options.signal);
|
|
361
371
|
} catch (error) {
|
|
362
372
|
for (const line of configNotices) await writeStream(stderr, line);
|
|
@@ -376,9 +386,10 @@ export async function runPlaybookRun(options = {}) {
|
|
|
376
386
|
try {
|
|
377
387
|
const current = executionConfigFromPlan(plan);
|
|
378
388
|
if (continuing) {
|
|
379
|
-
config =
|
|
389
|
+
config = await validateFrozenExecutionConfig(
|
|
380
390
|
priorRecord.structuralProjection,
|
|
381
391
|
current,
|
|
392
|
+
{ loadModule, prepareRegistryModule },
|
|
382
393
|
);
|
|
383
394
|
} else {
|
|
384
395
|
sessionId = (options.createLogicalSessionId ?? randomUUID)();
|
|
@@ -1007,7 +1018,10 @@ export async function createCaptainSessionHost({
|
|
|
1007
1018
|
restoreSnapshot,
|
|
1008
1019
|
reconcileUncertainTurnReplay = false,
|
|
1009
1020
|
signal,
|
|
1021
|
+
graphs = [],
|
|
1022
|
+
initialVisible = [],
|
|
1010
1023
|
}) {
|
|
1024
|
+
if (restoreSnapshot !== undefined) await sessionLease.assertContinuable({ cwd, executionProjection: config });
|
|
1011
1025
|
const hostCapabilities = await createRepositoryEffectCapabilities({
|
|
1012
1026
|
cwd,
|
|
1013
1027
|
catalog: config.catalog,
|
|
@@ -1065,9 +1079,30 @@ export async function createCaptainSessionHost({
|
|
|
1065
1079
|
"Captain session effect ledger requires reconciliation before source-state restoration",
|
|
1066
1080
|
);
|
|
1067
1081
|
}
|
|
1082
|
+
if (sourceSnapshot !== undefined && typeof sessionLease.consumeHints === "function") {
|
|
1083
|
+
sourceSnapshot = attachSessionHints(sourceSnapshot, await sessionLease.consumeHints());
|
|
1084
|
+
}
|
|
1085
|
+
const bufferedRecords = [];
|
|
1086
|
+
let presentationReady = false;
|
|
1087
|
+
const presentationTurnOffset = restoreSnapshot?.sequences.turn ?? 0;
|
|
1088
|
+
const forwardRecord = async (record) => {
|
|
1089
|
+
const projected = presentationTurnOffset > 0 && typeof record.turnId === "number"
|
|
1090
|
+
? { ...record, turnId: record.turnId + presentationTurnOffset, ...(record.type === "turn_started" ? { turn: { ...record.turn, id: record.turn.id + presentationTurnOffset } } : {}) }
|
|
1091
|
+
: record;
|
|
1092
|
+
for (const observer of observers ?? []) await observer.onRecord?.(projected);
|
|
1093
|
+
};
|
|
1094
|
+
const bufferedObservers = [{ async onRecord(record) {
|
|
1095
|
+
if (!presentationReady) bufferedRecords.push(record);
|
|
1096
|
+
else await forwardRecord(record);
|
|
1097
|
+
} }];
|
|
1068
1098
|
const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
|
|
1069
1099
|
loadModule,
|
|
1070
1100
|
hostCapabilities,
|
|
1101
|
+
continuity: {
|
|
1102
|
+
async beforeCall(participantId) { sessionLease.clearHint?.(participantId); await sessionLease.assertOwner(); },
|
|
1103
|
+
acknowledged(participantId, token) { sessionLease.acknowledgeHint?.(participantId, token); },
|
|
1104
|
+
async reset(participantId, reason) { await sessionLease.append({ type: "continuity_reset", timestamp: Date.now(), participantId, reason }); },
|
|
1105
|
+
},
|
|
1071
1106
|
unresolvedEffectSettlement: {
|
|
1072
1107
|
begin: (input) => sessionLease.beginUnresolvedEffectAbandonment(input),
|
|
1073
1108
|
complete: (input) =>
|
|
@@ -1092,7 +1127,7 @@ export async function createCaptainSessionHost({
|
|
|
1092
1127
|
...projectHostAgent(agent, `Captain execution config.players.${id}`),
|
|
1093
1128
|
})),
|
|
1094
1129
|
cwd,
|
|
1095
|
-
observers,
|
|
1130
|
+
observers: bufferedObservers,
|
|
1096
1131
|
...(signal ? { signal } : {}),
|
|
1097
1132
|
...(adapterImports ? { adapterImports } : {}),
|
|
1098
1133
|
});
|
|
@@ -1111,6 +1146,15 @@ export async function createCaptainSessionHost({
|
|
|
1111
1146
|
if (sessionId !== undefined) {
|
|
1112
1147
|
assertLogicalSessionIdDistinct({ sessionId, snapshot });
|
|
1113
1148
|
}
|
|
1149
|
+
if (typeof sessionLease.recordContext === "function") {
|
|
1150
|
+
await sessionLease.recordContext(validateSessionContext({
|
|
1151
|
+
type: "session_context", timestamp: Date.now(), contextVersion: 1,
|
|
1152
|
+
captainId: snapshot.captain.sessionId, configuration: config,
|
|
1153
|
+
graphs: Object.keys(config.catalog).map((playbookId) => ({ playbookId, graph: graphs.find((item) => item.playbookId === playbookId)?.graph ?? null })), initialVisible,
|
|
1154
|
+
}));
|
|
1155
|
+
}
|
|
1156
|
+
presentationReady = true;
|
|
1157
|
+
for (const record of bufferedRecords) await forwardRecord(record);
|
|
1114
1158
|
return { shell, host, snapshot, reconcileRepositoryEffects };
|
|
1115
1159
|
} catch (error) {
|
|
1116
1160
|
let cleanupError;
|
|
@@ -1699,7 +1743,7 @@ export async function reportSkippedCaptainSession(
|
|
|
1699
1743
|
{ sessionId, path, schemaVersion, reason },
|
|
1700
1744
|
) {
|
|
1701
1745
|
const explanation =
|
|
1702
|
-
kind === "legacy" ? legacyCaptainSessionReason(schemaVersion) : reason;
|
|
1746
|
+
kind === "legacy" ? reason ?? legacyCaptainSessionReason(schemaVersion) : reason;
|
|
1703
1747
|
await writeStream(
|
|
1704
1748
|
stderr,
|
|
1705
1749
|
`${commandName}: skipping ${kind} Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} because ${explanation}; move it outside the sessions directory or remove it to silence this warning\n`,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createCaptainSessionStore, projectCaptainSessionStructure } from './session-store.js';
|
|
6
|
+
import { createCaptainSessionHost, executionConfigFromPlan, installRetainedGenerationsForLaunch, validateFrozenExecutionConfig } from './run.js';
|
|
7
|
+
import { createReplayRecordObserver } from './replay-observer.js';
|
|
8
|
+
|
|
9
|
+
/** Own one session lease and the same durable turn transaction as the CLIs. */
|
|
10
|
+
export async function openSessionHost(options) {
|
|
11
|
+
const store = options.store ?? createCaptainSessionStore({ sessionsDir: options.sessionsDir });
|
|
12
|
+
const sessionId = options.sessionId ?? randomUUID();
|
|
13
|
+
const loadModule = options.loadModule ?? ((specifier) => import(specifier));
|
|
14
|
+
const lease = await store.acquire(sessionId);
|
|
15
|
+
let active, closed = false, closing;
|
|
16
|
+
let created;
|
|
17
|
+
let retryPending = options.mode === 'retry';
|
|
18
|
+
try {
|
|
19
|
+
let record = await lease.read();
|
|
20
|
+
if (record !== undefined) await lease.assertContinuable({ cwd: options.cwd });
|
|
21
|
+
record = await lease.recoverUnresolvedEffectAbandonment();
|
|
22
|
+
if (options.mode === 'new' && record !== undefined) throw new Error('session already exists');
|
|
23
|
+
if (options.mode !== 'new' && options.sessionId && record === undefined) throw new Error('session does not exist');
|
|
24
|
+
if (record?.state === 'uncertain' && !retryPending) throw new Error('session has an uncertain turn; select Retry or Discard');
|
|
25
|
+
if (retryPending && record?.state !== 'uncertain') throw new Error('session has no uncertain turn to retry');
|
|
26
|
+
const cwd = options.cwd ?? record?.cwd ?? process.cwd();
|
|
27
|
+
const selected = retryPending ? record.uncertain.attemptedExecutionProjection : options.config ?? (options.plan ? executionConfigFromPlan(options.plan) : record?.lastAppliedExecutionProjection);
|
|
28
|
+
if (!selected) throw new Error('a new session requires a validated execution configuration');
|
|
29
|
+
const structure = record?.structuralProjection ?? projectCaptainSessionStructure(selected);
|
|
30
|
+
if (record !== undefined) await lease.assertContinuable({ cwd, executionProjection: selected });
|
|
31
|
+
const config = await validateFrozenExecutionConfig(structure, selected, { loadModule, prepareRegistryModule: options.prepareRegistryModule });
|
|
32
|
+
const replay = createReplayRecordObserver({ lease, onIncomplete: options.onIncomplete ?? (() => {}), onStored: options.onStoredRecord });
|
|
33
|
+
let terminal, replies = [];
|
|
34
|
+
const observe = {
|
|
35
|
+
async onRecord(value) {
|
|
36
|
+
if (value.type === "turn_finished" || value.type === "turn_aborted") terminal = value;
|
|
37
|
+
if (value.type === "captain_reply") replies.push(value);
|
|
38
|
+
await replay.observer.onRecord(value);
|
|
39
|
+
for (const observer of options.observers ?? []) await observer.onRecord?.(value);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
created = await createCaptainSessionHost({ ...options, config, sessionId, cwd, sessionLease: lease, loadModule, observers: [observe], restoreSnapshot: record?.snapshot, reconcileUncertainTurnReplay: retryPending });
|
|
43
|
+
await installRetainedGenerationsForLaunch({ lease, shell: created.shell, ...(record === undefined ? { freshBoundary: { cwd, structuralProjection: structure, executionProjection: config, snapshot: created.snapshot } } : {}), retainedGenerations: record?.retainedGenerations ?? {}, reconcileRepositoryEffects: created.reconcileRepositoryEffects });
|
|
44
|
+
record = await lease.read();
|
|
45
|
+
await replay.flushStoredRecords();
|
|
46
|
+
const execute = async (input, retry) => {
|
|
47
|
+
if (closed || closing) throw new Error('session host is closing');
|
|
48
|
+
if (active) throw new Error('session turn is already active');
|
|
49
|
+
const operation = (async () => {
|
|
50
|
+
let prior = await lease.read();
|
|
51
|
+
if (retry) {
|
|
52
|
+
if (prior?.state !== 'uncertain') throw new Error('session has no uncertain turn to retry');
|
|
53
|
+
input = prior.uncertain.input;
|
|
54
|
+
if (!retryPending) throw new Error('retry requires reopening the uncertain checkpoint');
|
|
55
|
+
} else if (prior?.state !== 'settled') throw new Error('session has an uncertain turn; select Retry or Discard');
|
|
56
|
+
if (typeof input !== 'string' || input.trim().length === 0) throw new Error('session input must be nonempty');
|
|
57
|
+
await created.reconcileRepositoryEffects();
|
|
58
|
+
terminal = undefined; replies = [];
|
|
59
|
+
const attemptId = options.createAttemptId?.() ?? randomUUID();
|
|
60
|
+
const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attemptId, attemptedExecutionProjection: config });
|
|
61
|
+
retryPending = false;
|
|
62
|
+
await replay.flushStoredRecords();
|
|
63
|
+
await options.onCheckpoint?.(marked);
|
|
64
|
+
await lease.assertOwner();
|
|
65
|
+
await created.host.runBossTurn(input);
|
|
66
|
+
if (terminal?.type !== "turn_finished" || replies.length !== 1 || typeof replies[0].text !== "string" || replies[0].text.trim().length === 0) throw new Error("Captain turn did not finish with one reply; session remains uncertain");
|
|
67
|
+
const settlement = created.shell.exportSettlement();
|
|
68
|
+
if (settlement === undefined) throw new Error('Captain turn ended without durable settlement; session remains uncertain');
|
|
69
|
+
record = await lease.settle({ attemptId, snapshot: settlement.snapshot, unresolvedEffects: settlement.unresolvedEffects, retentionUpdates: settlement.retentionUpdates });
|
|
70
|
+
await replay.flushStoredRecords();
|
|
71
|
+
await options.onCheckpoint?.(record);
|
|
72
|
+
return record;
|
|
73
|
+
})();
|
|
74
|
+
active = operation;
|
|
75
|
+
try { return await operation; } finally { active = undefined; }
|
|
76
|
+
};
|
|
77
|
+
const dispose = () => {
|
|
78
|
+
if (closed) return Promise.resolve();
|
|
79
|
+
if (closing) return closing;
|
|
80
|
+
closing = (async () => {
|
|
81
|
+
try { await active; } catch { /* Preserve the durable uncertain marker. */ }
|
|
82
|
+
await created.host.dispose();
|
|
83
|
+
await lease.release(); closed = true;
|
|
84
|
+
})();
|
|
85
|
+
return closing;
|
|
86
|
+
};
|
|
87
|
+
return Object.freeze({ sessionId, host: created.host, shell: created.shell, lease, read: () => lease.read(), handleBossTurn: (input) => execute(input, false), retry: () => execute(undefined, true), dispose });
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
const failures = [cause];
|
|
90
|
+
let disposed = true;
|
|
91
|
+
try { await created?.host.dispose(); } catch (error) { disposed = false; failures.push(error); }
|
|
92
|
+
if (disposed) try { await lease.release(); } catch (error) { failures.push(error); }
|
|
93
|
+
throw failures.length === 1 ? cause : new AggregateError(failures, 'session host setup and cleanup failed');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function discardSessionUncertain(store, sessionId) {
|
|
98
|
+
const lease = await store.acquire(sessionId);
|
|
99
|
+
try {
|
|
100
|
+
const record = await lease.recoverUnresolvedEffectAbandonment();
|
|
101
|
+
if (record?.state !== 'uncertain') throw new Error('session has no uncertain turn to discard');
|
|
102
|
+
return await lease.discard({ attemptId: record.uncertain.attemptId });
|
|
103
|
+
} finally { await lease.release(); }
|
|
104
|
+
}
|