@bongos/core 1.20.37 → 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.
Files changed (50) hide show
  1. package/.bongos-core.json +93 -48
  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 +9 -9
  20. package/docs/copy-registry.json +9 -9
  21. package/docs/file-map.md +1 -0
  22. package/docs/module-api-changelog.md +2 -0
  23. package/docs/page-readings.json +2 -2
  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/platform-identity/instance-feedback.js +142 -0
  32. package/modules/platform-identity/module.json +1 -1
  33. package/modules/platform-identity/routes/sso.js +62 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +20 -0
  37. package/scripts/gds/autobongos-run.js +14 -4
  38. package/scripts/gds/feedback-send.js +131 -0
  39. package/scripts/gds/update-channel.js +82 -0
  40. package/src/bongos/routes/feedback.js +110 -0
  41. package/src/bongos/routes.js +2 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/autobongos_fence.mjs +62 -0
  44. package/tests/autobongos_loop.mjs +76 -0
  45. package/tests/autonomy_fence_priority.mjs +211 -0
  46. package/tests/bongos_cli.mjs +7 -1
  47. package/tests/filing_abuse_matrix.mjs +7 -2
  48. package/tests/idea_routing.mjs +1 -1
  49. package/tests/instance_feedback.mjs +327 -0
  50. package/tests/update_channel_db.mjs +124 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.37",
3
+ "version": "1.20.38",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8313,5 +8313,25 @@
8313
8313
  "id": "1004406",
8314
8314
  "text": "The unattended runner stops restarting every time anyone merges: it now updates its code in place and only restarts when its own code has changed."
8315
8315
  }
8316
+ ],
8317
+ "1.20.38": [
8318
+ {
8319
+ "id": "1004468",
8320
+ "text": "The nightly auto-update now follows the update rule you set on /deploy for each project, instead of a file on the server."
8321
+ },
8322
+ {
8323
+ "id": "1004453",
8324
+ "text": "When you start Autobongos from the Gate page you can now pick a priority goal: the runner works that goal first and moves to the other allowed goals only when it runs out of work there."
8325
+ },
8326
+ {
8327
+ "id": "1004472",
8328
+ "text": "Fixes the broken automatic check on main that was stopping everyone's finished work from landing."
8329
+ },
8330
+ {
8331
+ "text": "When a builder complains that Bongos isn't working, their assistant now reports it to the Bongos team by itself, so every frustration becomes something on the fix list."
8332
+ },
8333
+ {
8334
+ "text": "Builders on any Cloud Bongos project can now send feedback about Bongos itself with one command, and it arrives with the Bongos team as a task waiting for approval."
8335
+ }
8316
8336
  ]
8317
8337
  }
@@ -106,6 +106,15 @@ function record(entry) {
106
106
  return row;
107
107
  }
108
108
 
109
+ // pickedFrom — the row fields that say where a pick came from (task 1004453).
110
+ // `priority` is true only when the fence named a priority goal AND this pick came
111
+ // from it, so "the priority goal ran dry and the runner moved on" reads as
112
+ // priority:false beside the goal it moved on to.
113
+ function pickedFrom(goalId, gate) {
114
+ const p = gate && gate.priorityGoal != null ? gate.priorityGoal : null;
115
+ return { priority: p !== null && Number(goalId) === p };
116
+ }
117
+
109
118
  // pickTask — the allowlisted goals, in order, through sequence.js's halt matrix.
110
119
  // The matrix is reused rather than re-derived: it already refuses thin specs,
111
120
  // protected paths, judgement kinds and unmet dependencies, from facts on the row.
@@ -479,7 +488,7 @@ async function iteration(opts, deps = {}) {
479
488
  // looks like, and it must still reach the failure ladder (task 1004179).
480
489
  if (lastRefusal) return lastRefusal;
481
490
  return log({
482
- event: 'nothing_claimable', goals: gate.goals,
491
+ event: 'nothing_claimable', goals: gate.goals, priority_goal: gate.priorityGoal ?? null,
483
492
  // Carried so the cadence can tell an EMPTY queue from one where every task
484
493
  // halts — the reasons are the matrix's own codes, safe to print.
485
494
  skipped: (got.skipped || []).map((sk) => ({ id: sk.id, goal_id: sk.goal_id, reasons: (sk.reasons || []).map((r) => r.code) })),
@@ -501,10 +510,10 @@ async function iteration(opts, deps = {}) {
501
510
  task: candidate, goals: gate.goals, protectedHits: matchProtected(candidate.touches || []),
502
511
  });
503
512
  if (!taskGate.go) {
504
- return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: candidate.id, goal_id: got.goalId, globs: taskGate.globs || [] });
513
+ return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: candidate.id, goal_id: got.goalId, ...pickedFrom(got.goalId, gate), globs: taskGate.globs || [] });
505
514
  }
