@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.
- package/.bongos-core.json +95 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +2 -0
- package/docs/api/openapi.json +112 -3
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +377 -328
- package/docs/copy-registry.json +894 -421
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +546 -483
- package/docs/recipes/upgrading-the-core.md +30 -1
- package/modules/autonomy/db.js +13 -6
- package/modules/autonomy/migrations/autonomy_004_runner_identity.sql +30 -0
- package/modules/autonomy/routes/autonomy.js +16 -0
- package/modules/autonomy/runner-health.js +66 -5
- package/modules/hall-ui/public/gate.js +24 -8
- package/modules/provisioning/demo.js +176 -0
- package/modules/provisioning/migrations/provisioning_035_demo.sql +26 -0
- package/modules/provisioning/module.json +5 -3
- package/modules/provisioning/pollers/demo-archive.js +61 -0
- package/modules/provisioning/provisioning.js +2 -2
- package/modules/provisioning/routes/demo.js +54 -0
- package/modules/provisioning/routes/provisioning.js +7 -4
- package/modules/public-landing/public/projects-demo.states.json +53 -0
- package/modules/public-landing/public/projects.html +300 -23
- package/modules/public-landing/public/projects.probes.json +3 -3
- package/modules/public-landing/public/projects.states.json +2 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +114 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/autobongos-run.js +43 -2
- package/scripts/gds/update-channel.js +11 -2
- package/scripts/gds/update-sweep.js +717 -0
- package/src/module-api.js +1 -1
- package/tests/autonomy_runner_identity.mjs +215 -0
- package/tests/update_channel_db.mjs +13 -6
- package/tests/update_subscription_engine.mjs +5 -5
- package/tests/update_sweep_home.mjs +100 -0
- package/tests/wizard_demo.mjs +264 -0
- 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
|
|
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
|
package/modules/autonomy/db.js
CHANGED
|
@@ -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,
|
|
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),
|
|
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),
|
|
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),
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
</
|
|
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
|
|
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
|