@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.
Files changed (175) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +241 -0
  3. package/api/asyncapi.yaml +481 -0
  4. package/api/openapi.yaml +830 -0
  5. package/api/redocly.yaml +8 -0
  6. package/coverage.md +80 -0
  7. package/dist/cli.js +161 -0
  8. package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
  9. package/fixtures/conformance-agent-identity.json +27 -0
  10. package/fixtures/conformance-agent-low-confidence.json +29 -0
  11. package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
  12. package/fixtures/conformance-agent-memory-redaction.json +32 -0
  13. package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
  14. package/fixtures/conformance-agent-memory-ttl.json +31 -0
  15. package/fixtures/conformance-agent-pack-export.json +26 -0
  16. package/fixtures/conformance-agent-pack-install.json +26 -0
  17. package/fixtures/conformance-agent-pack-provenance.json +31 -0
  18. package/fixtures/conformance-agent-reasoning.json +29 -0
  19. package/fixtures/conformance-approval.json +27 -0
  20. package/fixtures/conformance-cancellable.json +33 -0
  21. package/fixtures/conformance-cap-breach.json +27 -0
  22. package/fixtures/conformance-capability-missing.json +23 -0
  23. package/fixtures/conformance-channel-ttl.json +60 -0
  24. package/fixtures/conformance-clarification.json +30 -0
  25. package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
  26. package/fixtures/conformance-conversation-lifecycle.json +32 -0
  27. package/fixtures/conformance-conversation-replay.json +33 -0
  28. package/fixtures/conformance-conversation-vs-clarification.json +26 -0
  29. package/fixtures/conformance-delay.json +33 -0
  30. package/fixtures/conformance-dispatch-loop.json +38 -0
  31. package/fixtures/conformance-failure.json +23 -0
  32. package/fixtures/conformance-idempotent.json +30 -0
  33. package/fixtures/conformance-identity.json +32 -0
  34. package/fixtures/conformance-interrupt-auth-required.json +28 -0
  35. package/fixtures/conformance-interrupt-external-event.json +33 -0
  36. package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
  37. package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
  38. package/fixtures/conformance-interrupt-quorum.json +30 -0
  39. package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
  40. package/fixtures/conformance-message-reducer.json +31 -0
  41. package/fixtures/conformance-multi-node.json +21 -0
  42. package/fixtures/conformance-noop.json +23 -0
  43. package/fixtures/conformance-orchestrator-dispatch.json +47 -0
  44. package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
  45. package/fixtures/conformance-orchestrator-terminate.json +44 -0
  46. package/fixtures/conformance-stream-text.json +26 -0
  47. package/fixtures/conformance-subworkflow-child.json +21 -0
  48. package/fixtures/conformance-subworkflow-parent.json +49 -0
  49. package/fixtures/conformance-version-fold.json +23 -0
  50. package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
  51. package/fixtures/pack-manifests/pack-private-example.json +26 -0
  52. package/fixtures.md +404 -0
  53. package/package.json +48 -0
  54. package/schemas/README.md +75 -0
  55. package/schemas/agent-manifest.schema.json +107 -0
  56. package/schemas/agent-ref.schema.json +53 -0
  57. package/schemas/capabilities.schema.json +287 -0
  58. package/schemas/channel-written-payload.schema.json +55 -0
  59. package/schemas/conversation-event.schema.json +120 -0
  60. package/schemas/conversation-turn.schema.json +72 -0
  61. package/schemas/debug-bundle.schema.json +196 -0
  62. package/schemas/dispatch-config.schema.json +46 -0
  63. package/schemas/error-envelope.schema.json +25 -0
  64. package/schemas/memory-entry.schema.json +36 -0
  65. package/schemas/memory-list-options.schema.json +21 -0
  66. package/schemas/node-pack-manifest.schema.json +235 -0
  67. package/schemas/orchestrator-decision.schema.json +60 -0
  68. package/schemas/run-event-payloads.schema.json +663 -0
  69. package/schemas/run-event.schema.json +116 -0
  70. package/schemas/run-options.schema.json +81 -0
  71. package/schemas/run-orchestrator-decided-event.schema.json +20 -0
  72. package/schemas/run-snapshot.schema.json +121 -0
  73. package/schemas/suspend-request.schema.json +182 -0
  74. package/schemas/workflow-definition.schema.json +430 -0
  75. package/src/cli.ts +187 -0
  76. package/src/lib/a2a-fake-peer.ts +233 -0
  77. package/src/lib/canaries.ts +186 -0
  78. package/src/lib/driver.ts +96 -0
  79. package/src/lib/env.ts +49 -0
  80. package/src/lib/fixtures.ts +93 -0
  81. package/src/lib/mcp-fake-server.ts +185 -0
  82. package/src/lib/multi-agent-capabilities.ts +155 -0
  83. package/src/lib/multiProcess.ts +141 -0
  84. package/src/lib/otel-collector.ts +312 -0
  85. package/src/lib/paths.ts +198 -0
  86. package/src/lib/polling.ts +81 -0
  87. package/src/lib/profiles.ts +258 -0
  88. package/src/lib/sse.ts +172 -0
  89. package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
  90. package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
  91. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
  92. package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
  93. package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
  94. package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
  95. package/src/scenarios/agentMessageReducer.test.ts +57 -0
  96. package/src/scenarios/agentMetadata.test.ts +56 -0
  97. package/src/scenarios/agentPackExport.test.ts +45 -0
  98. package/src/scenarios/agentPackInstall.test.ts +50 -0
  99. package/src/scenarios/agentPackProvenance.test.ts +53 -0
  100. package/src/scenarios/agentReasoningEvents.test.ts +72 -0
  101. package/src/scenarios/append-ordering.test.ts +91 -0
  102. package/src/scenarios/approval-payload.test.ts +120 -0
  103. package/src/scenarios/audit-log-integrity.test.ts +106 -0
  104. package/src/scenarios/auth.test.ts +55 -0
  105. package/src/scenarios/byok-roundtrip.test.ts +166 -0
  106. package/src/scenarios/cancellation.test.ts +68 -0
  107. package/src/scenarios/cap-breach.test.ts +149 -0
  108. package/src/scenarios/channel-ttl.test.ts +70 -0
  109. package/src/scenarios/configurable-schema.test.ts +76 -0
  110. package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
  111. package/src/scenarios/conversationLifecycle.test.ts +64 -0
  112. package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
  113. package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
  114. package/src/scenarios/cost-attribution.test.ts +207 -0
  115. package/src/scenarios/debugBundle.test.ts +222 -0
  116. package/src/scenarios/discovery.test.ts +147 -0
  117. package/src/scenarios/dispatchLoop.test.ts +52 -0
  118. package/src/scenarios/errors.test.ts +144 -0
  119. package/src/scenarios/eventOrdering.test.ts +144 -0
  120. package/src/scenarios/failure-path.test.ts +46 -0
  121. package/src/scenarios/fixtures-gating.test.ts +137 -0
  122. package/src/scenarios/fixtures-valid.test.ts +140 -0
  123. package/src/scenarios/highConcurrency.test.ts +263 -0
  124. package/src/scenarios/idempotency.test.ts +83 -0
  125. package/src/scenarios/idempotencyRetry.test.ts +130 -0
  126. package/src/scenarios/identity-passthrough.test.ts +54 -0
  127. package/src/scenarios/interrupt-approval.test.ts +97 -0
  128. package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
  129. package/src/scenarios/interrupt-clarification.test.ts +45 -0
  130. package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
  131. package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
  132. package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
  133. package/src/scenarios/interruptRace.test.ts +176 -0
  134. package/src/scenarios/maliciousManifest.test.ts +154 -0
  135. package/src/scenarios/mcp-discoverability.test.ts +129 -0
  136. package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
  137. package/src/scenarios/multi-node-ordering.test.ts +60 -0
  138. package/src/scenarios/multi-region-idempotency.test.ts +52 -0
  139. package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
  140. package/src/scenarios/orchestratorDispatch.test.ts +66 -0
  141. package/src/scenarios/orchestratorTermination.test.ts +54 -0
  142. package/src/scenarios/otel-emission.test.ts +113 -0
  143. package/src/scenarios/otel-trace-propagation.test.ts +90 -0
  144. package/src/scenarios/pack-registry-publish.test.ts +93 -0
  145. package/src/scenarios/pack-registry.test.ts +328 -0
  146. package/src/scenarios/pause-resume.test.ts +109 -0
  147. package/src/scenarios/policies.test.ts +162 -0
  148. package/src/scenarios/profileDerivation.test.ts +335 -0
  149. package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
  150. package/src/scenarios/rate-limit-envelope.test.ts +97 -0
  151. package/src/scenarios/redaction.test.ts +254 -0
  152. package/src/scenarios/redactionAdversarial.test.ts +162 -0
  153. package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
  154. package/src/scenarios/replay-fork.test.ts +216 -0
  155. package/src/scenarios/replayDeterminism.test.ts +171 -0
  156. package/src/scenarios/route-coverage.test.ts +129 -0
  157. package/src/scenarios/runs-lifecycle.test.ts +65 -0
  158. package/src/scenarios/runtime-capabilities.test.ts +118 -0
  159. package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
  160. package/src/scenarios/staleClaim.test.ts +223 -0
  161. package/src/scenarios/stream-modes-buffer.test.ts +148 -0
  162. package/src/scenarios/stream-modes-mixed.test.ts +149 -0
  163. package/src/scenarios/stream-modes.test.ts +139 -0
  164. package/src/scenarios/streamReconnect.test.ts +162 -0
  165. package/src/scenarios/subworkflow.test.ts +126 -0
  166. package/src/scenarios/version-negotiation.test.ts +157 -0
  167. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
  168. package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
  169. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
  170. package/src/scenarios/wasm-pack-load.test.ts +75 -0
  171. package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
  172. package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
  173. package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
  174. package/src/setup.ts +173 -0
  175. package/vitest.config.ts +17 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Interrupt profile: `openwop-interrupt-auth-required` (interrupt-profiles.md).
