@openwop/openwop-conformance 2.36.1 → 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 +11 -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 +438 -53
- 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/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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/errors.md` §"One code per state" — a resolve after the run
|
|
3
|
+
* ended (RFC 0213 §C; suite 2.37.0, target major 2; unaided; two runs created).
|
|
4
|
+
*
|
|
5
|
+
* A run-scoped resolve against an interrupt whose run is cancelled or completed
|
|
6
|
+
* MUST return `409 interrupt_already_resolved`; `interrupt_cancelled` is
|
|
7
|
+
* registered but names no state of the core resolve surfaces, and a host MUST
|
|
8
|
+
* NOT emit it. A resolve on an unknown run is `404 not_found`.
|
|
9
|
+
*
|
|
10
|
+
* Legs:
|
|
11
|
+
* 1. suspend (`conformance-approval`) → cancel → resolve ⇒ 409
|
|
12
|
+
* interrupt_already_resolved (a 410 `interrupt_cancelled`, a vendor
|
|
13
|
+
* "gone" code, or a 200 that "resumes" a cancelled run all fail);
|
|
14
|
+
* 2. suspend → resolve (accept) → completed → resolve again ⇒ the same 409;
|
|
15
|
+
* 3. a resolve on an unknown own-tenant runId ⇒ 404 not_found.
|
|
16
|
+
*
|
|
17
|
+
* The signed-token surface is a SHOULD and needs a token the suite cannot mint;
|
|
18
|
+
* its terminal outcome is the same row of `interrupt.md` and is not re-tested
|
|
19
|
+
* here.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v2/core/errors.md §One code per state
|
|
22
|
+
* @see spec/v2/core/interrupt.md §Resolve
|
|
23
|
+
* @see RFCS/0213-three-unstated-v2-outcomes.md §C
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
28
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
29
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
30
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
|
|
33
|
+
const DOC = 'spec/v2/core/errors.md §One code per state';
|
|
34
|
+
const ID_CANCELLED = 'openwop.requirement.0213.resolve-after-cancel';
|
|
35
|
+
const ID_COMPLETED = 'openwop.requirement.0213.resolve-after-complete';
|
|
36
|
+
const ID_UNKNOWN = 'openwop.requirement.0213.resolve-unknown-run';
|
|
37
|
+
const FIXTURE = 'conformance-approval';
|
|
38
|
+
const NODE_ID = 'gate';
|
|
39
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
40
|
+
|
|
41
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
42
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
43
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
44
|
+
|
|
45
|
+
async function statusOf(runId: string): Promise<string | null> {
|
|
46
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
47
|
+
return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
48
|
+
}
|
|
49
|
+
async function waitStatus(runId: string, wanted: ReadonlySet<string>, timeoutMs: number): Promise<string | null> {
|
|
50
|
+
const deadline = Date.now() + timeoutMs;
|
|
51
|
+
for (;;) {
|
|
52
|
+
const s = await statusOf(runId);
|
|
53
|
+
if (s !== null && wanted.has(s)) return s;
|
|
54
|
+
if (Date.now() > deadline) return s;
|
|
55
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function suspended(): Promise<{ runId: string } | { reason: string }> {
|
|
59
|
+
const res = await http(() => driver.post('/runs', { workflowId: FIXTURE }));
|
|
60
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
61
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
62
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${FIXTURE}) answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
|
|
63
|
+
const s = await waitStatus(runId, new Set(['waiting-approval', ...TERMINAL]), 10_000);
|
|
64
|
+
if (s !== 'waiting-approval') return { reason: `the ${FIXTURE} run did not suspend on its approval gate (status ${String(s)})` };
|
|
65
|
+
return { runId };
|
|
66
|
+
}
|
|
67
|
+
const resolve = (runId: string): Promise<OpenWOPResponse | null> => http(() => driver.post(`/runs/${enc(runId)}/interrupts/${enc(NODE_ID)}`, { resumeValue: { action: 'accept' } }));
|
|
68
|
+
|
|
69
|
+
describe('v2 interrupt-resolve-terminal (errors.md §One code per state, RFC 0213 §C)', () => {
|
|
70
|
+
it('a run-scoped resolve after the run was cancelled is 409 interrupt_already_resolved', async () => {
|
|
71
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
72
|
+
const s = await suspended(); if ('reason' in s) return softSkip('blocked', s.reason);
|
|
73
|
+
const cancel = await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
|
|
74
|
+
if (cancel === null || cancel.status !== 200) return softSkip('blocked', `POST /runs/{runId}/cancel answered ${cancel?.status ?? 'nothing'} — cancelRun owns that contract`);
|
|
75
|
+
const settled = await waitStatus(s.runId, new Set(['cancelled']), 10_000);
|
|
76
|
+
if (settled !== 'cancelled') return softSkip('blocked', `the cancelled run did not reach cancelled within 10 s (status ${String(settled)})`);
|
|
77
|
+
const res = await resolve(s.runId);
|
|
78
|
+
if (res === null) return softSkip('blocked', 'the resolve was unreachable (fetch failed)');
|
|
79
|
+
const code = readErrorCode(res.json);
|
|
80
|
+
expect({ status: res.status, code }, req(ID_CANCELLED, DOC, `a resolve against a cancelled run MUST return 409 interrupt_already_resolved — never 410 interrupt_cancelled, a vendor code, or a 200 (got ${res.status} ${String(code)})`)).toEqual({ status: 409, code: 'interrupt_already_resolved' });
|
|
81
|
+
}, 45_000);
|
|
82
|
+
|
|
83
|
+
it('a second run-scoped resolve after the run completed is 409 interrupt_already_resolved', async () => {
|
|
84
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
85
|
+
const s = await suspended(); if ('reason' in s) return softSkip('blocked', s.reason);
|
|
86
|
+
const first = await resolve(s.runId);
|
|
87
|
+
if (first === null || first.status < 200 || first.status >= 300) return softSkip('blocked', `the first resolve answered ${first?.status ?? 'nothing'} ${readErrorCode(first?.json) ?? ''} — interrupt resolution owns that contract`);
|
|
88
|
+
const settled = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
89
|
+
if (settled !== 'completed') return softSkip('blocked', `the accepted run did not complete within 10 s (status ${String(settled)})`);
|
|
90
|
+
const res = await resolve(s.runId);
|
|
91
|
+
if (res === null) return softSkip('blocked', 'the second resolve was unreachable (fetch failed)');
|
|
92
|
+
const code = readErrorCode(res.json);
|
|
93
|
+
expect({ status: res.status, code }, req(ID_COMPLETED, DOC, `a resolve against a completed run MUST return 409 interrupt_already_resolved (got ${res.status} ${String(code)})`)).toEqual({ status: 409, code: 'interrupt_already_resolved' });
|
|
94
|
+
}, 45_000);
|
|
95
|
+
|
|
96
|
+
it('a run-scoped resolve on an unknown run is 404 not_found', async () => {
|
|
97
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
98
|
+
const s = await suspended(); if ('reason' in s) return softSkip('blocked', s.reason);
|
|
99
|
+
await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
|
|
100
|
+
const slash = s.runId.indexOf('/');
|
|
101
|
+
if (slash <= 0) return softSkip('blocked', `the created runId ${s.runId} carries no tenant segment — v2-id-grammar owns that contract`);
|
|
102
|
+
const res = await resolve(`${s.runId.slice(0, slash)}/openwopconformanceunknown${Date.now().toString(36)}`);
|
|
103
|
+
if (res === null) return softSkip('blocked', 'the resolve was unreachable (fetch failed)');
|
|
104
|
+
const code = readErrorCode(res.json);
|
|
105
|
+
expect({ status: res.status, code }, req(ID_UNKNOWN, 'spec/v2/core/interrupt.md §Resolve', `a resolve on an unknown run MUST be 404 not_found (got ${res.status} ${String(code)})`)).toEqual({ status: 404, code: 'not_found' });
|
|
106
|
+
}, 45_000);
|
|
107
|
+
});
|
|
@@ -47,6 +47,9 @@ const META_C = 'io.modelcontextprotocol/clientCapabilities';
|
|
|
47
47
|
const ERR = { HEADER_MISMATCH: -32020, MISSING_CAPABILITY: -32021, UNSUPPORTED_VERSION: -32022, INVALID_PARAMS: -32602 } as const;
|
|
48
48
|
|
|
49
49
|
const MAP = JSON.parse(readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'interop-map.json'), 'utf8')) as { mcp: { features: Array<{ id: string; requiredFor: string[] }> } };
|
|
50
|
+
// `interruptId` is host-minted and tenant-bound (`<tenant>/<opaque>`); `nodeId` is author-chosen
|
|
51
|
+
// inside a workflow definition and carries no `/`, so the two grammars cannot both admit one string.
|
|
52
|
+
const INTERRUPT_ID = new RegExp((JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'ids.schema.json'), 'utf8')) as { $defs: { interruptId: { pattern: string } } }).$defs.interruptId.pattern);
|
|
50
53
|
const REQUIRED_FEATURES = MAP.mcp.features.filter((f) => f.requiredFor.includes(PROFILE)).map((f) => f.id).sort();
|
|
51
54
|
|
|
52
55
|
interface RpcError { code: number; message?: string; data?: Record<string, unknown> }
|
|
@@ -200,6 +203,44 @@ describe('RFC 0208 — v2-mcp-mount-map (host as MCP 2026-07-28 server, gated on
|
|
|
200
203
|
expect(forged.error !== undefined || forged.status >= 400, req(id, 'interop-map.json mcp.mrtr requestState (host as server)', `a requestState that fails integrity verification MUST be refused (got ${forged.status} ${JSON.stringify(forged.result)})`)).toBe(true);
|
|
201
204
|
});
|
|
202
205
|
|
|
206
|
+
it('the MRTR input-request key is the open interrupt’s interruptId, never the node it suspended on', async () => {
|
|
207
|
+
const m = await mount();
|
|
208
|
+
if (!m.ok) return skip(m);
|
|
209
|
+
const missing = needFixtures(['conformance-approval']);
|
|
210
|
+
if (missing) return softSkip('blocked', missing);
|
|
211
|
+
const id = R('mcp-mrtr-input-request-key');
|
|
212
|
+
const TOOL = 'conformance-approval';
|
|
213
|
+
const caps = { elicitation: {} };
|
|
214
|
+
const runs = async (): Promise<string[]> => ((((await driver.get(`/runs?workflowId=${TOOL}&limit=50`)).json as { runs?: Array<{ runId?: string }> } | undefined)?.runs ?? []).map((x) => String(x.runId)));
|
|
215
|
+
const listable = await familyAdvertised('runList');
|
|
216
|
+
const before = listable ? await runs() : [];
|
|
217
|
+
// TWO runs of ONE workflow: both suspend at the SAME node, so a node-keyed host
|
|
218
|
+
// advertises the SAME key for two different outstanding interrupts and the key stops
|
|
219
|
+
// naming the request. (`tasks/get` projects this same key — mcp.tasks.status.)
|
|
220
|
+
const a = await toolCall(m.url, TOOL, {}, { caps });
|
|
221
|
+
const b = await toolCall(m.url, TOOL, {}, { caps });
|
|
222
|
+
expect([a.result?.['resultType'], b.result?.['resultType']], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', `both calls MUST answer InputRequiredResult (got ${JSON.stringify([a.error ?? a.result, b.error ?? b.result])})`)).toEqual(['input_required', 'input_required']);
|
|
223
|
+
const keys = [a, b].map((r) => Object.keys((r.result?.['inputRequests'] ?? {}) as Record<string, unknown>));
|
|
224
|
+
expect(keys.map((k) => k.length), req(id, 'interop-map.json mcp.tasks.status waiting-approval', `inputRequests carries exactly one key per open interrupt (got ${JSON.stringify(keys)})`)).toEqual([1, 1]);
|
|
225
|
+
const [ka, kb] = [keys[0]![0]!, keys[1]![0]!];
|
|
226
|
+
expect([INTERRUPT_ID.test(ka), INTERRUPT_ID.test(kb)], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server); schemas/v2/ids.schema.json interruptId', `the key MUST be the interrupt’s interruptId, which is tenant-bound (${INTERRUPT_ID.source}) and so can never be an author-chosen nodeId (got ${JSON.stringify([ka, kb])})`)).toEqual([true, true]);
|
|
227
|
+
expect(ka === kb, req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server)', `two outstanding interrupts MUST NOT share a key — these runs suspend at the same node, so an equal key is that node’s id, not either interrupt’s (got ${ka})`)).toBe(false);
|
|
228
|
+
if (listable) {
|
|
229
|
+
// The positive tie: the key is an id the run’s own log minted, not merely a well-formed
|
|
230
|
+
// string. Runs another leg started in parallel only widen the pool, never narrow it.
|
|
231
|
+
const minted = new Set<string>();
|
|
232
|
+
for (const runId of (await runs()).filter((r) => !before.includes(r))) {
|
|
233
|
+
const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
234
|
+
for (const e of ((poll.json as { events?: Array<{ type?: string; payload?: { interruptId?: unknown } }> } | undefined)?.events ?? [])) if (e.type === 'node.suspended' && typeof e.payload?.interruptId === 'string') minted.add(e.payload.interruptId);
|
|
235
|
+
}
|
|
236
|
+
expect([minted.has(ka), minted.has(kb)], req(id, 'interop-map.json mcp.mrtr InputRequiredResult (host as server); runs.md node.suspended', `each key MUST be an interruptId the run’s own node.suspended carries (keys ${JSON.stringify([ka, kb])}; minted ${JSON.stringify([...minted])})`)).toEqual([true, true]);
|
|
237
|
+
}
|
|
238
|
+
// …and that key is the one the retry answers: the state minted beside it resolves the run.
|
|
239
|
+
const done = await toolCall(m.url, TOOL, { requestState: a.result?.['requestState'], inputResponses: { [ka]: { action: 'accept', content: { action: 'accept' } } } }, { caps });
|
|
240
|
+
expect([done.error, done.result?.['resultType'], done.result?.['isError']], req(id, 'interop-map.json mcp.mrtr inputResponses[key] (host as server)', `the interruptId key MUST be the key inputResponses is read under (got ${JSON.stringify(done.error ?? done.result)})`)).toEqual([undefined, 'complete', false]);
|
|
241
|
+
await toolCall(m.url, TOOL, { requestState: b.result?.['requestState'], inputResponses: { [kb]: { action: 'decline' } } }, { caps });
|
|
242
|
+
});
|
|
243
|
+
|
|
203
244
|
it('a list that differs per caller is cacheScope private', async () => {
|
|
204
245
|
const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
205
246
|
if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller half cannot run');
|
|
@@ -101,6 +101,12 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
101
101
|
try {
|
|
102
102
|
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
|
|
103
103
|
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)`);
|
|
104
|
+
// A SUCCESS is only evidence if the host negotiated with THIS peer — the
|
|
105
|
+
// one that offers only the lower version. Before 2.37.0, on a tunnelled
|
|
106
|
+
// cut, the host reached the shared fake instead and this leg read an
|
|
107
|
+
// untouched peer (`lib/front-mux.ts`). A refusal needs no contact (not
|
|
108
|
+
// calling satisfies the MUST NOT); a success with none is `blocked`.
|
|
109
|
+
if (res.status < 400 && peer.invocations().length === 0) return softSkip('blocked', `the seam answered ${res.status} and the lower-version suite peer at ${peer.hostFacingEndpoint()} received no request — the exchange this row measures did not reach it`);
|
|
104
110
|
const wire = peer.invocations().filter((i) => i.method !== 'GET').map((i) => String(i.headers['a2a-version'] ?? ''));
|
|
105
111
|
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated', preferred, A2A_LOWER, res, wire);
|
|
106
112
|
} finally {
|
|
@@ -121,6 +127,13 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
121
127
|
try {
|
|
122
128
|
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), authenticated: false });
|
|
123
129
|
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
|
|
130
|
+
// This leg has no seam knob forcing the lower offer: the ONLY thing that
|
|
131
|
+
// makes the exchange unauthenticated-and-lower is this server. Before
|
|
132
|
+
// 2.37.0 a tunnelled cut sent the host to the shared fake, which offers
|
|
133
|
+
// preferredVersion, so the leg passed having measured nothing
|
|
134
|
+
// (`lib/front-mux.ts`). A refusal needs no contact; a success with none
|
|
135
|
+
// is `blocked`, never a pass.
|
|
136
|
+
if (res.status < 400 && server.invocations().length === 0) return softSkip('blocked', `the seam answered ${res.status} and the lower-revision suite server at ${server.hostFacingEndpoint()} received no request — the exchange this row measures did not reach it`);
|
|
124
137
|
const wire = server.invocations().map((i) => String(i.headers['mcp-protocol-version'] ?? ''));
|
|
125
138
|
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated.mcp', preferred, MCP_LOWER, res, wire);
|
|
126
139
|
} finally {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/events.md` §SSE frames — `Last-Event-ID` is an exclusive
|
|
3
|
+
* cursor (RFC 0213 §A; suite 2.37.0, target major 2; unaided; one run created).
|
|
4
|
+
*
|
|
5
|
+
* `Last-Event-ID: N` streams the events with `sequence > N`. When N is at or
|
|
6
|
+
* beyond the last persisted sequence there is no backlog: a terminal run's
|
|
7
|
+
* stream closes without a frame. A host MUST NOT refuse a well-formed
|
|
8
|
+
* non-negative integer because no event carries that sequence; any other value
|
|
9
|
+
* SHOULD be refused `400 validation_error`. The header is evaluated only after
|
|
10
|
+
* the caller is authorized to read the run: for a run the caller cannot read
|
|
11
|
+
* the answer MUST be the one the host gives without the header.
|
|
12
|
+
*
|
|
13
|
+
* Off the core-standard floor: `v2-sse-last-event-id` is the floor scenario and
|
|
14
|
+
* this file adds legs a committed claim was never measured against (rc.59
|
|
15
|
+
* precedent). Promotion waits until the three bundle hosts are measured.
|
|
16
|
+
*
|
|
17
|
+
* Legs, on one completed noop run:
|
|
18
|
+
* 1. a future id (last + 1000) ⇒ 200, zero frames, closed by the server — a
|
|
19
|
+
* host that 400s the id fails the status, one that resumes from 0 fails
|
|
20
|
+
* the frame count;
|
|
21
|
+
* 2. `Last-Event-ID: abc` ⇒ if refused, `400 validation_error` (SHOULD: a
|
|
22
|
+
* host that ignores it is recorded, not failed);
|
|
23
|
+
* 3. an unknown own-tenant runId and a forged foreign-tenant runId answer the
|
|
24
|
+
* same status and error code with and without the header; and — the
|
|
25
|
+
* threat itself — an EXISTING run of a second tenant
|
|
26
|
+
* (`OPENWOP_TEST_TENANT_B_API_KEY`) answers the same with no header, a
|
|
27
|
+
* cursor inside its log and a cursor past it. A host that reads the
|
|
28
|
+
* cursor before authorizing can only be told apart on a run that
|
|
29
|
+
* exists; without the second credential the leg records
|
|
30
|
+
* `partial-witness`, because the nonexistent-run comparisons cannot
|
|
31
|
+
* catch that host.
|
|
32
|
+
*
|
|
33
|
+
* @see spec/v2/core/events.md §SSE frames
|
|
34
|
+
* @see RFCS/0213-three-unstated-v2-outcomes.md §A
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, it, expect } from 'vitest';
|
|
38
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
39
|
+
import { subscribe, type SseEvent } from '../lib/sse.js';
|
|
40
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
41
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
42
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
43
|
+
import { req } from '../lib/requirement-ids.js';
|
|
44
|
+
|
|
45
|
+
const DOC = 'spec/v2/core/events.md §SSE frames';
|
|
46
|
+
const ID_CURSOR = 'openwop.requirement.0213.last-event-id-exclusive-cursor';
|
|
47
|
+
const ID_MALFORMED = 'openwop.requirement.0213.last-event-id-malformed';
|
|
48
|
+
const ID_AUTHZ = 'openwop.requirement.0213.last-event-id-after-authorization';
|
|
49
|
+
const NOOP = 'conformance-noop';
|
|
50
|
+
const V2 = { 'OpenWOP-Version': '2.0' };
|
|
51
|
+
const FOREIGN_RUN_ID = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
|
|
52
|
+
|
|
53
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
54
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
55
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
56
|
+
function seqOf(f: SseEvent): number | null { if (f.id === null || f.id === '') return null; const n = Number.parseInt(f.id, 10); return Number.isFinite(n) ? n : null; }
|
|
57
|
+
|
|
58
|
+
async function createSettled(bearer?: string): Promise<{ runId: string } | { reason: string }> {
|
|
59
|
+
const as = bearer === undefined ? {} : { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } };
|
|
60
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP }, as));
|
|
61
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
62
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
63
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
|
|
64
|
+
const t0 = Date.now();
|
|
65
|
+
while (Date.now() - t0 < 10_000) {
|
|
66
|
+
const s = await http(() => driver.get(`/runs/${enc(runId)}`, as));
|
|
67
|
+
if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(String((s.json as { status?: unknown }).status))) return { runId };
|
|
68
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
69
|
+
}
|
|
70
|
+
return { reason: 'the noop run did not settle within 10 s' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Status and error code of a stream request, never reading an SSE body (a 200 is reported as-is). */
|
|
74
|
+
async function answer(path: string, lastEventId?: string): Promise<{ status: number; code: string | null }> {
|
|
75
|
+
const probe = await subscribe(path, { timeoutMs: 5_000, extraHeaders: V2, ...(lastEventId === undefined ? {} : { lastEventId }) });
|
|
76
|
+
if (probe.status === 200) return { status: 200, code: null };
|
|
77
|
+
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
|
78
|
+
if (lastEventId !== undefined) headers['Last-Event-ID'] = lastEventId;
|
|
79
|
+
const res = await http(() => driver.get(path, { headers }));
|
|
80
|
+
return { status: probe.status, code: res === null ? null : (readErrorCode(res.json) ?? null) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('v2 sse-last-event-id-cursor (events.md §SSE frames, RFC 0213 §A)', () => {
|
|
84
|
+
it('a Last-Event-ID past the log is an exclusive cursor: a terminal run answers 200 with no frame and closes', async () => {
|
|
85
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
86
|
+
const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
87
|
+
const path = `/runs/${enc(c.runId)}/events?streamMode=debug`;
|
|
88
|
+
const full = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2 });
|
|
89
|
+
if (full.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — streamRunEvents is a core operation and is not mounted');
|
|
90
|
+
const seqs = full.events.map(seqOf).filter((n): n is number => n !== null);
|
|
91
|
+
if (full.status !== 200 || seqs.length === 0) return softSkip('blocked', `the full stream did not yield numbered frames (status ${full.status}, ${seqs.length} ids) — v2-sse-last-event-id owns that contract`);
|
|
92
|
+
const future = String(Math.max(...seqs) + 1000);
|
|
93
|
+
const past = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: future });
|
|
94
|
+
expect(past.status, req(ID_CURSOR, DOC, `a well-formed Last-Event-ID no event carries (${future}) MUST NOT be refused — got ${past.status}`)).toBe(200);
|
|
95
|
+
expect(past.events.length, req(ID_CURSOR, DOC, `a cursor past the end of a terminal run's log MUST yield no frame — a host resuming from 0 re-sends all ${seqs.length} (got ${past.events.length})`)).toBe(0);
|
|
96
|
+
expect(past.closedBy, req(ID_CURSOR, DOC, 'on a terminal run the host MUST close the empty stream itself')).toBe('server');
|
|
97
|
+
}, 45_000);
|
|
98
|
+
|
|
99
|
+
it('a Last-Event-ID that is not a non-negative integer, when refused, is 400 validation_error', async () => {
|
|
100
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
101
|
+
const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
102
|
+
const got = await answer(`/runs/${enc(c.runId)}/events?streamMode=debug`, 'abc');
|
|
103
|
+
if (got.status === 200) return softSkip('inapplicable', 'the host accepted Last-Event-ID: abc (the refusal is a SHOULD — recorded, not failed)');
|
|
104
|
+
expect(got.status, req(ID_MALFORMED, DOC, `a refused malformed Last-Event-ID SHOULD be 400 — got ${got.status}`)).toBe(400);
|
|
105
|
+
expect(got.code, req(ID_MALFORMED, 'spec/v2/core/errors.md', `the refusal MUST carry validation_error — got ${String(got.code)}`)).toBe('validation_error');
|
|
106
|
+
}, 30_000);
|
|
107
|
+
|
|
108
|
+
it('the cursor never changes the answer for a run the caller cannot read', async () => {
|
|
109
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
110
|
+
const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
111
|
+
const slash = c.runId.indexOf('/');
|
|
112
|
+
if (slash <= 0) return softSkip('blocked', `the created runId ${c.runId} carries no tenant segment — v2-id-grammar owns that contract`);
|
|
113
|
+
const unknown = `${c.runId.slice(0, slash)}/openwopconformanceunknown${Date.now().toString(36)}`;
|
|
114
|
+
for (const [label, runId] of [['unknown own-tenant run', unknown], ['foreign-tenant run', FOREIGN_RUN_ID]] as const) {
|
|
115
|
+
const path = `/runs/${enc(runId)}/events?streamMode=debug`;
|
|
116
|
+
const bare = await answer(path);
|
|
117
|
+
const cursor = await answer(path, '0');
|
|
118
|
+
expect(bare.status, req(ID_AUTHZ, 'spec/v2/core/runs.md §Identity', `a ${label} MUST NOT be streamed (got ${bare.status})`)).not.toBe(200);
|
|
119
|
+
expect({ status: cursor.status, code: cursor.code }, req(ID_AUTHZ, DOC, `for a ${label} the answer with Last-Event-ID (${cursor.status} ${String(cursor.code)}) MUST equal the answer without it (${bare.status} ${String(bare.code)})`)).toEqual({ status: bare.status, code: bare.code });
|
|
120
|
+
}
|
|
121
|
+
const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
122
|
+
if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY is not set — the existing-foreign-run comparison, the only one a cursor-first host fails, did not run');
|
|
123
|
+
const theirs = await createSettled(other); if ('reason' in theirs) return softSkip('blocked', `tenant B could not create a run: ${theirs.reason}`);
|
|
124
|
+
if (theirs.runId.slice(0, theirs.runId.indexOf('/')) === c.runId.slice(0, slash)) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY — the leg needs a second tenant');
|
|
125
|
+
const path = `/runs/${enc(theirs.runId)}/events?streamMode=debug`;
|
|
126
|
+
const bare = await answer(path);
|
|
127
|
+
expect(bare.status, req(ID_AUTHZ, 'spec/v2/core/runs.md §Identity', `another tenant's existing run MUST NOT be streamed (got ${bare.status})`)).not.toBe(200);
|
|
128
|
+
for (const cur of ['0', '1000']) {
|
|
129
|
+
const got = await answer(path, cur);
|
|
130
|
+
expect({ status: got.status, code: got.code }, req(ID_AUTHZ, DOC, `for another tenant's existing run the answer with Last-Event-ID ${cur} (${got.status} ${String(got.code)}) MUST equal the answer without it (${bare.status} ${String(bare.code)})`)).toEqual({ status: bare.status, code: bare.code });
|
|
131
|
+
}
|
|
132
|
+
}, 45_000);
|
|
133
|
+
});
|
|
@@ -15,10 +15,20 @@
|
|
|
15
15
|
* finish after the first terminal event. Growth after a terminal event is the
|
|
16
16
|
* violation's own evidence, not an inconclusive read.
|
|
17
17
|
*
|
|
18
|
+
* 2.37.0 — the duplicate-delivery leg takes its receiver from
|
|
19
|
+
* `lib/effect-receiver.ts` rather than a local copy. Both this file and
|
|
20
|
+
* `v2-durability-recovery.test.ts` drive the same host seam, and both handed it
|
|
21
|
+
* the SAME destination URL; on a tunnelled cut that is one byte-identical
|
|
22
|
+
* string, so the two exercises shared one Layer-2 effect identity and a
|
|
23
|
+
* conformant host deduplicated the second one away. This leg survived it (it
|
|
24
|
+
* asserts on the LOG, and tolerates zero arrivals); `0158.duplicate-delivery`,
|
|
25
|
+
* which counts arrivals, did not, and flapped with vitest's file order. The
|
|
26
|
+
* shared receiver mints a per-exercise nonce so the collision cannot recur.
|
|
27
|
+
*
|
|
18
28
|
* @see spec/v2/core/events.md §The terminal event
|
|
29
|
+
* @see spec/v1/idempotency.md §"Layer 2 Keying"
|
|
19
30
|
* @see RFCS/0194-terminal-event-ends-forward-execution.md
|
|
20
31
|
*/
|
|
21
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
22
32
|
import { describe, expect, it } from 'vitest';
|
|
23
33
|
import { driver } from '../lib/driver.js';
|
|
24
34
|
import { v2Discovery } from '../lib/v2.js';
|
|
@@ -26,7 +36,7 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
26
36
|
import { softSkip } from '../lib/soft-skip.js';
|
|
27
37
|
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
28
38
|
import { req } from '../lib/requirement-ids.js';
|
|
29
|
-
import {
|
|
39
|
+
import { startEffectReceiver, waitForFirstArrival } from '../lib/effect-receiver.js';
|
|
30
40
|
import { terminalShapeViolation, TERMINAL_RUN_EVENTS } from '../lib/terminal-shape.js';
|
|
31
41
|
|
|
32
42
|
const ONCE = 'openwop.requirement.0194.terminal-once';
|
|
@@ -104,35 +114,19 @@ describe('v2 terminal event (RFC 0194 §A)', () => {
|
|
|
104
114
|
if (probe.status === 404 || probe.status === 405) return softSkip('inapplicable', `no RFC 0158 durability test hook at ${KILL_SEAM} (HTTP ${probe.status}) — a black-box suite cannot make a host redeliver accepted work, so this row needs the hook`);
|
|
105
115
|
const rx = await startEffectReceiver();
|
|
106
116
|
try {
|
|
107
|
-
const
|
|
108
|
-
const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url }));
|
|
117
|
+
const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: rx.url }));
|
|
109
118
|
const runId = (fired?.json as { runId?: unknown } | null)?.runId;
|
|
110
119
|
if (fired === null || fired.status >= 400 || typeof runId !== 'string') return softSkip('blocked', `the durability hook answered ${fired?.status ?? 'no response'} for mode=duplicate-delivery — no redelivered run to read`);
|
|
111
120
|
const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 60_000);
|
|
112
121
|
if (s === null || !TERMINAL_STATUS.has(s)) return softSkip('blocked', `the redelivered run did not reach a terminal status (last: ${s ?? 'unreadable'})`);
|
|
113
|
-
|
|
114
|
-
while (rx.arrivals === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
|
|
122
|
+
await waitForFirstArrival(rx, scaledTimeoutMs(20_000));
|
|
115
123
|
const types = await settledLog(runId);
|
|
116
124
|
if (types === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — the log is unobservable');
|
|
117
125
|
expect(types.some((t) => TERMINAL_RUN_EVENTS.has(t)), req(DUP, DOC, 'the redelivered run\'s log reached a terminal event')).toBe(true);
|
|
118
126
|
expect(terminalShapeViolation(types), req(DUP, DOC, `work delivered twice MUST leave exactly one terminal run event and nothing forward after it — log: ${types.join(' ')}`)).toBeNull();
|
|
119
127
|
} finally {
|
|
120
|
-
await
|
|
128
|
+
await rx.close();
|
|
121
129
|
}
|
|
122
130
|
}, 180_000);
|
|
123
131
|
});
|
|
124
132
|
|
|
125
|
-
async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: number }> {
|
|
126
|
-
const state = { arrivals: 0 };
|
|
127
|
-
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
128
|
-
request.on('data', () => { /* drain */ });
|
|
129
|
-
request.on('end', () => { state.arrivals++; res.writeHead(204); res.end(); });
|
|
130
|
-
});
|
|
131
|
-
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
132
|
-
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
133
|
-
const binding = receiverBinding();
|
|
134
|
-
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
135
|
-
const addr = server.address();
|
|
136
|
-
const out = { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/effect`, get arrivals() { return state.arrivals; } };
|
|
137
|
-
return out;
|
|
138
|
-
}
|