@bongos/core 1.19.1073 → 1.19.1074

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 (297) hide show
  1. package/.bongos-core.json +320 -465
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -60
  5. package/clients/bongos-client/index.cjs +4 -60
  6. package/clients/bongos-client/index.d.ts +5 -90
  7. package/clients/bongos-client/index.mjs +4 -60
  8. package/config/branding.neutral.json +1 -5
  9. package/config/modules.neutral.json +2 -3
  10. package/config/scheduled-routines.json +0 -2
  11. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  12. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  13. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  14. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  15. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
  16. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  17. package/docs/adr/README.md +1 -0
  18. package/docs/api/openapi.json +171 -1661
  19. package/docs/api-reference.md +9 -41
  20. package/docs/architecture.md +16 -1
  21. package/docs/branding-contract.md +0 -3
  22. package/docs/copy-inventory.md +454 -479
  23. package/docs/copy-registry.json +746 -978
  24. package/docs/file-map.md +5 -34
  25. package/docs/module-api-changelog.md +2 -0
  26. package/docs/modules-contract.md +13 -1
  27. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  28. package/docs/onboarding/diagrams/README.md +3 -3
  29. package/docs/onboarding/diagrams/assertions.json +2 -22
  30. package/docs/onboarding/primer.md +9 -28
  31. package/docs/page-inventory.json +4 -1
  32. package/docs/page-readings.json +1032 -1077
  33. package/docs/recipes/render-pilot.md +29 -10
  34. package/migrations/core_257_module_store_registry.sql +87 -0
  35. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  36. package/modules/agents/module.json +1 -0
  37. package/modules/autonomy/module.json +1 -0
  38. package/modules/builder-settings/module.json +1 -0
  39. package/modules/builder-settings/render-prefs.js +4 -5
  40. package/modules/copy-desk/docx.js +78 -5
  41. package/modules/copy-desk/module.json +1 -0
  42. package/modules/copy-desk/routes/copy-desk.js +9 -9
  43. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  44. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  45. package/modules/discord/module.json +1 -0
  46. package/modules/economy/module.json +1 -0
  47. package/modules/economy/routes/credits.js +1 -2
  48. package/modules/government/catalog.js +13 -17
  49. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  50. package/modules/government/module.json +1 -0
  51. package/modules/government/protected-surfaces.json +3 -11
  52. package/modules/government/resolver.js +3 -46
  53. package/modules/government/routes/government.js +0 -2
  54. package/modules/government/session-scopes.js +43 -83
  55. package/modules/government/session-scopes.json +8 -18
  56. package/modules/grading/module.json +1 -0
  57. package/modules/hall-ui/module.json +1 -0
  58. package/modules/hall-ui/public/board-lib.js +2 -2
  59. package/modules/hall-ui/public/builders.js +29 -40
  60. package/modules/hall-ui/public/collab.html +1 -1
  61. package/modules/hall-ui/public/collab.js +1 -1
  62. package/modules/hall-ui/public/diagrams.html +3 -3
  63. package/modules/hall-ui/public/dom-utils.js +1 -1
  64. package/modules/hall-ui/public/gate.js +2 -2
  65. package/modules/hall-ui/public/gate.states.json +1 -1
  66. package/modules/hall-ui/public/hall-render.js +43 -79
  67. package/modules/hall-ui/public/index.html +5 -1
  68. package/modules/hall-ui/public/modules.js +1 -0
  69. package/modules/hall-ui/public/oversight.css +5 -5
  70. package/modules/hall-ui/public/palette.js +0 -11
  71. package/modules/hall-ui/public/primer.js +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings-sessions.js +1 -1
  74. package/modules/hall-ui/public/settings.css +0 -1
  75. package/modules/hall-ui/public/settings.html +4 -33
  76. package/modules/hall-ui/public/settings.js +3 -33
  77. package/modules/hall-ui/public/settings.states.json +2 -2
  78. package/modules/hall-ui/public/studio.html +1 -1
  79. package/modules/hall-ui/public/style.css +10 -13
  80. package/modules/hall-ui/public/task.html +4 -1
  81. package/modules/hall-ui/public/task.js +36 -1
  82. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  83. package/modules/hall-ui/public/tweak-editor.css +16 -0
  84. package/modules/hall-ui/public/tweak-editor.html +26 -3
  85. package/modules/hall-ui/public/tweak-editor.js +32 -1
  86. package/modules/hall-ui/public/work.js +1 -1
  87. package/modules/hall-ui/records/tweak-editor.md +1 -0
  88. package/modules/ideas/module.json +1 -0
  89. package/modules/lifecycle/db-claim-reads.js +1 -58
  90. package/modules/lifecycle/db-ship.js +0 -5
  91. package/modules/lifecycle/db.js +0 -4
  92. package/modules/lifecycle/kickoff-checklist.js +0 -4
  93. package/modules/lifecycle/lifecycle.js +0 -8
  94. package/modules/lifecycle/module.json +1 -0
  95. package/modules/lifecycle/routes/tasks.js +5 -7
  96. package/modules/memory/module.json +1 -0
  97. package/modules/npm-release/module.json +1 -0
  98. package/modules/onboarding/module.json +1 -0
  99. package/modules/onboarding/onboarding-state.js +17 -36
  100. package/modules/onboarding/routes/access-requests.js +3 -3
  101. package/modules/platform-identity/module.json +1 -0
  102. package/modules/platform-identity/platform-identity.js +13 -5
  103. package/modules/platform-identity/routes/sso.js +2 -2
  104. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  105. package/modules/provisioning/module.json +1 -0
  106. package/modules/provisioning/provisioning.js +2 -2
  107. package/modules/provisioning/starter-bundles.js +5 -25
  108. package/modules/public-landing/module.json +1 -0
  109. package/modules/public-landing/public/assets/cosmos.css +1 -1
  110. package/modules/public-landing/public/contact.html +1 -1
  111. package/modules/public-landing/public/privacy.html +1 -1
  112. package/modules/public-landing/public/projects.html +101 -32
  113. package/modules/public-landing/public/projects.probes.json +2 -2
  114. package/modules/public-landing/public/projects.states.json +4 -3
  115. package/modules/public-landing/public/terms.html +1 -1
  116. package/modules/security/module.json +1 -0
  117. package/modules/sessions/module.json +1 -0
  118. package/modules/specialities/module.json +1 -0
  119. package/modules/status-ui/module.json +1 -0
  120. package/modules/ui-design/module.json +1 -0
  121. package/package-lock.json +2 -2
  122. package/package.json +1 -1
  123. package/release-notes.json +78 -0
  124. package/scripts/gds/artifact-format.js +203 -0
  125. package/scripts/gds/audit-authorship.sh +1 -13
  126. package/scripts/gds/claim.js +0 -15
  127. package/scripts/gds/cli-lib.js +8 -8
  128. package/scripts/gds/diagram-facts.js +12 -23
  129. package/scripts/gds/do-api.js +13 -80
  130. package/scripts/gds/doc-cli-guard.js +2 -0
  131. package/scripts/gds/doctor.js +1 -1
  132. package/scripts/gds/feedback-latest.js +139 -9
  133. package/scripts/gds/fitness-checks-identity.js +2 -7
  134. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  135. package/scripts/gds/fitness-ratchets.js +4 -4
  136. package/scripts/gds/fitness.js +10 -10
  137. package/scripts/gds/gen-diagrams.js +49 -71
  138. package/scripts/gds/http-api-client.js +12 -19
  139. package/scripts/gds/init.js +1 -1
  140. package/scripts/gds/lint-infra-exec.js +1 -1
  141. package/scripts/gds/local-preview-lib.js +8 -39
  142. package/scripts/gds/local-preview.js +2 -15
  143. package/scripts/gds/migration-namespace.js +3 -3
  144. package/scripts/gds/module-artifact.js +212 -0
  145. package/scripts/gds/module.js +95 -6
  146. package/scripts/gds/package-core.js +5 -116
  147. package/scripts/gds/provision-config.js +2 -2
  148. package/scripts/gds/provision-net.js +2 -3
  149. package/scripts/gds/provision.js +9 -9
  150. package/scripts/gds/publish-manifest.js +1 -2
  151. package/scripts/gds/regen-instance-docs.js +1 -1
  152. package/scripts/gds/run-unit-tests.js +2 -16
  153. package/scripts/gds/sandbox-stage.js +39 -341
  154. package/scripts/gds/ship-flow.js +6 -6
  155. package/scripts/gds/ship-land.js +3 -4
  156. package/scripts/gds/ship-regen.js +10 -41
  157. package/scripts/gds/ship.js +4 -24
  158. package/scripts/gds/start.js +0 -15
  159. package/scripts/hall-preview/README.md +5 -5
  160. package/scripts/hall-preview/server.js +6 -6
  161. package/scripts/render-diagrams.sh +1 -1
  162. package/src/bongos/api-errors.js +1 -1
  163. package/src/bongos/auth-github.js +2 -3
  164. package/src/bongos/auth.js +51 -126
  165. package/src/bongos/db-kernel.js +2 -3
  166. package/src/bongos/db.js +5 -28
  167. package/src/bongos/module-scope-map.js +14 -29
  168. package/src/bongos/module-store.js +141 -0
  169. package/src/bongos/route-rank-check.js +3 -16
  170. package/src/bongos/routes/auth.js +0 -116
  171. package/src/bongos/routes/builders.js +5 -15
  172. package/src/bongos/routes/instance.js +8 -14
  173. package/src/bongos/routes/me.js +6 -22
  174. package/src/bongos/routes/modules.js +83 -0
  175. package/src/bongos/routes.js +5 -0
  176. package/src/module-api.js +13 -6
  177. package/src/module-loader/catalog.js +3 -0
  178. package/src/module-loader/loader.js +4 -4
  179. package/src/module-loader/manifest-schema.js +15 -7
  180. package/src/module-seams.js +2 -3
  181. package/src/modules.js +42 -14
  182. package/tests/agents_spend_guard.mjs +1 -1
  183. package/tests/api_client.mjs +0 -15
  184. package/tests/auth_page_gate.mjs +10 -10
  185. package/tests/canonical_profile_url.mjs +1 -3
  186. package/tests/claim_action.mjs +4 -1
  187. package/tests/claim_from_task_and_home.mjs +127 -0
  188. package/tests/consumer_layout_boot.mjs +2 -0
  189. package/tests/copy_desk_page_docx.mjs +33 -2
  190. package/tests/core_upgrade_runner.mjs +6 -6
  191. package/tests/deploy_divergence_line.mjs +11 -11
  192. package/tests/diagram_facts_offset.mjs +1 -1
  193. package/tests/do_api.mjs +106 -0
  194. package/tests/feedback_latest.mjs +122 -0
  195. package/tests/fitness.mjs +44 -42
  196. package/tests/gate_approvals.mjs +0 -1
  197. package/tests/go_live.mjs +3 -3
  198. package/tests/government_abuse_matrix.mjs +20 -19
  199. package/tests/government_ownership_scope.mjs +33 -35
  200. package/tests/government_parity.mjs +2 -2
  201. package/tests/government_protected_surfaces.mjs +56 -37
  202. package/tests/government_require_permission.mjs +3 -3
  203. package/tests/government_seed.mjs +37 -4
  204. package/tests/government_session_scope.mjs +92 -366
  205. package/tests/hall_error_envelope.mjs +4 -0
  206. package/tests/hall_landing_boot.mjs +4 -1
  207. package/tests/hall_palette.mjs +1 -16
  208. package/tests/hall_record_world.mjs +6 -0
  209. package/tests/hall_tweak_editor.mjs +52 -1
  210. package/tests/host_topology_skips.mjs +1 -2
  211. package/tests/html_comment_nesting.mjs +137 -0
  212. package/tests/infra_exec_paths.mjs +9 -10
  213. package/tests/init.mjs +4 -5
  214. package/tests/instance_manifest.mjs +18 -25
  215. package/tests/kickoff_checklist.mjs +1 -1
  216. package/tests/lib_sh_resolution.mjs +0 -221
  217. package/tests/local_preview.mjs +0 -12
  218. package/tests/module-scope-map.mjs +9 -9
  219. package/tests/module_catalog.mjs +1 -1
  220. package/tests/module_cli.mjs +15 -15
  221. package/tests/module_contributions.mjs +6 -6
  222. package/tests/module_loader.mjs +6 -6
  223. package/tests/module_manifest.mjs +13 -4
  224. package/tests/module_store_publish.mjs +307 -0
  225. package/tests/module_store_publish_route.mjs +121 -0
  226. package/tests/module_store_registry_migration.mjs +70 -0
  227. package/tests/modules.mjs +56 -26
  228. package/tests/onboarding_route_signals.mjs +21 -31
  229. package/tests/onboarding_state.mjs +35 -49
  230. package/tests/permission_path.mjs +0 -3
  231. package/tests/platform_boot.mjs +1 -2
  232. package/tests/profile_route.mjs +1 -6
  233. package/tests/projects_hub.mjs +5 -5
  234. package/tests/projects_hub_app_step.mjs +138 -0
  235. package/tests/projects_hub_module_picker.mjs +2 -2
  236. package/tests/projects_hub_pre_uat.mjs +4 -5
  237. package/tests/provision.mjs +7 -7
  238. package/tests/provisioning_starter_bundles.mjs +0 -22
  239. package/tests/publish_branch_route.mjs +10 -18
  240. package/tests/publish_manifest.mjs +1 -2
  241. package/tests/rank_tier_single_source.mjs +1 -13
  242. package/tests/repo_map.mjs +3 -4
  243. package/tests/runner_drift.mjs +3 -21
  244. package/tests/sandbox_stage.mjs +57 -390
  245. package/tests/session_rename_fallback.mjs +0 -21
  246. package/tests/ship_error_shape.mjs +1 -1
  247. package/tests/task_visual_slots.mjs +31 -0
  248. package/tests/terms_acceptance.mjs +1 -1
  249. package/tests/upgrade.mjs +4 -4
  250. package/tests/watch_sealed_floor.mjs +0 -1
  251. package/tests/wizard_draft_resume.mjs +2 -2
  252. package/tests/wizard_intent_resume.mjs +21 -10
  253. package/tests/wizard_preselect_why.mjs +18 -18
  254. package/docs/onboarding/browser-terminal-guide.md +0 -73
  255. package/docs/recipes/managed-settings-remote-control.md +0 -416
  256. package/modules/dev-box/CLAUDE.md +0 -15
  257. package/modules/dev-box/box-access.js +0 -623
  258. package/modules/dev-box/box-credential.js +0 -94
  259. package/modules/dev-box/box-onboard.js +0 -486
  260. package/modules/dev-box/boxes.js +0 -1038
  261. package/modules/dev-box/db.js +0 -39
  262. package/modules/dev-box/module.json +0 -19
  263. package/modules/dev-box/routes/box.js +0 -1223
  264. package/scripts/gds/box-auth-check.js +0 -160
  265. package/scripts/gds/box-infra.js +0 -321
  266. package/scripts/gds/box-sync.js +0 -216
  267. package/scripts/gds/box.js +0 -1332
  268. package/scripts/gds/cf-tunnel.js +0 -558
  269. package/scripts/gds/smoke-box.sh +0 -150
  270. package/scripts/gds/tree-preflight.js +0 -241
  271. package/src/bongos/app-pair.js +0 -172
  272. package/tests/app_pair.mjs +0 -168
  273. package/tests/box_access.mjs +0 -578
  274. package/tests/box_auth_check.mjs +0 -114
  275. package/tests/box_code_staleness.mjs +0 -129
  276. package/tests/box_connect_e2e.mjs +0 -196
  277. package/tests/box_cost.mjs +0 -70
  278. package/tests/box_credential.mjs +0 -125
  279. package/tests/box_dns_repoint.mjs +0 -153
  280. package/tests/box_env.mjs +0 -64
  281. package/tests/box_host_keys.mjs +0 -126
  282. package/tests/box_infra_resolve.mjs +0 -246
  283. package/tests/box_onboard.mjs +0 -317
  284. package/tests/box_scope_session.mjs +0 -119
  285. package/tests/box_sweep_docs.mjs +0 -119
  286. package/tests/box_sync.mjs +0 -55
  287. package/tests/box_sync_scope_report.mjs +0 -126
  288. package/tests/box_task_scope.mjs +0 -169
  289. package/tests/box_task_scope_pause.mjs +0 -66
  290. package/tests/box_terminal_ssrf.mjs +0 -96
  291. package/tests/boxes.mjs +0 -1585
  292. package/tests/cf_ruleset_preflight.mjs +0 -142
  293. package/tests/cf_tunnel.mjs +0 -496
  294. package/tests/docs_scrubber_damage.mjs +0 -111
  295. package/tests/module_scope_active_claims.mjs +0 -94
  296. package/tests/ship_api_client_pathspec.mjs +0 -100
  297. package/tests/tree_preflight.mjs +0 -243
