@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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
-- provisioning_018_core_upgrade_intent.sql — the owner's deploy door (task
|
|
2
|
+
-- 1003159, ADR 0293).
|
|
3
|
+
--
|
|
4
|
+
-- NUMBERED 018, NOT 016: this shipped as 016 on a branch while 016_disconnect and
|
|
5
|
+
-- 017_domain_lookup_index landed on main. Two files claiming one number is not a
|
|
6
|
+
-- merge artefact to tidy after the fact — migrations run in name order, and the
|
|
7
|
+
-- action CHECK below REPLACES the one 016_disconnect writes, so running them in
|
|
8
|
+
-- the wrong order would drop 'disconnect' from the vocabulary. It goes last, and
|
|
9
|
+
-- it carries the whole set.
|
|
10
|
+
--
|
|
11
|
+
-- Moving an instance's core pin is gate 3 of the release pipeline and the only act
|
|
12
|
+
-- that makes shipped work visible to users. Until now it was reachable from exactly
|
|
13
|
+
-- one place: a shell on the control-plane box holding an ssh key. The owner said so
|
|
14
|
+
-- twice in a month — "I don't think I can make anything live", then "this is above
|
|
15
|
+
-- my paygrade" — and an agent could not close the loop either, because the
|
|
16
|
+
-- permission classifier blocks a mutating `sudo -u lars` over ssh by design.
|
|
17
|
+
--
|
|
18
|
+
-- Two widenings, and they are two halves of one door:
|
|
19
|
+
--
|
|
20
|
+
-- 1. 'core-upgrade' joins the intent vocabulary. The web tier QUEUES it; the
|
|
21
|
+
-- control-plane runner drains it and drives the proven `bongos upgrade` path
|
|
22
|
+
-- with its health gate and auto-rollback intact. The web process gains no
|
|
23
|
+
-- sudo and no ssh key — it gains the ability to ASK (ADR 0293 D1).
|
|
24
|
+
--
|
|
25
|
+
-- 2. 'control-plane' joins the hosting shapes. `provisioning_intents.instance_id`
|
|
26
|
+
-- is NOT NULL REFERENCES provisioning_instances(id), so an intent must name a
|
|
27
|
+
-- row, and the box this software runs on has never had one — the table holds
|
|
28
|
+
-- only tenant projects. Enrolling it is what gives the door something to point
|
|
29
|
+
-- at (ADR 0293 D2).
|
|
30
|
+
--
|
|
31
|
+
-- THE SELF ROW IS THE DANGEROUS HALF, AND THE GUARD IS IN CODE, NOT HERE.
|
|
32
|
+
-- 'teardown' aimed at the control-plane row would destroy the platform from inside
|
|
33
|
+
-- the platform's own UI. `intentAllowedForShape()` in modules/provisioning/
|
|
34
|
+
-- provisioning.js is the single place that decides, as an ALLOW-list
|
|
35
|
+
-- (CONTROL_PLANE_ACTIONS = core-upgrade, restart) rather than a deny-list of the
|
|
36
|
+
-- destructive ones — so an action added to INTENT_ACTIONS later is refused on the
|
|
37
|
+
-- self row until someone states it is safe there. That is the direction which
|
|
38
|
+
-- cannot destroy the platform by omission.
|
|
39
|
+
--
|
|
40
|
+
-- A CHECK constraint cannot express that rule (it would have to see both tables at
|
|
41
|
+
-- once), which is exactly why the shape is a distinct VALUE: it makes the rule
|
|
42
|
+
-- expressible in the one place that can enforce it.
|
|
43
|
+
--
|
|
44
|
+
-- Additive and namespaced (ADR 0083): no down-migration, every object
|
|
45
|
+
-- provisioning_-prefixed, idempotent — safe to re-run.
|
|
46
|
+
|
|
47
|
+
BEGIN;
|
|
48
|
+
|
|
49
|
+
-- 1. the intent vocabulary
|
|
50
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
51
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
52
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
|
|
53
|
+
'settings-apply', 'restart', 'disconnect', 'core-upgrade'));
|
|
54
|
+
|
|
55
|
+
-- 2. the hosting shapes
|
|
56
|
+
ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_hosting_shape_check;
|
|
57
|
+
ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_hosting_shape_check
|
|
58
|
+
CHECK (hosting_shape IN ('co-tenant', 'dedicated', 'standalone', 'control-plane'));
|
|
59
|
+
|
|
60
|
+
-- 3. the target a core-upgrade names.
|
|
61
|
+
--
|
|
62
|
+
-- No existing action carries a payload: settings-apply re-reads the instance row,
|
|
63
|
+
-- restart carries nothing. A core-upgrade MUST name a version, because "upgrade to
|
|
64
|
+
-- whatever is newest" resolved at drain time is a DIFFERENT act from the one the
|
|
65
|
+
-- owner previewed and confirmed (ADR 0293 D4) — main can move between the two.
|
|
66
|
+
--
|
|
67
|
+
-- The invariant is an EQUIVALENCE, and both directions are load-bearing: a
|
|
68
|
+
-- core-upgrade with no target is an intent the runner cannot execute, and a target
|
|
69
|
+
-- on any other action is a value nothing reads — which is how a payload column
|
|
70
|
+
-- quietly becomes a junk drawer. Valid immediately rather than NOT VALID: every
|
|
71
|
+
-- pre-existing row has a non-core-upgrade action and a NULL target, so it holds.
|
|
72
|
+
--
|
|
73
|
+
-- The format CHECK keeps free text out at the DB layer as well as the route's. The
|
|
74
|
+
-- REGISTRY is still the authority on whether the version exists; that check lives
|
|
75
|
+
-- in the runner, which can reach npm without blocking a web request.
|
|
76
|
+
ALTER TABLE provisioning_intents ADD COLUMN IF NOT EXISTS target_version text;
|
|
77
|
+
|
|
78
|
+
DO $$
|
|
79
|
+
BEGIN
|
|
80
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'provisioning_intents_target_version_chk') THEN
|
|
81
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_chk
|
|
82
|
+
CHECK ((action = 'core-upgrade') = (target_version IS NOT NULL));
|
|
83
|
+
END IF;
|
|
84
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'provisioning_intents_target_version_fmt_chk') THEN
|
|
85
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_fmt_chk
|
|
86
|
+
CHECK (target_version IS NULL OR target_version ~ '^[0-9]+\.[0-9]+\.[0-9]+$');
|
|
87
|
+
END IF;
|
|
88
|
+
END $$;
|
|
89
|
+
|
|
90
|
+
-- AT MOST ONE control-plane row, ever. The self row means "the box this process is
|
|
91
|
+
-- running on"; two of them would make "which one is me?" a question with no answer,
|
|
92
|
+
-- and the deploy door would point at whichever the query happened to return first.
|
|
93
|
+
-- A partial unique index over a constant is the standard way to say "at most one".
|
|
94
|
+
CREATE UNIQUE INDEX IF NOT EXISTS provisioning_instances_one_control_plane_idx
|
|
95
|
+
ON provisioning_instances ((hosting_shape))
|
|
96
|
+
WHERE hosting_shape = 'control-plane';
|
|
97
|
+
|
|
98
|
+
COMMIT;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"maintainer": "Off the Boats maintainers",
|
|
11
11
|
"maintenance": { "status": "core-maintained" },
|
|
12
12
|
"contributes": {
|
|
13
|
-
"routes": ["provisioning", "disconnect"],
|
|
13
|
+
"routes": ["provisioning", "disconnect", "core-upgrade"],
|
|
14
14
|
"migrations": true,
|
|
15
15
|
"pollers": ["liveness-sweep", "catalog-backfill"]
|
|
16
16
|
},
|
|
@@ -28,7 +28,7 @@ const INSTANCE_STATES = ['requested', 'provisioning', 'active', 'tearing_down',
|
|
|
28
28
|
// dedicated — its own DigitalOcean droplet (priced by tier)
|
|
29
29
|
// standalone — its OWN repo checkout + pinned @cloudbongos/core npm dep, co-located on
|
|
30
30
|
// the box (ADR 0108 destination model; owner-hosted, $0). See provision.js.
|
|
31
|
-
const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
|
|
31
|
+
const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone', 'control-plane'];
|
|
32
32
|
// How the instance's repo was onboarded (migration provisioning_004, ADR 0121):
|
|
33
33
|
// greenfield — a fresh EMPTY repo; the runner scaffolds it and pushes the first commit
|
|
34
34
|
// adopt — a repo the owner ALREADY has; the runner must LAYER onto it, preserving
|
|
@@ -59,7 +59,9 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
59
59
|
// env patch. Its own action because no existing one fits: provision refuses
|
|
60
60
|
// 'active' and settings-apply requires a value that actually changed.
|
|
61
61
|
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
62
|
-
|
|
62
|
+
// 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
|
|
63
|
+
// deploy door. The web tier only QUEUES it; the control-plane runner performs it.
|
|
64
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade'];
|
|
63
65
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
64
66
|
// state where there is provably nothing left to release. DERIVED from
|
|
65
67
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
@@ -1134,13 +1136,11 @@ async function setInstanceStatus(db, id, status, patch = {}) {
|
|
|
1134
1136
|
// Enqueue a provision/teardown intent. Idempotent: the partial unique index
|
|
1135
1137
|
// ux_provisioning_intents_open makes a second open enqueue collide (23505); we
|
|
1136
1138
|
// then return the already-open intent with created:false (mirrors boxes.enqueueIntent).
|
|
1137
|
-
async function enqueueIntent(db, instanceId, action, requestedBy) {
|
|
1139
|
+
async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion = null) {
|
|
1140
|
+
await require('./control-plane-guard').assertIntentAllowed(db, instanceId, action); // ADR 0293 D2; required HERE because this file is at the size ratchet
|
|
1138
1141
|
try {
|
|
1139
|
-
const { rows } = await db.query(
|
|
1140
|
-
|
|
1141
|
-
VALUES ($1, $2, $3, 'pending') RETURNING *`,
|
|
1142
|
-
[instanceId, action, requestedBy ?? null]
|
|
1143
|
-
);
|
|
1142
|
+
const { rows } = await db.query(`INSERT INTO provisioning_intents (instance_id, action, requested_by, state, target_version)
|
|
1143
|
+
VALUES ($1, $2, $3, 'pending', $4) RETURNING *`, [instanceId, action, requestedBy ?? null, targetVersion ?? null]);
|
|
1144
1144
|
return { intent: rows[0], created: true };
|
|
1145
1145
|
} catch (e) {
|
|
1146
1146
|
if (e && e.code === '23505') {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/routes/core-upgrade.js — the owner's deploy door
|
|
4
|
+
// (task 1003159, ADR 0293).
|
|
5
|
+
//
|
|
6
|
+
// Moving an instance's core pin is gate 3 of the release pipeline (ADR 0137) and
|
|
7
|
+
// the ONLY act that makes shipped work visible to users. Until this route it was
|
|
8
|
+
// reachable from exactly one place — a shell on the control-plane box holding an
|
|
9
|
+
// ssh key — which is why the owner said "I don't think I can make anything live",
|
|
10
|
+
// and why an agent could not close the loop for them either: the permission
|
|
11
|
+
// classifier blocks a mutating `sudo -u lars` over ssh by design.
|
|
12
|
+
//
|
|
13
|
+
// THE WEB TIER ONLY ASKS. This queues an intent; the control-plane runner drains it
|
|
14
|
+
// and drives the proven `bongos upgrade` path with its health gate and auto-rollback
|
|
15
|
+
// intact. This process gains no sudo, no ssh key, no new capability — ADR 0111 §2's
|
|
16
|
+
// rule and ADR 0293 D1's decision, and what keeps a stolen builder token able to
|
|
17
|
+
// REQUEST a deploy and never to perform one.
|
|
18
|
+
//
|
|
19
|
+
// ITS OWN ROUTER, not a block inside routes/provisioning.js, for the mechanical
|
|
20
|
+
// reason that file's own CLAUDE.md records: it sits AT the 1500-line fitness
|
|
21
|
+
// ratchet, and at that ceiling a route costs more lines than the build will give
|
|
22
|
+
// back. Multi-router modules are the norm here (lifecycle contributes eighteen), so
|
|
23
|
+
// this is the cheap, conventional shape rather than a workaround.
|
|
24
|
+
|
|
25
|
+
const express = require('express');
|
|
26
|
+
const api = require('../../../src/module-api');
|
|
27
|
+
const provisioning = require('../provisioning');
|
|
28
|
+
const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler } = require('../core-upgrade');
|
|
29
|
+
|
|
30
|
+
const log = api.logger('provisioning');
|
|
31
|
+
|
|
32
|
+
module.exports = function buildCoreUpgradeRouter() {
|
|
33
|
+
const router = express.Router();
|
|
34
|
+
|
|
35
|
+
// POST /provisioning/instances/:id/core-upgrade — move the core pin.
|
|
36
|
+
//
|
|
37
|
+
// rank: `core.pin.move` — archon floor AND system:true, so the catalog's own
|
|
38
|
+
// invariant forbids granting it to metic and no custom rank can be handed it
|
|
39
|
+
// without a deliberate, visible edit. BV1.R105 / ADR 0151 is why this is an atom
|
|
40
|
+
// and not `requireRank`: tests/government_require_permission.mjs fails the build
|
|
41
|
+
// on a new requireRank in a modules/*/routes gate.
|
|
42
|
+
//
|
|
43
|
+
// Body: { to: "1.19.757" } — and requiring it IS the second step of the two-step
|
|
44
|
+
// the owner asked for (ADR 0293 D4). A request that cannot fire without naming
|
|
45
|
+
// its target cannot fire from a stray click, and "move to whatever is newest",
|
|
46
|
+
// resolved at drain time, would be a different act from the one previewed: main
|
|
47
|
+
// moves between the preview and the confirm.
|
|
48
|
+
//
|
|
49
|
+
// What this does NOT check: whether the version is PUBLISHED, and whether the
|
|
50
|
+
// channel policy admits it. Both need the registry, and the only tool that reads
|
|
51
|
+
// it (update-channel.js `listAvailableVersions`) shells out synchronously — it
|
|
52
|
+
// would block the event loop for every other request on this process. It also
|
|
53
|
+
// lives in scripts/gds/, which a module may not require (ADR 0083). The runner
|
|
54
|
+
// enforces both, where it is a CLI and both are free. See ../core-upgrade.js.
|
|
55
|
+
router.post(
|
|
56
|
+
'/provisioning/instances/:id/core-upgrade',
|
|
57
|
+
api.requireBuilder,
|
|
58
|
+
api.requirePermission('core.pin.move'),
|
|
59
|
+
makeCoreUpgradeHandler({
|
|
60
|
+
provisioning,
|
|
61
|
+
pool: api.pool,
|
|
62
|
+
parseId: api.parseId,
|
|
63
|
+
validateOrRespond: api.validateOrRespond,
|
|
64
|
+
log,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// GET the same path — step ONE of the two-step (ADR 0293 D4): what a move would do.
|
|
69
|
+
//
|
|
70
|
+
// GATED THE SAME AS THE POST, deliberately. Reading it discloses the instance's exact
|
|
71
|
+
// core version and how far behind it is, which is a vulnerability-window disclosure —
|
|
72
|
+
// precisely the sort of thing ADR 0043 keeps off unauthenticated surfaces. A preview
|
|
73
|
+
// anyone could read would also let an attacker time a request against a known-stale
|
|
74
|
+
// window. The read being no cheaper than the write costs the owner nothing: they hold
|
|
75
|
+
// the atom by construction, because they are the only one who can press the button.
|
|
76
|
+
router.get(
|
|
77
|
+
'/provisioning/instances/:id/core-upgrade',
|
|
78
|
+
api.requireBuilder,
|
|
79
|
+
api.requirePermission('core.pin.move'),
|
|
80
|
+
makeCoreUpgradePreviewHandler({ provisioning, pool: api.pool, parseId: api.parseId, log }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// GET /provisioning/control-plane — "which row is this platform, and where does it
|
|
84
|
+
// stand?" The deploy page's first call: the self row is created once by hand at
|
|
85
|
+
// install time, so its id is per-install and nobody's to memorise. Same atom again,
|
|
86
|
+
// so the entrance is no wider than the door it leads to.
|
|
87
|
+
router.get(
|
|
88
|
+
'/provisioning/control-plane',
|
|
89
|
+
api.requireBuilder,
|
|
90
|
+
api.requirePermission('core.pin.move'),
|
|
91
|
+
makeControlPlaneHandler({ provisioning, pool: api.pool, log }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return router;
|
|
95
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.791",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.791",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.791",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// scripts/gds/accessibility-replay.js — the two-profile replay regression for the
|
|
2
|
+
// accessibility dimension (task 1002517; clauses 5 and 8 of criterion
|
|
3
|
+
// `accessibility-fluency-profile`, which named this run as its OWN proof).
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT PROVES, and why it is not already covered.
|
|
6
|
+
// tests/session_accessibility.mjs pins the scorer's contracts against synthetic
|
|
7
|
+
// fixtures it builds itself — a test of the arithmetic. It cannot answer the
|
|
8
|
+
// criterion's actual question, which is about PROSE: does the dimension reward a
|
|
9
|
+
// reply a newcomer can use, and does it leave an expert's reply alone? So this
|
|
10
|
+
// harness takes a FIXED set of eight questions a newcomer really asks, each the
|
|
11
|
+
// documented trigger of a surface that exists, answered twice — once in each
|
|
12
|
+
// register — and scores both runs.
|
|
13
|
+
//
|
|
14
|
+
// Four legs. Each one fails a different way of being wrong:
|
|
15
|
+
//
|
|
16
|
+
// 1. FLOOR the non-technical run, under the non-technical profile,
|
|
17
|
+
// clears FLOOR. The dimension recognises accessible prose.
|
|
18
|
+
// 2. NO PENALTY the engineer run, under the engineer profile, is not
|
|
19
|
+
// scored at all — every signal waived, score null.
|
|
20
|
+
// 3. DISCRIMINATION the SAME engineer prose under the NON-TECHNICAL profile
|
|
21
|
+
// falls BELOW FLOOR, with every detector registering hits.
|
|
22
|
+
// Without this leg 1 proves nothing — a scorer that returned
|
|
23
|
+
// 10 for everything, or three detectors that had gone blind,
|
|
24
|
+
// would pass leg 1 and look identical to a working one.
|
|
25
|
+
// 4. LENGTH `doubleRun` says the same prose twice. Chars double, hits
|
|
26
|
+
// double, the rate is unchanged, so the score must be
|
|
27
|
+
// IDENTICAL. That is the metric reading density rather than
|
|
28
|
+
// volume, asserted against real prose.
|
|
29
|
+
//
|
|
30
|
+
// WHERE TERSENESS PROTECTION ACTUALLY COMES FROM, since the task's "watch out"
|
|
31
|
+
// is a metric that punishes an expert's brevity. It is leg 2 — the WAIVER — not
|
|
32
|
+
// the per-1000-character rates. The rates make the score independent of how long
|
|
33
|
+
// an answer is at a fixed density (leg 4); they do NOT make it independent of
|
|
34
|
+
// density, and they should not: three undefined terms in three hundred
|
|
35
|
+
// characters really is denser than three in three thousand. What protects the
|
|
36
|
+
// expert is that their profile removes those two signals from the scoring set
|
|
37
|
+
// entirely. `terseVariant` — every turn cut to its first sentence, a genuinely
|
|
38
|
+
// shorter and thinner answer — is checked too, asserting only that answering
|
|
39
|
+
// more briefly never COSTS points; it is a guard, not the mechanism.
|
|
40
|
+
//
|
|
41
|
+
// One honest asymmetry this surfaced and the corpus now records: under
|
|
42
|
+
// `technical_level: engineer` with `toolchain_familiarity: new`, the engineer
|
|
43
|
+
// run still scores, because terminal-where-a-click-path-exists is waived by the
|
|
44
|
+
// TOOLCHAIN setting, not the technical one. "I think in code" and "I live in a
|
|
45
|
+
// terminal" are two different answers, and the scorer is right to keep them
|
|
46
|
+
// apart. tests/accessibility_replay.mjs pins that pairing so a future widening
|
|
47
|
+
// of the engineer waiver has to be a deliberate edit.
|
|
48
|
+
//
|
|
49
|
+
// The corpus is data — tests/fixtures/accessibility-replay.json — so adding a
|
|
50
|
+
// prompt is an edit to JSON, not to code.
|
|
51
|
+
//
|
|
52
|
+
// Run: node scripts/gds/accessibility-replay.js
|
|
53
|
+
// node scripts/gds/accessibility-replay.js --json
|
|
54
|
+
|
|
55
|
+
'use strict';
|
|
56
|
+
|
|
57
|
+
const fs = require('node:fs');
|
|
58
|
+
const path = require('node:path');
|
|
59
|
+
|
|
60
|
+
const { scoreAccessibility, bandFor } = require('../../modules/sessions/accessibility.js');
|
|
61
|
+
|
|
62
|
+
const CORPUS_PATH = path.join(__dirname, '..', '..', 'tests', 'fixtures', 'accessibility-replay.json');
|
|
63
|
+
|
|
64
|
+
// FLOOR — the score a non-technical run has to clear. It is bandFor()'s
|
|
65
|
+
// 'workable' boundary and deliberately not a second independent number: the
|
|
66
|
+
// scorer already draws the line between prose a newcomer can work with and prose
|
|
67
|
+
// that is merely dense, and inventing a different one here would let the two
|
|
68
|
+
// drift. tests/accessibility_replay.mjs asserts they stay tied.
|
|
69
|
+
const FLOOR = 6.0;
|
|
70
|
+
|
|
71
|
+
function loadCorpus(file = CORPUS_PATH) {
|
|
72
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// terseVariant — each turn cut to its first sentence: a shorter, thinner answer.
|
|
76
|
+
// Mechanical on purpose. A hand-written "terse version" in the corpus would be a
|
|
77
|
+
// version tuned until it passed; taking the first sentence of prose written for
|
|
78
|
+
// another purpose is a variant nobody got to tune.
|
|
79
|
+
function terseVariant(turns) {
|
|
80
|
+
return turns.map((t) => {
|
|
81
|
+
const m = /^[^.!?]*[.!?]/.exec(t);
|
|
82
|
+
return m ? m[0] : t;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// doubleRun — the same prose said twice. Every count doubles and so does the
|
|
87
|
+
// character total, so every rate is unchanged and the score must come back
|
|
88
|
+
// EXACTLY equal. This is the length-independence property at prose altitude: a
|
|
89
|
+
// scorer that had drifted to absolute counts would double its penalties here.
|
|
90
|
+
function doubleRun(turns) {
|
|
91
|
+
return [...turns, ...turns];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// asDetail — a run's replies as the per-turn digest shape scoreAccessibility
|
|
95
|
+
// reads. The user turns are included because a real digest has them and
|
|
96
|
+
// assistantProse must keep ignoring them; if it ever stopped, the prompts would
|
|
97
|
+
// start scoring the builder's own words and every number here would move.
|
|
98
|
+
function asDetail(corpus, replies, { transform } = {}) {
|
|
99
|
+
const detail = [];
|
|
100
|
+
let i = 0;
|
|
101
|
+
for (const { id, prompt } of corpus.prompts) {
|
|
102
|
+
detail.push({ i: i++, role: 'user', snippet: prompt });
|
|
103
|
+
const turns = replies[id] || [];
|
|
104
|
+
for (const snippet of transform ? transform(turns) : turns) {
|
|
105
|
+
detail.push({ i: i++, role: 'assistant', snippet });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return detail;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const TRANSFORMS = { terse: terseVariant, doubled: doubleRun };
|
|
112
|
+
|
|
113
|
+
// replay — score one named run under one profile. `profile` defaults to the
|
|
114
|
+
// run's own declared profile, which is the pairing the criterion asks about;
|
|
115
|
+
// passing another is how leg 3 reads engineer prose as a newcomer would.
|
|
116
|
+
function replay(corpus, runName, { profile, variant } = {}) {
|
|
117
|
+
const run = corpus.runs[runName];
|
|
118
|
+
if (!run) throw new Error(`no such run in the corpus: ${runName}`);
|
|
119
|
+
const transform = variant ? TRANSFORMS[variant] : undefined;
|
|
120
|
+
if (variant && !transform) throw new Error(`no such variant: ${variant}`);
|
|
121
|
+
const effective = profile || run.profile;
|
|
122
|
+
const result = scoreAccessibility(asDetail(corpus, run.replies, { transform }), { profile: effective });
|
|
123
|
+
return { run: runName, variant: variant || 'full', profile: effective, result };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The partial engineer profile — thinks in code, new to the toolchain. Named
|
|
127
|
+
// here because it is the pairing that shows the two waivers are independent.
|
|
128
|
+
const ENGINEER_NEW_TO_TOOLCHAIN = Object.freeze({ technical_level: 'engineer', toolchain_familiarity: 'new' });
|
|
129
|
+
|
|
130
|
+
function rows(corpus) {
|
|
131
|
+
const newcomer = corpus.runs.non_technical.profile;
|
|
132
|
+
return [
|
|
133
|
+
['non-technical prose, non-technical profile', replay(corpus, 'non_technical')],
|
|
134
|
+
['engineer prose, engineer profile', replay(corpus, 'engineer')],
|
|
135
|
+
['engineer prose, NON-TECHNICAL profile', replay(corpus, 'engineer', { profile: newcomer })],
|
|
136
|
+
['engineer prose, engineer but new to the terminal', replay(corpus, 'engineer', { profile: ENGINEER_NEW_TO_TOOLCHAIN })],
|
|
137
|
+
['non-technical prose, said twice', replay(corpus, 'non_technical', { variant: 'doubled' })],
|
|
138
|
+
['non-technical prose, first sentences only', replay(corpus, 'non_technical', { variant: 'terse' })],
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fmt(n) {
|
|
143
|
+
return n == null ? '—' : n.toFixed(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatReport(corpus) {
|
|
147
|
+
const lines = [`accessibility replay — ${corpus.prompts.length} fixed beginner prompts, floor ${FLOOR.toFixed(1)}`, ''];
|
|
148
|
+
for (const [label, r] of rows(corpus)) {
|
|
149
|
+
const { score, band, sampled_chars: chars, sampled_turns: turns } = r.result;
|
|
150
|
+
const waived = r.result.signals.filter((s) => s.waived).length;
|
|
151
|
+
lines.push(
|
|
152
|
+
` ${label.padEnd(48)} ${fmt(score).padStart(5)} ${band.padEnd(9)} ` +
|
|
153
|
+
`${String(turns).padStart(3)} turns ${String(chars).padStart(5)} chars ${waived}/3 waived`
|
|
154
|
+
);
|
|
155
|
+
for (const s of r.result.signals) {
|
|
156
|
+
if (s.waived) continue;
|
|
157
|
+
if (!s.count) continue;
|
|
158
|
+
lines.push(
|
|
159
|
+
` ${s.type.padEnd(28)} ${String(s.count).padStart(2)} hits rate ${s.rate.toFixed(2)}/1k −${s.penalty.toFixed(2)}`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// main — the CLI. `write` is injectable so the test can exercise both output
|
|
167
|
+
// paths without printing a screenful of JSON into the test log.
|
|
168
|
+
function main(argv = process.argv.slice(2), write = (s) => process.stdout.write(s)) {
|
|
169
|
+
const corpus = loadCorpus();
|
|
170
|
+
if (argv.includes('--json')) {
|
|
171
|
+
const out = { floor: FLOOR };
|
|
172
|
+
for (const [label, r] of rows(corpus)) out[label] = r.result;
|
|
173
|
+
write(`${JSON.stringify(out, null, 2)}\n`);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
write(`${formatReport(corpus)}\n`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
FLOOR,
|
|
182
|
+
CORPUS_PATH,
|
|
183
|
+
ENGINEER_NEW_TO_TOOLCHAIN,
|
|
184
|
+
loadCorpus,
|
|
185
|
+
terseVariant,
|
|
186
|
+
doubleRun,
|
|
187
|
+
asDetail,
|
|
188
|
+
replay,
|
|
189
|
+
rows,
|
|
190
|
+
formatReport,
|
|
191
|
+
bandFor,
|
|
192
|
+
main,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (require.main === module) process.exit(main());
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/enroll-control-plane.js — give the platform a row so its own pin can move
|
|
5
|
+
// (task 1003159, ADR 0293 D2).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS EXISTS AT ALL. `provisioning_intents.instance_id` is NOT NULL REFERENCES
|
|
8
|
+
// provisioning_instances(id), so every intent must name a row. That table has only ever
|
|
9
|
+
// held tenant projects — the box this software runs on has no row — which is the
|
|
10
|
+
// mechanical reason the owner had no deploy door: there was nothing to point the door at.
|
|
11
|
+
//
|
|
12
|
+
// IDEMPOTENT, AND ONLY EVER ONE. provisioning_instances_one_control_plane_idx makes a
|
|
13
|
+
// second control-plane row impossible at the database, because "which row is me?" must
|
|
14
|
+
// have exactly one answer; this script resolves the existing row and reports it rather
|
|
15
|
+
// than racing that index.
|
|
16
|
+
//
|
|
17
|
+
// RUN ONCE PER INSTALL, by whoever already has the box — the same person who runs
|
|
18
|
+
// migrations. This is deliberately NOT something the web tier does for itself: a process
|
|
19
|
+
// that could mint its own control-plane row could mint a second one, and the whole value
|
|
20
|
+
// of the self row is that it is a thing someone decided to create. See
|
|
21
|
+
// docs/recipes/core-release-pipeline.md.
|
|
22
|
+
//
|
|
23
|
+
// node scripts/gds/enroll-control-plane.js --owner <builderId> [--slug cloudbongos]
|
|
24
|
+
// [--port 3002] [--domain cloudbongos.com] [--apply]
|
|
25
|
+
//
|
|
26
|
+
// Without --apply it prints what it would do and changes nothing, matching provision.js.
|
|
27
|
+
|
|
28
|
+
const { Pool } = require('pg');
|
|
29
|
+
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
30
|
+
|
|
31
|
+
function arg(flag, argv, fallback = null) {
|
|
32
|
+
const i = argv.indexOf(flag);
|
|
33
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;
|
|
34
|
+
}
|
|
35
|
+
const hasFlag = (flag, argv) => argv.includes(flag);
|
|
36
|
+
|
|
37
|
+
async function enrollControlPlane(db, { ownerBuilderId, slug, port, domain, apply }) {
|
|
38
|
+
// The slug ends up as a systemd unit name and inside the runner's shell commands,
|
|
39
|
+
// so it is validated at the door rather than left to whoever runs this. The runner
|
|
40
|
+
// re-checks it at the point of use too — this one is so the operator hears about a
|
|
41
|
+
// typo now, instead of as a refused upgrade weeks later.
|
|
42
|
+
if (!isValidSlug(slug)) {
|
|
43
|
+
return { ok: false, created: false, error: `'${slug}' is not a valid slug — lowercase letters, digits and single hyphens, 2-40 characters` };
|
|
44
|
+
}
|
|
45
|
+
const { rows: existing } = await db.query(
|
|
46
|
+
"SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane'");
|
|
47
|
+
if (existing[0]) {
|
|
48
|
+
return { ok: true, created: false, instance: existing[0], reason: 'already enrolled' };
|
|
49
|
+
}
|
|
50
|
+
if (!apply) return { ok: true, created: false, wouldCreate: { slug, port, domain, ownerBuilderId } };
|
|
51
|
+
// status 'active' because it IS active — this process is the proof. A row parked at
|
|
52
|
+
// 'requested' would be refused by the upgrade leg's own status gate, so enrolling it
|
|
53
|
+
// any other way would produce a door that is visible and permanently refuses.
|
|
54
|
+
const { rows } = await db.query(
|
|
55
|
+
`INSERT INTO provisioning_instances
|
|
56
|
+
(owner_builder_id, slug, hosting_shape, domain, port, status, onboard_mode, description)
|
|
57
|
+
VALUES ($1, $2, 'control-plane', $3, $4, 'active', 'adopt', $5)
|
|
58
|
+
RETURNING *`,
|
|
59
|
+
[ownerBuilderId, slug, domain || null, port,
|
|
60
|
+
'The control plane itself — the box this platform runs on. Enrolled so its core pin can be moved from the hall (ADR 0293); it is not a tenant project and cannot be torn down.']);
|
|
61
|
+
return { ok: true, created: true, instance: rows[0] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
const argv = process.argv.slice(2);
|
|
66
|
+
if (hasFlag('--help', argv)) {
|
|
67
|
+
console.log('usage: node scripts/gds/enroll-control-plane.js --owner <builderId> [--slug <slug>] [--port <port>] [--domain <domain>] [--apply]');
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
const ownerBuilderId = arg('--owner', argv);
|
|
71
|
+
if (!ownerBuilderId) {
|
|
72
|
+
console.error('enroll-control-plane: --owner <builderId> is required — the self row needs an owner like any other, and an unattributed one is exactly what ADR 0293 D5 refuses.');
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
const url = process.env.DATABASE_URL;
|
|
76
|
+
if (!url) { console.error('enroll-control-plane: DATABASE_URL is not set.'); return 2; }
|
|
77
|
+
const apply = hasFlag('--apply', argv);
|
|
78
|
+
const pool = new Pool({ connectionString: url });
|
|
79
|
+
try {
|
|
80
|
+
const r = await enrollControlPlane(pool, {
|
|
81
|
+
ownerBuilderId,
|
|
82
|
+
slug: arg('--slug', argv, 'cloudbongos'),
|
|
83
|
+
port: Number(arg('--port', argv, '3002')),
|
|
84
|
+
domain: arg('--domain', argv),
|
|
85
|
+
apply,
|
|
86
|
+
});
|
|
87
|
+
if (r.error) { console.error(`enroll-control-plane: ${r.error}`); return 2; }
|
|
88
|
+
if (r.created) console.log(`enrolled the control plane as instance #${r.instance.id} (${r.instance.slug}, port ${r.instance.port})`);
|
|
89
|
+
else if (r.instance) console.log(`already enrolled: instance #${r.instance.id} (${r.instance.slug}, port ${r.instance.port})`);
|
|
90
|
+
else console.log(`would enroll the control plane as ${JSON.stringify(r.wouldCreate)} — re-run with --apply`);
|
|
91
|
+
return 0;
|
|
92
|
+
} finally {
|
|
93
|
+
try { await pool.end(); } catch { /* ignore */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (require.main === module) {
|
|
98
|
+
main().then((c) => process.exit(c)).catch((e) => { console.error(e.message); process.exit(1); });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { enrollControlPlane };
|