@openwop/openwop-conformance 2.0.0-rc.52 → 2.0.0-rc.54

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.0.0-rc.52",
4
- "stampSha256": "81cdf1c9820f034ec60ac59b15a2e1166af70f2e9744c72251ecd1cd88783bc7"
3
+ "version": "2.0.0-rc.54",
4
+ "stampSha256": "8af0e97d10089d2d711b67a0fae28d738b32eaf6154cfccbb9c370eea7806318"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.0-rc.52",
3
+ "version": "2.0.0-rc.54",
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.0.0-rc.52"
59
+ "@openwop/spec-artifacts": "2.0.0-rc.54"
60
60
  }
61
61
  }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "$comment": "old id → new id. Add a row when a test title is reworded so bundles citing the old id still resolve.",
3
3
  "aliases": {
4
- "openwop.it.v2-run-cancel.cancelling-a-terminal-run-answers-409-run-terminal-or-200-idempotently-the-prose": "openwop.it.v2-run-cancel.cancelling-a-terminal-run-is-refused-409-run-terminal"
4
+ "openwop.it.v2-run-cancel.cancelling-a-terminal-run-answers-409-run-terminal-or-200-idempotently-the-prose": "openwop.it.v2-run-cancel.cancelling-a-terminal-run-is-refused-409-run-terminal",
5
+ "openwop.it.pause-resume.pause-on-an-already-paused-run-is-a-no-op-200-202-idempotent": "openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless"
5
6
  }
6
7
  }
package/requirements.json CHANGED
@@ -3,10 +3,10 @@
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
5
  "files": 564,
6
- "tests": 2148,
7
- "withStableId": 2147,
6
+ "tests": 2149,
7
+ "withStableId": 2148,
8
8
  "interpolatedTitles": 1,
9
- "explicitIds": 2057
9
+ "explicitIds": 2058
10
10
  },
