@bongos/core 1.20.60 → 1.20.62

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 (45) hide show
  1. package/.bongos-core.json +95 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +6 -2
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +2 -0
  8. package/docs/api/openapi.json +112 -3
  9. package/docs/api-reference.md +5 -4
  10. package/docs/copy-inventory.md +377 -328
  11. package/docs/copy-registry.json +894 -421
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +4 -1
  14. package/docs/page-readings.json +546 -483
  15. package/docs/recipes/upgrading-the-core.md +30 -1
  16. package/modules/autonomy/db.js +13 -6
  17. package/modules/autonomy/migrations/autonomy_004_runner_identity.sql +30 -0
  18. package/modules/autonomy/routes/autonomy.js +16 -0
  19. package/modules/autonomy/runner-health.js +66 -5
  20. package/modules/hall-ui/public/gate.js +24 -8
  21. package/modules/provisioning/demo.js +176 -0
  22. package/modules/provisioning/migrations/provisioning_035_demo.sql +26 -0
  23. package/modules/provisioning/module.json +5 -3
  24. package/modules/provisioning/pollers/demo-archive.js +61 -0
  25. package/modules/provisioning/provisioning.js +2 -2
  26. package/modules/provisioning/routes/demo.js +54 -0
  27. package/modules/provisioning/routes/provisioning.js +7 -4
  28. package/modules/public-landing/public/projects-demo.states.json +53 -0
  29. package/modules/public-landing/public/projects.html +300 -23
  30. package/modules/public-landing/public/projects.probes.json +3 -3
  31. package/modules/public-landing/public/projects.states.json +2 -1
  32. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +114 -0
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +20 -0
  36. package/scripts/gds/autobongos-run.js +43 -2
  37. package/scripts/gds/update-channel.js +11 -2
  38. package/scripts/gds/update-sweep.js +717 -0
  39. package/src/module-api.js +1 -1
  40. package/tests/autonomy_runner_identity.mjs +215 -0
  41. package/tests/update_channel_db.mjs +13 -6
  42. package/tests/update_subscription_engine.mjs +5 -5
  43. package/tests/update_sweep_home.mjs +100 -0
  44. package/tests/wizard_demo.mjs +264 -0
  45. package/tests/wizard_front_door.mjs +16 -20
@@ -90,7 +90,7 @@ The sections above are the *manual* `bongos upgrade`. The **subscription** runs
90
90
 
91
91
  A **major** is never automatic; prereleases are never auto-targeted.
92
92
 