506
515
 
507
- if (opts.dryRun) return log({ event: 'dry_run', task_id: candidate.id, title: candidate.title, goal_id: got.goalId });
516
+ if (opts.dryRun) return log({ event: 'dry_run', task_id: candidate.id, title: candidate.title, goal_id: got.goalId, ...pickedFrom(got.goalId, gate) });
508
517
 
509
518
  // 3. The task's own working tree.
510
519
  const name = deps.worktreeName ? deps.worktreeName(candidate.id) : worktreeName(candidate.id);
@@ -566,6 +575,7 @@ async function iteration(opts, deps = {}) {
566
575
 
567
576
  const row = {
568
577
  event: 'worked', task_id: task.id, title: task.title, goal_id: picked.goalId,
578
+ ...pickedFrom(picked.goalId, gate),
569
579
  worktree: wtName,
570
580
  outcome: verdict.outcome, reason: verdict.reason,
571
581
  session_id: res.sessionId || null, duration_s: Math.round((Date.now() - started) / 1000),
@@ -1043,7 +1053,7 @@ async function forever(opts, deps = {}) {
1043
1053
  // task 1234" from "quiet because it is dead". A worker may hold the loop for
1044
1054
  // ninety minutes, and without this every honest hour of work looks like a
1045
1055
  // corpse.
1046
- await beat({ mode: state.mode, last_event: row.event, working_task_id: Number(row.task_id) || undefined });
1056
+ await beat({ mode: state.mode, last_event: row.event, working_task_id: Number(row.task_id) || undefined, working_goal_id: Number(row.goal_id) || undefined });
1047
1057
  const seen = cadence.classifyEvent(row);
1048
1058
  // A hold that names its reset SECOND waits until that second (task 1004454).
1049
1059
  // classifyEvent carries it as an absolute epoch; the cadence has no clock of
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // Send feedback about Bongos itself to the Cloud Bongos maintainers (task 1004462).
3
+ //
4
+ // Usage:
5
+ // bongos feedback "the claim command hung for two minutes"
6
+ // bongos feedback --idea "let me filter the task list by goal"
7
+ // bongos feedback --message-file notes.md
8
+ // [--json]
9
+ //
10
+ // Not the /feedback skill, which loads a locally recorded walkthrough into a
11
+ // session. This SENDS: it posts to the builder's OWN project (POST /feedback,
12
+ // src/bongos/routes/feedback.js), which relays it to the hub. A bug lands on the
13
+ // hub as a 'backlog' task in the maintenance goal awaiting a maintainer's nod; an
14
+ // idea lands in the hub's idea inbox. Only the typed text and version stamps
15
+ // travel — never code, a transcript or file contents.
16
+
17
+ 'use strict';
18
+
19
+ const os = require('node:os');
20
+ const { cliClient, requireSession, argText, hasFlag, errCode, errDetails, cliExit } = require('./cli-lib');
21
+
22
+ const USAGE = [
23
+ 'usage:',
24
+ ' bongos feedback "what went wrong" report a problem with Bongos',
25
+ ' bongos feedback --idea "what you wish for" suggest an improvement',
26
+ ' bongos feedback --message-file notes.md send a longer note from a file',
27
+ '',
28
+ 'Goes to the Cloud Bongos maintainers. Only the text you type and the Bongos',
29
+ 'versions you run are sent — no code, chat or files from your project.',
30
+ ].join('\n');
31
+
32
+ // The CLI release, as `bongos version` reports it: config/bongos-cli.json where
33
+ // the core checkout carries it (an instance does not — task 1824), else the
34
+ // package version. Read from disk, never required, so a vanilla core still runs.
35
+ function cliVersion() {
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+ for (const rel of ['../../config/bongos-cli.json', '../../package.json']) {
39
+ try {
40
+ const f = path.join(__dirname, rel);
41
+ if (fs.existsSync(f)) { const v = JSON.parse(fs.readFileSync(f, 'utf8')).version; if (v) return v; }
42
+ } catch { /* try the next */ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ // The message: --message / --message-file, else every positional word joined.
48
+ function readMessage(args) {
49
+ if (args.includes('--message') || args.includes('--message-file')) return argText('--message', args);
50
+ return args.filter((a) => !a.startsWith('--')).join(' ');
51
+ }
52
+
53
+ // Every report goes through the transcript-grade secret scrubber before it leaves
54
+ // this machine (task 1004467). An assistant may file unasked when a builder vents,
55
+ // so nobody reviews the text first — the scrubber is the check that does not
56
+ // depend on the assistant's judgment. FAIL CLOSED: no scrubber, nothing is sent.
57
+ // It also strips the builder's environment secrets by value, whatever their shape.
58
+ function scrubReport(message, env = process.env) {
59
+ let scrub;
60
+ try { scrub = require('../../modules/security/secret-scrub.js'); } catch { return null; }
61
+ const text = scrub.scrubSecrets(message, scrub.collectEnvSecrets(env));
62
+ const removed = text.split(scrub.REDACTED).length - message.split(scrub.REDACTED).length;
63
+ return { text, removed };
64
+ }
65
+
66
+ // Plain-language lines for each refusal the route can give.
67
+ const REFUSALS = {
68
+ message_required: 'Nothing to send — add your feedback after the command, in quotes.',
69
+ validation_failed: 'That could not be sent as written — keep it under 4,000 characters, or split it into two.',
70
+ not_hub_connected: 'This project is not connected to Cloud Bongos, so feedback cannot be relayed from here.\n'
71
+ + 'Report it to the Bongos maintainers on the Bongos core repository instead.',
72
+ membership_required: 'Cloud Bongos does not yet know you as a member of this project.\n'
73
+ + 'Sign in to this project once through Cloud Bongos in a browser, then try again.',
74
+ rate_limited: 'Too much feedback from you or this project in the last hour. Try again later.',
75
+ feedback_unavailable: 'Cloud Bongos is not taking bug reports right now (no version is being built). Try again later.',
76
+ hub_unreachable: 'Could not reach Cloud Bongos. Check your connection and try again.',
77
+ };
78
+
79
+ async function main(argv = process.argv.slice(2)) {
80
+ if (hasFlag('--help', argv) || hasFlag('-h', argv)) { console.log(USAGE); return 0; }
81
+ const json = hasFlag('--json', argv);
82
+ const kind = hasFlag('--idea', argv) ? 'idea' : 'bug';
83
+ const message = String(readMessage(argv) || '').trim();
84
+ if (!message) { console.error(USAGE); return 2; }
85
+
86
+ const scrubbed = scrubReport(message);
87
+ if (!scrubbed) {
88
+ console.error('Feedback was not sent: the secret filter that checks every report is missing from this install.');
89
+ return 1;
90
+ }
91
+
92
+ await requireSession();
93
+ const api = await cliClient();
94
+ const res = await api.request('POST', '/feedback', {
95
+ body: { message: scrubbed.text, kind, context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` } },
96
+ });
97
+ if (scrubbed.removed > 0 && !json) {
98
+ console.log(`Removed ${scrubbed.removed} secret-looking value(s) from the report before sending.`);
99
+ }
100
+
101
+ if (json) { console.log(JSON.stringify(res.data, null, 2)); return res.ok ? 0 : 1; }
102
+ if (!res.ok) {
103
+ const code = errCode(res.data);
104
+ const details = errDetails(res.data) || {};
105
+ let line = REFUSALS[code] || `Feedback was not sent (${code || `HTTP ${res.status}`}).`;
106
+ if (code === 'rate_limited' && details.retry_after_seconds) {
107
+ line += ` (in about ${Math.ceil(details.retry_after_seconds / 60)} min)`;
108
+ }
109
+ console.error(line);
110
+ return 1;
111
+ }
112
+ const d = res.data || {};
113
+ if (d.landed === 'task') {
114
+ console.log(`Thanks — sent to the Cloud Bongos maintainers as task ${d.task_id}.`);
115
+ console.log('It waits for a maintainer to approve it before anyone works on it.');
116
+ if (d.hub_origin) console.log(`${String(d.hub_origin).replace(/\/+$/, '')}/builders#/task/${d.task_id}`);
117
+ } else {
118
+ console.log(`Thanks — your idea is in the Cloud Bongos idea inbox (idea ${d.idea_id}) for the maintainers to review.`);
119
+ }
120
+ return 0;
121
+ }
122
+
123
+ if (require.main === module) {
124
+ // cliExit, never process.exit, after a request (task 1002734 — the Windows abort).
125
+ main().then((code) => cliExit(code), (err) => {
126
+ console.error(`Feedback was not sent: ${err && err.message ? err.message : err}`);
127
+ cliExit(1);
128
+ });
129
+ }
130
+
131
+ module.exports = { main, readMessage, REFUSALS, scrubReport };
@@ -228,6 +228,82 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
228
228
  };
229
229
  }
230
230
 
231
+ // ---- the per-project rule set on /deploy (task 1004468, ADR 0357) -----------
232
+ // The owner sets each project's update rule on /deploy, which writes
233
+ // provisioning_instances.update_channel (migration provisioning_029, task 1004445). That column
234
+ // is the rule; the roster's `channel` is only the fallback for an entry no project row matches,
235
+ // or for a sweep that cannot reach the database. Before this, the button followed the column and
236
+ // the sweep followed the roster, so a project set to `minor` on /deploy was still swept as `patch`.
237
+ //
238
+ // Matched by slug — the one stable key the table has (it records no directory). Read over psql,
239
+ // which is how upgrade.js recordLedger reaches a PGDATABASE (a bare name over the unix socket +
240
+ // peer auth, the self-host shape). recordLedger uses the pg driver for DATABASE_URL; this passes
241
+ // the URL to `psql -d` instead, because the sweep is synchronous and pg is not. With no psql the
242
+ // sweep falls back to the roster and says so.
243
+ //
244
+ // A failure's reason is a fixed class, never psql's own text: stderr can name a DB user or host,
245
+ // and the reason is printed into the sweep's log.
246
+ const CHANNEL_SOURCE_DEPLOY = 'deploy';
247
+ const CHANNEL_SOURCE_ROSTER = 'roster';
248
+ const DB_CHANNELS_SQL = 'SELECT slug, update_channel FROM provisioning_instances';
249
+ const DB_CHANNELS_TIMEOUT_MS = 15000;
250
+
251
+ // PURE. psql's stderr reduced to a class that is safe to log.
252
+ function psqlFailureClass(stderr) {
253
+ const s = String(stderr || '');
254
+ if (/relation .*provisioning_instances.* does not exist/i.test(s)) return 'no provisioning_instances table (provisioning module off?)';
255
+ if (/column .*update_channel.* does not exist/i.test(s)) return 'no update_channel column (migration provisioning_029 not applied)';
256
+ if (/authentication failed|role .* does not exist|permission denied/i.test(s)) return 'the sweep\'s database login was refused';
257
+ if (/could not connect|connection refused|no such file or directory|could not translate host/i.test(s)) return 'database unreachable';
258
+ if (/database .* does not exist/i.test(s)) return 'the named database does not exist';
259
+ return 'query failed';
260
+ }
261
+
262
+ // The line a sweep prints for one entry's channel. PURE.
263
+ function channelLine(inst) {
264
+ return ` • ${inst.slug}: channel ${inst.channel}, ${inst.channelNote}`;
265
+ }
266
+
267
+ // Read every project's update rule. Never throws: { ok:true, channels: Map<slug, channel> }, or
268
+ // { ok:false, reason } when there is no database to ask, psql is missing, or the table is absent
269
+ // (an instance with the provisioning module off). The caller falls back to the roster and says why.
270
+ function readDbChannels({ env = process.env, run = spawnSync } = {}) {
271
+ const target = env.DATABASE_URL || env.PGDATABASE;
272
+ if (!target) return { ok: false, reason: 'no DATABASE_URL or PGDATABASE set for the sweep' };
273
+ const r = run('psql', ['-d', target, '-At', '-F', '\t', '-v', 'ON_ERROR_STOP=1', '--no-psqlrc', '-c', DB_CHANNELS_SQL],
274
+ { encoding: 'utf8', timeout: DB_CHANNELS_TIMEOUT_MS });
275
+ if (!r || r.error) {
276
+ const code = r && r.error && r.error.code;
277
+ return { ok: false, reason: code === 'ENOENT' ? 'psql not found' : code === 'ETIMEDOUT' ? 'psql timed out' : 'psql could not be started' };
278
+ }
279
+ if (r.status !== 0) return { ok: false, reason: `psql exit ${r.status}: ${psqlFailureClass(r.stderr)}` };
280
+ const channels = new Map();
281
+ for (const line of String(r.stdout || '').split('\n')) {
282
+ const [slug, ch] = line.split('\t');
283
+ if (slug && ch) channels.set(slug.trim(), ch.trim());
284
+ }
285
+ return { ok: true, channels };
286
+ }
287
+
288
+ // PURE. Give each roster entry the channel it is actually under, and say where it came from:
289
+ // `channelSource` is 'deploy' (the project row) or 'roster', `channelNote` the one-line reason a
290
+ // sweep log prints. The roster's own value is kept as `rosterChannel`, so a disagreement stays
291
+ // visible in the log instead of silently vanishing.
292
+ function applyDbChannels(instances, db) {
293
+ return instances.map((inst) => {
294
+ if (db && db.ok && db.channels.has(inst.slug)) {
295
+ const ch = normalizeChannel(db.channels.get(inst.slug));
296
+ const differs = ch !== inst.channel;
297
+ return {
298
+ ...inst, channel: ch, channelSource: CHANNEL_SOURCE_DEPLOY, rosterChannel: inst.channel,
299
+ channelNote: `set on /deploy${differs ? ` (roster says ${inst.channel}, ignored)` : ''}`,
300
+ };
301
+ }
302
+ const why = db && db.ok ? 'no project row for this slug' : (db && db.reason) || 'database not read';
303
+ return { ...inst, channelSource: CHANNEL_SOURCE_ROSTER, rosterChannel: inst.channel, channelNote: `from the roster — ${why}` };
304
+ });
305
+ }
306
+
231
307
  // ---- reporting: make "nothing happened" audible -----------------------------
232
308
 
233
309
  // Turn a loadSubscriptions() result into the line a routine prints EVERY run, plus a level a caller
@@ -322,6 +398,12 @@ module.exports = {
322
398
  listAvailableVersions,
323
399
  parseNpmViewVersions,
324
400
  loadSubscriptions,
401
+ readDbChannels,
402
+ applyDbChannels,
403
+ channelLine,
404
+ psqlFailureClass,
405
+ CHANNEL_SOURCE_DEPLOY,
406
+ CHANNEL_SOURCE_ROSTER,
325
407
  ROSTER_OK,
326
408
  ROSTER_ABSENT,
327
409
  ROSTER_MALFORMED,
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/routes/feedback.js — POST /api/bongos/feedback (task 1004462).
4
+ //
5
+ // `bongos feedback "…"` lands here, on the builder's OWN project, with the session
6
+ // they already hold. This route stamps who sent it and which core this project
7
+ // runs, then relays it to the hub's POST /sso/feedback over the hub-federation
8
+ // client credential (ADR 0141) — the channel the activity rollup already uses. So
9
+ // a builder never signs in to cloudbongos.com to report something about Bongos.
10
+ //
11
+ // Only the text the builder typed and version stamps travel. No code, transcript,
12
+ // task or file content from this project is attached.
13
+ //
14
+ // In the CORE, not a module, for the reason core-update.js gives: the hub link is
15
+ // kernel (src/bongos/auth-config.js), and every hosted project must have this door
16
+ // whatever modules it runs. A project with no hub link (self-hosted, or the hub
17
+ // itself) answers 409 not_hub_connected — told, not silently dropped.
18
+
19
+ const express = require('express');
20
+ const { asyncHandler, validateOrRespond } = require('./_helpers');
21
+ const auth = require('../auth');
22
+ const { loadIdpConfig } = require('../auth-config');
23
+ const { userAgent } = require('../../branding');
24
+
25
+ const MESSAGE_MAX = 4000;
26
+ const HUB_TIMEOUT_MS = 15000;
27
+
28
+ function safeIdpConfig() {
29
+ try { return loadIdpConfig(); } catch { return null; }
30
+ }
31
+
32
+ function runningCore() {
33
+ try { return require('../../module-api').CORE_VERSION || null; } catch { return null; }
34
+ }
35
+
36
+ // Hub refusals the builder can act on are passed through by code; anything else
37
+ // is one generic failure, so the hub's internals never leak through this door.
38
+ const PASS_THROUGH = new Set(['membership_required', 'rate_limited', 'feedback_unavailable', 'validation_failed']);
39
+
40
+ async function relayToHub({ idp, builder, message, kind, context, fetchImpl = fetch }) {
41
+ const res = await fetchImpl(`${idp.origin.replace(/\/+$/, '')}/api/bongos/sso/feedback`, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
44
+ body: JSON.stringify({
45
+ client_id: idp.clientId,
46
+ client_secret: idp.clientSecret,
47
+ github_id: builder.github_id,
48
+ message,
49
+ kind,
50
+ context,
51
+ }),
52
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
53
+ });
54
+ let body = null;
55
+ try { body = await res.json(); } catch { /* non-JSON answer */ }
56
+ return { status: res.status, body };
57
+ }
58
+
59
+ function buildFeedbackRouter({ idpConfig = safeIdpConfig, fetchImpl = fetch } = {}) {
60
+ const router = express.Router();
61
+
62
+ // rank: any signed-in builder — reporting a problem with Bongos grants nothing
63
+ // here, and the hub files every report at 'backlog' behind a Metic+ nod.
64
+ router.post(
65
+ '/feedback',
66
+ auth.requireBuilder,
67
+ asyncHandler('POST /feedback', async (req, res) => {
68
+ if (validateOrRespond(req, res, {
69
+ message: { required: true, type: 'string', maxLength: MESSAGE_MAX },
70
+ kind: { type: 'string', enum: ['bug', 'idea'] },
71
+ context: {},
72
+ })) return;
73
+ const b = req.body || {};
74
+ const message = b.message.trim();
75
+ if (!message) return res.fail('message_required', 400);
76
+ const kind = b.kind || 'bug';
77
+
78
+ const idp = idpConfig();
79
+ if (!idp) return res.fail('not_hub_connected', 409);
80
+
81
+ const ctx = b.context && typeof b.context === 'object' ? b.context : {};
82
+ const context = {
83
+ core_version: runningCore(),
84
+ cli_version: typeof ctx.cli_version === 'string' ? ctx.cli_version.slice(0, 40) : null,
85
+ os: typeof ctx.os === 'string' ? ctx.os.slice(0, 40) : null,
86
+ };
87
+
88
+ let hub;
89
+ try {
90
+ hub = await relayToHub({ idp, builder: req.builder, message, kind, context, fetchImpl });
91
+ } catch {
92
+ return res.fail('hub_unreachable', 502);
93
+ }
94
+ if (hub.status === 201 && hub.body && hub.body.ok) {
95
+ const { ok: _ok, ...landing } = hub.body;
96
+ return res.status(201).json({ ok: true, hub_origin: idp.origin, ...landing });
97
+ }
98
+ const err = hub.body && hub.body.error && typeof hub.body.error === 'object' ? hub.body.error : null;
99
+ if (err && PASS_THROUGH.has(err.code)) {
100
+ const retry = err.details && err.details.retry_after_seconds;
101
+ return res.fail(err.code, hub.status, retry ? { retry_after_seconds: retry } : undefined);
102
+ }
103
+ return res.fail('hub_refused', 502, { hub_status: hub.status });
104
+ }, { errorCode: 'feedback_failed', message: false }),
105
+ );
106
+
107
+ return router;
108
+ }
109
+
110
+ module.exports = buildFeedbackRouter;
@@ -20,6 +20,7 @@ const express = require('express');
20
20
  const buildHealthzRouter = require('./routes/healthz');
21
21
  const buildInstanceRouter = require('./routes/instance');
22
22
  const buildCoreUpdateRouter = require('./routes/core-update');
23
+ const buildFeedbackRouter = require('./routes/feedback');
23
24
  const buildAuthRouter = require('./routes/auth');
24
25
  const buildGithubRouter = require('./routes/github');
25
26
  const buildMeRouter = require('./routes/me');
@@ -144,6 +145,7 @@ function buildGdsRouter() {
144
145
  router.use(buildHealthzRouter());
145
146
  router.use(buildInstanceRouter());
146
147
  router.use(buildCoreUpdateRouter());
148
+ router.use(buildFeedbackRouter());
147
149
  router.use(buildAuthRouter());
148
150
  router.use(buildGithubRouter());
149
151
  router.use(buildMeRouter());
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.37'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.38'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -260,3 +260,65 @@ test('no request means every allowlisted goal', () => {
260
260
  assert.deepEqual(allowedGoals(f, []).goals, [1, 2]);
261
261
  assert.deepEqual(allowedGoals(f).goals, [1, 2]);
262
262
  });
263
+
264
+ // ── the priority goal (task 1004453) ────────────────────────────────────────────
265
+ //
266
+ // Owner ruling: whoever starts the runner names the goal it works FIRST, as well as
267
+ // the set it may touch. The priority ORDERS the allowlist; it must never widen it.
268
+
269
+ test('the priority goal is read off the fence and moved to the front', () => {
270
+ const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 });
271
+ assert.equal(normaliseFence(f).priorityGoal, 3);
272
+ const d = decideRun({ fence: f, ...GRADER_OK });
273
+ assert.equal(d.go, true);
274
+ assert.deepEqual(d.goals, [3, 1, 2], 'the runner walks goals in this order, so the priority goes first');
275
+ assert.equal(d.priorityGoal, 3);
276
+ });
277
+
278
+ test('a priority that is not on the allowlist is ignored, never added', () => {
279
+ // The database refuses this with a foreign key; the pure half refuses it again,
280
+ // because a priority that could add a goal would be a second, unguarded allowlist.
281
+ const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: 99 });
282
+ assert.equal(normaliseFence(f).priorityGoal, null);
283
+ const d = decideRun({ fence: f, ...GRADER_OK });
284
+ assert.deepEqual(d.goals, [1, 2]);
285
+ assert.equal(d.priorityGoal, null);
286
+ });
287
+
288
+ test('a malformed or missing priority reads as "no priority", not as a refusal', () => {
289
+ for (const p of [undefined, null, '', 0, -4, 'abc', 1.5, {}]) {
290
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: p }), ...GRADER_OK });
291
+ assert.equal(d.go, true, `priority ${JSON.stringify(p)} must not stop the run`);
292
+ assert.deepEqual(d.goals, [1, 2], `priority ${JSON.stringify(p)} must not reorder`);
293
+ assert.equal(d.priorityGoal, null);
294
+ }
295
+ });
296
+
297
+ test('a stringified priority id (a bigint from the database) still counts', () => {
298
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: '2' }), ...GRADER_OK });
299
+ assert.deepEqual(d.goals, [2, 1]);
300
+ assert.equal(d.priorityGoal, 2);
301
+ });
302
+
303
+ test('narrowing on the command line keeps the priority first, and can drop it', () => {
304
+ const f = normaliseFence(open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }));
305
+ assert.deepEqual(allowedGoals(f, [1, 3]).goals, [3, 1]);
306
+ // An operator narrowing to goals that leave the priority out gets no priority —
307
+ // it is not quietly re-added behind their back.
308
+ assert.deepEqual(allowedGoals(f, [1, 2]).goals, [1, 2]);
309
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }), requestedGoals: [1, 2], ...GRADER_OK });
310
+ assert.equal(d.priorityGoal, null, 'a row must not claim a pick came from a priority this run excluded');
311
+ });
312
+
313
+ test('prioritise only reorders: same members, same count', () => {
314
+ assert.deepEqual(fence.prioritise([5, 6, 7], 6), [6, 5, 7]);
315
+ assert.deepEqual(fence.prioritise([5, 6, 7], 8), [5, 6, 7]);
316
+ assert.deepEqual(fence.prioritise([5, 6, 7], null), [5, 6, 7]);
317
+ });
318
+
319
+ test('a closed fence still refuses whatever the priority says', () => {
320
+ const d = decideRun({ fence: open({ enabled: false, priority_goal_id: 1000119 }), ...GRADER_OK });
321
+ assert.equal(d.go, false);
322
+ assert.equal(d.code, 'kill_switch');
323
+ assert.deepEqual(d.goals, []);
324
+ });
@@ -1303,3 +1303,79 @@ test('past the uptime floor, a drift still exits', async () => {
1303
1303
  });