11
11
  "records": [
12
12
  {
@@ -18727,80 +18727,131 @@
18727
18727
  {
18728
18728
  "id": "openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running",
18729
18729
  "file": "pause-resume.test.ts",
18730
- "line": 36,
18730
+ "line": 62,
18731
18731
  "title": "pause transitions to paused; resume returns the run to running",
18732
18732
  "explicitId": "openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running",
18733
18733
  "citations": [
18734
18734
  {
18735
- "section": "rest-endpoints.md POST /v1/runs/{runId}:pause",
18736
- "requirement": ":pause MUST return 202 on a pausable run"
18735
+ "section": null,
18736
+ "requirement": ":pause MUST return 202 on a pausable run",
18737
+ "interpolated": true
18737
18738
  },
18738
18739
  {
18739
- "section": "rest-endpoints.md POST /v1/runs/{runId}:resume",
18740
- "requirement": ":resume MUST return 202 on a paused run"
18740
+ "section": null,
18741
+ "requirement": ":resume MUST return 202 on a paused run",
18742
+ "interpolated": true
18741
18743
  }
18742
18744
  ]
18743
18745
  },
18744
18746
  {
18745
18747
  "id": "openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus",
18746
18748
  "file": "pause-resume.test.ts",
18747
- "line": 82,
18749
+ "line": 110,
18748
18750
  "title": "resuming a running (not paused) run returns 409 with details.runStatus",
18749
18751
  "explicitId": "openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus",
18750
18752
  "citations": [
18751
18753
  {
18752
- "section": "rest-endpoints.md POST /v1/runs/{runId}:resume",
18753
- "requirement": ":resume on a non-paused run MUST return 409"
18754
+ "section": null,
18755
+ "requirement": ":resume on a non-paused run MUST return 409",
18756
+ "interpolated": true
18757
+ },
18758
+ {
18759
+ "section": null,
18760
+ "requirement": "the 409 MUST carry details.runStatus with the actual state",
18761
+ "interpolated": true
18754
18762
  }
18755
18763
  ]
18756
18764
  },
18757
18765
  {
18758
- "id": "openwop.it.pause-resume.pause-on-an-already-paused-run-is-a-no-op-200-202-idempotent",
18766
+ "id": "openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless",
18759
18767
  "file": "pause-resume.test.ts",
18760
- "line": 115,
18761
- "title": ":pause on an already-paused run is a no-op (200/202) idempotent",
18762
- "explicitId": "openwop.it.pause-resume.pause-on-an-already-paused-run-is-a-no-op-200-202-idempotent",
18768
+ "line": 144,
18769
+ "title": ":pause on an already-paused run returns 409 with details.runStatus paused, unless the request carries the original Idempotency-Key",
18770
+ "explicitId": "openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless",
18763
18771
  "citations": [
18764
18772
  {
18765
- "section": "rest-endpoints.md POST /v1/runs/{runId}:pause",
18766
- "requirement": ":pause on an already-paused run MUST be idempotent (200/202), not 409"
18773
+ "section": null,
18774
+ "requirement": ":pause MUST return 202 on a pausable run",
18775
+ "interpolated": true
18776
+ },
18777
+ {
18778
+ "section": null,
18779
+ "requirement": ":pause on an already-paused run without a matching Idempotency-Key MUST return 409",
18780
+ "interpolated": true
18781
+ },
18782
+ {
18783
+ "section": null,
18784
+ "requirement": "the 409 MUST carry details.runStatus: paused",
18785
+ "interpolated": true
18786
+ },
18787
+ {
18788
+ "section": null,
18789
+ "requirement": ":pause with an Idempotency-Key MUST return 202 on a pausable run",
18790
+ "interpolated": true
18791
+ },
18792
+ {
18793
+ "section": null,
18794
+ "requirement": "a :pause carrying the original Idempotency-Key MUST return 202 with the cached response, not 409",
18795
+ "interpolated": true
18767
18796
  }
18768
18797
  ]
18769
18798
  },
18770
18799
  {
18771
18800
  "id": "openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409",
18772
18801
  "file": "pause-resume.test.ts",
18773
- "line": 153,
18802
+ "line": 205,
18774
18803
  "title": ":pause on a completed/cancelled/failed run MUST return 409",
18775
18804
  "explicitId": "openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409",
18776
18805
  "citations": [
18777
18806
  {
18778
- "section": "rest-endpoints.md POST /v1/runs/{runId}:pause",
18779
- "requirement": ":pause on a terminal run MUST return 409"
18807
+ "section": null,
18808
+ "requirement": ":pause on a terminal run MUST return 409",
18809
+ "interpolated": true
18810
+ }
18811
+ ]
18812
+ },
18813
+ {
18814
+ "id": "openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log",
18815
+ "file": "pause-resume.test.ts",
18816
+ "line": 229,
18817
+ "title": "under drain-current-node node.completed precedes run.paused in the log",
18818
+ "explicitId": "openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log",
18819
+ "citations": [
18820
+ {
18821
+ "section": null,
18822
+ "requirement": ":pause with drainPolicy drain-current-node MUST return 202 (pause requested)",
18823
+ "interpolated": true
18824
+ },
18825
+ {
18826
+ "section": null,
18827
+ "requirement": null,
18828
+ "interpolated": true
18780
18829
  }
18781
18830
  ]
18782
18831
  },
18783
18832
  {
18784
18833
  "id": "openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend",
18785
18834
  "file": "pause-resume.test.ts",
18786
- "line": 177,
18835
+ "line": 268,
18787
18836
  "title": ":pause MUST NOT silently override an active interrupt suspend",
18788
18837
  "explicitId": "openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend",
18789
18838
  "citations": [
18790
18839
  {
18791
- "section": "rest-endpoints.md POST /v1/runs/{runId}:pause",
18792
- "requirement": ":pause-during-suspend MUST surface the active waiting-* status in the conflict envelope"
18840
+ "section": null,
18841
+ "requirement": ":pause-during-suspend MUST surface the active waiting-* status in the conflict envelope",
18842
+ "interpolated": true
18793
18843
  },
18794
18844
  {
18795
- "section": "rest-endpoints.md POST /v1/runs/{runId}:pause",
18796
- "requirement": ":pause-during-suspend MUST NOT silently discard the active interrupt"
18845
+ "section": null,
18846
+ "requirement": ":pause-during-suspend MUST NOT silently discard the active interrupt",
18847
+ "interpolated": true
18797
18848
  }
18798
18849
  ]
18799
18850
  },
18800
18851
  {
18801
18852
  "id": "openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause",
18802
18853
  "file": "pause-resume.test.ts",
18803
- "line": 238,
18854
+ "line": 324,
18804
18855
  "title": "every drainPolicy advertised by the host is accepted on :pause",
18805
18856
  "explicitId": "openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause",
18806
18857
  "citations": [
@@ -1,7 +1,7 @@
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.0.0-rc.52",
4
+ "version": "2.0.0-rc.54",
5
5
  "corpusTag": null,
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
@@ -9,9 +9,9 @@
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "54fced8c0f57bb6af60f5d6f35d2da1b7e262ea895a225daa4ce13df3f5eb488",
13
- "api/v2/asyncapi.yaml": "4d337c6dcf7ff3d3c0256ae6d2388aa644741464b3e6911d2d4156655c7909e0",
14
- "api/v2/openapi.yaml": "c61a55d1cab7db1747d12fa6af82089ae21cac1415efd28cdf3ecb30c226c11b",
12
+ "api/seams-v2.yaml": "9357a2a3bc0e95b19ce253313cbf1d63ca079e7f5025fa079a1a8d4206d95e8e",
13
+ "api/v2/asyncapi.yaml": "0e1516fb4a80a3bedfe9fbb7e3cf30b756a03b2e544634989986107982265ed6",
14
+ "api/v2/openapi.yaml": "69c7e7d89260db65b08a64b26c27dc80fff690f9459741b904f84cc22df6f1e2",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -199,7 +199,7 @@
199
199
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
200
200
  "spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
201
201
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
202
- "spec/v1/core-standard-manifest.json": "6cca7e93821c9bb93c1a4b206e2300be1b2e7f6bea8eb4fdb870cf5c9e0a44d4",
202
+ "spec/v1/core-standard-manifest.json": "541259e7d6b12bf8d93bc0f13b05e45526a785c40ee45670b48724d03fd61096",
203
203
  "spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
204
204
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
205
205
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -230,8 +230,8 @@
230
230
  "spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
231
231
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
232
232
  "spec/v2/profiles.json": "5019ac8209540bfbeee9c3232530d4e599be0ab926d817ff48edcec8419eb209",
233
- "spec/v2/release.json": "80a24ebc5baa87dfe19476762f1999354fb522e58e115e52777618ca7272c298",
233
+ "spec/v2/release.json": "48be3e323433d659848bb575ccb907e9e994b55b1d98237a5975e58451dcb1dd",
234
234
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
235
235
  },
236
- "corpusCommit": "7dded60251d6e769e05b8131728b66c2652318fb"
236
+ "corpusCommit": "14eabff5bde7862ddecb900d92aa24ae1967d1cc"
237
237
  }
@@ -2,17 +2,31 @@
2
2
  * Track 13: operator-driven pause/resume (rest-endpoints.md v1.1).
3
3
  *
4
4
  * Exercises the `POST /v1/runs/{runId}:pause` and `:resume` endpoints
5
- * against a long-running fixture (`conformance-delay` or `conformance-cancellable`).
5
+ * against a long-running fixture (`conformance-cancellable` or `conformance-delay`;
6
+ * both take `inputs.delayMs` — conformance/fixtures.md).
6
7
  *
7
8
  * Verifies:
8
- * 1. :pause on a running run transitions to `paused` and emits `run.paused`.
9
- * 2. :resume on a paused run transitions to `running` and emits `run.resumed`.
10
- * 3. :pause on a terminal run returns 409 with details.runStatus.
11
- * 4. :resume on a non-paused run returns 409 with details.runStatus.
9
+ * 1. :pause (immediate) on a running run transitions to `paused`; :resume
10
+ * returns it to `running` and it reaches a terminal.
11
+ * 2. :resume on a non-paused run returns 409 with details.runStatus.
12
+ * 3. :pause on an already-paused run returns 409 (details.runStatus paused)
13
+ * without a matching Idempotency-Key, and 202 with the cached response
14
+ * when the same key is sent — rest-endpoints.md §:pause Idempotency.
15
+ * 4. :pause on a terminal run returns 409 with details.runStatus.
16
+ * 5. drain-current-node lets the executing node reach a terminal FIRST:
17
+ * node.completed precedes run.paused in the log.
12
18
  *
13
- * Capability gating: skips when the host doesn't advertise
14
- * `capabilities.runs.pauseResume.supported: true` (when present) AND
15
- * skips when no long-running fixture is advertised.
19
+ * rc.53 (2026-09-05): a tier-1 host un-skipping this file found it
20
+ * contradicting rest-endpoints.md in three places — it expected 200/202 on a
21
+ * second :pause (the prose says 409 unless the Idempotency-Key matches), it
22
+ * asserted an error code `conflict` that v1 never named (v1 mandates 409 +
23
+ * details.runStatus and names only run_terminal for a terminal run), and it
24
+ * paused a 30 s node under drain-current-node and polled 10 s for `paused`
25
+ * (drain lets the node finish; a host that yields mid-node under drain is the
26
+ * non-conforming one). It also sent `delaySeconds`, a field the fixtures do
27
+ * not define. v1 is frozen; the scenario was the defect in every case.
28
+ *
29
+ * Capability gating: skips when no long-running fixture is advertised.
16
30
  *
17
31
  * @see spec/v1/rest-endpoints.md §pause/resume
18
32
  */
@@ -31,35 +45,49 @@ const FIXTURE =
31
45
  null;
32
46
 
33
47
  const SKIP = !FIXTURE;
48
+ const DOC_PAUSE = 'rest-endpoints.md POST /v1/runs/{runId}:pause';
49
+ const DOC_RESUME = 'rest-endpoints.md POST /v1/runs/{runId}:resume';
50
+
51
+ async function cancel(runId: string): Promise<void> {
52
+ await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, { reason: 'conformance-cleanup' });
53
+ }
54
+
55
+ async function eventTypes(runId: string): Promise<string[]> {
56
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
57
+ const events = (res.json as { events?: unknown } | null)?.events;
58
+ return Array.isArray(events) ? events.map((e) => String((e as { type?: unknown }).type)) : [];
59
+ }
34
60
 
35
61
  describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal', () => {
36
62
  it('pause transitions to paused; resume returns the run to running', async () => {
37
63
  const create = await driver.post('/v1/runs', {
38
64
  workflowId: FIXTURE!,
39
- inputs: { delaySeconds: 30 },
65
+ // 3 s, not 30 s: the resumed node.started is a FRESH attempt (runs.md
66
+ // §Pause and resume; rc.52), so a 30 s node resumed at ~60 ms completes
67
+ // at ~30.2 s — past the suite's 30 s test timeout by construction on
68
+ // any conforming host. rc.53 shipped 30 s and a tier-1 host measured
69
+ // exactly that. The pause is `immediate`, issued within ~60 ms of
70
+ // `running`, so 3 s is ample for the pause to land first.
71
+ inputs: { delayMs: 3_000 },
40
72
  });
41
73
  expect(create.status).toBe(201);
42
74
  const runId = (create.json as { runId: string }).runId;
43
75
 
44
76
  await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
45
77
 
78
+ // `immediate` snapshots between events, so `paused` is observable within
79
+ // milliseconds. Under drain-current-node the node would finish first
80
+ // (rest-endpoints.md); that semantic is witnessed by the drain leg below.
46
81
  const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
47
82
  reason: 'conformance-test',
48
- drainPolicy: 'drain-current-node',
83
+ drainPolicy: 'immediate',
49
84
  });
50
85
  if (pause.status === 404) {
51
- // Pause endpoint not yet implemented by the host — surface the skip honestly.
52
- // eslint-disable-next-line no-console
53
- console.warn(
54
- '[pause-resume] host returned 404 for :pause — endpoint not implemented; skipping rest',
55
- );
56
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
57
- reason: 'conformance-cleanup',
58
- });
86
+ await cancel(runId);
59
87
  return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early ([pause-resume] host returned 404 for :pause — endpoint not implemented; skipping rest) (seam, prior step, or fixture unavailable)');
