@sublang/playbook 6.0.0 → 7.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/README.md +14 -10
- package/docs/cli.md +102 -57
- package/docs/configuration.md +57 -16
- package/package.json +3 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +938 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1171 -983
- package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +864 -68
- package/reference/sdlc/code.playbook/playbook-captain.ts +1296 -64
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +4 -14
- package/reference/sdlc/decide.playbook/decide.playbook.js +77 -13
- package/reference/sdlc/decide.playbook/decide.playbook.ts +93 -14
- package/slc/link.md +35 -12
- package/src/runtime.d.ts +14 -2
- package/src/runtime.ts +26 -6
- package/src/xstate-playbook-runtime.js +64 -14
- package/src/xstate-playbook-runtime.ts +79 -13
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +359 -57
- package/src/xstate-runtime.ts +491 -71
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
# SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# Shared `playbook` and `playbook run` launcher config.
|
|
5
5
|
# Top-level host fields only — no `config:` wrapper, no top-level `players`.
|
|
6
6
|
# The launcher injects captain.from and the namespaced <id>-<role> host
|
|
7
|
-
# players, then
|
|
7
|
+
# players, then hosts the same Playbook Captain shell interactively or headlessly.
|
|
8
8
|
|
|
9
9
|
# Every agent — the Captain and each playbook role — carries its own
|
|
10
10
|
# settings inline: an adapter shorthand (claude, codex) or a block with
|
|
@@ -33,7 +33,8 @@ captain:
|
|
|
33
33
|
permissions:
|
|
34
34
|
mode: auto
|
|
35
35
|
|
|
36
|
-
#
|
|
36
|
+
# Interactive host notifications. Headless runs ignore presentation fields.
|
|
37
|
+
# Omitting turn_aborted resolves it to off.
|
|
37
38
|
notifications:
|
|
38
39
|
player_finished: bell
|
|
39
40
|
turn_finished: desktop
|
|
@@ -90,14 +91,3 @@ playbooks:
|
|
|
90
91
|
permissions:
|
|
91
92
|
mode: auto
|
|
92
93
|
writablePaths: ['.git']
|
|
93
|
-
|
|
94
|
-
# Non-interactive `playbook run` defaults (optional). Each value is an
|
|
95
|
-
# agent string <adapter>[:<model>][@<effort>]; `player` is the catch-all
|
|
96
|
-
# for any required role without its own `players.<role>` entry, across
|
|
97
|
-
# every playbook. Flags override per role (--player / --captain), and
|
|
98
|
-
# `playbook run resume` keeps the lineup stored with the parked session.
|
|
99
|
-
#run:
|
|
100
|
-
# captain: claude:claude-opus-4-8@high
|
|
101
|
-
# player: claude:claude-opus-4-8@high
|
|
102
|
-
# players:
|
|
103
|
-
# coder: claude:claude-opus-4-8[1m]@xhigh
|
|
@@ -203,6 +203,22 @@ function parseJudgeJson(raw) {
|
|
|
203
203
|
function isPlainObject(value) {
|
|
204
204
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
205
205
|
}
|
|
206
|
+
function sortJson(value) {
|
|
207
|
+
if (Array.isArray(value))
|
|
208
|
+
return value.map((entry) => sortJson(entry));
|
|
209
|
+
if (value !== null && typeof value === 'object') {
|
|
210
|
+
const record = value;
|
|
211
|
+
const sorted = {};
|
|
212
|
+
for (const key of Object.keys(record).sort()) {
|
|
213
|
+
sorted[key] = sortJson(record[key]);
|
|
214
|
+
}
|
|
215
|
+
return sorted;
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
function stableJson(value, path) {
|
|
220
|
+
return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
|
|
221
|
+
}
|
|
206
222
|
function stripCodeFence(text) {
|
|
207
223
|
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
|
|
208
224
|
return fence ? fence[1].trim() : text;
|
|
@@ -677,13 +693,13 @@ export const createPlaybookRuntime = (options) => {
|
|
|
677
693
|
playerResumeTokens.set(playerId, token);
|
|
678
694
|
}
|
|
679
695
|
};
|
|
680
|
-
const currentState = () => {
|
|
696
|
+
const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
|
|
681
697
|
const live = actor;
|
|
682
698
|
if (!live) {
|
|
683
699
|
throw new Error('decide runtime: actor is not initialized');
|
|
684
700
|
}
|
|
685
701
|
return normalizePlaybookSnapshot(live.getSnapshot(), {
|
|
686
|
-
pendingCall
|
|
702
|
+
pendingCall,
|
|
687
703
|
});
|
|
688
704
|
};
|
|
689
705
|
const stateIdentity = (state) => {
|
|
@@ -1181,7 +1197,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1181
1197
|
// The caller rethrows its original failure. A restore failure skips
|
|
1182
1198
|
// the disposal trace — the parked session was never re-bound in this
|
|
1183
1199
|
// process, so its persisted snapshot stays authoritative (DR-014 §2).
|
|
1184
|
-
const cleanupFailedStart = async (options) => {
|
|
1200
|
+
const cleanupFailedStart = async (cause, options) => {
|
|
1185
1201
|
let finalState;
|
|
1186
1202
|
if (options.emitDisposal && actor) {
|
|
1187
1203
|
try {
|
|
@@ -1197,6 +1213,12 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1197
1213
|
catch {
|
|
1198
1214
|
// Preserve the original startup failure.
|
|
1199
1215
|
}
|
|
1216
|
+
try {
|
|
1217
|
+
await nestedBridge.abortPending(cause);
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
// Preserve the original startup failure.
|
|
1221
|
+
}
|
|
1200
1222
|
try {
|
|
1201
1223
|
await judgeQueue.onIdle();
|
|
1202
1224
|
await drainBoundaryCallsAndEmissions();
|
|
@@ -1269,7 +1291,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1269
1291
|
await flush();
|
|
1270
1292
|
}
|
|
1271
1293
|
catch (error) {
|
|
1272
|
-
await cleanupFailedStart({ emitDisposal: true });
|
|
1294
|
+
await cleanupFailedStart(error, { emitDisposal: true });
|
|
1273
1295
|
throw error;
|
|
1274
1296
|
}
|
|
1275
1297
|
finally {
|
|
@@ -1278,10 +1300,11 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1278
1300
|
initInFlight = undefined;
|
|
1279
1301
|
}
|
|
1280
1302
|
},
|
|
1281
|
-
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked
|
|
1303
|
+
// DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
|
|
1304
|
+
// session, including one already-started suspended REVIEW call.
|
|
1282
1305
|
// Defined only at a safe capture point — initialized, not disposing
|
|
1283
|
-
// or disposed, no active public boundary, and the actor quiescent
|
|
1284
|
-
//
|
|
1306
|
+
// or disposed, no active public boundary, and the actor quiescent with
|
|
1307
|
+
// status `active`.
|
|
1285
1308
|
exportSnapshot() {
|
|
1286
1309
|
if (!actor ||
|
|
1287
1310
|
!sessionIdentity ||
|
|
@@ -1292,15 +1315,40 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1292
1315
|
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1293
1316
|
return undefined;
|
|
1294
1317
|
}
|
|
1295
|
-
|
|
1318
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1319
|
+
const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
|
|
1320
|
+
if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
|
|
1321
|
+
return undefined;
|
|
1322
|
+
}
|
|
1323
|
+
if (pendingCall !== undefined &&
|
|
1324
|
+
bridgeSuspendedCall !== undefined &&
|
|
1325
|
+
(pendingCall.callId !== bridgeSuspendedCall.callId ||
|
|
1326
|
+
pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
|
|
1327
|
+
pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)) {
|
|
1296
1328
|
return undefined;
|
|
1329
|
+
}
|
|
1330
|
+
let suspendedCall;
|
|
1331
|
+
if (bridgeSuspendedCall !== undefined) {
|
|
1332
|
+
if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
|
|
1333
|
+
return undefined;
|
|
1334
|
+
}
|
|
1335
|
+
const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
|
|
1336
|
+
if (bridgeSuspendedCall.turnId !== undefined &&
|
|
1337
|
+
bridgeSuspendedCall.turnId !== turnId) {
|
|
1338
|
+
return undefined;
|
|
1339
|
+
}
|
|
1340
|
+
suspendedCall = {
|
|
1341
|
+
...bridgeSuspendedCall,
|
|
1342
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1297
1345
|
const state = currentState();
|
|
1298
1346
|
if (state.status !== 'active' || !state.quiescent)
|
|
1299
1347
|
return undefined;
|
|
1300
1348
|
const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
1301
1349
|
const context = actor.getSnapshot().context;
|
|
1302
1350
|
return {
|
|
1303
|
-
schemaVersion:
|
|
1351
|
+
schemaVersion: 2,
|
|
1304
1352
|
playbookId: sessionIdentity.playbookId,
|
|
1305
1353
|
machine,
|
|
1306
1354
|
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
@@ -1318,6 +1366,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1318
1366
|
question: pending.question,
|
|
1319
1367
|
sourceItem: pending.sourceItem,
|
|
1320
1368
|
})),
|
|
1369
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
1321
1370
|
};
|
|
1322
1371
|
},
|
|
1323
1372
|
// DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
|
|
@@ -1333,7 +1382,10 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1333
1382
|
throw new Error('decide runtime: restore(session, snapshot) may only be called once');
|
|
1334
1383
|
}
|
|
1335
1384
|
const identity = snapshotPlaybookSession(session);
|
|
1336
|
-
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
|
|
1385
|
+
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
|
|
1386
|
+
const suspendedCall = boundSnapshot.schemaVersion === 2
|
|
1387
|
+
? boundSnapshot.suspendedCall
|
|
1388
|
+
: undefined;
|
|
1337
1389
|
let finishInitialization;
|
|
1338
1390
|
const initialization = new Promise((resolve) => {
|
|
1339
1391
|
finishInitialization = resolve;
|
|
@@ -1353,16 +1405,28 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1353
1405
|
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
1354
1406
|
}
|
|
1355
1407
|
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1408
|
+
nestedBridge.prepareRestore(suspendedCall);
|
|
1409
|
+
if (suspendedCall !== undefined) {
|
|
1410
|
+
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
1411
|
+
}
|
|
1356
1412
|
suppressInspectionEmissions = true;
|
|
1357
1413
|
createRuntimeActor(boundSnapshot.machine);
|
|
1358
1414
|
actor?.start();
|
|
1359
|
-
const restoredState = currentState();
|
|
1415
|
+
const restoredState = currentState(suspendedCall);
|
|
1360
1416
|
if (restoredState.status !== 'active') {
|
|
1361
1417
|
throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
|
|
1362
1418
|
}
|
|
1363
|
-
|
|
1419
|
+
if (stableJson(restoredState, 'restored runtime state') !==
|
|
1420
|
+
stableJson(boundSnapshot.state, 'runtime snapshot state')) {
|
|
1421
|
+
throw new Error('decide runtime: restored actor state does not match snapshot state');
|
|
1422
|
+
}
|
|
1364
1423
|
previousState = restoredState;
|
|
1365
1424
|
await flush();
|
|
1425
|
+
suppressInspectionEmissions = false;
|
|
1426
|
+
// Final fallible step: after this publication the authoritative
|
|
1427
|
+
// child has rejoined ordinary resume/abort ownership, so no later
|
|
1428
|
+
// restore validation may trigger failed-start rollback.
|
|
1429
|
+
nestedBridge.confirmRestore();
|
|
1366
1430
|
}
|
|
1367
1431
|
catch (error) {
|
|
1368
1432
|
let failure = error;
|
|
@@ -1374,7 +1438,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1374
1438
|
failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
|
|
1375
1439
|
}
|
|
1376
1440
|
}
|
|
1377
|
-
await cleanupFailedStart({ emitDisposal: false });
|
|
1441
|
+
await cleanupFailedStart(failure, { emitDisposal: false });
|
|
1378
1442
|
throw failure;
|
|
1379
1443
|
}
|
|
1380
1444
|
finally {
|
|
@@ -324,6 +324,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
324
324
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
function sortJson(value: JsonValue): JsonValue {
|
|
328
|
+
if (Array.isArray(value)) return value.map((entry) => sortJson(entry));
|
|
329
|
+
if (value !== null && typeof value === 'object') {
|
|
330
|
+
const record = value as { readonly [key: string]: JsonValue };
|
|
331
|
+
const sorted: Record<string, JsonValue> = {};
|
|
332
|
+
for (const key of Object.keys(record).sort()) {
|
|
333
|
+
sorted[key] = sortJson(record[key]);
|
|
334
|
+
}
|
|
335
|
+
return sorted;
|
|
336
|
+
}
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function stableJson(value: unknown, path: string): string {
|
|
341
|
+
return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
|
|
342
|
+
}
|
|
343
|
+
|
|
327
344
|
function stripCodeFence(text: string): string {
|
|
328
345
|
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
|
|
329
346
|
return fence ? fence[1].trim() : text;
|
|
@@ -920,13 +937,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
920
937
|
playerResumeTokens.set(playerId, token);
|
|
921
938
|
}
|
|
922
939
|
};
|
|
923
|
-
const currentState = (
|
|
940
|
+
const currentState = (
|
|
941
|
+
pendingCall: PlaybookPendingCall | undefined =
|
|
942
|
+
nestedBridge.getPendingCall(),
|
|
943
|
+
): PlaybookState => {
|
|
924
944
|
const live = actor;
|
|
925
945
|
if (!live) {
|
|
926
946
|
throw new Error('decide runtime: actor is not initialized');
|
|
927
947
|
}
|
|
928
948
|
return normalizePlaybookSnapshot(live.getSnapshot(), {
|
|
929
|
-
pendingCall
|
|
949
|
+
pendingCall,
|
|
930
950
|
});
|
|
931
951
|
};
|
|
932
952
|
const stateIdentity = (state: PlaybookState): { stateId?: string } => {
|
|
@@ -1592,9 +1612,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1592
1612
|
// The caller rethrows its original failure. A restore failure skips
|
|
1593
1613
|
// the disposal trace — the parked session was never re-bound in this
|
|
1594
1614
|
// process, so its persisted snapshot stays authoritative (DR-014 §2).
|
|
1595
|
-
const cleanupFailedStart = async (
|
|
1596
|
-
|
|
1597
|
-
|
|
1615
|
+
const cleanupFailedStart = async (
|
|
1616
|
+
cause: unknown,
|
|
1617
|
+
options: { emitDisposal: boolean },
|
|
1618
|
+
): Promise<void> => {
|
|
1598
1619
|
let finalState: PlaybookState | undefined;
|
|
1599
1620
|
if (options.emitDisposal && actor) {
|
|
1600
1621
|
try {
|
|
@@ -1608,6 +1629,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1608
1629
|
} catch {
|
|
1609
1630
|
// Preserve the original startup failure.
|
|
1610
1631
|
}
|
|
1632
|
+
try {
|
|
1633
|
+
await nestedBridge.abortPending(cause);
|
|
1634
|
+
} catch {
|
|
1635
|
+
// Preserve the original startup failure.
|
|
1636
|
+
}
|
|
1611
1637
|
try {
|
|
1612
1638
|
await judgeQueue.onIdle();
|
|
1613
1639
|
await drainBoundaryCallsAndEmissions();
|
|
@@ -1682,7 +1708,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1682
1708
|
actor?.start();
|
|
1683
1709
|
await flush();
|
|
1684
1710
|
} catch (error) {
|
|
1685
|
-
await cleanupFailedStart({ emitDisposal: true });
|
|
1711
|
+
await cleanupFailedStart(error, { emitDisposal: true });
|
|
1686
1712
|
throw error;
|
|
1687
1713
|
} finally {
|
|
1688
1714
|
finishInitialization();
|
|
@@ -1690,10 +1716,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1690
1716
|
}
|
|
1691
1717
|
},
|
|
1692
1718
|
|
|
1693
|
-
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked
|
|
1719
|
+
// DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
|
|
1720
|
+
// session, including one already-started suspended REVIEW call.
|
|
1694
1721
|
// Defined only at a safe capture point — initialized, not disposing
|
|
1695
|
-
// or disposed, no active public boundary, and the actor quiescent
|
|
1696
|
-
//
|
|
1722
|
+
// or disposed, no active public boundary, and the actor quiescent with
|
|
1723
|
+
// status `active`.
|
|
1697
1724
|
exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
|
|
1698
1725
|
if (
|
|
1699
1726
|
!actor ||
|
|
@@ -1706,7 +1733,37 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1706
1733
|
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1707
1734
|
return undefined;
|
|
1708
1735
|
}
|
|
1709
|
-
|
|
1736
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1737
|
+
const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
|
|
1738
|
+
if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
|
|
1739
|
+
return undefined;
|
|
1740
|
+
}
|
|
1741
|
+
if (
|
|
1742
|
+
pendingCall !== undefined &&
|
|
1743
|
+
bridgeSuspendedCall !== undefined &&
|
|
1744
|
+
(pendingCall.callId !== bridgeSuspendedCall.callId ||
|
|
1745
|
+
pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
|
|
1746
|
+
pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)
|
|
1747
|
+
) {
|
|
1748
|
+
return undefined;
|
|
1749
|
+
}
|
|
1750
|
+
let suspendedCall: typeof bridgeSuspendedCall;
|
|
1751
|
+
if (bridgeSuspendedCall !== undefined) {
|
|
1752
|
+
if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
|
|
1753
|
+
return undefined;
|
|
1754
|
+
}
|
|
1755
|
+
const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
|
|
1756
|
+
if (
|
|
1757
|
+
bridgeSuspendedCall.turnId !== undefined &&
|
|
1758
|
+
bridgeSuspendedCall.turnId !== turnId
|
|
1759
|
+
) {
|
|
1760
|
+
return undefined;
|
|
1761
|
+
}
|
|
1762
|
+
suspendedCall = {
|
|
1763
|
+
...bridgeSuspendedCall,
|
|
1764
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1710
1767
|
const state = currentState();
|
|
1711
1768
|
if (state.status !== 'active' || !state.quiescent) return undefined;
|
|
1712
1769
|
const machine = detachPersistedMachineSnapshot(
|
|
@@ -1716,7 +1773,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1716
1773
|
actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
|
|
1717
1774
|
).context as unknown as Record<string, unknown>;
|
|
1718
1775
|
return {
|
|
1719
|
-
schemaVersion:
|
|
1776
|
+
schemaVersion: 2,
|
|
1720
1777
|
playbookId: sessionIdentity.playbookId,
|
|
1721
1778
|
machine,
|
|
1722
1779
|
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
@@ -1736,6 +1793,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1736
1793
|
sourceItem: pending.sourceItem,
|
|
1737
1794
|
}),
|
|
1738
1795
|
),
|
|
1796
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
1739
1797
|
};
|
|
1740
1798
|
},
|
|
1741
1799
|
|
|
@@ -1762,7 +1820,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1762
1820
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(
|
|
1763
1821
|
snapshot,
|
|
1764
1822
|
identity.playbookId,
|
|
1823
|
+
{ allowSuspendedCall: true },
|
|
1765
1824
|
);
|
|
1825
|
+
const suspendedCall =
|
|
1826
|
+
boundSnapshot.schemaVersion === 2
|
|
1827
|
+
? boundSnapshot.suspendedCall
|
|
1828
|
+
: undefined;
|
|
1766
1829
|
let finishInitialization!: () => void;
|
|
1767
1830
|
const initialization = new Promise<void>((resolve) => {
|
|
1768
1831
|
finishInitialization = resolve;
|
|
@@ -1784,18 +1847,34 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1784
1847
|
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
1785
1848
|
}
|
|
1786
1849
|
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1850
|
+
nestedBridge.prepareRestore(suspendedCall);
|
|
1851
|
+
if (suspendedCall !== undefined) {
|
|
1852
|
+
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
1853
|
+
}
|
|
1787
1854
|
suppressInspectionEmissions = true;
|
|
1788
1855
|
createRuntimeActor(boundSnapshot.machine);
|
|
1789
1856
|
actor?.start();
|
|
1790
|
-
const restoredState = currentState();
|
|
1857
|
+
const restoredState = currentState(suspendedCall);
|
|
1791
1858
|
if (restoredState.status !== 'active') {
|
|
1792
1859
|
throw new Error(
|
|
1793
1860
|
`decide runtime: restored actor status is ${restoredState.status}, expected active`,
|
|
1794
1861
|
);
|
|
1795
1862
|
}
|
|
1796
|
-
|
|
1863
|
+
if (
|
|
1864
|
+
stableJson(restoredState, 'restored runtime state') !==
|
|
1865
|
+
stableJson(boundSnapshot.state, 'runtime snapshot state')
|
|
1866
|
+
) {
|
|
1867
|
+
throw new Error(
|
|
1868
|
+
'decide runtime: restored actor state does not match snapshot state',
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1797
1871
|
previousState = restoredState;
|
|
1798
1872
|
await flush();
|
|
1873
|
+
suppressInspectionEmissions = false;
|
|
1874
|
+
// Final fallible step: after this publication the authoritative
|
|
1875
|
+
// child has rejoined ordinary resume/abort ownership, so no later
|
|
1876
|
+
// restore validation may trigger failed-start rollback.
|
|
1877
|
+
nestedBridge.confirmRestore();
|
|
1799
1878
|
} catch (error) {
|
|
1800
1879
|
let failure = error;
|
|
1801
1880
|
if (priorExternalPlayerTokens !== undefined) {
|
|
@@ -1808,7 +1887,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1808
1887
|
);
|
|
1809
1888
|
}
|
|
1810
1889
|
}
|
|
1811
|
-
await cleanupFailedStart({ emitDisposal: false });
|
|
1890
|
+
await cleanupFailedStart(failure, { emitDisposal: false });
|
|
1812
1891
|
throw failure;
|
|
1813
1892
|
} finally {
|
|
1814
1893
|
finishInitialization();
|
package/slc/link.md
CHANGED
|
@@ -1240,12 +1240,12 @@ durability, the pair shall behave as follows.
|
|
|
1240
1240
|
|
|
1241
1241
|
`exportSnapshot()` shall return `undefined` unless the runtime is at a safe
|
|
1242
1242
|
capture point: initialized, not disposing or disposed, no active
|
|
1243
|
-
`handleBossInput`/`resumePlaybookCall` boundary,
|
|
1244
|
-
|
|
1243
|
+
`handleBossInput`/`resumePlaybookCall` boundary, and the root actor at a
|
|
1244
|
+
quiescent state with actor status `active`.
|
|
1245
1245
|
At a safe capture point it shall return a JSON-safe
|
|
1246
1246
|
`PlaybookRuntimeSnapshot` carrying:
|
|
1247
1247
|
|
|
1248
|
-
- `schemaVersion`: literal `
|
|
1248
|
+
- `schemaVersion`: literal `2`.
|
|
1249
1249
|
- `playbookId`: the bound session's playbook id.
|
|
1250
1250
|
- `machine`: the root actor's `getPersistedSnapshot()` result, passed
|
|
1251
1251
|
through the shared JSON detachment with any raw `Error` context value
|
|
@@ -1255,19 +1255,33 @@ At a safe capture point it shall return a JSON-safe
|
|
|
1255
1255
|
(§PlaybookPorts contract).
|
|
1256
1256
|
- `sequences`: the live `trace`, `turn`, `judgeCall`, `playerCall`, and
|
|
1257
1257
|
`playbookCall` counters, plus `captainCall` when the runtime supports direct
|
|
1258
|
-
Captain calls.
|
|
1259
|
-
|
|
1258
|
+
Captain calls. A direct-Captain-capable runtime shall persist it in current
|
|
1259
|
+
schema-version-2 exports; it remains optional in legacy schema-version-1
|
|
1260
|
+
input, where restore uses `trace` as its collision-safe floor.
|
|
1260
1261
|
- `state`: the current normalized state descriptor.
|
|
1261
1262
|
- `pendingBossQuestions`: the pending Boss question(s) from FSM context as
|
|
1262
1263
|
a list of `{ questionId, player, question, sourceItem? }`, empty when the
|
|
1263
1264
|
parked state awaits no reply. This list exists so hosts can surface the
|
|
1264
1265
|
question without parsing status lines or telemetry.
|
|
1266
|
+
- `suspendedCall`: omitted when no nested call is pending; otherwise the
|
|
1267
|
+
shared nested bridge's complete `callId`, source `stateId`, target
|
|
1268
|
+
`playbookId`, exact handed-off `text`, and `childSessionId`, enriched with
|
|
1269
|
+
the call-to-turn map's optional `turnId`.
|
|
1270
|
+
|
|
1271
|
+
A pending nested call is exportable only when the bridge's pending identity
|
|
1272
|
+
and complete descriptor agree and the call-to-turn map owns that exact call
|
|
1273
|
+
id, including ownership whose value is absent. A bridge descriptor that
|
|
1274
|
+
already carries a turn id shall equal that map value. Any missing or
|
|
1275
|
+
inconsistent bridge, descriptor, or turn-ownership record makes the capture
|
|
1276
|
+
unsafe and returns `undefined`.
|
|
1265
1277
|
|
|
1266
1278
|
`restore(session, snapshot)` is an alternative to `init` under the same
|
|
1267
1279
|
lifecycle guards (§Session lifecycle): it shall reject when already
|
|
1268
1280
|
initialized, disposing, or disposed, and shall validate
|
|
1269
|
-
`
|
|
1270
|
-
`session.playbookId` before touching state.
|
|
1281
|
+
schema version `1` or `2` and that `snapshot.playbookId` equals
|
|
1282
|
+
`session.playbookId` before touching state. Schema version `1` remains a
|
|
1283
|
+
descriptor-free legacy input; schema version `2` may carry the suspended-call
|
|
1284
|
+
descriptor above.
|
|
1271
1285
|
The host supplies the same immutable `PlaybookSession` identity the
|
|
1272
1286
|
snapshot was exported under and recreates the runtime through the same
|
|
1273
1287
|
factory with equivalent options; the runtime does not diff options, and
|
|
@@ -1278,13 +1292,22 @@ make before calling `restore`.
|
|
|
1278
1292
|
sequence counters (using the persisted global `trace` counter as a
|
|
1279
1293
|
collision-safe floor for an absent legacy `captainCall`), and the
|
|
1280
1294
|
prior-state descriptor from the snapshot,
|
|
1281
|
-
|
|
1295
|
+
prepare the shared nested bridge with the suspended-call descriptor or its
|
|
1296
|
+
explicit absence, restore a descriptor's call-to-turn map entry, construct
|
|
1297
|
+
the actor with the persisted `machine` snapshot, and start it
|
|
1282
1298
|
with root inspection emissions suppressed so rehydration emits no
|
|
1283
1299
|
`session.started` trace, no transition trace, and no human status — the
|
|
1284
1300
|
session already started, and the next public boundary continues the
|
|
1285
|
-
contiguous trace sequence. After start,
|
|
1286
|
-
|
|
1287
|
-
|
|
1301
|
+
contiguous trace sequence. After start, the runtime shall normalize the
|
|
1302
|
+
actual actor state with the prepared suspended call as its pending identity
|
|
1303
|
+
and require it to equal the detached persisted state exactly, including
|
|
1304
|
+
active status. It shall drain suppressed startup work and invoke the bridge's
|
|
1305
|
+
`confirmRestore` as the final fallible restore step, publishing the pending
|
|
1306
|
+
identity only after every other validation succeeds. A missing, extra, or
|
|
1307
|
+
mismatched reconstructed invocation, an actual/persisted state mismatch, or
|
|
1308
|
+
any other failed validation shall fail `restore` through the same
|
|
1309
|
+
failed-start cleanup path as `init`, rolling back provisional bridge and turn
|
|
1310
|
+
ownership without a child-host call or duplicate start/finish boundary.
|
|
1288
1311
|
A restore failure shall leave the runtime unbound so `dispose` remains
|
|
1289
1312
|
callable and terminal.
|
|
1290
1313
|
|
|
@@ -1440,7 +1463,7 @@ runtime's emission-failure channel to surface from the next public boundary
|
|
|
1440
1463
|
that drains.
|
|
1441
1464
|
|
|
1442
1465
|
The recorded receipts and the recorded last classified event are
|
|
1443
|
-
process-local: the
|
|
1466
|
+
process-local: the durable runtime snapshot persists neither, and a
|
|
1444
1467
|
restored runtime advertises a retry again only after its next classified
|
|
1445
1468
|
event.
|
|
1446
1469
|
|
package/src/runtime.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export interface PlaybookPendingCall {
|
|
|
47
47
|
playbookId: string;
|
|
48
48
|
childSessionId: string;
|
|
49
49
|
}
|
|
50
|
+
export interface PlaybookSuspendedCall extends PlaybookPendingCall {
|
|
51
|
+
stateId: string;
|
|
52
|
+
text: string;
|
|
53
|
+
turnId?: number;
|
|
54
|
+
}
|
|
50
55
|
export interface PlaybookCallRequest {
|
|
51
56
|
callId: string;
|
|
52
57
|
playbookId: string;
|
|
@@ -137,8 +142,7 @@ export interface PlaybookPendingBossQuestion {
|
|
|
137
142
|
question: string;
|
|
138
143
|
sourceItem?: string;
|
|
139
144
|
}
|
|
140
|
-
|
|
141
|
-
schemaVersion: 1;
|
|
145
|
+
interface PlaybookRuntimeSnapshotFields {
|
|
142
146
|
playbookId: string;
|
|
143
147
|
machine: JsonValue;
|
|
144
148
|
playerResumeTokens: {
|
|
@@ -155,6 +159,13 @@ export interface PlaybookRuntimeSnapshot {
|
|
|
155
159
|
state: PlaybookState;
|
|
156
160
|
pendingBossQuestions: readonly PlaybookPendingBossQuestion[];
|
|
157
161
|
}
|
|
162
|
+
export type PlaybookRuntimeSnapshot = PlaybookRuntimeSnapshotFields & ({
|
|
163
|
+
schemaVersion: 1;
|
|
164
|
+
suspendedCall?: never;
|
|
165
|
+
} | {
|
|
166
|
+
schemaVersion: 2;
|
|
167
|
+
suspendedCall?: PlaybookSuspendedCall;
|
|
168
|
+
});
|
|
158
169
|
export interface PlaybookControlAction {
|
|
159
170
|
id: string;
|
|
160
171
|
label: string;
|
|
@@ -199,3 +210,4 @@ export interface PlaybookRuntime {
|
|
|
199
210
|
dispose(): Promise<void>;
|
|
200
211
|
}
|
|
201
212
|
export type PlaybookRuntimeFactory<Options = unknown> = (options: Options) => PlaybookRuntime;
|
|
213
|
+
export {};
|
package/src/runtime.ts
CHANGED
|
@@ -75,6 +75,15 @@ export interface PlaybookPendingCall {
|
|
|
75
75
|
childSessionId: string;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// DR-031 §5: complete durable identity for one nested call whose start
|
|
79
|
+
// boundary has already been published and whose child remains suspended.
|
|
80
|
+
// `turnId` is absent when the call was opened outside a Boss-turn boundary.
|
|
81
|
+
export interface PlaybookSuspendedCall extends PlaybookPendingCall {
|
|
82
|
+
stateId: string;
|
|
83
|
+
text: string;
|
|
84
|
+
turnId?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
export interface PlaybookCallRequest {
|
|
79
88
|
callId: string;
|
|
80
89
|
playbookId: string;
|
|
@@ -199,12 +208,11 @@ export interface PlaybookPendingBossQuestion {
|
|
|
199
208
|
sourceItem?: string;
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
// DR-014 §1: JSON-safe capture of a parked
|
|
203
|
-
//
|
|
204
|
-
// questions are first-class so a
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
schemaVersion: 1;
|
|
211
|
+
// DR-014 §1 / DR-031 §5: JSON-safe capture of a parked or nested-call
|
|
212
|
+
// suspended session. `machine` is the opaque XState persisted snapshot;
|
|
213
|
+
// pending Boss questions and a schema-2 suspended call are first-class so a
|
|
214
|
+
// host never has to reconstruct durable ownership from presentation records.
|
|
215
|
+
interface PlaybookRuntimeSnapshotFields {
|
|
208
216
|
playbookId: string;
|
|
209
217
|
machine: JsonValue;
|
|
210
218
|
playerResumeTokens: { readonly [playerId: string]: string };
|
|
@@ -220,6 +228,18 @@ export interface PlaybookRuntimeSnapshot {
|
|
|
220
228
|
pendingBossQuestions: readonly PlaybookPendingBossQuestion[];
|
|
221
229
|
}
|
|
222
230
|
|
|
231
|
+
export type PlaybookRuntimeSnapshot = PlaybookRuntimeSnapshotFields &
|
|
232
|
+
(
|
|
233
|
+
| {
|
|
234
|
+
schemaVersion: 1;
|
|
235
|
+
suspendedCall?: never;
|
|
236
|
+
}
|
|
237
|
+
| {
|
|
238
|
+
schemaVersion: 2;
|
|
239
|
+
suspendedCall?: PlaybookSuspendedCall;
|
|
240
|
+
}
|
|
241
|
+
);
|
|
242
|
+
|
|
223
243
|
// DR-029: one currently valid, runtime-advertised control action. The id
|
|
224
244
|
// is stable within the returned view; the label is runtime-written,
|
|
225
245
|
// Boss-appropriate text derived from source state descriptions.
|