@bongos/core 1.20.37 → 1.20.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.bongos-core.json +119 -64
  2. package/.claude/skills/backlog-review/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +66 -0
  4. package/.claude/skills/collab-review/SKILL.md +1 -1
  5. package/.claude/skills/priority-session/SKILL.md +1 -1
  6. package/.claude/skills/session-handoff/SKILL.md +1 -1
  7. package/.claude/skills/status/SKILL.md +1 -1
  8. package/bin/bongos.js +1 -0
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +8 -0
  11. package/clients/bongos-client/index.cjs +8 -0
  12. package/clients/bongos-client/index.d.ts +12 -1
  13. package/clients/bongos-client/index.mjs +8 -0
  14. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  15. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
  16. package/docs/adr/README.md +1 -0
  17. package/docs/api/openapi.json +188 -2
  18. package/docs/api-reference.md +12 -4
  19. package/docs/copy-inventory.md +10 -10
  20. package/docs/copy-registry.json +18 -18
  21. package/docs/file-map.md +1 -0
  22. package/docs/module-api-changelog.md +4 -0
  23. package/docs/page-readings.json +253 -250
  24. package/docs/recipes/upgrading-the-core.md +2 -0
  25. package/modules/autonomy/db.js +39 -8
  26. package/modules/autonomy/fence.js +26 -4
  27. package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
  28. package/modules/autonomy/routes/autonomy.js +28 -1
  29. package/modules/hall-ui/public/gate.js +33 -4
  30. package/modules/ideas/routing.js +8 -1
  31. package/modules/npm-release/preview/childlog.js +95 -0
  32. package/modules/npm-release/preview/commands.js +46 -5
  33. package/modules/npm-release/preview/env.js +18 -7
  34. package/modules/npm-release/preview/runtime.js +43 -9
  35. package/modules/npm-release/preview/supervisor.js +170 -31
  36. package/modules/npm-release/public/work.css +3 -0
  37. package/modules/npm-release/public/work.js +6 -1
  38. package/modules/npm-release/routes/preview.js +1 -0
  39. package/modules/platform-identity/instance-feedback.js +142 -0
  40. package/modules/platform-identity/module.json +1 -1
  41. package/modules/platform-identity/routes/sso.js +62 -0
  42. package/modules/provisioning/render-standup.js +13 -1
  43. package/modules/public-landing/public/projects.html +22 -8
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +30 -0
  47. package/scripts/gds/autobongos-run.js +14 -4
  48. package/scripts/gds/feedback-send.js +131 -0
  49. package/scripts/gds/provision-render.js +13 -1
  50. package/scripts/gds/render-payload.js +6 -5
  51. package/scripts/gds/update-channel.js +82 -0
  52. package/src/bongos/routes/feedback.js +110 -0
  53. package/src/bongos/routes.js +2 -0
  54. package/src/module-api.js +1 -1
  55. package/tests/autobongos_fence.mjs +62 -0
  56. package/tests/autobongos_loop.mjs +76 -0
  57. package/tests/autonomy_fence_priority.mjs +211 -0
  58. package/tests/bongos_cli.mjs +7 -1
  59. package/tests/filing_abuse_matrix.mjs +7 -2
  60. package/tests/idea_routing.mjs +1 -1
  61. package/tests/instance_feedback.mjs +327 -0
  62. package/tests/npm_release_preview_commands.mjs +1 -1
  63. package/tests/npm_release_preview_diagnosis.mjs +376 -0
  64. package/tests/npm_release_preview_supervisor.mjs +4 -4
  65. package/tests/projects_hub_app_step.mjs +49 -0
  66. package/tests/projects_hub_render_connect.mjs +2 -1
  67. package/tests/provision_render.mjs +23 -2
  68. package/tests/update_channel_db.mjs +124 -0
@@ -31,6 +31,18 @@ const MODES = ['adopt', 'greenfield'];
31
31
  // How the owner's app is built (task 1004350): a native runtime with their commands, or
32
32
  // `docker`, which builds the repository's own Dockerfile.
33
33
  const RUNTIMES = ['node', 'python', 'ruby', 'go', 'rust', 'elixir', 'docker'];
34
+ // Render refuses a native web service with no build command (measured live 2026-09-30,
35
+ // task 1004464), so every native runtime has one: Render's own default for it. The runner
36
+ // sends it when the owner gave none, and the wizard prefills it (projects.html keeps a
37
+ // copy, pinned equal by tests/projects_hub_app_step.mjs).
38
+ const BUILD_DEFAULTS = Object.freeze({
39
+ node: 'npm install',
40
+ python: 'pip install -r requirements.txt',
41
+ ruby: 'bundle install',
42
+ go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
43
+ rust: 'cargo build --release',
44
+ elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile',
45
+ });
34
46
 
35
47
  // Render API keys start `rnd_`; workspaces are `tea-…` (a team) or `usr-…` (a personal
