@bongos/core 1.19.1073 → 1.19.1075

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 (377) hide show
  1. package/.bongos-core.json +404 -549
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/bin/bongos.js +1 -3
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +4 -60
  6. package/clients/bongos-client/index.cjs +4 -60
  7. package/clients/bongos-client/index.d.ts +5 -90
  8. package/clients/bongos-client/index.mjs +4 -60
  9. package/config/branding.neutral.json +1 -5
  10. package/config/modules.neutral.json +2 -3
  11. package/config/scheduled-routines.json +0 -2
  12. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  13. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  14. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  15. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  16. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
  17. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  18. package/docs/adr/README.md +1 -0
  19. package/docs/api/openapi.json +175 -1665
  20. package/docs/api-reference.md +9 -41
  21. package/docs/architecture.md +16 -1
  22. package/docs/branding-contract.md +0 -3
  23. package/docs/copy-inventory.md +454 -479
  24. package/docs/copy-registry.json +746 -978
  25. package/docs/file-map.md +5 -34
  26. package/docs/module-api-changelog.md +4 -0
  27. package/docs/modules-contract.md +13 -1
  28. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  29. package/docs/onboarding/diagrams/README.md +3 -3
  30. package/docs/onboarding/diagrams/assertions.json +2 -22
  31. package/docs/onboarding/primer.md +9 -28
  32. package/docs/page-inventory.json +4 -1
  33. package/docs/page-readings.json +1032 -1077
  34. package/docs/recipes/render-pilot.md +29 -10
  35. package/migrations/core_257_module_store_registry.sql +87 -0
  36. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  37. package/modules/agents/module.json +1 -0
  38. package/modules/autonomy/module.json +1 -0
  39. package/modules/builder-settings/module.json +1 -0
  40. package/modules/builder-settings/render-prefs.js +4 -5
  41. package/modules/copy-desk/docx.js +78 -5
  42. package/modules/copy-desk/module.json +1 -0
  43. package/modules/copy-desk/routes/copy-desk.js +10 -10
  44. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  45. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  46. package/modules/discord/module.json +1 -0
  47. package/modules/discord/ship-broadcast.js +3 -3
  48. package/modules/economy/credits.js +2 -2
  49. package/modules/economy/module.json +1 -0
  50. package/modules/economy/routes/credits.js +1 -2
  51. package/modules/government/catalog.js +13 -17
  52. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  53. package/modules/government/module.json +1 -0
  54. package/modules/government/protected-surfaces.json +3 -11
  55. package/modules/government/resolver.js +3 -46
  56. package/modules/government/routes/government.js +0 -2
  57. package/modules/government/session-scopes.js +43 -83
  58. package/modules/government/session-scopes.json +8 -18
  59. package/modules/grading/module.json +1 -0
  60. package/modules/hall-ui/module.json +1 -0
  61. package/modules/hall-ui/public/board-lib.js +2 -2
  62. package/modules/hall-ui/public/builders.js +29 -40
  63. package/modules/hall-ui/public/collab.html +1 -1
  64. package/modules/hall-ui/public/collab.js +1 -1
  65. package/modules/hall-ui/public/diagrams.html +3 -3
  66. package/modules/hall-ui/public/dom-utils.js +1 -1
  67. package/modules/hall-ui/public/gate.js +2 -2
  68. package/modules/hall-ui/public/gate.states.json +1 -1
  69. package/modules/hall-ui/public/hall-render.js +43 -79
  70. package/modules/hall-ui/public/index.html +5 -1
  71. package/modules/hall-ui/public/modules.js +1 -0
  72. package/modules/hall-ui/public/oversight.css +5 -5
  73. package/modules/hall-ui/public/palette.js +0 -11
  74. package/modules/hall-ui/public/primer.js +1 -1
  75. package/modules/hall-ui/public/sessions.html +1 -1
  76. package/modules/hall-ui/public/settings-sessions.js +1 -1
  77. package/modules/hall-ui/public/settings.css +0 -1
  78. package/modules/hall-ui/public/settings.html +4 -33
  79. package/modules/hall-ui/public/settings.js +3 -33
  80. package/modules/hall-ui/public/settings.states.json +2 -2
  81. package/modules/hall-ui/public/studio.html +1 -1
  82. package/modules/hall-ui/public/style.css +10 -13
  83. package/modules/hall-ui/public/task.html +4 -1
  84. package/modules/hall-ui/public/task.js +36 -1
  85. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  86. package/modules/hall-ui/public/tweak-editor.css +16 -0
  87. package/modules/hall-ui/public/tweak-editor.html +26 -3
  88. package/modules/hall-ui/public/tweak-editor.js +32 -1
  89. package/modules/hall-ui/public/work.js +1 -1
  90. package/modules/hall-ui/records/tweak-editor.md +1 -0
  91. package/modules/ideas/module.json +1 -0
  92. package/modules/ideas/projection.js +1 -1
  93. package/modules/lifecycle/db-claim-reads.js +1 -58
  94. package/modules/lifecycle/db-ship.js +4 -9
  95. package/modules/lifecycle/db.js +0 -4
  96. package/modules/lifecycle/github-push.js +12 -14
  97. package/modules/lifecycle/kickoff-checklist.js +0 -4
  98. package/modules/lifecycle/lifecycle.js +0 -8
  99. package/modules/lifecycle/module.json +1 -0
  100. package/modules/lifecycle/publish-reconciler.js +10 -10
  101. package/modules/lifecycle/routes/tasks.js +13 -15
  102. package/modules/lifecycle/ship-card.js +1 -1
  103. package/modules/lifecycle/task-visuals.js +2 -2
  104. package/modules/memory/module.json +1 -0
  105. package/modules/memory/routes/memory.js +2 -2
  106. package/modules/npm-release/module.json +1 -0
  107. package/modules/onboarding/module.json +1 -0
  108. package/modules/onboarding/onboarding-state.js +17 -36
  109. package/modules/onboarding/routes/access-requests.js +3 -3
  110. package/modules/platform-identity/module.json +1 -0
  111. package/modules/platform-identity/platform-identity.js +13 -5
  112. package/modules/platform-identity/routes/sso.js +5 -4
  113. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  114. package/modules/provisioning/capacity.js +1 -1
  115. package/modules/provisioning/module.json +1 -0
  116. package/modules/provisioning/provisioning.js +6 -5
  117. package/modules/provisioning/routes/provisioning.js +4 -4
  118. package/modules/provisioning/starter-bundles.js +5 -25
  119. package/modules/public-landing/module.json +1 -0
  120. package/modules/public-landing/public/assets/cosmos.css +1 -1
  121. package/modules/public-landing/public/contact.html +1 -1
  122. package/modules/public-landing/public/privacy.html +1 -1
  123. package/modules/public-landing/public/projects.html +101 -32
  124. package/modules/public-landing/public/projects.probes.json +2 -2
  125. package/modules/public-landing/public/projects.states.json +4 -3
  126. package/modules/public-landing/public/terms.html +1 -1
  127. package/modules/security/module.json +1 -0
  128. package/modules/sessions/db.js +1 -1
  129. package/modules/sessions/module.json +1 -0
  130. package/modules/specialities/module.json +1 -0
  131. package/modules/status-ui/module.json +1 -0
  132. package/modules/ui-design/module.json +1 -0
  133. package/package-lock.json +2 -2
  134. package/package.json +1 -1
  135. package/release-notes.json +84 -0
  136. package/scripts/gds/artifact-format.js +203 -0
  137. package/scripts/gds/artifact-staleness.js +2 -2
  138. package/scripts/gds/audit-authorship.sh +1 -13
  139. package/scripts/gds/backfill-task-rewards.js +5 -5
  140. package/scripts/gds/claim.js +0 -15
  141. package/scripts/gds/claude-materialize.js +2 -2
  142. package/scripts/gds/cli-lib.js +15 -15
  143. package/scripts/gds/codemod-rename-src-gds.js +1 -1
  144. package/scripts/gds/context-pack.js +5 -7
  145. package/scripts/gds/diagram-facts.js +12 -23
  146. package/scripts/gds/do-api.js +13 -80
  147. package/scripts/gds/doc-cli-guard.js +2 -0
  148. package/scripts/gds/doctor.js +4 -4
  149. package/scripts/gds/feedback-latest.js +139 -9
  150. package/scripts/gds/fitness-checks-identity.js +2 -7
  151. package/scripts/gds/fitness-checks-packaging.js +4 -4
  152. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  153. package/scripts/gds/fitness-ratchets.js +5 -5
  154. package/scripts/gds/fitness.js +10 -10
  155. package/scripts/gds/gen-api-client.js +4 -4
  156. package/scripts/gds/gen-api-docs.js +1 -1
  157. package/scripts/gds/gen-diagrams.js +49 -71
  158. package/scripts/gds/gen-repo-map.js +13 -14
  159. package/scripts/gds/gen-session-index.js +8 -8
  160. package/scripts/gds/http-api-client.js +12 -19
  161. package/scripts/gds/init.js +1 -1
  162. package/scripts/gds/leak-scan-allowlist.js +1 -1
  163. package/scripts/gds/lint-infra-exec.js +1 -1
  164. package/scripts/gds/local-preview-lib.js +8 -39
  165. package/scripts/gds/local-preview.js +2 -15
  166. package/scripts/gds/migration-namespace.js +3 -3
  167. package/scripts/gds/module-artifact.js +212 -0
  168. package/scripts/gds/module.js +95 -6
  169. package/scripts/gds/package-core.js +5 -116
  170. package/scripts/gds/plain-cards.js +1 -1
  171. package/scripts/gds/provision-config.js +2 -2
  172. package/scripts/gds/provision-net.js +2 -3
  173. package/scripts/gds/provision.js +9 -9
  174. package/scripts/gds/publish-manifest.js +1 -2
  175. package/scripts/gds/push-path-brief.js +10 -10
  176. package/scripts/gds/regen-instance-docs.js +1 -1
  177. package/scripts/gds/rename-history-check.js +1 -1
  178. package/scripts/gds/run-unit-tests.js +2 -16
  179. package/scripts/gds/sandbox-stage.js +39 -341
  180. package/scripts/gds/session-digest-build.js +2 -2
  181. package/scripts/gds/ship-deploy-target.js +13 -13
  182. package/scripts/gds/ship-flow.js +7 -7
  183. package/scripts/gds/ship-land.js +9 -10
  184. package/scripts/gds/ship-merge.js +4 -4
  185. package/scripts/gds/ship-preflight-steps.js +4 -4
  186. package/scripts/gds/ship-regen.js +15 -49
  187. package/scripts/gds/ship.js +13 -33
  188. package/scripts/gds/skill-preflight.js +9 -8
  189. package/scripts/gds/start.js +0 -15
  190. package/scripts/hall-preview/README.md +5 -5
  191. package/scripts/hall-preview/server.js +6 -6
  192. package/scripts/render-diagrams.sh +1 -1
  193. package/src/bongos/api-errors.js +1 -1
  194. package/src/bongos/api-prefix.js +4 -4
  195. package/src/bongos/auth-github.js +2 -3
  196. package/src/bongos/auth.js +51 -126
  197. package/src/bongos/db-kernel.js +2 -3
  198. package/src/bongos/db.js +5 -28
  199. package/src/bongos/module-scope-map.js +14 -29
  200. package/src/bongos/module-store.js +141 -0
  201. package/src/bongos/platform-visibility-gate.js +1 -1
  202. package/src/bongos/route-rank-check.js +3 -16
  203. package/src/bongos/routes/auth.js +0 -116
  204. package/src/bongos/routes/builders.js +5 -15
  205. package/src/bongos/routes/instance.js +8 -14
  206. package/src/bongos/routes/me.js +7 -23
  207. package/src/bongos/routes/modules.js +83 -0
  208. package/src/bongos/routes/security.js +3 -3
  209. package/src/bongos/routes.js +7 -2
  210. package/src/bongos/serve-internal.js +5 -6
  211. package/src/bongos-downloads.js +1 -6
  212. package/src/branding.js +2 -2
  213. package/src/build-info.js +4 -6
  214. package/src/instance-config.js +2 -2
  215. package/src/module-api.js +13 -6
  216. package/src/module-loader/catalog.js +3 -0
  217. package/src/module-loader/loader.js +4 -4
  218. package/src/module-loader/manifest-schema.js +16 -8
  219. package/src/module-seams.js +2 -3
  220. package/src/modules.js +42 -14
  221. package/tests/agents_spend_guard.mjs +1 -1
  222. package/tests/api_alias_caller_ratchet.mjs +1 -1
  223. package/tests/api_alias_exceptions.mjs +1 -1
  224. package/tests/api_client.mjs +1 -16
  225. package/tests/api_path_404.mjs +1 -1
  226. package/tests/auth_page_gate.mjs +10 -10
  227. package/tests/backfill_task_rewards.mjs +1 -1
  228. package/tests/canonical_profile_url.mjs +1 -3
  229. package/tests/claim_action.mjs +4 -1
  230. package/tests/claim_from_task_and_home.mjs +127 -0
  231. package/tests/{box_ship_permissions.mjs → cli_allowlist_permissions.mjs} +5 -5
  232. package/tests/cli_sessions.mjs +1 -1
  233. package/tests/consumer_layout_boot.mjs +2 -0
  234. package/tests/context_pack.mjs +13 -13
  235. package/tests/copy_desk_page_docx.mjs +33 -2
  236. package/tests/copy_inventory.mjs +7 -7
  237. package/tests/core_upgrade_runner.mjs +6 -6
  238. package/tests/credit_grant.mjs +2 -2
  239. package/tests/criterion_suggest.mjs +1 -1
  240. package/tests/deploy_divergence_line.mjs +11 -11
  241. package/tests/design_tokens_sync.mjs +3 -2
  242. package/tests/diagram_facts_offset.mjs +1 -1
  243. package/tests/do_api.mjs +106 -0
  244. package/tests/feedback_latest.mjs +122 -0
  245. package/tests/fitness.mjs +44 -42
  246. package/tests/fitness_ratchets.mjs +2 -2
  247. package/tests/gate_approvals.mjs +0 -1
  248. package/tests/github_push_land_proof.mjs +1 -1
  249. package/tests/go_live.mjs +3 -3
  250. package/tests/goal_advisory.mjs +3 -3
  251. package/tests/goal_suggest.mjs +3 -3
  252. package/tests/government_abuse_matrix.mjs +21 -19
  253. package/tests/government_ownership_scope.mjs +33 -35
  254. package/tests/government_parity.mjs +2 -2
  255. package/tests/government_protected_surfaces.mjs +56 -37
  256. package/tests/government_require_permission.mjs +3 -3
  257. package/tests/government_seed.mjs +37 -4
  258. package/tests/government_session_scope.mjs +92 -366
  259. package/tests/hall_audit.mjs +1 -1
  260. package/tests/hall_error_envelope.mjs +4 -0
  261. package/tests/hall_landing_boot.mjs +4 -1
  262. package/tests/hall_palette.mjs +1 -16
  263. package/tests/hall_record_world.mjs +6 -0
  264. package/tests/hall_tweak_editor.mjs +52 -1
  265. package/tests/helpers.mjs +1 -1
  266. package/tests/host_topology_skips.mjs +3 -4
  267. package/tests/html_comment_nesting.mjs +137 -0
  268. package/tests/infra_exec_paths.mjs +9 -10
  269. package/tests/init.mjs +4 -5
  270. package/tests/instance_manifest.mjs +18 -25
  271. package/tests/kickoff_checklist.mjs +1 -1
  272. package/tests/lib_sh_resolution.mjs +0 -221
  273. package/tests/local_preview.mjs +0 -12
  274. package/tests/migration_namespace.mjs +6 -6
  275. package/tests/module-scope-map.mjs +9 -9
  276. package/tests/module_api.mjs +2 -2
  277. package/tests/module_catalog.mjs +1 -1
  278. package/tests/module_cli.mjs +20 -20
  279. package/tests/module_contributions.mjs +6 -6
  280. package/tests/module_loader.mjs +6 -6
  281. package/tests/module_manifest.mjs +26 -17
  282. package/tests/module_route_rank.mjs +2 -2
  283. package/tests/module_store_publish.mjs +307 -0
  284. package/tests/module_store_publish_route.mjs +121 -0
  285. package/tests/module_store_registry_migration.mjs +70 -0
  286. package/tests/modules.mjs +56 -26
  287. package/tests/modules_route_wiring.mjs +1 -1
  288. package/tests/onboarding_route_signals.mjs +21 -31
  289. package/tests/onboarding_state.mjs +35 -49
  290. package/tests/paste_token_session_store.mjs +1 -1
  291. package/tests/permission_path.mjs +0 -3
  292. package/tests/platform_boot.mjs +1 -2
  293. package/tests/platform_visibility_gate.mjs +1 -1
  294. package/tests/profile_route.mjs +1 -6
  295. package/tests/project_modules_ui.mjs +17 -17
  296. package/tests/projects_hub.mjs +5 -5
  297. package/tests/projects_hub_app_step.mjs +138 -0
  298. package/tests/projects_hub_module_picker.mjs +77 -76
  299. package/tests/projects_hub_pre_uat.mjs +4 -5
  300. package/tests/provision.mjs +7 -7
  301. package/tests/provisioning_capacity.mjs +1 -1
  302. package/tests/provisioning_recommendations.mjs +2 -1
  303. package/tests/provisioning_starter_bundles.mjs +0 -22
  304. package/tests/publish_branch_route.mjs +11 -19
  305. package/tests/publish_manifest.mjs +2 -3
  306. package/tests/publish_reconciler.mjs +8 -8
  307. package/tests/push_path_brief.mjs +10 -9
  308. package/tests/rank_tier_single_source.mjs +1 -13
  309. package/tests/regrade_eligibility.mjs +1 -1
  310. package/tests/rename_history_restraint.mjs +1 -1
  311. package/tests/repo_map.mjs +12 -13
  312. package/tests/runner_drift.mjs +3 -21
  313. package/tests/sandbox_stage.mjs +57 -390
  314. package/tests/seam_wiring_guard.mjs +11 -11
  315. package/tests/search_isolation.mjs +3 -3
  316. package/tests/session_records.mjs +1 -1
  317. package/tests/session_rename_fallback.mjs +1 -22
  318. package/tests/session_start_freshness.mjs +3 -3
  319. package/tests/session_token_hash_db.mjs +1 -1
  320. package/tests/ship_card.mjs +1 -1
  321. package/tests/ship_ci_deploy.mjs +17 -17
  322. package/tests/ship_error_shape.mjs +1 -1
  323. package/tests/ship_premerge.mjs +1 -1
  324. package/tests/ship_resume.mjs +1 -1
  325. package/tests/skill_preflight.mjs +2 -2
  326. package/tests/skip_is_not_pass.mjs +1 -1
  327. package/tests/task_visual_slots.mjs +31 -0
  328. package/tests/terms_acceptance.mjs +2 -2
  329. package/tests/upgrade.mjs +4 -4
  330. package/tests/watch_sealed_floor.mjs +0 -1
  331. package/tests/wizard_draft_resume.mjs +2 -2
  332. package/tests/wizard_intent_resume.mjs +21 -10
  333. package/tests/wizard_preselect_why.mjs +18 -18
  334. package/docs/onboarding/browser-terminal-guide.md +0 -73
  335. package/docs/recipes/managed-settings-remote-control.md +0 -416
  336. package/modules/dev-box/CLAUDE.md +0 -15
  337. package/modules/dev-box/box-access.js +0 -623
  338. package/modules/dev-box/box-credential.js +0 -94
  339. package/modules/dev-box/box-onboard.js +0 -486
  340. package/modules/dev-box/boxes.js +0 -1038
  341. package/modules/dev-box/db.js +0 -39
  342. package/modules/dev-box/module.json +0 -19
  343. package/modules/dev-box/routes/box.js +0 -1223
  344. package/scripts/gds/box-auth-check.js +0 -160
  345. package/scripts/gds/box-infra.js +0 -321
  346. package/scripts/gds/box-sync.js +0 -216
  347. package/scripts/gds/box.js +0 -1332
  348. package/scripts/gds/cf-tunnel.js +0 -558
  349. package/scripts/gds/smoke-box.sh +0 -150
  350. package/scripts/gds/tree-preflight.js +0 -241
  351. package/src/bongos/app-pair.js +0 -172
  352. package/tests/app_pair.mjs +0 -168
  353. package/tests/box_access.mjs +0 -578
  354. package/tests/box_auth_check.mjs +0 -114
  355. package/tests/box_code_staleness.mjs +0 -129
  356. package/tests/box_connect_e2e.mjs +0 -196
  357. package/tests/box_cost.mjs +0 -70
  358. package/tests/box_credential.mjs +0 -125
  359. package/tests/box_dns_repoint.mjs +0 -153
  360. package/tests/box_env.mjs +0 -64
  361. package/tests/box_host_keys.mjs +0 -126
  362. package/tests/box_infra_resolve.mjs +0 -246
  363. package/tests/box_onboard.mjs +0 -317
  364. package/tests/box_scope_session.mjs +0 -119
  365. package/tests/box_sweep_docs.mjs +0 -119
  366. package/tests/box_sync.mjs +0 -55
  367. package/tests/box_sync_scope_report.mjs +0 -126
  368. package/tests/box_task_scope.mjs +0 -169
  369. package/tests/box_task_scope_pause.mjs +0 -66
  370. package/tests/box_terminal_ssrf.mjs +0 -96
  371. package/tests/boxes.mjs +0 -1585
  372. package/tests/cf_ruleset_preflight.mjs +0 -142
  373. package/tests/cf_tunnel.mjs +0 -496
  374. package/tests/docs_scrubber_damage.mjs +0 -111
  375. package/tests/module_scope_active_claims.mjs +0 -94
  376. package/tests/ship_api_client_pathspec.mjs +0 -100
  377. package/tests/tree_preflight.mjs +0 -243
