@bongos/core 1.19.1081 → 1.20.1

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 (251) hide show
  1. package/.bongos-core.json +526 -191
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +32 -0
  5. package/clients/bongos-client/index.cjs +32 -0
  6. package/clients/bongos-client/index.d.ts +51 -6
  7. package/clients/bongos-client/index.mjs +32 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  14. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  15. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +851 -19
  18. package/docs/api-reference.md +33 -9
  19. package/docs/architecture.md +6 -2
  20. package/docs/copy-inventory.md +620 -553
  21. package/docs/copy-registry.json +1467 -820
  22. package/docs/file-map.md +2 -0
  23. package/docs/module-api-changelog.md +6 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1674 -1566
  27. package/migrations/core_260_goals_working_area.sql +63 -0
  28. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  29. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  30. package/modules/agents/lib/validate.js +43 -0
  31. package/modules/autonomy/gauge.js +38 -2
  32. package/modules/grading/grader-subagent.js +37 -2
  33. package/modules/grading/grader-workers/reader.js +65 -5
  34. package/modules/hall-ui/public/approval-queue.css +33 -7
  35. package/modules/hall-ui/public/approval-queue.js +9 -2
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/brand-holes.js +121 -0
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/collab.js +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +6 -1
  44. package/modules/hall-ui/public/deploy.js +22 -8
  45. package/modules/hall-ui/public/diagrams.html +1 -1
  46. package/modules/hall-ui/public/dom-utils.js +20 -0
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/idea.js +28 -0
  54. package/modules/hall-ui/public/ideas.html +1 -1
  55. package/modules/hall-ui/public/ideas.js +19 -3
  56. package/modules/hall-ui/public/index.html +5 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/primer.html +1 -1
  59. package/modules/hall-ui/public/profile-nudge.js +21 -4
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/profile.js +1 -1
  62. package/modules/hall-ui/public/project-settings.html +1 -1
  63. package/modules/hall-ui/public/ranks.html +1 -1
  64. package/modules/hall-ui/public/roadmap.html +1 -1
  65. package/modules/hall-ui/public/roster.html +1 -1
  66. package/modules/hall-ui/public/sessions.html +1 -1
  67. package/modules/hall-ui/public/settings.html +19 -30
  68. package/modules/hall-ui/public/settings.js +24 -177
  69. package/modules/hall-ui/public/settings.states.json +1 -1
  70. package/modules/hall-ui/public/shell.js +4 -2
  71. package/modules/hall-ui/public/studio.css +14 -8
  72. package/modules/hall-ui/public/studio.html +5 -4
  73. package/modules/hall-ui/public/task.html +1 -1
  74. package/modules/hall-ui/public/thinking.css +6 -3
  75. package/modules/hall-ui/public/thinking.html +1 -1
  76. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  77. package/modules/hall-ui/public/tweak-editor.css +142 -11
  78. package/modules/hall-ui/public/tweak-editor.html +25 -9
  79. package/modules/hall-ui/public/tweak-editor.js +243 -10
  80. package/modules/hall-ui/public/watch.html +1 -1
  81. package/modules/hall-ui/public/work.html +1 -1
  82. package/modules/hall-ui/records/approval-queue.md +6 -0
  83. package/modules/hall-ui/records/tweak-editor.md +6 -0
  84. package/modules/ideas/ratify.js +36 -8
  85. package/modules/ideas/routes/ratify-goal.js +6 -0
  86. package/modules/lifecycle/db-analytics.js +79 -10
  87. package/modules/lifecycle/db-claims.js +48 -1
  88. package/modules/lifecycle/db-deps-criteria.js +94 -40
  89. package/modules/lifecycle/db-goals.js +4 -1
  90. package/modules/lifecycle/db-grade.js +9 -1
  91. package/modules/lifecycle/db-relevance-flags.js +183 -0
  92. package/modules/lifecycle/db-versions.js +4 -3
  93. package/modules/lifecycle/db.js +19 -0
  94. package/modules/lifecycle/done-when.js +5 -3
  95. package/modules/lifecycle/est-advisory.js +31 -4
  96. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  97. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  98. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  99. package/modules/lifecycle/module.json +2 -1
  100. package/modules/lifecycle/routes/claims.js +8 -1
  101. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  102. package/modules/lifecycle/routes/goals.js +1 -1
  103. package/modules/lifecycle/routes/tasks.js +27 -24
  104. package/modules/lifecycle/routes/visuals.js +9 -0
  105. package/modules/lifecycle/ship-preflight.js +7 -11
  106. package/modules/lifecycle/task-visuals.js +109 -5
  107. package/modules/npm-release/module.json +2 -1
  108. package/modules/npm-release/preview/commands.js +81 -0
  109. package/modules/npm-release/preview/divert.js +95 -0
  110. package/modules/npm-release/preview/env.js +78 -0
  111. package/modules/npm-release/preview/proxy.js +107 -0
  112. package/modules/npm-release/preview/runtime.js +83 -0
  113. package/modules/npm-release/preview/supervisor.js +175 -0
  114. package/modules/npm-release/public/work.js +65 -1
  115. package/modules/npm-release/routes/preview.js +133 -0
  116. package/modules/provisioning/app-status.js +83 -0
  117. package/modules/provisioning/module.json +2 -1
  118. package/modules/provisioning/pollers/app-liveness.js +110 -0
  119. package/modules/provisioning/render-lookup.js +68 -0
  120. package/modules/provisioning/render-standup.js +44 -1
  121. package/modules/provisioning/routes/render-standup.js +81 -6
  122. package/modules/provisioning/starter-bundles.js +4 -1
  123. package/modules/public-landing/public/account-private.states.json +14 -0
  124. package/modules/public-landing/public/account.html +513 -0
  125. package/modules/public-landing/public/account.probes.json +37 -0
  126. package/modules/public-landing/public/account.states.json +16 -0
  127. package/modules/public-landing/public/index.html +5 -2
  128. package/modules/public-landing/public/projects.html +661 -18
  129. package/modules/public-landing/public/projects.states.json +8 -1
  130. package/modules/render-deploy/deploys.js +203 -0
  131. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  132. package/modules/render-deploy/module.json +25 -0
  133. package/modules/render-deploy/public/deploy.css +10 -0
  134. package/modules/render-deploy/public/deploy.js +249 -0
  135. package/modules/render-deploy/render.js +56 -0
  136. package/modules/render-deploy/routes/act.js +88 -0
  137. package/modules/render-deploy/routes/app.js +65 -0
  138. package/modules/render-deploy/routes/history.js +38 -0
  139. package/modules/specialities/db.js +27 -8
  140. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  141. package/modules/specialities/routes/specialities.js +51 -3
  142. package/modules/specialities/skills.js +94 -0
  143. package/modules/specialities/specialities.js +56 -9
  144. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  145. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  146. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  147. package/modules/ui-design/kit/lib.js +3 -1
  148. package/modules/ui-design/kit/serve.js +96 -0
  149. package/package-lock.json +2 -2
  150. package/package.json +1 -1
  151. package/release-notes.json +163 -0
  152. package/scripts/gds/agents-sync.js +13 -3
  153. package/scripts/gds/autobongos-run.js +80 -2
  154. package/scripts/gds/autobongos-service.cmd +12 -0
  155. package/scripts/gds/copy-apply.js +14 -0
  156. package/scripts/gds/dev-box-guard.js +3 -2
  157. package/scripts/gds/fitness.js +9 -0
  158. package/scripts/gds/grade-correlation-audit.js +42 -12
  159. package/scripts/gds/grade-replay.js +1 -1
  160. package/scripts/gds/provision-core-upgrade.js +38 -7
  161. package/scripts/gds/provision-pin-key.js +176 -0
  162. package/scripts/gds/provision-render.js +62 -31
  163. package/scripts/gds/provision-teardown.js +7 -2
  164. package/scripts/gds/provision-units.js +31 -0
  165. package/scripts/gds/provision.js +10 -10
  166. package/scripts/gds/publish-manifest.js +1 -0
  167. package/scripts/gds/render-api.js +19 -0
  168. package/scripts/gds/render-payload.js +39 -65
  169. package/scripts/gds/run-unit-tests.js +10 -0
  170. package/scripts/gds/ship-finish.js +19 -20
  171. package/scripts/gds/smoke-dependencies.sh +37 -8
  172. package/scripts/gds/status.js +12 -4
  173. package/scripts/gds/upgrade.js +2 -2
  174. package/scripts/public-mirror-export.js +7 -1
  175. package/src/bongos/module-scope-map.js +13 -0
  176. package/src/bongos/serve-internal.js +3 -1
  177. package/src/module-api.js +10 -1
  178. package/src/platform-server.js +39 -0
  179. package/tests/account_privacy_flags.mjs +10 -6
  180. package/tests/agents_authoring.mjs +15 -1
  181. package/tests/agents_sync.mjs +106 -5
  182. package/tests/agents_validate.mjs +45 -0
  183. package/tests/api_path_404.mjs +6 -0
  184. package/tests/autobongos_cadence.mjs +7 -0
  185. package/tests/autobongos_loop.mjs +136 -1
  186. package/tests/autonomy_gauge.mjs +62 -0
  187. package/tests/blocker_hall_live_proof.mjs +275 -0
  188. package/tests/claim_gate_ci_unblock.mjs +116 -0
  189. package/tests/claim_gate_rebase.mjs +15 -3
  190. package/tests/collab_page.mjs +10 -0
  191. package/tests/conductor_main_e2e.mjs +97 -0
  192. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  193. package/tests/core_upgrade_runner.mjs +1 -0
  194. package/tests/dependency_writes_atomic.mjs +191 -0
  195. package/tests/deploy_page_projects.mjs +2 -1
  196. package/tests/effective_visibility_predicate.mjs +15 -0
  197. package/tests/est_advisory.mjs +27 -0
  198. package/tests/goal_map_page.mjs +26 -0
  199. package/tests/goal_task_relevance.mjs +518 -0
  200. package/tests/goal_working_area.mjs +146 -0
  201. package/tests/grade_attempts.mjs +61 -0
  202. package/tests/grade_attribution.mjs +23 -0
  203. package/tests/grade_correlation_audit.mjs +54 -1
  204. package/tests/grader_reader_lens.mjs +55 -2
  205. package/tests/grader_root_outage.mjs +6 -1
  206. package/tests/grader_subagent_tools_arg.mjs +102 -0
  207. package/tests/hall_approval_queue.mjs +54 -2
  208. package/tests/hall_settings_world.mjs +3 -1
  209. package/tests/hall_tweak_editor.mjs +316 -11
  210. package/tests/hub_account_page.mjs +394 -0
  211. package/tests/idea_detail_page.mjs +64 -0
  212. package/tests/idea_goal_ratification.mjs +64 -0
  213. package/tests/idea_spark_hall.mjs +60 -9
  214. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  215. package/tests/landing_page.mjs +6 -3
  216. package/tests/module-scope-map.mjs +31 -8
  217. package/tests/module_api.mjs +1 -0
  218. package/tests/module_loader.mjs +1 -1
  219. package/tests/nav_permission_atoms.mjs +1 -1
  220. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  221. package/tests/npm_release_preview_commands.mjs +89 -0
  222. package/tests/npm_release_preview_divert.mjs +164 -0
  223. package/tests/npm_release_preview_env.mjs +100 -0
  224. package/tests/npm_release_preview_proxy.mjs +162 -0
  225. package/tests/npm_release_preview_routes.mjs +208 -0
  226. package/tests/npm_release_preview_supervisor.mjs +203 -0
  227. package/tests/pin_write_key.mjs +383 -0
  228. package/tests/planning_session_skill.mjs +27 -0
  229. package/tests/platform_boot.mjs +71 -0
  230. package/tests/profile_nudge_links.mjs +86 -0
  231. package/tests/profile_ui_cross_project.mjs +1 -1
  232. package/tests/projects_hub.mjs +2 -2
  233. package/tests/projects_hub_app_status.mjs +249 -0
  234. package/tests/projects_hub_app_step.mjs +282 -52
  235. package/tests/projects_hub_render_connect.mjs +295 -0
  236. package/tests/provision_render.mjs +121 -28
  237. package/tests/provision_settings_apply.mjs +62 -0
  238. package/tests/provisioning_app_status.mjs +204 -0
  239. package/tests/provisioning_render_route.mjs +126 -6
  240. package/tests/render_check.mjs +2 -1
  241. package/tests/render_deploy.mjs +495 -0
  242. package/tests/ship_preflight.mjs +61 -8
  243. package/tests/smoke_dependencies_witness.mjs +46 -0
  244. package/tests/speciality_skills.mjs +214 -0
  245. package/tests/studio_room_height.mjs +70 -0
  246. package/tests/task_detail_includes.mjs +10 -0
  247. package/tests/task_visuals_instance_root.mjs +150 -0
  248. package/tests/tweak_batch_apply.mjs +21 -0
  249. package/tests/ui_design_kit.mjs +36 -4
  250. package/tests/upgrade.mjs +1 -1
  251. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/preview/supervisor.js — starts, watches and stops the ONE version