@@ -1,1038 +0,0 @@
1
- // src/bongos/boxes.js — per-builder cloud dev box lifecycle (ADR 0031 §2 / §5, #598).
2
- //
3
- // Two layers in one module, deliberately split:
4
- //
5
- // 1. PURE helpers (no I/O) — the rank gate, the size/cost catalog, and the
6
- // idle/dormant SELECTION logic. These carry the lifecycle's real decisions,
7
- // so they are pure and unit-tested directly (tests/boxes.mjs) with no DB,
8
- // no DigitalOcean, no clock — `nowMs` is always injected.
9
- //
10
- // 2. DB helpers (async, take the pg pool) — read a builder's box, list the
11
- // roster, append a box_events audit row, bump the activity heartbeat, and
12
- // the state-transition writers. Shared by BOTH the control-plane CLI
13
- // (scripts/gds/box.js) and the read/heartbeat routes (routes/box.js).
14
- //
15
- // What is NOT here: the DigitalOcean API calls. Those live in src/bongos/do-api.js
16
- // (the only network module), and the CLI orchestrator composes pure-guard →
17
- // DO-call → DB-writer. Keeping DO out of this file is what lets the decisions be
18
- // tested hermetically.
19
- //
20
- // §9.3 (resolved in ADR 0031): auto-suspend = snapshot-and-park. A powered-off
21
- // DO droplet still bills, so "park" snapshots then DESTROYS the droplet and
22
- // "wake" recreates from the snapshot. The cost model below reflects that: a box
23
- // only accrues compute cost while `state='active'`.
24
-
25
- // ---------------------------------------------------------------------------
26
- // PURE: rank gate
27
- // ---------------------------------------------------------------------------
28
-
29
- // Three live ranks (ADR 0018 / migration 029): xenos < metic < archon.
30
- // ADR 0034 (#692) added `thetes` as the 4th live rank between xenos and metic.
31
- // It MUST appear here or rankMeetsBoxFloor() fails closed for Thetes (undefined
32
- // order → below any floor → 403 on /box/ensure), which boxed out the whole tier.
33
- const RANK_ORDER = { xenos: 0, thetes: 1, metic: 2, archon: 3 };
34
-
35
- // Default provisioning floor. ADR 0031 §5(b): a Newcomer (Xenos who has cleared
36
- // onboarding) gets an org-funded auto-suspended STARTER box — zero payment
37
- // barrier to begin. So the floor is the lowest live rank; the SCOPE (below)
38
- // is what differs by rank, not the right to a box. Override with
39
- // BOX_PROVISION_MIN_RANK to raise it.
40
- const DEFAULT_PROVISION_FLOOR = 'xenos';
41
-
42
- function rankMeetsBoxFloor(rank, floor = DEFAULT_PROVISION_FLOOR) {
43
- const r = RANK_ORDER[rank];
44
- const f = RANK_ORDER[floor];
45
- return Number.isInteger(r) && Number.isInteger(f) && r >= f;
46
- }
47
-
48
- // Source-access scope recorded at provision (consumed by #600). A Xenos or
49
- // Thetes gets a SCOPED 'starter' surface; a Metic+ gets 'full'. This is the
50
- // access boundary ADR 0031 §6 / open-question §9.2 describes — #598 records it,
51
- // #600 enforces it. (ADR 0034: Thetes can hold a box but earns no scope uplift.)
52
- function boxScopeForRank(rank) {
53
- return RANK_ORDER[rank] >= RANK_ORDER.metic ? 'full' : 'starter';
54
- }
55
-
56
- // The provisioning gate. Pure + throwing so it is unit-tested and so the CLI
57
- // and any future API path enforce the SAME rule (ADR 0016 trust boundary: the
58
- // check reads the builder's live DB rank, which their machine cannot write).
59
- function assertProvisionAllowed({ rank, floor = DEFAULT_PROVISION_FLOOR } = {}) {
60
- if (!rankMeetsBoxFloor(rank, floor)) {
61
- const err = new Error(
62
- `INSUFFICIENT_RANK: provisioning a box requires rank >= ${floor}; builder is ${rank ?? 'unknown'}`
63
- );
64
- err.code = 'INSUFFICIENT_RANK';
65
- err.required = floor;
66
- err.actual = rank ?? null;
67
- throw err;
68
- }
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // PURE: size + cost catalog
73
- // ---------------------------------------------------------------------------
74
-
75
- // DigitalOcean shared-CPU droplet sizes we offer, with their list prices
76
- // (USD; hourly is the monthly/672 DO rounds to). The default is s-2vcpu-4gb:
77
- // the devcontainer runs Node 22 + the art pipeline + a dev server, and
78
- // devcontainer.json sets a 4 GB Node heap — 4 GB RAM is the sane floor. Parked,
79
- // a box costs only its snapshot (~$0.30/mo for a ~5 GB image), not these rates.
80
- const SIZE_CATALOG = {
81
- 's-1vcpu-2gb': { hourlyUsd: 0.01786, monthlyUsd: 12, label: '1 vCPU / 2 GB' },
82
- 's-2vcpu-2gb': { hourlyUsd: 0.02679, monthlyUsd: 18, label: '2 vCPU / 2 GB' },
83
- 's-2vcpu-4gb': { hourlyUsd: 0.03571, monthlyUsd: 24, label: '2 vCPU / 4 GB' },
84
- 's-4vcpu-8gb': { hourlyUsd: 0.07143, monthlyUsd: 48, label: '4 vCPU / 8 GB' },
85
- };
86
- const DEFAULT_SIZE = 's-2vcpu-4gb';
87
-
88
- function sizeHourlyUsd(slug) {
89
- const s = SIZE_CATALOG[slug];
90
- return s ? s.hourlyUsd : 0;
91
- }
92
-
93
- // Compute cost of a live interval = hours running × the size's hourly rate.
94
- // Negative/zero/invalid intervals → 0 (a box that never went active owes nothing).
95
- function computeActiveCostUsd({ sizeSlug, activeSinceMs, untilMs } = {}) {
96
- if (!Number.isFinite(activeSinceMs) || !Number.isFinite(untilMs)) return 0;
97
- const ms = untilMs - activeSinceMs;
98
- if (ms <= 0) return 0;
99
- const hours = ms / 3_600_000;
100
- return round4(hours * sizeHourlyUsd(sizeSlug));
101
- }
102
-
103
- // ---- container cost ledger (#602) ----
104
- // DigitalOcean charges no provision FEE — billing starts at the first active
105
- // hour, already captured by computeActiveCostUsd. We still log a spin-up LEDGER
106
- // EVENT (at $0 by default) so the ledger records every create/resume as a
107
- // timestamped line; override BOX_SPINUP_COST_USD to model a provisioning overhead.
108
- const SPINUP_COST_USD = Number(process.env.BOX_SPINUP_COST_USD) || 0;
109
- // A PARKED box keeps only its ~5 GB snapshot (~$0.30/mo). A RUNNING box's disk is
110
- // included in the droplet hourly rate, so storage is a separate ledger line only
111
- // while parked. monthly / 730 ≈ hourly.
112
- const SNAPSHOT_MONTHLY_USD = Number(process.env.BOX_SNAPSHOT_MONTHLY_USD) || 0.30;
113
-
114
- // Storage cost of a parked interval = hours parked × the snapshot's hourly rate.
115
- // Invalid/zero/negative interval → 0.
116
- function storageCostUsd({ parkedSinceMs, untilMs } = {}) {
117
- if (!Number.isFinite(parkedSinceMs) || !Number.isFinite(untilMs)) return 0;
118
- const ms = untilMs - parkedSinceMs;
119
- if (ms <= 0) return 0;
120
- return round4((ms / 3_600_000) * (SNAPSHOT_MONTHLY_USD / 730));
121
- }
122
-
123
- // Pure: bucket cost_log-shaped rows (the box-* sources) into a per-builder
124
- // summary { spinup_usd, compute_usd, storage_usd, total_usd }. Keyed on `source`
125
- // so a $0 spin-up line stays distinct from compute. Exported for the cost views
126
- // + the unit test.
127
- function summarizeBoxCost(rows) {
128
- const out = { spinup_usd: 0, compute_usd: 0, storage_usd: 0, total_usd: 0 };
129
- for (const r of rows || []) {
130
- const amt = Number(r.amount_usd) || 0;
131
- if (r.source === 'box-spinup') out.spinup_usd = round4(out.spinup_usd + amt);
132
- else if (r.source === 'box-storage') out.storage_usd = round4(out.storage_usd + amt);
133
- else if (r.source === 'box-lifecycle') out.compute_usd = round4(out.compute_usd + amt);
134
- out.total_usd = round4(out.total_usd + amt);
135
- }
136
- return out;
137
- }
138
-
139
- // The cost_log sources this feature owns — the box cost ledger is exactly the
140
- // rows carrying one of these (compute already shipped under 'box-lifecycle').
141
- const BOX_COST_SOURCES = ['box-lifecycle', 'box-spinup', 'box-storage'];
142
-
143
- function round4(n) {
144
- return Math.round((n + Number.EPSILON) * 10000) / 10000;
145
- }
146
-
147
- // ---------------------------------------------------------------------------
148
- // PURE: idle / dormant selection (the sweep cores)
149
- // ---------------------------------------------------------------------------
150
-
151
- function toMs(ts) {
152
- if (ts == null) return null;
153
- if (ts instanceof Date) return ts.getTime();
154
- const n = Date.parse(ts);
155
- return Number.isNaN(n) ? null : n;
156
- }
157
-
158
- // A box's "last activity" for idle purposes: the heartbeat if present, else when
159
- // the current droplet went active, else when it was first provisioned. The
160
- // COALESCE means a freshly-provisioned box that has not heartbeated yet is dated
161
- // from active_since (so it gets a full idle window before its first park, not an
162
- // instant one).
163
- function effectiveActivityMs(row) {
164
- return toMs(row.last_activity_at) ?? toMs(row.active_since) ?? toMs(row.provisioned_at);
165
- }
166
-
167
- // ---- the unattended cap (task 1003507) ------------------------------------
168
- //
169
- // A box used to be able to assert "in use" forever from two proxies, neither of
170
- // which implies a human is present:
171
- //
172
- // 1. `pgrep -x claude` -> claude_active=true, which selectIdleBoxes skipped
173
- // with NO time bound. claude_active is a LATCH, not a level: only a
174
- // heartbeat ping writes it, and box-heartbeat.sh exits WITHOUT pinging when
175
- // it sees nothing — so silence, the very signal the sweep exists to act on,
176
- // could never clear it. Unreapable at EVERY threshold, forever.
177
- // 2. `load > 0.2` -> ACTIVE=1, which keeps bumping last_activity_at. A running
178
- // devcontainer plus an idle `claude` clears that floor on its own, so the
179
- // activity clock never went stale either.
180
- //
181
- // Together they made an abandoned interactive `claude` at its prompt in an
182
- // UNATTACHED tmux session indistinguishable from a working one, and one such box
183
- // burned $20.21 of unattended compute the sweep could not reach. Bounding only
184
- // (1) does not stop the bleeding, because (2) keeps the box out of the idle set
185
- // on its own — so both proxies are bounded by the same policy:
186
- //
187
- // A box may not stay active for longer than `unattendedMaxHours` without
188
- // evidence that a human was actually attached to it.
189
- //
190
- // "Attached" is what the heartbeat already knew and used to throw away by
191
- // folding it into one boolean: a login session (`who`), an established inbound
192
- // SSH connection, an open browser terminal (ttyd), or an ATTACHED tmux client.
193
- // It now reports that separately and the server stamps `last_attached_at`.
194
- const HOUR_MS = 3_600_000;
195
-
196
- // Has a human been attached recently enough for this box's claims to stand?
197
- // - no cap configured -> yes (the pre-1003507 unbounded behaviour;
198
- // the policy lives in the caller's CONFIG)
199
- // - last_attached_at unset -> yes. A box whose heartbeat predates this change
200
- // never reports attachment, so its column stays
201
- // NULL forever — treating unknown as unattended
202
- // would park every such box mid-work. NULL is
203
- // deliberately NOT backfilled for that reason.
204
- // - within the cap -> yes
205
- // - older than the cap -> no; the box is unattended
206
- function attendedRecently(row, { nowMs, unattendedMaxHours } = {}) {
207
- if (!Number.isFinite(unattendedMaxHours) || unattendedMaxHours <= 0) return true;
208
- const at = toMs(row.last_attached_at);
209
- if (at == null) return true;
210
- return at > nowMs - unattendedMaxHours * HOUR_MS;
211
- }
212
-
213
- // Does claude_active still earn this box a pass from the idle sweep? Bounded by
214
- // how long it has been claimed, read off `claude_active_since` (migration
215
- // core_238, stamped on the false->true edge so it measures the whole session
216
- // rather than the last beat). An unknown age is refused: core_238 backfills every
217
- // box that is claude_active at deploy, so a NULL stamp under a true flag is the
218
- // forever-latch this fixes, not a live session.
219
- function claudeVetoHolds(row, { nowMs, unattendedMaxHours } = {}) {
220
- if (!row.claude_active) return false;
221
- if (!Number.isFinite(unattendedMaxHours) || unattendedMaxHours <= 0) return true;
222
- const since = toMs(row.claude_active_since);
223
- if (since == null) return false;
224
- return since > nowMs - unattendedMaxHours * HOUR_MS;
225
- }
226
-
227
- // Idle = active boxes that are either (a) quiet past idleMinutes with no live
228
- // claude_active veto, or (b) still chattering but unattended past the cap. (b) is
229
- // the abandoned-session case: the heartbeat is fresh, so (a) can never fire.
230
- // Pure: caller passes the candidate rows and nowMs; returns the subset to park.
231
- function selectIdleBoxes(rows, { idleMinutes, nowMs, unattendedMaxHours } = {}) {
232
- if (!Number.isFinite(idleMinutes) || idleMinutes <= 0) return [];
233
- if (!Number.isFinite(nowMs)) return [];
234
- const cutoff = nowMs - idleMinutes * 60_000;
235
- return (rows || []).filter((r) => {
236
- if (r.state !== 'active') return false;
237
- if (!attendedRecently(r, { nowMs, unattendedMaxHours })) return true;
238
- if (claudeVetoHolds(r, { nowMs, unattendedMaxHours })) return false;
239
- const a = effectiveActivityMs(r);
240
- return a != null && a <= cutoff;
241
- });
242
- }
243
-
244
- // Why the sweep picked a box, for the operator log. Mirrors selectIdleBoxes'
245
- // branches exactly — if these two ever disagree the log is lying, so they are
246
- // read together.
247
- function idleReason(row, { nowMs, idleMinutes, unattendedMaxHours } = {}) {
248
- if (!attendedRecently(row, { nowMs, unattendedMaxHours })) {
249
- const h = Math.round((nowMs - toMs(row.last_attached_at)) / HOUR_MS);
250
- return `unattended ${h}h (cap ${unattendedMaxHours}h) — abandoned session`;
251
- }
252
- const mins = Math.round((nowMs - (effectiveActivityMs(row) || nowMs)) / 60_000);
253
- if (row.claude_active) {
254
- const since = toMs(row.claude_active_since);
255
- const held = since == null ? 'no claude_active_since recorded' : `claude_active ${Math.round((nowMs - since) / HOUR_MS)}h`;
256
- return `idle ${mins}m, ${held} past the ${unattendedMaxHours}h cap`;
257
- }
258
- return `idle ${mins}m`;
259
- }
260
-
261
- // Dormant = parked boxes that have been parked longer than dormantDays. These
262
- // get fully de-provisioned (snapshot deleted too) to reclaim the last cents.
263
- // Since task 1002726 this is the ONLY automated path that destroys a snapshot —
264
- // the idle sweep parks and preserves one — so it is the last thing standing
265
- // between a builder and losing uncommitted work. Task 1002727 gates it on a
266
- // delivered warning.
267
- function selectDormantBoxes(rows, { dormantDays, nowMs } = {}) {
268
- if (!Number.isFinite(dormantDays) || dormantDays <= 0) return [];
269
- if (!Number.isFinite(nowMs)) return [];
270
- const cutoff = nowMs - dormantDays * 86_400_000;
271
- return (rows || []).filter((r) => {
272
- if (r.state !== 'parked') return false;
273
- const p = toMs(r.parked_at);
274
- return p != null && p <= cutoff;
275
- });
276
- }
277
-
278
- // Task 1002727 — the WARNING window: parked long enough to be heading for
279
- // reclaim, but not yet reclaimable. A box here gets one heads-up so its builder
280
- // can wake it (or accept the loss) before the snapshot goes. Boxes already past
281
- // dormantDays are excluded — they belong to selectDormantBoxes, which refuses to
282
- // reclaim an unwarned box. Pure; the caller injects nowMs.
283
- function selectDormantWarnBoxes(rows, { dormantDays, warnLeadDays, nowMs } = {}) {
284
- if (!Number.isFinite(dormantDays) || dormantDays <= 0) return [];
285
- if (!Number.isFinite(warnLeadDays) || warnLeadDays <= 0) return [];
286
- if (!Number.isFinite(nowMs)) return [];
287
- const warnAfter = nowMs - Math.max(dormantDays - warnLeadDays, 0) * 86_400_000;
288
- const reclaimAfter = nowMs - dormantDays * 86_400_000;
289
- return (rows || []).filter((r) => {
290
- if (r.state !== 'parked') return false;
291
- const p = toMs(r.parked_at);
292
- return p != null && p <= warnAfter && p > reclaimAfter;
293
- });
294
- }
295
-
296
- // Task 1002727 — was this box already warned during its CURRENT parked interval?
297
- // box_events is the state store, so this needs no schema change: a
298
- // 'dormant_warning' row written at or after parked_at is the receipt. Keying on
299
- // parked_at (rather than just the newest event) means a wake→park cycle re-arms
300
- // the warning instead of reusing a stale one from a previous park.
301
- async function hasDormantWarning(db, { boxId, parkedAt } = {}) {
302
- if (!boxId || !parkedAt) return false;
303
- const { rows } = await db.query(
304
- `SELECT 1 FROM box_events
305
- WHERE box_id = $1 AND event = 'dormant_warning' AND created_at >= $2
306
- LIMIT 1`,
307
- [boxId, parkedAt]
308
- );
309
- return rows.length > 0;
310
- }
311
-
312
- // ---------------------------------------------------------------------------
313
- // PURE: state-transition legality
314
- // ---------------------------------------------------------------------------
315
-
316
- // Which lifecycle op is legal from which current state. 'error'/'destroyed' are
317
- // recoverable into a fresh provision; 'deprovision' is legal from anywhere that
318
- // holds a droplet or snapshot so an operator can always force-reclaim.
319
- const TRANSITIONS = {
320
- provision: ['none', 'destroyed', 'error'],
321
- park: ['active'],
322
- wake: ['parked'],
323
- deprovision: ['active', 'parking', 'parked', 'waking', 'error'],
324
- };
325
-
326
- function canTransition(op, state) {
327
- return (TRANSITIONS[op] || []).includes(state);
328
- }
329
-
330
- // ---------------------------------------------------------------------------
331
- // PURE: droplet ⇄ box-row drift detection (the reconcile-drift core, task 1289)
332
- // ---------------------------------------------------------------------------
333
-
334
- // States in which a box row legitimately OWNS a running droplet (so its
335
- // droplet_id should match a real DO droplet). 'parked' is excluded — a parked box
336
- // has no droplet, only a snapshot. Distinct from SOURCE_HOLDING_STATES (which is
337
- // about credential issuance, not droplet ownership).
338
- const DROPLET_HOLDING_STATES = ['active', 'provisioning', 'waking', 'parking'];
339
-
340
- // Derive the builder login a fleet droplet belongs to, from its `builder:<login>`
341
- // tag (how box.js tags every droplet) or, failing that, the `otb-box-<login>`
342
- // name. Lets the reconcile tear down the matching Cloudflare tunnel + DNS for a
343
- // zombie. null when neither encodes a login.
344
- function loginFromDroplet(d) {
345
- const tag = (d.tags || []).find((t) => typeof t === 'string' && t.startsWith('builder:'));
346
- if (tag) return tag.slice('builder:'.length) || null;
347
- const m = /^otb-box-(.+)$/.exec(d.name || '');
348
- return (m && m[1]) || null;
349
- }
350
-
351
- // Pure: given the LIVE DigitalOcean droplet list (already filtered to our fleet
352
- // tag) and the GDS box rows, compute lifecycle drift in BOTH directions — the
353
- // fix for the task-1289 zombie, where deprovision marked a box 'destroyed' WITHOUT
354
- // confirming the droplet was deleted, leaving a running droplet no row references.
355
- //
356
- // - zombies: a real droplet that NO box row claims in a droplet-holding state
357
- // (the row is destroyed/error/none, or its droplet_id was nulled). It bills,
358
- // stays SSH-reachable, and freezes on old code forever → force-delete it.
359
- // - ghosts: a box row in a droplet-holding state whose droplet_id is no longer
360
- // present in DO (deleted out-of-band) → mark it 'destroyed' so the DB stops
361
- // claiming a running box and the builder can re-provision.
362
- //
363
- // In-flight guard: a droplet created seconds ago can belong to a box still in
364
- // 'provisioning' (the row's droplet_id isn't written until the droplet settles
365
- // 'active'). Droplets younger than minAgeMinutes are NOT treated as zombies — they
366
- // are returned under `skippedYoung` so the operator sees them without risk of
367
- // deleting a provision in progress. Pure + injected (droplets/boxRows/nowMs) so it
368
- // is unit-tested with no DO, DB, or real clock.
369
- function planDropletDrift({ droplets = [], boxRows = [], nowMs = null, minAgeMinutes = 30 } = {}) {
370
- const liveDropletIds = new Set((droplets || []).map((d) => String(d.id)));
371
- const claimedDropletIds = new Set(
372
- (boxRows || [])
373
- .filter((b) => DROPLET_HOLDING_STATES.includes(b.state) && b.droplet_id != null)
374
- .map((b) => String(b.droplet_id))
375
- );
376
- const builderIdByLogin = new Map(
377
- (boxRows || []).filter((b) => b.github_login).map((b) => [b.github_login, b.builder_id])
378
- );
379
-
380
- const zombies = [];
381
- const skippedYoung = [];
382
- for (const d of droplets || []) {
383
- if (claimedDropletIds.has(String(d.id))) continue; // a live box owns it — in sync
384
- const login = loginFromDroplet(d);
385
- const entry = {
386
- dropletId: String(d.id), name: d.name || null, login,
387
- builderId: login ? (builderIdByLogin.get(login) ?? null) : null,
388
- tags: d.tags || [],
389
- };
390
- const createdMs = toMs(d.created_at);
391
- if (Number.isFinite(nowMs) && createdMs != null && (nowMs - createdMs) < minAgeMinutes * 60_000) {
392
- skippedYoung.push(entry);
393
- continue;
394
- }
395
- zombies.push(entry);
396
- }
397
-
398
- const ghosts = [];
399
- for (const b of boxRows || []) {
400
- if (!DROPLET_HOLDING_STATES.includes(b.state)) continue;
401
- if (b.droplet_id == null) continue;
402
- if (liveDropletIds.has(String(b.droplet_id))) continue; // droplet present — in sync
403
- ghosts.push({
404
- builderId: b.builder_id, login: b.github_login || null,
405
- dropletId: String(b.droplet_id), state: b.state,
406
- });
407
- }
408
-
409
- return { zombies, ghosts, skippedYoung };
410
- }
411
-
412
- // ---------------------------------------------------------------------------
413
- // PURE: the "make my box ready" decision (#701 box onboarding middleware)
414
- // ---------------------------------------------------------------------------
415
-
416
- // Given a box's current state and the builder's LIVE rank+status, decide what a
417
- // builder's "I want to connect" request (POST /box/ensure) should do. This is the
418
- // single decision that delivers BOTH halves of #701's onboarding gap:
419
- // - auto-provision (boxState none/destroyed/error → 'provision')
420
- // - wake-on-connect (boxState parked → 'wake')
421
- // plus the no-op / in-flight cases. Pure + tested (tests/boxes.mjs) so the route
422
- // and any future caller enforce the same rule; the rank gate mirrors
423
- // box-access.decideSourceAccess (ADR 0016: decided from the live DB rank, which
424
- // the builder's machine cannot forge). Fail-closed: an unknown rank/state denies.
425
- //
426
- // Returns one of:
427
- // { action: 'ready' } box is active — just heartbeat
428
- // { action: 'in_progress' } provision/wake/park already under way
429
- // { action: 'provision' } enqueue a provision intent
430
- // { action: 'wake' } enqueue a wake intent
431
- // { action: 'denied', reason, required?, actual? } not allowed a box
432
- //
433
- // `blocked` (migration 104, task 1163): an Archon block bars the two "open"
434
- // actions — provisioning a NEW box and waking a PARKED one — with the specific,
435
- // actionable reason BOX_PROVISION_BLOCKED. It is checked PER-BRANCH (not up top)
436
- // so it does NOT touch an already-active box: blocking is "no new opens" by
437
- // default, and an active box keeps heartbeating ('ready') until it idles out or
438
- // is closed. Tearing a running box down at block time is the Archon's separate,
439
- // explicit choice (PATCH /boxes/:id/block { close_current:true } → deprovision).
440
- function decideEnsureAction({ boxState, rank, status, blocked = false, floor = DEFAULT_PROVISION_FLOOR } = {}) {
441
- // Same gate + order as decideSourceAccess: inactive first, then rank floor.
442
- if (status !== 'active') {
443
- return { action: 'denied', reason: 'BUILDER_INACTIVE' };
444
- }
445
- if (!rankMeetsBoxFloor(rank, floor)) {
446
- return { action: 'denied', reason: 'INSUFFICIENT_RANK', required: floor, actual: rank ?? null };
447
- }
448
- switch (boxState) {
449
- case 'active':
450
- return { action: 'ready' };
451
- case 'provisioning':
452
- case 'waking':
453
- case 'parking':
454
- return { action: 'in_progress' };
455
- case 'parked':
456
- // Waking a parked box is an "open" — a block bars it (a blocked builder
457
- // can't resume a box, only keep running one that's already active).
458
- if (blocked) return { action: 'denied', reason: 'BOX_PROVISION_BLOCKED' };
459
- return { action: 'wake' };
460
- case 'none':
461
- case 'destroyed':
462
- case 'error':
463
- case undefined:
464
- case null:
465
- if (blocked) return { action: 'denied', reason: 'BOX_PROVISION_BLOCKED' };
466
- return { action: 'provision' };
467
- default:
468
- // An unrecognized state is not safe to act on automatically — fail closed
469
- // to in_progress so the runner/operator resolves it, never a blind provision.
470
- return { action: 'in_progress' };
471
- }
472
- }
473
-
474
- // ---------------------------------------------------------------------------
475
- // DB helpers (async). Each takes the pg pool (or a client mid-transaction).
476
- // ---------------------------------------------------------------------------
477
-
478
- // setBoxWidenPaths — replace a box row's builder-requested widen set (task
479
- // 1003087). Whole-set replace rather than add/remove SQL: the set is tiny and
480
- // capped, the caller has already normalized and rank-filtered it, and a
481
- // read-modify-write in one statement keeps two concurrent widens from
482
- // interleaving into a half-applied list.
483
- //
484
- // Returns the stored array, or null when the builder has no box row — the
485
- // caller turns that into a 404 rather than silently creating one, because a
486
- // widen with no box to apply it to is a request that has not happened yet.
487
- async function setBoxWidenPaths(db, builderId, paths) {
488
- const { rows } = await db.query(
489
- `UPDATE builder_boxes
490
- SET widen_paths = $2::text[], updated_at = now()
491
- WHERE builder_id = $1
492
- RETURNING widen_paths`,
493
- [builderId, Array.isArray(paths) ? paths : []]
494
- );
495
- return rows[0] ? rows[0].widen_paths : null;
496
- }
497
-
498
- async function getBoxByBuilderId(db, builderId) {
499
- const { rows } = await db.query(
500
- `SELECT * FROM builder_boxes WHERE builder_id = $1`,
501
- [builderId]
502
- );
503
- return rows[0] || null;
504
- }
505
-
506
- // Ensure a row exists for this builder (state defaults to 'none'); returns it.
507
- async function ensureBoxRow(db, builderId) {
508
- await db.query(
509
- `INSERT INTO builder_boxes (builder_id) VALUES ($1)
510
- ON CONFLICT (builder_id) DO NOTHING`,
511
- [builderId]
512
- );
513
- return getBoxByBuilderId(db, builderId);
514
- }
515
-
516
- async function listBoxes(db, { state } = {}) {
517
- const params = [];
518
- let where = '';
519
- if (state) { params.push(state); where = `WHERE bb.state = $1`; }
520
- const { rows } = await db.query(
521
- `SELECT bb.*, b.github_login, b.display_name, b.rank,
522
- b.box_blocked, b.box_blocked_reason, b.box_blocked_at
523
- FROM builder_boxes bb
524
- JOIN builders b ON b.id = bb.builder_id
525
- ${where}
526
- ORDER BY bb.updated_at DESC
527
- LIMIT 1000`,
528
- params
529
- );
530
- return rows;
531
- }
532
-
533
- async function recordEvent(db, { boxId, builderId, event, detail, costUsd, actor }) {
534
- await db.query(
535
- `INSERT INTO box_events (box_id, builder_id, event, detail, cost_usd, actor)
536
- VALUES ($1, $2, $3, $4, $5, $6)`,
537
- [boxId, builderId ?? null, event, detail ?? null, costUsd ?? null, actor ?? null]
538
- );
539
- }
540
-
541
- // Heartbeat: bump last_activity_at for a builder's ACTIVE box, and optionally
542
- // record whether a `claude` process is running (claudeActive). Returns the new
543
- // state (or null if there is no box). A parked/none box is not "kept alive" by
544
- // a stray heartbeat — only an active box's idle clock resets.
545
- //
546
- // Deliberately does NOT write a box_events row: heartbeats fire ~every 5 min per
547
- // active box, so logging one per beat would grow the ledger unbounded
548
- // (~100K rows/builder/year). last_activity_at IS the heartbeat record; box_events
549
- // is reserved for the rare lifecycle transitions (provision/park/wake/deprovision/cost).
550
- async function bumpActivity(db, builderId, { claudeActive, attached } = {}) {
551
- const hasClaude = typeof claudeActive === 'boolean';
552
- const claudeCol = hasClaude ? ', claude_active = $2' : '';
553
- // The FIRST time a box reports a live `claude` process, stamp
554
- // first_connected_at — the write-once, monotonic "this builder has actually
555
- // reached a working Claude on their box" signal that clears the onboarding
556
- // 'box_connect' step (#823). COALESCE so a later true beat never moves it,
557
- // and we only ever set it (never clear it) so a claude_active=false beat
558
- // leaves it intact. No new bind param — it reuses now() inline.
559
- const connectedCol = (hasClaude && claudeActive === true)
560
- ? ', first_connected_at = COALESCE(first_connected_at, now())'
561
- : '';
562
- // task 1003507 — claude_active_since is the EDGE, not the level: stamped when
563
- // the flag goes false->true (COALESCE keeps the original edge across a run of
564
- // true beats, so the latch measures the whole session, not the last 5 minutes)
565
- // and cleared on a false beat so the next session starts a fresh clock. Without
566
- // this the flag has no age, and selectIdleBoxes cannot bound its own veto.
567
- const sinceCol = hasClaude
568
- ? (claudeActive === true
569
- ? ', claude_active_since = COALESCE(claude_active_since, now())'
570
- : ', claude_active_since = NULL')
571
- : '';
572
- // task 1003507 — `attached` is the HUMAN signal (a login session, an inbound
573
- // SSH connection, an open browser terminal, or an attached tmux client), kept
574
- // separate from claude_active because a running process is not a person. Only
575
- // a true beat writes it: last_attached_at is a high-water mark, so a beat that
576
- // reports nobody attached leaves the last real attachment where it was and the
577
- // unattended clock keeps running. A box whose heartbeat predates this change
578
- // never sends the field, leaving the column NULL, which selectIdleBoxes reads
579
- // as "no data" rather than "unattended".
580
- const attachedCol = attached === true ? ', last_attached_at = now()' : '';
581
- const params = hasClaude ? [builderId, claudeActive] : [builderId];
582
- const { rows } = await db.query(
583
- `UPDATE builder_boxes
584
- SET last_activity_at = now(), updated_at = now()${claudeCol}${connectedCol}${sinceCol}${attachedCol}
585
- WHERE builder_id = $1 AND state = 'active'
586
- RETURNING state`,
587
- params
588
- );
589
- if (rows[0]) return rows[0].state;
590
- // No active box: report the current state (if any) without bumping.
591
- const cur = await getBoxByBuilderId(db, builderId);
592
- return cur ? cur.state : null;
593
- }
594
-
595
- // Has this builder's box EVER reported a live `claude` process? first_connected_at
596
- // is stamped write-once by bumpActivity (the first claude_active=true heartbeat),
597
- // so this is the monotonic "properly connected to the dev box" signal that backs
598
- // the onboarding 'box_connect' step (#823). Cheap boolean; null/absent row → false.
599
- async function hasEverConnected(db, builderId) {
600
- const { rows } = await db.query(
601
- `SELECT first_connected_at IS NOT NULL AS connected
602
- FROM builder_boxes WHERE builder_id = $1`,
603
- [builderId]
604
- );
605
- return !!(rows[0] && rows[0].connected);
606
- }
607
-
608
- // Columns setBoxState may write. The VALUES are parameterized, but the column
609
- // NAMES are interpolated into the SQL, so the keys are whitelisted here: an
610
- // unexpected key (e.g. if a future caller ever wires user-influenced input into
611
- // the patch) is rejected, not trusted. Closes the latent-injection surface.
612
- const SETTABLE_BOX_COLUMNS = new Set([
613
- 'scope', 'provisioned_rank', 'region', 'size_slug', 'droplet_id', 'droplet_name',
614
- 'snapshot_id', 'ip', 'hostname', 'last_activity_at', 'provisioned_at',
615
- 'active_since', 'parked_at', 'destroyed_at', 'error_note', 'claude_active',
616
- // task 1003507 — park/wake/deprovision clear the claude_active latch and its
617
- // clock together. A woken box that kept a stale claude_active_since would be
618
- // instantly reapable, which is the fix reintroducing the bug from the far side.
619
- 'claude_active_since', 'last_attached_at',
620
- // host_keys/host_keys_at are normally written by setBoxHostKeys(), but
621
- // deprovisionOne() clears them on teardown so a rebuilt box never serves its
622
- // predecessor's SSH identity (idea 310) — hence they are settable here too.
623
- 'host_keys', 'host_keys_at',
624
- ]);
625
-
626
- // Generic state-transition writer. `patch` is a column→value map merged into the
627
- // UPDATE; `updated_at` is always touched. Returns the updated row.
628
- async function setBoxState(db, builderId, state, patch = {}) {
629
- const cols = ['state = $2', 'updated_at = now()'];
630
- const params = [builderId, state];
631
- let i = 3;
632
- for (const [k, v] of Object.entries(patch)) {
633
- if (!SETTABLE_BOX_COLUMNS.has(k)) {
634
- throw new Error(`setBoxState: refusing to write non-allowlisted column '${k}'`);
635
- }
636
- cols.push(`${k} = $${i}`);
637
- params.push(v);
638
- i++;
639
- }
640
- const { rows } = await db.query(
641
- `UPDATE builder_boxes SET ${cols.join(', ')} WHERE builder_id = $1 RETURNING *`,
642
- params
643
- );
644
- return rows[0] || null;
645
- }
646
-
647
- // Accrue compute cost for a box: add to its running tally, append a 'cost'
648
- // event, AND mirror to cost_log (category 'compute', builder-attributed) so the
649
- // public status dashboard's by_builder split sees it. Called at park/deprovision.
650
- async function recordComputeCost(db, { box, untilMs, actor }) {
651
- const cost = computeActiveCostUsd({
652
- sizeSlug: box.size_slug,
653
- activeSinceMs: toMs(box.active_since),
654
- untilMs,
655
- });
656
- if (cost <= 0) return 0;
657
- await db.query(
658
- `UPDATE builder_boxes
659
- SET compute_cost_usd = compute_cost_usd + $2, updated_at = now()
660
- WHERE id = $1`,
661
- [box.id, cost]
662
- );
663
- await recordEvent(db, {
664
- boxId: box.id, builderId: box.builder_id, event: 'cost', costUsd: cost,
665
- detail: `${box.size_slug} active ${(((untilMs - toMs(box.active_since)) / 3_600_000) || 0).toFixed(2)}h`,
666
- actor,
667
- });
668
- // source_ref is unique PER ACCRUAL INTERVAL (box id + the interval's end ms),
669
- // not just per box — a box is parked/woken many times, each a distinct compute
670
- // interval. The cost_log UNIQUE(source, source_ref) (migration 004) would
671
- // otherwise collide on the second park and crash it. Keying on untilMs keeps
672
- // idempotency where it matters: replaying the EXACT same accrual de-dupes
673
- // (ON CONFLICT DO NOTHING) rather than double-charging.
674
- await db.query(
675
- `INSERT INTO cost_log (amount_usd, category, description, source, source_ref, builder_id)
676
- VALUES ($1, 'compute', $2, 'box-lifecycle', $3, $4)
677
- ON CONFLICT DO NOTHING`,
678
- [
679
- cost,
680
- `Dev box compute (${box.size_slug})`,
681
- `builder_boxes#${box.id}@${untilMs}`,
682
- box.builder_id,
683
- ]
684
- );
685
- return cost;
686
- }
687
-
688
- // Log a spin-up LEDGER EVENT each time a box is created (provision) or resumed
689
- // (wake). Mirrors recordComputeCost but ALWAYS writes the row (even at $0) — the
690
- // point is a timestamped "box came up" line in the per-builder ledger, not a
691
- // charge. Idempotent per (box, kind, atMs) via the unique cost_log source_ref.
692
- async function recordSpinupCost(db, { box, kind = 'provision', atMs, actor }) {
693
- const cost = SPINUP_COST_USD;
694
- if (cost > 0) {
695
- await db.query(
696
- `UPDATE builder_boxes SET compute_cost_usd = compute_cost_usd + $2, updated_at = now() WHERE id = $1`,
697
- [box.id, cost]
698
- );
699
- }
700
- await recordEvent(db, {
701
- boxId: box.id, builderId: box.builder_id, event: 'cost', costUsd: cost,
702
- detail: `Dev box spin-up (${kind})`, actor,
703
- });
704
- await db.query(
705
- `INSERT INTO cost_log (amount_usd, category, description, source, source_ref, builder_id)
706
- VALUES ($1, 'compute', $2, 'box-spinup', $3, $4)
707
- ON CONFLICT DO NOTHING`,
708
- [cost, `Dev box spin-up (${kind})`, `builder_boxes#${box.id}:spinup:${kind}@${atMs}`, box.builder_id]
709
- );
710
- return cost;
711
- }
712
-
713
- // Accrue STORAGE cost for a box that spent time parked (snapshot retained). The
714
- // running-box disk is already in compute; this is the snapshot-only line. Called
715
- // at deprovision when the box has a parked_at. No-op for a never-parked box.
716
- async function recordStorageCost(db, { box, untilMs, actor }) {
717
- const parkedSinceMs = toMs(box.parked_at);
718
- const cost = storageCostUsd({ parkedSinceMs, untilMs });
719
- if (cost <= 0) return 0;
720
- await db.query(
721
- `UPDATE builder_boxes SET compute_cost_usd = compute_cost_usd + $2, updated_at = now() WHERE id = $1`,
722
- [box.id, cost]
723
- );
724
- await recordEvent(db, {
725
- boxId: box.id, builderId: box.builder_id, event: 'cost', costUsd: cost,
726
- detail: `Dev box storage (snapshot) ${(((untilMs - parkedSinceMs) / 3_600_000) || 0).toFixed(2)}h`,
727
- actor,
728
- });
729
- await db.query(
730
- `INSERT INTO cost_log (amount_usd, category, description, source, source_ref, builder_id)
731
- VALUES ($1, 'compute', $2, 'box-storage', $3, $4)
732
- ON CONFLICT DO NOTHING`,
733
- [cost, 'Dev box storage (snapshot)', `builder_boxes#${box.id}:storage@${untilMs}`, box.builder_id]
734
- );
735
- return cost;
736
- }
737
-
738
- // The caller's own container-cost summary (#602): bucket their box-* cost_log
739
- // rows into { spinup_usd, compute_usd, storage_usd, total_usd } so a builder sees
740
- // what their dev box would cost. Own-scoped — callers pass req.builder.id.
741
- async function boxCostSummaryForBuilder(db, builderId) {
742
- const { rows } = await db.query(
743
- `SELECT amount_usd, source FROM cost_log
744
- WHERE builder_id = $1 AND source = ANY($2)`,
745
- [builderId, BOX_COST_SOURCES]
746
- );
747
- return summarizeBoxCost(rows);
748
- }
749
-
750
- // The full container-cost ledger for Archon oversight (#602): every box-* cost
751
- // row (newest first) joined to the builder, plus per-builder totals — so an
752
- // Archon sees all box spend without a DB query. limit caps the row list.
753
- async function boxCostLedger(db, { limit = 500 } = {}) {
754
- const { rows: ledger } = await db.query(
755
- `SELECT c.recorded_at, c.amount_usd, c.source, c.description, c.builder_id,
756
- b.github_login AS builder_login
757
- FROM cost_log c LEFT JOIN builders b ON b.id = c.builder_id
758
- WHERE c.source = ANY($1)
759
- ORDER BY c.recorded_at DESC
760
- LIMIT $2`,
761
- [BOX_COST_SOURCES, limit]
762
- );
763
- const { rows: totalsRows } = await db.query(
764
- `SELECT c.builder_id, b.github_login AS builder_login,
765
- SUM(c.amount_usd)::numeric(12,4) AS total_usd
766
- FROM cost_log c LEFT JOIN builders b ON b.id = c.builder_id
767
- WHERE c.source = ANY($1)
768
- GROUP BY c.builder_id, b.github_login
769
- ORDER BY total_usd DESC`,
770
- [BOX_COST_SOURCES]
771
- );
772
- return { ledger, totals_by_builder: totalsRows };
773
- }
774
-
775
- // ---------------------------------------------------------------------------
776
- // DB helpers — the #701 intent queue + #760 terminal-access store
777
- // ---------------------------------------------------------------------------
778
-
779
- // Enqueue a provision/wake intent for a builder. At most ONE open intent per
780
- // builder (the ux_box_intents_open partial unique index) — a second concurrent
781
- // enqueue collides and we return the ALREADY-OPEN intent instead of piling up.
782
- // Returns { intent, created }: created=false means an open intent already
783
- // existed (the route reports it rather than queuing a duplicate).
784
- //
785
- // The row always inserts state='pending' — the runner drains it on its next tick.
786
- async function enqueueIntent(db, builderId, action, requestedBy) {
787
- try {
788
- const { rows } = await db.query(
789
- `INSERT INTO box_intents (builder_id, action, requested_by, state)
790
- VALUES ($1, $2, $3, 'pending') RETURNING *`,
791
- [builderId, action, requestedBy ?? null]
792
- );
793
- return { intent: rows[0], created: true };
794
- } catch (e) {
795
- // 23505 = unique_violation on ux_box_intents_open: an open intent exists.
796
- if (e && e.code === '23505') {
797
- const existing = await getOpenIntent(db, builderId);
798
- if (existing) return { intent: existing, created: false };
799
- }
800
- throw e;
801
- }
802
- }
803
-
804
- // The builder's currently-open (pending|running) intent, if any.
805
- async function getOpenIntent(db, builderId) {
806
- const { rows } = await db.query(
807
- `SELECT * FROM box_intents
808
- WHERE builder_id = $1 AND state IN ('pending', 'running')
809
- ORDER BY created_at DESC LIMIT 1`,
810
- [builderId]
811
- );
812
- return rows[0] || null;
813
- }
814
-
815
- // Atomically claim the oldest pending intent for the runner: flip it to
816
- // 'running', bump attempts. FOR UPDATE SKIP LOCKED so concurrent runner ticks
817
- // (or an overlapping run) never grab the same row. Returns the claimed row or null.
818
- async function claimNextIntent(db) {
819
- const { rows } = await db.query(
820
- `UPDATE box_intents
821
- SET state = 'running', attempts = attempts + 1, updated_at = now()
822
- WHERE id = (
823
- SELECT id FROM box_intents
824
- WHERE state = 'pending'
825
- ORDER BY created_at
826
- FOR UPDATE SKIP LOCKED
827
- LIMIT 1
828
- )
829
- RETURNING *`
830
- );
831
- return rows[0] || null;
832
- }
833
-
834
- // Resolve a claimed intent to its terminal state ('done' | 'error').
835
- async function resolveIntent(db, id, state, lastError) {
836
- await db.query(
837
- `UPDATE box_intents
838
- SET state = $2, last_error = $3, updated_at = now(), resolved_at = now()
839
- WHERE id = $1`,
840
- [id, state, lastError ?? null]
841
- );
842
- }
843
-
844
- // Record the web-terminal access a box published (POST /box/terminal, ADR 0038).
845
- // `url` is the box's current Cloudflare-Tunnel terminal URL; `credential` is its
846
- // per-box basic-auth password (user:pass behind the URL). BOTH are stored only
847
- // for an ACTIVE box — they are meaningless for a parked/none box, and storing
848
- // them would let a stale URL/credential be served after a park. Returns the
849
- // updated state (or null if there is no box). Mirrors the migration-070 staleness
850
- // model: the read side (GET /box/terminal) also gates on state='active'.
851
- async function setTerminalAccess(db, builderId, { url, credential }) {
852
- const { rows } = await db.query(
853
- `UPDATE builder_boxes
854
- SET terminal_url = $2, terminal_credential = $3, terminal_at = now(), updated_at = now()
855
- WHERE builder_id = $1 AND state = 'active'
856
- RETURNING state`,
857
- [builderId, url, credential || null]
858
- );
859
- if (rows[0]) return rows[0].state;
860
- const cur = await getBoxByBuilderId(db, builderId);
861
- return cur ? cur.state : null;
862
- }
863
-
864
- // Record the SSH HOST PUBLIC keys a box reported (POST /box/host-keys — task 1187 /
865
- // idea 235). `hostKeys` is newline-joined bare `<keytype> <base64>` pairs, already
866
- // validated + normalized by box-onboard.validateHostKeyLines (no hostname prefix, no
867
- // comment). Stored ONLY for an ACTIVE box, mirroring setTerminalAccess: a parked/none
868
- // box's host identity is meaningless to pin, and the value persists across a snapshot
869
- // wake anyway (so no NULL-clear on park). Returns the updated state, or the current
870
- // state if there is no active row to write. PUBLIC keys only — never the private half.
871
- async function setBoxHostKeys(db, builderId, { hostKeys }) {
872
- const { rows } = await db.query(
873
- `UPDATE builder_boxes
874
- SET host_keys = $2, host_keys_at = now(), updated_at = now()
875
- WHERE builder_id = $1 AND state = 'active'
876
- RETURNING state`,
877
- [builderId, hostKeys || null]
878
- );
879
- if (rows[0]) return rows[0].state;
880
- const cur = await getBoxByBuilderId(db, builderId);
881
- return cur ? cur.state : null;
882
- }
883
-
884
- // --- box code-version reporting (idea 332 / task 1316, ADR 0072) -------------
885
- //
886
- // A box reports its /workspace HEAD commit so the server can flag "running old
887
- // code". WRITTEN only when state='active' (same gate as setBoxHostKeys) — we
888
- // don't record a version for a box we're not serving.
889
- async function setBoxCodeVersion(db, builderId, { sha, committedAt }) {
890
- const { rows } = await db.query(
891
- `UPDATE builder_boxes
892
- SET code_sha = $2, code_committed_at = $3, code_reported_at = now(), updated_at = now()
893
- WHERE builder_id = $1 AND state = 'active'
894
- RETURNING state`,
895
- [builderId, sha || null, committedAt || null]
896
- );
897
- if (rows[0]) return rows[0].state;
898
- const cur = await getBoxByBuilderId(db, builderId);
899
- return cur ? cur.state : null;
900
- }
901
-
902
- // A fresh box reports its version within ~10 min (the box-source-fetch cron tick).
903
- // Give a generous grace so a just-provisioned box that hasn't reported YET isn't
904
- // flagged, and a generous commit-date lag so a box mid-pull right after a deploy
905
- // isn't flagged — the target is WEEKS-stale boxes, not minutes-behind ones.
906
- const CODE_REPORT_GRACE_MS = 30 * 60 * 1000; // 30 min after provision
907
- const CODE_STALE_LAG_MS = 2 * 24 * 60 * 60 * 1000; // 2 days behind deployed code
908
-
909
- // Pure: decide whether a box is running stale code. Inputs accept Date | ISO
910
- // string | epoch-ms (pg hands us Dates; tests pass either). Returns
911
- // { stale, reason } where reason ∈
912
- // not_active — only active boxes are judged (parked/destroyed → n/a)
913
- // awaiting_first_report — active, never reported, still inside provision grace
914
- // never_reported — active, past grace, never reported ⇒ NOT stale. Silence
915
- // is an unknown, not a verdict: it means the reporter never
916
- // ran, which is at least as likely to be OUR fault as the
917
- // box's. It was our fault for the whole life of this feature
918
- // (task 1002698) — ADR 0072 specified infra/box-report-version.sh
919
- // and it was never written (task 1002699), so EVERY box in the
920
- // fleet read as stale, and the banner's only remedy (destroy the
921
- // box, provision a fresh one) could not possibly clear it. A
922
- // builder who believed it rebuilt in a loop, losing uncommitted
923
- // work and spending money each time. Only a box that has ACTUALLY
924
- // REPORTED can be called stale — see 'behind'.
925
- // behind — reported, but its commit is > CODE_STALE_LAG_MS older
926
- // than the server's deployed commit ⇒ STALE (a box whose
927
- // `pull --ff-only` has been silently failing)
928
- // current — reported and within the lag window
929
- // unknown — reported, but we can't compare (no commit dates) ⇒ not stale
930
- function computeCodeStaleness({ state, provisionedAt, codeReportedAt, codeCommittedAt, serverCommittedAt, now = Date.now() } = {}) {
931
- const toMs = (x) => {
932
- if (x == null) return null;
933
- const t = x instanceof Date ? x.getTime() : new Date(x).getTime();
934
- return Number.isFinite(t) ? t : null;
935
- };
936
- if (state !== 'active') return { stale: false, reason: 'not_active' };
937
-
938
- const reported = toMs(codeReportedAt);
939
- if (reported == null) {
940
- const prov = toMs(provisionedAt);
941
- const withinGrace = prov != null && (now - prov) < CODE_REPORT_GRACE_MS;
942
- if (withinGrace) return { stale: false, reason: 'awaiting_first_report' };
943
- // Not stale — see the 'never_reported' note above. The reason is still returned
944
- // so an operator surface can show "we don't know", which is the honest answer.
945
- return { stale: false, reason: 'never_reported' };
946
- }
947
-
948
- const boxCommit = toMs(codeCommittedAt);
949
- const srvCommit = toMs(serverCommittedAt);
950
- if (boxCommit == null || srvCommit == null) return { stale: false, reason: 'unknown' };
951
- const lagMs = srvCommit - boxCommit;
952
- if (lagMs > CODE_STALE_LAG_MS) return { stale: true, reason: 'behind', lag_ms: lagMs };
953
- return { stale: false, reason: 'current' };
954
- }
955
-
956
- // PURE: are the box's REPORTED SSH host keys older than the droplet now running?
957
- // A rebuild mints fresh host keys, and until the new box re-reports (~10 min) the
958
- // row can still carry its predecessor's identity — pinning that is the "Host denied
959
- // (verification failed)" lockout. A report that predates `active_since` therefore
960
- // describes a droplet that no longer exists; so does a missing timestamp on either
961
- // side. Callers must fall back to a live keyscan rather than pin a dead identity.
962
- function hostKeysAreStale({ hostKeysAt, activeSince } = {}) {
963
- const toMs = (x) => {
964
- if (x == null) return null;
965
- const t = x instanceof Date ? x.getTime() : new Date(x).getTime();
966
- return Number.isFinite(t) ? t : null;
967
- };
968
- const reported = toMs(hostKeysAt);
969
- const activated = toMs(activeSince);
970
- if (reported == null || activated == null) return true;
971
- return reported < activated;
972
- }
973
-
974
- module.exports = {
975
- // pure — rank
976
- RANK_ORDER,
977
- DEFAULT_PROVISION_FLOOR,
978
- rankMeetsBoxFloor,
979
- boxScopeForRank,
980
- assertProvisionAllowed,
981
- // pure — size/cost
982
- SIZE_CATALOG,
983
- DEFAULT_SIZE,
984
- sizeHourlyUsd,
985
- computeActiveCostUsd,
986
- // pure — container cost ledger (#602)
987
- SPINUP_COST_USD,
988
- SNAPSHOT_MONTHLY_USD,
989
- BOX_COST_SOURCES,
990
- storageCostUsd,
991
- summarizeBoxCost,
992
- // pure — selection
993
- attendedRecently,
994
- claudeVetoHolds,
995
- effectiveActivityMs,
996
- idleReason,
997
- selectIdleBoxes,
998
- selectDormantBoxes,
999
- selectDormantWarnBoxes,
1000
- hasDormantWarning,
1001
- // pure — transitions
1002
- TRANSITIONS,
1003
- canTransition,
1004
- // pure — code staleness (idea 332 / task 1316)
1005
- computeCodeStaleness,
1006
- CODE_REPORT_GRACE_MS,
1007
- CODE_STALE_LAG_MS,
1008
- // pure — host-key staleness (task 1002509)
1009
- hostKeysAreStale,
1010
- // pure — droplet⇄row drift (task 1289)
1011
- DROPLET_HOLDING_STATES,
1012
- loginFromDroplet,
1013
- planDropletDrift,
1014
- // pure — ensure (#701)
1015
- decideEnsureAction,
1016
- // db
1017
- getBoxByBuilderId,
1018
- setBoxWidenPaths,
1019
- ensureBoxRow,
1020
- listBoxes,
1021
- recordEvent,
1022
- bumpActivity,
1023
- hasEverConnected,
1024
- setBoxState,
1025
- setBoxCodeVersion,
1026
- recordComputeCost,
1027
- recordSpinupCost,
1028
- recordStorageCost,
1029
- boxCostSummaryForBuilder,
1030
- boxCostLedger,
1031
- // db — intent queue (#701) + terminal access (#760)
1032
- enqueueIntent,
1033
- getOpenIntent,
1034
- claimNextIntent,
1035
- resolveIntent,
1036
- setTerminalAccess,
1037
- setBoxHostKeys,
1038
- };