60
88
  }
61
- expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
62
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
89
+ expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
90
+ DOC_PAUSE,
63
91
  ':pause MUST return 202 on a pausable run',
64
92
  )).toBe(202);
65
93
 
@@ -68,8 +96,8 @@ describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal
68
96
  const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {
69
97
  reason: 'conformance-test',
70
98
  });
71
- expect(resume.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
72
- 'rest-endpoints.md POST /v1/runs/{runId}:resume',
99
+ expect(resume.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
100
+ DOC_RESUME,
73
101
  ':resume MUST return 202 on a paused run',
74
102
  )).toBe(202);
75
103
 
@@ -82,7 +110,7 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
82
110
  it('resuming a running (not paused) run returns 409 with details.runStatus', async () => {
83
111
  const create = await driver.post('/v1/runs', {
84
112
  workflowId: FIXTURE!,
85
- inputs: { delaySeconds: 30 },
113
+ inputs: { delayMs: 30_000 },
86
114
  });
87
115
  expect(create.status).toBe(201);
88
116
  const runId = (create.json as { runId: string }).runId;
@@ -91,61 +119,85 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
91
119
 
92
120
  const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {});
93
121
  if (resume.status === 404) {
94
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
95
- reason: 'conformance-cleanup',
96
- });
122
+ await cancel(runId);
97
123
  return softSkip('blocked', 'precondition not met — `resume.status === 404` returned early (seam, prior step, or fixture unavailable)');
