@bongos/core 1.21.5 → 1.21.6

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 (76) hide show
  1. package/.bongos-core.json +138 -68
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  4. package/docs/api/openapi.json +2 -1
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +156 -144
  7. package/docs/copy-registry.json +286 -158
  8. package/docs/module-api-changelog.md +2 -0
  9. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  10. package/docs/onboarding/diagrams/assertions.json +1 -1
  11. package/docs/page-inventory.json +31 -4
  12. package/docs/page-readings.json +121 -94
  13. package/modules/autonomy/config-idle.js +192 -0
  14. package/modules/autonomy/routes/autonomy.js +22 -0
  15. package/modules/autonomy/runner-health.js +22 -1
  16. package/modules/hall-ui/public/city-draw.js +438 -0
  17. package/modules/hall-ui/public/city.css +110 -0
  18. package/modules/hall-ui/public/city.html +71 -0
  19. package/modules/hall-ui/public/city.js +177 -0
  20. package/modules/hall-ui/public/city.states.json +30 -0
  21. package/modules/hall-ui/public/gate.js +11 -4
  22. package/modules/hall-ui/public/profile.css +4 -0
  23. package/modules/hall-ui/public/profile.js +25 -1
  24. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  25. package/modules/hall-ui/public/settings.css +26 -0
  26. package/modules/hall-ui/public/settings.html +8 -0
  27. package/modules/hall-ui/public/settings.js +43 -3
  28. package/modules/platform-identity/craft-rollup.js +72 -0
  29. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  30. package/modules/platform-identity/platform-identity.js +12 -6
  31. package/modules/platform-identity/routes/sso.js +4 -0
  32. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  33. package/modules/provisioning/core-upgrade.js +34 -7
  34. package/modules/provisioning/module.json +2 -1
  35. package/modules/provisioning/seams.js +2 -0
  36. package/modules/public-landing/public/projects.states.json +1 -0
  37. package/package-lock.json +2 -2
  38. package/package.json +1 -1
  39. package/release-notes.json +58 -0
  40. package/scripts/gds/autobongos-grade-cap.js +187 -0
  41. package/scripts/gds/autobongos-loop.js +4 -0
  42. package/scripts/gds/autobongos-run.js +154 -13
  43. package/scripts/gds/autobongos-verify.js +211 -14
  44. package/scripts/gds/provision-core-upgrade.js +14 -2
  45. package/scripts/gds/provision-repo.js +77 -13
  46. package/scripts/gds/ship-flow.js +5 -0
  47. package/scripts/gds/ship-preflight-steps.js +2 -1
  48. package/scripts/gds/ship.js +10 -0
  49. package/scripts/gds/update-sweep.js +24 -8
  50. package/scripts/gds/upgrade-outcome.js +1 -0
  51. package/src/bongos/auth-admission.js +95 -13
  52. package/src/bongos/db-kernel.js +1 -1
  53. package/src/bongos/routes/core-update.js +27 -2
  54. package/src/bongos/serve-internal.js +14 -0
  55. package/src/bongos/software-update.js +3 -2
  56. package/src/module-api.js +1 -1
  57. package/tests/activity_rollup_order_db.mjs +3 -1
  58. package/tests/activity_snapshots_db.mjs +2 -0
  59. package/tests/autobongos_grade_cap.mjs +125 -0
  60. package/tests/autobongos_loop.mjs +230 -1
  61. package/tests/autobongos_verify.mjs +239 -3
  62. package/tests/autonomy_config_idle.mjs +272 -0
  63. package/tests/core_update_banner.mjs +29 -0
  64. package/tests/core_upgrade_door.mjs +31 -1
  65. package/tests/core_upgrade_runner.mjs +20 -0
  66. package/tests/hall_audit.mjs +10 -1
  67. package/tests/hall_city.mjs +369 -0
  68. package/tests/hall_page_gate_map.mjs +5 -0
  69. package/tests/hub_craft_rollup.mjs +350 -0
  70. package/tests/profile_rollup_consent.mjs +2 -0
  71. package/tests/profile_route.mjs +16 -2
  72. package/tests/provision.mjs +78 -0
  73. package/tests/settings_main_role.mjs +190 -0
  74. package/tests/software_update.mjs +56 -1
  75. package/tests/update_subscription_engine.mjs +55 -3
  76. package/tests/wizard_physics_more.mjs +19 -0
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ // modules/autonomy/config-idle.js — idle because there is no work, or idle
4
+ // because of a setting the owner can fix? (task 1004539.) Pure: no I/O, no clock
5
+ // of its own; `nowMs` is injected and the one outward call (`post`) is too.
6
+ //
7
+ // THE INCIDENT. From 2026-10-01T23:48Z to 2026-10-02T09:47Z a runner sat fenced
8
+ // with `no_builder_scope` — its builder had picked no goals — while the hall said
9
+ // "alive, waiting for work" and nobody was told. Ten hours of a machine doing
10
+ // exactly what it was configured to do, which was nothing.
11
+ //
12
+ // TWO KINDS OF IDLE, and the whole point is that they must not look alike:
13
+ // · QUIET — nothing claimable, a capacity hold with a known reset, the owner's
14
+ // kill switch, the owner's pause on one builder. Either there is no work, or
15
+ // a person deliberately said stop. Telling the owner about their own decision
16
+ // is noise, and noise is how a status light gets ignored.
17
+ // · CONFIGURATION — the runner would work, but a setting stops it and nothing
18
+ // will change until someone changes the setting. These are the codes below.
19
+ //
20
+ // THE GRACE. A configuration refusal is surfaced only after it has held for its
21
+ // grace. Fifteen minutes for the settings a person changes on purpose (an owner
22
+ // mid-edit on the Gate page is not an incident), an hour for the two that can
23
+ // also be a passing blip (an instance restart, a gauge between sessions).
24
+ //
25
+ // THE SPLIT OF WORK. The RUNNER knows what refused it and for how long, so it
26
+ // tracks that (trackIdle) and, once the grace has passed, says so on its
27
+ // heartbeat as `last_event: 'config_idle:<code>'` (markBeat). The SERVER reads
28
+ // that one string: runner-health.js turns it into a not-ok state with the fix,
29
+ // and the notifier below posts one Discord notice per change. No new column: the
30
+ // heartbeat already carries a free-text last event, and a runner that predates
31
+ // this sends none of these, which reads exactly as before.
32
+
33
+ const GRACE_S = 15 * 60;
34
+ const LONG_GRACE_S = 60 * 60;
35
+
36
+ // Every configuration code, the sentence the owner reads, and the fix. A code
37
+ // that is not here is never an alert — that is how kill_switch, builder_paused
38
+ // and not_permitted stay quiet by construction rather than by an exception.
39
+ const CONFIG_IDLE = Object.freeze({
40
+ no_builder_scope: Object.freeze({
41
+ graceS: GRACE_S,
42
+ why: 'no goals are picked for this runner, so it takes no work',
43
+ remedy: 'Choose which goals your runner works in Settings → Your Autobongos runner (/builders/settings#autobongos).',
44
+ }),
45
+ no_allowlist: Object.freeze({
46
+ graceS: GRACE_S,
47
+ why: 'no goal is allowed for runners on this project',
48
+ remedy: 'Allow at least one goal for runners on the Gate page.',
49
+ }),
50
+ goal_not_allowlisted: Object.freeze({
51
+ graceS: GRACE_S,
52
+ why: 'it was started for goals that are not on the allowlist',
53
+ remedy: 'Add those goals on the Gate page, or restart the runner without --goal / AUTOBONGOS_GOALS.',
54
+ }),
55
+ fence_unreadable: Object.freeze({
56
+ graceS: LONG_GRACE_S,
57
+ why: 'it cannot read its fence from this instance, so nothing is permitted',
58
+ remedy: 'Check the runner PC can reach this instance and that its Bongos CLI session is still valid (re-issue it in Settings if not).',
59
+ }),
60
+ gauge_unknown: Object.freeze({
61
+ graceS: LONG_GRACE_S,
62
+ why: 'its usage gauge has no fresh reading, so it will not start work',
63
+ remedy: 'Wire the usage-gauge statusLine on the runner PC (modules/autonomy/CLAUDE.md, "Wiring the gauge"), or open one Claude session there.',
64
+ }),
65
+ });
66
+
67
+ const PREFIX = 'config_idle:';
68
+ const PENDING = 'config_pending:';
69
+
70
+ // configIdleCode — which configuration, if any, one run-log row is idle on.
71
+ function configIdleCode(row) {
72
+ if (!row || typeof row !== 'object') return null;
73
+ if (row.event === 'fenced') {
74
+ const code = row.code;
75
+ // goal_not_allowlisted counts only at RUN level (every requested goal
76
+ // refused). The same code on one TASK is the fence doing its job on one pick.
77
+ if (code === 'goal_not_allowlisted') return row.task_id ? null : code;
78
+ if (code === 'no_builder_scope' || code === 'no_allowlist' || code === 'fence_unreadable') return code;
79
+ return null;
80
+ }
81
+ // An UNKNOWN gauge, never a capacity hold: a spent window has a reset and ends
82
+ // by itself, and the usage limit is a hold the owner has ruled acceptable.
83
+ if (row.event === 'hold' && row.unknown === true && !row.usage_limit) return 'gauge_unknown';
84
+ return null;
85
+ }
86
+
87
+ // trackIdle — the runner's memory of how long it has been idle on one code.
88
+ // Any row that is not a configuration refusal ends the stretch; a DIFFERENT code
89
+ // starts a new one, because the fix changed and so did the owner's grace.
90
+ function trackIdle(prev, row, nowMs) {
91
+ const code = configIdleCode(row);
92
+ if (!code) return null;
93
+ if (prev && prev.code === code && Number.isFinite(prev.sinceMs)) return prev;
94
+ return { code, sinceMs: nowMs };
95
+ }
96
+
97
+ // idleEvent — the heartbeat word for a stretch that has outlived its grace.
98
+ function idleEvent(tracker, nowMs) {
99
+ if (!tracker || !CONFIG_IDLE[tracker.code]) return null;
100
+ if (!Number.isFinite(nowMs) || nowMs - tracker.sinceMs < CONFIG_IDLE[tracker.code].graceS * 1000) return null;
101
+ return `${PREFIX}${tracker.code}`;
102
+ }
103
+
104
+ // markBeat — the beat fields the runner sends. A beat that carries a task, a
105
+ // boot or an exit says something more specific than "idle" and is left alone.
106
+ // Inside the grace the stretch is PENDING (`config_pending:<code>`): the hall
107
+ // reads it as an ordinary fence wait, and the notifier knows it is the same
108
+ // setting still holding — so a runner that restarts mid-stretch does not "clear"
109
+ // and then re-raise its notice, while a real capacity hold or a kill switch
110
+ // (which end the stretch, so are sent as themselves) still clears it.
111
+ function markBeat(fields, tracker, nowMs) {
112
+ const f = fields || {};
113
+ if (f.working_task_id || f.last_event === 'boot' || f.last_event === 'upgrade_exit') return f;
114
+ const ev = idleEvent(tracker, nowMs);
115
+ if (ev) return { ...f, last_event: ev };
116
+ if (tracker && CONFIG_IDLE[tracker.code]) return { ...f, last_event: `${PENDING}${tracker.code}` };
117
+ return f;
118
+ }
119
+
120
+ // parseIdleEvent — the code a heartbeat's last_event names, or null. Only a KNOWN
121
+ // code is honoured, so a heartbeat cannot make the hall print arbitrary text.
122
+ function parseIdleEvent(lastEvent) {
123
+ if (typeof lastEvent !== 'string' || !lastEvent.startsWith(PREFIX)) return null;
124
+ const code = lastEvent.slice(PREFIX.length);
125
+ return Object.prototype.hasOwnProperty.call(CONFIG_IDLE, code) ? code : null;
126
+ }
127
+
128
+ // Heartbeat events that say nothing about whether the setting was fixed: the
129
+ // loop's own rhythm and a restart. A pending stretch on the SAME code is neutral
130
+ // too (see markBeat). Anything else — a fence or hold the runner sent as itself,
131
+ // a pick, a worker, "nothing claimable" — means the setting no longer holds it.
132
+ const NEUTRAL = new Set([null, undefined, '', 'boot', 'loop', 'waiting', 'code_refreshed', 'upgrade_exit']);
133
+
134
+ function pendingCode(lastEvent) {
135
+ if (typeof lastEvent !== 'string' || !lastEvent.startsWith(PENDING)) return null;
136
+ const code = lastEvent.slice(PENDING.length);
137
+ return Object.prototype.hasOwnProperty.call(CONFIG_IDLE, code) ? code : null;
138
+ }
139
+
140
+ // noticeDecision — post, or stay quiet? `notified` is the code last announced for
141
+ // this runner (null for none). Raise once per code, clear once when it resolves,
142
+ // and say nothing on every other beat.
143
+ function noticeDecision(notified, { lastEvent = null, workingTaskId = null } = {}) {
144
+ const code = parseIdleEvent(lastEvent);
145
+ if (code) return code === notified ? { action: null, next: notified } : { action: 'raise', code, next: code };
146
+ if (!notified) return { action: null, next: null };
147
+ if (!workingTaskId && (NEUTRAL.has(lastEvent) || pendingCode(lastEvent) === notified)) return { action: null, next: notified };
148
+ return { action: 'clear', code: notified, next: null };
149
+ }
150
+
151
+ // A host name is the builder's own text and lands in a shared channel, so it is
152
+ // cut to the characters a host name has and set as code — never a link, never
153
+ // formatting.
154
+ function safeHost(host) {
155
+ const h = String(host || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64);
156
+ return h ? ` on \`${h}\`` : '';
157
+ }
158
+
159
+ function noticeText({ action, code, host }) {
160
+ const spec = CONFIG_IDLE[code];
161
+ const pc = safeHost(host);
162
+ if (action === 'clear') return `Autobongos runner${pc}: no longer held — it was idle because ${spec.why}, and that has changed.`;
163
+ return `Autobongos runner${pc} is idle because ${spec.why}. Fix: ${spec.remedy}`;
164
+ }
165
+
166
+ // createNotifier — the de-duplicating Discord line. One remembered code per
167
+ // (builder, host), in memory: an instance restart forgets it and may repeat one
168
+ // notice, which is the cheap end of the trade against a migration for a flag.
169
+ // `post` is the discord.post port ({ channel, text }) or null when the instance
170
+ // has no Discord; then nothing is remembered either, so wiring Discord later
171
+ // still gets the first notice.
172
+ function createNotifier({ post = null, channel = 'ship_news' } = {}) {
173
+ const notified = new Map();
174
+ return {
175
+ notified,
176
+ async observe({ builderId, host, lastEvent, workingTaskId } = {}) {
177
+ const key = `${builderId}|${String(host || '').toLowerCase()}`;
178
+ const d = noticeDecision(notified.get(key) || null, { lastEvent, workingTaskId });
179
+ if (!d.action) return { posted: false, action: null };
180
+ if (typeof post !== 'function') return { posted: false, action: d.action, reason: 'no_discord' };
181
+ const res = await post({ channel, text: noticeText({ action: d.action, code: d.code, host }) });
182
+ if (res && res.posted === false) return { posted: false, action: d.action, reason: res.reason || 'not_posted' };
183
+ if (d.next) notified.set(key, d.next); else notified.delete(key);
184
+ return { posted: true, action: d.action, code: d.code };
185
+ },
186
+ };
187
+ }
188
+
189
+ module.exports = {
190
+ GRACE_S, LONG_GRACE_S, CONFIG_IDLE,
191
+ configIdleCode, trackIdle, idleEvent, markBeat, parseIdleEvent, pendingCode, noticeDecision, noticeText, createNotifier,
192
+ };
@@ -30,6 +30,7 @@ const autonomyGate = require('../../../scripts/gds/autonomy-gate');
30
30
  const db = require('../db');
