@bongos/core 1.20.37 → 1.20.39

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 (68) hide show
  1. package/.bongos-core.json +119 -64
  2. package/.claude/skills/backlog-review/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +66 -0
  4. package/.claude/skills/collab-review/SKILL.md +1 -1
  5. package/.claude/skills/priority-session/SKILL.md +1 -1
  6. package/.claude/skills/session-handoff/SKILL.md +1 -1
  7. package/.claude/skills/status/SKILL.md +1 -1
  8. package/bin/bongos.js +1 -0
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +8 -0
  11. package/clients/bongos-client/index.cjs +8 -0
  12. package/clients/bongos-client/index.d.ts +12 -1
  13. package/clients/bongos-client/index.mjs +8 -0
  14. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  15. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
  16. package/docs/adr/README.md +1 -0
  17. package/docs/api/openapi.json +188 -2
  18. package/docs/api-reference.md +12 -4
  19. package/docs/copy-inventory.md +10 -10
  20. package/docs/copy-registry.json +18 -18
  21. package/docs/file-map.md +1 -0
  22. package/docs/module-api-changelog.md +4 -0
  23. package/docs/page-readings.json +253 -250
  24. package/docs/recipes/upgrading-the-core.md +2 -0
  25. package/modules/autonomy/db.js +39 -8
  26. package/modules/autonomy/fence.js +26 -4
  27. package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
  28. package/modules/autonomy/routes/autonomy.js +28 -1
  29. package/modules/hall-ui/public/gate.js +33 -4
  30. package/modules/ideas/routing.js +8 -1
  31. package/modules/npm-release/preview/childlog.js +95 -0
  32. package/modules/npm-release/preview/commands.js +46 -5
  33. package/modules/npm-release/preview/env.js +18 -7
  34. package/modules/npm-release/preview/runtime.js +43 -9
  35. package/modules/npm-release/preview/supervisor.js +170 -31
  36. package/modules/npm-release/public/work.css +3 -0
  37. package/modules/npm-release/public/work.js +6 -1
  38. package/modules/npm-release/routes/preview.js +1 -0
  39. package/modules/platform-identity/instance-feedback.js +142 -0
  40. package/modules/platform-identity/module.json +1 -1
  41. package/modules/platform-identity/routes/sso.js +62 -0
  42. package/modules/provisioning/render-standup.js +13 -1
  43. package/modules/public-landing/public/projects.html +22 -8
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +30 -0
  47. package/scripts/gds/autobongos-run.js +14 -4
  48. package/scripts/gds/feedback-send.js +131 -0
  49. package/scripts/gds/provision-render.js +13 -1
  50. package/scripts/gds/render-payload.js +6 -5
  51. package/scripts/gds/update-channel.js +82 -0
  52. package/src/bongos/routes/feedback.js +110 -0
  53. package/src/bongos/routes.js +2 -0
  54. package/src/module-api.js +1 -1
  55. package/tests/autobongos_fence.mjs +62 -0
  56. package/tests/autobongos_loop.mjs +76 -0
  57. package/tests/autonomy_fence_priority.mjs +211 -0
  58. package/tests/bongos_cli.mjs +7 -1
  59. package/tests/filing_abuse_matrix.mjs +7 -2
  60. package/tests/idea_routing.mjs +1 -1
  61. package/tests/instance_feedback.mjs +327 -0
  62. package/tests/npm_release_preview_commands.mjs +1 -1
  63. package/tests/npm_release_preview_diagnosis.mjs +376 -0
  64. package/tests/npm_release_preview_supervisor.mjs +4 -4
  65. package/tests/projects_hub_app_step.mjs +49 -0
  66. package/tests/projects_hub_render_connect.mjs +2 -1
  67. package/tests/provision_render.mjs +23 -2
  68. package/tests/update_channel_db.mjs +124 -0
@@ -90,6 +90,8 @@ The sections above are the *manual* `bongos upgrade`. The **subscription** runs
90
90
 
91
91
  A **major** is never automatic; prereleases are never auto-targeted.
92
92
 