98
124
  }
99
- expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
100
- 'rest-endpoints.md POST /v1/runs/{runId}:resume',
125
+ expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
126
+ DOC_RESUME,
101
127
  ':resume on a non-paused run MUST return 409',
102
128
  )).toBe(409);
103
129
 
130
+ // v1 mandates the status and details.runStatus; it names no code for
131
+ // this case (the registry is open — error-envelope.schema.json), so
132
+ // none is asserted. `run_state_conflict` is the v2 answer (runs.md).
104
133
  const body = resume.json as { error?: string; details?: { runStatus?: string } };
105
- expect(body.error).toBe('conflict');
106
- expect(typeof body.details?.runStatus).toBe('string');
134
+ expect(typeof body.details?.runStatus, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
135
+ DOC_RESUME,
136
+ 'the 409 MUST carry details.runStatus with the actual state',
137
+ )).toBe('string');
107
138
 
108
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
109
- reason: 'conformance-cleanup',
110
- });
139
+ await cancel(runId);
111
140
  });
112
141
  });
113
142
 
114
- describe.skipIf(SKIP)('pause/resume: pause is idempotent when already paused', () => {
115
- it(':pause on an already-paused run is a no-op (200/202) idempotent', async () => {
143
+ describe.skipIf(SKIP)('pause/resume: a second :pause is 409 without a matching Idempotency-Key and 202 with one', () => {
144
+ it(':pause on an already-paused run returns 409 with details.runStatus paused, unless the request carries the original Idempotency-Key', async () => {
116
145
  const create = await driver.post('/v1/runs', {
117
146
  workflowId: FIXTURE!,
118
- inputs: { delaySeconds: 30 },
147
+ inputs: { delayMs: 30_000 },
119
148
  });
120
149
  expect(create.status).toBe(201);
121
150
  const runId = (create.json as { runId: string }).runId;
122
151
  await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
123
152
 
124
- const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
153
+ const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, { drainPolicy: 'immediate' });
125
154
  if (first.status === 404) {
126
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
127
- reason: 'conformance-cleanup',
128
- });
155
+ await cancel(runId);
129
156
  return softSkip('blocked', 'precondition not met — `first.status === 404` returned early (seam, prior step, or fixture unavailable)');
130
157
  }
131
- expect([200, 202]).toContain(first.status);
158
+ expect(first.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
159
+ DOC_PAUSE,
160
+ ':pause MUST return 202 on a pausable run',
161
+ )).toBe(202);
132
162
  await pollUntilStatus(runId, 'paused', { timeoutMs: 10_000 });
133
163
 
134
- // Idempotent second :pause MUST NOT 409 just because the run is
135
- // already paused. 200/202 are both acceptable per the additive
136
- // contract; 409 would force callers to read state before calling.
164
+ // rest-endpoints.md §:pause Idempotency: "a :pause against a run that is
165
+ // already paused returns 409 (with the existing pause's pausedAt in
166
+ // details) unless the request carries Idempotency-Key matching the
167
+ // original pause, in which case the host returns 202 with the cached
168
+ // response." Until rc.53 this leg asserted 200/202 here, citing an
169
+ // "additive contract" the prose does not contain.
137
170
  const second = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
138
- expect(
139
- [200, 202].includes(second.status),
140
- req('openwop.it.pause-resume.pause-on-an-already-paused-run-is-a-no-op-200-202-idempotent',
141
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
142
- ':pause on an already-paused run MUST be idempotent (200/202), not 409',
143
- ),
144
- ).toBe(true);
145
-
146
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
147
- reason: 'conformance-cleanup',
148
- });
171
+ expect(second.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
172
+ DOC_PAUSE,
173
+ ':pause on an already-paused run without a matching Idempotency-Key MUST return 409',
174
+ )).toBe(409);
175
+ const body = second.json as { details?: { runStatus?: string } };
176
+ expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
177
+ DOC_PAUSE,
178
+ 'the 409 MUST carry details.runStatus: paused',
179
+ )).toBe('paused');
180
+ await cancel(runId);
181
+
182
+ // The keyed form: the same Idempotency-Key on both pauses collapses the
183
+ // second into the cached 202.
184
+ const keyed = await driver.post('/v1/runs', { workflowId: FIXTURE!, inputs: { delayMs: 30_000 } });
185
+ expect(keyed.status).toBe(201);
186
+ const keyedId = (keyed.json as { runId: string }).runId;
187
+ await pollUntilStatus(keyedId, 'running', { timeoutMs: 10_000 });
188
+ const key = `openwop-conformance-pause-${keyedId.replace(/[^A-Za-z0-9._~-]/g, '-')}`.slice(0, 128);
189
+ const k1 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
190
+ expect(k1.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
191
+ DOC_PAUSE,
192
+ ':pause with an Idempotency-Key MUST return 202 on a pausable run',
193
+ )).toBe(202);
194
+ await pollUntilStatus(keyedId, 'paused', { timeoutMs: 10_000 });
195
+ const k2 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
196
+ expect(k2.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
197
+ DOC_PAUSE,
198
+ 'a :pause carrying the original Idempotency-Key MUST return 202 with the cached response, not 409',
199
+ )).toBe(202);
200
+ await cancel(keyedId);
149
201
  });
