@bongos/core 1.21.70 → 1.21.71

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 (63) hide show
  1. package/.bongos-core.json +114 -59
  2. package/.claude/skills/goal-create/SKILL.md +21 -3
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -3
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +46 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +25 -21
  13. package/docs/copy-registry.json +76 -30
  14. package/docs/file-map.md +1 -1
  15. package/docs/module-api-changelog.md +2 -0
  16. package/docs/page-readings.json +8 -8
  17. package/docs/recipes/core-release-pipeline.md +6 -0
  18. package/docs/recipes/upgrading-the-core.md +2 -0
  19. package/modules/hall-ui/public/settings-software-update.js +77 -2
  20. package/modules/hall-ui/public/settings.html +1 -1
  21. package/modules/lifecycle/db-tasks.js +12 -0
  22. package/modules/lifecycle/goal-spec-task.js +129 -0
  23. package/modules/lifecycle/routes/goals.js +7 -9
  24. package/modules/lifecycle/routes/task-write-routes.js +11 -1
  25. package/modules/lifecycle/upkeep-home.js +55 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +37 -0
  29. package/scripts/gds/claude-materialize.js +125 -31
  30. package/scripts/gds/claude-md-template.js +47 -10
  31. package/scripts/gds/cli-lib.js +4 -4
  32. package/scripts/gds/feedback-send.js +3 -3
  33. package/scripts/gds/paste-token.js +1 -1
  34. package/scripts/gds/provision-net.js +1 -8
  35. package/scripts/gds/provision-units.js +28 -2
  36. package/scripts/gds/reauth.js +1 -1
  37. package/scripts/gds/run-unit-tests.js +3 -0
  38. package/scripts/gds/setup.js +1 -1
  39. package/scripts/gds/start.js +4 -4
  40. package/scripts/gds/surface.js +15 -3
  41. package/scripts/gds/task.js +32 -5
  42. package/scripts/gds/upgrade-operator.js +73 -0
  43. package/scripts/gds/upgrade.js +7 -2
  44. package/scripts/hall-preview/server.js +3 -0
  45. package/src/bongos/routes/core-update.js +98 -4
  46. package/src/bongos/routes/feedback.js +11 -1
  47. package/src/bongos/upgrade-request.js +158 -0
  48. package/src/module-api.js +1 -1
  49. package/tests/claude_materialize.mjs +195 -18
  50. package/tests/cli_allowlist_permissions.mjs +6 -2
  51. package/tests/cli_surface.mjs +2 -2
  52. package/tests/fixtures/page-ledger.mjs +5 -0
  53. package/tests/goal_admission_override.mjs +1 -1
  54. package/tests/goal_category.mjs +1 -1
  55. package/tests/goal_create_spec_entry.mjs +61 -0
  56. package/tests/goal_spec_task.mjs +197 -0
  57. package/tests/goal_spec_task_db.mjs +95 -0
  58. package/tests/provision_settings_apply.mjs +25 -1
  59. package/tests/software_update.mjs +18 -1
  60. package/tests/start_env_guard.mjs +2 -2
  61. package/tests/upgrade_operator.mjs +105 -0
  62. package/tests/upgrade_request.mjs +348 -0
  63. package/tests/upkeep_home.mjs +156 -0
@@ -308,8 +308,12 @@ perTool('the read-only subset is pre-allowed', (tool) => {
308
308
  }
309
309
  });
310
310
 
