@sublang/playbook 12.2.1 → 12.3.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/package.json +1 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +11 -7
- package/reference/sdlc/code.playbook/bin/repository-effects.js +33 -23
- package/reference/sdlc/code.playbook/bin/session-store.js +16 -5
- package/reference/sdlc/code.playbook/code.fsm.js +43 -5
- package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +22 -5
- package/reference/sdlc/code.playbook/playbook-captain.ts +25 -5
- package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
- package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
- package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
- package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
- package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
- package/reference/sdlc/review.playbook/review.fsm.js +11 -1
- package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
- package/slc/gears2fsm.md +21 -3
- package/slc/link.md +21 -2
- package/src/runtime.d.ts +7 -0
- package/src/runtime.ts +12 -0
- package/src/xstate-playbook-runtime.d.ts +13 -0
- package/src/xstate-playbook-runtime.js +63 -0
- package/src/xstate-playbook-runtime.ts +73 -0
- package/src/xstate-runtime.js +35 -4
- package/src/xstate-runtime.ts +49 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, and DEV workflows driven by GEARS specs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,7 +28,10 @@ import {
|
|
|
28
28
|
stringify as stringifyYaml,
|
|
29
29
|
} from "yaml";
|
|
30
30
|
import { loadTmuxPlayConfig } from "@sublang/cligent/tmux-play";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
defaultCaptainSessionsDir,
|
|
33
|
+
isCanonicalLocalRoleId,
|
|
34
|
+
} from "./session-store.js";
|
|
32
35
|
|
|
33
36
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
34
37
|
const DEFAULT_TEMPLATE_PATH = resolve(
|
|
@@ -53,7 +56,6 @@ const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
|
|
|
53
56
|
const RESERVED_CAPTAIN_PLAYBOOK_ID = "captain";
|
|
54
57
|
const RESERVED_CAPTAIN_ROLE_ID = "captain";
|
|
55
58
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
56
|
-
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
57
59
|
|
|
58
60
|
// PBCLI-26: split ordered `--with <path>` pairs out of an argument vector.
|
|
59
61
|
// The returned arrays are new values; the caller's vector is never changed.
|
|
@@ -2060,8 +2062,11 @@ function assertPlayerId(value, path) {
|
|
|
2060
2062
|
}
|
|
2061
2063
|
|
|
2062
2064
|
function assertRoleId(value, path) {
|
|
2063
|
-
if (
|
|
2064
|
-
throw new Error(
|
|
2065
|
+
if (!isCanonicalLocalRoleId(value)) {
|
|
2066
|
+
throw new Error(
|
|
2067
|
+
`${path} must be a canonical lowercase local role id ` +
|
|
2068
|
+
"(the role name lowercased, no whitespace)",
|
|
2069
|
+
);
|
|
2065
2070
|
}
|
|
2066
2071
|
if (value === RESERVED_CAPTAIN_ROLE_ID) {
|
|
2067
2072
|
throw new Error(`${path} uses reserved local role id "captain"`);
|
|
@@ -2077,7 +2082,7 @@ function invalidManifestRoles(value) {
|
|
|
2077
2082
|
if (new Set(canonical).size !== canonical.length) {
|
|
2078
2083
|
return "contains roles that collide after canonical lowercase derivation";
|
|
2079
2084
|
}
|
|
2080
|
-
const invalid = value.find((role) => !
|
|
2085
|
+
const invalid = value.find((role) => !isCanonicalLocalRoleId(role));
|
|
2081
2086
|
if (invalid !== undefined) {
|
|
2082
2087
|
return `contains noncanonical role ${JSON.stringify(invalid)}`;
|
|
2083
2088
|
}
|
|
@@ -2099,8 +2104,7 @@ function invalidConcurrentRoleSets(value, requiredRoleIds) {
|
|
|
2099
2104
|
if (
|
|
2100
2105
|
set.some(
|
|
2101
2106
|
(role) =>
|
|
2102
|
-
|
|
2103
|
-
!ROLE_ID_PATTERN.test(role) ||
|
|
2107
|
+
!isCanonicalLocalRoleId(role) ||
|
|
2104
2108
|
role === RESERVED_CAPTAIN_ROLE_ID ||
|
|
2105
2109
|
!required.has(role),
|
|
2106
2110
|
)
|
|
@@ -1952,9 +1952,9 @@ async function releaseRepositoryClaim(claim, primaryError) {
|
|
|
1952
1952
|
}
|
|
1953
1953
|
}
|
|
1954
1954
|
|
|
1955
|
-
// A host binds the governed worktree identity for each durable call —
|
|
1956
|
-
//
|
|
1957
|
-
//
|
|
1955
|
+
// A host binds the governed worktree identity for each durable call —
|
|
1956
|
+
// resolved afresh from `cwd` by both the Captain host and the worktree host
|
|
1957
|
+
// capability — and derives the schema-3 authority naming it.
|
|
1958
1958
|
function bindHostWorktree(host) {
|
|
1959
1959
|
return host.resolveIdentity().then((identity) => ({
|
|
1960
1960
|
identity,
|
|
@@ -2978,33 +2978,43 @@ export async function createRepositoryEffectCapabilities({
|
|
|
2978
2978
|
await sessionLease.assertOwner();
|
|
2979
2979
|
const createdWriteAhead = await createWriteAhead(sessionLease);
|
|
2980
2980
|
const ledgerService = assertEffectLedgerService(createdWriteAhead);
|
|
2981
|
-
|
|
2981
|
+
// The governed worktree is bound afresh from `cwd` at every governed call
|
|
2982
|
+
// and observation, so a working directory that is not a repository yet is
|
|
2983
|
+
// governed from launch as its own prospective root and the `git init` a
|
|
2984
|
+
// workflow's own setup step runs there keeps that one identity.
|
|
2985
|
+
const resolveIdentity = async () => (await resolveGovernedWorktree(cwd)).identity;
|
|
2986
|
+
const identity = await resolveIdentity();
|
|
2982
2987
|
await sessionLease.assertOwner();
|
|
2983
2988
|
const coordinator = createRepositoryEffectCoordinator();
|
|
2984
2989
|
|
|
2985
2990
|
const capabilities = schema3Entries.map(
|
|
2986
2991
|
({ playbookId, requiredRoleIds, concurrentRoleSets }) => {
|
|
2987
|
-
const
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2992
|
+
const authorityNaming = (canonicalWorktree) =>
|
|
2993
|
+
deepFreeze({
|
|
2994
|
+
playbookId,
|
|
2995
|
+
artifactSchema: 3,
|
|
2996
|
+
cwd,
|
|
2997
|
+
sessionId,
|
|
2998
|
+
leaseOwnerToken: sessionLease.ownerToken,
|
|
2999
|
+
canonicalWorktree,
|
|
3000
|
+
requiredRoleIds,
|
|
3001
|
+
concurrentRoleSets,
|
|
3002
|
+
});
|
|
3003
|
+
const authority = authorityNaming(identity);
|
|
2997
3004
|
const host = Object.freeze({
|
|
2998
|
-
resolveIdentity
|
|
2999
|
-
authorityFor: () =>
|
|
3005
|
+
resolveIdentity,
|
|
3006
|
+
authorityFor: (canonicalWorktree) =>
|
|
3007
|
+
isDeepStrictEqual(canonicalWorktree, identity)
|
|
3008
|
+
? authority
|
|
3009
|
+
: authorityNaming(canonicalWorktree),
|
|
3000
3010
|
});
|
|
3001
3011
|
const observe = async (options = {}) => {
|
|
3002
3012
|
rejectBoundRepositoryOverride(options, 'observe', ['cwd']);
|
|
3003
|
-
return
|
|
3013
|
+
return observeGovernedWorktree(await resolveGovernedWorktree(cwd), options);
|
|
3004
3014
|
};
|
|
3005
3015
|
const acquire = async (options = {}) => {
|
|
3006
3016
|
rejectBoundRepositoryOverride(options, 'acquire', ['cwd']);
|
|
3007
|
-
return coordinator.acquire(
|
|
3017
|
+
return coordinator.acquire(cwd, options);
|
|
3008
3018
|
};
|
|
3009
3019
|
const runExclusive = async (options) => {
|
|
3010
3020
|
return runDurableExclusive({
|
|
@@ -3139,11 +3149,11 @@ async function assertExistingDirectory(cwd, label) {
|
|
|
3139
3149
|
// DR-046: the lease-free worktree capability an embedding host constructs
|
|
3140
3150
|
// through `@sublang/playbook/host-capabilities`. It runs the same claim,
|
|
3141
3151
|
// observation, receipt, completion, and deferred-operation path as the
|
|
3142
|
-
// Captain-hosted capability above, over one host-owned in-memory ledger
|
|
3143
|
-
//
|
|
3144
|
-
//
|
|
3145
|
-
//
|
|
3146
|
-
//
|
|
3152
|
+
// Captain-hosted capability above, over one host-owned in-memory ledger, and
|
|
3153
|
+
// binds its worktree the same lazy way: `cwd` need not be a repository yet,
|
|
3154
|
+
// and every governed call and observation re-resolves the governed worktree
|
|
3155
|
+
// of `cwd`, so a `git init` there — inside a governed call or between calls —
|
|
3156
|
+
// is observed rather than hidden behind a stale identity.
|
|
3147
3157
|
export async function createWorktreeHostCapabilities(options = {}) {
|
|
3148
3158
|
if (!isPlainObject(options)) {
|
|
3149
3159
|
throw new TypeError('worktree host capability options must be an object');
|
|
@@ -57,8 +57,22 @@ export const CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION = 1;
|
|
|
57
57
|
export const CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION = 2;
|
|
58
58
|
|
|
59
59
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
60
|
-
const
|
|
60
|
+
const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
|
|
61
61
|
const RESERVED_ID = 'captain';
|
|
62
|
+
|
|
63
|
+
// PBCLI-4: a local role id is its source role name lowercased by Unicode case
|
|
64
|
+
// mapping — nonempty, free of whitespace and control characters, and equal to
|
|
65
|
+
// its own lowercase form. The rule restricts neither script nor alphabet, so
|
|
66
|
+
// `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
|
|
67
|
+
// enforce the reserved `captain` name separately.
|
|
68
|
+
export function isCanonicalLocalRoleId(value) {
|
|
69
|
+
return (
|
|
70
|
+
typeof value === 'string' &&
|
|
71
|
+
value.length > 0 &&
|
|
72
|
+
value === value.toLowerCase() &&
|
|
73
|
+
!ROLE_ID_WHITESPACE_OR_CONTROL.test(value)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
62
76
|
const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
|
|
63
77
|
const KNOWN_ADAPTERS = new Set(KNOWN_PLAYER_ADAPTERS);
|
|
64
78
|
|
|
@@ -5080,10 +5094,7 @@ function validateRoleIds(value, path) {
|
|
|
5080
5094
|
if (
|
|
5081
5095
|
!Array.isArray(value) ||
|
|
5082
5096
|
value.some(
|
|
5083
|
-
(roleId) =>
|
|
5084
|
-
typeof roleId !== 'string' ||
|
|
5085
|
-
!ROLE_ID_PATTERN.test(roleId) ||
|
|
5086
|
-
roleId === RESERVED_ID,
|
|
5097
|
+
(roleId) => !isCanonicalLocalRoleId(roleId) || roleId === RESERVED_ID,
|
|
5087
5098
|
) ||
|
|
5088
5099
|
new Set(value).size !== value.length
|
|
5089
5100
|
) {
|
|
@@ -77,6 +77,18 @@ function playbookMeta(stateId, role) {
|
|
|
77
77
|
},
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
// DR-048: a final state additionally declares whether its outcome means the
|
|
81
|
+
// workflow succeeded or failed, so a caller learns that from the machine
|
|
82
|
+
// rather than from CODE's output fields.
|
|
83
|
+
function terminalMeta(stateId, terminal) {
|
|
84
|
+
return {
|
|
85
|
+
playbook: {
|
|
86
|
+
stateId,
|
|
87
|
+
description: STATE_DESCRIPTIONS[stateId],
|
|
88
|
+
terminal,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
80
92
|
function isRecord(value) {
|
|
81
93
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
82
94
|
return false;
|
|
@@ -206,6 +218,16 @@ function nestedResultFromError(error) {
|
|
|
206
218
|
const result = error.result;
|
|
207
219
|
return isRecord(result) ? result : undefined;
|
|
208
220
|
}
|
|
221
|
+
function isFailureTerminalRecord(value) {
|
|
222
|
+
if (!isRecord(value))
|
|
223
|
+
return false;
|
|
224
|
+
const allowed = new Set(['stateId', 'kind', 'description']);
|
|
225
|
+
return (Reflect.ownKeys(value).every((key) => typeof key === 'string' && allowed.has(key)) &&
|
|
226
|
+
isNonEmptyString(value.stateId) &&
|
|
227
|
+
value.kind === 'failure' &&
|
|
228
|
+
(!Object.prototype.hasOwnProperty.call(value, 'description') ||
|
|
229
|
+
typeof value.description === 'string'));
|
|
230
|
+
}
|
|
209
231
|
function normalizedReviewFailure(error) {
|
|
210
232
|
const result = nestedResultFromError(error);
|
|
211
233
|
if (result === undefined)
|
|
@@ -215,10 +237,12 @@ function normalizedReviewFailure(error) {
|
|
|
215
237
|
'playbookId',
|
|
216
238
|
'childSessionId',
|
|
217
239
|
'state',
|
|
218
|
-
'error',
|
|
240
|
+
...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
|
|
219
241
|
]);
|
|
220
242
|
if (Reflect.ownKeys(result).some((key) => typeof key !== 'string' || !allowed.has(key)) ||
|
|
221
|
-
(result.status !== 'aborted' &&
|
|
243
|
+
(result.status !== 'aborted' &&
|
|
244
|
+
result.status !== 'error' &&
|
|
245
|
+
result.status !== 'ok') ||
|
|
222
246
|
result.playbookId !== 'review') {
|
|
223
247
|
return undefined;
|
|
224
248
|
}
|
|
@@ -230,6 +254,19 @@ function normalizedReviewFailure(error) {
|
|
|
230
254
|
!isPlaybookState(result.state)) {
|
|
231
255
|
return undefined;
|
|
232
256
|
}
|
|
257
|
+
if (result.status === 'ok') {
|
|
258
|
+
if (!Object.prototype.hasOwnProperty.call(result, 'terminal') ||
|
|
259
|
+
!isFailureTerminalRecord(result.terminal) ||
|
|
260
|
+
!isNonEmptyString(result.childSessionId)) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
status: 'ok',
|
|
265
|
+
...(result.output === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { output: result.output }),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
233
270
|
let normalizedError;
|
|
234
271
|
if (Object.prototype.hasOwnProperty.call(result, 'error')) {
|
|
235
272
|
if (!isRecord(result.error))
|
|
@@ -274,8 +311,9 @@ function compactError(value) {
|
|
|
274
311
|
function authoredReviewError(event) {
|
|
275
312
|
const outer = isRecord(event) ? event.error : undefined;
|
|
276
313
|
const failure = normalizedReviewFailure(outer);
|
|
277
|
-
if (failure
|
|
314
|
+
if (failure !== undefined && failure.status !== 'ok' && failure.error) {
|
|
278
315
|
return failure.error;
|
|
316
|
+
}
|
|
279
317
|
if (failure?.status === 'aborted') {
|
|
280
318
|
return { name: 'AbortError', message: 'REVIEW was aborted.' };
|
|
281
319
|
}
|
|
@@ -787,13 +825,13 @@ export const codingMachine = machineSetup.createMachine({
|
|
|
787
825
|
reportedReviewFailure: {
|
|
788
826
|
id: 'reportedReviewFailure',
|
|
789
827
|
description: STATE_DESCRIPTIONS.reportedReviewFailure,
|
|
790
|
-
meta:
|
|
828
|
+
meta: terminalMeta('reportedReviewFailure', 'failure'),
|
|
791
829
|
type: 'final',
|
|
792
830
|
},
|
|
793
831
|
done: {
|
|
794
832
|
id: 'done',
|
|
795
833
|
description: STATE_DESCRIPTIONS.done,
|
|
796
|
-
meta:
|
|
834
|
+
meta: terminalMeta('done', 'success'),
|
|
797
835
|
type: 'final',
|
|
798
836
|
},
|
|
799
837
|
},
|
|
@@ -241,6 +241,22 @@ function playbookMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// DR-048: a final state additionally declares whether its outcome means the
|
|
245
|
+
// workflow succeeded or failed, so a caller learns that from the machine
|
|
246
|
+
// rather than from CODE's output fields.
|
|
247
|
+
function terminalMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
|
|
248
|
+
stateId: StateId,
|
|
249
|
+
terminal: 'success' | 'failure',
|
|
250
|
+
) {
|
|
251
|
+
return {
|
|
252
|
+
playbook: {
|
|
253
|
+
stateId,
|
|
254
|
+
description: STATE_DESCRIPTIONS[stateId],
|
|
255
|
+
terminal,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
244
260
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
245
261
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
246
262
|
return false;
|
|
@@ -419,10 +435,32 @@ function nestedResultFromError(error: unknown): Record<string, unknown> | undefi
|
|
|
419
435
|
return isRecord(result) ? result : undefined;
|
|
420
436
|
}
|
|
421
437
|
|
|
422
|
-
type AuthoredReviewFailure =
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
438
|
+
type AuthoredReviewFailure =
|
|
439
|
+
| {
|
|
440
|
+
readonly status: 'aborted' | 'error';
|
|
441
|
+
readonly error?: CompactError;
|
|
442
|
+
}
|
|
443
|
+
// DR-048: a child that completed at an authored failure terminal. The
|
|
444
|
+
// bridge rejects it through the same error path, so CODE recognizes the
|
|
445
|
+
// failure from REVIEW's own machine and never from its output fields.
|
|
446
|
+
| {
|
|
447
|
+
readonly status: 'ok';
|
|
448
|
+
readonly output?: JsonValue;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
function isFailureTerminalRecord(value: unknown): boolean {
|
|
452
|
+
if (!isRecord(value)) return false;
|
|
453
|
+
const allowed = new Set(['stateId', 'kind', 'description']);
|
|
454
|
+
return (
|
|
455
|
+
Reflect.ownKeys(value).every(
|
|
456
|
+
(key) => typeof key === 'string' && allowed.has(key),
|
|
457
|
+
) &&
|
|
458
|
+
isNonEmptyString(value.stateId) &&
|
|
459
|
+
value.kind === 'failure' &&
|
|
460
|
+
(!Object.prototype.hasOwnProperty.call(value, 'description') ||
|
|
461
|
+
typeof value.description === 'string')
|
|
462
|
+
);
|
|
463
|
+
}
|
|
426
464
|
|
|
427
465
|
function normalizedReviewFailure(
|
|
428
466
|
error: unknown,
|
|
@@ -434,13 +472,15 @@ function normalizedReviewFailure(
|
|
|
434
472
|
'playbookId',
|
|
435
473
|
'childSessionId',
|
|
436
474
|
'state',
|
|
437
|
-
'error',
|
|
475
|
+
...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
|
|
438
476
|
]);
|
|
439
477
|
if (
|
|
440
478
|
Reflect.ownKeys(result).some(
|
|
441
479
|
(key) => typeof key !== 'string' || !allowed.has(key),
|
|
442
480
|
) ||
|
|
443
|
-
(result.status !== 'aborted' &&
|
|
481
|
+
(result.status !== 'aborted' &&
|
|
482
|
+
result.status !== 'error' &&
|
|
483
|
+
result.status !== 'ok') ||
|
|
444
484
|
result.playbookId !== 'review'
|
|
445
485
|
) {
|
|
446
486
|
return undefined;
|
|
@@ -457,6 +497,21 @@ function normalizedReviewFailure(
|
|
|
457
497
|
) {
|
|
458
498
|
return undefined;
|
|
459
499
|
}
|
|
500
|
+
if (result.status === 'ok') {
|
|
501
|
+
if (
|
|
502
|
+
!Object.prototype.hasOwnProperty.call(result, 'terminal') ||
|
|
503
|
+
!isFailureTerminalRecord(result.terminal) ||
|
|
504
|
+
!isNonEmptyString(result.childSessionId)
|
|
505
|
+
) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
status: 'ok',
|
|
510
|
+
...(result.output === undefined
|
|
511
|
+
? {}
|
|
512
|
+
: { output: result.output as JsonValue }),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
460
515
|
let normalizedError: CompactError | undefined;
|
|
461
516
|
if (Object.prototype.hasOwnProperty.call(result, 'error')) {
|
|
462
517
|
if (!isRecord(result.error)) return undefined;
|
|
@@ -507,7 +562,9 @@ function compactError(value: unknown): CompactError {
|
|
|
507
562
|
function authoredReviewError(event: unknown): CompactError {
|
|
508
563
|
const outer = isRecord(event) ? event.error : undefined;
|
|
509
564
|
const failure = normalizedReviewFailure(outer);
|
|
510
|
-
if (failure
|
|
565
|
+
if (failure !== undefined && failure.status !== 'ok' && failure.error) {
|
|
566
|
+
return failure.error;
|
|
567
|
+
}
|
|
511
568
|
if (failure?.status === 'aborted') {
|
|
512
569
|
return { name: 'AbortError', message: 'REVIEW was aborted.' };
|
|
513
570
|
}
|
|
@@ -1052,13 +1109,13 @@ export const codingMachine = machineSetup.createMachine({
|
|
|
1052
1109
|
reportedReviewFailure: {
|
|
1053
1110
|
id: 'reportedReviewFailure',
|
|
1054
1111
|
description: STATE_DESCRIPTIONS.reportedReviewFailure,
|
|
1055
|
-
meta:
|
|
1112
|
+
meta: terminalMeta('reportedReviewFailure', 'failure'),
|
|
1056
1113
|
type: 'final',
|
|
1057
1114
|
},
|
|
1058
1115
|
done: {
|
|
1059
1116
|
id: 'done',
|
|
1060
1117
|
description: STATE_DESCRIPTIONS.done,
|
|
1061
|
-
meta:
|
|
1118
|
+
meta: terminalMeta('done', 'success'),
|
|
1062
1119
|
type: 'final',
|
|
1063
1120
|
},
|
|
1064
1121
|
},
|
|
@@ -60,8 +60,21 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
|
60
60
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
61
61
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
62
62
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
63
|
-
const
|
|
63
|
+
const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
|
|
64
64
|
const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
|
|
65
|
+
// PBCLI-4: a local role id is its source role name lowercased by Unicode case
|
|
66
|
+
// mapping — nonempty, free of whitespace and control characters, and equal to
|
|
67
|
+
// its own lowercase form. The rule restricts neither script nor alphabet, so
|
|
68
|
+
// `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
|
|
69
|
+
// enforce the reserved `captain` name separately. The CLI host owns the same
|
|
70
|
+
// predicate in `bin/session-store.js`; that private module already imports
|
|
71
|
+
// this one, so the text is repeated here rather than cycled back.
|
|
72
|
+
function isCanonicalLocalRoleId(value) {
|
|
73
|
+
return (typeof value === 'string' &&
|
|
74
|
+
value.length > 0 &&
|
|
75
|
+
value === value.toLowerCase() &&
|
|
76
|
+
!ROLE_ID_WHITESPACE_OR_CONTROL.test(value));
|
|
77
|
+
}
|
|
65
78
|
const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID = 'reconcile:unresolved-effect';
|
|
66
79
|
const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
|
|
67
80
|
// The fixed machine-syntax guard of a player's Boss-question suspension
|
|
@@ -685,9 +698,7 @@ function isValidRegistryEntry(value, artifactSchema) {
|
|
|
685
698
|
return false;
|
|
686
699
|
const e = value;
|
|
687
700
|
if (!Array.isArray(e.requiredRoleIds) ||
|
|
688
|
-
e.requiredRoleIds.some((role) =>
|
|
689
|
-
!ROLE_ID_PATTERN.test(role) ||
|
|
690
|
-
role === INTERNAL_CAPTAIN_ID) ||
|
|
701
|
+
e.requiredRoleIds.some((role) => !isCanonicalLocalRoleId(role) || role === INTERNAL_CAPTAIN_ID) ||
|
|
691
702
|
new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
|
|
692
703
|
!Array.isArray(e.concurrentRoleSets)) {
|
|
693
704
|
return false;
|
|
@@ -911,7 +922,7 @@ function snapshotPlayerSessions(value, path) {
|
|
|
911
922
|
function snapshotFrameRoleBindings(value, path) {
|
|
912
923
|
const bindings = snapshotRecord(value, path);
|
|
913
924
|
return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
|
|
914
|
-
if (!
|
|
925
|
+
if (!isCanonicalLocalRoleId(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
|
|
915
926
|
throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
|
|
916
927
|
}
|
|
917
928
|
const playerId = snapshotString(raw, `${path}.${roleId}`);
|
|
@@ -3305,6 +3316,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3305
3316
|
childSessionId: frame.sessionId,
|
|
3306
3317
|
state: result.state,
|
|
3307
3318
|
...(result.output !== undefined ? { output: result.output } : {}),
|
|
3319
|
+
// DR-048: the child runtime read this record from its own artifact;
|
|
3320
|
+
// the host relays it unchanged so the caller's bridge can route a
|
|
3321
|
+
// failure terminal without knowing the callee's output fields.
|
|
3322
|
+
...(result.terminal !== undefined
|
|
3323
|
+
? { terminal: result.terminal }
|
|
3324
|
+
: {}),
|
|
3308
3325
|
};
|
|
3309
3326
|
}
|
|
3310
3327
|
if (result.outcome === 'aborted') {
|
|
@@ -549,8 +549,24 @@ const INTERNAL_CAPTAIN_ID = 'captain';
|
|
|
549
549
|
const UUID_PATTERN =
|
|
550
550
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
551
551
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
552
|
-
const
|
|
552
|
+
const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
|
|
553
553
|
const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
|
|
554
|
+
|
|
555
|
+
// PBCLI-4: a local role id is its source role name lowercased by Unicode case
|
|
556
|
+
// mapping — nonempty, free of whitespace and control characters, and equal to
|
|
557
|
+
// its own lowercase form. The rule restricts neither script nor alphabet, so
|
|
558
|
+
// `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
|
|
559
|
+
// enforce the reserved `captain` name separately. The CLI host owns the same
|
|
560
|
+
// predicate in `bin/session-store.js`; that private module already imports
|
|
561
|
+
// this one, so the text is repeated here rather than cycled back.
|
|
562
|
+
function isCanonicalLocalRoleId(value: unknown): value is string {
|
|
563
|
+
return (
|
|
564
|
+
typeof value === 'string' &&
|
|
565
|
+
value.length > 0 &&
|
|
566
|
+
value === value.toLowerCase() &&
|
|
567
|
+
!ROLE_ID_WHITESPACE_OR_CONTROL.test(value)
|
|
568
|
+
);
|
|
569
|
+
}
|
|
554
570
|
const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
|
|
555
571
|
'reconcile:unresolved-effect';
|
|
556
572
|
const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
|
|
@@ -1452,9 +1468,7 @@ function isValidRegistryEntry(
|
|
|
1452
1468
|
!Array.isArray(e.requiredRoleIds) ||
|
|
1453
1469
|
e.requiredRoleIds.some(
|
|
1454
1470
|
(role) =>
|
|
1455
|
-
|
|
1456
|
-
!ROLE_ID_PATTERN.test(role) ||
|
|
1457
|
-
role === INTERNAL_CAPTAIN_ID,
|
|
1471
|
+
!isCanonicalLocalRoleId(role) || role === INTERNAL_CAPTAIN_ID,
|
|
1458
1472
|
) ||
|
|
1459
1473
|
new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
|
|
1460
1474
|
!Array.isArray(e.concurrentRoleSets)
|
|
@@ -1773,7 +1787,7 @@ function snapshotFrameRoleBindings(
|
|
|
1773
1787
|
const bindings = snapshotRecord(value, path);
|
|
1774
1788
|
return Object.fromEntries(
|
|
1775
1789
|
Object.entries(bindings).map(([roleId, raw]) => {
|
|
1776
|
-
if (!
|
|
1790
|
+
if (!isCanonicalLocalRoleId(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
|
|
1777
1791
|
throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
|
|
1778
1792
|
}
|
|
1779
1793
|
const playerId = snapshotString(raw, `${path}.${roleId}`);
|
|
@@ -5177,6 +5191,12 @@ export function createPlaybookCaptainShell(
|
|
|
5177
5191
|
childSessionId: frame.sessionId,
|
|
5178
5192
|
state: result.state,
|
|
5179
5193
|
...(result.output !== undefined ? { output: result.output } : {}),
|
|
5194
|
+
// DR-048: the child runtime read this record from its own artifact;
|
|
5195
|
+
// the host relays it unchanged so the caller's bridge can route a
|
|
5196
|
+
// failure terminal without knowing the callee's output fields.
|
|
5197
|
+
...(result.terminal !== undefined
|
|
5198
|
+
? { terminal: result.terminal }
|
|
5199
|
+
: {}),
|
|
5180
5200
|
};
|
|
5181
5201
|
}
|
|
5182
5202
|
if (result.outcome === 'aborted') {
|
|
@@ -960,6 +960,9 @@ export const decideMachine = setup({
|
|
|
960
960
|
playbook: {
|
|
961
961
|
stateId: 'reportedReviewFailure',
|
|
962
962
|
description: 'DECIDE reports REVIEW’s failure and its last commit.',
|
|
963
|
+
// DR-048: this final state means the workflow failed, so a caller
|
|
964
|
+
// learns that from the machine rather than from DECIDE's output.
|
|
965
|
+
terminal: 'failure',
|
|
963
966
|
},
|
|
964
967
|
},
|
|
965
968
|
},
|
|
@@ -971,6 +974,7 @@ export const decideMachine = setup({
|
|
|
971
974
|
playbook: {
|
|
972
975
|
stateId: 'done',
|
|
973
976
|
description: 'DECIDE completed with an approved commit.',
|
|
977
|
+
terminal: 'success',
|
|
974
978
|
},
|
|
975
979
|
},
|
|
976
980
|
},
|
|
@@ -1233,6 +1233,9 @@ export const decideMachine = setup({
|
|
|
1233
1233
|
playbook: {
|
|
1234
1234
|
stateId: 'reportedReviewFailure',
|
|
1235
1235
|
description: 'DECIDE reports REVIEW’s failure and its last commit.',
|
|
1236
|
+
// DR-048: this final state means the workflow failed, so a caller
|
|
1237
|
+
// learns that from the machine rather than from DECIDE's output.
|
|
1238
|
+
terminal: 'failure',
|
|
1236
1239
|
},
|
|
1237
1240
|
},
|
|
1238
1241
|
},
|
|
@@ -1244,6 +1247,7 @@ export const decideMachine = setup({
|
|
|
1244
1247
|
playbook: {
|
|
1245
1248
|
stateId: 'done',
|
|
1246
1249
|
description: 'DECIDE completed with an approved commit.',
|
|
1250
|
+
terminal: 'success',
|
|
1247
1251
|
},
|
|
1248
1252
|
},
|
|
1249
1253
|
},
|
|
@@ -19,7 +19,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
19
19
|
import PQueue from 'p-queue';
|
|
20
20
|
import { createActor, fromPromise } from 'xstate';
|
|
21
21
|
import { createAcceptedOutcomeConsumer, } from '../../../src/accepted-outcome.js';
|
|
22
|
-
import { assertJsonSafe, assertPlaybookEffectLedger, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, PlaybookSemanticCandidateStructureError, reconcilePlaybookSemanticEvidence, renderGovernedOutcomeContract, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
|
|
22
|
+
import { assertJsonSafe, assertPlaybookEffectLedger, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, PlaybookSemanticCandidateStructureError, reconcilePlaybookSemanticEvidence, renderGovernedOutcomeContract, snapshotJsonValue, snapshotPlaybookSession, terminalOutcomesFromMachine, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
|
|
23
23
|
import decideMachine from './decide.fsm.js';
|
|
24
24
|
function snapshotDecideRuntimeOptions(value) {
|
|
25
25
|
const captured = snapshotJsonValue(value, 'DECIDE runtime options');
|
|
@@ -54,6 +54,9 @@ function authoredStateDescriptions(states) {
|
|
|
54
54
|
return Object.freeze(descriptions);
|
|
55
55
|
}
|
|
56
56
|
const STATE_DESCRIPTIONS = authoredStateDescriptions(decideMachine.config.states);
|
|
57
|
+
// DR-048: each root final state's compiled terminal kind, read from this
|
|
58
|
+
// artifact through the shared reader the factory-backed runtimes use.
|
|
59
|
+
const TERMINAL_KINDS = terminalOutcomesFromMachine(decideMachine, 'DECIDE');
|
|
57
60
|
const ROLE_STATES = [
|
|
58
61
|
{ stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
|
|
59
62
|
{
|
|
@@ -2814,18 +2817,33 @@ function createDecidePlaybookRuntime(options, deferredEffects) {
|
|
|
2814
2817
|
const output = snapshot.output;
|
|
2815
2818
|
if (output !== undefined)
|
|
2816
2819
|
assertJsonSafe(output, 'terminal output');
|
|
2817
|
-
const
|
|
2818
|
-
?
|
|
2820
|
+
const finalStateId = state.activeStateIds.includes('done')
|
|
2821
|
+
? 'done'
|
|
2819
2822
|
: state.activeStateIds.includes('reportedReviewFailure')
|
|
2820
|
-
?
|
|
2823
|
+
? 'reportedReviewFailure'
|
|
2821
2824
|
: undefined;
|
|
2822
|
-
|
|
2825
|
+
const stateDescription = finalStateId === undefined
|
|
2826
|
+
? undefined
|
|
2827
|
+
: STATE_DESCRIPTIONS[finalStateId];
|
|
2828
|
+
if (finalStateId === undefined || stateDescription === undefined) {
|
|
2823
2829
|
throw new Error('decide runtime: completed actor has no authored final-state description');
|
|
2824
2830
|
}
|
|
2831
|
+
// DR-048: the reached final state's compiled terminal kind, read from
|
|
2832
|
+
// this artifact exactly as the shared factory reads it.
|
|
2833
|
+
const terminalKind = TERMINAL_KINDS.get(finalStateId);
|
|
2825
2834
|
return {
|
|
2826
2835
|
outcome: 'terminal',
|
|
2827
2836
|
state,
|
|
2828
2837
|
stateDescription,
|
|
2838
|
+
...(terminalKind === undefined
|
|
2839
|
+
? {}
|
|
2840
|
+
: {
|
|
2841
|
+
terminal: {
|
|
2842
|
+
stateId: finalStateId,
|
|
2843
|
+
kind: terminalKind,
|
|
2844
|
+
description: stateDescription,
|
|
2845
|
+
},
|
|
2846
|
+
}),
|
|
2829
2847
|
...(output === undefined ? {} : { output }),
|
|
2830
2848
|
};
|
|
2831
2849
|
}
|