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

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.53",
4
+ "stampSha256": "c922f572cdf72529a1d3daca8a6c16b5c4a2e12165f5c7cee3b04638912f414c"
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.53",
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.53"
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": 104,
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": 138,
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": 199,
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": 223,
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": 262,
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": 318,
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.53",
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": "44b449eba2eb915b84aadf3c3d0f06bdf7e0b8ade2952b6a17711459d7b3999e",
13
+ "api/v2/asyncapi.yaml": "6e4ad967752e5c6e87c48f656f4f4cbe3f85423faebc69ab60bd3bf9bf2b329e",
14
+ "api/v2/openapi.yaml": "aa8ee2c55604517ac9b2ac7923ad8a957f9fa4bc331bf1b91cdd5236933d6921",
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": "c7f3665189cd982747f26b98dfa977293c0c359a1fb986a3a5a3b8b95f9edd83",
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": "6be17bd4ee80838e3ee85dd7372a88532835448832fccd6b7834a5f1f0c6b326",
234
234
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
235
235
  },
236
- "corpusCommit": "7dded60251d6e769e05b8131728b66c2652318fb"
236
+ "corpusCommit": "693550295565e5f6072c9a852a3ea499275396fb"
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,43 @@ 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
+ inputs: { delayMs: 30_000 },
40
66
  });
41
67
  expect(create.status).toBe(201);
42
68
  const runId = (create.json as { runId: string }).runId;
43
69
 
44
70
  await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
45
71
 
72
+ // `immediate` snapshots between events, so `paused` is observable within
73
+ // seconds. Under drain-current-node this 30 s node would finish first
74
+ // (rest-endpoints.md); that semantic is witnessed by the drain leg below.
46
75
  const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
47
76
  reason: 'conformance-test',
48
- drainPolicy: 'drain-current-node',
77
+ drainPolicy: 'immediate',
49
78
  });
50
79
  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
- });
80
+ await cancel(runId);
59
81
  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
82
  }
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',
83
+ expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
84
+ DOC_PAUSE,
63
85
  ':pause MUST return 202 on a pausable run',
64
86
  )).toBe(202);
65
87
 
@@ -68,8 +90,8 @@ describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal
68
90
  const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {
69
91
  reason: 'conformance-test',
70
92
  });
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',
93
+ expect(resume.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
94
+ DOC_RESUME,
73
95
  ':resume MUST return 202 on a paused run',
74
96
  )).toBe(202);
75
97
 
@@ -82,7 +104,7 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
82
104
  it('resuming a running (not paused) run returns 409 with details.runStatus', async () => {
83
105
  const create = await driver.post('/v1/runs', {
84
106
  workflowId: FIXTURE!,
85
- inputs: { delaySeconds: 30 },
107
+ inputs: { delayMs: 30_000 },
86
108
  });
87
109
  expect(create.status).toBe(201);
88
110
  const runId = (create.json as { runId: string }).runId;
@@ -91,61 +113,85 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
91
113
 
92
114
  const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {});
93
115
  if (resume.status === 404) {
94
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
95
- reason: 'conformance-cleanup',
96
- });
116
+ await cancel(runId);
97
117
  return softSkip('blocked', 'precondition not met — `resume.status === 404` returned early (seam, prior step, or fixture unavailable)');
98
118
  }
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',
119
+ expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
120
+ DOC_RESUME,
101
121
  ':resume on a non-paused run MUST return 409',
102
122
  )).toBe(409);
103
123
 
124
+ // v1 mandates the status and details.runStatus; it names no code for
125
+ // this case (the registry is open — error-envelope.schema.json), so
126
+ // none is asserted. `run_state_conflict` is the v2 answer (runs.md).
104
127
  const body = resume.json as { error?: string; details?: { runStatus?: string } };
105
- expect(body.error).toBe('conflict');
106
- expect(typeof body.details?.runStatus).toBe('string');
128
+ expect(typeof body.details?.runStatus, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
129
+ DOC_RESUME,
130
+ 'the 409 MUST carry details.runStatus with the actual state',
131
+ )).toBe('string');
107
132
 
108
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
109
- reason: 'conformance-cleanup',
110
- });
133
+ await cancel(runId);
111
134
  });
112
135
  });
