@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.
- package/.bongos-core.json +119 -64
- package/.claude/skills/backlog-review/SKILL.md +1 -1
- package/.claude/skills/bongos-feedback/SKILL.md +66 -0
- package/.claude/skills/collab-review/SKILL.md +1 -1
- package/.claude/skills/priority-session/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/status/SKILL.md +1 -1
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
- package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +188 -2
- package/docs/api-reference.md +12 -4
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +18 -18
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +253 -250
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/autonomy/db.js +39 -8
- package/modules/autonomy/fence.js +26 -4
- package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
- package/modules/autonomy/routes/autonomy.js +28 -1
- package/modules/hall-ui/public/gate.js +33 -4
- package/modules/ideas/routing.js +8 -1
- package/modules/npm-release/preview/childlog.js +95 -0
- package/modules/npm-release/preview/commands.js +46 -5
- package/modules/npm-release/preview/env.js +18 -7
- package/modules/npm-release/preview/runtime.js +43 -9
- package/modules/npm-release/preview/supervisor.js +170 -31
- package/modules/npm-release/public/work.css +3 -0
- package/modules/npm-release/public/work.js +6 -1
- package/modules/npm-release/routes/preview.js +1 -0
- package/modules/platform-identity/instance-feedback.js +142 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso.js +62 -0
- package/modules/provisioning/render-standup.js +13 -1
- package/modules/public-landing/public/projects.html +22 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/scripts/gds/autobongos-run.js +14 -4
- package/scripts/gds/feedback-send.js +131 -0
- package/scripts/gds/provision-render.js +13 -1
- package/scripts/gds/render-payload.js +6 -5
- package/scripts/gds/update-channel.js +82 -0
- package/src/bongos/routes/feedback.js +110 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_fence.mjs +62 -0
- package/tests/autobongos_loop.mjs +76 -0
- package/tests/autonomy_fence_priority.mjs +211 -0
- package/tests/bongos_cli.mjs +7 -1
- package/tests/filing_abuse_matrix.mjs +7 -2
- package/tests/idea_routing.mjs +1 -1
- package/tests/instance_feedback.mjs +327 -0
- package/tests/npm_release_preview_commands.mjs +1 -1
- package/tests/npm_release_preview_diagnosis.mjs +376 -0
- package/tests/npm_release_preview_supervisor.mjs +4 -4
- package/tests/projects_hub_app_step.mjs +49 -0
- package/tests/projects_hub_render_connect.mjs +2 -1
- package/tests/provision_render.mjs +23 -2
- 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
|
package/modules/autonomy/db.js
CHANGED
|
@@ -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) => ({
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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() {
|
package/modules/ideas/routing.js
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
74
|
-
env: { ...env, INIT_CWD:
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|