93
+ **Where the channel comes from** ([ADR 0357](../adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md), task [1004468](https://cloudbongos.com/builders#/task/1004468)): the rule set on **/deploy** (`provisioning_instances.update_channel`) wins for any roster entry whose `slug` matches a project row. The roster's `channel` below is only the fallback, used when no row matches or the sweep can't read the database. The sweep reads the control plane's DB from its own `DATABASE_URL` / `PGDATABASE`, so the unit needs one of them set. Every run prints each entry's channel and its source.
94
+
93
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):
94
96
 
95
97
  ```json
@@ -76,7 +76,7 @@ async function readAutonomyRuns({ routine = null, limit = 20 } = {}) {
76
76
  // it unreadable to the caller, which refuses — never a permissive default.
77
77
  async function readFence() {
78
78
  const [fence, goals] = await Promise.all([
79
- pool.query('SELECT enabled, paused_reason, updated_at, updated_by FROM autonomy_fence WHERE id = 1'),
79
+ pool.query('SELECT enabled, paused_reason, priority_goal_id, updated_at, updated_by FROM autonomy_fence WHERE id = 1'),
80
80
  pool.query('SELECT goal_id, note, added_at, added_by FROM autonomy_allowed_goals ORDER BY added_at DESC'),
81
81
  ]);
82
82
  const row = fence.rows[0];
@@ -84,6 +84,9 @@ async function readFence() {
84
84
  return {
85
85
  enabled: row.enabled === true,
86
86
  paused_reason: row.paused_reason,
87
+ // A bigint, so a string from node-postgres; normalised for the same reason as
88
+ // the goal ids below. null = no priority (task 1004453).
89
+ priority_goal_id: row.priority_goal_id == null ? null : Number(row.priority_goal_id),
87
90
  updated_at: row.updated_at,
88
91
  updated_by: row.updated_by ? String(row.updated_by) : null,
89
92
  // goal_id is a bigint, which node-postgres hands back as a STRING. The runner
@@ -124,6 +127,28 @@ async function allowGoal({ goalId, note = null, builderId = null }) {
124
127
  return r ? { ...r, goal_id: Number(r.goal_id) } : null;
125
128
  }
126
129
 
130
+ // setPriorityGoal — name the goal the runner works first, or clear it with null
131
+ // (task 1004453). The WHERE refuses a goal that is not allowlisted, so the write
132
+ // either lands or returns nothing and the route can say why; the foreign key from
133
+ // autonomy_003 backs the same rule in the schema. Returns:
134
+ // { row } — written
135
+ // { notAllowed } — that goal is not on the allowlist
136
+ // { missing } — the singleton row is gone (migrations not run)
137
+ async function setPriorityGoal({ goalId, builderId = null }) {
138
+ const { rows } = await pool.query(
139
+ `UPDATE autonomy_fence
140
+ SET priority_goal_id = $1, updated_at = now(), updated_by = $2
141
+ WHERE id = 1
142
+ AND ($1::bigint IS NULL OR EXISTS (SELECT 1 FROM autonomy_allowed_goals WHERE goal_id = $1::bigint))
143
+ RETURNING priority_goal_id, updated_at`,
144
+ [goalId, builderId]
145
+ );
146
+ const r = rows[0];
147
+ if (r) return { row: { priority_goal_id: r.priority_goal_id == null ? null : Number(r.priority_goal_id), updated_at: r.updated_at } };
148
+ const { rows: fenceRows } = await pool.query('SELECT 1 FROM autonomy_fence WHERE id = 1');
149
+ return fenceRows.length ? { notAllowed: true } : { missing: true };
150
+ }
151
+
127
152
  // disallowGoal — take a goal off the allowlist. `removed` says whether a row
128
153
  // actually went, because "already gone" and "just revoked" are different answers
129
154
  // to an owner clicking Remove.
@@ -137,11 +162,11 @@ async function disallowGoal(goalId) {
137
162
  // recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
138
163
  // case is one write, and `host` is the key so a second runner appears as a second
139
164
  // row rather than silently overwriting the first.
140
- async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null }) {
165
+ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null, workingGoalId = null }) {
141
166
  const { rows } = await pool.query(
142
167
  `INSERT INTO autonomy_runner_heartbeat
143
- (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at, updated_at)
144
- VALUES ($8, $1, $2, $3, $4, $5, $6, $7, now(), now())
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())
145
170
  ON CONFLICT (builder_id, host) DO UPDATE SET
146
171
  pid = EXCLUDED.pid,
147
172
  started_at = COALESCE(EXCLUDED.started_at, autonomy_runner_heartbeat.started_at),
@@ -149,10 +174,11 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
149
174
  consecutive_failures = EXCLUDED.consecutive_failures,
150
175
  last_event = EXCLUDED.last_event,
151
176
  working_task_id = EXCLUDED.working_task_id,
177
+ working_goal_id = EXCLUDED.working_goal_id,
152
178
  last_seen_at = now(),
153
179
  updated_at = now()
154
- RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at`,
155
- [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId]
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]
156
182
  );
157
183
  return rows[0] || null;
158
184
  }
@@ -167,14 +193,18 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
167
193
  // liveness view entirely (grader finding, task 1003905).
168
194
  async function readRunnerHeartbeats(builderId) {
169
195
  const { rows } = await pool.query(
170
- `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at
196
+ `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at
171
197
  FROM autonomy_runner_heartbeat
172
198
  WHERE builder_id = $1
173
199
  ORDER BY last_seen_at DESC
174
200
  LIMIT 20`,
