@sublang/playbook 5.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 +22 -14
- package/docs/cli.md +138 -90
- package/docs/configuration.md +113 -29
- package/docs/embedding.md +24 -16
- package/package.json +42 -21
- package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
- package/reference/sdlc/code.md +55 -97
- 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/code.fsm.d.ts +229 -94
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
- package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
- package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
- package/reference/sdlc/code.playbook/code.gears.md +51 -263
- package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
- package/reference/sdlc/code.playbook/code.playbook.js +69 -656
- package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
- package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
- package/reference/sdlc/code.playbook/code.registry.js +20 -78
- package/reference/sdlc/code.playbook/code.registry.ts +58 -122
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
- package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
- package/reference/sdlc/decide.md +54 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
- package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
- package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +545 -372
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
- package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
- package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
- package/reference/sdlc/review.md +81 -0
- package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
- package/reference/sdlc/review.playbook/review.fsm.js +524 -0
- package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
- package/reference/sdlc/review.playbook/review.gears.md +112 -0
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
- package/reference/sdlc/review.playbook/review.playbook.js +112 -0
- package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
- package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
- package/reference/sdlc/review.playbook/review.registry.js +73 -0
- package/reference/sdlc/review.playbook/review.registry.ts +138 -0
- package/slc/gears2fsm.md +13 -4
- package/slc/link.md +83 -14
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +21 -2
- package/src/runtime.ts +38 -6
- package/src/xstate-playbook-runtime.d.ts +9 -2
- package/src/xstate-playbook-runtime.js +319 -35
- package/src/xstate-playbook-runtime.ts +412 -41
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +384 -57
- package/src/xstate-runtime.ts +542 -71
- package/reference/sdlc/discuss.md +0 -93
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
- package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
- package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
|
@@ -150,6 +150,12 @@ export interface ScheduledStatus {
|
|
|
150
150
|
data?: JsonValue;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** Boss-facing identity for one FSM state whose invoked actor is `player`. */
|
|
154
|
+
export interface XStatePlayerStateStatus {
|
|
155
|
+
player: string;
|
|
156
|
+
label: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
153
159
|
export interface XStateBossEventFieldSpec {
|
|
154
160
|
/** The judge supplies routing data; the runtime supplies exact Boss text. */
|
|
155
161
|
source: 'judge' | 'text';
|
|
@@ -391,8 +397,10 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
391
397
|
* recoverable FSM-result failures instead.
|
|
392
398
|
*/
|
|
393
399
|
captainStrategy?: XStateCaptainStrategy<TOptions>;
|
|
394
|
-
/** Status line emitted after classification
|
|
400
|
+
/** Status line emitted after classification; canonical metadata defaults to the event type, legacy artifacts to none. */
|
|
395
401
|
classificationStatus?: (event: EventObject) => string | undefined;
|
|
402
|
+
/** Complete FSM-derived Boss-facing metadata for every `player` state; its presence selects the canonical status profile. */
|
|
403
|
+
playerStates?: Readonly<Record<string, XStatePlayerStateStatus>>;
|
|
396
404
|
/** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
|
|
397
405
|
resolvePlayerId?: (input: PlaybookPlayerInput, options: TOptions) => string;
|
|
398
406
|
/** Compose the player prompt. Default: continuation blocks + `<field>` placeholder substitution. */
|
|
@@ -421,7 +429,7 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
421
429
|
controlContextFields?: readonly string[];
|
|
422
430
|
/** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
|
|
423
431
|
resumableStateIds?: ReadonlySet<string>;
|
|
424
|
-
/** Human status lines for a root transition. Default:
|
|
432
|
+
/** Human status lines for a root transition. Default: guard, declared-player, question, and failure lines. */
|
|
425
433
|
statusesForState?: (
|
|
426
434
|
state: PlaybookState,
|
|
427
435
|
context: Record<string, unknown>,
|
|
@@ -1055,6 +1063,60 @@ function collectInvokeSources(machine: AnyStateMachine): ReadonlySet<string> {
|
|
|
1055
1063
|
return sources;
|
|
1056
1064
|
}
|
|
1057
1065
|
|
|
1066
|
+
function collectPlayerStatePlayers(
|
|
1067
|
+
machine: AnyStateMachine,
|
|
1068
|
+
): ReadonlyMap<string, string> {
|
|
1069
|
+
const players = new Map<string, string>();
|
|
1070
|
+
const visit = (stateDef: unknown, stateKey: string): void => {
|
|
1071
|
+
if (!isPlainObject(stateDef)) return;
|
|
1072
|
+
const invoke = stateDef.invoke;
|
|
1073
|
+
const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
|
|
1074
|
+
if (
|
|
1075
|
+
invokes.some(
|
|
1076
|
+
(entry) =>
|
|
1077
|
+
isPlainObject(entry) && entry.src === 'player',
|
|
1078
|
+
)
|
|
1079
|
+
) {
|
|
1080
|
+
const playbookMeta = isPlainObject(stateDef.meta)
|
|
1081
|
+
? stateDef.meta.playbook
|
|
1082
|
+
: undefined;
|
|
1083
|
+
const stateId =
|
|
1084
|
+
isPlainObject(playbookMeta) &&
|
|
1085
|
+
typeof playbookMeta.stateId === 'string'
|
|
1086
|
+
? playbookMeta.stateId
|
|
1087
|
+
: typeof stateDef.id === 'string'
|
|
1088
|
+
? stateDef.id
|
|
1089
|
+
: stateKey;
|
|
1090
|
+
if (stateId.trim().length === 0) {
|
|
1091
|
+
throw new TypeError(
|
|
1092
|
+
'player state metadata must use a non-empty state id',
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
const player = isPlainObject(playbookMeta)
|
|
1096
|
+
? playbookMeta.player
|
|
1097
|
+
: undefined;
|
|
1098
|
+
if (typeof player !== 'string' || player.trim().length === 0) {
|
|
1099
|
+
throw new TypeError(
|
|
1100
|
+
`player state ${stateId} meta.playbook.player must be a non-empty string`,
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
players.set(stateId, player);
|
|
1104
|
+
}
|
|
1105
|
+
if (isPlainObject(stateDef.states)) {
|
|
1106
|
+
for (const [childKey, child] of Object.entries(stateDef.states)) {
|
|
1107
|
+
visit(child, childKey);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
1112
|
+
if (isPlainObject(config) && isPlainObject(config.states)) {
|
|
1113
|
+
for (const [stateKey, stateDef] of Object.entries(config.states)) {
|
|
1114
|
+
visit(stateDef, stateKey);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return players;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1058
1120
|
function transitionTargets(transition: unknown): string[] {
|
|
1059
1121
|
const arms = Array.isArray(transition) ? transition : [transition];
|
|
1060
1122
|
const targets: string[] = [];
|
|
@@ -1206,7 +1268,70 @@ function makeDefaultNormalizeTransitionEvent(
|
|
|
1206
1268
|
};
|
|
1207
1269
|
}
|
|
1208
1270
|
|
|
1209
|
-
function
|
|
1271
|
+
function snapshotPlayerStateStatuses(
|
|
1272
|
+
value: Readonly<Record<string, XStatePlayerStateStatus>> | undefined,
|
|
1273
|
+
label: string,
|
|
1274
|
+
machine: AnyStateMachine,
|
|
1275
|
+
stateDescriptions: ReadonlyMap<string, string>,
|
|
1276
|
+
): ReadonlyMap<string, XStatePlayerStateStatus> {
|
|
1277
|
+
if (value === undefined) return new Map();
|
|
1278
|
+
if (!isPlainObject(value)) {
|
|
1279
|
+
throw new TypeError(`${label} playerStates must be an object`);
|
|
1280
|
+
}
|
|
1281
|
+
const declared = collectPlayerStatePlayers(machine);
|
|
1282
|
+
const statuses = new Map<string, XStatePlayerStateStatus>();
|
|
1283
|
+
for (const [stateId, candidate] of Object.entries(value)) {
|
|
1284
|
+
if (!declared.has(stateId)) {
|
|
1285
|
+
throw new TypeError(
|
|
1286
|
+
`${label} playerStates.${stateId} does not name a player state`,
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
if (
|
|
1290
|
+
!isPlainObject(candidate) ||
|
|
1291
|
+
typeof candidate.player !== 'string' ||
|
|
1292
|
+
candidate.player.trim().length === 0 ||
|
|
1293
|
+
typeof candidate.label !== 'string' ||
|
|
1294
|
+
candidate.label.trim().length === 0
|
|
1295
|
+
) {
|
|
1296
|
+
throw new TypeError(
|
|
1297
|
+
`${label} playerStates.${stateId} must carry non-empty player and label strings`,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
const expectedLabel = stateDescriptions.get(stateId);
|
|
1301
|
+
if (candidate.label !== expectedLabel) {
|
|
1302
|
+
throw new TypeError(
|
|
1303
|
+
`${label} playerStates.${stateId}.label must equal its FSM description`,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
if (candidate.player !== declared.get(stateId)) {
|
|
1307
|
+
throw new TypeError(
|
|
1308
|
+
`${label} playerStates.${stateId}.player must equal its FSM player`,
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
statuses.set(stateId, {
|
|
1312
|
+
player: candidate.player,
|
|
1313
|
+
label: candidate.label,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
for (const stateId of declared.keys()) {
|
|
1317
|
+
if (!statuses.has(stateId)) {
|
|
1318
|
+
throw new TypeError(
|
|
1319
|
+
`${label} playerStates must declare player state ${stateId}`,
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return statuses;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function settlingGuard(event: unknown): string | undefined {
|
|
1327
|
+
if (!isPlainObject(event) || !isPlainObject(event.output)) return undefined;
|
|
1328
|
+
const guard = event.output.guard;
|
|
1329
|
+
return typeof guard === 'string' && guard.trim().length > 0
|
|
1330
|
+
? guard
|
|
1331
|
+
: undefined;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function legacyStatusesForState(
|
|
1210
1335
|
state: PlaybookState,
|
|
1211
1336
|
context: Record<string, unknown>,
|
|
1212
1337
|
): ScheduledStatus[] {
|
|
@@ -1214,11 +1339,14 @@ function defaultStatusesForState(
|
|
|
1214
1339
|
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) return [];
|
|
1215
1340
|
if (stateId === 'awaitBossReply') {
|
|
1216
1341
|
const pending = pendingBossQuestionFromContext(context);
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1342
|
+
return [
|
|
1343
|
+
{
|
|
1344
|
+
message:
|
|
1345
|
+
pending === undefined
|
|
1346
|
+
? 'Awaiting Boss reply.'
|
|
1347
|
+
: `${pending.player} asks: ${pending.question}`,
|
|
1348
|
+
},
|
|
1349
|
+
];
|
|
1222
1350
|
}
|
|
1223
1351
|
if (stateId === 'failed') {
|
|
1224
1352
|
const lastError = normalizeErrorFull(context.lastError);
|
|
@@ -1234,6 +1362,60 @@ function defaultStatusesForState(
|
|
|
1234
1362
|
return [{ message: `Entered ${stateId}.` }];
|
|
1235
1363
|
}
|
|
1236
1364
|
|
|
1365
|
+
function makeDefaultStatusesForState(
|
|
1366
|
+
playerStates: ReadonlyMap<string, XStatePlayerStateStatus>,
|
|
1367
|
+
): NonNullable<XStatePlaybookRuntimeSpec<unknown>['statusesForState']> {
|
|
1368
|
+
return (state, context, event): ScheduledStatus[] => {
|
|
1369
|
+
const statuses: ScheduledStatus[] = [];
|
|
1370
|
+
const guard = settlingGuard(event);
|
|
1371
|
+
if (guard !== undefined) statuses.push({ message: `→ ${guard}` });
|
|
1372
|
+
|
|
1373
|
+
const stateId = state.stateId;
|
|
1374
|
+
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
|
|
1375
|
+
return statuses;
|
|
1376
|
+
}
|
|
1377
|
+
if (stateId === 'awaitBossReply') {
|
|
1378
|
+
const pending = pendingBossQuestionFromContext(context);
|
|
1379
|
+
if (pending === undefined) {
|
|
1380
|
+
return [...statuses, { message: 'Awaiting Boss reply.' }];
|
|
1381
|
+
}
|
|
1382
|
+
return [
|
|
1383
|
+
...statuses,
|
|
1384
|
+
{ message: `${pending.player} asks: ${pending.question}` },
|
|
1385
|
+
{
|
|
1386
|
+
message:
|
|
1387
|
+
`◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
|
|
1388
|
+
`${pending.player} · ${pending.sourceItem}`,
|
|
1389
|
+
},
|
|
1390
|
+
];
|
|
1391
|
+
}
|
|
1392
|
+
if (stateId === 'failed') {
|
|
1393
|
+
const lastError = normalizeErrorCompact(context.lastError);
|
|
1394
|
+
return [
|
|
1395
|
+
...statuses,
|
|
1396
|
+
{
|
|
1397
|
+
message: '◆ workflow failed; awaiting Boss recovery.',
|
|
1398
|
+
...(lastError === undefined
|
|
1399
|
+
? {}
|
|
1400
|
+
: {
|
|
1401
|
+
data: snapshotJsonValue(
|
|
1402
|
+
{ lastError },
|
|
1403
|
+
'failed status data',
|
|
1404
|
+
),
|
|
1405
|
+
}),
|
|
1406
|
+
},
|
|
1407
|
+
];
|
|
1408
|
+
}
|
|
1409
|
+
const playerState = playerStates.get(stateId);
|
|
1410
|
+
if (playerState !== undefined) {
|
|
1411
|
+
statuses.push({
|
|
1412
|
+
message: `⤷ ${playerState.player}: ${playerState.label}`,
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
return statuses;
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1237
1419
|
// ---------------------------------------------------------------------------
|
|
1238
1420
|
// Default parked-state classifier (slc/link.md §Boss-event mapping): the
|
|
1239
1421
|
// runtime-owned textual fields are never requested from the judge; only the
|
|
@@ -1675,6 +1857,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1675
1857
|
// DR-029: source state descriptions label the control actions the
|
|
1676
1858
|
// runtime advertises through `describe()`.
|
|
1677
1859
|
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
1860
|
+
const hasCanonicalStatusProfile = spec.playerStates !== undefined;
|
|
1861
|
+
const playerStates = snapshotPlayerStateStatuses(
|
|
1862
|
+
spec.playerStates,
|
|
1863
|
+
label,
|
|
1864
|
+
machine,
|
|
1865
|
+
stateDescriptions,
|
|
1866
|
+
);
|
|
1678
1867
|
// PBRT-52: the artifact's own ControlView context projection. Nothing is
|
|
1679
1868
|
// exported by default, so an FSM context member — including one added
|
|
1680
1869
|
// after this artifact was linked — is private until named here. The two
|
|
@@ -1728,7 +1917,16 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1728
1917
|
const normalizeTransitionEvent =
|
|
1729
1918
|
spec.normalizeTransitionEvent ??
|
|
1730
1919
|
makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
|
|
1731
|
-
const statusesForState =
|
|
1920
|
+
const statusesForState =
|
|
1921
|
+
spec.statusesForState ??
|
|
1922
|
+
(hasCanonicalStatusProfile
|
|
1923
|
+
? makeDefaultStatusesForState(playerStates)
|
|
1924
|
+
: legacyStatusesForState);
|
|
1925
|
+
const classificationStatus =
|
|
1926
|
+
spec.classificationStatus ??
|
|
1927
|
+
(hasCanonicalStatusProfile
|
|
1928
|
+
? (event: EventObject) => event.type
|
|
1929
|
+
: () => undefined);
|
|
1732
1930
|
const machineInput =
|
|
1733
1931
|
spec.machineInput ?? ((options: TOptions) => options as unknown);
|
|
1734
1932
|
const scriptCwd =
|
|
@@ -1770,8 +1968,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1770
1968
|
// DR-029: the last event a public Boss boundary sent into the
|
|
1771
1969
|
// machine — classified, deterministic entry, or Boss reply — kept with
|
|
1772
1970
|
// its recorded payload so a failure-state retry action can replay the
|
|
1773
|
-
// event that drove the run into `failed`. Process-local: the
|
|
1774
|
-
//
|
|
1971
|
+
// event that drove the run into `failed`. Process-local: the durable
|
|
1972
|
+
// runtime snapshot does not persist it (PBRT-50).
|
|
1775
1973
|
let lastBossEvent: EventObject | undefined;
|
|
1776
1974
|
// DR-029: process-local at-most-once `apply` execution — the accepted receipt
|
|
1777
1975
|
// recorded for each idempotency key, returned verbatim on a repeated
|
|
@@ -1793,6 +1991,71 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1793
1991
|
// imperative boundaries await their queued work directly.
|
|
1794
1992
|
let emissionFailure: unknown;
|
|
1795
1993
|
|
|
1994
|
+
function selectPlayerResume(playerId: string): string | false {
|
|
1995
|
+
const selected = session?.playerSessions
|
|
1996
|
+
? session.playerSessions.select(playerId)
|
|
1997
|
+
: playerResumeTokens.get(playerId) ?? false;
|
|
1998
|
+
if (
|
|
1999
|
+
selected !== false &&
|
|
2000
|
+
(typeof selected !== 'string' || selected.trim().length === 0)
|
|
2001
|
+
) {
|
|
2002
|
+
throw new TypeError(
|
|
2003
|
+
`player session store returned an invalid resume token for ${playerId}`,
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
return selected;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function updatePlayerResume(
|
|
2010
|
+
playerId: string,
|
|
2011
|
+
resumeToken: string | undefined,
|
|
2012
|
+
): void {
|
|
2013
|
+
if (session?.playerSessions) {
|
|
2014
|
+
session.playerSessions.update(playerId, resumeToken);
|
|
2015
|
+
} else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
|
|
2016
|
+
playerResumeTokens.set(playerId, resumeToken);
|
|
2017
|
+
} else {
|
|
2018
|
+
playerResumeTokens.delete(playerId);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function snapshotPlayerResumeTokens(): Record<string, string> {
|
|
2023
|
+
const raw = session?.playerSessions
|
|
2024
|
+
? session.playerSessions.snapshot()
|
|
2025
|
+
: Object.fromEntries(playerResumeTokens);
|
|
2026
|
+
if (!isPlainObject(raw)) {
|
|
2027
|
+
throw new TypeError('player session store snapshot must be an object');
|
|
2028
|
+
}
|
|
2029
|
+
const detached: Record<string, string> = {};
|
|
2030
|
+
for (const [playerId, token] of Object.entries(raw)) {
|
|
2031
|
+
if (playerId.trim().length === 0) {
|
|
2032
|
+
throw new TypeError(
|
|
2033
|
+
'player session store snapshot player ids must be non-empty',
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
2037
|
+
throw new TypeError(
|
|
2038
|
+
`player session store snapshot token for ${playerId} must be a non-empty string`,
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
detached[playerId] = token;
|
|
2042
|
+
}
|
|
2043
|
+
return detached;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
function restorePlayerResumeTokens(
|
|
2047
|
+
tokens: Readonly<Record<string, string>>,
|
|
2048
|
+
): void {
|
|
2049
|
+
if (session?.playerSessions) {
|
|
2050
|
+
session.playerSessions.restore(tokens);
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
playerResumeTokens.clear();
|
|
2054
|
+
for (const [playerId, token] of Object.entries(tokens)) {
|
|
2055
|
+
playerResumeTokens.set(playerId, token);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1796
2059
|
function enqueueEmission(fn: () => Promise<void>): Promise<void> {
|
|
1797
2060
|
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
1798
2061
|
activeEmissionCalls.add(queued);
|
|
@@ -2001,9 +2264,16 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2001
2264
|
// State-entry telemetry/status must precede the call they describe.
|
|
2002
2265
|
await drainEmissions();
|
|
2003
2266
|
const turnId = activeTurnId;
|
|
2004
|
-
const callId = `player-${++playerCallSequence}`;
|
|
2005
2267
|
const stateId = input.stateId;
|
|
2006
|
-
|
|
2268
|
+
let resume: string | false;
|
|
2269
|
+
try {
|
|
2270
|
+
signal.throwIfAborted();
|
|
2271
|
+
resume = selectPlayerResume(playerId);
|
|
2272
|
+
} catch (error) {
|
|
2273
|
+
if (!signal.aborted) controlPlaneError ??= error;
|
|
2274
|
+
throw error;
|
|
2275
|
+
}
|
|
2276
|
+
const callId = `player-${++playerCallSequence}`;
|
|
2007
2277
|
const identity = {
|
|
2008
2278
|
purpose: 'captain' as const,
|
|
2009
2279
|
...stateIdentity(stateId),
|
|
@@ -2095,13 +2365,26 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2095
2365
|
throw error;
|
|
2096
2366
|
}
|
|
2097
2367
|
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2368
|
+
try {
|
|
2369
|
+
updatePlayerResume(
|
|
2370
|
+
playerId,
|
|
2371
|
+
typeof result.resumeToken === 'string' &&
|
|
2372
|
+
result.resumeToken.trim().length > 0
|
|
2373
|
+
? result.resumeToken
|
|
2374
|
+
: undefined,
|
|
2375
|
+
);
|
|
2376
|
+
} catch (error) {
|
|
2377
|
+
if (!signal.aborted) controlPlaneError ??= error;
|
|
2378
|
+
try {
|
|
2379
|
+
await emitTrace(
|
|
2380
|
+
'player.call.finished',
|
|
2381
|
+
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
2382
|
+
position,
|
|
2383
|
+
);
|
|
2384
|
+
} catch {
|
|
2385
|
+
// The continuation-store failure remains authoritative.
|
|
2386
|
+
}
|
|
2387
|
+
throw error;
|
|
2105
2388
|
}
|
|
2106
2389
|
|
|
2107
2390
|
await emitTrace(
|
|
@@ -3077,16 +3360,45 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3077
3360
|
}
|
|
3078
3361
|
},
|
|
3079
3362
|
|
|
3080
|
-
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked
|
|
3363
|
+
// DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
|
|
3364
|
+
// session, including one already-started suspended nested call.
|
|
3081
3365
|
// Defined only at a safe capture point — initialized, not disposing
|
|
3082
|
-
// or disposed, no active public boundary,
|
|
3083
|
-
//
|
|
3366
|
+
// or disposed, no active public boundary, and the actor quiescent with
|
|
3367
|
+
// status `active`.
|
|
3084
3368
|
exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
|
|
3085
3369
|
if (!actor || !session || disposed || disposalPromise !== undefined) {
|
|
3086
3370
|
return undefined;
|
|
3087
3371
|
}
|
|
3088
3372
|
if (activeSignal !== undefined) return undefined;
|
|
3089
|
-
|
|
3373
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
3374
|
+
const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
|
|
3375
|
+
if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
|
|
3376
|
+
return undefined;
|
|
3377
|
+
}
|
|
3378
|
+
let suspendedCall: typeof bridgeSuspendedCall;
|
|
3379
|
+
if (bridgeSuspendedCall !== undefined) {
|
|
3380
|
+
if (
|
|
3381
|
+
pendingCall?.callId !== bridgeSuspendedCall.callId ||
|
|
3382
|
+
pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
|
|
3383
|
+
pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId
|
|
3384
|
+
) {
|
|
3385
|
+
return undefined;
|
|
3386
|
+
}
|
|
3387
|
+
if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
|
|
3388
|
+
return undefined;
|
|
3389
|
+
}
|
|
3390
|
+
const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
|
|
3391
|
+
if (
|
|
3392
|
+
bridgeSuspendedCall.turnId !== undefined &&
|
|
3393
|
+
bridgeSuspendedCall.turnId !== turnId
|
|
3394
|
+
) {
|
|
3395
|
+
return undefined;
|
|
3396
|
+
}
|
|
3397
|
+
suspendedCall = {
|
|
3398
|
+
...bridgeSuspendedCall,
|
|
3399
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
3400
|
+
};
|
|
3401
|
+
}
|
|
3090
3402
|
const state = currentState();
|
|
3091
3403
|
if (state.status !== 'active' || !state.quiescent) return undefined;
|
|
3092
3404
|
const machineSnapshot = detachPersistedMachineSnapshot(
|
|
@@ -3096,10 +3408,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3096
3408
|
.context as Record<string, unknown>;
|
|
3097
3409
|
const pending = pendingBossQuestionFromContext(context ?? {});
|
|
3098
3410
|
return {
|
|
3099
|
-
schemaVersion:
|
|
3411
|
+
schemaVersion: 2,
|
|
3100
3412
|
playbookId: session.playbookId,
|
|
3101
3413
|
machine: machineSnapshot,
|
|
3102
|
-
playerResumeTokens:
|
|
3414
|
+
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
3103
3415
|
sequences: {
|
|
3104
3416
|
trace: traceSequence,
|
|
3105
3417
|
turn: turnSequence,
|
|
@@ -3122,6 +3434,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3122
3434
|
sourceItem: pending.sourceItem,
|
|
3123
3435
|
},
|
|
3124
3436
|
],
|
|
3437
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
3125
3438
|
};
|
|
3126
3439
|
},
|
|
3127
3440
|
|
|
@@ -3141,7 +3454,16 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3141
3454
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(
|
|
3142
3455
|
snapshot,
|
|
3143
3456
|
boundSession.playbookId,
|
|
3457
|
+
{ allowSuspendedCall: true },
|
|
3144
3458
|
);
|
|
3459
|
+
const suspendedCall =
|
|
3460
|
+
boundSnapshot.schemaVersion === 2
|
|
3461
|
+
? boundSnapshot.suspendedCall
|
|
3462
|
+
: undefined;
|
|
3463
|
+
let priorExternalPlayerTokens:
|
|
3464
|
+
| Readonly<Record<string, string>>
|
|
3465
|
+
| undefined;
|
|
3466
|
+
let externalStoreRestoreAttempted = false;
|
|
3145
3467
|
initialized = true;
|
|
3146
3468
|
let finishInitialization!: () => void;
|
|
3147
3469
|
const initialization = new Promise<void>((resolve) => {
|
|
@@ -3163,35 +3485,79 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3163
3485
|
// Every Captain call already consumed at least one trace number,
|
|
3164
3486
|
// so the global trace counter is a collision-safe id floor.
|
|
3165
3487
|
boundSnapshot.sequences.trace;
|
|
3166
|
-
// The
|
|
3167
|
-
//
|
|
3168
|
-
//
|
|
3169
|
-
//
|
|
3488
|
+
// The runtime snapshot carries no apply counter (PBRT-50); every
|
|
3489
|
+
// apply boundary consumed trace numbers, so the persisted trace
|
|
3490
|
+
// counter is a collision-safe id floor here too, keeping
|
|
3491
|
+
// `apply-<n>` call ids unique across restore.
|
|
3170
3492
|
applyCallSequence = boundSnapshot.sequences.trace;
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3493
|
+
if (boundSession.playerSessions) {
|
|
3494
|
+
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
3495
|
+
externalStoreRestoreAttempted = true;
|
|
3496
|
+
}
|
|
3497
|
+
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
3498
|
+
nestedBridge.prepareRestore(suspendedCall);
|
|
3499
|
+
if (suspendedCall !== undefined) {
|
|
3500
|
+
playbookCallTurnIds.set(
|
|
3501
|
+
suspendedCall.callId,
|
|
3502
|
+
suspendedCall.turnId,
|
|
3503
|
+
);
|
|
3176
3504
|
}
|
|
3177
3505
|
suppressInspectionEmissions = true;
|
|
3178
3506
|
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
3179
3507
|
actor.start();
|
|
3180
|
-
|
|
3508
|
+
if (controlPlaneError !== undefined) throw controlPlaneError;
|
|
3509
|
+
const restoredState = normalizePlaybookSnapshot(
|
|
3510
|
+
actor.getSnapshot(),
|
|
3511
|
+
suspendedCall === undefined
|
|
3512
|
+
? {}
|
|
3513
|
+
: {
|
|
3514
|
+
pendingCall: {
|
|
3515
|
+
callId: suspendedCall.callId,
|
|
3516
|
+
playbookId: suspendedCall.playbookId,
|
|
3517
|
+
childSessionId: suspendedCall.childSessionId,
|
|
3518
|
+
},
|
|
3519
|
+
},
|
|
3520
|
+
);
|
|
3181
3521
|
if (restoredState.status !== 'active') {
|
|
3182
3522
|
throw new Error(
|
|
3183
3523
|
`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`,
|
|
3184
3524
|
);
|
|
3185
3525
|
}
|
|
3186
|
-
|
|
3526
|
+
if (
|
|
3527
|
+
stableJson(restoredState, 'restored runtime state') !==
|
|
3528
|
+
stableJson(boundSnapshot.state, 'runtime snapshot state')
|
|
3529
|
+
) {
|
|
3530
|
+
throw new Error(
|
|
3531
|
+
'createPlaybookRuntime.restore: restored actor state does not match snapshot state',
|
|
3532
|
+
);
|
|
3533
|
+
}
|
|
3187
3534
|
priorState = restoredState;
|
|
3188
3535
|
await drainEmissions();
|
|
3536
|
+
suppressInspectionEmissions = false;
|
|
3537
|
+
// Final fallible step: after this publication the authoritative
|
|
3538
|
+
// child has rejoined ordinary resume/abort ownership, so no later
|
|
3539
|
+
// restore validation may trigger failed-start rollback.
|
|
3540
|
+
nestedBridge.confirmRestore();
|
|
3189
3541
|
})();
|
|
3190
3542
|
try {
|
|
3191
3543
|
await initTask;
|
|
3192
3544
|
} catch (error) {
|
|
3193
|
-
|
|
3194
|
-
|
|
3545
|
+
let failure = error;
|
|
3546
|
+
if (
|
|
3547
|
+
externalStoreRestoreAttempted &&
|
|
3548
|
+
priorExternalPlayerTokens !== undefined
|
|
3549
|
+
) {
|
|
3550
|
+
try {
|
|
3551
|
+
boundSession.playerSessions!.restore(priorExternalPlayerTokens);
|
|
3552
|
+
} catch (rollbackError) {
|
|
3553
|
+
failure = new AggregateError(
|
|
3554
|
+
[error, rollbackError],
|
|
3555
|
+
'createPlaybookRuntime.restore and player continuation rollback failed',
|
|
3556
|
+
);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
await cleanupFailedStart(failure, { emitDisposal: false });
|
|
3560
|
+
throw failure;
|
|
3195
3561
|
} finally {
|
|
3196
3562
|
finishInitialization();
|
|
3197
3563
|
if (initInFlight === initialization) initInFlight = undefined;
|
|
@@ -3607,7 +3973,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3607
3973
|
} else {
|
|
3608
3974
|
// 2. Optional Captain-pane classification line: the bare FSM
|
|
3609
3975
|
// event type, emitted before the FSM advances.
|
|
3610
|
-
const statusLine =
|
|
3976
|
+
const statusLine = classificationStatus(event);
|
|
3611
3977
|
if (statusLine !== undefined) {
|
|
3612
3978
|
await runtimePorts!.emitStatus(statusLine);
|
|
3613
3979
|
}
|
|
@@ -3806,7 +4172,12 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3806
4172
|
}
|
|
3807
4173
|
}
|
|
3808
4174
|
} finally {
|
|
3809
|
-
|
|
4175
|
+
// A composing host owns the shared store for the complete root
|
|
4176
|
+
// engagement tree. Child disposal must not erase a token its
|
|
4177
|
+
// caller will resume. The private fallback remains runtime-owned.
|
|
4178
|
+
if (session?.playerSessions === undefined) {
|
|
4179
|
+
playerResumeTokens.clear();
|
|
4180
|
+
}
|
|
3810
4181
|
activePlayerIds.clear();
|
|
3811
4182
|
playbookCallTurnIds.clear();
|
|
3812
4183
|
activeEmissionCalls.clear();
|
package/src/xstate-runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AnyActorRef, type PromiseActorLogic, type SnapshotFrom } from 'xstate';
|
|
2
|
-
import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlayerResult } from './runtime.js';
|
|
2
|
+
import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookSuspendedCall, PlayerResult } from './runtime.js';
|
|
3
3
|
export * from './xstate-playbook-runtime.js';
|
|
4
4
|
/**
|
|
5
5
|
* Compose invocation-lifetime and imperative-boundary cancellation without
|
|
@@ -31,7 +31,15 @@ export interface SnapshotNormalizationOptions {
|
|
|
31
31
|
}
|
|
32
32
|
export declare function normalizePlaybookSnapshot(snapshot: unknown, options?: SnapshotNormalizationOptions): PlaybookState;
|
|
33
33
|
export declare function detachPersistedMachineSnapshot(persisted: unknown): JsonValue;
|
|
34
|
-
export
|
|
34
|
+
export interface PlaybookRuntimeSnapshotValidationOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Opt in only when the restore path will prepare and confirm the suspended
|
|
37
|
+
* call transaction. The default is fail-closed so a legacy restore cannot
|
|
38
|
+
* reopen or ignore it.
|
|
39
|
+
*/
|
|
40
|
+
allowSuspendedCall?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function assertPlaybookRuntimeSnapshot(value: unknown, expectedPlaybookId: string, options?: PlaybookRuntimeSnapshotValidationOptions): PlaybookRuntimeSnapshot;
|
|
35
43
|
export interface NestedPlaybookInput {
|
|
36
44
|
stateId: string;
|
|
37
45
|
playbookId: string;
|
|
@@ -68,6 +76,15 @@ export interface PendingCallObserver {
|
|
|
68
76
|
}
|
|
69
77
|
export interface NestedPlaybookBridge<TInput extends NestedPlaybookInput = NestedPlaybookInput> extends PendingCallObserver {
|
|
70
78
|
actorLogic: PromiseActorLogic<JsonValue | undefined, TInput>;
|
|
79
|
+
/** Arm fail-closed actor startup for a snapshot with zero or one nested call. */
|
|
80
|
+
prepareRestore(call?: PlaybookSuspendedCall): void;
|
|
81
|
+
/**
|
|
82
|
+
* Commit restore startup after the persisted machine recreated exactly the
|
|
83
|
+
* expected zero or one nested invocation.
|
|
84
|
+
*/
|
|
85
|
+
confirmRestore(): void;
|
|
86
|
+
/** Complete durable identity; undefined until a normal or restored call suspends. */
|
|
87
|
+
getSuspendedCall(): PlaybookSuspendedCall | undefined;
|
|
71
88
|
resume(input: {
|
|
72
89
|
callId: string;
|
|
73
90
|
result: PlaybookCallResult;
|