@bongos/core 1.21.34 → 1.21.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -242,7 +242,12 @@ function accessRequestDocketMatter(r) {
242
242
  // The governor's docket: a pending request to join stands at People, shown only
243
243
  // to a viewer holding `access_request.review` (this queue's own atom, because it
244
244
  // exposes would-be builders' usernames) and needing that same viewer's act.
245
+ // The People desk's invite row (task 1004559), under the invite route's own atom:
246
+ // one GitHub name typed in place, sent to POST /access-requests/invite.
247
+ const INVITE_DESK_ROW = Object.freeze({ id: 'invite', name: 'Invite someone', fact: 'by their GitHub name', tool: true, ref: { type: 'invite', id: 'new' }, acts: [{ key: 'invite', field: 'github_login' }] });
248
+
245
249
  function contributeAccessRequestsToDocket() {
250
+ api.contribute('governor.docket', { desk: 'people.invite', gate: { permission: 'access_request.review' }, read: async () => [INVITE_DESK_ROW] });
246
251
  api.contribute('governor.docket', {
247
252
  source: 'access_requests',
248
253
  building: 'people',
@@ -651,3 +656,5 @@ module.exports = function buildAccessRequestsRouter() {
651
656
  };
652
657
 
653
658
  module.exports.accessRequestDocketMatter = accessRequestDocketMatter;
659
+
660
+ module.exports.INVITE_DESK_ROW = INVITE_DESK_ROW;
@@ -545,5 +545,5 @@ function makeUpdateChannelHandler({ provisioning, pool, parseId, validateOrRespo
545
545
  };
546
546
  }
547
547
 
548
- module.exports = { UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
548
+ module.exports = { loadPreview, UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
549
549
 
@@ -25,12 +25,45 @@
25
25
  const express = require('express');
26
26
  const api = require('../../../src/module-api');
27
27
  const provisioning = require('../provisioning');
28
- const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler } = require('../core-upgrade');
28
+ const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler, loadPreview } = require('../core-upgrade');
29
29
  const { requireOwnProjectOrPin } = require('../core-upgrade-authz');
30
30
 
31
31
  const log = api.logger('provisioning');
32
32
 
33
+ // The Release desk's core row (task 1004559): the platform's own move, in two
34
+ // steps on one row. Until a dry run has PASSED for the very version on offer the
35
+ // row offers only 'check' (POST …/core-upgrade with dry_run); 'take' (the real
36
+ // move) is handed only after that pass, so no desk can move the pin without the
37
+ // check. The chip carries the version, which the route requires as its own
38
+ // confirmation. A run in progress offers nothing (the row then steps aside for
39
+ // the plain core-update matter). Pure; exported for tests.
40
+ function coreDeskRows(preview) {
41
+ const p = preview || {};
42
+ const to = p.recommended_version;
43
+ if (!p.behind || !to || p.instance_id == null) return [];
44
+ if (p.open_intent && (p.open_intent.state === 'pending' || p.open_intent.state === 'running')) return [];
45
+ const pf = p.preflight && p.preflight.target === to ? p.preflight : null;
46
+ const version = [{ key: String(to), label: String(to) }];
47
+ const base = { id: p.instance_id, name: `Core ${to}`, over: 'core_update', opened_at: p.observed_at || null, ref: { type: 'core_pin', id: p.instance_id } };
48
+ if (pf && pf.ok) return [{ ...base, fact: 'checked · nothing blocks', facts: [`running ${p.served_version || 'unknown'}`], acts: [{ key: 'take', options: version }, { key: 'check', options: version }] }];
49
+ if (pf) return [{ ...base, fact: 'the check refused it', facts: [String(pf.error || 'no reason given'), `running ${p.served_version || 'unknown'}`], acts: [{ key: 'check', options: version }] }];
50
+ return [{ ...base, fact: 'not checked yet', facts: [`running ${p.served_version || 'unknown'}`], acts: [{ key: 'check', options: version }] }];
51
+ }
52
+ function contributeCoreDesk() {
53
+ api.contribute('governor.docket', {
54
+ desk: 'release.core',
55
+ // the control-plane read's own atom (GET /provisioning/control-plane)
56
+ gate: { permission: 'core.pin.move' },
57
+ read: async () => {
58
+ const { rows } = await api.pool.query("SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane' LIMIT 1");
59
+ if (!rows.length) return [];
60
+ return coreDeskRows(await loadPreview(api.pool, provisioning, rows[0]));
61
+ },
62
+ });
63
+ }
64
+
33
65
  module.exports = function buildCoreUpgradeRouter() {
66
+ contributeCoreDesk();
34
67
  const router = express.Router();
35
68
 
36
69
  // POST /provisioning/instances/:id/core-upgrade — move the core pin.
@@ -119,3 +152,5 @@ module.exports = function buildCoreUpgradeRouter() {
119
152
 
120
153
  return router;
121
154
  };
155
+
156
+ module.exports.coreDeskRows = coreDeskRows;
@@ -56,6 +56,23 @@ function redteamDocketMatter(r) {
56
56
  tally: null,
57
57
  href: '/watch',
58
58
  ref: { type: 'vulnerability_report', id: r.id },
59
+ // The floor desk's acts (task 1004559): the docket only lists this for a
60
+ // holder of the adjudicate atom, which both routes check.
61
+ acts: [{ key: 'confirm' }, { key: 'dispute' }],
62
+ };
63
+ }
64
+
65
+ // A confirmed report still waiting for its fix, as a Disputes desk row (task
66
+ // 1004559): one act, POST /security/reports/:id/fix. Pure; exported for tests.
67
+ function confirmedDeskRow(r) {
68
+ return {
69
+ id: r.id,
70
+ name: r.title,
71
+ fact: r.severity ? `${r.severity} · confirmed` : 'confirmed',
72
+ facts: ['confirmed, not fixed yet'],
73
+ opened_at: r.confirmed_at || r.reported_at || null,
74
+ ref: { type: 'vulnerability_report', id: r.id },
75
+ acts: [{ key: 'fix' }],
59
76
  };
60
77
  }
61
78
 
@@ -69,6 +86,11 @@ function contributeReportsToDocket() {
69
86
  return lists.flat().map(redteamDocketMatter);
70
87
  },
71
88
  });
89
+ api.contribute('governor.docket', {
90
+ desk: 'disputes.confirmed',
91
+ gate: { permission: 'security.report.adjudicate' },
92
+ read: async () => (await security.listVulnerabilityReports({ scope: 'all', status: 'confirmed', limit: 40 })).map(confirmedDeskRow),
93
+ });
72
94
  // The Disputes record (task 1004527): every report that was ruled on, dated by
73
95
  // its ruling (confirmed or fixed). Same atom as the list route it reads.
74
96
  api.contribute('governor.docket', {
@@ -318,3 +340,4 @@ module.exports = function buildSecurityReportsRouter() {
318
340
 
319
341
  module.exports.redteamDocketMatter = redteamDocketMatter;
320
342
  module.exports.rulingHistory = rulingHistory;
343
+ module.exports.confirmedDeskRow = confirmedDeskRow;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.34",
3
+ "version": "1.21.35",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.34",
9
+ "version": "1.21.35",
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.21.34",
3
+ "version": "1.21.35",
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",
@@ -8964,5 +8964,11 @@
8964
8964
  "id": "1004558",
8965
8965
  "text": "Inside every building in the governor city, the floor you pick is now a desk you can act from, built from the new floor desks design. It shows the floor's name and how many things wait on you, up to three short numbers, and on"
8966
8966
  }
8967
+ ],
8968
+ "1.21.35": [
8969
+ {
8970
+ "id": "1004559",
8971
+ "text": "Governing now happens inside the buildings. On a floor's desk you can set a builder's tier, deactivate them, invite someone by GitHub name, give or take back a custom rank; confirm, dispute or mark fixed a security report and"
8972
+ }
8967
8973
  ]
8968
8974
  }
@@ -24,6 +24,7 @@ const auth = require('../auth');
24
24
  const { branding } = require('../../branding');
25
25
  const { resolveEnv } = require('../../instance-config');
26
26
  const { asyncHandler } = require('./_helpers');
27
+ const seams = require('../../module-seams');
27
28
  // task 1003208: structured logging — was console.*. A core file logs through
28
29
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
29
30
  const log = require('../logger').logger.child({ src: 'backup' });
@@ -76,6 +77,20 @@ function isStaleDump(stamp, nowMs = Date.now(), staleAfterHours = STALE_AFTER_HO
76
77
  return age > staleAfterHours;
77
78
  }
78
79
 
80
+ // The newest dump's age for the Treasury desk (task 1004559), read WITHOUT
81
+ // blocking: the docket is read on every street visit, so this is one async
82
+ // directory listing and the age from the filename (no stat), never latestDump()'s
83
+ // synchronous reads. Same filename rule and staleness rule as latestDump().
84
+ async function newestDumpAge(nowMs = Date.now()) {
85
+ let files;
86
+ try { files = await fs.promises.readdir(BACKUP_DIR); } catch { return null; }
87
+ const dumps = files.filter((f) => f.startsWith(`${DB_NAME}-`) && f.endsWith('.sql.gz')).sort();
88
+ if (!dumps.length) return null;
89
+ const ts = dumps[dumps.length - 1].replace(`${DB_NAME}-`, '').replace('.sql.gz', '');
90
+ const age = dumpAgeHours(ts, nowMs);
91
+ return { age_hours: age === null ? null : Math.round(age * 10) / 10, stale: isStaleDump(ts, nowMs) };
92
+ }
93
+
79
94
  function latestDump(nowMs = Date.now()) {
80
95
  let files;
81
96
  try {
@@ -107,7 +122,16 @@ function latestDump(nowMs = Date.now()) {
107
122
  };
108
123
  }
109
124
 
125
+ // The Treasury desk's backup row (task 1004559), under the backup routes' own
126
+ // atom: one tap runs POST /backup/trigger. Pure; exported for tests.
127
+ function backupDeskRows(dump, running) {
128
+ const age = dump && Number.isFinite(Number(dump.age_hours)) ? Number(dump.age_hours) : null;
129
+ const fact = running ? 'backing up now' : age == null ? 'no backup yet' : age < 1 ? 'last one under an hour' : age < 48 ? `last ${Math.round(age)} hours ago` : `last ${Math.round(age / 24)} days ago`;
130
+ return [{ id: 'now', name: 'Back up now', fact, facts: dump && dump.stale ? ['the last one is stale'] : [], tool: true, ref: { type: 'backup', id: 'now' }, acts: running ? [] : [{ key: 'backup' }] }];
131
+ }
132
+
110
133
  module.exports = function buildBackupRouter() {
134
+ seams.contribute('governor.docket', { desk: 'treasury.backup', gate: { permission: 'backup.manage' }, read: async () => backupDeskRows(await newestDumpAge(), triggerRunning) });
111
135
  const router = express.Router();
112
136
 
113
137
  // rank: metic+archon — any working builder may check or trigger a backup. Gate:
@@ -168,3 +192,5 @@ module.exports.parseDumpTimestamp = parseDumpTimestamp;
168
192
  module.exports.dumpAgeHours = dumpAgeHours;
169
193
  module.exports.isStaleDump = isStaleDump;
170
194
  module.exports.STALE_AFTER_HOURS = STALE_AFTER_HOURS;
195
+
196
+ module.exports.backupDeskRows = backupDeskRows;
@@ -168,7 +168,45 @@ function fireDiscordBroadcast(text) {
168
168
  }
169
169
  }
170
170
 
171
+ // The People desk's builder rows (task 1004559): one row per active builder the
172
+ // viewer may act on, with the acts the viewer's own atoms allow: set tier
173
+ // (builder.rank.set, PATCH /builders/:id/rank) and deactivate (builder.status.set,
174
+ // PATCH /builders/:id/status). Never the viewer's own row: both routes refuse it.
175
+ // Pure; exported for tests.
176
+ const STOP_REASONS = Object.freeze([
177
+ { key: 'left', label: 'left the project' },
178
+ { key: 'quiet', label: 'gone quiet' },
179
+ { key: 'asked', label: 'asked to stop' },
180
+ ]);
181
+ function builderDeskRows(builders, { viewerId, mayRank, mayStop, ranks }) {
182
+ if (!mayRank && !mayStop) return [];
183
+ return (builders || []).filter((b) => b && String(b.id) !== String(viewerId)).map((b) => {
184
+ const acts = [];
185
+ // one rung up at most (the route refuses a multi-rung promotion), any rung down
186
+ const at = (ranks || []).indexOf(b.rank);
187
+ // a rank off the live ladder offers no tier chip: there is no rung to move by one
188
+ if (mayRank && at >= 0) acts.push({ key: 'tier', options: (ranks || []).filter((r, i) => r !== b.rank && i <= at + 1).map((r) => ({ key: r, label: r })) });
189
+ if (mayStop) acts.push({ key: 'deactivate', options: STOP_REASONS });
190
+ return { id: b.id, name: b.display_name || b.github_login || `builder ${b.id}`, fact: b.rank || 'builder', facts: b.github_login ? [`@${b.github_login}`] : [], ref: { type: 'builder', id: b.id }, acts };
191
+ });
192
+ }
193
+ // Contributed from the router factory: any builder passes the gate, and the read
194
+ // scopes itself to the viewer's atoms (an empty desk for a viewer holding neither).
195
+ function contributeBuilderDesk() {
196
+ seams.contribute('governor.docket', {
197
+ desk: 'people.builders',
198
+ gate: { anyBuilder: true },
199
+ read: async (ctx) => {
200
+ const mayRank = ctx.has('builder.rank.set');
201
+ const mayStop = ctx.has('builder.status.set');
202
+ if (!mayRank && !mayStop) return [];
203
+ return builderDeskRows(await db.listActiveBuilders(), { viewerId: ctx.builderId, mayRank, mayStop, ranks: db.LIVE_RANK_LADDER });
204
+ },
205
+ });
206
+ }
207
+
171
208
  module.exports = function buildBuildersRouter() {
209
+ contributeBuilderDesk();
172
210
  const router = express.Router();
173
211
 
174
212
  // rank: archon — sole chokepoint that mutates builders.rank; ADR 0016 §enforcement.
@@ -707,3 +745,5 @@ module.exports = function buildBuildersRouter() {
707
745
 
708
746
  return router;
709
747
  };
748
+
749
+ module.exports.builderDeskRows = builderDeskRows;
@@ -180,8 +180,24 @@ function contributeModuleRechecksToDocket() {
180
180
  });
181
181
  }
182
182
 
183
+ // The Rules desk's module rows (task 1004559), under the toggle's own atom
184
+ // (POST /modules/:key/enable, module.enable): each module on disk, turned on in
185
+ // one tap, or off with a one-tap 'yes' so a stray tap cannot pull one. Pure;
186
+ // exported for tests.
187
+ function moduleDeskRows(mods) {
188
+ return (mods || []).filter((m) => m && m.key).map((m) => ({
189
+ id: m.key, name: m.title || m.key, fact: m.enabled ? 'on' : 'off', facts: m.description ? [m.description] : [], tool: true,
190
+ ref: { type: 'module', id: m.key },
191
+ acts: [m.enabled ? { key: 'module_off', options: [{ key: 'yes', label: 'yes, turn off' }] } : { key: 'module_on' }],
192
+ }));
193
+ }
194
+ function contributeModuleDesk() {
195
+ seams.contribute('governor.docket', { desk: 'rules.modules', gate: { permission: 'module.enable' }, read: async () => moduleDeskRows(listModules()) });
196
+ }
197
+
183
198
  module.exports = function buildModulesRouter() {
184
199
  contributeModuleRechecksToDocket();
200
+ contributeModuleDesk();
185
201
  const router = express.Router();
186
202
  // Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
187
203
  // router starts empty.
@@ -702,3 +718,5 @@ module.exports = function buildModulesRouter() {
702
718
  // own disk scan, no DB), so it's safe to require directly outside an Express
703
719
  // request cycle, the same way tests/atlas.mjs requires gen-atlas.js's builders.
704
720
  module.exports.listModules = listModules;
721
+
722
+ module.exports.moduleDeskRows = moduleDeskRows;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.34'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.35'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -285,7 +285,7 @@ test('the response shape: counts, line, street-ordered buildings, sorted matters
285
285
  c.readings.set('flow', { reading: 'flow', gate: { anyBuilder: true }, read: async () => ({ value: 3, figure: '3 stuck', line: 'longest wait 4 days, waiting for review', hot: true }) });
286
286
  const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast', 'security.report.adjudicate'], contributions: c, now: NOW });
287
287
 
288
- assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'floors', 'generated_at', 'history', 'history_sources', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
288
+ assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'desks', 'floors', 'generated_at', 'history', 'history_sources', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
289
289
  assert.deepEqual(d.viewer, { id: '7' });
290
290
  // board 11 (needs) + board 12 (settled, closed this week) + blocker + report; board 13 is too old
291
291
  assert.deepEqual(d.counts, { needs: 2, moving: 1, settled: 1 });
@@ -734,3 +734,30 @@ test('GET /government/docket: 403 without board.vote.cast, the documented shape
734
734
  assert.equal(body.sources.find((s) => s.source === 'access_requests').status, 'not_yours');
735
735
  assert.ok(!JSON.stringify(body).includes('Admit Kai'), 'a hidden source leaks nothing');
736
736
  });
737
+
738
+ // ── the floor desks' acts and rows (task 1004559) ────────────────────────────
739
+
740
+ test('acts: a matter carries only well-formed acts, at most two, with at most five one-tap options', () => {
741
+ const m = docket.normalizeMatter({ id: 1, title: 't', acts: [{ key: 'confirm', options: [1, 2, 3, 4, 5, 6].map((n) => ({ key: n, label: `option ${n}` })) }, { key: 'Bad Key' }, { key: 'fix' }, { key: 'third' }] }, { source: 's', building: 'review' });
742
+ assert.deepEqual(m.acts.map((a) => a.key), ['confirm', 'fix']);
743
+ assert.equal(m.acts[0].options.length, 5);
744
+ assert.equal('acts' in docket.normalizeMatter({ id: 1, title: 't' }, { source: 's', building: 'review' }), false, 'no acts, no field');
745
+ assert.deepEqual(docket.normalizeActs([{ key: 'allow', pick: 'open_goals', except: [1, 2] }, { key: 'pause', pick: 'everything' }]), [{ key: 'allow', pick: 'open_goals', except: ['1', '2'] }, { key: 'pause' }]);
746
+ assert.equal(docket.normalizeDeskRow({ id: 1, name: 'n', acts: [] }, { desk: 'people.builders', building: 'people' }), null, 'a desk row with nothing to do is dropped');
747
+ });
748
+
749
+ test('desks: gated by the atom their write route checks; a gated-out desk is not_yours with no rows; a broken one is unavailable', async () => {
750
+ const c = none();
751
+ c.desks = new Map([
752
+ ['people.builders', { desk: 'people.builders', gate: { permission: 'builder.rank.set' }, read: async () => [{ id: 7, name: 'Ana', fact: 'thetes', acts: [{ key: 'tier', options: [{ key: 'metic', label: 'metic' }] }] }, { id: 8, name: 'no acts', acts: [] }] }],
753
+ ['automation.goals', { desk: 'automation.goals', gate: { anyBuilder: true }, read: async () => { throw new Error('boom'); } }],
754
+ ]);
755
+ const without = await docket.buildDocket({ viewer: VIEWER, permissions: [], contributions: c, now: NOW });
756
+ const by = (d, k) => d.desks.find((x) => x.key === k);
757
+ assert.deepEqual(by(without, 'people.builders'), { key: 'people.builders', building: 'people', status: 'not_yours', rows: [] });
758
+ assert.equal(by(without, 'automation.goals').status, 'unavailable');
759
+ assert.equal(by(without, 'automation.runners').status, 'off');
760
+ const withAtom = await docket.buildDocket({ viewer: VIEWER, permissions: ['builder.rank.set'], contributions: c, now: NOW });
761
+ assert.deepEqual(by(withAtom, 'people.builders').rows.map((r) => [r.id, r.name, r.acts[0].key]), [['people.builders:7', 'Ana', 'tier']]);
762
+ assert.equal(withAtom.matters.length, 0, 'desk rows never become matters (no pins, no counts)');
763
+ });
@@ -177,9 +177,18 @@ test('every act maps to the route that does it, with the body that route accepts
177
177
  assert.deepEqual(C.request(gate, 'release'), { method: 'POST', url: '/api/bongos/gate-approvals/812/approve', body: {} });
178
178
  });
179
179
 
180
- test('no act on any card reaches the core pin or a provisioning route', () => {
180
+ // Task 1004559 moved the core update onto the Release desk, check first: the ONLY
181
+ // way a card reaches the pin is the core_pin row, whose 'take' the server hands
182
+ // only after a passed dry run (tests/hall_street_desk.mjs pins that), and which
183
+ // needs the version chip as its confirmation. Nothing else may name the route.
184
+ test('only the core_pin row reaches the core pin, and never without its version', () => {
181
185
  const src = read('street-card.js').replace(/^\s*\/\/.*$/gm, '');
182
- assert.doesNotMatch(src, /core-upgrade|provisioning|\/core-update/);
186
+ const block = src.split('ACT_ROUTES.core_pin = {')[1].split('};')[0];
187
+ assert.equal(src.replace(block, '').match(/core-upgrade|provisioning|\/core-update/g), null, 'no other act names a provisioning route');
188
+ const cv = C.actsCard({ key: 'release.core:41', kind: 'core_pin', id: '41' });
189
+ assert.equal(C.request(cv, 'take', {}), null);
190
+ assert.equal(C.request(cv, 'check', {}), null);
191
+ assert.deepEqual(JSON.parse(JSON.stringify(C.request(cv, 'check', { option: '1.21.20' }))).body, { to: '1.21.20', dry_run: true });
183
192
  });
184
193
 
185
194
  test('an objection names its reason and its part before anything is sent; the parts are the server\'s own keys', () => {
@@ -326,3 +326,232 @@ test('the stylesheet: the desk in tokens only, its motion dies under reduced mot
326
326
  assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none !important; transition: none !important;/);
327
327
  assert.match(read('street.html'), /id="streetTodayPips"/);
328
328
  });
329
+
330
+ // ── the old-page actions on their floors (task 1004559) ───────────────────────
331
+ // Rows built by the REAL server mappers (each module's own pure function), run
332
+ // through the docket's own normalisers, into the real desk: so a field the
333
+ // server stops sending, or a desk row the desk cannot draw, reds here.
334
+ const SRV = (p) => require(path.join(ROOT, p));
335
+ const DK = SRV('modules/government/docket.js');
336
+ const reportsRoute = SRV('modules/security/routes/reports.js');
337
+ const lifeDocket = SRV('modules/lifecycle/governor-docket.js');
338
+ const autoDocket = SRV('modules/autonomy/governor-docket.js');
339
+ const buildersRoute = SRV('src/bongos/routes/builders.js');
340
+ const govFloors = SRV('modules/government/docket-floors.js');
341
+ const LADDER = ['xenos', 'thetes', 'metic', 'archon'];
342
+ const healthy = () => ({ ok: true, state: 'alive' });
343
+
344
+ function actedDocket({ longWords = false, force = true } = {}) {
345
+ const t = (s) => (longWords ? `${LONG} ${s}` : s);
346
+ const norm = (source, building, list) => list.map((m) => DK.normalizeMatter(m, { source, building })).filter(Boolean);
347
+ const matters = [
348
+ ...norm('redteam_reports', 'disputes', [reportsRoute.redteamDocketMatter({ id: 2, title: t('sign-in limits'), severity: 'high', status: 'open', reported_at: '2026-10-01T10:00:00.000Z' })]),
349
+ ...norm('removal_proposals', 'disputes', [lifeDocket.removalMatter({ proposal_task_id: '50', title: t('old search page'), filed_at: '2026-09-30T10:00:00.000Z' })]),
350
+ ...norm('criteria_uat', 'review', [
351
+ lifeDocket.uatMatter({ id: 8, criterion_md: t('Refunds work'), goal_id: 4, shipped_total: 3, backend_only: true }, { force }),
352
+ lifeDocket.uatMatter({ id: 9, criterion_md: t('The city draws'), goal_id: 4, shipped_total: 2, backend_only: false }, { force }),
353
+ ]),
354
+ ...norm('autobongos_fence', 'automation', autoDocket.fenceDocketMatters({ enabled: false, paused_reason: 'payments touched', goals: [] }, true)),
355
+ ];
356
+ const desk = (key, building, rows) => ({ key, building, status: 'live', rows: rows.map((r) => DK.normalizeDeskRow(r, { desk: key, building })).filter(Boolean) });
357
+ const project = { enabled: true, priority_goal_id: 1, goals: [{ goal_id: 1, added_at: '2026-09-20T10:00:00.000Z' }, { goal_id: 2, added_at: '2026-09-21T10:00:00.000Z' }] };
358
+ const desks = [
359
+ desk('people.builders', 'people', buildersRoute.builderDeskRows([{ id: 7, display_name: t('Ana Ruiz'), github_login: 'ana', rank: 'thetes' }, { id: 95, display_name: 'Me', rank: 'archon' }], { viewerId: '95', mayRank: true, mayStop: true, ranks: LADDER })),
360
+ desk('people.ranks', 'people', govFloors.rankDeskRows([{ rank_key: 'tester', label: t('Tester'), is_system: false }, { rank_key: 'archon', label: 'Archon', is_system: true }], [{ builder_id: 7, display_name: 'Ana Ruiz', rank_key: 'tester' }, { builder_id: 8, github_login: 'kai', rank_key: 'metic' }])),
361
+ desk('disputes.confirmed', 'disputes', [reportsRoute.confirmedDeskRow({ id: 3, title: t('token leak'), severity: 'low', confirmed_at: '2026-09-28T10:00:00.000Z' })]),
362
+ desk('automation.switch', 'automation', autoDocket.switchDeskRows(project)),
363
+ desk('automation.goals', 'automation', autoDocket.goalDeskRows(project)),
364
+ desk('automation.runners', 'automation', autoDocket.runnerDeskRows([{ builder_id: 7, display_name: t('Ana Ruiz'), runners: [{ working_task_id: 12 }] }, { builder_id: 8, owner_paused: true, owner_paused_reason: 'spending too much', runners: [] }], NOW, healthy)),
365
+ ];
366
+ return docket(matters, { desks });
367
+ }
368
+ const GOALS = [{ id: '1', title: 'Governor city', status: 'open' }, { id: '2', title: 'Payments', status: 'open' }, { id: '3', title: LONG, status: 'open' }];
369
+ const actedRows = (dk, key, floor) => D.rowsFor(key, floor, dk, { now: NOW, viewerId: '95', goals: GOALS });
370
+
371
+ test('the moved acts keep the line rule on every floor, long words or short', () => {
372
+ for (const longWords of [false, true]) {
373
+ const dk = actedDocket({ longWords });
374
+ const desks = [];
375
+ for (const key of S.CIVIC_KEYS) I.PLAN[key].floors.forEach((_f, floor) => desks.push(D.deskModel({ key, floor, docket: dk, goals: GOALS, now: NOW, viewerId: '95' })));
376
+ assert.deepEqual(desks.flatMap((d) => D.breaks(d)), []);
377
+ const rows = desks.flatMap((d) => d.rows);
378
+ assert.ok(rows.length >= 14, 'every moved act drew its row');
379
+ assert.ok(rows.every((r) => r.wait === '' || r.actions.length === 0));
380
+ }
381
+ });
382
+
383
+ test('breaks() catches an asking button with no chips of its own', () => {
384
+ const dk = actedDocket();
385
+ const dm = D.deskModel({ key: 'people', floor: 2, docket: dk, goals: GOALS, now: NOW, viewerId: '95' });
386
+ assert.deepEqual(D.breaks(dm), []);
387
+ const bad = j(dm);
388
+ bad.rows[0].choices = {};
389
+ assert.ok(D.breaks(bad).some((b) => /without its own chips/.test(b)));
390
+ });
391
+
392
+ test('People: set tier and deactivate are chips on the builder row, never your own; ranks are given and taken back', () => {
393
+ const dk = actedDocket();
394
+ const rows = actedRows(dk, 'people', 2);
395
+ assert.deepEqual(rows.map((r) => r.name), ['Ana Ruiz'], 'your own row is never drawn');
396
+ const ana = rows[0];
397
+ assert.deepEqual(ana.actions.map((a) => [a.key, a.label]), [['tier', 'tier'], ['deactivate', 'deactivate']]);
398
+ assert.deepEqual(ana.choices.tier.options.map((o) => o.key), ['xenos', 'metic'], 'one rung up at most, any rung down');
399
+ assert.deepEqual(j(C.request(ana.card, 'tier', ana.choices.tier.options[1].input)), { method: 'PATCH', url: '/api/bongos/builders/7/rank', body: { rank: 'metic' } });
400
+ assert.deepEqual(j(C.request(ana.card, 'deactivate', ana.choices.deactivate.options[0].input)), { method: 'PATCH', url: '/api/bongos/builders/7/status', body: { status: 'inactive', reason: 'left the project' } });
401
+ const rank = actedRows(dk, 'people', 5)[0];
402
+ assert.deepEqual(rank.actions.map((a) => a.key), ['give', 'take']);
403
+ assert.deepEqual(rank.choices.give.options.map((o) => o.label), ['kai']);
404
+ assert.deepEqual(j(C.request(rank.card, 'give', rank.choices.give.options[0].input)), { method: 'POST', url: '/api/bongos/government/assignments', body: { builder_id: 8, rank_key: 'tester' } });
405
+ assert.deepEqual(j(C.request(rank.card, 'take', rank.choices.take.options[0].input)), { method: 'DELETE', url: '/api/bongos/government/assignments/7/tester', body: {} });
406
+ assert.deepEqual(buildersRoute.builderDeskRows([{ id: 7, rank: 'xenos' }], { viewerId: '95', mayRank: false, mayStop: false, ranks: LADDER }), [], 'no atom, no rows');
407
+ assert.deepEqual(buildersRoute.builderDeskRows([{ id: 7, rank: 'xenos' }], { viewerId: '95', mayRank: false, mayStop: true, ranks: LADDER })[0].acts.map((a) => a.key), ['deactivate']);
408
+ });
409
+
410
+ test('Disputes: a report is confirmed or disputed, a confirmed one marked fixed, a removal confirmed or kept', () => {
411
+ const dk = actedDocket();
412
+ const [rep, fix] = actedRows(dk, 'disputes', 0);
413
+ assert.deepEqual(rep.actions.map((a) => a.label), ['confirm', 'dispute']);
414
+ assert.equal(C.request(rep.card, 'confirm').url, '/api/bongos/security/reports/2/confirm');
415
+ assert.equal(C.request(rep.card, 'dispute').url, '/api/bongos/security/reports/2/dispute');
416
+ assert.deepEqual(fix.actions.map((a) => a.label), ['fixed']);
417
+ assert.equal(C.request(fix.card, 'fix').url, '/api/bongos/security/reports/3/fix');
418
+ const rm = actedRows(dk, 'disputes', 1)[0];
419
+ assert.deepEqual(rm.actions.map((a) => a.label), ['remove', 'keep']);
420
+ assert.deepEqual(j(C.request(rm.card, 'remove')), { method: 'POST', url: '/api/bongos/removal-proposals/50/decide', body: { verdict: 'confirmed' } });
421
+ assert.deepEqual(j(C.request(rm.card, 'keep')).body, { verdict: 'dismissed' });
422
+ });
423
+
424
+ test('Automation: switch on and off, allow a goal from the open goals, first in line, disallow, pause and resume a runner', () => {
425
+ const dk = actedDocket();
426
+ const sw = actedRows(dk, 'automation', 0);
427
+ const off = sw.find((r) => r.source === 'autobongos_fence');
428
+ assert.deepEqual(off.actions.map((a) => a.label), ['switch on']);
429
+ assert.deepEqual(j(C.request(off.card, 'switch_on')), { method: 'POST', url: '/api/bongos/autonomy/fence', body: { enabled: true } });
430
+ const on = sw.find((r) => r.desk);
431
+ assert.deepEqual(j(C.request(on.card, 'switch_off', on.choices.switch_off.options[0].input)).body, { enabled: false, reason: 'pause for now' });
432
+ const goals = actedRows(dk, 'automation', 1);
433
+ const first = goals.find((r) => r.name === 'Governor city');
434
+ assert.ok(first, 'an allowed goal is lettered with its title');
435
+ assert.deepEqual(first.actions.map((a) => a.key), ['disallow'], 'the goal first in line is not offered first again');
436
+ assert.equal(j(C.request(first.card, 'disallow')).method, 'DELETE');
437
+ assert.equal(C.request(first.card, 'disallow').url, '/api/bongos/autonomy/fence/goals/1');
438
+ const pay = goals.find((r) => r.name === 'Payments');
439
+ assert.deepEqual(j(C.request(pay.card, 'first')), { method: 'POST', url: '/api/bongos/autonomy/fence/priority', body: { goal_id: 2 } });
440
+ const add = goals.find((r) => r.actions.some((a) => a.key === 'allow'));
441
+ assert.deepEqual(add.choices.allow.options.map((o) => o.key), ['3'], 'only open goals not yet allowed');
442
+ const goalDesk = D.deskModel({ key: 'automation', floor: 1, docket: dk, goals: GOALS, now: NOW });
443
+ assert.match(goalDesk.status, /^2 allowed/, 'the allow row is a tool, not a goal: the floor counts two');
444
+ assert.deepEqual(j(C.request(add.card, 'allow', add.choices.allow.options[0].input)).body, { goal_id: 3 });
445
+ const runners = actedRows(dk, 'automation', 2);
446
+ const busy = runners.find((r) => r.actions[0].key === 'pause');
447
+ assert.deepEqual(j(C.request(busy.card, 'pause', busy.choices.pause.options[2].input)), { method: 'POST', url: '/api/bongos/autonomy/builders/7/pause', body: { paused: true, reason: 'it is stuck' } });
448
+ const paused = runners.find((r) => r.actions[0].key === 'resume');
449
+ assert.deepEqual(j(C.request(paused.card, 'resume')).body, { paused: false });
450
+ // with every open goal allowed, the allow row has no chip to offer and is not drawn
451
+ const none = D.rowsFor('automation', 1, dk, { now: NOW, goals: GOALS.slice(0, 2) });
452
+ assert.ok(!none.some((r) => r.actions.some((a) => a.key === 'allow')));
453
+ });
454
+
455
+ test('Review: a backend-only promise signs off in one tap; a recorded one goes to its page; force sign-off only with its atom', () => {
456
+ const rows = actedRows(actedDocket(), 'review', 1);
457
+ const backend = rows.find((r) => r.actions[0].key === 'signoff');
458
+ assert.deepEqual(j(C.request(backend.card, 'signoff', backend.choices.signoff.options[0].input)), { method: 'POST', url: '/api/bongos/done-when/8/uat', body: { kind: 'backend_signoff', live_attested: true } });
459
+ assert.deepEqual(j(C.request(backend.card, 'force', backend.choices.force.options[0].input)), { method: 'POST', url: '/api/bongos/done-when/8/satisfy', body: { override_reason: 'Signed off on the floor desk: tested by hand.' } });
460
+ const ui = rows.find((r) => r.actions[0].key === 'record');
461
+ assert.equal(ui.actions[0].go, '/goals#/goal/4');
462
+ const noForce = actedRows(actedDocket({ force: false }), 'review', 1);
463
+ assert.ok(noForce.every((r) => !r.actions.some((a) => a.key === 'force')));
464
+ });
465
+
466
+ test('a viewer the server hands no acts sees the quiet line, not rows', () => {
467
+ const dk = actedDocket();
468
+ dk.desks = dk.desks.map((d) => ({ ...d, status: 'not_yours', rows: [] }));
469
+ const dm = D.deskModel({ key: 'people', floor: 2, docket: dk, goals: GOALS, now: NOW, viewerId: '95' });
470
+ assert.equal(dm.quiet, true);
471
+ assert.deepEqual(D.breaks(dm), []);
472
+ });
473
+
474
+ // ── Release, Rules, Treasury and the invite (task 1004559, second pass) ──────
475
+ const coreRoute = SRV('modules/provisioning/routes/core-upgrade.js');
476
+ const settingsRoute = SRV('modules/lifecycle/routes/project-settings.js');
477
+ const modulesRoute = SRV('src/bongos/routes/modules.js');
478
+ const backupRoute = SRV('src/bongos/routes/backup.js');
479
+ const accessRoute = SRV('modules/onboarding/routes/access-requests.js');
480
+ const PREVIEW = { instance_id: 41, served_version: '1.21.19', recommended_version: '1.21.20', behind: true, observed_at: '2026-10-03T10:00:00.000Z' };
481
+
482
+ function moreDesks({ preflight = null, longWords = false } = {}) {
483
+ const t = (s) => (longWords ? `${LONG} ${s}` : s);
484
+ const desk = (key, building, rows) => ({ key, building, status: 'live', rows: rows.map((r) => DK.normalizeDeskRow(r, { desk: key, building })).filter(Boolean) });
485
+ const dk = actedDocket({ longWords });
486
+ dk.matters.push(DK.normalizeMatter({ id: '1.21.20', rule: 'decide', state: 'needs', title: 'Take core 1.21.20 (running 1.21.19)', short: 'core 1.21.20', href: '/settings#software-update', ref: { type: 'core_version', id: '1.21.20' } }, { source: 'core_update', building: 'release' }));
487
+ dk.desks.push(
488
+ desk('release.core', 'release', coreRoute.coreDeskRows({ ...PREVIEW, preflight })),
489
+ desk('rules.settings', 'rules', settingsRoute.settingsDeskRows({ days: 14, categories: [{ active: true }, { active: false }] })),
490
+ desk('rules.modules', 'rules', modulesRoute.moduleDeskRows([{ key: 'discord', title: t('Discord'), enabled: true }, { key: 'economy', title: 'Economy', enabled: false }])),
491
+ desk('treasury.backup', 'treasury', backupRoute.backupDeskRows({ age_hours: 30, stale: false }, false)),
492
+ desk('people.invite', 'people', [accessRoute.INVITE_DESK_ROW]),
493
+ );
494
+ return dk;
495
+ }
496
+
497
+ test('the second-pass desks keep the line rule on every floor', () => {
498
+ for (const longWords of [false, true]) {
499
+ for (const preflight of [null, { target: '1.21.20', ok: true }, { target: '1.21.20', ok: false, error: LONG }]) {
500
+ const dk = moreDesks({ preflight, longWords });
501
+ const desks = [];
502
+ for (const key of S.CIVIC_KEYS) I.PLAN[key].floors.forEach((_f, floor) => desks.push(D.deskModel({ key, floor, docket: dk, goals: GOALS, now: NOW, viewerId: '95' })));
503
+ assert.deepEqual(desks.flatMap((d) => D.breaks(d)), []);
504
+ }
505
+ }
506
+ });
507
+
508
+ test('Release: the core update is checked first; take is offered only after a pass, for that version, and stands in for the link', () => {
509
+ const rows = (pf) => actedRows(moreDesks({ preflight: pf }), 'release', 3);
510
+ const fresh = rows(null);
511
+ assert.deepEqual(fresh.map((r) => r.source), ['release.core'], 'the core row replaces the plain core-update link');
512
+ assert.deepEqual(fresh[0].actions.map((a) => a.key), ['check'], 'no take before a check');
513
+ assert.deepEqual(j(C.request(fresh[0].card, 'check', fresh[0].choices.check.options[0].input)), { method: 'POST', url: '/api/bongos/provisioning/instances/41/core-upgrade', body: { to: '1.21.20', dry_run: true } });
514
+ assert.deepEqual(rows({ target: '1.21.20', ok: false, error: 'x' })[0].actions.map((a) => a.key), ['check'], 'a refused check offers no take');
515
+ assert.deepEqual(rows({ target: '1.21.19', ok: true })[0].actions.map((a) => a.key), ['check'], 'a pass for another version is no pass');
516
+ const passed = rows({ target: '1.21.20', ok: true })[0];
517
+ assert.deepEqual(passed.actions.map((a) => [a.key, a.label]), [['take', 'take it'], ['check', 'check']]);
518
+ assert.deepEqual(j(C.request(passed.card, 'take', passed.choices.take.options[0].input)), { method: 'POST', url: '/api/bongos/provisioning/instances/41/core-upgrade', body: { to: '1.21.20' } });
519
+ assert.equal(C.request(passed.card, 'take', {}), null, 'no version chip, no move');
520
+ assert.deepEqual(coreRoute.coreDeskRows({ ...PREVIEW, open_intent: { state: 'running' } }), [], 'a run in progress offers nothing');
521
+ assert.deepEqual(coreRoute.coreDeskRows({ ...PREVIEW, behind: false }), []);
522
+ });
523
+
524
+ test('Rules: the rot timer as day chips, work categories as a link, modules on in one tap and off with a yes', () => {
525
+ const rows = actedRows(moreDesks(), 'rules', 0);
526
+ const rot = rows.find((r) => r.name === 'Rot timer');
527
+ assert.deepEqual(rot.choices.rot.options.map((o) => o.key), ['7', '30', '60', '90'], 'the current value is not offered');
528
+ assert.deepEqual(j(C.request(rot.card, 'rot', rot.choices.rot.options[1].input)), { method: 'PATCH', url: '/api/bongos/project-settings/rot-days', body: { days: 30 } });
529
+ const cats = rows.find((r) => r.name === 'Work categories');
530
+ assert.equal(cats.actions[0].go, '/project-settings');
531
+ const on = rows.find((r) => r.name === 'Economy');
532
+ assert.deepEqual(j(C.request(on.card, 'module_on')), { method: 'POST', url: '/api/bongos/modules/economy/enable', body: { enabled: true } });
533
+ const off = rows.find((r) => r.name === 'Discord');
534
+ assert.equal(off.choices.module_off.options[0].label, 'yes, turn off');
535
+ assert.deepEqual(j(C.request(off.card, 'module_off', off.choices.module_off.options[0].input)).body, { enabled: false });
536
+ });
537
+
538
+ test('Treasury: back up now in one tap; People: invite takes one typed GitHub name', () => {
539
+ const dk = moreDesks();
540
+ const backup = actedRows(dk, 'treasury', 2)[0];
541
+ assert.equal(backup.fact, 'last 30 hours ago');
542
+ assert.deepEqual(j(C.request(backup.card, 'backup')), { method: 'POST', url: '/api/bongos/backup/trigger', body: {} });
543
+ assert.deepEqual(backupRoute.backupDeskRows(null, true)[0].acts, [], 'a backup already running offers no second one');
544
+ const invite = actedRows(dk, 'people', 4).find((r) => r.name === 'Invite someone');
545
+ assert.equal(invite.choices.invite.field, 'github_login');
546
+ assert.deepEqual(j(C.request(invite.card, 'invite', { option: ' ana-ruiz ' })), { method: 'POST', url: '/api/bongos/access-requests/invite', body: { github_login: 'ana-ruiz' } });
547
+ assert.equal(C.request(invite.card, 'invite', { option: ' ' }), null, 'nothing typed, nothing sent');
548
+ const tiles = D.deskModel({ key: 'treasury', floor: 2, docket: dk, goals: GOALS, now: NOW });
549
+ assert.deepEqual(D.breaks(tiles), []);
550
+ });
551
+
552
+ test('the Treasury desk reads the newest backup without blocking (no sync disk read on the docket path)', () => {
553
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/backup.js'), 'utf8');
554
+ assert.match(src, /desk: 'treasury\.backup'[^\n]*await newestDumpAge\(\)/);
555
+ const body = src.split('async function newestDumpAge')[1].split('\nfunction latestDump')[0];
556
+ assert.doesNotMatch(body, /Sync\(/);
557
+ });