150
202
  });
151
203
 
@@ -160,19 +212,58 @@ describe.skipIf(SKIP)('pause/resume: :pause on a terminal run returns 409', () =
160
212
 
161
213
  const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
162
214
  if (pause.status === 404) return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
163
- expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
164
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
215
+ expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
216
+ DOC_PAUSE,
165
217
  ':pause on a terminal run MUST return 409',
166
218
  )).toBe(409);
167
219
 
220
+ // v1 mandates details.runStatus; `run_terminal` is the one code v1 names
221
+ // for a mutation on a terminal run (rest-endpoints.md, bulk-cancel), and
222
+ // the registry is open, so the code is not asserted here.
168
223
  const body = pause.json as { error?: string; details?: { runStatus?: string } };
169
- expect(body.error).toBe('conflict');
170
- // Spec requires `details.runStatus` to disclose the terminal state so
171
- // the caller can decide whether to retry or surface the conflict.
172
224
  expect(['completed', 'failed', 'cancelled']).toContain(body.details?.runStatus);
173
225
  });
174
226
  });
175
227
 
228
+ describe.skipIf(SKIP)('pause/resume: drain-current-node lets the executing node reach a terminal first', () => {
229
+ it('under drain-current-node node.completed precedes run.paused in the log', async () => {
230
+ // A short node: drain has something to wait for that finishes inside the
231
+ // poll window. A 30 s node under drain pauses after 30 s — that is the
232
+ // semantic, not a bug (rest-endpoints.md: "lets the executing node reach
233
+ // a terminal before the run transitions to paused").
234
+ const create = await driver.post('/v1/runs', {
235
+ workflowId: FIXTURE!,
236
+ inputs: { delayMs: 3_000 },
237
+ });
238
+ expect(create.status).toBe(201);
239
+ const runId = (create.json as { runId: string }).runId;
240
+ await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
241
+
242
+ const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
243
+ reason: 'conformance-drain',
244
+ drainPolicy: 'drain-current-node',
245
+ });
246
+ if (pause.status === 404) {
247
+ await cancel(runId);
248
+ return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
249
+ }
250
+ expect(pause.status, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
251
+ DOC_PAUSE,
252
+ ':pause with drainPolicy drain-current-node MUST return 202 (pause requested)',
253
+ )).toBe(202);
254
+
255
+ await pollUntilStatus(runId, 'paused', { timeoutMs: 20_000 });
256
+ const types = await eventTypes(runId);
257
+ const done = types.indexOf('node.completed');
258
+ const paused = types.indexOf('run.paused');
259
+ expect(done >= 0 && paused > done, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
260
+ DOC_PAUSE,
261
+ `drain-current-node MUST let the executing node reach a terminal before the run transitions to paused — node.completed MUST precede run.paused (types: ${types.join(', ')})`,
262
+ )).toBe(true);
263
+ await cancel(runId);
264
+ });
265
+ });
266
+
176
267
  describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