3
+ *
4
+ * Exercises the auth-elevation behavior against the
5
+ * `conformance-interrupt-auth-required` fixture: an approval gate that
6
+ * REQUIRES a bearer credential (API key OR OAuth2 token) with the
7
+ * `approvals:respond` scope. Signed-token callback resume is REJECTED
8
+ * for this profile (the profile elevates auth).
9
+ *
10
+ * Verifies:
11
+ * 1. Bearer-token resume on the run-scoped endpoint succeeds.
12
+ * 2. Bearer-token resume with insufficient scope returns 403.
13
+ * 3. (Optional, when host issues a callback token at suspend time)
14
+ * Resolving via the signed-token surface is REJECTED for this fixture.
15
+ *
16
+ * Profile gating: a host claims this profile by seeding the fixture.
17
+ *
18
+ * @see spec/v1/interrupt-profiles.md §openwop-interrupt-auth-required
19
+ * @see conformance/fixtures/conformance-interrupt-auth-required.json
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { driver } from '../lib/driver.js';
24
+ import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
25
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
26
+
27
+ const WORKFLOW_ID = 'conformance-interrupt-auth-required';
28
+ const NODE_ID = 'gate';
29
+ const SKIP = !isFixtureAdvertised(WORKFLOW_ID);
30
+
31
+ describe.skipIf(SKIP)('interrupt: auth-required — bearer resume succeeds', () => {
32
+ it('valid bearer with approvals:respond drives terminal completed', async () => {
33
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
34
+ expect(create.status).toBe(201);
35
+ const runId = (create.json as { runId: string }).runId;
36
+
37
+ await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
38
+
39
+ const resolve = await driver.post(
40
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
41
+ { resumeValue: { action: 'accept' } },
42
+ );
43
+ expect(resolve.status, driver.describe(
44
+ 'interrupt-profiles.md §openwop-interrupt-auth-required',
45
+ 'bearer-token resume with approvals:respond scope MUST succeed',
46
+ )).toBeGreaterThanOrEqual(200);
47
+ expect(resolve.status).toBeLessThan(300);
48
+
49
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
50
+ expect(terminal.status).toBe('completed');
51
+ });
52
+ });
53
+
54
+ describe.skipIf(SKIP)('interrupt: auth-required — insufficient scope returns 403', () => {
55
+ it('bearer without approvals:respond scope is rejected', async () => {
56
+ // This scenario requires a separate test-only credential that lacks
57
+ // `approvals:respond`. Drivers wire it via the OPENWOP_TEST_LOW_SCOPE_KEY
58
+ // env var; when absent the test skips (rather than passing trivially).
59
+ const lowScopeKey = process.env.OPENWOP_TEST_LOW_SCOPE_KEY;
60
+ if (!lowScopeKey) {
61
+ // eslint-disable-next-line no-console
62
+ console.warn(
63
+ '[interrupt-auth-required-resume] skipping insufficient-scope subtest: ' +
64
+ 'OPENWOP_TEST_LOW_SCOPE_KEY not set',
65
+ );
66
+ return;
67
+ }
68
+
69
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
70
+ expect(create.status).toBe(201);
71
+ const runId = (create.json as { runId: string }).runId;
72
+ await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
73
+
74
+ const resolve = await driver.post(
75
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
76
+ { resumeValue: { action: 'accept' } },
77
+ { headers: { Authorization: `Bearer ${lowScopeKey}` } },
78
+ );
79
+ expect(resolve.status, driver.describe(
80
+ 'auth.md §scopes + interrupt-profiles.md §openwop-interrupt-auth-required',
81
+ 'bearer without approvals:respond scope MUST return 403',
82
+ )).toBe(403);
83
+
84
+ await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
85
+ reason: 'conformance-cleanup',
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Clarification-interrupt scenarios — exercises the run-scoped HITL
3
+ * resolve surface using the `conformance-clarification` fixture.
4
+ *
5
+ * Per fixtures.md, the clarification node id is `ask` and the resume
6
+ * schema requires `{answers: {q1: string}}`.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { driver } from '../lib/driver.js';
11
+ import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
12
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
13
+
14
+ const WORKFLOW_ID = 'conformance-clarification';
15
+ const NODE_ID = 'ask';
16
+ const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
17
+
18
+ describe.skipIf(SKIP_NO_FIXTURE)('interrupt: clarification answers resume to `completed`', () => {
19
+ it('run suspends at ask, answers payload drives terminal completed', async () => {
20
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
21
+ expect(create.status).toBe(201);
22
+ const runId = (create.json as { runId: string }).runId;
23
+
24
+ const suspended = await pollUntilStatus(runId, 'waiting-input', { timeoutMs: 10_000 });
25
+ expect(suspended.currentNodeId, driver.describe(
26
+ 'fixtures.md conformance-clarification',
27
+ 'suspended run MUST report currentNodeId === "ask"',
28
+ )).toBe(NODE_ID);
29
+
30
+ const resolve = await driver.post(
31
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
32
+ { resumeValue: { answers: { q1: 'blue' } } },
33
+ );
34
+ expect(resolve.status, driver.describe(
35
+ 'rest-endpoints.md POST /v1/runs/{runId}/interrupts/{nodeId}',
36
+ 'valid clarification resolve MUST return 200',
37
+ )).toBe(200);
38
+
39
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
40
+ expect(terminal.status, driver.describe(
41
+ 'fixtures.md conformance-clarification §Terminal status',
42
+ 'fixture after resolve MUST reach terminal `completed`',
43
+ )).toBe('completed');
44
+ });
45
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Interrupt profile: `openwop-interrupt-external-event` (interrupt-profiles.md).
3
+ *
4
+ * Exercises external-event correlation matching against the
5
+ * `conformance-interrupt-external-event` fixture: suspends waiting for
6
+ * a POST to `/v1/interrupts/{token}` with correlation `{orderId, status}`.
7
+ *
8
+ * Verifies:
9
+ * 1. The suspend persists a signed callback token.
10
+ * 2. A matching external POST resumes the run with the event body.
11
+ * 3. A mismatched correlation payload returns 422 without resuming.
12
+ *
13
+ * Profile gating: a host claims this profile by seeding the fixture;
14
+ * scenario skips when the fixture is not advertised.
15
+ *
16
+ * @see spec/v1/interrupt-profiles.md §openwop-interrupt-external-event
17
+ * @see spec/v1/interrupt.md §Signed-token callback
18
+ * @see conformance/fixtures/conformance-interrupt-external-event.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+ import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
24
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
25
+
26
+ const WORKFLOW_ID = 'conformance-interrupt-external-event';
27
+ const SKIP = !isFixtureAdvertised(WORKFLOW_ID);
28
+
29
+ interface SuspendDetails {
30
+ interruptToken?: string;
31
+ callbackUrl?: string;
32
+ }
33
+
34
+ async function fetchInterruptToken(runId: string): Promise<string | null> {
35
+ const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
36
+ const interrupt = (snap.json as { interrupt?: SuspendDetails }).interrupt;
37
+ if (interrupt?.interruptToken) return interrupt.interruptToken;
38
+ if (interrupt?.callbackUrl) {
39
+ const m = interrupt.callbackUrl.match(/\/v1\/interrupts\/([^/?]+)/);
40
+ if (m) return decodeURIComponent(m[1]);
41
+ }
42
+ return null;
43
+ }
44
+
45
+ describe.skipIf(SKIP)('interrupt: external-event — matching correlation resumes', () => {
46
+ it('signed-token POST with matching correlation drives terminal completed', async () => {
47
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
48
+ expect(create.status).toBe(201);
49
+ const runId = (create.json as { runId: string }).runId;
50
+
51
+ await pollUntilStatus(runId, 'waiting-external', { timeoutMs: 10_000 });
52
+
53
+ const token = await fetchInterruptToken(runId);
54
+ expect(token, driver.describe(
55
+ 'interrupt.md §Signed-token callback',
56
+ 'suspended external-event interrupt MUST expose a signed token to the caller',
57
+ )).not.toBeNull();
58
+
59
+ const resolve = await driver.post(`/v1/interrupts/${encodeURIComponent(token!)}`, {
60
+ resumeValue: {
61
+ orderId: 'fixture-order-1',
62
+ status: 'completed',
63
+ externalReference: 'conformance-test-123',
64
+ },
65
+ });
66
+ expect(resolve.status, driver.describe(
67
+ 'rest-endpoints.md POST /v1/interrupts/{token}',
68
+ 'token resolve with matching correlation MUST return 2xx',
69
+ )).toBeGreaterThanOrEqual(200);
70
+ expect(resolve.status).toBeLessThan(300);
71
+
72
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
73
+ expect(terminal.status, driver.describe(
74
+ 'fixtures.md conformance-interrupt-external-event',
75
+ 'matching external event MUST drive terminal completed',
76
+ )).toBe('completed');
77
+ });
78
+ });
79
+
80
+ describe.skipIf(SKIP)('interrupt: external-event — mismatched correlation rejected', () => {
81
+ it('correlation mismatch returns 422 and leaves run suspended', async () => {
82
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
83
+ expect(create.status).toBe(201);
84
+ const runId = (create.json as { runId: string }).runId;
85
+
86
+ await pollUntilStatus(runId, 'waiting-external', { timeoutMs: 10_000 });
87
+
88
+ const token = await fetchInterruptToken(runId);
89
+ expect(token).not.toBeNull();
90
+
91
+ const resolve = await driver.post(`/v1/interrupts/${encodeURIComponent(token!)}`, {
92
+ resumeValue: {
93
+ orderId: 'different-order',
94
+ status: 'completed',
95
+ },
96
+ });
97
+ expect(
98
+ [422, 400].includes(resolve.status),
99
+ driver.describe(
100
+ 'interrupt-profiles.md §openwop-interrupt-external-event',
101
+ 'correlation mismatch MUST return 422 (or 400) without resuming',
102
+ ),
103
+ ).toBe(true);
104
+
105
+ const still = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
106
+ const status = (still.json as { status: string }).status;
107
+ expect(status, 'run MUST remain suspended after correlation rejection').toMatch(/^waiting-/);
108
+
109
+ await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
110
+ reason: 'conformance-cleanup',
111
+ });
112
+ });
113
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Interrupt profile: `openwop-interrupt-parent-child` (interrupt-profiles.md).
3
+ *
4
+ * Exercises parent-cancellation cascade against the
5
+ * `conformance-interrupt-parent-child-cancel` fixture pair. Parent spawns
6
+ * a child sub-workflow that suspends on an approval, then the parent run
7
+ * is cancelled mid-suspend. The child MUST also transition to cancelled.
8
+ *
9
+ * Verifies:
10
+ * 1. Parent :cancel cascades to the child (child run terminal === 'cancelled').
11
+ * 2. Child cancellation reason includes 'parent-cancelled' (or equivalent).
12
+ * 3. Post-cascade resolve of the child's interrupt returns 410 Gone (or 409).
13
+ *
14
+ * Profile gating: fixture advertisement.
15
+ *
16
+ * @see spec/v1/interrupt-profiles.md §openwop-interrupt-parent-child
17
+ * @see conformance/fixtures/conformance-interrupt-parent-child-cancel.json
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
23
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
24
+
25
+ const PARENT_WORKFLOW = 'conformance-interrupt-parent-child-cancel';
26
+ const CHILD_WORKFLOW = 'conformance-interrupt-parent-child-cancel-child';
27
+ const CHILD_NODE_ID = 'child-gate';
28
+ const SKIP =
29
+ !isFixtureAdvertised(PARENT_WORKFLOW) || !isFixtureAdvertised(CHILD_WORKFLOW);
30
+
31
+ interface RunSnapshot {
32
+ status: string;
33
+ childRuns?: Array<{ runId: string; status: string }>;
34
+ }
35
+
36
+ async function findChildRunId(parentRunId: string): Promise<string | null> {
37
+ const snap = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}`);
38
+ const json = snap.json as RunSnapshot;
39
+ const child = json.childRuns?.[0];
40
+ return child?.runId ?? null;
41
+ }
42
+
43
+ describe.skipIf(SKIP)('interrupt: parent/child — parent cancel cascades to child', () => {
44
+ it('child transitions to cancelled when parent is cancelled mid-suspend', async () => {
45
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_WORKFLOW });
46
+ expect(create.status).toBe(201);
47
+ const parentRunId = (create.json as { runId: string }).runId;
48
+
49
+ // Wait for the parent to have spawned the child and the child to be suspended.
50
+ await pollUntilStatus(parentRunId, 'waiting-approval', { timeoutMs: 15_000 });
51
+
52
+ const childRunId = await findChildRunId(parentRunId);
53
+ expect(childRunId, driver.describe(
54
+ 'fixtures.md conformance-interrupt-parent-child-cancel',
55
+ 'parent snapshot MUST surface the spawned child runId',
56
+ )).not.toBeNull();
57
+
58
+ const cancel = await driver.post(`/v1/runs/${encodeURIComponent(parentRunId)}/cancel`, {
59
+ reason: 'conformance-test-cascade',
60
+ });
61
+ expect(cancel.status).toBeGreaterThanOrEqual(200);
62
+ expect(cancel.status).toBeLessThan(300);
63
+
64
+ const parentTerminal = await pollUntilTerminal(parentRunId, { timeoutMs: 10_000 });
65
+ expect(parentTerminal.status).toBe('cancelled');
66
+
67
+ const childTerminal = await pollUntilTerminal(childRunId!, { timeoutMs: 10_000 });
68
+ expect(childTerminal.status, driver.describe(
69
+ 'interrupt-profiles.md §openwop-interrupt-parent-child',
70
+ 'parent cancellation MUST cascade — child run MUST reach terminal cancelled',
71
+ )).toBe('cancelled');
72
+ });
73
+ });
74
+
75
+ describe.skipIf(SKIP)('interrupt: parent/child — post-cascade resolve of child returns 410/409', () => {
76
+ it('attempting to resolve the cascaded child interrupt is rejected', async () => {
77
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_WORKFLOW });
78
+ expect(create.status).toBe(201);
79
+ const parentRunId = (create.json as { runId: string }).runId;
80
+
81
+ await pollUntilStatus(parentRunId, 'waiting-approval', { timeoutMs: 15_000 });
82
+ const childRunId = await findChildRunId(parentRunId);
83
+ expect(childRunId).not.toBeNull();
84
+
85
+ await driver.post(`/v1/runs/${encodeURIComponent(parentRunId)}/cancel`, {
86
+ reason: 'conformance-test-cascade',
87
+ });
88
+ await pollUntilTerminal(childRunId!, { timeoutMs: 10_000 });
89
+
90
+ const lateResolve = await driver.post(
91
+ `/v1/runs/${encodeURIComponent(childRunId!)}/interrupts/${encodeURIComponent(CHILD_NODE_ID)}`,
92
+ { resumeValue: { action: 'accept' } },
93
+ );
94
+ expect(
95
+ [410, 409, 404].includes(lateResolve.status),
96
+ driver.describe(
97
+ 'interrupt-profiles.md §openwop-interrupt-parent-child',
98
+ 'resolving a cascaded child interrupt MUST be rejected (410 Gone preferred, 409/404 acceptable)',
99
+ ),
100
+ ).toBe(true);
101
+ });
102
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Interrupt profile: `openwop-interrupt-quorum` (interrupt-profiles.md).
3
+ *
4
+ * Exercises multi-approver quorum semantics against the
5
+ * `conformance-interrupt-quorum` fixture: requiredApprovals = 3,
6
+ * rejectionPolicy = 'majority'.
7
+ *
8
+ * Verifies:
9
+ * 1. Partial votes emit a per-vote event without resuming the run.
10
+ * 2. The N-th accept (N === requiredApprovals) fires the suspend resume.
11
+ * 3. A majority-reject path fails the gate with the rejection envelope.
12
+ *
13
+ * Capability-gated: skips unless the host advertises the quorum
14
+ * interrupt profile AND the fixture is seeded.
15
+ *
16
+ * @see spec/v1/interrupt-profiles.md §openwop-interrupt-quorum
17
+ * @see conformance/fixtures/conformance-interrupt-quorum.json
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
23
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
24
+
25
+ // Profile gating: a host claims `openwop-interrupt-quorum` support by
26
+ // advertising the fixture. Hosts that don't support quorum semantics
27
+ // MUST NOT seed `conformance-interrupt-quorum`; this scenario then skips.
28
+ const WORKFLOW_ID = 'conformance-interrupt-quorum';
29
+ const NODE_ID = 'gate';
30
+ const SKIP = !isFixtureAdvertised(WORKFLOW_ID);
31
+
32
+ describe.skipIf(SKIP)('interrupt: quorum — three accepts resume to completed', () => {
33
+ it('first two accepts persist without resuming; third accept drives terminal completed', async () => {
34
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
35
+ expect(create.status).toBe(201);
36
+ const runId = (create.json as { runId: string }).runId;
37
+
38
+ await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
39
+
40
+ for (let i = 1; i <= 2; i++) {
41
+ const partial = await driver.post(
42
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
43
+ { resumeValue: { action: 'accept', voter: `approver-${i}` } },
44
+ );
45
+ expect(partial.status, driver.describe(
46
+ 'interrupt-profiles.md §openwop-interrupt-quorum',
47
+ `partial vote ${i}/3 MUST be accepted (2xx) without terminating the run`,
48
+ )).toBeGreaterThanOrEqual(200);
49
+ expect(partial.status).toBeLessThan(300);
50
+
51
+ const stillWaiting = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
52
+ const status = (stillWaiting.json as { status: string }).status;
53
+ expect(status, driver.describe(
54
+ 'interrupt-profiles.md §openwop-interrupt-quorum',
55
+ `run MUST still be in waiting-approval after ${i}/3 votes (quorum not met)`,
56
+ )).toBe('waiting-approval');
57
+ }
58
+
59
+ const final = await driver.post(
60
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
61
+ { resumeValue: { action: 'accept', voter: 'approver-3' } },
62
+ );
63
+ expect(final.status).toBeGreaterThanOrEqual(200);
64
+ expect(final.status).toBeLessThan(300);
65
+
66
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
67
+ expect(terminal.status, driver.describe(
68
+ 'fixtures.md conformance-interrupt-quorum §Terminal status',
69
+ 'three accepts (quorum met) MUST drive terminal completed',
70
+ )).toBe('completed');
71
+ });
72
+ });
73
+
74
+ describe.skipIf(SKIP)('interrupt: quorum — majority reject fails the gate', () => {
75
+ it('two rejects out of three votes trigger the majority-reject termination', async () => {
76
+ const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
77
+ expect(create.status).toBe(201);
78
+ const runId = (create.json as { runId: string }).runId;
79
+
80
+ await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
81
+
82
+ await driver.post(
83
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
84
+ { resumeValue: { action: 'reject', voter: 'approver-1' } },
85
+ );
86
+ await driver.post(
87
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
88
+ { resumeValue: { action: 'reject', voter: 'approver-2' } },
89
+ );
90
+
91
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
92
+ expect(['failed', 'rejected'], driver.describe(
93
+ 'interrupt-profiles.md §openwop-interrupt-quorum (rejectionPolicy: majority)',
94
+ 'majority rejection MUST drive a non-completed terminal state',
95
+ )).toContain(terminal.status);
96
+ });
97
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Interrupt-race scenarios per spec/v1/interrupt.md.
3
+ *
4
+ * When a HITL interrupt is open at a node and a `cancel` request
5
+ * arrives concurrently with a resolution payload, the spec requires
6
+ * deterministic dispatch: exactly one of (a) the interrupt resolution
7
+ * advances the run normally, or (b) the cancel terminates the run
8
+ * with status `cancelled`. The two outcomes MUST be distinguishable
9
+ * by the response shapes.
10
+ *
11
+ * Profile gating: `openwop-interrupts`. Hosts that don't expose
12
+ * `clarification.request` envelope or interrupt resume routes
13
+ * skip-equivalent.
14
+ *
15
+ * **Tagged `@timing-sensitive`** — relies on a workflow that suspends
16
+ * at a HITL gate. Tolerance: 30s for setup; 5s for the race window.
17
+ *
18
+ * @see spec/v1/interrupt.md
19
+ * @see SECURITY/threat-model-prompt-injection.md (decidedBy invariants)
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { driver } from '../lib/driver.js';
24
+ import { pollUntil } from '../lib/polling.js';
25
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
26
+
27
+ const APPROVAL_WORKFLOW_ID = 'conformance-approval';
28
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
29
+ const SKIP_NO_APPROVAL = !isFixtureAdvertised(APPROVAL_WORKFLOW_ID);
30
+ const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
31
+
32
+ interface DiscoveryShape {
33
+ supportedEnvelopes?: unknown;
34
+ }
35
+
36
+ async function hostClaimsInterrupts(): Promise<boolean> {
37
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
38
+ if (res.status !== 200) return false;
39
+ const body = res.json as DiscoveryShape;
40
+ if (!Array.isArray(body.supportedEnvelopes)) return false;
41
+ return (body.supportedEnvelopes as string[]).includes('clarification.request');
42
+ }
43
+
44
+ describe.skipIf(SKIP_NO_APPROVAL)('interrupt-race: concurrent cancel + resolve dispatch deterministically', () => {
45
+ it(
46
+ 'concurrent cancel + interrupt-resolve resolves to one of: cancelled or completed',
47
+ async () => {
48
+ if (!(await hostClaimsInterrupts())) return; // skip-equivalent
49
+
50
+ // Phase 1: start a workflow that suspends at an approval gate.
51
+ const create = await driver.post('/v1/runs', { workflowId: APPROVAL_WORKFLOW_ID });
52
+ if (create.status !== 201) {
53
+ // Host may not seed conformance-approval fixture; skip.
54
+ return;
55
+ }
56
+ const runId = (create.json as { runId: string }).runId;
57
+
58
+ // Phase 2: poll until the run is suspended waiting for approval.
59
+ const suspended = await pollUntil(
60
+ runId,
61
+ (snap) => snap.status === 'waiting-approval' || snap.status === 'waiting-clarification',
62
+ { timeoutMs: 10_000 },
63
+ );
64
+
65
+ // The interrupt token is host-implementation-specific; some hosts
66
+ // expose it via `currentNodeId`, some via a separate suspended-
67
+ // node API. The conformance suite doesn't standardize the
68
+ // discovery path here — we just assert the dispatch outcome
69
+ // shape, not the resolve URL pattern.
70
+
71
+ const nodeId = suspended.currentNodeId;
72
+ if (typeof nodeId !== 'string') {
73
+ // Host doesn't expose currentNodeId on suspended snapshots —
74
+ // can't drive the race deterministically; skip-equivalent.
75
+ return;
76
+ }
77
+
78
+ // Phase 3: fire cancel + resolve concurrently. Promise.all so
79
+ // both go through the network at roughly the same instant.
80
+ const cancelPromise = driver.post(
81
+ `/v1/runs/${encodeURIComponent(runId)}/cancel`,
82
+ { reason: 'interrupt-race-test' },
83
+ );
84
+ const resolvePromise = driver.post(
85
+ `/v1/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(nodeId)}`,
86
+ { action: 'accept' },
87
+ );
88
+
89
+ const [cancelRes, resolveRes] = await Promise.all([cancelPromise, resolvePromise]);
90
+
91
+ // Both responses MUST be either 200 (operation accepted) or 409
92
+ // (operation lost the race). Anything else is non-deterministic.
93
+ expect(
94
+ [200, 202, 409].includes(cancelRes.status),
95
+ driver.describe(
96
+ 'spec/v1/interrupt.md',
97
+ `cancel response under race MUST be 200/202/409; got ${cancelRes.status}`,
98
+ ),
99
+ ).toBe(true);
100
+ expect(
101
+ [200, 202, 400, 404, 409].includes(resolveRes.status),
102
+ driver.describe(
103
+ 'spec/v1/interrupt.md',
104
+ `resolve response under race MUST be 200/202/400/404/409; got ${resolveRes.status}`,
105
+ ),
106
+ ).toBe(true);
107
+
108
+ // At least ONE operation MUST succeed (otherwise the run is stuck).
109
+ const cancelSucceeded = cancelRes.status === 200 || cancelRes.status === 202;
110
+ const resolveSucceeded = resolveRes.status === 200 || resolveRes.status === 202;
111
+ expect(
112
+ cancelSucceeded || resolveSucceeded,
113
+ driver.describe(
114
+ 'spec/v1/interrupt.md',
115
+ 'under cancel/resolve race, at least one operation MUST succeed',
116
+ ),
117
+ ).toBe(true);
118
+
119
+ // Phase 4: poll until terminal. Outcome MUST be one of completed
120
+ // (resolve won) / cancelled (cancel won) / failed (resolve hit a
121
+ // validation error and run continued, then cancel terminated it
122
+ // — also acceptable).
123
+ const terminal = await pollUntil(
124
+ runId,
125
+ (snap) =>
126
+ snap.status === 'completed' ||
127
+ snap.status === 'cancelled' ||
128
+ snap.status === 'failed',
129
+ { timeoutMs: 30_000 },
130
+ );
131
+
132
+ expect(
133
+ ['completed', 'cancelled', 'failed'].includes(terminal.status),
134
+ driver.describe(
135
+ 'spec/v1/interrupt.md',
136
+ 'race outcome MUST converge on a terminal status, not stay in waiting-approval forever',
137
+ ),
138
+ ).toBe(true);
139
+
140
+ // Determinism check: if the cancel won (cancelSucceeded === true
141
+ // AND resolveSucceeded === false), the terminal MUST be
142
+ // cancelled. If the resolve won, terminal MUST be completed
143
+ // (assuming the workflow has nothing else to fail on after the
144
+ // approval gate).
145
+ if (cancelSucceeded && !resolveSucceeded) {
146
+ expect(terminal.status, driver.describe(
147
+ 'spec/v1/interrupt.md',
148
+ 'when cancel wins the race, run MUST terminate as cancelled',
149
+ )).toBe('cancelled');
150
+ }
151
+ },
152
+ 90_000,
153
+ );
154
+ });
155
+
156
+ describe.skipIf(SKIP_NO_NOOP)('interrupt-race: cancel against a non-suspended run is well-formed', () => {
157
+ it('cancel of a completed run returns 200 with the existing terminal status (idempotent)', async () => {
158
+ // Self-test that doesn't require a race. Runs against any host
159
+ // that supports cancel (every conforming host does).
160
+ const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
161
+ if (create.status !== 201) return;
162
+ const runId = (create.json as { runId: string }).runId;
163
+
164
+ await pollUntil(
165
+ runId,
166
+ (snap) => snap.status === 'completed' || snap.status === 'failed' || snap.status === 'cancelled',
167
+ { timeoutMs: 10_000 },
168
+ );
169
+
170
+ const cancel = await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {});
171
+ expect(cancel.status, driver.describe(
172
+ 'spec/v1/rest-endpoints.md POST /v1/runs/{runId}/cancel',
173
+ 'cancel of an already-terminal run MUST return 200 (idempotent)',
174
+ )).toBe(200);
175
+ });
176
+ });