113
136
 
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 () => {
137
+ describe.skipIf(SKIP)('pause/resume: a second :pause is 409 without a matching Idempotency-Key and 202 with one', () => {
138
+ it(':pause on an already-paused run returns 409 with details.runStatus paused, unless the request carries the original Idempotency-Key', async () => {
116
139
  const create = await driver.post('/v1/runs', {
117
140
  workflowId: FIXTURE!,
118
- inputs: { delaySeconds: 30 },
141
+ inputs: { delayMs: 30_000 },
119
142
  });
120
143
  expect(create.status).toBe(201);
121
144
  const runId = (create.json as { runId: string }).runId;
122
145
  await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
123
146
 
124
- const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
147
+ const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, { drainPolicy: 'immediate' });
125
148
  if (first.status === 404) {
126
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
127
- reason: 'conformance-cleanup',
128
- });
149
+ await cancel(runId);
129
150
  return softSkip('blocked', 'precondition not met — `first.status === 404` returned early (seam, prior step, or fixture unavailable)');
130
151
  }
131
- expect([200, 202]).toContain(first.status);
152
+ expect(first.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
153
+ DOC_PAUSE,
154
+ ':pause MUST return 202 on a pausable run',
155
+ )).toBe(202);
132
156
  await pollUntilStatus(runId, 'paused', { timeoutMs: 10_000 });
133
157
 
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.
158
+ // rest-endpoints.md §:pause Idempotency: "a :pause against a run that is
159
+ // already paused returns 409 (with the existing pause's pausedAt in
160
+ // details) unless the request carries Idempotency-Key matching the
161
+ // original pause, in which case the host returns 202 with the cached
162
+ // response." Until rc.53 this leg asserted 200/202 here, citing an
163
+ // "additive contract" the prose does not contain.
137
164
  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
- });
165
+ expect(second.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
166
+ DOC_PAUSE,
167
+ ':pause on an already-paused run without a matching Idempotency-Key MUST return 409',
168
+ )).toBe(409);
169
+ const body = second.json as { details?: { runStatus?: string } };
170
+ expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
171
+ DOC_PAUSE,
172
+ 'the 409 MUST carry details.runStatus: paused',
173
+ )).toBe('paused');
174
+ await cancel(runId);
175
+
176
+ // The keyed form: the same Idempotency-Key on both pauses collapses the
177
+ // second into the cached 202.
178
+ const keyed = await driver.post('/v1/runs', { workflowId: FIXTURE!, inputs: { delayMs: 30_000 } });
179
+ expect(keyed.status).toBe(201);
180
+ const keyedId = (keyed.json as { runId: string }).runId;
181
+ await pollUntilStatus(keyedId, 'running', { timeoutMs: 10_000 });
182
+ const key = `openwop-conformance-pause-${keyedId.replace(/[^A-Za-z0-9._~-]/g, '-')}`.slice(0, 128);
183
+ const k1 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
184
+ expect(k1.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
185
+ DOC_PAUSE,
186
+ ':pause with an Idempotency-Key MUST return 202 on a pausable run',
187
+ )).toBe(202);
188
+ await pollUntilStatus(keyedId, 'paused', { timeoutMs: 10_000 });
189
+ const k2 = await driver.post(`/v1/runs/${encodeURIComponent(keyedId)}:pause`, { drainPolicy: 'immediate' }, { headers: { 'Idempotency-Key': key } });
190
+ expect(k2.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
191
+ DOC_PAUSE,
192
+ 'a :pause carrying the original Idempotency-Key MUST return 202 with the cached response, not 409',
193
+ )).toBe(202);
194
+ await cancel(keyedId);
149
195
  });
150
196
  });
151
197
 
@@ -160,19 +206,58 @@ describe.skipIf(SKIP)('pause/resume: :pause on a terminal run returns 409', () =
160
206
 
161
207
  const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
162
208
  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',
209
+ expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
210
+ DOC_PAUSE,
165
211
  ':pause on a terminal run MUST return 409',
166
212
  )).toBe(409);
167
213
 
214
+ // v1 mandates details.runStatus; `run_terminal` is the one code v1 names
215
+ // for a mutation on a terminal run (rest-endpoints.md, bulk-cancel), and
216
+ // the registry is open, so the code is not asserted here.
168
217
  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
218
  expect(['completed', 'failed', 'cancelled']).toContain(body.details?.runStatus);
173
219
  });
174
220
  });
175
221
 