177
268
  it(':pause MUST NOT silently override an active interrupt suspend', async () => {
178
269
  // If the host seeds an approval fixture, drive a suspend then attempt
@@ -198,18 +289,15 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
198
289
  reason: 'race-test',
199
290
  });
200
291
  if (pause.status === 404) {
201
- // Cleanup.
202
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
203
- reason: 'conformance-cleanup',
204
- });
292
+ await cancel(runId);
205
293
  return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
206
294
  }
207
295
 
208
296
  // Either rejection (preferred) or stacked-pause is OK; silent override is not.
209
297
  if (pause.status === 409) {
210
298
  const body = pause.json as { details?: { runStatus?: string } };
211
- expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
212
- 'rest-endpoints.md POST /v1/runs/{runId}:pause',
299
+ expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
300
+ DOC_PAUSE,
213
301
  ':pause-during-suspend MUST surface the active waiting-* status in the conflict envelope',
214
302
  )).toMatch(/^waiting-/);
215
303
  } else {
@@ -220,13 +308,11 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
220
308
  const status = (snap.json as { status: string }).status;
221
309
  expect(
222
310
  status === 'paused' || status.startsWith('waiting-'),
223
- req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend', 'rest-endpoints.md POST /v1/runs/{runId}:pause', ':pause-during-suspend MUST NOT silently discard the active interrupt'),
311
+ req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend', DOC_PAUSE, ':pause-during-suspend MUST NOT silently discard the active interrupt'),
224
312
  ).toBe(true);
225
313
  }
226
314
 
227
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
228
- reason: 'conformance-cleanup',
229
- });
315
+ await cancel(runId);
230
316
  });
231
317
  });
232
318
 
@@ -249,7 +335,7 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
249
335
  for (const policy of drainPolicies) {
250
336
  const create = await driver.post('/v1/runs', {
251
337
  workflowId: FIXTURE!,
252
- inputs: { delaySeconds: 30 },
338
+ inputs: { delayMs: 30_000 },
253
339
  });
254
340
  expect(create.status).toBe(201);
255
341
  const runId = (create.json as { runId: string }).runId;
@@ -260,14 +346,12 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
260
346
  reason: `conformance-drainpolicy-${policy}`,
261
347
  drainPolicy: policy,
262
348
  });
263
- expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
349
+ expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
264
350
  'capabilities.md §`runs.pauseResume.drainPolicies` + rest-endpoints.md POST /v1/runs/{runId}:pause',
265
351
  `host-advertised drainPolicy='${policy}' MUST be accepted on :pause`,
266
352
  )).toBe(202);
267
353
 
268
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
269
- reason: 'conformance-cleanup',
270
- });
354
+ await cancel(runId);
271
355
  }
272
356
  });
273
357
  });