175
201
  [builderId]
176
202
  );
177
- return rows.map((r) => ({ ...r, working_task_id: r.working_task_id ? String(r.working_task_id) : null }));
203
+ return rows.map((r) => ({
204
+ ...r,
205
+ working_task_id: r.working_task_id ? String(r.working_task_id) : null,
206
+ working_goal_id: r.working_goal_id ? String(r.working_goal_id) : null,
207
+ }));
178
208
  }
179
209
 
180
210
  module.exports = {
@@ -187,4 +217,5 @@ module.exports = {
187
217
  setFenceEnabled,
188
218
  allowGoal,
189
219
  disallowGoal,
220
+ setPriorityGoal,
190
221
  };
@@ -74,25 +74,44 @@ function normaliseFence(raw) {
74
74
  const n = Number(typeof g === 'object' && g !== null ? g.goal_id : g);
75
75
  if (Number.isSafeInteger(n) && n > 0) ids.push(n);
76
76
  }
77
+ // The priority goal (task 1004453). Honoured only when it is ON the allowlist:
78
+ // the database already enforces that with a foreign key, and this re-checks it
79
+ // so a priority can only ever ORDER the allowlist, never add to it. Anything
80
+ // else (missing, malformed, not allowlisted) reads as "no priority", which is
81
+ // the old behaviour, not a refusal: the allowlist alone is still a safe answer.
82
+ const p = Number(raw.priority_goal_id);
83
+ const priorityGoal = raw.priority_goal_id != null && Number.isSafeInteger(p) && p > 0 && ids.includes(p) ? p : null;
77
84
  return {
78
85
  enabled: raw.enabled === true,
79
86
  pausedReason: typeof raw.paused_reason === 'string' ? raw.paused_reason : null,
80
87
  goals: ids,
88
+ priorityGoal,
81
89
  };
82
90
  }
83
91
 
92
+ // prioritise — the same goals with the priority goal moved to the front. A goal
93
+ // that is not in the list is ignored, so this can reorder and never add.
94
+ function prioritise(goals, priorityGoal) {
95
+ if (priorityGoal == null || !goals.includes(priorityGoal)) return goals.slice();
96
+ return [priorityGoal, ...goals.filter((g) => g !== priorityGoal)];
97
+ }
98
+
84
99
  // allowedGoals — the goals this run may work, given the fence and whatever the
85
100
  // operator asked for on the command line.
86
101
  //
87
102
  // The INTERSECTION is the point. `requested` can only narrow: a goal the operator
88
103
  // named that the database does not allow is dropped, and reported, rather than
89
104
  // honoured. The runner host is not a place authority can come from.
105
+ //
106
+ // The priority goal goes FIRST, after the intersection, so an operator narrowing
107
+ // to goals that leave it out simply gets no priority rather than having it
108
+ // re-added.
90
109
  function allowedGoals(fence, requested = []) {
91
110
  const allowed = new Set(fence.goals);
92
111
  const asked = (requested || []).map((g) => Number(g)).filter((n) => Number.isSafeInteger(n) && n > 0);
93
- if (!asked.length) return { goals: fence.goals.slice(), refused: [] };
112
+ if (!asked.length) return { goals: prioritise(fence.goals, fence.priorityGoal), refused: [] };
94
113
  return {
95
- goals: asked.filter((g) => allowed.has(g)),
114
+ goals: prioritise(asked.filter((g) => allowed.has(g)), fence.priorityGoal),
96
115
  refused: asked.filter((g) => !allowed.has(g)),
97
116
  };
98
117
  }
@@ -137,7 +156,10 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
137
156
  : REFUSALS.no_allowlist,
138
157
  };
139
158
  }
140
- return { go: true, goals, refusedGoals: refused, code: null, reason: null };
159
+ // priorityGoal is reported only when it survived the intersection, so a row can
160
+ // say "this pick came from the priority goal" without re-deriving the rule.
161
+ const priorityGoal = fence.priorityGoal != null && goals[0] === fence.priorityGoal ? fence.priorityGoal : null;
162
+ return { go: true, goals, priorityGoal, refusedGoals: refused, code: null, reason: null };
141
163
  }
142
164
 
143
165
  // decideTask — may THIS task be handed to an unattended worker?
@@ -169,4 +191,4 @@ function decideTask({ task, goals = [], protectedHits = [] } = {}) {
169
191
  return { go: true, code: null, reason: null };
170
192
  }
171
193
 