311
- test('the instance starter mirrors this list in verb form (claude-materialize.js STARTER_ALLOW)', () => {
312
- assert.deepEqual([...STARTER_ALLOW].sort(), allowList().map((rule) => bongosify(rule)).sort());
311
+ // Compared as SETS: the core carries both `node scripts/gds/paste-token.js:*` (what its skills
312
+ // run) and `npx bongos paste-token:*` (what Settings → CLI access hands out, task 1004575), and
313
+ // in a project both are the one `npx bongos paste-token:*` rule (task 1004621).
314
+ test('the instance starter mirrors this list in its `npx bongos` form (claude-materialize.js STARTER_ALLOW)', () => {
315
+ assert.equal(new Set(STARTER_ALLOW).size, STARTER_ALLOW.length, 'the starter carries no duplicate rule');
316
+ assert.deepEqual([...STARTER_ALLOW].sort(), [...new Set(allowList().map((rule) => bongosify(rule)))].sort());
313
317
  });
314
318
 
315
319
  console.log('');
@@ -49,9 +49,9 @@ test('the surface is never inferred from isTTY', () => {
49
49
 
50
50
  test('actions name a command the reader can actually run', () => {
51
51
  assert.equal(surface.action('ship', 123, AGENT_ENV), '/builder-ship 123');
52
- assert.equal(surface.action('ship', 123, TERM_ENV), 'bongos ship 123');
52
+ assert.equal(surface.action('ship', 123, TERM_ENV), 'npx bongos ship 123'); // not on PATH in a project (task 1004621)
53
53
  assert.equal(surface.action('start', null, AGENT_ENV), '/builder-start');
54
- assert.equal(surface.action('start', null, TERM_ENV), 'bongos start');
54
+ assert.equal(surface.action('start', null, TERM_ENV), 'npx bongos start');
55
55
  });
56
56
 
57
57
  test('task refs stay hall links for agents and go plain for terminals', () => {
@@ -231,6 +231,11 @@ export function installPageLedger(api, { builders }) {
231
231
  for (const x of hit) Object.assign(x, { status: 'resolved', resolved_task_id: String(p[1]), resolved_by: String(p[2]), resolution_note: p[4] });
232
232
  return { rows: hit.map((x) => ({ id: x.id })) };
233
233
  }
234
+ // createTask's spec-task lookup (task 1004585, goal-spec-task.js): a page's tweak
235
+ // goal is never made in the hall form, so it has no spec task and gates nothing.
236
+ if (/^SELECT id FROM tasks WHERE goal_id = \$1 AND source = \$2 AND source_ref = \$3/.test(q)) {
237
+ return { rows: db.tasks.filter((t) => t.goal_id === p[0] && t.source === p[1] && t.source_ref === p[2] && !['shipped', 'abandoned'].includes(t.status)).map((t) => ({ id: t.id })) };
238
+ }
234
239
  throw new Error(`unexpected SQL: ${q.slice(0, 160)}`);
235
240
  }
236
241
 
@@ -156,7 +156,7 @@ test('reading a goal shows whether it was admitted', () => {
156
156
  });
157
157
 
158
158
  test('the create response says whether the override took effect', () => {
159
- assert.match(postGoals(), /admitted \}\);/);
159
+ assert.match(postGoals(), /admitted(, spec_task: specTask)? \}\);/);
160
160
  });
161
161
 
162
162
  // ---- ADR 0250 §3: the exception is counted, or it is not an exception --------
@@ -169,7 +169,7 @@ test('the advisory is advice on a 201, never a refusal', () => {
169
169
  // and never fails the create over it (best-effort, wrapped in try).
170
170
  const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
171
171
  assert.match(routes, /category_advisory = goalCategory\.adviseCategory/);
172
- assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted \}\)/,
172
+ assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted(, spec_task: specTask)? \}\)/,
173
173
  'it rides the 201 — advice appended to a request that already succeeded');
174
174
  });
175
175
 
