@openwop/openwop-conformance 2.34.1 → 2.35.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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * RFC 0194 §A — a run's terminal event is emitted once and ends its forward
3
+ * execution (`spec/v2/core/events.md` §The terminal event). Target major 2.
4
+ *
5
+ * Found by two independently written hosts under duplicate delivery: one wrote
6
+ * `run.completed` then `node.failed`, the other two `run.completed` events
7
+ * (openwop#1445). Nothing in v2 said either was wrong.
8
+ *
9
+ * A DEDICATED file rather than a check bolted onto every scenario that reaches
10
+ * a terminal state: a host that violates this RFC must fail this RFC's rows,
11
+ * not unrelated ones (openwop#1450 showed what misattribution costs).
12
+ *
13
+ * The log is read after it SETTLES — two reads at least 2 s apart returning the
14
+ * same length, bounded at 20 s — because the second delivery's executor can
15
+ * finish after the first terminal event. Growth after a terminal event is the
16
+ * violation's own evidence, not an inconclusive read.
17
+ *
18
+ * @see spec/v2/core/events.md §The terminal event
19
+ * @see RFCS/0194-terminal-event-ends-forward-execution.md
20
+ */
21
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
22
+ import { describe, expect, it } from 'vitest';
23
+ import { driver } from '../lib/driver.js';
24
+ import { v2Discovery } from '../lib/v2.js';
25
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
26
+ import { softSkip } from '../lib/soft-skip.js';
27
+ import { scaledTimeoutMs } from '../lib/polling.js';
28
+ import { req } from '../lib/requirement-ids.js';
29
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
30
+ import { terminalShapeViolation, TERMINAL_RUN_EVENTS } from '../lib/terminal-shape.js';
31
+
32
+ const ONCE = 'openwop.requirement.0194.terminal-once';
33
+ const DUP = 'openwop.requirement.0194.terminal-once.duplicate-delivery';
34
+ const DOC = 'spec/v2/core/events.md §The terminal event (RFC 0194 §A)';
35
+ const TERMINAL_STATUS = new Set(['completed', 'failed', 'cancelled']);
36
+ const KILL_SEAM = '/host/durability/kill';
37
+
38
+ async function http<T>(fn: () => Promise<T>): Promise<T | null> { try { return await fn(); } catch { return null; } }
39
+
40
+ async function status(runId: string): Promise<string | null> {
41
+ const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
42
+ return r !== null && r.status === 200 ? String((r.json as { status?: unknown } | null)?.status ?? '') : null;
43
+ }
44
+ async function waitStatus(runId: string, want: (s: string) => boolean, timeoutMs: number): Promise<string | null> {
45
+ const deadline = Date.now() + scaledTimeoutMs(timeoutMs);
46
+ let s: string | null = null;
47
+ while (Date.now() < deadline) { s = await status(runId); if (s !== null && want(s)) return s; await new Promise((r) => setTimeout(r, 250)); }
48
+ return s;
49
+ }
50
+ async function logTypes(runId: string): Promise<string[] | null> {
51
+ const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
52
+ if (r === null || r.status !== 200) return null;
53
+ const ev = (r.json as { events?: Array<{ type?: unknown }> } | null)?.events;
54
+ return Array.isArray(ev) ? ev.map((e) => String(e.type)) : null;
55
+ }
56
+ /** Re-read until two reads ≥ 2 s apart agree (bounded); returns the last read. */
57
+ async function settledLog(runId: string): Promise<string[] | null> {
58
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
59
+ let last = await logTypes(runId);
60
+ while (Date.now() < deadline) {
61
+ await new Promise((r) => setTimeout(r, 2_000));
62
+ const next = await logTypes(runId);
63
+ if (next === null) return last;
64
+ if (last !== null && next.length === last.length) return next;
65
+ last = next;
66
+ }
67
+ return last;
68
+ }
69
+
70
+ describe('v2 terminal event (RFC 0194 §A)', () => {
71
+ it('every run observed to a terminal state has exactly one terminal event and no forward execution after it', async () => {
72
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
73
+ req(ONCE, DOC, 'a run\'s log MUST contain exactly one terminal run event and no forward-execution event after it');
74
+ const cases: Array<{ fixture: string; cancel: boolean; want: string }> = [
75
+ { fixture: 'conformance-noop', cancel: false, want: 'completed' },
76
+ { fixture: 'conformance-noop', cancel: false, want: 'completed' },
77
+ { fixture: 'conformance-failure', cancel: false, want: 'failed' },
78
+ { fixture: 'conformance-cancellable', cancel: true, want: 'cancelled' },
79
+ ];
80
+ const observed: Array<{ fixture: string; runId: string }> = [];
81
+ for (const c of cases) {
82
+ if (!isFixtureAdvertised(c.fixture)) continue;
83
+ const create = await http(() => driver.post('/runs', { workflowId: c.fixture }));
84
+ const runId = (create?.json as { runId?: unknown } | null)?.runId;
85
+ if (create === null || create.status !== 201 || typeof runId !== 'string') continue;
86
+ if (c.cancel) await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
87
+ // `cancelling` is not terminal; wait for the terminal status itself.
88
+ const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 30_000);
89
+ if (s !== null && TERMINAL_STATUS.has(s)) observed.push({ fixture: c.fixture, runId });
90
+ }
91
+ if (observed.length === 0) return softSkip('blocked', 'no advertised fixture produced a run that reached a terminal status — there is no log to check');
92
+ for (const o of observed) {
93
+ const types = await settledLog(o.runId);
94
+ if (types === null) return softSkip('blocked', `GET /runs/{runId}/events/poll did not answer for ${o.fixture} — the log is unobservable`);
95
+ expect(terminalShapeViolation(types), req(ONCE, DOC, `${o.fixture} run ${o.runId}: exactly one terminal run event, and no forward-execution event after it — log: ${types.join(' ')}`)).toBeNull();
96
+ }
97
+ }, 180_000);
98
+
99
+ it('work delivered twice still leaves one terminal event and no forward execution after it', async () => {
100
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
101
+ req(DUP, DOC, 'under a duplicate delivery the log MUST still contain exactly one terminal run event and no forward-execution event after it');
102
+ const probe = await http(() => driver.get(KILL_SEAM));
103
+ if (probe === null) return softSkip('blocked', `GET ${KILL_SEAM} unreachable (fetch failed)`);
104
+ 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
+ const rx = await startEffectReceiver();
106
+ try {
107
+ const target = resolveRegistrationUrl(rx.url);
108
+ const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url }));
109
+ const runId = (fired?.json as { runId?: unknown } | null)?.runId;
110
+ 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
+ const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 60_000);
112
+ if (s === null || !TERMINAL_STATUS.has(s)) return softSkip('blocked', `the redelivered run did not reach a terminal status (last: ${s ?? 'unreadable'})`);
113
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
114
+ while (rx.arrivals === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
115
+ const types = await settledLog(runId);
116
+ if (types === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — the log is unobservable');
117
+ expect(types.some((t) => TERMINAL_RUN_EVENTS.has(t)), req(DUP, DOC, 'the redelivered run\'s log reached a terminal event')).toBe(true);
118
+ 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
+ } finally {
120
+ await new Promise<void>((ok) => rx.server.close(() => ok()));
121
+ }
122
+ }, 180_000);
123
+ });
124
+
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
+ }
@@ -64,6 +64,16 @@ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
64
64
  { url: 'https://192.168.255.1/openwop-egress-probe', cls: 'RFC 1918 (192.168/16)' },
