@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.
Files changed (64) hide show
  1. package/.bongos-core.json +143 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +129 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +89 -72
  12. package/docs/copy-registry.json +272 -98
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/core-release-pipeline.md +12 -0
  15. package/migrations/core_241_core_upgrades_actor.sql +65 -0
  16. package/migrations/core_242_core_version_snapshot.sql +54 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  18. package/modules/government/catalog.js +8 -0
  19. package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
  20. package/modules/hall-ui/public/deploy.html +64 -0
  21. package/modules/hall-ui/public/deploy.js +188 -0
  22. package/modules/hall-ui/public/hall-kit.js +7 -65
  23. package/modules/hall-ui/public/hall-render.js +1 -1
  24. package/modules/hall-ui/public/hall-visual.js +85 -0
  25. package/modules/hall-ui/public/index.html +1 -0
  26. package/modules/hall-ui/public/profile.html +1 -0
  27. package/modules/hall-ui/public/profile.js +2 -2
  28. package/modules/hall-ui/public/shell.js +4 -0
  29. package/modules/hall-ui/public/studio.css +1 -1
  30. package/modules/hall-ui/public/studio.html +1 -0
  31. package/modules/hall-ui/public/studio.js +4 -3
  32. package/modules/hall-ui/public/style.css +0 -16
  33. package/modules/hall-ui/public/task.html +1 -0
  34. package/modules/hall-ui/public/task.js +10 -13
  35. package/modules/provisioning/control-plane-guard.js +102 -0
  36. package/modules/provisioning/core-upgrade.js +289 -0
  37. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  38. package/modules/provisioning/module.json +1 -1
  39. package/modules/provisioning/provisioning.js +8 -8
  40. package/modules/provisioning/routes/core-upgrade.js +95 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/scripts/gds/accessibility-replay.js +195 -0
  44. package/scripts/gds/enroll-control-plane.js +101 -0
  45. package/scripts/gds/provision-core-upgrade.js +311 -0
  46. package/scripts/gds/provision.js +4 -2
  47. package/scripts/gds/upgrade.js +53 -14
  48. package/src/bongos/auth.js +19 -0
  49. package/src/bongos/serve-internal.js +21 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/accessibility_replay.mjs +201 -0
  52. package/tests/auth_resolve_failure.mjs +4 -2
  53. package/tests/core_upgrade_door.mjs +408 -0
  54. package/tests/core_upgrade_runner.mjs +377 -0
  55. package/tests/fixtures/accessibility-replay.json +93 -0
  56. package/tests/hall_nav.mjs +4 -1
  57. package/tests/hall_page_gate_map.mjs +7 -0
  58. package/tests/hall_studio_world.mjs +19 -11
  59. package/tests/provisioning_disconnect.mjs +30 -5
  60. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  61. package/tests/provisioning_restart.mjs +25 -9
  62. package/tests/provisioning_settings_apply.mjs +11 -2
  63. package/tests/provisioning_teardown_intent.mjs +7 -0
  64. 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 1003109, goal 1000072) -------------------
582
+ // ---- ship-time task visual — MOVED OUT (task 1003835) ---------------------
583
583
  //
584
- // A shipped task may carry an optional IMAGE alongside its value summary — the
585
- // same claim in another medium, so a non-engineer can SEE what changed rather
586
- // than read about it. In a FEED the summary is one line in a list, so the image
587
- // appears as a small thumbnail linking to the full-size file; the task page
588
- // renders it properly (task.js taskVisualHtml).
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.OTBKit.shipVisualThumbHtml(s)}</div>` : ''}
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>