@sublang/playbook 7.0.0 → 8.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 +17 -4
- package/docs/cli.md +74 -29
- package/docs/configuration.md +209 -112
- package/docs/embedding.md +71 -25
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
- package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
- package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +27 -23
- package/slc/link.md +113 -93
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +20 -16
- package/src/runtime.ts +19 -23
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +241 -149
- package/src/xstate-playbook-runtime.ts +331 -178
- package/src/xstate-runtime.js +63 -24
- package/src/xstate-runtime.ts +96 -28
|
@@ -78,15 +78,15 @@ function isEmptyOkRetryFailure(error) {
|
|
|
78
78
|
export const RUNTIME_ABI = 1;
|
|
79
79
|
/** The linked-artifact schema versions this engine accepts (DR-022). */
|
|
80
80
|
export const SUPPORTED_ARTIFACT_SCHEMAS = Object.freeze([
|
|
81
|
-
|
|
81
|
+
2,
|
|
82
82
|
]);
|
|
83
83
|
// PBRT-50: validate a declaration against the loaded engine, schema first,
|
|
84
|
-
// so one clear diagnostic covers a fully skewed artifact.
|
|
85
|
-
//
|
|
86
|
-
// loading unchanged (DR-019 §4), so there is nothing to check.
|
|
84
|
+
// so one clear diagnostic covers a fully skewed artifact. Declaration-free
|
|
85
|
+
// artifacts are schema 1 and cannot be interpreted as local-role artifacts.
|
|
87
86
|
function assertRuntimeCompat(compat, label) {
|
|
88
|
-
if (compat === undefined)
|
|
89
|
-
|
|
87
|
+
if (compat === undefined) {
|
|
88
|
+
throw new TypeError(`${label} spec.compat is required for local-role artifacts`);
|
|
89
|
+
}
|
|
90
90
|
if (compat === null || typeof compat !== 'object') {
|
|
91
91
|
throw new TypeError(`${label} spec.compat must be an object`);
|
|
92
92
|
}
|
|
@@ -249,15 +249,31 @@ export function pendingBossQuestionFromContext(context) {
|
|
|
249
249
|
if (typeof candidate.questionId !== 'string' ||
|
|
250
250
|
typeof candidate.resumeStateId !== 'string' ||
|
|
251
251
|
typeof candidate.sourceItem !== 'string' ||
|
|
252
|
-
|
|
252
|
+
!isPlainObject(candidate.asker) ||
|
|
253
253
|
typeof candidate.question !== 'string') {
|
|
254
254
|
return undefined;
|
|
255
255
|
}
|
|
256
|
+
let asker;
|
|
257
|
+
if (candidate.asker.kind === 'captain') {
|
|
258
|
+
if (Object.keys(candidate.asker).some((key) => key !== 'kind')) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
asker = { kind: 'captain' };
|
|
262
|
+
}
|
|
263
|
+
else if (candidate.asker.kind === 'role' &&
|
|
264
|
+
typeof candidate.asker.roleId === 'string' &&
|
|
265
|
+
candidate.asker.roleId.trim().length > 0 &&
|
|
266
|
+
Object.keys(candidate.asker).every((key) => key === 'kind' || key === 'roleId')) {
|
|
267
|
+
asker = { kind: 'role', roleId: candidate.asker.roleId };
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
256
272
|
return {
|
|
257
273
|
questionId: candidate.questionId,
|
|
258
274
|
resumeStateId: candidate.resumeStateId,
|
|
259
275
|
sourceItem: candidate.sourceItem,
|
|
260
|
-
|
|
276
|
+
asker,
|
|
261
277
|
question: candidate.question,
|
|
262
278
|
};
|
|
263
279
|
}
|
|
@@ -339,10 +355,6 @@ export function defaultComposeCaptainPrompt(input, placeholderFields = {}) {
|
|
|
339
355
|
blocks.push(body);
|
|
340
356
|
return blocks.join('\n\n');
|
|
341
357
|
}
|
|
342
|
-
/** Default player binding: each player to its lowercased name. */
|
|
343
|
-
export function defaultResolvePlayerId(input) {
|
|
344
|
-
return input.player.toLowerCase();
|
|
345
|
-
}
|
|
346
358
|
/**
|
|
347
359
|
* Default required-field extraction (slc/link.md §Captain adjudication).
|
|
348
360
|
* Limited to the description's `Output shall include` / `输出应包含` clause;
|
|
@@ -375,7 +387,7 @@ export function defaultBuildJudgePrompt(input, finalText) {
|
|
|
375
387
|
'seek external evidence. Decide only from the supplied player output ' +
|
|
376
388
|
'and outcome descriptions. Reply with exactly one JSON object and no prose.');
|
|
377
389
|
lines.push('');
|
|
378
|
-
lines.push(`The ${input.
|
|
390
|
+
lines.push(`The ${input.role} role just produced this output:`);
|
|
379
391
|
lines.push('');
|
|
380
392
|
lines.push('```');
|
|
381
393
|
lines.push(finalText);
|
|
@@ -447,11 +459,19 @@ function validateBossReplyOutput(input, output, resumableStateIds) {
|
|
|
447
459
|
export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onControlPlaneError) {
|
|
448
460
|
return fromPromise(async ({ input, signal }) => {
|
|
449
461
|
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
450
|
-
|
|
451
|
-
|
|
462
|
+
let roleId;
|
|
463
|
+
let prompt;
|
|
464
|
+
try {
|
|
465
|
+
roleId = spec.resolveRoleId(input);
|
|
466
|
+
prompt = spec.composePlayerPrompt(input);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
onControlPlaneError?.(error);
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
452
472
|
const callPlayer = (resume) => boundary
|
|
453
|
-
? boundary.callPlayer(input,
|
|
454
|
-
: ports.callPlayer(
|
|
473
|
+
? boundary.callPlayer(input, roleId, prompt, activeSignal)
|
|
474
|
+
: ports.callPlayer(roleId, prompt, activeSignal, { resume });
|
|
455
475
|
let result = await callPlayer(false);
|
|
456
476
|
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
457
477
|
// An abort that lands between the empty first result and the
|
|
@@ -589,8 +609,8 @@ function collectInvokeSources(machine) {
|
|
|
589
609
|
visit(machine.config);
|
|
590
610
|
return sources;
|
|
591
611
|
}
|
|
592
|
-
function
|
|
593
|
-
const
|
|
612
|
+
function collectPlayerStateRoles(machine) {
|
|
613
|
+
const roles = new Map();
|
|
594
614
|
const visit = (stateDef, stateKey) => {
|
|
595
615
|
if (!isPlainObject(stateDef))
|
|
596
616
|
return;
|
|
@@ -609,13 +629,13 @@ function collectPlayerStatePlayers(machine) {
|
|
|
609
629
|
if (stateId.trim().length === 0) {
|
|
610
630
|
throw new TypeError('player state metadata must use a non-empty state id');
|
|
611
631
|
}
|
|
612
|
-
const
|
|
613
|
-
? playbookMeta.
|
|
632
|
+
const role = isPlainObject(playbookMeta)
|
|
633
|
+
? playbookMeta.role
|
|
614
634
|
: undefined;
|
|
615
|
-
if (typeof
|
|
616
|
-
throw new TypeError(`player state ${stateId} meta.playbook.
|
|
635
|
+
if (typeof role !== 'string' || role.trim().length === 0) {
|
|
636
|
+
throw new TypeError(`player state ${stateId} meta.playbook.role must be a non-empty string`);
|
|
617
637
|
}
|
|
618
|
-
|
|
638
|
+
roles.set(stateId, role);
|
|
619
639
|
}
|
|
620
640
|
if (isPlainObject(stateDef.states)) {
|
|
621
641
|
for (const [childKey, child] of Object.entries(stateDef.states)) {
|
|
@@ -629,7 +649,7 @@ function collectPlayerStatePlayers(machine) {
|
|
|
629
649
|
visit(stateDef, stateKey);
|
|
630
650
|
}
|
|
631
651
|
}
|
|
632
|
-
return
|
|
652
|
+
return roles;
|
|
633
653
|
}
|
|
634
654
|
function transitionTargets(transition) {
|
|
635
655
|
const arms = Array.isArray(transition) ? transition : [transition];
|
|
@@ -774,40 +794,48 @@ function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
|
|
|
774
794
|
return snapshotJsonValue(out, 'FSM event');
|
|
775
795
|
};
|
|
776
796
|
}
|
|
777
|
-
function
|
|
778
|
-
if (value === undefined)
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
797
|
+
function snapshotRoleStateStatuses(value, label, machine, stateDescriptions) {
|
|
798
|
+
if (value === undefined) {
|
|
799
|
+
throw new TypeError(`${label} roleStates must be supplied for schema 2`);
|
|
800
|
+
}
|
|
801
|
+
const captured = snapshotJsonValue(value, `${label} roleStates`);
|
|
802
|
+
if (!isPlainObject(captured)) {
|
|
803
|
+
throw new TypeError(`${label} roleStates must be an object`);
|
|
782
804
|
}
|
|
783
|
-
const declared =
|
|
805
|
+
const declared = collectPlayerStateRoles(machine);
|
|
784
806
|
const statuses = new Map();
|
|
785
|
-
for (const [stateId, candidate] of Object.entries(
|
|
807
|
+
for (const [stateId, candidate] of Object.entries(captured)) {
|
|
786
808
|
if (!declared.has(stateId)) {
|
|
787
|
-
throw new TypeError(`${label}
|
|
809
|
+
throw new TypeError(`${label} roleStates.${stateId} does not name a player state`);
|
|
810
|
+
}
|
|
811
|
+
if (isPlainObject(candidate)) {
|
|
812
|
+
const extra = Object.keys(candidate).find((key) => key !== 'role' && key !== 'label');
|
|
813
|
+
if (extra !== undefined) {
|
|
814
|
+
throw new TypeError(`${label} roleStates.${stateId}.${extra} is not allowed`);
|
|
815
|
+
}
|
|
788
816
|
}
|
|
789
817
|
if (!isPlainObject(candidate) ||
|
|
790
|
-
typeof candidate.
|
|
791
|
-
candidate.
|
|
818
|
+
typeof candidate.role !== 'string' ||
|
|
819
|
+
candidate.role.trim().length === 0 ||
|
|
792
820
|
typeof candidate.label !== 'string' ||
|
|
793
821
|
candidate.label.trim().length === 0) {
|
|
794
|
-
throw new TypeError(`${label}
|
|
822
|
+
throw new TypeError(`${label} roleStates.${stateId} must carry non-empty role and label strings`);
|
|
795
823
|
}
|
|
796
824
|
const expectedLabel = stateDescriptions.get(stateId);
|
|
797
825
|
if (candidate.label !== expectedLabel) {
|
|
798
|
-
throw new TypeError(`${label}
|
|
826
|
+
throw new TypeError(`${label} roleStates.${stateId}.label must equal its FSM description`);
|
|
799
827
|
}
|
|
800
|
-
if (candidate.
|
|
801
|
-
throw new TypeError(`${label}
|
|
828
|
+
if (candidate.role !== declared.get(stateId)) {
|
|
829
|
+
throw new TypeError(`${label} roleStates.${stateId}.role must equal its FSM role`);
|
|
802
830
|
}
|
|
803
831
|
statuses.set(stateId, {
|
|
804
|
-
|
|
832
|
+
role: candidate.role,
|
|
805
833
|
label: candidate.label,
|
|
806
834
|
});
|
|
807
835
|
}
|
|
808
836
|
for (const stateId of declared.keys()) {
|
|
809
837
|
if (!statuses.has(stateId)) {
|
|
810
|
-
throw new TypeError(`${label}
|
|
838
|
+
throw new TypeError(`${label} roleStates must declare player state ${stateId}`);
|
|
811
839
|
}
|
|
812
840
|
}
|
|
813
841
|
return statuses;
|
|
@@ -820,34 +848,10 @@ function settlingGuard(event) {
|
|
|
820
848
|
? guard
|
|
821
849
|
: undefined;
|
|
822
850
|
}
|
|
823
|
-
function
|
|
824
|
-
|
|
825
|
-
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
|
|
826
|
-
return [];
|
|
827
|
-
if (stateId === 'awaitBossReply') {
|
|
828
|
-
const pending = pendingBossQuestionFromContext(context);
|
|
829
|
-
return [
|
|
830
|
-
{
|
|
831
|
-
message: pending === undefined
|
|
832
|
-
? 'Awaiting Boss reply.'
|
|
833
|
-
: `${pending.player} asks: ${pending.question}`,
|
|
834
|
-
},
|
|
835
|
-
];
|
|
836
|
-
}
|
|
837
|
-
if (stateId === 'failed') {
|
|
838
|
-
const lastError = normalizeErrorFull(context.lastError);
|
|
839
|
-
return [
|
|
840
|
-
{
|
|
841
|
-
message: 'Workflow failed; awaiting Boss recovery.',
|
|
842
|
-
...(lastError === undefined
|
|
843
|
-
? {}
|
|
844
|
-
: { data: snapshotJsonValue({ lastError }, 'failed status data') }),
|
|
845
|
-
},
|
|
846
|
-
];
|
|
847
|
-
}
|
|
848
|
-
return [{ message: `Entered ${stateId}.` }];
|
|
851
|
+
function askerLabel(asker) {
|
|
852
|
+
return asker.kind === 'captain' ? 'Captain' : asker.roleId;
|
|
849
853
|
}
|
|
850
|
-
function makeDefaultStatusesForState(
|
|
854
|
+
function makeDefaultStatusesForState(roleStates) {
|
|
851
855
|
return (state, context, event) => {
|
|
852
856
|
const statuses = [];
|
|
853
857
|
const guard = settlingGuard(event);
|
|
@@ -864,10 +868,10 @@ function makeDefaultStatusesForState(playerStates) {
|
|
|
864
868
|
}
|
|
865
869
|
return [
|
|
866
870
|
...statuses,
|
|
867
|
-
{ message: `${pending.
|
|
871
|
+
{ message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
|
|
868
872
|
{
|
|
869
873
|
message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
|
|
870
|
-
`${pending.
|
|
874
|
+
`${askerLabel(pending.asker)} · ${pending.sourceItem}`,
|
|
871
875
|
},
|
|
872
876
|
];
|
|
873
877
|
}
|
|
@@ -885,10 +889,10 @@ function makeDefaultStatusesForState(playerStates) {
|
|
|
885
889
|
},
|
|
886
890
|
];
|
|
887
891
|
}
|
|
888
|
-
const
|
|
889
|
-
if (
|
|
892
|
+
const roleState = roleStates.get(stateId);
|
|
893
|
+
if (roleState !== undefined) {
|
|
890
894
|
statuses.push({
|
|
891
|
-
message: `⤷ ${
|
|
895
|
+
message: `⤷ ${roleState.role}: ${roleState.label}`,
|
|
892
896
|
});
|
|
893
897
|
}
|
|
894
898
|
return statuses;
|
|
@@ -1071,7 +1075,7 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
|
|
|
1071
1075
|
`Current state: ${currentState}`,
|
|
1072
1076
|
];
|
|
1073
1077
|
if (pending !== undefined) {
|
|
1074
|
-
lines.push(`Pending question id: ${pending.questionId}`, `Pending
|
|
1078
|
+
lines.push(`Pending question id: ${pending.questionId}`, `Pending asker: ${askerLabel(pending.asker)}`, `Pending Boss question: ${pending.question}`);
|
|
1075
1079
|
}
|
|
1076
1080
|
lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
|
|
1077
1081
|
for (const contract of applicable) {
|
|
@@ -1191,6 +1195,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1191
1195
|
// DR-022 / PBRT-50: reject an incompatible artifact declaration before any
|
|
1192
1196
|
// machine interpretation, against this loaded engine's own self-report.
|
|
1193
1197
|
assertRuntimeCompat(spec.compat, label);
|
|
1198
|
+
const specDescriptors = Object.getOwnPropertyDescriptors(spec);
|
|
1199
|
+
if (Object.prototype.hasOwnProperty.call(specDescriptors, 'playerStates')) {
|
|
1200
|
+
throw new TypeError(`${label} schema-2 artifacts must supply roleStates, not playerStates`);
|
|
1201
|
+
}
|
|
1202
|
+
if (Object.prototype.hasOwnProperty.call(specDescriptors, 'resolvePlayerId')) {
|
|
1203
|
+
throw new TypeError(`${label} schema-2 artifacts must not derive concrete player bindings`);
|
|
1204
|
+
}
|
|
1194
1205
|
if (machineDeclaresParallelState(machine)) {
|
|
1195
1206
|
throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
|
|
1196
1207
|
}
|
|
@@ -1199,8 +1210,15 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1199
1210
|
// DR-029: source state descriptions label the control actions the
|
|
1200
1211
|
// runtime advertises through `describe()`.
|
|
1201
1212
|
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1213
|
+
const roleStatesDescriptor = specDescriptors.roleStates;
|
|
1214
|
+
if (roleStatesDescriptor !== undefined &&
|
|
1215
|
+
!Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')) {
|
|
1216
|
+
throw new TypeError(`${label} roleStates must be an own data property`);
|
|
1217
|
+
}
|
|
1218
|
+
const roleStates = snapshotRoleStateStatuses(roleStatesDescriptor?.value, label, machine, stateDescriptions);
|
|
1219
|
+
const declaredRoleIds = Object.freeze([
|
|
1220
|
+
...new Set([...roleStates.values()].map(({ role }) => role)),
|
|
1221
|
+
]);
|
|
1204
1222
|
// PBRT-52: the artifact's own ControlView context projection. Nothing is
|
|
1205
1223
|
// exported by default, so an FSM context member — including one added
|
|
1206
1224
|
// after this artifact was linked — is private until named here. The two
|
|
@@ -1215,7 +1233,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1215
1233
|
throw new Error(`${label} controlContextFields must not name ${field}: the control view surfaces it first-class`);
|
|
1216
1234
|
}
|
|
1217
1235
|
}
|
|
1218
|
-
const resolvePlayerIdSpec = spec.resolvePlayerId;
|
|
1219
1236
|
const composePlayerPrompt = spec.composePlayerPrompt ??
|
|
1220
1237
|
((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
|
|
1221
1238
|
const composeCaptainPrompt = spec.composeCaptainPrompt ??
|
|
@@ -1241,13 +1258,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1241
1258
|
const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
|
|
1242
1259
|
makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
|
|
1243
1260
|
const statusesForState = spec.statusesForState ??
|
|
1244
|
-
(
|
|
1245
|
-
? makeDefaultStatusesForState(playerStates)
|
|
1246
|
-
: legacyStatusesForState);
|
|
1261
|
+
makeDefaultStatusesForState(roleStates);
|
|
1247
1262
|
const classificationStatus = spec.classificationStatus ??
|
|
1248
|
-
(
|
|
1249
|
-
? (event) => event.type
|
|
1250
|
-
: () => undefined);
|
|
1263
|
+
((event) => event.type);
|
|
1251
1264
|
const machineInput = spec.machineInput ?? ((options) => options);
|
|
1252
1265
|
const scriptCwd = spec.scriptCwd ??
|
|
1253
1266
|
((options) => {
|
|
@@ -1294,8 +1307,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1294
1307
|
// acceptance records nothing, so a later call with that key may still
|
|
1295
1308
|
// execute.
|
|
1296
1309
|
const appliedReceipts = new Map();
|
|
1297
|
-
const
|
|
1298
|
-
const
|
|
1310
|
+
const privateResumeTokens = new Map();
|
|
1311
|
+
const activePlayerKeys = new Set();
|
|
1299
1312
|
const playbookCallTurnIds = new Map();
|
|
1300
1313
|
// Captain and judge work share one serialized lane (slc/link.md
|
|
1301
1314
|
// §Session lifecycle).
|
|
@@ -1306,55 +1319,143 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1306
1319
|
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
1307
1320
|
// imperative boundaries await their queued work directly.
|
|
1308
1321
|
let emissionFailure;
|
|
1309
|
-
function
|
|
1322
|
+
function bindSession(nextSession) {
|
|
1323
|
+
const bound = snapshotPlaybookSession(nextSession);
|
|
1324
|
+
if (bound.roleBindings === undefined)
|
|
1325
|
+
return bound;
|
|
1326
|
+
const actual = Object.keys(bound.roleBindings).sort();
|
|
1327
|
+
const expected = [...declaredRoleIds].sort();
|
|
1328
|
+
const missing = expected.filter((roleId) => !actual.includes(roleId));
|
|
1329
|
+
const extra = actual.filter((roleId) => !expected.includes(roleId));
|
|
1330
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1331
|
+
throw new TypeError(`${label} session roleBindings must cover exactly [${expected.join(', ')}]` +
|
|
1332
|
+
`${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
|
|
1333
|
+
`${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`);
|
|
1334
|
+
}
|
|
1335
|
+
return bound;
|
|
1336
|
+
}
|
|
1337
|
+
function requireRoleId(input) {
|
|
1338
|
+
const roleId = input.role;
|
|
1339
|
+
if (typeof roleId !== 'string' ||
|
|
1340
|
+
roleId.trim().length === 0 ||
|
|
1341
|
+
!declaredRoleIds.includes(roleId)) {
|
|
1342
|
+
throw new TypeError(`${label} player input role must name a declared local role`);
|
|
1343
|
+
}
|
|
1344
|
+
return roleId;
|
|
1345
|
+
}
|
|
1346
|
+
function resolvedPlayerId(roleId) {
|
|
1347
|
+
return session?.roleBindings?.[roleId]?.playerId;
|
|
1348
|
+
}
|
|
1349
|
+
function promptIdentity(roleId) {
|
|
1350
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
1351
|
+
throw new TypeError(`${label} prompt identity lookup rejected undeclared role ${roleId}`);
|
|
1352
|
+
}
|
|
1353
|
+
return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
|
|
1354
|
+
}
|
|
1355
|
+
function composeBoundPlayerPrompt(input) {
|
|
1356
|
+
let active = true;
|
|
1357
|
+
const lookup = (roleId) => {
|
|
1358
|
+
if (!active) {
|
|
1359
|
+
throw new Error(`${label} prompt identity lookup is no longer active`);
|
|
1360
|
+
}
|
|
1361
|
+
return promptIdentity(roleId);
|
|
1362
|
+
};
|
|
1363
|
+
try {
|
|
1364
|
+
return composePlayerPrompt(input, lookup);
|
|
1365
|
+
}
|
|
1366
|
+
finally {
|
|
1367
|
+
active = false;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
function continuationKey(roleId, playerId) {
|
|
1371
|
+
return playerId ?? roleId;
|
|
1372
|
+
}
|
|
1373
|
+
function roleTokensByContinuationKey(tokens) {
|
|
1374
|
+
const byKey = new Map();
|
|
1375
|
+
for (const [roleId, token] of Object.entries(tokens)) {
|
|
1376
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
1377
|
+
throw new TypeError(`runtime role tokens contain unknown role ${roleId}`);
|
|
1378
|
+
}
|
|
1379
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
1380
|
+
const existing = byKey.get(key);
|
|
1381
|
+
if (existing !== undefined && existing !== token) {
|
|
1382
|
+
throw new TypeError(`runtime snapshot assigns conflicting tokens to roles bound to player ${key}`);
|
|
1383
|
+
}
|
|
1384
|
+
byKey.set(key, token);
|
|
1385
|
+
}
|
|
1386
|
+
const rolesByKey = new Map();
|
|
1387
|
+
for (const roleId of declaredRoleIds) {
|
|
1388
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
1389
|
+
rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
|
|
1390
|
+
}
|
|
1391
|
+
for (const [key, roles] of rolesByKey) {
|
|
1392
|
+
if (roles.length < 2)
|
|
1393
|
+
continue;
|
|
1394
|
+
const present = roles.filter((roleId) => tokens[roleId] !== undefined);
|
|
1395
|
+
if (present.length !== 0 && present.length !== roles.length) {
|
|
1396
|
+
throw new TypeError(`runtime role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return byKey;
|
|
1400
|
+
}
|
|
1401
|
+
function selectPlayerResume(roleId, playerId) {
|
|
1402
|
+
const key = continuationKey(roleId, playerId);
|
|
1310
1403
|
const selected = session?.playerSessions
|
|
1311
|
-
? session.playerSessions.select(
|
|
1312
|
-
:
|
|
1404
|
+
? session.playerSessions.select(roleId)
|
|
1405
|
+
: privateResumeTokens.get(key) ?? false;
|
|
1313
1406
|
if (selected !== false &&
|
|
1314
1407
|
(typeof selected !== 'string' || selected.trim().length === 0)) {
|
|
1315
|
-
throw new TypeError(`player session store returned an invalid resume token for ${
|
|
1408
|
+
throw new TypeError(`player session store returned an invalid resume token for role ${roleId}`);
|
|
1316
1409
|
}
|
|
1317
1410
|
return selected;
|
|
1318
1411
|
}
|
|
1319
|
-
function updatePlayerResume(playerId,
|
|
1412
|
+
function updatePlayerResume(roleId, playerId, result) {
|
|
1413
|
+
const resumeToken = result.resumeToken;
|
|
1414
|
+
if (resumeToken === undefined && result.status !== 'ok')
|
|
1415
|
+
return;
|
|
1416
|
+
const key = continuationKey(roleId, playerId);
|
|
1320
1417
|
if (session?.playerSessions) {
|
|
1321
|
-
session.playerSessions.update(
|
|
1418
|
+
session.playerSessions.update(roleId, resumeToken);
|
|
1322
1419
|
}
|
|
1323
|
-
else if (resumeToken !== undefined
|
|
1324
|
-
|
|
1420
|
+
else if (resumeToken !== undefined) {
|
|
1421
|
+
privateResumeTokens.set(key, resumeToken);
|
|
1325
1422
|
}
|
|
1326
1423
|
else {
|
|
1327
|
-
|
|
1424
|
+
privateResumeTokens.delete(key);
|
|
1328
1425
|
}
|
|
1329
1426
|
}
|
|
1330
|
-
function
|
|
1331
|
-
const raw = session?.playerSessions
|
|
1427
|
+
function snapshotRoleResumeTokens() {
|
|
1428
|
+
const raw = snapshotJsonValue(session?.playerSessions
|
|
1332
1429
|
? session.playerSessions.snapshot()
|
|
1333
|
-
: Object.fromEntries(
|
|
1430
|
+
: Object.fromEntries(declaredRoleIds.flatMap((roleId) => {
|
|
1431
|
+
const token = privateResumeTokens.get(continuationKey(roleId, resolvedPlayerId(roleId)));
|
|
1432
|
+
return token === undefined ? [] : [[roleId, token]];
|
|
1433
|
+
})), 'player session store snapshot');
|
|
1334
1434
|
if (!isPlainObject(raw)) {
|
|
1335
1435
|
throw new TypeError('player session store snapshot must be an object');
|
|
1336
1436
|
}
|
|
1337
1437
|
const detached = {};
|
|
1338
|
-
for (const [
|
|
1339
|
-
if (
|
|
1340
|
-
throw new TypeError(
|
|
1438
|
+
for (const [roleId, token] of Object.entries(raw)) {
|
|
1439
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
1440
|
+
throw new TypeError(`player session store snapshot contains unknown role ${roleId}`);
|
|
1341
1441
|
}
|
|
1342
1442
|
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
1343
|
-
throw new TypeError(`player session store snapshot token for ${
|
|
1443
|
+
throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
|
|
1344
1444
|
}
|
|
1345
|
-
detached[
|
|
1445
|
+
detached[roleId] = token;
|
|
1346
1446
|
}
|
|
1447
|
+
roleTokensByContinuationKey(detached);
|
|
1347
1448
|
return detached;
|
|
1348
1449
|
}
|
|
1349
|
-
function
|
|
1450
|
+
function restoreRoleResumeTokens(tokens) {
|
|
1451
|
+
const byKey = roleTokensByContinuationKey(tokens);
|
|
1350
1452
|
if (session?.playerSessions) {
|
|
1351
1453
|
session.playerSessions.restore(tokens);
|
|
1352
1454
|
return;
|
|
1353
1455
|
}
|
|
1354
|
-
|
|
1355
|
-
for (const [
|
|
1356
|
-
|
|
1357
|
-
}
|
|
1456
|
+
privateResumeTokens.clear();
|
|
1457
|
+
for (const [key, token] of byKey)
|
|
1458
|
+
privateResumeTokens.set(key, token);
|
|
1358
1459
|
}
|
|
1359
1460
|
function enqueueEmission(fn) {
|
|
1360
1461
|
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
@@ -1399,7 +1500,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1399
1500
|
const currentSession = requireSession();
|
|
1400
1501
|
const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
1401
1502
|
return {
|
|
1402
|
-
schemaVersion:
|
|
1503
|
+
schemaVersion: 3,
|
|
1403
1504
|
sessionId: currentSession.sessionId,
|
|
1404
1505
|
playbookId: currentSession.playbookId,
|
|
1405
1506
|
rootSessionId: currentSession.rootSessionId,
|
|
@@ -1508,15 +1609,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1508
1609
|
}
|
|
1509
1610
|
}
|
|
1510
1611
|
const boundary = {
|
|
1511
|
-
async callPlayer(input,
|
|
1612
|
+
async callPlayer(input, roleId, prompt, signal) {
|
|
1512
1613
|
// State-entry telemetry/status must precede the call they describe.
|
|
1513
1614
|
await drainEmissions();
|
|
1514
1615
|
const turnId = activeTurnId;
|
|
1515
1616
|
const stateId = input.stateId;
|
|
1617
|
+
const playerId = resolvedPlayerId(roleId);
|
|
1516
1618
|
let resume;
|
|
1517
1619
|
try {
|
|
1518
1620
|
signal.throwIfAborted();
|
|
1519
|
-
resume = selectPlayerResume(playerId);
|
|
1621
|
+
resume = selectPlayerResume(roleId, playerId);
|
|
1520
1622
|
}
|
|
1521
1623
|
catch (error) {
|
|
1522
1624
|
if (!signal.aborted)
|
|
@@ -1525,23 +1627,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1525
1627
|
}
|
|
1526
1628
|
const callId = `player-${++playerCallSequence}`;
|
|
1527
1629
|
const identity = {
|
|
1528
|
-
purpose: 'captain',
|
|
1529
1630
|
...stateIdentity(stateId),
|
|
1530
1631
|
sourceItem: input.sourceItem,
|
|
1531
|
-
|
|
1632
|
+
roleId,
|
|
1633
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1532
1634
|
resume,
|
|
1533
1635
|
};
|
|
1534
1636
|
const position = {
|
|
1535
1637
|
...(turnId !== undefined ? { turnId } : {}),
|
|
1536
1638
|
callId,
|
|
1537
1639
|
};
|
|
1538
|
-
|
|
1539
|
-
|
|
1640
|
+
const playerKey = continuationKey(roleId, playerId);
|
|
1641
|
+
if (activePlayerKeys.has(playerKey)) {
|
|
1642
|
+
const error = new Error(`simultaneous calls to player key ${playerKey} are not allowed`);
|
|
1540
1643
|
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position);
|
|
1541
1644
|
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1542
1645
|
throw error;
|
|
1543
1646
|
}
|
|
1544
|
-
|
|
1647
|
+
activePlayerKeys.add(playerKey);
|
|
1545
1648
|
try {
|
|
1546
1649
|
await emitTrace('player.call.started', { ...identity, prompt }, position);
|
|
1547
1650
|
let rawResult;
|
|
@@ -1551,7 +1654,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1551
1654
|
// never start after abort, so settle the already-started pair
|
|
1552
1655
|
// as `aborted` through the catch below.
|
|
1553
1656
|
signal.throwIfAborted();
|
|
1554
|
-
rawResult = await requireHostPorts().callPlayer(
|
|
1657
|
+
rawResult = await requireHostPorts().callPlayer(roleId, prompt, signal, { resume });
|
|
1555
1658
|
// A host promise is not required to honor cancellation. Do not let
|
|
1556
1659
|
// a late result mutate continuity or publish a successful finish.
|
|
1557
1660
|
signal.throwIfAborted();
|
|
@@ -1589,10 +1692,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1589
1692
|
throw error;
|
|
1590
1693
|
}
|
|
1591
1694
|
try {
|
|
1592
|
-
updatePlayerResume(playerId,
|
|
1593
|
-
result.resumeToken.trim().length > 0
|
|
1594
|
-
? result.resumeToken
|
|
1595
|
-
: undefined);
|
|
1695
|
+
updatePlayerResume(roleId, playerId, result);
|
|
1596
1696
|
}
|
|
1597
1697
|
catch (error) {
|
|
1598
1698
|
if (!signal.aborted)
|
|
@@ -1621,7 +1721,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1621
1721
|
return result;
|
|
1622
1722
|
}
|
|
1623
1723
|
finally {
|
|
1624
|
-
|
|
1724
|
+
activePlayerKeys.delete(playerKey);
|
|
1625
1725
|
}
|
|
1626
1726
|
},
|
|
1627
1727
|
async callJudge(purpose, stateId, prompt, signal) {
|
|
@@ -1786,15 +1886,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1786
1886
|
});
|
|
1787
1887
|
},
|
|
1788
1888
|
};
|
|
1789
|
-
function resolvePlayerId(input) {
|
|
1790
|
-
return resolvePlayerIdSpec
|
|
1791
|
-
? resolvePlayerIdSpec(input, boundOptions)
|
|
1792
|
-
: defaultResolvePlayerId(input);
|
|
1793
|
-
}
|
|
1794
1889
|
function playerActor(ports) {
|
|
1795
1890
|
return createPlayerBridge({
|
|
1796
|
-
|
|
1797
|
-
composePlayerPrompt,
|
|
1891
|
+
resolveRoleId: requireRoleId,
|
|
1892
|
+
composePlayerPrompt: composeBoundPlayerPrompt,
|
|
1798
1893
|
adjudication,
|
|
1799
1894
|
resumableStateIds,
|
|
1800
1895
|
}, ports, () => activeSignal, boundary, (error) => {
|
|
@@ -2210,8 +2305,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2210
2305
|
// The session-start error remains authoritative.
|
|
2211
2306
|
}
|
|
2212
2307
|
}
|
|
2213
|
-
|
|
2214
|
-
|
|
2308
|
+
privateResumeTokens.clear();
|
|
2309
|
+
activePlayerKeys.clear();
|
|
2215
2310
|
playbookCallTurnIds.clear();
|
|
2216
2311
|
activeEmissionCalls.clear();
|
|
2217
2312
|
emissionQueue.clear();
|
|
@@ -2394,7 +2489,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2394
2489
|
if (initialized || disposed || disposalPromise !== undefined) {
|
|
2395
2490
|
throw new Error('createPlaybookRuntime.init: already initialized');
|
|
2396
2491
|
}
|
|
2397
|
-
const boundSession =
|
|
2492
|
+
const boundSession = bindSession(nextSession);
|
|
2398
2493
|
initialized = true;
|
|
2399
2494
|
let finishInitialization;
|
|
2400
2495
|
const initialization = new Promise((resolve) => {
|
|
@@ -2468,10 +2563,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2468
2563
|
.context;
|
|
2469
2564
|
const pending = pendingBossQuestionFromContext(context ?? {});
|
|
2470
2565
|
return {
|
|
2471
|
-
schemaVersion:
|
|
2566
|
+
schemaVersion: 3,
|
|
2472
2567
|
playbookId: session.playbookId,
|
|
2473
2568
|
machine: machineSnapshot,
|
|
2474
|
-
|
|
2569
|
+
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
2475
2570
|
sequences: {
|
|
2476
2571
|
trace: traceSequence,
|
|
2477
2572
|
turn: turnSequence,
|
|
@@ -2488,7 +2583,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2488
2583
|
: [
|
|
2489
2584
|
{
|
|
2490
2585
|
questionId: pending.questionId,
|
|
2491
|
-
|
|
2586
|
+
asker: pending.asker,
|
|
2492
2587
|
question: pending.question,
|
|
2493
2588
|
sourceItem: pending.sourceItem,
|
|
2494
2589
|
},
|
|
@@ -2505,11 +2600,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2505
2600
|
if (initialized || disposed || disposalPromise !== undefined) {
|
|
2506
2601
|
throw new Error('createPlaybookRuntime.restore: already initialized');
|
|
2507
2602
|
}
|
|
2508
|
-
const boundSession =
|
|
2603
|
+
const boundSession = bindSession(nextSession);
|
|
2509
2604
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId, { allowSuspendedCall: true });
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2605
|
+
if (declaredActors.has('captain') &&
|
|
2606
|
+
boundSnapshot.sequences.captainCall === undefined) {
|
|
2607
|
+
throw new TypeError('runtime snapshot sequences.captainCall is required for a direct-Captain artifact');
|
|
2608
|
+
}
|
|
2609
|
+
const suspendedCall = boundSnapshot.suspendedCall;
|
|
2513
2610
|
let priorExternalPlayerTokens;
|
|
2514
2611
|
let externalStoreRestoreAttempted = false;
|
|
2515
2612
|
initialized = true;
|
|
@@ -2527,22 +2624,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2527
2624
|
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
2528
2625
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
2529
2626
|
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
2530
|
-
captainCallSequence =
|
|
2531
|
-
boundSnapshot.sequences.captainCall ??
|
|
2532
|
-
// Legacy schema-v1 snapshots predate this dedicated counter.
|
|
2533
|
-
// Every Captain call already consumed at least one trace number,
|
|
2534
|
-
// so the global trace counter is a collision-safe id floor.
|
|
2535
|
-
boundSnapshot.sequences.trace;
|
|
2627
|
+
captainCallSequence = boundSnapshot.sequences.captainCall ?? 0;
|
|
2536
2628
|
// The runtime snapshot carries no apply counter (PBRT-50); every
|
|
2537
2629
|
// apply boundary consumed trace numbers, so the persisted trace
|
|
2538
2630
|
// counter is a collision-safe id floor here too, keeping
|
|
2539
2631
|
// `apply-<n>` call ids unique across restore.
|
|
2540
2632
|
applyCallSequence = boundSnapshot.sequences.trace;
|
|
2541
2633
|
if (boundSession.playerSessions) {
|
|
2542
|
-
priorExternalPlayerTokens =
|
|
2634
|
+
priorExternalPlayerTokens = snapshotRoleResumeTokens();
|
|
2543
2635
|
externalStoreRestoreAttempted = true;
|
|
2544
2636
|
}
|
|
2545
|
-
|
|
2637
|
+
restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
|
|
2546
2638
|
nestedBridge.prepareRestore(suspendedCall);
|
|
2547
2639
|
if (suspendedCall !== undefined) {
|
|
2548
2640
|
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
@@ -2632,7 +2724,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2632
2724
|
: [
|
|
2633
2725
|
{
|
|
2634
2726
|
questionId: pending.questionId,
|
|
2635
|
-
|
|
2727
|
+
asker: pending.asker,
|
|
2636
2728
|
question: pending.question,
|
|
2637
2729
|
sourceItem: pending.sourceItem,
|
|
2638
2730
|
},
|
|
@@ -3126,9 +3218,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
3126
3218
|
// engagement tree. Child disposal must not erase a token its
|
|
3127
3219
|
// caller will resume. The private fallback remains runtime-owned.
|
|
3128
3220
|
if (session?.playerSessions === undefined) {
|
|
3129
|
-
|
|
3221
|
+
privateResumeTokens.clear();
|
|
3130
3222
|
}
|
|
3131
|
-
|
|
3223
|
+
activePlayerKeys.clear();
|
|
3132
3224
|
playbookCallTurnIds.clear();
|
|
3133
3225
|
activeEmissionCalls.clear();
|
|
3134
3226
|
emissionQueue.clear();
|