@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
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.62'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// tests/autonomy_runner_identity.mjs — the runner card says which PC, which Claude
|
|
2
|
+
// account, which code, and whether the runner is live or restarting (task 1004407).
|
|
3
|
+
//
|
|
4
|
+
// Owner ruling, 2026-10-01: showing the code version is good, and showing when it
|
|
5
|
+
// is restarting versus live is better, along with which always-on PC and which
|
|
6
|
+
// Claude account it runs on. These pin each of those from the runner that reports
|
|
7
|
+
// them, through the route and the SQL, to the verdict the hall renders. The rule
|
|
8
|
+
// that holds across all of it: an unknown is shown as nothing, never as a guess,
|
|
9
|
+
// and a secret is never what gets sent.
|
|
10
|
+
//
|
|
11
|
+
// DB-free, no network. Run: node --test tests/autonomy_runner_identity.mjs
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const req = createRequire(import.meta.url);
|
|
22
|
+
const express = req('express');
|
|
23
|
+
const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
24
|
+
const health = req(path.join(ROOT, 'modules/autonomy/runner-health.js'));
|
|
25
|
+
const runner = req(path.join(ROOT, 'scripts/gds/autobongos-run.js'));
|
|
26
|
+
|
|
27
|
+
const NOW = Date.parse('2026-10-01T12:00:00.000Z');
|
|
28
|
+
const agoS = (s) => new Date(NOW - s * 1000).toISOString();
|
|
29
|
+
const A = 'a'.repeat(40);
|
|
30
|
+
const B = 'b'.repeat(40);
|
|
31
|
+
const hb = (over = {}) => ({ host: 'gmk-always-on', mode: 'full', last_event: 'loop', last_seen_at: agoS(30), ...over });
|
|
32
|
+
|
|
33
|
+
// ── live or restarting ───────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
test('a runner that just exited to pick up new code reads RESTARTING, not dead', () => {
|
|
36
|
+
const d = health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(90) }), { nowMs: NOW });
|
|
37
|
+
assert.equal(d.state, 'restarting');
|
|
38
|
+
assert.equal(d.ok, true, 'a restart is not a fault');
|
|
39
|
+
assert.match(d.detail, /restarting on gmk-always-on to pick up new code/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a restart that never came back is dead, and says it was a restart', () => {
|
|
43
|
+
const d = health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S + 60) }), { nowMs: NOW });
|
|
44
|
+
assert.equal(d.state, 'dead');
|
|
45
|
+
assert.equal(d.ok, false);
|
|
46
|
+
assert.match(d.detail, /exited on gmk-always-on .* to restart on new code and has not come back/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the restart window ends exactly at RESTART_S', () => {
|
|
50
|
+
assert.equal(health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S) }), { nowMs: NOW }).state, 'restarting');
|
|
51
|
+
assert.equal(health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S + 1) }), { nowMs: NOW }).state, 'dead');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a live runner says what it last did, in words', () => {
|
|
55
|
+
assert.match(health.describeRunner(hb({ last_event: 'code_refreshed' }), { nowMs: NOW }).detail, /updated its code in place on gmk-always-on/);
|
|
56
|
+
assert.match(health.describeRunner(hb({ last_event: 'waiting' }), { nowMs: NOW }).detail, /waiting for work/);
|
|
57
|
+
assert.match(health.describeRunner(hb({ last_event: 'loop' }), { nowMs: NOW }).detail, /^running on/, 'an event with no words keeps the old wording');
|
|
58
|
+
assert.match(health.describeRunner(hb({ working_task_id: '4242', last_event: 'worked' }), { nowMs: NOW }).detail, /working task 4242/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── which code ───────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
test('the code version is shown short, and flagged when it is behind main', () => {
|
|
64
|
+
const d = health.describeRunner(hb({ head: A, main_head: B }), { nowMs: NOW });
|
|
65
|
+
assert.equal(d.code.short, 'aaaaaaaa');
|
|
66
|
+
assert.equal(d.code.behind, true);
|
|
67
|
+
assert.equal(d.code.mainShort, 'bbbbbbbb');
|
|
68
|
+
assert.equal(health.describeRunner(hb({ head: A, main_head: A }), { nowMs: NOW }).code.behind, false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('an unknown main is never drawn as "out of date"', () => {
|
|
72
|
+
const c = health.describeRunner(hb({ head: A }), { nowMs: NOW }).code;
|
|
73
|
+
assert.equal(c.behind, null);
|
|
74
|
+
assert.equal(c.mainShort, null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a checkout refreshed in place is current by what is on DISK', () => {
|
|
78
|
+
// Workers and child processes run what is on disk (task 1004406), so a
|
|
79
|
+
// refreshed runner whose own process is older is still up to date.
|
|
80
|
+
const c = health.describeRunner(hb({ head: A, disk_head: B, main_head: B }), { nowMs: NOW }).code;
|
|
81
|
+
assert.equal(c.behind, false);
|
|
82
|
+
assert.equal(c.diskShort, 'bbbbbbbb');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('no head, or a head that is not a commit, shows no code line at all', () => {
|
|
86
|
+
for (const head of [null, undefined, '', 'abc', 'Z'.repeat(40), A.toUpperCase()]) {
|
|
87
|
+
assert.equal(health.describeRunner(hb({ head }), { nowMs: NOW }).code, null, String(head));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── which PC and which account ───────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
test('the PC and the Claude account ride on every state, dead included', () => {
|
|
94
|
+
for (const [over, state] of [[{}, 'alive'], [{ last_seen_at: agoS(health.LATE_S + 1) }, 'dead'], [{ last_event: 'upgrade_exit' }, 'restarting']]) {
|
|
95
|
+
const d = health.describeRunner(hb({ claude_account: 'me@example.com', head: A, ...over }), { nowMs: NOW });
|
|
96
|
+
assert.equal(d.state, state);
|
|
97
|
+
assert.equal(d.host, 'gmk-always-on');
|
|
98
|
+
assert.equal(d.account, 'me@example.com', `${state} must still say whose account it spent`);
|
|
99
|
+
assert.ok(d.code, `${state} must still say which code`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the account label names a key or a token by its variable, never its value', () => {
|
|
104
|
+
const secret = 'sk-ant-SECRET-VALUE';
|
|
105
|
+
const viaKey = runner.claudeAccountLabel({ env: { ANTHROPIC_API_KEY: secret }, readFile: () => { throw new Error('unread'); } });
|
|
106
|
+
assert.equal(viaKey, 'an API key (ANTHROPIC_API_KEY)');
|
|
107
|
+
const viaToken = runner.claudeAccountLabel({ env: { CLAUDE_CODE_OAUTH_TOKEN: secret }, readFile: () => { throw new Error('unread'); } });
|
|
108
|
+
assert.equal(viaToken, 'a Claude token (CLAUDE_CODE_OAUTH_TOKEN)');
|
|
109
|
+
assert.ok(!viaKey.includes(secret) && !viaToken.includes(secret));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('otherwise the label is the login Claude Code records, with its organisation', () => {
|
|
113
|
+
const seen = [];
|
|
114
|
+
const file = (o) => (p) => { seen.push(p); return JSON.stringify({ oauthAccount: o, primaryApiKey: 'never-read' }); };
|
|
115
|
+
assert.equal(runner.claudeAccountLabel({ env: { CLAUDE_CONFIG_DIR: '/cfg' }, readFile: file({ emailAddress: 'me@example.com', organizationName: 'Acme' }) }), 'me@example.com · Acme');
|
|
116
|
+
assert.equal(path.normalize(seen[0]), path.normalize('/cfg/.claude.json'), 'CLAUDE_CONFIG_DIR is where Claude Code keeps it when set');
|
|
117
|
+
assert.equal(runner.claudeAccountLabel({ env: {}, readFile: file({ emailAddress: 'me@example.com' }) }), 'me@example.com');
|
|
118
|
+
assert.equal(runner.claudeAccountLabel({ env: {}, readFile: file({}) }), null, 'no login reads as unknown');
|
|
119
|
+
assert.equal(runner.claudeAccountLabel({ env: {}, readFile: () => 'not json' }), null);
|
|
120
|
+
assert.equal(runner.claudeAccountLabel({ env: {}, readFile: () => { throw new Error('ENOENT'); } }), null);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── the runner sends it ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
test('every server check-in carries the code and the account, and the newest main seen', async () => {
|
|
126
|
+
const bodies = [];
|
|
127
|
+
const api = { autonomy: { postAutonomyHeartbeat: async ({ body }) => { bodies.push(body); return { ok: true }; } } };
|
|
128
|
+
const state = runner.newRunnerState();
|
|
129
|
+
// codeDrifted is what learns main's tip; it must keep it for the heartbeat.
|
|
130
|
+
await runner.codeDrifted({ loadedHead: async () => A, run: async () => ({ ok: true, stdout: `${B}\trefs/heads/main\n` }), state });
|
|
131
|
+
assert.equal(state.mainHead, B);
|
|
132
|
+
await runner.loadedHead({ run: async () => ({ ok: true, stdout: `${A}\n` }) });
|
|
133
|
+
await runner.postHeartbeat({ last_event: 'loop' }, { api, state, claudeAccountLabel: () => 'me@example.com' });
|
|
134
|
+
assert.equal(bodies[0].head, A);
|
|
135
|
+
assert.equal(bodies[0].main_head, B);
|
|
136
|
+
assert.equal(bodies[0].claude_account, 'me@example.com');
|
|
137
|
+
assert.equal(bodies[0].last_event, 'loop');
|
|
138
|
+
assert.equal('disk_head' in bodies[0], false, 'no refresh in place, so no disk_head');
|
|
139
|
+
|
|
140
|
+
state.diskHead = B;
|
|
141
|
+
await runner.postHeartbeat({}, { api, state, claudeAccountLabel: () => null });
|
|
142
|
+
assert.equal(bodies[1].disk_head, B);
|
|
143
|
+
assert.equal('claude_account' in bodies[1], false, 'an unknown account is left out, not sent empty');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── the route and the SQL ────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function autonomyRouter() {
|
|
149
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
150
|
+
const saved = { b: api.requireBuilder, p: api.requirePermission };
|
|
151
|
+
api.requireBuilder = (r, _s, next) => { r.builder = { id: 3, rank: 'archon' }; next(); };
|
|
152
|
+
api.requirePermission = () => (_r, _s, next) => next();
|
|
153
|
+
const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
|
|
154
|
+
delete req.cache[routePath];
|
|
155
|
+
try { return req(routePath)(); } finally { api.requireBuilder = saved.b; api.requirePermission = saved.p; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
test('the route stores a commit only if it is one, and the account label as sent', async () => {
|
|
159
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
160
|
+
const saved = db.recordRunnerHeartbeat;
|
|
161
|
+
const seen = [];
|
|
162
|
+
db.recordRunnerHeartbeat = async (a) => { seen.push(a); return {}; };
|
|
163
|
+
const app = express(); app.use(express.json()); app.use(attachFail); app.use(autonomyRouter());
|
|
164
|
+
const server = app.listen(0); await new Promise((r) => server.once('listening', r));
|
|
165
|
+
const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/autonomy/heartbeat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
166
|
+
try {
|
|
167
|
+
let r = await post({ host: 'gmk-always-on', head: A, disk_head: B, main_head: B, claude_account: ' me@example.com ' });
|
|
168
|
+
assert.equal(r.status, 200);
|
|
169
|
+
assert.deepEqual([seen[0].head, seen[0].diskHead, seen[0].mainHead, seen[0].claudeAccount], [A, B, B, 'me@example.com']);
|
|
170
|
+
r = await post({ host: 'gmk-always-on', head: 'not-a-commit', main_head: A.toUpperCase(), claude_account: '' });
|
|
171
|
+
assert.equal(r.status, 200, 'a bad version never costs the runner its check-in');
|
|
172
|
+
assert.deepEqual([seen[1].head, seen[1].mainHead, seen[1].claudeAccount], [null, null, null]);
|
|
173
|
+
r = await post({ host: 'gmk-always-on', claude_account: 'x'.repeat(201) });
|
|
174
|
+
assert.equal(r.status, 400, 'the label is capped, so a key pasted into it by mistake is refused, not stored');
|
|
175
|
+
} finally {
|
|
176
|
+
server.close();
|
|
177
|
+
db.recordRunnerHeartbeat = saved;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('the heartbeat SQL overwrites the identity every beat and reads it back', async () => {
|
|
182
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
183
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
184
|
+
const saved = api.pool.query;
|
|
185
|
+
const calls = [];
|
|
186
|
+
api.pool.query = async (sql, params) => { calls.push({ sql, params }); return { rows: [{ head: A }] }; };
|
|
187
|
+
try {
|
|
188
|
+
await db.recordRunnerHeartbeat({ builderId: 3, host: 'gmk-always-on', head: A, diskHead: B, mainHead: B, claudeAccount: 'me@example.com' });
|
|
189
|
+
for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
|
|
190
|
+
assert.match(calls[0].sql, new RegExp(`\\b${col} = EXCLUDED\\.${col}\\b`), `${col} is overwritten, never kept from the last process`);
|
|
191
|
+
}
|
|
192
|
+
assert.deepEqual(calls[0].params.slice(9), [A, B, B, 'me@example.com']);
|
|
193
|
+
await db.readRunnerHeartbeats(3);
|
|
194
|
+
assert.match(calls[1].sql, /head, disk_head, main_head, claude_account/);
|
|
195
|
+
} finally {
|
|
196
|
+
api.pool.query = saved;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the migration adds the four columns, additively', () => {
|
|
201
|
+
const sql = fs.readFileSync(path.join(ROOT, 'modules/autonomy/migrations/autonomy_004_runner_identity.sql'), 'utf8')
|
|
202
|
+
.split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
|
|
203
|
+
for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
|
|
204
|
+
assert.match(sql, new RegExp(`ADD COLUMN IF NOT EXISTS ${col} text`));
|
|
205
|
+
}
|
|
206
|
+
assert.doesNotMatch(sql, /\bDROP\b/i);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('the gate card draws the PC, the account, the code and the restarting label', () => {
|
|
210
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
|
|
211
|
+
assert.match(src, /restarting: 'Restarting'/);
|
|
212
|
+
assert.match(src, /PC <strong>\$\{escapeHtml\(h\.host\)\}<\/strong>/);
|
|
213
|
+
assert.match(src, /Claude account \$\{escapeHtml\(h\.account\)\}/);
|
|
214
|
+
assert.match(src, /behind main/);
|
|
215
|
+
});
|
|
@@ -46,8 +46,8 @@ test('DB pinned: the sweep skips it even when the roster says minor', () => {
|
|
|
46
46
|
const [inst] = uc.applyDbChannels([entry({ channel: 'minor' })], dbOf({ cloudbongos: 'pinned' }));
|
|
47
47
|
assert.equal(inst.channel, 'pinned');
|
|
48
48
|
assert.equal(targetFor(inst), null);
|
|
49
|
-
//
|
|
50
|
-
const src = readFileSync(path.join(ROOT, '
|
|
49
|
+
// update-sweep.js skips on exactly this value, and decides it AFTER the DB rule is applied.
|
|
50
|
+
const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
|
|
51
51
|
const applied = src.indexOf('channel.applyDbChannels(');
|
|
52
52
|
const skip = src.indexOf("inst.channel === 'pinned'");
|
|
53
53
|
assert.ok(applied > 0 && skip > applied, 'the pinned skip must read the channel after the /deploy rule is applied');
|
|
@@ -69,10 +69,17 @@ test('readDbChannels: no database named → not read, with the reason, and psql
|
|
|
69
69
|
let spawned = false;
|
|
70
70
|
const r = uc.readDbChannels({ env: {}, run: () => { spawned = true; } });
|
|
71
71
|
assert.equal(r.ok, false);
|
|
72
|
-
assert.match(r.reason, /no
|
|
72
|
+
assert.match(r.reason, /no UPDATE_CHANNEL_DB \(or DATABASE_URL \/ PGDATABASE\)/);
|
|
73
73
|
assert.equal(spawned, false);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test('readDbChannels: UPDATE_CHANNEL_DB wins, so a unit need not set PGDATABASE (which leaks into tenant upgrades)', () => {
|
|
77
|
+
let target;
|
|
78
|
+
uc.readDbChannels({ env: { UPDATE_CHANNEL_DB: 'cloudbongos', DATABASE_URL: 'postgres://other', PGDATABASE: 'tenant' }, run: (_c, a) => { target = a[1]; return { status: 0, stdout: '' }; } });
|
|
79
|
+
assert.equal(target, 'cloudbongos');
|
|
80
|
+
assert.equal(uc.CHANNEL_DB_ENV, 'UPDATE_CHANNEL_DB');
|
|
81
|
+
});
|
|
82
|
+
|
|
76
83
|
test('readDbChannels: parses psql -At tab output into slug → channel, from the sweep env only', () => {
|
|
77
84
|
let args;
|
|
78
85
|
const run = (cmd, a) => { args = [cmd, ...a]; return { status: 0, stdout: 'cloudbongos\tminor\ncharter\tpatch\n\n', stderr: '' }; };
|
|
@@ -112,12 +119,12 @@ test('channelLine: the sweep log names the channel and where it came from', () =
|
|
|
112
119
|
[entry(), entry({ slug: 'charter', channel: 'patch' })], dbOf({ cloudbongos: 'minor' }));
|
|
113
120
|
assert.equal(uc.channelLine(fromDeploy), ' • cloudbongos: channel minor, set on /deploy (roster says patch, ignored)');
|
|
114
121
|
assert.equal(uc.channelLine(fromRoster), ' • charter: channel patch, from the roster — no project row for this slug');
|
|
115
|
-
const src = readFileSync(path.join(ROOT, '
|
|
122
|
+
const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
|
|
116
123
|
assert.match(src, /console\.log\(channel\.channelLine\(inst\)\)/);
|
|
117
124
|
});
|
|
118
125
|
|
|
119
|
-
test('
|
|
120
|
-
const src = readFileSync(path.join(ROOT, '
|
|
126
|
+
test('update-sweep.js reads the DB from its own env, never an entry env (which names that instance\'s DB)', () => {
|
|
127
|
+
const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
|
|
121
128
|
assert.match(src, /channel\.readDbChannels\(\{ env: process\.env \}\)/);
|
|
122
129
|
// A core too old to carry the reader must keep the roster rather than crash the sweep.
|
|
123
130
|
assert.match(src, /typeof channel\.readDbChannels === 'function'/);
|
|
@@ -334,7 +334,7 @@ test('the heal branch is reachable only when the dirty files ARE the pin', () =>
|
|
|
334
334
|
// ---------------------------------------------------------------------------
|
|
335
335
|
|
|
336
336
|
test('every commitPin call site in the lane decides about push', () => {
|
|
337
|
-
const src = readFileSync(path.join(ROOT, '
|
|
337
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
338
338
|
// Every CALL, excluding the definition (which is `function commitPin({ ... push = true })` —
|
|
339
339
|
// its default is what these sites must not silently inherit).
|
|
340
340
|
const calls = [];
|
|
@@ -358,7 +358,7 @@ test('every commitPin call site in the lane decides about push', () => {
|
|
|
358
358
|
});
|
|
359
359
|
|
|
360
360
|
test('the direct-upgrade fallback asks pinFlagsFor rather than deciding inline', () => {
|
|
361
|
-
const src = readFileSync(path.join(ROOT, '
|
|
361
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
362
362
|
// The fourth version of this assertion. It used to pin the inline `if` verbatim, which froze
|
|
363
363
|
// the bug of task 1004122 in place: the line was correct about NOT pushing a co-tenant pin and
|
|
364
364
|
// wrong about how, and a test that quotes an expression can only ever confirm it is still there.
|
|
@@ -610,7 +610,7 @@ test('preflightFlagsFor has the precedent it claims — go-live.js really does s
|
|
|
610
610
|
});
|
|
611
611
|
|
|
612
612
|
test('the direct-upgrade fallback asks preflightFlagsFor rather than deciding inline', () => {
|
|
613
|
-
const src = readFileSync(path.join(ROOT, '
|
|
613
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
614
614
|
assert.match(src, /upArgs\.push\(\.\.\.preflightFlagsFor\(pinMode\)\)/,
|
|
615
615
|
'the clean-tree policy is a pin-mode decision and must go through the named function');
|
|
616
616
|
});
|
|
@@ -618,7 +618,7 @@ test('the direct-upgrade fallback asks preflightFlagsFor rather than deciding in
|
|
|
618
618
|
test('every commitPin call site in the lane also decides what the upgrade generated', () => {
|
|
619
619
|
// The sibling of the push assertion in section 8, and for the same reason: a site that inherits
|
|
620
620
|
// the `generated = []` default silently reverts to the two-bucket guard that wedged the lane.
|
|
621
|
-
const src = readFileSync(path.join(ROOT, '
|
|
621
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
622
622
|
const calls = [];
|
|
623
623
|
const re = /commitPin\(\{/g;
|
|
624
624
|
let m;
|
|
@@ -756,7 +756,7 @@ test('the post-upgrade net re-probes, rather than reusing the set from before th
|
|
|
756
756
|
// A skill the NEW core adds is absent from the pre-upgrade answer, so reusing it would drop that
|
|
757
757
|
// file into `other` and wedge the lane one release later — this defect again, wearing the next
|
|
758
758
|
// version number. The heal, which runs BEFORE the bump, must keep using the pre-bump set.
|
|
759
|
-
const src = readFileSync(path.join(ROOT, '
|
|
759
|
+
const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
|
|
760
760
|
const afterSpawn = src.split('const generatedAfter = laneGeneratedPaths(inst.dir);')[1];
|
|
761
761
|
assert.ok(afterSpawn, 'the post-upgrade re-probe is gone');
|
|
762
762
|
assert.equal((afterSpawn.match(/generated: generatedAfter,/g) || []).length, 2,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// tests/update_sweep_home.mjs — the update sweep lives in the core package and runs from it
|
|
2
|
+
// (task 1004307).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The sweep used to live only under .claude/scheduled-tasks/, which the core package never
|
|
5
|
+
// ships, so cloudbongos.com's sweep ran a checkout frozen at 2026-08-25 and every later lane fix was
|
|
6
|
+
// inert there. It now lives at scripts/gds/update-sweep.js. These pin the three things that keep it
|
|
7
|
+
// runnable from node_modules/@bongos/core: it publishes, it needs nothing the package lacks (the
|
|
8
|
+
// .claude/hooks resolver is topology-excluded), and it reads the roster from where it is told,
|
|
9
|
+
// not from where its own file sits.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/update_sweep_home.mjs
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const SWEEP = path.join(ROOT, 'scripts', 'gds', 'update-sweep.js');
|
|
25
|
+
const sweep = require(SWEEP);
|
|
26
|
+
const { isPublishable } = require('../scripts/gds/publish-manifest.js');
|
|
27
|
+
|
|
28
|
+
test('the sweep and every core file it requires are in the published package', () => {
|
|
29
|
+
for (const f of ['scripts/gds/update-sweep.js', 'scripts/gds/update-channel.js', 'scripts/gds/upgrade.js', 'src/module-api.js']) {
|
|
30
|
+
assert.equal(isPublishable(f), true, `${f} must publish, or the installed sweep cannot run`);
|
|
31
|
+
}
|
|
32
|
+
// The reason it moved: the old home never ships.
|
|
33
|
+
assert.equal(isPublishable('.claude/scheduled-tasks/core-update-subscription/subscribe.js'), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the sweep requires nothing the package lacks (no .claude/ path, no hook resolver)', () => {
|
|
37
|
+
const src = readFileSync(SWEEP, 'utf8');
|
|
38
|
+
const requires = [...src.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]);
|
|
39
|
+
for (const r of requires) {
|
|
40
|
+
assert.ok(!r.includes('.claude') && !r.includes('_core-resolve'), `update-sweep.js requires ${r}, which the core package does not ship`);
|
|
41
|
+
if (r.startsWith('.')) assert.equal(isPublishable(path.posix.join('scripts/gds', r).replace(/\\/g, '/')), true, `${r} must publish`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('its target-instance resolver searches the same packages, in the same order, as _core-resolve.js', () => {
|
|
46
|
+
const hook = readFileSync(path.join(ROOT, '.claude', 'hooks', '_core-resolve.js'), 'utf8');
|
|
47
|
+
const m = hook.match(/const CORE_PKG_NAMES = (\[[^\]]*\]);/);
|
|
48
|
+
assert.ok(m, '_core-resolve.js CORE_PKG_NAMES not found');
|
|
49
|
+
assert.deepEqual(sweep.CORE_PKG_NAMES, JSON.parse(m[1].replace(/'/g, '"')));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('coreModulePathFrom finds a target instance\'s INSTALLED core, root first', () => {
|
|
53
|
+
const inst = mkdtempSync(path.join(tmpdir(), 'sweep-home-'));
|
|
54
|
+
const pkg = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
|
|
55
|
+
mkdirSync(pkg, { recursive: true });
|
|
56
|
+
writeFileSync(path.join(pkg, 'upgrade.js'), '');
|
|
57
|
+
assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/upgrade.js'), path.join(pkg, 'upgrade.js'));
|
|
58
|
+
mkdirSync(path.join(inst, 'scripts', 'gds'), { recursive: true });
|
|
59
|
+
writeFileSync(path.join(inst, 'scripts', 'gds', 'upgrade.js'), '');
|
|
60
|
+
assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/upgrade.js'), path.join(inst, 'scripts', 'gds', 'upgrade.js'), 'a monolith root wins, as in _core-resolve.js');
|
|
61
|
+
assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/absent.js'), null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('run from elsewhere, it reads the roster named by --instance and says which core is sweeping', () => {
|
|
65
|
+
const rosterRoot = mkdtempSync(path.join(tmpdir(), 'sweep-roster-'));
|
|
66
|
+
mkdirSync(path.join(rosterRoot, 'config'));
|
|
67
|
+
writeFileSync(path.join(rosterRoot, 'config', 'update-subscriptions.json'),
|
|
68
|
+
JSON.stringify({ instances: [{ slug: 'held', dir: path.join(rosterRoot, 'nowhere'), channel: 'pinned' }] }));
|
|
69
|
+
const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
|
|
70
|
+
const r = spawnSync(process.execPath, [SWEEP, '--instance', rosterRoot], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
|
|
71
|
+
assert.equal(r.status, 0, r.stderr);
|
|
72
|
+
assert.match(r.stdout, new RegExp(`update-sweep · running from .+ \\(core \\d+\\.\\d+\\.\\d+\\) · roster ${rosterRoot.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')}`));
|
|
73
|
+
assert.match(r.stdout, /1 subscribed instance/);
|
|
74
|
+
assert.match(r.stdout, /held: pinned — skipped/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('without --instance it reads the roster in its working directory (the unit\'s WorkingDirectory)', () => {
|
|
78
|
+
const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
|
|
79
|
+
const r = spawnSync(process.execPath, [SWEEP], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
|
|
80
|
+
assert.equal(r.status, 0, r.stderr);
|
|
81
|
+
assert.match(r.stdout, /no roster file — nothing subscribed/);
|
|
82
|
+
assert.ok(r.stdout.includes(`roster ${cwd}`) || r.stdout.includes(`roster ${path.resolve(cwd)}`), r.stdout);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the subscribe.js shim runs the same body, with its instance root as the default roster root', () => {
|
|
86
|
+
const shim = require('../.claude/scheduled-tasks/core-update-subscription/subscribe.js');
|
|
87
|
+
assert.equal(shim, sweep, 'the shim re-exports the core body, so tests and callers see one sweep');
|
|
88
|
+
const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
|
|
89
|
+
assert.match(src, /requireCore\('scripts\/gds\/update-sweep\.js'\)/);
|
|
90
|
+
assert.match(src, /sweep\.main\(\{ instanceRoot: INSTANCE_ROOT \}\)/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('the recipe\'s drop-in names the file that really ships, and the safe database setting', () => {
|
|
94
|
+
const recipe = readFileSync(path.join(ROOT, 'docs', 'recipes', 'upgrading-the-core.md'), 'utf8');
|
|
95
|
+
const execs = [...recipe.matchAll(/node_modules\/@bongos\/core\/(scripts\/gds\/[\w-]+\.js)/g)].map((m) => m[1]);
|
|
96
|
+
assert.ok(execs.length >= 2, 'the drop-in and its dry run both name the installed sweep');
|
|
97
|
+
for (const rel of execs) assert.equal(rel, 'scripts/gds/update-sweep.js');
|
|
98
|
+
assert.match(recipe, /Environment=UPDATE_CHANNEL_DB=/);
|
|
99
|
+
assert.doesNotMatch(recipe, /Environment=PGDATABASE=/, 'a unit-level PGDATABASE leaks into tenant upgrades');
|
|
100
|
+
});
|