172
- module.exports = { REFUSALS, normaliseFence, allowedGoals, decideRun, decideTask };
194
+ module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, decideRun, decideTask };
@@ -0,0 +1,32 @@
1
+ -- autonomy_003_priority_goal.sql — the fence's priority goal (task 1004453).
2
+ --
3
+ -- Owner ruling, 2026-09-30: whoever starts the runner says which goal comes FIRST
4
+ -- as well as which goals it may touch at all. The priority sits on the fence row,
5
+ -- next to the kill switch, for the same reason the allowlist does: the runner
6
+ -- host can read it and cannot write it (see autonomy_001's header).
7
+ --
8
+ -- A FOREIGN KEY TO THE ALLOWLIST, deliberately. The priority goal must be an
9
+ -- allowlisted goal; the route checks that, and this makes it a fact the database
10
+ -- holds rather than a rule one route remembers. ON DELETE SET NULL is the safe
11
+ -- direction: revoking the priority goal from the allowlist clears the priority,
12
+ -- so a revoked goal can never be worked because it was once "first".
13
+ --
14
+ -- The priority ORDERS the allowlist; it never widens it. NULL (the default) means
15
+ -- no priority, which is today's behaviour.
16
+ --
17
+ -- working_goal_id on the heartbeat is the other half of the done-when: the hall
18
+ -- can say which goal the runner's current task came from. Nullable, no FK — a
19
+ -- heartbeat is a report, and a report about a goal deleted since is still true.
20
+ --
21
+ -- Additive and idempotent (ADR 0083 §Decision #5): safe to re-run.
22
+
23
+ BEGIN;
24
+
25
+ ALTER TABLE autonomy_fence
26
+ ADD COLUMN IF NOT EXISTS priority_goal_id bigint
27
+ REFERENCES autonomy_allowed_goals (goal_id) ON DELETE SET NULL;
28
+
29
+ ALTER TABLE autonomy_runner_heartbeat
30
+ ADD COLUMN IF NOT EXISTS working_goal_id bigint;
31
+
32
+ COMMIT;
@@ -88,7 +88,7 @@ module.exports = function buildAutonomyRouter() {
88
88
 
89
89
  // ── the Autobongos fence (task 1003908, ADR 0307 §6) ───────────────────────
90
90
  //
91
- // These four routes ARE the fence. Everything the unattended runner is allowed
91
+ // These five routes ARE the fence. Everything the unattended runner is allowed
92
92
  // to do is decided by rows only these can write, on a server the runner host
93
93
  // cannot reach — that separation is the entire safety argument, so read the
94
94
  // rank gates below as load-bearing rather than boilerplate.
@@ -172,6 +172,31 @@ module.exports = function buildAutonomyRouter() {
172
172
  }
173
173
  });
174
174
 
175
+ // The priority goal (task 1004453): which allowlisted goal the runner works
176
+ // first. Same gate as the allowlist — choosing what an unattended machine does
177
+ // first is part of choosing what it may do. The goal must already be on the
178
+ // allowlist (409 otherwise), so a priority can order the list and never widen
179
+ // it. `goal_id: null` clears it.
180
+ router.post('/autonomy/fence/priority', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
181
+ // Declared with an open rule: the value is a number OR null, which the
182
+ // validator has no type for, so the shape is checked just below.
183
+ if (validateOrRespond(req, res, { goal_id: {} })) return;
184
+ const raw = req.body ? req.body.goal_id : undefined;
185
+ if (raw === undefined) return res.fail('goal_id_required', { status: 400, message: 'pass { "goal_id": <number> } or { "goal_id": null } to clear' });
186
+ const goalId = raw === null ? null : Number(raw);
187
+ if (goalId !== null && (!Number.isSafeInteger(goalId) || goalId <= 0)) return res.fail('goal_id_required', { status: 400, message: 'pass { "goal_id": <number> } or { "goal_id": null } to clear' });
188
+ try {
189
+ const out = await db.setPriorityGoal({ goalId, builderId: req.builder.id });
190
+ if (out.missing) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
191
+ if (out.notAllowed) return res.fail('goal_not_allowlisted', { status: 409, message: `goal ${goalId} is not on the allowlist — add it first` });
192
+ log.info(`[gds] autonomy fence: priority goal ${goalId === null ? 'CLEARED' : `set to ${goalId}`} by builder ${req.builder.id} (${req.builder.rank})`);
193
+ res.json(out.row);
194
+ } catch (err) {
195
+ log.error('[gds] POST /autonomy/fence/priority', err);
196
+ res.fail('priority_failed', { status: 500, message: 'internal error' });
197
+ }
198
+ });
199
+
175
200
  // ── the runner heartbeat (task 1003905) ────────────────────────────────────
176
201
  //
177
202
  // A dead runner must be VISIBLE, not silent. The runner also writes a heartbeat
