@bongos/core 1.19.789 → 1.19.791
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/.bongos-core.json +143 -53
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +129 -1
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +89 -72
- package/docs/copy-registry.json +272 -98
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +12 -0
- package/migrations/core_241_core_upgrades_actor.sql +65 -0
- package/migrations/core_242_core_version_snapshot.sql +54 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +8 -0
- package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
- package/modules/hall-ui/public/deploy.html +64 -0
- package/modules/hall-ui/public/deploy.js +188 -0
- package/modules/hall-ui/public/hall-kit.js +7 -65
- package/modules/hall-ui/public/hall-render.js +1 -1
- package/modules/hall-ui/public/hall-visual.js +85 -0
- package/modules/hall-ui/public/index.html +1 -0
- package/modules/hall-ui/public/profile.html +1 -0
- package/modules/hall-ui/public/profile.js +2 -2
- package/modules/hall-ui/public/shell.js +4 -0
- package/modules/hall-ui/public/studio.css +1 -1
- package/modules/hall-ui/public/studio.html +1 -0
- package/modules/hall-ui/public/studio.js +4 -3
- package/modules/hall-ui/public/style.css +0 -16
- package/modules/hall-ui/public/task.html +1 -0
- package/modules/hall-ui/public/task.js +10 -13
- package/modules/provisioning/control-plane-guard.js +102 -0
- package/modules/provisioning/core-upgrade.js +289 -0
- package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +8 -8
- package/modules/provisioning/routes/core-upgrade.js +95 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/accessibility-replay.js +195 -0
- package/scripts/gds/enroll-control-plane.js +101 -0
- package/scripts/gds/provision-core-upgrade.js +311 -0
- package/scripts/gds/provision.js +4 -2
- package/scripts/gds/upgrade.js +53 -14
- package/src/bongos/auth.js +19 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/accessibility_replay.mjs +201 -0
- package/tests/auth_resolve_failure.mjs +4 -2
- package/tests/core_upgrade_door.mjs +408 -0
- package/tests/core_upgrade_runner.mjs +377 -0
- package/tests/fixtures/accessibility-replay.json +93 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +7 -0
- package/tests/hall_studio_world.mjs +19 -11
- package/tests/provisioning_disconnect.mjs +30 -5
- package/tests/provisioning_paid_shape_gate.mjs +14 -4
- package/tests/provisioning_restart.mjs +25 -9
- package/tests/provisioning_settings_apply.mjs +11 -2
- package/tests/provisioning_teardown_intent.mjs +7 -0
- package/tests/task_visuals.mjs +91 -27
|
@@ -2052,5 +2052,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2052
2052
|
landed since 1.19.787 with no explicit bump. run 35159266737. (task 1002620)
|
|
2053
2053
|
1.19.789 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2054
2054
|
landed since 1.19.788 with no explicit bump. run 35159544354. (task 1002620)
|
|
2055
|
+
1.19.790 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2056
|
+
landed since 1.19.789 with no explicit bump. run 35163350575. (task 1002620)
|
|
2057
|
+
1.19.791 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2058
|
+
landed since 1.19.790 with no explicit bump. run 35165066743. (task 1002620)
|
|
2055
2059
|
---------------------------------------------------------------------------
|
|
2056
2060
|
```
|
|
@@ -112,6 +112,18 @@ node scripts/gds/package-core.js --publish --live # permanent
|
|
|
112
112
|
|
|
113
113
|
## Gate 3 — move the instance's pin
|
|
114
114
|
|
|
115
|
+
> **There is now a control for this, and it is the owner's** (task 1003159, [ADR 0293](../adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md)). Everything below still describes what actually happens on the box, and is what you need when something goes wrong — but the ordinary path no longer requires a shell or an ssh key.
|
|
116
|
+
>
|
|
117
|
+
> **Hall → Deploy** (`/deploy`) shows the version the box is actually serving, what it could move to, and moves it on an explicit second click. It is gated on the `core.pin.move` atom — archon-floor and `system: true` — and every move lands in the `core_upgrades` ledger with the builder who asked. The web tier never gains a shell: it queues a `core-upgrade` intent and the control-plane runner drains it, driving exactly the `bongos upgrade` invocation documented below, with its health gate and auto-rollback intact.
|
|
118
|
+
>
|
|
119
|
+
> **One install-time step enables it.** The door needs a `provisioning_instances` row to point at, and the control plane has never had one:
|
|
120
|
+
>
|
|
121
|
+
> ```bash
|
|
122
|
+
> node scripts/gds/enroll-control-plane.js --owner <builderId> --apply
|
|
123
|
+
> ```
|
|
124
|
+
>
|
|
125
|
+
> Until that is run, `/deploy` says so and names this command. The self row carries `hosting_shape='control-plane'`, which every destructive intent action refuses by name — a teardown aimed at it would destroy the platform from inside its own UI, so the guard is an allow-list (`core-upgrade`, `restart`) enforced at `enqueueIntent`, the one place every intent passes.
|
|
126
|
+
|
|
115
127
|
Each instance decides, independently, when to take a release. **Which mechanism it uses is a per-instance fact you must establish before prescribing anything** — read its `package.json`:
|
|
116
128
|
|
|
117
129
|
| Pin in `package.json` | Path | How it updates |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- core_241_core_upgrades_actor.sql — who asked for this upgrade (task 1003159,
|
|
2
|
+
-- ADR 0293 D5).
|
|
3
|
+
--
|
|
4
|
+
-- core_189 built the per-instance core-upgrade ledger with from_version, to_version,
|
|
5
|
+
-- source_commit and a free-text `note`. That was right for the world it was written
|
|
6
|
+
-- in: every upgrade came from a human at a shell, and the note carried "who ran it"
|
|
7
|
+
-- as prose when someone remembered.
|
|
8
|
+
--
|
|
9
|
+
-- Task 1003159 makes an upgrade something an HTTP request can START. That is the
|
|
10
|
+
-- most privileged act in the system — it restarts a service and changes what code
|
|
11
|
+
-- the platform runs — and the task's own non-negotiable is that **every invocation
|
|
12
|
+
-- lands in this ledger with an actor**. Prose in a nullable note is not an actor:
|
|
13
|
+
-- it cannot be joined, cannot be audited, and is absent exactly when someone was in
|
|
14
|
+
-- a hurry.
|
|
15
|
+
--
|
|
16
|
+
-- NULLABLE, DELIBERATELY. An upgrade run the old way — a human at a shell on the
|
|
17
|
+
-- box — has no builder to attribute and must keep working unchanged; this column is
|
|
18
|
+
-- not a gate on the CLI path. NULL therefore reads as "not requested through the
|
|
19
|
+
-- door", which is a true and useful statement rather than missing data. The route
|
|
20
|
+
-- is what makes it non-null in practice: it has an authenticated builder by
|
|
21
|
+
-- construction, and a request it cannot attribute is refused before it reaches here.
|
|
22
|
+
--
|
|
23
|
+
-- ON DELETE SET NULL matches every other actor reference in the schema: deleting a
|
|
24
|
+
-- person must not erase the record of what the platform did.
|
|
25
|
+
--
|
|
26
|
+
-- Additive, idempotent, no down-migration.
|
|
27
|
+
|
|
28
|
+
BEGIN;
|
|
29
|
+
|
|
30
|
+
ALTER TABLE core_upgrades ADD COLUMN IF NOT EXISTS requested_by_builder_id bigint
|
|
31
|
+
REFERENCES builders(id) ON DELETE SET NULL;
|
|
32
|
+
|
|
33
|
+
-- How the upgrade was started. 'shell' is every pre-existing row and every future
|
|
34
|
+
-- CLI run; 'owner-control' is the door this task builds. Text + CHECK rather than
|
|
35
|
+
-- an enum type, the fail-closed shape the rest of the schema uses: an unwired
|
|
36
|
+
-- source is refused at insert, and widening later is a one-line constraint change.
|
|
37
|
+
ALTER TABLE core_upgrades ADD COLUMN IF NOT EXISTS source text;
|
|
38
|
+
|
|
39
|
+
DO $$
|
|
40
|
+
BEGIN
|
|
41
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'core_upgrades_source_chk') THEN
|
|
42
|
+
ALTER TABLE core_upgrades ADD CONSTRAINT core_upgrades_source_chk
|
|
43
|
+
CHECK (source IS NULL OR source IN ('shell', 'owner-control'));
|
|
44
|
+
END IF;
|
|
45
|
+
|
|
46
|
+
-- An upgrade that came through the DOOR must name its actor. A shell run need
|
|
47
|
+
-- not, and pre-existing rows carry neither. NOT VALID so the constraint binds
|
|
48
|
+
-- every future write without scanning history that predates the concept — the
|
|
49
|
+
-- same trade agents_002_payer.sql made for the same reason.
|
|
50
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'core_upgrades_door_needs_actor_chk') THEN
|
|
51
|
+
ALTER TABLE core_upgrades ADD CONSTRAINT core_upgrades_door_needs_actor_chk
|
|
52
|
+
CHECK (source IS DISTINCT FROM 'owner-control' OR requested_by_builder_id IS NOT NULL) NOT VALID;
|
|
53
|
+
END IF;
|
|
54
|
+
END $$;
|
|
55
|
+
|
|
56
|
+
-- "What has this builder deployed?" — the audit read. Partial: a shell run has no
|
|
57
|
+
-- actor and would only dilute the scan.
|
|
58
|
+
CREATE INDEX IF NOT EXISTS core_upgrades_actor_idx
|
|
59
|
+
ON core_upgrades (requested_by_builder_id, applied_at DESC)
|
|
60
|
+
WHERE requested_by_builder_id IS NOT NULL;
|
|
61
|
+
|
|
62
|
+
INSERT INTO schema_migrations (version) VALUES ('core_241_core_upgrades_actor')
|
|
63
|
+
ON CONFLICT (version) DO NOTHING;
|
|
64
|
+
|
|
65
|
+
COMMIT;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- core_242_core_version_snapshot.sql — what the box serves and what it could take
|
|
2
|
+
-- (task 1003159, ADR 0293 D3 + D6).
|
|
3
|
+
--
|
|
4
|
+
-- THE PROBLEM THIS SOLVES IS A LAYERING ONE, not a storage one. The deploy control has
|
|
5
|
+
-- to show the owner two facts before they press anything: the version the box is
|
|
6
|
+
-- ACTUALLY serving, and the versions it could be moved to. Neither is reachable from the
|
|
7
|
+
-- web tier:
|
|
8
|
+
--
|
|
9
|
+
-- • the served version is `curl 127.0.0.1:<port>/version` ON the box (ADR 0293 D6) —
|
|
10
|
+
-- the apex sits behind pre-launch basic_auth and its HTML rides a CDN, so a public
|
|
11
|
+
-- read can report a stale answer with total confidence;
|
|
12
|
+
-- • the available versions are `npm view`, which update-channel.js runs via spawnSync.
|
|
13
|
+
-- On a single-process web server that blocks every other request for the seconds npm
|
|
14
|
+
-- takes — and npm took 8–13 minutes to publish on 2026-09-16 (learnings 1000263,
|
|
15
|
+
-- 1000266), so this is not a theoretical stall.
|
|
16
|
+
--
|
|
17
|
+
-- Both ARE cheap for the control-plane runner, which is a CLI process on the box and
|
|
18
|
+
-- already wakes on a timer. So the runner writes what it sees; the web tier reads a row.
|
|
19
|
+
-- That is the same direction of travel as the intent queue itself (ADR 0293 D1): the
|
|
20
|
+
-- privileged side acts and reports, the web side asks and displays.
|
|
21
|
+
--
|
|
22
|
+
-- ONE ROW PER INSTANCE, REPLACED IN PLACE. This is a cache of an observation, not a
|
|
23
|
+
-- ledger — core_upgrades is the ledger and keeps every move forever. Keeping history
|
|
24
|
+
-- here would grow a row per instance per tick to answer a question only ever asked about
|
|
25
|
+
-- the present.
|
|
26
|
+
--
|
|
27
|
+
-- observed_at IS PART OF THE ANSWER, NOT METADATA. A cache that renders as though it
|
|
28
|
+
-- were live is how a control tells someone the box is on a version it left an hour ago.
|
|
29
|
+
-- The UI is expected to say how old this is, which it can only do if the age is stored.
|
|
30
|
+
--
|
|
31
|
+
-- Additive, idempotent, no down-migration.
|
|
32
|
+
|
|
33
|
+
BEGIN;
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS core_version_snapshot (
|
|
36
|
+
instance_id bigint PRIMARY KEY REFERENCES provisioning_instances(id) ON DELETE CASCADE,
|
|
37
|
+
-- What 127.0.0.1/version answered. NULL means the box did not answer — a real and
|
|
38
|
+
-- different state from "answered with an old version", and the control must not
|
|
39
|
+
-- collapse the two into a confident number.
|
|
40
|
+
served_version text,
|
|
41
|
+
-- Versions the registry reports, newest last, as update-channel.js parsed them.
|
|
42
|
+
-- jsonb rather than text[]: it is written whole and read whole, never queried inside.
|
|
43
|
+
available jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
44
|
+
-- What the channel policy would actually take from `available` right now. Stored
|
|
45
|
+
-- rather than recomputed in the browser so the control and the runner cannot disagree
|
|
46
|
+
-- about what pressing the button would do.
|
|
47
|
+
recommended text,
|
|
48
|
+
observed_at timestamptz NOT NULL DEFAULT now()
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
INSERT INTO schema_migrations (version) VALUES ('core_242_core_version_snapshot')
|
|
52
|
+
ON CONFLICT (version) DO NOTHING;
|
|
53
|
+
|
|
54
|
+
COMMIT;
|
|
@@ -688,6 +688,8 @@ function createClient(opts = {}) {
|
|
|
688
688
|
postProjectsInvite: (args) => request("POST", "/projects/invite", { hasBody: true }, args),
|
|
689
689
|
},
|
|
690
690
|
"provisioning": {
|
|
691
|
+
// GET /provisioning/control-plane — rank: archon — GET /provisioning/control-plane
|
|
692
|
+
getProvisioningControlPlane: (args) => request("GET", "/provisioning/control-plane", { hasBody: false }, args),
|
|
691
693
|
// GET /provisioning/fleet — rank: metic+archon — GET /provisioning/fleet
|
|
692
694
|
getProvisioningFleet: (args) => request("GET", "/provisioning/fleet", { hasBody: false }, args),
|
|
693
695
|
// GET /provisioning/fleet/cost-ledger — rank: metic+archon — GET /provisioning/fleet/cost-ledger
|
|
@@ -704,6 +706,10 @@ function createClient(opts = {}) {
|
|
|
704
706
|
postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
|
|
705
707
|
// GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
|
|
706
708
|
getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
|
|
709
|
+
// GET /provisioning/instances/{id}/core-upgrade — rank: archon — GET /provisioning/instances/:id/core-upgrade
|
|
710
|
+
getProvisioningInstancesIdCoreUpgrade: (args) => request("GET", "/provisioning/instances/{id}/core-upgrade", { hasBody: false }, args),
|
|
711
|
+
// POST /provisioning/instances/{id}/core-upgrade — rank: archon — POST /provisioning/instances/:id/core-upgrade
|
|
712
|
+
postProvisioningInstancesIdCoreUpgrade: (args) => request("POST", "/provisioning/instances/{id}/core-upgrade", { hasBody: true }, args),
|
|
707
713
|
// PATCH /provisioning/instances/{id}/detail — rank: any-builder — PATCH /provisioning/instances/:id/detail
|
|
708
714
|
patchProvisioningInstancesIdDetail: (args) => request("PATCH", "/provisioning/instances/{id}/detail", { hasBody: true }, args),
|
|
709
715
|
// POST /provisioning/instances/{id}/disconnect — rank: any-builder — POST /provisioning/instances/:id/disconnect
|
|
@@ -101,6 +101,14 @@ const PERMISSIONS = [
|
|
|
101
101
|
// enforced out of band and can never be delegated by a row in this database.
|
|
102
102
|
{ key: 'builder.rank.set', system: true, floor: 'archon', guards: 'PATCH /builders/:id/rank — the single rank writer' },
|
|
103
103
|
{ key: 'version.close', system: true, floor: 'archon', guards: 'version close disposition (never delegated below Archon)' },
|
|
104
|
+
// core.pin.move (task 1003159, ADR 0293) — MOVE AN INSTANCE'S CORE PIN: the act
|
|
105
|
+
// that makes shipped work visible to users, and the most privileged thing an HTTP
|
|
106
|
+
// request can start here (it restarts a service and changes what code runs).
|
|
107
|
+
// system:true + archon, alongside version.close, because it is a HARD GATE the
|
|
108
|
+
// owner holds: the catalog's own invariant forbids granting a system key to metic,
|
|
109
|
+
// so this cannot be delegated downward by a custom rank. The web tier still gains
|
|
110
|
+
// no shell - the route only QUEUES an intent the control-plane runner drains.
|
|
111
|
+
{ key: 'core.pin.move', system: true, floor: 'archon', guards: 'POST /provisioning/instances/:id/core-upgrade — gate 3 of the release pipeline (ADR 0293)' },
|
|
104
112
|
// BV1.R21 (task 1003608, goal 1000086). ADR 0250 §6 amends ADR 0157 for exactly
|
|
105
113
|
// this key: `version.create` returns to the Archon floor.
|
|
106
114
|
//
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- government_014_core_pin_move.sql — grant `core.pin.move` to the Archon
|
|
2
|
+
-- rank-role (task 1003159, ADR 0293).
|
|
3
|
+
--
|
|
4
|
+
-- The atom gates the owner's deploy door: POST /provisioning/instances/:id/
|
|
5
|
+
-- core-upgrade, which queues an intent the control-plane runner drains to move an
|
|
6
|
+
-- instance's core pin. That is gate 3 of the release pipeline (ADR 0137) and the
|
|
7
|
+
-- only act that makes shipped work visible to users.
|
|
8
|
+
--
|
|
9
|
+
-- ARCHON ONLY, AND system:true. Two separate statements about the same key:
|
|
10
|
+
--
|
|
11
|
+
-- • the FLOOR is archon because deploy is one of the hard gates the owner holds.
|
|
12
|
+
-- Cloud Bongos is AI-first — agents write the code, the human holds the gates —
|
|
13
|
+
-- and this task exists because that gate had no instrument, not because it
|
|
14
|
+
-- should be widely held.
|
|
15
|
+
--
|
|
16
|
+
-- • `system: true` is what makes the floor un-delegatable. The catalog's own
|
|
17
|
+
-- invariant (tests/government_catalog.mjs) forbids granting a system key to
|
|
18
|
+
-- metic, so no custom rank can be handed this without clearing the flag first —
|
|
19
|
+
-- a deliberate, visible edit. It sits beside `version.close` and
|
|
20
|
+
-- `task.confirm.grade_bypass` for exactly the reason ADR 0157 gives about the
|
|
21
|
+
-- escalation keys: a power that can be delegated downward by someone holding it
|
|
22
|
+
-- is not really a gate.
|
|
23
|
+
--
|
|
24
|
+
-- ONLY archon is granted, so unlike government_007 this adds exactly one row.
|
|
25
|
+
-- There is no metic grant to write, which is the point.
|
|
26
|
+
--
|
|
27
|
+
-- The compat seed (governance_002) is RUN-ONCE, so a new atom cannot be added by
|
|
28
|
+
-- editing it — every already-migrated database would never see the grant. This is
|
|
29
|
+
-- the follow-on grant migration that header calls for.
|
|
30
|
+
--
|
|
31
|
+
-- tests/governance_seed.mjs reads the UNION of the seed and every later government_*
|
|
32
|
+
-- grant migration and asserts it equals catalog.js's floor-derived RANK_ROLE_SEED,
|
|
33
|
+
-- so this file is what keeps the SQL snapshot equal to the catalog entry.
|
|
34
|
+
--
|
|
35
|
+
-- Additive, idempotent (ON CONFLICT DO NOTHING), forward-safe: INSERT only, nothing
|
|
36
|
+
-- dropped or retyped, no down-migration.
|
|
37
|
+
|
|
38
|
+
BEGIN;
|
|
39
|
+
|
|
40
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
41
|
+
SELECT 'archon', unnest(ARRAY[
|
|
42
|
+
'core.pin.move'
|
|
43
|
+
])
|
|
44
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
45
|
+
|
|
46
|
+
COMMIT;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Deploy — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="Move this instance onto a newer core and read back the version the box actually serves — the deploy gate, without a shell or an ssh key.">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
+
<link rel="stylesheet" href="/builders/oversight.css">
|
|
13
|
+
<link rel="stylesheet" href="/builders/project-settings.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body data-page="deploy" data-page-title="Deploy">
|
|
16
|
+
|
|
17
|
+
<div class="app">
|
|
18
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
19
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
20
|
+
<div class="app-body">
|
|
21
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
22
|
+
<div class="app-content">
|
|
23
|
+
<div class="page-head page-head--board">
|
|
24
|
+
<h1 class="page-head__h">Deploy</h1>
|
|
25
|
+
<p class="page-head__sub" id="dp-sub">Loading…</p>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<main id="hall-main">
|
|
29
|
+
<section class="scroll" id="dp-sealed" aria-labelledby="dp-sealed-h" hidden>
|
|
30
|
+
<h2 class="scroll__h" id="dp-sealed-h">Restricted</h2>
|
|
31
|
+
<p class="scroll__lede" id="dp-sealed-body">This page opens at a higher rank.</p>
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<section class="scroll" id="dp-body" aria-labelledby="dp-body-h" hidden>
|
|
35
|
+
<h2 class="scroll__h" id="dp-body-h">What users can see</h2>
|
|
36
|
+
<!-- The honest framing, and the one thing a reader must not misread:
|
|
37
|
+
shipping work and deploying it are different acts. "Merged" does not
|
|
38
|
+
mean visible (ADR 0137's three gates); this page is the third one. -->
|
|
39
|
+
<p class="scroll__lede">
|
|
40
|
+
Merging work does not put it in front of anyone. This is the step that does:
|
|
41
|
+
it moves this instance onto a newer core, waits for the box to answer, and
|
|
42
|
+
puts itself back if it does not.
|
|
43
|
+
</p>
|
|
44
|
+
<!-- One mount, written by deploy.js. Nothing here is authored as markup with
|
|
45
|
+
a version in it: every number on this page is a reading the control plane
|
|
46
|
+
took, carrying the time it took it. -->
|
|
47
|
+
<div id="dp-panel"><span class="kit-skel"></span></div>
|
|
48
|
+
</section>
|
|
49
|
+
</main>
|
|
50
|
+
|
|
51
|
+
<footer class="app-foot" id="app-foot"></footer>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
57
|
+
|
|
58
|
+
<script src="/builders/shell.js?v=2026-07-02-shell1"></script>
|
|
59
|
+
<script src="/builders/palette.js" defer></script>
|
|
60
|
+
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
61
|
+
<script src="/builders/hall-kit.js"></script>
|
|
62
|
+
<script src="/builders/deploy.js?v=2026-09-16-deploy1"></script>
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// deploy.js — the owner's deploy door (task 1003159, ADR 0293).
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS PAGE IS FOR. The owner, twice in a month: "I don't think I can make anything
|
|
4
|
+
// live", then "this is above my paygrade." Moving this instance's core pin is gate 3 of
|
|
5
|
+
// the release pipeline and the ONLY act that makes shipped work visible to users — and it
|
|
6
|
+
// was reachable from exactly one place, a shell on the control-plane box holding an ssh
|
|
7
|
+
// key. An agent could not close the loop either: the permission classifier blocks a
|
|
8
|
+
// mutating `sudo -u lars` over ssh by design. So the one gate that is the owner's to hold
|
|
9
|
+
// was the one gate with no instrument. This page is the instrument.
|
|
10
|
+
//
|
|
11
|
+
// THE RULE THAT SHAPES EVERY LINE HERE: this page KNOWS NOTHING. Every version on it is a
|
|
12
|
+
// reading the control-plane runner took on the box, carried through the server with the
|
|
13
|
+
// time it was taken. Nothing is computed here, no version is compared here, and "up to
|
|
14
|
+
// date" is never inferred from an absence — the server says `behind` or it does not, and
|
|
15
|
+
// a missing reading renders as a missing reading. A deploy control that guesses is worse
|
|
16
|
+
// than none, because it is trusted.
|
|
17
|
+
//
|
|
18
|
+
// TWO STEPS, NEVER ONE (ADR 0293 D4). The owner asked for the confirmation. Auto-rollback
|
|
19
|
+
// already guards the bad release; this guards the wrong button. Step one shows what would
|
|
20
|
+
// move; step two names the version it saw and sends that. The confirm button is built
|
|
21
|
+
// FROM the previewed version, so a release that lands between the two steps cannot
|
|
22
|
+
// silently change what the press does.
|
|
23
|
+
(() => {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const API = '/api/bongos';
|
|
27
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
28
|
+
const { escapeHtml } = window.OTB;
|
|
29
|
+
const toast = (msg) => window.OTB.toast(msg);
|
|
30
|
+
const $ = (id) => document.getElementById(id);
|
|
31
|
+
|
|
32
|
+
// The preview the server last handed us. The confirm step reads its version from HERE
|
|
33
|
+
// rather than from the DOM, so what is sent is what was previewed even if a poll
|
|
34
|
+
// repaints between the two clicks.
|
|
35
|
+
let previewed = null;
|
|
36
|
+
|
|
37
|
+
async function fetchJson(path) {
|
|
38
|
+
const res = await api.request('GET', `${API}${path}`);
|
|
39
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
40
|
+
if (res.status === 403) { throw { kind: '403' }; }
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const err = (res.data && res.data.error) || {};
|
|
43
|
+
throw new Error(err.message && err.message !== err.code ? err.message : (err.code || `${path} → ${res.status}`));
|
|
44
|
+
}
|
|
45
|
+
return res.data;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function sendJson(method, path, body) {
|
|
49
|
+
const res = await api.request(method, `${API}${path}`, { body: body || {} });
|
|
50
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
51
|
+
const data = res.data || {};
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const err = data.error || {};
|
|
54
|
+
// The server's sentence when it wrote one — its refusals name what to do instead,
|
|
55
|
+
// and re-wording them here would lose that. Never a message invented on this page.
|
|
56
|
+
throw new Error(err.message && err.message !== err.code ? err.message : (err.code || `${path} → ${res.status}`));
|
|
57
|
+
}
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// "3 minutes ago" for a reading's age. The age is part of the answer, not decoration:
|
|
62
|
+
// every number on this page came from a cache, and a cache rendered as though it were
|
|
63
|
+
// live is how someone is told their box is on a version it left an hour ago.
|
|
64
|
+
function ago(iso) {
|
|
65
|
+
if (!iso) return 'never';
|
|
66
|
+
const secs = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
|
|
67
|
+
if (secs < 90) return `${secs}s ago`;
|
|
68
|
+
const mins = Math.round(secs / 60);
|
|
69
|
+
if (mins < 90) return `${mins} min ago`;
|
|
70
|
+
return `${Math.round(mins / 60)}h ago`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function versionHtml(v) {
|
|
74
|
+
return v ? `<span class="ov-mono">${escapeHtml(String(v))}</span>` : '<span class="ps-pill ps-pill--absent">no reading</span>';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- the three states this page can be in --------------------------------
|
|
78
|
+
|
|
79
|
+
// Something is already running. The press is not offered at all — offering a button
|
|
80
|
+
// that the server will refuse with 'intent_conflict' is a worse experience than saying
|
|
81
|
+
// plainly that the box is busy.
|
|
82
|
+
function busyHtml(p) {
|
|
83
|
+
const i = p.open_intent;
|
|
84
|
+
return `
|
|
85
|
+
<p class="ov-row__sub">A <strong>${escapeHtml(i.action)}</strong> is ${escapeHtml(i.state)} for this box${
|
|
86
|
+
i.target_version ? ` (to core ${escapeHtml(i.target_version)})` : ''}.
|
|
87
|
+
Nothing else can be queued until it settles; this page will notice when it does.</p>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stepOneHtml(p) {
|
|
91
|
+
return `
|
|
92
|
+
<p class="ov-row__sub">Core <strong>${escapeHtml(String(p.recommended_version))}</strong> is published and
|
|
93
|
+
this box is not on it yet.</p>
|
|
94
|
+
<div class="ps-controls">
|
|
95
|
+
<button type="button" class="btn" id="dp-preview">See what would change</button>
|
|
96
|
+
</div>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Step two. The version is written into the button's own label and into `previewed`,
|
|
100
|
+
// so the owner confirms the thing they were shown rather than "the newest", which is a
|
|
101
|
+
// different act by the time it is resolved.
|
|
102
|
+
function stepTwoHtml(p) {
|
|
103
|
+
return `
|
|
104
|
+
<div class="ps-controls" id="dp-confirm-wrap">
|
|
105
|
+
<p class="ov-row__sub">This restarts the platform onto core
|
|
106
|
+
<strong>${escapeHtml(String(p.recommended_version))}</strong>. It waits for the box to answer and
|
|
107
|
+
puts itself back on ${escapeHtml(String(p.served_version))} if it does not.</p>
|
|
108
|
+
<button type="button" class="btn btn--primary" id="dp-confirm">Move to ${escapeHtml(String(p.recommended_version))}</button>
|
|
109
|
+
<button type="button" class="btn" id="dp-cancel">Cancel</button>
|
|
110
|
+
</div>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function panelHtml(p, step) {
|
|
114
|
+
const rows = [
|
|
115
|
+
`<li class="ov-row"><div class="ov-row__main">
|
|
116
|
+
<span class="ov-row__title">Serving now</span>
|
|
117
|
+
<p class="ov-row__sub">${versionHtml(p.served_version)} — read from the box itself, ${escapeHtml(ago(p.observed_at))}.</p>
|
|
118
|
+
${p.reading === 'no_answer'
|
|
119
|
+
? `<p class="ps-invalid">The box did not answer when the control plane last looked, so this version is unknown rather than unchanged. A move is not offered while that is true.</p>`
|
|
120
|
+
: ''}
|
|
121
|
+
</div></li>`,
|
|
122
|
+
];
|
|
123
|
+
let action = '';
|
|
124
|
+
if (p.open_intent) action = busyHtml(p);
|
|
125
|
+
else if (p.reading === 'no_answer') action = '';
|
|
126
|
+
else if (!p.behind) action = `<p class="ov-row__sub">Nothing newer is published that this box can take. There is nothing to deploy.</p>`;
|
|
127
|
+
else action = step === 'confirm' ? stepTwoHtml(p) : stepOneHtml(p);
|
|
128
|
+
rows.push(`<li class="ov-row"><div class="ov-row__main">
|
|
129
|
+
<span class="ov-row__title">Make it live</span>
|
|
130
|
+
${action}
|
|
131
|
+
</div></li>`);
|
|
132
|
+
return `<ul class="ov-lines">${rows.join('')}</ul>
|
|
133
|
+
<p class="ps-prov">reads the control plane's own observation · ADR 0293 · every move is recorded with who asked</p>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- wiring --------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function render(p, step) {
|
|
139
|
+
previewed = p;
|
|
140
|
+
$('dp-panel').innerHTML = panelHtml(p, step);
|
|
141
|
+
const preview = $('dp-preview');
|
|
142
|
+
if (preview) preview.addEventListener('click', () => render(p, 'confirm'));
|
|
143
|
+
const cancel = $('dp-cancel');
|
|
144
|
+
if (cancel) cancel.addEventListener('click', () => render(p, 'preview'));
|
|
145
|
+
const confirm = $('dp-confirm');
|
|
146
|
+
if (confirm) confirm.addEventListener('click', () => move(p));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function move(p) {
|
|
150
|
+
const btn = $('dp-confirm');
|
|
151
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
|
|
152
|
+
try {
|
|
153
|
+
// `p.recommended_version` — the version this owner was SHOWN, not whatever is
|
|
154
|
+
// newest at the moment of the click.
|
|
155
|
+
const r = await sendJson('POST', `/provisioning/instances/${p.instance_id}/core-upgrade`, { to: p.recommended_version });
|
|
156
|
+
toast(r.message || 'Queued.');
|
|
157
|
+
await load();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (e && e.kind === '401') return;
|
|
160
|
+
toast(e.message || 'Could not queue the move.');
|
|
161
|
+
if (btn) { btn.disabled = false; btn.textContent = `Move to ${p.recommended_version}`; }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function load() {
|
|
166
|
+
try {
|
|
167
|
+
const d = await fetchJson('/provisioning/control-plane');
|
|
168
|
+
$('dp-sub').textContent = `${d.preview.slug} — the box this platform runs on`;
|
|
169
|
+
$('dp-body').hidden = false;
|
|
170
|
+
render(d.preview, 'preview');
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e && e.kind === '401') return;
|
|
173
|
+
if (e && e.kind === '403') {
|
|
174
|
+
$('dp-sealed').hidden = false;
|
|
175
|
+
$('dp-sub').textContent = 'Restricted';
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Including 'control_plane_not_enrolled', whose message names the one command
|
|
179
|
+
// that fixes it — which is why the server's sentence is shown rather than a
|
|
180
|
+
// generic failure line.
|
|
181
|
+
$('dp-body').hidden = false;
|
|
182
|
+
$('dp-sub').textContent = 'Unavailable';
|
|
183
|
+
$('dp-panel').innerHTML = `<p class="ps-invalid">${escapeHtml(e.message || 'Could not read the deploy state.')}</p>`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
document.addEventListener('DOMContentLoaded', load);
|
|
188
|
+
})();
|
|
@@ -579,70 +579,14 @@
|
|
|
579
579
|
};
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
-
// ---- ship-time task visual (task
|
|
582
|
+
// ---- ship-time task visual — MOVED OUT (task 1003835) ---------------------
|
|
583
583
|
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
// Lives here because three separate feeds render it (Standing's ship ledger,
|
|
591
|
-
// a profile's recent ships, the status dashboard) and a fourth copy of an
|
|
592
|
-
// HTML-building helper is how the pre-kit drift started.
|
|
593
|
-
|
|
594
|
-
// Same-origin, SERVER-MINTED paths only. The URL arrives from our own API, but
|
|
595
|
-
// it lands in an href and a src, so it is matched literally against the shape
|
|
596
|
-
// modules/lifecycle/task-visuals.js mints rather than trusted. Anything else
|
|
597
|
-
// renders nothing at all.
|
|
598
|
-
const VISUAL_URL_RE = /^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
|
|
599
|
-
function isTaskVisualUrl(url) {
|
|
600
|
-
return VISUAL_URL_RE.test(String(url ?? ''));
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// The compact thumbnail. Returns '' when the row carries no visual — a task
|
|
604
|
-
// without one must never look incomplete, so nothing is drawn (no placeholder).
|
|
605
|
-
function shipVisualThumbHtml(row) {
|
|
606
|
-
const url = String((row && row.visual_url) ?? '');
|
|
607
|
-
if (!isTaskVisualUrl(url)) return '';
|
|
608
|
-
const alt = String((row && row.visual_alt) || `Visual for task ${(row && row.id) ?? ''}`).trim();
|
|
609
|
-
return `<a href="${esc(url)}" target="_blank" rel="noopener" title="${esc(alt)}">` +
|
|
610
|
-
`<img class="ship-visual-thumb" src="${esc(url)}" alt="${esc(alt)}" loading="lazy"></a>`;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// The FULL-SIZE form: the picture as the thing being looked at, with
|
|
614
|
-
// visual_alt as its caption. The thumbnail above is for a FEED, where the
|
|
615
|
-
// summary is one line in a list; this is for a surface whose subject IS the
|
|
616
|
-
// image — the task record, and the Studio's review card (task 1003829).
|
|
617
|
-
//
|
|
618
|
-
// It lives here so the Studio did not become the THIRD hand-rolled <figure>
|
|
619
|
-
// for this one image. Be precise about what that did and did not fix: the two
|
|
620
|
-
// that already existed — task.js's taskVisualHtml (.tk-visual) and
|
|
621
|
-
// task-detail.js's overlay (.td-visual) — are NOT converged onto this yet and
|
|
622
|
-
// are untouched by the task that added it (1003829). Neither page loads the
|
|
623
|
-
// kit, so migrating them means adding it to task.html, which is a call of its
|
|
624
|
-
// own. So: one canonical renderer exists and new callers take it; the two
|
|
625
|
-
// legacy copies are a known, named cleanup rather than a claim already met.
|
|
626
|
-
//
|
|
627
|
-
// `className` is the caller's BEM base, because the size is the surface's
|
|
628
|
-
// decision and not the kit's: the task record caps at 52vh inside a record
|
|
629
|
-
// column (.tk-visual), the Studio gives the plate the card's whole width.
|
|
630
|
-
// The markup, the URL guard and the escaping are the kit's and identical
|
|
631
|
-
// everywhere. Returns '' with no visual, like the thumbnail.
|
|
632
|
-
function taskVisualFigureHtml(row, { className = 'task-visual' } = {}) {
|
|
633
|
-
const url = String((row && row.visual_url) ?? '');
|
|
634
|
-
if (!isTaskVisualUrl(url)) return '';
|
|
635
|
-
const written = String((row && row.visual_alt) || '').trim();
|
|
636
|
-
const alt = written || `Visual for task ${(row && row.id) ?? ''}`;
|
|
637
|
-
const base = String(className).replace(/[^A-Za-z0-9_-]/g, '');
|
|
638
|
-
// The caption is only drawn when the shipper WROTE one — the fallback alt is
|
|
639
|
-
// a machine sentence for a screen reader, not a caption to show a reader.
|
|
640
|
-
const caption = written ? `<figcaption class="${base}__caption">${esc(written)}</figcaption>` : '';
|
|
641
|
-
return `<figure class="${base}">` +
|
|
642
|
-
`<a href="${esc(url)}" target="_blank" rel="noopener">` +
|
|
643
|
-
`<img class="${base}__img" src="${esc(url)}" alt="${esc(alt)}" loading="lazy">` +
|
|
644
|
-
`</a>${caption}</figure>`;
|
|
645
|
-
}
|
|
584
|
+
// isTaskVisualUrl / shipVisualThumbHtml / taskVisualFigureHtml now live in
|
|
585
|
+
// hall-visual.js. They left because the task record page renders a visual and
|
|
586
|
+
// needs nothing else in this file, so converging it here would have loaded 61KB
|
|
587
|
+
// on the hall's most-visited record page for one function. A page that draws a
|
|
588
|
+
// visual loads hall-visual.js; pages that only use the kit no longer carry the
|
|
589
|
+
// renderers at all. Callers read window.OTBVisual, not OTBKit.
|
|
646
590
|
|
|
647
591
|
// ---- charts (task 1003445) ------------------------------------------------
|
|
648
592
|
//
|
|
@@ -1218,8 +1162,6 @@
|
|
|
1218
1162
|
plotBox, plotWindow,
|
|
1219
1163
|
// states
|
|
1220
1164
|
emptyStateHtml, skeletonRows, collapsibleCard,
|
|
1221
|
-
// ship-time task visual (task 1003109; the full-size figure, task 1003829)
|
|
1222
|
-
isTaskVisualUrl, shipVisualThumbHtml, taskVisualFigureHtml,
|
|
1223
1165
|
// age (task 1002955)
|
|
1224
1166
|
fmtAge, fmtAbsDate, ageChipHtml, AGE_STALE_DAYS,
|
|
1225
1167
|
};
|
|
@@ -1100,7 +1100,7 @@
|
|
|
1100
1100
|
<td>
|
|
1101
1101
|
<div>${escapeHtml(s.title)}</div>
|
|
1102
1102
|
${s.value_summary ? `<div style="color:var(--ink-soft);font-size:0.88rem;font-style:italic;margin-top:2px">${escapeHtml(s.value_summary)}</div>` : ''}
|
|
1103
|
-
${s.visual_url ? `<div style="margin-top:6px">${window.
|
|
1103
|
+
${s.visual_url && window.OTBVisual ? `<div style="margin-top:6px">${window.OTBVisual.shipVisualThumbHtml(s)}</div>` : ''}
|
|
1104
1104
|
</td>
|
|
1105
1105
|
<td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
|
|
1106
1106
|
<td class="ledger__date">${fmtDate(s.shipped_at)}</td>
|