@openwop/openwop-conformance 2.44.6 → 2.44.8
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
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.44.8] — 2026-09-29 — every subscribed event of a run is delivered
|
|
4
|
+
|
|
5
|
+
- **`0173.webhook-delivery-complete`: every matching event reaches a healthy receiver at least once** (#1781). A new leg in `v2-webhook-durable-delivery` (target major 2, gated on `webhooks` and `conformance-noop`) reads `webhooks.md` §Durability, "deliver each matching event at least once", across a whole run; no row did before. It registers one subscription to `run.started`, `node.started`, `node.completed` and `run.completed` with a receiver that answers `204` to everything, and runs the noop fixture. The expected set is every logged event of a subscribed type (`GET /runs/{runId}/events/poll`), keyed by `sequence`. Each must arrive at least once, deduplicated on `(webhookId, runId, sequence)`; duplicates are counted, never held against the host. The wait is `retryWaitFor`: the advertised `maxElapsedMs` plus the grace (RFC 0225), else the cap or the floor. The row convicts when some of the run's subscribed events arrived and others did not, or when nothing arrived and nothing reached the listener; nothing of this run arriving while other traffic did is `blocked`. A pass carries an `observed:` detail (counts, duplicates, slowest first arrival, wait path). The leg unregisters its subscription. Measured on the v2 reference host (openwop-examples `d0255b4b`): `executed-pass` in 255 ms; with its `run.started` fan-out removed, `executed-fail` naming `run.started (sequence 0)` after the 90 s window, and the file's other five legs stay green, which is the gap: myndhyve#560's lost `run.started` fan-out was convicted only incidentally, under the wrong MUST (#1780). Surveyed against the three hosts' code: each delivers every subscribed type and passes on its certified deployment (MyndHyve's window is its advertised 600000 ms plus the grace).
|
|
6
|
+
|
|
7
|
+
## [2.44.7] — 2026-09-29 — RFC 0223 G11 and RFC 0140 G10 close on a certified cut
|
|
8
|
+
|
|
9
|
+
- **The 2.44.7 cycle opens.** The packed `@openwop/spec-artifacts` gaps register changes: RFC 0223 G11 (`0223.reject-loopback-reasks`) and RFC 0140 G10 (`replay.suppression-execution-ordinal`) close on the v2 reference host's certified 2.44.6 cut. No scenario is added or removed.
|
|
10
|
+
|
|
3
11
|
## [2.44.6] — 2026-09-29 — RFC 0228 registers the v1 host-service codes, RFC 0227 moves the generated error table out of the core budget, and the last core families get v2 homes
|
|
4
12
|
|
|
5
13
|
- **`0171.error-registry-prose-parity` is re-pointed (RFC 0227).** The `errors.md` status table moved to `spec/v2/generated/error-codes.md`, which the packed `@openwop/spec-artifacts` now ships. The row now requires that `errors.md` links the generated table, that the table holds every registered code, and that every count either document states equals the registry. No scenario is added or removed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.44.
|
|
3
|
+
"version": "2.44.8",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.44.
|
|
59
|
+
"@openwop/spec-artifacts": "2.44.8"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
5
|
"files": 649,
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
6
|
+
"tests": 2495,
|
|
7
|
+
"withStableId": 2495,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2374
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -34418,7 +34418,7 @@
|
|
|
34418
34418
|
{
|
|
34419
34419
|
"id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
|
|
34420
34420
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
34421
|
-
"line":
|
|
34421
|
+
"line": 309,
|
|
34422
34422
|
"title": "a failed attempt is retried and the event is delivered at least once",
|
|
34423
34423
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery",
|
|
34424
34424
|
"citations": [
|
|
@@ -34455,7 +34455,7 @@
|
|
|
34455
34455
|
{
|
|
34456
34456
|
"id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
|
|
34457
34457
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
34458
|
-
"line":
|
|
34458
|
+
"line": 430,
|
|
34459
34459
|
"title": "an exhausted delivery is dead-lettered, never dropped",
|
|
34460
34460
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
|
|
34461
34461
|
"citations": [
|
|
@@ -34494,7 +34494,7 @@
|
|
|
34494
34494
|
{
|
|
34495
34495
|
"id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
|
|
34496
34496
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
34497
|
-
"line":
|
|
34497
|
+
"line": 539,
|
|
34498
34498
|
"title": "the dead-letter read is served and its records carry no payload",
|
|
34499
34499
|
"explicitId": "openwop.requirement.0188.dead-letter-read",
|
|
34500
34500
|
"citations": [
|
|
@@ -34507,7 +34507,7 @@
|
|
|
34507
34507
|
{
|
|
34508
34508
|
"id": "openwop.it.v2-webhook-durable-delivery.after-unregister-the-dead-letter-read-answers-404-not-found-as-for-a-subscriptio",
|
|
34509
34509
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
34510
|
-
"line":
|
|
34510
|
+
"line": 560,
|
|
34511
34511
|
"title": "after unregister, the dead-letter read answers 404 not_found, as for a subscription that never existed",
|
|
34512
34512
|
"explicitId": "openwop.requirement.0217.dead-letter-read-after-unregister",
|
|
34513
34513
|
"citations": [
|
|
@@ -34534,7 +34534,7 @@
|
|
|
34534
34534
|
{
|
|
34535
34535
|
"id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
|
|
34536
34536
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
34537
|
-
"line":
|
|
34537
|
+
"line": 593,
|
|
34538
34538
|
"title": "a dead-letter record carries no delivered payload",
|
|
34539
34539
|
"explicitId": "openwop.requirement.0188.dead-letter-content-free",
|
|
34540
34540
|
"citations": [
|
|
@@ -34549,6 +34549,33 @@
|
|
|
34549
34549
|
}
|
|
34550
34550
|
]
|
|
34551
34551
|
},
|
|
34552
|
+
{
|
|
34553
|
+
"id": "openwop.it.v2-webhook-durable-delivery.every-logged-event-of-a-subscribed-type-reaches-a-healthy-receiver-at-least-once",
|
|
34554
|
+
"file": "v2-webhook-durable-delivery.test.ts",
|
|
34555
|
+
"line": 688,
|
|
34556
|
+
"title": "every logged event of a subscribed type reaches a healthy receiver at least once within the retry bound",
|
|
34557
|
+
"explicitId": "openwop.requirement.0173.webhook-delivery-complete",
|
|
34558
|
+
"citations": [
|
|
34559
|
+
{
|
|
34560
|
+
"section": "webhooks.md §Surfaces",
|
|
34561
|
+
"requirement": null,
|
|
34562
|
+
"interpolated": true
|
|
34563
|
+
},
|
|
34564
|
+
{
|
|
34565
|
+
"section": "webhooks.md §Surfaces",
|
|
34566
|
+
"requirement": "the 201 body MUST carry `webhookId`"
|
|
34567
|
+
},
|
|
34568
|
+
{
|
|
34569
|
+
"section": "runs.md §Create",
|
|
34570
|
+
"requirement": "POST /runs MUST answer 201 for the noop fixture"
|
|
34571
|
+
},
|
|
34572
|
+
{
|
|
34573
|
+
"section": "webhooks.md §Durability",
|
|
34574
|
+
"requirement": null,
|
|
34575
|
+
"interpolated": true
|
|
34576
|
+
}
|
|
34577
|
+
]
|
|
34578
|
+
},
|
|
34552
34579
|
{
|
|
34553
34580
|
"id": "openwop.it.v2-webhook-egress-refusal.a-webhook-registration-naming-a-destination-the-guard-must-refuse-is-refused-400",
|
|
34554
34581
|
"file": "v2-webhook-egress-refusal.test.ts",
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.44.
|
|
5
|
-
"corpusTag": "v2.44.
|
|
4
|
+
"version": "2.44.8",
|
|
5
|
+
"corpusTag": "v2.44.8",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "69464bc0e1e71ef3b543a73d4fa4f67202343d2603c80af8033f6c3619ef1b30",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "e0a08989687d29d95bc10702aca5a31fcc846cf8f55ae0dd362fb4666fbdf8f3",
|
|
13
|
+
"api/v2/asyncapi.yaml": "ab568b0539dcb71c77a3a3218a433cab6fa1f3a7dd04a6b70824e2abba46a104",
|
|
14
|
+
"api/v2/openapi.yaml": "7cd9c6703ee7e27f06dea079ca41ec7f0227501f6f2b6ab30ba233bafdf1e63e",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -204,13 +204,13 @@
|
|
|
204
204
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
205
205
|
"spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
|
|
206
206
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
207
|
-
"spec/v1/core-standard-manifest.json": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "635549fa9beb8e0ee92a31461803d1170fea36a5738d79aaaa0ef0c1ae150234",
|
|
208
208
|
"spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
|
|
209
209
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
210
210
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
211
211
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
212
212
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
213
|
-
"spec/v1/gaps.json": "
|
|
213
|
+
"spec/v1/gaps.json": "7ba482a850d94e185f12b69929e35054d24b72131a997d0ef70d59c10b83f7eb",
|
|
214
214
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
215
215
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
216
216
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -303,9 +303,9 @@
|
|
|
303
303
|
"spec/v2/path-manifest.json": "17298a245c5da9a70aa7ab0b7f2db4277e06209d6fcdd7187a8b4cea179c08f3",
|
|
304
304
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
305
305
|
"spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
|
|
306
|
-
"spec/v2/release.json": "
|
|
306
|
+
"spec/v2/release.json": "623dc26d0c678f0050670e702c73cf5a1dea4b3f5a09284e920843897417527a",
|
|
307
307
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
308
|
-
"spec/v2/surface-baseline.json": "
|
|
308
|
+
"spec/v2/surface-baseline.json": "2f618a23b875d663316b0aebebd07fbfc9795fa01396935d898f9b8123e5a9a6"
|
|
309
309
|
},
|
|
310
|
-
"corpusCommit": "
|
|
310
|
+
"corpusCommit": "a6a1325b67b6dc08122aa78d3abb62ebf7959d11"
|
|
311
311
|
}
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* (`spec/v2/core/webhooks.md` §Durability; security-defaults.md §Webhook
|
|
9
9
|
* durability; RFC 0173 §B row C6.3).
|
|
10
10
|
*
|
|
11
|
+
* The last leg (suite 2.44.8, openwop#1781) reads "deliver each matching event
|
|
12
|
+
* at least once" across a whole run, with a receiver that never fails: see
|
|
13
|
+
* `webhook-delivery-complete` below.
|
|
14
|
+
*
|
|
11
15
|
* How the receiver is driven: the suite boots its own HTTP receiver (the same
|
|
12
16
|
* shape `webhook-signed-delivery.test.ts` uses) in a FAILING mode — it answers
|
|
13
17
|
* `500` to the first N attempts for a delivery key and `204` afterwards — so
|
|
@@ -35,7 +39,7 @@ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedR
|
|
|
35
39
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
36
40
|
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
37
41
|
import { req } from '../lib/requirement-ids.js';
|
|
38
|
-
import { pastAdvertisedBound, retryTestTimeoutMs, retryWaitCapMs, retryWaitFor, retryWaitSelection, waitObservation, windowClosedNote } from '../lib/webhook-retry-window.js';
|
|
42
|
+
import { pastAdvertisedBound, retryTestTimeoutMs, retryWaitCapMs, retryWaitFor, retryWaitSelection, waitObservation, waitPathNote, windowClosedNote } from '../lib/webhook-retry-window.js';
|
|
39
43
|
import { noteObservation } from '../lib/row-observation.js';
|
|
40
44
|
|
|
41
45
|
export const REQUIRES_HOST_CALLBACK = 'the host POSTs and retries webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
|
|
@@ -43,7 +47,7 @@ export const REQUIRES_HOST_CALLBACK = 'the host POSTs and retries webhook delive
|
|
|
43
47
|
const FIXTURE = 'conformance-noop';
|
|
44
48
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
45
49
|
|
|
46
|
-
interface Attempt { readonly key: string; readonly runId: string | null; readonly webhookId: string; readonly status: number; readonly at: number }
|
|
50
|
+
interface Attempt { readonly key: string; readonly runId: string | null; readonly webhookId: string; readonly sequence: number | null; readonly status: number; readonly at: number }
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* A receiver that fails the first `failFirst` attempts for each delivery key
|
|
@@ -75,7 +79,7 @@ async function startReceiver(failFirst: number): Promise<ScopedReceiver & { atte
|
|
|
75
79
|
const n = (seen.get(key) ?? 0) + 1;
|
|
76
80
|
seen.set(key, n);
|
|
77
81
|
const status = n <= failFirst ? 500 : 204;
|
|
78
|
-
attempts.push({ key, runId, webhookId, status, at: Date.now() });
|
|
82
|
+
attempts.push({ key, runId, webhookId, sequence: typeof sequence === 'number' ? sequence : null, status, at: Date.now() });
|
|
79
83
|
res.writeHead(status);
|
|
80
84
|
res.end();
|
|
81
85
|
});
|
|
@@ -275,6 +279,32 @@ async function register(rx: ScopedReceiver): Promise<{ webhookId: string } | nul
|
|
|
275
279
|
return { webhookId: webhookId as string };
|
|
276
280
|
}
|
|
277
281
|
|
|
282
|
+
/**
|
|
283
|
+
* `webhook-delivery-complete` (suite 2.44.8, openwop#1781): the lifecycle types
|
|
284
|
+
* a one-node run emits. The EXPECTED deliveries are still read from the host's
|
|
285
|
+
* own log, so the suite assumes nothing about what the fixture emits.
|
|
286
|
+
*/
|
|
287
|
+
const COMPLETE_ID = 'openwop.requirement.0173.webhook-delivery-complete';
|
|
288
|
+
const COMPLETE_TYPES = ['run.started', 'node.started', 'node.completed', 'run.completed'] as const;
|
|
289
|
+
const COMPLETE_TERMINAL_WAIT_MS = 30_000;
|
|
290
|
+
|
|
291
|
+
/** The run's logged events of a subscribed type, by sequence; null when the log is unreadable or carries no sequences. */
|
|
292
|
+
async function subscribedLog(runId: string): Promise<Map<number, string> | null> {
|
|
293
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
294
|
+
if (res.status !== 200) return null;
|
|
295
|
+
const events = (res.json as { events?: Array<{ type?: unknown; sequence?: unknown }> } | null)?.events;
|
|
296
|
+
if (!Array.isArray(events)) return null;
|
|
297
|
+
const want = new Map<number, string>();
|
|
298
|
+
for (const e of events) {
|
|
299
|
+
if (typeof e.type !== 'string' || !(COMPLETE_TYPES as readonly string[]).includes(e.type)) continue;
|
|
300
|
+
if (typeof e.sequence !== 'number') return null;
|
|
301
|
+
want.set(e.sequence, e.type);
|
|
302
|
+
}
|
|
303
|
+
return want;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const bareId = (id: string): string => (id.includes('/') ? id.slice(id.indexOf('/') + 1) : id);
|
|
307
|
+
|
|
278
308
|
describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () => {
|
|
279
309
|
it('a failed attempt is retried and the event is delivered at least once', async () => {
|
|
280
310
|
const doc = await discovery();
|
|
@@ -626,4 +656,93 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
626
656
|
req('openwop.requirement.0188.dead-letter-content-free', 'RFC 0188 §B.1', `a dead-letter record MUST NOT carry the delivered body, headers or subscription secret — the queue is precisely the traffic the subscriber never received, so a record carrying any of it turns one read scope into a replay of that traffic for the whole retention window (read ${rows.length} record(s))`),
|
|
627
657
|
).toBe(true);
|
|
628
658
|
}, DEAD_LETTER_TEST_TIMEOUT_MS);
|
|
659
|
+
/**
|
|
660
|
+
* openwop#1781: "deliver each matching event at least once" binds EVERY
|
|
661
|
+
* matching event, and no row read it that way. `v2-webhook-delivery-shape`
|
|
662
|
+
* records `blocked` on a missing delivery; `v2-webhook-standard-webhooks-delivery`
|
|
663
|
+
* fails only on a missing `run.completed`; the legs above subscribe to
|
|
664
|
+
* `run.completed` alone and drive retries after a FAILING receiver. So a host
|
|
665
|
+
* that lost part of a run's fan-out certified green. Measured on a tier-2
|
|
666
|
+
* host: its `run.started` fan-out was fire-and-forget work that Cloud Run CPU
|
|
667
|
+
* throttling starved once the appending request ended (myndhyve#560); the
|
|
668
|
+
* only row that reddened was `0215.no-head-of-line`, under the wrong MUST,
|
|
669
|
+
* and #1780 correctly stopped it convicting.
|
|
670
|
+
*
|
|
671
|
+
* One subscription to the four lifecycle types, a receiver that answers 204
|
|
672
|
+
* to everything, one noop run. Every logged event of a subscribed type must
|
|
673
|
+
* arrive at least once, deduplicated on `(webhookId, runId, sequence)` — the
|
|
674
|
+
* triple webhooks.md §Verification names. Duplicates are counted in the
|
|
675
|
+
* `observed:` detail and never held against the host: at-least-once permits
|
|
676
|
+
* them. The wait is `retryWaitFor`: the advertised `maxElapsedMs` plus the
|
|
677
|
+
* grace when the host states one (RFC 0225), else the cap or the floor. It
|
|
678
|
+
* only bounds how long a missing event is waited for; a healthy receiver
|
|
679
|
+
* needs no retry.
|
|
680
|
+
*
|
|
681
|
+
* Convicts only when some of this run's subscribed events arrived and others
|
|
682
|
+
* did not, or when nothing arrived and nothing else reached the listener (the
|
|
683
|
+
* rule the first leg applies). Nothing of this run arriving while other
|
|
684
|
+
* traffic did is `blocked`: the path works, and what is absent is this
|
|
685
|
+
* exercise's identity. Negative control: the v2 reference host with its
|
|
686
|
+
* `run.started` fan-out removed fails this leg naming `run.started`.
|
|
687
|
+
*/
|
|
688
|
+
it('every logged event of a subscribed type reaches a healthy receiver at least once within the retry bound', async () => {
|
|
689
|
+
const doc = await discovery();
|
|
690
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
691
|
+
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
692
|
+
if (!fixtureAdvertised(doc, FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to deliver`);
|
|
693
|
+
|
|
694
|
+
const receiver = await startReceiver(0); // 204 to every attempt
|
|
695
|
+
active = receiver;
|
|
696
|
+
const reg = await driver.post('/webhooks', { url: receiver.url, events: [...COMPLETE_TYPES] });
|
|
697
|
+
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
698
|
+
if (!receiver.tunnelled) return softSkip('blocked', 'host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https tunnel in front of the suite receiver');
|
|
699
|
+
expect.fail(`host rejected the operator-supplied public https receiver (${receiver.url}) with webhook_url_rejected — a public https destination is legitimate under webhooks.md §Egress`);
|
|
700
|
+
}
|
|
701
|
+
expect(reg.status, req(COMPLETE_ID, 'webhooks.md §Surfaces', `POST /webhooks MUST answer 201 { webhookId } for events [${COMPLETE_TYPES.join(', ')}], each a v2 event type name`)).toBe(201);
|
|
702
|
+
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
703
|
+
expect(typeof webhookId, req(COMPLETE_ID, 'webhooks.md §Surfaces', 'the 201 body MUST carry `webhookId`')).toBe('string');
|
|
704
|
+
const sub = webhookId as string;
|
|
705
|
+
try {
|
|
706
|
+
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
707
|
+
expect(create.status, req(COMPLETE_ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
708
|
+
const runId = (create.json as { runId: string }).runId;
|
|
709
|
+
const createdAt = Date.now();
|
|
710
|
+
const status = await waitTerminal(runId, COMPLETE_TERMINAL_WAIT_MS);
|
|
711
|
+
if (status === null || !TERMINAL.has(status)) return softSkip('blocked', `the ${FIXTURE} run did not reach a terminal status inside ${COMPLETE_TERMINAL_WAIT_MS}ms (last ${status ?? 'unreadable'}), so its event log, the expected set, is not final`);
|
|
712
|
+
const want = await subscribedLog(runId);
|
|
713
|
+
if (want === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not yield the run\'s events with a numeric sequence, so the expected deliveries cannot be keyed (webhooks.md §Verification dedupes on sequence)');
|
|
714
|
+
if (want.size === 0) return softSkip('blocked', `the run's event log holds no event of a subscribed type [${COMPLETE_TYPES.join(', ')}]: nothing to deliver, nothing measured`);
|
|
715
|
+
|
|
716
|
+
const ours = (): Attempt[] => receiver.attempts.filter((a) => a.runId !== null && bareId(a.runId) === bareId(runId) && bareId(a.webhookId) === bareId(sub));
|
|
717
|
+
const missing = (): number[] => {
|
|
718
|
+
const got = new Set(ours().map((a) => a.sequence));
|
|
719
|
+
return [...want.keys()].filter((q) => !got.has(q)).sort((a, b) => a - b);
|
|
720
|
+
};
|
|
721
|
+
const policy = advertisedRetryPolicy(doc);
|
|
722
|
+
const selection = retryWaitSelection(policy, RETRY_WAIT_CAP_MS);
|
|
723
|
+
const deadline = Date.now() + retryWaitFor(policy, RETRY_WAIT_CAP_MS);
|
|
724
|
+
while (missing().length > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
|
|
725
|
+
|
|
726
|
+
const arrivals = ours();
|
|
727
|
+
const firstAt = new Map<number, number>();
|
|
728
|
+
for (const a of arrivals) if (a.sequence !== null && want.has(a.sequence) && !firstAt.has(a.sequence)) firstAt.set(a.sequence, a.at);
|
|
729
|
+
const slowest = firstAt.size === 0 ? null : Math.max(...[...firstAt.values()].map((t) => t - createdAt));
|
|
730
|
+
const duplicates = arrivals.filter((a) => a.sequence !== null && want.has(a.sequence)).length - firstAt.size;
|
|
731
|
+
const lost = missing();
|
|
732
|
+
noteObservation(`${firstAt.size}/${want.size} subscribed event(s) delivered, ${duplicates} duplicate arrival(s); slowest first arrival ${slowest === null ? 'none' : `${slowest}ms after the run was created`}; ${waitPathNote(selection)}`);
|
|
733
|
+
|
|
734
|
+
if (arrivals.length === 0 && absenceIsUnmeasured(receiver)) {
|
|
735
|
+
return blockedDespiteAssertions(noDeliveryCause(receiver, 'delivery for this run'));
|
|
736
|
+
}
|
|
737
|
+
const lostList = lost.map((q) => `${want.get(q)} (sequence ${q})`).join(', ');
|
|
738
|
+
expect(
|
|
739
|
+
lost,
|
|
740
|
+
req(COMPLETE_ID, 'webhooks.md §Durability', `the host MUST deliver each matching event at least once; best-effort delivery is not a conforming mode (RFC 0173 §B). ${lost.length} of ${want.size} subscribed event(s) in this run's log never reached the healthy receiver inside ${selection.windowMs}ms: ${lostList}. ${firstAt.size > 0 ? `${firstAt.size} other(s) did arrive, so the receiver was reachable.` : noDeliveryCause(receiver, 'delivery for this run')}`),
|
|
741
|
+
).toEqual([]);
|
|
742
|
+
} finally {
|
|
743
|
+
// Four lifecycle types on a live subscription would keep fanning every later
|
|
744
|
+
// run's events at a closed nonce, each retried to exhaustion.
|
|
745
|
+
try { await driver.delete(`/webhooks/${encodeURIComponent(sub)}`); } catch { /* best effort: the verdict is already recorded */ }
|
|
746
|
+
}
|
|
747
|
+
}, retryTestTimeoutMs(1, COMPLETE_TERMINAL_WAIT_MS + WAIT_SLACK_MS));
|
|
629
748
|
});
|