36
48
  // one); services `srv-…`. Shape checks only — whether the key works, and whether it can
@@ -134,4 +146,4 @@ async function attachToProvision(db, instanceId, { keyEnc, appHost }) {
134
146
  return rows[0] || null;
135
147
  }
136
148
 
137
- module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, badRequest, appHostFrom, enqueueRenderStandup };
149
+ module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, BUILD_DEFAULTS, badRequest, appHostFrom, enqueueRenderStandup };
@@ -1433,7 +1433,7 @@ summary{min-height:24px;padding:3px 0;}
1433
1433
  <option value="docker">Docker — use my repository’s Dockerfile</option>
1434
1434
  </select>
1435
1435
  </label>
1436
- <label class="wizField" id="renderBuildWrap"><span>Build command <small>— leave empty if there is none</small></span>
1436
+ <label class="wizField" id="renderBuildWrap"><span>Build command <small>— Render needs one; the usual one is filled in</small></span>
1437
1437
  <input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm ci">
1438
1438
  </label>
1439
1439
  <label class="wizField" id="renderStartWrap"><span>Start command</span>
@@ -1808,6 +1808,18 @@ summary{min-height:24px;padding:3px 0;}
1808
1808
  function planPrice(p) { return p[2] ? 'about $' + p[2] + '/month' : RENDER_FREE_NOTE; }
1809
1809
  var RENDER_DB_PLANS = [['basic_256mb', 'Basic 256 MB', 6], ['basic_1gb', 'Basic 1 GB', 19], ['basic_4gb', 'Basic 4 GB', 75]];
1810
1810
  var RENDER_RUNTIMES = { node: 'Node.js', python: 'Python', ruby: 'Ruby', go: 'Go', rust: 'Rust', elixir: 'Elixir', docker: 'Docker' };
1811
+ /* Render refuses a native app with no build command (task 1004464), so the box starts
1812
+ with Render's own default for the runtime — a real value the owner can change, and
1813
+ one that follows the runtime until they do. A copy of render-standup.js's
1814
+ BUILD_DEFAULTS (this page cannot require it), pinned equal by a test. */
1815
+ var RENDER_BUILD_DEFAULTS = {
1816
+ node: 'npm install',
1817
+ python: 'pip install -r requirements.txt',
1818
+ ruby: 'bundle install',
1819
+ go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
1820
+ rust: 'cargo build --release',
1821
+ elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile'
1822
+ };
1811
1823
  var RENDER_KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
1812
1824
  function planLabel(list, v) {
1813
1825
  var p = list.filter(function (x) { return x[0] === v; })[0] || list[0];
@@ -1894,6 +1906,8 @@ summary{min-height:24px;padding:3px 0;}
1894
1906
  q('f-renderRuntime').value = st.renderRuntime || 'node';
1895
1907
  var docker = q('f-renderRuntime').value === 'docker';
1896
1908
  q('renderBuildWrap').hidden = docker; q('renderStartWrap').hidden = docker;
1909
+ // the runtime's usual build command until the owner types their own (task 1004464)
1910
+ if (!st.renderBuildTouched) st.renderBuild = RENDER_BUILD_DEFAULTS[q('f-renderRuntime').value] || '';
1897
1911
  q('f-renderBuild').value = st.renderBuild || '';
1898
1912
  q('f-renderStart').value = st.renderStart || '';
1899
1913
  fillPlans(q('f-renderPlan'), RENDER_WEB_PLANS, st.renderPlan || 'starter');
@@ -1975,13 +1989,12 @@ summary{min-height:24px;padding:3px 0;}
1975
1989
  hooks.save(); syncNew();
1976
1990
  });
1977
1991
  q('renderNew').addEventListener('input', function (ev) {
1978
- if (ev.target.id === q('f-renderBuild').id) st.renderBuild = ev.target.value;
1979
- if (ev.target.id === q('f-renderStart').id) {
1980
- st.renderStart = ev.target.value;
1981
- // the "say how your app starts" ask is answered the moment there is a command —
1982
- // it used to stay on screen until the next press of Next
1983
- if (String(st.renderStart).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
1984
- }
1992
+ var typed = null;
1993
+ if (ev.target.id === q('f-renderBuild').id) { st.renderBuild = ev.target.value; st.renderBuildTouched = true; typed = st.renderBuild; }
1994
+ if (ev.target.id === q('f-renderStart').id) { st.renderStart = ev.target.value; typed = st.renderStart; }
1995
+ // a "say how your app starts / is built" ask is answered the moment that box has a
1996
+ // command — it used to stay on screen until the next press of Next
1997
+ if (typed != null && String(typed).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
1985
1998
  hooks.save();
1986
1999
  });
1987
2000
  function validate() {
@@ -1993,6 +2006,7 @@ summary{min-height:24px;padding:3px 0;}
1993
2006
  if (st.renderMode === 'adopt') {
1994
2007
  if (!services.some(function (s) { return s.id === st.renderServiceId && repoOf(s.repo) === repo().toLowerCase(); })) return bad('Choose the app to link, or create a new one.');
1995
2008
  } else if (st.renderMode === 'greenfield') {
2009
+ if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderBuild || '').trim()) return bad('Say how your app is built — Render needs a build command, for example “npm install”.');
1996
2010
  if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderStart || '').trim()) return bad('Say how your app starts — the command Render should run, for example “npm start”.');