@@ -0,0 +1,61 @@
1
+ // tests/goal_create_spec_entry.mjs — /goal-create's "finish a hall-made goal's spec
2
+ // task" entry (task 1004586, ADR 0366 D3) names real routes, fields and identifiers.
3
+ // A skill is prose an agent follows literally; when the code it cites moves, the agent
4
+ // follows a path that no longer exists. So every load-bearing claim in the new section
5
+ // is held against the code it describes, not against a copy of it.
6
+ //
7
+ // Run: node --test tests/goal_create_spec_entry.mjs
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const require_ = createRequire(import.meta.url);
18
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
19
+ const skill = read('.claude/skills/goal-create/SKILL.md');
20
+ const section = skill.slice(skill.indexOf("## Finishing a hall-made goal's spec task"), skill.indexOf('## The sibling advisory'));
21
+ const spec = require_(path.join(ROOT, 'modules/lifecycle/goal-spec-task.js'));
22
+ const goalsRoutes = read('modules/lifecycle/routes/goals.js');
23
+
24
+ test('the section exists and the description carries its trigger', () => {
25
+ assert.ok(section.length > 500, 'the spec-task section is present');
26
+ assert.match(skill.split('\n---')[0], /"spec goal N"/, 'the trigger phrase is in the description');
27
+ });
28
+
29
+ test('the spec task it tells you to find is the one the server files', () => {
30
+ assert.match(section, /`source: goal-spec`/);
31
+ assert.equal(spec.SPEC_SOURCE, 'goal-spec');
32
+ assert.match(section, /`source_ref: goal-<N>-spec`/);
33
+ assert.equal(spec.specSourceRef(7), 'goal-7-spec');
34
+ assert.match(spec.specTaskFields({ id: 7, version_id: 'V', title: 't' }).title, /^Spec goal 7: its scope, criteria and first tasks$/);
35
+ assert.match(section, /"Spec goal N: its scope, criteria and first tasks"/);
36
+ });
37
+
38
+ test('the scope write it names is the real route and body', () => {
39
+ assert.match(section, /POST \/api\/bongos\/goals\/<N>\/scope --body '\{"add":\["<key>"\]\}'/);
40
+ assert.match(goalsRoutes, /router\.post\('\/goals\/:id\/scope'/);
41
+ const scopeRoute = goalsRoutes.slice(goalsRoutes.indexOf("router.post('/goals/:id/scope'"));
42
+ assert.match(scopeRoute.slice(0, 3000), /add:/, 'the route reads an `add` list');
43
+ });
44
+
45
+ test('"do not promote them" is true: new tasks in the goal wait on the open spec task', () => {
46
+ assert.match(section, /do not promote them/);
47
+ assert.match(read('modules/lifecycle/db-tasks.js'), /await gateOnSpecTask\(rows\[0\], deps\.client \|\| pool\)/);
48
+ });
49
+
50
+ test('the strict-fields note lists spec_task and the validator accepts it', () => {
51
+ assert.match(skill, /plus admit_reason[\s\S]{0,40}and spec_task, which ONLY the hall form sends/);
52
+ assert.match(goalsRoutes, /spec_task: \{ type: 'boolean' \}/);
53
+ assert.match(goalsRoutes, /admit_reason: \{ type: 'string'/);
54
+ });
55
+
56
+ test('skill-lint passes on the edited skill (the same lint fitness runs in CI)', async () => {
57
+ const { spawnSync } = await import('node:child_process');
58
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts/gds/skill-lint.js')], { cwd: ROOT, encoding: 'utf8' });
59
+ assert.equal(r.status, 0, `skill-lint failed:\n${r.stdout}\n${r.stderr}`);
60
+ assert.match(r.stdout, /0 error\(s\)/);
61
+ });
@@ -0,0 +1,197 @@
1
+ // tests/goal_spec_task.mjs — a goal made in the hall carries its own spec task, and
2
+ // its other work waits behind it (task 1004585, ADR 0366 D3). DB-free: the gate
3
+ // against a SQL-aware fake client, createTask's hook through its own client seam, and
4
+ // POST /goals through the assembled router. The real-Postgres half (the claim gate's
5
+ // read and the ship-time auto-promotion) is tests/goal_spec_task_db.mjs.
6
+ //
7
+ // Run: node --test tests/goal_spec_task.mjs
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ process.env.NODE_ENV = 'test';
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const require_ = createRequire(import.meta.url);
18
+ const spec = require_(path.join(ROOT, 'modules/lifecycle/goal-spec-task.js'));
19
+
20
+ // A fake client that knows one goal's spec task and records every edge written.
21
+ function fakeClient({ specId = null, specStatus = 'ready' } = {}) {
22
+ const edges = [];
23
+ return {
24
+ edges,
25
+ async query(sql, params) {
26
+ const s = String(sql).replace(/\s+/g, ' ');
27
+ if (/SELECT id FROM tasks WHERE goal_id = \$1 AND source = \$2 AND source_ref = \$3/.test(s)) {
28
+ assert.match(s, /status NOT IN \('shipped', 'abandoned'\)/, 'only an OPEN spec task gates');
29
+ const open = specId != null && !['shipped', 'abandoned'].includes(specStatus);
30
+ return { rows: open && params[1] === spec.SPEC_SOURCE && params[2] === spec.specSourceRef(params[0]) ? [{ id: String(specId) }] : [] };
31
+ }
32
+ if (/INSERT INTO dependencies/.test(s)) {
33
+ assert.match(s, /VALUES \('task', \$1, 'task', \$2\)/, 'a plain task→task edge, the shape unsatisfiedDeps reads');
34
+ edges.push([Number(params[0]), Number(params[1])]);
35
+ return { rows: [] };
36
+ }
37
+ return { rows: [] };
38
+ },
39
+ };
40
+ }
41
+
42
+ test('the spec task is a ready spike in the goal, keyed for idempotency', () => {
43
+ const f = spec.specTaskFields({ id: 42, version_id: 'V9', title: 'Hall goal' }, { createdBy: 7 });
44
+ assert.equal(f.status, 'ready');
45
+ assert.equal(f.kind, 'spike');
46
+ assert.equal(f.goalId, 42);
47
+ assert.equal(f.versionId, 'V9');
48
+ assert.equal(f.source, 'goal-spec');
49
+ assert.equal(f.sourceRef, 'goal-42-spec');
50
+ assert.equal(f.createdBy, 7);
51
+ assert.match(f.title, /^Spec goal 42: /);
52
+ assert.match(f.description, /POST \/goals\/42\/scope/);
53
+ assert.match(f.description, /POST \/versions\/V9\/done-when/);
54
+ });
55
+
56
+ test('a task entering a goal with an open spec task waits on it', async () => {
57
+ const c = fakeClient({ specId: 500 });
58
+ assert.equal(await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, c), 500);
59
+ assert.deepEqual(c.edges, [[501, 500]]);
60
+ });
61
+
62
+ test('the spec task never waits on itself', async () => {
63
+ const c = fakeClient({ specId: 500 });
64
+ assert.equal(await spec.gateOnSpecTask({ id: 500, goal_id: 42, source: 'goal-spec', source_ref: 'goal-42-spec' }, c), null);
65
+ assert.deepEqual(c.edges, []);
66
+ });
67
+
68
+ test('no edge when the goal has no spec task, or it shipped, or it was abandoned, or the task has no goal', async () => {
69
+ for (const c of [fakeClient(), fakeClient({ specId: 500, specStatus: 'shipped' }), fakeClient({ specId: 500, specStatus: 'abandoned' })]) {
70
+ assert.equal(await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, c), null);
71
+ assert.deepEqual(c.edges, []);
72
+ }
73
+ const c = fakeClient({ specId: 500 });
74
+ assert.equal(await spec.gateOnSpecTask({ id: 501, goal_id: null }, c), null);
75
+ });
76
+
77
+ test('a MOVED task goes through the cycle-checked path, and a cycle leaves the move without the edge', async () => {
78
+ const calls = [];
79
+ const ok = await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, fakeClient({ specId: 500 }), {
80
+ addTaskDependency: async (a, b) => { calls.push([a, b]); },
81
+ });
82
+ assert.equal(ok, 500);
83
+ assert.deepEqual(calls, [[501, 500]]);
84
+ const cyc = await spec.gateOnSpecTask({ id: 501, goal_id: 42 }, fakeClient({ specId: 500 }), {
85
+ addTaskDependency: async () => { throw { code: 'CYCLE', path: [500, 501] }; },
86
+ });
87
+ assert.equal(cyc, null);
88
+ await assert.rejects(spec.gateOnSpecTask({ id: 501, goal_id: 42 }, fakeClient({ specId: 500 }), {
89
+ addTaskDependency: async () => { throw { code: 'TASK_NOT_FOUND' }; },
90
+ }), (e) => e.code === 'TASK_NOT_FOUND', 'any other failure is not swallowed');
91
+ });
92
+
93
+ test('createTask itself adds the edge — every create vector inherits it', async () => {
94
+ const db = require_(path.join(ROOT, 'modules/lifecycle/db-tasks.js'));
95
+ const c = fakeClient({ specId: 500 });
96
+ const inner = c.query.bind(c);
97
+ c.query = async (sql, params) => {
98
+ if (/INSERT INTO tasks/.test(sql)) return { rows: [{ id: '777', goal_id: params[22], source: params[11], source_ref: params[12] }] };
99
+ return inner(sql, params);
100
+ };
101
+ const row = await db.createTask({ versionId: 'V9', goalId: 42, title: 'Build the page', kind: 'feature', discipline: 'engineer', creditsReward: 5 }, { client: c });
102
+ assert.equal(row.id, '777');
103
+ assert.deepEqual(c.edges, [[777, 500]]);
104
+ });
105
+
106
+ // ── POST /goals through the assembled router ─────────────────────────────────
107
+
108
+ async function withGoalsServer(fn) {
109
+ const r2 = createRequire(import.meta.url);
110
+ const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
111
+ const routePath = r2.resolve(path.join(ROOT, 'modules/lifecycle/routes/goals.js'));
112
+ const auth = r2(authPath);
113
+ const api = r2(path.join(ROOT, 'src/module-api.js'));
114
+ const db = r2(path.join(ROOT, 'modules/lifecycle/db.js'));
115
+ const saved = { rb: auth.requireBuilder, rp: auth.requirePermission, tx: api.withTx, gv: db.getVersion, cg: db.createGoal, ct: db.createTask };
116
+ const calls = { createGoal: [], createTask: [], tx: 0 };
117
+ auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank: 'metic' }; next(); };
118
+ auth.requirePermission = () => (_req, _res, next) => next();
119
+ const txClient = { query: async () => ({ rows: [] }) };
120
+ api.withTx = async (f) => { calls.tx += 1; return f(txClient); };
121
+ db.getVersion = async (id) => ({ id, status: 'planning' });
122
+ db.createGoal = async (fields, deps = {}) => { calls.createGoal.push({ fields, client: deps.client || null }); return { id: 42, version_id: fields.versionId, title: fields.title }; };
123
+ db.createTask = async (fields, deps = {}) => { calls.createTask.push({ fields, client: deps.client || null }); return { id: 500, ...fields }; };
124
+ delete r2.cache[routePath];
125
+ const express = r2('express');
126
+ const { attachFail } = r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
127
+ const app = express();
128
+ app.use(express.json());
129
+ app.use(attachFail);
130
+ app.use(r2(routePath)());
131
+ const server = app.listen(0);
132
+ await new Promise((res) => server.once('listening', res));
133
+ const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/goals`, {
134
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
135
+ });
136
+ try { await fn(post, calls, txClient); } finally {
137
+ server.close();
138
+ auth.requireBuilder = saved.rb; auth.requirePermission = saved.rp; api.withTx = saved.tx;
139
+ db.getVersion = saved.gv; db.createGoal = saved.cg; db.createTask = saved.ct;
140
+ delete r2.cache[routePath];
141
+ }
142
+ }
143
+
144
+ test('POST /goals with spec_task creates the goal and its spec task in ONE transaction', async () => {
145
+ await withGoalsServer(async (post, calls, txClient) => {
146
+ const res = await post({ version_id: 'V9', title: 'Hall goal', category_id: undefined, spec_task: true });
147
+ assert.equal(res.status, 201, await res.clone().text());
148
+ const body = await res.json();
149
+ assert.equal(calls.tx, 1);
150
+ assert.equal(calls.createGoal[0].client, txClient, 'the goal is written inside the transaction');
151
+ assert.equal(calls.createTask.length, 1);
152
+ assert.equal(calls.createTask[0].client, txClient, 'and so is its spec task');
153
+ assert.equal(calls.createTask[0].fields.sourceRef, 'goal-42-spec');
154
+ assert.equal(body.spec_task.id, 500);
155
+ });
156
+ });
157
+
158
+ test('POST /goals without spec_task behaves exactly as before', async () => {
159
+ await withGoalsServer(async (post, calls) => {
160
+ const res = await post({ version_id: 'V9', title: 'CLI goal' });
161
+ assert.equal(res.status, 201);
162
+ assert.equal(calls.tx, 0);
163
+ assert.equal(calls.createGoal[0].client, null);
164
+ assert.equal(calls.createTask.length, 0);
165
+ assert.equal((await res.json()).spec_task, null);
166
+ });
167
+ });
168
+
169
+ test('spec_task must be a boolean', async () => {
170
+ await withGoalsServer(async (post, calls) => {
171
+ const res = await post({ version_id: 'V9', title: 'Hall goal', spec_task: 'yes' });
172
+ assert.equal(res.status, 400);
173
+ assert.equal(calls.createGoal.length, 0);
174
+ });
175
+ });
176
+
177
+ test('ensureSpecTask is safe to run twice: the second run returns the first spec task and writes nothing', async () => {
178
+ // A stateful fake: the spec task the first run creates is what the second run's read finds.
179
+ const created = [];
180
+ const client = {
181
+ async query(sql, params) {
182
+ if (/SELECT id FROM tasks WHERE goal_id = \$1 AND source = \$2 AND source_ref = \$3/.test(String(sql).replace(/\s+/g, ' '))) {
183
+ const hit = created.find((t) => t.goalId === params[0] && t.source === params[1] && t.sourceRef === params[2]);
184
+ return { rows: hit ? [{ id: String(hit.id) }] : [] };
185
+ }
186
+ return { rows: [] };
187
+ },
188
+ };
189
+ const db = { createTask: async (fields) => { const t = { id: 600 + created.length, ...fields }; created.push(t); return t; } };
190
+ const goal = { id: 42, version_id: 'V9', title: 'Hall goal' };
191
+ const first = await spec.ensureSpecTask({ db, goal, client, createdBy: 7 });
192
+ const second = await spec.ensureSpecTask({ db, goal, client, createdBy: 7 });
193
+ assert.equal(first.created, true);
194
+ assert.equal(second.created, false);
195
+ assert.equal(second.specTask.id, first.specTask.id);
196
+ assert.equal(created.length, 1, 'exactly one spec task for the goal');
197
+ });
@@ -0,0 +1,95 @@
1
+ // tests/goal_spec_task_db.mjs — a goal made in the hall carries its own spec task,
2
+ // and its other work waits behind it (task 1004585, ADR 0366 D3). REAL DB: the
3
+ // claim gate and the auto-promotion are the database's, so this proves the edge
4
+ // gateOnSpecTask writes is one they actually honour.
5
+ //
6
+ // Real-DB integration test (self-skips without a reachable Postgres; in the
7
+ // INTEGRATION set so the default DB-free lane never runs it).
8
+ //
9
+ // Run: DATABASE_URL=... node tests/goal_spec_task_db.mjs
10
+
11
+ import { createRequire } from 'node:module';
12
+ import { strict as assert } from 'node:assert';
13
+ const require = createRequire(import.meta.url);
14
+ const { Pool } = require('pg');
15
+
16
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
17
+ try {
18
+ await pool.query('SELECT 1');
19
+ } catch (e) {
20
+ console.log(`goal_spec_task_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
21
+ await pool.end().catch(() => {});
22
+ process.exit(0);
23
+ }
24
+
25
+ const seams = require('../src/module-seams.js');
26
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
27
+ const db = require('../modules/lifecycle/db.js');
28
+ const spec = require('../modules/lifecycle/goal-spec-task.js');
29
+
30
+ const VER = 'ZZZ-SPEC', WORKER = 90000401;
31
+ const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
32
+
33
+ async function cleanup() {
34
+ const ids = (await q(`SELECT id FROM tasks WHERE version_id=$1`, [VER]).catch(() => [])).map((r) => r.id);
35
+ await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [ids]).catch(() => {});
36
+ await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, ids]).catch(() => {});
37
+ await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
38
+ await q(`DELETE FROM dependencies WHERE (from_kind='task' AND from_id = ANY($1::bigint[])) OR (to_kind='task' AND to_id = ANY($1::bigint[]))`, [ids]).catch(() => {});
39
+ await q(`DELETE FROM tasks WHERE version_id=$1`, [VER]).catch(() => {});
40
+ await q(`DELETE FROM goal_members WHERE goal_id IN (SELECT id FROM goals WHERE version_id=$1)`, [VER]).catch(() => {});
41
+ await q(`DELETE FROM goals WHERE version_id=$1`, [VER]).catch(() => {});
42
+ await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
43
+ await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
44
+ }
45
+
46
+ let pass = 0;
47
+ const ok = (cond, msg) => { assert.ok(cond, msg); console.log(` ok ${msg}`); pass++; };
48
+ const statusOf = async (id) => (await q(`SELECT status FROM tasks WHERE id=$1`, [id]))[0].status;
49
+ const edges = async (id) => (await q(`SELECT to_id FROM dependencies WHERE from_kind='task' AND from_id=$1 AND to_kind='task'`, [id])).map((r) => Number(r.to_id));
50
+
51
+ try {
52
+ await cleanup();
53
+ await q(`INSERT INTO versions (id,name,status,track) VALUES ($1,'spec','building','internal') ON CONFLICT (id) DO NOTHING`, [VER]);
54
+ await q(`INSERT INTO builders (id,github_id,github_login,display_name,rank) VALUES ($1,'spec-w','spec-worker','Spec Worker','archon')`, [WORKER]);
55
+
56
+ // A hall-made goal and its spec task (what POST /goals { spec_task: true } writes).
57
+ const goal = await db.createGoal({ versionId: VER, title: 'Hall goal', createdBy: WORKER });
58
+ const specTask = await db.createTask(spec.specTaskFields(goal, { createdBy: WORKER }));
59
+ ok(specTask.status === 'ready' && specTask.kind === 'spike', 'the spec task is born ready, a spike');
60
+ ok((await edges(specTask.id)).length === 0, 'the spec task does not wait on itself');
61
+
62
+ // 1) A task filed into the goal waits on the spec task, and the claim gate says so.
63
+ const work = await db.createTask({ versionId: VER, goalId: goal.id, title: 'Build the first page', kind: 'feature', discipline: 'engineer', creditsReward: 10 });
64
+ ok((await edges(work.id)).includes(Number(specTask.id)), 'a filed task carries an edge to the spec task');
65
+ // unsatisfiedDeps is the read the claim gate refuses on (db-claims.js: a non-empty
66
+ // answer throws DEPS_NOT_SHIPPED). The full claim path is not driven here — its own
67
+ // DB test (claim_gate_open_blocker) is parked in this lane for unrelated setup.
68
+ const gating = await db.unsatisfiedDeps(work.id);
69
+ ok(gating.some((d) => d.kind === 'task' && Number(d.id) === Number(specTask.id)), 'the claim gate read names the spec task as unshipped');
70
+
71
+ // 2) Shipping the spec task releases it: the auto-promotion trigger moves it to ready.
72
+ await q(`UPDATE tasks SET status='shipped' WHERE id=$1`, [specTask.id]);
73
+ ok((await db.unsatisfiedDeps(work.id)).length === 0, 'nothing gates the task once the spec ships');
74
+ ok((await statusOf(work.id)) === 'ready', `the waiting task auto-promotes to ready (got ${await statusOf(work.id)})`);
75
+
76
+ // 3) A task filed after the spec shipped waits on nothing.
77
+ const later = await db.createTask({ versionId: VER, goalId: goal.id, title: 'Build the second page', kind: 'feature', discipline: 'engineer' });
78
+ ok((await edges(later.id)).length === 0, 'a shipped spec gates nothing new');
79
+
80
+ // 4) A goal made WITHOUT a spec task behaves exactly as before.
81
+ const plain = await db.createGoal({ versionId: VER, title: 'CLI goal', createdBy: WORKER });
82
+ const plainTask = await db.createTask({ versionId: VER, goalId: plain.id, title: 'Build the third page', kind: 'feature', discipline: 'engineer' });
83
+ ok((await edges(plainTask.id)).length === 0, 'a goal with no spec task adds no edge');
84
+
85
+ // 5) A task MOVED into a goal with an open spec task waits on it too.
86
+ const goal2 = await db.createGoal({ versionId: VER, title: 'Second hall goal', createdBy: WORKER });
87
+ const spec2 = await db.createTask(spec.specTaskFields(goal2, { createdBy: WORKER }));
88
+ await db.updateTaskGoal(plainTask.id, goal2.id);
89
+ ok((await edges(plainTask.id)).includes(Number(spec2.id)), 'a moved-in task carries an edge to the open spec task');
90
+
91
+ console.log(`\n${pass} passed, 0 failed`);
92
+ } finally {
93
+ await cleanup();
94
+ await pool.end();
95
+ }
@@ -78,6 +78,7 @@ ${NEUTRAL_PREFIX}_LOGO_URL=
78
78
  ${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
79
79
  ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
80
80
  ${NEUTRAL_PREFIX}_DEMO_HOURS=
81
+ ${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
81
82
  `;
82
83
 
83
84
  t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
@@ -147,6 +148,8 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
147
148
  [`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
148
149
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
149
150
  [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
151
+ // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
152
+ [`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
150
153
  });
151
154
  const body = P.webEnvBody(inst, {});
152
155
  for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
@@ -247,7 +250,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
247
250
  assert.equal(r.ok, true);
248
251
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
249
252
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
250
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
253
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
251
254
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
252
255
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
253
256
  });
@@ -630,5 +633,26 @@ await ta('a failed write reports the error without the value', async () => {
630
633
  assert.ok(!r.error.includes(SECRET), r.error);
631
634
  });
632
635
 
636
+ console.log('\nhubUpgradeEnvVarsFor — where a hosted project\'s Update button goes (task 1004608):');
637
+
638
+ t('both tenant shapes get the hub door for THEIR slug; no other shape gets the key; it rides standup AND the push', () => {
639
+ const saved = process.env.PROVISION_HUB_ORIGIN;
640
+ process.env.PROVISION_HUB_ORIGIN = 'https://hub.example.com/';
641
+ try {
642
+ const key = `${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`;
643
+ for (const shape of ['cloud-host', 'co-tenant']) {
644
+ const inst = { slug: 'mercury', port: 3009, hosting_shape: shape, target_ref: 'will/mercury' };
645
+ assert.deepEqual(require('../scripts/gds/provision-units.js').hubUpgradeEnvVarsFor(inst), { [key]: 'https://hub.example.com/deploy?project=mercury' }, shape);
646
+ assert.equal(P.settingsEnvVarsFor(inst)[key], 'https://hub.example.com/deploy?project=mercury', `${shape} push`);
647
+ assert.match(P.webEnvBody(inst, {}), new RegExp(`^${key}=https://hub\\.example\\.com/deploy\\?project=mercury$`, 'm'), `${shape} standup`);
648
+ }
649
+ for (const shape of ['byo-host', 'control-plane']) {
650
+ assert.deepEqual(require('../scripts/gds/provision-units.js').hubUpgradeEnvVarsFor({ slug: 'x', hosting_shape: shape }), {}, shape);
651
+ }
652
+ } finally {
653
+ if (saved === undefined) delete process.env.PROVISION_HUB_ORIGIN; else process.env.PROVISION_HUB_ORIGIN = saved;
654
+ }
655
+ });
656
+
633
657
  console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
634
658
  process.exit(failed ? 1 : 0);
@@ -180,7 +180,8 @@ async function drive(deps) {
180
180
  const build = require('../src/bongos/routes/core-update.js');
181
181
  const express = require('express');
182
182
  const app = express();
183
- app.use(build(deps));
183
+ // The ask state's stored row is the DB's; these tests never reach one (task 1004595).
184
+ app.use(build({ askDeps: () => ({ hub: 'https://cloudbongos.com', getSetting: async () => null }), ...deps }));
184
185
  auth.requireBuilder = saved;
185
186
  const server = app.listen(0);
186
187
  await new Promise((r) => server.once('listening', r));
@@ -205,6 +206,22 @@ test('a hosted project: the Update button goes to the hub\'s door for THIS proje
205
206
  assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=hoodify-finance' });
206
207
  });
207
208
 
209
+ // Task 1004608: Will's mercury (a cloud-host that keeps its own GitHub app) has no sign-in
210
+ // federation, so the panel told its owner to run `bongos upgrade` on the platform's server.
211
+ test('a hosted project that does not federate sign-in still gets the hub\'s door, from HUB_UPGRADE_URL', async () => {
212
+ const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
213
+ doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: 'https://cloudbongos.com/deploy?project=mercury' }) });
214
+ assert.deepEqual(r.body.door, { kind: 'hub', url: 'https://cloudbongos.com/deploy?project=mercury' });
215
+ });
216
+
217
+ test('HUB_UPGRADE_URL is taken only as a hub /deploy address; anything else stays the cli answer', async () => {
218
+ for (const bad of ['', 'javascript:alert(1)', 'https://evil.example/phish', 'https://cloudbongos.com/deploy?project=a b', '/deploy']) {
219
+ const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true,
220
+ doorInputs: () => ({ provisioning: false, idp: null, hubUpgradeUrl: bad }) });
221
+ assert.deepEqual(r.body.door, { kind: 'cli', url: null }, bad);
222
+ }
223
+ });
224
+
208
225
  test('a self-hosted instance has no door to link, and says how it is upgraded instead', async () => {
209
226
  const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
210
227
  assert.deepEqual(r.body.door, { kind: 'cli', url: null });
@@ -94,8 +94,8 @@ t('offers the wrong-instance escape hatch (bongos login)', () => {
94
94
  t('a hosted origin never routes to bongos dev', () => {
95
95
  for (const deps of [true, false]) {
96
96
  const card = instanceNotRunningCard({ origin: 'https://example-instance.cloudbongos.com', depsMissing: deps });
97
- assert.ok(!/^\s*\d+\.\s*`bongos dev`/m.test(card), 'must not list `bongos dev` as a step');
98
- assert.ok(/Don't run `bongos dev`/.test(card), 'must say plainly not to, and why');
97
+ assert.ok(!/^\s*\d+\.\s*`(npx )?bongos dev`/m.test(card), 'must not list `bongos dev` as a step');
98
+ assert.ok(/Don't run `npx bongos dev`/.test(card), 'must say plainly not to, and why');
99
99
  assert.ok(/empty database/.test(card), 'must name the consequence, not just forbid it');
100
100
  }
101
101
  });
@@ -0,0 +1,105 @@
1
+ // tests/upgrade_operator.mjs — on a project its operator runs, `bongos upgrade` from a
2
+ // builder's own computer says who upgrades it and how to ask, with no server steps
3
+ // (task 1004596).
4
+ //
5
+ // WHAT IS AT RISK. A builder on a hosted project ran `bongos upgrade` from their laptop and
6
+ // got "cannot determine which database to migrate. Set PGDATABASE…". Their assistant then
7
+ // wrote them server instructions full of guessed names for a machine they cannot reach.
8
+ // This pins (1) the wording that replaces that dead end and (2) the condition that shows
9
+ // it — including that it never fires where a database is known, which is the operator's
10
+ // own run on the server.
11
+ //
12
+ // Run: node --test tests/upgrade_operator.mjs
13
+
14
+ import { test } from 'node:test';
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const op = require('../scripts/gds/upgrade-operator.js');
20
+ const upgrade = require('../scripts/gds/upgrade.js');
21
+
22
+ const HUB = { kind: 'hub', url: 'https://hub.example.com/deploy?project=mercury', hall: 'https://mercury.example.com/builders' };
23
+
24
+ test('the message says who upgrades it and how to ask — and nothing to run on a server', () => {
25
+ const msg = op.operatorRunMessage(HUB);
26
+ assert.match(msg, /^This project is upgraded by its operator, not from this computer\./);
27
+ assert.match(msg, /https:\/\/mercury\.example\.com\/builders\/settings#software-update/);
28
+ assert.match(msg, /bongos feedback "Please upgrade this project to the latest version"/);
29
+ assert.match(msg, /There are no server steps for you to take\./);
30
+ // None of what sent the last assistant down the wrong road.
31
+ for (const bad of [/PGDATABASE/, /ssh/i, /sudo/, /--health-url/, /--service/, /systemctl/, /<[a-z_-]+>/i, /log in to the server/i]) {
32
+ assert.doesNotMatch(msg, bad, `the operator message must not mention ${bad}`);
33
+ }
34
+ // Without a known hall it still names the place, in words.
35
+ assert.match(op.operatorRunMessage({ kind: 'hub' }), /Open Settings → Software update and use the button there\./);
36
+ });
37
+
38
+ test('a database known here (the server) → no message, and the project is never even asked', async () => {
39
+ let asked = false;
40
+ const door = await op.operatorRunsThisProject({ instanceDir: '/srv/x' }, {
41
+ resolveInstanceDb: () => ({ db: 'mercury', ran: true }),
42
+ readUpdateDoor: async () => { asked = true; return HUB; },
43
+ });
44
+ assert.equal(door, null);
45
+ assert.equal(asked, false, 'the operator\'s own run must not depend on any network answer');
46
+ });
47
+
48
+ test('no database here + the project says its door is the hub → operator-run', async () => {
49
+ for (const probe of [{ db: null, ran: true }, { db: null, ran: false }]) {
50
+ const door = await op.operatorRunsThisProject({ instanceDir: '/laptop/x' }, {
51
+ resolveInstanceDb: () => probe, readUpdateDoor: async () => HUB,
52
+ });
53
+ assert.deepEqual(door, HUB);
54
+ }
55
+ });
56
+
57
+ test('operatorNotice is the message exactly when the project is operator-run', async () => {
58
+ const deps = { resolveInstanceDb: () => ({ db: null, ran: true }) };
59
+ assert.equal(await op.operatorNotice({ instanceDir: '/x' }, { ...deps, readUpdateDoor: async () => HUB }), op.operatorRunMessage(HUB));
60
+ assert.equal(await op.operatorNotice({ instanceDir: '/x' }, { ...deps, readUpdateDoor: async () => ({ kind: 'cli' }) }), null);
61
+ });
62
+
63
+ test('no database here, but a self-hosted / provisioning project, or no answer → behave as before', async () => {
64
+ for (const answer of [{ kind: 'cli', url: null }, { kind: 'local', url: '/deploy' }, null]) {
65
+ const door = await op.operatorRunsThisProject({ instanceDir: '/laptop/x' }, {
66
+ resolveInstanceDb: () => ({ db: null, ran: true }), readUpdateDoor: async () => answer,
67
+ });
68
+ assert.equal(door, null, `door ${JSON.stringify(answer)} must not trigger the operator message`);
69
+ }
70
+ });
71
+
72
+ function capture(stream) {
73
+ const orig = stream.write.bind(stream);
74
+ let out = '';
75
+ stream.write = (chunk, ...rest) => { out += String(chunk); return true; };
76
+ return { text: () => out, restore: () => { stream.write = orig; } };
77
+ }
78
+
79
+ test('`bongos upgrade --to …` on an operator-run project refuses with the message before anything moves', async () => {
80
+ const err = capture(process.stderr);
81
+ let code;
82
+ try {
83
+ code = await upgrade.main(['--to', '99.0.0', '--dry-run'], { operatorNotice: async () => op.operatorRunMessage(HUB) });
84
+ } finally { err.restore(); }
85
+ assert.equal(code, 1);
86
+ assert.match(err.text(), /✖ This project is upgraded by its operator/);
87
+ assert.doesNotMatch(err.text(), /cannot determine which database/);
88
+ });
89
+
90
+ test('bare `bongos upgrade` keeps its pre-check result and adds who upgrades it', async () => {
91
+ const out = capture(process.stdout);
92
+ let code;
93
+ try {
94
+ code = await upgrade.main([], { cmdUpgrade: async () => 0, operatorNotice: async () => op.operatorRunMessage(HUB) });
95
+ } finally { out.restore(); }
96
+ assert.equal(code, 0, 'the pre-check exit code is unchanged');
97
+ assert.match(out.text(), /This project is upgraded by its operator/);
98
+
99
+ const quiet = capture(process.stdout);
100
+ try {
101
+ code = await upgrade.main([], { cmdUpgrade: async () => 1, operatorNotice: async () => null });
102
+ } finally { quiet.restore(); }
103
+ assert.equal(code, 1);
104
+ assert.doesNotMatch(quiet.text(), /upgraded by its operator/, 'not an operator-run project → no message');
105
+ });