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