@bongos/core 1.20.36 → 1.20.38
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 +104 -49
- 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/architecture.md +2 -0
- package/docs/copy-inventory.md +9 -9
- package/docs/copy-registry.json +9 -9
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +2 -2
- 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/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/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/scripts/gds/autobongos-run.js +121 -16
- package/scripts/gds/feedback-send.js +131 -0
- package/scripts/gds/module-assess-tests.js +248 -0
- 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 +281 -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/module_assess_tests.mjs +232 -0
- package/tests/update_channel_db.mjs +124 -0
package/docs/copy-registry.json
CHANGED
|
@@ -2976,7 +2976,7 @@
|
|
|
2976
2976
|
"surface": "builders-hall",
|
|
2977
2977
|
"text": "Allow goal",
|
|
2978
2978
|
"file": "modules/hall-ui/public/gate.js",
|
|
2979
|
-
"line":
|
|
2979
|
+
"line": 303,
|
|
2980
2980
|
"origin": "js-markup",
|
|
2981
2981
|
"confidence": "certain"
|
|
2982
2982
|
},
|
|
@@ -3003,7 +3003,7 @@
|
|
|
3003
3003
|
"surface": "builders-hall",
|
|
3004
3004
|
"text": "Allowlisted goals",
|
|
3005
3005
|
"file": "modules/hall-ui/public/gate.js",
|
|
3006
|
-
"line":
|
|
3006
|
+
"line": 300,
|
|
3007
3007
|
"origin": "js-markup",
|
|
3008
3008
|
"confidence": "certain"
|
|
3009
3009
|
},
|
|
@@ -3741,7 +3741,7 @@
|
|
|
3741
3741
|
"surface": "builders-hall",
|
|
3742
3742
|
"text": "Changes touching protected build surfaces, held for your approval.",
|
|
3743
3743
|
"file": "modules/hall-ui/public/gate.js",
|
|
3744
|
-
"line":
|
|
3744
|
+
"line": 432,
|
|
3745
3745
|
"origin": "js-text-assign",
|
|
3746
3746
|
"confidence": "certain"
|
|
3747
3747
|
},
|
|
@@ -5370,7 +5370,7 @@
|
|
|
5370
5370
|
"surface": "builders-hall",
|
|
5371
5371
|
"text": "Enter a goal number.",
|
|
5372
5372
|
"file": "modules/hall-ui/public/gate.js",
|
|
5373
|
-
"line":
|
|
5373
|
+
"line": 315,
|
|
5374
5374
|
"origin": "js-notice",
|
|
5375
5375
|
"confidence": "certain"
|
|
5376
5376
|
},
|
|
@@ -6054,7 +6054,7 @@
|
|
|
6054
6054
|
"surface": "builders-hall",
|
|
6055
6055
|
"text": "Goal {…}",
|
|
6056
6056
|
"file": "modules/hall-ui/public/gate.js",
|
|
6057
|
-
"line":
|
|
6057
|
+
"line": 288,
|
|
6058
6058
|
"origin": "js-markup",
|
|
6059
6059
|
"confidence": "certain"
|
|
6060
6060
|
},
|
|
@@ -6063,7 +6063,7 @@
|
|
|
6063
6063
|
"surface": "builders-hall",
|
|
6064
6064
|
"text": "Goal {…} allowlisted.",
|
|
6065
6065
|
"file": "modules/hall-ui/public/gate.js",
|
|
6066
|
-
"line":
|
|
6066
|
+
"line": 318,
|
|
6067
6067
|
"origin": "js-notice",
|
|
6068
6068
|
"confidence": "certain"
|
|
6069
6069
|
},
|
|
@@ -6072,7 +6072,7 @@
|
|
|
6072
6072
|
"surface": "builders-hall",
|
|
6073
6073
|
"text": "Goal {…} removed.",
|
|
6074
6074
|
"file": "modules/hall-ui/public/gate.js",
|
|
6075
|
-
"line":
|
|
6075
|
+
"line": 341,
|
|
6076
6076
|
"origin": "js-notice",
|
|
6077
6077
|
"confidence": "certain"
|
|
6078
6078
|
},
|
|
@@ -14442,7 +14442,7 @@
|
|
|
14442
14442
|
"surface": "builders-hall",
|
|
14443
14443
|
"text": "last check-in {…} ago",
|
|
14444
14444
|
"file": "modules/hall-ui/public/gate.js",
|
|
14445
|
-
"line":
|
|
14445
|
+
"line": 391,
|
|
14446
14446
|
"origin": "js-markup",
|
|
14447
14447
|
"confidence": "certain"
|
|
14448
14448
|
},
|
|
@@ -15846,7 +15846,7 @@
|
|
|
15846
15846
|
"surface": "builders-hall",
|
|
15847
15847
|
"text": "why (optional)",
|
|
15848
15848
|
"file": "modules/hall-ui/public/gate.js",
|
|
15849
|
-
"line":
|
|
15849
|
+
"line": 303,
|
|
15850
15850
|
"origin": "js-markup",
|
|
15851
15851
|
"confidence": "certain"
|
|
15852
15852
|
},
|
package/docs/file-map.md
CHANGED
|
@@ -365,6 +365,7 @@ tests/
|
|
|
365
365
|
├── backlog-review/SKILL.md ← /backlog-review — daily walk of status=backlog, the pre-workable state a human must say go on: splits rows waiting on a PERSON (promote / kill / water) from rows waiting on a live dep TRIGGER (counted, never walked, migration 163) and surfaces rows stranded behind an abandoned dep; runs scripts/gds/backlog-review.js (task 1003746); Metic+
|
|
366
366
|
├── blocker-review/SKILL.md ← /blocker-review — daily review of open Bongos blockers (resolve / escalate / note); Metic+
|
|
367
367
|
├── blocker-solve/SKILL.md ← /blocker-solve N — drive ONE blocker to done: do the doable parts, hand back owner-only steps, verify, auto-resolve (auto-promotes waiters); Metic+
|
|
368
|
+
├── bongos-feedback/SKILL.md ← /bongos-feedback — send feedback about Bongos itself upstream to the Cloud Bongos maintainers via `bongos feedback` (a bug lands as a backlog task in the maintenance goal, an idea in the hub inbox); backed by scripts/gds/feedback-send.js (task 1004462)
|
|
368
369
|
├── bug-triage/SKILL.md ← /bug-triage — daily walk through open kind=bug tasks (batch-cluster near-dups, merge / won't-fix); the idea-triage counterpart built on the new tasks.merge primitive (task 1001478); Metic+
|
|
369
370
|
├── builder-backup/SKILL.md ← /builder-backup — check DB backup status or trigger a fresh local dump before risky changes (migrations, destructive SQL); no SSH required
|
|
370
371
|
├── builder-claim/SKILL.md ← /builder-claim N — atomic claim; prints the claim-time context pack + discipline playbook routing
|
|
@@ -2729,5 +2729,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2729
2729
|
landed since 1.20.34 with no explicit bump. run 36787635391. (task 1002620)
|
|
2730
2730
|
1.20.36 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2731
2731
|
landed since 1.20.35 with no explicit bump. run 36788696388. (task 1002620)
|
|
2732
|
+
1.20.37 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2733
|
+
landed since 1.20.36 with no explicit bump. run 36790298715. (task 1002620)
|
|
2734
|
+
1.20.38 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2735
|
+
landed since 1.20.37 with no explicit bump. run 36794921587. (task 1002620)
|
|
2732
2736
|
---------------------------------------------------------------------------
|
|
2733
2737
|
```
|
package/docs/page-readings.json
CHANGED
|
@@ -873,7 +873,7 @@
|
|
|
873
873
|
"surface": "builders",
|
|
874
874
|
"title": "Gate",
|
|
875
875
|
"states_read": ["queue","out","approve"],
|
|
876
|
-
"files_hash": "
|
|
876
|
+
"files_hash": "14ac6944f79fb3cb",
|
|
877
877
|
"reading_hash": "08d3e5d3657e5afb",
|
|
878
878
|
"counts": {"lines":41,"placed":17,"shared":5,"unplaced":19},
|
|
879
879
|
"omitted": {"data":34},
|
|
@@ -884,7 +884,7 @@
|
|
|
884
884
|
{"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":487,"string_id":"eba610d25523"},
|
|
885
885
|
{"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
|
|
886
886
|
{"key":"L0005","section":"Gate","text":"Gate","placement":"unplaced"},
|
|
887
|
-
{"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":
|
|
887
|
+
{"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":431,"string_id":"4b714191b423"},
|
|
888
888
|
{"key":"L0007","section":"Gate","text":"Awaiting your approval","placement":"unplaced"},
|
|
889
889
|
{"key":"L0008","section":"Gate","text":"held at the gate check","placement":"unplaced"},
|
|
890
890
|
{"key":"L0009","section":"Gate","text":"Hard-floor holds","placement":"unplaced"},
|
|
@@ -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,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/platform-identity/instance-feedback.js — feedback about Bongos ITSELF,
|
|
4
|
+
// relayed up from a federated project to the hub (task 1004462).
|
|
5
|
+
//
|
|
6
|
+
// A builder on a hosted project runs `bongos feedback "…"`. Their OWN instance
|
|
7
|
+
// stamps who sent it and which core it runs, and relays it here over the
|
|
8
|
+
// hub-federation client credential (ADR 0141) — the same server-to-server channel
|
|
9
|
+
// as the activity rollup. This file turns that relay into a filing on the hub:
|
|
10
|
+
//
|
|
11
|
+
// kind 'bug' → a task in the building version's MAINTENANCE goal, at 'backlog'
|
|
12
|
+
// (ADR 0262's homeless-bug home, reached through the
|
|
13
|
+
// 'instance-feedback' routing vector).
|
|
14
|
+
// kind 'idea' → an idea_inbox row for /idea-triage, exactly as any goal-less
|
|
15
|
+
// idea lands — a suggestion is not a defect, and the maintenance
|
|
16
|
+
// goal is for defects.
|
|
17
|
+
//
|
|
18
|
+
// ALWAYS 'backlog', never 'ready' — the owner's decision (2026-09-30). Anyone on
|
|
19
|
+
// any tenant project can send this, so an approval stands between a report and
|
|
20
|
+
// paid agent work. That is enforced by passing NO filer rank: decideLanding reads
|
|
21
|
+
// an absent rank as untrusted, so even the owner's own feedback waits for the nod
|
|
22
|
+
// rather than inheriting a hub rank the instance never vouched for.
|
|
23
|
+
//
|
|
24
|
+
// THE ATTRIBUTION IS THE HUB'S, NOT THE INSTANCE'S. Client credentials prove WHICH
|
|
25
|
+
// PROJECT is calling and nothing about WHO sent it, so the sender must hold a
|
|
26
|
+
// membership on that project carrying the hub's own sign-in witness
|
|
27
|
+
// (membershipBacksRollup — the ADR 0205 rule the rollup uses). The login printed
|
|
28
|
+
// on the task is then read from the hub's account row, never from the body.
|
|
29
|
+
|
|
30
|
+
const FEEDBACK_KINDS = Object.freeze({ bug: 'bug', idea: 'feature' });
|
|
31
|
+
const MESSAGE_MAX = 4000;
|
|
32
|
+
const TITLE_MAX = 120;
|
|
33
|
+
|
|
34
|
+
// Per-hour caps, counted in memory on this hub process. Two, because they stop
|
|
35
|
+
// different things: one noisy builder, and one project relaying for many.
|
|
36
|
+
const FEEDBACK_WINDOW_MS = 60 * 60 * 1000;
|
|
37
|
+
const PER_SENDER_LIMIT = 10;
|
|
38
|
+
const PER_PROJECT_LIMIT = 30;
|
|
39
|
+
|
|
40
|
+
// makeFeedbackLimiter — a sliding-window counter keyed by project and sender.
|
|
41
|
+
// `take` records a filing only when BOTH caps have room, so a refusal on one cap
|
|
42
|
+
// never spends the other.
|
|
43
|
+
function makeFeedbackLimiter({ now = Date.now } = {}) {
|
|
44
|
+
const buckets = new Map(); // key -> timestamp[]
|
|
45
|
+
function prune(key, t) {
|
|
46
|
+
const arr = buckets.get(key);
|
|
47
|
+
if (!arr) return [];
|
|
48
|
+
while (arr.length && arr[0] <= t - FEEDBACK_WINDOW_MS) arr.shift();
|
|
49
|
+
if (arr.length === 0) buckets.delete(key);
|
|
50
|
+
return arr;
|
|
51
|
+
}
|
|
52
|
+
function take(clientId, githubId) {
|
|
53
|
+
const t = now();
|
|
54
|
+
const checks = [
|
|
55
|
+
[`p:${clientId}`, PER_PROJECT_LIMIT, 'project'],
|
|
56
|
+
[`s:${clientId}:${githubId}`, PER_SENDER_LIMIT, 'sender'],
|
|
57
|
+
];
|
|
58
|
+
for (const [key, limit, scope] of checks) {
|
|
59
|
+
const arr = prune(key, t);
|
|
60
|
+
if (arr.length >= limit) {
|
|
61
|
+
const retryAfter = Math.max(1, Math.ceil((arr[0] + FEEDBACK_WINDOW_MS - t) / 1000));
|
|
62
|
+
return { ok: false, scope, retryAfter };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [key] of checks) {
|
|
66
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
67
|
+
buckets.get(key).push(t);
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
}
|
|
71
|
+
return { take, _buckets: buckets };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A version stamp from the instance: short, and only the characters a version
|
|
75
|
+
// string or an OS name uses, so it cannot carry markdown or links into the task.
|
|
76
|
+
function cleanStamp(v) {
|
|
77
|
+
if (typeof v !== 'string') return null;
|
|
78
|
+
const s = v.trim().slice(0, 40);
|
|
79
|
+
return /^[\w.+\- ]+$/.test(s) ? s : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The task title: the first non-empty line, trimmed to fit.
|
|
83
|
+
function feedbackTitle(message) {
|
|
84
|
+
const first = String(message).split('\n').map((l) => l.trim()).find(Boolean) || 'Feedback';
|
|
85
|
+
const title = first.length > TITLE_MAX ? `${first.slice(0, TITLE_MAX - 1)}…` : first;
|
|
86
|
+
return `Feedback: ${title}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
90
|
+
const who = account && account.github_login ? `@${account.github_login}` : `GitHub user ${githubId}`;
|
|
91
|
+
const project = client.name ? `${client.name} (${client.origin || client.client_id})` : (client.origin || client.client_id);
|
|
92
|
+
const stamps = [
|
|
93
|
+
['Core', cleanStamp(context.core_version)],
|
|
94
|
+
['CLI', cleanStamp(context.cli_version)],
|
|
95
|
+
['OS', cleanStamp(context.os)],
|
|
96
|
+
].filter(([, v]) => v).map(([k, v]) => `${k} ${v}`).join(' · ');
|
|
97
|
+
// The hub's own attribution comes FIRST and the sender's words follow as a
|
|
98
|
+
// quote, so text they type can never pose as the footer a reviewer trusts.
|
|
99
|
+
const quoted = String(message).trim().split('\n').map((l) => `> ${l}`).join('\n');
|
|
100
|
+
return [
|
|
101
|
+
'Sent from a managed project with `bongos feedback`.',
|
|
102
|
+
`- Project: ${project}`,
|
|
103
|
+
`- Sent by: ${who}`,
|
|
104
|
+
...(stamps ? [`- Running: ${stamps}`] : []),
|
|
105
|
+
'',
|
|
106
|
+
quoted,
|
|
107
|
+
].join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// fileInstanceFeedback — file one relayed report. `deps.capture` is the
|
|
111
|
+
// `ideas.capture` port; `deps.getAccount` and `deps.hubBuilderId` read the hub's
|
|
112
|
+
// own rows for the sender. Returns the landing the CLI prints.
|
|
113
|
+
async function fileInstanceFeedback({ client, githubId, message, kind, context }, deps) {
|
|
114
|
+
const account = await deps.getAccount(githubId).catch(() => null);
|
|
115
|
+
const capturedBy = await deps.hubBuilderId(githubId).catch(() => null);
|
|
116
|
+
const idea = await deps.capture({
|
|
117
|
+
title: feedbackTitle(message),
|
|
118
|
+
bodyMd: feedbackBody({ message, client, account, githubId, context }),
|
|
119
|
+
kind: FEEDBACK_KINDS[kind],
|
|
120
|
+
capturedBy,
|
|
121
|
+
filerRank: null, // untrusted by design — see the header
|
|
122
|
+
filedVia: 'api',
|
|
123
|
+
vector: 'instance-feedback',
|
|
124
|
+
suggestedPriority: 3,
|
|
125
|
+
});
|
|
126
|
+
const routing = (idea && idea.routing) || {};
|
|
127
|
+
return routing.routed
|
|
128
|
+
? { landed: 'task', task_id: String(routing.task_id), goal_id: String(routing.goal_id), status: routing.status, idea_id: String(idea.id) }
|
|
129
|
+
: { landed: 'inbox', idea_id: String(idea.id) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
FEEDBACK_KINDS,
|
|
134
|
+
MESSAGE_MAX,
|
|
135
|
+
PER_SENDER_LIMIT,
|
|
136
|
+
PER_PROJECT_LIMIT,
|
|
137
|
+
FEEDBACK_WINDOW_MS,
|
|
138
|
+
makeFeedbackLimiter,
|
|
139
|
+
feedbackTitle,
|
|
140
|
+
feedbackBody,
|
|
141
|
+
fileInstanceFeedback,
|
|
142
|
+
};
|