4
+ // preview this hall may run (task 1004300, ADR 0349).
5
+ //
6
+ // A preview is a second Bongos process, running an installed copy of an older core against
7
+ // a COPY of the live database, reached through the live hall's own address (divert.js). This
8
+ // file owns its lifecycle and nothing else: install → copy the database → migrate → launch →
9
+ // wait until it answers. Every outward step goes through an injected function (run for a
10
+ // command, spawn for the long-lived child, probe for the health check, sleep, now), which is
11
+ // what lets the whole lifecycle be tested without a process, a database or a network.
12
+ //
13
+ // ONE AT A TIME, STOPPED WHEN IDLE. A second preview is refused, not queued; a running one
14
+ // that nothing has touched for IDLE_MS is stopped and its copy of the database dropped. That
15
+ // bound is what keeps a preview from becoming a permanent second tenant of the droplet.
16
+
17
+ const os = require('node:os');
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const { buildPreviewEnv } = require('./env');
21
+ const cmds = require('./commands');
22
+
23
+ const IDLE_MS = 20 * 60 * 1000;
24
+ const DEFAULT_PORT = 3190;
25
+ const HEALTH_TRIES = 40;
26
+ const HEALTH_INTERVAL_MS = 1500;
27
+
28
+ // How far behind the running version a preview may reach, counted in PUBLISHED versions.
29
+ const WINDOW = 30;
30
+
31
+ function compare(a, b) {
32
+ const x = a.split('.').map(Number);
33
+ const y = b.split('.').map(Number);
34
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
35
+ return 0;
36
+ }
37
+
38
+ /**
39
+ * Is this version one a preview may open? Published versions only, and no older than
40
+ * `window` published versions before the one this hall runs. Newer than the running
41
+ * version is fine — it is how a release is tried before it is adopted.
42
+ *
43
+ * @returns {{ok:true}|{ok:false, reason:string, oldest?:string}} oldest names the earliest version still allowed
44
+ */
45
+ function previewable(version, { published, running, window = WINDOW } = {}) {
46
+ if (!cmds.VERSION_RE.test(String(version))) return { ok: false, reason: 'not_a_version' };
47
+ const list = Object.keys(published || {}).filter((v) => cmds.VERSION_RE.test(v)).sort(compare);
48
+ if (!list.includes(version)) return { ok: false, reason: 'not_published' };
49
+ const at = running && list.includes(running) ? list.indexOf(running) : list.length - 1;
50
+ const floor = Math.max(0, at - window);
51
+ if (list.indexOf(version) < floor) return { ok: false, reason: 'too_old', oldest: list[floor] };
52
+ return { ok: true };
53
+ }
54
+
55
+ /**
56
+ * @param {object} o
57
+ * @param {Function} o.run (command) => Promise of { code, stderr } — runs one command to the end
58
+ * @param {Function} o.spawn node:child_process spawn (the long-lived child)
59
+ * @param {Function} o.probe (port) => Promise of boolean — does the preview answer its health check
60
+ * @param {string} o.liveDb the live database name (the source of the copy)
61
+ * @param {string} o.cacheRoot where versions are installed
62
+ * @param {object} [o.env] the live environment (read by buildPreviewEnv only)
63
+ */
64
+ function createSupervisor({
65
+ run, spawn, probe, liveDb, cacheRoot,
66
+ env = process.env, port = DEFAULT_PORT,
67
+ now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
68
+ fsImpl = fs, tmpdir = os.tmpdir(), idleMs = IDLE_MS,
69
+ log = { info() {}, warn() {}, error() {} },
70
+ } = {}) {
71
+ let state = { state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null };
72
+ let child = null;
73
+ let workDir = null;
74
+
75
+ const set = (patch) => { state = { ...state, ...patch }; };
76
+ const status = () => ({ ...state, port, idle_ms: idleMs });
77
+
78
+ async function step(stage, command) {
79
+ set({ stage });
80
+ const r = await run(command);
81
+ if (!r || r.code !== 0) {
82
+ const tail = r && r.stderr ? `: ${String(r.stderr).trim().slice(-300)}` : '';
83
+ throw new Error(`${stage} failed (exit ${r ? r.code : '?'})${tail}`);
84
+ }
85
+ }
86
+
87
+ // Everything a start or a stop leaves behind: the child, the copy, the scratch directory.
88
+ async function cleanup() {
89
+ if (child) { try { child.kill(); } catch (e) { log.warn(`preview child would not stop: ${e && e.message}`); } child = null; }
90
+ try { await run(cmds.dropDbCommand()); } catch (e) { log.warn(`preview copy could not be dropped: ${e && e.message}`); }
91
+ if (workDir) { try { fsImpl.rmSync(workDir, { recursive: true, force: true }); } catch { /* best effort */ } workDir = null; }
92
+ }
93
+
94
+ async function launch(version) {
95
+ try {
96
+ workDir = fsImpl.mkdtempSync(path.join(tmpdir, 'bongos-preview-'));
97
+ const home = path.join(workDir, 'home');
98
+ fsImpl.mkdirSync(home, { recursive: true });
99
+ const dumpFile = path.join(workDir, 'live.dump');
100
+ const childEnv = buildPreviewEnv({ env, port, scratchHome: home });
101
+
102
+ await step('install', { ...cmds.installCommand({ cacheRoot, version }), env: childEnv });
103
+ await step('drop-old-copy', cmds.dropDbCommand());
104
+ await step('create-copy', cmds.createDbCommand());
105
+ await step('dump', cmds.dumpCommand({ liveDb, dumpFile }));
106
+ await step('restore', cmds.restoreCommand({ dumpFile }));
107
+ await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv }));
108
+
109
+ set({ stage: 'launch' });
110
+ child = spawn(process.execPath, [cmds.entryPath(cacheRoot, version)], {
111
+ cwd: cmds.versionDir(cacheRoot, version), env: childEnv, stdio: 'ignore',
112
+ });
113
+ child.on('exit', (code) => {
114
+ // A child that dies on its own must not leave a copy behind or a page waiting on it.
115
+ if (child && state.state === 'running') {
116
+ log.warn(`preview of ${version} exited (${code})`);
117
+ child = null;
118
+ set({ state: 'failed', stage: 'exited', error: `the preview process exited (${code})` });
119
+ cleanup();
120
+ }
121
+ });
122
+
123
+ set({ stage: 'health' });
124
+ let up = false;
125
+ for (let i = 0; i < HEALTH_TRIES && !up; i++) {
126
+ up = await probe(port);
127
+ if (!up) await sleep(HEALTH_INTERVAL_MS);
128
+ }
129
+ if (!up) throw new Error('the preview did not answer in time');
130
+ set({ state: 'running', stage: null, startedAt: now(), lastTouch: now() });
131
+ } catch (e) {
132
+ log.error(`preview of ${version} failed: ${e && e.message}`);
133
+ set({ state: 'failed', error: e && e.message ? e.message : String(e) });
134
+ await cleanup();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Begin previewing a version. Returns straight away with ok, status and done: the long
140
+ * steps run behind it (done settles when they finish, for tests), and callers follow
141
+ * along with status().
142
+ */
143
+ function start(version) {
144
+ if (!cmds.VERSION_RE.test(String(version))) return { ok: false, code: 'not_a_version', status: status() };
145
+ if (state.state === 'starting' || state.state === 'running') {
146
+ if (state.version === version) return { ok: true, already: true, status: status(), done: Promise.resolve() };
147
+ return { ok: false, code: 'preview_busy', status: status() };
148
+ }
149
+ set({ state: 'starting', version, stage: 'queued', error: null, startedAt: null, lastTouch: now() });
150
+ return { ok: true, status: status(), done: launch(version) };
151
+ }
152
+
153
+ /** Stop the preview and drop its copy. Safe to call at any point. */
154
+ async function stop() {
155
+ await cleanup();
156
+ set({ state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null });
157
+ return status();
158
+ }
159
+
160
+ /** Note that someone used the preview: every forwarded request calls this. */
161
+ function touch() { if (state.state === 'running') set({ lastTouch: now() }); }
162
+
163
+ /** Stop a running preview nobody has touched for the idle window. */
164
+ async function reapIdle() {
165
+ if (state.state !== 'running') return false;
166
+ if (now() - state.lastTouch < idleMs) return false;
167
+ log.info(`preview of ${state.version} idle, stopping`);
168
+ await stop();
169
+ return true;
170
+ }
171
+
172
+ return { start, stop, status, touch, reapIdle };
173
+ }
174
+
175
+ module.exports = { createSupervisor, previewable, IDLE_MS, DEFAULT_PORT, WINDOW };
@@ -166,11 +166,69 @@
166
166
  <span class="ov-group__name ov-key">${escapeHtml(v.version)}</span>
