@openwop/openwop-conformance 2.36.0 → 2.37.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/CHANGELOG.md +15 -0
- package/README.md +4 -3
- package/coverage.md +1 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +449 -56
- package/scenario-majors.json +14 -2
- package/schemas/CORPUS-STAMP.json +27 -27
- package/src/lib/a2a-error-info.ts +44 -0
- package/src/lib/a2a-fake-peer.ts +50 -7
- package/src/lib/effect-receiver.ts +135 -0
- package/src/lib/front-mux.ts +95 -0
- package/src/lib/mcp-fake-server.ts +15 -4
- package/src/lib/oidc-issuer.ts +15 -5
- package/src/scenarios/a2a-1-0-agent-card.test.ts +19 -7
- package/src/scenarios/mcp-2026-07-28-discover.test.ts +22 -7
- package/src/scenarios/v2-a2a-client-error-details.test.ts +70 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +84 -2
- package/src/scenarios/v2-durability-recovery.test.ts +83 -35
- package/src/scenarios/v2-idempotency-in-flight.test.ts +77 -0
- package/src/scenarios/v2-interrupt-resolve-terminal.test.ts +107 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +41 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +13 -0
- package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +133 -0
- package/src/scenarios/v2-terminal-event-once.test.ts +15 -21
package/src/lib/oidc-issuer.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import {
|
|
20
20
|
createSign,
|
|
21
21
|
generateKeyPairSync,
|
|
22
|
+
randomBytes,
|
|
22
23
|
type KeyObject,
|
|
23
24
|
} from 'node:crypto';
|
|
24
25
|
|
|
@@ -166,10 +167,22 @@ export function createSyntheticOIDCIssuer(
|
|
|
166
167
|
throw new Error('[oidc-issuer] issuer and audience are required');
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// The kid is unique PER INSTANCE, not just per rotation (2.37.0). Every
|
|
171
|
+
// instance generates a fresh key, and two scenarios can publish at the same
|
|
172
|
+
// OPENWOP_TEST_OIDC_ISSUER_URL (v2-oidc-id-token-audience and
|
|
173
|
+
// v2-lane-exp-only-bound both do). With a per-instance counter alone every
|
|
174
|
+
// instance minted `openwop-conformance-key-1`, so a host that caches JWKS by
|
|
175
|
+
// kid — RFC 7517 §4.5 says a kid identifies ONE key — verified the second
|
|
176
|
+
// scenario's tokens against the first scenario's key and rejected its VALID
|
|
177
|
+
// control as `invalid_signature`. Measured on openwop-app (suite 2.36.1):
|
|
178
|
+
// 4/4 red in a full run, 4/4 green filtered. A new kid over a new key lets a
|
|
179
|
+
// conformant host see an unknown kid and re-fetch, as it must.
|
|
180
|
+
const instance = randomBytes(6).toString('hex');
|
|
181
|
+
const kidFor = (n: number): string => `openwop-conformance-key-${instance}-${n}`;
|
|
169
182
|
let rotationCounter = 1;
|
|
170
183
|
let material = generateKeyMaterial(
|
|
171
184
|
algorithm,
|
|
172
|
-
opts.keyId ??
|
|
185
|
+
opts.keyId ?? kidFor(rotationCounter),
|
|
173
186
|
);
|
|
174
187
|
|
|
175
188
|
return {
|
|
@@ -232,10 +245,7 @@ export function createSyntheticOIDCIssuer(
|
|
|
232
245
|
|
|
233
246
|
rotateKey(): void {
|
|
234
247
|
rotationCounter += 1;
|
|
235
|
-
material = generateKeyMaterial(
|
|
236
|
-
algorithm,
|
|
237
|
-
`openwop-conformance-key-${rotationCounter}`,
|
|
238
|
-
);
|
|
248
|
+
material = generateKeyMaterial(algorithm, kidFor(rotationCounter));
|
|
239
249
|
},
|
|
240
250
|
};
|
|
241
251
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - a legacy-only peer serves the 0.3 card (so the shape follows the revision,
|
|
16
16
|
* not the calendar);
|
|
17
17
|
* - `A2A-Version` absent ⇒ 0.3 semantics; unsupported ⇒ `-32009`
|
|
18
|
-
* `VERSION_NOT_SUPPORTED` with `supportedVersions
|
|
18
|
+
* `VERSION_NOT_SUPPORTED` with the supported versions (in `error.data`, an array of `Any` per A2A 1.0.1 §9.5: `google.rpc.ErrorInfo` `metadata.supportedVersions`, comma-joined); a 1.0-only peer
|
|
19
19
|
* rejects header-less requests;
|
|
20
20
|
* - `SendMessage` returns `{ task }` (the `SendMessageResponse` oneof) with
|
|
21
21
|
* `TASK_STATE_*`, `ROLE_*`, `Part` as a `oneof` (no `kind`), `status.timestamp`;
|
|
@@ -42,10 +42,20 @@ async function rpc(endpoint: string, method: string, params: unknown, version?:
|
|
|
42
42
|
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
43
43
|
if (version !== undefined) headers['A2A-Version'] = version;
|
|
44
44
|
const res = await fetch(`${endpoint}/a2a/jsonrpc`, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
|
|
45
|
-
const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?:
|
|
45
|
+
const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?: unknown } };
|
|
46
46
|
return { status: res.status, ...body };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A2A 1.0.1 §9.5: `error.data` is an array of ProtoJSON `Any`; the error type is the
|
|
51
|
+
* `google.rpc.ErrorInfo` element's `reason` (§10.6/§11.6). Returns that element, or
|
|
52
|
+
* undefined when `data` is not such an array (so a bare-object `data` fails the leg).
|
|
53
|
+
*/
|
|
54
|
+
const errorInfo = (e: { data?: unknown } | undefined): { reason?: string; domain?: string; metadata?: Record<string, string> } | undefined => {
|
|
55
|
+
if (!Array.isArray(e?.data)) return undefined;
|
|
56
|
+
return (e!.data as Array<Record<string, unknown>>).find((d) => d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo') as { reason?: string; domain?: string; metadata?: Record<string, string> } | undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
49
59
|
describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', () => {
|
|
50
60
|
// Explicit 1.0-first so a header-less GET returns the 1.0 card (the default
|
|
51
61
|
// is 0.3-first for today's 0.3 clients; both eras are spoken either way).
|
|
@@ -115,8 +125,10 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
|
|
|
115
125
|
const r = await rpc(peer.endpoint(), 'SendMessage', { message: { messageId: 'm2', role: 'ROLE_USER', parts: [{ text: 'hi' }] } }, '99.0');
|
|
116
126
|
expect(r.status, req('openwop.it.a2a-1-0-agent-card.an-unsupported-version-fails-32009-version-not-supported-with-supportedversions', 'RFC 0152 §A/§B/§C/§D', 'an unsupported version fails -32009 VERSION_NOT_SUPPORTED with supportedVersions[] (HTTP 400)')).toBe(400);
|
|
117
127
|
expect(r.error?.code).toBe(-32009);
|
|
118
|
-
expect(r.error?.data
|
|
119
|
-
expect(r.error?.
|
|
128
|
+
expect(Array.isArray(r.error?.data), req('openwop.it.a2a-1-0-agent-card.an-unsupported-version-fails-32009-version-not-supported-with-supportedversions', 'RFC 0152 §A/§B/§C/§D; A2A 1.0.1 §9.5', 'error.data is an array of Any carrying google.rpc.ErrorInfo')).toBe(true);
|
|
129
|
+
expect(errorInfo(r.error)?.reason).toBe('VERSION_NOT_SUPPORTED');
|
|
130
|
+
expect(errorInfo(r.error)?.domain).toBe('a2a-protocol.org');
|
|
131
|
+
expect(errorInfo(r.error)?.metadata?.['supportedVersions']).toBe('1.0,0.3'); // constructor order; ErrorInfo.metadata is map<string,string>
|
|
120
132
|
});
|
|
121
133
|
|
|
122
134
|
it('a 1.0-only peer rejects a header-less request (which is 0.3 by rule)', async () => {
|
|
@@ -125,7 +137,7 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
|
|
|
125
137
|
try {
|
|
126
138
|
const r = await rpc(only10.endpoint(), 'message/send', { message: { parts: [] } });
|
|
127
139
|
expect(r.error?.code, req('openwop.it.a2a-1-0-agent-card.a-1-0-only-peer-rejects-a-header-less-request-which-is-0-3-by-rule', 'RFC 0152 §A/§B/§C/§D', 'a 1.0-only peer rejects a header-less request (which is 0.3 by rule)')).toBe(-32009);
|
|
128
|
-
expect(r.error?.
|
|
140
|
+
expect(errorInfo(r.error)?.metadata?.['requested']).toBe('0.3');
|
|
129
141
|
} finally {
|
|
130
142
|
await only10.stop();
|
|
131
143
|
}
|
|
@@ -161,10 +173,10 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
|
|
|
161
173
|
expect((cancelled.result as { status: { state: string } }).status.state).toBe('TASK_STATE_CANCELED');
|
|
162
174
|
const again = await rpc(peer.endpoint(), 'CancelTask', { id }, '1.0');
|
|
163
175
|
expect(again.error?.code).toBe(-32002);
|
|
164
|
-
expect(again.error?.
|
|
176
|
+
expect(errorInfo(again.error)?.reason).toBe('TASK_NOT_CANCELABLE');
|
|
165
177
|
const missing = await rpc(peer.endpoint(), 'GetTask', { id: 'nope' }, '1.0');
|
|
166
178
|
expect(missing.error?.code).toBe(-32001);
|
|
167
|
-
expect(missing.error?.
|
|
179
|
+
expect(errorInfo(missing.error)?.reason).toBe('TASK_NOT_FOUND');
|
|
168
180
|
});
|
|
169
181
|
|
|
170
182
|
it('a 0.3 method name under a 1.0 header is method-not-found — loudly', async () => {
|
|
@@ -114,14 +114,29 @@ describe('RFC 0153 §B — the suite MCP server speaks 2026-07-28 (dual-era McpF
|
|
|
114
114
|
const r = await call(server.endpoint(), 'tools/list', {}, { version: '2026-07-28', mcpMethod: 'tools/list' });
|
|
115
115
|
expect(r.result?.['resultType'], req('openwop.it.mcp-2026-07-28-discover.tools-list-2026-07-28-carries-resulttype-cacheableresult-hints-and-lists-the-mrt', 'RFC 0153 §B', 'tools/list @2026-07-28 carries resultType + CacheableResult hints and lists the MRTR tool')).toBe('complete');
|
|
116
116
|
expect(typeof r.result?.['ttlMs']).toBe('number');
|
|
117
|
-
|
|
117
|
+
// `private`, not `public`, and the difference is normative: mcp-integration.md §D
|
|
118
|
+
// says `cacheScope` MUST be `private` whenever a result depends on the caller's
|
|
119
|
+
// tenant, workspace, principal or authorization — "which for `tools/list` on a
|
|
120
|
+
// multi-tenant host is always" — and permits `public` ONLY when the result is
|
|
121
|
+
// byte-identical for every caller. The fake server was corrected to `private` in
|
|
122
|
+
// 2.36.0 (RFC 0204, #1497); this assertion was left demanding `public`, so the leg
|
|
123
|
+
// convicted every host of a rule the spec does not have. Same drift the note below
|
|
124
|
+
// records for `needs_input_loop`: the fixture moved, the assertion did not.
|
|
125
|
+
expect(r.result?.['cacheScope']).toBe('private');
|
|
118
126
|
const names = (r.result?.['tools'] as Array<{ name: string }>).map((t) => t.name);
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
127
|
+
// The MRTR tool this `it` is named for is `needs_input`, and it is NOT on page one.
|
|
128
|
+
// The fixture grew to six tools and paginates at TOOLS_PAGE_SIZE (RFC 0204 added
|
|
129
|
+
// `structured-echo`, `always-error` and the read-only-claim tool), so an assertion
|
|
130
|
+
// naming three tools in one page had been describing a fixture that no longer
|
|
131
|
+
// existed. Follow the cursor and assert what the leg is actually for: the catalogue
|
|
132
|
+
// is paged deterministically and the MRTR tool is reachable through it.
|
|
133
|
+
expect(names).toEqual(['echo', 'structured-echo', 'always-error']);
|
|
134
|
+
const cursor = r.result?.['nextCursor'];
|
|
135
|
+
expect(typeof cursor, req('openwop.it.mcp-2026-07-28-discover.tools-list-2026-07-28-carries-resulttype-cacheableresult-hints-and-lists-the-mrt', 'mcp-integration.md §D', 'a truncated tools/list MUST carry a pagination cursor')).toBe('string');
|
|
136
|
+
const page2 = await call(server.endpoint(), 'tools/list', { cursor }, { version: '2026-07-28', mcpMethod: 'tools/list' });
|
|
137
|
+
const rest = (page2.result?.['tools'] as Array<{ name: string }>).map((t) => t.name);
|
|
138
|
+
expect(rest).toEqual([server.readonlyClaimToolName(), 'needs_input', 'needs_input_loop']);
|
|
139
|
+
expect(page2.result?.['nextCursor'], req('openwop.it.mcp-2026-07-28-discover.tools-list-2026-07-28-carries-resulttype-cacheableresult-hints-and-lists-the-mrt', 'mcp-integration.md §D', 'the last page of tools/list MUST NOT advertise a further cursor')).toBeUndefined();
|
|
125
140
|
// legacy list does NOT carry the current-revision fields
|
|
126
141
|
const legacy = await call(server.endpoint(), 'tools/list', {});
|
|
127
142
|
expect(legacy.result?.['ttlMs']).toBeUndefined();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0211 §F — the host as an A2A 1.0 client reads a peer's error in either shape.
|
|
3
|
+
* Target major 2; gated on the v2 `a2a` record and the seams profile.
|
|
4
|
+
*
|
|
5
|
+
* A2A v1.0.1 §9.5 puts a JSON-RPC error's details in `error.data` as an ARRAY of
|
|
6
|
+
* `Any` objects; the suite's fake peer and the reference host shipped a bare
|
|
7
|
+
* `{ reason, domain }` object through 2.36.x. A host's A2A client MUST accept the
|
|
8
|
+
* array and SHOULD accept the object for the rest of 2.x
|
|
9
|
+
* (`spec/v2/core/interop.md` §"The operation mappings", A2A error details).
|
|
10
|
+
*
|
|
11
|
+
* The suite drives the host's client at its own 1.0-only fake peer through the
|
|
12
|
+
* §22 invoke seam with `requestVersion: '99.0'`, so the peer answers
|
|
13
|
+
* `VersionNotSupportedError` (-32009) — once with `data` as `Any[]`, once as the
|
|
14
|
+
* legacy object — and the host MUST project both to `interop_version_unsupported`.
|
|
15
|
+
*
|
|
16
|
+
* Weak by construction, and said so: a host that reads only one shape still
|
|
17
|
+
* identifies the error by its JSON-RPC code, so this leg witnesses that the client
|
|
18
|
+
* tolerates both shapes (does not throw, does not mis-project to `internal_error`),
|
|
19
|
+
* not that it reads `reason` out of the array. `interop_version_unsupported` has
|
|
20
|
+
* `details: null` in `spec/v2/errors.json`, so no projected `supportedVersions` is
|
|
21
|
+
* asserted.
|
|
22
|
+
*
|
|
23
|
+
* Callback-shaped (the host calls the suite's peer): unwitnessable when the host is
|
|
24
|
+
* in a separate network namespace — `../lib/host-callback.ts`.
|
|
25
|
+
*
|
|
26
|
+
* @see RFCS/0211-a2a-error-details-are-errorinfo.md §F
|
|
27
|
+
* @see spec/v2/interop-map.json a2a.errors VersionNotSupportedError (clientProjection)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
33
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
34
|
+
import { A2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
35
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
36
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
37
|
+
import { req } from '../lib/requirement-ids.js';
|
|
38
|
+
|
|
39
|
+
export const REQUIRES_HOST_CALLBACK = "the host's A2A client calls a suite-owned 1.0 peer that refuses the requested version with -32009";
|
|
40
|
+
|
|
41
|
+
const ID = 'openwop.requirement.0211.a2a-client-reads-either-shape';
|
|
42
|
+
const DOC = 'spec/v2/core/interop.md §"The operation mappings", A2A error details (RFC 0211 §F)';
|
|
43
|
+
|
|
44
|
+
describe('RFC 0211 §F — v2-a2a-client-error-details (host as A2A 1.0 client; gated on a2a + seams)', () => {
|
|
45
|
+
it('a peer\'s version error projects to interop_version_unsupported whether error.data is an array or a legacy object', async () => {
|
|
46
|
+
const doc = await v2Discovery().catch(() => null);
|
|
47
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
48
|
+
const facet = await familyAdvertised('a2a');
|
|
49
|
+
if (!facet) return softSkip('inapplicable', 'a2a is not advertised at major 2 — the host has no A2A client to hold');
|
|
50
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the client exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
51
|
+
const peer = new A2AFakePeer({ protocolVersions: ['1.0'] });
|
|
52
|
+
await peer.start();
|
|
53
|
+
const seen: string[] = [];
|
|
54
|
+
try {
|
|
55
|
+
for (const legacy of [false, true]) {
|
|
56
|
+
peer.reset();
|
|
57
|
+
peer.setLegacyErrorData(legacy);
|
|
58
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: true, requestVersion: '99.0' });
|
|
59
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
|
|
60
|
+
const reached = peer.invocations().some((i) => i.method !== 'GET');
|
|
61
|
+
if (!reached) return softSkip('blocked', `the host never called the suite peer (${legacy ? 'legacy' : 'Any[]'} pass) — the host cannot reach it, so no error was projected`);
|
|
62
|
+
const code = readErrorCode(res.json);
|
|
63
|
+
seen.push(`${legacy ? 'legacy object' : 'Any[]'}: HTTP ${res.status} ${String(code)}`);
|
|
64
|
+
expect(code, req(ID, DOC, `a peer's -32009 MUST project to interop_version_unsupported whichever shape error.data takes (${seen.join('; ')})`)).toBe('interop_version_unsupported');
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
await peer.stop();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* @see spec/v2/interop-map.json a2a.operations / a2a.taskState / a2a.errors
|
|
26
26
|
* @see RFCS/0208-v2-a2a-mcp-operation-mappings.md §C, §D, §E
|
|
27
27
|
* @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §D.1 (the credential leg; interop-map.json a2a.taskState override row)
|
|
28
|
+
* @see RFCS/0211-a2a-error-details-are-errorinfo.md §A–§E (the error-details legs; the isolation comparator)
|
|
28
29
|
*/
|
|
29
30
|
import { describe, it, expect } from 'vitest';
|
|
30
31
|
import { readFileSync } from 'node:fs';
|
|
@@ -37,6 +38,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
37
38
|
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
39
|
import { req } from '../lib/requirement-ids.js';
|
|
39
40
|
import { SEAMS_PREFIX } from '../lib/seams.js';
|
|
41
|
+
import { errorInfos, normaliseErrorData, isOpenwopEnvelope, A2A_ERROR_DOMAIN } from '../lib/a2a-error-info.js';
|
|
40
42
|
|
|
41
43
|
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own card lists; nothing harness-hosted is handed to the host';
|
|
42
44
|
|
|
@@ -44,6 +46,8 @@ const DOC = 'spec/v2/core/interop.md §"The operation mappings" (RFC 0208)';
|
|
|
44
46
|
const PROFILE = 'a2a-1.0';
|
|
45
47
|
const SKILL = 'conformance-approval';
|
|
46
48
|
const R = (slug: string): string => `openwop.requirement.0208.${slug}`;
|
|
49
|
+
const R11 = (slug: string): string => `openwop.requirement.0211.${slug}`;
|
|
50
|
+
const DOC11 = 'spec/v2/core/interop.md §"The operation mappings", A2A error details (RFC 0211)';
|
|
47
51
|
const ACCEPT = { action: 'accept' };
|
|
48
52
|
|
|
49
53
|
interface MapStateRow { runStatus: string; wire: string; interruptKind?: string }
|
|
@@ -184,8 +188,12 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
|
|
|
184
188
|
const b = await rpc(t.url, 'GetTask', { id: foreign });
|
|
185
189
|
expect(a.error?.code, req(R('a2a-unreadable-not-found'), 'a2a.errors TaskNotFoundError', `an unknown task MUST be TaskNotFoundError -32001 (got ${JSON.stringify(a.error ?? a.result)})`)).toBe(-32001);
|
|
186
190
|
expect(b.error?.code, req(R('a2a-unreadable-not-found'), 'interop.md §"The operation mappings" Isolation', `a task the caller cannot read — here a foreign tenant segment REST refuses 403 — MUST be answered exactly as a nonexistent one, -32001 (got ${JSON.stringify(b.error ?? b.result)}, HTTP ${b.status})`)).toBe(-32001);
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
// RFC 0211 §D: compare the details per element. `Object.keys(data)` of a one-element
|
|
192
|
+
// array is ["0"] whatever the element discloses, so a key comparison is vacuous.
|
|
193
|
+
const na = normaliseErrorData(a.error?.data, fabricated);
|
|
194
|
+
const nb = normaliseErrorData(b.error?.data, foreign);
|
|
195
|
+
expect(errorInfos(a.error?.data).length, req(R11('a2a-unreadable-not-found-details'), DOC11, `positive control: the unknown-id answer MUST carry error.data as an array with a google.rpc.ErrorInfo — without one the comparison below proves nothing (got ${JSON.stringify(a.error?.data)})`)).toBeGreaterThanOrEqual(1);
|
|
196
|
+
expect(JSON.stringify(nb) + String(b.status), req(R11('a2a-unreadable-not-found-details'), 'interop.md §"The operation mappings" Isolation; RFC 0211 §D (A2A 1.0.1 §3.3.2)', 'the foreign-tenant answer MUST be indistinguishable from the unknown-id answer: same HTTP status and the same normalised error details (@type, reason, domain, sorted metadata keys, minus an echo of the requested id)')).toBe(JSON.stringify(na) + String(a.status));
|
|
189
197
|
await rpc(t.url, 'CancelTask', { id });
|
|
190
198
|
});
|
|
191
199
|
|
|
@@ -274,6 +282,80 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
|
|
|
274
282
|
await rpc(t.url, 'CancelTask', { id: theirId }, { bearer: other });
|
|
275
283
|
});
|
|
276
284
|
|
|
285
|
+
it('an A2A error\'s data is an array carrying one ErrorInfo with the map row\'s reason and the A2A domain', async () => {
|
|
286
|
+
const t = await target(true);
|
|
287
|
+
if (!t.ok) return skip(t, R11('a2a-error-data-shape'));
|
|
288
|
+
const first = await startApprovalTask(t.url);
|
|
289
|
+
const id = first.task?.id;
|
|
290
|
+
expect(typeof id === 'string' && id.includes('/'), req(R11('a2a-error-data-shape'), DOC, `SendMessage MUST start a task with a tenant-bound id: ${JSON.stringify(first.rpc.error)}`)).toBe(true);
|
|
291
|
+
const tenant = id!.slice(0, id!.indexOf('/'));
|
|
292
|
+
const unknown = await rpc(t.url, 'GetTask', { id: `${tenant}/${randomBytes(16).toString('hex').slice(0, 22)}` });
|
|
293
|
+
await rpc(t.url, 'SendMessage', { message: acceptMessage(id!) });
|
|
294
|
+
await settle(id!, (s) => s !== undefined && TERMINAL.has(s));
|
|
295
|
+
const terminal = await rpc(t.url, 'CancelTask', { id });
|
|
296
|
+
for (const [label, r, reason] of [['GetTask on an unknown id', unknown, 'TASK_NOT_FOUND'], ['CancelTask on a terminal task', terminal, 'TASK_NOT_CANCELABLE']] as const) {
|
|
297
|
+
expect(Array.isArray(r.error?.data), req(R11('a2a-error-data-shape'), `${DOC11} (A2A §9.5)`, `${label}: error.data MUST be an array of objects each carrying @type (got ${JSON.stringify(r.error ?? r.result)})`)).toBe(true);
|
|
298
|
+
const all = (Array.isArray(r.error?.data) ? r.error!.data : []) as unknown[];
|
|
299
|
+
expect(all.every((x) => typeof x === 'object' && x !== null && typeof (x as Record<string, unknown>)['@type'] === 'string'), req(R11('a2a-error-data-shape'), `${DOC11} (A2A §9.5)`, `${label}: every element of error.data MUST carry @type (got ${JSON.stringify(all)})`)).toBe(true);
|
|
300
|
+
const infos = errorInfos(r.error?.data);
|
|
301
|
+
expect(infos.length, req(R11('a2a-error-data-shape'), DOC11, `${label}: error.data MUST include exactly one google.rpc.ErrorInfo (got ${JSON.stringify(all)})`)).toBe(1);
|
|
302
|
+
expect([infos[0]?.reason, infos[0]?.domain], req(R11('a2a-error-data-shape'), `${DOC11}; interop-map.json a2a.errors reason`, `${label}: the ErrorInfo reason MUST be the map row's reason and the domain ${A2A_ERROR_DOMAIN}`)).toEqual([reason, A2A_ERROR_DOMAIN]);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('no response on the card\'s JSON-RPC interface URL is the OpenWOP error envelope', async () => {
|
|
307
|
+
const t = await target(false);
|
|
308
|
+
if (!t.ok) return skip(t, R11('a2a-no-openwop-envelope'));
|
|
309
|
+
const probes: Array<[string, RequestInit]> = [
|
|
310
|
+
['a body that is not JSON', { method: 'POST', headers: { 'content-type': 'application/json', 'A2A-Version': '1.0', ...(process.env.OPENWOP_API_KEY ? { authorization: `Bearer ${process.env.OPENWOP_API_KEY}` } : {}) }, body: '{"jsonrpc":"2.0","id":1,' }],
|
|
311
|
+
['a request with no credential', { method: 'POST', headers: { 'content-type': 'application/json', 'A2A-Version': '1.0' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'GetTask', params: { id: 'x/y' } }) }],
|
|
312
|
+
['an unknown method', { method: 'POST', headers: { 'content-type': 'application/json', 'A2A-Version': '1.0', ...(process.env.OPENWOP_API_KEY ? { authorization: `Bearer ${process.env.OPENWOP_API_KEY}` } : {}) }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'openwop.conformance.NoSuchMethod', params: {} }) }],
|
|
313
|
+
];
|
|
314
|
+
const seen: string[] = [];
|
|
315
|
+
for (const [label, init] of probes) {
|
|
316
|
+
const res = await fetch(t.url, init);
|
|
317
|
+
const text = await res.text();
|
|
318
|
+
let body: unknown = null;
|
|
319
|
+
try { body = text.length > 0 ? JSON.parse(text) : null; } catch { body = null; }
|
|
320
|
+
seen.push(`${label}: HTTP ${res.status}`);
|
|
321
|
+
expect(isOpenwopEnvelope(body), req(R11('a2a-no-openwop-envelope'), `${DOC11}; RFC 0211 §C`, `${label}: a response on an interface URL the card lists — including a refusal before dispatch — MUST be in the binding's shape, never the OpenWOP { error, message } envelope (HTTP ${res.status}, body ${text.slice(0, 200)})`)).toBe(false);
|
|
322
|
+
}
|
|
323
|
+
expect(seen.length, req(R11('a2a-no-openwop-envelope'), DOC11, 'every probe reached the interface')).toBe(probes.length);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('VersionNotSupportedError carries ErrorInfo VERSION_NOT_SUPPORTED, and supportedVersions is a comma-joined subset of the card', async () => {
|
|
327
|
+
const t = await target(false);
|
|
328
|
+
if (!t.ok) return skip(t, R11('a2a-version-not-supported-shape'));
|
|
329
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
330
|
+
const card = (await (await fetch(String(a2a['agentCardUrl']), { headers: { accept: 'application/json', 'A2A-Version': '1.0' } })).json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolVersion?: string }> };
|
|
331
|
+
const offered = new Set((card.supportedInterfaces ?? []).filter((i) => i.url === t.url).map((i) => String(i.protocolVersion)));
|
|
332
|
+
const r = await rpc(t.url, 'GetTask', { id: 'zz-conformance/unknown' }, { version: '99.0' });
|
|
333
|
+
expect(r.error?.code, req(R11('a2a-version-not-supported-shape'), 'a2a.errors VersionNotSupportedError', `an A2A-Version the interface does not serve MUST be -32009, not -32600 (got ${JSON.stringify(r.error ?? r.result)})`)).toBe(-32009);
|
|
334
|
+
const info = errorInfos(r.error?.data)[0];
|
|
335
|
+
expect(info?.reason, req(R11('a2a-version-not-supported-shape'), DOC11, `the -32009 error MUST carry ErrorInfo reason VERSION_NOT_SUPPORTED (got ${JSON.stringify(r.error?.data)})`)).toBe('VERSION_NOT_SUPPORTED');
|
|
336
|
+
const sv = (info?.metadata as Record<string, unknown> | undefined)?.['supportedVersions'];
|
|
337
|
+
// metadata.supportedVersions is a SHOULD (RFC 0211 §E): absent is not a failure — a client
|
|
338
|
+
// falls back to the card. When present it MUST be a comma-joined subset of the card's versions.
|
|
339
|
+
if (sv !== undefined) {
|
|
340
|
+
expect(typeof sv === 'string' && sv.split(',').every((v) => offered.has(v.trim())), req(R11('a2a-version-not-supported-shape'), `${DOC11}; RFC 0211 §E`, `when present, metadata.supportedVersions MUST be a comma-joined string of versions the card lists for this interface (got ${JSON.stringify(sv)}; card ${JSON.stringify([...offered])})`)).toBe(true);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('an HTTP+JSON interface answers an A2A error with google.rpc.Status carrying the ErrorInfo', async () => {
|
|
345
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
346
|
+
const a2a = await familyAdvertised('a2a');
|
|
347
|
+
if (!a2a || !(Array.isArray(a2a['profiles']) && (a2a['profiles'] as unknown[]).includes(PROFILE))) return softSkip('inapplicable', 'a2a-1.0 is not claimed — no A2A interface to hold');
|
|
348
|
+
const card = (await (await fetch(String(a2a['agentCardUrl']), { headers: { accept: 'application/json', 'A2A-Version': '1.0' } })).json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }> };
|
|
349
|
+
const rest = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'HTTP+JSON' && i.protocolVersion === '1.0');
|
|
350
|
+
if (typeof rest?.url !== 'string') return softSkip('inapplicable', 'the card lists no HTTP+JSON interface at 1.0 — RFC 0211 §B binds only a host that lists one');
|
|
351
|
+
const headers: Record<string, string> = { accept: 'application/a2a+json, application/json', 'A2A-Version': '1.0' };
|
|
352
|
+
if (process.env.OPENWOP_API_KEY) headers['authorization'] = `Bearer ${process.env.OPENWOP_API_KEY}`;
|
|
353
|
+
const res = await fetch(`${rest.url.replace(/\/$/, '')}/tasks/${encodeURIComponent(`zz-conformance/${randomBytes(8).toString('hex')}`)}`, { headers });
|
|
354
|
+
const body = (await res.json().catch(() => ({}))) as { error?: { code?: unknown; status?: unknown; message?: unknown; details?: unknown } };
|
|
355
|
+
expect([typeof body.error?.code, typeof body.error?.status, typeof body.error?.message], req(R11('a2a-httpjson-status'), `${DOC11} (A2A §11.6)`, `an HTTP+JSON A2A error MUST be google.rpc.Status { error: { code, status, message, details } } (HTTP ${res.status}, got ${JSON.stringify(body)})`)).toEqual(['number', 'string', 'string']);
|
|
356
|
+
expect(errorInfos(body.error?.details)[0]?.reason, req(R11('a2a-httpjson-status'), `${DOC11} (A2A §11.6)`, 'the Status details MUST carry the ErrorInfo TASK_NOT_FOUND')).toBe('TASK_NOT_FOUND');
|
|
357
|
+
});
|
|
358
|
+
|
|
277
359
|
it('a run suspended on a credential interrupt projects to TASK_STATE_AUTH_REQUIRED, naming the provider and carrying connectUrl', async () => {
|
|
278
360
|
const id = 'openwop.requirement.0199.a2a-auth-required';
|
|
279
361
|
const t = await target(false);
|
|
@@ -64,7 +64,6 @@
|
|
|
64
64
|
* @see RFCS/0158-durable-execution-and-disaster-recovery-qualification.md §B.4 §D.9 §E
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
68
67
|
import { describe, expect, it } from 'vitest';
|
|
69
68
|
import { driver } from '../lib/driver.js';
|
|
70
69
|
import { v2Discovery } from '../lib/v2.js';
|
|
@@ -72,7 +71,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
72
71
|
import { softSkip } from '../lib/soft-skip.js';
|
|
73
72
|
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
74
73
|
import { req } from '../lib/requirement-ids.js';
|
|
75
|
-
import {
|
|
74
|
+
import { startEffectReceiver, waitForFirstArrival } from '../lib/effect-receiver.js';
|
|
76
75
|
import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
|
|
77
76
|
import { noteEvidence, parseRecoveryBounds, EVIDENCE_NAME_PATTERN } from '../lib/durability-evidence.js';
|
|
78
77
|
|
|
@@ -267,26 +266,15 @@ function watch(runId: string, budgetMs: number, resumed: (o: Observation) => boo
|
|
|
267
266
|
}
|
|
268
267
|
|
|
269
268
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
269
|
+
* How long to wait for the ONE legitimate arrival before concluding none came.
|
|
270
|
+
*
|
|
271
|
+
* Separate from `QUIET_WINDOW_MS`, and for the opposite reason: this is a wait
|
|
272
|
+
* FOR something that must happen, so a generous bound weakens nothing, while
|
|
273
|
+
* the quiet window that follows is a wait for something that must NOT happen.
|
|
274
|
+
* Collapsing the two into one blind sleep made the row measure the host's
|
|
275
|
+
* effect latency under load — see `lib/effect-receiver.ts`.
|
|
275
276
|
*/
|
|
276
|
-
|
|
277
|
-
const arrivals: Array<{ method: string; at: number }> = [];
|
|
278
|
-
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
279
|
-
request.on('data', () => { /* drain */ });
|
|
280
|
-
request.on('end', () => { arrivals.push({ method: request.method ?? '', at: Date.now() }); res.writeHead(204); res.end(); });
|
|
281
|
-
});
|
|
282
|
-
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
283
|
-
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
284
|
-
const binding = receiverBinding();
|
|
285
|
-
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
286
|
-
const addr = server.address();
|
|
287
|
-
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
288
|
-
return { server, url: `http://${binding.advertise}:${port}/effect`, arrivals };
|
|
289
|
-
}
|
|
277
|
+
const FIRST_ARRIVAL_BUDGET_MS = 20_000;
|
|
290
278
|
|
|
291
279
|
describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-single-instance rows)', () => {
|
|
292
280
|
it('accepted work survives a kill before dispatch and dispatches on resume', async () => {
|
|
@@ -421,12 +409,29 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
421
409
|
// (`conformance-noop` records none). The seam is host test infrastructure
|
|
422
410
|
// (§E) and chooses the work; it MUST stage work that performs EXACTLY ONE
|
|
423
411
|
// outbound effect, addressed to `effectUrl`, and deliver it twice.
|
|
412
|
+
//
|
|
413
|
+
// 2.37.0 — THE DESTINATION CARRIES A PER-EXERCISE NONCE, and that is what
|
|
414
|
+
// makes this row deterministic. An effect's Layer-2 identity is its
|
|
415
|
+
// BUSINESS identity (idempotency.md §"Layer 2 Keying"): tenant, workflow,
|
|
416
|
+
// node, request digest — and the destination URL is part of that digest,
|
|
417
|
+
// while the runId deliberately is not. Until 2.37.0 this leg and
|
|
418
|
+
// `0194.terminal-once.duplicate-delivery` both handed the seam
|
|
419
|
+
// `resolveRegistrationUrl(...)`, which on a tunnelled cut is
|
|
420
|
+
// OPENWOP_WEBHOOK_RECEIVER_URL verbatim — the same string for both. Same
|
|
421
|
+
// fixture, same node, same URL ⇒ SAME effect identity, so a CONFORMANT
|
|
422
|
+
// host resolved the second exercise to the first's recorded outcome and
|
|
423
|
+
// called out zero times. Whichever leg vitest ran second read zero
|
|
424
|
+
// arrivals: `blocked` on loopback, a hard `executed-fail` on a tunnelled
|
|
425
|
+
// cut. Measured on the v2 reference host — exercise 2's ledger row read
|
|
426
|
+
// `invocationId: "deduplicated-of:<run 1>"`. Nothing but the file order
|
|
427
|
+
// differed between a pass and a fail, and the row was measuring
|
|
428
|
+
// cross-exercise deduplication rather than §C's within-exercise
|
|
429
|
+
// exactly-once. The nonce gives every exercise its own identity.
|
|
424
430
|
const rx = await startEffectReceiver();
|
|
425
431
|
try {
|
|
426
|
-
const
|
|
427
|
-
const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url });
|
|
432
|
+
const fired = await driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: rx.url });
|
|
428
433
|
if (fired.status >= 400) {
|
|
429
|
-
return softSkip('blocked', `the durability seam answered ${fired.status} for mode=duplicate-delivery with effectUrl ${
|
|
434
|
+
return softSkip('blocked', `the durability seam answered ${fired.status} for mode=duplicate-delivery with effectUrl ${rx.tunnelled ? '(tunnelled front)' : rx.url} — the host exposes the route but could not stage a double delivery; if its egress guard refused the receiver, the operator precondition is the webhook rows' own: a publicly-resolvable https front (OPENWOP_WEBHOOK_RECEIVER_URL) or a host run with its private-egress relaxation recorded`);
|
|
430
435
|
}
|
|
431
436
|
const runId = (fired.json as { runId?: unknown } | null)?.runId;
|
|
432
437
|
if (typeof runId !== 'string') {
|
|
@@ -434,28 +439,71 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
434
439
|
}
|
|
435
440
|
|
|
436
441
|
await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(60_000) });
|
|
437
|
-
//
|
|
438
|
-
// that must not
|
|
442
|
+
// Wait FOR the one legitimate arrival, then wait OUT the quiet window for
|
|
443
|
+
// a second that must not come. A single blind sleep conflated the two and
|
|
444
|
+
// made a slow-but-correct host read as zero.
|
|
445
|
+
await waitForFirstArrival(rx, scaledTimeoutMs(FIRST_ARRIVAL_BUDGET_MS));
|
|
439
446
|
await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
|
|
440
447
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
448
|
+
// Read the host's OWN account first, so a zero can SAY WHY. The two
|
|
449
|
+
// zeroes are different facts and must not share one disposition: nothing
|
|
450
|
+
// was ever sent, or the host resolved this work to an outcome it had
|
|
451
|
+
// already recorded (a `deduplicated-of:` / `replay-of:` invocation) —
|
|
452
|
+
// correct Layer-2 behaviour that leaves §C unmeasured, not refuted.
|
|
453
|
+
const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
|
|
454
|
+
const effects = eff.status === 200 ? ((eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? []) : [];
|
|
455
|
+
const resolvedElsewhere = effects.filter((e) => /^(deduplicated-of|replay-of):/.test(String(e['invocationId'] ?? '')));
|
|
456
|
+
// An effect the host ATTEMPTED and whose transport failed. `released` is
|
|
457
|
+
// the state persistence.md gives a claim that was taken and given back;
|
|
458
|
+
// a host that names it is telling us the invocation was tried and never
|
|
459
|
+
// reached the destination. That is a THIRD kind of zero and it must not
|
|
460
|
+
// be reported as either of the others.
|
|
461
|
+
const attemptedAndFailed = effects.filter((e) => String(e['state'] ?? '') === 'released');
|
|
462
|
+
const arrivals = rx.arrivals();
|
|
463
|
+
if (arrivals === 0) {
|
|
464
|
+
// A BARE FAIL ON ZERO IS UNINFORMATIVE, so the zero is classified from
|
|
465
|
+
// the host's own ledger before any disposition is chosen. Three states
|
|
466
|
+
// wear the same symptom and only one of them is about §C:
|
|
467
|
+
// (a) resolved to an outcome the host had ALREADY recorded — correct
|
|
468
|
+
// Layer-2 keying, and this exercise simply performed no
|
|
469
|
+
// invocation to count;
|
|
470
|
+
// (b) attempted and the transport failed — a missed fire, which is
|
|
471
|
+
// the opposite of a double fire and cannot refute exactly-once;
|
|
472
|
+
// (c) nothing in the ledger at all — the seam never staged the one
|
|
473
|
+
// effect the mode requires, so the exercise did not happen.
|
|
474
|
+
// None of the three is evidence AGAINST §C, so none of them is an
|
|
475
|
+
// `executed-fail`; each is `blocked` with its own cause named. The only
|
|
476
|
+
// thing this row ever fails on is a count that is not 1 with at least
|
|
477
|
+
// one real invocation observed.
|
|
478
|
+
const why = resolvedElsewhere.length > 0
|
|
479
|
+
? `the host resolved the staged effect to an outcome it had ALREADY RECORDED (${resolvedElsewhere.map((e) => String(e['invocationId'])).join(', ')}) — correct Layer-2 keying (idempotency.md §"Layer 2 Keying"), but it means no invocation happened in THIS exercise, so §C's exactly-once is unmeasured here rather than violated. The destination this suite minted carries a per-exercise nonce precisely so this cannot happen; a host that keys on something coarser than the request MUST stage a fresh business identity per exercise`
|
|
480
|
+
: attemptedAndFailed.length > 0
|
|
481
|
+
? `the host ATTEMPTED the staged effect and its transport failed — ${attemptedAndFailed.length} ledger row(s) in state \`released\`${attemptedAndFailed.map((e) => ` (${String(e['effectId'] ?? '?')} attempt ${String(e['attempt'] ?? '?')})`).join('')}. A MISSED fire is not an exactly-once violation, so this is not a refutation of §C and is not recorded as one; the operator precondition is a destination the host can actually reach inside its own effect timeout, and a host whose staged work takes no transport retry will show this whenever the round trip exceeds that timeout`
|
|
482
|
+
: `the staged work's effect never reached the suite's receiver at ${rx.localUrl}${rx.tunnelled ? ` (fronted as ${rx.url})` : ''} within ${FIRST_ARRIVAL_BUDGET_MS}ms of terminal, and the host's own effect ledger records no attempt — for mode=duplicate-delivery the seam MUST aim exactly one outbound effect at the given effectUrl; with nothing landed there is no invocation to count, and the ledger alone cannot witness a double-fire${rx.foreign() > 0 ? `. ${rx.foreign()} request(s) DID reach this listener without this exercise's nonce, so the front is wired but the host addressed something else` : ''}`;
|
|
483
|
+
// Zero is never a pass, and — since 2.37.0 — never a bare fail either.
|
|
484
|
+
// Until now a tunnelled cut turned every zero into `executed-fail`
|
|
485
|
+
// ("a mis-wired tunnel must not read as a pass"), which is right about
|
|
486
|
+
// the pass and wrong about the fail: it convicted a host of violating
|
|
487
|
+
// exactly-once on the evidence that it fired too FEW times. The
|
|
488
|
+
// mis-wired tunnel it was defending against is now the `foreign()`
|
|
489
|
+
// count — a front that reaches this listener but carries someone
|
|
490
|
+
// else's nonce — which the reason above names. `blocked` is not a
|
|
491
|
+
// softening: a blocked row DENIES certification (RFC 0168 §E.1)
|
|
492
|
+
// exactly as a failed one does, so the rung is still refused; what
|
|
493
|
+
// changes is that the bundle says which of the three zeroes happened
|
|
494
|
+
// instead of asserting a violation that was never observed.
|
|
495
|
+
return softSkip('blocked', why);
|
|
444
496
|
}
|
|
445
|
-
// With a tunnel declared, zero arrivals is a hard failure, never a skip
|
|
446
|
-
// (webhook-receiver.ts): a mis-wired tunnel must not read as a pass.
|
|
447
497
|
expect(
|
|
448
498
|
arrivals,
|
|
449
|
-
req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the same accepted work delivered twice MUST fire each effect exactly once, counted at the effect's destination — the suite's receiver observed ${arrivals} arrival(s)
|
|
499
|
+
req('openwop.requirement.0158.duplicate-delivery', 'RFC 0158 §C', `the same accepted work delivered twice MUST fire each effect exactly once, counted at the effect's destination — the suite's receiver observed ${arrivals} arrival(s) bearing this exercise's nonce ${rx.nonce} for run ${runId}${rx.foreign() > 0 ? ` (and ${rx.foreign()} unrelated request(s), not counted)` : ''}`),
|
|
450
500
|
).toBe(1);
|
|
451
501
|
|
|
452
502
|
// Secondary, and labelled for what it is: the host's own account agrees
|
|
453
503
|
// with what landed. On an identity-keyed ledger this can never exceed one
|
|
454
504
|
// row per identity, so it witnesses that the PROJECTION IS CONSISTENT, not
|
|
455
505
|
// that no double-fire happened — the arrival count above owns that.
|
|
456
|
-
const eff = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
|
|
457
506
|
if (eff.status === 200) {
|
|
458
|
-
const effects = (eff.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? [];
|
|
459
507
|
const byIdentity = new Map<string, number>();
|
|
460
508
|
for (const e of effects) {
|
|
461
509
|
const id = String(e['effectId'] ?? e['keying'] ?? '');
|
|
@@ -469,7 +517,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
469
517
|
).toBe(true);
|
|
470
518
|
}
|
|
471
519
|
} finally {
|
|
472
|
-
await
|
|
520
|
+
await rx.close();
|
|
473
521
|
}
|
|
474
522
|
}, 180_000);
|
|
475
523
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/idempotency.md` Concurrency row — the loser of a same-key race
|
|
3
|
+
* (RFC 0213 §B; suite 2.37.0, target major 2; unaided; one run created).
|
|
4
|
+
*
|
|
5
|
+
* Of concurrent same-key requests a host MUST process exactly one. The others
|
|
6
|
+
* MAY wait and receive the winner's response only if it is a final outcome,
|
|
7
|
+
* marked `OpenWOP-Idempotent-Replay: true`; otherwise the host MUST answer
|
|
8
|
+
* `409 idempotency_in_flight` with no retry timing in `details`, and SHOULD set
|
|
9
|
+
* `Retry-After`. The v2 counterpart of the major-1 `highConcurrency.test.ts`.
|
|
10
|
+
*
|
|
11
|
+
* N = 5 parallel `POST /runs` with one key. The record is in flight only
|
|
12
|
+
* while the winning create request is being handled — not while its run
|
|
13
|
+
* executes — so a host that answers create in milliseconds rarely refuses a
|
|
14
|
+
* loser; the fixture choice does not widen that window:
|
|
15
|
+
* - exactly one distinct runId across the successes (two runs = the claim
|
|
16
|
+
* was not honored);
|
|
17
|
+
* - every success but one carries `OpenWOP-Idempotent-Replay: true`;
|
|
18
|
+
* - every refusal is `409 idempotency_in_flight`, carries no
|
|
19
|
+
* `details.retryAfter*`, and a `Retry-After`, when present, parses.
|
|
20
|
+
*
|
|
21
|
+
* Non-vacuity: overlap is not guaranteed. When no request was refused in flight
|
|
22
|
+
* the file records `partial-witness` — the 409 branch never ran, so a pass is
|
|
23
|
+
* not claimed for it.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v2/core/idempotency.md
|
|
26
|
+
* @see RFCS/0213-three-unstated-v2-outcomes.md §B
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
31
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
32
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
33
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
34
|
+
import { req } from '../lib/requirement-ids.js';
|
|
35
|
+
|
|
36
|
+
const DOC = 'spec/v2/core/idempotency.md Concurrency';
|
|
37
|
+
const ID_ONE = 'openwop.requirement.0213.in-flight-one-winner';
|
|
38
|
+
const ID_LOSER = 'openwop.requirement.0213.in-flight-loser-outcome';
|
|
39
|
+
const FIXTURE = 'conformance-delay';
|
|
40
|
+
const N = 5;
|
|
41
|
+
|
|
42
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
43
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
44
|
+
function parsesRetryAfter(v: string): boolean { return /^\d+$/.test(v.trim()) || !Number.isNaN(Date.parse(v)); }
|
|
45
|
+
|
|
46
|
+
describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)', () => {
|
|
47
|
+
it('concurrent same-key creates yield one run; each loser is a marked replay of a final outcome or 409 idempotency_in_flight with no retry timing in details', async () => {
|
|
48
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
49
|
+
const key = `openwopconf-inflight-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
50
|
+
const body = { workflowId: FIXTURE, inputs: { delayMs: 1500 } };
|
|
51
|
+
const results = await Promise.all(Array.from({ length: N }, () => http(() => driver.post('/runs', body, { headers: { 'Idempotency-Key': key } }))));
|
|
52
|
+
if (results.some((r) => r === null)) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
53
|
+
const rs = results as OpenWOPResponse[];
|
|
54
|
+
const first = rs.find((r) => r.status >= 200 && r.status < 300);
|
|
55
|
+
if (first === undefined) {
|
|
56
|
+
const codes = rs.map((r) => `${r.status} ${readErrorCode(r.json) ?? ''}`.trim()).join(', ');
|
|
57
|
+
if (rs.every((r) => r.status === 404 || r.status === 422)) return softSkip('blocked', `the ${FIXTURE} fixture is not runnable (${codes})`);
|
|
58
|
+
}
|
|
59
|
+
const successes = rs.filter((r) => r.status >= 200 && r.status < 300);
|
|
60
|
+
const refusals = rs.filter((r) => !(r.status >= 200 && r.status < 300));
|
|
61
|
+
expect(successes.length, req(ID_ONE, DOC, `at least one of ${N} same-key creates MUST complete (statuses: ${rs.map((r) => r.status).join(',')})`)).toBeGreaterThan(0);
|
|
62
|
+
const runIds = new Set(successes.map((r) => (r.json as { runId?: unknown } | null)?.runId).filter((x): x is string => typeof x === 'string'));
|
|
63
|
+
expect(runIds.size, req(ID_ONE, DOC, `a host MUST NOT process two same-key requests: distinct runIds ${[...runIds].join(', ')}`)).toBe(1);
|
|
64
|
+
const unmarked = successes.filter((r) => r.headers.get('openwop-idempotent-replay') !== 'true');
|
|
65
|
+
expect(unmarked.length, req(ID_LOSER, DOC, `exactly one success is the winner; every other MUST carry OpenWOP-Idempotent-Replay: true (${unmarked.length} of ${successes.length} unmarked)`)).toBe(1);
|
|
66
|
+
for (const r of refusals) {
|
|
67
|
+
const code = readErrorCode(r.json);
|
|
68
|
+
expect({ status: r.status, code }, req(ID_LOSER, DOC, `a loser that is not a replay MUST be 409 idempotency_in_flight (got ${r.status} ${String(code)})`)).toEqual({ status: 409, code: 'idempotency_in_flight' });
|
|
69
|
+
const details = (r.json as { details?: Record<string, unknown> } | null)?.details ?? {};
|
|
70
|
+
const timing = Object.keys(details).filter((k) => /^retryAfter/i.test(k));
|
|
71
|
+
expect(timing, req(ID_LOSER, 'spec/v2/core/errors.md §Retry timing', `retry timing MUST NOT travel in details (found ${timing.join(', ')})`)).toEqual([]);
|
|
72
|
+
const ra = r.headers.get('retry-after');
|
|
73
|
+
if (ra !== null) expect(parsesRetryAfter(ra), req(ID_LOSER, DOC, `a Retry-After that is present MUST parse (got ${ra})`)).toBe(true);
|
|
74
|
+
}
|
|
75
|
+
if (refusals.length === 0) return softSkip('blocked', `no loser was refused in flight — all ${N} answers were successes, so the 409 branch did not run on this host`);
|
|
76
|
+
}, 60_000);
|
|
77
|
+
});
|