93
- **Where the channel comes from** ([ADR 0357](../adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md), task [1004468](https://cloudbongos.com/builders#/task/1004468)): the rule set on **/deploy** (`provisioning_instances.update_channel`) wins for any roster entry whose `slug` matches a project row. The roster's `channel` below is only the fallback, used when no row matches or the sweep can't read the database. The sweep reads the control plane's DB from its own `DATABASE_URL` / `PGDATABASE`, so the unit needs one of them set. Every run prints each entry's channel and its source.
93
+ **Where the channel comes from** ([ADR 0357](../adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md), task [1004468](https://cloudbongos.com/builders#/task/1004468)): the rule set on **/deploy** (`provisioning_instances.update_channel`) wins for any roster entry whose `slug` matches a project row. The roster's `channel` below is only the fallback, used when no row matches or the sweep can't read the database. The sweep reads the control plane's DB from `UPDATE_CHANNEL_DB` (a db name or connection string), else `DATABASE_URL` / `PGDATABASE`. On a control plane, set `UPDATE_CHANNEL_DB`, never `PGDATABASE`: the sweep's env reaches every upgrade it spawns, so a tenant entry with no database of its own would migrate against the control plane's. Every run prints each entry's channel and its source.
94
94
 
95
95
  **Enrolling an instance** — add it to `config/update-subscriptions.json` (instance-side) (ships empty). `env` carries per-instance vars merged into the upgrade child — set `PGDATABASE` so `migrate` hits the right DB (see the Gotchas):
96
96
 
@@ -118,6 +118,35 @@ Each subscribed instance: read its installed core → ask the registry (`npm vie
118
118
 
119
119
  **Triple-gated, so nothing moves by accident:** the `core-update-subscription` routine is `requiresAutonomy: true` + **default OFF** in [`config/scheduled-routines.json`](../../config/scheduled-routines.json), the roster ships empty, and every upgrade is health-gated. Arm it by enabling the routine's timer on the control plane once autonomy is on.
120
120
 
121
+ #### Run the sweep from the installed core (task 1004307)
122
+
123
+ The sweep's body is `scripts/gds/update-sweep.js`, which ships in every core release.
124
+ `.claude/scheduled-tasks/core-update-subscription/subscribe.js` is only a shim. A control plane
125
+ should run the body **straight from the core it installs**, so each upgrade brings the next sweep
126
+ current code. A sweep run from a checkout is only as new as that checkout, and on cloudbongos.com
127
+ one sat frozen for a month while every fix to the lane went inert. The roster stays where it is;
128
+ `--instance` names it.
129
+
130
+ ```ini
131
+ # /etc/systemd/system/<instance>-core-update-subscription.service.d/run-from-core.conf
132
+ [Service]
133
+ ExecStart=
134
+ ExecStart=/usr/bin/node <served-root>/node_modules/@bongos/core/scripts/gds/update-sweep.js --apply --instance <roster-root>
135
+ Environment=UPDATE_CHANNEL_DB=<control-plane-db>
136
+ ```
137
+
138
+ `<served-root>` is the root the sweep keeps current (the roster entry for the control plane itself);
139
+ `<roster-root>` is where `config/update-subscriptions.json` lives. Prove it with a dry run as the
140
+ unit's user before restarting anything. Its first line names the core doing the sweeping, and each
141
+ entry's line names its channel and source:
142
+
143
+ ```bash
144
+ sudo -u <user> env UPDATE_CHANNEL_DB=<control-plane-db> node <served-root>/node_modules/@bongos/core/scripts/gds/update-sweep.js --instance <roster-root>
145
+ ```
146
+
147
+ Every run prints `update-sweep · running from <core dir> (core <version>) · roster <root>`, so
148
+ `journalctl -u <instance>-core-update-subscription.service` answers "is the box running current code?".
149
+
121
150
  #### How often it sweeps — the lag knob (task 1003674)
122
151
 
123
152
  **This timer's period IS the delay between a builder's merge and the live site
@@ -162,11 +162,11 @@ async function disallowGoal(goalId) {
162
162
  // recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
163
163
  // case is one write, and `host` is the key so a second runner appears as a second
164
164
  // row rather than silently overwriting the first.
165
- async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null, workingGoalId = null }) {
165
+ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null, workingGoalId = null, head = null, diskHead = null, mainHead = null, claudeAccount = null }) {
166
166
  const { rows } = await pool.query(
167
167
  `INSERT INTO autonomy_runner_heartbeat
168
- (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at, updated_at)
169
- VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, now(), now())
168
+ (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, last_seen_at, updated_at)
169
+ VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, $10, $11, $12, $13, now(), now())
170
170
  ON CONFLICT (builder_id, host) DO UPDATE SET
171
171
  pid = EXCLUDED.pid,
172
172
  started_at = COALESCE(EXCLUDED.started_at, autonomy_runner_heartbeat.started_at),
@@ -175,10 +175,16 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
175
175
  last_event = EXCLUDED.last_event,
176
176
  working_task_id = EXCLUDED.working_task_id,
177
177
  working_goal_id = EXCLUDED.working_goal_id,
178
+ -- Overwritten, never COALESCEd (task 1004407): a fresh process that cannot
179
+ -- read its own commit must say "unknown", not inherit the last process's.
180
+ head = EXCLUDED.head,
181
+ disk_head = EXCLUDED.disk_head,
182
+ main_head = EXCLUDED.main_head,
183
+ claude_account = EXCLUDED.claude_account,
178
184
  last_seen_at = now(),
179
185
  updated_at = now()
180
- RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at`,
181
- [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId]
186
+ RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, head, disk_head, main_head, claude_account, last_seen_at`,
187
+ [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId, head, diskHead, mainHead, claudeAccount]
182
188
  );
183
189
  return rows[0] || null;
184
190
  }
@@ -193,7 +199,8 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
193
199
  // liveness view entirely (grader finding, task 1003905).
194
200
  async function readRunnerHeartbeats(builderId) {
195
201
  const { rows } = await pool.query(
196
- `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at
202
+ `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id,
203
+ head, disk_head, main_head, claude_account, last_seen_at
197
204
  FROM autonomy_runner_heartbeat
198
205
  WHERE builder_id = $1
199
206
  ORDER BY last_seen_at DESC
@@ -0,0 +1,30 @@
1
+ -- autonomy_004_runner_identity.sql — what the runner card can say about a runner
2
+ -- (task 1004407).
3
+ --
4
+ -- Owner ruling, 2026-10-01: the gate page must show which code each runner is
5
+ -- running, whether it is live or restarting, which always-on PC it is on, and
6
+ -- which Claude account its workers spend. `host` already names the PC
7
+ -- (autonomy_002); these four columns carry the rest.
8
+ --
9
+ -- head the commit the supervisor process is RUNNING (40-char hex)
10
+ -- disk_head the commit on disk, when the checkout was refreshed in place
11
+ -- past what is running (task 1004406) — null otherwise
12
+ -- main_head the newest main the runner has seen, so the card can say
13
+ -- "behind main" without the server reaching GitHub
14
+ -- claude_account a LABEL for the Claude login its workers use (an email, or
15
+ -- "API key" / "Claude token" when one is set). Never a token or
16
+ -- a key: the route caps it at 200 characters and the runner only
17
+ -- ever sends the label.
18
+ --
19
+ -- Nullable, no defaults: a runner that predates this reports nothing, and the
20
+ -- card says nothing rather than guessing. Additive and idempotent.
21
+
22
+ BEGIN;
23
+
24
+ ALTER TABLE autonomy_runner_heartbeat
25
+ ADD COLUMN IF NOT EXISTS head text,
26
+ ADD COLUMN IF NOT EXISTS disk_head text,
27
+ ADD COLUMN IF NOT EXISTS main_head text,
28
+ ADD COLUMN IF NOT EXISTS claude_account text;
29
+
30
+ COMMIT;
@@ -52,6 +52,11 @@ function runsLimit(raw) {
52
52
  return Math.min(RUNS_MAX_LIMIT, Math.max(1, n));
53
53
  }
54
54
 
55
+ // commitOrNull — a full lowercase commit sha, or null (task 1004407).
56
+ function commitOrNull(v) {
57
+ return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
58
+ }
59
+
55
60
  module.exports = function buildAutonomyRouter() {
56
61
  const router = express.Router();
57
62
 
@@ -219,6 +224,11 @@ module.exports = function buildAutonomyRouter() {
219
224
  last_event: { type: 'string', maxLength: 64 },
220
225
  working_task_id: { type: 'number' },
221
226
  working_goal_id: { type: 'number' },
227
+ // What the runner card says about the runner (task 1004407).
228
+ head: { type: 'string', maxLength: 40 },
229
+ disk_head: { type: 'string', maxLength: 40 },
230
+ main_head: { type: 'string', maxLength: 40 },
231
+ claude_account: { type: 'string', maxLength: 200 },
222
232
  })) return;
223
233
  const b = req.body || {};
224
234
  try {
@@ -234,6 +244,12 @@ module.exports = function buildAutonomyRouter() {
234
244
  lastEvent: b.last_event || null,
235
245
  workingTaskId: Number.isInteger(b.working_task_id) ? b.working_task_id : null,
236
246
  workingGoalId: Number.isInteger(b.working_goal_id) ? b.working_goal_id : null,
247
+ // A commit is stored only if it IS one: anything that is not 40 hex
248
+ // characters is dropped to null rather than rendered as a version.
249
+ head: commitOrNull(b.head),
250
+ diskHead: commitOrNull(b.disk_head),
251
+ mainHead: commitOrNull(b.main_head),
252
+ claudeAccount: typeof b.claude_account === 'string' && b.claude_account.trim() ? b.claude_account.trim() : null,
237
253
  });
238
254
  res.json({ ok: true, heartbeat: row });
239
255
  } catch (err) {
@@ -19,6 +19,11 @@
19
19
  // exactly what it is for, and crying dead every time it works is worse than
20
20
  // saying nothing.
21
21
  // · dead — quiet longer than any legitimate explanation.
22
+ // · restarting — its last word was "exiting to pick up new code" (upgrade_exit,
23
+ // task 1004406), and that was recently. The wrapper fetches, resets and
24
+ // relaunches, which takes a minute or two, and the owner ruled that a restart
25
+ // must read as a restart, never as a death (task 1004407). Past RESTART_S the
26
+ // relaunch has failed and it is dead again — with a detail that says so.
22
27
 
23
28
  // Reasoned from the runner's own numbers, not picked. The loop beats at the top of
24
29
  // every pass and again on each poll inside a wait (every 60s), so a healthy runner
@@ -28,6 +33,43 @@ const ALIVE_S = 15 * 60;
28
33
  // worker holds the loop for its whole run. Two hours is that ceiling plus room for
29
34
  // the ship, the grade and the verify that follow it.
30
35
  const LATE_S = 2 * 60 * 60;
36
+ // A relaunch is fetch + reset + boot + the first beat. Ten minutes is several
37
+ // times that, so a slow fetch is not called a death, and short enough that a
38
+ // relaunch which never happened is noticed the same evening.
39
+ const RESTART_S = 10 * 60;
40
+
41
+ const SHA_RE = /^[0-9a-f]{40}$/;
42
+ const sha = (v) => (typeof v === 'string' && SHA_RE.test(v) ? v : null);
43
+
44
+ // describeCode — what the card says about the code a runner is on (task 1004407).
45
+ // `behind` is true only when BOTH sides are known and differ: the runner reports
46
+ // the newest main it has seen, and an unknown is never drawn as "out of date".
47
+ // A checkout refreshed in place counts by what is on disk, because that is what
48
+ // every worker and child process runs (task 1004406).
49
+ function describeCode(hb) {
50
+ const head = sha(hb && hb.head);
51
+ if (!head) return null;
52
+ const disk = sha(hb.disk_head);
53
+ const main = sha(hb.main_head);
54
+ const current = disk || head;
55
+ return {
56
+ head, short: head.slice(0, 8),
57
+ diskShort: disk && disk !== head ? disk.slice(0, 8) : null,
58
+ mainShort: main ? main.slice(0, 8) : null,
59
+ behind: main ? main !== current : null,
60
+ };
61
+ }
62
+
63
+ // What the runner was last doing, in words. Keyed on the run-log event names the
64
+ // runner sends as last_event; an unknown event says nothing rather than guessing.
65
+ const ACTIVITY = {
66
+ boot: 'just started',
67
+ waiting: 'waiting for work',
68
+ code_refreshed: 'updated its code in place',
69
+ nothing_claimable: 'waiting — nothing to claim',
70
+ fenced: 'held by the fence',
71
+ hold: 'pausing',
72
+ };
31
73
 
32
74
  function ageS(lastSeenAt, nowMs) {
33
75
  const t = Date.parse(lastSeenAt || '');
@@ -68,19 +110,38 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
68
110
  const mode = heartbeat.mode === 'probing' ? 'probing' : (heartbeat.mode === 'full' ? 'full' : null);
69
111
  const working = heartbeat.working_task_id ? String(heartbeat.working_task_id) : null;
70
112
  const where = host ? ` on ${host}` : '';
113
+ const ident = {
114
+ host, mode, workingTaskId: working,
115
+ account: typeof heartbeat.claude_account === 'string' && heartbeat.claude_account ? heartbeat.claude_account : null,
116
+ code: describeCode(heartbeat),
117
+ };
118
+
119
+ if (heartbeat.last_event === 'upgrade_exit') {
120
+ if (age <= RESTART_S) {
121
+ return {
122
+ state: 'restarting', ok: true, ageS: age, age: humanAge(age), ...ident,
123
+ detail: `restarting${where} to pick up new code`,
124
+ };
125
+ }
126
+ return {
127
+ state: 'dead', ok: false, ageS: age, age: humanAge(age), ...ident,
128
+ detail: `exited${where} ${humanAge(age)} ago to restart on new code and has not come back`,
129
+ };
130
+ }
71
131
 
72
132
  if (age <= aliveS) {
133
+ const doing = ACTIVITY[heartbeat.last_event];
73
134
  return {
74
- state: 'alive', ok: true, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
135
+ state: 'alive', ok: true, ageS: age, age: humanAge(age), ...ident,
75
136
  detail: working
76
137
  ? `working task ${working}${where}`
77
- : `${mode === 'probing' ? 'probing after failures' : 'running'}${where}`,
138
+ : `${mode === 'probing' ? 'probing after failures' : (doing || 'running')}${where}`,
78
139
  };
79
140
  }
80
141
 
81
142
  if (age <= lateS) {
82
143
  return {
83
- state: 'late', ok: true, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
144
+ state: 'late', ok: true, ageS: age, age: humanAge(age), ...ident,
84
145
  // Deliberately NOT an alarm. This is what a runner doing its job looks like.
85
146
  detail: working
86
147
  ? `quiet for ${humanAge(age)} — expected while task ${working} builds`
@@ -89,9 +150,9 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
89
150
  }
90
151
 
91
152
  return {
92
- state: 'dead', ok: false, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
153
+ state: 'dead', ok: false, ageS: age, age: humanAge(age), ...ident,
93
154
  detail: `no check-in for ${humanAge(age)}${where} — longer than any worker takes, so the runner is not running`,
94
155
  };
95
156
  }
96
157
 
97
- module.exports = { ALIVE_S, LATE_S, ageS, humanAge, describeRunner };
158
+ module.exports = { ALIVE_S, LATE_S, RESTART_S, ageS, humanAge, describeRunner };
@@ -358,7 +358,7 @@
358
358
  // exist here, resolved to inherited grey, and drew a dead runner in exactly the
359
359
  // same colour as a healthy one. Named classes cannot fail that way silently —
360
360
  // a missing class is visible in the stylesheet, a missing var is not.
361
- const HEALTH_CLASS = { alive: 'fact-pill--ok', late: 'fact-pill--warn', dead: 'fact-pill--danger', never_seen: 'fact-pill--info' };
361
+ const HEALTH_CLASS = { alive: 'fact-pill--ok', late: 'fact-pill--warn', dead: 'fact-pill--danger', never_seen: 'fact-pill--info', restarting: 'fact-pill--info' };
362
362
 
363
363
  async function loadRunners() {
364
364
  const el = document.getElementById('fence-runner');
@@ -384,13 +384,29 @@
384
384
  // NOT the word "running": the switch below already uses it for a different
385
385
  // thing (allowed to work), and the two lines sit one above the other. A
386
386
  // reader should never have to work out which sense is meant.
387
- const label = { dead: 'Not checking in', late: 'Quiet', alive: 'Checking in' }[h.state] || 'Unknown';
388
- return `<p class="ov-foot" style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;">
389
- <span class="fact-pill ${cls}">${escapeHtml(label)}</span>
390
- <span>${escapeHtml(h.detail || '')}</span>
391
- ${r.working_goal_id ? `<span class="ov-fact">from goal ${escapeHtml(String(r.working_goal_id))}</span>` : ''}
392
- <span class="ov-fact">last check-in ${escapeHtml(h.age || 'unknown')} ago</span>
393
- </p>`;
387
+ const label = { dead: 'Not checking in', late: 'Quiet', alive: 'Checking in', restarting: 'Restarting' }[h.state] || 'Unknown';
388
+ // Which PC, which Claude account, which code (task 1004407). Each line is
389
+ // drawn only when the runner reported it, so an older runner shows less
390
+ // rather than a guess.
391
+ const facts = [];
392
+ if (h.host) facts.push(`<span class="ov-fact">PC <strong>${escapeHtml(h.host)}</strong></span>`);
393
+ if (h.account) facts.push(`<span class="ov-fact">Claude account ${escapeHtml(h.account)}</span>`);
394
+ if (h.code) {
395
+ const c = h.code;
396
+ const status = c.behind === true ? ` — behind main (${escapeHtml(c.mainShort)})`
397
+ : c.behind === false ? ' — up to date' : '';
398
+ const disk = c.diskShort ? `, updated in place to ${escapeHtml(c.diskShort)}` : '';
399
+ facts.push(`<span class="ov-fact">code <code>${escapeHtml(c.short)}</code>${disk}${status}</span>`);
400
+ }
401
+ return `<div class="ov-foot">
402
+ <p style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin:0;">
403
+ <span class="fact-pill ${cls}">${escapeHtml(label)}</span>
404
+ <span>${escapeHtml(h.detail || '')}</span>
405
+ ${r.working_goal_id ? `<span class="ov-fact">from goal ${escapeHtml(String(r.working_goal_id))}</span>` : ''}
406
+ <span class="ov-fact">last check-in ${escapeHtml(h.age || 'unknown')} ago</span>
407
+ </p>
408
+ ${facts.length ? `<p style="display:flex;gap:14px;flex-wrap:wrap;margin:4px 0 0;">${facts.join('')}</p>` : ''}
409
+ </div>`;
394
410
  }).join('');
395
411
  }
396
412
 
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/demo.js — THE DEMO ROUTE: training wheels, a soft time box, and
4
+ // "keep your planet?" (task 1004420 / BV2.PS09, goal 1000121; spec
5
+ // docs/specs/bongos-v2-project-startup.md D3 + D9; design of record
6
+ // docs/design/mocks/project-startup/DemoSetup|DemoReady|DemoKeep.dc.html).
7
+ //
8
+ // A DEMO IS A NORMAL PROJECT MARKED "DEMO" (D9). It is created by the same POST
9
+ // /provisioning/instances as any project, with one more field, and it builds in the
10
+ // normal hall. What makes it a demo is the `demo` jsonb column (provisioning_035):
11
+ // { people, hours_each, started_at, ends_at, outcome, settled_at, warned_at, offline_at }
12
+ // and the defaults the demo picks for its founder: Monarchy / BDFL is already every new
13
+ // project's government (the government module's default board), so the demo adds only
14
+ // "no rewards" and "all managed" as answers (`detail`), and "private to the team" as
15
+ // settings (the map door closed, joinable by invite only). Everyone uses their own AI
16
+ // plan, which is how every project already works.
17
+ //
18
+ // THE TIME BOX IS SOFT (D3). `ends_at` is the start plus the hours each person said they
19
+ // have, and it only ever drives a countdown and the "keep your planet?" question. Nothing
20
+ // reads it to block or freeze work.
21
+ //
22
+ // A DEMO NOBODY KEEPS GOES OFFLINE AFTER 30 DAYS (D9). The demo-archive poller takes
23
+ // it offline through the existing teardown path, so it is restorable the way any
24
+ // torn-down project is ("bring it back"), with the repo, database and backups kept. The
25
+ // owner is told first: its own project page shows the date from the start, and from
26
+ // three days before it says plainly that the demo is about to go offline. "Carry it over" makes it a real project and the clock never applies again;
27
+ // "keep it as a demo" settles the question but leaves the 30-day rule in place.
28
+ //
29
+ // No route or SQL lives here beyond the small reads and writes the demo needs, so the
30
+ // two oversized files (provisioning.js, routes/provisioning.js) each gain a line, not a
31
+ // feature.
32
+
33
+ const DEMO_LIMITS = Object.freeze({ people: Object.freeze([1, 12]), hours_each: Object.freeze([1, 40]) });
34
+ const DEMO_OFFLINE_DAYS = 30;
35
+ const DEMO_WARN_DAYS = 3;
36
+ const DAY_MS = 86400000;
37
+ // The demo's picked answers (DemoReady): no rewards, all managed by Bongos. An owner's
38
+ // own answer to either always wins.
39
+ const DEMO_DETAIL = Object.freeze({ reward_intents: Object.freeze([]), framing: 'managed' });
40
+ // Private to the team: off the public map, and joinable by invite only.
41
+ const DEMO_SETTINGS = Object.freeze({ visibility: 'private', joinability: 'invite_only' });
42
+ const OUTCOMES = Object.freeze(['carried', 'kept']);
43
+
44
+ const intIn = (v, [lo, hi]) => Number.isInteger(v) && v >= lo && v <= hi;
45
+
46
+ // parseDemo(raw) — PURE. The create request's `demo` field, or why it is refused.
47
+ function parseDemo(raw) {
48
+ if (raw === undefined || raw === null) return { ok: true, demo: null };
49
+ if (typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'demo must be an object: { people, hours_each }.' };
50
+ const extra = Object.keys(raw).filter((k) => !['people', 'hours_each'].includes(k));
51
+ if (extra.length) return { ok: false, reason: `Unknown demo field "${extra[0]}": a demo takes people and hours_each.` };
52
+ if (!intIn(raw.people, DEMO_LIMITS.people)) return { ok: false, reason: 'people is a whole number from 1 to 12, you included.' };
53
+ if (!intIn(raw.hours_each, DEMO_LIMITS.hours_each)) return { ok: false, reason: 'hours_each is a whole number from 1 to 40.' };
54
+ return { ok: true, demo: { people: raw.people, hours_each: raw.hours_each } };
55
+ }
56
+
57
+ // fitCheck(people, hours) — PURE. The setup screen's first read: builder-hours and what
58
+ // fits in them. The wizard carries a copy of these sentences, held equal by test.
59
+ function fitCheck(people, hours) {
60
+ const total = people * hours;
61
+ const verdict = total < 8 ? 'tight' : total < 20 ? 'small' : 'plenty';
62
+ const sentence = {
63
+ tight: 'Too tight for this idea. Try one list with voting only, or add hours.',
64
+ small: 'Enough for a small version. Trim it to one shared list, voting and a winner reveal; accounts and notifications will not fit.',
65
+ plenty: 'Plenty for this idea, with room for one extra feature.',
66
+ }[verdict];
67
+ return { total, verdict, sentence };
68
+ }
69
+
70
+ // withDemoDetail(detail) — PURE. The demo's picked answers under the owner's own.
71
+ function withDemoDetail(detail) {
72
+ const own = detail && typeof detail === 'object' && !Array.isArray(detail) ? detail : {};
73
+ return { ...DEMO_DETAIL, reward_intents: [], ...own };
74
+ }
75
+
76
+ // demoRecord(demo, now) — PURE. What the column holds at the start.
77
+ function demoRecord(demo, now = new Date()) {
78
+ return {
79
+ people: demo.people,
80
+ hours_each: demo.hours_each,
81
+ started_at: now.toISOString(),
82
+ ends_at: new Date(now.getTime() + demo.hours_each * 3600000).toISOString(),
83
+ outcome: null,
84
+ };
85
+ }
86
+
87
+ const ms = (v) => { const t = v ? new Date(v).getTime() : NaN; return Number.isFinite(t) ? t : NaN; };
88
+
89
+ // demoView(row, now) — PURE. The demo as an owner read shows it, or null for a project
90
+ // that is not one (or was carried over: it is a real project now).
91
+ function demoView(row, now = new Date()) {
92
+ const d = row && row.demo;
93
+ if (!d || typeof d !== 'object' || d.outcome === 'carried') return null;
94
+ const start = ms(d.started_at);
95
+ const end = ms(d.ends_at);
96
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
97
+ const offline = start + DEMO_OFFLINE_DAYS * DAY_MS;
98
+ return {
99
+ people: d.people,
100
+ hours_each: d.hours_each,
101
+ builder_hours: fitCheck(d.people, d.hours_each).total,
102
+ started_at: d.started_at,
103
+ ends_at: d.ends_at,
104
+ remaining_seconds: Math.max(0, Math.round((end - now.getTime()) / 1000)),
105
+ time_up: now.getTime() >= end,
106
+ outcome: OUTCOMES.includes(d.outcome) ? d.outcome : null,
107
+ offline_on: new Date(offline).toISOString(),
108
+ offline_soon: now.getTime() >= offline - DEMO_WARN_DAYS * DAY_MS,
109
+ };
110
+ }
111
+
112
+ // The writes. `db` is the pool (or a client); every write is one statement on the row.
113
+ async function startDemo(db, instanceId, demo, now = new Date()) {
114
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [instanceId, JSON.stringify(demoRecord(demo, now))]);
115
+ }
116
+
117
+ // startDemoProject — right after the create files a demo's row: mark it, and make it
118
+ // private to the team. Returns the updated row, so the catalog projection that follows
119
+ // sees the closed door rather than the public default for a moment. `provisioning` is
120
+ // passed in (this file must not require it: provisioning.js requires this one).
121
+ async function startDemoProject(db, provisioning, instanceId, demo, now = new Date()) {
122
+ await startDemo(db, instanceId, demo, now);
123
+ return provisioning.updateInstanceSettings(db, instanceId, { ...DEMO_SETTINGS });
124
+ }
125
+
126
+ // settleDemo — the "keep your planet?" answer. Carry it over: the marker's outcome says
127
+ // so and the project is a real one from here on. Keep it as a demo: the question is
128
+ // settled and the 30-day rule still applies. Answered once; a second answer is refused.
129
+ async function settleDemo(db, inst, outcome, now = new Date()) {
130
+ if (!OUTCOMES.includes(outcome)) return { ok: false, reason: 'bad_outcome' };
131
+ const d = inst && inst.demo;
132
+ if (!d || typeof d !== 'object') return { ok: false, reason: 'not_a_demo' };
133
+ if (d.outcome) return { ok: false, reason: 'already_settled' };
134
+ const next = { ...d, outcome, settled_at: now.toISOString() };
135
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [inst.id, JSON.stringify(next)]);
136
+ return { ok: true, demo: next };
137
+ }
138
+
139
+ // sweepDemos — the demo-archive poller's tick. For every demo nobody carried over that
140
+ // is still up: stamp the warning three days before (the project page says it is about to go),
141
+ // and at 30 days take it offline through the existing teardown path.
142
+ async function sweepDemos(db, { enqueueTeardown, recordEvent, now = new Date() } = {}) {
143
+ const { rows } = await db.query(
144
+ `SELECT id, owner_builder_id, demo, status FROM provisioning_instances
145
+ WHERE demo IS NOT NULL
146
+ AND COALESCE(demo->>'outcome', '') <> 'carried'
147
+ AND demo->>'offline_at' IS NULL
148
+ AND status NOT IN ('tearing_down', 'torn_down')`,
149
+ );
150
+ const out = { warned: 0, offline: 0 };
151
+ for (const row of rows) {
152
+ const start = ms(row.demo.started_at);
153
+ if (!Number.isFinite(start)) continue;
154
+ const offlineAt = start + DEMO_OFFLINE_DAYS * DAY_MS;
155
+ if (now.getTime() >= offlineAt) {
156
+ await enqueueTeardown(db, row.id, null);
157
+ const next = { ...row.demo, offline_at: now.toISOString() };
158
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [row.id, JSON.stringify(next)]);
159
+ if (recordEvent) {
160
+ await recordEvent(db, { instanceId: row.id, ownerBuilderId: row.owner_builder_id, event: 'demo-offline',
161
+ detail: { days: DEMO_OFFLINE_DAYS }, actor: 'system:demo-archive' }).catch(() => {});
162
+ }
163
+ out.offline += 1;
164
+ } else if (now.getTime() >= offlineAt - DEMO_WARN_DAYS * DAY_MS && !row.demo.warned_at) {
165
+ const next = { ...row.demo, warned_at: now.toISOString() };
166
+ await db.query('UPDATE provisioning_instances SET demo = $2::jsonb WHERE id = $1', [row.id, JSON.stringify(next)]);
167
+ out.warned += 1;
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ module.exports = {
174
+ DEMO_LIMITS, DEMO_OFFLINE_DAYS, DEMO_WARN_DAYS, DEMO_DETAIL, DEMO_SETTINGS, OUTCOMES,
175
+ parseDemo, fitCheck, withDemoDetail, demoRecord, demoView, startDemo, startDemoProject, settleDemo, sweepDemos,
176
+ };
@@ -0,0 +1,26 @@
1
+ -- provisioning_035_demo.sql — the demo marker on a project (task 1004420, BV2.PS09; goal
2
+ -- 1000121, spec docs/specs/bongos-v2-project-startup.md D3 + D9).
3
+ --
4
+ -- WHY THIS EXISTS. The front door's "Try the demo" route creates a normal project marked
5
+ -- "demo" (D9): a small team tries Bongos in a few hours and keeps what they made. The mark
6
+ -- has to live on the project row, because the hub reads it for the countdown and the
7
+ -- "keep your planet?" question, and the demo-archive poller reads it to take a demo
8
+ -- nobody kept offline after 30 days.
9
+ --
10
+ -- WHAT IT ADDS.
11
+ -- provisioning_instances.demo — NULL for every ordinary project. For a demo:
12
+ -- { people, hours_each, started_at, ends_at, outcome, settled_at, warned_at, offline_at }
13
+ -- written at create (modules/provisioning/demo.js demoRecord), then by the "keep your
14
+ -- planet?" answer (outcome 'carried' | 'kept') and by the poller (warned_at, offline_at).
15
+ --
16
+ -- A column and not a `detail` key: detail is what the OWNER said, editable and
17
+ -- un-answerable by them; this is platform state an owner must not be able to erase to
18
+ -- dodge the archive. Nullable, no backfill: no project before this was a demo.
19
+ --
20
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
21
+
22
+ BEGIN;
23
+
24
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS demo jsonb;
25
+
26
+ COMMIT;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "key": "provisioning",
3
3
  "title": "Instance provisioning",
4
- "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state \u2014 it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
4
+ "description": "Stand up + operate a Cloud Bongos instance's own infrastructure (server, database, domain, DNS, TLS) via the API, so an owner does not manage infra by hand (ADR 0111). Owns the web-tier REQUEST + STATE surface only: it enqueues provisioning intents + reads instance state — it holds NO cloud-provider tokens and calls NO cloud API. A separate control-plane runner (scripts/gds/provision.js, task P3) is the sole token-holder that drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute WORKLOADS.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.14.0",
7
7
  "default": false,
@@ -20,13 +20,15 @@
20
20
  "env-manifest",
21
21
  "repo-private",
22
22
  "render-standup",
23
- "look"
23
+ "look",
24
+ "demo"
24
25
  ],
25
26
  "migrations": true,
26
27
  "pollers": [
27
28
  "liveness-sweep",
28
29
  "catalog-backfill",
29
- "app-liveness"
30
+ "app-liveness",
31
+ "demo-archive"
30
32
  ]
31
33
  },