167
167
  <span class="ov-group__n">${v.entries ? v.entries.length : '?'}</span>
168
168
  <span class="ov-group__note">${escapeHtml(note)}</span>
169
+ <button type="button" class="ov-toggle" data-preview="${escapeHtml(v.version)}"${preview && preview.state !== 'idle' && preview.state !== 'failed' ? ' disabled' : ''}>View</button>
169
170
  </div>
170
171
  ${body}
171
172
  </div>`;
172
173
  }
173
174
 
175
+ // ---- previewing a version (task 1004300, ADR 0349) -----------------------------------
176
+ // The server owns the state (idle, starting, running, failed); this only shows it and
177
+ // asks for changes. One preview at a time, so one panel above the version list.
178
+ let preview = null;
179
+ let previewErr = '';
180
+ let pollTimer = null;
181
+ const PREVIEW_STEP = {
182
+ queued: 'getting ready', install: 'installing that version', 'drop-old-copy': 'clearing the old copy',
183
+ 'create-copy': 'making a copy of the data', dump: 'copying the live data', restore: 'loading the copy',
184
+ migrate: 'running its migrations on the copy', launch: 'starting it', health: 'waiting for it to answer',
185
+ };
186
+
187
+ function previewHtml() {
188
+ if (previewErr) return `<div class="ov-note ov-note--warn"><p>${escapeHtml(previewErr)}</p></div>`;
189
+ if (!preview || preview.state === 'idle') return '';
190
+ const v = escapeHtml(preview.version || '');
191
+ if (preview.state === 'starting') {
192
+ return `<div class="ov-note"><p><strong>Preparing a preview of ${v}</strong> — ${escapeHtml(PREVIEW_STEP[preview.stage] || 'working')}. This can take a few minutes.</p></div>`;
193
+ }
194
+ if (preview.state === 'failed') {
195
+ return `<div class="ov-note ov-note--warn"><p><strong>The preview of ${v} did not start.</strong> ${escapeHtml(preview.error || '')}</p><button type="button" class="ov-toggle" data-preview-stop>Clear</button></div>`;
196
+ }
197
+ return `<div class="ov-note"><p><strong>Preview of ${v} is running</strong> on a copy of this hall's data. Nothing done there reaches the live hall. It stops by itself after ${Math.round((preview.idle_ms || 0) / 60000)} minutes unused.</p>
198
+ <a class="ov-toggle" href="${API}/npm-release/preview/enter?v=${encodeURIComponent(preview.version)}">Open the preview</a>
199
+ <button type="button" class="ov-toggle" data-preview-stop>Stop it</button></div>`;
200
+ }
201
+
202
+ async function previewCall(method, body) {
203
+ const res = await client.request(method, `${API}/npm-release/preview`, body ? { body } : {});
204
+ if (!res.ok) {
205
+ const err = (res.data && res.data.error) || {};
206
+ throw new Error(err.message || `preview → ${res.status}`);
207
+ }
208
+ return res.data;
209
+ }
210
+
211
+ function followPreview() {
212
+ if (pollTimer) return;
213
+ pollTimer = setInterval(async () => {
214
+ try {
215
+ preview = (await previewCall('GET')).preview;
216
+ } catch (e) { previewErr = e.message || String(e); }
217
+ if (!preview || preview.state !== 'starting') { clearInterval(pollTimer); pollTimer = null; }
218
+ paint();
219
+ }, 3000);
220
+ }
221
+
222
+ async function previewAct(fn) {
223
+ previewErr = '';
224
+ try {
225
+ const d = await fn();
226
+ preview = d.preview;
227
+ if (preview && preview.state === 'starting') followPreview();
228
+ } catch (e) { previewErr = e.message || String(e); }
229
+ paint();
230
+ }
231
+
174
232
  let data = null;
175
233
  let history = [];
176
234
  let hasMore = false;
@@ -193,6 +251,7 @@
193
251
  if (vers) {
194
252
  vers.innerHTML = `
