@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,312 @@
1
+ /**
2
+ * In-process OTLP/HTTP receiver for OTel conformance verification (Track 11).
3
+ *
4
+ * Purpose: the conformance suite is otherwise black-box (HTTP + SSE). To
5
+ * verify that hosts emit the canonical `openwop.*` OTel spans + metrics
6
+ * documented in `spec/v1/observability.md`, we need an inbound channel
7
+ * that captures what the host's OTLP exporter emits.
8
+ *
9
+ * Approach: stand up a minimal HTTP server (Node stdlib only — no
10
+ * `@opentelemetry/*` deps) that accepts POST to `/v1/traces` and
11
+ * `/v1/metrics` in OTLP/HTTP-JSON encoding, parses the payloads, and
12
+ * exposes them via a query API. Scenarios assert on the captured data.
13
+ *
14
+ * Operator contract: when running the OTel conformance scenarios, the
15
+ * host MUST be configured to export to `OTEL_EXPORTER_OTLP_ENDPOINT`
16
+ * pointing at the collector started here. The suite reads
17
+ * `OPENWOP_OTEL_COLLECTOR_PORT` (default `4318`, OTLP/HTTP convention) to
18
+ * determine where to bind. The operator sets `OTEL_EXPORTER_OTLP_ENDPOINT`
19
+ * on the host process to the matching `http://localhost:<port>` value.
20
+ *
21
+ * Wire format: OTLP/HTTP-JSON per
22
+ * https://opentelemetry.io/docs/specs/otlp/#otlphttp-json-encoded-payload
23
+ * Shape (simplified):
24
+ * { resourceSpans: [{ resource, scopeSpans: [{ scope, spans: [...] }] }] }
25
+ * { resourceMetrics: [{ resource, scopeMetrics: [{ scope, metrics: [...] }] }] }
26
+ *
27
+ * Span attribute encoding (OTLP): each attribute is
28
+ * { key: string, value: { stringValue | intValue | doubleValue | boolValue | arrayValue | kvlistValue } }
29
+ *
30
+ * @see spec/v1/observability.md
31
+ */
32
+
33
+ import { createServer, type Server } from 'node:http';
34
+ import type { AddressInfo } from 'node:net';
35
+
36
+ export interface OtelAttribute {
37
+ readonly key: string;
38
+ readonly value: string | number | boolean | null | readonly unknown[];
39
+ }
40
+
41
+ export interface CapturedSpan {
42
+ readonly traceId: string;
43
+ readonly spanId: string;
44
+ readonly parentSpanId: string | undefined;
45
+ readonly name: string;
46
+ readonly startTimeUnixNano: string;
47
+ readonly endTimeUnixNano: string;
48
+ readonly attributes: ReadonlyMap<string, string | number | boolean | null | readonly unknown[]>;
49
+ readonly resourceAttributes: ReadonlyMap<string, string | number | boolean | null | readonly unknown[]>;
50
+ }
51
+
52
+ export interface CapturedMetric {
53
+ readonly name: string;
54
+ readonly description: string | undefined;
55
+ readonly unit: string | undefined;
56
+ readonly kind: 'sum' | 'gauge' | 'histogram' | 'unknown';
57
+ /** First captured data point — sufficient for shape assertions. */
58
+ readonly dataPoint: {
59
+ readonly attributes: ReadonlyMap<string, string | number | boolean | null | readonly unknown[]>;
60
+ readonly value: number | undefined;
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Decode an OTLP attribute-value object into a primitive. Returns `null`
66
+ * when the value shape is unrecognized.
67
+ */
68
+ function decodeAttrValue(v: unknown): string | number | boolean | null | readonly unknown[] {
69
+ if (v === null || typeof v !== 'object') return null;
70
+ const obj = v as Record<string, unknown>;
71
+ if (typeof obj.stringValue === 'string') return obj.stringValue;
72
+ if (typeof obj.intValue === 'string') return Number(obj.intValue);
73
+ if (typeof obj.intValue === 'number') return obj.intValue;
74
+ if (typeof obj.doubleValue === 'number') return obj.doubleValue;
75
+ if (typeof obj.boolValue === 'boolean') return obj.boolValue;
76
+ if (obj.arrayValue && typeof obj.arrayValue === 'object') {
77
+ const av = (obj.arrayValue as { values?: unknown[] }).values ?? [];
78
+ return av.map(decodeAttrValue);
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function decodeAttributes(
84
+ attrs: ReadonlyArray<{ key: unknown; value: unknown }> | undefined,
85
+ ): Map<string, string | number | boolean | null | readonly unknown[]> {
86
+ const out = new Map<string, string | number | boolean | null | readonly unknown[]>();
87
+ if (!attrs) return out;
88
+ for (const a of attrs) {
89
+ if (typeof a.key !== 'string') continue;
90
+ out.set(a.key, decodeAttrValue(a.value));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export class OtelCollector {
96
+ private readonly _spans: CapturedSpan[] = [];
97
+ private readonly _metrics: CapturedMetric[] = [];
98
+ private _server: Server | null = null;
99
+ private _boundPort: number = 0;
100
+
101
+ /**
102
+ * Start the collector. If `port` is `0` (or unset), an ephemeral port
103
+ * is assigned and exposed via `boundPort()`.
104
+ */
105
+ async start(port: number = 0): Promise<void> {
106
+ return new Promise((resolve, reject) => {
107
+ const server = createServer((req, res) => this._handle(req, res));
108
+ server.on('error', reject);
109
+ server.listen(port, '127.0.0.1', () => {
110
+ const addr = server.address() as AddressInfo;
111
+ this._server = server;
112
+ this._boundPort = addr.port;
113
+ resolve();
114
+ });
115
+ });
116
+ }
117
+
118
+ async stop(): Promise<void> {
119
+ if (!this._server) return;
120
+ const server = this._server;
121
+ this._server = null;
122
+ return new Promise((resolve, reject) => {
123
+ server.close((err) => (err ? reject(err) : resolve()));
124
+ });
125
+ }
126
+
127
+ boundPort(): number {
128
+ return this._boundPort;
129
+ }
130
+
131
+ endpoint(): string {
132
+ return `http://127.0.0.1:${this._boundPort}`;
133
+ }
134
+
135
+ reset(): void {
136
+ this._spans.length = 0;
137
+ this._metrics.length = 0;
138
+ }
139
+
140
+ /** All captured spans, in capture (arrival) order. */
141
+ spans(): readonly CapturedSpan[] {
142
+ return this._spans;
143
+ }
144
+
145
+ /** Spans filtered by an attribute equality. Use for run-scoping. */
146
+ spansWithAttribute(key: string, value: unknown): readonly CapturedSpan[] {
147
+ return this._spans.filter((s) => s.attributes.get(key) === value);
148
+ }
149
+
150
+ spansByName(name: string): readonly CapturedSpan[] {
151
+ return this._spans.filter((s) => s.name === name);
152
+ }
153
+
154
+ metrics(): readonly CapturedMetric[] {
155
+ return this._metrics;
156
+ }
157
+
158
+ metricByName(name: string): CapturedMetric | undefined {
159
+ return this._metrics.find((m) => m.name === name);
160
+ }
161
+
162
+ private async _handle(
163
+ req: import('node:http').IncomingMessage,
164
+ res: import('node:http').ServerResponse,
165
+ ): Promise<void> {
166
+ if (req.method !== 'POST') {
167
+ res.writeHead(405).end();
168
+ return;
169
+ }
170
+ const chunks: Buffer[] = [];
171
+ for await (const c of req) {
172
+ chunks.push(c as Buffer);
173
+ }
174
+ const body = Buffer.concat(chunks).toString('utf8');
175
+ let payload: Record<string, unknown> = {};
176
+ try {
177
+ payload = body.length > 0 ? (JSON.parse(body) as Record<string, unknown>) : {};
178
+ } catch {
179
+ // Protobuf-encoded OTLP arrives as binary; we currently support JSON only.
180
+ // Hosts that emit protobuf-encoded OTLP need to be configured for
181
+ // `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`.
182
+ res.writeHead(415).end('OTLP/HTTP-JSON only');
183
+ return;
184
+ }
185
+
186
+ if (req.url?.includes('/v1/traces')) {
187
+ this._ingestTraces(payload);
188
+ } else if (req.url?.includes('/v1/metrics')) {
189
+ this._ingestMetrics(payload);
190
+ } else {
191
+ // Ignore /v1/logs and unknown paths; respond OK so the exporter doesn't retry.
192
+ }
193
+
194
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
195
+ }
196
+
197
+ private _ingestTraces(payload: Record<string, unknown>): void {
198
+ const rs = (payload.resourceSpans ?? []) as ReadonlyArray<Record<string, unknown>>;
199
+ for (const r of rs) {
200
+ const resourceAttrs = decodeAttributes(
201
+ ((r.resource ?? {}) as { attributes?: ReadonlyArray<{ key: unknown; value: unknown }> })
202
+ .attributes,
203
+ );
204
+ const ss = (r.scopeSpans ?? []) as ReadonlyArray<Record<string, unknown>>;
205
+ for (const s of ss) {
206
+ const spans = (s.spans ?? []) as ReadonlyArray<Record<string, unknown>>;
207
+ for (const sp of spans) {
208
+ this._spans.push({
209
+ traceId: String(sp.traceId ?? ''),
210
+ spanId: String(sp.spanId ?? ''),
211
+ parentSpanId: sp.parentSpanId ? String(sp.parentSpanId) : undefined,
212
+ name: String(sp.name ?? ''),
213
+ startTimeUnixNano: String(sp.startTimeUnixNano ?? '0'),
214
+ endTimeUnixNano: String(sp.endTimeUnixNano ?? '0'),
215
+ attributes: decodeAttributes(
216
+ sp.attributes as ReadonlyArray<{ key: unknown; value: unknown }> | undefined,
217
+ ),
218
+ resourceAttributes: resourceAttrs,
219
+ });
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ private _ingestMetrics(payload: Record<string, unknown>): void {
226
+ const rm = (payload.resourceMetrics ?? []) as ReadonlyArray<Record<string, unknown>>;
227
+ for (const r of rm) {
228
+ const sm = (r.scopeMetrics ?? []) as ReadonlyArray<Record<string, unknown>>;
229
+ for (const s of sm) {
230
+ const metrics = (s.metrics ?? []) as ReadonlyArray<Record<string, unknown>>;
231
+ for (const m of metrics) {
232
+ let kind: CapturedMetric['kind'] = 'unknown';
233
+ let dp:
234
+ | {
235
+ attributes: Map<string, string | number | boolean | null | readonly unknown[]>;
236
+ value: number | undefined;
237
+ }
238
+ | undefined;
239
+
240
+ for (const k of ['sum', 'gauge', 'histogram'] as const) {
241
+ const slot = m[k] as { dataPoints?: ReadonlyArray<Record<string, unknown>> } | undefined;
242
+ if (!slot?.dataPoints?.length) continue;
243
+ kind = k;
244
+ const first = slot.dataPoints[0];
245
+ const attrs = decodeAttributes(
246
+ first.attributes as
247
+ | ReadonlyArray<{ key: unknown; value: unknown }>
248
+ | undefined,
249
+ );
250
+ const value =
251
+ typeof first.asDouble === 'number'
252
+ ? first.asDouble
253
+ : typeof first.asInt === 'string'
254
+ ? Number(first.asInt)
255
+ : typeof first.asInt === 'number'
256
+ ? first.asInt
257
+ : undefined;
258
+ dp = { attributes: attrs, value };
259
+ break;
260
+ }
261
+
262
+ if (!dp) continue;
263
+
264
+ this._metrics.push({
265
+ name: String(m.name ?? ''),
266
+ description: typeof m.description === 'string' ? m.description : undefined,
267
+ unit: typeof m.unit === 'string' ? m.unit : undefined,
268
+ kind,
269
+ dataPoint: dp,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Module-scope collector instance. Vitest setup file starts it; scenarios
279
+ * call `getCollector()` to fetch the running instance.
280
+ */
281
+ let _instance: OtelCollector | null = null;
282
+
283
+ export function setCollector(c: OtelCollector | null): void {
284
+ _instance = c;
285
+ }
286
+
287
+ export function getCollector(): OtelCollector | null {
288
+ return _instance;
289
+ }
290
+
291
+ /**
292
+ * Convenience: poll the collector for spans matching a run-id, with a
293
+ * timeout. Hosts emit OTel spans asynchronously after run terminal, so
294
+ * scenarios MUST poll rather than read once.
295
+ */
296
+ export async function waitForRunSpans(
297
+ runId: string,
298
+ options: { timeoutMs?: number; pollIntervalMs?: number; minCount?: number } = {},
299
+ ): Promise<readonly CapturedSpan[]> {
300
+ const collector = getCollector();
301
+ if (!collector) return [];
302
+ const timeoutMs = options.timeoutMs ?? 5_000;
303
+ const pollIntervalMs = options.pollIntervalMs ?? 100;
304
+ const minCount = options.minCount ?? 1;
305
+ const deadline = Date.now() + timeoutMs;
306
+ while (Date.now() < deadline) {
307
+ const matching = collector.spansWithAttribute('openwop.run_id', runId);
308
+ if (matching.length >= minCount) return matching;
309
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
310
+ }
311
+ return collector.spansWithAttribute('openwop.run_id', runId);
312
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Layout-aware path resolver for the offline subset.
3
+ *
4
+ * The same suite source runs in two layouts:
5
+ *
6
+ * 1. Repo checkout — `openwop/conformance/src/scenarios/X.test.ts`. Schemas,
7
+ * api/, and prose docs live one level above the conformance package
8
+ * at the repo root.
9
+ *
10
+ * 2. Published tarball — `node_modules/@openwop/openwop-conformance/src/...`.
11
+ * The `prepack` script vendors `api/` and `schemas/` INTO the package,
12
+ * so they resolve relative to the package root instead of a parent.
13
+ * Spec prose (`spec/v1/*.md`) is NOT bundled — those tests skip.
14
+ *
15
+ * Earlier offline scenarios computed `__dirname/../../..` to find
16
+ * the repo root. That works in a checkout but lands in `node_modules/@openwop/`
17
+ * after npx-style install, breaking `npx -y @openwop/openwop-conformance --offline`
18
+ * with `ENOENT: ... node_modules/@openwop/schemas/workflow-definition.schema.json`.
19
+ *
20
+ * This module centralises the resolution. Strategy:
21
+ *
22
+ * - If `OPENWOP_CONFORMANCE_ROOT` is set, treat its value as the layout root
23
+ * (the directory that contains `schemas/`, `api/`, and either
24
+ * `conformance/fixtures/` (repo) or `fixtures/` directly (vendored)).
25
+ * Used by integrators who put the suite in an unusual location.
26
+ *
27
+ * - Otherwise compute the package root from `import.meta.url` (= the
28
+ * directory containing `package.json`) and probe whether the schemas
29
+ * are vendored at the package root (published) or at the parent (repo).
30
+ *
31
+ * Exported paths are non-null for the materials always present in both
32
+ * layouts; the prose-doc and fixtures.md catalog dirs may resolve to
33
+ * `null` under the published layout, in which case the corresponding
34
+ * scenarios skip cleanly (see `spec-corpus-validity.test.ts`).
35
+ */
36
+
37
+ import { existsSync } from 'node:fs';
38
+ import { fileURLToPath } from 'node:url';
39
+ import { dirname, join, resolve as pathResolve } from 'node:path';
40
+
41
+ // `dirname(fileURLToPath(import.meta.url))` for an ESM module compiled or
42
+ // run from `src/lib/paths.ts` returns `<pkg>/src/lib/`. The conformance
43
+ // package root is therefore two directories above this file in BOTH the
44
+ // repo checkout and the published tarball — the source layout is
45
+ // identical between the two; only the parent of `<pkg>` differs.
46
+ const HERE = dirname(fileURLToPath(import.meta.url));
47
+ const PKG_ROOT = pathResolve(HERE, '..', '..');
48
+
49
+ interface ResolvedLayout {
50
+ /** Conformance package directory (where `package.json` lives). */
51
+ readonly pkgRoot: string;
52
+ /** Directory containing JSON Schemas. */
53
+ readonly schemasDir: string;
54
+ /** Directory containing the OpenAPI/AsyncAPI specs. */
55
+ readonly apiDir: string;
56
+ /** Directory containing the conformance fixtures (top-level + sub-dirs). */
57
+ readonly fixturesDir: string;
58
+ /** Directory containing scenario test files, if present in this layout. */
59
+ readonly scenariosDir: string | null;
60
+ /** Path to the conformance package README, if present in this layout. */
61
+ readonly conformanceReadmePath: string | null;
62
+ /** Path to `fixtures.md` catalog, if present in this layout. */
63
+ readonly fixturesDocPath: string | null;
64
+ /** Path to `coverage.md` operation-coverage map, if present in this layout. */
65
+ readonly coverageDocPath: string | null;
66
+ /** Directory containing v1 prose docs (`*.md`), if present in this layout. */
67
+ readonly v1Dir: string | null;
68
+ /** Path to repository README.md, if present in this layout. */
69
+ readonly readmePath: string | null;
70
+ /** Path to the TypeScript SDK run-helper source, if present in this layout. */
71
+ readonly typescriptRunHelpersPath: string | null;
72
+ /** Path to the Python SDK types source, if present in this layout. */
73
+ readonly pythonTypesPath: string | null;
74
+ /** Path to the Go SDK types source, if present in this layout. */
75
+ readonly goTypesPath: string | null;
76
+ /** Discriminator — which layout did we resolve to? */
77
+ readonly layout: 'env-override' | 'repo' | 'published';
78
+ }
79
+
80
+ function resolveFromRoot(root: string, layout: ResolvedLayout['layout']): ResolvedLayout {
81
+ // Two on-disk shapes for the layout root:
82
+ // - Repo: <root>/schemas, <root>/api, <root>/conformance/fixtures,
83
+ // <root>/conformance/{fixtures.md,coverage.md}, <root>/spec/v1/*.md
84
+ // (Where `<root>` = the repo root, e.g. `openwop/`.)
85
+ // - Vendored / published: <root>/schemas, <root>/api, <root>/fixtures,
86
+ // <root>/fixtures.md (when bundled), no spec/v1.
87
+ // Probe by checking whether `schemas/` lives at the conformance pkg root
88
+ // (vendored) vs one level up (repo).
89
+ const schemasDir = join(root, 'schemas');
90
+ const apiDir = join(root, 'api');
91
+ const repoFixturesDir = join(root, 'conformance', 'fixtures');
92
+ const vendoredFixturesDir = join(root, 'fixtures');
93
+ const fixturesDir = existsSync(repoFixturesDir) ? repoFixturesDir : vendoredFixturesDir;
94
+ const repoScenariosDir = join(root, 'conformance', 'src', 'scenarios');
95
+ const vendoredScenariosDir = join(PKG_ROOT, 'src', 'scenarios');
96
+ const scenariosDir = existsSync(repoScenariosDir)
97
+ ? repoScenariosDir
98
+ : existsSync(vendoredScenariosDir)
99
+ ? vendoredScenariosDir
100
+ : null;
101
+ const repoConformanceReadme = join(root, 'conformance', 'README.md');
102
+ const vendoredConformanceReadme = join(PKG_ROOT, 'README.md');
103
+ const conformanceReadmePath = existsSync(repoConformanceReadme)
104
+ ? repoConformanceReadme
105
+ : existsSync(vendoredConformanceReadme)
106
+ ? vendoredConformanceReadme
107
+ : null;
108
+ const repoFixturesDoc = join(root, 'conformance', 'fixtures.md');
109
+ const vendoredFixturesDoc = join(root, 'fixtures.md');
110
+ const fixturesDocPath = existsSync(repoFixturesDoc)
111
+ ? repoFixturesDoc
112
+ : existsSync(vendoredFixturesDoc)
113
+ ? vendoredFixturesDoc
114
+ : null;
115
+ const repoCoverageDoc = join(root, 'conformance', 'coverage.md');
116
+ const vendoredCoverageDoc = join(root, 'coverage.md');
117
+ const coverageDocPath = existsSync(repoCoverageDoc)
118
+ ? repoCoverageDoc
119
+ : existsSync(vendoredCoverageDoc)
120
+ ? vendoredCoverageDoc
121
+ : null;
122
+ const v1Probe = join(root, 'spec', 'v1');
123
+ const v1Dir = existsSync(v1Probe) ? v1Probe : null;
124
+ const readmeProbe = join(root, 'README.md');
125
+ const readmePath = existsSync(readmeProbe) ? readmeProbe : null;
126
+ const typescriptRunHelpersProbe = join(root, 'sdk', 'typescript', 'src', 'run-helpers.ts');
127
+ const typescriptRunHelpersPath = existsSync(typescriptRunHelpersProbe) ? typescriptRunHelpersProbe : null;
128
+ const pythonTypesProbe = join(root, 'sdk', 'python', 'src', 'openwop_client', 'types.py');
129
+ const pythonTypesPath = existsSync(pythonTypesProbe) ? pythonTypesProbe : null;
130
+ const goTypesProbe = join(root, 'sdk', 'go', 'types.go');
131
+ const goTypesPath = existsSync(goTypesProbe) ? goTypesProbe : null;
132
+ return {
133
+ pkgRoot: PKG_ROOT,
134
+ schemasDir,
135
+ apiDir,
136
+ fixturesDir,
137
+ scenariosDir,
138
+ conformanceReadmePath,
139
+ fixturesDocPath,
140
+ coverageDocPath,
141
+ v1Dir,
142
+ readmePath,
143
+ typescriptRunHelpersPath,
144
+ pythonTypesPath,
145
+ goTypesPath,
146
+ layout,
147
+ };
148
+ }
149
+
150
+ function resolveLayout(): ResolvedLayout {
151
+ const override = process.env.OPENWOP_CONFORMANCE_ROOT?.trim();
152
+ if (override && override.length > 0) {
153
+ return resolveFromRoot(pathResolve(override), 'env-override');
154
+ }
155
+ // Vendored / published-tarball layout: `prepack` copies `schemas/` +
156
+ // `api/` to the package root. Repo layout: schemas live one level
157
+ // above the conformance package.
158
+ //
159
+ // Edge case: a developer running `npm pack` locally without a
160
+ // postpack cleanup leaves schemas/ in BOTH places transiently. When
161
+ // both exist, prefer the parent (repo layout) so prose-doc tests
162
+ // continue to run — the parent is the canonical source.
163
+ const parent = pathResolve(PKG_ROOT, '..');
164
+ const parentHasSchemas = existsSync(join(parent, 'schemas'));
165
+ const pkgHasSchemas = existsSync(join(PKG_ROOT, 'schemas'));
166
+ if (parentHasSchemas) {
167
+ return resolveFromRoot(parent, 'repo');
168
+ }
169
+ if (pkgHasSchemas) {
170
+ return resolveFromRoot(PKG_ROOT, 'published');
171
+ }
172
+ // Neither — return the published-style resolution rooted at PKG_ROOT
173
+ // so error messages name a concrete directory rather than a
174
+ // computed-from-undefined path.
175
+ return resolveFromRoot(PKG_ROOT, 'published');
176
+ }
177
+
178
+ const _layout = resolveLayout();
179
+
180
+ export const PKG_ROOT_PATH: string = _layout.pkgRoot;
181
+ export const SCHEMAS_DIR: string = _layout.schemasDir;
182
+ export const API_DIR: string = _layout.apiDir;
183
+ export const FIXTURES_DIR: string = _layout.fixturesDir;
184
+ export const SCENARIOS_DIR: string | null = _layout.scenariosDir;
185
+ export const CONFORMANCE_README_PATH: string | null = _layout.conformanceReadmePath;
186
+ export const FIXTURES_DOC_PATH: string | null = _layout.fixturesDocPath;
187
+ export const COVERAGE_DOC_PATH: string | null = _layout.coverageDocPath;
188
+ export const V1_DIR: string | null = _layout.v1Dir;
189
+ export const README_PATH: string | null = _layout.readmePath;
190
+ export const TYPESCRIPT_RUN_HELPERS_PATH: string | null = _layout.typescriptRunHelpersPath;
191
+ export const PYTHON_TYPES_PATH: string | null = _layout.pythonTypesPath;
192
+ export const GO_TYPES_PATH: string | null = _layout.goTypesPath;
193
+ export const LAYOUT: ResolvedLayout['layout'] = _layout.layout;
194
+
195
+ /** Test-only — re-resolve in case env var or filesystem changed. */
196
+ export function __resolveLayoutForTests(): ResolvedLayout {
197
+ return resolveLayout();
198
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Polling helpers for run-state assertions.
3
+ *
4
+ * The conformance suite uses `GET /v1/runs/{runId}` polling rather than
5
+ * SSE because SSE termination semantics vary across implementations.
6
+ * Polling is the lowest-common-denominator wire; SSE-specific scenarios
7
+ * live in stream-modes.test.ts.
8
+ *
9
+ * Bound long polls with OPENWOP_LIFECYCLE_TIMEOUT_MS env var (default 10s).
10
+ */
11
+
12
+ import { driver } from './driver.js';
13
+
14
+ export interface RunSnapshot {
15
+ readonly runId: string;
16
+ readonly status: string;
17
+ readonly workflowId?: string;
18
+ readonly currentNodeId?: string;
19
+ readonly nodeStates?: Record<string, unknown>;
20
+ readonly variables?: Record<string, unknown>;
21
+ readonly error?: { code?: string; message?: string };
22
+ readonly metrics?: {
23
+ readonly openwopCost?: {
24
+ readonly usd?: number;
25
+ readonly tokens?: { readonly input?: number; readonly output?: number };
26
+ readonly model?: string;
27
+ readonly provider?: string;
28
+ readonly duration_ms?: number;
29
+ };
30
+ };
31
+ }
32
+
33
+ const POLL_INTERVAL_MS = 250;
34
+ const DEFAULT_TIMEOUT_MS = Number(process.env.OPENWOP_LIFECYCLE_TIMEOUT_MS ?? 10_000);
35
+
36
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
37
+
38
+ export async function getRun(runId: string): Promise<RunSnapshot> {
39
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
40
+ if (res.status !== 200) {
41
+ throw new Error(`GET /v1/runs/${runId} returned ${res.status}: ${res.text.slice(0, 200)}`);
42
+ }
43
+ return res.json as RunSnapshot;
44
+ }
45
+
46
+ export async function pollUntil(
47
+ runId: string,
48
+ predicate: (snap: RunSnapshot) => boolean,
49
+ opts: { timeoutMs?: number; label?: string } = {},
50
+ ): Promise<RunSnapshot> {
51
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
52
+ const deadline = Date.now() + timeoutMs;
53
+ let last: RunSnapshot | null = null;
54
+ while (Date.now() < deadline) {
55
+ try {
56
+ last = await getRun(runId);
57
+ if (predicate(last)) return last;
58
+ } catch {
59
+ // 404 right after POST is plausible while the run is being committed —
60
+ // swallow and retry. Other errors will retry too; they'll surface via
61
+ // the timeout message if persistent.
62
+ }
63
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
64
+ }
65
+ const label = opts.label ?? 'predicate';
66
+ throw new Error(
67
+ `Run ${runId} did not satisfy ${label} within ${timeoutMs}ms (last status: ${last?.status ?? 'unknown'})`,
68
+ );
69
+ }
70
+
71
+ export function pollUntilTerminal(runId: string, opts: { timeoutMs?: number } = {}): Promise<RunSnapshot> {
72
+ return pollUntil(runId, (s) => TERMINAL.has(s.status), { ...opts, label: 'terminal status' });
73
+ }
74
+
75
+ export function pollUntilStatus(
76
+ runId: string,
77
+ expected: string,
78
+ opts: { timeoutMs?: number } = {},
79
+ ): Promise<RunSnapshot> {
80
+ return pollUntil(runId, (s) => s.status === expected, { ...opts, label: `status === ${expected}` });
81
+ }