32
34
  "provides": [
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/pollers/demo-archive.js — a demo nobody kept goes offline after 30
4
+ // days, and its owner is told first (task 1004420, BV2.PS09; spec D9).
5
+ //
6
+ // One sweep an hour over the demo-marked projects (demo.js sweepDemos): from three days
7
+ // before, a demo carries its offline date on its own card (the warning); at 30 days it is
8
+ // taken offline through the existing teardown intent, so it is restorable the way any
9
+ // torn-down project is. A demo carried over is a real project and is never swept.
10
+ // Kill-switched by PROVISIONING_DEMO_ARCHIVE_DISABLED=1; the liveness sweep's shape
11
+ // (self-rescheduling, jittered, unref'd, idempotent start).
12
+
13
+ const api = require('../../../src/module-api');
14
+ const provisioning = require('../provisioning');
15
+ const demo = require('../demo');
16
+
17
+ const log = api.logger('provisioning');
18
+
19
+ const INTERVAL_MS = 60 * 60 * 1000;
20
+ const FIRST_SWEEP_DELAY_MS = 5 * 60 * 1000;
21
+ const JITTER_MS = 5 * 60 * 1000;
22
+
23
+ let started = false;
24
+ let timer = null;
25
+
26
+ async function sweepOnce(deps = {}) {
27
+ const db = deps.pool || api.pool;
28
+ const out = await demo.sweepDemos(db, {
29
+ enqueueTeardown: deps.enqueueTeardown || provisioning.enqueueTeardown,
30
+ recordEvent: deps.recordEvent || provisioning.recordEvent,
31
+ now: deps.now,
32
+ });
33
+ if (out.warned || out.offline) log.info(`[demo-archive] warned ${out.warned}, took ${out.offline} offline`);
34
+ return out;
35
+ }
36
+
37
+ function arm(deps, delay) {
38
+ timer = setTimeout(() => {
39
+ sweepOnce(deps)
40
+ .catch((e) => log.error(`[demo-archive] sweep crashed: ${e && e.message ? e.message : e}`))
41
+ .then(() => { if (started) arm(deps, INTERVAL_MS + Math.floor(Math.random() * JITTER_MS)); });
42
+ }, delay);
43
+ if (timer.unref) timer.unref();
44
+ }
45
+
46
+ function start(deps = {}) {
47
+ if (process.env.PROVISIONING_DEMO_ARCHIVE_DISABLED === '1') {
48
+ log.info('[demo-archive] disabled (PROVISIONING_DEMO_ARCHIVE_DISABLED=1)');
49
+ return;
50
+ }
51
+ if (started) return;
52
+ started = true;
53
+ arm(deps, FIRST_SWEEP_DELAY_MS);
54
+ }
55
+
56
+ function stop() {
57
+ started = false;
58
+ if (timer) { clearTimeout(timer); timer = null; }
59
+ }
60
+
61
+ module.exports = { start, stop, sweepOnce, INTERVAL_MS };
@@ -23,7 +23,7 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
23
23
  // pure declarations beside this file, for the same reason: storage and the read
24
24
  // projection both have to read them.
25
25
  const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
- const { softLimitsFor } = require('./soft-limits');
26
+ const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); // the demo marker's owner view (task 1004420)
27
27
  const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look');
28
28
 
29
29
  // ---------------------------------------------------------------------------
@@ -450,7 +450,7 @@ function publicInstance(row) {
450
450
  detail,
451
451
  // Which startup options those answers hide, grey or annotate, each with its reason
452
452
  // (BV2.PS02, spec D6). Advice only — `enforced: false` says so on every read.
453
- soft_limits: softLimitsFor(detail),
453
+ soft_limits: softLimitsFor(detail), demo: demoView(row), // null unless a demo nobody carried over (task 1004420, ./demo.js)
454
454
  // The creation picker's module set (task 1002339): the always-on core, what
455
455
  // the owner ends up with, and whether that came from their own toggles or
456
456
  // from the type's starter bundle because they never answered. Resolved, not