1997
2011
  } else return bad('Choose whether to create a new app or link one you already run.');
1998
2012
  hint('h-render', '');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.37",
3
+ "version": "1.20.39",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.37",
9
+ "version": "1.20.39",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.37",
3
+ "version": "1.20.39",
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,35 @@
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
+ }
8336
+ ],
8337
+ "1.20.39": [
8338
+ {
8339
+ "id": "1004465",
8340
+ "text": "A version preview that cannot read its data now fails with the child's own output shown, and stopping it really removes its copy."
8341
+ },
8342
+ {
8343
+ "id": "1004464",
8344
+ "text": "Setting up an app on Render from the project wizard no longer fails when the owner leaves the build command empty. The box now comes filled in with Render's usual build command for the chosen language, which the owner can chan"
8345
+ }
8316
8346
  ]
8317
8347
  }
@@ -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 };
@@ -117,6 +117,18 @@ async function greenfield(api, inst, host, deps) {
117
117
  return { ok: true };
118
118
  }
119
119
 
120
+ // The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
121
+ // from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
122
+ // so an answer from Render is re-said as a sentence with Render's own reason kept whole
123
+ // (task 1004464). Anything else is already the leg's own words.
124
+ function plainReason(e) {
125
+ const msg = e && e.message ? e.message : String(e);
126
+ const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
127
+ if (said) return `Render would not set up the app: ${said}`;
128
+ const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
129
+ return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
130
+ }
131
+
120
132
  async function renderStandupInstance(inst, deps, intent) {
121
133
  const { db, log } = deps;
122
134
  const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
@@ -137,7 +149,7 @@ async function renderStandupInstance(inst, deps, intent) {
137
149
 
138
150
  return host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps);
139
151
  } catch (e) {
140
- return refuse(e && e.message ? e.message : String(e));
152
+ return refuse(plainReason(e));
141
153
  } finally {
142
154
  if (intent && intent.id != null) await clearBorrowedKey(db, intent.id);
143
155
  }
@@ -21,7 +21,7 @@
21
21
  'use strict';
22
22
 
23
23
  // The vocabulary lives with the web tier's validation, so the two can never disagree.
24
- const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES } = require('../../modules/provisioning/render-standup.js');
24
+ const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES, BUILD_DEFAULTS } = require('../../modules/provisioning/render-standup.js');
25
25
  const DEFAULTS = Object.freeze({ webPlan: 'starter', dbPlan: 'basic_256mb', region: 'virginia' });
26
26
 
27
27
  // A Render resource name from the project's slug. Render allows letters, digits and
@@ -47,14 +47,15 @@ function plansFor(appHost) {
47
47
  }
48
48
 
49
49
  // How the app is built and started, as the owner said. `docker` builds the repository's
50
- // own Dockerfile and needs no commands; every other runtime needs a start command, and a
51
- // build command if the app has a build step.
50
+ // own Dockerfile and needs no commands; every other runtime needs a start command AND a
51
+ // build command — Render refuses a native service without one (task 1004464), so a row
52
+ // saved with none (including every row from before that fix) gets the runtime's default.
52
53
  function buildFor(appHost) {
53
54
  const h = appHost || {};
54
55
  if (!RUNTIMES.includes(h.runtime)) throw new Error(`unknown Render runtime '${h.runtime || 'none'}'`);
55
56
  if (h.runtime === 'docker') return { runtime: 'docker' };
56
57
  if (!h.startCommand) throw new Error('the app needs a start command');
57
- return { runtime: h.runtime, buildCommand: h.buildCommand || null, startCommand: h.startCommand };
58
+ return { runtime: h.runtime, buildCommand: h.buildCommand || BUILD_DEFAULTS[h.runtime], startCommand: h.startCommand };
58
59
  }
59
60
 
60
61
  // POST /postgres. `plan` is never 'free' — see the header.
@@ -68,7 +69,7 @@ function renderPostgresPayload(inst, { ownerId, dbPlan, region }) {
68
69
  function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region, build, dbUrl }) {
69
70
  const envSpecificDetails = build.runtime === 'docker'
70
71
  ? {}
71
- : { ...(build.buildCommand ? { buildCommand: build.buildCommand } : {}), startCommand: build.startCommand };
72
+ : { buildCommand: build.buildCommand, startCommand: build.startCommand };
72
73
  return {
73
74
  type: 'web_service',
74
75
  name: resourceName(inst),
@@ -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.39'; // 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');