@openwop/openwop-conformance 1.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/LICENSE +201 -0
- package/README.md +241 -0
- package/api/asyncapi.yaml +481 -0
- package/api/openapi.yaml +830 -0
- package/api/redocly.yaml +8 -0
- package/coverage.md +80 -0
- package/dist/cli.js +161 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
- package/fixtures/conformance-agent-identity.json +27 -0
- package/fixtures/conformance-agent-low-confidence.json +29 -0
- package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
- package/fixtures/conformance-agent-memory-redaction.json +32 -0
- package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
- package/fixtures/conformance-agent-memory-ttl.json +31 -0
- package/fixtures/conformance-agent-pack-export.json +26 -0
- package/fixtures/conformance-agent-pack-install.json +26 -0
- package/fixtures/conformance-agent-pack-provenance.json +31 -0
- package/fixtures/conformance-agent-reasoning.json +29 -0
- package/fixtures/conformance-approval.json +27 -0
- package/fixtures/conformance-cancellable.json +33 -0
- package/fixtures/conformance-cap-breach.json +27 -0
- package/fixtures/conformance-capability-missing.json +23 -0
- package/fixtures/conformance-channel-ttl.json +60 -0
- package/fixtures/conformance-clarification.json +30 -0
- package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
- package/fixtures/conformance-conversation-lifecycle.json +32 -0
- package/fixtures/conformance-conversation-replay.json +33 -0
- package/fixtures/conformance-conversation-vs-clarification.json +26 -0
- package/fixtures/conformance-delay.json +33 -0
- package/fixtures/conformance-dispatch-loop.json +38 -0
- package/fixtures/conformance-failure.json +23 -0
- package/fixtures/conformance-idempotent.json +30 -0
- package/fixtures/conformance-identity.json +32 -0
- package/fixtures/conformance-interrupt-auth-required.json +28 -0
- package/fixtures/conformance-interrupt-external-event.json +33 -0
- package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
- package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
- package/fixtures/conformance-interrupt-quorum.json +30 -0
- package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
- package/fixtures/conformance-message-reducer.json +31 -0
- package/fixtures/conformance-multi-node.json +21 -0
- package/fixtures/conformance-noop.json +23 -0
- package/fixtures/conformance-orchestrator-dispatch.json +47 -0
- package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
- package/fixtures/conformance-orchestrator-terminate.json +44 -0
- package/fixtures/conformance-stream-text.json +26 -0
- package/fixtures/conformance-subworkflow-child.json +21 -0
- package/fixtures/conformance-subworkflow-parent.json +49 -0
- package/fixtures/conformance-version-fold.json +23 -0
- package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
- package/fixtures/pack-manifests/pack-private-example.json +26 -0
- package/fixtures.md +404 -0
- package/package.json +48 -0
- package/schemas/README.md +75 -0
- package/schemas/agent-manifest.schema.json +107 -0
- package/schemas/agent-ref.schema.json +53 -0
- package/schemas/capabilities.schema.json +287 -0
- package/schemas/channel-written-payload.schema.json +55 -0
- package/schemas/conversation-event.schema.json +120 -0
- package/schemas/conversation-turn.schema.json +72 -0
- package/schemas/debug-bundle.schema.json +196 -0
- package/schemas/dispatch-config.schema.json +46 -0
- package/schemas/error-envelope.schema.json +25 -0
- package/schemas/memory-entry.schema.json +36 -0
- package/schemas/memory-list-options.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +235 -0
- package/schemas/orchestrator-decision.schema.json +60 -0
- package/schemas/run-event-payloads.schema.json +663 -0
- package/schemas/run-event.schema.json +116 -0
- package/schemas/run-options.schema.json +81 -0
- package/schemas/run-orchestrator-decided-event.schema.json +20 -0
- package/schemas/run-snapshot.schema.json +121 -0
- package/schemas/suspend-request.schema.json +182 -0
- package/schemas/workflow-definition.schema.json +430 -0
- package/src/cli.ts +187 -0
- package/src/lib/a2a-fake-peer.ts +233 -0
- package/src/lib/canaries.ts +186 -0
- package/src/lib/driver.ts +96 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/fixtures.ts +93 -0
- package/src/lib/mcp-fake-server.ts +185 -0
- package/src/lib/multi-agent-capabilities.ts +155 -0
- package/src/lib/multiProcess.ts +141 -0
- package/src/lib/otel-collector.ts +312 -0
- package/src/lib/paths.ts +198 -0
- package/src/lib/polling.ts +81 -0
- package/src/lib/profiles.ts +258 -0
- package/src/lib/sse.ts +172 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
- package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
- package/src/scenarios/agentMessageReducer.test.ts +57 -0
- package/src/scenarios/agentMetadata.test.ts +56 -0
- package/src/scenarios/agentPackExport.test.ts +45 -0
- package/src/scenarios/agentPackInstall.test.ts +50 -0
- package/src/scenarios/agentPackProvenance.test.ts +53 -0
- package/src/scenarios/agentReasoningEvents.test.ts +72 -0
- package/src/scenarios/append-ordering.test.ts +91 -0
- package/src/scenarios/approval-payload.test.ts +120 -0
- package/src/scenarios/audit-log-integrity.test.ts +106 -0
- package/src/scenarios/auth.test.ts +55 -0
- package/src/scenarios/byok-roundtrip.test.ts +166 -0
- package/src/scenarios/cancellation.test.ts +68 -0
- package/src/scenarios/cap-breach.test.ts +149 -0
- package/src/scenarios/channel-ttl.test.ts +70 -0
- package/src/scenarios/configurable-schema.test.ts +76 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
- package/src/scenarios/conversationLifecycle.test.ts +64 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
- package/src/scenarios/cost-attribution.test.ts +207 -0
- package/src/scenarios/debugBundle.test.ts +222 -0
- package/src/scenarios/discovery.test.ts +147 -0
- package/src/scenarios/dispatchLoop.test.ts +52 -0
- package/src/scenarios/errors.test.ts +144 -0
- package/src/scenarios/eventOrdering.test.ts +144 -0
- package/src/scenarios/failure-path.test.ts +46 -0
- package/src/scenarios/fixtures-gating.test.ts +137 -0
- package/src/scenarios/fixtures-valid.test.ts +140 -0
- package/src/scenarios/highConcurrency.test.ts +263 -0
- package/src/scenarios/idempotency.test.ts +83 -0
- package/src/scenarios/idempotencyRetry.test.ts +130 -0
- package/src/scenarios/identity-passthrough.test.ts +54 -0
- package/src/scenarios/interrupt-approval.test.ts +97 -0
- package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
- package/src/scenarios/interrupt-clarification.test.ts +45 -0
- package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
- package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
- package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
- package/src/scenarios/interruptRace.test.ts +176 -0
- package/src/scenarios/maliciousManifest.test.ts +154 -0
- package/src/scenarios/mcp-discoverability.test.ts +129 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
- package/src/scenarios/multi-node-ordering.test.ts +60 -0
- package/src/scenarios/multi-region-idempotency.test.ts +52 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
- package/src/scenarios/orchestratorDispatch.test.ts +66 -0
- package/src/scenarios/orchestratorTermination.test.ts +54 -0
- package/src/scenarios/otel-emission.test.ts +113 -0
- package/src/scenarios/otel-trace-propagation.test.ts +90 -0
- package/src/scenarios/pack-registry-publish.test.ts +93 -0
- package/src/scenarios/pack-registry.test.ts +328 -0
- package/src/scenarios/pause-resume.test.ts +109 -0
- package/src/scenarios/policies.test.ts +162 -0
- package/src/scenarios/profileDerivation.test.ts +335 -0
- package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
- package/src/scenarios/rate-limit-envelope.test.ts +97 -0
- package/src/scenarios/redaction.test.ts +254 -0
- package/src/scenarios/redactionAdversarial.test.ts +162 -0
- package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
- package/src/scenarios/replay-fork.test.ts +216 -0
- package/src/scenarios/replayDeterminism.test.ts +171 -0
- package/src/scenarios/route-coverage.test.ts +129 -0
- package/src/scenarios/runs-lifecycle.test.ts +65 -0
- package/src/scenarios/runtime-capabilities.test.ts +118 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
- package/src/scenarios/staleClaim.test.ts +223 -0
- package/src/scenarios/stream-modes-buffer.test.ts +148 -0
- package/src/scenarios/stream-modes-mixed.test.ts +149 -0
- package/src/scenarios/stream-modes.test.ts +139 -0
- package/src/scenarios/streamReconnect.test.ts +162 -0
- package/src/scenarios/subworkflow.test.ts +126 -0
- package/src/scenarios/version-negotiation.test.ts +157 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
- package/src/scenarios/wasm-pack-load.test.ts +75 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
- package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
- package/src/setup.ts +173 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arbitrary-event fork coverage — exercises `POST /v1/runs/{runId}:fork`
|
|
3
|
+
* at mid-range `fromSeq` values, not just `fromSeq=0`.
|
|
4
|
+
*
|
|
5
|
+
* Closes the Track 5 (Replay & Determinism) gap from
|
|
6
|
+
* `docs/PROTOCOL-GAP-CLOSURE-PLAN.md`: the existing `replay-fork.test.ts`
|
|
7
|
+
* + `replayDeterminism.test.ts` files only fork from `fromSeq=0` (full
|
|
8
|
+
* replay from start). Real-world replay forks pick a mid-run sequence
|
|
9
|
+
* to skip past expensive nodes or branch from a known-good state, and
|
|
10
|
+
* `replay.md` §"Replay determinism" guarantees apply per-event — not
|
|
11
|
+
* just at the start. This file exercises the arbitrary-fromSeq path.
|
|
12
|
+
*
|
|
13
|
+
* Strategy: use the `conformance-multi-node` fixture (3 noop nodes
|
|
14
|
+
* a → b → c, producing roughly 7-9 events). Start a source run, wait
|
|
15
|
+
* for terminal, read the event log, then pick a fromSeq that lands
|
|
16
|
+
* between node boundaries (after node b's completion event) and fork
|
|
17
|
+
* from there.
|
|
18
|
+
*
|
|
19
|
+
* Gating:
|
|
20
|
+
* - Outer: `conformance-multi-node` fixture advertised (else the
|
|
21
|
+
* source run can't be created).
|
|
22
|
+
* - Inner: `capabilities.replay.modes` includes the mode under test
|
|
23
|
+
* (`'replay'` or `'branch'`); ctx.skip() when not advertised.
|
|
24
|
+
* - Inner: 501 response → ctx.skip() (mode advertised but not
|
|
25
|
+
* implemented; matches the existing scenarios' tolerance).
|
|
26
|
+
*
|
|
27
|
+
* @see spec/v1/replay.md §"Replay-from-event-log internals"
|
|
28
|
+
* @see docs/PROTOCOL-GAP-CLOSURE-PLAN.md Track 5
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
34
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
35
|
+
|
|
36
|
+
const MULTI_NODE_WORKFLOW_ID = 'conformance-multi-node';
|
|
37
|
+
const SKIP_NO_MULTI = !isFixtureAdvertised(MULTI_NODE_WORKFLOW_ID);
|
|
38
|
+
|
|
39
|
+
interface RawEvent {
|
|
40
|
+
readonly eventId?: string;
|
|
41
|
+
readonly seq?: number;
|
|
42
|
+
readonly sequence?: number;
|
|
43
|
+
readonly type?: string;
|
|
44
|
+
readonly nodeId?: string | null;
|
|
45
|
+
readonly data?: unknown;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getSeq(e: RawEvent): number | null {
|
|
50
|
+
if (typeof e.sequence === 'number') return e.sequence;
|
|
51
|
+
if (typeof e.seq === 'number') return e.seq;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ReplayCapability {
|
|
56
|
+
supported?: unknown;
|
|
57
|
+
modes?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchReplayCapability(): Promise<ReplayCapability | null> {
|
|
61
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
62
|
+
if (res.status !== 200) return null;
|
|
63
|
+
return (res.json as { replay?: ReplayCapability })?.replay ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function startAndFinishMultiNode(): Promise<string> {
|
|
67
|
+
const create = await driver.post('/v1/runs', { workflowId: MULTI_NODE_WORKFLOW_ID });
|
|
68
|
+
if (create.status !== 201) {
|
|
69
|
+
throw new Error(`Failed to start ${MULTI_NODE_WORKFLOW_ID}: ${create.status}`);
|
|
70
|
+
}
|
|
71
|
+
const runId = (create.json as { runId: string }).runId;
|
|
72
|
+
await pollUntilTerminal(runId, { timeoutMs: 15_000 });
|
|
73
|
+
return runId;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readEvents(runId: string): Promise<readonly RawEvent[]> {
|
|
77
|
+
const res = await driver.get(
|
|
78
|
+
`/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`,
|
|
79
|
+
);
|
|
80
|
+
if (res.status !== 200) {
|
|
81
|
+
throw new Error(`Failed to read events for ${runId}: ${res.status}`);
|
|
82
|
+
}
|
|
83
|
+
return (res.json as { events?: RawEvent[] })?.events ?? [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pick a mid-range `fromSeq` corresponding to "after node B completes."
|
|
88
|
+
* In the conformance-multi-node fixture (a → b → c), forking here means
|
|
89
|
+
* a + b are inherited as fixed history and only c is re-executed.
|
|
90
|
+
*
|
|
91
|
+
* Returns `null` if the event log doesn't contain a recognisable
|
|
92
|
+
* `node.completed` event for node b — caller treats that as
|
|
93
|
+
* "skip arbitrary-fromSeq tests, the fixture's wire shape is host-
|
|
94
|
+
* specific in a way this scenario doesn't yet handle." Better than
|
|
95
|
+
* a false negative against a perfectly-conformant but unusual host.
|
|
96
|
+
*/
|
|
97
|
+
function pickMidRangeFromSeq(events: readonly RawEvent[]): number | null {
|
|
98
|
+
const bCompleted = events.find(
|
|
99
|
+
(e) => e.type === 'node.completed' && e.nodeId === 'b',
|
|
100
|
+
);
|
|
101
|
+
if (!bCompleted) return null;
|
|
102
|
+
const seq = getSeq(bCompleted);
|
|
103
|
+
if (seq === null) return null;
|
|
104
|
+
return seq + 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function structuralShape(
|
|
108
|
+
events: readonly RawEvent[],
|
|
109
|
+
): Array<{ type: unknown; nodeId: unknown; data: unknown }> {
|
|
110
|
+
return events.map((e) => ({
|
|
111
|
+
type: e.type,
|
|
112
|
+
nodeId: e.nodeId ?? null,
|
|
113
|
+
data: e.data ?? null,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe.skipIf(SKIP_NO_MULTI)(
|
|
118
|
+
'replay-fork-arbitrary: fork from mid-range fromSeq in replay mode reaches terminal',
|
|
119
|
+
() => {
|
|
120
|
+
it('mid-fromSeq replay fork produces a new run that reaches `completed`', async (ctx) => {
|
|
121
|
+
const replay = await fetchReplayCapability();
|
|
122
|
+
if (replay?.supported !== true) {
|
|
123
|
+
ctx.skip();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const modes = Array.isArray(replay.modes)
|
|
127
|
+
? replay.modes.filter((m): m is string => typeof m === 'string')
|
|
128
|
+
: [];
|
|
129
|
+
if (!modes.includes('replay')) {
|
|
130
|
+
ctx.skip();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sourceRunId = await startAndFinishMultiNode();
|
|
135
|
+
const sourceEvents = await readEvents(sourceRunId);
|
|
136
|
+
const fromSeq = pickMidRangeFromSeq(sourceEvents);
|
|
137
|
+
if (fromSeq === null) {
|
|
138
|
+
// Fixture's wire shape doesn't expose node.completed(b) with a
|
|
139
|
+
// numeric sequence — skip rather than fail. Conformant hosts
|
|
140
|
+
// with the standard event shape will hit the assertions below.
|
|
141
|
+
ctx.skip();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const fork = await driver.post(
|
|
146
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
147
|
+
{ fromSeq, mode: 'replay' },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (fork.status === 501) {
|
|
151
|
+
ctx.skip();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
expect(
|
|
155
|
+
fork.status,
|
|
156
|
+
driver.describe(
|
|
157
|
+
'spec/v1/replay.md §"Replay-from-event-log internals"',
|
|
158
|
+
`mid-range fromSeq=${fromSeq} replay fork MUST return 201`,
|
|
159
|
+
),
|
|
160
|
+
).toBe(201);
|
|
161
|
+
|
|
162
|
+
const body = fork.json as {
|
|
163
|
+
runId?: unknown;
|
|
164
|
+
sourceRunId?: unknown;
|
|
165
|
+
mode?: unknown;
|
|
166
|
+
};
|
|
167
|
+
expect(typeof body.runId, 'fork response MUST include a new runId').toBe(
|
|
168
|
+
'string',
|
|
169
|
+
);
|
|
170
|
+
expect(
|
|
171
|
+
body.runId,
|
|
172
|
+
'forked runId MUST differ from source',
|
|
173
|
+
).not.toBe(sourceRunId);
|
|
174
|
+
expect(body.sourceRunId, 'fork response MUST echo sourceRunId').toBe(
|
|
175
|
+
sourceRunId,
|
|
176
|
+
);
|
|
177
|
+
expect(body.mode, 'fork response MUST echo mode').toBe('replay');
|
|
178
|
+
|
|
179
|
+
const newRunId = body.runId as string;
|
|
180
|
+
const terminal = await pollUntilTerminal(newRunId, { timeoutMs: 15_000 });
|
|
181
|
+
expect(
|
|
182
|
+
terminal.status,
|
|
183
|
+
driver.describe(
|
|
184
|
+
'spec/v1/replay.md §"Replay determinism"',
|
|
185
|
+
`replay fork from mid-range fromSeq=${fromSeq} MUST reach the same terminal status as the source`,
|
|
186
|
+
),
|
|
187
|
+
).toBe('completed');
|
|
188
|
+
}, 60_000);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
describe.skipIf(SKIP_NO_MULTI)(
|
|
193
|
+
'replay-fork-arbitrary: two replay forks at the same mid-range fromSeq yield identical event shape',
|
|
194
|
+
() => {
|
|
195
|
+
it(
|
|
196
|
+
'mid-fromSeq determinism — same source + same fromSeq → identical post-fork events (modulo IDs + timestamps)',
|
|
197
|
+
async (ctx) => {
|
|
198
|
+
const replay = await fetchReplayCapability();
|
|
199
|
+
if (replay?.supported !== true) {
|
|
200
|
+
ctx.skip();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const modes = Array.isArray(replay.modes)
|
|
204
|
+
? replay.modes.filter((m): m is string => typeof m === 'string')
|
|
205
|
+
: [];
|
|
206
|
+
if (!modes.includes('replay')) {
|
|
207
|
+
ctx.skip();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sourceRunId = await startAndFinishMultiNode();
|
|
212
|
+
const sourceEvents = await readEvents(sourceRunId);
|
|
213
|
+
const fromSeq = pickMidRangeFromSeq(sourceEvents);
|
|
214
|
+
if (fromSeq === null) {
|
|
215
|
+
ctx.skip();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Fork twice at the same mid-range point. Per replay.md, both
|
|
220
|
+
// re-executions of the same source past the same fromSeq MUST
|
|
221
|
+
// produce structurally-identical event tails (modulo
|
|
222
|
+
// timestamps, eventIds, runIds — handled by structuralShape).
|
|
223
|
+
const fork1 = await driver.post(
|
|
224
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
225
|
+
{ fromSeq, mode: 'replay' },
|
|
226
|
+
);
|
|
227
|
+
if (fork1.status === 501) {
|
|
228
|
+
ctx.skip();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
expect(fork1.status).toBe(201);
|
|
232
|
+
const fork1Id = (fork1.json as { runId: string }).runId;
|
|
233
|
+
await pollUntilTerminal(fork1Id, { timeoutMs: 15_000 });
|
|
234
|
+
|
|
235
|
+
const fork2 = await driver.post(
|
|
236
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
237
|
+
{ fromSeq, mode: 'replay' },
|
|
238
|
+
);
|
|
239
|
+
if (fork2.status === 501) {
|
|
240
|
+
ctx.skip();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
expect(fork2.status).toBe(201);
|
|
244
|
+
const fork2Id = (fork2.json as { runId: string }).runId;
|
|
245
|
+
await pollUntilTerminal(fork2Id, { timeoutMs: 15_000 });
|
|
246
|
+
|
|
247
|
+
const fork1Events = await readEvents(fork1Id);
|
|
248
|
+
const fork2Events = await readEvents(fork2Id);
|
|
249
|
+
|
|
250
|
+
// Filter to events emitted AFTER the inherited prefix. Per
|
|
251
|
+
// replay.md, events at sequence < fromSeq are fixed history
|
|
252
|
+
// (inherited verbatim from source); events at sequence >=
|
|
253
|
+
// fromSeq are re-executed and MUST be deterministic across
|
|
254
|
+
// identical-input replays of the same source point.
|
|
255
|
+
//
|
|
256
|
+
// Because the fork inherits the prefix wholesale, both forks
|
|
257
|
+
// share IDENTICAL events for seq < fromSeq. The interesting
|
|
258
|
+
// determinism property is on the re-executed tail.
|
|
259
|
+
const tail1 = fork1Events.filter((e) => {
|
|
260
|
+
const s = getSeq(e);
|
|
261
|
+
return s !== null && s >= fromSeq;
|
|
262
|
+
});
|
|
263
|
+
const tail2 = fork2Events.filter((e) => {
|
|
264
|
+
const s = getSeq(e);
|
|
265
|
+
return s !== null && s >= fromSeq;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(
|
|
269
|
+
tail1.length,
|
|
270
|
+
driver.describe(
|
|
271
|
+
'spec/v1/replay.md §"Replay determinism"',
|
|
272
|
+
`two replay forks at fromSeq=${fromSeq} MUST produce the same number of re-executed events`,
|
|
273
|
+
),
|
|
274
|
+
).toBe(tail2.length);
|
|
275
|
+
|
|
276
|
+
expect(
|
|
277
|
+
structuralShape(tail1),
|
|
278
|
+
driver.describe(
|
|
279
|
+
'spec/v1/replay.md §"Replay determinism"',
|
|
280
|
+
`event sequence (type/nodeId/data) post-fromSeq=${fromSeq} MUST be identical across two replay forks`,
|
|
281
|
+
),
|
|
282
|
+
).toEqual(structuralShape(tail2));
|
|
283
|
+
},
|
|
284
|
+
90_000,
|
|
285
|
+
);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
describe.skipIf(SKIP_NO_MULTI)(
|
|
290
|
+
'replay-fork-arbitrary: fork from mid-range fromSeq in branch mode reaches terminal with overlay',
|
|
291
|
+
() => {
|
|
292
|
+
it('mid-fromSeq branch fork with empty overlay produces a new run that reaches `completed`', async (ctx) => {
|
|
293
|
+
const replay = await fetchReplayCapability();
|
|
294
|
+
if (replay?.supported !== true) {
|
|
295
|
+
ctx.skip();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const modes = Array.isArray(replay.modes)
|
|
299
|
+
? replay.modes.filter((m): m is string => typeof m === 'string')
|
|
300
|
+
: [];
|
|
301
|
+
if (!modes.includes('branch')) {
|
|
302
|
+
ctx.skip();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const sourceRunId = await startAndFinishMultiNode();
|
|
307
|
+
const sourceEvents = await readEvents(sourceRunId);
|
|
308
|
+
const fromSeq = pickMidRangeFromSeq(sourceEvents);
|
|
309
|
+
if (fromSeq === null) {
|
|
310
|
+
ctx.skip();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Branch mode with an empty overlay is the boundary case: the
|
|
315
|
+
// overlay is documented as branch-only, and an empty overlay
|
|
316
|
+
// exercises the schema gate while leaving runtime behavior
|
|
317
|
+
// semantically equivalent to a replay (no input change). The
|
|
318
|
+
// run still MUST reach terminal `completed`.
|
|
319
|
+
const fork = await driver.post(
|
|
320
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
321
|
+
{ fromSeq, mode: 'branch', runOptionsOverlay: {} },
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
if (fork.status === 501) {
|
|
325
|
+
ctx.skip();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
expect(
|
|
329
|
+
fork.status,
|
|
330
|
+
driver.describe(
|
|
331
|
+
'spec/v1/replay.md §"branch mode"',
|
|
332
|
+
`mid-range fromSeq=${fromSeq} branch fork with empty overlay MUST return 201`,
|
|
333
|
+
),
|
|
334
|
+
).toBe(201);
|
|
335
|
+
|
|
336
|
+
const newRunId = (fork.json as { runId: string }).runId;
|
|
337
|
+
const terminal = await pollUntilTerminal(newRunId, { timeoutMs: 15_000 });
|
|
338
|
+
expect(
|
|
339
|
+
terminal.status,
|
|
340
|
+
driver.describe(
|
|
341
|
+
'spec/v1/replay.md',
|
|
342
|
+
`branch fork from mid-range fromSeq=${fromSeq} MUST reach terminal status`,
|
|
343
|
+
),
|
|
344
|
+
).toBe('completed');
|
|
345
|
+
}, 60_000);
|
|
346
|
+
},
|
|
347
|
+
);
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay/fork scenarios — exercises `POST /v1/runs/{runId}:fork` per
|
|
3
|
+
* `replay.md` and `rest-endpoints.md`.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: start a `conformance-noop` run, wait for terminal, then
|
|
6
|
+
* fork it. Two modes covered:
|
|
7
|
+
* - replay: re-execute from `fromSeq=0` (full replay). Should produce
|
|
8
|
+
* a new runId in terminal `completed` with no inputs change.
|
|
9
|
+
* - branch: re-execute from `fromSeq=0` with optional runOptionsOverlay.
|
|
10
|
+
*
|
|
11
|
+
* Plus error-path tests:
|
|
12
|
+
* - 400 on negative fromSeq.
|
|
13
|
+
* - 422 on fromSeq beyond the source run's event log length.
|
|
14
|
+
* - 400 on `replay` mode with non-empty runOptionsOverlay (per
|
|
15
|
+
* openapi.yaml — overlay is for branch only).
|
|
16
|
+
*
|
|
17
|
+
* Mode-enumeration gating: tests are gated on advertised
|
|
18
|
+
* `capabilities.replay.modes` per
|
|
19
|
+
* `spec/v1/profiles.md` §"openwop-replay-fork." A host advertising only
|
|
20
|
+
* `['branch']` (e.g., OpenWOP) skip-equivalents the replay-mode
|
|
21
|
+
* tests; a host advertising only `['replay']` skip-equivalents the
|
|
22
|
+
* branch-mode tests. Hosts that advertise no replay capability at all
|
|
23
|
+
* skip every test in this file.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { driver } from '../lib/driver.js';
|
|
28
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
29
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
30
|
+
|
|
31
|
+
const SOURCE_WORKFLOW_ID = 'conformance-noop';
|
|
32
|
+
const SKIP_NO_NOOP = !isFixtureAdvertised(SOURCE_WORKFLOW_ID);
|
|
33
|
+
|
|
34
|
+
async function fetchReplayModes(): Promise<readonly string[]> {
|
|
35
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
36
|
+
if (res.status !== 200) return [];
|
|
37
|
+
// Discovery body IS the capabilities object — `replay` lives at the
|
|
38
|
+
// top level, not under a `capabilities` envelope. Matches the
|
|
39
|
+
// convention in lib/profiles.ts:isReplayFork() + replayDeterminism.test.ts.
|
|
40
|
+
const replay = (res.json as { replay?: { supported?: unknown; modes?: unknown } })?.replay;
|
|
41
|
+
if (replay?.supported !== true) return [];
|
|
42
|
+
if (!Array.isArray(replay.modes)) return [];
|
|
43
|
+
return replay.modes.filter((m): m is string => typeof m === 'string');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function startAndFinishNoop(): Promise<string> {
|
|
47
|
+
const create = await driver.post('/v1/runs', { workflowId: SOURCE_WORKFLOW_ID });
|
|
48
|
+
if (create.status !== 201) {
|
|
49
|
+
throw new Error(`Failed to start ${SOURCE_WORKFLOW_ID}: ${create.status}`);
|
|
50
|
+
}
|
|
51
|
+
const runId = (create.json as { runId: string }).runId;
|
|
52
|
+
await pollUntilTerminal(runId);
|
|
53
|
+
return runId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in replay mode', () => {
|
|
57
|
+
it('produces a new run that reaches terminal `completed`', async (ctx) => {
|
|
58
|
+
const modes = await fetchReplayModes();
|
|
59
|
+
if (!modes.includes('replay')) {
|
|
60
|
+
// Visible skip — earlier this was a silent `return` that
|
|
61
|
+
// collapsed to a vacuous pass and made it impossible to tell
|
|
62
|
+
// unexercised tests apart from honest passes.
|
|
63
|
+
ctx.skip();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const sourceRunId = await startAndFinishNoop();
|
|
67
|
+
|
|
68
|
+
const fork = await driver.post(
|
|
69
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
70
|
+
{ fromSeq: 0, mode: 'replay' },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (fork.status === 501) return; // mode advertised but not implemented; skip-equivalent
|
|
74
|
+
expect(fork.status, driver.describe(
|
|
75
|
+
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
76
|
+
'fork MUST return 201 on accepted replay',
|
|
77
|
+
)).toBe(201);
|
|
78
|
+
|
|
79
|
+
const body = fork.json as { runId?: unknown; sourceRunId?: unknown; mode?: unknown };
|
|
80
|
+
expect(typeof body.runId, driver.describe(
|
|
81
|
+
'replay.md',
|
|
82
|
+
'fork response MUST include a new runId',
|
|
83
|
+
)).toBe('string');
|
|
84
|
+
expect(body.runId, 'forked runId MUST differ from source').not.toBe(sourceRunId);
|
|
85
|
+
expect(body.sourceRunId, driver.describe(
|
|
86
|
+
'replay.md',
|
|
87
|
+
'fork response MUST echo sourceRunId',
|
|
88
|
+
)).toBe(sourceRunId);
|
|
89
|
+
expect(body.mode, 'fork response MUST echo mode').toBe('replay');
|
|
90
|
+
|
|
91
|
+
const newRunId = body.runId as string;
|
|
92
|
+
const terminal = await pollUntilTerminal(newRunId, { timeoutMs: 15_000 });
|
|
93
|
+
expect(terminal.status, driver.describe(
|
|
94
|
+
'replay.md',
|
|
95
|
+
'replay of a successful run MUST reach the same terminal status',
|
|
96
|
+
)).toBe('completed');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in branch mode with empty overlay', () => {
|
|
101
|
+
it('produces a new run that reaches terminal `completed`', async (ctx) => {
|
|
102
|
+
const modes = await fetchReplayModes();
|
|
103
|
+
if (!modes.includes('branch')) {
|
|
104
|
+
ctx.skip();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const sourceRunId = await startAndFinishNoop();
|
|
108
|
+
|
|
109
|
+
const fork = await driver.post(
|
|
110
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
111
|
+
{ fromSeq: 0, mode: 'branch', runOptionsOverlay: {} },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (fork.status === 501) return; // mode advertised but not implemented; skip-equivalent
|
|
115
|
+
expect(fork.status, driver.describe(
|
|
116
|
+
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
117
|
+
'branch fork MUST return 201',
|
|
118
|
+
)).toBe(201);
|
|
119
|
+
|
|
120
|
+
const body = fork.json as { runId: string; mode: string };
|
|
121
|
+
expect(body.mode).toBe('branch');
|
|
122
|
+
|
|
123
|
+
const terminal = await pollUntilTerminal(body.runId, { timeoutMs: 15_000 });
|
|
124
|
+
expect(terminal.status).toBe('completed');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
129
|
+
// Earlier each of these tests had a silent `return;` early-exit
|
|
130
|
+
// when the host advertised no replay modes (or only the wrong mode for
|
|
131
|
+
// the assertion). That collapsed unexercised paths into vacuous green
|
|
132
|
+
// — a host that didn't implement replay/fork at all "passed" every
|
|
133
|
+
// validation test. Migrated to `ctx.skip()` so suite output now
|
|
134
|
+
// distinguishes "skipped because host doesn't claim this surface" from
|
|
135
|
+
// "exercised the validation path and got the expected error code."
|
|
136
|
+
|
|
137
|
+
it('rejects negative fromSeq with 400', async (ctx) => {
|
|
138
|
+
const modes = await fetchReplayModes();
|
|
139
|
+
if (modes.length === 0) {
|
|
140
|
+
ctx.skip();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const mode = modes.includes('branch') ? 'branch' : 'replay';
|
|
144
|
+
const sourceRunId = await startAndFinishNoop();
|
|
145
|
+
const res = await driver.post(
|
|
146
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
147
|
+
{ fromSeq: -1, mode },
|
|
148
|
+
);
|
|
149
|
+
expect(res.status, driver.describe(
|
|
150
|
+
'rest-endpoints.md',
|
|
151
|
+
'negative fromSeq MUST return 400',
|
|
152
|
+
)).toBe(400);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects fromSeq beyond source event log length with 422', async (ctx) => {
|
|
156
|
+
const modes = await fetchReplayModes();
|
|
157
|
+
if (modes.length === 0) {
|
|
158
|
+
ctx.skip();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const mode = modes.includes('branch') ? 'branch' : 'replay';
|
|
162
|
+
const sourceRunId = await startAndFinishNoop();
|
|
163
|
+
// conformance-noop has at most a handful of events; 99999 is
|
|
164
|
+
// guaranteed to be past the end.
|
|
165
|
+
const res = await driver.post(
|
|
166
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
167
|
+
{ fromSeq: 99999, mode },
|
|
168
|
+
);
|
|
169
|
+
expect(res.status, driver.describe(
|
|
170
|
+
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
171
|
+
'fromSeq beyond source event log MUST return 422',
|
|
172
|
+
)).toBe(422);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rejects replay mode with non-empty runOptionsOverlay (overlay is branch-only)', async (ctx) => {
|
|
176
|
+
const modes = await fetchReplayModes();
|
|
177
|
+
if (!modes.includes('replay')) {
|
|
178
|
+
// The rule under test (`replay` + non-empty `runOptionsOverlay`
|
|
179
|
+
// → 400) only applies on hosts that advertise the `replay` mode.
|
|
180
|
+
// A `branch`-only host has no path to even attempt the request.
|
|
181
|
+
// Visible skip rather than silent vacuous pass.
|
|
182
|
+
ctx.skip();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const sourceRunId = await startAndFinishNoop();
|
|
186
|
+
const res = await driver.post(
|
|
187
|
+
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
188
|
+
{
|
|
189
|
+
fromSeq: 0,
|
|
190
|
+
mode: 'replay',
|
|
191
|
+
runOptionsOverlay: { configurable: { recursionLimit: 50 } },
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
expect(res.status, driver.describe(
|
|
195
|
+
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
196
|
+
'replay mode + non-empty overlay MUST return 400 (overlay is branch-only)',
|
|
197
|
+
)).toBe(400);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects fork on a non-existent run with 404', async (ctx) => {
|
|
201
|
+
const modes = await fetchReplayModes();
|
|
202
|
+
if (modes.length === 0) {
|
|
203
|
+
ctx.skip();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const mode = modes.includes('branch') ? 'branch' : 'replay';
|
|
207
|
+
const res = await driver.post(
|
|
208
|
+
'/v1/runs/openwop-conformance-no-such-run-id:fork',
|
|
209
|
+
{ fromSeq: 0, mode },
|
|
210
|
+
);
|
|
211
|
+
expect(
|
|
212
|
+
[403, 404].includes(res.status),
|
|
213
|
+
driver.describe('rest-endpoints.md', 'fork on unknown run MUST return 404 or 403'),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|