195
253
  <h2 class="scroll__h" id="nr-versions-h">Versions</h2>
254
+ ${previewHtml()}
196
255
  <p class="scroll__lede">What each version carried, newest first — the task each merge named, in the words its ship wrote.</p>
197
256
  ${history.map(versionHtml).join('')}
198
257
  ${hasMore ? `<button type="button" class="ov-toggle" id="nr-older"${loadingMore ? ' disabled' : ''}>${loadingMore ? 'Loading…' : 'Show older versions'}</button>` : ''}`;
@@ -231,6 +290,8 @@
231
290
  history = d.history.versions;
232
291
  hasMore = d.history.more;
233
292
  paint();
293
+ // A quiet first read: a preview already running (or half started) shows at once.
294
+ previewCall('GET').then((r) => { preview = r.preview; if (preview.state === 'starting') followPreview(); paint(); }).catch(() => {});
234
295
  } catch (e) {
235
296
  work.innerHTML = `<h2 class="scroll__h">Where the work is</h2>
236
297
  <div class="ov-note ov-note--warn"><p><strong>Could not read where the work is.</strong> ${escapeHtml(e.message || String(e))}</p></div>`;
@@ -259,7 +320,10 @@
259
320
  if (!(t instanceof Element)) return;
260
321
  const ex = t.closest('[data-expand]');
261
322
  if (ex) { expanded.add(ex.getAttribute('data-expand')); paint(); return; }
262
- if (t.closest('#nr-older')) loadOlder();
323
+ if (t.closest('#nr-older')) { loadOlder(); return; }
324
+ const pv = t.closest('[data-preview]');
325
+ if (pv) { previewAct(() => previewCall('POST', { version: pv.getAttribute('data-preview') })); return; }
326
+ if (t.closest('[data-preview-stop]')) previewAct(() => previewCall('DELETE'));
263
327
  });
264
328
 
265
329
  load();
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/routes/preview.js — start, watch, enter, leave and stop a version
4
+ // preview from the deploy page (task 1004300, ADR 0349).
5
+ //
6
+ // GET /npm-release/preview what is running, and how far a start has got
7
+ // POST /npm-release/preview start one { version }
8
+ // DELETE /npm-release/preview stop it and drop its copy of the database
9
+ // GET /npm-release/preview/enter?v= set the steering cookie, go to the hall inside it
10
+ // GET /npm-release/preview/exit clear the cookie, come back to the deploy page
11
+ //
12
+ // EVERY ROUTE IS GATED ON core.pin.move, the permission /deploy itself needs (an atom, not
13
+ // a rank — tests/government_require_permission.mjs). Starting a preview runs a
14
+ // published package and copies the live database, so it is no wider than moving the pin.
15
+ //
16
+ // This file also registers this module's provider for the kernel's request.divert seam:
17
+ // the hook that lets the platform hand a request to divert.js before anything else answers.
18
+ // An instance that does not have this module on never registers it, and the kernel's seam
19
+ // call then falls straight through.
20
+
21
+ const express = require('express');
22
+ const api = require('../../../src/module-api');
23
+ const { createDivert, clearCookie } = require('../preview/divert');
24
+ const { createProxy, COOKIE_NAME } = require('../preview/proxy');
25
+ const { previewable } = require('../preview/supervisor');
26
+ const { VERSION_RE, PACKAGE } = require('../preview/commands');
27
+
28
+ const log = api.logger('npm-release');
29
+
30
+ // Published versions of the core, from the registry the deploy page already reads.
31
+ async function publishedVersions() {
32
+ const { registry } = await api.readPackageRegistry({ pkg: PACKAGE, haveNotesFor: () => true });
33
+ return registry.ok ? registry.published : null;
34
+ }
35
+
36
+ // The real supervisor is built on first use, so a boot that never previews anything starts
37
+ // no timer and touches no process. Until then it reads as idle, which is what it is.
38
+ function lazySupervisor(make) {
39
+ let real = null;
40
+ const get = () => { if (!real) real = make(); return real; };
41
+ return {
42
+ status: () => (real ? real.status() : { state: 'idle', version: null, stage: null, error: null, port: null }),
43
+ touch: () => { if (real) real.touch(); },
44
+ start: (v) => get().start(v),
45
+ stop: async () => (real ? real.stop() : undefined),
46
+ };
47
+ }
48
+
49
+ module.exports = function buildNpmReleasePreviewRouter({
50
+ supervisor = null,
51
+ listPublished = publishedVersions,
52
+ runningVersion = () => api.CORE_VERSION,
53
+ registerDivert = true,
54
+ } = {}) {
55
+ const router = express.Router();
56
+ const sup = supervisor || lazySupervisor(() => require('../preview/runtime').createRuntime({
57
+ liveDb: process.env.PGDATABASE || api.instanceDbName(),
58
+ log,
59
+ }));
60
+ const mayPreview = api.requirePermission('core.pin.move');
61
+
62
+ if (registerDivert && !api.hasProvider('request.divert')) {
63
+ const forward = createProxy({
64
+ port: () => sup.status().port,
65
+ banner: () => ({ viewing: sup.status().version || '?', running: runningVersion() }),
66
+ exitHref: '/api/bongos/npm-release/preview/exit',
67
+ });
68
+ api.registerProvider('request.divert', createDivert({
69
+ supervisor: sup,
70
+ forward,
71
+ requireBuilder: api.requireBuilder,
72
+ requirePermission: api.requirePermission,
73
+ }));
74
+ }
75
+
76
+ const failure = (res, status, code, message) => res.status(status).json({ error: { code, message } });
77
+ const view = () => ({ preview: sup.status(), running: runningVersion() });
78
+
79
+ router.get('/npm-release/preview', api.requireBuilder, mayPreview, (req, res) => res.json(view()));
80
+
81
+ router.post('/npm-release/preview', api.requireBuilder, mayPreview, async (req, res) => {
82
+ if (api.validateOrRespond(req, res, { version: { required: true, type: 'string', maxLength: 32 } })) return;
83
+ const version = req.body.version;
84
+ if (!VERSION_RE.test(version)) return failure(res, 400, 'not_a_version', 'That is not a version number.');
85
+ try {
86
+ const published = await listPublished();
87
+ if (!published) return failure(res, 502, 'registry_unreadable', 'The list of published versions could not be read, so nothing was started.');
88
+ const verdict = previewable(version, { published, running: runningVersion() });
89
+ if (!verdict.ok) {
90
+ const why = verdict.reason === 'too_old'
91
+ ? `That version is older than previews reach back. The oldest you can preview now is ${verdict.oldest}; pick that one or a newer one.`
92
+ : 'That version has not been published. Pick one from the list below.';
93
+ return failure(res, 400, verdict.reason, why);
94
+ }
95
+ const r = sup.start(version);
96
+ if (!r.ok) return failure(res, 409, r.code, 'A preview of another version is already running. Stop it first.');
97
+ log.info(`preview of ${version} requested by builder ${req.builder && req.builder.id}`);
98
+ return res.status(202).json(view());
99
+ } catch (e) {
100
+ log.error(`preview start failed: ${e && e.message}`);
101
+ return failure(res, 500, 'preview_start_failed', 'Could not start the preview.');
102
+ }
103
+ });
104
+
105
+ router.delete('/npm-release/preview', api.requireBuilder, mayPreview, async (req, res) => {
106
+ try {
107
+ await sup.stop();
108
+ res.json(view());
109
+ } catch (e) {
110
+ log.error(`preview stop failed: ${e && e.message}`);
111
+ failure(res, 500, 'preview_stop_failed', 'Could not stop the preview.');
112
+ }
113
+ });
114
+
115
+ router.get('/npm-release/preview/enter', api.requireBuilder, mayPreview, (req, res) => {
116
+ const v = typeof req.query.v === 'string' ? req.query.v : '';
117
+ const st = sup.status();
118
+ if (!VERSION_RE.test(v) || st.state !== 'running' || st.version !== v) {
119
+ return failure(res, 409, 'preview_not_running', 'That preview is not running.');
120
+ }
121
+ const secure = req.secure ? '; Secure' : '';
122
+ res.append('Set-Cookie', `${COOKIE_NAME}=${v}; Path=/; HttpOnly; SameSite=Lax${secure}`);
123
+ sup.touch();
124
+ return res.redirect(302, '/');
125
+ });
126
+
127
+ router.get('/npm-release/preview/exit', api.requireBuilder, mayPreview, (req, res) => {
128
+ res.append('Set-Cookie', clearCookie());
129
+ res.redirect(302, '/deploy');
130
+ });
131
+
132
+ return router;
133
+ };
@@ -0,0 +1,83 @@
1
+ // modules/provisioning/app-status.js — what the owner is told about their app's setup on
2
+ // Render (task 1004353; ADR 0327, ADR 0345, ADR 0348).
3
+ //
4
+ // The setup runs in the runner and can take minutes, often after the wizard has closed,
5
+ // and the key is gone once it has run. So the owner needs one honest answer, from the row
6
+ // alone: where the setup is, the app's address once it answers, the way to Render, and —
7
+ // on a failure — the sentence that says what went wrong, with a retry that is theirs to
8
+ // start (a retry needs the key again; the platform never retries by itself).
9
+ //
10
+ // Nothing here calls Render or holds a key. The address check that moves `deploying` to
11
+ // `live` is the poller beside this file (pollers/app-liveness.js): a plain GET of the
12
+ // app's own public address, the same credential-free pull ADR 0252 made for halls.
13
+ 'use strict';
14
+
15
+ // The one dashboard the owner is sent to. Service ids are checked before they are put in
16
+ // a link, so a stored value can never shape one.
17
+ const DASHBOARD = 'https://dashboard.render.com';
18
+ const SERVICE_ID_RE = /^srv-[a-z0-9]{8,64}$/;
19
+ // Render gives a web service a https://<name>.onrender.com address. Only that shape is
20
+ // shown as the app's address or probed by the poller — a stored value can never point
21
+ // either at something else.
22
+ const RENDER_URL_RE = /^https:\/\/[a-z0-9-]{1,63}\.onrender\.com\/?$/;
23
+
24
+ // The stages the owner sees, in order. `failed` is not a stage: it is the setup stopping.
25
+ const STAGES = ['creating', 'building', 'live'];
26
+ const STAGE_OF = { requested: 'creating', creating: 'creating', deploying: 'building', linked: 'building', live: 'live' };
27
+
28
+ function serviceLinks(serviceId) {
29
+ if (!SERVICE_ID_RE.test(String(serviceId || ''))) return null;
30
+ const base = `${DASHBOARD}/web/${serviceId}`;
31
+ return { service: base, env: `${base}/env` };
32
+ }
33
+
34
+ function appUrl(url) { return RENDER_URL_RE.test(String(url || '')) ? String(url).replace(/\/+$/, '') : null; }
35
+
36
+ // The owner's view of app_host: the stage, the address, the links, the failure sentence,
37
+ // and the answers a retry re-sends (never the key — there is none to send).
38
+ function appStatus(appHost, { keyWaiting = false } = {}) {
39
+ const h = appHost || null;
40
+ if (!h || h.platform !== 'render') return null;
41
+ if (h.state === 'disconnected') return { platform: 'render', state: 'disconnected', stage: null };
42
+ const failed = h.state === 'failed';
43
+ return {
44
+ platform: 'render',
45
+ state: h.state || 'requested',
46
+ stage: failed ? null : (STAGE_OF[h.state] || 'creating'),
47
+ // an intent that still holds the lent key has not run yet, so the owner waits rather
48
+ // than retries
49
+ waiting: !!keyWaiting,
50
+ mode: h.mode === 'adopt' ? 'adopt' : 'greenfield',
51
+ workspace: h.ownerId || null,
52
+ url: appUrl(h.serviceUrl),
53
+ links: serviceLinks(h.serviceId),
54
+ error: failed ? oneSentence(h.error) : null,
55
+ retry: failed ? retryAnswers(h) : null,
56
+ };
57
+ }
58
+
59
+ // A runner's reason, as one sentence the owner can read: its first line, capped, ending
60
+ // in a full stop. The reasons are the leg's own words (provision-render.js), already
61
+ // written for the owner.
62
+ function oneSentence(err) {
63
+ const s = String(err || 'The setup stopped without saying why.').split('\n')[0].trim().slice(0, 300);
64
+ const first = s.charAt(0).toUpperCase() + s.slice(1);
65
+ return /[.!?]$/.test(first) ? first : first + '.';
66
+ }
67
+
68
+ // What the owner already answered, so a retry asks only for the key again.
69
+ function retryAnswers(h) {
70
+ const r = { owner_id: h.ownerId || null, mode: h.mode === 'adopt' ? 'adopt' : 'greenfield' };
71
+ if (r.mode === 'adopt') { r.service_id = h.serviceId || null; return r; }
72
+ r.runtime = h.runtime || null;
73
+ if (h.runtime !== 'docker') {
74
+ if (h.startCommand) r.start_command = h.startCommand;
75
+ if (h.buildCommand) r.build_command = h.buildCommand;
76
+ }
77
+ if (h.webPlan) r.web_plan = h.webPlan;
78
+ r.database = h.database === true;
79
+ if (r.database && h.dbPlan) r.db_plan = h.dbPlan;
80
+ return r;
81
+ }
82
+
83
+ module.exports = { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers, STAGES, RENDER_URL_RE, SERVICE_ID_RE };
@@ -24,7 +24,8 @@
24
24
  "migrations": true,
25
25
  "pollers": [
26
26
  "liveness-sweep",
27
- "catalog-backfill"
27
+ "catalog-backfill",
28
+ "app-liveness"
28
29
  ]
29
30
  },
30
31
  "provides": [
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/pollers/app-liveness.js — when an app the platform set up on its
4
+ // owner's Render account starts answering, say so (task 1004353). The loader's
5
+ // startModulePollers requires this file and calls start(deps) at boot, for the ENABLED
6
+ // module only — the liveness-sweep shape.
7
+ //
8
+ // WHY. The runner creates the service and hands the rest to Render, which builds and
9
+ // deploys on its own; the key is gone by then (ADR 0327 §2), so the platform cannot ask
10
+ // Render how the build went. What it CAN do is what ADR 0252 does for halls: knock on the
11
+ // app's own public address. While Render is still building, that address answers 502
12
+ // with `x-render-routing: no-deploy` (measured by the pilot, task 1004142); once any
13
+ // deploy is live, it answers whatever the app answers.
14
+ //
15
+ // SO:
16
+ // - only rows whose app is `deploying` (a new app) or `linked` (an existing one) are
17
+ // knocked on, and only at a https://<name>.onrender.com address — a stored value can
18
+ // never aim the probe anywhere else (app-status.js RENDER_URL_RE), and redirects are
19
+ // never followed;
20
+ // - any answer below 500 that is not Render's no-deploy page is `live`. A 404 at the
21
+ // root is still an app answering;
22
+ // - the write is compare-and-set on the state it read, so it can never overwrite what
23
+ // the runner wrote since (a retry that failed, a disconnect);
24
+ // - a GET with no credential of any kind. Nothing here calls Render's API.
25
+ //
26
+ // Best-effort: a failed knock is simply "not yet", and the next tick asks again.
27
+
28
+ const api = require('../../../src/module-api');
29
+ const { RENDER_URL_RE } = require('../app-status');
30
+
31
+ const logger = api.logger('provisioning');
32
+
33
+ const INTERVAL_MS = Number(process.env.PROVISIONING_APP_LIVENESS_MS) || 60_000;
34
+ const FIRST_DELAY_MS = 20_000;
35
+ const PROBE_TIMEOUT_MS = 10_000;
36
+ const SWEEP_LIMIT = 50;
37
+ const CONCURRENCY = 5;
38
+
39
+ let timer = null;
40
+ let started = false;
41
+
42
+ // One knock. Returns true when the app answers as deployed.
43
+ async function appAnswers(url, { fetchImpl, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
44
+ if (!RENDER_URL_RE.test(String(url || ''))) return false;
45
+ try {
46
+ const res = await (fetchImpl || globalThis.fetch)(url, {
47
+ method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs),
48
+ headers: { accept: 'text/html, application/json;q=0.9, */*;q=0.8' },
49
+ });
50
+ const routing = res.headers && typeof res.headers.get === 'function' ? res.headers.get('x-render-routing') : null;
51
+ if (routing && /no-deploy/i.test(routing)) return false;
52
+ return res.status < 500;
53
+ } catch (_) {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ async function sweepOnce(deps = {}) {
59
+ const db = deps.pool || api.pool;
60
+ const log = deps.log || ((m) => logger.info(m));
61
+ const { rows } = await db.query(
62
+ `SELECT id, slug, app_host FROM provisioning_instances
63
+ WHERE app_host->>'platform' = 'render' AND app_host->>'state' IN ('deploying', 'linked')
64
+ AND status <> 'torn_down'
65
+ ORDER BY id LIMIT ${SWEEP_LIMIT}`);
66
+ let live = 0;
67
+ // a few at a time, so one app timing out never holds the others up for its full timeout
68
+ const answers = [];
69
+ for (let i = 0; i < rows.length; i += CONCURRENCY) {
70
+ const batch = rows.slice(i, i + CONCURRENCY);
71
+ answers.push(...(await Promise.all(batch.map((r) => appAnswers((r.app_host || {}).serviceUrl, { fetchImpl: deps.fetchImpl, timeoutMs: deps.timeoutMs })))));
72
+ }
73
+ for (const [i, r] of rows.entries()) {
74
+ const h = r.app_host || {};
75
+ if (!answers[i]) continue;
76
+ const { rowCount } = await db.query(
77
+ `UPDATE provisioning_instances
78
+ SET app_host = app_host || jsonb_build_object('state', 'live', 'liveAt', now()::text, 'error', NULL)
79
+ WHERE id = $1 AND app_host->>'state' = $2`,
80
+ [r.id, h.state]);
81
+ if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its app answers at ${h.serviceUrl} — live`); }
82
+ }
83
+ return { checked: rows.length, live };
84
+ }
85
+
86
+ function start(deps = {}) {
87
+ if (process.env.PROVISIONING_APP_LIVENESS_DISABLED === '1') {
88
+ logger.info('[app-liveness] disabled (PROVISIONING_APP_LIVENESS_DISABLED=1)');
89
+ return;
90
+ }
91
+ if (started) return;
92
+ started = true;
93
+ arm(deps, FIRST_DELAY_MS);
94
+ }
95
+
96
+ function arm(deps, delay) {
97
+ timer = setTimeout(() => {
98
+ sweepOnce(deps)
99
+ .catch((e) => logger.error(`[app-liveness] sweep failed: ${e && e.message ? e.message : e}`))
100
+ .then(() => { if (started) arm(deps, INTERVAL_MS); });
101
+ }, delay);
102
+ if (timer.unref) timer.unref();
103
+ }
104
+
105
+ function stop() {
106
+ started = false;
107
+ if (timer) { clearTimeout(timer); timer = null; }
108
+ }
109
+
110
+ module.exports = { start, stop, sweepOnce, appAnswers, INTERVAL_MS };