@sublang/playbook 8.0.0 → 10.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 +3 -3
- package/docs/cli.md +66 -23
- package/docs/configuration.md +13 -8
- package/docs/embedding.md +45 -14
- package/package.json +7 -3
- package/reference/sdlc/captain.md +14 -10
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
- package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
- package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
- package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
- package/reference/sdlc/captain.playbook/captain.playbook.js +151 -10
- package/reference/sdlc/captain.playbook/captain.playbook.ts +200 -14
- package/reference/sdlc/code.md +0 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
- package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
- package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
- package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
- package/reference/sdlc/code.playbook/bin/run.js +365 -63
- package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -1
- package/reference/sdlc/code.playbook/code.fsm.js +85 -29
- package/reference/sdlc/code.playbook/code.fsm.ts +95 -33
- package/reference/sdlc/code.playbook/code.gears.md +0 -2
- package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
- package/reference/sdlc/code.playbook/code.playbook.js +67 -4
- package/reference/sdlc/code.playbook/code.playbook.ts +87 -8
- package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
- package/reference/sdlc/code.playbook/code.registry.js +10 -3
- package/reference/sdlc/code.playbook/code.registry.ts +23 -5
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
- package/reference/sdlc/code.playbook/playbook-captain.js +1894 -82
- package/reference/sdlc/code.playbook/playbook-captain.ts +2809 -109
- package/reference/sdlc/decide.md +0 -1
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +8 -1
- package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
- package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
- package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +15 -5
- package/reference/sdlc/decide.playbook/decide.playbook.js +1994 -191
- package/reference/sdlc/decide.playbook/decide.playbook.ts +3209 -404
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
- package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
- package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
- package/reference/sdlc/review.playbook/review.fsm.js +133 -12
- package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
- package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
- package/reference/sdlc/review.playbook/review.playbook.js +78 -4
- package/reference/sdlc/review.playbook/review.playbook.ts +95 -8
- package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
- package/reference/sdlc/review.playbook/review.registry.js +10 -3
- package/reference/sdlc/review.playbook/review.registry.ts +23 -5
- package/slc/gears2fsm.md +25 -7
- package/slc/link.md +727 -82
- package/src/accepted-outcome.d.ts +18 -0
- package/src/accepted-outcome.js +94 -0
- package/src/accepted-outcome.ts +140 -0
- package/src/runtime.d.ts +165 -3
- package/src/runtime.ts +214 -2
- package/src/xstate-playbook-runtime.d.ts +162 -13
- package/src/xstate-playbook-runtime.js +3344 -564
- package/src/xstate-playbook-runtime.ts +4873 -637
- package/src/xstate-runtime.d.ts +76 -8
- package/src/xstate-runtime.js +1001 -64
- package/src/xstate-runtime.ts +1640 -91
package/src/xstate-runtime.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import {
|
|
5
6
|
fromPromise,
|
|
6
7
|
waitFor,
|
|
@@ -18,6 +19,12 @@ import type {
|
|
|
18
19
|
PlaybookCallStart,
|
|
19
20
|
PlaybookPendingBossQuestion,
|
|
20
21
|
PlaybookPendingCall,
|
|
22
|
+
PlaybookEffectBoundary,
|
|
23
|
+
PlaybookEffectLedger,
|
|
24
|
+
PlaybookEffectLogicalOperation,
|
|
25
|
+
PlaybookRepositoryObservation,
|
|
26
|
+
PlaybookRepositoryDisposition,
|
|
27
|
+
PlaybookRepositoryReceipt,
|
|
21
28
|
PlaybookRuntimeSnapshot,
|
|
22
29
|
PlaybookSession,
|
|
23
30
|
PlaybookState,
|
|
@@ -68,11 +75,45 @@ function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
68
75
|
});
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
// slc/link.md §Abort: cancellation is causal identity with the applicable
|
|
79
|
+
// signal's reason; an `AbortError`-named rejection that is not that exact
|
|
80
|
+
// reason is a control-plane failure to surface, never an abort to swallow.
|
|
71
81
|
function isAbortReason(error: unknown, signal: AbortSignal): boolean {
|
|
72
|
-
return (
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Immutable cancellation provenance for one runtime operation. The captured
|
|
87
|
+
* signal identities do not change when a mutable runtime advances to another
|
|
88
|
+
* public boundary, while each signal's eventual reason remains observable.
|
|
89
|
+
*/
|
|
90
|
+
interface AbortReasonClassifier {
|
|
91
|
+
isAbortReason(error: unknown): boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createAbortReasonClassifier(
|
|
95
|
+
...sources: readonly (
|
|
96
|
+
| AbortSignal
|
|
97
|
+
| AbortReasonClassifier
|
|
98
|
+
| undefined
|
|
99
|
+
)[]
|
|
100
|
+
): AbortReasonClassifier {
|
|
101
|
+
const captured = Object.freeze(
|
|
102
|
+
sources.filter(
|
|
103
|
+
(
|
|
104
|
+
source,
|
|
105
|
+
): source is AbortSignal | AbortReasonClassifier =>
|
|
106
|
+
source !== undefined,
|
|
107
|
+
),
|
|
75
108
|
);
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
isAbortReason: (error: unknown): boolean =>
|
|
111
|
+
captured.some((source) =>
|
|
112
|
+
source instanceof AbortSignal
|
|
113
|
+
? isAbortReason(error, source)
|
|
114
|
+
: source.isAbortReason(error),
|
|
115
|
+
),
|
|
116
|
+
});
|
|
76
117
|
}
|
|
77
118
|
|
|
78
119
|
const NEVER_ABORTED_SIGNAL = new AbortController().signal;
|
|
@@ -119,7 +160,10 @@ export function registerPlaybookAbortCleanup(
|
|
|
119
160
|
void cleanup.catch(() => undefined);
|
|
120
161
|
}
|
|
121
162
|
|
|
122
|
-
async function drainPlaybookAbortCleanups(
|
|
163
|
+
async function drainPlaybookAbortCleanups(
|
|
164
|
+
signal: AbortSignal,
|
|
165
|
+
aborts: AbortReasonClassifier,
|
|
166
|
+
): Promise<void> {
|
|
123
167
|
const failures: unknown[] = [];
|
|
124
168
|
while (true) {
|
|
125
169
|
const pending = abortCleanups.get(signal);
|
|
@@ -128,7 +172,12 @@ async function drainPlaybookAbortCleanups(signal: AbortSignal): Promise<void> {
|
|
|
128
172
|
pending.clear();
|
|
129
173
|
const outcomes = await Promise.allSettled(batch);
|
|
130
174
|
for (const outcome of outcomes) {
|
|
131
|
-
if (
|
|
175
|
+
if (
|
|
176
|
+
outcome.status === 'rejected' &&
|
|
177
|
+
!aborts.isAbortReason(outcome.reason)
|
|
178
|
+
) {
|
|
179
|
+
failures.push(outcome.reason);
|
|
180
|
+
}
|
|
132
181
|
}
|
|
133
182
|
}
|
|
134
183
|
abortCleanups.delete(signal);
|
|
@@ -671,7 +720,10 @@ function normalizeStateValue(
|
|
|
671
720
|
|
|
672
721
|
export interface PlaybookStateMetadata {
|
|
673
722
|
stateId: string;
|
|
674
|
-
|
|
723
|
+
// Optional by contract: a state whose source declares no description
|
|
724
|
+
// carries none, and no id is ever promoted into one
|
|
725
|
+
// (slc/link.md §Snapshot normalization).
|
|
726
|
+
description?: string;
|
|
675
727
|
}
|
|
676
728
|
|
|
677
729
|
interface MachineSnapshotLike {
|
|
@@ -718,17 +770,31 @@ export function activePlaybookStateMetadata(
|
|
|
718
770
|
meta.playbook.stateId,
|
|
719
771
|
`${nodeId}.meta.playbook.stateId`,
|
|
720
772
|
);
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
773
|
+
// Description is optional: a state may declare none and stay fully
|
|
774
|
+
// usable, merely carrying no `stateDescription` downstream. A declared
|
|
775
|
+
// description must still be a nonempty string.
|
|
776
|
+
const description =
|
|
777
|
+
meta.playbook.description === undefined
|
|
778
|
+
? undefined
|
|
779
|
+
: requireNonEmptyString(
|
|
780
|
+
meta.playbook.description,
|
|
781
|
+
`${nodeId}.meta.playbook.description`,
|
|
782
|
+
);
|
|
725
783
|
const previous = byStateId.get(stateId);
|
|
726
|
-
if (
|
|
784
|
+
if (
|
|
785
|
+
previous?.description !== undefined &&
|
|
786
|
+
description !== undefined &&
|
|
787
|
+
previous.description !== description
|
|
788
|
+
) {
|
|
727
789
|
throw new TypeError(
|
|
728
790
|
`active state id ${stateId} has conflicting descriptions`,
|
|
729
791
|
);
|
|
730
792
|
}
|
|
731
|
-
|
|
793
|
+
const effective = description ?? previous?.description;
|
|
794
|
+
byStateId.set(stateId, {
|
|
795
|
+
stateId,
|
|
796
|
+
...(effective === undefined ? {} : { description: effective }),
|
|
797
|
+
});
|
|
732
798
|
}
|
|
733
799
|
return [...byStateId.values()].sort((left, right) =>
|
|
734
800
|
left.stateId.localeCompare(right.stateId),
|
|
@@ -810,6 +876,1231 @@ const SNAPSHOT_SEQUENCE_KEYS = [
|
|
|
810
876
|
'playbookCall',
|
|
811
877
|
] as const;
|
|
812
878
|
|
|
879
|
+
const EFFECT_UUID_PATTERN =
|
|
880
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
881
|
+
const EFFECT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
882
|
+
const EFFECT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
883
|
+
const EFFECT_DISPOSITIONS = new Set([
|
|
884
|
+
'unchanged',
|
|
885
|
+
'one-descendant-commit',
|
|
886
|
+
'deferred',
|
|
887
|
+
]);
|
|
888
|
+
const EFFECT_RECEIPT_CLASSIFICATIONS = new Set([
|
|
889
|
+
'unchanged',
|
|
890
|
+
'one-descendant-commit',
|
|
891
|
+
'multiple-commits',
|
|
892
|
+
'rewritten-or-non-descendant',
|
|
893
|
+
'worktree-only-change',
|
|
894
|
+
'concurrent-or-foreign-change',
|
|
895
|
+
'observation-ambiguous',
|
|
896
|
+
]);
|
|
897
|
+
|
|
898
|
+
function effectUuid(value: unknown, path: string): string {
|
|
899
|
+
const id = requireNonEmptyString(value, path);
|
|
900
|
+
if (!EFFECT_UUID_PATTERN.test(id)) {
|
|
901
|
+
throw new TypeError(`${path} must be a canonical UUID`);
|
|
902
|
+
}
|
|
903
|
+
return id;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function effectInteger(value: unknown, path: string, minimum = 0): number {
|
|
907
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
|
908
|
+
throw new TypeError(`${path} must be an integer greater than or equal to ${minimum}`);
|
|
909
|
+
}
|
|
910
|
+
return value as number;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function jsonValuesEqual(left: JsonValue, right: JsonValue): boolean {
|
|
914
|
+
if (Object.is(left, right)) return true;
|
|
915
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
916
|
+
return (
|
|
917
|
+
Array.isArray(left) &&
|
|
918
|
+
Array.isArray(right) &&
|
|
919
|
+
left.length === right.length &&
|
|
920
|
+
left.every((entry, index) => jsonValuesEqual(entry, right[index]!))
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
if (isRecord(left) && isRecord(right)) {
|
|
924
|
+
const leftKeys = Object.keys(left).sort();
|
|
925
|
+
const rightKeys = Object.keys(right).sort();
|
|
926
|
+
return (
|
|
927
|
+
leftKeys.length === rightKeys.length &&
|
|
928
|
+
leftKeys.every(
|
|
929
|
+
(key, index) =>
|
|
930
|
+
key === rightKeys[index] &&
|
|
931
|
+
jsonValuesEqual(left[key] as JsonValue, right[key] as JsonValue),
|
|
932
|
+
)
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function projectionText(
|
|
939
|
+
projection: PlaybookRepositoryObservation['projection'],
|
|
940
|
+
): string {
|
|
941
|
+
return JSON.stringify(projection);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function projectionsEqual(
|
|
945
|
+
left: PlaybookRepositoryObservation,
|
|
946
|
+
right: PlaybookRepositoryObservation,
|
|
947
|
+
): boolean {
|
|
948
|
+
return (
|
|
949
|
+
left.projectionDigest === right.projectionDigest &&
|
|
950
|
+
projectionText(left.projection) === projectionText(right.projection)
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function projectionPreservesBaseline(
|
|
955
|
+
baseline: PlaybookRepositoryObservation,
|
|
956
|
+
after: PlaybookRepositoryObservation,
|
|
957
|
+
): boolean {
|
|
958
|
+
return Object.entries(baseline.projection).every(
|
|
959
|
+
([path, entry]) =>
|
|
960
|
+
own(after.projection, path) &&
|
|
961
|
+
JSON.stringify(entry) === JSON.stringify(after.projection[path]),
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function effectObservation(
|
|
966
|
+
value: unknown,
|
|
967
|
+
path: string,
|
|
968
|
+
): PlaybookRepositoryObservation {
|
|
969
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
970
|
+
rejectUnknownKeys(
|
|
971
|
+
value,
|
|
972
|
+
['worktree', 'gitDir', 'head', 'projection', 'projectionDigest'],
|
|
973
|
+
path,
|
|
974
|
+
);
|
|
975
|
+
requireNonEmptyString(value.worktree, `${path}.worktree`);
|
|
976
|
+
requireNonEmptyString(value.gitDir, `${path}.gitDir`);
|
|
977
|
+
const head = requireNonEmptyString(value.head, `${path}.head`);
|
|
978
|
+
if (!EFFECT_OID_PATTERN.test(head)) {
|
|
979
|
+
throw new TypeError(`${path}.head must be a canonical Git commit OID`);
|
|
980
|
+
}
|
|
981
|
+
if (!isRecord(value.projection)) {
|
|
982
|
+
throw new TypeError(`${path}.projection must be a path-keyed object`);
|
|
983
|
+
}
|
|
984
|
+
for (const key of Object.keys(value.projection)) {
|
|
985
|
+
if (key.length === 0) {
|
|
986
|
+
throw new TypeError(`${path}.projection must not contain an empty path`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const projectionDigest = requireNonEmptyString(
|
|
990
|
+
value.projectionDigest,
|
|
991
|
+
`${path}.projectionDigest`,
|
|
992
|
+
);
|
|
993
|
+
if (!EFFECT_DIGEST_PATTERN.test(projectionDigest)) {
|
|
994
|
+
throw new TypeError(`${path}.projectionDigest must be a canonical SHA-256 identity`);
|
|
995
|
+
}
|
|
996
|
+
const expectedDigest = `sha256:${createHash('sha256')
|
|
997
|
+
.update(JSON.stringify(value.projection))
|
|
998
|
+
.digest('hex')}`;
|
|
999
|
+
if (projectionDigest !== expectedDigest) {
|
|
1000
|
+
throw new TypeError(`${path}.projectionDigest does not match its projection`);
|
|
1001
|
+
}
|
|
1002
|
+
return value as unknown as PlaybookRepositoryObservation;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function assertObservationIdentity(
|
|
1006
|
+
observation: PlaybookRepositoryObservation,
|
|
1007
|
+
identity: { readonly worktree: string; readonly gitDir: string },
|
|
1008
|
+
path: string,
|
|
1009
|
+
): void {
|
|
1010
|
+
if (
|
|
1011
|
+
observation.worktree !== identity.worktree ||
|
|
1012
|
+
observation.gitDir !== identity.gitDir
|
|
1013
|
+
) {
|
|
1014
|
+
throw new TypeError(`${path} does not match its canonical worktree identity`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function effectReceipt(
|
|
1019
|
+
value: unknown,
|
|
1020
|
+
path: string,
|
|
1021
|
+
expectedBaseline?: PlaybookRepositoryObservation,
|
|
1022
|
+
expectedAfter?: PlaybookRepositoryObservation,
|
|
1023
|
+
matchExpectedAfter = false,
|
|
1024
|
+
dispositions?: readonly string[],
|
|
1025
|
+
): PlaybookRepositoryReceipt {
|
|
1026
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1027
|
+
rejectUnknownKeys(
|
|
1028
|
+
value,
|
|
1029
|
+
['classification', 'baseline', 'after', 'commitOid'],
|
|
1030
|
+
path,
|
|
1031
|
+
);
|
|
1032
|
+
if (
|
|
1033
|
+
typeof value.classification !== 'string' ||
|
|
1034
|
+
!EFFECT_RECEIPT_CLASSIFICATIONS.has(value.classification)
|
|
1035
|
+
) {
|
|
1036
|
+
throw new TypeError(`${path}.classification is not supported`);
|
|
1037
|
+
}
|
|
1038
|
+
const classification = value.classification;
|
|
1039
|
+
const baseline = effectObservation(value.baseline, `${path}.baseline`);
|
|
1040
|
+
const after = own(value, 'after')
|
|
1041
|
+
? effectObservation(value.after, `${path}.after`)
|
|
1042
|
+
: undefined;
|
|
1043
|
+
assertObservationIdentity(after ?? baseline, baseline, `${path}.after`);
|
|
1044
|
+
if (
|
|
1045
|
+
expectedBaseline !== undefined &&
|
|
1046
|
+
!jsonValuesEqual(
|
|
1047
|
+
baseline as unknown as JsonValue,
|
|
1048
|
+
expectedBaseline as unknown as JsonValue,
|
|
1049
|
+
)
|
|
1050
|
+
) {
|
|
1051
|
+
throw new TypeError(`${path}.baseline does not match its boundary baseline`);
|
|
1052
|
+
}
|
|
1053
|
+
if (matchExpectedAfter) {
|
|
1054
|
+
if (
|
|
1055
|
+
expectedAfter !== undefined &&
|
|
1056
|
+
(after === undefined ||
|
|
1057
|
+
!jsonValuesEqual(
|
|
1058
|
+
after as unknown as JsonValue,
|
|
1059
|
+
expectedAfter as unknown as JsonValue,
|
|
1060
|
+
))
|
|
1061
|
+
) {
|
|
1062
|
+
throw new TypeError(`${path}.after does not match its expected observation`);
|
|
1063
|
+
}
|
|
1064
|
+
if (expectedAfter === undefined && after !== undefined) {
|
|
1065
|
+
throw new TypeError(`${path}.after has no matching expected observation`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (classification !== 'observation-ambiguous' && after === undefined) {
|
|
1069
|
+
throw new TypeError(`${path}.after is required for ${classification}`);
|
|
1070
|
+
}
|
|
1071
|
+
const commitOid = own(value, 'commitOid')
|
|
1072
|
+
? requireNonEmptyString(value.commitOid, `${path}.commitOid`)
|
|
1073
|
+
: undefined;
|
|
1074
|
+
if (classification === 'one-descendant-commit') {
|
|
1075
|
+
if (
|
|
1076
|
+
commitOid === undefined ||
|
|
1077
|
+
!EFFECT_OID_PATTERN.test(commitOid) ||
|
|
1078
|
+
after?.head !== commitOid
|
|
1079
|
+
) {
|
|
1080
|
+
throw new TypeError(
|
|
1081
|
+
`${path}.commitOid must equal the after HEAD for one-descendant-commit`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
} else if (commitOid !== undefined) {
|
|
1085
|
+
throw new TypeError(
|
|
1086
|
+
`${path}.commitOid is permitted only for one-descendant-commit`,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
if (
|
|
1090
|
+
classification === 'unchanged' &&
|
|
1091
|
+
after !== undefined &&
|
|
1092
|
+
!jsonValuesEqual(
|
|
1093
|
+
baseline as unknown as JsonValue,
|
|
1094
|
+
after as unknown as JsonValue,
|
|
1095
|
+
)
|
|
1096
|
+
) {
|
|
1097
|
+
throw new TypeError(`${path} classified unchanged observations that differ`);
|
|
1098
|
+
}
|
|
1099
|
+
if (
|
|
1100
|
+
classification === 'one-descendant-commit' &&
|
|
1101
|
+
after !== undefined &&
|
|
1102
|
+
(baseline.head === after.head ||
|
|
1103
|
+
!projectionsEqual(baseline, after))
|
|
1104
|
+
) {
|
|
1105
|
+
throw new TypeError(
|
|
1106
|
+
`${path} one-descendant-commit must change HEAD and preserve the projection`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
if (
|
|
1110
|
+
classification === 'worktree-only-change' &&
|
|
1111
|
+
after !== undefined &&
|
|
1112
|
+
(baseline.head !== after.head ||
|
|
1113
|
+
projectionsEqual(baseline, after) ||
|
|
1114
|
+
!projectionPreservesBaseline(baseline, after))
|
|
1115
|
+
) {
|
|
1116
|
+
throw new TypeError(
|
|
1117
|
+
`${path} worktree-only-change must preserve HEAD and change the projection`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
if (
|
|
1121
|
+
(classification === 'multiple-commits' ||
|
|
1122
|
+
classification === 'rewritten-or-non-descendant') &&
|
|
1123
|
+
after?.head === baseline.head
|
|
1124
|
+
) {
|
|
1125
|
+
throw new TypeError(`${path} ${classification} must change HEAD`);
|
|
1126
|
+
}
|
|
1127
|
+
if (
|
|
1128
|
+
(classification === 'one-descendant-commit' ||
|
|
1129
|
+
classification === 'multiple-commits' ||
|
|
1130
|
+
classification === 'rewritten-or-non-descendant' ||
|
|
1131
|
+
classification === 'worktree-only-change') &&
|
|
1132
|
+
!dispositions?.includes('one-descendant-commit')
|
|
1133
|
+
) {
|
|
1134
|
+
throw new TypeError(
|
|
1135
|
+
`${path}.classification is incompatible with its boundary dispositions`,
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
if (
|
|
1139
|
+
classification === 'concurrent-or-foreign-change' &&
|
|
1140
|
+
(!dispositions?.every((value) => value === 'unchanged') ||
|
|
1141
|
+
(after !== undefined &&
|
|
1142
|
+
baseline.head === after.head &&
|
|
1143
|
+
projectionsEqual(baseline, after)))
|
|
1144
|
+
) {
|
|
1145
|
+
throw new TypeError(
|
|
1146
|
+
`${path}.classification is incompatible with its boundary dispositions`,
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
if (
|
|
1150
|
+
classification === 'observation-ambiguous' &&
|
|
1151
|
+
after !== undefined &&
|
|
1152
|
+
baseline.head === after.head &&
|
|
1153
|
+
projectionsEqual(baseline, after)
|
|
1154
|
+
) {
|
|
1155
|
+
throw new TypeError(
|
|
1156
|
+
`${path} observation-ambiguous requires an absent or changed after observation`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
return value as unknown as PlaybookRepositoryReceipt;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Authority for one governed delegated-player output field (DR-040 §1). */
|
|
1163
|
+
export type PlaybookSemanticFieldAuthority =
|
|
1164
|
+
| 'presentation'
|
|
1165
|
+
| 'semantic'
|
|
1166
|
+
| 'effect'
|
|
1167
|
+
| 'runtime';
|
|
1168
|
+
|
|
1169
|
+
/** One already-validated state-local governed outcome declaration. */
|
|
1170
|
+
export interface PlaybookSemanticOutcomeSpec {
|
|
1171
|
+
readonly fields: Readonly<Record<string, PlaybookSemanticFieldAuthority>>;
|
|
1172
|
+
readonly repositoryDisposition: PlaybookRepositoryDisposition;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** Evidence available when one governed semantic candidate is reconciled. */
|
|
1176
|
+
export interface PlaybookSemanticEvidenceInput {
|
|
1177
|
+
readonly outcomes: Readonly<Record<string, PlaybookSemanticOutcomeSpec>>;
|
|
1178
|
+
readonly semanticCandidate: unknown;
|
|
1179
|
+
readonly finalText?: unknown;
|
|
1180
|
+
readonly receipt?: unknown;
|
|
1181
|
+
readonly runtimeFields?: unknown;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** The exact detached actor output admitted to FSM delivery. */
|
|
1185
|
+
export type PlaybookReconciledSemanticOutput = Readonly<
|
|
1186
|
+
Record<string, string>
|
|
1187
|
+
> & {
|
|
1188
|
+
readonly guard: string;
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
/** Retained presentation and semantic evidence, kept separate from output. */
|
|
1192
|
+
export interface PlaybookRetainedSemanticEvidence {
|
|
1193
|
+
readonly finalText?: string;
|
|
1194
|
+
readonly semanticCandidate: Readonly<Record<string, string>> & {
|
|
1195
|
+
readonly guard: string;
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
export type PlaybookSemanticReconciliationReason =
|
|
1200
|
+
| 'missing-presentation-evidence'
|
|
1201
|
+
| 'missing-repository-receipt'
|
|
1202
|
+
| 'invalid-repository-receipt'
|
|
1203
|
+
| 'repository-disposition-mismatch'
|
|
1204
|
+
| 'missing-effect-evidence'
|
|
1205
|
+
| 'missing-runtime-evidence'
|
|
1206
|
+
| 'inconsistent-runtime-evidence';
|
|
1207
|
+
|
|
1208
|
+
/** A fail-closed semantic/effect reconciliation decision. */
|
|
1209
|
+
export type PlaybookSemanticReconciliation =
|
|
1210
|
+
| {
|
|
1211
|
+
readonly status: 'resolved' | 'deferred';
|
|
1212
|
+
readonly output: PlaybookReconciledSemanticOutput;
|
|
1213
|
+
readonly evidence: PlaybookRetainedSemanticEvidence;
|
|
1214
|
+
}
|
|
1215
|
+
| {
|
|
1216
|
+
readonly status: 'unresolved';
|
|
1217
|
+
readonly reason: PlaybookSemanticReconciliationReason;
|
|
1218
|
+
readonly evidence: PlaybookRetainedSemanticEvidence;
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* A judge candidate is structurally invalid, rather than merely awaiting or
|
|
1223
|
+
* conflicting with effect evidence. Callers use this distinction to spend at
|
|
1224
|
+
* most one durable correction budget before asking another hidden judge.
|
|
1225
|
+
*/
|
|
1226
|
+
export class PlaybookSemanticCandidateStructureError extends TypeError {
|
|
1227
|
+
constructor(message: string) {
|
|
1228
|
+
super(`semantic candidate ${message}`);
|
|
1229
|
+
this.name = 'PlaybookSemanticCandidateStructureError';
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function semanticCandidateStructureError(message: string): never {
|
|
1234
|
+
throw new PlaybookSemanticCandidateStructureError(message);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function snapshotSemanticCandidate(
|
|
1238
|
+
value: unknown,
|
|
1239
|
+
outcomes: Readonly<Record<string, PlaybookSemanticOutcomeSpec>>,
|
|
1240
|
+
): {
|
|
1241
|
+
readonly candidate: Readonly<Record<string, string>> & {
|
|
1242
|
+
readonly guard: string;
|
|
1243
|
+
};
|
|
1244
|
+
readonly outcome: PlaybookSemanticOutcomeSpec;
|
|
1245
|
+
} {
|
|
1246
|
+
let detached: JsonValue;
|
|
1247
|
+
try {
|
|
1248
|
+
detached = snapshotJsonValue(value, 'semantic candidate');
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
const detail = error instanceof Error ? `: ${error.message}` : '';
|
|
1251
|
+
semanticCandidateStructureError(`must be detached plain JSON${detail}`);
|
|
1252
|
+
}
|
|
1253
|
+
if (!isRecord(detached)) {
|
|
1254
|
+
semanticCandidateStructureError('must be an object');
|
|
1255
|
+
}
|
|
1256
|
+
const guard = detached.guard;
|
|
1257
|
+
if (typeof guard !== 'string' || guard.length === 0) {
|
|
1258
|
+
semanticCandidateStructureError('must contain a nonempty string guard');
|
|
1259
|
+
}
|
|
1260
|
+
if (!own(outcomes, guard)) {
|
|
1261
|
+
semanticCandidateStructureError(
|
|
1262
|
+
`guard ${JSON.stringify(guard)} is not declared`,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const outcome = outcomes[guard]!;
|
|
1266
|
+
const semanticFields = Object.entries(outcome.fields)
|
|
1267
|
+
.filter(([, authority]) => authority === 'semantic')
|
|
1268
|
+
.map(([field]) => field);
|
|
1269
|
+
const expected = new Set(['guard', ...semanticFields]);
|
|
1270
|
+
const actual = Object.keys(detached);
|
|
1271
|
+
const missing = [...expected].filter((field) => !actual.includes(field));
|
|
1272
|
+
const extra = actual.filter((field) => !expected.has(field));
|
|
1273
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1274
|
+
semanticCandidateStructureError(
|
|
1275
|
+
'must contain exactly its guard and semantic-owned fields' +
|
|
1276
|
+
(missing.length === 0 ? '' : `; missing ${missing.join(', ')}`) +
|
|
1277
|
+
(extra.length === 0 ? '' : `; extra or wrongly owned ${extra.join(', ')}`),
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
for (const field of semanticFields) {
|
|
1281
|
+
if (typeof detached[field] !== 'string') {
|
|
1282
|
+
semanticCandidateStructureError(
|
|
1283
|
+
`field ${JSON.stringify(field)} must be a string`,
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return {
|
|
1288
|
+
candidate: detached as Readonly<Record<string, string>> & {
|
|
1289
|
+
readonly guard: string;
|
|
1290
|
+
},
|
|
1291
|
+
outcome,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function retainedSemanticEvidence(
|
|
1296
|
+
candidate: Readonly<Record<string, string>> & { readonly guard: string },
|
|
1297
|
+
finalText: unknown,
|
|
1298
|
+
): PlaybookRetainedSemanticEvidence {
|
|
1299
|
+
return Object.freeze({
|
|
1300
|
+
semanticCandidate: candidate,
|
|
1301
|
+
...(typeof finalText === 'string' ? { finalText } : {}),
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function unresolvedSemanticEvidence(
|
|
1306
|
+
reason: PlaybookSemanticReconciliationReason,
|
|
1307
|
+
evidence: PlaybookRetainedSemanticEvidence,
|
|
1308
|
+
): PlaybookSemanticReconciliation {
|
|
1309
|
+
return Object.freeze({ status: 'unresolved', reason, evidence });
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Reconcile one exact semantic candidate with host-owned effect evidence.
|
|
1314
|
+
* Presentation prose remains opaque: it is retained verbatim and only its
|
|
1315
|
+
* trimmed value is copied into linker-declared presentation fields. No
|
|
1316
|
+
* repository fact is inferred from that prose or from the semantic candidate.
|
|
1317
|
+
*/
|
|
1318
|
+
export function reconcilePlaybookSemanticEvidence(
|
|
1319
|
+
input: PlaybookSemanticEvidenceInput,
|
|
1320
|
+
): PlaybookSemanticReconciliation {
|
|
1321
|
+
const { candidate, outcome } = snapshotSemanticCandidate(
|
|
1322
|
+
input.semanticCandidate,
|
|
1323
|
+
input.outcomes,
|
|
1324
|
+
);
|
|
1325
|
+
const evidence = retainedSemanticEvidence(candidate, input.finalText);
|
|
1326
|
+
const finalText =
|
|
1327
|
+
typeof input.finalText === 'string' ? input.finalText.trim() : undefined;
|
|
1328
|
+
if (finalText === undefined || finalText.length === 0) {
|
|
1329
|
+
return unresolvedSemanticEvidence('missing-presentation-evidence', evidence);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (input.receipt === undefined) {
|
|
1333
|
+
return unresolvedSemanticEvidence('missing-repository-receipt', evidence);
|
|
1334
|
+
}
|
|
1335
|
+
let receipt: PlaybookRepositoryReceipt;
|
|
1336
|
+
try {
|
|
1337
|
+
const detachedReceipt = snapshotJsonValue(
|
|
1338
|
+
input.receipt,
|
|
1339
|
+
'semantic reconciliation receipt',
|
|
1340
|
+
);
|
|
1341
|
+
receipt = effectReceipt(
|
|
1342
|
+
detachedReceipt,
|
|
1343
|
+
'semantic reconciliation receipt',
|
|
1344
|
+
undefined,
|
|
1345
|
+
undefined,
|
|
1346
|
+
false,
|
|
1347
|
+
Object.values(input.outcomes).map(
|
|
1348
|
+
({ repositoryDisposition }) => repositoryDisposition,
|
|
1349
|
+
),
|
|
1350
|
+
);
|
|
1351
|
+
} catch {
|
|
1352
|
+
return unresolvedSemanticEvidence('invalid-repository-receipt', evidence);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const disposition = outcome.repositoryDisposition;
|
|
1356
|
+
const dispositionMatches =
|
|
1357
|
+
(disposition === 'unchanged' && receipt.classification === 'unchanged') ||
|
|
1358
|
+
(disposition === 'one-descendant-commit' &&
|
|
1359
|
+
receipt.classification === 'one-descendant-commit') ||
|
|
1360
|
+
(disposition === 'deferred' &&
|
|
1361
|
+
candidate.guard === 'needsBossReply' &&
|
|
1362
|
+
Object.entries(input.outcomes).some(
|
|
1363
|
+
([guard, declared]) =>
|
|
1364
|
+
guard !== candidate.guard &&
|
|
1365
|
+
declared.repositoryDisposition === 'one-descendant-commit',
|
|
1366
|
+
) &&
|
|
1367
|
+
(receipt.classification === 'unchanged' ||
|
|
1368
|
+
receipt.classification === 'worktree-only-change') &&
|
|
1369
|
+
receipt.after !== undefined &&
|
|
1370
|
+
receipt.after.head === receipt.baseline.head);
|
|
1371
|
+
if (!dispositionMatches) {
|
|
1372
|
+
return unresolvedSemanticEvidence(
|
|
1373
|
+
'repository-disposition-mismatch',
|
|
1374
|
+
evidence,
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
let runtimeFields: Readonly<Record<string, JsonValue>> = Object.freeze({});
|
|
1379
|
+
if (input.runtimeFields !== undefined) {
|
|
1380
|
+
try {
|
|
1381
|
+
const detached = snapshotJsonValue(
|
|
1382
|
+
input.runtimeFields,
|
|
1383
|
+
'semantic reconciliation runtime fields',
|
|
1384
|
+
);
|
|
1385
|
+
if (!isRecord(detached)) {
|
|
1386
|
+
return unresolvedSemanticEvidence(
|
|
1387
|
+
'inconsistent-runtime-evidence',
|
|
1388
|
+
evidence,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
runtimeFields = detached;
|
|
1392
|
+
} catch {
|
|
1393
|
+
return unresolvedSemanticEvidence(
|
|
1394
|
+
'inconsistent-runtime-evidence',
|
|
1395
|
+
evidence,
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
const declaredRuntimeFields = Object.entries(outcome.fields)
|
|
1400
|
+
.filter(([, authority]) => authority === 'runtime')
|
|
1401
|
+
.map(([field]) => field);
|
|
1402
|
+
if (
|
|
1403
|
+
Object.keys(runtimeFields).some(
|
|
1404
|
+
(field) => !declaredRuntimeFields.includes(field),
|
|
1405
|
+
)
|
|
1406
|
+
) {
|
|
1407
|
+
return unresolvedSemanticEvidence('inconsistent-runtime-evidence', evidence);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const output: Record<string, string> = {};
|
|
1411
|
+
defineEnumerableDataProperty(output, 'guard', candidate.guard);
|
|
1412
|
+
for (const [field, authority] of Object.entries(outcome.fields)) {
|
|
1413
|
+
let value: string | undefined;
|
|
1414
|
+
if (authority === 'semantic') {
|
|
1415
|
+
value = candidate[field];
|
|
1416
|
+
} else if (authority === 'presentation') {
|
|
1417
|
+
value = finalText;
|
|
1418
|
+
} else if (authority === 'effect') {
|
|
1419
|
+
value = field === 'latestCommit' ? receipt.commitOid : undefined;
|
|
1420
|
+
if (value === undefined) {
|
|
1421
|
+
return unresolvedSemanticEvidence('missing-effect-evidence', evidence);
|
|
1422
|
+
}
|
|
1423
|
+
} else {
|
|
1424
|
+
const runtimeValue = runtimeFields[field];
|
|
1425
|
+
if (runtimeValue === undefined) {
|
|
1426
|
+
return unresolvedSemanticEvidence('missing-runtime-evidence', evidence);
|
|
1427
|
+
}
|
|
1428
|
+
if (typeof runtimeValue !== 'string') {
|
|
1429
|
+
return unresolvedSemanticEvidence(
|
|
1430
|
+
'inconsistent-runtime-evidence',
|
|
1431
|
+
evidence,
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
value = runtimeValue;
|
|
1435
|
+
}
|
|
1436
|
+
defineEnumerableDataProperty(output, field, value);
|
|
1437
|
+
}
|
|
1438
|
+
const detachedOutput = snapshotJsonValue(
|
|
1439
|
+
output,
|
|
1440
|
+
'reconciled semantic output',
|
|
1441
|
+
) as PlaybookReconciledSemanticOutput;
|
|
1442
|
+
return Object.freeze({
|
|
1443
|
+
status: disposition === 'deferred' ? 'deferred' : 'resolved',
|
|
1444
|
+
output: detachedOutput,
|
|
1445
|
+
evidence,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function effectPendingQuestion(
|
|
1450
|
+
value: unknown,
|
|
1451
|
+
path: string,
|
|
1452
|
+
): PlaybookPendingBossQuestion {
|
|
1453
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1454
|
+
rejectUnknownKeys(value, ['questionId', 'asker', 'question', 'sourceItem'], path);
|
|
1455
|
+
const questionId = requireNonEmptyString(
|
|
1456
|
+
value.questionId,
|
|
1457
|
+
`${path}.questionId`,
|
|
1458
|
+
);
|
|
1459
|
+
const question = requireNonEmptyString(value.question, `${path}.question`);
|
|
1460
|
+
if (!isRecord(value.asker)) {
|
|
1461
|
+
throw new TypeError(`${path}.asker must be an object`);
|
|
1462
|
+
}
|
|
1463
|
+
let asker: PlaybookPendingBossQuestion['asker'];
|
|
1464
|
+
if (value.asker.kind === 'captain') {
|
|
1465
|
+
rejectUnknownKeys(value.asker, ['kind'], `${path}.asker`);
|
|
1466
|
+
asker = { kind: 'captain' };
|
|
1467
|
+
} else if (value.asker.kind === 'role') {
|
|
1468
|
+
rejectUnknownKeys(value.asker, ['kind', 'roleId'], `${path}.asker`);
|
|
1469
|
+
asker = {
|
|
1470
|
+
kind: 'role',
|
|
1471
|
+
roleId: requireNonEmptyString(value.asker.roleId, `${path}.asker.roleId`),
|
|
1472
|
+
};
|
|
1473
|
+
} else {
|
|
1474
|
+
throw new TypeError(`${path}.asker.kind must be "captain" or "role"`);
|
|
1475
|
+
}
|
|
1476
|
+
return Object.freeze({
|
|
1477
|
+
questionId,
|
|
1478
|
+
asker: Object.freeze(asker),
|
|
1479
|
+
question,
|
|
1480
|
+
...(own(value, 'sourceItem')
|
|
1481
|
+
? {
|
|
1482
|
+
sourceItem: requireNonEmptyString(
|
|
1483
|
+
value.sourceItem,
|
|
1484
|
+
`${path}.sourceItem`,
|
|
1485
|
+
),
|
|
1486
|
+
}
|
|
1487
|
+
: {}),
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function effectBoundary(value: unknown, index: number): PlaybookEffectBoundary {
|
|
1492
|
+
const path = `effect ledger boundaries[${index}]`;
|
|
1493
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1494
|
+
rejectUnknownKeys(
|
|
1495
|
+
value,
|
|
1496
|
+
[
|
|
1497
|
+
'sequence',
|
|
1498
|
+
'boundaryId',
|
|
1499
|
+
'attemptId',
|
|
1500
|
+
'attemptNumber',
|
|
1501
|
+
'playbookId',
|
|
1502
|
+
'runtimeSessionId',
|
|
1503
|
+
'turnId',
|
|
1504
|
+
'callId',
|
|
1505
|
+
'roleId',
|
|
1506
|
+
'sourceStateId',
|
|
1507
|
+
'sourceOutcomeSchema',
|
|
1508
|
+
'dispositions',
|
|
1509
|
+
'canonicalWorktree',
|
|
1510
|
+
'baseline',
|
|
1511
|
+
'after',
|
|
1512
|
+
'physicalReceipt',
|
|
1513
|
+
'finalText',
|
|
1514
|
+
'semanticCandidate',
|
|
1515
|
+
'initialSemanticCandidate',
|
|
1516
|
+
'correctionBudget',
|
|
1517
|
+
'cohortId',
|
|
1518
|
+
'logicalOperationId',
|
|
1519
|
+
],
|
|
1520
|
+
path,
|
|
1521
|
+
);
|
|
1522
|
+
const sequence = effectInteger(value.sequence, `${path}.sequence`, 1);
|
|
1523
|
+
if (sequence !== index + 1) {
|
|
1524
|
+
throw new TypeError('effect ledger boundary sequence must be contiguous from one');
|
|
1525
|
+
}
|
|
1526
|
+
effectUuid(value.boundaryId, `${path}.boundaryId`);
|
|
1527
|
+
effectUuid(value.attemptId, `${path}.attemptId`);
|
|
1528
|
+
effectInteger(value.attemptNumber, `${path}.attemptNumber`, 1);
|
|
1529
|
+
requireNonEmptyString(value.playbookId, `${path}.playbookId`);
|
|
1530
|
+
effectUuid(value.runtimeSessionId, `${path}.runtimeSessionId`);
|
|
1531
|
+
effectInteger(value.turnId, `${path}.turnId`, 1);
|
|
1532
|
+
requireNonEmptyString(value.callId, `${path}.callId`);
|
|
1533
|
+
requireNonEmptyString(value.roleId, `${path}.roleId`);
|
|
1534
|
+
requireNonEmptyString(value.sourceStateId, `${path}.sourceStateId`);
|
|
1535
|
+
if (!own(value, 'sourceOutcomeSchema')) {
|
|
1536
|
+
throw new TypeError(`${path}.sourceOutcomeSchema is required`);
|
|
1537
|
+
}
|
|
1538
|
+
if (!Array.isArray(value.dispositions) || value.dispositions.length === 0) {
|
|
1539
|
+
throw new TypeError(`${path}.dispositions must be a nonempty array`);
|
|
1540
|
+
}
|
|
1541
|
+
for (const [dispositionIndex, disposition] of value.dispositions.entries()) {
|
|
1542
|
+
if (typeof disposition !== 'string' || !EFFECT_DISPOSITIONS.has(disposition)) {
|
|
1543
|
+
throw new TypeError(
|
|
1544
|
+
`${path}.dispositions[${dispositionIndex}] is not supported`,
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (new Set(value.dispositions).size !== value.dispositions.length) {
|
|
1549
|
+
throw new TypeError(`${path}.dispositions must not contain duplicates`);
|
|
1550
|
+
}
|
|
1551
|
+
if (!isRecord(value.canonicalWorktree)) {
|
|
1552
|
+
throw new TypeError(`${path}.canonicalWorktree must be an object`);
|
|
1553
|
+
}
|
|
1554
|
+
rejectUnknownKeys(
|
|
1555
|
+
value.canonicalWorktree,
|
|
1556
|
+
['worktree', 'gitDir'],
|
|
1557
|
+
`${path}.canonicalWorktree`,
|
|
1558
|
+
);
|
|
1559
|
+
const canonicalWorktree = {
|
|
1560
|
+
worktree: requireNonEmptyString(
|
|
1561
|
+
value.canonicalWorktree.worktree,
|
|
1562
|
+
`${path}.canonicalWorktree.worktree`,
|
|
1563
|
+
),
|
|
1564
|
+
gitDir: requireNonEmptyString(
|
|
1565
|
+
value.canonicalWorktree.gitDir,
|
|
1566
|
+
`${path}.canonicalWorktree.gitDir`,
|
|
1567
|
+
),
|
|
1568
|
+
};
|
|
1569
|
+
const baseline = effectObservation(value.baseline, `${path}.baseline`);
|
|
1570
|
+
assertObservationIdentity(baseline, canonicalWorktree, `${path}.baseline`);
|
|
1571
|
+
const after = own(value, 'after')
|
|
1572
|
+
? effectObservation(value.after, `${path}.after`)
|
|
1573
|
+
: undefined;
|
|
1574
|
+
if (after !== undefined) {
|
|
1575
|
+
assertObservationIdentity(after, canonicalWorktree, `${path}.after`);
|
|
1576
|
+
}
|
|
1577
|
+
if (after !== undefined && !own(value, 'physicalReceipt')) {
|
|
1578
|
+
throw new TypeError(`${path}.after requires an atomic physicalReceipt`);
|
|
1579
|
+
}
|
|
1580
|
+
if (own(value, 'physicalReceipt')) {
|
|
1581
|
+
effectReceipt(
|
|
1582
|
+
value.physicalReceipt,
|
|
1583
|
+
`${path}.physicalReceipt`,
|
|
1584
|
+
baseline,
|
|
1585
|
+
after,
|
|
1586
|
+
true,
|
|
1587
|
+
value.dispositions as readonly string[],
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
if (own(value, 'finalText') && typeof value.finalText !== 'string') {
|
|
1591
|
+
throw new TypeError(`${path}.finalText must be a string`);
|
|
1592
|
+
}
|
|
1593
|
+
if (own(value, 'initialSemanticCandidate')) {
|
|
1594
|
+
if (
|
|
1595
|
+
!own(value, 'semanticCandidate') ||
|
|
1596
|
+
!isRecord(value.correctionBudget) ||
|
|
1597
|
+
value.correctionBudget.spent !== true ||
|
|
1598
|
+
jsonValuesEqual(
|
|
1599
|
+
value.initialSemanticCandidate as JsonValue,
|
|
1600
|
+
value.semanticCandidate as JsonValue,
|
|
1601
|
+
)
|
|
1602
|
+
) {
|
|
1603
|
+
throw new TypeError(
|
|
1604
|
+
`${path}.initialSemanticCandidate requires a spent budget and a distinct current semanticCandidate`,
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if (own(value, 'cohortId')) {
|
|
1609
|
+
effectUuid(value.cohortId, `${path}.cohortId`);
|
|
1610
|
+
}
|
|
1611
|
+
if (!isRecord(value.correctionBudget)) {
|
|
1612
|
+
throw new TypeError(`${path}.correctionBudget must be an object`);
|
|
1613
|
+
}
|
|
1614
|
+
rejectUnknownKeys(
|
|
1615
|
+
value.correctionBudget,
|
|
1616
|
+
['limit', 'spent'],
|
|
1617
|
+
`${path}.correctionBudget`,
|
|
1618
|
+
);
|
|
1619
|
+
if (
|
|
1620
|
+
value.correctionBudget.limit !== 1 ||
|
|
1621
|
+
typeof value.correctionBudget.spent !== 'boolean'
|
|
1622
|
+
) {
|
|
1623
|
+
throw new TypeError(
|
|
1624
|
+
`${path}.correctionBudget must contain exactly limit 1 and a boolean spent`,
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
if (own(value, 'logicalOperationId')) {
|
|
1628
|
+
effectUuid(value.logicalOperationId, `${path}.logicalOperationId`);
|
|
1629
|
+
}
|
|
1630
|
+
return value as unknown as PlaybookEffectBoundary;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function effectLogicalOperation(
|
|
1634
|
+
value: unknown,
|
|
1635
|
+
index: number,
|
|
1636
|
+
boundaryById: ReadonlyMap<string, PlaybookEffectBoundary>,
|
|
1637
|
+
): PlaybookEffectLogicalOperation {
|
|
1638
|
+
const path = `effect ledger logicalOperations[${index}]`;
|
|
1639
|
+
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
1640
|
+
rejectUnknownKeys(
|
|
1641
|
+
value,
|
|
1642
|
+
[
|
|
1643
|
+
'sequence',
|
|
1644
|
+
'operationId',
|
|
1645
|
+
'playbookId',
|
|
1646
|
+
'runtimeSessionId',
|
|
1647
|
+
'boundaryIds',
|
|
1648
|
+
'originalBaseline',
|
|
1649
|
+
'checkpoint',
|
|
1650
|
+
'pendingQuestion',
|
|
1651
|
+
'playerContinuation',
|
|
1652
|
+
'checkpointRestorationEligible',
|
|
1653
|
+
'logicalReceipt',
|
|
1654
|
+
],
|
|
1655
|
+
path,
|
|
1656
|
+
);
|
|
1657
|
+
const sequence = effectInteger(value.sequence, `${path}.sequence`, 1);
|
|
1658
|
+
if (sequence !== index + 1) {
|
|
1659
|
+
throw new TypeError(
|
|
1660
|
+
'effect ledger logical-operation sequence must be contiguous from one',
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
const operationId = effectUuid(value.operationId, `${path}.operationId`);
|
|
1664
|
+
const playbookId = requireNonEmptyString(value.playbookId, `${path}.playbookId`);
|
|
1665
|
+
const runtimeSessionId = effectUuid(
|
|
1666
|
+
value.runtimeSessionId,
|
|
1667
|
+
`${path}.runtimeSessionId`,
|
|
1668
|
+
);
|
|
1669
|
+
if (!Array.isArray(value.boundaryIds) || value.boundaryIds.length === 0) {
|
|
1670
|
+
throw new TypeError(`${path}.boundaryIds must be a nonempty array`);
|
|
1671
|
+
}
|
|
1672
|
+
const boundaries = value.boundaryIds.map((boundaryId, boundaryIndex) => {
|
|
1673
|
+
const id = effectUuid(boundaryId, `${path}.boundaryIds[${boundaryIndex}]`);
|
|
1674
|
+
const boundary = boundaryById.get(id);
|
|
1675
|
+
if (boundary === undefined) {
|
|
1676
|
+
throw new TypeError(`${path}.boundaryIds[${boundaryIndex}] is dangling`);
|
|
1677
|
+
}
|
|
1678
|
+
if (
|
|
1679
|
+
boundary.playbookId !== playbookId ||
|
|
1680
|
+
boundary.runtimeSessionId !== runtimeSessionId ||
|
|
1681
|
+
boundary.logicalOperationId !== operationId
|
|
1682
|
+
) {
|
|
1683
|
+
throw new TypeError(
|
|
1684
|
+
`${path}.boundaryIds[${boundaryIndex}] does not belong to this logical operation`,
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
return boundary;
|
|
1688
|
+
});
|
|
1689
|
+
if (new Set(value.boundaryIds).size !== value.boundaryIds.length) {
|
|
1690
|
+
throw new TypeError(`${path}.boundaryIds must not contain duplicates`);
|
|
1691
|
+
}
|
|
1692
|
+
if (
|
|
1693
|
+
boundaries.some(
|
|
1694
|
+
(boundary, boundaryIndex) =>
|
|
1695
|
+
boundaryIndex > 0 &&
|
|
1696
|
+
boundaries[boundaryIndex - 1]!.sequence >= boundary.sequence,
|
|
1697
|
+
)
|
|
1698
|
+
) {
|
|
1699
|
+
throw new TypeError(`${path}.boundaryIds must follow physical boundary order`);
|
|
1700
|
+
}
|
|
1701
|
+
const originalBaseline = effectObservation(
|
|
1702
|
+
value.originalBaseline,
|
|
1703
|
+
`${path}.originalBaseline`,
|
|
1704
|
+
);
|
|
1705
|
+
if (
|
|
1706
|
+
!jsonValuesEqual(
|
|
1707
|
+
originalBaseline as unknown as JsonValue,
|
|
1708
|
+
boundaries[0]!.baseline as unknown as JsonValue,
|
|
1709
|
+
)
|
|
1710
|
+
) {
|
|
1711
|
+
throw new TypeError(`${path}.originalBaseline must equal its first boundary baseline`);
|
|
1712
|
+
}
|
|
1713
|
+
for (const [boundaryIndex, boundary] of boundaries.entries()) {
|
|
1714
|
+
assertObservationIdentity(
|
|
1715
|
+
boundary.baseline,
|
|
1716
|
+
originalBaseline,
|
|
1717
|
+
`${path}.boundaryIds[${boundaryIndex}] baseline`,
|
|
1718
|
+
);
|
|
1719
|
+
if (boundaryIndex === 0) continue;
|
|
1720
|
+
const previous = boundaries[boundaryIndex - 1]!;
|
|
1721
|
+
if (
|
|
1722
|
+
previous.physicalReceipt === undefined ||
|
|
1723
|
+
previous.after === undefined ||
|
|
1724
|
+
!jsonValuesEqual(
|
|
1725
|
+
boundary.baseline as unknown as JsonValue,
|
|
1726
|
+
previous.after as unknown as JsonValue,
|
|
1727
|
+
)
|
|
1728
|
+
) {
|
|
1729
|
+
throw new TypeError(
|
|
1730
|
+
`${path}.boundaryIds must form one completed checkpoint chain`,
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const checkpoint = own(value, 'checkpoint')
|
|
1735
|
+
? effectObservation(value.checkpoint, `${path}.checkpoint`)
|
|
1736
|
+
: undefined;
|
|
1737
|
+
const latestAfter = boundaries.at(-1)!.after;
|
|
1738
|
+
if (checkpoint !== undefined) {
|
|
1739
|
+
assertObservationIdentity(
|
|
1740
|
+
checkpoint,
|
|
1741
|
+
originalBaseline,
|
|
1742
|
+
`${path}.checkpoint`,
|
|
1743
|
+
);
|
|
1744
|
+
if (
|
|
1745
|
+
latestAfter === undefined ||
|
|
1746
|
+
!jsonValuesEqual(
|
|
1747
|
+
checkpoint as unknown as JsonValue,
|
|
1748
|
+
latestAfter as unknown as JsonValue,
|
|
1749
|
+
)
|
|
1750
|
+
) {
|
|
1751
|
+
throw new TypeError(`${path}.checkpoint must equal its latest boundary after`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
const pendingQuestion = own(value, 'pendingQuestion')
|
|
1755
|
+
? effectPendingQuestion(value.pendingQuestion, `${path}.pendingQuestion`)
|
|
1756
|
+
: undefined;
|
|
1757
|
+
const bindingCount = [
|
|
1758
|
+
own(value, 'checkpoint'),
|
|
1759
|
+
own(value, 'pendingQuestion'),
|
|
1760
|
+
own(value, 'playerContinuation'),
|
|
1761
|
+
].filter(Boolean).length;
|
|
1762
|
+
if (bindingCount !== 0 && bindingCount !== 3) {
|
|
1763
|
+
throw new TypeError(
|
|
1764
|
+
`${path} checkpoint, pendingQuestion, and playerContinuation must be all present or all absent`,
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
if (typeof value.checkpointRestorationEligible !== 'boolean') {
|
|
1768
|
+
throw new TypeError(`${path}.checkpointRestorationEligible must be boolean`);
|
|
1769
|
+
}
|
|
1770
|
+
if (
|
|
1771
|
+
value.checkpointRestorationEligible &&
|
|
1772
|
+
(checkpoint === undefined ||
|
|
1773
|
+
pendingQuestion === undefined ||
|
|
1774
|
+
!own(value, 'playerContinuation'))
|
|
1775
|
+
) {
|
|
1776
|
+
throw new TypeError(
|
|
1777
|
+
`${path}.checkpointRestorationEligible requires checkpoint, pendingQuestion, and playerContinuation`,
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
if (own(value, 'logicalReceipt')) {
|
|
1781
|
+
if (boundaries.some((boundary) => boundary.physicalReceipt === undefined)) {
|
|
1782
|
+
throw new TypeError(
|
|
1783
|
+
`${path}.logicalReceipt requires every physical boundary receipt`,
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
effectReceipt(
|
|
1787
|
+
value.logicalReceipt,
|
|
1788
|
+
`${path}.logicalReceipt`,
|
|
1789
|
+
originalBaseline,
|
|
1790
|
+
latestAfter,
|
|
1791
|
+
true,
|
|
1792
|
+
boundaries.at(-1)!.dispositions,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
return value as unknown as PlaybookEffectLogicalOperation;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/** Return the canonical empty host-owned effect-ledger mirror. */
|
|
1799
|
+
export function emptyPlaybookEffectLedger(): PlaybookEffectLedger {
|
|
1800
|
+
return Object.freeze({
|
|
1801
|
+
schemaVersion: 1,
|
|
1802
|
+
revision: 0,
|
|
1803
|
+
boundaries: Object.freeze([]),
|
|
1804
|
+
logicalOperations: Object.freeze([]),
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/** Validate, detach, and recursively freeze one effect-ledger mirror. */
|
|
1809
|
+
export function assertPlaybookEffectLedger(
|
|
1810
|
+
value: unknown,
|
|
1811
|
+
path = 'effect ledger',
|
|
1812
|
+
): PlaybookEffectLedger {
|
|
1813
|
+
const detached = snapshotJsonValue(value, path);
|
|
1814
|
+
if (!isRecord(detached)) throw new TypeError(`${path} must be an object`);
|
|
1815
|
+
rejectUnknownKeys(
|
|
1816
|
+
detached,
|
|
1817
|
+
['schemaVersion', 'revision', 'boundaries', 'logicalOperations'],
|
|
1818
|
+
path,
|
|
1819
|
+
);
|
|
1820
|
+
if (detached.schemaVersion !== 1) {
|
|
1821
|
+
throw new TypeError(`${path}.schemaVersion must equal 1`);
|
|
1822
|
+
}
|
|
1823
|
+
const revision = effectInteger(detached.revision, `${path}.revision`);
|
|
1824
|
+
if (!Array.isArray(detached.boundaries)) {
|
|
1825
|
+
throw new TypeError(`${path}.boundaries must be an array`);
|
|
1826
|
+
}
|
|
1827
|
+
if (!Array.isArray(detached.logicalOperations)) {
|
|
1828
|
+
throw new TypeError(`${path}.logicalOperations must be an array`);
|
|
1829
|
+
}
|
|
1830
|
+
const isEmpty =
|
|
1831
|
+
detached.boundaries.length === 0 && detached.logicalOperations.length === 0;
|
|
1832
|
+
if ((revision === 0) !== isEmpty) {
|
|
1833
|
+
throw new TypeError(
|
|
1834
|
+
`${path}.revision must be zero if and only if both ordered ledgers are empty`,
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
const boundaries = detached.boundaries.map(effectBoundary);
|
|
1838
|
+
const boundaryById = new Map(
|
|
1839
|
+
boundaries.map((boundary) => [boundary.boundaryId, boundary] as const),
|
|
1840
|
+
);
|
|
1841
|
+
if (boundaryById.size !== boundaries.length) {
|
|
1842
|
+
throw new TypeError(`${path}.boundaries must not reuse a boundaryId`);
|
|
1843
|
+
}
|
|
1844
|
+
const cohorts = new Map<string, PlaybookEffectBoundary[]>();
|
|
1845
|
+
for (const boundary of boundaries) {
|
|
1846
|
+
if (boundary.cohortId === undefined) continue;
|
|
1847
|
+
const members = cohorts.get(boundary.cohortId) ?? [];
|
|
1848
|
+
members.push(boundary);
|
|
1849
|
+
cohorts.set(boundary.cohortId, members);
|
|
1850
|
+
}
|
|
1851
|
+
for (const [cohortId, members] of cohorts) {
|
|
1852
|
+
const first = members[0]!;
|
|
1853
|
+
const commonKeys = [
|
|
1854
|
+
'attemptId',
|
|
1855
|
+
'attemptNumber',
|
|
1856
|
+
'playbookId',
|
|
1857
|
+
'runtimeSessionId',
|
|
1858
|
+
'turnId',
|
|
1859
|
+
'canonicalWorktree',
|
|
1860
|
+
'baseline',
|
|
1861
|
+
] as const;
|
|
1862
|
+
if (
|
|
1863
|
+
members.length < 2 ||
|
|
1864
|
+
members.some(
|
|
1865
|
+
(boundary, index) =>
|
|
1866
|
+
boundary.sequence !== first.sequence + index ||
|
|
1867
|
+
!boundary.dispositions.every(
|
|
1868
|
+
(disposition) => disposition === 'unchanged',
|
|
1869
|
+
) ||
|
|
1870
|
+
!commonKeys.every((key) =>
|
|
1871
|
+
jsonValuesEqual(
|
|
1872
|
+
boundary[key] as JsonValue,
|
|
1873
|
+
first[key] as JsonValue,
|
|
1874
|
+
),
|
|
1875
|
+
),
|
|
1876
|
+
) ||
|
|
1877
|
+
new Set(members.map((boundary) => boundary.roleId)).size !==
|
|
1878
|
+
members.length ||
|
|
1879
|
+
new Set(
|
|
1880
|
+
members.map((boundary) =>
|
|
1881
|
+
boundary.physicalReceipt === undefined ? 'started' : 'complete',
|
|
1882
|
+
),
|
|
1883
|
+
).size !== 1 ||
|
|
1884
|
+
(first.physicalReceipt !== undefined &&
|
|
1885
|
+
members.some(
|
|
1886
|
+
(boundary) =>
|
|
1887
|
+
!jsonValuesEqual(
|
|
1888
|
+
boundary.physicalReceipt as unknown as JsonValue,
|
|
1889
|
+
first.physicalReceipt as unknown as JsonValue,
|
|
1890
|
+
) ||
|
|
1891
|
+
!jsonValuesEqual(
|
|
1892
|
+
boundary.after as unknown as JsonValue,
|
|
1893
|
+
first.after as unknown as JsonValue,
|
|
1894
|
+
),
|
|
1895
|
+
))
|
|
1896
|
+
) {
|
|
1897
|
+
throw new TypeError(
|
|
1898
|
+
`effect ledger cohort ${JSON.stringify(cohortId)} is not one contiguous all-unchanged boundary group`,
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const logicalOperations = detached.logicalOperations.map((operation, index) =>
|
|
1903
|
+
effectLogicalOperation(operation, index, boundaryById),
|
|
1904
|
+
);
|
|
1905
|
+
const operationById = new Map(
|
|
1906
|
+
logicalOperations.map(
|
|
1907
|
+
(operation) => [operation.operationId, operation] as const,
|
|
1908
|
+
),
|
|
1909
|
+
);
|
|
1910
|
+
if (operationById.size !== logicalOperations.length) {
|
|
1911
|
+
throw new TypeError(`${path}.logicalOperations must not reuse an operationId`);
|
|
1912
|
+
}
|
|
1913
|
+
for (const [index, operation] of logicalOperations.entries()) {
|
|
1914
|
+
const firstBoundary = boundaryById.get(operation.boundaryIds[0]!)!;
|
|
1915
|
+
if (
|
|
1916
|
+
index > 0 &&
|
|
1917
|
+
boundaryById.get(logicalOperations[index - 1]!.boundaryIds[0]!)!
|
|
1918
|
+
.sequence >= firstBoundary.sequence
|
|
1919
|
+
) {
|
|
1920
|
+
throw new TypeError(
|
|
1921
|
+
`${path}.logicalOperations must follow their first physical boundary order`,
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
for (const boundary of boundaries) {
|
|
1926
|
+
if (
|
|
1927
|
+
boundary.logicalOperationId !== undefined &&
|
|
1928
|
+
!operationById.has(boundary.logicalOperationId)
|
|
1929
|
+
) {
|
|
1930
|
+
throw new TypeError(
|
|
1931
|
+
`${path}.boundaries[${boundary.sequence - 1}].logicalOperationId is dangling`,
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
if (
|
|
1935
|
+
boundary.logicalOperationId !== undefined &&
|
|
1936
|
+
!operationById
|
|
1937
|
+
.get(boundary.logicalOperationId)!
|
|
1938
|
+
.boundaryIds.includes(boundary.boundaryId)
|
|
1939
|
+
) {
|
|
1940
|
+
throw new TypeError(
|
|
1941
|
+
`${path}.boundaries[${boundary.sequence - 1}].logicalOperationId has no reciprocal operation reference`,
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return detached as unknown as PlaybookEffectLedger;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function optionalEvidenceExtends(
|
|
1949
|
+
baseline: Record<string, unknown>,
|
|
1950
|
+
current: Record<string, unknown>,
|
|
1951
|
+
keys: readonly string[],
|
|
1952
|
+
): boolean {
|
|
1953
|
+
return keys.every(
|
|
1954
|
+
(key) =>
|
|
1955
|
+
!own(baseline, key) ||
|
|
1956
|
+
(own(current, key) &&
|
|
1957
|
+
jsonValuesEqual(
|
|
1958
|
+
baseline[key] as JsonValue,
|
|
1959
|
+
current[key] as JsonValue,
|
|
1960
|
+
)),
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/** Whether current preserves every durable fact in baseline and only extends it. */
|
|
1965
|
+
export function isPlaybookEffectLedgerMonotonicExtension(
|
|
1966
|
+
baselineValue: unknown,
|
|
1967
|
+
currentValue: unknown,
|
|
1968
|
+
): boolean {
|
|
1969
|
+
let baseline: PlaybookEffectLedger;
|
|
1970
|
+
let current: PlaybookEffectLedger;
|
|
1971
|
+
try {
|
|
1972
|
+
baseline = assertPlaybookEffectLedger(baselineValue, 'baseline effect ledger');
|
|
1973
|
+
current = assertPlaybookEffectLedger(currentValue, 'current effect ledger');
|
|
1974
|
+
} catch {
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
if (
|
|
1978
|
+
current.revision < baseline.revision ||
|
|
1979
|
+
current.boundaries.length < baseline.boundaries.length ||
|
|
1980
|
+
current.logicalOperations.length < baseline.logicalOperations.length
|
|
1981
|
+
) {
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1984
|
+
if (
|
|
1985
|
+
current.revision === baseline.revision &&
|
|
1986
|
+
!jsonValuesEqual(
|
|
1987
|
+
baseline as unknown as JsonValue,
|
|
1988
|
+
current as unknown as JsonValue,
|
|
1989
|
+
)
|
|
1990
|
+
) {
|
|
1991
|
+
return false;
|
|
1992
|
+
}
|
|
1993
|
+
const boundaryStableKeys = [
|
|
1994
|
+
'sequence',
|
|
1995
|
+
'boundaryId',
|
|
1996
|
+
'attemptId',
|
|
1997
|
+
'attemptNumber',
|
|
1998
|
+
'playbookId',
|
|
1999
|
+
'runtimeSessionId',
|
|
2000
|
+
'turnId',
|
|
2001
|
+
'callId',
|
|
2002
|
+
'roleId',
|
|
2003
|
+
'sourceStateId',
|
|
2004
|
+
'sourceOutcomeSchema',
|
|
2005
|
+
'dispositions',
|
|
2006
|
+
'canonicalWorktree',
|
|
2007
|
+
'baseline',
|
|
2008
|
+
'cohortId',
|
|
2009
|
+
] as const;
|
|
2010
|
+
const boundaryEvidenceKeys = [
|
|
2011
|
+
'after',
|
|
2012
|
+
'physicalReceipt',
|
|
2013
|
+
'finalText',
|
|
2014
|
+
'initialSemanticCandidate',
|
|
2015
|
+
] as const;
|
|
2016
|
+
for (const [index, prior] of baseline.boundaries.entries()) {
|
|
2017
|
+
const next = current.boundaries[index]!;
|
|
2018
|
+
if (
|
|
2019
|
+
!boundaryStableKeys.every((key) =>
|
|
2020
|
+
jsonValuesEqual(
|
|
2021
|
+
prior[key] as JsonValue,
|
|
2022
|
+
next[key] as JsonValue,
|
|
2023
|
+
),
|
|
2024
|
+
) ||
|
|
2025
|
+
!optionalEvidenceExtends(
|
|
2026
|
+
prior as unknown as Record<string, unknown>,
|
|
2027
|
+
next as unknown as Record<string, unknown>,
|
|
2028
|
+
boundaryEvidenceKeys,
|
|
2029
|
+
) ||
|
|
2030
|
+
!semanticCandidateExtends(prior, next) ||
|
|
2031
|
+
(prior.logicalOperationId !== undefined &&
|
|
2032
|
+
prior.logicalOperationId !== next.logicalOperationId) ||
|
|
2033
|
+
(prior.correctionBudget.spent && !next.correctionBudget.spent)
|
|
2034
|
+
) {
|
|
2035
|
+
return false;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const operationStableKeys = [
|
|
2039
|
+
'sequence',
|
|
2040
|
+
'operationId',
|
|
2041
|
+
'playbookId',
|
|
2042
|
+
'runtimeSessionId',
|
|
2043
|
+
'originalBaseline',
|
|
2044
|
+
] as const;
|
|
2045
|
+
// The current deferred binding is replaceable across authored repeated
|
|
2046
|
+
// questions; only a completed logical receipt becomes immutable evidence.
|
|
2047
|
+
const operationEvidenceKeys = ['logicalReceipt'] as const;
|
|
2048
|
+
for (const [index, prior] of baseline.logicalOperations.entries()) {
|
|
2049
|
+
const next = current.logicalOperations[index]!;
|
|
2050
|
+
if (
|
|
2051
|
+
!operationStableKeys.every((key) =>
|
|
2052
|
+
jsonValuesEqual(
|
|
2053
|
+
prior[key] as JsonValue,
|
|
2054
|
+
next[key] as JsonValue,
|
|
2055
|
+
),
|
|
2056
|
+
) ||
|
|
2057
|
+
next.boundaryIds.length < prior.boundaryIds.length ||
|
|
2058
|
+
!prior.boundaryIds.every(
|
|
2059
|
+
(boundaryId, boundaryIndex) =>
|
|
2060
|
+
boundaryId === next.boundaryIds[boundaryIndex],
|
|
2061
|
+
) ||
|
|
2062
|
+
!optionalEvidenceExtends(
|
|
2063
|
+
prior as unknown as Record<string, unknown>,
|
|
2064
|
+
next as unknown as Record<string, unknown>,
|
|
2065
|
+
operationEvidenceKeys,
|
|
2066
|
+
)
|
|
2067
|
+
) {
|
|
2068
|
+
return false;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return true;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function semanticCandidateExtends(
|
|
2075
|
+
prior: PlaybookEffectBoundary,
|
|
2076
|
+
next: PlaybookEffectBoundary,
|
|
2077
|
+
): boolean {
|
|
2078
|
+
if (prior.semanticCandidate === undefined) {
|
|
2079
|
+
return (
|
|
2080
|
+
!prior.correctionBudget.spent ||
|
|
2081
|
+
next.initialSemanticCandidate === undefined
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
if (
|
|
2085
|
+
next.semanticCandidate !== undefined &&
|
|
2086
|
+
jsonValuesEqual(prior.semanticCandidate, next.semanticCandidate)
|
|
2087
|
+
) {
|
|
2088
|
+
return (
|
|
2089
|
+
prior.initialSemanticCandidate !== undefined ||
|
|
2090
|
+
next.initialSemanticCandidate === undefined
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
return (
|
|
2094
|
+
prior.initialSemanticCandidate === undefined &&
|
|
2095
|
+
next.correctionBudget.spent &&
|
|
2096
|
+
next.initialSemanticCandidate !== undefined &&
|
|
2097
|
+
jsonValuesEqual(
|
|
2098
|
+
prior.semanticCandidate,
|
|
2099
|
+
next.initialSemanticCandidate,
|
|
2100
|
+
)
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
813
2104
|
export interface PlaybookRuntimeSnapshotValidationOptions {
|
|
814
2105
|
/**
|
|
815
2106
|
* Opt in only when the restore path will prepare and confirm the suspended
|
|
@@ -829,7 +2120,15 @@ function snapshotSuspendedCall(
|
|
|
829
2120
|
}
|
|
830
2121
|
rejectUnknownKeys(
|
|
831
2122
|
captured,
|
|
832
|
-
[
|
|
2123
|
+
[
|
|
2124
|
+
'callId',
|
|
2125
|
+
'stateId',
|
|
2126
|
+
'playbookId',
|
|
2127
|
+
'text',
|
|
2128
|
+
'childSessionId',
|
|
2129
|
+
'turnId',
|
|
2130
|
+
'effectBoundaryPrefixSequence',
|
|
2131
|
+
],
|
|
833
2132
|
path,
|
|
834
2133
|
);
|
|
835
2134
|
const call: PlaybookSuspendedCall = {
|
|
@@ -854,13 +2153,22 @@ function snapshotSuspendedCall(
|
|
|
854
2153
|
}
|
|
855
2154
|
call.turnId = captured.turnId as number;
|
|
856
2155
|
}
|
|
2156
|
+
if (own(captured, 'effectBoundaryPrefixSequence')) {
|
|
2157
|
+
call.effectBoundaryPrefixSequence =
|
|
2158
|
+
captured.effectBoundaryPrefixSequence === null
|
|
2159
|
+
? null
|
|
2160
|
+
: effectInteger(
|
|
2161
|
+
captured.effectBoundaryPrefixSequence,
|
|
2162
|
+
`${path}.effectBoundaryPrefixSequence`,
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
857
2165
|
return Object.freeze(call);
|
|
858
2166
|
}
|
|
859
2167
|
|
|
860
|
-
// DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
|
|
861
|
-
// schema-
|
|
2168
|
+
// DR-014 §1 / DR-031 §5 / DR-032 / DR-040: validate and detach a host-supplied
|
|
2169
|
+
// schema-4 runtime snapshot before restore touches any state. A suspended
|
|
862
2170
|
// call is rejected unless the restore path explicitly promises to seed and
|
|
863
|
-
// claim it; older schemas are rejected
|
|
2171
|
+
// claim it; older schemas are rejected by this public restore boundary.
|
|
864
2172
|
export function assertPlaybookRuntimeSnapshot(
|
|
865
2173
|
value: unknown,
|
|
866
2174
|
expectedPlaybookId: string,
|
|
@@ -891,9 +2199,9 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
891
2199
|
);
|
|
892
2200
|
}
|
|
893
2201
|
const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
|
|
894
|
-
if (snapshot.schemaVersion !==
|
|
2202
|
+
if (snapshot.schemaVersion !== 4) {
|
|
895
2203
|
throw new TypeError(
|
|
896
|
-
`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected
|
|
2204
|
+
`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 4)`,
|
|
897
2205
|
);
|
|
898
2206
|
}
|
|
899
2207
|
rejectUnknownKeys(
|
|
@@ -906,6 +2214,10 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
906
2214
|
'sequences',
|
|
907
2215
|
'state',
|
|
908
2216
|
'pendingBossQuestions',
|
|
2217
|
+
'effectLedger',
|
|
2218
|
+
'retainedEffectSourceSessionId',
|
|
2219
|
+
'retainedEffectReconciliation',
|
|
2220
|
+
'failedEffectAttempt',
|
|
909
2221
|
'suspendedCall',
|
|
910
2222
|
],
|
|
911
2223
|
'runtime snapshot',
|
|
@@ -1066,6 +2378,133 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
1066
2378
|
return Object.freeze(question);
|
|
1067
2379
|
},
|
|
1068
2380
|
);
|
|
2381
|
+
const effectLedger = assertPlaybookEffectLedger(
|
|
2382
|
+
snapshot.effectLedger,
|
|
2383
|
+
'runtime snapshot effectLedger',
|
|
2384
|
+
);
|
|
2385
|
+
const retainedEffectSourceSessionId = own(
|
|
2386
|
+
snapshot,
|
|
2387
|
+
'retainedEffectSourceSessionId',
|
|
2388
|
+
)
|
|
2389
|
+
? effectUuid(
|
|
2390
|
+
snapshot.retainedEffectSourceSessionId,
|
|
2391
|
+
'runtime snapshot retainedEffectSourceSessionId',
|
|
2392
|
+
)
|
|
2393
|
+
: undefined;
|
|
2394
|
+
let retainedEffectReconciliation:
|
|
2395
|
+
| PlaybookRuntimeSnapshot['retainedEffectReconciliation']
|
|
2396
|
+
| undefined;
|
|
2397
|
+
if (own(snapshot, 'retainedEffectReconciliation')) {
|
|
2398
|
+
if (!isRecord(snapshot.retainedEffectReconciliation)) {
|
|
2399
|
+
throw new TypeError(
|
|
2400
|
+
'runtime snapshot retainedEffectReconciliation must be an object',
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
rejectUnknownKeys(
|
|
2404
|
+
snapshot.retainedEffectReconciliation,
|
|
2405
|
+
['sourceSessionId', 'checkpoint'],
|
|
2406
|
+
'runtime snapshot retainedEffectReconciliation',
|
|
2407
|
+
);
|
|
2408
|
+
const sourceSessionId = effectUuid(
|
|
2409
|
+
snapshot.retainedEffectReconciliation.sourceSessionId,
|
|
2410
|
+
'runtime snapshot retainedEffectReconciliation.sourceSessionId',
|
|
2411
|
+
);
|
|
2412
|
+
const checkpoint = assertPlaybookEffectLedger(
|
|
2413
|
+
snapshot.retainedEffectReconciliation.checkpoint,
|
|
2414
|
+
'runtime snapshot retainedEffectReconciliation.checkpoint',
|
|
2415
|
+
);
|
|
2416
|
+
if (
|
|
2417
|
+
checkpoint.boundaries.some(
|
|
2418
|
+
({ physicalReceipt }) => physicalReceipt === undefined,
|
|
2419
|
+
)
|
|
2420
|
+
) {
|
|
2421
|
+
throw new TypeError(
|
|
2422
|
+
'runtime snapshot retainedEffectReconciliation.checkpoint contains an incomplete physical boundary',
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
2425
|
+
if (!isPlaybookEffectLedgerMonotonicExtension(checkpoint, effectLedger)) {
|
|
2426
|
+
throw new TypeError(
|
|
2427
|
+
'runtime snapshot retainedEffectReconciliation.checkpoint is not a monotonic prefix of effectLedger',
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
if (
|
|
2431
|
+
retainedEffectSourceSessionId === undefined ||
|
|
2432
|
+
retainedEffectSourceSessionId !== sourceSessionId
|
|
2433
|
+
) {
|
|
2434
|
+
throw new TypeError(
|
|
2435
|
+
'runtime snapshot retainedEffectReconciliation.sourceSessionId must equal retainedEffectSourceSessionId',
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2438
|
+
retainedEffectReconciliation = Object.freeze({
|
|
2439
|
+
sourceSessionId,
|
|
2440
|
+
checkpoint,
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
if (
|
|
2444
|
+
typeof suspendedCall?.effectBoundaryPrefixSequence === 'number' &&
|
|
2445
|
+
suspendedCall.effectBoundaryPrefixSequence >
|
|
2446
|
+
(effectLedger.boundaries.at(-1)?.sequence ?? 0)
|
|
2447
|
+
) {
|
|
2448
|
+
throw new TypeError(
|
|
2449
|
+
'runtime snapshot suspendedCall.effectBoundaryPrefixSequence exceeds the effect ledger',
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
let failedEffectAttempt: PlaybookRuntimeSnapshot['failedEffectAttempt'];
|
|
2453
|
+
if (own(snapshot, 'failedEffectAttempt')) {
|
|
2454
|
+
if (state.stateId !== 'failed') {
|
|
2455
|
+
throw new TypeError(
|
|
2456
|
+
'runtime snapshot failedEffectAttempt requires the failed state',
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
if (!isRecord(snapshot.failedEffectAttempt)) {
|
|
2460
|
+
throw new TypeError(
|
|
2461
|
+
'runtime snapshot failedEffectAttempt must be an object',
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
rejectUnknownKeys(
|
|
2465
|
+
snapshot.failedEffectAttempt,
|
|
2466
|
+
['boundaryPrefix', 'attemptId'],
|
|
2467
|
+
'runtime snapshot failedEffectAttempt',
|
|
2468
|
+
);
|
|
2469
|
+
const boundaryPrefix = effectInteger(
|
|
2470
|
+
snapshot.failedEffectAttempt.boundaryPrefix,
|
|
2471
|
+
'runtime snapshot failedEffectAttempt.boundaryPrefix',
|
|
2472
|
+
);
|
|
2473
|
+
const lastBoundarySequence = effectLedger.boundaries.at(-1)?.sequence ?? 0;
|
|
2474
|
+
if (boundaryPrefix > lastBoundarySequence) {
|
|
2475
|
+
throw new TypeError(
|
|
2476
|
+
'runtime snapshot failedEffectAttempt.boundaryPrefix exceeds the effect ledger',
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
const attemptId =
|
|
2480
|
+
snapshot.failedEffectAttempt.attemptId === null
|
|
2481
|
+
? null
|
|
2482
|
+
: effectUuid(
|
|
2483
|
+
snapshot.failedEffectAttempt.attemptId,
|
|
2484
|
+
'runtime snapshot failedEffectAttempt.attemptId',
|
|
2485
|
+
);
|
|
2486
|
+
const causalBoundaries = effectLedger.boundaries.filter(
|
|
2487
|
+
({ sequence }) => sequence > boundaryPrefix,
|
|
2488
|
+
);
|
|
2489
|
+
if (attemptId === null && causalBoundaries.length !== 0) {
|
|
2490
|
+
throw new TypeError(
|
|
2491
|
+
'runtime snapshot failedEffectAttempt null attemptId requires an empty causal suffix',
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
if (
|
|
2495
|
+
attemptId !== null &&
|
|
2496
|
+
(causalBoundaries.length === 0 ||
|
|
2497
|
+
causalBoundaries.some(
|
|
2498
|
+
({ attemptId: boundaryAttemptId }) =>
|
|
2499
|
+
boundaryAttemptId !== attemptId,
|
|
2500
|
+
))
|
|
2501
|
+
) {
|
|
2502
|
+
throw new TypeError(
|
|
2503
|
+
'runtime snapshot failedEffectAttempt does not match its causal ledger suffix',
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
failedEffectAttempt = Object.freeze({ boundaryPrefix, attemptId });
|
|
2507
|
+
}
|
|
1069
2508
|
const fields = {
|
|
1070
2509
|
playbookId,
|
|
1071
2510
|
machine,
|
|
@@ -1073,9 +2512,19 @@ export function assertPlaybookRuntimeSnapshot(
|
|
|
1073
2512
|
sequences: Object.freeze(sequences),
|
|
1074
2513
|
state,
|
|
1075
2514
|
pendingBossQuestions: Object.freeze(pendingBossQuestions),
|
|
2515
|
+
effectLedger,
|
|
2516
|
+
...(retainedEffectSourceSessionId === undefined
|
|
2517
|
+
? {}
|
|
2518
|
+
: { retainedEffectSourceSessionId }),
|
|
2519
|
+
...(retainedEffectReconciliation === undefined
|
|
2520
|
+
? {}
|
|
2521
|
+
: { retainedEffectReconciliation }),
|
|
2522
|
+
...(failedEffectAttempt === undefined
|
|
2523
|
+
? {}
|
|
2524
|
+
: { failedEffectAttempt }),
|
|
1076
2525
|
};
|
|
1077
2526
|
return Object.freeze({
|
|
1078
|
-
schemaVersion:
|
|
2527
|
+
schemaVersion: 4,
|
|
1079
2528
|
...fields,
|
|
1080
2529
|
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
1081
2530
|
});
|
|
@@ -1106,12 +2555,29 @@ export interface NestedPlaybookBridgeOptions {
|
|
|
1106
2555
|
request: PlaybookCallRequest,
|
|
1107
2556
|
signal: AbortSignal,
|
|
1108
2557
|
): Promise<PlaybookCallStart>;
|
|
1109
|
-
emitStarted(
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
2558
|
+
emitStarted(
|
|
2559
|
+
event: PlaybookCallStarted,
|
|
2560
|
+
aborts?: AbortReasonClassifier,
|
|
2561
|
+
): Promise<void>;
|
|
2562
|
+
emitFinished(
|
|
2563
|
+
event: PlaybookCallFinished,
|
|
2564
|
+
aborts?: AbortReasonClassifier,
|
|
2565
|
+
): Promise<void>;
|
|
2566
|
+
drain(aborts?: AbortReasonClassifier): Promise<void>;
|
|
2567
|
+
bindResumeSignal?(
|
|
2568
|
+
signal: AbortSignal,
|
|
2569
|
+
aborts?: AbortReasonClassifier,
|
|
2570
|
+
): void;
|
|
2571
|
+
/** Bind provenance to the root transition caused by this child result. */
|
|
2572
|
+
bindActorSettlement?(aborts: AbortReasonClassifier): void;
|
|
2573
|
+
onControlPlaneError?(
|
|
2574
|
+
error: unknown,
|
|
2575
|
+
aborts?: AbortReasonClassifier,
|
|
2576
|
+
): void;
|
|
2577
|
+
onBackgroundError?(
|
|
2578
|
+
error: unknown,
|
|
2579
|
+
aborts?: AbortReasonClassifier,
|
|
2580
|
+
): void;
|
|
1115
2581
|
}
|
|
1116
2582
|
|
|
1117
2583
|
export class NestedPlaybookCallError extends Error {
|
|
@@ -1135,6 +2601,7 @@ interface ActiveCall {
|
|
|
1135
2601
|
readonly finished: Deferred<void>;
|
|
1136
2602
|
readonly controller: AbortController;
|
|
1137
2603
|
readonly signal: AbortSignal;
|
|
2604
|
+
readonly aborts: AbortReasonClassifier;
|
|
1138
2605
|
phase: 'starting' | 'restoring' | 'suspended' | 'settling';
|
|
1139
2606
|
childSessionId?: string;
|
|
1140
2607
|
abortListener?: () => void;
|
|
@@ -1509,21 +2976,29 @@ export function createNestedPlaybookBridge<
|
|
|
1509
2976
|
(pendingCall: PlaybookPendingCall) => void
|
|
1510
2977
|
>();
|
|
1511
2978
|
|
|
1512
|
-
const reportBackgroundError = (
|
|
2979
|
+
const reportBackgroundError = (
|
|
2980
|
+
error: unknown,
|
|
2981
|
+
aborts?: AbortReasonClassifier,
|
|
2982
|
+
): void => {
|
|
2983
|
+
if (aborts?.isAbortReason(error)) return;
|
|
1513
2984
|
try {
|
|
1514
|
-
options.onBackgroundError?.(error);
|
|
2985
|
+
options.onBackgroundError?.(error, aborts);
|
|
1515
2986
|
} catch {
|
|
1516
2987
|
// Background observers are a terminal sink and cannot own cleanup.
|
|
1517
2988
|
}
|
|
1518
2989
|
};
|
|
1519
2990
|
|
|
1520
|
-
const reportControlPlaneError = (
|
|
2991
|
+
const reportControlPlaneError = (
|
|
2992
|
+
error: unknown,
|
|
2993
|
+
aborts?: AbortReasonClassifier,
|
|
2994
|
+
): void => {
|
|
2995
|
+
if (aborts?.isAbortReason(error)) return;
|
|
1521
2996
|
try {
|
|
1522
|
-
options.onControlPlaneError?.(error);
|
|
2997
|
+
options.onControlPlaneError?.(error, aborts);
|
|
1523
2998
|
} catch (callbackError) {
|
|
1524
2999
|
// Observability callbacks must never prevent terminal cleanup of the
|
|
1525
3000
|
// invocation they are observing.
|
|
1526
|
-
reportBackgroundError(callbackError);
|
|
3001
|
+
reportBackgroundError(callbackError, aborts);
|
|
1527
3002
|
}
|
|
1528
3003
|
};
|
|
1529
3004
|
|
|
@@ -1578,18 +3053,31 @@ export function createNestedPlaybookBridge<
|
|
|
1578
3053
|
if (current === active) current = undefined;
|
|
1579
3054
|
};
|
|
1580
3055
|
|
|
3056
|
+
// A failure causally identical to an applicable abort reason is the
|
|
3057
|
+
// cancellation's own evidence, never a control-plane error.
|
|
3058
|
+
const reportNonAbortControlError = (
|
|
3059
|
+
error: unknown,
|
|
3060
|
+
aborts: AbortReasonClassifier,
|
|
3061
|
+
): void => {
|
|
3062
|
+
reportControlPlaneError(error, aborts);
|
|
3063
|
+
};
|
|
3064
|
+
|
|
1581
3065
|
const emitFinish = async (
|
|
1582
3066
|
active: ActiveCall,
|
|
1583
3067
|
result: PlaybookCallResult,
|
|
3068
|
+
aborts: AbortReasonClassifier,
|
|
1584
3069
|
): Promise<void> => {
|
|
1585
|
-
await options.emitFinished(
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
3070
|
+
await options.emitFinished(
|
|
3071
|
+
{
|
|
3072
|
+
callId: active.callId,
|
|
3073
|
+
stateId: active.input.stateId,
|
|
3074
|
+
playbookId: active.input.playbookId,
|
|
3075
|
+
text: active.input.text,
|
|
3076
|
+
result,
|
|
3077
|
+
},
|
|
3078
|
+
aborts,
|
|
3079
|
+
);
|
|
3080
|
+
await options.drain(aborts);
|
|
1593
3081
|
};
|
|
1594
3082
|
|
|
1595
3083
|
const finishImmediate = async (
|
|
@@ -1598,20 +3086,25 @@ export function createNestedPlaybookBridge<
|
|
|
1598
3086
|
controlError?: unknown,
|
|
1599
3087
|
resultAfterAbortCleanup?: () => PlaybookCallResult,
|
|
1600
3088
|
): Promise<JsonValue | undefined> => {
|
|
3089
|
+
const aborts = active.aborts;
|
|
1601
3090
|
let effectiveResult = result;
|
|
1602
3091
|
let cleanupControlError: unknown;
|
|
1603
3092
|
if (result.status === 'aborted' || active.signal.aborted) {
|
|
1604
3093
|
try {
|
|
1605
|
-
await drainPlaybookAbortCleanups(active.signal);
|
|
3094
|
+
await drainPlaybookAbortCleanups(active.signal, aborts);
|
|
1606
3095
|
} catch (error) {
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
3096
|
+
// A cleanup rejection identical to an applicable abort reason is
|
|
3097
|
+
// the cancellation's own evidence — no latch, no result override.
|
|
3098
|
+
if (!aborts.isAbortReason(error)) {
|
|
3099
|
+
cleanupControlError = error;
|
|
3100
|
+
reportControlPlaneError(error, aborts);
|
|
3101
|
+
effectiveResult = resultFromThrown(
|
|
3102
|
+
active.input.playbookId,
|
|
3103
|
+
active.childSessionId,
|
|
3104
|
+
error,
|
|
3105
|
+
false,
|
|
3106
|
+
);
|
|
3107
|
+
}
|
|
1615
3108
|
}
|
|
1616
3109
|
if (cleanupControlError === undefined && resultAfterAbortCleanup) {
|
|
1617
3110
|
effectiveResult = resultAfterAbortCleanup();
|
|
@@ -1619,15 +3112,16 @@ export function createNestedPlaybookBridge<
|
|
|
1619
3112
|
}
|
|
1620
3113
|
let finishControlError: unknown;
|
|
1621
3114
|
try {
|
|
1622
|
-
await emitFinish(active, effectiveResult);
|
|
3115
|
+
await emitFinish(active, effectiveResult, aborts);
|
|
1623
3116
|
} catch (error) {
|
|
1624
|
-
|
|
3117
|
+
reportNonAbortControlError(error, aborts);
|
|
1625
3118
|
finishControlError = error;
|
|
1626
3119
|
} finally {
|
|
1627
3120
|
// An immediate call can never be resumed. Even when its finish
|
|
1628
3121
|
// emission fails, do not leave a permanently unresumable call in the
|
|
1629
3122
|
// bridge and prevent disposal or a later invocation.
|
|
1630
3123
|
clear(active);
|
|
3124
|
+
options.bindActorSettlement?.(aborts);
|
|
1631
3125
|
}
|
|
1632
3126
|
if (controlError !== undefined) throw controlError;
|
|
1633
3127
|
if (cleanupControlError !== undefined) throw cleanupControlError;
|
|
@@ -1639,6 +3133,7 @@ export function createNestedPlaybookBridge<
|
|
|
1639
3133
|
active: ActiveCall,
|
|
1640
3134
|
result: PlaybookCallResult,
|
|
1641
3135
|
controlError?: unknown,
|
|
3136
|
+
aborts: AbortReasonClassifier = active.aborts,
|
|
1642
3137
|
): Promise<void> => {
|
|
1643
3138
|
if (active.phase === 'settling' && active.settlement) {
|
|
1644
3139
|
await active.settlement;
|
|
@@ -1656,37 +3151,45 @@ export function createNestedPlaybookBridge<
|
|
|
1656
3151
|
effectiveResult = resultFromThrown(
|
|
1657
3152
|
active.input.playbookId,
|
|
1658
3153
|
active.childSessionId,
|
|
1659
|
-
active.signal.reason
|
|
1660
|
-
new Error('Nested playbook invocation aborted'),
|
|
3154
|
+
active.signal.reason,
|
|
1661
3155
|
true,
|
|
1662
3156
|
);
|
|
1663
3157
|
}
|
|
1664
3158
|
try {
|
|
1665
|
-
await drainPlaybookAbortCleanups(active.signal);
|
|
3159
|
+
await drainPlaybookAbortCleanups(active.signal, aborts);
|
|
1666
3160
|
} catch (cleanupError) {
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
3161
|
+
// A cleanup rejection identical to an applicable abort reason is
|
|
3162
|
+
// the cancellation's own evidence — no latch, no result override.
|
|
3163
|
+
if (!aborts.isAbortReason(cleanupError)) {
|
|
3164
|
+
cleanupControlError = cleanupError;
|
|
3165
|
+
reportControlPlaneError(cleanupError, aborts);
|
|
3166
|
+
effectiveResult = resultFromThrown(
|
|
3167
|
+
active.input.playbookId,
|
|
3168
|
+
active.childSessionId,
|
|
3169
|
+
cleanupError,
|
|
3170
|
+
false,
|
|
3171
|
+
);
|
|
3172
|
+
}
|
|
1675
3173
|
}
|
|
1676
3174
|
}
|
|
1677
3175
|
try {
|
|
1678
|
-
await emitFinish(active, effectiveResult);
|
|
3176
|
+
await emitFinish(active, effectiveResult, aborts);
|
|
1679
3177
|
} catch (error) {
|
|
1680
3178
|
// A finish event is the durable return boundary. If it cannot be
|
|
1681
3179
|
// emitted and drained, the child result must not remain retryable:
|
|
1682
3180
|
// clear the identity and fail the promise actor so its parent takes
|
|
1683
|
-
// onError instead of observing a phantom suspended child.
|
|
1684
|
-
|
|
3181
|
+
// onError instead of observing a phantom suspended child. A finish
|
|
3182
|
+
// rejection that is an applicable abort reason — the invocation's
|
|
3183
|
+
// or the settling resume's — evidences cancellation, not a
|
|
3184
|
+
// control-plane failure (slc/link.md §Abort).
|
|
3185
|
+
reportControlPlaneError(error, aborts);
|
|
1685
3186
|
clear(active);
|
|
3187
|
+
options.bindActorSettlement?.(aborts);
|
|
1686
3188
|
active.deferred.reject(error);
|
|
1687
3189
|
throw error;
|
|
1688
3190
|
}
|
|
1689
3191
|
clear(active);
|
|
3192
|
+
options.bindActorSettlement?.(aborts);
|
|
1690
3193
|
if (controlError !== undefined) {
|
|
1691
3194
|
active.deferred.reject(controlError);
|
|
1692
3195
|
} else if (cleanupControlError !== undefined) {
|
|
@@ -1720,6 +3223,7 @@ export function createNestedPlaybookBridge<
|
|
|
1720
3223
|
active.restoreRolledBack = true;
|
|
1721
3224
|
clear(active);
|
|
1722
3225
|
usedCallIds.delete(active.callId);
|
|
3226
|
+
options.bindActorSettlement?.(active.aborts);
|
|
1723
3227
|
active.deferred.reject(error);
|
|
1724
3228
|
return active;
|
|
1725
3229
|
};
|
|
@@ -1733,11 +3237,11 @@ export function createNestedPlaybookBridge<
|
|
|
1733
3237
|
const result = resultFromThrown(
|
|
1734
3238
|
active.input.playbookId,
|
|
1735
3239
|
active.childSessionId,
|
|
1736
|
-
active.signal.reason
|
|
3240
|
+
active.signal.reason,
|
|
1737
3241
|
true,
|
|
1738
3242
|
);
|
|
1739
3243
|
void settlePending(active, result).catch((error: unknown) => {
|
|
1740
|
-
reportBackgroundError(error);
|
|
3244
|
+
reportBackgroundError(error, active.aborts);
|
|
1741
3245
|
});
|
|
1742
3246
|
};
|
|
1743
3247
|
active.abortListener = abortListener;
|
|
@@ -1750,7 +3254,7 @@ export function createNestedPlaybookBridge<
|
|
|
1750
3254
|
try {
|
|
1751
3255
|
listener(pendingCall);
|
|
1752
3256
|
} catch (error) {
|
|
1753
|
-
reportBackgroundError(error);
|
|
3257
|
+
reportBackgroundError(error, active.aborts);
|
|
1754
3258
|
}
|
|
1755
3259
|
}
|
|
1756
3260
|
if (active.signal.aborted) abortListener();
|
|
@@ -1834,10 +3338,17 @@ export function createNestedPlaybookBridge<
|
|
|
1834
3338
|
}
|
|
1835
3339
|
const controller = new AbortController();
|
|
1836
3340
|
let callSignal: AbortSignal;
|
|
3341
|
+
let callAborts: AbortReasonClassifier;
|
|
1837
3342
|
try {
|
|
3343
|
+
const boundarySignal = options.getBoundarySignal?.();
|
|
1838
3344
|
callSignal = combineAbortSignals(
|
|
1839
3345
|
invocationSignal,
|
|
1840
|
-
|
|
3346
|
+
boundarySignal,
|
|
3347
|
+
controller.signal,
|
|
3348
|
+
);
|
|
3349
|
+
callAborts = createAbortReasonClassifier(
|
|
3350
|
+
invocationSignal,
|
|
3351
|
+
boundarySignal,
|
|
1841
3352
|
controller.signal,
|
|
1842
3353
|
);
|
|
1843
3354
|
} catch (error) {
|
|
@@ -1854,6 +3365,7 @@ export function createNestedPlaybookBridge<
|
|
|
1854
3365
|
finished: deferred<void>(),
|
|
1855
3366
|
controller,
|
|
1856
3367
|
signal: callSignal,
|
|
3368
|
+
aborts: callAborts,
|
|
1857
3369
|
phase: 'restoring',
|
|
1858
3370
|
childSessionId: seed.childSessionId,
|
|
1859
3371
|
};
|
|
@@ -1872,8 +3384,7 @@ export function createNestedPlaybookBridge<
|
|
|
1872
3384
|
}
|
|
1873
3385
|
rollbackRestoredCall(
|
|
1874
3386
|
mode,
|
|
1875
|
-
active.signal.reason
|
|
1876
|
-
new Error('Restored nested playbook invocation aborted'),
|
|
3387
|
+
active.signal.reason,
|
|
1877
3388
|
);
|
|
1878
3389
|
};
|
|
1879
3390
|
active.abortListener = restoreAbortListener;
|
|
@@ -1915,10 +3426,17 @@ export function createNestedPlaybookBridge<
|
|
|
1915
3426
|
usedCallIds.add(callId);
|
|
1916
3427
|
const controller = new AbortController();
|
|
1917
3428
|
let callSignal: AbortSignal;
|
|
3429
|
+
let callAborts: AbortReasonClassifier;
|
|
1918
3430
|
try {
|
|
3431
|
+
const boundarySignal = options.getBoundarySignal?.();
|
|
1919
3432
|
callSignal = combineAbortSignals(
|
|
1920
3433
|
invocationSignal,
|
|
1921
|
-
|
|
3434
|
+
boundarySignal,
|
|
3435
|
+
controller.signal,
|
|
3436
|
+
);
|
|
3437
|
+
callAborts = createAbortReasonClassifier(
|
|
3438
|
+
invocationSignal,
|
|
3439
|
+
boundarySignal,
|
|
1922
3440
|
controller.signal,
|
|
1923
3441
|
);
|
|
1924
3442
|
} catch (error) {
|
|
@@ -1931,6 +3449,7 @@ export function createNestedPlaybookBridge<
|
|
|
1931
3449
|
finished: deferred<void>(),
|
|
1932
3450
|
controller,
|
|
1933
3451
|
signal: callSignal,
|
|
3452
|
+
aborts: callAborts,
|
|
1934
3453
|
phase: 'starting',
|
|
1935
3454
|
};
|
|
1936
3455
|
current = active;
|
|
@@ -1940,40 +3459,56 @@ export function createNestedPlaybookBridge<
|
|
|
1940
3459
|
// for their entering state. Yield through the runtime's global queue so
|
|
1941
3460
|
// that transition/status telemetry is enqueued before call.started.
|
|
1942
3461
|
try {
|
|
1943
|
-
await options.drain();
|
|
3462
|
+
await options.drain(active.aborts);
|
|
1944
3463
|
} catch (error) {
|
|
1945
|
-
|
|
3464
|
+
reportNonAbortControlError(error, active.aborts);
|
|
1946
3465
|
clear(active);
|
|
1947
3466
|
throw error;
|
|
1948
3467
|
}
|
|
1949
3468
|
try {
|
|
1950
|
-
await options.emitStarted(
|
|
3469
|
+
await options.emitStarted(
|
|
3470
|
+
{ callId, ...normalizedInput },
|
|
3471
|
+
active.aborts,
|
|
3472
|
+
);
|
|
1951
3473
|
} catch (error) {
|
|
1952
|
-
|
|
3474
|
+
// A start-sink rejection identical to the applicable abort
|
|
3475
|
+
// reason is the cancellation itself: the pair finishes
|
|
3476
|
+
// `aborted` and nothing is reported (slc/link.md §Abort).
|
|
3477
|
+
const controlError = active.aborts.isAbortReason(error)
|
|
3478
|
+
? undefined
|
|
3479
|
+
: error;
|
|
3480
|
+
if (controlError !== undefined) {
|
|
3481
|
+
reportControlPlaneError(controlError, active.aborts);
|
|
3482
|
+
}
|
|
1953
3483
|
return await finishImmediate(
|
|
1954
3484
|
active,
|
|
1955
3485
|
resultFromThrown(
|
|
1956
3486
|
normalizedInput.playbookId,
|
|
1957
3487
|
undefined,
|
|
1958
3488
|
error,
|
|
1959
|
-
|
|
3489
|
+
controlError === undefined,
|
|
1960
3490
|
),
|
|
1961
|
-
|
|
3491
|
+
controlError,
|
|
1962
3492
|
);
|
|
1963
3493
|
}
|
|
1964
3494
|
try {
|
|
1965
|
-
await options.drain();
|
|
3495
|
+
await options.drain(active.aborts);
|
|
1966
3496
|
} catch (error) {
|
|
1967
|
-
|
|
3497
|
+
const controlError = active.aborts.isAbortReason(error)
|
|
3498
|
+
? undefined
|
|
3499
|
+
: error;
|
|
3500
|
+
if (controlError !== undefined) {
|
|
3501
|
+
reportControlPlaneError(controlError, active.aborts);
|
|
3502
|
+
}
|
|
1968
3503
|
return await finishImmediate(
|
|
1969
3504
|
active,
|
|
1970
3505
|
resultFromThrown(
|
|
1971
3506
|
normalizedInput.playbookId,
|
|
1972
3507
|
undefined,
|
|
1973
3508
|
error,
|
|
1974
|
-
|
|
3509
|
+
controlError === undefined,
|
|
1975
3510
|
),
|
|
1976
|
-
|
|
3511
|
+
controlError,
|
|
1977
3512
|
);
|
|
1978
3513
|
}
|
|
1979
3514
|
|
|
@@ -2002,7 +3537,7 @@ export function createNestedPlaybookBridge<
|
|
|
2002
3537
|
const openingCleanup = starting.then(
|
|
2003
3538
|
() => undefined,
|
|
2004
3539
|
(error: unknown) => {
|
|
2005
|
-
if (isAbortReason(error
|
|
3540
|
+
if (active.aborts.isAbortReason(error)) return;
|
|
2006
3541
|
throw error;
|
|
2007
3542
|
},
|
|
2008
3543
|
);
|
|
@@ -2019,19 +3554,23 @@ export function createNestedPlaybookBridge<
|
|
|
2019
3554
|
}
|
|
2020
3555
|
rawStart = await withAbort(starting, active.signal);
|
|
2021
3556
|
} catch (error) {
|
|
2022
|
-
const controlError = active.
|
|
2023
|
-
|
|
3557
|
+
const controlError = active.aborts.isAbortReason(error)
|
|
3558
|
+
? undefined
|
|
3559
|
+
: error;
|
|
3560
|
+
if (controlError !== undefined) {
|
|
3561
|
+
reportControlPlaneError(controlError, active.aborts);
|
|
3562
|
+
}
|
|
2024
3563
|
const result = resultFromThrown(
|
|
2025
3564
|
normalizedInput.playbookId,
|
|
2026
3565
|
undefined,
|
|
2027
3566
|
error,
|
|
2028
|
-
active.signal.aborted,
|
|
3567
|
+
controlError === undefined && active.signal.aborted,
|
|
2029
3568
|
);
|
|
2030
3569
|
return await finishImmediate(
|
|
2031
3570
|
active,
|
|
2032
3571
|
result,
|
|
2033
3572
|
controlError,
|
|
2034
|
-
active.signal.aborted
|
|
3573
|
+
controlError === undefined && active.signal.aborted
|
|
2035
3574
|
? () =>
|
|
2036
3575
|
resultFromThrown(
|
|
2037
3576
|
normalizedInput.playbookId,
|
|
@@ -2208,9 +3747,7 @@ export function createNestedPlaybookBridge<
|
|
|
2208
3747
|
}
|
|
2209
3748
|
const active = mode.active;
|
|
2210
3749
|
if (active.signal.aborted) {
|
|
2211
|
-
const error =
|
|
2212
|
-
active.signal.reason ??
|
|
2213
|
-
new Error('Restored nested playbook invocation aborted');
|
|
3750
|
+
const error = active.signal.reason;
|
|
2214
3751
|
rollbackRestoredCall(mode, error);
|
|
2215
3752
|
restoreMode = undefined;
|
|
2216
3753
|
throw error;
|
|
@@ -2235,7 +3772,7 @@ export function createNestedPlaybookBridge<
|
|
|
2235
3772
|
try {
|
|
2236
3773
|
listener(pendingCall);
|
|
2237
3774
|
} catch (error) {
|
|
2238
|
-
reportBackgroundError(error);
|
|
3775
|
+
reportBackgroundError(error, current?.aborts);
|
|
2239
3776
|
}
|
|
2240
3777
|
}
|
|
2241
3778
|
return () => pendingListeners.delete(listener);
|
|
@@ -2277,8 +3814,20 @@ export function createNestedPlaybookBridge<
|
|
|
2277
3814
|
}
|
|
2278
3815
|
throw error;
|
|
2279
3816
|
}
|
|
2280
|
-
|
|
2281
|
-
|
|
3817
|
+
// A resume whose signal is already aborted delivers nothing: the
|
|
3818
|
+
// validated child result is not consumed, no finish is emitted, and
|
|
3819
|
+
// the pending call survives for a later resume with a fresh signal
|
|
3820
|
+
// (slc/link.md §Nested playbook bridge). Identity and validation
|
|
3821
|
+
// control errors above still win — they are the caller's defects.
|
|
3822
|
+
if (signal.aborted) {
|
|
3823
|
+
throw signal.reason;
|
|
3824
|
+
}
|
|
3825
|
+
const resumeAborts = createAbortReasonClassifier(
|
|
3826
|
+
active.aborts,
|
|
3827
|
+
signal,
|
|
3828
|
+
);
|
|
3829
|
+
options.bindResumeSignal?.(signal, resumeAborts);
|
|
3830
|
+
await settlePending(active, validatedResult, undefined, resumeAborts);
|
|
2282
3831
|
},
|
|
2283
3832
|
abortPending,
|
|
2284
3833
|
async dispose() {
|