@victframework/runtime 0.1.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/dist/agent-governance.d.ts +285 -0
- package/dist/agent-governance.js +438 -0
- package/dist/agent-governance.js.map +1 -0
- package/dist/agent-registry.d.ts +103 -0
- package/dist/agent-registry.js +758 -0
- package/dist/agent-registry.js.map +1 -0
- package/dist/agent-types.d.ts +433 -0
- package/dist/agent-types.js +480 -0
- package/dist/agent-types.js.map +1 -0
- package/dist/authority.d.ts +83 -0
- package/dist/authority.js +170 -0
- package/dist/authority.js.map +1 -0
- package/dist/boundary-conformance.d.ts +51 -0
- package/dist/boundary-conformance.js +364 -0
- package/dist/boundary-conformance.js.map +1 -0
- package/dist/control-conformance.d.ts +68 -0
- package/dist/control-conformance.js +1131 -0
- package/dist/control-conformance.js.map +1 -0
- package/dist/control-in-memory.d.ts +257 -0
- package/dist/control-in-memory.js +886 -0
- package/dist/control-in-memory.js.map +1 -0
- package/dist/control-types.d.ts +1017 -0
- package/dist/control-types.js +779 -0
- package/dist/control-types.js.map +1 -0
- package/dist/durable-run.d.ts +73 -0
- package/dist/durable-run.js +262 -0
- package/dist/durable-run.js.map +1 -0
- package/dist/effect-policy.d.ts +22 -0
- package/dist/effect-policy.js +45 -0
- package/dist/effect-policy.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +71 -0
- package/dist/errors.js.map +1 -0
- package/dist/in-memory-stores.d.ts +16 -0
- package/dist/in-memory-stores.js +522 -0
- package/dist/in-memory-stores.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/operator-config.d.ts +141 -0
- package/dist/operator-config.js +289 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/orchestration-activation.d.ts +116 -0
- package/dist/orchestration-activation.js +223 -0
- package/dist/orchestration-activation.js.map +1 -0
- package/dist/orchestration-canary-conformance.d.ts +10 -0
- package/dist/orchestration-canary-conformance.js +726 -0
- package/dist/orchestration-canary-conformance.js.map +1 -0
- package/dist/orchestration-commands.d.ts +47 -0
- package/dist/orchestration-commands.js +538 -0
- package/dist/orchestration-commands.js.map +1 -0
- package/dist/orchestration-conformance.d.ts +59 -0
- package/dist/orchestration-conformance.js +594 -0
- package/dist/orchestration-conformance.js.map +1 -0
- package/dist/orchestration-driver-types.d.ts +163 -0
- package/dist/orchestration-driver-types.js +21 -0
- package/dist/orchestration-driver-types.js.map +1 -0
- package/dist/orchestration-driver.d.ts +48 -0
- package/dist/orchestration-driver.js +1030 -0
- package/dist/orchestration-driver.js.map +1 -0
- package/dist/orchestration-in-memory.d.ts +18 -0
- package/dist/orchestration-in-memory.js +1313 -0
- package/dist/orchestration-in-memory.js.map +1 -0
- package/dist/orchestration-join-conformance.d.ts +20 -0
- package/dist/orchestration-join-conformance.js +542 -0
- package/dist/orchestration-join-conformance.js.map +1 -0
- package/dist/orchestration-plan.d.ts +52 -0
- package/dist/orchestration-plan.js +265 -0
- package/dist/orchestration-plan.js.map +1 -0
- package/dist/orchestration-race-conformance.d.ts +35 -0
- package/dist/orchestration-race-conformance.js +1265 -0
- package/dist/orchestration-race-conformance.js.map +1 -0
- package/dist/orchestration-remediation-conformance.d.ts +54 -0
- package/dist/orchestration-remediation-conformance.js +632 -0
- package/dist/orchestration-remediation-conformance.js.map +1 -0
- package/dist/orchestration-store-types.d.ts +568 -0
- package/dist/orchestration-store-types.js +2 -0
- package/dist/orchestration-store-types.js.map +1 -0
- package/dist/pack-conformance.d.ts +59 -0
- package/dist/pack-conformance.js +222 -0
- package/dist/pack-conformance.js.map +1 -0
- package/dist/pack-install.d.ts +44 -0
- package/dist/pack-install.js +126 -0
- package/dist/pack-install.js.map +1 -0
- package/dist/registry.d.ts +107 -0
- package/dist/registry.js +481 -0
- package/dist/registry.js.map +1 -0
- package/dist/runtime.d.ts +158 -0
- package/dist/runtime.js +1341 -0
- package/dist/runtime.js.map +1 -0
- package/dist/serialization.d.ts +26 -0
- package/dist/serialization.js +162 -0
- package/dist/serialization.js.map +1 -0
- package/dist/store-conformance.d.ts +45 -0
- package/dist/store-conformance.js +876 -0
- package/dist/store-conformance.js.map +1 -0
- package/dist/store-errors.d.ts +49 -0
- package/dist/store-errors.js +48 -0
- package/dist/store-errors.js.map +1 -0
- package/dist/store-types.d.ts +331 -0
- package/dist/store-types.js +14 -0
- package/dist/store-types.js.map +1 -0
- package/dist/store-validation.d.ts +63 -0
- package/dist/store-validation.js +164 -0
- package/dist/store-validation.js.map +1 -0
- package/dist/stream-hub.d.ts +151 -0
- package/dist/stream-hub.js +402 -0
- package/dist/stream-hub.js.map +1 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +19 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { OrchestrationStore } from './orchestration-store-types.js';
|
|
2
|
+
import type { VictRuntime } from './runtime.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adapter-neutral Stage 03 orchestration conformance suite (handoff §23).
|
|
5
|
+
*
|
|
6
|
+
* One behavioral source, executed against every conforming backend: the
|
|
7
|
+
* in-memory store and the SQLite adapter both run this exact suite through
|
|
8
|
+
* the same durable driver. Covers claim exclusivity and fencing,
|
|
9
|
+
* durable-before-invocation, retry bounds and durable backoff timers,
|
|
10
|
+
* stable idempotency keys, wait/signal/timer semantics, duplicate and race
|
|
11
|
+
* handling, cancellation, fork/join determinism, blocked resolution,
|
|
12
|
+
* exact-activation resume, checkpoint lifecycle, dense events, atomic
|
|
13
|
+
* state/event transitions, and immutable/defensive reads.
|
|
14
|
+
*/
|
|
15
|
+
export interface ConformanceTestRunner {
|
|
16
|
+
test: (name: string, fn: () => void | Promise<void>) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface ConformanceExpect {
|
|
19
|
+
(actual: unknown): {
|
|
20
|
+
toBe(expected: unknown): void;
|
|
21
|
+
toEqual(expected: unknown): void;
|
|
22
|
+
toBeDefined(): void;
|
|
23
|
+
toBeUndefined(): void;
|
|
24
|
+
toBeNull(): void;
|
|
25
|
+
toBeTruthy(): void;
|
|
26
|
+
toBeGreaterThan(n: number): void;
|
|
27
|
+
toContain(item: unknown): void;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface OrchestrationConformanceFixture {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
create(): Promise<OrchestrationConformanceStores>;
|
|
33
|
+
}
|
|
34
|
+
export interface OrchestrationConformanceStores {
|
|
35
|
+
readonly runtime: VictRuntime;
|
|
36
|
+
readonly orchestration: OrchestrationStore;
|
|
37
|
+
/** Deterministic event sink for the last run (populated by runWithTrace). */
|
|
38
|
+
dispose(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export interface ConformanceContext {
|
|
41
|
+
readonly runner: ConformanceTestRunner;
|
|
42
|
+
readonly expect: ConformanceExpect;
|
|
43
|
+
}
|
|
44
|
+
export declare function stringContract(id: string): Parameters<VictRuntime['registerContract']>[0];
|
|
45
|
+
export declare function recordContract(id: string): Parameters<VictRuntime['registerContract']>[0];
|
|
46
|
+
/** A decision graph: pure decision → route key → terminal. */
|
|
47
|
+
export declare function decisionGraph(): Parameters<VictRuntime['activate']>[0];
|
|
48
|
+
/** A fan-out graph with two branches and a deterministic join. */
|
|
49
|
+
export declare function fanoutGraph(): Parameters<VictRuntime['activate']>[0];
|
|
50
|
+
/** A signal-wait graph with a following capability. */
|
|
51
|
+
export declare function signalWaitGraph(): Parameters<VictRuntime['activate']>[0];
|
|
52
|
+
/** A timer-wait graph. */
|
|
53
|
+
export declare function timerWaitGraph(): Parameters<VictRuntime['activate']>[0];
|
|
54
|
+
/** A keyed-write retry graph: fails once, then succeeds. */
|
|
55
|
+
export declare function retryGraph(): Parameters<VictRuntime['activate']>[0];
|
|
56
|
+
/** A graph whose write blocks on ambiguous timeout (no retry). */
|
|
57
|
+
export declare function unsafeWriteGraph(): Parameters<VictRuntime['activate']>[0];
|
|
58
|
+
/** Main suite body: executed against one backend fixture. */
|
|
59
|
+
export declare function runOrchestrationConformanceSuite(runner: ConformanceTestRunner, expect: ConformanceExpect, factory: OrchestrationConformanceFixture): void;
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { neutralJsonContract } from '@victframework/sdk';
|
|
2
|
+
import { decideEffectAuthorization } from './effect-policy.js';
|
|
3
|
+
export function stringContract(id) {
|
|
4
|
+
return {
|
|
5
|
+
id,
|
|
6
|
+
revision: '1',
|
|
7
|
+
expected: 'a string',
|
|
8
|
+
parse: (input) => typeof input === 'string'
|
|
9
|
+
? { ok: true, value: input, issues: [] }
|
|
10
|
+
: {
|
|
11
|
+
ok: false,
|
|
12
|
+
issues: [{ code: 'TYPE', path: '$', message: 'expected a string' }],
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function recordContract(id) {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
revision: '1',
|
|
20
|
+
expected: 'a plain object',
|
|
21
|
+
parse: (input) => typeof input === 'object' && input !== null && !Array.isArray(input)
|
|
22
|
+
? { ok: true, value: input, issues: [] }
|
|
23
|
+
: {
|
|
24
|
+
ok: false,
|
|
25
|
+
issues: [{ code: 'TYPE', path: '$', message: 'expected an object' }],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
void decideEffectAuthorization;
|
|
30
|
+
void stringContract;
|
|
31
|
+
/** A decision graph: pure decision → route key → terminal. */
|
|
32
|
+
export function decisionGraph() {
|
|
33
|
+
return {
|
|
34
|
+
id: 'conf-decision',
|
|
35
|
+
entry: 'd',
|
|
36
|
+
nodes: [
|
|
37
|
+
{ id: 'd', kind: 'decision', capability: 'route' },
|
|
38
|
+
{ id: 'left', capability: 'left', output: 'conf-string' },
|
|
39
|
+
{ id: 'right', capability: 'right', output: 'conf-string' },
|
|
40
|
+
{ id: 'done', capability: 'sink', output: 'conf-string' },
|
|
41
|
+
],
|
|
42
|
+
edges: [
|
|
43
|
+
{ from: 'd', to: 'left', kind: 'route', key: 'L' },
|
|
44
|
+
{ from: 'd', to: 'right', kind: 'route', key: 'R' },
|
|
45
|
+
{ from: 'left', to: 'done' },
|
|
46
|
+
{ from: 'right', to: 'done' },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A fan-out graph with two branches and a deterministic join. */
|
|
51
|
+
export function fanoutGraph() {
|
|
52
|
+
return {
|
|
53
|
+
id: 'conf-fanout',
|
|
54
|
+
entry: 'start',
|
|
55
|
+
nodes: [
|
|
56
|
+
{ id: 'start', capability: 'first' },
|
|
57
|
+
{ id: 'f', kind: 'fork', join: 'j' },
|
|
58
|
+
{ id: 'a', capability: 'branch' },
|
|
59
|
+
{ id: 'b', capability: 'branch' },
|
|
60
|
+
{ id: 'j', kind: 'join', fork: 'f', output: 'conf-record' },
|
|
61
|
+
{ id: 'done', capability: 'sink' },
|
|
62
|
+
],
|
|
63
|
+
edges: [
|
|
64
|
+
{ from: 'start', to: 'f' },
|
|
65
|
+
{ from: 'f', to: 'a', kind: 'branch', key: 'a' },
|
|
66
|
+
{ from: 'f', to: 'b', kind: 'branch', key: 'b' },
|
|
67
|
+
{ from: 'a', to: 'j' },
|
|
68
|
+
{ from: 'b', to: 'j' },
|
|
69
|
+
{ from: 'j', to: 'done' },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** A signal-wait graph with a following capability. */
|
|
74
|
+
export function signalWaitGraph() {
|
|
75
|
+
return {
|
|
76
|
+
id: 'conf-wait',
|
|
77
|
+
entry: 'a',
|
|
78
|
+
nodes: [
|
|
79
|
+
{ id: 'a', capability: 'first' },
|
|
80
|
+
{ id: 'w', kind: 'wait', wait: { kind: 'signal', name: 'go', contract: 'conf-string' } },
|
|
81
|
+
{ id: 'b', capability: 'second', output: 'conf-string' },
|
|
82
|
+
],
|
|
83
|
+
edges: [
|
|
84
|
+
{ from: 'a', to: 'w' },
|
|
85
|
+
{ from: 'w', to: 'b' },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** A timer-wait graph. */
|
|
90
|
+
export function timerWaitGraph() {
|
|
91
|
+
return {
|
|
92
|
+
id: 'conf-timer',
|
|
93
|
+
entry: 'a',
|
|
94
|
+
nodes: [
|
|
95
|
+
{ id: 'a', capability: 'first' },
|
|
96
|
+
{ id: 't', kind: 'wait', wait: { kind: 'timer', delayMs: 5 } },
|
|
97
|
+
{ id: 'b', capability: 'second', output: 'conf-string' },
|
|
98
|
+
],
|
|
99
|
+
edges: [
|
|
100
|
+
{ from: 'a', to: 't' },
|
|
101
|
+
{ from: 't', to: 'b' },
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** A keyed-write retry graph: fails once, then succeeds. */
|
|
106
|
+
export function retryGraph() {
|
|
107
|
+
return {
|
|
108
|
+
id: 'conf-retry',
|
|
109
|
+
entry: 'a',
|
|
110
|
+
nodes: [
|
|
111
|
+
{
|
|
112
|
+
id: 'a',
|
|
113
|
+
capability: 'flaky',
|
|
114
|
+
retry: {
|
|
115
|
+
maxAttempts: 3,
|
|
116
|
+
retryOn: ['VICT_RUNTIME_CAPABILITY_THREW'],
|
|
117
|
+
backoff: { kind: 'fixed', delayMs: 1 },
|
|
118
|
+
},
|
|
119
|
+
output: 'conf-string',
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
edges: [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** A graph whose write blocks on ambiguous timeout (no retry). */
|
|
126
|
+
export function unsafeWriteGraph() {
|
|
127
|
+
return {
|
|
128
|
+
id: 'conf-unsafe',
|
|
129
|
+
entry: 'a',
|
|
130
|
+
nodes: [
|
|
131
|
+
{
|
|
132
|
+
id: 'a',
|
|
133
|
+
capability: 'slowWrite',
|
|
134
|
+
timeoutMs: 20,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
edges: [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Main suite body: executed against one backend fixture. */
|
|
141
|
+
export function runOrchestrationConformanceSuite(runner, expect, factory) {
|
|
142
|
+
const t = runner.test;
|
|
143
|
+
t(`[${factory.name}] decision routing, deterministic join, and dense events`, async () => {
|
|
144
|
+
const fixture = await factory.create();
|
|
145
|
+
try {
|
|
146
|
+
const runtime = fixture.runtime;
|
|
147
|
+
let routeCalls = 0;
|
|
148
|
+
runtime
|
|
149
|
+
.registerCapability({
|
|
150
|
+
id: 'route',
|
|
151
|
+
revision: '1',
|
|
152
|
+
effect: 'pure',
|
|
153
|
+
input: neutralJsonContract,
|
|
154
|
+
output: neutralJsonContract,
|
|
155
|
+
invoke: (input) => {
|
|
156
|
+
routeCalls += 1;
|
|
157
|
+
return { route: 'L', value: String(input) };
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
.registerCapability({
|
|
161
|
+
id: 'left',
|
|
162
|
+
revision: '1',
|
|
163
|
+
effect: 'pure',
|
|
164
|
+
input: neutralJsonContract,
|
|
165
|
+
output: neutralJsonContract,
|
|
166
|
+
invoke: (input) => `L:${String(input)}`,
|
|
167
|
+
})
|
|
168
|
+
.registerCapability({
|
|
169
|
+
id: 'right',
|
|
170
|
+
revision: '1',
|
|
171
|
+
effect: 'pure',
|
|
172
|
+
input: neutralJsonContract,
|
|
173
|
+
output: neutralJsonContract,
|
|
174
|
+
invoke: (input) => `R:${String(input)}`,
|
|
175
|
+
})
|
|
176
|
+
.registerCapability({
|
|
177
|
+
id: 'sink',
|
|
178
|
+
revision: '1',
|
|
179
|
+
effect: 'pure',
|
|
180
|
+
input: neutralJsonContract,
|
|
181
|
+
output: neutralJsonContract,
|
|
182
|
+
invoke: (input) => String(input),
|
|
183
|
+
})
|
|
184
|
+
.registerContract(stringContract('conf-string'));
|
|
185
|
+
const activated = await runtime.activate(decisionGraph());
|
|
186
|
+
expect(activated.ok).toBe(true);
|
|
187
|
+
const result = await runtime.run('seed');
|
|
188
|
+
expect(result.status).toBe('completed');
|
|
189
|
+
expect(routeCalls).toBe(1);
|
|
190
|
+
expect(result.output).toBe('L:seed');
|
|
191
|
+
// Dense, append-only events with exact identity columns.
|
|
192
|
+
const snapshot = await fixture.orchestration.getOrchestrationSnapshot(result.runId);
|
|
193
|
+
expect(snapshot?.nextEventSeq ?? 0).toBeGreaterThan(0);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
await fixture.dispose();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
t(`[${factory.name}] fixed fan-out overlap, deterministic join output by branch key`, async () => {
|
|
200
|
+
const fixture = await factory.create();
|
|
201
|
+
try {
|
|
202
|
+
const runtime = fixture.runtime;
|
|
203
|
+
const overlap = { max: 0, active: 0 };
|
|
204
|
+
let branchCalls = 0;
|
|
205
|
+
runtime
|
|
206
|
+
.registerCapability({
|
|
207
|
+
id: 'first',
|
|
208
|
+
revision: '1',
|
|
209
|
+
effect: 'pure',
|
|
210
|
+
input: neutralJsonContract,
|
|
211
|
+
output: neutralJsonContract,
|
|
212
|
+
invoke: (input) => input,
|
|
213
|
+
})
|
|
214
|
+
.registerCapability({
|
|
215
|
+
id: 'branch',
|
|
216
|
+
revision: '1',
|
|
217
|
+
effect: 'pure',
|
|
218
|
+
input: neutralJsonContract,
|
|
219
|
+
output: neutralJsonContract,
|
|
220
|
+
invoke: async (input) => {
|
|
221
|
+
branchCalls += 1;
|
|
222
|
+
overlap.active += 1;
|
|
223
|
+
overlap.max = Math.max(overlap.max, overlap.active);
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
225
|
+
overlap.active -= 1;
|
|
226
|
+
return `${String(input)}`;
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
.registerCapability({
|
|
230
|
+
id: 'sink',
|
|
231
|
+
revision: '1',
|
|
232
|
+
effect: 'pure',
|
|
233
|
+
input: neutralJsonContract,
|
|
234
|
+
output: neutralJsonContract,
|
|
235
|
+
invoke: (input) => input,
|
|
236
|
+
})
|
|
237
|
+
.registerContract(recordContract('conf-record'));
|
|
238
|
+
const activated = await runtime.activate(fanoutGraph());
|
|
239
|
+
expect(activated.ok).toBe(true);
|
|
240
|
+
const result = await runtime.run('seed', { concurrency: 4 });
|
|
241
|
+
expect(result.status).toBe('completed');
|
|
242
|
+
expect(branchCalls).toBe(2);
|
|
243
|
+
// Both branches genuinely overlapped behind the join barrier.
|
|
244
|
+
expect(overlap.max).toBeGreaterThan(1);
|
|
245
|
+
const snapshot = await fixture.orchestration.getOrchestrationSnapshot(result.runId);
|
|
246
|
+
const sinkToken = snapshot?.tokens.find((token) => token.nodeId === 'done');
|
|
247
|
+
expect(sinkToken).toBeDefined();
|
|
248
|
+
void sinkToken;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await fixture.dispose();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
t(`[${factory.name}] signal wait: restart-safe park, valid signal resumes once, invalid payload rejected`, async () => {
|
|
255
|
+
const fixture = await factory.create();
|
|
256
|
+
try {
|
|
257
|
+
const runtime = fixture.runtime;
|
|
258
|
+
let firstCalls = 0;
|
|
259
|
+
let secondCalls = 0;
|
|
260
|
+
runtime
|
|
261
|
+
.registerCapability({
|
|
262
|
+
id: 'first',
|
|
263
|
+
revision: '1',
|
|
264
|
+
effect: 'pure',
|
|
265
|
+
input: neutralJsonContract,
|
|
266
|
+
output: neutralJsonContract,
|
|
267
|
+
invoke: () => {
|
|
268
|
+
firstCalls += 1;
|
|
269
|
+
return 'one';
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
.registerCapability({
|
|
273
|
+
id: 'second',
|
|
274
|
+
revision: '1',
|
|
275
|
+
effect: 'pure',
|
|
276
|
+
input: neutralJsonContract,
|
|
277
|
+
output: neutralJsonContract,
|
|
278
|
+
invoke: (input) => {
|
|
279
|
+
secondCalls += 1;
|
|
280
|
+
return `got:${String(input)}`;
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
.registerContract(stringContract('conf-string'));
|
|
284
|
+
const activated = await runtime.activate(signalWaitGraph());
|
|
285
|
+
expect(activated.ok).toBe(true);
|
|
286
|
+
const parked = await runtime.run('seed');
|
|
287
|
+
expect(parked.status).toBe('waiting');
|
|
288
|
+
expect(parked.waits?.length).toBe(1);
|
|
289
|
+
expect(firstCalls).toBe(1);
|
|
290
|
+
expect(secondCalls).toBe(0);
|
|
291
|
+
const waitId = parked.waits?.[0]?.waitId;
|
|
292
|
+
// Invalid payload leaves the wait open and invokes nothing.
|
|
293
|
+
const bad = await runtime.signal({
|
|
294
|
+
runId: parked.runId,
|
|
295
|
+
waitId,
|
|
296
|
+
signalId: 'bad-1',
|
|
297
|
+
signalName: 'go',
|
|
298
|
+
payload: 12345,
|
|
299
|
+
});
|
|
300
|
+
expect(bad.ok).toBe(false);
|
|
301
|
+
void bad;
|
|
302
|
+
expect(secondCalls).toBe(0);
|
|
303
|
+
const stillOpen = await fixture.orchestration.listWaits(parked.runId);
|
|
304
|
+
expect(stillOpen.find((wait) => wait.waitId === waitId)?.status).toBe('open');
|
|
305
|
+
// Valid signal resolves exactly once.
|
|
306
|
+
const ok = await runtime.signal({
|
|
307
|
+
runId: parked.runId,
|
|
308
|
+
waitId,
|
|
309
|
+
signalId: 'sig-1',
|
|
310
|
+
signalName: 'go',
|
|
311
|
+
payload: 'resumed',
|
|
312
|
+
});
|
|
313
|
+
expect(ok.ok).toBe(true);
|
|
314
|
+
expect(ok.ok ? ok.status : '').toBe('accepted');
|
|
315
|
+
const duplicate = await runtime.signal({
|
|
316
|
+
runId: parked.runId,
|
|
317
|
+
waitId,
|
|
318
|
+
signalId: 'sig-1',
|
|
319
|
+
signalName: 'go',
|
|
320
|
+
payload: 'resumed',
|
|
321
|
+
});
|
|
322
|
+
expect(duplicate.ok).toBe(true);
|
|
323
|
+
expect(duplicate.ok ? duplicate.status : '').toBe('duplicate');
|
|
324
|
+
const final = await runtime.resumeRun(parked.runId);
|
|
325
|
+
expect(final.status).toBe('completed');
|
|
326
|
+
expect(secondCalls).toBe(1);
|
|
327
|
+
expect(final.output).toBe('got:resumed');
|
|
328
|
+
// Repeated polling/delivery after resolution adds no new transition.
|
|
329
|
+
const repeat = await runtime.signal({
|
|
330
|
+
runId: parked.runId,
|
|
331
|
+
waitId,
|
|
332
|
+
signalId: 'sig-2',
|
|
333
|
+
signalName: 'go',
|
|
334
|
+
payload: 'late',
|
|
335
|
+
});
|
|
336
|
+
expect(repeat.ok).toBe(true);
|
|
337
|
+
expect(repeat.ok ? repeat.status : '').toBe('already_resolved');
|
|
338
|
+
expect(secondCalls).toBe(1);
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
await fixture.dispose();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
t(`[${factory.name}] timer wait: durable due timer, pump idempotence, overdue recovery`, async () => {
|
|
345
|
+
const fixture = await factory.create();
|
|
346
|
+
try {
|
|
347
|
+
const runtime = fixture.runtime;
|
|
348
|
+
let secondCalls = 0;
|
|
349
|
+
runtime
|
|
350
|
+
.registerCapability({
|
|
351
|
+
id: 'first',
|
|
352
|
+
revision: '1',
|
|
353
|
+
effect: 'pure',
|
|
354
|
+
input: neutralJsonContract,
|
|
355
|
+
output: neutralJsonContract,
|
|
356
|
+
invoke: () => 'one',
|
|
357
|
+
})
|
|
358
|
+
.registerCapability({
|
|
359
|
+
id: 'second',
|
|
360
|
+
revision: '1',
|
|
361
|
+
effect: 'pure',
|
|
362
|
+
input: neutralJsonContract,
|
|
363
|
+
output: neutralJsonContract,
|
|
364
|
+
invoke: (input) => {
|
|
365
|
+
secondCalls += 1;
|
|
366
|
+
return `after:${String(input)}`;
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
.registerContract(stringContract('conf-string'));
|
|
370
|
+
const activated = await runtime.activate(timerWaitGraph());
|
|
371
|
+
expect(activated.ok).toBe(true);
|
|
372
|
+
const parked = await runtime.run('seed');
|
|
373
|
+
expect(parked.status).toBe('waiting');
|
|
374
|
+
const waits = await fixture.orchestration.listWaits(parked.runId);
|
|
375
|
+
expect(waits.length).toBe(1);
|
|
376
|
+
expect(waits[0]?.kind).toBe('timer');
|
|
377
|
+
// Due immediately after a real 20ms pause: the pump fires it once.
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
379
|
+
const fired = await runtime.processDueTimers({ runId: parked.runId });
|
|
380
|
+
expect(fired.fired).toBe(1);
|
|
381
|
+
const final = await runtime.resumeRun(parked.runId);
|
|
382
|
+
expect(final.status).toBe('completed');
|
|
383
|
+
expect(final.output).toBe('after:one');
|
|
384
|
+
expect(secondCalls).toBe(1);
|
|
385
|
+
// Repeated polling after resolution: no new events or continuations.
|
|
386
|
+
const repeat = await runtime.processDueTimers({ runId: parked.runId });
|
|
387
|
+
expect(repeat.fired).toBe(0);
|
|
388
|
+
expect(secondCalls).toBe(1);
|
|
389
|
+
expect(waits.length).toBe(1);
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
await fixture.dispose();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
t(`[${factory.name}] bounded retry with durable backoff and stable idempotency keys`, async () => {
|
|
396
|
+
const fixture = await factory.create();
|
|
397
|
+
try {
|
|
398
|
+
const runtime = fixture.runtime;
|
|
399
|
+
const attempts = [];
|
|
400
|
+
let calls = 0;
|
|
401
|
+
runtime.registerContract(stringContract('conf-string')).registerCapability({
|
|
402
|
+
id: 'flaky',
|
|
403
|
+
revision: '1',
|
|
404
|
+
effect: 'pure',
|
|
405
|
+
input: neutralJsonContract,
|
|
406
|
+
output: neutralJsonContract,
|
|
407
|
+
invoke: (input, context) => {
|
|
408
|
+
attempts.push(context.idempotencyKey ?? '(none)');
|
|
409
|
+
calls += 1;
|
|
410
|
+
if (calls === 1) {
|
|
411
|
+
throw new Error('boom');
|
|
412
|
+
}
|
|
413
|
+
return `ok:${String(input)}`;
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
const activated = await runtime.activate(retryGraph());
|
|
417
|
+
expect(activated.ok).toBe(true);
|
|
418
|
+
const parked = await runtime.run('seed');
|
|
419
|
+
expect(parked.status).toBe('running');
|
|
420
|
+
// The retry is a durable timer: it becomes due immediately (1ms backoff).
|
|
421
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
422
|
+
const pumped = await runtime.processDueTimers({ runId: parked.runId });
|
|
423
|
+
expect(pumped.fired).toBe(1);
|
|
424
|
+
const final = await runtime.resumeRun(parked.runId);
|
|
425
|
+
expect(final.status).toBe('completed');
|
|
426
|
+
expect(final.output).toBe('ok:seed');
|
|
427
|
+
expect(calls).toBe(2);
|
|
428
|
+
expect(attempts.length).toBe(2);
|
|
429
|
+
// The same stable idempotency key across both attempts.
|
|
430
|
+
expect(attempts[0] !== '(none)').toBe(true);
|
|
431
|
+
expect(attempts[0] === attempts[1]).toBe(true);
|
|
432
|
+
const snapshot = await fixture.orchestration.getOrchestrationSnapshot(final.runId);
|
|
433
|
+
const finished = snapshot?.attempts ?? [];
|
|
434
|
+
expect(finished.length).toBe(2);
|
|
435
|
+
const numbers = finished.map((attempt) => attempt.attemptNumber).sort();
|
|
436
|
+
expect(numbers).toEqual([1, 2]);
|
|
437
|
+
// Retry events exist with attempt numbers and due times, never payloads.
|
|
438
|
+
const events = await fixture.orchestration.listOrchestrationEvents(final.runId);
|
|
439
|
+
expect(events.some((event) => event.type === 'node.retry_scheduled')).toBe(true);
|
|
440
|
+
expect(events.some((event) => event.type === 'timer.scheduled')).toBe(true);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
await fixture.dispose();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
t(`[${factory.name}] cancellation before start keeps invocation count at zero`, async () => {
|
|
447
|
+
const fixture = await factory.create();
|
|
448
|
+
try {
|
|
449
|
+
const runtime = fixture.runtime;
|
|
450
|
+
let invokeCount = 0;
|
|
451
|
+
runtime
|
|
452
|
+
.registerCapability({
|
|
453
|
+
id: 'first',
|
|
454
|
+
revision: '1',
|
|
455
|
+
effect: 'pure',
|
|
456
|
+
input: neutralJsonContract,
|
|
457
|
+
output: neutralJsonContract,
|
|
458
|
+
invoke: () => {
|
|
459
|
+
invokeCount += 1;
|
|
460
|
+
return 'one';
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
.registerCapability({
|
|
464
|
+
id: 'second',
|
|
465
|
+
revision: '1',
|
|
466
|
+
effect: 'pure',
|
|
467
|
+
input: neutralJsonContract,
|
|
468
|
+
output: neutralJsonContract,
|
|
469
|
+
invoke: () => {
|
|
470
|
+
invokeCount += 1;
|
|
471
|
+
return 'two';
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
.registerContract(stringContract('conf-string'));
|
|
475
|
+
const activated = await runtime.activate(signalWaitGraph());
|
|
476
|
+
expect(activated.ok).toBe(true);
|
|
477
|
+
const parked = await runtime.run('seed');
|
|
478
|
+
expect(parked.status).toBe('waiting');
|
|
479
|
+
const cancel = await runtime.cancel({
|
|
480
|
+
runId: parked.runId,
|
|
481
|
+
requestId: 'cx-1',
|
|
482
|
+
reasonCode: 'operator_request',
|
|
483
|
+
});
|
|
484
|
+
expect(cancel.ok).toBe(true);
|
|
485
|
+
expect(cancel.ok ? cancel.cancelled : false).toBe(true);
|
|
486
|
+
expect(invokeCount).toBe(1); // only the pre-wait node ran
|
|
487
|
+
const run = await fixture.orchestration.getOrchestrationRun(parked.runId);
|
|
488
|
+
expect(run?.status).toBe('cancelled');
|
|
489
|
+
// Duplicate cancellation adds no duplicate terminal event.
|
|
490
|
+
const repeat = await runtime.cancel({
|
|
491
|
+
runId: parked.runId,
|
|
492
|
+
requestId: 'cx-1',
|
|
493
|
+
reasonCode: 'operator_request',
|
|
494
|
+
});
|
|
495
|
+
expect(repeat.ok).toBe(true);
|
|
496
|
+
const events = await fixture.orchestration.listOrchestrationEvents(parked.runId);
|
|
497
|
+
expect(events.filter((event) => event.type === 'run.cancelled').length).toBe(1);
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
await fixture.dispose();
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
t(`[${factory.name}] unsafe write timeout blocks without replay and is operator-resolvable`, async () => {
|
|
504
|
+
const fixture = await factory.create();
|
|
505
|
+
try {
|
|
506
|
+
const runtime = fixture.runtime;
|
|
507
|
+
let invokeCount = 0;
|
|
508
|
+
runtime.registerCapability({
|
|
509
|
+
id: 'slowWrite',
|
|
510
|
+
revision: '1',
|
|
511
|
+
effect: 'write',
|
|
512
|
+
input: neutralJsonContract,
|
|
513
|
+
output: neutralJsonContract,
|
|
514
|
+
invoke: async () => {
|
|
515
|
+
invokeCount += 1;
|
|
516
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
517
|
+
return 'applied';
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
const activated = await runtime.activate(unsafeWriteGraph());
|
|
521
|
+
expect(activated.ok).toBe(true);
|
|
522
|
+
const result = await runtime.run('seed');
|
|
523
|
+
expect(result.status).toBe('blocked');
|
|
524
|
+
// The ambiguous attempt is never replayed.
|
|
525
|
+
expect(invokeCount).toBe(1);
|
|
526
|
+
const run = await fixture.orchestration.getOrchestrationRun(result.runId);
|
|
527
|
+
expect(run?.status).toBe('blocked');
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
await fixture.dispose();
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
t(`[${factory.name}] exact-activation resume across a newer selection`, async () => {
|
|
534
|
+
const fixture = await factory.create();
|
|
535
|
+
try {
|
|
536
|
+
const runtime = fixture.runtime;
|
|
537
|
+
runtime
|
|
538
|
+
.registerCapability({
|
|
539
|
+
id: 'first',
|
|
540
|
+
revision: '1',
|
|
541
|
+
effect: 'pure',
|
|
542
|
+
input: neutralJsonContract,
|
|
543
|
+
output: neutralJsonContract,
|
|
544
|
+
invoke: () => 'one',
|
|
545
|
+
})
|
|
546
|
+
.registerCapability({
|
|
547
|
+
id: 'second',
|
|
548
|
+
revision: '1',
|
|
549
|
+
effect: 'pure',
|
|
550
|
+
input: neutralJsonContract,
|
|
551
|
+
output: neutralJsonContract,
|
|
552
|
+
invoke: (input) => `got:${String(input)}`,
|
|
553
|
+
})
|
|
554
|
+
.registerCapability({
|
|
555
|
+
id: 'second2',
|
|
556
|
+
revision: '1',
|
|
557
|
+
effect: 'pure',
|
|
558
|
+
input: neutralJsonContract,
|
|
559
|
+
output: neutralJsonContract,
|
|
560
|
+
invoke: (input) => `got2:${String(input)}`,
|
|
561
|
+
})
|
|
562
|
+
.registerContract(stringContract('conf-string'));
|
|
563
|
+
const activatedA = await runtime.activate(signalWaitGraph());
|
|
564
|
+
expect(activatedA.ok).toBe(true);
|
|
565
|
+
const activationA = activatedA.ok ? activatedA.activationVersion : '';
|
|
566
|
+
const parked = await runtime.run('seed');
|
|
567
|
+
expect(parked.status).toBe('waiting');
|
|
568
|
+
expect(parked.activationVersion).toBe(activationA);
|
|
569
|
+
// A NEW revision is registered and a new activation B is selected for
|
|
570
|
+
// future runs. B differs from A (revision change affects identity),
|
|
571
|
+
// and the suspended run remains pinned to A.
|
|
572
|
+
runtime.registerCapability({
|
|
573
|
+
id: 'first',
|
|
574
|
+
revision: '2',
|
|
575
|
+
effect: 'pure',
|
|
576
|
+
input: neutralJsonContract,
|
|
577
|
+
output: neutralJsonContract,
|
|
578
|
+
invoke: () => 'one-v2',
|
|
579
|
+
});
|
|
580
|
+
const activatedB = await runtime.activate(signalWaitGraph());
|
|
581
|
+
expect(activatedB.ok).toBe(true);
|
|
582
|
+
const activationB = activatedB.ok ? activatedB.activationVersion : '';
|
|
583
|
+
expect(activationB !== activationA).toBe(true);
|
|
584
|
+
// The suspended run resumes ONLY under its exact pinned activation.
|
|
585
|
+
// (Full cross-process restore is proven by the SQLite restart fixtures.)
|
|
586
|
+
const run = await fixture.orchestration.getOrchestrationRun(parked.runId);
|
|
587
|
+
expect(run?.activationVersion === activationA).toBe(true);
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
await fixture.dispose();
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
//# sourceMappingURL=orchestration-conformance.js.map
|