31
31
  const fence = require('../fence');
32
32
  const runnerHealth = require('../runner-health');
33
+ const configIdle = require('../config-idle');
33
34
  // The doorway's namespaced logger and strict body validator. Raw console.* is
34
35
  // ratcheted repo-wide (fitness.js console_call_count) and unstructured besides.
35
36
  const log = api.logger('autonomy');
@@ -109,6 +110,18 @@ module.exports = function buildAutonomyRouter() {
109
110
  governorDocket.contributeAutonomyToDocket();
110
111
  const router = express.Router();
111
112
 
113
+ // The one Discord line for a runner idle on a setting (task 1004539). The port
114
+ // is resolved per post, not here: the discord module registers it at mount and
115
+ // may mount after this router. No Discord on the instance answers no_discord,
116
+ // and the hall's runner panel still says it either way.
117
+ const idleNotifier = configIdle.createNotifier({
118
+ post: async (msg) => {
119
+ const port = typeof api.resolveOptional === 'function' ? api.resolveOptional('discord.post', null) : null;
120
+ if (typeof port !== 'function') return { posted: false, reason: 'no_discord' };
121
+ return port(msg);
122
+ },
123
+ });
124
+
112
125
  router.get('/autonomy/precheck', auth.requireBuilder, auth.requirePermission('autonomy.precheck'), async (req, res) => {
113
126
  const routine = String(req.query.routine || '').slice(0, 64);
114
127
  if (!routine) return res.fail('routine_required', { status: 400, message: 'pass ?routine=<name>' });
@@ -377,6 +390,15 @@ module.exports = function buildAutonomyRouter() {
377
390
  claudeAccount: typeof b.claude_account === 'string' && b.claude_account.trim() ? b.claude_account.trim() : null,
378
391
  });
379
392
  res.json({ ok: true, heartbeat: row });
393
+ // AFTER the answer, and never awaited by it: a notice that cannot be posted
394
+ // must not turn a recorded check-in into a failed one (the fail-soft rule
395
+ // below). It de-duplicates itself — once per cause, once when it clears.
396
+ idleNotifier.observe({
397
+ builderId: req.builder.id,
398
+ host: b.host,
399
+ lastEvent: b.last_event || null,
400
+ workingTaskId: Number.isInteger(b.working_task_id) ? b.working_task_id : null,
401
+ }).catch((e) => log.warn(`[gds] autonomy: idle notice not posted: ${e && e.message ? e.message : e}`));
380
402
  } catch (err) {
381
403
  // FAIL SOFT, and deliberately unlike the fence's GET. A heartbeat that
382
404
  // cannot be written is a monitoring gap; turning it into an error the runner
@@ -24,6 +24,12 @@
24
24
  // relaunches, which takes a minute or two, and the owner ruled that a restart
25
25
  // must read as a restart, never as a death (task 1004407). Past RESTART_S the
26
26
  // relaunch has failed and it is dead again — with a detail that says so.
27
+ // · idle_config — alive, but idle on a setting the owner can fix (no goals
28
+ // picked, an empty allowlist, a fence it cannot read, a gauge with no
29
+ // reading), for longer than that setting's grace (task 1004539). NOT ok, and
30
+ // it carries the fix as `remedy`. The runner decides the grace and says
31
+ // `config_idle:<code>` on its heartbeat; only a code config-idle.js knows is
32
+ // honoured, so the owner's kill switch and pause can never read as an alarm.
27
33
 
28
34
  // Reasoned from the runner's own numbers, not picked. The loop beats at the top of
29
35
  // every pass and again on each poll inside a wait (every 60s), so a healthy runner
@@ -38,6 +44,8 @@ const LATE_S = 2 * 60 * 60;
38
44
  // relaunch which never happened is noticed the same evening.
39
45
  const RESTART_S = 10 * 60;
40
46
 
47
+ const configIdle = require('./config-idle.js');
48
+
41
49
  const SHA_RE = /^[0-9a-f]{40}$/;
42
50
  const sha = (v) => (typeof v === 'string' && SHA_RE.test(v) ? v : null);
43
51
 
@@ -129,8 +137,21 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
129
137
  };
130
138
  }
131
139
 
140
+ // Idle on configuration — but only while the runner is still talking. Past the
141
+ // dead line silence wins: a dead runner cannot vouch for its settings.
142
+ const idleCode = configIdle.parseIdleEvent(heartbeat.last_event);
143
+ if (idleCode && age <= lateS) {
144
+ const spec = configIdle.CONFIG_IDLE[idleCode];
145
+ return {
146
+ state: 'idle_config', ok: false, ageS: age, age: humanAge(age), ...ident,
147
+ idleCode, why: spec.why, remedy: spec.remedy,
148
+ detail: `idle${where} because ${spec.why}`,
149
+ };
150
+ }
151
+
132
152
  if (age <= aliveS) {
133
- const doing = ACTIVITY[heartbeat.last_event];
153
+ // A setting still inside its grace reads as the fence wait it is, not "running".
154
+ const doing = ACTIVITY[heartbeat.last_event] || (configIdle.pendingCode(heartbeat.last_event) ? ACTIVITY.fenced : null);
134
155
  return {
135
156
  state: 'alive', ok: true, ageS: age, age: humanAge(age), ...ident,
136
157
  detail: working