@sublang/playbook 5.0.0 → 6.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 +11 -7
- package/docs/cli.md +38 -35
- package/docs/configuration.md +58 -15
- package/docs/embedding.md +24 -16
- package/package.json +40 -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/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.js +93 -15
- package/reference/sdlc/code.playbook/playbook-captain.ts +115 -19
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
- 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} +471 -362
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +575 -443
- 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 +48 -2
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +7 -0
- package/src/runtime.ts +12 -0
- package/src/xstate-playbook-runtime.d.ts +9 -2
- package/src/xstate-playbook-runtime.js +255 -21
- package/src/xstate-playbook-runtime.ts +333 -28
- package/src/xstate-runtime.js +25 -0
- package/src/xstate-runtime.ts +51 -0
- 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 =
|
|
@@ -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(
|
|
@@ -3099,7 +3382,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3099
3382
|
schemaVersion: 1,
|
|
3100
3383
|
playbookId: session.playbookId,
|
|
3101
3384
|
machine: machineSnapshot,
|
|
3102
|
-
playerResumeTokens:
|
|
3385
|
+
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
3103
3386
|
sequences: {
|
|
3104
3387
|
trace: traceSequence,
|
|
3105
3388
|
turn: turnSequence,
|
|
@@ -3142,6 +3425,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3142
3425
|
snapshot,
|
|
3143
3426
|
boundSession.playbookId,
|
|
3144
3427
|
);
|
|
3428
|
+
let priorExternalPlayerTokens:
|
|
3429
|
+
| Readonly<Record<string, string>>
|
|
3430
|
+
| undefined;
|
|
3431
|
+
let externalStoreRestoreAttempted = false;
|
|
3145
3432
|
initialized = true;
|
|
3146
3433
|
let finishInitialization!: () => void;
|
|
3147
3434
|
const initialization = new Promise<void>((resolve) => {
|
|
@@ -3168,12 +3455,11 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3168
3455
|
// the persisted trace counter is a collision-safe id floor here
|
|
3169
3456
|
// too, keeping `apply-<n>` call ids unique across restore.
|
|
3170
3457
|
applyCallSequence = boundSnapshot.sequences.trace;
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
)) {
|
|
3175
|
-
playerResumeTokens.set(playerId, token);
|
|
3458
|
+
if (boundSession.playerSessions) {
|
|
3459
|
+
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
3460
|
+
externalStoreRestoreAttempted = true;
|
|
3176
3461
|
}
|
|
3462
|
+
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
3177
3463
|
suppressInspectionEmissions = true;
|
|
3178
3464
|
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
3179
3465
|
actor.start();
|
|
@@ -3190,8 +3476,22 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3190
3476
|
try {
|
|
3191
3477
|
await initTask;
|
|
3192
3478
|
} catch (error) {
|
|
3193
|
-
|
|
3194
|
-
|
|
3479
|
+
let failure = error;
|
|
3480
|
+
if (
|
|
3481
|
+
externalStoreRestoreAttempted &&
|
|
3482
|
+
priorExternalPlayerTokens !== undefined
|
|
3483
|
+
) {
|
|
3484
|
+
try {
|
|
3485
|
+
boundSession.playerSessions!.restore(priorExternalPlayerTokens);
|
|
3486
|
+
} catch (rollbackError) {
|
|
3487
|
+
failure = new AggregateError(
|
|
3488
|
+
[error, rollbackError],
|
|
3489
|
+
'createPlaybookRuntime.restore and player continuation rollback failed',
|
|
3490
|
+
);
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
await cleanupFailedStart(failure, { emitDisposal: false });
|
|
3494
|
+
throw failure;
|
|
3195
3495
|
} finally {
|
|
3196
3496
|
finishInitialization();
|
|
3197
3497
|
if (initInFlight === initialization) initInFlight = undefined;
|
|
@@ -3607,7 +3907,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3607
3907
|
} else {
|
|
3608
3908
|
// 2. Optional Captain-pane classification line: the bare FSM
|
|
3609
3909
|
// event type, emitted before the FSM advances.
|
|
3610
|
-
const statusLine =
|
|
3910
|
+
const statusLine = classificationStatus(event);
|
|
3611
3911
|
if (statusLine !== undefined) {
|
|
3612
3912
|
await runtimePorts!.emitStatus(statusLine);
|
|
3613
3913
|
}
|
|
@@ -3806,7 +4106,12 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3806
4106
|
}
|
|
3807
4107
|
}
|
|
3808
4108
|
} finally {
|
|
3809
|
-
|
|
4109
|
+
// A composing host owns the shared store for the complete root
|
|
4110
|
+
// engagement tree. Child disposal must not erase a token its
|
|
4111
|
+
// caller will resume. The private fallback remains runtime-owned.
|
|
4112
|
+
if (session?.playerSessions === undefined) {
|
|
4113
|
+
playerResumeTokens.clear();
|
|
4114
|
+
}
|
|
3810
4115
|
activePlayerIds.clear();
|
|
3811
4116
|
playbookCallTurnIds.clear();
|
|
3812
4117
|
activeEmissionCalls.clear();
|
package/src/xstate-runtime.js
CHANGED
|
@@ -248,6 +248,26 @@ function capturedPort(descriptors, name) {
|
|
|
248
248
|
}
|
|
249
249
|
return value;
|
|
250
250
|
}
|
|
251
|
+
function capturedSessionStore(descriptors) {
|
|
252
|
+
const captured = capturedDataValue(descriptors, 'playerSessions', 'playbook session playerSessions');
|
|
253
|
+
if (!isRecord(captured)) {
|
|
254
|
+
throw new TypeError('playbook session playerSessions must be an object');
|
|
255
|
+
}
|
|
256
|
+
const storeDescriptors = Object.getOwnPropertyDescriptors(captured);
|
|
257
|
+
const method = (name) => {
|
|
258
|
+
const value = capturedDataValue(storeDescriptors, name, `playbook session playerSessions.${name}`);
|
|
259
|
+
if (typeof value !== 'function') {
|
|
260
|
+
throw new TypeError(`playbook session playerSessions.${name} must be a function`);
|
|
261
|
+
}
|
|
262
|
+
return value;
|
|
263
|
+
};
|
|
264
|
+
return Object.freeze({
|
|
265
|
+
select: method('select'),
|
|
266
|
+
update: method('update'),
|
|
267
|
+
snapshot: method('snapshot'),
|
|
268
|
+
restore: method('restore'),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
251
271
|
/** Validate session causality and detach its immutable identity from the host. */
|
|
252
272
|
export function snapshotPlaybookSession(session) {
|
|
253
273
|
if (!isRecord(session)) {
|
|
@@ -266,6 +286,7 @@ export function snapshotPlaybookSession(session) {
|
|
|
266
286
|
const capturedParentCallId = capturedDataValue(sessionDescriptors, 'parentCallId', 'playbook session parentCallId', false);
|
|
267
287
|
const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
|
|
268
288
|
const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
|
|
289
|
+
const hasPlayerSessions = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'playerSessions');
|
|
269
290
|
let parentSessionId;
|
|
270
291
|
let parentCallId;
|
|
271
292
|
if (depth === 0) {
|
|
@@ -296,6 +317,9 @@ export function snapshotPlaybookSession(session) {
|
|
|
296
317
|
emitStatus: capturedPort(portDescriptors, 'emitStatus'),
|
|
297
318
|
emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
|
|
298
319
|
});
|
|
320
|
+
const playerSessions = hasPlayerSessions
|
|
321
|
+
? capturedSessionStore(sessionDescriptors)
|
|
322
|
+
: undefined;
|
|
299
323
|
return Object.freeze({
|
|
300
324
|
sessionId,
|
|
301
325
|
playbookId,
|
|
@@ -303,6 +327,7 @@ export function snapshotPlaybookSession(session) {
|
|
|
303
327
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
304
328
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
305
329
|
depth,
|
|
330
|
+
...(playerSessions === undefined ? {} : { playerSessions }),
|
|
306
331
|
ports,
|
|
307
332
|
});
|
|
308
333
|
}
|
package/src/xstate-runtime.ts
CHANGED
|
@@ -342,6 +342,49 @@ function capturedPort<K extends keyof PlaybookSession['ports']>(
|
|
|
342
342
|
return value as PlaybookSession['ports'][K];
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
function capturedSessionStore(
|
|
346
|
+
descriptors: PropertyDescriptorMap,
|
|
347
|
+
): PlaybookSession['playerSessions'] {
|
|
348
|
+
const captured = capturedDataValue(
|
|
349
|
+
descriptors,
|
|
350
|
+
'playerSessions',
|
|
351
|
+
'playbook session playerSessions',
|
|
352
|
+
);
|
|
353
|
+
if (!isRecord(captured)) {
|
|
354
|
+
throw new TypeError('playbook session playerSessions must be an object');
|
|
355
|
+
}
|
|
356
|
+
const storeDescriptors = Object.getOwnPropertyDescriptors(captured);
|
|
357
|
+
const method = (
|
|
358
|
+
name: keyof NonNullable<PlaybookSession['playerSessions']>,
|
|
359
|
+
): ((...args: never[]) => unknown) => {
|
|
360
|
+
const value = capturedDataValue(
|
|
361
|
+
storeDescriptors,
|
|
362
|
+
name,
|
|
363
|
+
`playbook session playerSessions.${name}`,
|
|
364
|
+
);
|
|
365
|
+
if (typeof value !== 'function') {
|
|
366
|
+
throw new TypeError(
|
|
367
|
+
`playbook session playerSessions.${name} must be a function`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return value as (...args: never[]) => unknown;
|
|
371
|
+
};
|
|
372
|
+
return Object.freeze({
|
|
373
|
+
select: method('select') as NonNullable<
|
|
374
|
+
PlaybookSession['playerSessions']
|
|
375
|
+
>['select'],
|
|
376
|
+
update: method('update') as NonNullable<
|
|
377
|
+
PlaybookSession['playerSessions']
|
|
378
|
+
>['update'],
|
|
379
|
+
snapshot: method('snapshot') as NonNullable<
|
|
380
|
+
PlaybookSession['playerSessions']
|
|
381
|
+
>['snapshot'],
|
|
382
|
+
restore: method('restore') as NonNullable<
|
|
383
|
+
PlaybookSession['playerSessions']
|
|
384
|
+
>['restore'],
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
345
388
|
/** Validate session causality and detach its immutable identity from the host. */
|
|
346
389
|
export function snapshotPlaybookSession(
|
|
347
390
|
session: PlaybookSession,
|
|
@@ -405,6 +448,10 @@ export function snapshotPlaybookSession(
|
|
|
405
448
|
sessionDescriptors,
|
|
406
449
|
'parentCallId',
|
|
407
450
|
);
|
|
451
|
+
const hasPlayerSessions = Object.prototype.hasOwnProperty.call(
|
|
452
|
+
sessionDescriptors,
|
|
453
|
+
'playerSessions',
|
|
454
|
+
);
|
|
408
455
|
let parentSessionId: string | undefined;
|
|
409
456
|
let parentCallId: string | undefined;
|
|
410
457
|
if (depth === 0) {
|
|
@@ -450,6 +497,9 @@ export function snapshotPlaybookSession(
|
|
|
450
497
|
emitStatus: capturedPort(portDescriptors, 'emitStatus'),
|
|
451
498
|
emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
|
|
452
499
|
});
|
|
500
|
+
const playerSessions = hasPlayerSessions
|
|
501
|
+
? capturedSessionStore(sessionDescriptors)
|
|
502
|
+
: undefined;
|
|
453
503
|
return Object.freeze({
|
|
454
504
|
sessionId,
|
|
455
505
|
playbookId,
|
|
@@ -457,6 +507,7 @@ export function snapshotPlaybookSession(
|
|
|
457
507
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
458
508
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
459
509
|
depth,
|
|
510
|
+
...(playerSessions === undefined ? {} : { playerSessions }),
|
|
460
511
|
ports,
|
|
461
512
|
});
|
|
462
513
|
}
|