222
+ describe.skipIf(SKIP)('pause/resume: drain-current-node lets the executing node reach a terminal first', () => {
223
+ it('under drain-current-node node.completed precedes run.paused in the log', async () => {
224
+ // A short node: drain has something to wait for that finishes inside the
225
+ // poll window. A 30 s node under drain pauses after 30 s — that is the
226
+ // semantic, not a bug (rest-endpoints.md: "lets the executing node reach
227
+ // a terminal before the run transitions to paused").
228
+ const create = await driver.post('/v1/runs', {
229
+ workflowId: FIXTURE!,
230
+ inputs: { delayMs: 3_000 },
231
+ });
232
+ expect(create.status).toBe(201);
233
+ const runId = (create.json as { runId: string }).runId;
234
+ await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
235
+
236
+ const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {
237
+ reason: 'conformance-drain',
238
+ drainPolicy: 'drain-current-node',
239
+ });
240
+ if (pause.status === 404) {
241
+ await cancel(runId);
242
+ return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
243
+ }
244
+ expect(pause.status, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
245
+ DOC_PAUSE,
246
+ ':pause with drainPolicy drain-current-node MUST return 202 (pause requested)',
247
+ )).toBe(202);
248
+
249
+ await pollUntilStatus(runId, 'paused', { timeoutMs: 20_000 });
250
+ const types = await eventTypes(runId);
251
+ const done = types.indexOf('node.completed');
252
+ const paused = types.indexOf('run.paused');
253
+ expect(done >= 0 && paused > done, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
254
+ DOC_PAUSE,
255
+ `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(', ')})`,
256
+ )).toBe(true);
257
+ await cancel(runId);
258
+ });
259
+ });
260
+
176
261
  describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
177
262
  it(':pause MUST NOT silently override an active interrupt suspend', async () => {
178
263
  // If the host seeds an approval fixture, drive a suspend then attempt
@@ -198,18 +283,15 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
198
283
  reason: 'race-test',
199
284
  });
200
285
  if (pause.status === 404) {
201
- // Cleanup.
202
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
203
- reason: 'conformance-cleanup',
204
- });
286
+ await cancel(runId);
205
287
  return softSkip('blocked', 'precondition not met — `pause.status === 404` returned early (seam, prior step, or fixture unavailable)');
206
288
  }
207
289
 
208
290
  // Either rejection (preferred) or stacked-pause is OK; silent override is not.
209
291
  if (pause.status === 409) {
210
292
  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',
293
+ expect(body.details?.runStatus, req('openwop.it.pause-resume.pause-must-not-silently-override-an-active-interrupt-suspend',
294
+ DOC_PAUSE,
213
295
  ':pause-during-suspend MUST surface the active waiting-* status in the conflict envelope',
214
296
  )).toMatch(/^waiting-/);
215
297
  } else {
@@ -220,13 +302,11 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
220
302
  const status = (snap.json as { status: string }).status;
221
303
  expect(
222
304
  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'),
305
+ 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
306
  ).toBe(true);
225
307
  }
226
308
 
227
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
228
- reason: 'conformance-cleanup',
229
- });
309
+ await cancel(runId);
230
310
  });
231
311
  });
232
312
 
@@ -249,7 +329,7 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
249
329
  for (const policy of drainPolicies) {
250
330
  const create = await driver.post('/v1/runs', {
251
331
  workflowId: FIXTURE!,
252
- inputs: { delaySeconds: 30 },
332
+ inputs: { delayMs: 30_000 },
253
333
  });
254
334
  expect(create.status).toBe(201);
255
335
  const runId = (create.json as { runId: string }).runId;
@@ -260,14 +340,12 @@ describe.skipIf(SKIP)('pause/resume: drainPolicy discrimination per capabilities
260
340
  reason: `conformance-drainpolicy-${policy}`,
261
341
  drainPolicy: policy,
262
342
  });
263
- expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
343
+ expect(pause.status, req('openwop.it.pause-resume.every-drainpolicy-advertised-by-the-host-is-accepted-on-pause',
264
344
  'capabilities.md §`runs.pauseResume.drainPolicies` + rest-endpoints.md POST /v1/runs/{runId}:pause',
265
345
  `host-advertised drainPolicy='${policy}' MUST be accepted on :pause`,
266
346
  )).toBe(202);
267
347
 
268
- await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
269
- reason: 'conformance-cleanup',
270
- });
348
+ await cancel(runId);
271
349
  }
272
350
  });
273
351
  });