@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,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-concurrency scenarios — drives N parallel run creations against
|
|
3
|
+
* a host and asserts correct rate-limit, idempotency, and queue
|
|
4
|
+
* behavior per `spec/v1/scale-profiles.md`.
|
|
5
|
+
*
|
|
6
|
+
* **Tagged `@scale-profile-production`** — these scenarios are gated on
|
|
7
|
+
* the host claiming `production` or `high-throughput`. A host claiming
|
|
8
|
+
* `minimal` MAY skip via env var `OPENWOP_SKIP_SCALE_PRODUCTION=1`.
|
|
9
|
+
*
|
|
10
|
+
* Methodology:
|
|
11
|
+
* - Spawn N concurrent `POST /v1/runs` requests with a mix of
|
|
12
|
+
* idempotency-keyed and non-idempotency-keyed requests.
|
|
13
|
+
* - Measure: success count, idempotency-replay count, 429/503 count,
|
|
14
|
+
* wall-clock from first request to last response.
|
|
15
|
+
* - Assert: zero double-execution; rate-limit responses carry
|
|
16
|
+
* Retry-After; advertised ack-timeout (per RFC 0002) honored.
|
|
17
|
+
*
|
|
18
|
+
* Latency-percentile measurement is deliberately conservative: we run
|
|
19
|
+
* a small N (10) and verify shape, not microbenchmark numbers, because
|
|
20
|
+
* conformance must be reproducible across host environments. Real
|
|
21
|
+
* benchmark runs (against `production`/`high-throughput` claims) need
|
|
22
|
+
* larger N + warm-up, which is out of scope for the conformance suite.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { driver } from '../lib/driver.js';
|
|
27
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
28
|
+
|
|
29
|
+
const WORKFLOW_ID = 'conformance-idempotent';
|
|
30
|
+
const SKIP_SCALE = process.env.OPENWOP_SKIP_SCALE_PRODUCTION === '1';
|
|
31
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
32
|
+
const SKIP = SKIP_SCALE || SKIP_NO_FIXTURE;
|
|
33
|
+
|
|
34
|
+
function freshKey(suffix: string): string {
|
|
35
|
+
return `openwop-conformance-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${suffix}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RunCreateResult {
|
|
39
|
+
status: number;
|
|
40
|
+
runId: string | undefined;
|
|
41
|
+
replay: boolean;
|
|
42
|
+
retryAfter: number | undefined;
|
|
43
|
+
errorCode: string | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function createRun(body: unknown, key?: string): Promise<RunCreateResult> {
|
|
47
|
+
const headers: Record<string, string> = {};
|
|
48
|
+
if (key !== undefined) headers['Idempotency-Key'] = key;
|
|
49
|
+
const res = await driver.post('/v1/runs', body, { headers });
|
|
50
|
+
|
|
51
|
+
const json = res.json as {
|
|
52
|
+
runId?: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
details?: { retryAfter?: number };
|
|
55
|
+
} | undefined;
|
|
56
|
+
const retryAfterHeader = res.headers.get('retry-after');
|
|
57
|
+
return {
|
|
58
|
+
status: res.status,
|
|
59
|
+
runId: json?.runId,
|
|
60
|
+
replay: res.headers.get('openwop-idempotent-replay') === 'true',
|
|
61
|
+
retryAfter: json?.details?.retryAfter ?? (retryAfterHeader !== null ? Number(retryAfterHeader) : undefined),
|
|
62
|
+
errorCode: json?.error,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe.skipIf(SKIP)(
|
|
67
|
+
'high-concurrency: parallel POST /v1/runs per scale-profiles.md §"Conformance scenarios"',
|
|
68
|
+
() => {
|
|
69
|
+
it(
|
|
70
|
+
'10 parallel requests with same key yield ONE runId and 9 replays',
|
|
71
|
+
async () => {
|
|
72
|
+
const key = freshKey('parallel-same-key');
|
|
73
|
+
const body = { workflowId: WORKFLOW_ID, inputs: { nonce: 'parallel-1' } };
|
|
74
|
+
|
|
75
|
+
const results = await Promise.all(
|
|
76
|
+
Array.from({ length: 10 }, () => createRun(body, key)),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const succeeded = results.filter((r) => r.status === 200 || r.status === 201);
|
|
80
|
+
const conflicted = results.filter((r) => r.status === 409);
|
|
81
|
+
|
|
82
|
+
expect(
|
|
83
|
+
succeeded.length + conflicted.length,
|
|
84
|
+
driver.describe(
|
|
85
|
+
'idempotency.md §Concurrent duplicates',
|
|
86
|
+
'every concurrent request MUST resolve to either the cached response or a deterministic 409',
|
|
87
|
+
),
|
|
88
|
+
).toBe(10);
|
|
89
|
+
|
|
90
|
+
const runIds = new Set(succeeded.map((r) => r.runId).filter((id): id is string => !!id));
|
|
91
|
+
expect(
|
|
92
|
+
runIds.size,
|
|
93
|
+
driver.describe(
|
|
94
|
+
'idempotency.md §Layer 1',
|
|
95
|
+
'same idempotency key MUST yield exactly ONE runId across all successful responses',
|
|
96
|
+
),
|
|
97
|
+
).toBe(1);
|
|
98
|
+
|
|
99
|
+
// Per idempotency.md, 409 carries idempotency_in_flight and
|
|
100
|
+
// retry-after metadata under the canonical details slot.
|
|
101
|
+
for (const c of conflicted) {
|
|
102
|
+
expect(
|
|
103
|
+
c.errorCode === 'idempotency_in_flight' || c.errorCode === undefined,
|
|
104
|
+
driver.describe(
|
|
105
|
+
'idempotency.md',
|
|
106
|
+
'409 on parallel idempotency-keyed retry MUST carry error="idempotency_in_flight"',
|
|
107
|
+
),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
if (c.retryAfter !== undefined) {
|
|
110
|
+
expect(
|
|
111
|
+
c.retryAfter,
|
|
112
|
+
driver.describe(
|
|
113
|
+
'idempotency.md',
|
|
114
|
+
'409 idempotency_in_flight MUST include a numeric retryAfter',
|
|
115
|
+
),
|
|
116
|
+
).toBeGreaterThan(0);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
30000,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
it(
|
|
124
|
+
'10 parallel requests with distinct keys yield 10 distinct runIds',
|
|
125
|
+
async () => {
|
|
126
|
+
const body = { workflowId: WORKFLOW_ID, inputs: { nonce: 'parallel-2' } };
|
|
127
|
+
|
|
128
|
+
const results = await Promise.all(
|
|
129
|
+
Array.from({ length: 10 }, (_, i) => createRun(body, freshKey(`parallel-distinct-${i}`))),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const succeeded = results.filter((r) => r.status === 200 || r.status === 201);
|
|
133
|
+
const rateLimited = results.filter((r) => r.status === 429 || r.status === 503);
|
|
134
|
+
|
|
135
|
+
// A `production`-tier host MUST handle 10 parallel run creations.
|
|
136
|
+
// A `minimal`-tier host MAY rate-limit; documented in scale-profiles.md.
|
|
137
|
+
expect(
|
|
138
|
+
succeeded.length + rateLimited.length,
|
|
139
|
+
driver.describe(
|
|
140
|
+
'rest-endpoints.md',
|
|
141
|
+
'concurrent POST /v1/runs requests MUST resolve to either success or rate-limited',
|
|
142
|
+
),
|
|
143
|
+
).toBe(10);
|
|
144
|
+
|
|
145
|
+
// Among successful responses, every runId is distinct (no Layer-1 dedup
|
|
146
|
+
// because keys are distinct).
|
|
147
|
+
const succeededRunIds = succeeded.map((r) => r.runId);
|
|
148
|
+
const uniqueRunIds = new Set(succeededRunIds);
|
|
149
|
+
expect(
|
|
150
|
+
uniqueRunIds.size,
|
|
151
|
+
driver.describe(
|
|
152
|
+
'idempotency.md §Layer 1',
|
|
153
|
+
'distinct idempotency keys MUST yield distinct runIds (Layer 1 dedup is keyed)',
|
|
154
|
+
),
|
|
155
|
+
).toBe(succeededRunIds.length);
|
|
156
|
+
|
|
157
|
+
// Any rate-limited response MUST set Retry-After per scale-profiles.md
|
|
158
|
+
// §"Backpressure semantics."
|
|
159
|
+
for (const r of rateLimited) {
|
|
160
|
+
expect(
|
|
161
|
+
r.retryAfter,
|
|
162
|
+
driver.describe(
|
|
163
|
+
'scale-profiles.md §Backpressure',
|
|
164
|
+
'429/503 response MUST include numeric Retry-After',
|
|
165
|
+
),
|
|
166
|
+
).toBeGreaterThan(0);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
30000,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
it(
|
|
173
|
+
'5 sequential retries with same key 100ms apart all succeed (idempotency cache survives retry storm)',
|
|
174
|
+
async () => {
|
|
175
|
+
const key = freshKey('retry-storm');
|
|
176
|
+
const body = { workflowId: WORKFLOW_ID, inputs: { nonce: 'retry-storm' } };
|
|
177
|
+
|
|
178
|
+
const results: RunCreateResult[] = [];
|
|
179
|
+
for (let i = 0; i < 5; i++) {
|
|
180
|
+
results.push(await createRun(body, key));
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const succeeded = results.filter((r) => r.status === 200 || r.status === 201);
|
|
185
|
+
expect(
|
|
186
|
+
succeeded.length,
|
|
187
|
+
driver.describe(
|
|
188
|
+
'scale-profiles.md §Retry semantics',
|
|
189
|
+
'host MUST handle ≥5 retries with same key 100ms apart without losing the cached response',
|
|
190
|
+
),
|
|
191
|
+
).toBe(5);
|
|
192
|
+
|
|
193
|
+
const runIds = new Set(succeeded.map((r) => r.runId));
|
|
194
|
+
expect(
|
|
195
|
+
runIds.size,
|
|
196
|
+
driver.describe(
|
|
197
|
+
'idempotency.md §Layer 1',
|
|
198
|
+
'all 5 retries with same key MUST resolve to the same runId',
|
|
199
|
+
),
|
|
200
|
+
).toBe(1);
|
|
201
|
+
|
|
202
|
+
// First request is fresh; subsequent are replays.
|
|
203
|
+
expect(
|
|
204
|
+
results[0]!.replay,
|
|
205
|
+
driver.describe(
|
|
206
|
+
'idempotency.md §Server responsibilities',
|
|
207
|
+
'first request with new key MUST NOT be marked as replay',
|
|
208
|
+
),
|
|
209
|
+
).toBe(false);
|
|
210
|
+
// RFC 0002 §1 promotes openwop-Idempotent-Replay from SHOULD to MUST.
|
|
211
|
+
// Until RFC 0002 is Accepted, the assertion is permissive: at
|
|
212
|
+
// least one of the subsequent requests SHOULD be marked as replay
|
|
213
|
+
// (the spec today says SHOULD, RFC 0002 promotes to MUST).
|
|
214
|
+
const someReplay = results.slice(1).some((r) => r.replay);
|
|
215
|
+
expect(
|
|
216
|
+
someReplay,
|
|
217
|
+
driver.describe(
|
|
218
|
+
'idempotency.md §Server responsibilities #2',
|
|
219
|
+
'replay responses SHOULD set openwop-Idempotent-Replay: true',
|
|
220
|
+
),
|
|
221
|
+
).toBe(true);
|
|
222
|
+
},
|
|
223
|
+
30000,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
it(
|
|
227
|
+
'concurrent distinct-key requests respect advertised idempotency cache retention',
|
|
228
|
+
async () => {
|
|
229
|
+
// This is a structural assertion against /.well-known/openwop, NOT a
|
|
230
|
+
// wall-clock test. We can't realistically wait 24h for cache
|
|
231
|
+
// expiration in a conformance run. Instead we assert the host
|
|
232
|
+
// advertises a cache retention compatible with scale-profiles.md.
|
|
233
|
+
const discovery = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
234
|
+
expect(discovery.status).toBe(200);
|
|
235
|
+
|
|
236
|
+
const limits = (discovery.json as { limits?: Record<string, unknown> })?.limits;
|
|
237
|
+
expect(
|
|
238
|
+
limits,
|
|
239
|
+
driver.describe(
|
|
240
|
+
'capabilities.md §3',
|
|
241
|
+
'limits MUST be advertised in discovery',
|
|
242
|
+
),
|
|
243
|
+
).toBeDefined();
|
|
244
|
+
|
|
245
|
+
// Per idempotency.md the optional `idempotencyAckTimeoutSec` field
|
|
246
|
+
// is a normative-additive field. If the host advertises it, the
|
|
247
|
+
// value MUST be ≥5. If absent, the spec default is 5.
|
|
248
|
+
const ackTimeout = (limits as { idempotencyAckTimeoutSec?: unknown })
|
|
249
|
+
?.idempotencyAckTimeoutSec;
|
|
250
|
+
if (ackTimeout !== undefined) {
|
|
251
|
+
expect(
|
|
252
|
+
typeof ackTimeout === 'number' && Number.isInteger(ackTimeout) && ackTimeout >= 5,
|
|
253
|
+
driver.describe(
|
|
254
|
+
'idempotency.md',
|
|
255
|
+
'limits.idempotencyAckTimeoutSec MUST be integer ≥5 when advertised',
|
|
256
|
+
),
|
|
257
|
+
).toBe(true);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
10000,
|
|
261
|
+
);
|
|
262
|
+
},
|
|
263
|
+
);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency scenarios — exercises the `Idempotency-Key` header
|
|
3
|
+
* contract on `POST /v1/runs` per `idempotency.md` and
|
|
4
|
+
* `rest-endpoints.md`.
|
|
5
|
+
*
|
|
6
|
+
* Uses the `conformance-idempotent` fixture. Server MUST have seeded
|
|
7
|
+
* it. The fixture's `nonce` input has no side effect — it exists so
|
|
8
|
+
* the conformance suite can vary the body without affecting behavior.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { driver } from '../lib/driver.js';
|
|
13
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
14
|
+
|
|
15
|
+
const WORKFLOW_ID = 'conformance-idempotent';
|
|
16
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
17
|
+
|
|
18
|
+
function freshKey(suffix: string): string {
|
|
19
|
+
return `openwop-conformance-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + same body replays per idempotency.md §Layer 1', () => {
|
|
23
|
+
it('returns same runId twice and sets openwop-Idempotent-Replay on the replay', async () => {
|
|
24
|
+
const key = freshKey('replay');
|
|
25
|
+
const body = { workflowId: WORKFLOW_ID, inputs: { nonce: 'abc-123' } };
|
|
26
|
+
|
|
27
|
+
const first = await driver.post('/v1/runs', body, {
|
|
28
|
+
headers: { 'Idempotency-Key': key },
|
|
29
|
+
});
|
|
30
|
+
expect(first.status, driver.describe(
|
|
31
|
+
'rest-endpoints.md',
|
|
32
|
+
'first POST /v1/runs MUST return 201',
|
|
33
|
+
)).toBe(201);
|
|
34
|
+
const firstRunId = (first.json as { runId: string }).runId;
|
|
35
|
+
|
|
36
|
+
const replay = await driver.post('/v1/runs', body, {
|
|
37
|
+
headers: { 'Idempotency-Key': key },
|
|
38
|
+
});
|
|
39
|
+
expect(
|
|
40
|
+
[200, 201].includes(replay.status),
|
|
41
|
+
driver.describe(
|
|
42
|
+
'idempotency.md §Layer 1',
|
|
43
|
+
'replay request with same key + same body MUST return success status (200/201)',
|
|
44
|
+
),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
|
|
47
|
+
const replayRunId = (replay.json as { runId: string }).runId;
|
|
48
|
+
expect(replayRunId, driver.describe(
|
|
49
|
+
'idempotency.md §Layer 1',
|
|
50
|
+
'replay MUST return the SAME runId (no new run created)',
|
|
51
|
+
)).toBe(firstRunId);
|
|
52
|
+
|
|
53
|
+
const replayHeader = replay.headers.get('openwop-idempotent-replay');
|
|
54
|
+
expect(replayHeader, driver.describe(
|
|
55
|
+
'rest-endpoints.md POST /v1/runs response headers',
|
|
56
|
+
'openwop-Idempotent-Replay header MUST be set on cache-served responses',
|
|
57
|
+
)).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + different body conflicts per idempotency.md §Layer 1', () => {
|
|
62
|
+
it('returns 409 when the body changes under the same key', async () => {
|
|
63
|
+
const key = freshKey('conflict');
|
|
64
|
+
|
|
65
|
+
const first = await driver.post(
|
|
66
|
+
'/v1/runs',
|
|
67
|
+
{ workflowId: WORKFLOW_ID, inputs: { nonce: 'first' } },
|
|
68
|
+
{ headers: { 'Idempotency-Key': key } },
|
|
69
|
+
);
|
|
70
|
+
expect(first.status).toBe(201);
|
|
71
|
+
|
|
72
|
+
const conflict = await driver.post(
|
|
73
|
+
'/v1/runs',
|
|
74
|
+
{ workflowId: WORKFLOW_ID, inputs: { nonce: 'DIFFERENT' } },
|
|
75
|
+
{ headers: { 'Idempotency-Key': key } },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(conflict.status, driver.describe(
|
|
79
|
+
'idempotency.md §Layer 1',
|
|
80
|
+
'same Idempotency-Key with a different body MUST return 409',
|
|
81
|
+
)).toBe(409);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency-retry scenarios per spec/v1/idempotency.md.
|
|
3
|
+
*
|
|
4
|
+
* Builds on `idempotency.test.ts` (which covers basic Layer-1 cache and
|
|
5
|
+
* 409-on-body-conflict) by exercising the deterministic-dispatch
|
|
6
|
+
* additions documented in idempotency.md:
|
|
7
|
+
*
|
|
8
|
+
* 1. openwop-Idempotent-Replay header is present on every keyed response
|
|
9
|
+
* (idempotency.md §Server responsibilities).
|
|
10
|
+
* 2. Retry-budget floor — hosts handle ≥5 retries 100ms apart with
|
|
11
|
+
* the cached response (scale-profiles.md §"Retry semantics").
|
|
12
|
+
* 3. Same-key replay returns same runId across the budget.
|
|
13
|
+
* 4. (Optional) hosts that advertise `limits.idempotencyAckTimeoutSec`
|
|
14
|
+
* MUST set it to integer ≥ 5 per idempotency.md.
|
|
15
|
+
*
|
|
16
|
+
* Profile gating: `openwop-core` (and `openwop-stream-poll` to read snapshots).
|
|
17
|
+
* Every conforming host runs these.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v1/idempotency.md
|
|
20
|
+
* @see spec/v1/scale-profiles.md §"Retry semantics"
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
26
|
+
|
|
27
|
+
const WORKFLOW_ID = 'conformance-idempotent';
|
|
28
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
29
|
+
|
|
30
|
+
function freshKey(suffix: string): string {
|
|
31
|
+
return `openwop-conformance-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${suffix}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe.skipIf(SKIP_NO_FIXTURE)('idempotency-retry: openwop-Idempotent-Replay header per idempotency.md', () => {
|
|
35
|
+
it('first request with new key returns false (or absent SHOULD per current spec); replay returns true', async () => {
|
|
36
|
+
const key = freshKey('replay-header');
|
|
37
|
+
const body = { workflowId: WORKFLOW_ID, inputs: { nonce: 'replay-test' } };
|
|
38
|
+
|
|
39
|
+
const first = await driver.post('/v1/runs', body, { headers: { 'Idempotency-Key': key } });
|
|
40
|
+
expect(first.status, driver.describe(
|
|
41
|
+
'rest-endpoints.md',
|
|
42
|
+
'first POST /v1/runs returns 201',
|
|
43
|
+
)).toBe(201);
|
|
44
|
+
|
|
45
|
+
const firstReplay = first.headers.get('openwop-idempotent-replay');
|
|
46
|
+
// Per idempotency.md: header SHOULD be present even on the first call,
|
|
47
|
+
// set to "false". Pre-RFC spec only requires it on the replay.
|
|
48
|
+
// Permissive assertion: if present, MUST be "false" or "true".
|
|
49
|
+
if (firstReplay !== null) {
|
|
50
|
+
expect(['false', 'true'].includes(firstReplay), driver.describe(
|
|
51
|
+
'idempotency.md §Server responsibilities',
|
|
52
|
+
'openwop-Idempotent-Replay value MUST be "true" or "false"',
|
|
53
|
+
)).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const replay = await driver.post('/v1/runs', body, { headers: { 'Idempotency-Key': key } });
|
|
57
|
+
expect(
|
|
58
|
+
[200, 201].includes(replay.status),
|
|
59
|
+
driver.describe('idempotency.md §Layer 1', 'replay returns 200 or 201'),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
|
|
62
|
+
const replayHeader = replay.headers.get('openwop-idempotent-replay');
|
|
63
|
+
// Per idempotency.md §Server responsibilities #2: SHOULD be set.
|
|
64
|
+
// RFC 0002 §1 promotes to MUST. Today's strictness: present on replay.
|
|
65
|
+
expect(replayHeader, driver.describe(
|
|
66
|
+
'idempotency.md §Server responsibilities #2',
|
|
67
|
+
'openwop-Idempotent-Replay SHOULD be set on idempotent replay responses',
|
|
68
|
+
)).not.toBeNull();
|
|
69
|
+
if (replayHeader !== null) {
|
|
70
|
+
expect(replayHeader, driver.describe(
|
|
71
|
+
'idempotency.md §Server responsibilities #2',
|
|
72
|
+
'openwop-Idempotent-Replay on replay MUST be "true"',
|
|
73
|
+
)).toBe('true');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe.skipIf(SKIP_NO_FIXTURE)('idempotency-retry: 5-retry budget per scale-profiles.md §"Retry semantics"', () => {
|
|
79
|
+
it('5 retries 100ms apart with same key all return the same runId', async () => {
|
|
80
|
+
const key = freshKey('retry-budget');
|
|
81
|
+
const body = { workflowId: WORKFLOW_ID, inputs: { nonce: 'retry-budget' } };
|
|
82
|
+
|
|
83
|
+
const responses = [];
|
|
84
|
+
for (let i = 0; i < 5; i++) {
|
|
85
|
+
const res = await driver.post('/v1/runs', body, { headers: { 'Idempotency-Key': key } });
|
|
86
|
+
responses.push(res);
|
|
87
|
+
if (i < 4) await new Promise((r) => setTimeout(r, 100));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const res of responses) {
|
|
91
|
+
expect(
|
|
92
|
+
[200, 201].includes(res.status),
|
|
93
|
+
driver.describe(
|
|
94
|
+
'scale-profiles.md §Retry semantics',
|
|
95
|
+
'host MUST handle ≥5 retries 100ms apart without losing the cached response',
|
|
96
|
+
),
|
|
97
|
+
).toBe(true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const runIds = new Set(responses.map((r) => (r.json as { runId?: string })?.runId));
|
|
101
|
+
expect(runIds.size, driver.describe(
|
|
102
|
+
'idempotency.md §Layer 1',
|
|
103
|
+
'5 retries with same key MUST collapse to exactly one runId',
|
|
104
|
+
)).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('idempotency-retry: limits.idempotencyAckTimeoutSec contract per idempotency.md', () => {
|
|
109
|
+
it('host advertising idempotencyAckTimeoutSec sets integer ≥ 5', async () => {
|
|
110
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
|
|
113
|
+
const limits = (res.json as { limits?: Record<string, unknown> })?.limits;
|
|
114
|
+
if (!limits) return; // limits required per capabilities.md §3 — covered elsewhere
|
|
115
|
+
const ack = limits.idempotencyAckTimeoutSec;
|
|
116
|
+
if (ack === undefined) {
|
|
117
|
+
// Per idempotency.md, the field is optional; absence implies the
|
|
118
|
+
// 5-second floor. Nothing to assert.
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
expect(typeof ack === 'number' && Number.isInteger(ack), driver.describe(
|
|
122
|
+
'idempotency.md',
|
|
123
|
+
'limits.idempotencyAckTimeoutSec MUST be an integer when advertised',
|
|
124
|
+
)).toBe(true);
|
|
125
|
+
expect(ack as number, driver.describe(
|
|
126
|
+
'idempotency.md',
|
|
127
|
+
'limits.idempotencyAckTimeoutSec MUST be ≥ 5',
|
|
128
|
+
)).toBeGreaterThanOrEqual(5);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity-passthrough scenario — exercises the `conformance-identity`
|
|
3
|
+
* fixture. The fixture echoes its `payload` input back to the
|
|
4
|
+
* `payload` variable on output. This verifies:
|
|
5
|
+
*
|
|
6
|
+
* 1. inputs.{var} on POST /v1/runs is observable in subsequent
|
|
7
|
+
* RunSnapshot.variables.{var} (per fixtures.md §conformance-identity).
|
|
8
|
+
* 2. Object equality is preserved end-to-end (no JSON serialization
|
|
9
|
+
* drift on the server).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import { driver } from '../lib/driver.js';
|
|
14
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
15
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
16
|
+
|
|
17
|
+
const WORKFLOW_ID = 'conformance-identity';
|
|
18
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
19
|
+
|
|
20
|
+
describe.skipIf(SKIP_NO_FIXTURE)('identity: conformance-identity fixture echoes payload input to variables', () => {
|
|
21
|
+
it('arbitrary nested JSON payload round-trips through inputs → variables', async () => {
|
|
22
|
+
const payload = {
|
|
23
|
+
stringField: 'hello',
|
|
24
|
+
intField: 42,
|
|
25
|
+
boolField: true,
|
|
26
|
+
arrayField: [1, 'two', { three: 3 }],
|
|
27
|
+
nested: {
|
|
28
|
+
deeper: { stillDeeper: { value: 'leaf' } },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const create = await driver.post('/v1/runs', {
|
|
33
|
+
workflowId: WORKFLOW_ID,
|
|
34
|
+
inputs: { payload },
|
|
35
|
+
});
|
|
36
|
+
expect(create.status, driver.describe(
|
|
37
|
+
'rest-endpoints.md',
|
|
38
|
+
'POST /v1/runs MUST return 201',
|
|
39
|
+
)).toBe(201);
|
|
40
|
+
const runId = (create.json as { runId: string }).runId;
|
|
41
|
+
|
|
42
|
+
const terminal = await pollUntilTerminal(runId);
|
|
43
|
+
|
|
44
|
+
expect(terminal.status, driver.describe(
|
|
45
|
+
'fixtures.md conformance-identity',
|
|
46
|
+
'identity fixture MUST reach terminal `completed`',
|
|
47
|
+
)).toBe('completed');
|
|
48
|
+
|
|
49
|
+
expect(terminal.variables?.payload, driver.describe(
|
|
50
|
+
'fixtures.md conformance-identity §Expected behavior',
|
|
51
|
+
'RunSnapshot.variables.payload MUST deep-equal the input payload',
|
|
52
|
+
)).toEqual(payload);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-interrupt scenarios — exercises the run-scoped HITL
|
|
3
|
+
* resolve surface (`POST /v1/runs/{runId}/interrupts/{nodeId}`)
|
|
4
|
+
* using the `conformance-approval` fixture.
|
|
5
|
+
*
|
|
6
|
+
* Per fixtures.md, the fixture's approval node id is `gate` and the
|
|
7
|
+
* resume schema is `{action: 'accept' | 'reject'}`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { driver } from '../lib/driver.js';
|
|
12
|
+
import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
|
|
13
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
14
|
+
|
|
15
|
+
const WORKFLOW_ID = 'conformance-approval';
|
|
16
|
+
const NODE_ID = 'gate';
|
|
17
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
18
|
+
|
|
19
|
+
describe.skipIf(SKIP_NO_FIXTURE)('interrupt: approval accept resumes to `completed`', () => {
|
|
20
|
+
it('run suspends at gate, accept resolution drives terminal completed', async () => {
|
|
21
|
+
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
22
|
+
expect(create.status).toBe(201);
|
|
23
|
+
const runId = (create.json as { runId: string }).runId;
|
|
24
|
+
|
|
25
|
+
const suspended = await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
26
|
+
expect(suspended.currentNodeId, driver.describe(
|
|
27
|
+
'fixtures.md conformance-approval',
|
|
28
|
+
'suspended run MUST report currentNodeId === "gate"',
|
|
29
|
+
)).toBe(NODE_ID);
|
|
30
|
+
|
|
31
|
+
const resolve = await driver.post(
|
|
32
|
+
`/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
|
|
33
|
+
{ resumeValue: { action: 'accept' } },
|
|
34
|
+
);
|
|
35
|
+
expect(resolve.status, driver.describe(
|
|
36
|
+
'rest-endpoints.md POST /v1/runs/{runId}/interrupts/{nodeId}',
|
|
37
|
+
'valid approval resolve MUST return 200',
|
|
38
|
+
)).toBe(200);
|
|
39
|
+
|
|
40
|
+
const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
41
|
+
expect(terminal.status, driver.describe(
|
|
42
|
+
'fixtures.md conformance-approval §Terminal status',
|
|
43
|
+
'fixture after accept MUST reach terminal `completed`',
|
|
44
|
+
)).toBe('completed');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe.skipIf(SKIP_NO_FIXTURE)('interrupt: invalid resolve payload rejected per resumeSchema', () => {
|
|
49
|
+
it('400 (or 422) when action is not in {accept, reject}', async () => {
|
|
50
|
+
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
51
|
+
expect(create.status).toBe(201);
|
|
52
|
+
const runId = (create.json as { runId: string }).runId;
|
|
53
|
+
|
|
54
|
+
await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
55
|
+
|
|
56
|
+
const resolve = await driver.post(
|
|
57
|
+
`/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
|
|
58
|
+
{ resumeValue: { action: 'maybe' } },
|
|
59
|
+
);
|
|
60
|
+
expect(
|
|
61
|
+
[400, 422].includes(resolve.status),
|
|
62
|
+
driver.describe(
|
|
63
|
+
'interrupt.md + resumeSchema validation',
|
|
64
|
+
'resolve payload that violates resumeSchema MUST return 400 or 422',
|
|
65
|
+
),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
|
|
68
|
+
// Cleanup: cancel the still-suspended run so the test doesn't leave
|
|
69
|
+
// a dangling fixture run on the server.
|
|
70
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
71
|
+
reason: 'conformance-cleanup',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe.skipIf(SKIP_NO_FIXTURE)('interrupt: resolving an unknown interrupt returns 404', () => {
|
|
77
|
+
it('400/404 when nodeId does not match an active interrupt', async () => {
|
|
78
|
+
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
79
|
+
expect(create.status).toBe(201);
|
|
80
|
+
const runId = (create.json as { runId: string }).runId;
|
|
81
|
+
|
|
82
|
+
await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
83
|
+
|
|
84
|
+
const resolve = await driver.post(
|
|
85
|
+
`/v1/runs/${encodeURIComponent(runId)}/interrupts/no-such-node`,
|
|
86
|
+
{ resumeValue: { action: 'accept' } },
|
|
87
|
+
);
|
|
88
|
+
expect(resolve.status, driver.describe(
|
|
89
|
+
'rest-endpoints.md POST /v1/runs/{runId}/interrupts/{nodeId}',
|
|
90
|
+
'resolving an unknown nodeId MUST return 404',
|
|
91
|
+
)).toBe(404);
|
|
92
|
+
|
|
93
|
+
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
94
|
+
reason: 'conformance-cleanup',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|