@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,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
|
+
});
|