package/tests/boxes.mjs DELETED
@@ -1,1585 +0,0 @@
1
- // tests/boxes.mjs
2
- //
3
- // Hermetic unit smoke for the per-builder box lifecycle (ADR 0031 §2/§5, #598).
4
- // Covers the PURE decision core of modules/dev-box/boxes.js (rank gate, size/cost
5
- // catalog, idle/dormant SELECTION, transition legality) and the dry-run/live
6
- // contract of modules/dev-box/do-api.js — all with NO database, NO DigitalOcean, NO
7
- // network, and NO real clock (nowMs is always injected). The DB-backed
8
- // orchestration in scripts/gds/box.js is exercised on the control plane in
9
- // dry-run; the decisions it relies on are pinned here.
10
- //
11
- // Run: node tests/boxes.mjs
12
-
13
- import { strict as assert } from 'node:assert';
14
- import { createRequire } from 'node:module';
15
-
16
- const require = createRequire(import.meta.url);
17
- const boxes = require('../modules/dev-box/boxes.js');
18
- const { DoApi, DoApiError } = require('../scripts/gds/do-api.js');
19
- const { isTunnelReadyStatus } = require('../modules/dev-box/routes/box.js');
20
-
21
- let passed = 0;
22
- let failed = 0;
23
- function t(name, fn) {
24
- try { fn(); passed++; console.log(` PASS ${name}`); }
25
- catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
26
- }
27
- async function ta(name, fn) {
28
- try { await fn(); passed++; console.log(` PASS ${name}`); }
29
- catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
30
- }
31
-
32
- const HOUR = 3_600_000;
33
- const DAY = 86_400_000;
34
- const NOW = Date.parse('2026-06-04T12:00:00Z');
35
-
36
- console.log('rank gate:');
37
-
38
- t('rankMeetsBoxFloor: ordering xenos < thetes < metic < archon', () => {
39
- assert.equal(boxes.rankMeetsBoxFloor('xenos', 'xenos'), true);
40
- assert.equal(boxes.rankMeetsBoxFloor('metic', 'xenos'), true);
41
- assert.equal(boxes.rankMeetsBoxFloor('archon', 'metic'), true);
42
- assert.equal(boxes.rankMeetsBoxFloor('xenos', 'metic'), false);
43
- assert.equal(boxes.rankMeetsBoxFloor('metic', 'archon'), false);
44
- // ADR 0034 regression (#869): thetes sits above the xenos floor, below metic.
45
- assert.equal(boxes.rankMeetsBoxFloor('thetes', 'xenos'), true);
46
- assert.equal(boxes.rankMeetsBoxFloor('thetes', 'metic'), false);
47
- assert.equal(boxes.rankMeetsBoxFloor('metic', 'thetes'), true);
48
- });
49
-
50
- t('rankMeetsBoxFloor: unknown rank or floor is false (fail-closed)', () => {
51
- assert.equal(boxes.rankMeetsBoxFloor('bogus', 'xenos'), false);
52
- assert.equal(boxes.rankMeetsBoxFloor(undefined, 'xenos'), false);
53
- assert.equal(boxes.rankMeetsBoxFloor('archon', 'bogus'), false);
54
- });
55
-
56
- t('boxScopeForRank: xenos/thetes→starter, metic/archon→full', () => {
57
- assert.equal(boxes.boxScopeForRank('xenos'), 'starter');
58
- assert.equal(boxes.boxScopeForRank('metic'), 'full');
59
- assert.equal(boxes.boxScopeForRank('archon'), 'full');
60
- // ADR 0034: a Thetes may hold a box but earns no scope uplift — still 'starter'.
61
- assert.equal(boxes.boxScopeForRank('thetes'), 'starter');
62
- });
63
-
64
- t('thetes can provision a box (ADR 0034 regression, #869)', () => {
65
- // The bug: thetes missing from RANK_ORDER → fail-closed below the xenos floor
66
- // → 403 on /box/ensure for the whole tier (builder 25 hit 6+ of these).
67
- assert.doesNotThrow(() => boxes.assertProvisionAllowed({ rank: 'thetes' }));
68
- const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'thetes', status: 'active' });
69
- assert.equal(d.action, 'provision');
70
- });
71
-
72
- t('assertProvisionAllowed: passes at/above floor, throws INSUFFICIENT_RANK below', () => {
73
- // Default floor (xenos): even a Xenos may get a (scoped) box — ADR 0031 §5(b).
74
- assert.doesNotThrow(() => boxes.assertProvisionAllowed({ rank: 'xenos' }));
75
- assert.doesNotThrow(() => boxes.assertProvisionAllowed({ rank: 'archon' }));
76
- // Raised floor: a Xenos is refused.
77
- let err;
78
- try { boxes.assertProvisionAllowed({ rank: 'xenos', floor: 'metic' }); }
79
- catch (e) { err = e; }
80
- assert.ok(err, 'should throw');
81
- assert.equal(err.code, 'INSUFFICIENT_RANK');
82
- assert.equal(err.required, 'metic');
83
- assert.equal(err.actual, 'xenos');
84
- });
85
-
86
- console.log('box block (idea 257 / task 1163):');
87
-
88
- t('blocked builder is denied a NEW box (state none → BOX_PROVISION_BLOCKED)', () => {
89
- const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'metic', status: 'active', blocked: true });
90
- assert.equal(d.action, 'denied');
91
- assert.equal(d.reason, 'BOX_PROVISION_BLOCKED');
92
- });
93
-
94
- t('blocked builder cannot WAKE a parked box (state parked → BOX_PROVISION_BLOCKED)', () => {
95
- const d = boxes.decideEnsureAction({ boxState: 'parked', rank: 'metic', status: 'active', blocked: true });
96
- assert.equal(d.action, 'denied');
97
- assert.equal(d.reason, 'BOX_PROVISION_BLOCKED');
98
- });
99
-
100
- t('block is FUTURE-ONLY: an already-active box still heartbeats (state active → ready)', () => {
101
- // Lars's "block future opens only" default: blocking does not tear down a
102
- // running box; the active box keeps heartbeating until it idles out or the
103
- // Archon explicitly closes it (close_current).
104
- const d = boxes.decideEnsureAction({ boxState: 'active', rank: 'metic', status: 'active', blocked: true });
105
- assert.equal(d.action, 'ready');
106
- });
107
-
108
- t('block does not disturb an in-flight provision (state provisioning → in_progress)', () => {
109
- const d = boxes.decideEnsureAction({ boxState: 'provisioning', rank: 'metic', status: 'active', blocked: true });
110
- assert.equal(d.action, 'in_progress');
111
- });
112
-
113
- t('NOT blocked is unchanged (state none → provision)', () => {
114
- const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'metic', status: 'active', blocked: false });
115
- assert.equal(d.action, 'provision');
116
- // Default (blocked omitted) must behave exactly as before this feature.
117
- const d2 = boxes.decideEnsureAction({ boxState: 'parked', rank: 'metic', status: 'active' });
118
- assert.equal(d2.action, 'wake');
119
- });
120
-
121
- console.log('size + cost catalog:');
122
-
123
- t('default size is in the catalog and is the 4 GB box', () => {
124
- assert.ok(boxes.SIZE_CATALOG[boxes.DEFAULT_SIZE], 'default size present');
125
- assert.equal(boxes.DEFAULT_SIZE, 's-2vcpu-4gb');
126
- });
127
-
128
- t('computeActiveCostUsd: hours × hourly rate', () => {
129
- const cost = boxes.computeActiveCostUsd({
130
- sizeSlug: 's-2vcpu-4gb', activeSinceMs: NOW - 10 * HOUR, untilMs: NOW,
131
- });
132
- // 10h × 0.03571 ≈ 0.3571
133
- assert.ok(Math.abs(cost - 0.3571) < 0.001, `expected ~0.3571, got ${cost}`);
134
- });
135
-
136
- t('computeActiveCostUsd: zero/negative/invalid interval → 0', () => {
137
- assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 's-2vcpu-4gb', activeSinceMs: NOW, untilMs: NOW }), 0);
138
- assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 's-2vcpu-4gb', activeSinceMs: NOW, untilMs: NOW - HOUR }), 0);
139
- assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 's-2vcpu-4gb', activeSinceMs: null, untilMs: NOW }), 0);
140
- assert.equal(boxes.computeActiveCostUsd({ sizeSlug: 'no-such-size', activeSinceMs: NOW - HOUR, untilMs: NOW }), 0);
141
- });
142
-
143
- console.log('idle / dormant selection:');
144
-
145
- t('selectIdleBoxes: picks active boxes past the idle threshold, ignores others', () => {
146
- const rows = [
147
- { state: 'active', github_login: 'old', last_activity_at: new Date(NOW - 45 * 60000).toISOString() },
148
- { state: 'active', github_login: 'fresh', last_activity_at: new Date(NOW - 5 * 60000).toISOString() },
149
- { state: 'parked', github_login: 'parked', last_activity_at: new Date(NOW - 999 * 60000).toISOString() },
150
- ];
151
- const idle = boxes.selectIdleBoxes(rows, { idleMinutes: 30, nowMs: NOW });
152
- assert.equal(idle.length, 1);
153
- assert.equal(idle[0].github_login, 'old');
154
- });
155
-
156
- t('selectIdleBoxes: COALESCEs to active_since when never heartbeated', () => {
157
- const rows = [
158
- // No heartbeat, but active_since is recent → NOT idle (full window from boot).
159
- { state: 'active', github_login: 'booting', last_activity_at: null, active_since: new Date(NOW - 2 * 60000).toISOString() },
160
- // No heartbeat, active_since old → idle.
161
- { state: 'active', github_login: 'stale', last_activity_at: null, active_since: new Date(NOW - 90 * 60000).toISOString() },
162
- ];
163
- const idle = boxes.selectIdleBoxes(rows, { idleMinutes: 30, nowMs: NOW });
164
- assert.deepEqual(idle.map((r) => r.github_login), ['stale']);
165
- });
166
-
167
- t('selectIdleBoxes: guards bad inputs', () => {
168
- assert.deepEqual(boxes.selectIdleBoxes(null, { idleMinutes: 30, nowMs: NOW }), []);
169
- assert.deepEqual(boxes.selectIdleBoxes([], { idleMinutes: 0, nowMs: NOW }), []);
170
- assert.deepEqual(boxes.selectIdleBoxes([], { idleMinutes: 30, nowMs: NaN }), []);
171
- });
172
-
173
- t('selectIdleBoxes: with NO cap configured, claude_active=true skips regardless of age (pre-1003507 path)', () => {
174
- const rows = [
175
- // Old + claude running → kept alive.
176
- { state: 'active', github_login: 'clauding', last_activity_at: new Date(NOW - 60 * 60000).toISOString(), claude_active: true },
177
- // Old + claude NOT running → idle.
178
- { state: 'active', github_login: 'idle', last_activity_at: new Date(NOW - 60 * 60000).toISOString(), claude_active: false },
179
- // Old + no claude_active field (legacy row) → idle.
180
- { state: 'active', github_login: 'legacy', last_activity_at: new Date(NOW - 60 * 60000).toISOString() },
181
- ];
182
- const idle = boxes.selectIdleBoxes(rows, { idleMinutes: 30, nowMs: NOW });
183
- assert.deepEqual(idle.map((r) => r.github_login).sort(), ['idle', 'legacy']);
184
- });
185
-
186
- // --- task 1003507: the unattended cap ---------------------------------------
187
- //
188
- // The bug had TWO legs and either one alone kept an abandoned box alive:
189
- // (1) claude_active is a latch only a ping can clear, and the sweep honoured it
190
- // with no time bound at all -> unreapable at every threshold, forever;
191
- // (2) load > 0.2 kept last_activity_at fresh, so the idle clock never expired.
192
- // The reported box had BOTH, which is why these tests model a FRESH heartbeat:
193
- // a fix that only bounds (1) leaves the box alive through (2) and looks green
194
- // against a stale-heartbeat fixture.
195
- const CAP = 12;
196
- const HOURS = (n) => n * 60 * 60000;
197
- const at = (ms) => new Date(ms).toISOString();
198
-
199
- // The reported incident, to scale: droplet up 5 days, heartbeat 5 min old, a
200
- // `claude` process running, nobody attached for 21h.
201
- const abandonedBox = () => ({
202
- state: 'active', github_login: 'abandoned', claude_active: true,
203
- claude_active_since: at(NOW - HOURS(21)),
204
- last_activity_at: at(NOW - 5 * 60000),
205
- last_attached_at: at(NOW - HOURS(21)),
206
- active_since: at(NOW - 5 * 24 * 60 * 60000),
207
- });
208
- const swept = (rows, over = {}) => boxes.selectIdleBoxes(rows, {
209
- idleMinutes: 30, nowMs: NOW, unattendedMaxHours: CAP, ...over,
210
- }).map((r) => r.github_login);
211
-
212
- t('selectIdleBoxes: an unattended box past the cap is reaped even with a FRESH heartbeat', () => {
213
- // The regression that matters: last_activity_at is 5 min old, so the ordinary
214
- // idle rule can never fire. Only the attachment clock catches this box.
215
- assert.deepEqual(swept([abandonedBox()]), ['abandoned']);
216
- });
217
-
218
- t('selectIdleBoxes: a box with a human attached is spared regardless of the cap', () => {
219
- assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - 10 * 60000) }]), []);
220
- assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - HOURS(11)) }]), []);
221
- });
222
-
223
- t('selectIdleBoxes: the cap boundary', () => {
224
- assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - HOURS(12) + 60000) }]), []);
225
- assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - HOURS(12) - 60000) }]), ['abandoned']);
226
- });
227
-
228
- t('selectIdleBoxes: a latched claude_active box that stopped heartbeating is reaped', () => {
229
- // Leg (1) on its own: silence cannot clear the flag, so before this fix the
230
- // row survived every sweep at every threshold.
231
- assert.deepEqual(swept([{
232
- state: 'active', github_login: 'zombie', claude_active: true,
233
- claude_active_since: at(NOW - 365 * 24 * 60 * 60000),
234
- last_activity_at: at(NOW - 365 * 24 * 60 * 60000),
235
- last_attached_at: null,
236
- }]), ['zombie']);
237
- });
238
-
239
- t('selectIdleBoxes: a box with NO attachment data keeps the old behaviour', () => {
240
- // A box still running the pre-1003507 heartbeat never reports `attached`, so
241
- // last_attached_at stays NULL. NULL must read as "no data", never as
242
- // "unattended" — otherwise this fix parks every un-upgraded box mid-work.
243
- // (core_238 deliberately does not backfill the column for the same reason.)
244
- assert.deepEqual(swept([{
245
- state: 'active', github_login: 'legacy', claude_active: true,
246
- claude_active_since: at(NOW - HOURS(2)),
247
- last_activity_at: at(NOW - 5 * 60000),
248
- last_attached_at: null,
249
- }]), []);
250
- // ...but it is still reapable the ordinary way once it goes quiet.
251
- assert.deepEqual(swept([{
252
- state: 'active', github_login: 'legacy2', claude_active: false,
253
- last_activity_at: at(NOW - HOURS(1)), last_attached_at: null,
254
- }]), ['legacy2']);
255
- });
256
-
257
- t('selectIdleBoxes: omitting the cap preserves the unbounded veto exactly', () => {
258
- assert.deepEqual(swept([abandonedBox()], { unattendedMaxHours: undefined }), []);
259
- assert.deepEqual(swept([abandonedBox()], { unattendedMaxHours: 0 }), []);
260
- });
261
-
262
- t('attendedRecently: NULL is unknown (true), not unattended', () => {
263
- assert.equal(boxes.attendedRecently({ last_attached_at: null }, { nowMs: NOW, unattendedMaxHours: CAP }), true);
264
- assert.equal(boxes.attendedRecently({ last_attached_at: at(NOW - HOURS(1)) }, { nowMs: NOW, unattendedMaxHours: CAP }), true);
265
- assert.equal(boxes.attendedRecently({ last_attached_at: at(NOW - HOURS(20)) }, { nowMs: NOW, unattendedMaxHours: CAP }), false);
266
- // No cap configured -> nothing is ever "unattended".
267
- assert.equal(boxes.attendedRecently({ last_attached_at: at(NOW - HOURS(999)) }, { nowMs: NOW }), true);
268
- });
269
-
270
- t('claudeVetoHolds: a true flag with no recorded start is refused', () => {
271
- // An unknown latch age is exactly the forever-latch being fixed, so it must
272
- // NOT be honoured — core_238 stamps every box that is claude_active on deploy.
273
- assert.equal(boxes.claudeVetoHolds(
274
- { claude_active: true, claude_active_since: null }, { nowMs: NOW, unattendedMaxHours: CAP }
275
- ), false);
276
- assert.equal(boxes.claudeVetoHolds(
277
- { claude_active: true, claude_active_since: at(NOW - HOURS(2)) }, { nowMs: NOW, unattendedMaxHours: CAP }
278
- ), true);
279
- assert.equal(boxes.claudeVetoHolds(
280
- { claude_active: true, claude_active_since: at(NOW - HOURS(20)) }, { nowMs: NOW, unattendedMaxHours: CAP }
281
- ), false);
282
- assert.equal(boxes.claudeVetoHolds({ claude_active: false }, { nowMs: NOW, unattendedMaxHours: CAP }), false);
283
- });
284
-
285
- t('idleReason: names the clock that actually selected the box', () => {
286
- const opts = { nowMs: NOW, idleMinutes: 30, unattendedMaxHours: CAP };
287
- assert.match(boxes.idleReason(abandonedBox(), opts), /unattended 21h/);
288
- assert.match(boxes.idleReason({
289
- state: 'active', claude_active: false,
290
- last_activity_at: at(NOW - HOURS(1)), last_attached_at: at(NOW - 60000),
291
- }, opts), /idle 60m/);
292
- });
293
-
294
- t('selectDormantBoxes: picks parked boxes past the dormant threshold', () => {
295
- const rows = [
296
- { state: 'parked', github_login: 'gone', parked_at: new Date(NOW - 20 * DAY).toISOString() },
297
- { state: 'parked', github_login: 'recent', parked_at: new Date(NOW - 3 * DAY).toISOString() },
298
- { state: 'active', github_login: 'working', parked_at: null },
299
- ];
300
- const dormant = boxes.selectDormantBoxes(rows, { dormantDays: 14, nowMs: NOW });
301
- assert.equal(dormant.length, 1);
302
- assert.equal(dormant[0].github_login, 'gone');
303
- });
304
-
305
- console.log('transition legality:');
306
-
307
- t('canTransition: legal moves', () => {
308
- assert.equal(boxes.canTransition('provision', 'none'), true);
309
- assert.equal(boxes.canTransition('provision', 'destroyed'), true);
310
- assert.equal(boxes.canTransition('park', 'active'), true);
311
- assert.equal(boxes.canTransition('wake', 'parked'), true);
312
- assert.equal(boxes.canTransition('deprovision', 'parked'), true);
313
- assert.equal(boxes.canTransition('deprovision', 'active'), true);
314
- });
315
-
316
- t('canTransition: illegal moves', () => {
317
- assert.equal(boxes.canTransition('provision', 'active'), false, 'no double-provision');
318
- assert.equal(boxes.canTransition('park', 'parked'), false, 'no double-park');
319
- assert.equal(boxes.canTransition('wake', 'active'), false, 'no wake an active box');
320
- assert.equal(boxes.canTransition('park', 'none'), false);
321
- });
322
-
323
- console.log('\nprovision dry-run inertness (#1034):');
324
-
325
- // cmdProvision is dependency-injected (pool, boxes, boxOnboard) and box.js exports
326
- // it for tests (the CLI IIFE is require.main-guarded). We inject recording fakes
327
- // so we can assert the literal contract the #1034 bug violated: a dry-run
328
- // (no --apply) must not write to builder_boxes — i.e. ensureBoxRow / setBoxState /
329
- // recordEvent must never fire, and a MISSING row (getBoxByBuilderId → null) must
330
- // still be treated as the provisionable 'none' state.
331
- const boxCli = require('../scripts/gds/box.js');
332
-
333
- function fakeProvisionDeps({ existingBox = null } = {}) {
334
- const calls = [];
335
- const fakeBoxes = {
336
- assertProvisionAllowed() {}, // no throw → rank gate passes
337
- boxScopeForRank() { return 'full'; },
338
- canTransition: boxes.canTransition, // exercise the REAL transition map
339
- async getBoxByBuilderId() { calls.push('getBoxByBuilderId'); return existingBox; },
340
- async ensureBoxRow() { calls.push('ensureBoxRow'); return { id: 1, state: 'none', provisioned_at: null }; },
341
- async setBoxState() { calls.push('setBoxState'); return {}; },
342
- async recordEvent() { calls.push('recordEvent'); },
343
- async setTerminalAccess() { calls.push('setTerminalAccess'); },
344
- };
345
- const fakeOnboard = { async listSshKeys() { return []; }, wrapManagedBlock() { return ''; } };
346
- const pool = {
347
- async query() {
348
- return { rows: [{ id: 42, github_login: 'tester', display_name: 'T', rank: 'metic', status: 'active' }] };
349
- },
350
- };
351
- return { calls, fakeBoxes, fakeOnboard, pool };
352
- }
353
-
354
- await ta('cmdProvision dry-run (no row yet) reads the gate but writes NOTHING to builder_boxes', async () => {
355
- const { calls, fakeBoxes, fakeOnboard, pool } = fakeProvisionDeps({ existingBox: null });
356
- const res = await boxCli.cmdProvision(pool, fakeBoxes, fakeOnboard, 'tester', { apply: false });
357
- assert.equal(res.dryRun, true, 'dry-run returns {dryRun:true}');
358
- assert.ok(calls.includes('getBoxByBuilderId'), 'gate reads via getBoxByBuilderId');
359
- assert.ok(!calls.includes('ensureBoxRow'), 'dry-run must NOT ensureBoxRow (the #1034 bug)');
360
- assert.ok(!calls.includes('setBoxState'), 'dry-run must NOT write box state');
361
- assert.ok(!calls.includes('recordEvent'), 'dry-run must NOT write box_events');
362
- });
363
-
364
- await ta('cmdProvision dry-run still rejects an illegal transition (active box → no double-provision)', async () => {
365
- const { calls, fakeBoxes, fakeOnboard, pool } = fakeProvisionDeps({ existingBox: { state: 'active' } });
366
- const res = await boxCli.cmdProvision(pool, fakeBoxes, fakeOnboard, 'tester', { apply: false });
367
- assert.equal(res.ok, false, "an 'active' box cannot be re-provisioned");
368
- assert.match(res.error, /'active'/);
369
- assert.ok(!calls.includes('ensureBoxRow'), 'a rejected dry-run also writes nothing');
370
- });
371
-
372
- console.log('DigitalOcean client (do-api):');
373
-
374
- await ta('dry-run is the DEFAULT and sends NO network request', async () => {
375
- let called = 0;
376
- const api = new DoApi({ fetchImpl: () => { called++; throw new Error('should not fetch'); } });
377
- assert.equal(api.dryRun, true);
378
- const r = await api.createDroplet({ name: 'x', region: 'nyc3', size: 's-2vcpu-4gb', image: 'ubuntu-24-04-x64' });
379
- assert.equal(called, 0, 'fetch must not be called in dry-run');
380
- assert.equal(r.dryRun, true);
381
- assert.ok(r.droplet && String(r.droplet.id).startsWith('dryrun-'), 'returns a synthetic droplet');
382
- });
383
-
384
- await ta('live mode without a token throws clearly (never silent no-op)', async () => {
385
- assert.throws(
386
- () => new DoApi({ dryRun: false }),
387
- (e) => e instanceof DoApiError && /DO_API_TOKEN/.test(e.message)
388
- );
389
- });
390
-
391
- await ta('live createDroplet builds the right request via injected fetch', async () => {
392
- const seen = {};
393
- const fakeFetch = async (url, opts) => {
394
- seen.url = url; seen.opts = opts;
395
- return { ok: true, status: 202, text: async () => JSON.stringify({ droplet: { id: 123, status: 'new' } }) };
396
- };
397
- const api = new DoApi({ dryRun: false, token: 'tok_test', fetchImpl: fakeFetch });
398
- const r = await api.createDroplet({ name: 'otb-box-someone', region: 'nyc3', size: 's-2vcpu-4gb', image: 'ubuntu-24-04-x64', tags: ['otb-builder-box'] });
399
- assert.equal(r.droplet.id, 123);
400
- assert.match(seen.url, /\/v2\/droplets$/);
401
- assert.equal(seen.opts.method, 'POST');
402
- assert.equal(seen.opts.headers.Authorization, 'Bearer tok_test');
403
- const body = JSON.parse(seen.opts.body);
404
- assert.equal(body.name, 'otb-box-someone');
405
- assert.equal(body.size, 's-2vcpu-4gb');
406
- });
407
-
408
- await ta('live mode surfaces a DO error body as DoApiError', async () => {
409
- const fakeFetch = async () => ({ ok: false, status: 422, text: async () => JSON.stringify({ id: 'unprocessable', message: 'bad size' }) });
410
- const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
411
- let err;
412
- try { await api.getDroplet(1); } catch (e) { err = e; }
413
- assert.ok(err instanceof DoApiError);
414
- assert.equal(err.status, 422);
415
- assert.match(err.message, /bad size/);
416
- });
417
-
418
- console.log('DB helpers (fake pg client — no real Postgres):');
419
-
420
- // A fake pg client: records every query and returns canned rows in order.
421
- function fakeDb(responses = []) {
422
- const calls = [];
423
- let i = 0;
424
- return {
425
- calls,
426
- async query(sql, params) { calls.push({ sql, params }); return responses[i++] || { rows: [] }; },
427
- };
428
- }
429
-
430
- await ta('recordComputeCost: tallies + writes a cost_log row keyed PER INTERVAL', async () => {
431
- const db = fakeDb();
432
- const box = { id: 7, builder_id: 42, size_slug: 's-2vcpu-4gb', active_since: new Date(NOW - 5 * HOUR).toISOString() };
433
- const cost = await boxes.recordComputeCost(db, { box, untilMs: NOW, actor: 'sweep:idle' });
434
- assert.ok(Math.abs(cost - 0.1786) < 0.001, `5h × rate ≈ 0.1786, got ${cost}`);
435
- // 3 writes: UPDATE tally, INSERT box_events(cost), INSERT cost_log.
436
- assert.equal(db.calls.length, 3);
437
- const costLog = db.calls.find((c) => /INSERT INTO cost_log/.test(c.sql));
438
- assert.ok(costLog, 'cost_log insert fired');
439
- const sourceRef = costLog.params[2];
440
- // source_ref must be per-interval (box id + interval end), so a second park
441
- // of the same box does not collide on cost_log's UNIQUE(source, source_ref).
442
- assert.match(sourceRef, /^builder_boxes#7@\d+$/, `source_ref per-interval, got ${sourceRef}`);
443
- assert.equal(costLog.params[3], 42, 'builder_id attributed');
444
- });
445
-
446
- await ta('recordComputeCost: zero interval writes nothing', async () => {
447
- const db = fakeDb();
448
- const box = { id: 1, builder_id: 2, size_slug: 's-2vcpu-4gb', active_since: null };
449
- const cost = await boxes.recordComputeCost(db, { box, untilMs: NOW });
450
- assert.equal(cost, 0);
451
- assert.equal(db.calls.length, 0, 'no writes for a zero-cost interval');
452
- });
453
-
454
- await ta('bumpActivity: an active box resets its clock with NO per-heartbeat event', async () => {
455
- const db = fakeDb([{ rows: [{ state: 'active' }] }]);
456
- const state = await boxes.bumpActivity(db, 42);
457
- assert.equal(state, 'active');
458
- assert.equal(db.calls.length, 1, 'only the UPDATE — heartbeats must not churn box_events');
459
- assert.match(db.calls[0].sql, /UPDATE builder_boxes[\s\S]*state = 'active'/);
460
- });
461
-
462
- await ta('bumpActivity: with claudeActive=true writes claude_active column', async () => {
463
- const db = fakeDb([{ rows: [{ state: 'active' }] }]);
464
- const state = await boxes.bumpActivity(db, 42, { claudeActive: true });
465
- assert.equal(state, 'active');
466
- assert.match(db.calls[0].sql, /claude_active = \$2/);
467
- assert.deepEqual(db.calls[0].params, [42, true]);
468
- });
469
-
470
- await ta('bumpActivity: without claudeActive does NOT write claude_active column', async () => {
471
- const db = fakeDb([{ rows: [{ state: 'active' }] }]);
472
- await boxes.bumpActivity(db, 42);
473
- assert.ok(!db.calls[0].sql.includes('claude_active'), 'no claude_active column when flag not supplied');
474
- });
475
-
476
- await ta('bumpActivity: claudeActive=true stamps first_connected_at write-once (#823)', async () => {
477
- const db = fakeDb([{ rows: [{ state: 'active' }] }]);
478
- await boxes.bumpActivity(db, 42, { claudeActive: true });
479
- // COALESCE so a later beat never moves it — the monotonic "connected" signal.
480
- assert.match(db.calls[0].sql, /first_connected_at = COALESCE\(first_connected_at, now\(\)\)/);
481
- // Still no extra bind param — first_connected_at uses now() inline.
482
- assert.deepEqual(db.calls[0].params, [42, true]);
483
- });
484
-
485
- await ta('bumpActivity: claudeActive=false does NOT touch first_connected_at (never un-ticks)', async () => {
486
- const db = fakeDb([{ rows: [{ state: 'active' }] }]);
487
- await boxes.bumpActivity(db, 42, { claudeActive: false });
488
- assert.ok(!db.calls[0].sql.includes('first_connected_at'), 'a claude_active=false beat leaves first_connected_at intact');
489
- });
490
-
491
- await ta('hasEverConnected: true when first_connected_at is set, false otherwise (#823)', async () => {
492
- const connected = fakeDb([{ rows: [{ connected: true }] }]);
493
- assert.equal(await boxes.hasEverConnected(connected, 42), true);
494
- assert.match(connected.calls[0].sql, /first_connected_at IS NOT NULL/);
495
-
496
- const notYet = fakeDb([{ rows: [{ connected: false }] }]);
497
- assert.equal(await boxes.hasEverConnected(notYet, 42), false);
498
-
499
- const noRow = fakeDb([{ rows: [] }]); // no box row at all
500
- assert.equal(await boxes.hasEverConnected(noRow, 42), false);
501
- });
502
-
503
- await ta('setBoxState: rejects a non-allowlisted column (injection guard)', async () => {
504
- const db = fakeDb([{ rows: [{ id: 1 }] }]);
505
- await assert.rejects(
506
- () => boxes.setBoxState(db, 1, 'active', { 'ip = oops; DROP TABLE builder_boxes; --': 'x' }),
507
- /not.*allowlist|allowlist/i
508
- );
509
- // A legit column still goes through, parameterized.
510
- const db2 = fakeDb([{ rows: [{ id: 1, state: 'active' }] }]);
511
- const row = await boxes.setBoxState(db2, 1, 'active', { ip: '1.2.3.4', snapshot_id: null });
512
- assert.ok(row);
513
- assert.match(db2.calls[0].sql, /UPDATE builder_boxes SET state = \$2/);
514
- });
515
-
516
- await ta('setBoxState: host_keys + host_keys_at are allowlisted, parameterized (idea 310)', async () => {
517
- const db = fakeDb([{ rows: [{ id: 1, state: 'destroyed' }] }]);
518
- const row = await boxes.setBoxState(db, 1, 'destroyed', { host_keys: null, host_keys_at: null });
519
- assert.ok(row);
520
- // Settable (no allowlist throw) and bound as params, never interpolated.
521
- assert.match(db.calls[0].sql, /host_keys = \$\d+/);
522
- assert.match(db.calls[0].sql, /host_keys_at = \$\d+/);
523
- assert.deepEqual(db.calls[0].params, [1, 'destroyed', null, null]);
524
- });
525
-
526
- console.log('\nidea 310 — a rebuilt box never serves a stale SSH identity:');
527
-
528
- await ta('deprovisionOne clears host_keys + host_keys_at on teardown', async () => {
529
- // The lockout bug: a reaped box rebuilds with fresh host keys, but the GDS row
530
- // kept the OLD keys (~10 min) so GET /box/me served a stale identity and the
531
- // desktop app pinned the WRONG key. deprovisionOne must null them.
532
- const calls = [];
533
- const fakeBoxes = {
534
- async recordComputeCost() { calls.push('recordComputeCost'); return 0; },
535
- async recordStorageCost() { calls.push('recordStorageCost'); return 0; },
536
- async setBoxState(_db, _bid, state, patch) { calls.push({ state, patch }); return {}; },
537
- async recordEvent() { calls.push('recordEvent'); },
538
- };
539
- const doApi = { dryRun: true, async deleteDroplet() {}, async deleteSnapshot() {} };
540
- // No droplet/snapshot/parked_at → no cost or DO calls; CF/DNS unconfigured in
541
- // the test env → tunnel + blackhole-DNS teardown are skipped.
542
- const box = {
543
- id: 7, builder_id: 42, state: 'active', droplet_id: null, snapshot_id: null,
544
- parked_at: null, github_login: 'tester', hostname: 'box-tester.dev.example.com',
545
- };
546
- await boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'test:sweep', login: 'tester' });
547
- const destroyed = calls.find((c) => c && c.state === 'destroyed');
548
- assert.ok(destroyed, 'sets state destroyed');
549
- assert.equal(destroyed.patch.host_keys, null, 'host_keys cleared on teardown');
550
- assert.equal(destroyed.patch.host_keys_at, null, 'host_keys_at cleared on teardown');
551
- });
552
-
553
- await ta('bumpActivity: a non-active box is a no-op that reports current state', async () => {
554
- // UPDATE matches nothing → falls through to a state read.
555
- const db = fakeDb([{ rows: [] }, { rows: [{ state: 'parked' }] }]);
556
- const state = await boxes.bumpActivity(db, 42);
557
- assert.equal(state, 'parked');
558
- assert.equal(db.calls.length, 2, 'no heartbeat event written for a non-active box');
559
- });
560
-
561
- console.log('\nensure decision (#701 onboarding middleware):');
562
-
563
- t('decideEnsureAction: inactive builder is denied (fail-closed, checked first)', () => {
564
- const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'archon', status: 'inactive' });
565
- assert.equal(d.action, 'denied');
566
- assert.equal(d.reason, 'BUILDER_INACTIVE');
567
- });
568
-
569
- t('decideEnsureAction: below the floor is denied with required/actual', () => {
570
- const d = boxes.decideEnsureAction({ boxState: 'none', rank: 'xenos', status: 'active', floor: 'metic' });
571
- assert.equal(d.action, 'denied');
572
- assert.equal(d.reason, 'INSUFFICIENT_RANK');
573
- assert.equal(d.required, 'metic');
574
- assert.equal(d.actual, 'xenos');
575
- });
576
-
577
- t('decideEnsureAction: no box → provision (auto-provision); a Xenos qualifies at the default floor', () => {
578
- for (const s of ['none', 'destroyed', 'error', undefined, null]) {
579
- assert.equal(boxes.decideEnsureAction({ boxState: s, rank: 'xenos', status: 'active' }).action, 'provision', `state=${s}`);
580
- }
581
- });
582
-
583
- t('decideEnsureAction: parked → wake (wake-on-connect)', () => {
584
- assert.equal(boxes.decideEnsureAction({ boxState: 'parked', rank: 'xenos', status: 'active' }).action, 'wake');
585
- });
586
-
587
- t('decideEnsureAction: active → ready; in-flight states → in_progress', () => {
588
- assert.equal(boxes.decideEnsureAction({ boxState: 'active', rank: 'metic', status: 'active' }).action, 'ready');
589
- for (const s of ['provisioning', 'waking', 'parking']) {
590
- assert.equal(boxes.decideEnsureAction({ boxState: s, rank: 'metic', status: 'active' }).action, 'in_progress', `state=${s}`);
591
- }
592
- });
593
-
594
- t('decideEnsureAction: an unknown state fails closed to in_progress (never a blind provision)', () => {
595
- assert.equal(boxes.decideEnsureAction({ boxState: 'frobnicating', rank: 'metic', status: 'active' }).action, 'in_progress');
596
- });
597
-
598
- console.log('\nintent queue (#701):');
599
-
600
- await ta('enqueueIntent: a fresh insert returns created=true and always enqueues state=pending', async () => {
601
- const db = fakeDb([{ rows: [{ id: 1, builder_id: 42, action: 'provision', state: 'pending' }] }]);
602
- const { intent, created } = await boxes.enqueueIntent(db, 42, 'provision', 'api:self');
603
- assert.equal(created, true);
604
- assert.equal(intent.action, 'provision');
605
- assert.equal(intent.state, 'pending', 'enqueueIntent always enqueues a plain pending intent');
606
- assert.match(db.calls[0].sql, /INSERT INTO box_intents/);
607
- });
608
-
609
- await ta('getOpenIntent: a pending or running intent is open (blocks a duplicate enqueue)', async () => {
610
- const db = fakeDb([{ rows: [{ id: 9, state: 'pending' }] }]);
611
- const open = await boxes.getOpenIntent(db, 7);
612
- assert.equal(open.state, 'pending');
613
- // Only the two live states gate a re-enqueue — there is no approval-hold state.
614
- assert.match(db.calls[0].sql, /state IN \('pending', 'running'\)/);
615
- });
616
-
617
- await ta('enqueueIntent: a unique-violation (open intent exists) returns the existing one, created=false', async () => {
618
- const db = {
619
- calls: [],
620
- async query(sql) {
621
- this.calls.push({ sql });
622
- if (/INSERT INTO box_intents/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
623
- return { rows: [{ id: 9, builder_id: 42, action: 'wake', state: 'pending' }] };
624
- },
625
- };
626
- const { intent, created } = await boxes.enqueueIntent(db, 42, 'wake', 'api:self');
627
- assert.equal(created, false);
628
- assert.equal(intent.id, 9);
629
- });
630
-
631
- await ta('claimNextIntent: claims with FOR UPDATE SKIP LOCKED and flips to running', async () => {
632
- const db = fakeDb([{ rows: [{ id: 3, action: 'provision', state: 'running' }] }]);
633
- const intent = await boxes.claimNextIntent(db);
634
- assert.equal(intent.id, 3);
635
- assert.match(db.calls[0].sql, /FOR UPDATE SKIP LOCKED/);
636
- assert.match(db.calls[0].sql, /state = 'running'/);
637
- });
638
-
639
- await ta('setTerminalAccess: only an active box stores the url + credential', async () => {
640
- const db = fakeDb([{ rows: [{ state: 'active' }] }]);
641
- const state = await boxes.setTerminalAccess(db, 42, { url: 'https://abc.trycloudflare.com', credential: 's3cr3t' });
642
- assert.equal(state, 'active');
643
- assert.match(db.calls[0].sql, /UPDATE builder_boxes[\s\S]*state = 'active'/);
644
- assert.match(db.calls[0].sql, /terminal_url = \$2[\s\S]*terminal_credential = \$3/);
645
- assert.deepEqual(db.calls[0].params, [42, 'https://abc.trycloudflare.com', 's3cr3t']);
646
- });
647
-
648
- await ta('setTerminalAccess: a missing credential is stored as null', async () => {
649
- const db = fakeDb([{ rows: [{ state: 'active' }] }]);
650
- await boxes.setTerminalAccess(db, 42, { url: 'https://abc.trycloudflare.com' });
651
- assert.equal(db.calls[0].params[2], null);
652
- });
653
-
654
- await ta('setTerminalAccess: a non-active box does not store, reports current state', async () => {
655
- const db = fakeDb([{ rows: [] }, { rows: [{ state: 'parked' }] }]);
656
- const state = await boxes.setTerminalAccess(db, 42, { url: 'https://abc.trycloudflare.com', credential: 'x' });
657
- assert.equal(state, 'parked');
658
- assert.equal(db.calls.length, 2);
659
- });
660
-
661
- console.log('\ngit non-interactive (the #854 ttyd/SSH input-freeze fix):');
662
-
663
- t('infra pins GIT_TERMINAL_PROMPT=0 across every box arrival path (#854)', () => {
664
- const fs = require('node:fs');
665
- const path = require('node:path');
666
- const { fileURLToPath } = require('node:url');
667
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
668
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
669
-
670
- const unit = read('infra/box-terminal.service');
671
- const cloudInit = read('infra/builder-box-cloud-init.yaml');
672
- const fetchSh = read('infra/box-source-fetch.sh');
673
-
674
- // 1. ttyd process tree: the systemd unit pins the env var for the whole tree
675
- // (bash -l -> claude -> any git). This is the canonical file box.js inlines.
676
- assert.match(unit, /^Environment=GIT_TERMINAL_PROMPT=0$/m,
677
- 'box-terminal.service must pin GIT_TERMINAL_PROMPT=0 for the ttyd process tree');
678
-
679
- // 2. Fresh boxes (cloud-init write_files): a profile.d export for LOGIN shells
680
- // AND an /etc/environment entry (pam_env) for NON-login SSH are both baked.
681
- assert.match(cloudInit, /path: \/etc\/profile\.d\/05-git-noninteractive\.sh/,
682
- 'cloud-init must write the profile.d git-noninteractive file');
683
- assert.match(cloudInit, /export GIT_TERMINAL_PROMPT=0/,
684
- 'cloud-init profile.d entry must export GIT_TERMINAL_PROMPT=0');
685
- assert.match(cloudInit, /path: \/etc\/environment[\s\S]{0,160}GIT_TERMINAL_PROMPT=0/,
686
- 'cloud-init must add GIT_TERMINAL_PROMPT=0 to /etc/environment (covers non-login SSH)');
687
-
688
- // 3. Existing boxes self-heal: the source-fetch cron re-asserts BOTH files
689
- // (runs before the token check, idempotent, no disruptive restart).
690
- assert.match(fetchSh, /GIT_NOPROMPT_FILE=\/etc\/profile\.d\/05-git-noninteractive\.sh/,
691
- 'box-source-fetch.sh must re-assert the profile.d file on existing boxes');
692
- assert.match(fetchSh, /grep -qs '\^GIT_TERMINAL_PROMPT=0\$' \/etc\/environment/,
693
- 'box-source-fetch.sh must re-assert /etc/environment on existing boxes');
694
-
695
- // 4. Delivery wiring intact: cloud-init still inlines the canonical unit (the
696
- // #774 placeholder box.js base64-fills), so a fresh box gets the unit env.
697
- assert.match(cloudInit, /__BOX_TERMINAL_SERVICE_B64__/,
698
- 'cloud-init must still inline box-terminal.service via its placeholder');
699
- });
700
-
701
- t('box-source-fetch.sh reports host keys IMMEDIATELY, not only via the */10 cron (task 1460)', () => {
702
- const fs = require('node:fs');
703
- const path = require('node:path');
704
- const { fileURLToPath } = require('node:url');
705
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
706
- const fetchSh = fs.readFileSync(path.join(ROOT, 'infra/box-source-fetch.sh'), 'utf8');
707
-
708
- // The off->on bug: a re-provision mints FRESH /etc/ssh host keys and clears the
709
- // GDS's stored copy (idea 310), but the reporter ran ONLY on a */10 cron — so
710
- // GET /box/me had no key for up to 10 min and the laptop's stale known_hosts pin
711
- // stranded the connect ("Host denied"). box-source-fetch.sh runs at first boot
712
- // (cloud-init) and every */10, so it must INVOKE the reporter directly — not just
713
- // wire the cron — to close that window from minutes to seconds.
714
- assert.match(fetchSh, /echo "\*\/10 \* \* \* \* \$WORKSPACE\/infra\/box-report-host-keys\.sh/,
715
- 'box-source-fetch.sh must still wire the */10 host-keys cron (the backstop)');
716
- assert.match(fetchSh, /^\s*"\$WORKSPACE\/infra\/box-report-host-keys\.sh" >> \/tmp\/box-report-host-keys\.log 2>&1 \|\| true\s*$/m,
717
- 'box-source-fetch.sh must ALSO invoke the host-keys reporter immediately (task 1460), non-fatal under set -e');
718
- });
719
-
720
- t('box-heartbeat.sh counts an open ttyd browser terminal as in-use (idea #294 / task 1161)', () => {
721
- const fs = require('node:fs');
722
- const path = require('node:path');
723
- const { fileURLToPath } = require('node:url');
724
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
725
- const hb = fs.readFileSync(path.join(ROOT, 'infra/box-heartbeat.sh'), 'utf8');
726
-
727
- // The bug: the browser on-ramp (#760/#774) reaches the box via ttyd over the
728
- // Cloudflare tunnel — not SSH, no utmp, ~0% CPU — so the heartbeat saw "idle"
729
- // and the sweep destroyed an in-use box. An OPEN browser terminal holds an
730
- // ESTABLISHED connection to ttyd's local port (default 7681, overridable).
731
- assert.match(hb, /TTYD_PORT="\$\{BOX_TTYD_PORT:-7681\}"/,
732
- 'must default the ttyd port to 7681, overridable via BOX_TTYD_PORT');
733
- assert.match(hb, /state established "\( sport = :\$TTYD_PORT \)"/,
734
- 'must detect an ESTABLISHED connection to the ttyd port (the listening socket is filtered out)');
735
- assert.match(hb, /ACTIVE=1; TTYD_CONNECTED=1/,
736
- 'an open ttyd session must set ACTIVE=1 so the heartbeat actually pings');
737
- assert.match(hb, /if \[ "\$TTYD_CONNECTED" -eq 1 \]; then CLAUDE_ACTIVE="true"; fi/,
738
- 'an open ttyd session must report claude_active=true so the reaper spares it');
739
- });
740
-
741
- t('#893: terminal session persists (tmux) + close-box teardown command is wired', () => {
742
- const fs = require('node:fs');
743
- const path = require('node:path');
744
- const { fileURLToPath } = require('node:url');
745
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
746
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
747
-
748
- const unit = read('infra/box-terminal.service');
749
- const cloudInit = read('infra/builder-box-cloud-init.yaml');
750
- const fetchSh = read('infra/box-source-fetch.sh');
751
- const boxJs = read('scripts/gds/box.js');
752
- const closeBox = read('infra/close-box.sh');
753
- const launch = read('infra/otb-box-launch.sh');
754
-
755
- // --- persistence: task 986 moved the tmux logic OUT of box-terminal.service's
756
- // inline ExecStart into /usr/local/bin/otb-box-launch (canonical copy:
757
- // infra/otb-box-launch.sh) and made it auto-run `claude`. #893's intent is
758
- // unchanged -- a browser disconnect only detaches the tmux CLIENT; the "otb"
759
- // session (with claude) survives on the box and the next connect reattaches
760
- // -- it just lives in the launcher now. So assert the service INVOKES the
761
- // launcher, and that the tmux attach-or-create persistence + `bash -l`
762
- // fallback live in the launcher.
763
- assert.match(unit, /--writable \/usr\/local\/bin\/otb-box-launch/,
764
- 'box-terminal.service must run ttyd against /usr/local/bin/otb-box-launch (task 986)');
765
- assert.match(launch, /tmux has-session -t otb/,
766
- 'otb-box-launch must reuse the existing "otb" tmux session when present (#893 persistence)');
767
- assert.match(launch, /tmux new-session -d -s otb\b/,
768
- 'otb-box-launch must create the "otb" tmux session when absent (#893 attach-or-create)');
769
- assert.match(launch, /exec tmux attach-session -t otb/,
770
- 'otb-box-launch must attach to the persistent "otb" session on connect (#893)');
771
- assert.match(launch, /command -v tmux[\s\S]*?else[\s\S]*?exec bash -l/,
772
- 'otb-box-launch must fall back to bash -l when tmux is absent');
773
- // fresh boxes get tmux from the package list.
774
- assert.match(cloudInit, /packages:[\s\S]*?^\s*- tmux$/m,
775
- 'cloud-init must install tmux so the persistence path works on fresh boxes');
776
- // launcher delivery chain (mirrors the close-box chain below): canonical infra
777
- // copy -> cloud-init placeholder on first boot -> box.js gz+b64 fill ->
778
- // box-source-fetch self-heal on existing boxes.
779
- assert.match(cloudInit, /path: \/usr\/local\/bin\/otb-box-launch[\s\S]{0,160}__OTB_BOX_LAUNCH_B64__/,
780
- 'cloud-init must write /usr/local/bin/otb-box-launch via its placeholder');
781
- assert.match(boxJs, /__OTB_BOX_LAUNCH_B64__[\s\S]{0,80}fileGzB64\('otb-box-launch\.sh'\)/,
782
- "box.js must gz+b64-fill the otb-box-launch placeholder from infra/otb-box-launch.sh");
783
- assert.match(fetchSh, /install -m 0755 "\$WORKSPACE\/infra\/otb-box-launch\.sh" \/usr\/local\/bin\/otb-box-launch/,
784
- 'box-source-fetch.sh must self-heal /usr/local/bin/otb-box-launch on existing boxes');
785
-
786
- // --- close-box command: canonical script + first-boot delivery (cloud-init
787
- // placeholder box.js fills) + existing-box self-heal in source-fetch.
788
- assert.match(closeBox, /\/box\/close/,
789
- 'infra/close-box.sh must POST to /box/close (the deprovision action)');
790
- assert.match(cloudInit, /path: \/usr\/local\/bin\/close-box[\s\S]{0,120}__CLOSE_BOX_B64__/,
791
- 'cloud-init must write /usr/local/bin/close-box via its placeholder');
792
- assert.match(boxJs, /__CLOSE_BOX_B64__[\s\S]{0,60}fileGzB64\('close-box\.sh'\)/,
793
- "box.js must gz+b64-fill the close-box placeholder from infra/close-box.sh");
794
- assert.match(fetchSh, /install -m 0755 "\$WORKSPACE\/infra\/close-box\.sh" \/usr\/local\/bin\/close-box/,
795
- 'box-source-fetch.sh must self-heal /usr/local/bin/close-box on existing boxes');
796
- });
797
-
798
- console.log('\n#913 - terminal credential drift (systemd % expansion + self-heal):');
799
-
800
- t('no unescaped % in any infra/*.service Exec line (#913 class guard)', () => {
801
- const fs = require('node:fs');
802
- const path = require('node:path');
803
- const { fileURLToPath } = require('node:url');
804
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
805
- const dir = path.join(ROOT, 'infra');
806
- const units = fs.readdirSync(dir).filter((n) => n.endsWith('.service'));
807
- assert.ok(units.length > 0, 'expected at least one infra/*.service unit');
808
- for (const name of units) {
809
- const lines = fs.readFileSync(path.join(dir, name), 'utf8').split('\n');
810
- lines.forEach((line, i) => {
811
- if (!/^\s*Exec\w*=/.test(line)) return;
812
- // systemd expands % specifiers in Exec* lines; a literal % must be %%.
813
- const residue = line.split('%%').join('');
814
- assert.ok(!residue.includes('%'),
815
- `infra/${name}:${i + 1} has an unescaped % in an Exec line - systemd ` +
816
- 'expands % specifiers before the shell runs (write %% for a literal %). ' +
817
- 'This is the #913 bug class: printf "%s" became a directive-free format ' +
818
- 'and seeded a garbage ttyd password.');
819
- });
820
- }
821
- });
822
-
823
- t('box-terminal.service seeds the ttyd password via escaped %%s printf (#913)', () => {
824
- const fs = require('node:fs');
825
- const path = require('node:path');
826
- const { fileURLToPath } = require('node:url');
827
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
828
- const unit = fs.readFileSync(path.join(ROOT, 'infra/box-terminal.service'), 'utf8');
829
- assert.match(unit, /printf "%%s" "\$BOX_TERMINAL_CREDENTIAL"/,
830
- 'the ExecStartPre seed must printf "%%s" so the shell sees printf "%s"');
831
- });
832
-
833
- t('box-report-terminal.sh self-heals live ttyd credential drift (#913)', () => {
834
- const fs = require('node:fs');
835
- const path = require('node:path');
836
- const { fileURLToPath } = require('node:url');
837
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
838
- const sh = fs.readFileSync(path.join(ROOT, 'infra/box-report-terminal.sh'), 'utf8');
839
- // reads the password the RUNNING ttyd enforces from its argv...
840
- assert.match(sh, /\/proc\/\$TTYD_PID\/cmdline/,
841
- 'must read the live ttyd argv to learn the password actually being enforced');
842
- assert.match(sh, /--credential/,
843
- 'must locate the --credential argv entry');
844
- // ...and restarts the unit on mismatch so ttyd re-reads CRED_FILE.
845
- assert.match(sh, /systemctl restart box-terminal/,
846
- 'must restart box-terminal when the live password differs from CRED_FILE');
847
- });
848
-
849
- // ---------------------------------------------------------------------------
850
- // #889 — GET /box/terminal readiness probe must NOT treat a Cloudflare
851
- // tunnel-down (HTTP 530 / error 1033) page as a live tunnel. The terminal URL
852
- // is recorded at provision time, before cloudflared connects, so a too-eager
853
- // probe handed out a clickable link that 1033s (builder 25, term-iammasterqua).
854
- // Ready ONLY when the ORIGIN (ttyd) answered: 401 basic-auth or 2xx/3xx.
855
- // ---------------------------------------------------------------------------
856
- t('terminal probe: 401 (ttyd basic-auth challenge) is the ready signal', () => {
857
- assert.equal(isTunnelReadyStatus(401), true);
858
- });
859
- t('terminal probe: 2xx/3xx (origin answered) is ready', () => {
860
- assert.equal(isTunnelReadyStatus(200), true);
861
- assert.equal(isTunnelReadyStatus(204), true);
862
- assert.equal(isTunnelReadyStatus(302), true);
863
- });
864
- t('terminal probe: CF 1033 tunnel-down (530) is NOT ready (the #889 bug)', () => {
865
- assert.equal(isTunnelReadyStatus(530), false);
866
- });
867
- t('terminal probe: CF origin-unreachable 5xx are NOT ready', () => {
868
- for (const s of [500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526]) {
869
- assert.equal(isTunnelReadyStatus(s), false, `status ${s} must not be ready`);
870
- }
871
- });
872
- t('terminal probe: a 4xx other than 401 is NOT ready', () => {
873
- assert.equal(isTunnelReadyStatus(403), false);
874
- assert.equal(isTunnelReadyStatus(404), false);
875
- });
876
-
877
- console.log('\nlive game preview (#898/#903, ADR 0044) + cloud-init 64KB budget:');
878
-
879
- t('#903: rendered cloud-init user-data stays under DigitalOcean\'s 64KB cap', () => {
880
- const fs = require('node:fs');
881
- const path = require('node:path');
882
- const zlib = require('node:zlib');
883
- const { fileURLToPath } = require('node:url');
884
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
885
- const infra = (n) => path.join(ROOT, 'infra', n);
886
- const gz = (n) => zlib.gzipSync(fs.readFileSync(infra(n))).toString('base64');
887
-
888
- // Mirror box.js readCloudInit's substitution: EVERY inlined infra file is gz+b64
889
- // (#903 margin; task 1314's source-fetch growth blew the cap, task 1321 gz+b64'd
890
- // the rest), and ONLY the dynamic runtime values (keys/env/session) are plain b64.
891
- // Realistic named-tunnel secrets (2 keys, tokens) give a worst-case render size.
892
- let out = fs.readFileSync(infra('builder-box-cloud-init.yaml'), 'utf8')
893
- .split('__BOX_SOURCE_FETCH_B64__').join(gz('box-source-fetch.sh'))
894
- .split('__BOX_AUTHKEYS_FETCH_B64__').join(gz('box-authorized-keys-fetch.sh'))
895
- .split('__BOX_TERMINAL_SERVICE_B64__').join(gz('box-terminal.service'))
896
- .split('__BOX_TERMINAL_CREDSYNC_SERVICE_B64__').join(gz('box-terminal-credsync.service'))
897
- .split('__BOX_TERMINAL_CREDENTIAL_PATH_B64__').join(gz('box-terminal-credential.path'))
898
- .split('__CLOUDFLARED_SERVICE_B64__').join(gz('cloudflared.service'))
899
- .split('__CLOUDFLARED_RUN_B64__').join(gz('cloudflared-run.sh'))
900
- .split('__CLOSE_BOX_B64__').join(gz('close-box.sh'))
901
- .split('__REPO_URL__').join('git@github.com:owner/example.git')
902
- .split('__BUILDER_AUTHORIZED_KEYS_B64__').join(Buffer.from('ssh-ed25519 AAAA...k1\nssh-ed25519 AAAA...k2\n').toString('base64'))
903
- .split('__BOX_ENV_B64__').join(Buffer.from('BOX_TUNNEL_TOKEN=' + 'x'.repeat(200) + '\nBOX_TERMINAL_HOSTNAME=term-x.example.com\nBOX_TERMINAL_CREDENTIAL=' + 'a'.repeat(48) + '\nBOX_PREVIEW_HOSTNAME=sandbox-x.example.com\n').toString('base64'))
904
- .split('__GDS_SESSION_B64__').join(Buffer.from(JSON.stringify({ token: 'x'.repeat(64), builder: { id: 1, github_login: 'x', rank: 'archon' }, api_base: 'https://example.com' })).toString('base64'));
905
- const bytes = Buffer.byteLength(out, 'utf8');
906
- assert.ok(bytes < 64 * 1024, `rendered cloud-init is ${bytes}B; must stay under DO's 65536B cap`);
907
- });
908
-
909
- t('task 1040: cloud-init keeps SSH host keys stable across wake (ssh_deletekeys:false drop-in) + stays ASCII', () => {
910
- const fs = require('node:fs');
911
- const path = require('node:path');
912
- const { fileURLToPath } = require('node:url');
913
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
914
- const raw = fs.readFileSync(path.join(ROOT, 'infra', 'builder-box-cloud-init.yaml'), 'utf8');
915
-
916
- // The box is recreated from a snapshot on every `wake` (new instance-id), and
917
- // cmdWake passes NO user_data — so without ssh_deletekeys:false baked INTO the
918
- // snapshot, cloud-init's cc_ssh regenerates /etc/ssh/ssh_host_* on every wake
919
- // and stales every builder's known_hosts pin (Claude Desktop's strict ssh2
920
- // verifier then hard-fails with "Host denied (verification failed)"). The
921
- // drop-in must live in cloud.cfg.d so it rides in the snapshot and is parsed on
922
- // every wake boot.
923
- assert.match(raw, /path: \/etc\/cloud\/cloud\.cfg\.d\/99-otb-keep-hostkeys\.cfg/,
924
- 'cloud-init must write the host-key-persistence drop-in into /etc/cloud/cloud.cfg.d/');
925
- assert.match(raw, /ssh_deletekeys:\s*false/,
926
- 'the drop-in must set ssh_deletekeys: false so cc_ssh preserves the snapshot host keys');
927
-
928
- // cloud-init's YAML loader silently DISCARDS the entire config on any non-ASCII
929
- // byte (learning #48 / #724) — which would void this drop-in along with it.
930
- // Guard that the whole file stays 7-bit ASCII.
931
- const nonAscii = [...raw].findIndex((c) => c.charCodeAt(0) > 127);
932
- assert.equal(nonAscii, -1,
933
- `builder-box-cloud-init.yaml must be ASCII-only (cloud-init drops non-ASCII configs); first offending char at index ${nonAscii}`);
934
- });
935
-
936
- t('#903: cloud-init gzips the big source-fetch script + box.js has the size guard', () => {
937
- const fs = require('node:fs');
938
- const path = require('node:path');
939
- const { fileURLToPath } = require('node:url');
940
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
941
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
942
-
943
- // The source-fetch write_files entry must use gz+b64 (that's the margin), and
944
- // box.js must gzip it + fail fast before DO if the render exceeds the cap.
945
- assert.match(read('infra/builder-box-cloud-init.yaml'),
946
- /path: \/usr\/local\/bin\/box-source-fetch\.sh[\s\S]{0,80}encoding: gz\+b64/,
947
- 'box-source-fetch.sh must be inlined as gz+b64');
948
- const boxjs = read('scripts/gds/box.js');
949
- assert.match(boxjs, /fileGzB64\('box-source-fetch\.sh'\)/, 'box.js must gz+b64 the source-fetch script');
950
- assert.match(boxjs, /MAX_USERDATA_BYTES/, 'box.js must define the user-data size guard');
951
- assert.match(boxjs, /over DigitalOcean's .* limit/, 'box.js must fail fast with an actionable over-limit message');
952
- });
953
-
954
- console.log('\nterminal credential race (#917, ideas #232/#233):');
955
-
956
- t('#917: box-terminal.service never launches ttyd with an empty password and writes the credential atomically', () => {
957
- const fs = require('node:fs');
958
- const path = require('node:path');
959
- const { fileURLToPath } = require('node:url');
960
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
961
- const unit = fs.readFileSync(path.join(ROOT, 'infra', 'box-terminal.service'), 'utf8');
962
-
963
- // ExecStart must read the credential into a var, refuse to start when empty
964
- // (Restart=always retries), and never inline an unguarded $(cat ...).
965
- assert.match(unit, /ExecStart=.*\[ -n "\$CRED" \] \|\|.*exit 1/,
966
- 'ExecStart must refuse to launch ttyd with an empty credential');
967
- assert.doesNotMatch(unit, /ExecStart=.*--credential "otb:\$\(cat /,
968
- 'ExecStart must not inline an unguarded $(cat ...) as the password');
969
- // ExecStartPre must write tmp + mv (atomic) — `printf > file` truncates first,
970
- // opening the empty-read window that locked a blank password into ttyd.
971
- assert.match(unit, /ExecStartPre=.*terminal-credential\.tmp && mv -f .*terminal-credential\.tmp/,
972
- 'ExecStartPre must write the credential atomically (tmp + mv)');
973
- // ...and wait for a credential source (named-tunnel race) before the random
974
- // fallback, without stalling quick-tunnel boxes (box.env present, no var -> break).
975
- assert.match(unit, /ExecStartPre=.*for i in \$\(seq[\s\S]*?sleep 5/,
976
- 'ExecStartPre must poll/wait for the credential before falling back');
977
- assert.match(unit, /\[ -f \/etc\/otb\/box\.env \] && break/,
978
- 'a quick-tunnel box (box.env present, no baked credential) must skip the wait');
979
- });
980
-
981
- t('#917: the credential-drift watcher pair restarts ttyd only on a real mismatch', () => {
982
- const fs = require('node:fs');
983
- const path = require('node:path');
984
- const { fileURLToPath } = require('node:url');
985
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
986
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
987
-
988
- const pathUnit = read('infra/box-terminal-credential.path');
989
- assert.match(pathUnit, /PathModified=\/etc\/otb\/terminal-credential/,
990
- 'the path unit must watch the credential file (create + change)');
991
- assert.match(pathUnit, /Unit=box-terminal-credsync\.service/,
992
- 'the path unit must trigger credsync');
993
-
994
- const credsync = read('infra/box-terminal-credsync.service');
995
- assert.match(credsync, /Type=oneshot/, 'credsync must be a oneshot');
996
- // Compares the live ttyd argv password to the file; restarts only on mismatch
997
- // (loop-safe: after the restart they match, so the next trigger no-ops).
998
- assert.match(credsync, /\/proc\/\$PID\/cmdline/, 'credsync must read the live ttyd argv');
999
- assert.match(credsync, /\[ "\$RUN" = "\$FILE" \] && exit 0/,
1000
- 'credsync must no-op when the running password matches the file');
1001
- assert.match(credsync, /systemctl restart box-terminal/,
1002
- 'credsync must restart box-terminal on a real mismatch');
1003
- });
1004
-
1005
- t('#917: delivery wiring — cloud-init (fresh boxes) + source-fetch self-heal (existing boxes)', () => {
1006
- const fs = require('node:fs');
1007
- const path = require('node:path');
1008
- const { fileURLToPath } = require('node:url');
1009
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1010
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
1011
-
1012
- const cloudInit = read('infra/builder-box-cloud-init.yaml');
1013
- assert.match(cloudInit, /path: \/etc\/systemd\/system\/box-terminal-credsync\.service[\s\S]{0,120}__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/,
1014
- 'cloud-init must inline box-terminal-credsync.service via its placeholder');
1015
- assert.match(cloudInit, /path: \/etc\/systemd\/system\/box-terminal-credential\.path[\s\S]{0,120}__BOX_TERMINAL_CREDENTIAL_PATH_B64__/,
1016
- 'cloud-init must inline box-terminal-credential.path via its placeholder');
1017
- assert.match(cloudInit, /systemctl enable --now box-terminal-credential\.path/,
1018
- 'cloud-init must arm the credential watcher at first boot');
1019
- // The runcmd credential seed must be atomic too (tmp + mv, same race as the unit).
1020
- assert.match(cloudInit, /terminal-credential\.tmp && mv -f \/etc\/otb\/terminal-credential\.tmp \/etc\/otb\/terminal-credential/,
1021
- 'the cloud-init credential seed must write atomically (tmp + mv)');
1022
-
1023
- const boxjs = read('scripts/gds/box.js');
1024
- assert.match(boxjs, /fileGzB64\('box-terminal-credsync\.service'\)/,
1025
- 'box.js must gz+b64-fill the credsync placeholder');
1026
- assert.match(boxjs, /fileGzB64\('box-terminal-credential\.path'\)/,
1027
- 'box.js must gz+b64-fill the path-unit placeholder');
1028
-
1029
- const fetchSh = read('infra/box-source-fetch.sh');
1030
- // Units refresh when they DIFFER (not only when absent — the idea #222 gap that
1031
- // kept already-provisioned boxes on stale units forever).
1032
- assert.match(fetchSh, /for UNIT in box-terminal\.service box-terminal-credsync\.service box-terminal-credential\.path cloudflared\.service/,
1033
- 'source-fetch must refresh all terminal units from the repo copy');
1034
- assert.match(fetchSh, /cmp -s "\$WORKSPACE\/infra\/\$UNIT" "\/etc\/systemd\/system\/\$UNIT"/,
1035
- 'source-fetch must compare repo vs installed unit (refresh on diff, not only on absent)');
1036
- // And every tick: arm the watcher + run one drift check (self-heals a raced box
1037
- // within one cron tick even if it predates the path unit).
1038
- assert.match(fetchSh, /systemctl enable --now box-terminal-credential\.path/,
1039
- 'source-fetch must arm the credential watcher on existing boxes');
1040
- assert.match(fetchSh, /systemctl start box-terminal-credsync\.service/,
1041
- 'source-fetch must run a drift check on every tick');
1042
- });
1043
-
1044
- t('#1001/ADR 0052: live game preview enables on EVERY named-tunnel box (reverses #903 full-clone gate)', () => {
1045
- const fs = require('node:fs');
1046
- const path = require('node:path');
1047
- const { fileURLToPath } = require('node:url');
1048
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1049
- const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
1050
-
1051
- const fetchSh = read('infra/box-source-fetch.sh');
1052
- // ADR 0052: the game (public/, src/world, src/rooms) is now in the starter
1053
- // sparse scope and the unit runs the game-only entry, so the preview enables on
1054
- // EVERY box, gated ONLY on the named tunnel (BOX_PREVIEW_HOSTNAME). The
1055
- // src/rooms presence check survives as a belt-and-braces "clone finished" guard
1056
- // (it now passes on every box), not as a full-clone gate.
1057
- assert.match(fetchSh, /BOX_PREVIEW_HOSTNAME=.*box\.env/,
1058
- 'preview enable must still require the named-tunnel preview host');
1059
- assert.match(fetchSh, /-d "\$WORKSPACE\/src\/rooms"/,
1060
- 'preview enable keeps the src/rooms "game materialized" sanity guard');
1061
- assert.match(fetchSh, /install -m 0755 "\$WORKSPACE\/infra\/box-preview\.sh" \/usr\/local\/bin\/box-preview/,
1062
- 'box-source-fetch must install the box-preview command');
1063
- // box.js bakes the preview host so the box knows its own URL.
1064
- assert.match(read('scripts/gds/box.js'), /BOX_PREVIEW_HOSTNAME=\$\{ph\}/,
1065
- 'box.js must bake BOX_PREVIEW_HOSTNAME from the tunnel previewHostname');
1066
-
1067
- // ADR 0052 — the game must be in the starter clone so a Xenos/Thetes box can
1068
- // serve the sandbox.
1069
- const boxAccess = read('modules/dev-box/box-access.js');
1070
- for (const p of ["'src/world'", "'src/rooms'", "'public'"]) {
1071
- assert.ok(boxAccess.includes(p), `STARTER_SPARSE_PATHS must include ${p} (ADR 0052)`);
1072
- }
1073
- // migrations/ stays excluded — a degraded-DB preview needs no schema.
1074
- assert.ok(!/['"]migrations['"]/.test(boxAccess.split('STARTER_SPARSE_PATHS')[1].split(']')[0]),
1075
- 'STARTER_SPARSE_PATHS must NOT include migrations/ (degraded-DB preview needs no schema)');
1076
-
1077
- // The unit runs the GAME-ONLY entry, never the full server.js.
1078
- const svc = read('infra/box-game-preview.service');
1079
- assert.match(svc, /node \/workspace\/src\/preview-server\.js/,
1080
- 'box-game-preview.service must run the game-only entry');
1081
- assert.ok(!/ExecStart=.*node \/workspace\/server\.js/.test(svc),
1082
- 'box-game-preview.service must NOT run the full server.js');
1083
-
1084
- // The game-only entry must never be able to mount the internal API surface.
1085
- // Test the CODE, not the prose — the file's own comments explain that it does
1086
- // NOT mount /api/gds, so strip comments before asserting on real wiring.
1087
- const prevCode = read('src/preview-server.js')
1088
- .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
1089
- .split('\n').map((l) => l.replace(/\/\/.*$/, '')).join('\n'); // line comments
1090
- assert.ok(!/\brequire\(\s*['"][^'"]*\/gds\b/.test(prevCode),
1091
- 'src/preview-server.js must not require any src/bongos module');
1092
- assert.ok(!/buildGdsRouter/.test(prevCode),
1093
- 'src/preview-server.js must not import the GDS router builder');
1094
- assert.ok(!/\.use\(\s*['"]\/api\b/.test(prevCode),
1095
- 'src/preview-server.js must not mount any /api/* surface');
1096
- assert.ok(!/BuildersHallRoom/.test(prevCode),
1097
- 'src/preview-server.js must not register the internal BuildersHallRoom');
1098
- // …and it MUST register the game rooms via the shared helper (no drift).
1099
- assert.match(prevCode, /registerGameRooms\(/,
1100
- 'src/preview-server.js must register game rooms via the shared helper');
1101
- });
1102
-
1103
- t('#1044: box-game-preview ExecStartPre guards on a real install sentinel, not a bare -d node_modules', () => {
1104
- const fs = require('node:fs');
1105
- const path = require('node:path');
1106
- const { fileURLToPath } = require('node:url');
1107
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1108
- const svc = fs.readFileSync(path.join(ROOT, 'infra/box-game-preview.service'), 'utf8');
1109
- const guard = svc.split('\n').find((l) => l.startsWith('ExecStartPre=') && l.includes('npm'));
1110
- assert.ok(guard, 'box-game-preview.service must have a dep-install ExecStartPre');
1111
- // The antipattern (#1044): `-d node_modules` treats an empty-but-present dir as
1112
- // "installed", so npm ci never runs and the preview crash-loops on
1113
- // `Cannot find module 'express'`. The guard must probe a real install sentinel.
1114
- assert.ok(!/\[\s*-d\s+\/workspace\/node_modules\s*\]/.test(guard),
1115
- 'guard must NOT short-circuit on a bare `-d /workspace/node_modules` (empty dir passes it — #1044)');
1116
- assert.match(guard, /\[\s*-[ef]\s+\/workspace\/node_modules\/(\.package-lock\.json|[^/\s]+\/package\.json)\s*\]/,
1117
- 'guard must probe a real install sentinel (node_modules/.package-lock.json or a known dep\'s package.json)');
1118
- // Still self-heals an uninstalled box.
1119
- assert.match(guard, /npm ci --omit=dev \|\| npm install --omit=dev/,
1120
- 'guard must still run npm ci (fallback npm install), --omit=dev, when the sentinel is absent');
1121
- });
1122
-
1123
- console.log('\nDO poll loops (waitForAction + settleDroplet share _poll, #1092 C8):');
1124
-
1125
- await ta('settleDroplet returns {ip:null} in dry-run and never fetches', async () => {
1126
- let called = 0;
1127
- const api = new DoApi({ fetchImpl: () => { called++; throw new Error('should not fetch'); } });
1128
- const r = await api.settleDroplet('drop1');
1129
- assert.deepEqual(r, { ip: null });
1130
- assert.equal(called, 0);
1131
- });
1132
-
1133
- await ta('settleDroplet polls getDroplet until active and returns the public IPv4', async () => {
1134
- let n = 0;
1135
- const fakeFetch = async () => {
1136
- n++;
1137
- const droplet = n >= 3
1138
- ? { id: 'd1', status: 'active', networks: { v4: [{ type: 'private', ip_address: '10.0.0.1' }, { type: 'public', ip_address: '203.0.113.7' }] } }
1139
- : { id: 'd1', status: 'new', networks: { v4: [] } };
1140
- return { ok: true, status: 200, text: async () => JSON.stringify({ droplet }) };
1141
- };
1142
- const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1143
- const slept = [];
1144
- const r = await api.settleDroplet('d1', { sleepFn: async (ms) => { slept.push(ms); } });
1145
- assert.deepEqual(r, { ip: '203.0.113.7' });
1146
- assert.equal(n, 3, 'polled until the droplet reported active');
1147
- assert.equal(slept.length, 2, 'slept only between the two not-yet-active polls');
1148
- });
1149
-
1150
- await ta('settleDroplet throws once the try budget is exhausted', async () => {
1151
- const fakeFetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ droplet: { id: 'd1', status: 'new', networks: { v4: [] } } }) });
1152
- const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1153
- await assert.rejects(
1154
- () => api.settleDroplet('d1', { tries: 3, sleepFn: async () => {} }),
1155
- /did not reach 'active'/
1156
- );
1157
- });
1158
-
1159
- await ta('waitForAction returns true once the action completes', async () => {
1160
- let n = 0;
1161
- const fakeFetch = async () => { n++; return { ok: true, status: 200, text: async () => JSON.stringify({ action: { id: 'a1', status: n >= 2 ? 'completed' : 'in-progress' } }) }; };
1162
- const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1163
- const ok = await api.waitForAction('a1', { sleepFn: async () => {} });
1164
- assert.equal(ok, true);
1165
- assert.equal(n, 2);
1166
- });
1167
-
1168
- await ta('waitForAction throws DoApiError when the action errors', async () => {
1169
- const fakeFetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ action: { id: 'a1', status: 'errored' } }) });
1170
- const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1171
- let err;
1172
- try { await api.waitForAction('a1', { sleepFn: async () => {} }); } catch (e) { err = e; }
1173
- assert.ok(err instanceof DoApiError);
1174
- assert.match(err.message, /errored/);
1175
- });
1176
-
1177
- await ta('waitForAction throws on timeout (try budget exhausted)', async () => {
1178
- const fakeFetch = async () => ({ ok: true, status: 200, text: async () => JSON.stringify({ action: { id: 'a1', status: 'in-progress' } }) });
1179
- const api = new DoApi({ dryRun: false, token: 'tok', fetchImpl: fakeFetch });
1180
- await assert.rejects(() => api.waitForAction('a1', { tries: 2, sleepFn: async () => {} }), /did not complete in time/);
1181
- });
1182
-
1183
- console.log('\ntask 1289 — droplet⇄row drift detection (planDropletDrift, pure):');
1184
-
1185
- t('planDropletDrift: a droplet a live box owns is in sync (no drift)', () => {
1186
- const droplets = [{ id: 111, name: 'otb-box-alice', tags: ['otb-builder-box', 'builder:alice'] }];
1187
- const boxRows = [{ builder_id: 1, github_login: 'alice', state: 'active', droplet_id: '111' }];
1188
- const { zombies, ghosts } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW });
1189
- assert.equal(zombies.length, 0);
1190
- assert.equal(ghosts.length, 0);
1191
- });
1192
-
1193
- t('planDropletDrift: ZOMBIE — droplet exists but its box row is destroyed (the bug)', () => {
1194
- // The exact task-1289 shape: deprovision marked the row destroyed + nulled
1195
- // droplet_id, but the droplet was never actually deleted.
1196
- const droplets = [{ id: 222, name: 'otb-box-example-owner', tags: ['otb-builder-box', 'builder:example-owner'], created_at: '2026-05-01T00:00:00Z' }];
1197
- const boxRows = [{ builder_id: 3, github_login: 'example-owner', state: 'destroyed', droplet_id: null }];
1198
- const { zombies, ghosts } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW });
1199
- assert.equal(ghosts.length, 0);
1200
- assert.equal(zombies.length, 1);
1201
- assert.equal(zombies[0].dropletId, '222');
1202
- assert.equal(zombies[0].login, 'example-owner');
1203
- assert.equal(zombies[0].builderId, 3, 'maps the zombie back to its builder for edge teardown');
1204
- });
1205
-
1206
- t('planDropletDrift: ZOMBIE — a droplet with no box row at all', () => {
1207
- const droplets = [{ id: 333, name: 'otb-box-ghostuser', tags: ['otb-builder-box', 'builder:ghostuser'], created_at: '2026-01-01T00:00:00Z' }];
1208
- const { zombies } = boxes.planDropletDrift({ droplets, boxRows: [], nowMs: NOW });
1209
- assert.equal(zombies.length, 1);
1210
- assert.equal(zombies[0].login, 'ghostuser');
1211
- assert.equal(zombies[0].builderId, null);
1212
- });
1213
-
1214
- t('planDropletDrift: in-flight provision is NOT a zombie (young-droplet guard)', () => {
1215
- // Droplet created 2 min before NOW; box still provisioning, droplet_id not yet
1216
- // written. Must be skipped, never deleted.
1217
- const created = new Date(NOW - 2 * 60_000).toISOString();
1218
- const droplets = [{ id: 444, name: 'otb-box-newbie', tags: ['builder:newbie'], created_at: created }];
1219
- const boxRows = [{ builder_id: 9, github_login: 'newbie', state: 'provisioning', droplet_id: null }];
1220
- const { zombies, skippedYoung } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW, minAgeMinutes: 30 });
1221
- assert.equal(zombies.length, 0, 'young droplet not killed');
1222
- assert.equal(skippedYoung.length, 1);
1223
- assert.equal(skippedYoung[0].dropletId, '444');
1224
- });
1225
-
1226
- t('planDropletDrift: GHOST — live row whose droplet vanished from DO', () => {
1227
- const droplets = []; // DO has none
1228
- const boxRows = [{ builder_id: 5, github_login: 'bob', state: 'active', droplet_id: '555' }];
1229
- const { zombies, ghosts } = boxes.planDropletDrift({ droplets, boxRows, nowMs: NOW });
1230
- assert.equal(zombies.length, 0);
1231
- assert.equal(ghosts.length, 1);
1232
- assert.equal(ghosts[0].builderId, 5);
1233
- assert.equal(ghosts[0].dropletId, '555');
1234
- assert.equal(ghosts[0].state, 'active');
1235
- });
1236
-
1237
- t('planDropletDrift: a parked box (no droplet) is never a ghost', () => {
1238
- const boxRows = [{ builder_id: 6, github_login: 'carol', state: 'parked', droplet_id: null, snapshot_id: 's-1' }];
1239
- const { ghosts } = boxes.planDropletDrift({ droplets: [], boxRows, nowMs: NOW });
1240
- assert.equal(ghosts.length, 0);
1241
- });
1242
-
1243
- t('loginFromDroplet: prefers the builder: tag, falls back to the otb-box- name', () => {
1244
- assert.equal(boxes.loginFromDroplet({ tags: ['otb-builder-box', 'builder:dave'], name: 'otb-box-x' }), 'dave');
1245
- assert.equal(boxes.loginFromDroplet({ tags: ['otb-builder-box'], name: 'otb-box-erin' }), 'erin');
1246
- assert.equal(boxes.loginFromDroplet({ tags: [], name: 'something-else' }), null);
1247
- });
1248
-
1249
- console.log('\ntask 1289 — idempotent delete + confirm-before-destroyed:');
1250
-
1251
- await ta('deleteWithRetry: a 404 (already gone) is success, not failure', async () => {
1252
- let calls = 0;
1253
- const r = await boxCli.deleteWithRetry(async () => { calls++; const e = new Error('not found'); e.status = 404; throw e; }, { sleepFn: async () => {} });
1254
- assert.equal(r.ok, true);
1255
- assert.equal(r.alreadyGone, true);
1256
- assert.equal(calls, 1, 'a 404 short-circuits — no retry');
1257
- });
1258
-
1259
- await ta('deleteWithRetry: retries a transient failure, then succeeds', async () => {
1260
- let calls = 0;
1261
- const r = await boxCli.deleteWithRetry(async () => { calls++; if (calls < 2) throw new Error('503 transient'); }, { sleepFn: async () => {} });
1262
- assert.equal(r.ok, true);
1263
- assert.equal(calls, 2);
1264
- });
1265
-
1266
- await ta('deleteWithRetry: a real failure after the retry budget returns ok:false', async () => {
1267
- let calls = 0;
1268
- const r = await boxCli.deleteWithRetry(async () => { calls++; throw new Error('500 boom'); }, { tries: 3, sleepFn: async () => {} });
1269
- assert.equal(r.ok, false);
1270
- assert.match(r.error.message, /boom/);
1271
- assert.equal(calls, 3);
1272
- });
1273
-
1274
- await ta('deprovisionOne: a failed droplet delete parks at ERROR (not destroyed) and throws — the zombie fix', async () => {
1275
- // This is the heart of task 1289: the row must NEVER reach 'destroyed' while the
1276
- // droplet is still alive. A delete that keeps failing leaves state='error' with
1277
- // droplet_id preserved (not nulled) and throws so the caller surfaces it.
1278
- const states = [];
1279
- const fakeBoxes = {
1280
- async recordComputeCost() { return 0; },
1281
- async recordStorageCost() { return 0; },
1282
- async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1283
- async recordEvent() {},
1284
- };
1285
- const doApi = { dryRun: false, async deleteDroplet() { throw new Error('DO 500'); }, async deleteSnapshot() {} };
1286
- const box = { id: 7, builder_id: 42, state: 'active', droplet_id: '999', snapshot_id: null, parked_at: null, github_login: 'tester' };
1287
- await assert.rejects(
1288
- () => boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'test', login: 'tester', sleepFn: async () => {} }),
1289
- /DO 500/
1290
- );
1291
- assert.equal(states.length, 1, 'exactly one state write');
1292
- assert.equal(states[0].state, 'error', 'parks at error, NOT destroyed');
1293
- assert.ok(!('droplet_id' in states[0].patch), 'droplet_id is PRESERVED (not nulled) so the zombie stays queryable');
1294
- assert.match(states[0].patch.error_note, /delete failed/);
1295
- });
1296
-
1297
- await ta('deprovisionOne: a droplet already gone (404) proceeds cleanly to destroyed', async () => {
1298
- const states = [];
1299
- const fakeBoxes = {
1300
- async recordComputeCost() { return 0; },
1301
- async recordStorageCost() { return 0; },
1302
- async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1303
- async recordEvent() {},
1304
- };
1305
- const doApi = { dryRun: false, async deleteDroplet() { const e = new Error('gone'); e.status = 404; throw e; }, async deleteSnapshot() {} };
1306
- const box = { id: 8, builder_id: 43, state: 'active', droplet_id: '1000', snapshot_id: null, parked_at: null, github_login: 'tester2' };
1307
- await boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'test', login: 'tester2', sleepFn: async () => {} });
1308
- const destroyed = states.find((s) => s.state === 'destroyed');
1309
- assert.ok(destroyed, 'a 404 droplet is treated as deleted → reaches destroyed');
1310
- assert.equal(destroyed.patch.droplet_id, null);
1311
- });
1312
-
1313
- console.log('\ntask 2034 — a post-destroyed teardown failure must not clobber state=destroyed:');
1314
-
1315
- await ta('deprovisionOne: a post-destroyed recordEvent throw does NOT undo the teardown (stays destroyed, no reject)', async () => {
1316
- // The builder-95 false-error (2026-07-04): the droplet delete succeeded and the
1317
- // row committed 'destroyed', but the follow-up audit event threw. That exception
1318
- // used to propagate to the sweep's catch, which flipped the row back to 'error'
1319
- // with a null droplet_id. The audit write is best-effort — it must be swallowed
1320
- // so deprovisionOne resolves and 'destroyed' stands.
1321
- const states = [];
1322
- const fakeBoxes = {
1323
- async recordComputeCost() { return 0; },
1324
- async recordStorageCost() { return 0; },
1325
- async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1326
- async recordEvent() { throw new Error('box_events insert failed'); },
1327
- };
1328
- const doApi = { dryRun: false, async deleteDroplet() {}, async deleteSnapshot() {} };
1329
- const box = { id: 9, builder_id: 95, state: 'active', droplet_id: '2000', snapshot_id: null, parked_at: null, github_login: 'jaxri' };
1330
- // Must NOT reject — the teardown itself succeeded.
1331
- await boxCli.deprovisionOne({}, fakeBoxes, doApi, box, { actor: 'sweep:idle', login: 'jaxri', sleepFn: async () => {} });
1332
- assert.equal(states.length, 1, 'exactly one state write');
1333
- assert.equal(states[0].state, 'destroyed', "stays 'destroyed' despite the audit-event failure");
1334
- assert.equal(states[0].patch.droplet_id, null, 'droplet_id nulled — the teardown is real');
1335
- });
1336
-
1337
- await ta('markErrorUnlessDestroyed: never flips an already-destroyed box back to error (guard)', async () => {
1338
- // Second line of defence: even if some OTHER post-commit step throws (e.g. the
1339
- // reconcile path's recordSourceEvent) and lands in a caller's catch, a box that
1340
- // already reached 'destroyed' must be left alone.
1341
- const writes = [];
1342
- const fakeBoxes = {
1343
- async getBoxByBuilderId() { return { state: 'destroyed' }; },
1344
- async setBoxState(_db, _bid, state, patch) { writes.push({ state, patch }); return {}; },
1345
- };
1346
- await boxCli.markErrorUnlessDestroyed({}, fakeBoxes, 95, 'recordSourceEvent failed after teardown');
1347
- assert.equal(writes.length, 0, 'a destroyed box is left alone — no error downgrade');
1348
- });
1349
-
1350
- await ta('markErrorUnlessDestroyed: a genuine (pre-destroyed) failure still parks at error (guard)', async () => {
1351
- const writes = [];
1352
- const fakeBoxes = {
1353
- async getBoxByBuilderId() { return { state: 'active' }; }, // never reached 'destroyed'
1354
- async setBoxState(_db, _bid, state, patch) { writes.push({ state, patch }); return {}; },
1355
- };
1356
- await boxCli.markErrorUnlessDestroyed({}, fakeBoxes, 96, 'DO 500 before teardown');
1357
- assert.equal(writes.length, 1, 'a not-destroyed box is parked at error, as before');
1358
- assert.equal(writes[0].state, 'error');
1359
- assert.match(writes[0].patch.error_note, /DO 500/);
1360
- });
1361
-
1362
- console.log('\ntask 1002726 — the idle sweep parks, it does not destroy:');
1363
-
1364
- // The regression this pins: cmdSweepIdle used to call deprovisionOne, so an idle
1365
- // box lost its droplet AND its snapshot — every sweep:idle row in the live
1366
- // box_events history was a full teardown, and nothing could be woken. The sweep
1367
- // must snapshot-and-park instead, leaving the filesystem recoverable.
1368
- function sweepHarness({ idleRows, parkThrows = null }) {
1369
- const events = [];
1370
- const states = [];
1371
- const fakeBoxes = {
1372
- async listBoxes() { return idleRows; },
1373
- selectIdleBoxes: boxes.selectIdleBoxes,
1374
- effectiveActivityMs: boxes.effectiveActivityMs,
1375
- idleReason: boxes.idleReason,
1376
- async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1377
- async recordComputeCost() { return 0; },
1378
- async recordEvent(_db, e) { events.push(e); },
1379
- async getBoxByBuilderId() { return { state: 'active' }; },
1380
- };
1381
- return { fakeBoxes, events, states, parkThrows };
1382
- }
1383
-
1384
- await ta('cmdSweepIdle: an idle box is PARKED — snapshot retained, no snapshot delete', async () => {
1385
- const NOW_ROW = {
1386
- id: 3, builder_id: 3, state: 'active', droplet_id: '591743808', droplet_name: 'box-example-owner',
1387
- github_login: 'example-owner', last_activity_at: new Date(Date.now() - 5 * 3600_000).toISOString(),
1388
- claude_active: false, snapshot_id: null,
1389
- };
1390
- const h = sweepHarness({ idleRows: [NOW_ROW] });
1391
- const deleted = [];
1392
- const doApi = {
1393
- dryRun: false,
1394
- async powerOffDroplet() { return { action: { id: 1 } }; },
1395
- async waitForAction() {},
1396
- async snapshotDroplet() { return { action: { id: 2 } }; },
1397
- async getDroplet() { return { droplet: { snapshot_ids: ['999'] } }; },
1398
- async deleteDroplet(id) { deleted.push(`droplet:${id}`); },
1399
- async deleteSnapshot(id) { deleted.push(`snapshot:${id}`); },
1400
- };
1401
- // makeDo() is only reached under --apply; inject our fake by stubbing the DO
1402
- // client the command builds. cmdSweepIdle takes (pool, boxes, {apply}), so we
1403
- // exercise parkOne directly with the same contract the sweep now uses.
1404
- await boxCli.parkOne({}, h.fakeBoxes, doApi, NOW_ROW, { actor: 'sweep:idle' });
1405
-
1406
- const parked = h.states.find((s) => s.state === 'parked');
1407
- assert.ok(parked, 'box reaches parked');
1408
- assert.equal(parked.patch.snapshot_id, '999', 'snapshot id retained on the row');
1409
- assert.equal(parked.patch.droplet_id, null, 'droplet released');
1410
- assert.ok(deleted.includes('droplet:591743808'), 'droplet destroyed (compute billing stops)');
1411
- assert.ok(!deleted.some((d) => d.startsWith('snapshot:')), 'the snapshot is NEVER deleted by an idle park');
1412
- assert.equal(h.events.find((e) => e.event === 'park')?.actor, 'sweep:idle', 'logged as a park, not a deprovision');
1413
- });
1414
-
1415
- await ta('cmdSweepIdle: dry-run reports "would park" and touches nothing', async () => {
1416
- const row = {
1417
- id: 3, builder_id: 3, state: 'active', droplet_id: '591743808', github_login: 'example-owner',
1418
- last_activity_at: new Date(Date.now() - 5 * 3600_000).toISOString(), claude_active: false,
1419
- };
1420
- const h = sweepHarness({ idleRows: [row] });
1421
- const lines = [];
1422
- const origLog = console.log;
1423
- console.log = (...a) => lines.push(a.join(' '));
1424
- try {
1425
- const res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: false });
1426
- assert.equal(res.parked, 0, 'dry-run parks nothing');
1427
- } finally { console.log = origLog; }
1428
- assert.ok(lines.some((l) => /would park/.test(l)), 'dry-run says park, not deprovision');
1429
- assert.ok(!lines.some((l) => /would deprovision/.test(l)), 'no destroy language survives');
1430
- assert.equal(h.states.length, 0, 'no state written in dry-run');
1431
- });
1432
-
1433
- await ta('cmdSweepIdle: a box mid-session is never parked', async () => {
1434
- // Genuinely in use: Claude started 20 min ago and somebody is attached.
1435
- const busy = {
1436
- id: 4, builder_id: 4, state: 'active', droplet_id: '1', github_login: 'busy',
1437
- last_activity_at: new Date(Date.now() - 99 * 3600_000).toISOString(), claude_active: true,
1438
- claude_active_since: new Date(Date.now() - 20 * 60_000).toISOString(),
1439
- last_attached_at: new Date(Date.now() - 20 * 60_000).toISOString(),
1440
- };
1441
- const h = sweepHarness({ idleRows: [busy] });
1442
- const res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: true });
1443
- assert.equal(res.parked, 0, 'a live session still holds the box open');
1444
- assert.equal(h.states.length, 0);
1445
- });
1446
-
1447
- await ta('cmdSweepIdle: task 1003507 — a box claiming claude_active for 99h with nobody attached IS parked', async () => {
1448
- // The reported incident, end to end through the command. Before this task the
1449
- // sweep skipped the row on claude_active alone and reported 0 idle; it now
1450
- // parks it (snapshot kept), and the log names the clock that selected it.
1451
- const abandoned = {
1452
- id: 5, builder_id: 5, state: 'active', droplet_id: '591743808', github_login: 'abandoned',
1453
- last_activity_at: new Date(Date.now() - 5 * 60_000).toISOString(),
1454
- claude_active: true,
1455
- claude_active_since: new Date(Date.now() - 99 * 3600_000).toISOString(),
1456
- last_attached_at: new Date(Date.now() - 99 * 3600_000).toISOString(),
1457
- };
1458
- const h = sweepHarness({ idleRows: [abandoned] });
1459
- const lines = [];
1460
- const origLog = console.log;
1461
- console.log = (...a) => lines.push(a.join(' '));
1462
- let res;
1463
- try {
1464
- res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: false });
1465
- } finally { console.log = origLog; }
1466
- assert.equal(res.parked, 0, 'dry-run writes nothing');
1467
- assert.ok(lines.some((l) => /would park .*abandoned/.test(l)), 'the abandoned box is selected');
1468
- assert.ok(lines.some((l) => /unattended 99h/.test(l)), 'the log names the unattended clock, not the idle one');
1469
- });
1470
-
1471
- await ta('loadDeps: task 1003507 — resolving deps does not throw ReferenceError', async () => {
1472
- // _deps was UNDECLARED, so loadDeps() threw on its first call and every
1473
- // command reaching makeDo()/makeCf() died with it — including
1474
- // `sweep-idle --apply`, which builds its DO client before the park loop, so
1475
- // the sweep could never park anything. Reached here through the same
1476
- // apply:true path that hid the bug: the assertion is that we get PAST dep
1477
- // resolution, whatever the fake DO client then does.
1478
- const row = {
1479
- id: 6, builder_id: 6, state: 'active', droplet_id: '1', github_login: 'reap',
1480
- last_activity_at: new Date(Date.now() - 99 * 3600_000).toISOString(), claude_active: false,
1481
- };
1482
- const h = sweepHarness({ idleRows: [row] });
1483
- const origLog = console.log;
1484
- console.log = () => {};
1485
- let err = null;
1486
- try {
1487
- await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: true });
1488
- } catch (e) { err = e; } finally { console.log = origLog; }
1489
- assert.ok(!(err && /_deps is not defined/.test(err.message)), `loadDeps threw: ${err && err.message}`);
1490
- });
1491
-
1492
- console.log('\ntask 1002727 — the dormant sweep warns before it takes:');
1493
-
1494
- const DORMANT_DAY = 86_400_000;
1495
- const parked = (d, extra = {}) => ({
1496
- id: 9, builder_id: 9, state: 'parked', github_login: 'sleeper', snapshot_id: 's1',
1497
- parked_at: new Date(NOW - d * DORMANT_DAY).toISOString(), ...extra,
1498
- });
1499
-
1500
- t('selectDormantWarnBoxes: picks boxes in the lead window, not before, not after', () => {
1501
- const rows = [parked(10), parked(12), parked(15), parked(3)];
1502
- const warn = boxes.selectDormantWarnBoxes(rows, { dormantDays: 14, warnLeadDays: 3, nowMs: NOW });
1503
- const days = warn.map((r) => Math.round((NOW - Date.parse(r.parked_at)) / DORMANT_DAY)).sort((a, b) => a - b);
1504
- assert.deepEqual(days, [12], 'only the box inside [11d, 14d) is warned');
1505
- });
1506
-
1507
- t('selectDormantWarnBoxes: a box already past the threshold is NOT in the warn set', () => {
1508
- // It belongs to selectDormantBoxes, which refuses to reclaim it unwarned.
1509
- const warn = boxes.selectDormantWarnBoxes([parked(20)], { dormantDays: 14, warnLeadDays: 3, nowMs: NOW });
1510
- assert.equal(warn.length, 0);
1511
- });
1512
-
1513
- t('selectDormantWarnBoxes: guards bad inputs', () => {
1514
- assert.deepEqual(boxes.selectDormantWarnBoxes(null, { dormantDays: 14, warnLeadDays: 3, nowMs: NOW }), []);
1515
- assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 0, warnLeadDays: 3, nowMs: NOW }), []);
1516
- assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 14, warnLeadDays: 0, nowMs: NOW }), []);
1517
- assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 14, warnLeadDays: 3, nowMs: NaN }), []);
1518
- });
1519
-
1520
- await ta('hasDormantWarning: keyed on the CURRENT parked interval, so a re-park re-arms', async () => {
1521
- const seen = [];
1522
- const db = { async query(sql, params) { seen.push({ sql, params }); return { rows: [] }; } };
1523
- const parkedAt = new Date(NOW - 5 * DORMANT_DAY).toISOString();
1524
- const got = await boxes.hasDormantWarning(db, { boxId: 9, parkedAt });
1525
- assert.equal(got, false, 'no receipt → not warned');
1526
- assert.match(seen[0].sql, /dormant_warning/);
1527
- assert.match(seen[0].sql, /created_at >= \$2/, 'scoped to events AFTER this park, not any historical one');
1528
- assert.deepEqual(seen[0].params, [9, parkedAt]);
1529
- });
1530
-
1531
- await ta('hasDormantWarning: a missing box id or parked_at is false, never a query', async () => {
1532
- const db = { async query() { throw new Error('should not query'); } };
1533
- assert.equal(await boxes.hasDormantWarning(db, { boxId: null, parkedAt: 'x' }), false);
1534
- assert.equal(await boxes.hasDormantWarning(db, { boxId: 9, parkedAt: null }), false);
1535
- });
1536
-
1537
- await ta('cmdSweepDormant: a box past the threshold that was NEVER warned is DEFERRED, not reclaimed', async () => {
1538
- // The safety property: even if the warning window was missed entirely (runner
1539
- // downtime, chat outage), the snapshot is not destroyed on this pass.
1540
- const box = parked(30);
1541
- const destroyed = [];
1542
- const events = [];
1543
- const fakeBoxes = {
1544
- async listBoxes() { return [box]; },
1545
- selectDormantBoxes: boxes.selectDormantBoxes,
1546
- selectDormantWarnBoxes: boxes.selectDormantWarnBoxes,
1547
- async hasDormantWarning() { return false; }, // never warned
1548
- async recordEvent(_db, e) { events.push(e); },
1549
- async setBoxState() { return {}; },
1550
- async recordComputeCost() { return 0; },
1551
- async recordStorageCost() { return 0; },
1552
- async getBoxByBuilderId() { return { state: 'parked' }; },
1553
- };
1554
- const lines = [];
1555
- const origLog = console.log;
1556
- console.log = (...a) => lines.push(a.join(' '));
1557
- let res;
1558
- try { res = await boxCli.cmdSweepDormant({}, fakeBoxes, { apply: true }); }
1559
- finally { console.log = origLog; }
1560
- assert.equal(res.reclaimed, 0, 'NOTHING reclaimed without a delivered warning');
1561
- assert.equal(res.deferred, 1, 'the reclaim is deferred to a later sweep');
1562
- assert.equal(destroyed.length, 0);
1563
- assert.ok(lines.some((l) => /deferred/.test(l)), 'the deferral is logged, not silent');
1564
- });
1565
-
1566
- await ta('cmdSweepDormant: dry-run reclaims and warns nothing', async () => {
1567
- const fakeBoxes = {
1568
- async listBoxes() { return [parked(30), parked(12)]; },
1569
- selectDormantBoxes: boxes.selectDormantBoxes,
1570
- selectDormantWarnBoxes: boxes.selectDormantWarnBoxes,
1571
- async hasDormantWarning() { return true; },
1572
- async recordEvent() { throw new Error('dry-run must not write'); },
1573
- async setBoxState() { throw new Error('dry-run must not write'); },
1574
- };
1575
- const origLog = console.log;
1576
- console.log = () => {};
1577
- let res;
1578
- try { res = await boxCli.cmdSweepDormant({}, fakeBoxes, { apply: false }); }
1579
- finally { console.log = origLog; }
1580
- assert.equal(res.reclaimed, 0);
1581
- assert.equal(res.warned, 0);
1582
- });
1583
-
1584
- console.log(`\nboxes.mjs: ${passed} passed, ${failed} failed`);
1585
- process.exit(failed === 0 ? 0 : 1);