1304
1304
  assert.equal(code, runner.UPGRADE_EXIT_CODE);
1305
1305
  });
1306
+
1307
+ // ── the priority goal reaches the picker, the rows and the heartbeat (task 1004453) ──
1308
+
1309
+ const twoGoalFence = (priority) => async () => ({
1310
+ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }, { goal_id: 1000090 }], priority_goal_id: priority },
1311
+ graderBypassed: false,
1312
+ });
1313
+
1314
+ test('the picker is handed the priority goal first', async () => {
1315
+ let sawGoals = null;
1316
+ const h = harness({
1317
+ readFence: twoGoalFence(1000090),
1318
+ pickTask: async (goals) => { sawGoals = goals; return { task: { ...aTask, goal_id: 1000090 }, goalId: 1000090, skipped: [] }; },
1319
+ });
1320
+ await runner.iteration({ ...opts, goals: [] }, h.deps);
1321
+ assert.deepEqual(sawGoals, [1000090, 1000119]);
1322
+ });
1323
+
1324
+ test('a worked row says its pick came from the priority goal', async () => {
1325
+ const h = harness({
1326
+ readFence: twoGoalFence(1000090),
1327
+ pickTask: async () => ({ task: { ...aTask, goal_id: 1000090 }, goalId: 1000090, skipped: [] }),
1328
+ });
1329
+ const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
1330
+ assert.equal(row.event, 'worked');
1331
+ assert.equal(row.goal_id, 1000090);
1332
+ assert.equal(row.priority, true);
1333
+ });
1334
+
1335
+ test('once the priority goal runs dry, a pick from the rest reads priority:false', async () => {
1336
+ const h = harness({
1337
+ readFence: twoGoalFence(1000090),
1338
+ pickTask: async () => ({ task: aTask, goalId: 1000119, skipped: [] }),
1339
+ });
1340
+ const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
1341
+ assert.equal(row.event, 'worked');
1342
+ assert.equal(row.goal_id, 1000119);
1343
+ assert.equal(row.priority, false);
1344
+ });
1345
+
1346
+ test('with no priority set, no pick claims to be one', async () => {
1347
+ const h = harness({ readFence: twoGoalFence(null) });
1348
+ const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
1349
+ assert.equal(row.priority, false);
1350
+ });
1351
+
1352
+ test('a dry run and an empty queue both name the priority', async () => {
1353
+ const dry = await runner.iteration({ ...opts, goals: [], dryRun: true }, harness({
1354
+ readFence: twoGoalFence(1000119),
1355
+ }).deps);
1356
+ assert.equal(dry.event, 'dry_run');
1357
+ assert.equal(dry.priority, true);
1358
+ const none = await runner.iteration({ ...opts, goals: [] }, harness({
1359
+ readFence: twoGoalFence(1000119),
1360
+ pickTask: async () => ({ none: true, skipped: [] }),
1361
+ }).deps);
1362
+ assert.equal(none.event, 'nothing_claimable');
1363
+ assert.equal(none.priority_goal, 1000119);
1364
+ });
1365
+
1366
+ test('the server heartbeat carries the goal the current task came from', async () => {
1367
+ const beats = [];
1368
+ await runner.forever({ goals: [], maxTasks: 1, maxLoops: 1 }, {
1369
+ iteration: async () => ({ event: 'worked', task_id: 4242, goal_id: 1000090, priority: true }),
1370
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
1371
+ waitOrJump: async () => ({ waited: 0, jumped: false }),
1372
+ writeHeartbeat: () => true,
1373
+ postHeartbeat: async (fields) => { beats.push(fields); return true; },
1374
+ emit: () => {},
1375
+ record: (r) => r,
1376
+ codeDrifted: async () => false,
1377
+ });
1378
+ const b = beats.find((x) => x.working_task_id === 4242);
1379
+ assert.ok(b, 'the beat after a worked row must name its task');
1380
+ assert.equal(b.working_goal_id, 1000090);
1381
+ });