65
65
  { url: 'https://169.254.169.254/latest/meta-data/', cls: 'link-local / cloud metadata' },
66
66
  { url: 'https://[fd00::1]/openwop-egress-probe', cls: 'IPv6 ULA' },
67
+ // 2.35.0 — the same MUST, spelled the way a URL parser spells it. An
68
+ // IPv4-mapped IPv6 literal is normalised to HEX (`[::ffff:127.0.0.1]` has
69
+ // hostname `::ffff:7f00:1`), and a guard that matched only the dotted mapped
70
+ // form let loopback, metadata and RFC 1918 through at registration and at
71
+ // delivery. Measured on the steward's own reference host.
72
+ // These are loopback / link-local / RFC 1918 addresses by any reading of
73
+ // webhooks.md §SSRF, so they enforce the existing obligation — same id.
74
+ { url: 'https://[::ffff:7f00:1]/openwop-egress-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
75
+ { url: 'https://[::ffff:a9fe:a9fe]/latest/meta-data/', cls: 'cloud metadata as IPv4-mapped IPv6 (hex)' },
76
+ { url: 'https://[::ffff:a00:1]/openwop-egress-probe', cls: 'RFC 1918 (10/8) as IPv4-mapped IPv6 (hex)' },
67
77
  ];
68
78
 
