@bongos/core 1.17.0
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 +5727 -0
- package/.claude/skills/blocker-review/SKILL.md +91 -0
- package/.claude/skills/blocker-solve/SKILL.md +96 -0
- package/.claude/skills/builder-backup/SKILL.md +54 -0
- package/.claude/skills/builder-box/SKILL.md +45 -0
- package/.claude/skills/builder-claim/SKILL.md +76 -0
- package/.claude/skills/builder-connect/SKILL.md +59 -0
- package/.claude/skills/builder-cost/SKILL.md +47 -0
- package/.claude/skills/builder-end/SKILL.md +81 -0
- package/.claude/skills/builder-exit/SKILL.md +88 -0
- package/.claude/skills/builder-key/SKILL.md +40 -0
- package/.claude/skills/builder-reauth/SKILL.md +50 -0
- package/.claude/skills/builder-redteam/SKILL.md +109 -0
- package/.claude/skills/builder-release/SKILL.md +41 -0
- package/.claude/skills/builder-setup/SKILL.md +62 -0
- package/.claude/skills/builder-ship/SKILL.md +79 -0
- package/.claude/skills/builder-stage/SKILL.md +51 -0
- package/.claude/skills/builder-start/SKILL.md +46 -0
- package/.claude/skills/close-box/SKILL.md +43 -0
- package/.claude/skills/design-sync/SKILL.md +69 -0
- package/.claude/skills/dev/SKILL.md +49 -0
- package/.claude/skills/feedback/SKILL.md +32 -0
- package/.claude/skills/figma-design-sync/SKILL.md +74 -0
- package/.claude/skills/goal-review/SKILL.md +89 -0
- package/.claude/skills/idea-triage/SKILL.md +144 -0
- package/.claude/skills/ideate/SKILL.md +82 -0
- package/.claude/skills/merge-mode/SKILL.md +169 -0
- package/.claude/skills/new-project/SKILL.md +108 -0
- package/.claude/skills/otb-character-review/SKILL.md +71 -0
- package/.claude/skills/otb-design-review/SKILL.md +47 -0
- package/.claude/skills/otb-feedback-capture/SKILL.md +45 -0
- package/.claude/skills/otb-figma-sync/SKILL.md +54 -0
- package/.claude/skills/otb-tile-generate/SKILL.md +45 -0
- package/.claude/skills/paint/SKILL.md +70 -0
- package/.claude/skills/planning-session/SKILL.md +299 -0
- package/.claude/skills/priority-session/SKILL.md +134 -0
- package/.claude/skills/read-session-export/SKILL.md +58 -0
- package/.claude/skills/recall/SKILL.md +52 -0
- package/.claude/skills/session-handoff/SKILL.md +46 -0
- package/.claude/skills/status/SKILL.md +33 -0
- package/.devcontainer/Dockerfile +69 -0
- package/.devcontainer/README.md +73 -0
- package/.devcontainer/devcontainer-lock.json +19 -0
- package/.devcontainer/devcontainer.json +93 -0
- package/.devcontainer/healthcheck.sh +84 -0
- package/.devcontainer/init-firewall.sh +198 -0
- package/.devcontainer/post-create.sh +153 -0
- package/.dockerignore +36 -0
- package/.env.local.example +32 -0
- package/.gitattributes +83 -0
- package/.gitignore +91 -0
- package/CLAUDE.md +242 -0
- package/CODE_OF_CONDUCT.md +46 -0
- package/CONTRIBUTING.md +51 -0
- package/Dockerfile +46 -0
- package/GOVERNANCE.md +44 -0
- package/LICENSE +661 -0
- package/README.md +88 -0
- package/bin/bongos.js +222 -0
- package/clients/bongos-client/README.md +48 -0
- package/clients/bongos-client/bongos-client.global.js +617 -0
- package/clients/bongos-client/examples/hello-world.mjs +62 -0
- package/clients/bongos-client/index.cjs +619 -0
- package/clients/bongos-client/index.d.ts +793 -0
- package/clients/bongos-client/index.mjs +614 -0
- package/clients/bongos-client/package.json +32 -0
- package/config/branding.neutral.json +60 -0
- package/config/design-tokens.neutral.json +51 -0
- package/config/design-tokens.schema.json +96 -0
- package/config/modules.neutral.json +9 -0
- package/docker-compose.yml +52 -0
- package/docker-entrypoint.sh +30 -0
- package/docs/adr/0001-stack-choice-single-vps.md +45 -0
- package/docs/adr/0002-digitalocean-over-hetzner.md +27 -0
- package/docs/adr/0003-cloudflare-origin-cert-over-letsencrypt.md +35 -0
- package/docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md +45 -0
- package/docs/adr/0005-invisible-other-players-in-v1.md +37 -0
- package/docs/adr/0006-singleton-worldroom-plus-queueroom.md +30 -0
- package/docs/adr/0007-persistent-identity-via-localstorage-uuid.md +31 -0
- package/docs/adr/0008-family-based-tile-generation.md +86 -0
- package/docs/adr/0008-google-chat-oauth-user-auth.md +37 -0
- package/docs/adr/0009-autotile-architecture.md +181 -0
- package/docs/adr/0010-pms-architecture.md +70 -0
- package/docs/adr/0011-terrain-id-rendering-model.md +174 -0
- package/docs/adr/0012-ground-object-cell-split.md +208 -0
- package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +158 -0
- package/docs/adr/0014-families-canonical-over-vocabulary.md +103 -0
- package/docs/adr/0015-task-dependencies-and-auto-promotion.md +125 -0
- package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +132 -0
- package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +74 -0
- package/docs/adr/0018-three-rank-model-goes-live.md +97 -0
- package/docs/adr/0019-red-team-bounty-payout-table.md +52 -0
- package/docs/adr/0020-security-model-public-stub.md +70 -0
- package/docs/adr/0021-per-builder-skill-model-preferences.md +99 -0
- package/docs/adr/0022-secrets-policy.md +124 -0
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +118 -0
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +277 -0
- package/docs/adr/0024-multi-agent-system-architecture.md +125 -0
- package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +59 -0
- package/docs/adr/0025-structured-criterion-task-link.md +44 -0
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +117 -0
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +170 -0
- package/docs/adr/0028-honest-llm-spend-accounting.md +109 -0
- package/docs/adr/0029-status-page-outage-resilience.md +59 -0
- package/docs/adr/0030-builders-hall-live-updates.md +59 -0
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +222 -0
- package/docs/adr/0032-discord-mirror-of-gds.md +50 -0
- package/docs/adr/0033-discord-bot-service-principal.md +82 -0
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +53 -0
- package/docs/adr/0035-builder-onboarding-three-paths.md +128 -0
- package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +40 -0
- package/docs/adr/0037-discord-channel-management.md +51 -0
- package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +111 -0
- package/docs/adr/0039-setup-first-onboarding-ux.md +49 -0
- package/docs/adr/0040-remote-control-default-browser-onramp.md +47 -0
- package/docs/adr/0041-temporary-grader-bypass-killswitch.md +40 -0
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +82 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +74 -0
- package/docs/adr/0044-mediterranean-palette-replacement.md +73 -0
- package/docs/adr/0044-per-box-live-game-preview.md +57 -0
- package/docs/adr/0045-devbox-desktop-app.md +133 -0
- package/docs/adr/0046-sandbox-first-review-gate.md +72 -0
- package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +36 -0
- package/docs/adr/0048-works-in-progress-ia-split.md +32 -0
- package/docs/adr/0049-split-parallel-safety-contract.md +46 -0
- package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +43 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +57 -0
- package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +70 -0
- package/docs/adr/0053-scoped-dev-box-session.md +55 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +63 -0
- package/docs/adr/0055-server-mediated-branch-publish.md +47 -0
- package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +62 -0
- package/docs/adr/0057-container-cost-ledger.md +28 -0
- package/docs/adr/0057-discord-archon-approval-channels.md +64 -0
- package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +64 -0
- package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +37 -0
- package/docs/adr/0060-gds-retrieval-layer.md +97 -0
- package/docs/adr/0061-context-layer-decomposition.md +61 -0
- package/docs/adr/0062-medusa-instance-model-decoupling.md +107 -0
- package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +44 -0
- package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +49 -0
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +69 -0
- package/docs/adr/0066-generated-file-map-sections.md +51 -0
- package/docs/adr/0067-session-start-fast-forward-pull.md +43 -0
- package/docs/adr/0068-shared-art-key-for-newcomers.md +38 -0
- package/docs/adr/0069-hall-live-channel-sse-decouple.md +35 -0
- package/docs/adr/0070-deterministic-session-card-delivery.md +44 -0
- package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +59 -0
- package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +35 -0
- package/docs/adr/0072-dev-box-code-staleness-visibility.md +39 -0
- package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +64 -0
- package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +37 -0
- package/docs/adr/0074-memory-map-stays-light.md +32 -0
- package/docs/adr/0075-per-system-cost-attribution.md +37 -0
- package/docs/adr/0076-in-session-deterministic-compaction.md +58 -0
- package/docs/adr/0077-content-addressed-llm-cache.md +32 -0
- package/docs/adr/0078-deterministic-first-cron.md +48 -0
- package/docs/adr/0079-value-gated-autonomous-engagement.md +39 -0
- package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +46 -0
- package/docs/adr/0081-tool-agnostic-design-layer.md +62 -0
- package/docs/adr/0082-productized-key-storage-provisioning.md +32 -0
- package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +55 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +143 -0
- package/docs/adr/0084-auto-derived-per-task-rank-floor.md +86 -0
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +39 -0
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +123 -0
- package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +49 -0
- package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +65 -0
- package/docs/adr/0088-consistent-character-generative-animation.md +169 -0
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +179 -0
- package/docs/adr/0090-metic-task-authoring.md +73 -0
- package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +106 -0
- package/docs/adr/0092-hosting-module-and-on-prem-target.md +117 -0
- package/docs/adr/0093-tranche-2-core-carve-sequence.md +72 -0
- package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +49 -0
- package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +65 -0
- package/docs/adr/0095-cross-agent-context-management.md +58 -0
- package/docs/adr/0096-require-reward-before-workable.md +209 -0
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +46 -0
- package/docs/adr/0097-retroactive-reward-backfill.md +124 -0
- package/docs/adr/0098-public-mirror-publish-manifest.md +92 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +55 -0
- package/docs/adr/0100-two-repo-instance-model.md +60 -0
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +90 -0
- package/docs/adr/0102-finish-bounding-the-kernel.md +36 -0
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +70 -0
- package/docs/adr/0103-gdsv4-bongos-consolidation.md +28 -0
- package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +42 -0
- package/docs/adr/0105-instance-seed-migrations-out-of-core.md +47 -0
- package/docs/adr/0106-goal-owner-manager-authority.md +84 -0
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +83 -0
- package/docs/adr/0108-instance-composition-configurable-root.md +87 -0
- package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +44 -0
- package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +49 -0
- package/docs/adr/0111-instance-hosting-provisioning-module.md +135 -0
- package/docs/adr/0112-private-goals-invitations-join-requests.md +72 -0
- package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +41 -0
- package/docs/adr/0114-instance-docs-discovery-contract.md +32 -0
- package/docs/adr/0115-scheduled-routines-portability.md +31 -0
- package/docs/adr/0116-api-error-envelope-and-code-taxonomy.md +62 -0
- package/docs/adr/0117-api-versioning-and-deprecation-policy.md +50 -0
- package/docs/adr/0118-api-contract-source-of-truth-code-first.md +47 -0
- package/docs/adr/0119-api-pagination-contract.md +34 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +108 -0
- package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +97 -0
- package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +43 -0
- package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +36 -0
- package/docs/adr/0124-typed-api-response-bodies.md +37 -0
- package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +36 -0
- package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +30 -0
- package/docs/adr/0127-cross-box-fleet-observability.md +64 -0
- package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +46 -0
- package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +34 -0
- package/docs/adr/0130-provision-remote-exec-over-ssh.md +61 -0
- package/docs/adr/0131-rank-scoped-skill-visibility.md +47 -0
- package/docs/adr/0132-cohost-fleet-control-plane-migration.md +54 -0
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +42 -0
- package/docs/adr/0134-private-first-npm-distribution.md +38 -0
- package/docs/adr/README.md +208 -0
- package/docs/api/openapi.json +13720 -0
- package/docs/api-reference.md +437 -0
- package/docs/architecture.md +244 -0
- package/docs/branding-contract.md +66 -0
- package/docs/branding-fill-prompt.md +25 -0
- package/docs/canonical-permissions.md +241 -0
- package/docs/design/container-exterior-tiles.md +49 -0
- package/docs/design/container-exterior.md +81 -0
- package/docs/design/container-interior.md +77 -0
- package/docs/design/modular-architecture/00-research-report.md +107 -0
- package/docs/design/modular-architecture/01-architecture.md +125 -0
- package/docs/design/modular-architecture/02-module-map.md +116 -0
- package/docs/design/modular-architecture/03-builder-flows.md +104 -0
- package/docs/design/modular-architecture/04-module-lifecycle.md +62 -0
- package/docs/design/modular-architecture/README.md +27 -0
- package/docs/design/vanilla-hall-ui-redesign-scope.md +284 -0
- package/docs/design/world-map-scroll.md +41 -0
- package/docs/design-contract.md +320 -0
- package/docs/file-map.md +418 -0
- package/docs/handoff-template.md +92 -0
- package/docs/modules-contract.md +293 -0
- package/docs/onboarding/browser-terminal-guide.md +73 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +28 -0
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +54 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +37 -0
- package/docs/onboarding/diagrams/04-architecture.mmd +51 -0
- package/docs/onboarding/diagrams/05-versioning.mmd +29 -0
- package/docs/onboarding/diagrams/README.md +94 -0
- package/docs/onboarding/diagrams/assertions.json +170 -0
- package/docs/onboarding/diagrams/puppeteer-config.json +1 -0
- package/docs/onboarding/diagrams/render-config.json +9 -0
- package/docs/onboarding/drachmae-and-karma.md +178 -0
- package/docs/onboarding/primer.md +202 -0
- package/docs/project-context.template.md +67 -0
- package/docs/recipes/README.md +35 -0
- package/docs/recipes/audit-replay.md +100 -0
- package/docs/recipes/bongos-cli-release.md +66 -0
- package/docs/recipes/builder-box-lifecycle.md +152 -0
- package/docs/recipes/claude-design-loop.md +143 -0
- package/docs/recipes/claude-gemini-bongos-doors.md +155 -0
- package/docs/recipes/cloudbongos-staging-gate.md +139 -0
- package/docs/recipes/cloudbongos-standup.md +111 -0
- package/docs/recipes/devbox-app-release.md +93 -0
- package/docs/recipes/devbox-app-windows-test.md +128 -0
- package/docs/recipes/failed-ship-recovery-proof.md +102 -0
- package/docs/recipes/figma-design-loop.md +129 -0
- package/docs/recipes/gds-db-backup.md +255 -0
- package/docs/recipes/instance-deploy.md +37 -0
- package/docs/recipes/managed-settings-remote-control.md +416 -0
- package/docs/recipes/memory-backup.md +71 -0
- package/docs/recipes/memory-sync-conflicts.md +98 -0
- package/docs/recipes/multi-builder-merge.md +78 -0
- package/docs/recipes/ops-gotchas.md +212 -0
- package/docs/recipes/overnight-builder.md +53 -0
- package/docs/recipes/packaging-the-core.md +49 -0
- package/docs/recipes/parallel-load-results-2026-05-30.md +63 -0
- package/docs/recipes/parallel-load-test.md +32 -0
- package/docs/recipes/pixel-art-pipeline.md +411 -0
- package/docs/recipes/private-npm-distribution.md +79 -0
- package/docs/recipes/search-index-ingestion.md +67 -0
- package/docs/recipes/self-host.md +60 -0
- package/docs/recipes/ship-and-regrade.md +72 -0
- package/docs/recipes/staging-data.md +50 -0
- package/docs/recipes/staging-environment.md +215 -0
- package/docs/recipes/upgrading-the-core.md +58 -0
- package/docs/research/cursor-competitive-analysis.md +255 -0
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +227 -0
- package/migrations/003_pms.sql +284 -0
- package/migrations/004_pms_cost_idempotency.sql +13 -0
- package/migrations/005_pms_smoke_filter.sql +44 -0
- package/migrations/006_pms_v2.sql +378 -0
- package/migrations/007_pms_v2_hierarchy_trigger.sql +59 -0
- package/migrations/008_pms_v2_migration_allocator.sql +27 -0
- package/migrations/009_pms_v2_blockers_table.sql +68 -0
- package/migrations/010_uptime.sql +29 -0
- package/migrations/011_backfill_v2_v3_done_when.sql +20 -0
- package/migrations/012_pms_v2_task_lifecycle_states.sql +82 -0
- package/migrations/013_claim_locking.sql +22 -0
- package/migrations/014_pms_task_discipline.sql +86 -0
- package/migrations/015_pms_estimation_calibration.sql +140 -0
- package/migrations/016_pms_task_grades.sql +60 -0
- package/migrations/017_version_progress_exclude_abandoned.sql +49 -0
- package/migrations/019_rename_pms_v3_to_gds_v3.sql +65 -0
- package/migrations/020_task_dependencies.sql +99 -0
- package/migrations/021_v3_renumber_and_deps.sql +224 -0
- package/migrations/022_v3_reconcile_dep_gated_status.sql +41 -0
- package/migrations/023_pms_rank.sql +30 -0
- package/migrations/025_builder_exit_status.sql +15 -0
- package/migrations/027_builders_preferred_disciplines.sql +46 -0
- package/migrations/028_tasks_parallel_safe.sql +71 -0
- package/migrations/029_rank_three_live.sql +32 -0
- package/migrations/030_vulnerability_reports.sql +61 -0
- package/migrations/031_reward_simplification.sql +57 -0
- package/migrations/032_repo_health_snapshots.sql +56 -0
- package/migrations/033_audit_log.sql +64 -0
- package/migrations/034_lifecycle_breakdown_leaf_only.sql +62 -0
- package/migrations/035_auth_token_hygiene.sql +66 -0
- package/migrations/037_builder_skill_model_prefs.sql +44 -0
- package/migrations/038_task_override_requests.sql +51 -0
- package/migrations/039_newcomer_friendly_column.sql +124 -0
- package/migrations/040_builder_karma.sql +58 -0
- package/migrations/041_kind_multipliers_and_idea_bonus.sql +42 -0
- package/migrations/042_criterion_proposal_workflow.sql +78 -0
- package/migrations/043_builder_onboarding_observability.sql +66 -0
- package/migrations/044_tasks_requires_rank.sql +97 -0
- package/migrations/045_builder_onboarding_state.sql +147 -0
- package/migrations/046_cost_log_builder_attribution.sql +42 -0
- package/migrations/054_builder_memory.sql +108 -0
- package/migrations/055_task_criteria.sql +90 -0
- package/migrations/058_peer_votes.sql +41 -0
- package/migrations/059_onboarding_pick_disciplines.sql +59 -0
- package/migrations/060_access_requests.sql +46 -0
- package/migrations/061_backfill_override_ship_attribution.sql +29 -0
- package/migrations/062_session_records.sql +34 -0
- package/migrations/063_session_records_tool_errors_idx.sql +11 -0
- package/migrations/064_grade_attempts.sql +18 -0
- package/migrations/065_bfg_principal.sql +58 -0
- package/migrations/066_live_notify_triggers.sql +69 -0
- package/migrations/067_builder_boxes.sql +117 -0
- package/migrations/067_discord_bot_foundation.sql +69 -0
- package/migrations/068_thetes_requires_rank.sql +27 -0
- package/migrations/069_builder_ssh_keys.sql +66 -0
- package/migrations/070_box_intents.sql +101 -0
- package/migrations/071_box_first_provision_approval.sql +46 -0
- package/migrations/072_onboarding_ship_three_remove_primer.sql +80 -0
- package/migrations/073_box_terminal_access.sql +46 -0
- package/migrations/075_builder_sound_prefs.sql +28 -0
- package/migrations/078_box_claude_active.sql +22 -0
- package/migrations/079_box_first_connected_at.sql +20 -0
- package/migrations/081_tasks_needs_migration.sql +34 -0
- package/migrations/084_tasks_security_sensitive.sql +24 -0
- package/migrations/085_security_engagement_docs.sql +34 -0
- package/migrations/086_rename_disciplines.sql +125 -0
- package/migrations/088_builders_anthropic_link.sql +28 -0
- package/migrations/089_builders_monthly_budget.sql +19 -0
- package/migrations/090_box_scoped_sessions.sql +38 -0
- package/migrations/091_session_token_reward.sql +44 -0
- package/migrations/093_tasks_sandbox_review.sql +22 -0
- package/migrations/102_remove_box_first_provision_approval.sql +58 -0
- package/migrations/103_search_chunks.sql +90 -0
- package/migrations/104_builder_box_blocked.sql +34 -0
- package/migrations/105_box_host_keys.sql +35 -0
- package/migrations/111_medusa_customer0_framing.sql +35 -0
- package/migrations/112_drop_track_check.sql +25 -0
- package/migrations/113_merge_lease.sql +16 -0
- package/migrations/114_cloudbongos_rename_live_strings.sql +32 -0
- package/migrations/118_currency_label_decoupling.sql +23 -0
- package/migrations/119_builder_behavior_prefs.sql +31 -0
- package/migrations/121_box_code_version.sql +37 -0
- package/migrations/122_builder_gemini_key.sql +30 -0
- package/migrations/123_cost_log_tiers.sql +33 -0
- package/migrations/124_cost_baseline.sql +45 -0
- package/migrations/125_llm_cache.sql +54 -0
- package/migrations/126_autonomy_runs.sql +33 -0
- package/migrations/143_cost_calibration.sql +63 -0
- package/migrations/145_backfill_task_requires_rank.sql +84 -0
- package/migrations/160_goal_tier.sql +88 -0
- package/migrations/163_polymorphic_dependencies.sql +187 -0
- package/migrations/164_event_sound_prefs.sql +20 -0
- package/migrations/165_task_module_key.sql +15 -0
- package/migrations/171_claims_published_head_sha.sql +24 -0
- package/migrations/174_close_orphaned_claims_on_terminal_tasks.sql +30 -0
- package/migrations/175_task_grades_diff_hash.sql +9 -0
- package/migrations/176_promotion_respects_open_blockers.sql +58 -0
- package/migrations/176_ui_discipline.sql +40 -0
- package/migrations/177_require_reward_before_workable.sql +76 -0
- package/migrations/178_reward_gate_auto_assign_revert_autopromote.sql +61 -0
- package/migrations/179_search_chunk_usefulness.sql +33 -0
- package/migrations/180_one_active_claim_per_session.sql +54 -0
- package/migrations/181_cost_log_skill_name.sql +33 -0
- package/migrations/184_claim_release_requested.sql +25 -0
- package/migrations/185_module_upstreaming_goal_wiring.sql +59 -0
- package/migrations/187_goal_sort_order.sql +30 -0
- package/migrations/187_tasks_kind_check_add_verify.sql +21 -0
- package/migrations/README.md +88 -0
- package/migrations/core_188_goal_visibility_and_membership_requests.sql +71 -0
- package/migrations/core_189_core_upgrades_ledger.sql +37 -0
- package/migrations/core_189_credit_log_task_id_index.sql +19 -0
- package/migrations/core_190_rebuild_claimable_task_views.sql +71 -0
- package/migrations/instance/018_pms_v3_planning_seed.sql +131 -0
- package/migrations/instance/080_gds_v4_planning_seed.sql +75 -0
- package/migrations/instance/110_medusa_planning_seed.sql +70 -0
- package/migrations/instance/120_bongos_v1_planning_seed.sql +69 -0
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +26 -0
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +29 -0
- package/migrations/instance/172_v2_world_planning_seed.sql +163 -0
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +33 -0
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +132 -0
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +116 -0
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +69 -0
- package/modules/autonomy/CLAUDE.md +49 -0
- package/modules/autonomy/db.js +50 -0
- package/modules/autonomy/module.json +13 -0
- package/modules/autonomy/pollers/routine-timers.js +146 -0
- package/modules/autonomy/routes/autonomy.js +37 -0
- package/modules/builder-settings/CLAUDE.md +58 -0
- package/modules/builder-settings/builder-needs.js +115 -0
- package/modules/builder-settings/db.js +168 -0
- package/modules/builder-settings/display-name.js +84 -0
- package/modules/builder-settings/event-sound-prefs.js +169 -0
- package/modules/builder-settings/interaction-prefs.js +201 -0
- package/modules/builder-settings/model-alloc-prefs.js +147 -0
- package/modules/builder-settings/module.json +10 -0
- package/modules/builder-settings/render-prefs.js +103 -0
- package/modules/builder-settings/routes/settings.js +417 -0
- package/modules/builder-settings/settings.js +52 -0
- package/modules/builder-settings/skill-prefs.js +196 -0
- package/modules/builder-settings/sound-prefs.js +178 -0
- package/modules/builder-settings/wandering-prefs.js +282 -0
- package/modules/dev-box/CLAUDE.md +38 -0
- package/modules/dev-box/box-access.js +275 -0
- package/modules/dev-box/box-credential.js +94 -0
- package/modules/dev-box/box-onboard.js +486 -0
- package/modules/dev-box/boxes.js +842 -0
- package/modules/dev-box/db.js +39 -0
- package/modules/dev-box/module.json +14 -0
- package/modules/dev-box/routes/box.js +977 -0
- package/modules/discord/CLAUDE.md +50 -0
- package/modules/discord/auth.js +54 -0
- package/modules/discord/bug-attachments.js +210 -0
- package/modules/discord/db.js +96 -0
- package/modules/discord/discord-approvals.js +145 -0
- package/modules/discord/discord-audit.js +29 -0
- package/modules/discord/discord-bot.js +545 -0
- package/modules/discord/discord-bugs.js +257 -0
- package/modules/discord/discord-channels.js +412 -0
- package/modules/discord/discord-inbound.js +147 -0
- package/modules/discord/discord-message-buffer.js +54 -0
- package/modules/discord/discord-reaction.js +186 -0
- package/modules/discord/discord-role-sync.js +318 -0
- package/modules/discord/module.json +14 -0
- package/modules/discord/pollers/discord-bot.js +19 -0
- package/modules/discord/routes/bug-attachments.js +44 -0
- package/modules/discord/routes/discord.js +102 -0
- package/modules/discord/ship-broadcast.js +78 -0
- package/modules/discord/webhook.js +74 -0
- package/modules/economy/CLAUDE.md +48 -0
- package/modules/economy/achievements.js +594 -0
- package/modules/economy/cost-classify.js +331 -0
- package/modules/economy/cost-summary-shape.js +90 -0
- package/modules/economy/cost.js +438 -0
- package/modules/economy/credits.js +430 -0
- package/modules/economy/migrations/economy_001_fix_gds_shipper_description.sql +91 -0
- package/modules/economy/module.json +10 -0
- package/modules/economy/reward.js +61 -0
- package/modules/economy/routes/cost.js +97 -0
- package/modules/economy/routes/leaderboard.js +26 -0
- package/modules/economy/routes/reward.js +42 -0
- package/modules/economy/streak.js +285 -0
- package/modules/grading/CLAUDE.md +42 -0
- package/modules/grading/db.js +39 -0
- package/modules/grading/grader-rubric.json +151 -0
- package/modules/grading/grader-workers/efficiency.js +213 -0
- package/modules/grading/grader-workers/extract-json.js +80 -0
- package/modules/grading/grader-workers/fence.js +100 -0
- package/modules/grading/grader-workers/hacker.js +239 -0
- package/modules/grading/grader-workers/index.js +183 -0
- package/modules/grading/grader-workers/narc.js +207 -0
- package/modules/grading/grader-workers/quality.js +198 -0
- package/modules/grading/grader-workers/rubric.js +20 -0
- package/modules/grading/grader-workers/worker-prompt.js +89 -0
- package/modules/grading/grader-workers/worker-verdict.js +50 -0
- package/modules/grading/grader.js +1790 -0
- package/modules/grading/module.json +12 -0
- package/modules/grading/routes/grade.js +51 -0
- package/modules/hall-ui/CLAUDE.md +61 -0
- package/modules/hall-ui/module.json +14 -0
- package/modules/hall-ui/public/atlas.html +180 -0
- package/modules/hall-ui/public/atlas.js +336 -0
- package/modules/hall-ui/public/box-panel.js +423 -0
- package/modules/hall-ui/public/brand-apply.js +117 -0
- package/modules/hall-ui/public/builders.js +3028 -0
- package/modules/hall-ui/public/chime.wav +0 -0
- package/modules/hall-ui/public/copy-on-click.js +174 -0
- package/modules/hall-ui/public/cursors/arrow.png +0 -0
- package/modules/hall-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier-out.png +0 -0
- package/modules/hall-ui/public/cursors/magnifier.png +0 -0
- package/modules/hall-ui/public/diagram-viewer.css +125 -0
- package/modules/hall-ui/public/diagram-viewer.js +330 -0
- package/modules/hall-ui/public/diagrams.html +113 -0
- package/modules/hall-ui/public/dom-utils.js +212 -0
- package/modules/hall-ui/public/drachmae.html +68 -0
- package/modules/hall-ui/public/drachmae.js +136 -0
- package/modules/hall-ui/public/gate.html +143 -0
- package/modules/hall-ui/public/gate.js +228 -0
- package/modules/hall-ui/public/goals-page.js +1297 -0
- package/modules/hall-ui/public/goals.html +376 -0
- package/modules/hall-ui/public/goals.js +119 -0
- package/modules/hall-ui/public/hall-widgets.js +189 -0
- package/modules/hall-ui/public/harbor.html +167 -0
- package/modules/hall-ui/public/harbor.js +335 -0
- package/modules/hall-ui/public/index.html +116 -0
- package/modules/hall-ui/public/instances-panel.js +172 -0
- package/modules/hall-ui/public/md-reader.js +234 -0
- package/modules/hall-ui/public/modules.html +119 -0
- package/modules/hall-ui/public/modules.js +154 -0
- package/modules/hall-ui/public/new-project-wizard.js +186 -0
- package/modules/hall-ui/public/not-ready.html +48 -0
- package/modules/hall-ui/public/pair.html +119 -0
- package/modules/hall-ui/public/pair.js +132 -0
- package/modules/hall-ui/public/primer.css +404 -0
- package/modules/hall-ui/public/primer.html +85 -0
- package/modules/hall-ui/public/primer.js +641 -0
- package/modules/hall-ui/public/profile.html +85 -0
- package/modules/hall-ui/public/profile.js +236 -0
- package/modules/hall-ui/public/ranks.html +69 -0
- package/modules/hall-ui/public/ranks.js +206 -0
- package/modules/hall-ui/public/roadmap.html +209 -0
- package/modules/hall-ui/public/roadmap.js +375 -0
- package/modules/hall-ui/public/sessions.html +92 -0
- package/modules/hall-ui/public/sessions.js +222 -0
- package/modules/hall-ui/public/settings.html +640 -0
- package/modules/hall-ui/public/settings.js +1219 -0
- package/modules/hall-ui/public/shell.js +495 -0
- package/modules/hall-ui/public/style.css +1748 -0
- package/modules/hall-ui/public/task-detail.js +253 -0
- package/modules/hall-ui/public/task.html +83 -0
- package/modules/hall-ui/public/task.js +326 -0
- package/modules/hall-ui/public/watch.html +333 -0
- package/modules/hall-ui/public/watch.js +859 -0
- package/modules/hall-ui/public/work.html +469 -0
- package/modules/hall-ui/public/work.js +1292 -0
- package/modules/ideas/CLAUDE.md +37 -0
- package/modules/ideas/blockers.js +100 -0
- package/modules/ideas/inbox.js +459 -0
- package/modules/ideas/module.json +10 -0
- package/modules/ideas/routes/blockers.js +120 -0
- package/modules/ideas/routes/inbox.js +230 -0
- package/modules/lifecycle/CLAUDE.md +63 -0
- package/modules/lifecycle/conflict-resolve.js +436 -0
- package/modules/lifecycle/db.js +4970 -0
- package/modules/lifecycle/done-when.js +355 -0
- package/modules/lifecycle/github-push.js +969 -0
- package/modules/lifecycle/goal-conflicts.js +133 -0
- package/modules/lifecycle/hierarchy.js +104 -0
- package/modules/lifecycle/lifecycle.js +58 -0
- package/modules/lifecycle/merge-lock.js +261 -0
- package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +28 -0
- package/modules/lifecycle/migrations/lifecycle_002_backfill_orphan_task_goals.sql +36 -0
- package/modules/lifecycle/module.json +13 -0
- package/modules/lifecycle/pollers/publish-reconciler.js +20 -0
- package/modules/lifecycle/publish-reconciler.js +418 -0
- package/modules/lifecycle/routes/analytics.js +63 -0
- package/modules/lifecycle/routes/claims.js +506 -0
- package/modules/lifecycle/routes/dependencies.js +82 -0
- package/modules/lifecycle/routes/done-when.js +136 -0
- package/modules/lifecycle/routes/gate-approvals.js +197 -0
- package/modules/lifecycle/routes/goals.js +1419 -0
- package/modules/lifecycle/routes/lifecycle.js +43 -0
- package/modules/lifecycle/routes/tasks.js +1703 -0
- package/modules/lifecycle/routes/versions.js +128 -0
- package/modules/lifecycle/ship-card.js +505 -0
- package/modules/lifecycle/ship-preflight.js +225 -0
- package/modules/lifecycle/task-classifier.js +512 -0
- package/modules/lifecycle/version-close.js +106 -0
- package/modules/memory/CLAUDE.md +40 -0
- package/modules/memory/learnings.js +275 -0
- package/modules/memory/memory.js +572 -0
- package/modules/memory/module.json +12 -0
- package/modules/memory/routes/learnings.js +90 -0
- package/modules/memory/routes/memory.js +916 -0
- package/modules/memory/routes/search.js +188 -0
- package/modules/memory/search-chunker.js +252 -0
- package/modules/memory/search.js +320 -0
- package/modules/onboarding/CLAUDE.md +55 -0
- package/modules/onboarding/approval-broadcast.js +59 -0
- package/modules/onboarding/db.js +54 -0
- package/modules/onboarding/module.json +10 -0
- package/modules/onboarding/newcomer-restock.js +212 -0
- package/modules/onboarding/onboarding-state.js +532 -0
- package/modules/onboarding/onboarding.js +508 -0
- package/modules/onboarding/port.js +63 -0
- package/modules/onboarding/routes/access-requests.js +208 -0
- package/modules/onboarding/routes/onboarding.js +54 -0
- package/modules/security/CLAUDE.md +44 -0
- package/modules/security/module.json +10 -0
- package/modules/security/routes/reports.js +251 -0
- package/modules/security/secret-scrub.js +201 -0
- package/modules/security/security.js +294 -0
- package/modules/sessions/CLAUDE.md +39 -0
- package/modules/sessions/bfg.js +357 -0
- package/modules/sessions/db.js +326 -0
- package/modules/sessions/module.json +10 -0
- package/modules/sessions/pulse.js +149 -0
- package/modules/sessions/routes/sessions.js +331 -0
- package/modules/status-ui/CLAUDE.md +47 -0
- package/modules/status-ui/module.json +14 -0
- package/modules/status-ui/public/README.md +29 -0
- package/modules/status-ui/public/cursors/arrow.png +0 -0
- package/modules/status-ui/public/cursors/hand-pointer.png +0 -0
- package/modules/status-ui/public/index.html +363 -0
- package/modules/status-ui/public/snapshots/.gitkeep +0 -0
- package/modules/status-ui/public/status.js +1375 -0
- package/modules/status-ui/public/style.css +1342 -0
- package/package-lock.json +975 -0
- package/package.json +28 -0
- package/public-docs/index.html +35 -0
- package/public-docs/redoc.standalone.js +1838 -0
- package/public-docs/redoc.standalone.js.LICENSE.txt +102 -0
- package/public-landing/buddha.png +0 -0
- package/public-landing/create.html +868 -0
- package/public-landing/index.html +168 -0
- package/scripts/bongos-mcp.js +28 -0
- package/scripts/cron/style_guide_audit.sh +134 -0
- package/scripts/discord/README.md +52 -0
- package/scripts/discord/lib.js +80 -0
- package/scripts/discord/post.js +51 -0
- package/scripts/gds/CLAUDE.md +144 -0
- package/scripts/gds/abandon.js +58 -0
- package/scripts/gds/adopt-detect.js +123 -0
- package/scripts/gds/adopt-import.js +111 -0
- package/scripts/gds/adopt-preflight.js +121 -0
- package/scripts/gds/api.js +78 -0
- package/scripts/gds/architect-audit.js +288 -0
- package/scripts/gds/art-stage.js +152 -0
- package/scripts/gds/audit-authorship.sh +243 -0
- package/scripts/gds/audit-deps.js +121 -0
- package/scripts/gds/audit-exports.js +128 -0
- package/scripts/gds/audit-replay.sh +161 -0
- package/scripts/gds/audit-ships.js +251 -0
- package/scripts/gds/autonomy-gate.js +135 -0
- package/scripts/gds/backfill-cost.js +165 -0
- package/scripts/gds/backfill-sessions.js +351 -0
- package/scripts/gds/backfill-task-rewards.js +408 -0
- package/scripts/gds/backup.js +94 -0
- package/scripts/gds/bfg-principal-token.js +65 -0
- package/scripts/gds/box-auth-check.js +160 -0
- package/scripts/gds/box-connect-lib.js +453 -0
- package/scripts/gds/box.js +1258 -0
- package/scripts/gds/build-bongos.js +94 -0
- package/scripts/gds/capture.js +123 -0
- package/scripts/gds/cf-tunnel.js +419 -0
- package/scripts/gds/ci-grade.js +82 -0
- package/scripts/gds/claim-recall.js +112 -0
- package/scripts/gds/claim.js +333 -0
- package/scripts/gds/classify-discipline.js +153 -0
- package/scripts/gds/claude-materialize.js +176 -0
- package/scripts/gds/cleanup-orphans.js +74 -0
- package/scripts/gds/cli-lib.js +518 -0
- package/scripts/gds/code.js +101 -0
- package/scripts/gds/codemod-rename-src-gds.js +145 -0
- package/scripts/gds/conductor-dispatch.js +426 -0
- package/scripts/gds/context-pack.js +228 -0
- package/scripts/gds/coreB-carve-proof.sh +716 -0
- package/scripts/gds/cost.js +47 -0
- package/scripts/gds/db-backup-nightly.sh +160 -0
- package/scripts/gds/db-backup-offsite.sh +152 -0
- package/scripts/gds/demote.js +40 -0
- package/scripts/gds/design-sync.js +62 -0
- package/scripts/gds/diagnose-achievements.js +118 -0
- package/scripts/gds/diagram-facts.js +319 -0
- package/scripts/gds/discipline-modes.json +25 -0
- package/scripts/gds/discord-channels.js +66 -0
- package/scripts/gds/do-api.js +176 -0
- package/scripts/gds/docs-entropy.js +403 -0
- package/scripts/gds/doctor.js +389 -0
- package/scripts/gds/feedback-latest.js +94 -0
- package/scripts/gds/fetch-art-key.js +106 -0
- package/scripts/gds/fetch-sound-prefs.js +88 -0
- package/scripts/gds/figma-design-sync.js +61 -0
- package/scripts/gds/fitness.js +1446 -0
- package/scripts/gds/gate-review.js +322 -0
- package/scripts/gds/gating.js +325 -0
- package/scripts/gds/gds-db-backup.service +24 -0
- package/scripts/gds/gds-db-backup.timer +20 -0
- package/scripts/gds/gds-db-offsite.service +29 -0
- package/scripts/gds/gds-db-offsite.timer +18 -0
- package/scripts/gds/gen-api-client.js +508 -0
- package/scripts/gds/gen-api-docs.js +1037 -0
- package/scripts/gds/gen-atlas.js +366 -0
- package/scripts/gds/gen-diagrams.js +608 -0
- package/scripts/gds/gen-file-map.js +196 -0
- package/scripts/gds/gen-repo-map.js +519 -0
- package/scripts/gds/gen-session-index.js +210 -0
- package/scripts/gds/git-merge-regen.js +147 -0
- package/scripts/gds/grade-smoke.js +205 -0
- package/scripts/gds/hooks/pre-push.js +169 -0
- package/scripts/gds/http-api-client.js +88 -0
- package/scripts/gds/init.js +903 -0
- package/scripts/gds/install-git-hooks.js +114 -0
- package/scripts/gds/key-card.js +213 -0
- package/scripts/gds/key-status.js +126 -0
- package/scripts/gds/land-watch.js +168 -0
- package/scripts/gds/learning-capture.js +145 -0
- package/scripts/gds/lib.sh +44 -0
- package/scripts/gds/linkify-refs.js +473 -0
- package/scripts/gds/local-preview-lib.js +159 -0
- package/scripts/gds/local-preview.js +95 -0
- package/scripts/gds/login.js +225 -0
- package/scripts/gds/main-audit.js +279 -0
- package/scripts/gds/me.js +88 -0
- package/scripts/gds/memory-map.js +174 -0
- package/scripts/gds/merge-lock.js +71 -0
- package/scripts/gds/migration-namespace.js +200 -0
- package/scripts/gds/mirror-lineage-check.js +319 -0
- package/scripts/gds/mirror-redact.js +305 -0
- package/scripts/gds/module.js +482 -0
- package/scripts/gds/newcomer-floor.config.json +13 -0
- package/scripts/gds/newcomer-floor.js +415 -0
- package/scripts/gds/newcomer-floor.templates.json +86 -0
- package/scripts/gds/newcomer-floor.test.js +235 -0
- package/scripts/gds/next-number.js +163 -0
- package/scripts/gds/oauth-secret.js +197 -0
- package/scripts/gds/onboard.js +217 -0
- package/scripts/gds/onboarding-analyze.js +226 -0
- package/scripts/gds/onboarding-config.js +178 -0
- package/scripts/gds/onboarding-ship.js +92 -0
- package/scripts/gds/package-core.js +684 -0
- package/scripts/gds/paste-token.js +131 -0
- package/scripts/gds/portability-smoke-art.sh +85 -0
- package/scripts/gds/portability-smoke-memory.sh +147 -0
- package/scripts/gds/provision.js +1324 -0
- package/scripts/gds/publish-manifest.js +307 -0
- package/scripts/gds/pulse.js +309 -0
- package/scripts/gds/push-main.js +45 -0
- package/scripts/gds/read-session-export.js +359 -0
- package/scripts/gds/reauth.js +149 -0
- package/scripts/gds/recall.js +135 -0
- package/scripts/gds/release.js +71 -0
- package/scripts/gds/render-ideas.js +173 -0
- package/scripts/gds/repo-metrics.js +363 -0
- package/scripts/gds/resolve-skill-model.js +57 -0
- package/scripts/gds/run-routine.js +197 -0
- package/scripts/gds/run-unit-tests.js +178 -0
- package/scripts/gds/sandbox-stage.js +653 -0
- package/scripts/gds/search-ingest-db.js +209 -0
- package/scripts/gds/search-ingest-md.js +225 -0
- package/scripts/gds/security-baseline-check.sh +180 -0
- package/scripts/gds/seed-adopt-tasks.js +75 -0
- package/scripts/gds/seed-api-contract-goal-tasks.js +141 -0
- package/scripts/gds/seed-bongos-app-tasks.js +294 -0
- package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +81 -0
- package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +100 -0
- package/scripts/gds/seed-bongos-goal-hierarchy-tasks.js +307 -0
- package/scripts/gds/seed-bongos-module-system-tasks.js +346 -0
- package/scripts/gds/seed-bongos-tasks.js +214 -0
- package/scripts/gds/seed-gds-v4-tasks.js +624 -0
- package/scripts/gds/seed-governance-tasks.js +340 -0
- package/scripts/gds/seed-medusa-tasks.js +440 -0
- package/scripts/gds/seed-module-upstreaming.js +219 -0
- package/scripts/gds/seed-productization-tasks.js +235 -0
- package/scripts/gds/seed-provisioning-tasks.js +83 -0
- package/scripts/gds/seed-staging.sh +104 -0
- package/scripts/gds/seed-v2-world-tasks.js +283 -0
- package/scripts/gds/seed-v3-tasks.js +1981 -0
- package/scripts/gds/session-cost.js +393 -0
- package/scripts/gds/session-digest.js +179 -0
- package/scripts/gds/session-eval.js +576 -0
- package/scripts/gds/session.js +404 -0
- package/scripts/gds/setup.js +504 -0
- package/scripts/gds/shell.js +100 -0
- package/scripts/gds/ship.js +3618 -0
- package/scripts/gds/smoke-batch-claim.sh +213 -0
- package/scripts/gds/smoke-batch-release.sh +296 -0
- package/scripts/gds/smoke-box.sh +150 -0
- package/scripts/gds/smoke-claim-consistency.sh +212 -0
- package/scripts/gds/smoke-dependencies.sh +84 -0
- package/scripts/gds/smoke-gds.js +125 -0
- package/scripts/gds/smoke-write.sh +168 -0
- package/scripts/gds/start.js +782 -0
- package/scripts/gds/status.js +365 -0
- package/scripts/gds/triage.js +366 -0
- package/scripts/gds/upgrade.js +421 -0
- package/scripts/gds/validate-design.js +254 -0
- package/scripts/gds/version-close.js +549 -0
- package/scripts/migrate.sh +183 -0
- package/scripts/migrate_cost_ledger.sh +178 -0
- package/scripts/public-mirror-export.js +291 -0
- package/scripts/render-diagrams.sh +88 -0
- package/scripts/security/dep-audit.js +276 -0
- package/scripts/setup-builder.js +274 -0
- package/scripts/setup-builder.sh +28 -0
- package/scripts/snapshots/backfill-history.mjs +292 -0
- package/scripts/snapshots/maybe-render-on-deploy.sh +66 -0
- package/scripts/snapshots/render-pre-art-eras.mjs +329 -0
- package/scripts/snapshots/render-world.mjs +322 -0
- package/scripts/status-mirror-sync.js +192 -0
- package/scripts/status-probe.mjs +185 -0
- package/server.js +212 -0
- package/src/asset-version.js +71 -0
- package/src/bongos/CLAUDE.md +64 -0
- package/src/bongos/api-errors.js +281 -0
- package/src/bongos/api-path-404.js +67 -0
- package/src/bongos/api-prefix.js +38 -0
- package/src/bongos/app-pair.js +172 -0
- package/src/bongos/auth.js +979 -0
- package/src/bongos/cascade-dispatch.js +123 -0
- package/src/bongos/db-kernel.js +252 -0
- package/src/bongos/db.js +911 -0
- package/src/bongos/goal-scope-check.js +120 -0
- package/src/bongos/hierarchy-config.js +189 -0
- package/src/bongos/live-channel.js +145 -0
- package/src/bongos/llm-cache.js +260 -0
- package/src/bongos/llm-pricing.js +220 -0
- package/src/bongos/metrics.js +286 -0
- package/src/bongos/middleware/audit.js +274 -0
- package/src/bongos/middleware/error-envelope.js +93 -0
- package/src/bongos/middleware/rate-limit.js +195 -0
- package/src/bongos/module-scope-map.js +271 -0
- package/src/bongos/optimizer.js +166 -0
- package/src/bongos/path-match.js +47 -0
- package/src/bongos/permission-path-check.js +248 -0
- package/src/bongos/pool.js +55 -0
- package/src/bongos/repo-info.js +91 -0
- package/src/bongos/route-rank-check.js +420 -0
- package/src/bongos/routes/CLAUDE.md +43 -0
- package/src/bongos/routes/_helpers.js +369 -0
- package/src/bongos/routes/audit-log.js +59 -0
- package/src/bongos/routes/auth.js +755 -0
- package/src/bongos/routes/backup.js +104 -0
- package/src/bongos/routes/builders.js +526 -0
- package/src/bongos/routes/healthz.js +16 -0
- package/src/bongos/routes/instance.js +129 -0
- package/src/bongos/routes/live.js +125 -0
- package/src/bongos/routes/llm-cache.js +119 -0
- package/src/bongos/routes/me.js +380 -0
- package/src/bongos/routes/modules.js +131 -0
- package/src/bongos/routes/optimizer.js +54 -0
- package/src/bongos/routes/public.js +476 -0
- package/src/bongos/routes/security.js +207 -0
- package/src/bongos/routes.js +166 -0
- package/src/bongos/secret-box.js +177 -0
- package/src/bongos/sensitive-surfaces.js +30 -0
- package/src/bongos/serve-internal.js +889 -0
- package/src/bongos/uptime-poller.js +90 -0
- package/src/bongos-downloads.js +70 -0
- package/src/branding.js +182 -0
- package/src/build-info.js +77 -0
- package/src/devbox-downloads.js +67 -0
- package/src/instance-config.js +200 -0
- package/src/module-api.js +366 -0
- package/src/module-loader/loader.js +268 -0
- package/src/module-loader/manifest-schema.js +140 -0
- package/src/module-loader/provenance.js +28 -0
- package/src/module-loader/semver.js +100 -0
- package/src/module-seams.js +291 -0
- package/src/modules.js +254 -0
- package/src/platform-server.js +135 -0
- package/src/ui/adapters/claude-design/index.js +333 -0
- package/src/ui/adapters/figma/index.js +328 -0
- package/tests/abandon_task.mjs +138 -0
- package/tests/achievements_reconcile.mjs +167 -0
- package/tests/adopt_detect.mjs +141 -0
- package/tests/adopt_import.mjs +132 -0
- package/tests/adopt_preflight.mjs +105 -0
- package/tests/anti_grid.mjs +89 -0
- package/tests/api_client.mjs +231 -0
- package/tests/api_docs.mjs +283 -0
- package/tests/api_error_envelope_buildcheck.mjs +56 -0
- package/tests/api_error_envelope_middleware.mjs +99 -0
- package/tests/api_error_registry.mjs +108 -0
- package/tests/api_pagination_contract.mjs +51 -0
- package/tests/api_path_404.mjs +96 -0
- package/tests/app_pair.mjs +168 -0
- package/tests/architect.mjs +140 -0
- package/tests/art_key.mjs +121 -0
- package/tests/art_stage.mjs +121 -0
- package/tests/asset_version.mjs +94 -0
- package/tests/atlas.mjs +69 -0
- package/tests/audit_deps.mjs +74 -0
- package/tests/audit_middleware.mjs +385 -0
- package/tests/auth_admission_gate.mjs +164 -0
- package/tests/auth_anthropic_identity.mjs +95 -0
- package/tests/auth_duplicate_cookie.mjs +56 -0
- package/tests/auth_error_brand.mjs +77 -0
- package/tests/auth_flow_slice1.mjs +223 -0
- package/tests/auth_inactive_builder.mjs +94 -0
- package/tests/auth_page_gate.mjs +92 -0
- package/tests/auth_session_ttl.mjs +155 -0
- package/tests/autonomous_fit_protected_paths.mjs +115 -0
- package/tests/autonomy_gate.mjs +85 -0
- package/tests/autotile_variants.mjs +256 -0
- package/tests/autotile_xverify.mjs +133 -0
- package/tests/backfill_sessions.mjs +101 -0
- package/tests/backfill_task_rewards.mjs +152 -0
- package/tests/backup_cli.mjs +104 -0
- package/tests/bfg.mjs +327 -0
- package/tests/bfg_write.mjs +423 -0
- package/tests/bongos_box_connect.mjs +89 -0
- package/tests/bongos_dispatch.mjs +74 -0
- package/tests/bongos_downloads.mjs +118 -0
- package/tests/bongos_login.mjs +34 -0
- package/tests/box_access.mjs +186 -0
- package/tests/box_auth_check.mjs +114 -0
- package/tests/box_code_staleness.mjs +116 -0
- package/tests/box_connect_e2e.mjs +196 -0
- package/tests/box_cost.mjs +70 -0
- package/tests/box_dns_repoint.mjs +151 -0
- package/tests/box_host_keys.mjs +102 -0
- package/tests/box_onboard.mjs +317 -0
- package/tests/box_scope_session.mjs +115 -0
- package/tests/box_ship_permissions.mjs +71 -0
- package/tests/box_terminal_ssrf.mjs +90 -0
- package/tests/boxes.mjs +1255 -0
- package/tests/branding.mjs +88 -0
- package/tests/bug_attachments.mjs +189 -0
- package/tests/build_info.mjs +88 -0
- package/tests/builder_needs.mjs +99 -0
- package/tests/cap_newcomer_tasks.mjs +114 -0
- package/tests/cascade-dispatch.mjs +95 -0
- package/tests/cf_tunnel.mjs +459 -0
- package/tests/claim_recall.mjs +150 -0
- package/tests/claims_bind_session.mjs +47 -0
- package/tests/claude_materialize.mjs +148 -0
- package/tests/cli_box_scope.mjs +52 -0
- package/tests/cli_lib_formatters.mjs +87 -0
- package/tests/cli_token_reissue.mjs +147 -0
- package/tests/client_bundle_route.mjs +47 -0
- package/tests/cloud_bongos_pack.mjs +85 -0
- package/tests/conductor.mjs +496 -0
- package/tests/confirm_attribution.mjs +156 -0
- package/tests/conflict_resolve.mjs +462 -0
- package/tests/context_pack.mjs +240 -0
- package/tests/cost.mjs +94 -0
- package/tests/cost_audit.mjs +122 -0
- package/tests/cost_budget_status.mjs +78 -0
- package/tests/cost_reconcile.mjs +102 -0
- package/tests/cost_summary_shape.mjs +94 -0
- package/tests/credit_grant.mjs +376 -0
- package/tests/criterion_progress.mjs +220 -0
- package/tests/criterion_proposal.mjs +109 -0
- package/tests/criterion_ref_parse.mjs +53 -0
- package/tests/currency_label.mjs +119 -0
- package/tests/day_cycle.mjs +197 -0
- package/tests/dependencies_migration.mjs +77 -0
- package/tests/dependencies_polymorphic.mjs +212 -0
- package/tests/deploy_instance.mjs +78 -0
- package/tests/devbox_downloads.mjs +122 -0
- package/tests/diff_derived_signals.mjs +115 -0
- package/tests/discord_approvals.mjs +174 -0
- package/tests/discord_autojoin.mjs +158 -0
- package/tests/discord_bot.mjs +94 -0
- package/tests/discord_bugs.mjs +297 -0
- package/tests/discord_channels.mjs +337 -0
- package/tests/discord_foundation.mjs +76 -0
- package/tests/discord_inbound.mjs +190 -0
- package/tests/discord_message_buffer.mjs +87 -0
- package/tests/discord_oauth.mjs +55 -0
- package/tests/discord_reaction.mjs +170 -0
- package/tests/discord_role_sync.mjs +151 -0
- package/tests/display_name.mjs +123 -0
- package/tests/dns_preflight.mjs +74 -0
- package/tests/event_sound_prefs.mjs +194 -0
- package/tests/eviction_policy.mjs +279 -0
- package/tests/figma_adapter.mjs +199 -0
- package/tests/file_map.mjs +91 -0
- package/tests/first_admin_bootstrap.mjs +40 -0
- package/tests/fitness.mjs +435 -0
- package/tests/fitness_two_root.mjs +104 -0
- package/tests/fixtures/session-506-digest.json +34 -0
- package/tests/gate_approvals.mjs +451 -0
- package/tests/gate_review.mjs +152 -0
- package/tests/git_merge_regen.mjs +151 -0
- package/tests/github_push_ancestry.mjs +119 -0
- package/tests/github_push_land_proof.mjs +127 -0
- package/tests/github_push_merge_driver.mjs +175 -0
- package/tests/goal_abuse_matrix.mjs +187 -0
- package/tests/goal_achievement.mjs +146 -0
- package/tests/goal_conflicts.mjs +305 -0
- package/tests/goal_dependencies_read.mjs +73 -0
- package/tests/goal_member_roles.mjs +420 -0
- package/tests/goal_membership_requests.mjs +332 -0
- package/tests/goal_routes.mjs +203 -0
- package/tests/goal_scope_check.mjs +167 -0
- package/tests/goal_tier.mjs +312 -0
- package/tests/goal_wall_proof.mjs +112 -0
- package/tests/grade_attempts.mjs +73 -0
- package/tests/grade_attribution.mjs +117 -0
- package/tests/grade_diff_hash.mjs +191 -0
- package/tests/grade_server_authoritative.mjs +305 -0
- package/tests/grade_unavailable_display.mjs +66 -0
- package/tests/grader.mjs +1960 -0
- package/tests/grader_bypass.mjs +57 -0
- package/tests/grader_panel.mjs +252 -0
- package/tests/grader_prompt_fencing.mjs +192 -0
- package/tests/helpers.mjs +52 -0
- package/tests/hierarchy-config.mjs +81 -0
- package/tests/init.mjs +463 -0
- package/tests/instance_config.mjs +76 -0
- package/tests/instance_manifest.mjs +110 -0
- package/tests/interaction_prefs.mjs +72 -0
- package/tests/key-card.mjs +110 -0
- package/tests/key-status.mjs +45 -0
- package/tests/land_watch.mjs +115 -0
- package/tests/lifecycle-card.mjs +145 -0
- package/tests/lifecycle_breakdown_view.mjs +236 -0
- package/tests/linkify_refs.mjs +264 -0
- package/tests/live_channel.mjs +128 -0
- package/tests/live_sse.mjs +122 -0
- package/tests/llm-cache-route.mjs +110 -0
- package/tests/llm-cache.mjs +140 -0
- package/tests/llm-pricing.mjs +152 -0
- package/tests/local_preview.mjs +136 -0
- package/tests/main_audit.mjs +137 -0
- package/tests/memory_isolation.mjs +431 -0
- package/tests/memory_map.mjs +107 -0
- package/tests/memory_pull.mjs +429 -0
- package/tests/merge_lease.mjs +100 -0
- package/tests/merge_lock.mjs +312 -0
- package/tests/metrics.mjs +147 -0
- package/tests/migration_allocation.mjs +146 -0
- package/tests/migration_namespace.mjs +185 -0
- package/tests/migrations_fresh_replay_guard.mjs +38 -0
- package/tests/mirror_lineage_fulltree.mjs +45 -0
- package/tests/mirror_lineage_proof.mjs +72 -0
- package/tests/mirror_redact.mjs +193 -0
- package/tests/model_alloc_prefs.mjs +63 -0
- package/tests/module-scope-map.mjs +88 -0
- package/tests/module_api.mjs +110 -0
- package/tests/module_cli.mjs +292 -0
- package/tests/module_contributions.mjs +148 -0
- package/tests/module_loader.mjs +286 -0
- package/tests/module_manifest.mjs +194 -0
- package/tests/module_owned_tests.mjs +105 -0
- package/tests/module_provenance.mjs +34 -0
- package/tests/module_route_rank.mjs +147 -0
- package/tests/module_seams.mjs +68 -0
- package/tests/modules.mjs +156 -0
- package/tests/modules_route.mjs +38 -0
- package/tests/newcomer_floor.mjs +17 -0
- package/tests/newcomer_restock.mjs +80 -0
- package/tests/next_number.mjs +154 -0
- package/tests/oauth_secret.mjs +60 -0
- package/tests/onboard.mjs +70 -0
- package/tests/onboarding_gate.mjs +267 -0
- package/tests/onboarding_observability.mjs +344 -0
- package/tests/onboarding_route_signals.mjs +175 -0
- package/tests/onboarding_state.mjs +346 -0
- package/tests/one_active_claim_per_session.mjs +111 -0
- package/tests/orphaned_claim_ship.mjs +135 -0
- package/tests/override_attribution.mjs +142 -0
- package/tests/override_request_ownership.mjs +235 -0
- package/tests/package_core.mjs +435 -0
- package/tests/parallel-load.mjs +402 -0
- package/tests/path_match.mjs +62 -0
- package/tests/permission_path.mjs +151 -0
- package/tests/platform_boot.mjs +167 -0
- package/tests/pre_compact.mjs +60 -0
- package/tests/preview_server.mjs +127 -0
- package/tests/profile_route.mjs +197 -0
- package/tests/provision.mjs +805 -0
- package/tests/provisioning_github_app_routes.mjs +98 -0
- package/tests/public_mirror_export.mjs +124 -0
- package/tests/public_task_resolver.mjs +112 -0
- package/tests/publish_branch_route.mjs +422 -0
- package/tests/publish_manifest.mjs +184 -0
- package/tests/publish_reconciler.mjs +728 -0
- package/tests/rank_gate.mjs +463 -0
- package/tests/rate_limit_public.mjs +217 -0
- package/tests/read_session_export.mjs +89 -0
- package/tests/recall_cli.mjs +82 -0
- package/tests/recall_hints.mjs +119 -0
- package/tests/recent_shipped_window.mjs +80 -0
- package/tests/regrade_eligibility.mjs +202 -0
- package/tests/render_prefs.mjs +122 -0
- package/tests/repo_map.mjs +92 -0
- package/tests/reward_gate.mjs +284 -0
- package/tests/route_rank_check.mjs +243 -0
- package/tests/route_validation.mjs +490 -0
- package/tests/routine_timers.mjs +72 -0
- package/tests/rubric_fixtures.mjs +140 -0
- package/tests/run_routine.mjs +69 -0
- package/tests/sandbox_stage.mjs +582 -0
- package/tests/scrubber_corpus.mjs +257 -0
- package/tests/search_chunker.mjs +209 -0
- package/tests/search_ingest.mjs +96 -0
- package/tests/search_ingest_db.mjs +88 -0
- package/tests/search_isolation.mjs +204 -0
- package/tests/search_route.mjs +95 -0
- package/tests/secret_box.mjs +189 -0
- package/tests/security_docs.mjs +186 -0
- package/tests/security_route.mjs +108 -0
- package/tests/served_paths_shipped.mjs +118 -0
- package/tests/session-cards.mjs +142 -0
- package/tests/session_506_regression.mjs +69 -0
- package/tests/session_caps.mjs +238 -0
- package/tests/session_cost.mjs +133 -0
- package/tests/session_digest.mjs +73 -0
- package/tests/session_eval.mjs +386 -0
- package/tests/session_index.mjs +105 -0
- package/tests/session_records.mjs +369 -0
- package/tests/session_search.mjs +78 -0
- package/tests/session_start_freshness.mjs +123 -0
- package/tests/setup_smoke.mjs +154 -0
- package/tests/ship_autolinkify.mjs +60 -0
- package/tests/ship_automerge_injection.mjs +174 -0
- package/tests/ship_broadcast_routing.mjs +125 -0
- package/tests/ship_broadcast_server.mjs +175 -0
- package/tests/ship_card.mjs +280 -0
- package/tests/ship_ci_deploy.mjs +374 -0
- package/tests/ship_preflight.mjs +368 -0
- package/tests/ship_premerge.mjs +75 -0
- package/tests/ship_resume.mjs +129 -0
- package/tests/ship_upload_skip.mjs +89 -0
- package/tests/ship_zero_commit.mjs +60 -0
- package/tests/single_host_hall.mjs +98 -0
- package/tests/skill_prefs.mjs +150 -0
- package/tests/smoke_preflight.mjs +66 -0
- package/tests/snapshot_picker.mjs +233 -0
- package/tests/sound_prefs.mjs +198 -0
- package/tests/sprite_facing.mjs +35 -0
- package/tests/status-probe.mjs +89 -0
- package/tests/status_backfill.mjs +94 -0
- package/tests/stop_refcheck.mjs +127 -0
- package/tests/streak.mjs +175 -0
- package/tests/subdomain_auth.mjs +58 -0
- package/tests/task_classifier_vocab.mjs +41 -0
- package/tests/task_create_idempotency.mjs +96 -0
- package/tests/task_goal_link.mjs +93 -0
- package/tests/task_ownership_gate.mjs +125 -0
- package/tests/terrain_picker.mjs +298 -0
- package/tests/touches_overlap.mjs +75 -0
- package/tests/triage_development.mjs +140 -0
- package/tests/turn_in_place.mjs +280 -0
- package/tests/upgrade.mjs +357 -0
- package/tests/verify_task_guard.mjs +64 -0
- package/tests/version_close.mjs +76 -0
- package/tests/versions_route.mjs +93 -0
- package/tests/walkability.mjs +188 -0
- package/tests/wandering_prefs.mjs +176 -0
- package/tests/worktree_disambiguation.mjs +105 -0
- package/tests/worktree_guard.mjs +90 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/box.js — per-builder cloud dev box lifecycle CLI (ADR 0031 §2/§5, #598).
|
|
3
|
+
//
|
|
4
|
+
// The control-plane entrypoint for the box lifecycle. Like provision-staging.sh,
|
|
5
|
+
// it is meant to RUN ON A TRUSTED CONTROL PLANE (the prod droplet, or an
|
|
6
|
+
// operator's machine) — NOT inside a builder box. A builder box provisioning
|
|
7
|
+
// boxes would be a privilege-escalation; the builder firewall doesn't even
|
|
8
|
+
// allow-list the DO API. The rank gate is enforced here against the builder's
|
|
9
|
+
// LIVE DB rank (the same value requireRank reads), so it is a real ADR-0016
|
|
10
|
+
// server-side check, not local prose.
|
|
11
|
+
//
|
|
12
|
+
// Subcommands:
|
|
13
|
+
// provision <builder> create + boot a box for a builder (rank-gated)
|
|
14
|
+
// park <builder> snapshot + destroy the droplet (stop compute billing)
|
|
15
|
+
// wake <builder> recreate the droplet from its snapshot
|
|
16
|
+
// deprovision <builder> destroy droplet AND snapshot (full reclaim)
|
|
17
|
+
// sweep-idle deprovision every active box idle > BOX_IDLE_MINUTES (#804)
|
|
18
|
+
// sweep-dormant deprovision every box parked > BOX_DORMANT_DAYS (pings builder)
|
|
19
|
+
// status print the box roster + estimated cost
|
|
20
|
+
//
|
|
21
|
+
// <builder> is a GitHub login or a numeric builder id.
|
|
22
|
+
//
|
|
23
|
+
// SAFETY: dry-run is the DEFAULT. Nothing touches DigitalOcean or writes the DB
|
|
24
|
+
// unless you pass --apply. Run any command once without --apply to see the plan,
|
|
25
|
+
// exactly like `pulse.js --release-stale --dry-run`.
|
|
26
|
+
//
|
|
27
|
+
// node scripts/gds/box.js sweep-idle # plan only
|
|
28
|
+
// node scripts/gds/box.js sweep-idle --apply # actually park idle boxes
|
|
29
|
+
//
|
|
30
|
+
// Config (env): DO_API_TOKEN (live only), BOX_PROVISION_MIN_RANK (default xenos),
|
|
31
|
+
// BOX_REGION (nyc3), BOX_SIZE (s-2vcpu-4gb), BOX_BASE_IMAGE (ubuntu-24-04-x64),
|
|
32
|
+
// BOX_IDLE_MINUTES (10), BOX_DORMANT_DAYS (14), BOX_SSH_KEY_IDS (comma list),
|
|
33
|
+
// BOX_DNS_HOOK (optional command, gets BOX_HOSTNAME/BOX_IP in env), BOX_TAG.
|
|
34
|
+
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
const zlib = require('node:zlib');
|
|
39
|
+
const { execFileSync } = require('node:child_process');
|
|
40
|
+
|
|
41
|
+
// DigitalOcean hard-caps droplet user-data (cloud-init) at 64 KiB. The rendered
|
|
42
|
+
// cloud-init base64-inlines several infra scripts, so it sits close to the cap;
|
|
43
|
+
// #903 (after #898 tipped it over) gzips the biggest one and adds a fail-fast
|
|
44
|
+
// guard. Keep a little headroom under the true 65536 so a stray key/byte is safe.
|
|
45
|
+
const MAX_USERDATA_BYTES = 64 * 1024;
|
|
46
|
+
|
|
47
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
48
|
+
|
|
49
|
+
const CONFIG = {
|
|
50
|
+
floor: process.env.BOX_PROVISION_MIN_RANK || 'xenos',
|
|
51
|
+
region: process.env.BOX_REGION || 'nyc3',
|
|
52
|
+
size: process.env.BOX_SIZE || 's-2vcpu-4gb',
|
|
53
|
+
baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
|
|
54
|
+
idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
|
|
55
|
+
dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
|
|
56
|
+
sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
57
|
+
dnsHook: process.env.BOX_DNS_HOOK || null,
|
|
58
|
+
tag: process.env.BOX_TAG || 'otb-builder-box',
|
|
59
|
+
dnsZone: process.env.BOX_DNS_ZONE || 'dev.amazonprimea.com',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Named-tunnel automation for the browser terminal (#771, ADR 0038 Part B).
|
|
63
|
+
// OPT-IN, default OFF — an unset BOX_NAMED_TUNNEL leaves the box on the tokenless
|
|
64
|
+
// quick tunnel (trycloudflare.com), the zero-secret #760 default. When the
|
|
65
|
+
// operator turns it on AND supplies a Cloudflare token with Tunnel + DNS scope
|
|
66
|
+
// (the same CLOUDFLARE_API_TOKEN box-dns uses, scope added) + the account id, every
|
|
67
|
+
// provision creates a stable term-<login>.dev.amazonprimea.com tunnel and bakes
|
|
68
|
+
// its connector token into the box; deprovision tears it down.
|
|
69
|
+
const CF = {
|
|
70
|
+
enabled: /^(1|true|yes)$/i.test(process.env.BOX_NAMED_TUNNEL || ''),
|
|
71
|
+
apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
|
|
72
|
+
accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
|
|
73
|
+
// The Cloudflare ZONE the DNS record lives in (registrable apex), e.g.
|
|
74
|
+
// amazonprimea.com — term-<login>.dev.amazonprimea.com is a record WITHIN it,
|
|
75
|
+
// matching box-dns-cloudflare.sh. Distinct from terminalZone (the host suffix).
|
|
76
|
+
zone: process.env.CLOUDFLARE_ZONE || 'amazonprimea.com',
|
|
77
|
+
// The host suffix the terminal hostname is built on (term-<login>.<terminalZone>).
|
|
78
|
+
// DEFAULTS TO THE APEX, NOT dev.* — the terminal rides a PROXIED Cloudflare
|
|
79
|
+
// tunnel (HTTPS at the edge), and free Universal SSL covers `amazonprimea.com` +
|
|
80
|
+
// `*.amazonprimea.com` (ONE label) but NOT `term-x.dev.amazonprimea.com` (TWO
|
|
81
|
+
// labels) → a 2-level host fails TLS at the edge (verified live, #771). One
|
|
82
|
+
// level (term-<login>.amazonprimea.com) is covered with no paid cert. An
|
|
83
|
+
// operator with an Advanced Certificate for *.dev.amazonprimea.com can override
|
|
84
|
+
// via BOX_TERMINAL_ZONE. (The SSH A-record path, box-dns-cloudflare.sh, stays on
|
|
85
|
+
// dev.* — it is proxied=false / direct-IP, so it needs no edge cert.)
|
|
86
|
+
terminalZone: process.env.BOX_TERMINAL_ZONE || 'amazonprimea.com',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Named-tunnel automation is live only when the operator opted in AND the
|
|
90
|
+
// credentials are present. Missing creds while enabled is a loud config error at
|
|
91
|
+
// provision time (see cmdProvision), not a silent fall-back, so an operator who
|
|
92
|
+
// meant to use named tunnels learns immediately.
|
|
93
|
+
function namedTunnelEnabled() { return CF.enabled; }
|
|
94
|
+
function namedTunnelConfigured() { return CF.enabled && CF.apiToken && CF.accountId; }
|
|
95
|
+
|
|
96
|
+
function makeCf(apply) {
|
|
97
|
+
const { CfTunnelApi } = loadDeps();
|
|
98
|
+
return new CfTunnelApi({
|
|
99
|
+
dryRun: !apply,
|
|
100
|
+
token: CF.apiToken,
|
|
101
|
+
accountId: CF.accountId,
|
|
102
|
+
log: (m) => console.log(' ' + m),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function numEnv(name, dflt) {
|
|
107
|
+
const v = process.env[name];
|
|
108
|
+
if (v == null || v === '') return dflt;
|
|
109
|
+
const n = Number(v);
|
|
110
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasFlag(args, name) { return args.includes(name); }
|
|
114
|
+
|
|
115
|
+
// Lazy + memoized DB + lib load so `--help` and arg errors don't require Postgres
|
|
116
|
+
// (this CLI runs on a non-droplet machine for dry-run planning of pure logic too):
|
|
117
|
+
// nothing is require()d until the first makeDo/makeCf/IIFE call, and repeat calls
|
|
118
|
+
// reuse the first resolution instead of re-running the six requires.
|
|
119
|
+
let _deps;
|
|
120
|
+
function loadDeps() {
|
|
121
|
+
if (_deps) return _deps;
|
|
122
|
+
// eslint-disable-next-line global-require
|
|
123
|
+
const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
|
|
124
|
+
// eslint-disable-next-line global-require
|
|
125
|
+
const boxes = require(path.join(REPO_ROOT, 'modules/dev-box/boxes'));
|
|
126
|
+
// eslint-disable-next-line global-require
|
|
127
|
+
const boxAccess = require(path.join(REPO_ROOT, 'modules/dev-box/box-access'));
|
|
128
|
+
// eslint-disable-next-line global-require
|
|
129
|
+
const boxOnboard = require(path.join(REPO_ROOT, 'modules/dev-box/box-onboard'));
|
|
130
|
+
// eslint-disable-next-line global-require
|
|
131
|
+
const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
|
|
132
|
+
// eslint-disable-next-line global-require
|
|
133
|
+
const { CfTunnelApi } = require(path.join(REPO_ROOT, 'scripts/gds/cf-tunnel'));
|
|
134
|
+
_deps = { pool, boxes, boxAccess, boxOnboard, DoApi, CfTunnelApi };
|
|
135
|
+
return _deps;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function makeDo(apply) {
|
|
139
|
+
const { DoApi } = loadDeps();
|
|
140
|
+
return new DoApi({
|
|
141
|
+
dryRun: !apply,
|
|
142
|
+
token: process.env.DO_API_TOKEN || null,
|
|
143
|
+
log: (m) => console.log(' ' + m),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A LIVE read-only DO client for enumerating droplets in the drift reconcile —
|
|
148
|
+
// listing is non-mutating, so it runs for real even in dry-run planning (only the
|
|
149
|
+
// DELETEs are gated by --apply, via the separate makeDo(apply) client). Returns
|
|
150
|
+
// null when DO_API_TOKEN is unset (this is a control-plane command; the token
|
|
151
|
+
// always lives on the prod box) so the caller can fail with a clear message
|
|
152
|
+
// instead of the constructor's throw.
|
|
153
|
+
function makeDoRead() {
|
|
154
|
+
const token = process.env.DO_API_TOKEN || null;
|
|
155
|
+
if (!token) return null;
|
|
156
|
+
const { DoApi } = loadDeps();
|
|
157
|
+
return new DoApi({ dryRun: false, token, log: (m) => console.log(' ' + m) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Best-effort outbound ping to the team Discord (mirrors ship.js's broadcast).
|
|
161
|
+
// OUTBOUND messaging lives in scripts/discord/ as of ADR 0032/#607 — scripts/chat/
|
|
162
|
+
// is inbound-only now, so this must NOT route through chat/post.js (it would fail
|
|
163
|
+
// silently and the builder would never learn their box was reclaimed). Never throws.
|
|
164
|
+
function postReclaimPing(text) {
|
|
165
|
+
try {
|
|
166
|
+
execFileSync('node', ['scripts/discord/post.js', text], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
167
|
+
return { ok: true };
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Resolve a builder by login or numeric id. Returns the full row or null.
|
|
174
|
+
async function resolveBuilder(pool, ref) {
|
|
175
|
+
const isId = /^\d+$/.test(String(ref));
|
|
176
|
+
const { rows } = await pool.query(
|
|
177
|
+
isId
|
|
178
|
+
? `SELECT id, github_login, display_name, rank, status FROM builders WHERE id = $1`
|
|
179
|
+
: `SELECT id, github_login, display_name, rank, status FROM builders WHERE lower(github_login) = lower($1)`,
|
|
180
|
+
[ref]
|
|
181
|
+
);
|
|
182
|
+
return rows[0] || null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A short, DNS-safe-ish suffix from the current time (CLI context — Date is fine).
|
|
186
|
+
function shortStamp() {
|
|
187
|
+
return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(2, 14);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function dropletName(login) { return `otb-box-${login}`; }
|
|
191
|
+
function hostnameFor(login) { return `box-${login}.${CONFIG.dnsZone}`; }
|
|
192
|
+
|
|
193
|
+
// Run the optional DNS hook so the per-builder hostname tracks the new IP. When
|
|
194
|
+
// no hook is configured, just print the reminder — the stable-hostname CUTOVER
|
|
195
|
+
// is #599's (Desktop managed-settings); #598 only records IP + hostname + offers
|
|
196
|
+
// the hook so an operator can wire DO/Cloudflare DNS without editing this file.
|
|
197
|
+
// Returns { ok, error?, skipped? }. task 1180: callers MUST act on ok:false
|
|
198
|
+
// (record a box_events row + flag the box) instead of silently continuing — the
|
|
199
|
+
// swallow that left @kingaonline's A record pinned at a dead IP while provision
|
|
200
|
+
// reported success. The hook itself now retries transient Cloudflare blips
|
|
201
|
+
// (infra/box-dns-cloudflare.sh), so ok:false here is a real, exhausted failure.
|
|
202
|
+
// `hook` is injectable so the contract can be unit-tested with a fake (#1034).
|
|
203
|
+
function runDnsHook(hostname, ip, hook = CONFIG.dnsHook) {
|
|
204
|
+
if (!ip) return { ok: true, skipped: true };
|
|
205
|
+
if (!hook) {
|
|
206
|
+
console.log(` ↳ DNS: point ${hostname} → ${ip} (no BOX_DNS_HOOK set; #599 owns the cutover)`);
|
|
207
|
+
return { ok: true, skipped: true };
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
execFileSync('bash', ['-c', hook], {
|
|
211
|
+
cwd: REPO_ROOT, stdio: 'pipe',
|
|
212
|
+
env: { ...process.env, BOX_HOSTNAME: hostname, BOX_IP: ip },
|
|
213
|
+
});
|
|
214
|
+
console.log(` ↳ DNS hook updated ${hostname} → ${ip}`);
|
|
215
|
+
return { ok: true };
|
|
216
|
+
} catch (e) {
|
|
217
|
+
const msg = (e.stderr && e.stderr.toString().trim()) || e.message;
|
|
218
|
+
console.log(` ⚠ DNS hook failed: ${msg} (point ${hostname} → ${ip} manually)`);
|
|
219
|
+
return { ok: false, error: msg };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Blackhole sentinel for a torn-down box's A record (idea 309). 192.0.2.1 is in
|
|
224
|
+
// TEST-NET-1 (RFC 5737) — reserved for documentation and GUARANTEED never to be a
|
|
225
|
+
// routable host, so no recycled DigitalOcean IP can ever land here. A perfect
|
|
226
|
+
// dead end to park a record at.
|
|
227
|
+
const DNS_BLACKHOLE_IP = '192.0.2.1';
|
|
228
|
+
|
|
229
|
+
// Park the per-builder A record at the blackhole sentinel on deprovision (idea
|
|
230
|
+
// 309, refining task 1180). 1180 DELETED the record on teardown so it couldn't
|
|
231
|
+
// dangle at a recycled DO IP — but delete-then-recreate across a reap→rebuild
|
|
232
|
+
// left a window where the name returned NXDOMAIN, which resolvers negative-cache
|
|
233
|
+
// for the zone's SOA neg-TTL (~30 min on the Cloudflare amazonprimea.com zone,
|
|
234
|
+
// not builder-editable). A client that queried during that window (e.g. an open
|
|
235
|
+
// Claude Desktop) then kept getting ENOTFOUND long after the box was back and the
|
|
236
|
+
// record recreated. REPOINTING to a non-routable sentinel instead keeps the name
|
|
237
|
+
// always-resolving (no NXDOMAIN → nothing to negative-cache) while preserving
|
|
238
|
+
// 1180's guarantee that a torn-down box's name can never dial a recycled
|
|
239
|
+
// stranger's IP. The next provision/wake PUTs the record straight to the live IP
|
|
240
|
+
// — a clean in-place repoint at the 60s record TTL, no gap. Best-effort (like the
|
|
241
|
+
// prior delete): a failure just leaves the old IP, which the next provision
|
|
242
|
+
// repoints anyway. Reuses the UPSERT path of runDnsHook; `hook` is injectable for
|
|
243
|
+
// tests. Returns { ok, error?, skipped? }.
|
|
244
|
+
function runDnsHookBlackhole(hostname, hook = CONFIG.dnsHook) {
|
|
245
|
+
if (!hook || !hostname) return { ok: true, skipped: true };
|
|
246
|
+
return runDnsHook(hostname, DNS_BLACKHOLE_IP, hook);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Read the cloud-init template and substitute the repo URL from BOX_REPO_URL
|
|
250
|
+
// (kept out of the repo file so no GitHub identity is hardcoded — V3.R12 rule).
|
|
251
|
+
// An unset URL leaves the placeholder; the cloud-init then logs guidance rather
|
|
252
|
+
// than cloning, and #600 wires the scoped credential.
|
|
253
|
+
// Read + render the cloud-init. `authorizedKeysBlock` is the otb-managed block
|
|
254
|
+
// for the builder being provisioned (#685) — base64'd here and baked in so the
|
|
255
|
+
// FIRST SSH login works before the box has a GDS token. An empty/absent block
|
|
256
|
+
// renders an empty managed block (a keyless box; the operator is warned upstream).
|
|
257
|
+
function readCloudInit({ authorizedKeysBlock = '', boxEnv = '', gdsSession = '' } = {}) {
|
|
258
|
+
const p = path.join(REPO_ROOT, 'infra', 'builder-box-cloud-init.yaml');
|
|
259
|
+
// gzip+base64 a repo file into the cloud-init write_files (`encoding: gz+b64`).
|
|
260
|
+
// EVERY inlined infra file goes through this: cloud-init gunzips it back to the
|
|
261
|
+
// exact bytes on the box, so it is byte-identical to plain b64 but ~2-3x smaller,
|
|
262
|
+
// which keeps the rendered user-data well under DO's 64 KiB cap (#903; the source
|
|
263
|
+
// -fetch growth in task 1314 had pushed the all-b64-but-source-fetch render to
|
|
264
|
+
// 67 KB — gz+b64-ing the rest brings it back to ~51 KB, task 1321). Only the
|
|
265
|
+
// dynamic runtime values (authorized_keys, box.env, gds-session) stay plain b64
|
|
266
|
+
// below — they are minted per-provision, not read from infra/. Warn loudly (not
|
|
267
|
+
// throw) if a file is unreadable so a half-wired box surfaces, not boots silent.
|
|
268
|
+
const fileGzB64 = (name) => {
|
|
269
|
+
try {
|
|
270
|
+
return zlib.gzipSync(fs.readFileSync(path.join(REPO_ROOT, 'infra', name))).toString('base64');
|
|
271
|
+
} catch (e) {
|
|
272
|
+
console.error(`⚠ ${name} not readable (${e.message}) — the box will boot WITHOUT it; fix infra/${name} before provisioning`);
|
|
273
|
+
return '';
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
try {
|
|
277
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
278
|
+
// Inject the rank-gated source-fetch (#600) + authorized-keys-fetch (#685)
|
|
279
|
+
// scripts as gz+b64 (a single YAML-safe token — no indentation/escaping
|
|
280
|
+
// pitfalls; cloud-init gunzips encoding: gz+b64 back to the exact file), plus
|
|
281
|
+
// the baked authorized_keys block. ONE canonical copy of each lives in infra/.
|
|
282
|
+
return raw
|
|
283
|
+
.replace(/__REPO_URL__/g, process.env.BOX_REPO_URL || '')
|
|
284
|
+
.replace(/__BOX_SOURCE_FETCH_B64__/g, fileGzB64('box-source-fetch.sh'))
|
|
285
|
+
.replace(/__BOX_AUTHKEYS_FETCH_B64__/g, fileGzB64('box-authorized-keys-fetch.sh'))
|
|
286
|
+
.replace(/__BUILDER_AUTHORIZED_KEYS_B64__/g, Buffer.from(authorizedKeysBlock || '', 'utf8').toString('base64'))
|
|
287
|
+
// /etc/otb/box.env — empty by default (quick tunnel); the named-tunnel
|
|
288
|
+
// connector token + stable hostname when BOX_NAMED_TUNNEL is on (#771).
|
|
289
|
+
.replace(/__BOX_ENV_B64__/g, Buffer.from(boxEnv || '', 'utf8').toString('base64'))
|
|
290
|
+
// /root/.config/otb/gds-session.json — the builder's GDS session, minted at
|
|
291
|
+
// provision so box-source-fetch (#600) can authenticate on FIRST boot and
|
|
292
|
+
// clone the rank-scoped repo. Without it the box never clones (the #818
|
|
293
|
+
// chicken-and-egg: the session normally comes from /builder-setup, which
|
|
294
|
+
// lives in the un-cloned repo).
|
|
295
|
+
.replace(/__GDS_SESSION_B64__/g, Buffer.from(gdsSession || '', 'utf8').toString('base64'))
|
|
296
|
+
// #774: the browser-terminal units, inlined so they start at FIRST BOOT
|
|
297
|
+
// (independent of the repo clone). ONE canonical copy lives in infra/.
|
|
298
|
+
.replace(/__BOX_TERMINAL_SERVICE_B64__/g, fileGzB64('box-terminal.service'))
|
|
299
|
+
// #917: credential-drift watcher pair — restarts ttyd when the credential
|
|
300
|
+
// file lands/changes after the service started (the wake/first-boot race).
|
|
301
|
+
.replace(/__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/g, fileGzB64('box-terminal-credsync.service'))
|
|
302
|
+
.replace(/__BOX_TERMINAL_CREDENTIAL_PATH_B64__/g, fileGzB64('box-terminal-credential.path'))
|
|
303
|
+
.replace(/__CLOUDFLARED_SERVICE_B64__/g, fileGzB64('cloudflared.service'))
|
|
304
|
+
.replace(/__CLOUDFLARED_RUN_B64__/g, fileGzB64('cloudflared-run.sh'))
|
|
305
|
+
// #893: the `close-box` teardown command, present at first boot so a bare
|
|
306
|
+
// browser-only box has it before any repo clone (box-source-fetch refreshes
|
|
307
|
+
// it on existing boxes). ONE canonical copy lives in infra/close-box.sh.
|
|
308
|
+
.replace(/__CLOSE_BOX_B64__/g, fileGzB64('close-box.sh'))
|
|
309
|
+
// task 986: the browser-terminal auto-launcher — runs `claude` on first
|
|
310
|
+
// connect so the builder skips the "type claude" step. box-source-fetch
|
|
311
|
+
// refreshes it on existing boxes. ONE canonical copy in infra/otb-box-launch.sh.
|
|
312
|
+
.replace(/__OTB_BOX_LAUNCH_B64__/g, fileGzB64('otb-box-launch.sh'));
|
|
313
|
+
} catch { return null; }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- provision -------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
|
|
319
|
+
const builder = await resolveBuilder(pool, ref);
|
|
320
|
+
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
321
|
+
if (builder.status && builder.status !== 'active') {
|
|
322
|
+
return fail(`builder @${builder.github_login} is ${builder.status}, not active — no box`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Rank gate (ADR 0016 trust boundary: checked against the live DB rank).
|
|
326
|
+
try {
|
|
327
|
+
boxes.assertProvisionAllowed({ rank: builder.rank, floor: CONFIG.floor });
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return fail(`${e.message}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Dry-run must touch nothing (#1034): READ the row instead of ensuring it, so a
|
|
333
|
+
// bare `provision` (no --apply) cannot write a placeholder builder_boxes row. A
|
|
334
|
+
// missing row is semantically 'none' — and 'none' is a valid provision source —
|
|
335
|
+
// so map null→'none' for the gate (NOT the `!box || …` short-circuit the
|
|
336
|
+
// park/wake paths use: those forbid 'none', but provision allows it). The row is
|
|
337
|
+
// materialized under --apply below, mirroring wake/park/deprovision (all read via
|
|
338
|
+
// getBoxByBuilderId).
|
|
339
|
+
let box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
340
|
+
const boxState = box ? box.state : 'none';
|
|
341
|
+
if (!boxes.canTransition('provision', boxState)) {
|
|
342
|
+
return fail(`box for @${builder.github_login} is '${boxState}' — provision only from none/destroyed/error (try wake?)`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const scope = boxes.boxScopeForRank(builder.rank);
|
|
346
|
+
const name = dropletName(builder.github_login);
|
|
347
|
+
const hostname = hostnameFor(builder.github_login);
|
|
348
|
+
|
|
349
|
+
// Bake the builder's registered SSH keys (#685) into the cloud-init so the
|
|
350
|
+
// FIRST login works before the box has a GDS token. No keys yet → an empty
|
|
351
|
+
// managed block (the box-authorized-keys-fetch cron fills it in once the
|
|
352
|
+
// builder authenticates; onboard warns about this case upstream).
|
|
353
|
+
const sshKeys = await boxOnboard.listSshKeys(pool, builder.id);
|
|
354
|
+
const authorizedKeysBlock = boxOnboard.wrapManagedBlock(sshKeys);
|
|
355
|
+
|
|
356
|
+
console.log(`provision box for @${builder.github_login} (${builder.rank} → scope=${scope})`);
|
|
357
|
+
console.log(` region=${CONFIG.region} size=${CONFIG.size} image=${CONFIG.baseImage} host=${hostname}`);
|
|
358
|
+
console.log(` baked SSH keys: ${sshKeys.length}${sshKeys.length === 0 ? ' (⚠ builder cannot SSH in until a key is registered — see `box.js onboard`)' : ''}`);
|
|
359
|
+
|
|
360
|
+
// Named-tunnel automation (#771, ADR 0038 Part B). OPT-IN: off → the box uses
|
|
361
|
+
// the tokenless quick tunnel (#760 default) and box.env is left empty. On but
|
|
362
|
+
// misconfigured → fail loud now (an operator who turned it on meant it).
|
|
363
|
+
if (namedTunnelEnabled() && !namedTunnelConfigured()) {
|
|
364
|
+
return fail('BOX_NAMED_TUNNEL is on but CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ACCOUNT_ID are not set — supply both (token needs Tunnel + DNS scope) or unset BOX_NAMED_TUNNEL for the quick-tunnel default');
|
|
365
|
+
}
|
|
366
|
+
const termHost = namedTunnelEnabled() ? `term-${builder.github_login}.${CF.terminalZone}` : null;
|
|
367
|
+
if (namedTunnelEnabled()) {
|
|
368
|
+
console.log(` named tunnel: ${termHost} (Cloudflare zone ${CF.zone})`);
|
|
369
|
+
} else {
|
|
370
|
+
console.log(' browser terminal: quick tunnel (tokenless trycloudflare.com — set BOX_NAMED_TUNNEL=1 for a stable hostname)');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!apply) {
|
|
374
|
+
console.log(' [dry-run] would create droplet + mark active. Re-run with --apply.');
|
|
375
|
+
if (namedTunnelEnabled()) await makeCf(false).ensureNamedTunnel({ login: builder.github_login, zone: CF.zone, terminalZone: CF.terminalZone });
|
|
376
|
+
return { ok: true, dryRun: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --apply only: NOW materialize the box row (the dry-run path above left the DB
|
|
380
|
+
// untouched, #1034). ensureBoxRow is idempotent — returns the existing row or
|
|
381
|
+
// creates a fresh state='none' one — so box.id / box.provisioned_at below are
|
|
382
|
+
// always real. Identical to the pre-#1034 --apply behavior; nothing between the
|
|
383
|
+
// gate above and here reads or writes the box row.
|
|
384
|
+
box = await boxes.ensureBoxRow(pool, builder.id);
|
|
385
|
+
|
|
386
|
+
// Create (or reuse) the named tunnel BEFORE the droplet so its connector token
|
|
387
|
+
// bakes into the cloud-init box.env. A failure here aborts provision — better
|
|
388
|
+
// no box than a box that boots without its promised stable terminal.
|
|
389
|
+
// #774: on the named-tunnel path the control plane knows EVERYTHING the browser
|
|
390
|
+
// on-ramp needs at provision time — the stable hostname (here) + a password (we
|
|
391
|
+
// generate it). So we bake the password into box.env (the box serves ttyd with
|
|
392
|
+
// it) and, once the box is active, RECORD {url, password} into the GDS ourselves
|
|
393
|
+
// (setTerminalAccess) — the builder reads them from the hall with no token ever
|
|
394
|
+
// placed on the box and no box-side reporter (that path was the quick-tunnel
|
|
395
|
+
// fallback and the source of the #771/#774 chicken-and-egg). ADR 0016 holds: no
|
|
396
|
+
// builder/infra credential is funneled into the box.
|
|
397
|
+
let boxEnv = '';
|
|
398
|
+
let terminalHostname = null;
|
|
399
|
+
let terminalCredential = null;
|
|
400
|
+
if (namedTunnelEnabled()) {
|
|
401
|
+
try {
|
|
402
|
+
const { token, hostname: th, previewHostname: ph } = await makeCf(true).ensureNamedTunnel({
|
|
403
|
+
login: builder.github_login, zone: CF.zone, terminalZone: CF.terminalZone,
|
|
404
|
+
});
|
|
405
|
+
terminalHostname = th;
|
|
406
|
+
terminalCredential = crypto.randomBytes(24).toString('hex');
|
|
407
|
+
// ADR 0044: ensureNamedTunnel also created the sandbox-<login> ingress route
|
|
408
|
+
// + DNS for the live game preview. Bake its hostname so the box's
|
|
409
|
+
// box-preview command can show the builder their URL (no DB row needed — the
|
|
410
|
+
// host is deterministic; box-game-preview.service serves :3000 behind it).
|
|
411
|
+
boxEnv = `# /etc/otb/box.env — baked by box.js provision (#771/#774, ADR 0038 named tunnel)\nBOX_TUNNEL_TOKEN=${token}\nBOX_TERMINAL_HOSTNAME=${th}\nBOX_TERMINAL_CREDENTIAL=${terminalCredential}\nBOX_PREVIEW_HOSTNAME=${ph}\n`;
|
|
412
|
+
console.log(` ✓ named tunnel ready — ${th}`);
|
|
413
|
+
console.log(` ✓ live game preview — https://${ph} (ADR 0044)`);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
await boxes.setBoxState(pool, builder.id, 'error', { error_note: `named tunnel: ${e.message}` });
|
|
416
|
+
return fail(`named tunnel setup failed: ${e.message}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Bootstrap the builder's GDS session onto the box (#818). box-source-fetch
|
|
421
|
+
// (#600) authenticates as the BUILDER to pull the rank-scoped repo; without a
|
|
422
|
+
// session at ~/.config/otb/gds-session.json it no-ops forever, so the repo
|
|
423
|
+
// never clones, the devcontainer never comes up, and /builder-* skills are
|
|
424
|
+
// absent. The session normally comes from /builder-setup — which lives in the
|
|
425
|
+
// un-cloned repo (the chicken-and-egg). So we mint a session here and bake it
|
|
426
|
+
// into cloud-init, exactly like the SSH keys / tunnel token / terminal credential.
|
|
427
|
+
//
|
|
428
|
+
// SECURITY (#919, SR#2): this baked session is source='box' — a SCOPED, 24h
|
|
429
|
+
// refresh-on-use credential. The token lives ON the box (readable by any on-box
|
|
430
|
+
// process + the metadata endpoint), which is OUTSIDE the ADR 0016 trust boundary,
|
|
431
|
+
// so it must NOT carry the builder's full authority. auth.requireBuilder denies a
|
|
432
|
+
// 'box' session on every route except the 4 the on-box crons need (source-access,
|
|
433
|
+
// authorized-keys, heartbeat, terminal — gated by auth.allowBoxScope). The first
|
|
434
|
+
// source-fetch clones on boot; the repo persists in the snapshot. To do real work
|
|
435
|
+
// FROM the box (claim/ship) the builder runs /builder-reauth (device flow), minting
|
|
436
|
+
// a normal cli/web session — never auto-baked. user_agent='box-provision' is the
|
|
437
|
+
// marker migration 090 keys on to retroactively scope already-baked tokens.
|
|
438
|
+
const gdsDb = require(path.join(REPO_ROOT, 'src/bongos/db'));
|
|
439
|
+
const sessionToken = crypto.randomBytes(32).toString('hex');
|
|
440
|
+
await gdsDb.createSession({
|
|
441
|
+
builderId: builder.id, token: sessionToken, source: 'box', userAgent: 'box-provision',
|
|
442
|
+
});
|
|
443
|
+
const gdsSession = JSON.stringify({
|
|
444
|
+
token: sessionToken,
|
|
445
|
+
builder: { id: builder.id, github_login: builder.github_login, rank: builder.rank },
|
|
446
|
+
api_base: process.env.GDS_PUBLIC_ORIGIN || 'https://amazonprimea.com',
|
|
447
|
+
}, null, 2);
|
|
448
|
+
|
|
449
|
+
const doApi = makeDo(apply);
|
|
450
|
+
// Render + size-check the cloud-init BEFORE touching DO (#903). DO rejects
|
|
451
|
+
// user-data over 64 KiB with an opaque error; fail fast with an actionable one
|
|
452
|
+
// so an over-budget cloud-init is caught here, not as a cryptic API 4xx.
|
|
453
|
+
const userData = readCloudInit({ authorizedKeysBlock, boxEnv, gdsSession });
|
|
454
|
+
const userDataBytes = userData ? Buffer.byteLength(userData, 'utf8') : 0;
|
|
455
|
+
if (userDataBytes > MAX_USERDATA_BYTES) {
|
|
456
|
+
await boxes.setBoxState(pool, builder.id, 'error', {
|
|
457
|
+
error_note: `cloud-init ${userDataBytes}B > ${MAX_USERDATA_BYTES}B`,
|
|
458
|
+
});
|
|
459
|
+
return fail(`cloud-init user-data is ${userDataBytes}B, over DigitalOcean's ${MAX_USERDATA_BYTES}B limit — trim infra/box-source-fetch.sh or gzip more of the inlined files (infra/builder-box-cloud-init.yaml)`);
|
|
460
|
+
}
|
|
461
|
+
await boxes.setBoxState(pool, builder.id, 'provisioning', {
|
|
462
|
+
scope, provisioned_rank: builder.rank, region: CONFIG.region, size_slug: CONFIG.size,
|
|
463
|
+
droplet_name: name, hostname, error_note: null,
|
|
464
|
+
});
|
|
465
|
+
try {
|
|
466
|
+
const { droplet } = await doApi.createDroplet({
|
|
467
|
+
name, region: CONFIG.region, size: CONFIG.size, image: CONFIG.baseImage,
|
|
468
|
+
userData, tags: [CONFIG.tag, `builder:${builder.github_login}`],
|
|
469
|
+
sshKeys: CONFIG.sshKeyIds.length ? CONFIG.sshKeyIds : undefined,
|
|
470
|
+
});
|
|
471
|
+
const { ip } = await doApi.settleDroplet(droplet.id);
|
|
472
|
+
const now = new Date();
|
|
473
|
+
await boxes.setBoxState(pool, builder.id, 'active', {
|
|
474
|
+
droplet_id: String(droplet.id), ip, snapshot_id: null,
|
|
475
|
+
provisioned_at: box.provisioned_at || now, active_since: now, last_activity_at: now,
|
|
476
|
+
});
|
|
477
|
+
await boxes.recordEvent(pool, {
|
|
478
|
+
boxId: box.id, builderId: builder.id, event: 'provision',
|
|
479
|
+
detail: `droplet ${droplet.id} ${ip || ''} ${CONFIG.size}`, actor: actorTag(),
|
|
480
|
+
});
|
|
481
|
+
// #602: log a spin-up event in the per-builder cost ledger.
|
|
482
|
+
await boxes.recordSpinupCost(pool, { box, kind: 'provision', atMs: now.getTime(), actor: actorTag() });
|
|
483
|
+
// #774: record the browser-terminal access NOW (named-tunnel path) so the hall
|
|
484
|
+
// serves it immediately — no waiting on the box to report, no token on the box.
|
|
485
|
+
// The box brings ttyd up at first boot with this same password (baked above).
|
|
486
|
+
if (terminalHostname && terminalCredential) {
|
|
487
|
+
try {
|
|
488
|
+
await boxes.setTerminalAccess(pool, builder.id, {
|
|
489
|
+
url: `https://${terminalHostname}`, credential: terminalCredential,
|
|
490
|
+
});
|
|
491
|
+
console.log(` ✓ terminal access recorded — https://${terminalHostname}`);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
console.log(` ⚠ could not record terminal access (non-fatal): ${e.message}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const dns = runDnsHook(hostname, ip);
|
|
497
|
+
if (!dns.ok) {
|
|
498
|
+
// task 1180: a stale DNS must NOT pass as a clean provision. Record it so the
|
|
499
|
+
// box is queryable as DNS-stale (the box still provisions — failure-open).
|
|
500
|
+
await boxes.recordEvent(pool, {
|
|
501
|
+
boxId: box.id, builderId: builder.id, event: 'error',
|
|
502
|
+
detail: `dns_repoint_failed: ${dns.error || 'DNS hook failed'} (${hostname} -> ${ip})`,
|
|
503
|
+
actor: actorTag(),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
console.log(` ✓ active — droplet ${droplet.id}${ip ? ` @ ${ip}` : ''}${dns.ok ? '' : ' ⚠ DNS STALE — Desktop SSH will fail until repointed (browser terminal unaffected)'}`);
|
|
507
|
+
return { ok: true };
|
|
508
|
+
} catch (e) {
|
|
509
|
+
await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
|
|
510
|
+
await boxes.recordEvent(pool, { boxId: box.id, builderId: builder.id, event: 'error', detail: e.message, actor: actorTag() });
|
|
511
|
+
return fail(`provision failed: ${e.message}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---- onboard (the operator one-shot: provision + welcome packet) -----------
|
|
516
|
+
|
|
517
|
+
// `box.js onboard <login>` is the operator's single command for #685: it
|
|
518
|
+
// provisions the box (baking the builder's already-registered SSH keys) and then
|
|
519
|
+
// prints a copy-paste "welcome packet" to forward to the builder. The canonical
|
|
520
|
+
// order is: builder runs box-connect (registers key + installs managed-settings)
|
|
521
|
+
// → operator runs onboard → builder restarts Desktop and connects. Because the
|
|
522
|
+
// box can't fetch its own authorized_keys until the builder has a token ON the
|
|
523
|
+
// box (chicken-and-egg), onboard REQUIRES at least one registered key by default;
|
|
524
|
+
// --allow-no-keys overrides for an operator who knows they'll re-provision later.
|
|
525
|
+
function gdsBase() {
|
|
526
|
+
return (process.env.GDS_API_BASE || 'https://amazonprimea.com').replace(/\/+$/, '');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function printWelcomePacket(login, scope) {
|
|
530
|
+
const base = gdsBase();
|
|
531
|
+
const host = hostnameFor(login);
|
|
532
|
+
console.log('');
|
|
533
|
+
console.log(`──────── welcome packet for @${login} — forward to the builder ────────`);
|
|
534
|
+
console.log('');
|
|
535
|
+
console.log('If you have not already connected this device, run ONE of these on your');
|
|
536
|
+
console.log('own Mac/Windows (it signs you in, makes + registers your SSH key, and');
|
|
537
|
+
console.log('installs the Claude Desktop connection file):');
|
|
538
|
+
console.log('');
|
|
539
|
+
console.log(` macOS/Linux: curl -fsSL ${base}/api/gds/box/connect.sh | bash`);
|
|
540
|
+
console.log(` Windows: irm ${base}/api/gds/box/connect.ps1 | iex`);
|
|
541
|
+
console.log(` (guide: ${base}/api/gds/box/connect )`);
|
|
542
|
+
console.log('');
|
|
543
|
+
console.log('Then: restart Claude Desktop → Code → Environment → "Amazonprimea Dev Box".');
|
|
544
|
+
console.log('');
|
|
545
|
+
console.log('On a Chromebook / browser-only device (no Claude Desktop)? Skip the above —');
|
|
546
|
+
console.log('your box serves its own terminal in a browser tab (ADR 0038). Once it is');
|
|
547
|
+
console.log('active, get the URL + password and open it:');
|
|
548
|
+
console.log(` GET ${base}/api/gds/box/terminal (or run /builder-box)`);
|
|
549
|
+
console.log('In that terminal, type `claude` to start working.');
|
|
550
|
+
console.log('');
|
|
551
|
+
console.log(`Your box: ${host} (scope=${scope})`);
|
|
552
|
+
console.log('Your memory + credits are server-side and follow you to any box (ADR 0024).');
|
|
553
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function cmdOnboard(pool, boxes, boxOnboard, ref, { apply, allowNoKeys }) {
|
|
557
|
+
const builder = await resolveBuilder(pool, ref);
|
|
558
|
+
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
559
|
+
|
|
560
|
+
const keys = await boxOnboard.listSshKeys(pool, builder.id);
|
|
561
|
+
if (keys.length === 0 && !allowNoKeys) {
|
|
562
|
+
console.error(`✖ @${builder.github_login} has no registered SSH key yet — the box would have no way in.`);
|
|
563
|
+
console.error('');
|
|
564
|
+
console.error(' Ask the builder to connect FIRST (this registers their key), then re-run onboard:');
|
|
565
|
+
console.error(` macOS/Linux: curl -fsSL ${gdsBase()}/api/gds/box/connect.sh | bash`);
|
|
566
|
+
console.error(` Windows: irm ${gdsBase()}/api/gds/box/connect.ps1 | iex`);
|
|
567
|
+
console.error('');
|
|
568
|
+
console.error(' (Override with --allow-no-keys to provision a keyless box anyway — you will');
|
|
569
|
+
console.error(' then have to re-provision once they register a key.)');
|
|
570
|
+
return fail('no SSH key registered for this builder');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
console.log(`onboard @${builder.github_login} — provision + welcome packet (${keys.length} SSH key(s) registered)`);
|
|
574
|
+
const result = await cmdProvision(pool, boxes, boxOnboard, ref, { apply });
|
|
575
|
+
if (result && result.ok === false) return result;
|
|
576
|
+
|
|
577
|
+
const scope = boxes.boxScopeForRank(builder.rank);
|
|
578
|
+
printWelcomePacket(builder.github_login, scope);
|
|
579
|
+
if (!apply) console.log('\n[dry-run] no box created — re-run with --apply to provision for real.');
|
|
580
|
+
return { ok: true, dryRun: !apply };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---- park (snapshot + destroy) ---------------------------------------------
|
|
584
|
+
|
|
585
|
+
async function parkOne(pool, boxes, doApi, box, { actor }) {
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
await boxes.setBoxState(pool, box.builder_id, 'parking');
|
|
588
|
+
// Power off for a clean snapshot, snapshot, wait, then destroy the droplet.
|
|
589
|
+
await doApi.powerOffDroplet(box.droplet_id).then((r) => r.action && doApi.waitForAction(r.action.id));
|
|
590
|
+
const snapName = `otb-snap-${box.droplet_name || box.builder_id}-${shortStamp()}`;
|
|
591
|
+
const snapRes = await doApi.snapshotDroplet(box.droplet_id, snapName);
|
|
592
|
+
if (snapRes.action) await doApi.waitForAction(snapRes.action.id);
|
|
593
|
+
// Resolve the new snapshot id from the droplet's snapshot_ids (live mode).
|
|
594
|
+
let snapshotId = null;
|
|
595
|
+
if (!doApi.dryRun) {
|
|
596
|
+
const { droplet } = await doApi.getDroplet(box.droplet_id);
|
|
597
|
+
const ids = (droplet && droplet.snapshot_ids) || [];
|
|
598
|
+
snapshotId = ids.length ? String(ids[ids.length - 1]) : null;
|
|
599
|
+
} else {
|
|
600
|
+
snapshotId = `dryrun-snap-${box.builder_id}`;
|
|
601
|
+
}
|
|
602
|
+
// Accrue the compute cost for this active interval BEFORE we lose active_since.
|
|
603
|
+
await boxes.recordComputeCost(pool, { box, untilMs: now, actor });
|
|
604
|
+
await doApi.deleteDroplet(box.droplet_id);
|
|
605
|
+
await boxes.setBoxState(pool, box.builder_id, 'parked', {
|
|
606
|
+
snapshot_id: snapshotId, parked_at: new Date(now),
|
|
607
|
+
droplet_id: null, ip: null, active_since: null,
|
|
608
|
+
});
|
|
609
|
+
await boxes.recordEvent(pool, {
|
|
610
|
+
boxId: box.id, builderId: box.builder_id, event: 'park',
|
|
611
|
+
detail: `snapshot ${snapshotId}; droplet ${box.droplet_id} destroyed`, actor,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function cmdPark(pool, boxes, ref, { apply }) {
|
|
616
|
+
const builder = await resolveBuilder(pool, ref);
|
|
617
|
+
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
618
|
+
const box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
619
|
+
if (!box || !boxes.canTransition('park', box.state)) {
|
|
620
|
+
return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — park only from active`);
|
|
621
|
+
}
|
|
622
|
+
console.log(`park box for @${builder.github_login} (droplet ${box.droplet_id})`);
|
|
623
|
+
if (!apply) { console.log(' [dry-run] would snapshot + destroy droplet. Re-run with --apply.'); return { ok: true, dryRun: true }; }
|
|
624
|
+
try {
|
|
625
|
+
await parkOne(pool, boxes, makeDo(apply), box, { actor: actorTag() });
|
|
626
|
+
console.log(' ✓ parked (snapshot kept; droplet destroyed — compute billing stopped)');
|
|
627
|
+
return { ok: true };
|
|
628
|
+
} catch (e) {
|
|
629
|
+
await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
|
|
630
|
+
return fail(`park failed: ${e.message}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ---- wake (recreate from snapshot) -----------------------------------------
|
|
635
|
+
|
|
636
|
+
async function cmdWake(pool, boxes, ref, { apply }) {
|
|
637
|
+
const builder = await resolveBuilder(pool, ref);
|
|
638
|
+
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
639
|
+
const box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
640
|
+
if (!box || !boxes.canTransition('wake', box.state)) {
|
|
641
|
+
return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — wake only from parked`);
|
|
642
|
+
}
|
|
643
|
+
if (!box.snapshot_id) return fail(`parked box for @${builder.github_login} has no snapshot_id — cannot wake (deprovision + re-provision)`);
|
|
644
|
+
console.log(`wake box for @${builder.github_login} (from snapshot ${box.snapshot_id})`);
|
|
645
|
+
if (!apply) { console.log(' [dry-run] would recreate droplet from snapshot. Re-run with --apply.'); return { ok: true, dryRun: true }; }
|
|
646
|
+
const doApi = makeDo(apply);
|
|
647
|
+
await boxes.setBoxState(pool, builder.id, 'waking');
|
|
648
|
+
try {
|
|
649
|
+
const { droplet } = await doApi.createDropletFromSnapshot({
|
|
650
|
+
name: box.droplet_name || dropletName(builder.github_login),
|
|
651
|
+
region: box.region, size: box.size_slug, snapshotId: box.snapshot_id,
|
|
652
|
+
tags: [CONFIG.tag, `builder:${builder.github_login}`],
|
|
653
|
+
sshKeys: CONFIG.sshKeyIds.length ? CONFIG.sshKeyIds : undefined,
|
|
654
|
+
});
|
|
655
|
+
const { ip } = await doApi.settleDroplet(droplet.id);
|
|
656
|
+
const now = new Date();
|
|
657
|
+
await boxes.setBoxState(pool, builder.id, 'active', {
|
|
658
|
+
droplet_id: String(droplet.id), ip, active_since: now, last_activity_at: now, parked_at: null,
|
|
659
|
+
});
|
|
660
|
+
await boxes.recordEvent(pool, { boxId: box.id, builderId: builder.id, event: 'wake', detail: `droplet ${droplet.id} ${ip || ''}`, actor: actorTag() });
|
|
661
|
+
// #602: the box just ended a PARKED interval (snapshot retained) — accrue its
|
|
662
|
+
// storage cost before active_since restarts compute, then log the wake spin-up.
|
|
663
|
+
// `box` is the pre-wake row, so it still carries parked_at.
|
|
664
|
+
await boxes.recordStorageCost(pool, { box, untilMs: now.getTime(), actor: actorTag() });
|
|
665
|
+
await boxes.recordSpinupCost(pool, { box, kind: 'wake', atMs: now.getTime(), actor: actorTag() });
|
|
666
|
+
const wakeHost = box.hostname || hostnameFor(builder.github_login);
|
|
667
|
+
const dns = runDnsHook(wakeHost, ip);
|
|
668
|
+
if (!dns.ok) {
|
|
669
|
+
await boxes.recordEvent(pool, {
|
|
670
|
+
boxId: box.id, builderId: builder.id, event: 'error',
|
|
671
|
+
detail: `dns_repoint_failed: ${dns.error || 'DNS hook failed'} (${wakeHost} -> ${ip})`,
|
|
672
|
+
actor: actorTag(),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
console.log(` ✓ active — droplet ${droplet.id}${ip ? ` @ ${ip}` : ''}${dns.ok ? '' : ' ⚠ DNS STALE — Desktop SSH will fail until repointed (browser terminal unaffected)'}`);
|
|
676
|
+
return { ok: true };
|
|
677
|
+
} catch (e) {
|
|
678
|
+
await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
|
|
679
|
+
return fail(`wake failed: ${e.message}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---- deprovision (destroy droplet + snapshot) ------------------------------
|
|
684
|
+
|
|
685
|
+
// Idempotent delete with a few retries for transient DigitalOcean blips. A 404
|
|
686
|
+
// means the resource is ALREADY gone — that is success, not failure (the whole
|
|
687
|
+
// point of an idempotent teardown). Returns { ok:true } once the resource is
|
|
688
|
+
// confirmed absent, or { ok:false, error } after the retry budget is spent on a
|
|
689
|
+
// real (non-404) failure. `sleepFn` is injectable so tests drive it instantly.
|
|
690
|
+
async function deleteWithRetry(fn, { tries = 3, sleepMs = 2000, sleepFn } = {}) {
|
|
691
|
+
const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
692
|
+
let lastErr = null;
|
|
693
|
+
for (let i = 0; i < tries; i++) {
|
|
694
|
+
try {
|
|
695
|
+
await fn();
|
|
696
|
+
return { ok: true, alreadyGone: false };
|
|
697
|
+
} catch (e) {
|
|
698
|
+
if (e && e.status === 404) return { ok: true, alreadyGone: true };
|
|
699
|
+
lastErr = e;
|
|
700
|
+
if (i < tries - 1) await sleep(sleepMs);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return { ok: false, error: lastErr || new Error('delete failed') };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// CONFIRM-BEFORE-DESTROYED (task 1289). The old teardown swallowed a failed
|
|
707
|
+
// droplet delete (`.catch(log)`) and then set state='destroyed' UNCONDITIONALLY,
|
|
708
|
+
// nulling droplet_id — so a transient DO failure left a droplet running + billing
|
|
709
|
+
// + SSH-reachable while the DB claimed it was gone (the zombie). Now the droplet
|
|
710
|
+
// delete is the GATE: it is retried + idempotent, and if it genuinely fails the box
|
|
711
|
+
// is parked at state='error' with its droplet_id PRESERVED (so it's queryable and
|
|
712
|
+
// the drift reconcile can find it) and the function THROWS — the row never reaches
|
|
713
|
+
// 'destroyed' until DO confirms the droplet is gone. Snapshot/tunnel/DNS stay
|
|
714
|
+
// best-effort (a leftover snapshot is cost-only; the box is already gone for the
|
|
715
|
+
// tunnel/DNS), but the snapshot delete is now idempotent too.
|
|
716
|
+
async function deprovisionOne(pool, boxes, doApi, box, { actor, login, sleepFn } = {}) {
|
|
717
|
+
const now = Date.now();
|
|
718
|
+
if (box.state === 'active' && box.droplet_id) {
|
|
719
|
+
await boxes.recordComputeCost(pool, { box, untilMs: now, actor });
|
|
720
|
+
}
|
|
721
|
+
// #602: deprovisioning a PARKED box ends a snapshot-storage interval — accrue it
|
|
722
|
+
// (no-op for an active/never-parked box, where parked_at is null).
|
|
723
|
+
if (box.parked_at) {
|
|
724
|
+
await boxes.recordStorageCost(pool, { box, untilMs: now, actor });
|
|
725
|
+
}
|
|
726
|
+
if (box.droplet_id) {
|
|
727
|
+
const r = await deleteWithRetry(() => doApi.deleteDroplet(box.droplet_id), { sleepFn });
|
|
728
|
+
if (!r.ok) {
|
|
729
|
+
// Do NOT mark 'destroyed' — that is the bug. Park at 'error' with droplet_id
|
|
730
|
+
// intact so the row stays truthful (a droplet is still out there) and throw.
|
|
731
|
+
const msg = (r.error && r.error.message) || 'droplet delete failed';
|
|
732
|
+
await boxes.setBoxState(pool, box.builder_id, 'error', {
|
|
733
|
+
error_note: `droplet ${box.droplet_id} delete failed (not destroyed): ${msg}`,
|
|
734
|
+
});
|
|
735
|
+
await boxes.recordEvent(pool, {
|
|
736
|
+
boxId: box.id, builderId: box.builder_id, event: 'error',
|
|
737
|
+
detail: `deprovision aborted: droplet ${box.droplet_id} delete failed: ${msg}`, actor,
|
|
738
|
+
});
|
|
739
|
+
throw r.error || new Error(msg);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (box.snapshot_id) {
|
|
743
|
+
const sr = await deleteWithRetry(() => doApi.deleteSnapshot(box.snapshot_id), { sleepFn });
|
|
744
|
+
if (!sr.ok) console.log(` ⚠ snapshot delete (left behind, cost-only): ${(sr.error && sr.error.message) || 'failed'}`);
|
|
745
|
+
}
|
|
746
|
+
// Named-tunnel teardown (#771, ADR 0038 Part B). Best-effort — the box is gone
|
|
747
|
+
// by now, so a leftover tunnel/DNS is only cosmetic, never a blocker. The
|
|
748
|
+
// droplet was destroyed above, dropping the connector connections, so the
|
|
749
|
+
// tunnel deletes cleanly. login is the deterministic key (tunnel name + host).
|
|
750
|
+
const who = login || box.github_login;
|
|
751
|
+
if (namedTunnelConfigured() && who) {
|
|
752
|
+
try {
|
|
753
|
+
const res = await makeCf(!doApi.dryRun).teardownNamedTunnel({ login: who, zone: CF.zone, terminalZone: CF.terminalZone });
|
|
754
|
+
if (res.tunnelDeleted || res.dnsDeleted) console.log(` ✓ named tunnel torn down (tunnel=${res.tunnelDeleted} dns=${res.dnsDeleted})`);
|
|
755
|
+
for (const err of res.errors) console.log(` ⚠ tunnel teardown ${err}`);
|
|
756
|
+
} catch (e) { console.log(` ⚠ named tunnel teardown: ${e.message}`); }
|
|
757
|
+
}
|
|
758
|
+
// task 1180 + idea 309: park the per-builder SSH A record at the blackhole
|
|
759
|
+
// sentinel (don't delete it) after teardown. This still stops the record
|
|
760
|
+
// dangling at a recycled DO IP (the amplifier behind the stale-DNS outage),
|
|
761
|
+
// AND it avoids the NXDOMAIN gap that a delete-then-recreate opened on a
|
|
762
|
+
// reap→rebuild (idea 309): a deleted name negative-caches for the zone neg-TTL
|
|
763
|
+
// (~30 min), so a client that queried mid-gap kept getting ENOTFOUND long after
|
|
764
|
+
// the box was back. The name now always resolves (to a non-routable sentinel),
|
|
765
|
+
// so there's nothing to negative-cache; the next provision/wake repoints it to
|
|
766
|
+
// the live IP in place. Best-effort/non-fatal, like the tunnel teardown above;
|
|
767
|
+
// box.hostname was stamped at provision, and the browser terminal rides the
|
|
768
|
+
// tunnel, not this record.
|
|
769
|
+
const dnsHost = box.hostname || (who ? hostnameFor(who) : null);
|
|
770
|
+
if (CONFIG.dnsHook && dnsHost) runDnsHookBlackhole(dnsHost);
|
|
771
|
+
await boxes.setBoxState(pool, box.builder_id, 'destroyed', {
|
|
772
|
+
destroyed_at: new Date(now), droplet_id: null, ip: null, snapshot_id: null,
|
|
773
|
+
active_since: null, parked_at: null, claude_active: false,
|
|
774
|
+
// idea 310: a rebuilt box mints fresh SSH host keys, but the GDS row kept the
|
|
775
|
+
// OLD keys until the new box re-reported (~10 min). In that window GET /box/me
|
|
776
|
+
// served a stale identity and the desktop app pinned the WRONG key — the
|
|
777
|
+
// "Host denied (verification failed)" that locked builders out. Clear them on
|
|
778
|
+
// teardown; the next box re-reports via POST /box/host-keys on provision. (A
|
|
779
|
+
// park→wake keeps its keys via the snapshot, so this only fires on full destroy.)
|
|
780
|
+
host_keys: null, host_keys_at: null,
|
|
781
|
+
});
|
|
782
|
+
// Post-'destroyed' audit-log write. The teardown is already COMMITTED above
|
|
783
|
+
// (droplet confirmed gone, row at 'destroyed', ids nulled), so this event is
|
|
784
|
+
// best-effort like the snapshot/tunnel/DNS steps — it MUST NOT throw. A failed
|
|
785
|
+
// audit write here used to propagate to the caller's catch (the sweep /
|
|
786
|
+
// deprovision / reconcile loops), which then flipped the just-committed
|
|
787
|
+
// 'destroyed' back to 'error' with a null droplet_id — the false-error that
|
|
788
|
+
// stranded builder 95 on 2026-07-04 (task 2034). Swallow + log instead; the
|
|
789
|
+
// markErrorUnlessDestroyed guard below is the second line of defence for any
|
|
790
|
+
// OTHER post-commit write (e.g. the reconcile path's recordSourceEvent).
|
|
791
|
+
try {
|
|
792
|
+
await boxes.recordEvent(pool, {
|
|
793
|
+
boxId: box.id, builderId: box.builder_id, event: 'deprovision',
|
|
794
|
+
detail: `droplet ${box.droplet_id || '-'} + snapshot ${box.snapshot_id || '-'} destroyed`, actor,
|
|
795
|
+
});
|
|
796
|
+
} catch (e) {
|
|
797
|
+
console.log(` ⚠ teardown complete (state=destroyed) but the deprovision audit event failed to record: ${e.message}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// A teardown that has already COMMITTED state='destroyed' must never be flipped
|
|
802
|
+
// back to 'error' by a LATER failure. deprovisionOne commits 'destroyed' and then
|
|
803
|
+
// runs best-effort follow-ups (its own recordEvent — now wrapped — and, on the
|
|
804
|
+
// reconcile path, recordSourceEvent); if one of those throws it lands in the
|
|
805
|
+
// caller's catch, which would otherwise overwrite the good 'destroyed' with
|
|
806
|
+
// 'error' and a null droplet_id (the false-error that stranded builder 95,
|
|
807
|
+
// 2026-07-04 — task 2034). This guard re-reads the row and only parks at 'error'
|
|
808
|
+
// when the box did NOT reach 'destroyed'. Never throws (it runs inside a catch).
|
|
809
|
+
async function markErrorUnlessDestroyed(pool, boxes, builderId, errNote) {
|
|
810
|
+
try {
|
|
811
|
+
const current = await boxes.getBoxByBuilderId(pool, builderId);
|
|
812
|
+
if (current && current.state === 'destroyed') {
|
|
813
|
+
console.log(` ⚠ teardown completed (state=destroyed); ignoring a post-teardown failure: ${errNote}`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
await boxes.setBoxState(pool, builderId, 'error', { error_note: errNote });
|
|
817
|
+
} catch (e) {
|
|
818
|
+
console.log(` ⚠ could not record error state for builder ${builderId}: ${e.message}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function cmdDeprovision(pool, boxes, ref, { apply }) {
|
|
823
|
+
const builder = await resolveBuilder(pool, ref);
|
|
824
|
+
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
825
|
+
const box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
826
|
+
if (!box || !boxes.canTransition('deprovision', box.state)) {
|
|
827
|
+
return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — nothing to deprovision`);
|
|
828
|
+
}
|
|
829
|
+
console.log(`deprovision box for @${builder.github_login} (droplet ${box.droplet_id || '-'}, snapshot ${box.snapshot_id || '-'})`);
|
|
830
|
+
if (!apply) { console.log(' [dry-run] would destroy droplet AND snapshot. Re-run with --apply.'); return { ok: true, dryRun: true }; }
|
|
831
|
+
try {
|
|
832
|
+
await deprovisionOne(pool, boxes, makeDo(apply), box, { actor: actorTag(), login: builder.github_login });
|
|
833
|
+
console.log(' ✓ destroyed (droplet + snapshot gone; all box cost stopped)');
|
|
834
|
+
return { ok: true };
|
|
835
|
+
} catch (e) {
|
|
836
|
+
await markErrorUnlessDestroyed(pool, boxes, builder.id, e.message);
|
|
837
|
+
return fail(`deprovision failed: ${e.message}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ---- sweep-idle ------------------------------------------------------------
|
|
842
|
+
// Idle boxes are deprovisioned directly (no park step). Park is removed from the
|
|
843
|
+
// automated lifecycle — it added snapshot storage cost (~$0.30/mo) with no
|
|
844
|
+
// benefit given boxes are ephemeral by design (builders provision fresh each session).
|
|
845
|
+
// Park remains available as a manual CLI command for Archon use.
|
|
846
|
+
|
|
847
|
+
async function cmdSweepIdle(pool, boxes, { apply }) {
|
|
848
|
+
const rows = await boxes.listBoxes(pool, { state: 'active' });
|
|
849
|
+
const idle = boxes.selectIdleBoxes(rows, { idleMinutes: CONFIG.idleMinutes, nowMs: Date.now() });
|
|
850
|
+
console.log(`idle sweep (threshold=${CONFIG.idleMinutes}m${apply ? '' : ', dry-run'}): ${idle.length} of ${rows.length} active box(es) idle`);
|
|
851
|
+
if (idle.length === 0) { console.log(' nothing idle — exit clean'); return { deprovisioned: 0 }; }
|
|
852
|
+
const doApi = apply ? makeDo(apply) : null;
|
|
853
|
+
let deprovisioned = 0, errors = 0;
|
|
854
|
+
for (const box of idle) {
|
|
855
|
+
const mins = Math.round((Date.now() - (boxes.effectiveActivityMs(box) || Date.now())) / 60000);
|
|
856
|
+
if (!apply) { console.log(` would deprovision @${box.github_login}'s box (idle ${mins}m, droplet ${box.droplet_id})`); continue; }
|
|
857
|
+
try {
|
|
858
|
+
await deprovisionOne(pool, boxes, doApi, box, { actor: 'sweep:idle', login: box.github_login });
|
|
859
|
+
deprovisioned++;
|
|
860
|
+
console.log(` ✓ deprovisioned @${box.github_login} (idle ${mins}m)`);
|
|
861
|
+
} catch (e) {
|
|
862
|
+
errors++;
|
|
863
|
+
await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
|
|
864
|
+
console.log(` ✖ @${box.github_login}: ${e.message}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
console.log(`done — deprovisioned=${deprovisioned} errors=${errors} dry-run=${!apply}`);
|
|
868
|
+
return { deprovisioned, errors };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ---- sweep-dormant ---------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
async function cmdSweepDormant(pool, boxes, { apply }) {
|
|
874
|
+
const rows = await boxes.listBoxes(pool, { state: 'parked' });
|
|
875
|
+
const dormant = boxes.selectDormantBoxes(rows, { dormantDays: CONFIG.dormantDays, nowMs: Date.now() });
|
|
876
|
+
console.log(`dormant sweep (threshold=${CONFIG.dormantDays}d${apply ? '' : ', dry-run'}): ${dormant.length} of ${rows.length} parked box(es) dormant`);
|
|
877
|
+
if (dormant.length === 0) { console.log(' nothing dormant — exit clean'); return { reclaimed: 0 }; }
|
|
878
|
+
const doApi = apply ? makeDo(apply) : null;
|
|
879
|
+
let reclaimed = 0, errors = 0, pingFailed = 0;
|
|
880
|
+
for (const box of dormant) {
|
|
881
|
+
const days = Math.round((Date.now() - Date.parse(box.parked_at)) / 86400000);
|
|
882
|
+
if (!apply) { console.log(` would reclaim @${box.github_login}'s box (parked ${days}d, snapshot ${box.snapshot_id})`); continue; }
|
|
883
|
+
try {
|
|
884
|
+
await deprovisionOne(pool, boxes, doApi, box, { actor: 'sweep:dormant' });
|
|
885
|
+
reclaimed++;
|
|
886
|
+
const ping = postReclaimPing(`@${box.github_login} — your dev box was idle for ${days} days and has been reclaimed to save cost. Ask an Archon to re-provision when you're back; your memory is server-side and unaffected.`);
|
|
887
|
+
if (!ping.ok) pingFailed++;
|
|
888
|
+
console.log(` ✓ reclaimed @${box.github_login} (parked ${days}d)${ping.ok ? '' : ' [chat ping failed]'}`);
|
|
889
|
+
} catch (e) { errors++; await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message); console.log(` ✖ @${box.github_login}: ${e.message}`); }
|
|
890
|
+
}
|
|
891
|
+
console.log(`done — reclaimed=${reclaimed} ping-failed=${pingFailed} errors=${errors} dry-run=${!apply}`);
|
|
892
|
+
return { reclaimed, errors, pingFailed };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---- reconcile (source-access clawback) ------------------------------------
|
|
896
|
+
|
|
897
|
+
// The control-plane half of rank-gated source access (ADR 0031 §6, #600). The
|
|
898
|
+
// CREDENTIAL layer is already gated live on every GET /box/source-access fetch, so
|
|
899
|
+
// revocation is instant there; this is the slower droplet-level reconcile that
|
|
900
|
+
// brings each box into line with its builder's LIVE rank+status:
|
|
901
|
+
// - below the floor / builder inactive → deprovision (no box, no source)
|
|
902
|
+
// - 'full' box whose builder fell below Metic+ → deprovision (can't un-see the
|
|
903
|
+
// crown jewels; a fresh starter box can be provisioned)
|
|
904
|
+
// - 'starter' box whose builder graduated to Metic+ → rescope to 'full' (the box
|
|
905
|
+
// re-clones the full surface on its next source-access refresh)
|
|
906
|
+
// Run it on a schedule (alongside the sweeps) for hands-off offboarding. DRY-RUN
|
|
907
|
+
// DEFAULT — pass --apply to actually deprovision / rescope.
|
|
908
|
+
async function cmdReconcile(pool, boxes, boxAccess, { apply }) {
|
|
909
|
+
const rows = await boxAccess.listBoxesForReconcile(pool);
|
|
910
|
+
const actions = rows
|
|
911
|
+
.map((box) => ({
|
|
912
|
+
box,
|
|
913
|
+
decision: boxAccess.reconcileSourceAccess({
|
|
914
|
+
boxScope: box.scope, boxState: box.state,
|
|
915
|
+
rank: box.rank, status: box.status, floor: CONFIG.floor,
|
|
916
|
+
}),
|
|
917
|
+
}))
|
|
918
|
+
.filter((x) => x.decision.action !== 'none');
|
|
919
|
+
|
|
920
|
+
console.log(`source-access reconcile (floor=${CONFIG.floor}${apply ? '' : ', dry-run'}): ${actions.length} of ${rows.length} box(es) need action`);
|
|
921
|
+
if (actions.length === 0) { console.log(' all boxes in sync — exit clean'); return { deprovisioned: 0, rescoped: 0 }; }
|
|
922
|
+
|
|
923
|
+
const doApi = apply ? makeDo(apply) : null;
|
|
924
|
+
let deprovisioned = 0, rescoped = 0, errors = 0;
|
|
925
|
+
for (const { box, decision } of actions) {
|
|
926
|
+
if (!apply) {
|
|
927
|
+
console.log(` would ${decision.action} @${box.github_login}'s box — ${decision.reason} (scope=${box.scope}, rank=${box.rank}, status=${box.status})`);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
if (decision.action === 'deprovision') {
|
|
932
|
+
await deprovisionOne(pool, boxes, doApi, box, { actor: 'reconcile:clawback' });
|
|
933
|
+
await boxAccess.recordSourceEvent(pool, {
|
|
934
|
+
box, builderId: box.builder_id, event: 'source_revoke',
|
|
935
|
+
detail: `deprovisioned: ${decision.reason} (rank=${box.rank}, status=${box.status})`, actor: 'reconcile',
|
|
936
|
+
});
|
|
937
|
+
deprovisioned++;
|
|
938
|
+
console.log(` ✓ deprovisioned @${box.github_login} — ${decision.reason}`);
|
|
939
|
+
} else if (decision.action === 'rescope') {
|
|
940
|
+
await boxes.setBoxState(pool, box.builder_id, box.state, {
|
|
941
|
+
scope: decision.newScope, provisioned_rank: box.rank,
|
|
942
|
+
});
|
|
943
|
+
await boxAccess.recordSourceEvent(pool, {
|
|
944
|
+
box, builderId: box.builder_id, event: 'source_rescope',
|
|
945
|
+
detail: `${box.scope}→${decision.newScope}: ${decision.reason}`, actor: 'reconcile',
|
|
946
|
+
});
|
|
947
|
+
rescoped++;
|
|
948
|
+
console.log(` ✓ rescoped @${box.github_login} ${box.scope}→${decision.newScope} (box re-clones on next refresh)`);
|
|
949
|
+
}
|
|
950
|
+
} catch (e) {
|
|
951
|
+
errors++;
|
|
952
|
+
await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
|
|
953
|
+
console.log(` ✖ @${box.github_login}: ${e.message}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
console.log(`done — deprovisioned=${deprovisioned} rescoped=${rescoped} errors=${errors} dry-run=${!apply}`);
|
|
957
|
+
return { deprovisioned, rescoped, errors };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ---- reconcile-drift (DO droplet list ⇄ GDS box rows, task 1289) -----------
|
|
961
|
+
|
|
962
|
+
// Best-effort teardown of a login's edge resources (Cloudflare named tunnel + the
|
|
963
|
+
// per-builder DNS A record), shared by the zombie + ghost reconcile paths so no
|
|
964
|
+
// orphan tunnel/DNS is left behind. Never throws — the droplet (the thing that
|
|
965
|
+
// bills + is SSH-reachable) is the hard part; a leftover tunnel/DNS is cosmetic.
|
|
966
|
+
async function teardownEdgeForLogin(login, { apply }) {
|
|
967
|
+
if (!login) return;
|
|
968
|
+
if (namedTunnelConfigured()) {
|
|
969
|
+
try {
|
|
970
|
+
const res = await makeCf(apply).teardownNamedTunnel({ login, zone: CF.zone, terminalZone: CF.terminalZone });
|
|
971
|
+
if (res.tunnelDeleted || res.dnsDeleted) console.log(` ✓ tunnel torn down (tunnel=${res.tunnelDeleted} dns=${res.dnsDeleted})`);
|
|
972
|
+
for (const err of res.errors) console.log(` ⚠ tunnel teardown ${err}`);
|
|
973
|
+
} catch (e) { console.log(` ⚠ tunnel teardown: ${e.message}`); }
|
|
974
|
+
}
|
|
975
|
+
if (CONFIG.dnsHook && apply) runDnsHookBlackhole(hostnameFor(login));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// The bidirectional drift sweep. Lists the REAL fleet droplets (live read, even in
|
|
979
|
+
// dry-run) and reconciles against the GDS box rows:
|
|
980
|
+
// - ZOMBIE (droplet exists, no live row claims it) → force-delete droplet + edge.
|
|
981
|
+
// - GHOST (live row, its droplet vanished from DO) → mark 'destroyed' + edge.
|
|
982
|
+
// This is the backstop for any teardown that half-failed (now far less likely with
|
|
983
|
+
// confirm-before-destroyed in deprovisionOne) and for out-of-band DO changes.
|
|
984
|
+
// DRY-RUN DEFAULT — pass --apply to actually delete / mark.
|
|
985
|
+
async function cmdReconcileDrift(pool, boxes, { apply }) {
|
|
986
|
+
const readApi = makeDoRead();
|
|
987
|
+
if (!readApi) {
|
|
988
|
+
return fail('reconcile-drift needs DO_API_TOKEN to enumerate droplets — run on the control plane (prod droplet)');
|
|
989
|
+
}
|
|
990
|
+
let droplets;
|
|
991
|
+
try {
|
|
992
|
+
({ droplets } = await readApi.listDroplets({ tag: CONFIG.tag }));
|
|
993
|
+
} catch (e) {
|
|
994
|
+
return fail(`could not list DigitalOcean droplets: ${e.message}`);
|
|
995
|
+
}
|
|
996
|
+
const boxRows = await boxes.listBoxes(pool, {});
|
|
997
|
+
const { zombies, ghosts, skippedYoung } = boxes.planDropletDrift({
|
|
998
|
+
droplets: droplets || [], boxRows, nowMs: Date.now(),
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
console.log(`drift reconcile (fleet tag=${CONFIG.tag}${apply ? '' : ', dry-run'}): ${(droplets || []).length} droplet(s) live, ${boxRows.length} box row(s)`);
|
|
1002
|
+
console.log(` zombies=${zombies.length} ghosts=${ghosts.length} skipped-young=${skippedYoung.length}`);
|
|
1003
|
+
for (const z of skippedYoung) console.log(` · skip young droplet ${z.dropletId} (${z.name || '?'}) — likely a provision in flight`);
|
|
1004
|
+
if (zombies.length === 0 && ghosts.length === 0) { console.log(' DO ⇄ DB consistent — exit clean'); return { zombies: 0, ghosts: 0 }; }
|
|
1005
|
+
|
|
1006
|
+
const mutateDo = makeDo(apply); // dry-run logs the DELETEs; --apply executes them
|
|
1007
|
+
let killed = 0, marked = 0, errors = 0;
|
|
1008
|
+
|
|
1009
|
+
// ZOMBIES: a droplet no live row owns. Force-delete it (idempotent + retried),
|
|
1010
|
+
// then tear down its edge. The box row (if any) is already 'destroyed'/'error' —
|
|
1011
|
+
// we don't change DB state, we reclaim the leaked DO resource + record an audit row.
|
|
1012
|
+
for (const z of zombies) {
|
|
1013
|
+
const who = z.login ? `@${z.login}` : `droplet ${z.dropletId}`;
|
|
1014
|
+
if (!apply) { console.log(` would FORCE-DELETE zombie droplet ${z.dropletId} (${z.name || '?'}, ${who})`); continue; }
|
|
1015
|
+
try {
|
|
1016
|
+
const r = await deleteWithRetry(() => mutateDo.deleteDroplet(z.dropletId));
|
|
1017
|
+
if (!r.ok) throw r.error || new Error('droplet delete failed');
|
|
1018
|
+
await teardownEdgeForLogin(z.login, { apply });
|
|
1019
|
+
const row = boxRows.find((b) => b.builder_id === z.builderId);
|
|
1020
|
+
if (row) {
|
|
1021
|
+
await boxes.recordEvent(pool, {
|
|
1022
|
+
boxId: row.id, builderId: z.builderId, event: 'deprovision',
|
|
1023
|
+
detail: `drift: zombie droplet ${z.dropletId} force-deleted (row was '${row.state}')`,
|
|
1024
|
+
actor: 'reconcile:drift',
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
killed++;
|
|
1028
|
+
console.log(` ✓ killed zombie droplet ${z.dropletId} (${who})`);
|
|
1029
|
+
} catch (e) {
|
|
1030
|
+
errors++;
|
|
1031
|
+
console.log(` ✖ zombie droplet ${z.dropletId} (${who}): ${e.message}`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// GHOSTS: a live row whose droplet is gone from DO. Accrue final compute, tear
|
|
1036
|
+
// down any leftover edge, then mark 'destroyed' (clearing the dead droplet_id +
|
|
1037
|
+
// SSH host keys, idea 310) so the DB stops claiming a running box.
|
|
1038
|
+
for (const g of ghosts) {
|
|
1039
|
+
if (!apply) { console.log(` would MARK DESTROYED @${g.login || g.builderId}'s box (state '${g.state}', droplet ${g.dropletId} gone from DO)`); continue; }
|
|
1040
|
+
try {
|
|
1041
|
+
const row = boxRows.find((b) => b.builder_id === g.builderId);
|
|
1042
|
+
const nowMs = Date.now();
|
|
1043
|
+
if (row && row.state === 'active' && row.droplet_id) {
|
|
1044
|
+
await boxes.recordComputeCost(pool, { box: row, untilMs: nowMs, actor: 'reconcile:drift' });
|
|
1045
|
+
}
|
|
1046
|
+
await teardownEdgeForLogin(g.login, { apply });
|
|
1047
|
+
await boxes.setBoxState(pool, g.builderId, 'destroyed', {
|
|
1048
|
+
destroyed_at: new Date(nowMs), droplet_id: null, ip: null, snapshot_id: null,
|
|
1049
|
+
active_since: null, parked_at: null, claude_active: false,
|
|
1050
|
+
host_keys: null, host_keys_at: null,
|
|
1051
|
+
});
|
|
1052
|
+
if (row) {
|
|
1053
|
+
await boxes.recordEvent(pool, {
|
|
1054
|
+
boxId: row.id, builderId: g.builderId, event: 'deprovision',
|
|
1055
|
+
detail: `drift: droplet ${g.dropletId} vanished from DO — row '${g.state}'→'destroyed'`,
|
|
1056
|
+
actor: 'reconcile:drift',
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
marked++;
|
|
1060
|
+
console.log(` ✓ marked @${g.login || g.builderId}'s box destroyed (ghost droplet ${g.dropletId})`);
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
errors++;
|
|
1063
|
+
console.log(` ✖ ghost @${g.login || g.builderId}: ${e.message}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
console.log(`done — zombies-killed=${killed} ghosts-marked=${marked} errors=${errors} dry-run=${!apply}`);
|
|
1068
|
+
return { zombies: killed, ghosts: marked, errors };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ---- run-intents (#701: drain the auto-provision / wake-on-connect queue) --
|
|
1072
|
+
|
|
1073
|
+
// The control-plane half of the #701 onboarding middleware. The web process can
|
|
1074
|
+
// only ENQUEUE a provision/wake intent (POST /box/ensure) — it never holds the DO
|
|
1075
|
+
// token. This subcommand, run on a short systemd timer (box-intent-runner.timer,
|
|
1076
|
+
// every minute), drains box_intents by calling the SAME cmdProvision/cmdWake the
|
|
1077
|
+
// operator uses. DRY-RUN DEFAULT: without --apply it only lists the pending queue
|
|
1078
|
+
// (and does NOT claim rows). With --apply it claims + executes each pending intent.
|
|
1079
|
+
//
|
|
1080
|
+
// A small attempt cap (BOX_INTENT_MAX_ATTEMPTS, default 3) retires a poison intent
|
|
1081
|
+
// to 'error' so a doomed provision can't loop forever burning the DO API.
|
|
1082
|
+
const MAX_INTENT_ATTEMPTS = numEnv('BOX_INTENT_MAX_ATTEMPTS', 3);
|
|
1083
|
+
|
|
1084
|
+
async function cmdRunIntents(pool, boxes, boxOnboard, { apply }) {
|
|
1085
|
+
if (!apply) {
|
|
1086
|
+
const { rows } = await pool.query(
|
|
1087
|
+
`SELECT bi.*, b.github_login FROM box_intents bi
|
|
1088
|
+
JOIN builders b ON b.id = bi.builder_id
|
|
1089
|
+
WHERE bi.state = 'pending' ORDER BY bi.created_at`
|
|
1090
|
+
);
|
|
1091
|
+
console.log(`intent runner (dry-run): ${rows.length} pending intent(s)`);
|
|
1092
|
+
for (const r of rows) console.log(` would ${r.action} @${r.github_login} (intent #${r.id}, attempts=${r.attempts})`);
|
|
1093
|
+
if (rows.length === 0) console.log(' queue empty — exit clean');
|
|
1094
|
+
return { ran: 0 };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
let ran = 0, ok = 0, errors = 0;
|
|
1098
|
+
// Drain the queue one claimed intent at a time (FOR UPDATE SKIP LOCKED), so two
|
|
1099
|
+
// overlapping runner ticks never execute the same intent.
|
|
1100
|
+
for (;;) {
|
|
1101
|
+
const intent = await boxes.claimNextIntent(pool);
|
|
1102
|
+
if (!intent) break;
|
|
1103
|
+
ran++;
|
|
1104
|
+
const builder = await resolveBuilder(pool, String(intent.builder_id));
|
|
1105
|
+
const who = builder ? `@${builder.github_login}` : `builder#${intent.builder_id}`;
|
|
1106
|
+
// Poison guard: a claimed intent already past the cap is retired, not retried.
|
|
1107
|
+
if (intent.attempts > MAX_INTENT_ATTEMPTS) {
|
|
1108
|
+
await boxes.resolveIntent(pool, intent.id, 'error', `exceeded ${MAX_INTENT_ATTEMPTS} attempts`);
|
|
1109
|
+
errors++;
|
|
1110
|
+
console.log(` ✖ ${who}: intent #${intent.id} retired (too many attempts)`);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
console.log(` ${intent.action} ${who} (intent #${intent.id}, attempt ${intent.attempts})`);
|
|
1114
|
+
try {
|
|
1115
|
+
const ref = String(intent.builder_id);
|
|
1116
|
+
const result = intent.action === 'provision'
|
|
1117
|
+
? await cmdProvision(pool, boxes, boxOnboard, ref, { apply: true })
|
|
1118
|
+
: intent.action === 'deprovision'
|
|
1119
|
+
? await cmdDeprovision(pool, boxes, ref, { apply: true })
|
|
1120
|
+
: await cmdWake(pool, boxes, ref, { apply: true });
|
|
1121
|
+
if (result && result.ok === false) {
|
|
1122
|
+
// Leave it 'pending' for a retry UNLESS we've hit the cap (next claim
|
|
1123
|
+
// bumps attempts; the guard above retires it once over the cap).
|
|
1124
|
+
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
1125
|
+
await boxes.resolveIntent(pool, intent.id, 'error', result.error || 'lifecycle op failed');
|
|
1126
|
+
} else {
|
|
1127
|
+
await pool.query(`UPDATE box_intents SET state = 'pending', last_error = $2, updated_at = now() WHERE id = $1`,
|
|
1128
|
+
[intent.id, result.error || 'lifecycle op failed']);
|
|
1129
|
+
}
|
|
1130
|
+
errors++;
|
|
1131
|
+
console.log(` ✖ ${who}: ${result.error || 'failed'}`);
|
|
1132
|
+
} else {
|
|
1133
|
+
await boxes.resolveIntent(pool, intent.id, 'done', null);
|
|
1134
|
+
ok++;
|
|
1135
|
+
console.log(` ✓ ${who}: ${intent.action} done`);
|
|
1136
|
+
}
|
|
1137
|
+
} catch (e) {
|
|
1138
|
+
await boxes.resolveIntent(pool, intent.id, 'error', e.message).catch(() => {});
|
|
1139
|
+
errors++;
|
|
1140
|
+
console.log(` ✖ ${who}: ${e.message}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
console.log(`done — ran=${ran} ok=${ok} errors=${errors}`);
|
|
1144
|
+
return { ran, ok, errors };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ---- status ----------------------------------------------------------------
|
|
1148
|
+
|
|
1149
|
+
async function cmdStatus(pool, boxes) {
|
|
1150
|
+
const rows = await boxes.listBoxes(pool, {});
|
|
1151
|
+
if (rows.length === 0) { console.log('no builder boxes yet.'); return; }
|
|
1152
|
+
let monthly = 0, spent = 0;
|
|
1153
|
+
console.log('builder boxes:');
|
|
1154
|
+
for (const b of rows) {
|
|
1155
|
+
const sz = boxes.SIZE_CATALOG[b.size_slug];
|
|
1156
|
+
const liveMo = b.state === 'active' && sz ? sz.monthlyUsd : 0;
|
|
1157
|
+
monthly += liveMo;
|
|
1158
|
+
spent += Number(b.compute_cost_usd || 0);
|
|
1159
|
+
const where = b.state === 'active' ? `${b.ip || '?'} (${b.size_slug})` : b.state === 'parked' ? `snapshot ${b.snapshot_id || '?'}` : '';
|
|
1160
|
+
console.log(` @${b.github_login.padEnd(16)} ${b.state.padEnd(12)} scope=${b.scope.padEnd(8)} $${Number(b.compute_cost_usd || 0).toFixed(2)} spent ${where}`);
|
|
1161
|
+
}
|
|
1162
|
+
console.log(`\n active monthly run-rate: ~$${monthly.toFixed(2)}/mo total compute spent: $${spent.toFixed(2)}`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ---- shared ----------------------------------------------------------------
|
|
1166
|
+
|
|
1167
|
+
let APPLY = false;
|
|
1168
|
+
function actorTag() {
|
|
1169
|
+
const who = process.env.GDS_ACTOR || process.env.USER || 'cli';
|
|
1170
|
+
return `cli:${who}`;
|
|
1171
|
+
}
|
|
1172
|
+
function fail(msg) { console.error(`✖ ${msg}`); return { ok: false, error: msg }; }
|
|
1173
|
+
|
|
1174
|
+
function usage() {
|
|
1175
|
+
console.log(`box.js — per-builder dev box lifecycle (ADR 0031 §2/§5, #598)
|
|
1176
|
+
|
|
1177
|
+
Usage:
|
|
1178
|
+
node scripts/gds/box.js <command> [args] [--apply]
|
|
1179
|
+
|
|
1180
|
+
Commands:
|
|
1181
|
+
provision <builder> create + boot a box (rank-gated, floor=${CONFIG.floor})
|
|
1182
|
+
onboard <builder> provision + print the builder welcome packet (#685);
|
|
1183
|
+
requires a registered SSH key unless --allow-no-keys
|
|
1184
|
+
park <builder> snapshot + destroy droplet (stop compute billing)
|
|
1185
|
+
wake <builder> recreate droplet from its snapshot
|
|
1186
|
+
deprovision <builder> destroy droplet AND snapshot (full reclaim)
|
|
1187
|
+
run-intents drain the auto-provision/wake-on-connect queue (#701):
|
|
1188
|
+
execute each pending box_intents row (provision/wake)
|
|
1189
|
+
sweep-idle park active boxes idle > ${CONFIG.idleMinutes}m
|
|
1190
|
+
sweep-dormant deprovision boxes parked > ${CONFIG.dormantDays}d (pings builder)
|
|
1191
|
+
reconcile source-access clawback: deprovision below-floor/inactive
|
|
1192
|
+
boxes, rescope graduated ones (ADR 0031 §6, #600)
|
|
1193
|
+
reconcile-drift repair DO⇄DB drift BOTH ways (task 1289): force-delete
|
|
1194
|
+
zombie droplets (running but no live box row) + mark
|
|
1195
|
+
ghost rows destroyed (droplet gone from DO)
|
|
1196
|
+
status print the box roster + cost
|
|
1197
|
+
|
|
1198
|
+
DRY-RUN IS DEFAULT. Pass --apply to actually touch DigitalOcean + the DB.
|
|
1199
|
+
<builder> = GitHub login or numeric builder id.
|
|
1200
|
+
|
|
1201
|
+
Named browser-terminal tunnel (#771, ADR 0038 — OPT-IN, default OFF):
|
|
1202
|
+
BOX_NAMED_TUNNEL=1 turn on per-builder named Cloudflare tunnels (provision
|
|
1203
|
+
creates term-<login>.${CF.terminalZone} + bakes the
|
|
1204
|
+
connector token; deprovision tears it down). Off → the
|
|
1205
|
+
zero-secret quick tunnel (trycloudflare.com).
|
|
1206
|
+
CLOUDFLARE_API_TOKEN token with Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit
|
|
1207
|
+
CLOUDFLARE_ACCOUNT_ID the Cloudflare account the tunnels live in
|
|
1208
|
+
CLOUDFLARE_ZONE DNS zone (default ${CF.zone})
|
|
1209
|
+
BOX_TERMINAL_ZONE hostname suffix (default ${CF.terminalZone})`);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// box.js is a CLI: run directly it dispatches; require()d (only by tests — prod
|
|
1213
|
+
// never imports it, it is always spawned) it exposes the orchestration fns so the
|
|
1214
|
+
// dry-run/live contract can be exercised with injected fakes (#1034). The guard
|
|
1215
|
+
// keeps a require() from running the CLI + calling process.exit.
|
|
1216
|
+
async function main() {
|
|
1217
|
+
const args = process.argv.slice(2);
|
|
1218
|
+
const cmd = args[0];
|
|
1219
|
+
APPLY = hasFlag(args, '--apply');
|
|
1220
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
|
|
1221
|
+
|
|
1222
|
+
const known = ['provision', 'onboard', 'park', 'wake', 'deprovision', 'run-intents', 'sweep-idle', 'sweep-dormant', 'reconcile', 'reconcile-drift', 'status'];
|
|
1223
|
+
if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
|
|
1224
|
+
|
|
1225
|
+
const ref = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1226
|
+
const needsRef = ['provision', 'onboard', 'park', 'wake', 'deprovision'].includes(cmd);
|
|
1227
|
+
if (needsRef && !ref) { console.error(`${cmd} needs a <builder> (login or id)`); process.exit(2); }
|
|
1228
|
+
|
|
1229
|
+
let pool, boxes, boxAccess, boxOnboard;
|
|
1230
|
+
try { ({ pool, boxes, boxAccess, boxOnboard } = loadDeps()); }
|
|
1231
|
+
catch (e) { console.error(`cannot load DB layer (run this on the control plane / droplet): ${e.message}`); process.exit(1); }
|
|
1232
|
+
|
|
1233
|
+
let result;
|
|
1234
|
+
try {
|
|
1235
|
+
const opts = { apply: APPLY, allowNoKeys: hasFlag(args, '--allow-no-keys') };
|
|
1236
|
+
if (cmd === 'provision') result = await cmdProvision(pool, boxes, boxOnboard, ref, opts);
|
|
1237
|
+
else if (cmd === 'onboard') result = await cmdOnboard(pool, boxes, boxOnboard, ref, opts);
|
|
1238
|
+
else if (cmd === 'park') result = await cmdPark(pool, boxes, ref, opts);
|
|
1239
|
+
else if (cmd === 'wake') result = await cmdWake(pool, boxes, ref, opts);
|
|
1240
|
+
else if (cmd === 'deprovision') result = await cmdDeprovision(pool, boxes, ref, opts);
|
|
1241
|
+
else if (cmd === 'run-intents') result = await cmdRunIntents(pool, boxes, boxOnboard, opts);
|
|
1242
|
+
else if (cmd === 'sweep-idle') result = await cmdSweepIdle(pool, boxes, opts);
|
|
1243
|
+
else if (cmd === 'sweep-dormant') result = await cmdSweepDormant(pool, boxes, opts);
|
|
1244
|
+
else if (cmd === 'reconcile') result = await cmdReconcile(pool, boxes, boxAccess, opts);
|
|
1245
|
+
else if (cmd === 'reconcile-drift') result = await cmdReconcileDrift(pool, boxes, opts);
|
|
1246
|
+
else if (cmd === 'status') result = await cmdStatus(pool, boxes);
|
|
1247
|
+
} catch (e) {
|
|
1248
|
+
console.error('fatal:', e && e.message ? e.message : e);
|
|
1249
|
+
await pool.end().catch(() => {});
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
}
|
|
1252
|
+
await pool.end().catch(() => {});
|
|
1253
|
+
process.exit(result && result.ok === false ? 1 : 0);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (require.main === module) main();
|
|
1257
|
+
|
|
1258
|
+
module.exports = { cmdProvision, deprovisionOne, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed };
|