@@ -193,6 +218,7 @@ module.exports = function buildAutonomyRouter() {
193
218
  consecutive_failures: { type: 'number' },
194
219
  last_event: { type: 'string', maxLength: 64 },
195
220
  working_task_id: { type: 'number' },
221
+ working_goal_id: { type: 'number' },
196
222
  })) return;
197
223
  const b = req.body || {};
198
224
  try {
@@ -207,6 +233,7 @@ module.exports = function buildAutonomyRouter() {
207
233
  consecutiveFailures: Number.isInteger(b.consecutive_failures) ? b.consecutive_failures : null,
208
234
  lastEvent: b.last_event || null,
209
235
  workingTaskId: Number.isInteger(b.working_task_id) ? b.working_task_id : null,
236
+ workingGoalId: Number.isInteger(b.working_goal_id) ? b.working_goal_id : null,
210
237
  });
211
238
  res.json({ ok: true, heartbeat: row });
212
239
  } catch (err) {
@@ -275,16 +275,31 @@
275
275
  });
276
276
  }
277
277
 
278
- function renderFenceGoals(goals) {
278
+ // The priority goal (task 1004453) is one of the allowlisted rows, marked and
279
+ // listed first, because that is the order the runner works them in. Its button
280
+ // clears it; every other row's button makes that goal the priority.
281
+ function renderFenceGoals(goals, priorityGoalId) {
279
282
  const el = document.getElementById('fence-goals');
280
- const rows = (goals || []).map((g) => `
283
+ const pri = priorityGoalId == null ? null : Number(priorityGoalId);
284
+ // true - false = 1, so the priority row sorts ahead of every other; the rest keep their order.
285
+ const ordered = (goals || []).slice().sort((a, b) => (Number(b.goal_id) === pri) - (Number(a.goal_id) === pri));
286
+ const rows = ordered.map((g) => {
287
+ const isPri = Number(g.goal_id) === pri;
288
+ return `
281
289
  <li class="ov-row" style="display:flex;align-items:center;gap:12px;">
282
290
  <a href="/builders#/goal/${encodeURIComponent(g.goal_id)}">Goal ${escapeHtml(String(g.goal_id))}</a>
291
+ ${isPri ? '<span class="fact-pill fact-pill--ok">Priority</span>' : ''}
283
292
  <span style="flex:1;">${escapeHtml(g.note || '')}</span>
293
+ <button type="button" class="btn" data-priority="${isPri ? '' : escapeHtml(String(g.goal_id))}">${isPri ? 'Clear priority' : 'Make priority'}</button>
284
294
  <button type="button" class="btn" data-revoke="${escapeHtml(String(g.goal_id))}">Remove</button>
285
- </li>`).join('');
295
+ </li>`;
296
+ }).join('');
297
+ const priLine = pri === null
298
+ ? 'No priority goal: the runner works these in any order.'
299
+ : `The runner works goal ${escapeHtml(String(pri))} first, then the others once it has nothing left to claim there.`;
286
300
  el.innerHTML = `
287
301
  <h3 class="scroll__h" style="font-size:1rem;">Allowlisted goals</h3>
302
+ ${goals && goals.length ? `<p class="ov-foot">${priLine}</p>` : ''}
288
303
  ${goals && goals.length ? `<ul class="ov-rows">${rows}</ul>`
289
304
  : emptyStateHtml('No goal is allowlisted, so the runner has nothing it may work on.')}
290
305
  <form id="fence-add" class="ov-foot" style="display:flex;gap:8px;align-items:center;margin-top:12px;">
@@ -305,6 +320,19 @@
305
320
  } catch (err) { toast(err.message || 'Could not add that goal.'); }
306
321
  });
307
322
 
323
+ for (const btn of el.querySelectorAll('[data-priority]')) {
324
+ btn.addEventListener('click', async () => {
325
+ // An empty attribute is the Clear button on the current priority row.
326
+ const v = btn.getAttribute('data-priority');
327
+ const goalId = v ? Number(v) : null;
328
+ try {
329
+ await sendJson('POST', '/autonomy/fence/priority', { goal_id: goalId });
330
+ toast(goalId === null ? 'Priority cleared.' : `Goal ${goalId} is now the priority.`);
331
+ await loadFence();
332
+ } catch (err) { toast(err.message || 'Could not change the priority.'); }
333
+ });
334
+ }
335
+
308
336
  for (const btn of el.querySelectorAll('[data-revoke]')) {
309
337
  btn.addEventListener('click', async () => {
310
338
  const goalId = btn.getAttribute('data-revoke');
@@ -360,6 +388,7 @@
360
388
  return `<p class="ov-foot" style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;">
361
389
  <span class="fact-pill ${cls}">${escapeHtml(label)}</span>
362
390
  <span>${escapeHtml(h.detail || '')}</span>
391
+ ${r.working_goal_id ? `<span class="ov-fact">from goal ${escapeHtml(String(r.working_goal_id))}</span>` : ''}
363
392
  <span class="ov-fact">last check-in ${escapeHtml(h.age || 'unknown')} ago</span>
364
393
  </p>`;
365
394
  }).join('');
@@ -379,7 +408,7 @@
379
408
  return;
380
409
  }
381
410
  renderFenceState(fence);
382
- renderFenceGoals(fence.goals);
411
+ renderFenceGoals(fence.goals, fence.priority_goal_id);
383
412
  }
384
413
 
385
414
  async function init() {
@@ -95,9 +95,16 @@ const NEVER_ROUTE_GRADE = 'full';
95
95
  // 'discord-bugs' — the #bugs channel, where the CHANNEL is the classification
96
96
  // (modules/discord/discord-bugs.js forces kind='bug'), and
97
97
  // which already carries a rate cap + an unlinked-author guard.
98
+ // 'instance-feedback' — POST /sso/feedback on the hub (task 1004462): a
99
+ // federated project relaying `bongos feedback` about Bongos
100
+ // itself. It earns the vector the way #bugs does — the
101
+ // sender must be a hub-witnessed member of the calling
102
+ // project, and the route caps filings per sender and per
103
+ // project — and it always files with NO rank, so every
104
+ // report lands at 'backlog' awaiting a nod.
98
105
  // 'discord-ideas' is deliberately ABSENT: it is an ideation channel, and the bug
99
106
  // reports that belong to Discord already have #bugs.
100
- const HOMELESS_ROUTING_VECTORS = new Set(['api', 'discord-bugs']);
107
+ const HOMELESS_ROUTING_VECTORS = new Set(['api', 'discord-bugs', 'instance-feedback']);
101
108
 
102
109
  // decideLanding — the matrix, as one pure function.
103
110
  //
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/childlog.js — what the preview child printed, kept small and
4
+ // with secrets taken out (task 1004465).
5
+ //
6
+ // WHY. The child used to be spawned with stdio 'ignore', so when a preview started but could
7
+ // not serve a single page, its own error went nowhere and the only way to find out was ssh.
8
+ // This keeps the last CAP bytes of its stdout and stderr, writes them to a file in the
9
+ // preview's scratch directory, and hands the deploy page a short redacted tail.
10
+ //
11
+ // REDACTION IS BY VALUE AND BY SHAPE. Every value in the environment the child was given
12
+ // (and the live one) whose name looks secret is replaced wherever it appears; then the common
13
+ // shapes (a password in a connection URL, `token=...`, a bearer header, a GitHub or API key)
14
+ // are replaced even if nobody listed them. The tail never reaches a caller unredacted: it is
15
+ // redacted at read time as well as at write time, so a secret that arrives split across two
16
+ // chunks is still caught.
17
+
18
+ const CAP = 64 * 1024;
19
+ // The file is rewritten at most this often, asynchronously: the child's output is handled
20
+ // inside the LIVE hall's process, and a chatty or crash-looping child must never turn into a
21
+ // run of blocking disk writes on its event loop.
22
+ const FLUSH_MS = 500;
23
+ const TAIL_LINES = 20;
24
+ const MAX_LINE = 300;
25
+ const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PASS|KEY|CREDENTIAL|COOKIE|AUTH)/i;
26
+
27
+ const SHAPES = [
28
+ [/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:)[^\s@/]+@/gi, '$1[redacted]@'],
29
+ [/\b((?:api[_-]?key|token|secret|password|passwd|authorization|cookie|signing[_-]?key)\w*\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
30
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]'],
31
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,})/g, '[redacted]'],
32
+ ];
33
+
34
+ /** The values in these environments that look like secrets and are long enough to matter. */
35
+ function secretValues(...envs) {
36
+ const out = new Set();
37
+ for (const env of envs) {
38
+ for (const [k, v] of Object.entries(env || {})) {
39
+ if (SECRET_NAME.test(k) && typeof v === 'string' && v.length >= 6) out.add(v);
40
+ }
41
+ }
42
+ // Longest first, so a value that contains another is replaced whole.
43
+ return [...out].sort((a, b) => b.length - a.length);
44
+ }
45
+
46
+ /** Take the secrets out of one piece of text. Pure. */
47
+ function redact(text, secrets = []) {
48
+ let s = String(text);
49
+ for (const v of secrets) s = s.split(v).join('[redacted]');
50
+ for (const [re, to] of SHAPES) s = s.replace(re, to);
51
+ return s;
52
+ }
53
+
54
+ /**
55
+ * @param {object} [o]
56
+ * @param {string[]} [o.secrets] exact values to take out (from secretValues)
57
+ * @param {object} [o.fsImpl] fs (writeFile, the callback form) — optional, with `file`
58
+ * @param {string} [o.file] where to keep the capped raw-but-redacted log
59
+ * @param {number} [o.cap]
60
+ */
61
+ function createChildLog({ secrets = [], fsImpl = null, file = null, cap = CAP, flushMs = FLUSH_MS } = {}) {
62
+ let buf = '';
63
+ let timer = null;
64
+ let closed = false;
65
+ const canWrite = () => !!(fsImpl && file && typeof fsImpl.writeFile === 'function');
66
+ function flush() {
67
+ timer = null;
68
+ if (closed || !canWrite()) return;
69
+ try { fsImpl.writeFile(file, buf, () => { /* best effort */ }); } catch { /* best effort */ }
70
+ }
71
+ return {
72
+ /** Add output. Never throws: a log that cannot be kept must not fail a start. */
73
+ append(chunk) {
74
+ try {
75
+ buf = (buf + redact(chunk, secrets)).slice(-cap);
76
+ if (canWrite() && !timer && !closed) {
77
+ timer = setTimeout(flush, flushMs);
78
+ if (timer && typeof timer.unref === 'function') timer.unref();
79
+ }
80
+ } catch { /* best effort */ }
81
+ },
82
+ /** Write the file now (tests; the timer does this in production). */
83
+ flush,
84
+ /** Stop writing: the scratch directory is about to be removed. */
85
+ close() { closed = true; if (timer) { clearTimeout(timer); timer = null; } },
86
+ /** The last `n` non-empty lines, each shortened. */
87
+ tail(n = TAIL_LINES) {
88
+ return redact(buf, secrets).split(/\r?\n/).filter((l) => l.trim())
89
+ .slice(-n).map((l) => (l.length > MAX_LINE ? `${l.slice(0, MAX_LINE)}…` : l));
90
+ },
91
+ size: () => buf.length,
92
+ };
93
+ }
94
+
95
+ module.exports = { createChildLog, redact, secretValues };
@@ -36,11 +36,49 @@ function installCommand({ cacheRoot, version }) {
36
36
  return { cmd: 'npm', args: ['install', '--prefix', versionDir(cacheRoot, version), `${PACKAGE}@${version}`, '--omit=dev'] };
37
37
  }
38
38
 
39
- /** Drop the copy if one is left over — used before a start and on every stop. */
39
+ /**
40
+ * Drop the copy if one is left over — used before a start and on every stop. --force ends any
41
+ * connection still open on the copy (Postgres 13+), which is exactly the case that used to
42
+ * leave it behind: a child that had not finished exiting. The name is the fixed PREVIEW_DB
43
+ * and nothing is interpolated, so this can never name the live database.
44
+ */
40
45
  function dropDbCommand() {
46
+ return { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] };
47
+ }
48
+
49
+ /** Plain drop, for a server whose dropdb has no --force (Postgres 12 and older). */
50
+ function dropDbPlainCommand() {
41
51
  return { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
42
52
  }
43
53
 
54
+ /**
55
+ * End every connection to the copy and ONLY the copy: the database name is the literal
56
+ * PREVIEW_DB, never a parameter. Run on the maintenance database, since the copy cannot be
57
+ * connected to while it is being emptied.
58
+ */
59
+ function terminateBackendsCommand() {
60
+ return {
61
+ cmd: 'psql',
62
+ args: ['-X', '-d', 'postgres', '-tAc',
63
+ `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${PREVIEW_DB}' AND pid <> pg_backend_pid()`],
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Can this command only ever touch the copy? True for the drop commands above and nothing
69
+ * else. The supervisor checks it before running any drop, so a future edit that pointed one
70
+ * at another database is refused at run time, not discovered on the droplet.
71
+ */
72
+ function targetsOnlyPreviewDb(command) {
73
+ if (!command || !Array.isArray(command.args)) return false;
74
+ if (command.cmd === 'dropdb') {
75
+ const last = command.args[command.args.length - 1];
76
+ return last === PREVIEW_DB && command.args.slice(0, -1).every((a) => a.startsWith('--'));
77
+ }
78
+ if (command.cmd === 'psql') return JSON.stringify(command.args) === JSON.stringify(terminateBackendsCommand().args);
79
+ return false;
80
+ }
81
+
44
82
  /** An empty database for the copy. Deliberately no template (see the header). */
45
83
  function createDbCommand() {
46
84
  return { cmd: 'createdb', args: [PREVIEW_DB] };
@@ -65,17 +103,20 @@ function restoreCommand({ dumpFile }) {
65
103
  * DATABASE_URL is blanked because the pool prefers it to PGDATABASE, and PGDATABASE is
66
104
  * pinned to the copy so nothing can be applied to the live database by mistake.
67
105
  */
68
- function migrateCommand({ cacheRoot, version, env }) {
106
+ function migrateCommand({ cacheRoot, version, env, instanceDir }) {
69
107
  const dir = versionDir(cacheRoot, version);
108
+ // With a scratch instance directory, THAT is the instance root (its config/ and
109
+ // migrations/instance/ are copies of the live instance's); without one, the install directory.
110
+ const root = instanceDir || dir;
70
111
  return {
71
112
  cmd: 'bash',
72
113
  args: [path.join(dir, 'node_modules', '@bongos', 'core', 'scripts', 'migrate.sh')],
73
- cwd: dir,
74
- env: { ...env, INIT_CWD: dir, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
114
+ cwd: root,
115
+ env: { ...env, INIT_CWD: root, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
75
116
  };
76
117
  }
77
118
 
78
119
  module.exports = {
79
120
  VERSION_RE, PACKAGE, versionDir, entryPath,
80
- installCommand, dropDbCommand, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
121
+ installCommand, dropDbCommand, dropDbPlainCommand, terminateBackendsCommand, targetsOnlyPreviewDb, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
81
122
  };
@@ -41,12 +41,15 @@ const POLLERS_OFF = Object.freeze([
41
41
 
42
42
  // Modules that talk to the outside world, off. npm-release is off too: a preview must not
43
43
  // offer previews of its own.
44
- const MODULES_OFF = Object.freeze([
45
- 'BONGOS_MODULE_DISCORD',
46
- 'BONGOS_MODULE_AGENTS',
47
- 'BONGOS_MODULE_PROVISIONING',
48
- 'BONGOS_MODULE_NPM_RELEASE',
49
- ]);
44
+ //
45
+ // EVERY SPELLING OF THE PREFIX. The core reads <PREFIX>_MODULE_<KEY> with the PREFIX the
46
+ // instance's branding pack names, then the legacy ones. The child now runs with a copy of the
47
+ // live instance's config/ (task 1004465), so its prefix may be the live instance's own, and a
48
+ // switch spelled only BONGOS_ would then be ignored — Discord and the agents would come back
49
+ // on in the copy. So each switch is set under every prefix the core can resolve.
50
+ const MODULE_KEYS = Object.freeze(['DISCORD', 'AGENTS', 'PROVISIONING', 'NPM_RELEASE']);
51
+ const PREFIXES = Object.freeze(['BONGOS', 'CLOUDBONGOS', 'OTB', 'GDS', 'PMS']);
52
+ const MODULES_OFF = Object.freeze(PREFIXES.flatMap((pfx) => MODULE_KEYS.map((k) => `${pfx}_MODULE_${k}`)));
50
53
 
51
54
  /**
52
55
  * Build the child's environment.
@@ -55,9 +58,12 @@ const MODULES_OFF = Object.freeze([
55
58
  * @param {object} [o.env] the live process's environment (only CARRIED names are read)
56
59
  * @param {number|string} o.port the loopback port the preview listens on
57
60
  * @param {string} o.scratchHome an empty directory to serve as HOME
61
+ * @param {string} [o.envPrefix] the env prefix of the copied instance's branding pack, if any
62
+ * @param {string} [o.instanceRoot] the scratch directory shaped like an instance root (config/,
63
+ * migrations/instance/); the child reads its host content from it
58
64
  * @returns {Record<string,string>} a fresh object, never `env` itself
59
65
  */
60
- function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
66
+ function buildPreviewEnv({ env = process.env, port, scratchHome, instanceRoot, envPrefix } = {}) {
61
67
  if (!port) throw new TypeError('buildPreviewEnv: port is required');
62
68
  if (!scratchHome) throw new TypeError('buildPreviewEnv: scratchHome is required');
63
69
  const out = {};
@@ -68,10 +74,15 @@ function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
68
74
  out.PORT = String(port);
69
75
  out.HOST = '127.0.0.1';
70
76
  out.HOME = scratchHome;
77
+ // Named explicitly: without it the core takes the process's working directory as the instance
78
+ // root, and the install directory is not one (no config/, so no branding, no module switches).
79
+ if (instanceRoot) out.BONGOS_INSTANCE_ROOT = instanceRoot;
71
80
  out.PGDATABASE = PREVIEW_DB;
72
81
  out.CHAT_DRY_RUN = '1';
73
82
  for (const name of POLLERS_OFF) out[name] = '1';
74
83
  for (const name of MODULES_OFF) out[name] = '0';
84
+ // The copied instance's own prefix, when it is none of the above.
85
+ if (/^[A-Z][A-Z0-9_]*$/.test(String(envPrefix || ''))) for (const k of MODULE_KEYS) out[`${envPrefix}_MODULE_${k}`] = '0';
75
86
  return out;
76
87
  }
77
88