69
79
  /** The `webhooks.*` obligations the operator declared relaxed, if any. */
package/src/setup.ts CHANGED
@@ -50,6 +50,17 @@ import { takeNotedEvidence } from './lib/durability-evidence.js';
50
50
  // vacuous `inapplicable`. Measured 2026-09-05 on a host answering in 200 ms.
51
51
  const SUITE_INIT_TIMEOUT_MS = 20_000;
52
52
  const SUITE_INIT_ATTEMPTS = 2;
53
+ /**
54
+ * 2.34.2: a TRANSPORT failure (refused, reset, closed) is retried for up to
55
+ * this long, 1 s apart, before discovery is declared unreadable. Two
56
+ * back-to-back attempts gave a host restarted by the RFC 0158 kill rows about
57
+ * a millisecond to come back: every file set up while it was restarting logged
58
+ * "discovery unreadable" and recorded its fixture gates `blocked`, so one kill
59
+ * contaminated the rest of the run. An HTTP answer is not retried here - a host
60
+ * that answers 500 is up and its answer stands. A timeout (the 20 s abort) is
61
+ * not a transport failure either and still counts as an attempt.
62
+ */
63
+ const SUITE_INIT_RESTART_WINDOW_MS = 45_000;
53
64
 
54
65
  async function loadHostFixtures(): Promise<void> {
55
66
  const baseUrl = process.env.OPENWOP_BASE_URL?.trim();
@@ -67,6 +78,7 @@ async function loadHostFixtures(): Promise<void> {
67
78
  if (targetMajor() === 2) headers['OpenWOP-Version'] = '2.0';
68
79
 
69
80
  let lastFailure = 'unknown';
81
+ const initStarted = Date.now();
70
82
  for (let attempt = 1; attempt <= SUITE_INIT_ATTEMPTS; attempt += 1) {
71
83
  const controller = new AbortController();
72
84
  const timer = setTimeout(() => controller.abort(), SUITE_INIT_TIMEOUT_MS);
@@ -82,6 +94,11 @@ async function loadHostFixtures(): Promise<void> {
82
94
  return;
83
95
  } catch (err) {
84
96
  lastFailure = `${(err as Error).message ?? 'unknown'} on attempt ${attempt}`;
97
+ const aborted = (err as Error).name === 'AbortError';
98
+ if (!aborted && Date.now() - initStarted < SUITE_INIT_RESTART_WINDOW_MS) {
99
+ attempt -= 1; // a restarting host: wait, and do not spend an attempt on it
100
+ await new Promise((r) => setTimeout(r, 1_000));
101
+ }
85
102
  } finally {
86
103
  clearTimeout(timer);
87
104
  }
@@ -453,7 +470,7 @@ afterEach(({ task }) => {
453
470
  // `inapplicable` came out `blocked` — which denies certification bundle-wide).
454
471
  const noted = softSkipDispositionSince(file, _itSoftSkipMarks.get(file) ?? 0);
455
472
  const err = (task.result?.errors ?? [])[0] as { message?: string } | undefined;
456
- const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message);
473
+ const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2);
457
474
  disposition = rec.disposition;
458
475
  detail = rec.detail;
459
476
  }