@openwop/openwop-conformance 2.46.34 → 2.46.35
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 +22 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures.md +25 -0
- package/package.json +2 -2
- package/requirements.json +116 -4
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +32 -32
- package/src/lib/deployment-lifecycle-witness.ts +308 -0
- package/src/scenarios/v2-deployment-lifecycle.test.ts +526 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## [2.46.35] — 2026-10-29 — MyndHyve's legacy runtime retired
|
|
2
|
+
|
|
3
|
+
- **Registers.** MyndHyve's legacy `workflow-runtime` (`api.myndhyve.ai`) retired on 2026-10-29; MyndHyve runs on the openwop-app white-label. Register rows that measured the legacy host close or re-gate, so the packed `spec/v1/gaps.json` changes.
|
|
4
|
+
- **New scenario `v2-deployment-lifecycle` (RFC 0082 §B, §C, §E, §F).** It drives `core.conformance.channel-agent` through `listAgentDeployments` and `transitionAgentDeployment`, plus runs and a replay of the existing fixture `conformance-agent-channel-dispatch`, with no seam. Fourteen legs:
|
|
5
|
+
- `0082.list-valid`: the list answers 200 and every record validates against `agent-deployment.schema.json`.
|
|
6
|
+
- `0082.pin-recorded`: the run's `agent.invocation-started` carries `resolvedChannel: stable` and a version active on it.
|
|
7
|
+
- `0082.scope-fail-closed`: a promote sent with `OPENWOP_TEST_LOW_SCOPE_KEY` (no `deploy:*`) is refused `403 forbidden` and the record does not move. If the key is unset, the leg records `blocked`.
|
|
8
|
+
- `0082.transition-illegal-refused`: `pause` and `deprecate` of a draft are refused and the record does not move.
|
|
9
|
+
- `0082.state-unadvertised-refused`: an otherwise legal transition into a state missing from `states` is `400 validation_error`.
|
|
10
|
+
- `0082.transition-legal`: the promotion path answers 200 at each step with a valid record in the target state.
|
|
11
|
+
- `0082.replay-pin-reread`: after that promote moves `stable`, a replay of the first run still records its original version.
|
|
12
|
+
- `0082.canary-adjusted` (with `canary`) and `0082.canary-unsupported-refused` (without it): `adjust-canary` to 50.
|
|
13
|
+
- `0082.rollback-pointer` (with `rollback`): the version ends `rolled-back`, and its `rollbackPointer` names the restored version.
|
|
14
|
+
- **RFC 0254 rulings.** An illegal edge must be `409 deployment_state_conflict` with `details.state` and `details.transition`. Absent `states` means all seven, so the walk runs. `promote` moves one step over the advertised states, the first without `toState`. A rollback must restore the incumbent.
|
|
15
|
+
- **Scope challenge.** `0082.scope-fail-closed` also requires code `forbidden`, and a `WWW-Authenticate` challenge, when sent, must name `scope="deploy:*"`.
|
|
16
|
+
- **Four new legs.** `0082.promote-skip-refused`: a draft promoted straight to `active` gets the 409. `0082.resume-from-paused` (with `paused`): pause, then `promote` back to `active`. `0082.transition-run-linked`: every 200 record carries `transitionRunId`.
|
|
17
|
+
- **The management run.** `0082.transition-event-recorded`: the `transitionRunId` run's events include the transition's `deployment.*` event, valid against `run-event-payloads`. It is the first behavioural witness of `deployment-event-no-content-leak`.
|
|
18
|
+
- **Resume event.** A resume (`promote` of a paused version) must record `deployment.state.changed`; `deployment.promoted` is for forward steps only (RFC 0254 §A).
|
|
19
|
+
- **Judges.** They are in `lib/deployment-lifecycle-witness.ts`, with self-tests; every new or tightened leg is sabotage-proved on a scratch host.
|
|
20
|
+
- RFC 0253 (`Draft`) adds its gap register, which also lands in the packed `spec/v1/gaps.json`.
|
|
21
|
+
- **Host impact:** opt-in. The legs need `agents.deployment` and the fixture listed in `fixtures`. Listing it at major 2 states the `fixtures.md` preconditions: an incumbent `active` on `stable`, a `draft`, no gate, and `runs:read` on the management run. No host advertises the facet, so every leg is `inapplicable`.
|
|
22
|
+
|
|
1
23
|
## [2.46.34] — 2026-10-27 — RFC 0244 G3 closed
|
|
2
24
|
|
|
3
25
|
- **No assertion changes.** RFC 0244's G3 closes on openwop-app's certified cut `2c8bc27d6` (`0244.trial-memory-isolated` `executed-pass`), so the packed `spec/v1/gaps.json` changes.
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ Each scenario file records its disposition and assertion count in `<report-dir>/
|
|
|
137
137
|
|
|
138
138
|
## What's Covered
|
|
139
139
|
|
|
140
|
-
The current suite has
|
|
140
|
+
The current suite has 665 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 225 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
|
|
141
141
|
|
|
142
142
|
The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
|
|
143
143
|
|
|
@@ -166,7 +166,7 @@ Three kinds of scenario share the tree:
|
|
|
166
166
|
- **Host** checks drive a live host over HTTP.
|
|
167
167
|
- **Gated** checks run only when the host advertises the family, profile or fixture they need, and otherwise record `inapplicable` or `blocked` with a reason. `--require-behavior` turns an advertised-but-unwitnessable behaviour into a failure.
|
|
168
168
|
|
|
169
|
-
Current source tree:
|
|
169
|
+
Current source tree: 665 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
|
|
170
170
|
|
|
171
171
|
## How the 2.x suite is built
|
|
172
172
|
|
package/fixtures.md
CHANGED
|
@@ -723,6 +723,31 @@ Hosts that don't ship a BYOK SecretResolver MAY return `404` / `422` on the star
|
|
|
723
723
|
3. **Replay** via `POST /runs/{runId}:fork {mode:"replay"}`; **assert** the fork's `agent.invocation.started` re-reads the SAME `resolvedAgentVersion`.
|
|
724
724
|
4. **(Seam-guarded)** Move the `stable` channel via the deployment seam; **assert** a replay of the original run STILL carries the original pin — never re-resolving the moved channel.
|
|
725
725
|
|
|
726
|
+
### At major 2: the deployment lifecycle (`v2-deployment-lifecycle`)
|
|
727
|
+
|
|
728
|
+
At major 2, `v2-deployment-lifecycle.test.ts` drives `core.conformance.channel-agent` through `listAgentDeployments` and `transitionAgentDeployment` (`GET` / `POST /agents/core.conformance.channel-agent/deployments`), under the transition rules RFC 0254 adds to the `agents.deployment` facet. It moves the `stable` channel itself, so it needs no seam. A host that advertises `agents.deployment` and lists `conformance-agent-channel-dispatch` in discovery `fixtures` states that, at the start of every cut:
|
|
729
|
+
|
|
730
|
+
- it holds exactly one record of `core.conformance.channel-agent` that is `active` on `stable`, at `canaryPercent` 100 or absent (the incumbent);
|
|
731
|
+
- it holds at least one `draft` record of that agent (the candidate);
|
|
732
|
+
- both are readable by the conformance credential at the `GET` above, and that credential holds the `deploy:*` scope and `runs:read`;
|
|
733
|
+
- no approval gate and no `requiredEval` applies to that credential's transitions of this agent, so a legal transition answers `200` with the updated record, never `202`;
|
|
734
|
+
- every `200` record carries `transitionRunId`, and the conformance credential can read that run through `GET /runs/{runId}/events/poll`; the run's events include the transition's `deployment.*` event;
|
|
735
|
+
- the run's `agent.invocation-started` carries `resolvedChannel` and `resolvedAgentVersion`, as above.
|
|
736
|
+
|
|
737
|
+
The suite, in order:
|
|
738
|
+
|
|
739
|
+
1. sends refused transitions, each of which must leave the records unchanged: a promote with `OPENWOP_TEST_LOW_SCOPE_KEY`, `pause` and `deprecate` of the candidate, a promote of the candidate with `toState: "active"`, and on a host advertising a subset of `states`, one transition into an unadvertised state;
|
|
740
|
+
2. promotes the candidate one step at a time over the advertised states to `active`, the last step with `channel: "stable"` and `canaryPercent: 100`; the first step omits `toState`;
|
|
741
|
+
3. runs this fixture again and replays the first run; a run started after that promote must resolve `stable` to the candidate, or the replay leg records `blocked`;
|
|
742
|
+
4. adjusts the candidate's canary share to 50, then rolls it back, which must restore the incumbent to `active`;
|
|
743
|
+
5. with `paused` advertised, pauses the version then `active` on `stable` (the incumbent after a rollback) and promotes it back to `active`.
|
|
744
|
+
|
|
745
|
+
A refused transition that the host applies instead moves the candidate, and later legs then record `blocked`.
|
|
746
|
+
|
|
747
|
+
**Restoring the preconditions between cuts.** A clean cut leaves the candidate `rolled-back` (or `active` on a host without `rollback`) and the incumbent `active` on `stable`. Before the next cut the host restores both preconditions, for example by minting a fresh `draft` version and resetting the incumbent to the only `active` record on `stable`. The suite does not reset them. A host whose records do not meet the preconditions records `blocked`, never a failure.
|
|
748
|
+
|
|
749
|
+
The scope leg sends a promote with `OPENWOP_TEST_LOW_SCOPE_KEY`, a key with no `deploy:*` scope. It expects `403` with code `forbidden`; a `WWW-Authenticate` challenge, if sent, names `scope="deploy:*"`. If that variable is unset, the leg records `blocked`.
|
|
750
|
+
|
|
726
751
|
---
|
|
727
752
|
|
|
728
753
|
## `conformance-artifact-emit` (RFC 0205 — an artifact the suite can read back)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.46.
|
|
3
|
+
"version": "2.46.35",
|
|
4
4
|
"description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
|
|
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.46.
|
|
59
|
+
"@openwop/spec-artifacts": "2.46.35"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 753,
|
|
6
|
+
"tests": 2877,
|
|
7
|
+
"withStableId": 2877,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2750
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -30198,6 +30198,118 @@
|
|
|
30198
30198
|
"explicitId": "openwop.requirement.0082.eval-gate-unmet-unchanged",
|
|
30199
30199
|
"citations": []
|
|
30200
30200
|
},
|
|
30201
|
+
{
|
|
30202
|
+
"id": "openwop.it.v2-deployment-lifecycle.lists-valid-deployment-records-of-the-fixture-agent",
|
|
30203
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30204
|
+
"line": 379,
|
|
30205
|
+
"title": "lists valid deployment records of the fixture agent",
|
|
30206
|
+
"explicitId": "openwop.requirement.0082.list-valid",
|
|
30207
|
+
"citations": []
|
|
30208
|
+
},
|
|
30209
|
+
{
|
|
30210
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-channel-bound-run-records-the-channel-and-the-version-it-resolved",
|
|
30211
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30212
|
+
"line": 386,
|
|
30213
|
+
"title": "a channel-bound run records the channel and the version it resolved",
|
|
30214
|
+
"explicitId": "openwop.requirement.0082.pin-recorded",
|
|
30215
|
+
"citations": []
|
|
30216
|
+
},
|
|
30217
|
+
{
|
|
30218
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-transition-without-the-deploy-scope-is-refused-403-forbidden-and-moves-nothing",
|
|
30219
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30220
|
+
"line": 394,
|
|
30221
|
+
"title": "a transition without the deploy:* scope is refused 403 forbidden and moves nothing",
|
|
30222
|
+
"explicitId": "openwop.requirement.0082.scope-fail-closed",
|
|
30223
|
+
"citations": []
|
|
30224
|
+
},
|
|
30225
|
+
{
|
|
30226
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-pause-or-deprecate-of-a-draft-is-409-deployment-state-conflict-and-moves-nothi",
|
|
30227
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30228
|
+
"line": 403,
|
|
30229
|
+
"title": "a pause or deprecate of a draft is 409 deployment_state_conflict and moves nothing",
|
|
30230
|
+
"explicitId": "openwop.requirement.0082.transition-illegal-refused",
|
|
30231
|
+
"citations": []
|
|
30232
|
+
},
|
|
30233
|
+
{
|
|
30234
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-promote-of-a-draft-straight-to-active-is-409-deployment-state-conflict-and-mov",
|
|
30235
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30236
|
+
"line": 412,
|
|
30237
|
+
"title": "a promote of a draft straight to active is 409 deployment_state_conflict and moves nothing",
|
|
30238
|
+
"explicitId": "openwop.requirement.0082.promote-skip-refused",
|
|
30239
|
+
"citations": []
|
|
30240
|
+
},
|
|
30241
|
+
{
|
|
30242
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-transition-into-a-state-the-host-does-not-advertise-is-a-validation-error",
|
|
30243
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30244
|
+
"line": 424,
|
|
30245
|
+
"title": "a transition into a state the host does not advertise is a validation_error",
|
|
30246
|
+
"explicitId": "openwop.requirement.0082.state-unadvertised-refused",
|
|
30247
|
+
"citations": []
|
|
30248
|
+
},
|
|
30249
|
+
{
|
|
30250
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-draft-promotes-one-step-at-a-time-over-the-advertised-states-to-active-on-a-ch",
|
|
30251
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30252
|
+
"line": 437,
|
|
30253
|
+
"title": "a draft promotes one step at a time over the advertised states to active on a channel",
|
|
30254
|
+
"explicitId": "openwop.requirement.0082.transition-legal",
|
|
30255
|
+
"citations": []
|
|
30256
|
+
},
|
|
30257
|
+
{
|
|
30258
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-replay-re-reads-the-recorded-version-after-the-channel-moved",
|
|
30259
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30260
|
+
"line": 453,
|
|
30261
|
+
"title": "a replay re-reads the recorded version after the channel moved",
|
|
30262
|
+
"explicitId": "openwop.requirement.0082.replay-pin-reread",
|
|
30263
|
+
"citations": []
|
|
30264
|
+
},
|
|
30265
|
+
{
|
|
30266
|
+
"id": "openwop.it.v2-deployment-lifecycle.adjust-canary-sets-the-share-on-a-canary-host",
|
|
30267
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30268
|
+
"line": 464,
|
|
30269
|
+
"title": "adjust-canary sets the share on a canary host",
|
|
30270
|
+
"explicitId": "openwop.requirement.0082.canary-adjusted",
|
|
30271
|
+
"citations": []
|
|
30272
|
+
},
|
|
30273
|
+
{
|
|
30274
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-host-without-canary-refuses-a-share-below-100",
|
|
30275
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30276
|
+
"line": 476,
|
|
30277
|
+
"title": "a host without canary refuses a share below 100",
|
|
30278
|
+
"explicitId": "openwop.requirement.0082.canary-unsupported-refused",
|
|
30279
|
+
"citations": []
|
|
30280
|
+
},
|
|
30281
|
+
{
|
|
30282
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-rollback-restores-the-incumbent-and-points-at-it",
|
|
30283
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30284
|
+
"line": 488,
|
|
30285
|
+
"title": "a rollback restores the incumbent and points at it",
|
|
30286
|
+
"explicitId": "openwop.requirement.0082.rollback-pointer",
|
|
30287
|
+
"citations": []
|
|
30288
|
+
},
|
|
30289
|
+
{
|
|
30290
|
+
"id": "openwop.it.v2-deployment-lifecycle.a-paused-version-resumes-to-active-by-promote",
|
|
30291
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30292
|
+
"line": 500,
|
|
30293
|
+
"title": "a paused version resumes to active by promote",
|
|
30294
|
+
"explicitId": "openwop.requirement.0082.resume-from-paused",
|
|
30295
|
+
"citations": []
|
|
30296
|
+
},
|
|
30297
|
+
{
|
|
30298
|
+
"id": "openwop.it.v2-deployment-lifecycle.every-accepted-transition-names-its-deployment-management-run",
|
|
30299
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30300
|
+
"line": 509,
|
|
30301
|
+
"title": "every accepted transition names its deployment-management run",
|
|
30302
|
+
"explicitId": "openwop.requirement.0082.transition-run-linked",
|
|
30303
|
+
"citations": []
|
|
30304
|
+
},
|
|
30305
|
+
{
|
|
30306
|
+
"id": "openwop.it.v2-deployment-lifecycle.the-management-run-records-the-transition-s-content-free-deployment-event",
|
|
30307
|
+
"file": "v2-deployment-lifecycle.test.ts",
|
|
30308
|
+
"line": 517,
|
|
30309
|
+
"title": "the management run records the transition's content-free deployment event",
|
|
30310
|
+
"explicitId": "openwop.requirement.0082.transition-event-recorded",
|
|
30311
|
+
"citations": []
|
|
30312
|
+
},
|
|
30201
30313
|
{
|
|
30202
30314
|
"id": "openwop.it.v2-discovery-etag.the-discovery-document-carries-an-etag",
|
|
30203
30315
|
"file": "v2-discovery-etag.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 665,
|
|
5
5
|
"v1": 455,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 225
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1428,6 +1428,9 @@
|
|
|
1428
1428
|
"v2-deployment-eval-gate.test.ts": [
|
|
1429
1429
|
2
|
|
1430
1430
|
],
|
|
1431
|
+
"v2-deployment-lifecycle.test.ts": [
|
|
1432
|
+
2
|
|
1433
|
+
],
|
|
1431
1434
|
"v2-discovery-etag.test.ts": [
|
|
1432
1435
|
2
|
|
1433
1436
|
],
|
|
@@ -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.46.
|
|
5
|
-
"corpusTag": "v2.46.
|
|
4
|
+
"version": "2.46.35",
|
|
5
|
+
"corpusTag": "v2.46.35",
|
|
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": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
|
|
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": "9e0e957200bc2f2eab4da5d2ffde610468cbaa47386c503e19d94a2d6b7f078e",
|
|
13
|
+
"api/v2/asyncapi.yaml": "11128d5b4632d020ce4b5aa8dd6178d56ac02d4ebb60a9163533e8d263eb7feb",
|
|
14
|
+
"api/v2/openapi.yaml": "454b15732dcab236e8027618c5ffe7985248005156a35b4d08125301b2b597e6",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -101,8 +101,8 @@
|
|
|
101
101
|
"schemas/ui-plugin-message.schema.json": "346638babf1f8ea7b161cde75c0038b518cd2cc5bf0631772e20a9b74c96b5dd",
|
|
102
102
|
"schemas/v2/a2a-task-state.schema.json": "1810363e2d9bddb5997923bdac4044cc17c6f46ec5a40515d2ff21d9c82c1ad9",
|
|
103
103
|
"schemas/v2/a2ui-surface-delta-frame.schema.json": "6889c5da3fe7e76cb614038c0eea6774776fa640448558b258eb87fb50cb9b64",
|
|
104
|
-
"schemas/v2/agent-deployment-transition.schema.json": "
|
|
105
|
-
"schemas/v2/agent-deployment.schema.json": "
|
|
104
|
+
"schemas/v2/agent-deployment-transition.schema.json": "fe2d56f9d8ccb5503c94f1f4dff8af5a7286cc304802b089d42d665ddefcf111",
|
|
105
|
+
"schemas/v2/agent-deployment.schema.json": "64d5f2848419b6ff797bcf34c0e5f4b05a612c3237d2cf0a034fb41f8b8ebc6b",
|
|
106
106
|
"schemas/v2/agent-eval-suite.schema.json": "a49ad7a232ec781512375001f8d8c70ab073b69ca094f5251dbb30a0bbd972a7",
|
|
107
107
|
"schemas/v2/agent-inventory-response.schema.json": "5f74c3da946dfc3114624567a989717748081ab947b4d2af3f49034d67f4306b",
|
|
108
108
|
"schemas/v2/agent-manifest.schema.json": "004ff150e955deebb9e64273de5093f5144b9b3895b09953e65aa74b61140778",
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
"schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
|
|
118
118
|
"schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
|
|
119
119
|
"schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
|
|
120
|
-
"schemas/v2/capabilities.schema.json": "
|
|
120
|
+
"schemas/v2/capabilities.schema.json": "3c494e0914f766209017bf1d876852c3e4daa9f0d7e7e840f6cad23ad00d769d",
|
|
121
121
|
"schemas/v2/certification-bundle.schema.json": "5cfa7fb5247160333e33a58b60d42bcd4e5269d8391d6a112d301e14828892ed",
|
|
122
122
|
"schemas/v2/channel-presence-payload.schema.json": "fc03df0c20cc12a25f7ee6a47503a4c5931d79fe3d5ff24815e8fd7c1bcb9a87",
|
|
123
123
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"schemas/v2/envelopes/schema.request.schema.json": "be2c5f7aa881cdd252ccff47ac02193659e1393633a1541f4297383205d384a2",
|
|
145
145
|
"schemas/v2/envelopes/schema.response.schema.json": "d049a081267c06c28879c331b147f3ed821b14ccfd93005c67aff64db0a8c68e",
|
|
146
146
|
"schemas/v2/envelopes/ui.a2ui-surface.schema.json": "e92a2548ba65454c9e0a34f72ea6768d18f59e61fa5da7668d0b2d5baddaddc8",
|
|
147
|
-
"schemas/v2/error-envelope.schema.json": "
|
|
147
|
+
"schemas/v2/error-envelope.schema.json": "0e9fe379d2f37d928f247dcca89e7b946a8b90b61a68efa5f3e73deb7d1237db",
|
|
148
148
|
"schemas/v2/eval-summary.schema.json": "d07f4d116c5410997a81d0bc5387545fb61933a6333c360a26ab8a71c749336b",
|
|
149
149
|
"schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
|
|
150
150
|
"schemas/v2/form-content-pack-manifest.schema.json": "a1942a124e250a6e9fbdb6b811c5a125e78fbcbcaa46dfc71d0636f24710a339",
|
|
@@ -212,14 +212,14 @@
|
|
|
212
212
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
213
213
|
"spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
|
|
214
214
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
215
|
-
"spec/v1/core-standard-manifest.json": "
|
|
215
|
+
"spec/v1/core-standard-manifest.json": "e64402d125ca8da121b7595b1dbaa580d72cd915e45b80b79d309c38a492c075",
|
|
216
216
|
"spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
|
|
217
217
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
218
218
|
"spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
|
|
219
219
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
220
220
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
221
221
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
222
|
-
"spec/v1/gaps.json": "
|
|
222
|
+
"spec/v1/gaps.json": "1c495d62d144e2ce1b0e9282df38c92cdd96015c55a986a047aa8a1c9612d6ac",
|
|
223
223
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
224
224
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
225
225
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -231,7 +231,7 @@
|
|
|
231
231
|
"spec/v2/core/conformance.md": "3ca291e63bc70a6ef829ae8759ad0ded3741ea7aa507bbbdffc57de443ab2ae2",
|
|
232
232
|
"spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
|
|
233
233
|
"spec/v2/core/conversation.md": "8d6abedf6af408a74577753bfca24719d318e2632e10b45ff5d63bdcf31a8aaa",
|
|
234
|
-
"spec/v2/core/errors.md": "
|
|
234
|
+
"spec/v2/core/errors.md": "53b63a7cfafb9801614fad4ecfa1e2859cff4d142639d7bf6eab5027ed392b74",
|
|
235
235
|
"spec/v2/core/events.md": "fd1d95f77565b0b0a747544f4f2126bd02ae4f7cf837ead1da4d89e580b7f694",
|
|
236
236
|
"spec/v2/core/execution.md": "4218dbb397534940f83ca5d46561f02bf8f3fdf68d2ae7fefe7f2eb2ed771103",
|
|
237
237
|
"spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
|
|
@@ -261,31 +261,31 @@
|
|
|
261
261
|
"spec/v2/declaration.json": "c914e17b8be18ffd186dbd63556c5a06816562b949029413ba46f556e1cd0d54",
|
|
262
262
|
"spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
|
|
263
263
|
"spec/v2/eos-override.json": "4c72d2f6a658943b01c1de1643cf294642091933af7832f38ee5495df684cb2d",
|
|
264
|
-
"spec/v2/errors.json": "
|
|
264
|
+
"spec/v2/errors.json": "98be280804a0efaa279243cab79edb657f3f262829ee53f10e01d005c32fea41",
|
|
265
265
|
"spec/v2/errors.schema.json": "47404ad53e6147cf9017b813483712466573e419fb33e6c4b6c12d3e7deba1bc",
|
|
266
266
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
267
267
|
"spec/v2/event-codemap.schema.json": "b173f2a9bcc0bf9b62a474ce3b91e1431d597e64fd1560fc58662cd8606eaf9c",
|
|
268
|
-
"spec/v2/ext/README.md": "
|
|
268
|
+
"spec/v2/ext/README.md": "3f3b5f09f961b65514cea6880c303470d4b133a3615c734ad7f3c28ba2da8117",
|
|
269
269
|
"spec/v2/ext/a2uiSurface/README.md": "5518c7f355262eb7cc584422f4ff8e548f066b211878f969274a3a578127622d",
|
|
270
|
-
"spec/v2/ext/brand/README.md": "
|
|
271
|
-
"spec/v2/ext/canvas/README.md": "
|
|
272
|
-
"spec/v2/ext/chat/README.md": "
|
|
273
|
-
"spec/v2/ext/coordination/README.md": "
|
|
274
|
-
"spec/v2/ext/dataIntegration/README.md": "
|
|
275
|
-
"spec/v2/ext/entities/README.md": "
|
|
270
|
+
"spec/v2/ext/brand/README.md": "aa64eeaa7d091110ff4378842e2a72eaa19feed0016a41fbce5ffb7d9959b4a0",
|
|
271
|
+
"spec/v2/ext/canvas/README.md": "6829ee68ea4fec4dc97990a13289155fd5bb3940aeb4d3a98301c06cee763e51",
|
|
272
|
+
"spec/v2/ext/chat/README.md": "6bcd56f7927f635853c6e6ffc67386ad6cb2f80c7d954197467ba34b3db11b96",
|
|
273
|
+
"spec/v2/ext/coordination/README.md": "13d5a9f64ee3edd6ac532785d18c461c1f0eac6f4ba5a548644df05ab3898592",
|
|
274
|
+
"spec/v2/ext/dataIntegration/README.md": "4a3a03815bfba5fa59356e27b8a0b4feb1622a9e23f2d4f9b2792182851fe894",
|
|
275
|
+
"spec/v2/ext/entities/README.md": "899baab987333273609d58d7a2f01fc14967bcd1b1b8748c3eeaad1da3f3d40e",
|
|
276
276
|
"spec/v2/ext/grpc-transport/README.md": "6ba378e3c6d1f2e3269b392cfdf0c40565ee9bfa259d1baaa9ee1d350e38e792",
|
|
277
|
-
"spec/v2/ext/kanban/README.md": "
|
|
278
|
-
"spec/v2/ext/knowledge/README.md": "
|
|
279
|
-
"spec/v2/ext/launchStudio/README.md": "
|
|
280
|
-
"spec/v2/ext/messaging/README.md": "
|
|
277
|
+
"spec/v2/ext/kanban/README.md": "1b9f7dfc2171bedf71a94a26f9f5c8d0f4ee190912a2ac0c19256b08cff1a4d8",
|
|
278
|
+
"spec/v2/ext/knowledge/README.md": "bec06e526730142425cb9cf04d977e5c95b2b69f913b87d4399483490b75ae76",
|
|
279
|
+
"spec/v2/ext/launchStudio/README.md": "2654738ee36acb3451f3180b9019c7c625857aa9eaa9d559dd0f44dbad38d4e5",
|
|
280
|
+
"spec/v2/ext/messaging/README.md": "4ad1071e853733e4a39247375b0c323ecd9b530b139d488a73ccda012d60ff6c",
|
|
281
281
|
"spec/v2/ext/portability/README.md": "c59423ee446638b748a911b9e97e803c4786157be6120f656f0a060586bc7b82",
|
|
282
282
|
"spec/v2/ext/provider-idempotency/README.md": "9d1bccef0ec16a19a3370ae79c2f5a1726ce0d1d25a51352907f288a2ccb15d7",
|
|
283
283
|
"spec/v2/ext/provider-idempotency/registry.json": "5b5fecad604fb4ab38da39f5abe697a4e57b05406c398f8afeb377331b2bd965",
|
|
284
|
-
"spec/v2/ext/restTransport/README.md": "
|
|
284
|
+
"spec/v2/ext/restTransport/README.md": "a301c4eaa36022ee9257a9176adbfd7ffa0d28f1dcaa0a8fec85ebf30e55a5d5",
|
|
285
285
|
"spec/v2/ext/sandbox-runtime-notes/README.md": "0d1290fb1e20b2e35c0b15051541ab6546e4e2a6f693b4f912cb3f7d6a65e92c",
|
|
286
|
-
"spec/v2/ext/webResearch/README.md": "
|
|
286
|
+
"spec/v2/ext/webResearch/README.md": "2771686719161b811c2a50361815a334c58f09e442528fb8e23e4fcdcd42d630",
|
|
287
287
|
"spec/v2/facets/a2a.schema.json": "b68bf5e29e455e46c2ad7cfcccf5963730ec2310ff9db81884550d8df30fd6ef",
|
|
288
|
-
"spec/v2/facets/agents.schema.json": "
|
|
288
|
+
"spec/v2/facets/agents.schema.json": "cc9abae19f500b8a2c6f2b3494284f6f9bb782cde7eec13e4f2f5363a1b44ad5",
|
|
289
289
|
"spec/v2/facets/aiProviders.schema.json": "6e4327bcd48110686cc1adfd26ddd6fbbf4ff529c476dcc74eeff8c5ae9965ce",
|
|
290
290
|
"spec/v2/facets/auditLogIntegrity.schema.json": "9a7f81320c8f940c6f4129bc7dd895a4063231c5458a717a08c2cbf9bb886c46",
|
|
291
291
|
"spec/v2/facets/auth.schema.json": "7d98bc81a0f7d307460d039eebfebd21388091bfe1d538762583940f94c0bd2a",
|
|
@@ -307,10 +307,10 @@
|
|
|
307
307
|
"spec/v2/facets/uiPlugins.schema.json": "1fd3f8440cfb6e4422553a7c4b5f091eb3e10a40da0d43e8b977a18929116758",
|
|
308
308
|
"spec/v2/facets/webhooks.schema.json": "54b576f1d150e28e1642693d846ce471c118e9c521bdf3924185305fbd1d0de4",
|
|
309
309
|
"spec/v2/facets/workflowChainPacks.schema.json": "ea625ce818fbeb746478f00f31f96ee9613d538e4409c258c1bfb517acdeaba9",
|
|
310
|
-
"spec/v2/generated/error-codes.md": "
|
|
310
|
+
"spec/v2/generated/error-codes.md": "a184067ea983f7398001b7461732767e0773d7c8a820fc769efa7e4e858cc943",
|
|
311
311
|
"spec/v2/harness-trust-anchors.json": "6007b92eae16a070fbc3777424efb0abfe865fda89d096963109b96282b91205",
|
|
312
312
|
"spec/v2/harness-trust-anchors.schema.json": "372b5d92337c893d3449eff3f6bfd009ac09fdac285296d8bdbe0a08fff9e34c",
|
|
313
|
-
"spec/v2/id-field-bindings.json": "
|
|
313
|
+
"spec/v2/id-field-bindings.json": "a7f5d03f5684c02d5792cc52bb1d310c123840e338a499aec4e9b82faf4d3708",
|
|
314
314
|
"spec/v2/interop-map.json": "02255dafe7eb55f61ca7f9ae7c21f079d1848a6756991343088975ac9d184b8d",
|
|
315
315
|
"spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
|
|
316
316
|
"spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
|
|
@@ -318,9 +318,9 @@
|
|
|
318
318
|
"spec/v2/path-manifest.json": "20f598f56df460ad3ed50aba3d6bc2933ea16a3c96ce125da1c5dd6989d4155c",
|
|
319
319
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
320
320
|
"spec/v2/profiles.json": "b382b261c7844798575f7c1b37c934f0da19846016a2337d883d4f5ef6e05479",
|
|
321
|
-
"spec/v2/release.json": "
|
|
321
|
+
"spec/v2/release.json": "28c53661beb7a649be7c474d495832d8920cdae8d1c17f9022fa14f11421d61b",
|
|
322
322
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
323
|
-
"spec/v2/surface-baseline.json": "
|
|
323
|
+
"spec/v2/surface-baseline.json": "9b2275c5649b410381590509f1a60b827f8d1915a429e05c0318e1f473851829"
|
|
324
324
|
},
|
|
325
|
-
"corpusCommit": "
|
|
325
|
+
"corpusCommit": "cddc595c6fcea6ff052ff1cfa199293737e04d53"
|
|
326
326
|
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0082 at major 2: the deployment lifecycle. Pure judges for
|
|
3
|
+
* `v2-deployment-lifecycle.test.ts`, over answers from `listAgentDeployments`
|
|
4
|
+
* and `transitionAgentDeployment` and over the event logs of the
|
|
5
|
+
* `conformance-agent-channel-dispatch` fixture's runs.
|
|
6
|
+
*
|
|
7
|
+
* The rules are the `agents.deployment` facet description in
|
|
8
|
+
* `spec/v2/facets/agents.schema.json`, `schemas/v2/agent-deployment.schema.json`
|
|
9
|
+
* (the record and its legal transitions), `agent-deployment-transition.schema.json`
|
|
10
|
+
* and the two operations in `api/v2/openapi.yaml`, with the transition rules
|
|
11
|
+
* RFC 0254 adds to that facet text. Each judge returns findings and asserts
|
|
12
|
+
* nothing; the scenario maps them to `req()` ids.
|
|
13
|
+
*
|
|
14
|
+
* @see RFCS/0082-agent-deployment-lifecycle.md §B, §C, §E, §F
|
|
15
|
+
* @see RFCS/0254-deployment-transitions-at-v2.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readErrorCode } from './error-envelope.js';
|
|
19
|
+
|
|
20
|
+
export interface DeployFinding { readonly ok: boolean; readonly message: string }
|
|
21
|
+
export interface Answer {
|
|
22
|
+
readonly status: number;
|
|
23
|
+
readonly json: unknown;
|
|
24
|
+
/** The `WWW-Authenticate` header, when the answer carried one. */
|
|
25
|
+
readonly wwwAuthenticate?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
export type Validate = (doc: unknown) => { ok: boolean; errors: string };
|
|
28
|
+
type Rec = Record<string, unknown>;
|
|
29
|
+
const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
30
|
+
const f = (ok: boolean, message: string): DeployFinding => ({ ok, message });
|
|
31
|
+
const shown = (a: Answer | null): string => (a === null ? 'no answer' : `${a.status} ${readErrorCode(a.json) ?? ''}`.trim());
|
|
32
|
+
|
|
33
|
+
/** The forward promotion path `agent-deployment.schema.json` names. */
|
|
34
|
+
export const PROMOTION_PATH = ['draft', 'test', 'staged', 'active'] as const;
|
|
35
|
+
export const ALL_STATES = ['draft', 'test', 'staged', 'active', 'paused', 'deprecated', 'rolled-back'] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The promotion path over the advertised states: absent `states` means all
|
|
39
|
+
* seven, and over a subset `promote` moves to the next advertised state in
|
|
40
|
+
* draft→test→staged→active order (RFC 0254).
|
|
41
|
+
*/
|
|
42
|
+
export function promotionPath(states: readonly string[] | null): string[] {
|
|
43
|
+
return PROMOTION_PATH.filter((s) => states === null || states.includes(s));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `details` of an error body: the canonical root `details`, else the legacy nested `error.details`. */
|
|
47
|
+
export function errorDetails(body: unknown): Rec {
|
|
48
|
+
if (!isRec(body)) return {};
|
|
49
|
+
if (isRec(body['details'])) return body['details'];
|
|
50
|
+
return isRec(body['error']) && isRec(body['error']['details']) ? body['error']['details'] : {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The record for `version` in a list body, or undefined. */
|
|
54
|
+
export function recordOf(list: unknown, version: string): Rec | undefined {
|
|
55
|
+
return Array.isArray(list) ? list.find((r): r is Rec => isRec(r) && r['version'] === version) : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The versions listed `active` with `channel` in `channels`. */
|
|
59
|
+
export function activeOn(list: unknown, channel: string): string[] {
|
|
60
|
+
if (!Array.isArray(list)) return [];
|
|
61
|
+
return list.filter((r): r is Rec => isRec(r) && r['state'] === 'active' && Array.isArray(r['channels']) && (r['channels'] as unknown[]).includes(channel) && typeof r['version'] === 'string')
|
|
62
|
+
.map((r) => r['version'] as string);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The first `draft` record's version, or undefined. */
|
|
66
|
+
export function pickDraft(list: unknown): string | undefined {
|
|
67
|
+
if (!Array.isArray(list)) return undefined;
|
|
68
|
+
const r = list.find((x): x is Rec => isRec(x) && x['state'] === 'draft' && typeof x['version'] === 'string');
|
|
69
|
+
return r === undefined ? undefined : (r['version'] as string);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `listAgentDeployments` answers 200 with an array of valid records of the agent. */
|
|
73
|
+
export function judgeList(a: Answer | null, agentId: string, validate: Validate): DeployFinding[] {
|
|
74
|
+
if (a === null || a.status !== 200) return [f(false, `GET /agents/{agentId}/deployments MUST answer 200 for an advertised facet (got ${shown(a)})`)];
|
|
75
|
+
if (!Array.isArray(a.json)) return [f(false, 'the 200 body MUST be an array of deployment records')];
|
|
76
|
+
const out: DeployFinding[] = [f(a.json.length > 0, 'the fixture agent MUST have at least one listed deployment record')];
|
|
77
|
+
a.json.forEach((r, i) => {
|
|
78
|
+
const v = validate(r);
|
|
79
|
+
out.push(f(v.ok, `record [${i}] MUST validate against agent-deployment.schema.json (${v.errors})`));
|
|
80
|
+
out.push(f(isRec(r) && r['agentId'] === agentId, `record [${i}] MUST belong to ${agentId} (got ${JSON.stringify(isRec(r) ? r['agentId'] : r)})`));
|
|
81
|
+
});
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const KEPT = ['state', 'channels', 'canaryPercent', 'rollbackPointer'] as const;
|
|
86
|
+
function unchanged(before: Rec, after: unknown, why: string): DeployFinding[] {
|
|
87
|
+
const version = String(before['version']);
|
|
88
|
+
const now = recordOf(after, version);
|
|
89
|
+
if (now === undefined) return [f(false, `the record for ${version} MUST still be listed after ${why}`)];
|
|
90
|
+
return KEPT.map((k) => f(JSON.stringify(before[k]) === JSON.stringify(now[k]), `${why} MUST NOT change ${k} (was ${JSON.stringify(before[k])}, now ${JSON.stringify(now[k])})`));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A transition the record's state allows no edge for, or a `promote` past the
|
|
95
|
+
* next state: `409 deployment_state_conflict` naming the current state and the
|
|
96
|
+
* transition, and the record does not move (RFC 0254).
|
|
97
|
+
*/
|
|
98
|
+
export function judgeStateConflict(label: string, transition: string, a: Answer | null, before: Rec, after: unknown): DeployFinding[] {
|
|
99
|
+
const d = errorDetails(a?.json);
|
|
100
|
+
return [
|
|
101
|
+
f(a?.status === 409, `${label} is not a legal edge from ${JSON.stringify(before['state'])} and MUST be refused 409 (got ${shown(a)})`),
|
|
102
|
+
f(readErrorCode(a?.json) === 'deployment_state_conflict', `${label} MUST carry deployment_state_conflict (got ${JSON.stringify(readErrorCode(a?.json))})`),
|
|
103
|
+
f(d['state'] === before['state'], `details.state MUST be the record's current state ${JSON.stringify(before['state'])} (got ${JSON.stringify(d['state'])})`),
|
|
104
|
+
f(d['transition'] === transition, `details.transition MUST name the refused transition ${JSON.stringify(transition)} (got ${JSON.stringify(d['transition'])})`),
|
|
105
|
+
...unchanged(before, after, `the refused ${label}`),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The scopes a `WWW-Authenticate` challenge names in `scope="…"`, or undefined when it names none. */
|
|
110
|
+
export function challengeScopes(header: string): string[] | undefined {
|
|
111
|
+
const m = /\bscope="([^"]*)"/i.exec(header);
|
|
112
|
+
return m === null ? undefined : (m[1] ?? '').split(/\s+/).filter((x) => x.length > 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A transition by a principal without `deploy:*` is refused `403 forbidden`
|
|
117
|
+
* and moves nothing. `deploy:*` is one literal scope: a `WWW-Authenticate`
|
|
118
|
+
* challenge, when present, names it in `scope=` (RFC 0254).
|
|
119
|
+
*/
|
|
120
|
+
export function judgeScopeRefused(a: Answer | null, before: Rec, after: unknown): DeployFinding[] {
|
|
121
|
+
const out = [
|
|
122
|
+
f(a?.status === 403, `a transition by a principal without the deploy:* scope MUST be refused 403, fail-closed (got ${shown(a)})`),
|
|
123
|
+
f(readErrorCode(a?.json) === 'forbidden', `the 403 MUST carry forbidden (got ${JSON.stringify(readErrorCode(a?.json))})`),
|
|
124
|
+
];
|
|
125
|
+
const h = a?.wwwAuthenticate;
|
|
126
|
+
if (typeof h === 'string' && h.length > 0) {
|
|
127
|
+
const scopes = challengeScopes(h);
|
|
128
|
+
out.push(f(scopes !== undefined && scopes.includes('deploy:*'), `a WWW-Authenticate challenge on the 403 MUST name scope="deploy:*" (got ${JSON.stringify(h)})`));
|
|
129
|
+
}
|
|
130
|
+
return [...out, ...unchanged(before, after, 'the unauthorized transition')];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `400 validation_error`, the record unchanged: a transition the advertised `states` or `canary` cannot satisfy. */
|
|
134
|
+
export function judgeValidationRefused(label: string, a: Answer | null, before: Rec, after: unknown): DeployFinding[] {
|
|
135
|
+
return [
|
|
136
|
+
f(a?.status === 400, `${label} MUST be refused 400 (got ${shown(a)})`),
|
|
137
|
+
f(readErrorCode(a?.json) === 'validation_error', `${label} MUST carry validation_error (got ${JSON.stringify(readErrorCode(a?.json))})`),
|
|
138
|
+
...unchanged(before, after, `the refused ${label}`),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One legal step answers 200 with the valid record in the target state. */
|
|
143
|
+
export function judgeStep(a: Answer | null, version: string, toState: string, validate: Validate): DeployFinding[] {
|
|
144
|
+
if (a === null || a.status !== 200) return [f(false, `promote ${version} to ${toState} is a legal transition and MUST answer 200 (got ${shown(a)})`)];
|
|
145
|
+
const v = validate(a.json);
|
|
146
|
+
const r = isRec(a.json) ? a.json : {};
|
|
147
|
+
return [
|
|
148
|
+
f(v.ok, `the record returned for ${toState} MUST validate against agent-deployment.schema.json (${v.errors})`),
|
|
149
|
+
f(r['version'] === version, `the returned record MUST be version ${version} (got ${JSON.stringify(r['version'])})`),
|
|
150
|
+
f(r['state'] === toState, `the returned record MUST be in ${toState} (got ${JSON.stringify(r['state'])})`),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** After the walk the list shows the version `active` on the channel the last promote named. */
|
|
155
|
+
export function judgeBound(after: unknown, version: string, channel: string): DeployFinding[] {
|
|
156
|
+
const r = recordOf(after, version);
|
|
157
|
+
return [
|
|
158
|
+
f(r?.['state'] === 'active', `after the walk ${version} MUST be listed active (got ${JSON.stringify(r?.['state'])})`),
|
|
159
|
+
f(Array.isArray(r?.['channels']) && (r['channels'] as unknown[]).includes(channel), `a promote naming channel ${channel} MUST bind it (channels ${JSON.stringify(r?.['channels'])})`),
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The payload of the first `agent.invocation-started`, or undefined. */
|
|
164
|
+
export function firstInvocation(events: ReadonlyArray<{ type: string; payload: Rec }>, type: string): Rec | undefined {
|
|
165
|
+
return events.find((e) => e.type === type)?.payload;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A channel-bound run records the channel and a version active on it when the run started. */
|
|
169
|
+
export function judgePinned(payload: Rec | undefined, channel: string, activeVersions: readonly string[]): DeployFinding[] {
|
|
170
|
+
if (payload === undefined) return [f(false, 'a channel-bound agent run MUST emit agent.invocation-started')];
|
|
171
|
+
const v = payload['resolvedAgentVersion'];
|
|
172
|
+
return [
|
|
173
|
+
f(payload['resolvedChannel'] === channel, `agent.invocation-started MUST carry resolvedChannel ${JSON.stringify(channel)} (got ${JSON.stringify(payload['resolvedChannel'])})`),
|
|
174
|
+
f(typeof v === 'string' && activeVersions.includes(v), `resolvedAgentVersion MUST be a version active on ${channel} at run start, ${JSON.stringify(activeVersions)} (got ${JSON.stringify(v)})`),
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A replay of the source run re-reads its recorded version, not the moved channel. */
|
|
179
|
+
export function judgeReplayPinned(source: string, moved: string, payload: Rec | undefined): DeployFinding[] {
|
|
180
|
+
if (payload === undefined) return [f(false, 'the replay MUST emit agent.invocation-started')];
|
|
181
|
+
const v = payload['resolvedAgentVersion'];
|
|
182
|
+
return [f(v === source, `a replay MUST re-read the recorded resolvedAgentVersion ${source}, not re-resolve the channel now serving ${moved} (got ${JSON.stringify(v)})`)];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `adjust-canary` answers 200 with the record at the requested share, and the list agrees. */
|
|
186
|
+
export function judgeCanaryAdjusted(a: Answer | null, version: string, percent: number, after: unknown, validate: Validate): DeployFinding[] {
|
|
187
|
+
if (a === null || a.status !== 200) return [f(false, `adjust-canary of active ${version} to ${percent} on a canary host MUST answer 200 (got ${shown(a)})`)];
|
|
188
|
+
const v = validate(a.json);
|
|
189
|
+
const r = isRec(a.json) ? a.json : {};
|
|
190
|
+
return [
|
|
191
|
+
f(v.ok, `the adjusted record MUST validate against agent-deployment.schema.json (${v.errors})`),
|
|
192
|
+
f(r['canaryPercent'] === percent, `the returned record MUST carry canaryPercent ${percent} (got ${JSON.stringify(r['canaryPercent'])})`),
|
|
193
|
+
f(recordOf(after, version)?.['canaryPercent'] === percent, `the list MUST then show canaryPercent ${percent} (got ${JSON.stringify(recordOf(after, version)?.['canaryPercent'])})`),
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A rollback leaves the version `rolled-back`, and restores the version that
|
|
199
|
+
* was `active` on the channel immediately before it: the incumbent, which
|
|
200
|
+
* `rollbackPointer` names and which is listed `active` afterwards (RFC 0254).
|
|
201
|
+
*/
|
|
202
|
+
export function judgeRolledBack(a: Answer | null, version: string, incumbent: readonly string[], after: unknown, validate: Validate): DeployFinding[] {
|
|
203
|
+
if (a === null || a.status !== 200) return [f(false, `rollback of active ${version} on a rollback host MUST answer 200 (got ${shown(a)})`)];
|
|
204
|
+
const v = validate(a.json);
|
|
205
|
+
const r = isRec(a.json) ? a.json : {};
|
|
206
|
+
const ptr = r['rollbackPointer'];
|
|
207
|
+
const restored = typeof ptr === 'string' ? recordOf(after, ptr) : undefined;
|
|
208
|
+
return [
|
|
209
|
+
f(v.ok, `the rolled-back record MUST validate against agent-deployment.schema.json (${v.errors})`),
|
|
210
|
+
f(r['state'] === 'rolled-back', `the returned record MUST be rolled-back (got ${JSON.stringify(r['state'])})`),
|
|
211
|
+
f(typeof ptr === 'string' && incumbent.includes(ptr), `rollbackPointer MUST name the version active on the channel immediately before ${version}, ${JSON.stringify(incumbent)} (got ${JSON.stringify(ptr)})`),
|
|
212
|
+
f(restored?.['state'] === 'active', `the version rollbackPointer names MUST be listed active (got ${JSON.stringify(restored?.['state'])})`),
|
|
213
|
+
f(recordOf(after, version)?.['state'] === 'rolled-back', `the list MUST then show ${version} rolled-back (got ${JSON.stringify(recordOf(after, version)?.['state'])})`),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A `pause` then a `promote` of a paused version: each 200, ending `paused` then `active` (RFC 0254). */
|
|
218
|
+
export function judgeResumed(pause: Answer | null, resume: Answer | null, version: string, after: unknown, validate: Validate): DeployFinding[] {
|
|
219
|
+
if (pause === null || pause.status !== 200) return [f(false, `pause of active ${version} is a legal edge and MUST answer 200 (got ${shown(pause)})`)];
|
|
220
|
+
const p = isRec(pause.json) ? pause.json : {};
|
|
221
|
+
const vp = validate(pause.json);
|
|
222
|
+
const out = [
|
|
223
|
+
f(vp.ok, `the paused record MUST validate against agent-deployment.schema.json (${vp.errors})`),
|
|
224
|
+
f(p['state'] === 'paused', `the pause MUST return the record paused (got ${JSON.stringify(p['state'])})`),
|
|
225
|
+
];
|
|
226
|
+
if (resume === null || resume.status !== 200) return [...out, f(false, `promote of paused ${version} is the paused→active edge and MUST answer 200 (got ${shown(resume)})`)];
|
|
227
|
+
const r = isRec(resume.json) ? resume.json : {};
|
|
228
|
+
const vr = validate(resume.json);
|
|
229
|
+
return [
|
|
230
|
+
...out,
|
|
231
|
+
f(vr.ok, `the resumed record MUST validate against agent-deployment.schema.json (${vr.errors})`),
|
|
232
|
+
f(r['state'] === 'active', `promote of a paused version MUST return it active (got ${JSON.stringify(r['state'])})`),
|
|
233
|
+
f(recordOf(after, version)?.['state'] === 'active', `the list MUST then show ${version} active (got ${JSON.stringify(recordOf(after, version)?.['state'])})`),
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One transition the host accepted with 200: what was asked, and the record it returned. */
|
|
238
|
+
export interface Accepted {
|
|
239
|
+
readonly label: string;
|
|
240
|
+
readonly transition: string;
|
|
241
|
+
readonly version: string;
|
|
242
|
+
/** The record's state before the transition. */
|
|
243
|
+
readonly fromState: string;
|
|
244
|
+
readonly answer: Answer;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Every 200 transition answer names its deployment-management run in `transitionRunId` (RFC 0254). */
|
|
248
|
+
export function judgeRunLinked(accepted: readonly Accepted[]): DeployFinding[] {
|
|
249
|
+
return accepted.map((x) => {
|
|
250
|
+
const id = isRec(x.answer.json) ? x.answer.json['transitionRunId'] : undefined;
|
|
251
|
+
return f(typeof id === 'string' && id.length > 0, `the record ${x.label} returned MUST carry transitionRunId naming its deployment-management run (got ${JSON.stringify(id)})`);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The `transitionRunId` of an accepted answer, or undefined. */
|
|
256
|
+
export function runIdOf(x: Accepted): string | undefined {
|
|
257
|
+
const id = isRec(x.answer.json) ? x.answer.json['transitionRunId'] : undefined;
|
|
258
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A `deployment.*` event the transition must have recorded: its v1 name (the codemap key), its payload $def, and how it names this transition. */
|
|
262
|
+
export interface Expected { readonly v1: string; readonly def: string; readonly matches: (p: Rec) => boolean }
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The `deployment.*` event a transition must record. A resume (`promote` of a
|
|
266
|
+
* paused version) records `deployment.state.changed`, as pause and deprecate
|
|
267
|
+
* do; `deployment.promoted` records only a forward step (RFC 0254 §A).
|
|
268
|
+
*/
|
|
269
|
+
export function expectedEvents(x: Accepted): Expected[] {
|
|
270
|
+
const r = isRec(x.answer.json) ? x.answer.json : {};
|
|
271
|
+
const state = r['state'];
|
|
272
|
+
const promoted: Expected = { v1: 'deployment.promoted', def: 'deploymentPromoted', matches: (p) => p['toVersion'] === x.version && p['toState'] === state };
|
|
273
|
+
const changed: Expected = { v1: 'deployment.state.changed', def: 'deploymentStateChanged', matches: (p) => p['version'] === x.version && p['fromState'] === x.fromState && p['toState'] === state };
|
|
274
|
+
switch (x.transition) {
|
|
275
|
+
case 'promote': return x.fromState === 'paused' ? [changed] : [promoted];
|
|
276
|
+
case 'pause':
|
|
277
|
+
case 'deprecate': return [changed];
|
|
278
|
+
case 'adjust-canary': return [{ v1: 'deployment.canary.adjusted', def: 'deploymentCanaryAdjusted', matches: (p) => p['version'] === x.version && p['toPercent'] === r['canaryPercent'] }];
|
|
279
|
+
case 'rollback': return [{ v1: 'deployment.rolled-back', def: 'deploymentRolledBack', matches: (p) => p['fromVersion'] === x.version && p['toVersion'] === r['rollbackPointer'] }];
|
|
280
|
+
default: return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The management run's events include the `deployment.*` event matching the
|
|
286
|
+
* transition, and its payload validates against its `run-event-payloads` $def.
|
|
287
|
+
* `typeOf` maps a v1 name to the major's wire name; `validators` are keyed by $def.
|
|
288
|
+
*/
|
|
289
|
+
export function judgeEventRecorded(
|
|
290
|
+
x: Accepted,
|
|
291
|
+
events: ReadonlyArray<{ type: string; payload: Rec }> | undefined,
|
|
292
|
+
typeOf: (v1: string) => string,
|
|
293
|
+
validators: Readonly<Record<string, Validate>>,
|
|
294
|
+
): DeployFinding[] {
|
|
295
|
+
const runId = runIdOf(x);
|
|
296
|
+
if (runId === undefined) return [f(false, `${x.label}: no transitionRunId, so its deployment.* event cannot be read`)];
|
|
297
|
+
if (events === undefined) return [f(false, `${x.label}: the events of management run ${runId} MUST be readable by a caller with runs:read in the same tenant`)];
|
|
298
|
+
const want = expectedEvents(x);
|
|
299
|
+
const names = want.map((w) => typeOf(w.v1));
|
|
300
|
+
const hit = want.flatMap((w) => events.filter((e) => e.type === typeOf(w.v1) && w.matches(e.payload)).map((e) => ({ w, e })))[0];
|
|
301
|
+
if (hit === undefined) {
|
|
302
|
+
const seen = events.filter((e) => e.type.startsWith('deployment.')).map((e) => `${e.type} ${JSON.stringify(e.payload)}`);
|
|
303
|
+
return [f(false, `${x.label}: management run ${runId} MUST record ${names.join(' or ')} for this transition (deployment.* seen: ${seen.length === 0 ? 'none' : seen.join('; ')})`)];
|
|
304
|
+
}
|
|
305
|
+
const validate = validators[hit.w.def];
|
|
306
|
+
const v = validate === undefined ? { ok: false, errors: `no validator for ${hit.w.def}` } : validate(hit.e.payload);
|
|
307
|
+
return [f(v.ok, `${x.label}: the ${hit.e.type} payload MUST validate against run-event-payloads#/$defs/${hit.w.def}, content-free (${v.errors})`)];
|
|
308
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the agent deployment lifecycle on the wire (RFC 0082 §B, §C, §E, §F;
|
|
3
|
+
* the transition rules of RFC 0254). The rules are the `agents.deployment`
|
|
4
|
+
* facet description in `spec/v2/facets/agents.schema.json` ("Transitions
|
|
5
|
+
* (RFC 0254)"), `schemas/v2/agent-deployment.schema.json`,
|
|
6
|
+
* `agent-deployment-transition.schema.json`, `listAgentDeployments` /
|
|
7
|
+
* `transitionAgentDeployment` in `api/v2/openapi.yaml` and the registered
|
|
8
|
+
* `409 deployment_state_conflict` in `spec/v2/errors.json`.
|
|
9
|
+
*
|
|
10
|
+
* Gated on `agents.deployment` and on the fixture `conformance-agent-channel-dispatch`
|
|
11
|
+
* in discovery `fixtures`. At major 2, listing it states that the host holds an
|
|
12
|
+
* `active` record of `core.conformance.channel-agent` on `stable` and a `draft`
|
|
13
|
+
* one, and that the conformance credential's transitions of that agent pass no
|
|
14
|
+
* approval or eval gate (contract in `conformance/fixtures.md`). Absent `states`
|
|
15
|
+
* means all seven. The suite then, once per worker:
|
|
16
|
+
* list → run the fixture (R1) → unscoped promote → illegal pause / deprecate →
|
|
17
|
+
* promote draft straight to active → a transition into an unadvertised state →
|
|
18
|
+
* promote one step at a time to active on `stable` at 100 (the first step
|
|
19
|
+
* without `toState`) → run the fixture again (R2) → replay R1 → adjust-canary →
|
|
20
|
+
* rollback → pause and resume the version active on `stable` → read each
|
|
21
|
+
* accepted transition's management run.
|
|
22
|
+
* Judges in `lib/deployment-lifecycle-witness.ts`.
|
|
23
|
+
* - `0082.list-valid` — the list is 200 and every record validates.
|
|
24
|
+
* - `0082.pin-recorded` — R1's `agent.invocation-started` carries
|
|
25
|
+
* `resolvedChannel: stable` and a version active on it.
|
|
26
|
+
* - `0082.scope-fail-closed` — `OPENWOP_TEST_LOW_SCOPE_KEY` (no `deploy:*`)
|
|
27
|
+
* is refused `403 forbidden`, a `WWW-Authenticate` challenge if present
|
|
28
|
+
* names `scope="deploy:*"`, and the record does not move. Unset key: `blocked`.
|
|
29
|
+
* - `0082.transition-illegal-refused` — `pause` and `deprecate` of a draft
|
|
30
|
+
* (each when its target state is advertised; else `adjust-canary` at 100)
|
|
31
|
+
* are `409 deployment_state_conflict` with `details.state` and
|
|
32
|
+
* `details.transition`, record unchanged.
|
|
33
|
+
* - `0082.promote-skip-refused` — promote of the draft with `toState: active`
|
|
34
|
+
* when `active` is not the next state is the same 409, record unchanged.
|
|
35
|
+
* - `0082.state-unadvertised-refused` — an otherwise legal transition into a
|
|
36
|
+
* state outside `states` is `400 validation_error`, record unchanged.
|
|
37
|
+
* Every state advertised, or `states` absent: `inapplicable`.
|
|
38
|
+
* - `0082.transition-legal` — each step over the advertised states is 200
|
|
39
|
+
* with a valid record in the next state, and the version ends `active` on
|
|
40
|
+
* `stable`. An advertised subset without `draft` and `active` fails here.
|
|
41
|
+
* - `0082.replay-pin-reread` — after the walk moved `stable` (R2 resolves the
|
|
42
|
+
* new version), a replay of R1 still records R1's version.
|
|
43
|
+
* - `0082.canary-adjusted` / `0082.canary-unsupported-refused` — by `canary`.
|
|
44
|
+
* - `0082.rollback-pointer` — with `rollback`, the version ends `rolled-back`
|
|
45
|
+
* and its `rollbackPointer` names the incumbent, listed `active` again.
|
|
46
|
+
* - `0082.resume-from-paused` — with `paused`, a pause then a `promote` of the
|
|
47
|
+
* version active on `stable` (the incumbent after a rollback) each answer
|
|
48
|
+
* 200, ending `paused` then `active`.
|
|
49
|
+
* - `0082.transition-run-linked` — every 200 transition record carries
|
|
50
|
+
* `transitionRunId`.
|
|
51
|
+
* - `0082.transition-event-recorded` — that run's events include the
|
|
52
|
+
* transition's `deployment.*` event, valid against `run-event-payloads`
|
|
53
|
+
* (content-free: SECURITY `deployment-event-no-content-leak`).
|
|
54
|
+
*
|
|
55
|
+
* Not witnessed here: `authorization.decided` on the management run, the 202 of
|
|
56
|
+
* a gated transition (no gated fixture), that a refused transition creates no
|
|
57
|
+
* run (not portably observable), and a rollback with no prior version.
|
|
58
|
+
*
|
|
59
|
+
* @see RFCS/0082-agent-deployment-lifecycle.md
|
|
60
|
+
* @see RFCS/0254-deployment-transitions-at-v2.md
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { describe, it, expect } from 'vitest';
|
|
64
|
+
import { driver, type OpenWOPRequestInit } from '../lib/driver.js';
|
|
65
|
+
import { v2Discovery, v2RefValidator, v2Validator } from '../lib/v2.js';
|
|
66
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
67
|
+
import { req } from '../lib/requirement-ids.js';
|
|
68
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
69
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
70
|
+
import { isRecord, pollTerminal, readEvents, type Ev } from '../lib/fixture-run-observer.js';
|
|
71
|
+
import {
|
|
72
|
+
ALL_STATES, activeOn, expectedEvents, firstInvocation, judgeBound, judgeCanaryAdjusted, judgeEventRecorded, judgeList,
|
|
73
|
+
judgePinned, judgeReplayPinned, judgeResumed, judgeRolledBack, judgeRunLinked, judgeScopeRefused, judgeStateConflict, judgeStep,
|
|
74
|
+
judgeValidationRefused, pickDraft, promotionPath, recordOf, runIdOf, type Accepted, type Answer, type DeployFinding, type Validate,
|
|
75
|
+
} from '../lib/deployment-lifecycle-witness.js';
|
|
76
|
+
|
|
77
|
+
const PROFILE = majorProfile(2);
|
|
78
|
+
const DOC = 'agents.schema.json §deployment, agent-deployment.schema.json, transitionAgentDeployment (RFC 0082, RFC 0254)';
|
|
79
|
+
const FIXTURE = 'conformance-agent-channel-dispatch';
|
|
80
|
+
const AGENT = 'core.conformance.channel-agent';
|
|
81
|
+
const CHANNEL = 'stable';
|
|
82
|
+
const CANARY = 50;
|
|
83
|
+
const DEPLOYMENTS = `/agents/${encodeURIComponent(AGENT)}/deployments`;
|
|
84
|
+
const INVOCATION_STARTED = PROFILE.eventType('agent.invocation.started') ?? 'agent.invocation-started';
|
|
85
|
+
const ID_LIST = 'openwop.requirement.0082.list-valid';
|
|
86
|
+
const ID_PIN = 'openwop.requirement.0082.pin-recorded';
|
|
87
|
+
const ID_SCOPE = 'openwop.requirement.0082.scope-fail-closed';
|
|
88
|
+
const ID_ILLEGAL = 'openwop.requirement.0082.transition-illegal-refused';
|
|
89
|
+
const ID_SKIP = 'openwop.requirement.0082.promote-skip-refused';
|
|
90
|
+
const ID_UNADVERTISED = 'openwop.requirement.0082.state-unadvertised-refused';
|
|
91
|
+
const ID_LEGAL = 'openwop.requirement.0082.transition-legal';
|
|
92
|
+
const ID_REPLAY = 'openwop.requirement.0082.replay-pin-reread';
|
|
93
|
+
const ID_CANARY = 'openwop.requirement.0082.canary-adjusted';
|
|
94
|
+
const ID_CANARY_REFUSED = 'openwop.requirement.0082.canary-unsupported-refused';
|
|
95
|
+
const ID_ROLLBACK = 'openwop.requirement.0082.rollback-pointer';
|
|
96
|
+
const ID_RESUME = 'openwop.requirement.0082.resume-from-paused';
|
|
97
|
+
const ID_RUN_LINKED = 'openwop.requirement.0082.transition-run-linked';
|
|
98
|
+
const ID_EVENT = 'openwop.requirement.0082.transition-event-recorded';
|
|
99
|
+
|
|
100
|
+
type Rec = Record<string, unknown>;
|
|
101
|
+
type Skip = { skip: [Parameters<typeof softSkip>[0], string] };
|
|
102
|
+
interface Gate { facet: Rec; states: readonly string[] | null; replay: boolean }
|
|
103
|
+
/** A refusal and the list read after it. */
|
|
104
|
+
interface Refusal { readonly before: Rec; readonly answer: Answer | null; readonly after: unknown }
|
|
105
|
+
|
|
106
|
+
interface Drive {
|
|
107
|
+
list0: Answer | null;
|
|
108
|
+
draft?: string | undefined;
|
|
109
|
+
activeAtR1: readonly string[];
|
|
110
|
+
r1?: { runId: string; pin: Rec | undefined } | string | undefined;
|
|
111
|
+
scope?: Refusal | string | undefined;
|
|
112
|
+
illegal?: Array<{ label: string; transition: string } & Refusal> | undefined;
|
|
113
|
+
skip?: Refusal | string | undefined;
|
|
114
|
+
unadvertised?: ({ state: string } & Refusal) | string | undefined;
|
|
115
|
+
walk?: { steps: Array<{ toState: string; answer: Answer | null }>; after: unknown } | string | undefined;
|
|
116
|
+
replay?: { source: string; moved: string; pin: Rec | undefined } | string | undefined;
|
|
117
|
+
canary?: { answer: Answer | null; before: Rec; after: unknown } | string | undefined;
|
|
118
|
+
rollback?: { answer: Answer | null; after: unknown } | string | undefined;
|
|
119
|
+
resume?: { version: string; pause: Answer | null; resume: Answer | null; after: unknown } | string | undefined;
|
|
120
|
+
/** Every transition answered 200, in order. */
|
|
121
|
+
accepted: Accepted[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function ask(method: string, path: string, body?: unknown, bearer?: string): Promise<Answer | null> {
|
|
125
|
+
const init: OpenWOPRequestInit = {
|
|
126
|
+
...(body === undefined ? {} : { body }),
|
|
127
|
+
...(bearer === undefined ? {} : { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } }),
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const r = await driver.request(method, path, init);
|
|
131
|
+
return { status: r.status, json: r.json, wwwAuthenticate: r.headers.get('www-authenticate') ?? undefined };
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const list = async (): Promise<unknown> => { const a = await ask('GET', DEPLOYMENTS); return a?.status === 200 ? a.json : undefined; };
|
|
137
|
+
const transition = (body: Rec, bearer?: string): Promise<Answer | null> => ask('POST', DEPLOYMENTS, body, bearer);
|
|
138
|
+
|
|
139
|
+
async function gate(): Promise<Skip | Gate> {
|
|
140
|
+
let doc: Rec | null;
|
|
141
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
142
|
+
if (!doc) return { skip: ['blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0'] };
|
|
143
|
+
const agents = doc['agents'];
|
|
144
|
+
if (!isRecord(agents) || !isRecord(agents['deployment'])) return { skip: ['inapplicable', 'the host does not advertise agents.deployment'] };
|
|
145
|
+
if (!isFixtureAdvertised(FIXTURE)) return { skip: ['inapplicable', `the host does not advertise fixture ${FIXTURE}`] };
|
|
146
|
+
const facet = agents['deployment'];
|
|
147
|
+
const states = Array.isArray(facet['states']) ? facet['states'].filter((s): s is string => typeof s === 'string') : null;
|
|
148
|
+
const replay = isRecord(doc['replay']) && Array.isArray(doc['replay']['modes']) && (doc['replay']['modes'] as unknown[]).includes('replay');
|
|
149
|
+
return { facet, states, replay };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Absent `states` means all seven (RFC 0254 §B). */
|
|
153
|
+
const advertises = (g: Gate, state: string): boolean => g.states === null || g.states.includes(state);
|
|
154
|
+
|
|
155
|
+
/** The §B violation of an advertised subset without `draft` and `active`, or null. */
|
|
156
|
+
function subsetInvalid(g: Gate): string | null {
|
|
157
|
+
const missing = ['draft', 'active'].filter((s) => !advertises(g, s));
|
|
158
|
+
return missing.length === 0 ? null : `agents.deployment.states ${JSON.stringify(g.states)} omits ${missing.join(' and ')}; an advertised subset MUST include draft and active`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Why the forward walk cannot run on this host, or null. */
|
|
162
|
+
function walkInapplicable(g: Gate): string | null {
|
|
163
|
+
const invalid = subsetInvalid(g);
|
|
164
|
+
if (invalid !== null) return invalid;
|
|
165
|
+
const channels = Array.isArray(g.facet['channels']) ? g.facet['channels'] : [];
|
|
166
|
+
if (!channels.includes(CHANNEL)) return `agents.deployment.channels does not list ${CHANNEL}`;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const rollbackServed = (g: Gate): boolean => g.facet['rollback'] === true && advertises(g, 'rolled-back');
|
|
170
|
+
/** The state a promote of a draft moves to over the advertised states. */
|
|
171
|
+
const nextAfterDraft = (g: Gate): string | undefined => promotionPath(g.states)[1];
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Transitions a draft has no edge for, whose target the host advertises (an
|
|
175
|
+
* unadvertised target is the 400 checked first): `pause` and `deprecate`, or,
|
|
176
|
+
* when neither state is advertised, `adjust-canary` at 100.
|
|
177
|
+
*/
|
|
178
|
+
function illegalProbes(g: Gate): Array<{ label: string; body: { transition: string } & Rec }> {
|
|
179
|
+
const out = (['pause', 'deprecate'] as const)
|
|
180
|
+
.filter((t) => advertises(g, t === 'pause' ? 'paused' : 'deprecated'))
|
|
181
|
+
.map((t) => ({ label: `${t} of a draft`, body: { transition: t } }));
|
|
182
|
+
return out.length > 0 ? out : [{ label: 'adjust-canary of a draft', body: { transition: 'adjust-canary', canaryPercent: 100 } }];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A transition that the state machine allows but whose target state is not
|
|
187
|
+
* advertised, so the only ground for refusing it is `states`: promote a draft
|
|
188
|
+
* into `test`, or pause, deprecate or roll back the active incumbent.
|
|
189
|
+
*/
|
|
190
|
+
const PROBES = [
|
|
191
|
+
{ state: 'test', on: 'draft', transition: 'promote' },
|
|
192
|
+
{ state: 'paused', on: 'active', transition: 'pause' },
|
|
193
|
+
{ state: 'deprecated', on: 'active', transition: 'deprecate' },
|
|
194
|
+
{ state: 'rolled-back', on: 'active', transition: 'rollback' },
|
|
195
|
+
] as const;
|
|
196
|
+
function unadvertisedProbe(states: readonly string[] | null): (typeof PROBES)[number] | undefined {
|
|
197
|
+
return states === null ? undefined : PROBES.find((p) => !states.includes(p.state));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function runFixture(): Promise<{ runId: string; pin: Rec | undefined } | string> {
|
|
201
|
+
const created = await ask('POST', PROFILE.runsPath, { workflowId: FIXTURE });
|
|
202
|
+
const runId = isRecord(created?.json) && typeof created.json['runId'] === 'string' ? created.json['runId'] : undefined;
|
|
203
|
+
if ((created?.status !== 201 && created?.status !== 202) || runId === undefined) return `createRun of ${FIXTURE} answered ${created?.status ?? 'nothing'}`;
|
|
204
|
+
const end = await pollTerminal(PROFILE, runId, { timeoutMs: 30_000 });
|
|
205
|
+
if (end.snapshot['status'] !== 'completed') return `run ${runId} of ${FIXTURE} ended ${String(end.snapshot['status'])}, not completed`;
|
|
206
|
+
return { runId, pin: firstInvocation(await readEvents(PROFILE, runId), INVOCATION_STARTED) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function replayOf(runId: string): Promise<Rec | undefined | string> {
|
|
210
|
+
const fork = await ask('POST', `${PROFILE.runsPath}/${PROFILE.idSegment(runId)}:fork`, { mode: 'replay' });
|
|
211
|
+
const forkId = isRecord(fork?.json) && typeof fork.json['runId'] === 'string' ? fork.json['runId'] : undefined;
|
|
212
|
+
if (fork?.status !== 201 || forkId === undefined) return `POST /runs/{runId}:fork {mode: replay} answered ${fork?.status ?? 'nothing'}`;
|
|
213
|
+
await pollTerminal(PROFILE, forkId, { timeoutMs: 30_000 });
|
|
214
|
+
return firstInvocation(await readEvents(PROFILE, forkId), INVOCATION_STARTED);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function refuse(version: string, body: Rec, bearer?: string): Promise<Refusal | string> {
|
|
218
|
+
const before = recordOf(await list(), version);
|
|
219
|
+
if (before === undefined) return `the record for ${version} is no longer listed`;
|
|
220
|
+
const answer = await transition(body, bearer);
|
|
221
|
+
return { before, answer, after: await list() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const typeOf = (v1: string): string => PROFILE.eventType(v1) ?? v1;
|
|
225
|
+
|
|
226
|
+
/** A management run's events, read until the transition's `deployment.*` event appears or 10 s pass. */
|
|
227
|
+
async function managementEvents(x: Accepted, runId: string): Promise<Ev[] | undefined> {
|
|
228
|
+
const want = expectedEvents(x);
|
|
229
|
+
const deadline = Date.now() + 10_000;
|
|
230
|
+
for (;;) {
|
|
231
|
+
const r = await ask('GET', `${PROFILE.runsPath}/${PROFILE.idSegment(runId)}/events/poll`);
|
|
232
|
+
if (r?.status !== 200) return undefined;
|
|
233
|
+
const events = await readEvents(PROFILE, runId);
|
|
234
|
+
if (want.some((w) => events.some((e) => e.type === typeOf(w.v1) && w.matches(e.payload))) || Date.now() > deadline) return events;
|
|
235
|
+
await new Promise((res) => setTimeout(res, 250));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let once: Promise<Drive> | undefined;
|
|
240
|
+
/** One lifecycle per worker: every leg judges a slice of it. */
|
|
241
|
+
function lifecycle(g: Gate): Promise<Drive> {
|
|
242
|
+
once ??= drive(g);
|
|
243
|
+
return once;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function drive(g: Gate): Promise<Drive> {
|
|
247
|
+
const list0 = await ask('GET', DEPLOYMENTS);
|
|
248
|
+
const records = list0?.status === 200 ? list0.json : undefined;
|
|
249
|
+
const activeAtR1 = activeOn(records, CHANNEL);
|
|
250
|
+
const d: Drive = { list0, draft: pickDraft(records), activeAtR1, accepted: [] };
|
|
251
|
+
d.r1 = activeAtR1.length === 0 ? `the host lists ${FIXTURE} but no ${AGENT} record is active on ${CHANNEL}` : await runFixture();
|
|
252
|
+
|
|
253
|
+
/** Send a transition by the conformance credential; a 200 joins `accepted`. */
|
|
254
|
+
const send = async (label: string, body: Rec & { version: string; transition: string }): Promise<Answer | null> => {
|
|
255
|
+
const fromState = recordOf(await list(), body.version)?.['state'];
|
|
256
|
+
const answer = await transition(body);
|
|
257
|
+
if (answer?.status === 200) d.accepted.push({ label, transition: body.transition, version: body.version, fromState: String(fromState), answer });
|
|
258
|
+
return answer;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (d.draft !== undefined) await forward(g, d, d.draft, send);
|
|
262
|
+
d.resume = await resume(g, d, send);
|
|
263
|
+
return d;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let eventsOnce: Promise<Map<string, Ev[] | undefined>> | undefined;
|
|
267
|
+
/** The events of every accepted transition's management run, read concurrently, once. */
|
|
268
|
+
function managementRuns(d: Drive): Promise<Map<string, Ev[] | undefined>> {
|
|
269
|
+
eventsOnce ??= (async () => {
|
|
270
|
+
const linked = d.accepted.flatMap((x) => { const id = runIdOf(x); return id === undefined ? [] : [{ x, id }]; });
|
|
271
|
+
const read = await Promise.all(linked.map(async ({ x, id }) => [id, await managementEvents(x, id)] as const));
|
|
272
|
+
return new Map(read);
|
|
273
|
+
})();
|
|
274
|
+
return eventsOnce;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type Send = (label: string, body: Rec & { version: string; transition: string }) => Promise<Answer | null>;
|
|
278
|
+
|
|
279
|
+
/** The refusals, the walk, the replay, canary and rollback, on the fixture's draft. */
|
|
280
|
+
async function forward(g: Gate, d: Drive, draft: string, send: Send): Promise<void> {
|
|
281
|
+
const low = process.env['OPENWOP_TEST_LOW_SCOPE_KEY']?.trim();
|
|
282
|
+
d.scope = !low
|
|
283
|
+
? 'OPENWOP_TEST_LOW_SCOPE_KEY (a key without any deploy:* scope) is not set'
|
|
284
|
+
: await refuse(draft, { version: draft, transition: 'promote' }, low);
|
|
285
|
+
|
|
286
|
+
d.illegal = [];
|
|
287
|
+
for (const p of illegalProbes(g)) {
|
|
288
|
+
const r = await refuse(draft, { version: draft, ...p.body });
|
|
289
|
+
if (typeof r !== 'string') d.illegal.push({ label: p.label, transition: p.body.transition, ...r });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (walkInapplicable(g) === null && nextAfterDraft(g) !== 'active') {
|
|
293
|
+
d.skip = await refuse(draft, { version: draft, transition: 'promote', toState: 'active' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const probe = unadvertisedProbe(g.states);
|
|
297
|
+
if (probe !== undefined) {
|
|
298
|
+
const version = probe.on === 'draft' ? draft : d.activeAtR1[0];
|
|
299
|
+
if (version === undefined) d.unadvertised = `no ${AGENT} record is active on ${CHANNEL} to ${probe.transition}`;
|
|
300
|
+
else {
|
|
301
|
+
const r = await refuse(version, { version, transition: probe.transition, ...(probe.on === 'draft' ? { toState: probe.state } : {}) });
|
|
302
|
+
d.unadvertised = typeof r === 'string' ? r : { state: probe.state, ...r };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const why = walkInapplicable(g);
|
|
307
|
+
if (why !== null) { d.walk = why; return; }
|
|
308
|
+
const current = recordOf(await list(), draft)?.['state'];
|
|
309
|
+
if (current !== 'draft') { d.walk = `${draft} is no longer listed as a draft (now ${JSON.stringify(current)})`; return; }
|
|
310
|
+
const path = promotionPath(g.states).slice(1);
|
|
311
|
+
const steps: Array<{ toState: string; answer: Answer | null }> = [];
|
|
312
|
+
for (const [i, toState] of path.entries()) {
|
|
313
|
+
const last = toState === 'active';
|
|
314
|
+
// The first step omits toState: absent means the next state.
|
|
315
|
+
const body = { version: draft, transition: 'promote', ...(i === 0 ? {} : { toState }), ...(last ? { channel: CHANNEL, canaryPercent: 100 } : {}) };
|
|
316
|
+
const answer = await send(`promote ${draft} to ${toState}`, body);
|
|
317
|
+
steps.push({ toState, answer });
|
|
318
|
+
if (answer?.status !== 200) break;
|
|
319
|
+
}
|
|
320
|
+
d.walk = { steps, after: await list() };
|
|
321
|
+
if (steps.length < path.length || steps.some((s) => s.answer?.status !== 200)) return;
|
|
322
|
+
|
|
323
|
+
if (!g.replay) d.replay = 'the host does not advertise replay.modes including "replay"';
|
|
324
|
+
else if (typeof d.r1 !== 'object') d.replay = d.r1 ?? 'R1 did not run';
|
|
325
|
+
else if (typeof d.r1.pin?.['resolvedAgentVersion'] !== 'string') d.replay = 'R1 recorded no resolvedAgentVersion to re-read';
|
|
326
|
+
else {
|
|
327
|
+
const source = d.r1.pin['resolvedAgentVersion'] as string;
|
|
328
|
+
const r2 = await runFixture();
|
|
329
|
+
const moved = typeof r2 === 'string' ? undefined : r2.pin?.['resolvedAgentVersion'];
|
|
330
|
+
if (typeof r2 === 'string') d.replay = r2;
|
|
331
|
+
else if (moved !== draft || moved === source) d.replay = `the channel move is not observable: a fresh run resolved ${JSON.stringify(moved)} after ${draft} went active on ${CHANNEL} at 100`;
|
|
332
|
+
else {
|
|
333
|
+
const pin = await replayOf(d.r1.runId);
|
|
334
|
+
d.replay = typeof pin === 'string' ? pin : { source, moved: draft, pin };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const beforeCanary = recordOf(await list(), draft);
|
|
339
|
+
if (beforeCanary === undefined) d.canary = `the record for ${draft} is no longer listed`;
|
|
340
|
+
else {
|
|
341
|
+
const answer = await send(`adjust-canary of ${draft} to ${CANARY}`, { version: draft, transition: 'adjust-canary', canaryPercent: CANARY });
|
|
342
|
+
d.canary = { answer, before: beforeCanary, after: await list() };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!rollbackServed(g)) d.rollback = 'the host does not advertise agents.deployment.rollback with rolled-back in states';
|
|
346
|
+
else if (d.activeAtR1.length === 0) d.rollback = `no version was active on ${CHANNEL} before the walk, so there is nothing to restore`;
|
|
347
|
+
else {
|
|
348
|
+
const answer = await send(`rollback of ${draft}`, { version: draft, transition: 'rollback', reason: 'conformance' });
|
|
349
|
+
d.rollback = { answer, after: await list() };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Pause, then promote, the version active on `stable`: the incumbent when it
|
|
355
|
+
* is active again (after a rollback, or never displaced), else the walked
|
|
356
|
+
* version. Runs last, so no later leg depends on what it leaves.
|
|
357
|
+
*/
|
|
358
|
+
async function resume(g: Gate, d: Drive, send: Send): Promise<Drive['resume']> {
|
|
359
|
+
if (!advertises(g, 'paused')) return 'agents.deployment.states does not advertise paused';
|
|
360
|
+
const active = activeOn(await list(), CHANNEL);
|
|
361
|
+
const version = active.find((v) => d.activeAtR1.includes(v)) ?? active[0];
|
|
362
|
+
if (version === undefined) return `no ${AGENT} record is active on ${CHANNEL} to pause`;
|
|
363
|
+
const pause = await send(`pause of ${version}`, { version, transition: 'pause' });
|
|
364
|
+
const back = pause?.status === 200 ? await send(`promote of paused ${version}`, { version, transition: 'promote' }) : null;
|
|
365
|
+
return { version, pause, resume: back, after: await list() };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function expectAll(id: string, findings: readonly DeployFinding[]): void {
|
|
369
|
+
for (const x of findings) expect(x.ok, req(id, DOC, x.message)).toBe(true);
|
|
370
|
+
}
|
|
371
|
+
const NO_DRAFT = `the host lists ${FIXTURE} but holds no draft record of ${AGENT}`;
|
|
372
|
+
const record = v2Validator('agent-deployment');
|
|
373
|
+
const PAYLOADS: Record<string, Validate> = Object.fromEntries(
|
|
374
|
+
['deploymentPromoted', 'deploymentStateChanged', 'deploymentCanaryAdjusted', 'deploymentRolledBack']
|
|
375
|
+
.map((def) => [def, v2RefValidator(`run-event-payloads.schema.json#/$defs/${def}`)]),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
describe('v2 RFC 0082 — the agent deployment lifecycle (host)', () => {
|
|
379
|
+
it('lists valid deployment records of the fixture agent', async () => {
|
|
380
|
+
const g = await gate();
|
|
381
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
382
|
+
const d = await lifecycle(g);
|
|
383
|
+
expectAll(ID_LIST, judgeList(d.list0, AGENT, record));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('a channel-bound run records the channel and the version it resolved', async () => {
|
|
387
|
+
const g = await gate();
|
|
388
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
389
|
+
const d = await lifecycle(g);
|
|
390
|
+
if (d.r1 === undefined || typeof d.r1 === 'string') return softSkip('blocked', d.r1 ?? 'the fixture run did not start');
|
|
391
|
+
expectAll(ID_PIN, judgePinned(d.r1.pin, CHANNEL, d.activeAtR1));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('a transition without the deploy:* scope is refused 403 forbidden and moves nothing', async () => {
|
|
395
|
+
const g = await gate();
|
|
396
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
397
|
+
const d = await lifecycle(g);
|
|
398
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
399
|
+
if (d.scope === undefined || typeof d.scope === 'string') return softSkip('blocked', d.scope ?? 'the unscoped transition was not sent');
|
|
400
|
+
expectAll(ID_SCOPE, judgeScopeRefused(d.scope.answer, d.scope.before, d.scope.after));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('a pause or deprecate of a draft is 409 deployment_state_conflict and moves nothing', async () => {
|
|
404
|
+
const g = await gate();
|
|
405
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
406
|
+
const d = await lifecycle(g);
|
|
407
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
408
|
+
if (d.illegal === undefined || d.illegal.length === 0) return softSkip('blocked', `the draft ${d.draft} was not listed when the illegal transitions were sent`);
|
|
409
|
+
expectAll(ID_ILLEGAL, d.illegal.flatMap((x) => judgeStateConflict(x.label, x.transition, x.answer, x.before, x.after)));
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('a promote of a draft straight to active is 409 deployment_state_conflict and moves nothing', async () => {
|
|
413
|
+
const g = await gate();
|
|
414
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
415
|
+
const why = walkInapplicable(g);
|
|
416
|
+
if (why !== null) return softSkip('inapplicable', why);
|
|
417
|
+
if (nextAfterDraft(g) === 'active') return softSkip('inapplicable', 'the host advertises neither test nor staged, so active is the next state after draft and the promote is legal');
|
|
418
|
+
const d = await lifecycle(g);
|
|
419
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
420
|
+
if (d.skip === undefined || typeof d.skip === 'string') return softSkip('blocked', d.skip ?? 'the skipping promote was not sent');
|
|
421
|
+
expectAll(ID_SKIP, judgeStateConflict('promote of a draft straight to active', 'promote', d.skip.answer, d.skip.before, d.skip.after));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('a transition into a state the host does not advertise is a validation_error', async () => {
|
|
425
|
+
const g = await gate();
|
|
426
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
427
|
+
const states = g.states;
|
|
428
|
+
if (states === null) return softSkip('inapplicable', 'agents.deployment.states is absent, which means all seven, so no state is unadvertised');
|
|
429
|
+
if (ALL_STATES.every((s) => states.includes(s))) return softSkip('inapplicable', 'the host advertises all seven states, so no state is unadvertised');
|
|
430
|
+
if (unadvertisedProbe(states) === undefined) return softSkip('inapplicable', 'every unadvertised state is one no legal transition from a draft or the active incumbent enters, so a refusal could not be told apart from an illegal-transition refusal');
|
|
431
|
+
const d = await lifecycle(g);
|
|
432
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
433
|
+
if (d.unadvertised === undefined || typeof d.unadvertised === 'string') return softSkip('blocked', d.unadvertised ?? 'the probe was not sent');
|
|
434
|
+
expectAll(ID_UNADVERTISED, judgeValidationRefused(`a transition into unadvertised ${d.unadvertised.state}`, d.unadvertised.answer, d.unadvertised.before, d.unadvertised.after));
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('a draft promotes one step at a time over the advertised states to active on a channel', async () => {
|
|
438
|
+
const g = await gate();
|
|
439
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
440
|
+
const invalid = subsetInvalid(g);
|
|
441
|
+
expectAll(ID_LEGAL, invalid === null ? [] : [{ ok: false, message: invalid }]);
|
|
442
|
+
const why = walkInapplicable(g);
|
|
443
|
+
if (why !== null) return softSkip('inapplicable', why);
|
|
444
|
+
const d = await lifecycle(g);
|
|
445
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
446
|
+
if (d.walk === undefined || typeof d.walk === 'string') return softSkip('blocked', d.walk ?? 'the walk did not run');
|
|
447
|
+
const version = d.draft;
|
|
448
|
+
const findings = d.walk.steps.flatMap((s) => judgeStep(s.answer, version, s.toState, record));
|
|
449
|
+
const done = d.walk.steps.length === promotionPath(g.states).length - 1 && d.walk.steps.every((s) => s.answer?.status === 200);
|
|
450
|
+
expectAll(ID_LEGAL, done ? [...findings, ...judgeBound(d.walk.after, version, CHANNEL)] : findings);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('a replay re-reads the recorded version after the channel moved', async () => {
|
|
454
|
+
const g = await gate();
|
|
455
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
456
|
+
if (!g.replay) return softSkip('inapplicable', 'the host does not advertise replay.modes including "replay"');
|
|
457
|
+
const why = walkInapplicable(g);
|
|
458
|
+
if (why !== null) return softSkip('inapplicable', `the channel cannot be moved: ${why}`);
|
|
459
|
+
const d = await lifecycle(g);
|
|
460
|
+
if (d.replay === undefined || typeof d.replay === 'string') return softSkip('blocked', d.replay ?? (d.draft === undefined ? NO_DRAFT : 'the walk did not reach active, so the channel never moved'));
|
|
461
|
+
expectAll(ID_REPLAY, judgeReplayPinned(d.replay.source, d.replay.moved, d.replay.pin));
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('adjust-canary sets the share on a canary host', async () => {
|
|
465
|
+
const g = await gate();
|
|
466
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
467
|
+
if (g.facet['canary'] !== true) return softSkip('inapplicable', 'the host does not advertise agents.deployment.canary');
|
|
468
|
+
const why = walkInapplicable(g);
|
|
469
|
+
if (why !== null) return softSkip('inapplicable', `no active version to adjust: ${why}`);
|
|
470
|
+
const d = await lifecycle(g);
|
|
471
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
472
|
+
if (d.canary === undefined || typeof d.canary === 'string') return softSkip('blocked', d.canary ?? 'the walk did not reach active');
|
|
473
|
+
expectAll(ID_CANARY, judgeCanaryAdjusted(d.canary.answer, d.draft, CANARY, d.canary.after, record));
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('a host without canary refuses a share below 100', async () => {
|
|
477
|
+
const g = await gate();
|
|
478
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
479
|
+
if (g.facet['canary'] === true) return softSkip('inapplicable', 'the host advertises agents.deployment.canary');
|
|
480
|
+
const why = walkInapplicable(g);
|
|
481
|
+
if (why !== null) return softSkip('inapplicable', `no active version to adjust: ${why}`);
|
|
482
|
+
const d = await lifecycle(g);
|
|
483
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
484
|
+
if (d.canary === undefined || typeof d.canary === 'string') return softSkip('blocked', d.canary ?? 'the walk did not reach active');
|
|
485
|
+
expectAll(ID_CANARY_REFUSED, judgeValidationRefused(`adjust-canary to ${CANARY} without canary`, d.canary.answer, d.canary.before, d.canary.after));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('a rollback restores the incumbent and points at it', async () => {
|
|
489
|
+
const g = await gate();
|
|
490
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
491
|
+
if (!rollbackServed(g)) return softSkip('inapplicable', 'the host does not advertise agents.deployment.rollback with rolled-back in states');
|
|
492
|
+
const why = walkInapplicable(g);
|
|
493
|
+
if (why !== null) return softSkip('inapplicable', `no active version to roll back: ${why}`);
|
|
494
|
+
const d = await lifecycle(g);
|
|
495
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
496
|
+
if (d.rollback === undefined || typeof d.rollback === 'string') return softSkip('blocked', d.rollback ?? 'the walk did not reach active');
|
|
497
|
+
expectAll(ID_ROLLBACK, judgeRolledBack(d.rollback.answer, d.draft, d.activeAtR1, d.rollback.after, record));
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('a paused version resumes to active by promote', async () => {
|
|
501
|
+
const g = await gate();
|
|
502
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
503
|
+
if (!advertises(g, 'paused')) return softSkip('inapplicable', 'agents.deployment.states does not advertise paused');
|
|
504
|
+
const d = await lifecycle(g);
|
|
505
|
+
if (d.resume === undefined || typeof d.resume === 'string') return softSkip('blocked', d.resume ?? 'the pause was not sent');
|
|
506
|
+
expectAll(ID_RESUME, judgeResumed(d.resume.pause, d.resume.resume, d.resume.version, d.resume.after, record));
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('every accepted transition names its deployment-management run', async () => {
|
|
510
|
+
const g = await gate();
|
|
511
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
512
|
+
const d = await lifecycle(g);
|
|
513
|
+
if (d.accepted.length === 0) return softSkip('blocked', 'no transition answered 200, so no record to read transitionRunId from');
|
|
514
|
+
expectAll(ID_RUN_LINKED, judgeRunLinked(d.accepted));
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('the management run records the transition\'s content-free deployment event', async () => {
|
|
518
|
+
const g = await gate();
|
|
519
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
520
|
+
const d = await lifecycle(g);
|
|
521
|
+
const linked = d.accepted.filter((x) => runIdOf(x) !== undefined);
|
|
522
|
+
if (linked.length === 0) return softSkip('blocked', 'no 200 transition record named a transitionRunId, so there is no management run to read');
|
|
523
|
+
const events = await managementRuns(d);
|
|
524
|
+
expectAll(ID_EVENT, linked.flatMap((x) => judgeEventRecorded(x, events.get(runIdOf(x) ?? ''), typeOf, PAYLOADS)));
|
|
525
|
+
});
|
|
526
|
+
});
|