@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,162 @@
1
+ /**
2
+ * Stream-reconnect scenarios per spec/v1/stream-modes.md §"Reconnection."
3
+ *
4
+ * After a client connects, reads some events, and disconnects, a fresh
5
+ * connection with `Last-Event-ID: <last-seq-seen>` MUST resume from the
6
+ * next event without loss or duplication.
7
+ *
8
+ * Profile gating: `openwop-stream-sse`. Hosts that don't expose SSE
9
+ * skip-equivalent.
10
+ *
11
+ * **Tagged `@timing-sensitive`** — relies on a long-running fixture
12
+ * (`conformance-cancellable` with `delayMs > 1000`) so the reconnect
13
+ * happens mid-stream. Tolerance window: 30s for the full run lifecycle.
14
+ *
15
+ * @see spec/v1/stream-modes.md §"Reconnection"
16
+ * @see lib/sse.ts — subscribe() accepts lastEventId
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver } from '../lib/driver.js';
21
+ import { subscribe, type SseEvent } from '../lib/sse.js';
22
+ import { pollUntilTerminal } from '../lib/polling.js';
23
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
24
+
25
+ const WORKFLOW_ID = 'conformance-cancellable';
26
+ const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
27
+ const TERMINAL_TYPES = new Set(['run.completed', 'run.failed', 'run.cancelled']);
28
+
29
+ interface EventPayload {
30
+ seq?: number;
31
+ sequence?: number;
32
+ type?: string;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ function getSeq(event: SseEvent): number | null {
37
+ if (event.id !== null) {
38
+ const parsed = Number(event.id);
39
+ if (Number.isFinite(parsed)) return parsed;
40
+ }
41
+ try {
42
+ const payload = JSON.parse(event.data) as EventPayload;
43
+ if (typeof payload.sequence === 'number') return payload.sequence;
44
+ if (typeof payload.seq === 'number') return payload.seq;
45
+ } catch {
46
+ // ignore — not all events carry JSON data
47
+ }
48
+ return null;
49
+ }
50
+
51
+ describe.skipIf(SKIP_NO_FIXTURE)('stream-reconnect: Last-Event-ID resume per spec/v1/stream-modes.md', () => {
52
+ it(
53
+ 'reconnect with Last-Event-ID resumes without loss or duplication',
54
+ async () => {
55
+ // Phase 1: kick off a long-running run.
56
+ const create = await driver.post('/v1/runs', {
57
+ workflowId: WORKFLOW_ID,
58
+ inputs: { delayMs: 2000 },
59
+ });
60
+ if (create.status !== 201) return; // host doesn't seed cancellable fixture; skip-equivalent
61
+
62
+ const runId = (create.json as { runId: string }).runId;
63
+
64
+ // Phase 2: connect, take ~1s of stream, disconnect.
65
+ const firstHalf = await subscribe(`/v1/runs/${encodeURIComponent(runId)}/events`, {
66
+ timeoutMs: 1000, // disconnect after ~1s
67
+ });
68
+ expect(firstHalf.status, driver.describe(
69
+ 'spec/v1/stream-modes.md',
70
+ 'SSE endpoint MUST return 200',
71
+ )).toBe(200);
72
+ // The first connection might or might not have caught the terminal
73
+ // event before timeout — either way, the test assertion is on the
74
+ // resume.
75
+
76
+ // Find the highest sequence number we saw.
77
+ const firstSeqs = firstHalf.events.map(getSeq).filter((s): s is number => s !== null);
78
+ const lastSeen = firstSeqs.length > 0 ? Math.max(...firstSeqs) : -1;
79
+
80
+ if (lastSeen < 0) {
81
+ // First connection emitted no events with a parseable sequence — e.g.
82
+ // because the run already completed and the server closed before we
83
+ // got events. Skip the rest of this scenario; the host is fast enough
84
+ // that the reconnect path doesn't apply.
85
+ return;
86
+ }
87
+
88
+ // Phase 3: reconnect with Last-Event-ID set to the last seq we saw.
89
+ // Per stream-modes.md, the resume MUST yield events with seq > lastSeen.
90
+ const resume = await subscribe(`/v1/runs/${encodeURIComponent(runId)}/events`, {
91
+ timeoutMs: 5000,
92
+ lastEventId: String(lastSeen),
93
+ });
94
+ expect(resume.status, driver.describe(
95
+ 'spec/v1/stream-modes.md §"Reconnection"',
96
+ 'reconnection with Last-Event-ID MUST return 200',
97
+ )).toBe(200);
98
+
99
+ // Phase 4: assert no duplicates.
100
+ const resumeSeqs = resume.events.map(getSeq).filter((s): s is number => s !== null);
101
+ for (const s of resumeSeqs) {
102
+ // Hosts MAY replay the boundary event (some impls do; spec is
103
+ // permissive). The strict assertion is "no event with seq <
104
+ // lastSeen-1" — i.e., no events from before the resume point.
105
+ expect(s, driver.describe(
106
+ 'spec/v1/stream-modes.md §"Reconnection"',
107
+ `resume MUST NOT yield events with seq < lastSeen-1; got ${s} after lastSeen=${lastSeen}`,
108
+ )).toBeGreaterThanOrEqual(lastSeen - 1);
109
+ }
110
+
111
+ // Phase 5: ensure the run has reached terminal state by now.
112
+ await pollUntilTerminal(runId, { timeoutMs: 10_000 });
113
+ },
114
+ 60_000, // overall scenario timeout — well above the 30s @timing-sensitive budget
115
+ );
116
+
117
+ it(
118
+ 'reconnect with Last-Event-ID equal to terminal seq closes immediately',
119
+ async () => {
120
+ // Quick run, observe terminal seq, then attempt a reconnect after
121
+ // terminal — server SHOULD close immediately with no events.
122
+ const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
123
+ if (create.status !== 201) return;
124
+ const runId = (create.json as { runId: string }).runId;
125
+
126
+ const initial = await subscribe(`/v1/runs/${encodeURIComponent(runId)}/events`, {
127
+ timeoutMs: 5000,
128
+ });
129
+ if (initial.status !== 200 || initial.events.length === 0) return;
130
+
131
+ const terminalEvent = initial.events.find(
132
+ (e) => TERMINAL_TYPES.has(e.event) && e.id !== null && Number.isFinite(Number(e.id)),
133
+ );
134
+ if (!terminalEvent || terminalEvent.id === null) return;
135
+
136
+ const lastSeq = Number(terminalEvent.id);
137
+ if (!Number.isFinite(lastSeq)) return;
138
+
139
+ const reconnect = await subscribe(`/v1/runs/${encodeURIComponent(runId)}/events`, {
140
+ timeoutMs: 5000,
141
+ lastEventId: String(lastSeq),
142
+ });
143
+
144
+ // Reconnect MUST succeed (200) and SHOULD close quickly with no
145
+ // additional events beyond the terminal boundary. Permissive: the
146
+ // host MAY replay the terminal event itself.
147
+ expect(reconnect.status, driver.describe(
148
+ 'spec/v1/stream-modes.md §"Reconnection"',
149
+ 'reconnect after terminal MUST return 200',
150
+ )).toBe(200);
151
+
152
+ const newSeqs = reconnect.events
153
+ .map(getSeq)
154
+ .filter((s): s is number => s !== null && s > lastSeq);
155
+ expect(newSeqs.length, driver.describe(
156
+ 'spec/v1/stream-modes.md §"Reconnection"',
157
+ 'reconnect after terminal MUST NOT yield events with seq > lastSeq',
158
+ )).toBe(0);
159
+ },
160
+ 30_000,
161
+ );
162
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Sub-workflow scenarios (G3 / F2) — exercises `conformance-subworkflow-parent`
3
+ * which invokes `conformance-subworkflow-child` via `core.subWorkflow` with
4
+ * blocking dispatch.
5
+ *
6
+ * Verifies:
7
+ * 1. Parent run reaches terminal `completed`.
8
+ * 2. Child run was created and reached terminal `completed`.
9
+ * 3. Child run carries parent linkage (`parentRunId`, `parentNodeId`).
10
+ * 4. Child variables propagate to parent via outputMapping.
11
+ * 5. Both runs terminate within the parent's timeout.
12
+ *
13
+ * Spec references:
14
+ * - node-packs.md §Reserved Core openwop typeIds → `core.subWorkflow`
15
+ * - conformance/fixtures.md §F2 sub-workflow fixture
16
+ * - spec gap G3
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver } from '../lib/driver.js';
21
+ import { pollUntilTerminal } from '../lib/polling.js';
22
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
23
+
24
+ const PARENT_WORKFLOW_ID = 'conformance-subworkflow-parent';
25
+ const CHILD_WORKFLOW_ID = 'conformance-subworkflow-child';
26
+ const SKIP_NO_FIXTURE =
27
+ !isFixtureAdvertised(PARENT_WORKFLOW_ID) || !isFixtureAdvertised(CHILD_WORKFLOW_ID);
28
+
29
+ interface RunSnapshot {
30
+ readonly runId: string;
31
+ readonly status: string;
32
+ readonly variables?: Record<string, unknown>;
33
+ readonly parentRunId?: string;
34
+ readonly parentNodeId?: string;
35
+ readonly childDepth?: number;
36
+ readonly error?: { code?: string; message?: string };
37
+ }
38
+
39
+ interface RunEvent {
40
+ readonly type: string;
41
+ readonly nodeId?: string;
42
+ readonly sequence: number;
43
+ readonly payload?: unknown;
44
+ }
45
+
46
+ describe.skipIf(SKIP_NO_FIXTURE)('subworkflow: conformance-subworkflow-parent dispatches child + completes', () => {
47
+ it('parent run reaches terminal completed and child variable is propagated via outputMapping', async () => {
48
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_WORKFLOW_ID });
49
+ expect(create.status).toBe(201);
50
+ const parentRunId = (create.json as { runId: string }).runId;
51
+
52
+ const parentTerminal = await pollUntilTerminal(parentRunId);
53
+ expect(parentTerminal.status, driver.describe(
54
+ 'fixtures.md conformance-subworkflow-parent §Terminal status',
55
+ 'parent fixture MUST reach terminal `completed` after child finishes',
56
+ )).toBe('completed');
57
+
58
+ // outputMapping in the parent fixture maps child's `childResult` →
59
+ // parent's `childOutcome`. The variable should appear on the parent's
60
+ // final variables.
61
+ const parentVars = (parentTerminal as RunSnapshot).variables ?? {};
62
+ expect(parentVars.childOutcome, driver.describe(
63
+ 'node-packs.md §core.subWorkflow outputMapping',
64
+ 'parent variables MUST include `childOutcome` mapped from child `childResult`',
65
+ )).toBeDefined();
66
+ expect(parentVars.childOutcome).toBe('child-completed');
67
+ });
68
+
69
+ it('child run is created with parent linkage fields and reaches terminal completed', async () => {
70
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_WORKFLOW_ID });
71
+ const parentRunId = (create.json as { runId: string }).runId;
72
+
73
+ await pollUntilTerminal(parentRunId);
74
+
75
+ // Find the child run id from the parent's event log. The
76
+ // `node.completed` event for `subwf-call` carries `outputs.childRunId`
77
+ // per `core.subWorkflow`'s outputSchema.
78
+ const eventsRes = await driver.get(
79
+ `/v1/runs/${encodeURIComponent(parentRunId)}/events/poll?lastSequence=0&timeout=1`,
80
+ );
81
+ expect(eventsRes.status).toBe(200);
82
+ const events = (eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? [];
83
+
84
+ const subwfCompleted = events.find(
85
+ (e) => e.type === 'node.completed' && e.nodeId === 'subwf-call',
86
+ );
87
+ expect(subwfCompleted, driver.describe(
88
+ 'node-packs.md §core.subWorkflow',
89
+ 'parent event log MUST contain node.completed for the subwf-call node',
90
+ )).toBeDefined();
91
+
92
+ const subwfPayload = subwfCompleted?.payload as
93
+ | { outputs?: { childRunId?: string; childStatus?: string; skipped?: boolean } }
94
+ | undefined;
95
+ const childRunId = subwfPayload?.outputs?.childRunId;
96
+ expect(typeof childRunId, driver.describe(
97
+ 'node-packs.md §core.subWorkflow outputSchema',
98
+ 'core.subWorkflow output MUST include childRunId as a string',
99
+ )).toBe('string');
100
+
101
+ expect(subwfPayload?.outputs?.childStatus, driver.describe(
102
+ 'node-packs.md §core.subWorkflow outputSchema',
103
+ 'core.subWorkflow output MUST include childStatus="completed" on success',
104
+ )).toBe('completed');
105
+
106
+ // Fetch the child run snapshot and verify parent linkage.
107
+ const childRes = await driver.get(`/v1/runs/${encodeURIComponent(childRunId!)}`);
108
+ expect(childRes.status, 'child run snapshot MUST be retrievable').toBe(200);
109
+ const child = childRes.json as RunSnapshot;
110
+
111
+ expect(child.status, driver.describe(
112
+ 'fixtures.md conformance-subworkflow-child §Terminal status',
113
+ 'child MUST reach terminal `completed`',
114
+ )).toBe('completed');
115
+
116
+ expect(child.parentRunId, driver.describe(
117
+ 'spec gap G3 parent linkage',
118
+ 'child run MUST carry parentRunId pointing back to dispatcher',
119
+ )).toBe(parentRunId);
120
+
121
+ expect(child.parentNodeId, driver.describe(
122
+ 'spec gap G3 parent linkage',
123
+ 'child run MUST carry parentNodeId pointing back to the subwf-call node',
124
+ )).toBe('subwf-call');
125
+ });
126
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Version-negotiation scenarios — exercises the surface defined by
3
+ * `version-negotiation.md`. Spec gap (per fixtures.md §F5): full
4
+ * cross-version compat scenarios need a server with multiple
5
+ * `engineVersion` releases or a schema-version cycle, which the v1.0
6
+ * black-box suite can't synthesize.
7
+ *
8
+ * What we CAN test cheaply:
9
+ * 1. Server advertises a `protocolVersion` in `Capabilities`.
10
+ * 2. The four version axes (`engineVersion`,
11
+ * `eventLogSchemaVersion`, per-event `schemaVersion`,
12
+ * `pinnedVersions`) appear where the spec says they should.
13
+ * 3. Forward-compat read: events carrying an UNKNOWN
14
+ * `schemaVersion` SHOULD still be readable via the events/poll
15
+ * endpoint without 5xx (best-effort fold per
16
+ * run-event.schema.json §schemaVersion description).
17
+ * We can't synthesize unknown schemaVersions from the client, so
18
+ * this is checked indirectly — every event the server emits today
19
+ * MUST carry `eventId`, `runId`, `type`, `payload`, `timestamp`,
20
+ * `sequence` (the required fields per the JSON Schema). Drift in
21
+ * the canonical shape would trip this scenario before any future
22
+ * version-bump scenario could.
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { driver } from '../lib/driver.js';
27
+ import { pollUntilTerminal } from '../lib/polling.js';
28
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
29
+
30
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
31
+ const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
32
+
33
+ interface RunEvent {
34
+ readonly eventId: string;
35
+ readonly runId: string;
36
+ readonly type: string;
37
+ readonly payload: unknown;
38
+ readonly timestamp: string;
39
+ readonly sequence: number;
40
+ readonly schemaVersion?: number;
41
+ readonly engineVersion?: string;
42
+ }
43
+
44
+ describe('version-negotiation: Capabilities advertises a protocolVersion', () => {
45
+ it('GET /.well-known/openwop returns Capabilities with protocolVersion (string)', async () => {
46
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
47
+ expect(res.status).toBe(200);
48
+
49
+ const caps = res.json as { protocolVersion?: unknown };
50
+ expect(typeof caps.protocolVersion, driver.describe(
51
+ 'capabilities.md §3 + version-negotiation.md',
52
+ 'Capabilities.protocolVersion MUST be a non-empty string',
53
+ )).toBe('string');
54
+ expect(String(caps.protocolVersion).length).toBeGreaterThan(0);
55
+ });
56
+ });
57
+
58
+ describe.skipIf(SKIP_NO_NOOP)('version-negotiation: persisted events carry the canonical RunEventDoc shape', () => {
59
+ it('every event has the 6 required RunEventDoc fields per run-event.schema.json', async () => {
60
+ const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
61
+ expect(create.status).toBe(201);
62
+ const runId = (create.json as { runId: string }).runId;
63
+
64
+ await pollUntilTerminal(runId);
65
+
66
+ const eventsRes = await driver.get(
67
+ `/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`,
68
+ );
69
+ expect(eventsRes.status).toBe(200);
70
+
71
+ const events = (eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? [];
72
+ expect(events.length, 'noop run MUST emit at least one event').toBeGreaterThan(0);
73
+
74
+ for (const e of events) {
75
+ expect(typeof e.eventId, driver.describe(
76
+ 'run-event.schema.json §required',
77
+ 'eventId MUST be a string',
78
+ )).toBe('string');
79
+ expect(typeof e.runId, driver.describe(
80
+ 'run-event.schema.json §required',
81
+ 'runId MUST be a string',
82
+ )).toBe('string');
83
+ expect(typeof e.type, driver.describe(
84
+ 'run-event.schema.json §required',
85
+ 'type MUST be a string (RunEventType discriminator)',
86
+ )).toBe('string');
87
+ expect(e.payload, driver.describe(
88
+ 'run-event.schema.json §required',
89
+ 'payload MUST be present (any JSON value, including null)',
90
+ )).not.toBe(undefined);
91
+ expect(typeof e.timestamp, driver.describe(
92
+ 'run-event.schema.json §required',
93
+ 'timestamp MUST be an ISO 8601 string',
94
+ )).toBe('string');
95
+ expect(Number.isInteger(e.sequence), driver.describe(
96
+ 'run-event.schema.json §required',
97
+ 'sequence MUST be a non-negative integer',
98
+ )).toBe(true);
99
+ expect(e.sequence, 'sequence MUST be >= 0').toBeGreaterThanOrEqual(0);
100
+ }
101
+ });
102
+
103
+ it('event sequences within a run are strictly monotonic', async () => {
104
+ const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
105
+ expect(create.status).toBe(201);
106
+ const runId = (create.json as { runId: string }).runId;
107
+
108
+ await pollUntilTerminal(runId);
109
+
110
+ const eventsRes = await driver.get(
111
+ `/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`,
112
+ );
113
+ const events = (eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? [];
114
+
115
+ const sequences = events.map((e) => e.sequence);
116
+ for (let i = 1; i < sequences.length; i++) {
117
+ const prev = sequences[i - 1] ?? -1;
118
+ const curr = sequences[i] ?? -1;
119
+ expect(
120
+ curr,
121
+ driver.describe(
122
+ 'run-event.schema.json §sequence + idempotency.md',
123
+ `event[${i}].sequence (${curr}) MUST be > event[${i - 1}].sequence (${prev}) — strictly monotonic per run`,
124
+ ),
125
+ ).toBeGreaterThan(prev);
126
+ }
127
+ });
128
+ });
129
+
130
+ describe.skipIf(SKIP_NO_NOOP)('version-negotiation: events/poll forward-compat tolerance', () => {
131
+ it('events/poll with lastSequence past current end returns empty events + isComplete', async () => {
132
+ const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
133
+ expect(create.status).toBe(201);
134
+ const runId = (create.json as { runId: string }).runId;
135
+
136
+ await pollUntilTerminal(runId);
137
+
138
+ // For a terminal run, asking for events past the end is a benign
139
+ // empty response — not a 4xx. Forward-compat readers will use this
140
+ // pattern after recovering from a deploy that bumped sequence numbers.
141
+ const eventsRes = await driver.get(
142
+ `/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=99999&timeout=1`,
143
+ );
144
+
145
+ expect(
146
+ eventsRes.status,
147
+ driver.describe(
148
+ 'rest-endpoints.md GET /v1/runs/{runId}/events/poll',
149
+ 'lastSequence beyond the current end MUST return 200 with empty events, not 4xx',
150
+ ),
151
+ ).toBe(200);
152
+
153
+ const body = eventsRes.json as { events?: RunEvent[]; isComplete?: boolean };
154
+ expect(Array.isArray(body.events)).toBe(true);
155
+ expect(body.events?.length).toBe(0);
156
+ });
157
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * RFC 0008 §Conformance — scenario 6/6: ABI version mismatch.
3
+ *
4
+ * Verifies that a host refuses to load a WASM pack whose declared ABI
5
+ * version is not in the host's advertised `abiVersions[]`. The host's
6
+ * loader MUST surface a recognizable `unsupported_abi_version` error
7
+ * (or equivalent) and MUST NOT silently dispatch to the pack's
8
+ * `openwop_node_invoke`.
9
+ *
10
+ * Driving this end-to-end requires a pack with a deliberately wrong
11
+ * ABI version. That pack is filed as v1.x follow-up (an
12
+ * `examples/packs/abi-mismatch/`). The framework here asserts the
13
+ * shape of the host's advertisement so future scenarios can rely on it.
14
+ *
15
+ * @see RFCS/0008-wasm-abi.md §H (abiVersions array)
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver } from '../lib/driver.js';
20
+
21
+ describe('wasm-pack-abi-version-rejection: host advertises supported ABI versions', () => {
22
+ it('abiVersions[] contains positive integers; loader rejects unsupported versions', async () => {
23
+ const disco = await driver.get('/.well-known/openwop');
24
+ const wasm =
25
+ (disco.json as {
26
+ capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean; abiVersions?: unknown } } };
27
+ }).capabilities?.nodePackRuntimes?.wasm;
28
+
29
+ if (!wasm?.supported) return;
30
+
31
+ expect(Array.isArray(wasm.abiVersions), driver.describe(
32
+ 'RFCS/0008-wasm-abi.md §H',
33
+ 'capabilities.nodePackRuntimes.wasm.abiVersions MUST be an array',
34
+ )).toBe(true);
35
+
36
+ if (Array.isArray(wasm.abiVersions)) {
37
+ expect(wasm.abiVersions.length).toBeGreaterThan(0);
38
+ for (const v of wasm.abiVersions) {
39
+ expect(typeof v).toBe('number');
40
+ expect(Number.isInteger(v)).toBe(true);
41
+ expect(v).toBeGreaterThan(0);
42
+ }
43
+ // v1.1 hosts MUST support ABI v1 if they support WASM at all.
44
+ expect((wasm.abiVersions as number[]).includes(1)).toBe(true);
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * RFC 0008 §Conformance — scenario 2/6: invoke returning `outcome: 'completed'`.
3
+ *
4
+ * Verifies that a WASM-packaged node's completed-outcome response
5
+ * round-trips through the host:
6
+ * 1. Workflow runs to `completed` terminal.
7
+ * 2. The node's `node.completed` event carries the WASM-emitted output.
8
+ *
9
+ * Uses the reference Rust pack's `vendor.openwop.rust-hello.greet` typeId,
10
+ * whose contract is: `{ name: string }` → `{ greeting: "Hello, <name>!" }`.
11
+ *
12
+ * @see RFCS/0008-wasm-abi.md §D (response envelope shapes)
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import { driver } from '../lib/driver.js';
17
+ import { pollUntilTerminal } from '../lib/polling.js';
18
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
19
+
20
+ const FIXTURE = 'conformance-wasm-pack-roundtrip';
21
+
22
+ async function isWasmSupported(): Promise<boolean> {
23
+ const disco = await driver.get('/.well-known/openwop');
24
+ return Boolean(
25
+ (disco.json as { capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean } } } })
26
+ .capabilities?.nodePackRuntimes?.wasm?.supported,
27
+ );
28
+ }
29
+
30
+ describe('wasm-pack-invoke-completed: round-trip output', () => {
31
+ it('greet node returns Hello, <name>! and run reaches completed', async () => {
32
+ if (!isFixtureAdvertised(FIXTURE)) return;
33
+ if (!(await isWasmSupported())) {
34
+ // eslint-disable-next-line no-console
35
+ console.warn('[wasm-pack-invoke-completed] WASM not advertised; skipping');
36
+ return;
37
+ }
38
+
39
+ const create = await driver.post('/v1/runs', {
40
+ workflowId: FIXTURE,
41
+ inputs: { name: 'openwop' },
42
+ });
43
+ expect(create.status).toBe(201);
44
+ const runId = (create.json as { runId: string }).runId;
45
+
46
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 15_000 });
47
+ expect(terminal.status, driver.describe(
48
+ 'RFCS/0008-wasm-abi.md §D',
49
+ "WASM 'completed' outcome MUST drive terminal 'completed' run status",
50
+ )).toBe('completed');
51
+
52
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
53
+ const list = (events.json as { events?: Array<{ type: string; data?: unknown }> }).events ?? [];
54
+ const completedNode = list.find((e) => e.type === 'node.completed');
55
+ const haystack = JSON.stringify(completedNode ?? {}).toLowerCase();
56
+ // Hosts MAY surface the WASM output on node.completed.data.output (this
57
+ // host's convention) OR via a downstream artifact. Either way the
58
+ // string "hello, openwop" MUST appear in the event log so observers
59
+ // can audit the round-trip.
60
+ const fullLog = JSON.stringify(list).toLowerCase();
61
+ expect(
62
+ haystack.includes('hello, openwop') || fullLog.includes('hello, openwop'),
63
+ driver.describe(
64
+ 'RFCS/0008-wasm-abi.md §D',
65
+ "WASM completion output MUST be surfaced somewhere in the run's event log so consumers can read it",
66
+ ),
67
+ ).toBe(true);
68
+ });
69
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * RFC 0008 §Conformance — scenario 3/6: invoke returning `outcome: 'suspended'`.
3
+ *
4
+ * Verifies that when a WASM-packaged node returns `outcome: 'suspended'`
5
+ * the host honors the suspension contract:
6
+ * 1. Run transitions to a `waiting-*` state (NOT terminal failure).
7
+ * 2. The interrupt payload reaches the run's interrupt surface.
8
+ * 3. Resolving the interrupt resumes the node, which re-invokes the
9
+ * WASM `openwop_node_invoke` with the resume value available.
10
+ *
11
+ * Hosts that don't support WASM-driven suspends MAY return a recognizable
12
+ * `wasm_suspend_not_implemented` failure code — the scenario soft-passes
13
+ * in that case (the contract is "if supported, honor it"; explicit
14
+ * non-support is acceptable for v1.1).
15
+ *
16
+ * @see RFCS/0008-wasm-abi.md §D (response envelope) + §C (openwop_interrupt import)
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver } from '../lib/driver.js';
21
+ import { pollUntilTerminal } from '../lib/polling.js';
22
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
23
+
24
+ const FIXTURE = 'conformance-wasm-pack-roundtrip';
25
+
26
+ async function isWasmSupported(): Promise<boolean> {
27
+ const disco = await driver.get('/.well-known/openwop');
28
+ return Boolean(
29
+ (disco.json as { capabilities?: { nodePackRuntimes?: { wasm?: { supported?: boolean } } } })
30
+ .capabilities?.nodePackRuntimes?.wasm?.supported,
31
+ );
32
+ }
33
+
34
+ describe('wasm-pack-invoke-suspended: suspend → resume round-trip', () => {
35
+ it('host either suspends the run or explicitly reports wasm_suspend_not_implemented', async () => {
36
+ if (!isFixtureAdvertised(FIXTURE)) return;
37
+ if (!(await isWasmSupported())) return;
38
+
39
+ // The reference rust-hello pack does NOT itself suspend (it always
40
+ // returns `completed`), so against that pack this scenario can only
41
+ // assert the negative path: a run completes without entering a
42
+ // waiting-* state. A pack that explicitly suspends would be needed
43
+ // to exercise the positive path; tracked as v1.x follow-up.
44
+ const create = await driver.post('/v1/runs', {
45
+ workflowId: FIXTURE,
46
+ inputs: { name: 'suspend-probe' },
47
+ });
48
+ expect(create.status).toBe(201);
49
+ const runId = (create.json as { runId: string }).runId;
50
+
51
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 15_000 });
52
+
53
+ if (terminal.status === 'failed') {
54
+ // Acceptable if the host reports the recognizable code from
55
+ // RFC 0008 §D for hosts that don't implement WASM suspends.
56
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
57
+ const list = (events.json as { events?: Array<{ type: string; data?: unknown }> }).events ?? [];
58
+ const haystack = JSON.stringify(list).toLowerCase();
59
+ const ok =
60
+ haystack.includes('wasm_suspend_not_implemented') ||
61
+ haystack.includes('suspend_not_supported');
62
+ expect(ok, driver.describe(
63
+ 'RFCS/0008-wasm-abi.md §D',
64
+ "if a host doesn't implement WASM-driven suspends it MUST surface a recognizable code",
65
+ )).toBe(true);
66
+ return;
67
+ }
68
+
69
+ // Completed path: the reference pack never suspends. Asserting
70
+ // 'completed' confirms the host did not spuriously enter a
71
+ // waiting-* state.
72
+ expect(terminal.status).toBe('completed');
73
+ });
74
+ });