deepline 0.3.89 → 0.3.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/contracts/install-commands.json +29 -0
  2. package/dist/bundling-sources/sdk/src/client.ts +221 -43
  3. package/dist/bundling-sources/sdk/src/index.ts +4 -0
  4. package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
  5. package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
  6. package/dist/bundling-sources/sdk/src/pagination.ts +25 -0
  7. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
  8. package/dist/bundling-sources/sdk/src/release.ts +11 -9
  9. package/dist/bundling-sources/sdk/src/types.ts +29 -1
  10. package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
  11. package/dist/bundling-sources/shared_libs/monitors/analytics-public.ts +10 -0
  12. package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
  13. package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
  14. package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
  15. package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
  16. package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +8 -1
  17. package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +4 -4
  18. package/dist/bundling-sources/shared_libs/monitors/inventory-reconciliation.ts +59 -0
  19. package/dist/bundling-sources/shared_libs/monitors/lifecycle-notifications.ts +7 -9
  20. package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
  21. package/dist/bundling-sources/shared_libs/monitors/package.json +113 -4
  22. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +56 -9
  23. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-consumer-health.ts +2 -2
  24. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-identity-claims.ts +2 -2
  25. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-public-aliases.ts +1 -1
  26. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-dead-letter.ts +245 -0
  27. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-message.ts +38 -0
  28. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue-ack-probe.ts +94 -0
  29. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue-delivery.ts +60 -0
  30. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-queue.ts +60 -0
  31. package/dist/bundling-sources/shared_libs/monitors/snitcher-install-validation.ts +1 -1
  32. package/dist/bundling-sources/shared_libs/monitors/snitcher-ip2company.ts +2 -2
  33. package/dist/bundling-sources/shared_libs/monitors/snitcher-radar.ts +1 -1
  34. package/dist/bundling-sources/shared_libs/monitors/tracking-provider-integrations.ts +1 -1
  35. package/dist/bundling-sources/shared_libs/observability/build-context.ts +13 -0
  36. package/dist/bundling-sources/shared_libs/observability/log-metadata.ts +160 -0
  37. package/dist/bundling-sources/shared_libs/observability/request-context.ts +68 -0
  38. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +690 -0
  39. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +35 -0
  40. package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +5 -0
  41. package/dist/bundling-sources/shared_libs/play-data-plane/neon-constants.ts +9 -0
  42. package/dist/bundling-sources/shared_libs/play-data-plane/r2.ts +272 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +46 -1
  44. package/dist/bundling-sources/shared_libs/play-runtime/async-operation.ts +66 -1
  45. package/dist/bundling-sources/shared_libs/play-runtime/async-play-invocation.ts +0 -1
  46. package/dist/bundling-sources/shared_libs/play-runtime/bounceban-batching.ts +137 -0
  47. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +779 -140
  48. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +46 -0
  49. package/dist/bundling-sources/shared_libs/play-runtime/daytona-payload-contract.ts +68 -0
  50. package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-janitor.ts +103 -0
  51. package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-reaper.ts +725 -0
  52. package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
  53. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
  54. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
  56. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
  57. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
  58. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
  59. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
  60. package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
  61. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
  62. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
  63. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
  64. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
  65. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
  66. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
  67. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
  68. package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +29 -0
  69. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +23 -0
  70. package/dist/bundling-sources/shared_libs/play-runtime/execution-gateway-invocation-fence.ts +442 -0
  71. package/dist/bundling-sources/shared_libs/play-runtime/execution-protocol.ts +3 -3
  72. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +18 -0
  73. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +9 -0
  74. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +1 -1
  75. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +1 -1
  76. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/contracts.ts +66 -0
  77. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/job.ts +145 -0
  78. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
  79. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/ports.ts +21 -0
  80. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/postgres-store.ts +170 -0
  81. package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +1322 -0
  82. package/dist/bundling-sources/shared_libs/play-runtime/internal-step-ids.ts +2 -2
  83. package/dist/bundling-sources/shared_libs/play-runtime/lane-retirement-gate.ts +1 -1
  84. package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +112 -0
  85. package/dist/bundling-sources/shared_libs/play-runtime/map-execution-frame.ts +8 -0
  86. package/dist/bundling-sources/shared_libs/play-runtime/perf-trace.ts +127 -0
  87. package/dist/bundling-sources/shared_libs/play-runtime/play-artifact-runtime-contract.ts +5 -0
  88. package/dist/bundling-sources/shared_libs/play-runtime/play-run-recovery-policy.ts +24 -1
  89. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +56 -0
  90. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +13 -0
  91. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/job.ts +41 -0
  92. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/ports.ts +17 -0
  93. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +54 -0
  94. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/contracts.ts +114 -0
  95. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/engine-suspension.ts +108 -0
  96. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/index.ts +36 -0
  97. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/job.ts +97 -0
  98. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/ports.ts +104 -0
  99. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/postgres-fence-adapter.ts +263 -0
  100. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/attempts/worker-terminal-projection.ts +64 -0
  101. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/contracts.ts +29 -0
  102. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/gateway-adapter.ts +56 -0
  103. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/index.ts +15 -0
  104. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/job.ts +65 -0
  105. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/ports.ts +17 -0
  106. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/contracts.ts +42 -0
  107. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/index.ts +17 -0
  108. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/job.ts +142 -0
  109. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/ports.ts +42 -0
  110. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/sandbox-cleanup/postgres-adapter.ts +102 -0
  111. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/contracts.ts +60 -0
  112. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/index.ts +16 -0
  113. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/job.ts +78 -0
  114. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/ports.ts +54 -0
  115. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/postgres-adapter.ts +71 -0
  116. package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-batching-registry.ts +4 -0
  117. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +2 -0
  118. package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +5 -8
  119. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +3 -5
  120. package/dist/bundling-sources/shared_libs/play-runtime/run-identity.ts +168 -0
  121. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
  122. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
  123. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
  124. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
  125. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
  126. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
  127. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
  128. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
  129. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
  130. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
  131. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +72 -9
  132. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +6 -12
  133. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +19 -0
  134. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/bundle.ts +0 -1
  135. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +14 -44
  136. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +103 -208
  137. package/dist/bundling-sources/shared_libs/play-runtime/runtime-authority.ts +201 -0
  138. package/dist/bundling-sources/shared_libs/play-runtime/runtime-env.ts +6 -0
  139. package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +67 -0
  140. package/dist/bundling-sources/shared_libs/play-runtime/runtime-release-environment.ts +2 -0
  141. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sandbox-contract.ts +68 -0
  142. package/dist/bundling-sources/shared_libs/play-runtime/runtime-scheduler-topology.ts +14 -2
  143. package/dist/bundling-sources/shared_libs/play-runtime/runtime-telemetry.ts +108 -0
  144. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/contract.ts +4 -0
  145. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/runner-backend-adapter.ts +3 -0
  146. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  147. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-deadline-reconciliation.ts +88 -0
  148. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-deploy-task.ts +124 -0
  149. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-run-task.ts +68 -0
  150. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-pool.ts +193 -0
  151. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-queue-lifecycle.ts +269 -0
  152. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-queue-suffix.ts +73 -0
  153. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +6642 -0
  154. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +897 -0
  155. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-metrics.ts +120 -0
  156. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-required-env.ts +76 -0
  157. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +476 -0
  158. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-heavy-request-admission.ts +47 -0
  159. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-progress-registry.ts +147 -0
  160. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/index.ts +27 -0
  161. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/lazy.ts +50 -0
  162. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/play-run-recovery.ts +98 -0
  163. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-budget-state.ts +260 -0
  164. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +1416 -0
  165. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +1255 -0
  166. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +942 -0
  167. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +14636 -0
  168. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +888 -0
  169. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +355 -0
  170. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-terminal-transition.ts +46 -0
  171. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runner-push.ts +60 -0
  172. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-api-origin.ts +33 -0
  173. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-receipt-gateway-health.ts +64 -0
  174. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-capacity.ts +835 -0
  175. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-ledger-reconciliation.ts +1218 -0
  176. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-sandbox-operation.ts +207 -0
  177. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-scheduler-schema.ts +10 -0
  178. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/scheduler-capacity.ts +123 -0
  179. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/scheduler-launch-read-observability.ts +78 -0
  180. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +169 -0
  181. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/terminal-contract.ts +20 -0
  182. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-queue-metrics.ts +34 -0
  183. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-resource-metrics.ts +249 -0
  184. package/dist/bundling-sources/shared_libs/play-runtime/sheet-attempt-sql.ts +7 -0
  185. package/dist/bundling-sources/shared_libs/play-runtime/step-progress.ts +296 -0
  186. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/native-batch.ts +3 -3
  187. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/runtime-step-receipts-adapter.ts +20 -1
  188. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +60 -15
  189. package/dist/bundling-sources/shared_libs/play-runtime/tool-execution-outcome.ts +20 -0
  190. package/dist/bundling-sources/shared_libs/play-runtime/transient-service-error.ts +12 -0
  191. package/dist/bundling-sources/shared_libs/play-runtime/vercel-protection.ts +14 -1
  192. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
  193. package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-storage-contract.ts +165 -0
  194. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +15 -23
  195. package/dist/bundling-sources/shared_libs/play-runtime/workflow-executor-auth.ts +169 -0
  196. package/dist/bundling-sources/shared_libs/play-runtime/zerobounce-batching.ts +122 -0
  197. package/dist/bundling-sources/shared_libs/plays/artifact-types.ts +2 -0
  198. package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
  199. package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
  200. package/dist/bundling-sources/shared_libs/plays/contracts.ts +2 -0
  201. package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
  202. package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
  203. package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
  204. package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
  205. package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
  206. package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
  207. package/dist/bundling-sources/shared_libs/plays/deployment-status.ts +1 -1
  208. package/dist/bundling-sources/shared_libs/plays/docflow.ts +1 -1
  209. package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
  210. package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
  211. package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
  212. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +112 -0
  213. package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
  214. package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
  215. package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
  216. package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
  217. package/dist/bundling-sources/shared_libs/plays/share/copy.ts +271 -0
  218. package/dist/bundling-sources/shared_libs/plays/share/metrics.ts +140 -0
  219. package/dist/bundling-sources/shared_libs/plays/share/public-read-model.ts +86 -0
  220. package/dist/bundling-sources/shared_libs/plays/share/seo-quality.ts +181 -0
  221. package/dist/bundling-sources/shared_libs/plays/share/static-pipeline-summary.ts +658 -0
  222. package/dist/bundling-sources/shared_libs/plays/share/types.ts +197 -0
  223. package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
  224. package/dist/bundling-sources/shared_libs/plays/slack-hitl-timeout-error.ts +37 -0
  225. package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
  226. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +6 -1
  227. package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
  228. package/dist/bundling-sources/shared_libs/plays/tool-result-types.ts +1 -1
  229. package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
  230. package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
  231. package/dist/bundling-sources/shared_libs/product-notifications/configuration.ts +7 -1
  232. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +3 -2
  233. package/dist/bundling-sources/shared_libs/product-notifications/overflow.ts +18 -0
  234. package/dist/bundling-sources/shared_libs/product-notifications/rolling-limit.ts +24 -0
  235. package/dist/bundling-sources/shared_libs/product-notifications/slack-delivery.ts +624 -0
  236. package/dist/bundling-sources/shared_libs/product-notifications/slack-payload.ts +140 -0
  237. package/dist/bundling-sources/shared_libs/product-notifications/slack-transport.ts +75 -0
  238. package/dist/bundling-sources/shared_libs/runtime-env.ts +6 -0
  239. package/dist/bundling-sources/shared_libs/security/email-blacklist.ts +42 -0
  240. package/dist/bundling-sources/shared_libs/security/header-safe-env.ts +10 -3
  241. package/dist/bundling-sources/shared_libs/security/slack-sanitize.ts +133 -0
  242. package/dist/cli/getting-started.d.mts +52 -0
  243. package/dist/cli/getting-started.d.ts +52 -0
  244. package/dist/cli/getting-started.js +65 -0
  245. package/dist/cli/getting-started.mjs +36 -0
  246. package/dist/cli/index.d.mts +9 -0
  247. package/dist/cli/index.d.ts +9 -0
  248. package/dist/cli/index.js +895 -407
  249. package/dist/cli/index.mjs +893 -414
  250. package/dist/{compiler-manifest-DsLbQaew.d.mts → compiler-manifest-x14KL83E.d.mts} +4 -2
  251. package/dist/{compiler-manifest-DsLbQaew.d.ts → compiler-manifest-x14KL83E.d.ts} +4 -2
  252. package/dist/index.d.mts +124 -154
  253. package/dist/index.d.ts +124 -154
  254. package/dist/index.js +132 -28
  255. package/dist/index.mjs +132 -28
  256. package/dist/install-integrity.json +182 -2
  257. package/dist/monitor-fleet-contract.d.mts +131 -0
  258. package/dist/monitor-fleet-contract.d.ts +131 -0
  259. package/dist/monitor-fleet-contract.js +406 -0
  260. package/dist/monitor-fleet-contract.mjs +367 -0
  261. package/dist/plays/bundle-play-file.d.mts +2 -2
  262. package/dist/plays/bundle-play-file.d.ts +2 -2
  263. package/dist/release.d.mts +255 -0
  264. package/dist/release.d.ts +255 -0
  265. package/dist/release.js +210 -0
  266. package/dist/release.mjs +183 -0
  267. package/package.json +21 -4
@@ -0,0 +1,1844 @@
1
+ /**
2
+ * fly-absurd-lane — operate the Absurd runtime worker as immutable, per-release
3
+ * lanes on Fly Machines.
4
+ *
5
+ * A "lane" is one release of the Absurd worker fleet. Every release gets a
6
+ * stable slug `releaseId`, stamped into each Machine environment and
7
+ * `config.metadata.release_id`. The worker polls
8
+ * ONLY its own lane's queue (`runs_<releaseId>`; see the runtime lane contract). New work goes to the new lane; the old lane
9
+ * drains and self-exits. This is how we ship a breaking shared-plane change
10
+ * without co-mingling two runtime versions on one queue.
11
+ *
12
+ * The verbs:
13
+ * push-image build + push the lane image to the Fly registry (via flyctl).
14
+ * plan-image report whether a lane's registered image still exists, or must
15
+ * be rebuilt from its recorded revision before a rollback boots it.
16
+ * boot create K Machines for a release and wait until they pass checks.
17
+ * list show machines for a release (or all lanes).
18
+ * drain flip a lane's machines to DEEPLINE_ABSURD_DRAIN_EXIT=1 so they
19
+ * finish in-flight work then exit(0).
20
+ * retire delete a drained/stopped lane's machines (after the queue-empty
21
+ * admin gate, or --force).
22
+ *
23
+ * Fly Machines API mechanics (cited inline where non-obvious):
24
+ * https://fly.io/docs/machines/api/ — base https://api.machines.dev/v1
25
+ * https://fly.io/docs/machines/api/machines-resource/
26
+ * Dependency-free: `fetch` + `node:child_process` only. FLY_API_TOKEN is a
27
+ * secret and correctly lives in the environment.
28
+ */
29
+ import { spawnSync } from 'node:child_process';
30
+ import { appendFileSync } from 'node:fs';
31
+ import { RUNTIME_CAPACITY_POLICY } from '../runtime-capacity-policy';
32
+ import { ABSURD_RELEASE_ID_RE } from '../runtime-scheduler-topology';
33
+ import {
34
+ authenticateFlyRegistry,
35
+ runtimeImageManifestExists,
36
+ } from './fly-registry';
37
+
38
+ export const FLY_MACHINES_API_BASE = 'https://api.machines.dev/v1';
39
+ export const FLY_REGISTRY_HOST = 'registry.fly.io';
40
+
41
+ /** The one release-id shape. See `ABSURD_RELEASE_ID_RE` for why it is shared. */
42
+ export const RELEASE_ID_RE = ABSURD_RELEASE_ID_RE;
43
+ /** Git SHA used for the image label `git-<sha>`. Full or abbreviated hex. */
44
+ export const GIT_SHA_RE = /^[0-9a-f]{7,40}$/;
45
+
46
+ export const DEFAULT_WORKER_APP = 'deepline-absurd-worker-prod';
47
+ export const DOCKERFILE_ABSURD = 'deploy/fly/runtime-worker/Dockerfile.absurd';
48
+ export const ABSURD_WORKER_CONFIG =
49
+ 'deploy/fly/runtime-worker/absurd-worker.toml';
50
+
51
+ export const RELEASE_METADATA_KEY = 'release_id';
52
+ /** Content-addressed Vercel ↔ Fly runtime boundary, generated at build time. */
53
+ export const RUNTIME_SURFACE_METADATA_KEY = 'runtime_surface_sha256';
54
+ export const ROLLBACK_HOLD_UNTIL_METADATA_KEY = 'rollback_hold_until_ms';
55
+ export const RUNTIME_DEPLOY_VERSION_ENV = 'DEEPLINE_RUNTIME_DEPLOY_VERSION';
56
+ export const DRAIN_EXIT_ENV = 'DEEPLINE_ABSURD_DRAIN_EXIT';
57
+ // This is an image compatibility contract, not the current source location.
58
+ // Historical images only have this path; new images retain it as a thin entrypoint
59
+ // so both old and new scalers can boot, drain, or recover either generation.
60
+ export const ABSURD_RUNTIME_WORKER_EXEC = [
61
+ 'bun',
62
+ 'scripts/run-absurd-runtime-worker.ts',
63
+ '--slots',
64
+ String(RUNTIME_CAPACITY_POLICY.absurd.workerSlotsPerMachine),
65
+ ] as const;
66
+
67
+ /** Default health/boot poll ceilings. Loud timeout, never a silent give-up. */
68
+ export const DEFAULT_BOOT_TIMEOUT_MS = 300_000;
69
+ export const DEFAULT_BOOT_POLL_INTERVAL_MS = 5_000;
70
+ /**
71
+ * A Machines config update is accepted before a later list necessarily shows
72
+ * the new version. Drain is a retirement precondition, so callers must not
73
+ * race that propagation and mistake the old, still-serving config for a
74
+ * failed drain.
75
+ */
76
+ export const DEFAULT_DRAIN_CONVERGENCE_TIMEOUT_MS = 60_000;
77
+ export const DEFAULT_DRAIN_CONVERGENCE_POLL_INTERVAL_MS = 2_000;
78
+ /** Keep workers beside the receipt gateway and scaler in Northern Virginia. */
79
+ export const DEFAULT_ABSURD_FLY_REGION = 'iad';
80
+ export const DEFAULT_IMAGE_PROPAGATION_RETRY_DELAYS_MS = [
81
+ 1_000, 2_000, 4_000, 8_000, 16_000,
82
+ ] as const;
83
+ /**
84
+ * A rate-limited read is safe to repeat. Mutating requests intentionally do
85
+ * not use this policy: their caller must re-read exact provider state before
86
+ * deciding whether a timed-out effect needs recovery.
87
+ */
88
+ export const FLY_READ_RATE_LIMIT_RETRY_DELAYS_MS = [
89
+ 1_000, 2_000, 4_000,
90
+ ] as const;
91
+ /** A provider request may not hold the production release fence forever. */
92
+ export const FLY_MACHINES_REQUEST_TIMEOUT_MS = 30_000;
93
+
94
+ export type FetchLike = (
95
+ input: string,
96
+ init?: {
97
+ method?: string;
98
+ headers?: Record<string, string>;
99
+ body?: string;
100
+ signal?: AbortSignal;
101
+ },
102
+ ) => Promise<{
103
+ ok: boolean;
104
+ status: number;
105
+ text(): Promise<string>;
106
+ json(): Promise<unknown>;
107
+ headers?: { get(name: string): string | null };
108
+ }>;
109
+
110
+ /**
111
+ * Read a successful response body as JSON, keeping three outcomes distinct.
112
+ *
113
+ * - **Absent.** A body with no bytes (or only whitespace) is `null`. An
114
+ * endpoint with nothing to say is a legitimate answer, not a failure, and
115
+ * "this lane has no machines" must never surface as a parse exception.
116
+ * - **Parsed.** A body that parses is returned as-is.
117
+ * - **Malformed.** A body that has bytes and does not parse stays loud, but it
118
+ * names the request. `Response.json()` raises a bare `Failed to parse JSON`
119
+ * which identifies neither the endpoint, the status, nor the payload; a
120
+ * production release once failed on exactly that line with nothing to
121
+ * diagnose. Never soften this branch: a non-JSON answer from an endpoint
122
+ * whose contract is JSON (an HTML proxy interstitial, a truncated body) is a
123
+ * real defect and must not be read as "nothing here".
124
+ */
125
+ export async function readJsonResponseBody(input: {
126
+ response: { status: number; text(): Promise<string> };
127
+ method: string;
128
+ url: string;
129
+ }): Promise<unknown> {
130
+ const raw = await input.response.text();
131
+ if (raw.trim() === '') return null;
132
+ try {
133
+ return JSON.parse(raw) as unknown;
134
+ } catch (error) {
135
+ throw new Error(
136
+ `${input.method} ${input.url} returned HTTP ${input.response.status} with a ` +
137
+ `non-JSON body (${raw.length} bytes): ${raw.slice(0, 300)}` +
138
+ ` [${error instanceof Error ? error.message : String(error)}]`,
139
+ );
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Headers for a call to the release-owning application. `x-vercel-protection-bypass`
145
+ * is not optional decoration: a Vercel deployment URL under Deployment
146
+ * Protection answers an unbypassed request with an HTML challenge instead of
147
+ * the route, which is precisely a non-JSON body on a JSON contract.
148
+ */
149
+ export function adminRequestHeaders(input: {
150
+ adminToken: string;
151
+ protectionBypassToken?: string;
152
+ contentType?: boolean;
153
+ }): Record<string, string> {
154
+ return {
155
+ ...(input.contentType ? { 'content-type': 'application/json' } : {}),
156
+ // Send BOTH auth forms. The deployed internal-token route accepts
157
+ // `x-deepline-internal-token` and 401s a plain `Authorization: Bearer`;
158
+ // local and newer routes accept either. Header form is primary.
159
+ 'x-deepline-internal-token': input.adminToken,
160
+ authorization: `Bearer ${input.adminToken}`,
161
+ ...(input.protectionBypassToken
162
+ ? { 'x-vercel-protection-bypass': input.protectionBypassToken }
163
+ : {}),
164
+ };
165
+ }
166
+
167
+ export type FlyMachineCheck = {
168
+ name?: string;
169
+ status?: string; // 'passing' | 'warning' | 'critical'
170
+ };
171
+
172
+ export type FlyMachine = {
173
+ id: string;
174
+ name?: string;
175
+ state?: string; // 'started' | 'stopped' | 'created' | ...
176
+ /** Fly's machine cordon flag: a cordoned machine is excluded from request
177
+ * routing. roll-execution-relay drains by cordoning the old generation and
178
+ * compares this strictly (`!== false` / `!== true`), so absence reads as
179
+ * "unknown", never as either verdict. */
180
+ cordoned?: boolean;
181
+ /** Fly's immutable Machine creation timestamp. Used to distinguish a live
182
+ * pre-registration deploy candidate from an abandoned candidate. */
183
+ created_at?: string;
184
+ /** Provider lifecycle history. Gateway retirement uses the most recent
185
+ * routing event as an explicit legacy-drain fence. */
186
+ events?: Array<{ type?: string; timestamp?: number }>;
187
+ region?: string;
188
+ config?: {
189
+ image?: string;
190
+ metadata?: Record<string, string>;
191
+ env?: Record<string, string>;
192
+ init?: {
193
+ exec?: string[];
194
+ };
195
+ };
196
+ checks?: FlyMachineCheck[];
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Arg parsing (pure, unit-tested).
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export type ParsedArgs = {
204
+ command: string;
205
+ flags: Record<string, string | boolean>;
206
+ };
207
+
208
+ /**
209
+ * Minimal flag parser. `--flag value` and `--flag=value` set a string;
210
+ * `--flag` with no following value (or before another `--flag`) is a boolean.
211
+ * No positional args beyond the leading command.
212
+ */
213
+ export function parseArgs(argv: readonly string[]): ParsedArgs {
214
+ const command = argv[0] ?? '';
215
+ const flags: Record<string, string | boolean> = {};
216
+ for (let index = 1; index < argv.length; index += 1) {
217
+ const token = argv[index]!;
218
+ if (!token.startsWith('--')) {
219
+ throw new Error(`Unexpected positional argument: ${token}`);
220
+ }
221
+ const eq = token.indexOf('=');
222
+ if (eq !== -1) {
223
+ flags[token.slice(2, eq)] = token.slice(eq + 1);
224
+ continue;
225
+ }
226
+ const key = token.slice(2);
227
+ const next = argv[index + 1];
228
+ if (next === undefined || next.startsWith('--')) {
229
+ flags[key] = true;
230
+ } else {
231
+ flags[key] = next;
232
+ index += 1;
233
+ }
234
+ }
235
+ return { command, flags };
236
+ }
237
+
238
+ export function requireStringFlag(
239
+ flags: Record<string, string | boolean>,
240
+ name: string,
241
+ ): string {
242
+ const value = flags[name];
243
+ if (typeof value !== 'string' || value.trim() === '') {
244
+ throw new Error(`--${name} is required and must be a non-empty string.`);
245
+ }
246
+ return value.trim();
247
+ }
248
+
249
+ export function requireReleaseId(
250
+ flags: Record<string, string | boolean>,
251
+ ): string {
252
+ const releaseId = requireStringFlag(flags, 'release');
253
+ if (!RELEASE_ID_RE.test(releaseId)) {
254
+ throw new Error(
255
+ `--release "${releaseId}" is not a valid lane id (${RELEASE_ID_RE}). ` +
256
+ 'Use a stable lowercase slug without repeated or trailing hyphens; it becomes the queue suffix runs_<release>.',
257
+ );
258
+ }
259
+ if (
260
+ (releaseId === 'dev' || releaseId.startsWith('dev-')) &&
261
+ flags.environment !== 'preview'
262
+ ) {
263
+ throw new Error(
264
+ `--release "${releaseId}" uses the dev/dev-* namespace reserved for preview Absurd lanes; pass --environment preview only for an isolated Preview worker app.`,
265
+ );
266
+ }
267
+ return releaseId;
268
+ }
269
+
270
+ export function requireSha(flags: Record<string, string | boolean>): string {
271
+ const sha = requireStringFlag(flags, 'sha').toLowerCase();
272
+ if (!GIT_SHA_RE.test(sha)) {
273
+ throw new Error(`--sha "${sha}" is not a git sha (${GIT_SHA_RE}).`);
274
+ }
275
+ return sha;
276
+ }
277
+
278
+ export function requireCount(flags: Record<string, string | boolean>): number {
279
+ const raw = flags.count;
280
+ const count = typeof raw === 'string' ? Number(raw) : NaN;
281
+ if (!Number.isInteger(count) || count < 1 || count > 64) {
282
+ throw new Error('--count must be an integer between 1 and 64.');
283
+ }
284
+ return count;
285
+ }
286
+
287
+ export function resolveApp(flags: Record<string, string | boolean>): string {
288
+ const app = flags.app;
289
+ return typeof app === 'string' && app.trim()
290
+ ? app.trim()
291
+ : DEFAULT_WORKER_APP;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Request shaping (pure, unit-tested).
296
+ // ---------------------------------------------------------------------------
297
+
298
+ export function laneImageRef(app: string, sha: string): string {
299
+ return `${FLY_REGISTRY_HOST}/${app}:git-${sha}`;
300
+ }
301
+
302
+ export function laneQueueName(releaseId: string): string {
303
+ return `runs_${releaseId}`;
304
+ }
305
+
306
+ /**
307
+ * Machine create config for a lane worker.
308
+ * https://fly.io/docs/machines/api/machines-resource/#create-a-machine
309
+ * - `metadata.release_id` is the lane filter used by `list`/`drain`/`retire`.
310
+ * - `env.DEEPLINE_RUNTIME_DEPLOY_VERSION` is the worker's runtime identity; it
311
+ * also derives the polled queue (runs_<releaseId>), so this per-Machine env
312
+ * is the only release identity and makes a source image safely reusable.
313
+ * - restart.policy = 'on-failure': a crash-looped machine is retried, not left
314
+ * silently stopped. (Prod steady-state config uses 'always' in
315
+ * absurd-worker.toml; per-lane boots want on-failure so a permanently broken
316
+ * image surfaces as a failed boot rather than an infinite restart.)
317
+ */
318
+ export function buildBootMachineConfig(input: {
319
+ app: string;
320
+ releaseId: string;
321
+ sha: string;
322
+ runtimeSurfaceHash?: string;
323
+ region?: string;
324
+ rollbackHoldUntilMs?: number;
325
+ }): Record<string, unknown> {
326
+ return {
327
+ region: input.region ?? DEFAULT_ABSURD_FLY_REGION,
328
+ config: {
329
+ image: laneImageRef(input.app, input.sha),
330
+ metadata: {
331
+ [RELEASE_METADATA_KEY]: input.releaseId,
332
+ ...(input.runtimeSurfaceHash
333
+ ? { [RUNTIME_SURFACE_METADATA_KEY]: input.runtimeSurfaceHash }
334
+ : {}),
335
+ ...(input.rollbackHoldUntilMs
336
+ ? {
337
+ [ROLLBACK_HOLD_UNTIL_METADATA_KEY]: String(
338
+ input.rollbackHoldUntilMs,
339
+ ),
340
+ }
341
+ : {}),
342
+ },
343
+ env: {
344
+ [RUNTIME_DEPLOY_VERSION_ENV]: input.releaseId,
345
+ },
346
+ // Override the image CMD so rollback and recovery of an older image still
347
+ // honor today's fleet-wide slot ceiling. Every historical Absurd worker
348
+ // supports --slots even when its baked default predates this policy.
349
+ init: {
350
+ exec: [...ABSURD_RUNTIME_WORKER_EXEC],
351
+ },
352
+ restart: {
353
+ policy: 'on-failure',
354
+ max_retries: 3,
355
+ },
356
+ checks: {
357
+ worker_alive: {
358
+ type: 'http',
359
+ port: 8787,
360
+ path: '/healthz',
361
+ interval: '15s',
362
+ timeout: '5s',
363
+ grace_period: '30s',
364
+ },
365
+ },
366
+ guest: {
367
+ cpu_kind: 'shared',
368
+ cpus: 1,
369
+ memory_mb: 1024,
370
+ },
371
+ },
372
+ };
373
+ }
374
+
375
+ /**
376
+ * A machine belongs to a lane iff its metadata.release_id matches. The Machines
377
+ * list endpoint has no server-side metadata filter, so we list-then-filter
378
+ * (https://fly.io/docs/machines/api/machines-resource/#list-machines).
379
+ */
380
+ export function machineIsInLane(
381
+ machine: FlyMachine,
382
+ releaseId: string,
383
+ ): boolean {
384
+ return machine.config?.metadata?.[RELEASE_METADATA_KEY] === releaseId;
385
+ }
386
+
387
+ export function machineChecksPassing(machine: FlyMachine): boolean {
388
+ const checks = machine.checks ?? [];
389
+ if (checks.length === 0) return false;
390
+ return checks.every((check) => check.status === 'passing');
391
+ }
392
+
393
+ export function machineIsStarted(machine: FlyMachine): boolean {
394
+ return machine.state === 'started';
395
+ }
396
+
397
+ /**
398
+ * Boot health: a machine is healthy iff it is `started` AND — only if it declares
399
+ * checks — all of them pass. Machines created through the Machines API do NOT
400
+ * inherit the app's `[checks]` block, so a healthy lane machine often reports zero
401
+ * checks; requiring non-empty passing checks (`machineChecksPassing`) would poll to
402
+ * the boot timeout even for a fully-healthy machine. An empty checks array with a
403
+ * started state therefore counts as healthy. Injecting a checks block into the
404
+ * create body was rejected: it couples this script to the worker's healthz shape.
405
+ */
406
+ export function machineIsHealthyForBoot(machine: FlyMachine): boolean {
407
+ if (!machineIsStarted(machine)) return false;
408
+ const checks = machine.checks ?? [];
409
+ if (checks.length === 0) return true;
410
+ return checks.every((check) => check.status === 'passing');
411
+ }
412
+
413
+ /**
414
+ * A lane is safe to retire when every one of its machines is either already
415
+ * stopped or explicitly draining (DRAIN_EXIT env set). A `started` machine
416
+ * without the drain flag means live traffic — refuse to delete it.
417
+ */
418
+ export function machineIsRetirable(machine: FlyMachine): boolean {
419
+ if (machine.state === 'stopped' || machine.state === 'destroyed') return true;
420
+ return machine.config?.env?.[DRAIN_EXIT_ENV] === '1';
421
+ }
422
+
423
+ /**
424
+ * In-place env update. The Fly Machines update endpoint REPLACES the machine
425
+ * config and creates a new machine *version* in place (same id) — it does NOT
426
+ * require destroy+recreate. We therefore read the machine, merge the drain env
427
+ * into its existing config, and PUT the whole config back.
428
+ * https://fly.io/docs/machines/api/machines-resource/#update-a-machine
429
+ */
430
+ export function buildDrainUpdateConfig(machine: FlyMachine): {
431
+ config: Record<string, unknown>;
432
+ skip_launch?: boolean;
433
+ } {
434
+ const capacityUpdate = buildWorkerSlotCapUpdateConfig(machine);
435
+ return {
436
+ ...capacityUpdate,
437
+ config: {
438
+ ...capacityUpdate.config,
439
+ env: {
440
+ ...(machine.config?.env ?? {}),
441
+ [DRAIN_EXIT_ENV]: '1',
442
+ },
443
+ },
444
+ };
445
+ }
446
+
447
+ export function buildRollbackHoldUpdateConfig(
448
+ machine: FlyMachine,
449
+ rollbackHoldUntilMs: number,
450
+ ): { config: Record<string, unknown>; skip_launch?: boolean } {
451
+ const capacityUpdate = buildWorkerSlotCapUpdateConfig(machine);
452
+ return {
453
+ ...capacityUpdate,
454
+ config: {
455
+ ...capacityUpdate.config,
456
+ // Rollback reactivates this lane. Remove drain mode on every reused
457
+ // Machine so the fixed-capacity pair is the only serving fleet.
458
+ env: Object.fromEntries(
459
+ Object.entries(machine.config?.env ?? {}).filter(
460
+ ([key]) => key !== DRAIN_EXIT_ENV,
461
+ ),
462
+ ),
463
+ metadata: {
464
+ ...(machine.config?.metadata ?? {}),
465
+ [ROLLBACK_HOLD_UNTIL_METADATA_KEY]: String(rollbackHoldUntilMs),
466
+ },
467
+ },
468
+ };
469
+ }
470
+
471
+ export function buildWorkerSlotCapUpdateConfig(machine: FlyMachine): {
472
+ config: Record<string, unknown>;
473
+ skip_launch?: boolean;
474
+ } {
475
+ const existing = machine.config ?? {};
476
+ return {
477
+ // Fly launches a new Machine version after update by default. Preserve a
478
+ // stopped machine's state so its caller can issue exactly one explicit start.
479
+ ...(machine.state === 'stopped' ? { skip_launch: true } : {}),
480
+ config: {
481
+ ...existing,
482
+ // Historical images support this CLI override even when their baked
483
+ // default predates the fixed capacity policy.
484
+ init: {
485
+ ...(existing.init ?? {}),
486
+ exec: [...ABSURD_RUNTIME_WORKER_EXEC],
487
+ },
488
+ },
489
+ };
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Fly Machines API client (thin; injectable fetch for tests).
494
+ // ---------------------------------------------------------------------------
495
+
496
+ export function requireFlyToken(
497
+ env: Readonly<Record<string, string | undefined>> = process.env,
498
+ ): string {
499
+ const token = env.FLY_API_TOKEN?.trim();
500
+ if (!token) {
501
+ throw new Error('FLY_API_TOKEN is required for the Fly Machines API.');
502
+ }
503
+ return token;
504
+ }
505
+
506
+ export class FlyMachinesClient {
507
+ constructor(
508
+ private readonly app: string,
509
+ private readonly token: string,
510
+ private readonly fetchImpl: FetchLike,
511
+ private readonly rateLimitOptions: Readonly<{
512
+ wait?: (ms: number) => Promise<void>;
513
+ retryDelaysMs?: readonly number[];
514
+ requestTimeoutMs?: number;
515
+ }> = {},
516
+ ) {}
517
+
518
+ private async request(
519
+ method: string,
520
+ path: string,
521
+ body?: unknown,
522
+ ): Promise<unknown> {
523
+ const retryDelaysMs =
524
+ this.rateLimitOptions.retryDelaysMs ??
525
+ FLY_READ_RATE_LIMIT_RETRY_DELAYS_MS;
526
+ const wait = this.rateLimitOptions.wait ?? sleep;
527
+ for (let attempt = 0; ; attempt += 1) {
528
+ const requestTimeoutMs =
529
+ this.rateLimitOptions.requestTimeoutMs ??
530
+ FLY_MACHINES_REQUEST_TIMEOUT_MS;
531
+ const signal = AbortSignal.timeout(requestTimeoutMs);
532
+ let response: Awaited<ReturnType<FetchLike>>;
533
+ try {
534
+ response = await this.fetchImpl(
535
+ `${FLY_MACHINES_API_BASE}/apps/${this.app}${path}`,
536
+ {
537
+ method,
538
+ headers: {
539
+ authorization: `Bearer ${this.token}`,
540
+ 'content-type': 'application/json',
541
+ },
542
+ body: body === undefined ? undefined : JSON.stringify(body),
543
+ signal,
544
+ },
545
+ );
546
+ } catch (error) {
547
+ if (signal.aborted) {
548
+ throw new Error(
549
+ `Fly Machines API ${method} ${path} timed out after ${requestTimeoutMs}ms.`,
550
+ { cause: error },
551
+ );
552
+ }
553
+ throw error;
554
+ }
555
+ if (
556
+ method === 'GET' &&
557
+ response.status === 429 &&
558
+ attempt < retryDelaysMs.length
559
+ ) {
560
+ const retryAfter = response.headers?.get('retry-after');
561
+ const retryAfterSeconds =
562
+ typeof retryAfter === 'string' && retryAfter.trim() !== ''
563
+ ? Number(retryAfter)
564
+ : Number.NaN;
565
+ const delayMs = Number.isFinite(retryAfterSeconds)
566
+ ? Math.max(0, retryAfterSeconds * 1_000)
567
+ : retryDelaysMs[attempt]!;
568
+ console.info(
569
+ `[fly-absurd-lane] Fly Machines API GET ${path} was rate limited; retrying in ${delayMs}ms ` +
570
+ `(attempt ${attempt + 2}/${retryDelaysMs.length + 1}).`,
571
+ );
572
+ await wait(delayMs);
573
+ continue;
574
+ }
575
+ if (!response.ok && method === 'DELETE' && response.status === 404) {
576
+ return null;
577
+ }
578
+ if (!response.ok) {
579
+ const detail = await response.text().catch(() => '');
580
+ throw new Error(
581
+ `Fly Machines API ${method} ${path} failed: ${response.status} ${detail}`,
582
+ );
583
+ }
584
+ if (response.status === 204) return null;
585
+ return await readJsonResponseBody({
586
+ response,
587
+ method,
588
+ url: `${FLY_MACHINES_API_BASE}/apps/${this.app}${path}`,
589
+ });
590
+ }
591
+ }
592
+
593
+ async listMachines(): Promise<FlyMachine[]> {
594
+ const result = await this.request('GET', '/machines');
595
+ // An absent body is the Machines API saying "no machines"; that is a
596
+ // successful empty inventory. Any other non-array shape is a contract
597
+ // break and must stay loud rather than masquerade as an empty app, which
598
+ // would let a caller conclude a lane is already gone and skip its work.
599
+ if (result === null || result === undefined) return [];
600
+ if (!Array.isArray(result)) {
601
+ throw new Error(
602
+ `Fly Machines API GET /apps/${this.app}/machines returned ${typeof result}, expected an array of machines.`,
603
+ );
604
+ }
605
+ return result as FlyMachine[];
606
+ }
607
+
608
+ async getMachine(id: string): Promise<FlyMachine> {
609
+ return (await this.request('GET', `/machines/${id}`)) as FlyMachine;
610
+ }
611
+
612
+ async createMachine(config: Record<string, unknown>): Promise<FlyMachine> {
613
+ return (await this.request('POST', '/machines', config)) as FlyMachine;
614
+ }
615
+
616
+ async updateMachine(
617
+ id: string,
618
+ config: Record<string, unknown>,
619
+ ): Promise<FlyMachine> {
620
+ return (await this.request(
621
+ 'POST',
622
+ `/machines/${id}`,
623
+ config,
624
+ )) as FlyMachine;
625
+ }
626
+
627
+ /**
628
+ * Metadata has its own Machines API endpoint. Unlike a config update, this
629
+ * does not create a new Machine version, so it is safe for durable operation
630
+ * fences on a cordoned candidate.
631
+ * https://fly.io/docs/machines/api/machines-resource/#add-or-update-machine-metadata
632
+ */
633
+ async setMachineMetadata(
634
+ id: string,
635
+ key: string,
636
+ value: string,
637
+ ): Promise<void> {
638
+ await this.request(
639
+ 'POST',
640
+ `/machines/${id}/metadata/${encodeURIComponent(key)}`,
641
+ { value },
642
+ );
643
+ }
644
+
645
+ async deleteMachine(id: string, force: boolean): Promise<void> {
646
+ await this.request(
647
+ 'DELETE',
648
+ `/machines/${id}?force=${force ? 'true' : 'false'}`,
649
+ );
650
+ }
651
+
652
+ async startMachine(id: string): Promise<void> {
653
+ await this.request('POST', `/machines/${id}/start`);
654
+ }
655
+
656
+ /**
657
+ * Remove a Machine from Fly Proxy admission without stopping accepted work.
658
+ * Runtime tuple recovery uses this exact provider transition; a config
659
+ * rewrite would create a new Machine version and lose the retained source
660
+ * identity needed to undo a cutover.
661
+ */
662
+ async cordonMachine(id: string): Promise<void> {
663
+ await this.request('POST', `/machines/${id}/cordon`);
664
+ }
665
+
666
+ /** Restore a retained Machine to Fly Proxy admission. */
667
+ async uncordonMachine(id: string): Promise<void> {
668
+ await this.request('POST', `/machines/${id}/uncordon`);
669
+ }
670
+
671
+ async stopMachine(
672
+ id: string,
673
+ options?: { signal?: string; timeout?: string },
674
+ ): Promise<void> {
675
+ await this.request('POST', `/machines/${id}/stop`, options);
676
+ }
677
+
678
+ async machinesInLane(releaseId: string): Promise<FlyMachine[]> {
679
+ const machines = await this.listMachines();
680
+ return machines.filter((machine) => machineIsInLane(machine, releaseId));
681
+ }
682
+ }
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // Verb implementations.
686
+ // ---------------------------------------------------------------------------
687
+
688
+ function runFlyctl(args: readonly string[]): void {
689
+ const result = spawnSync('flyctl', args as string[], {
690
+ stdio: 'inherit',
691
+ env: process.env,
692
+ });
693
+ if (result.error) {
694
+ throw new Error(`flyctl failed to spawn: ${result.error.message}`);
695
+ }
696
+ if (result.status !== 0) {
697
+ throw new Error(
698
+ `flyctl ${args.join(' ')} exited with status ${result.status ?? 'null'}.`,
699
+ );
700
+ }
701
+ }
702
+
703
+ function runBun(args: readonly string[]): void {
704
+ const result = spawnSync('bun', args as string[], {
705
+ stdio: 'inherit',
706
+ env: process.env,
707
+ });
708
+ if (result.error) {
709
+ throw new Error(`bun failed to spawn: ${result.error.message}`);
710
+ }
711
+ if (result.status !== 0) {
712
+ throw new Error(
713
+ `bun ${args.join(' ')} exited with status ${result.status ?? 'null'}.`,
714
+ );
715
+ }
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // Rollback lane image resolution (pure, unit-tested).
720
+ // ---------------------------------------------------------------------------
721
+
722
+ /** The image tag a production lane carries is always the full commit. */
723
+ export const FULL_GIT_SHA_RE = /^[0-9a-f]{40}$/;
724
+ /** Production release ids are `main-<full sha>` (see the deploy job's env). */
725
+ export const MAIN_RELEASE_ID_SHA_RE = /^main-([0-9a-f]{40})$/;
726
+
727
+ export type RollbackLaneImageResolution = {
728
+ /** The immutable revision the lane image must be built from. */
729
+ sha: string;
730
+ /** The immutable tag rollback boots from. */
731
+ imageRef: string;
732
+ /** Exactly what the registry row recorded, or null when it recorded none. */
733
+ registeredImageRef: string | null;
734
+ };
735
+
736
+ /**
737
+ * Resolve the immutable image a rollback must boot, from the two independent
738
+ * identities the release carries: the registry row's `imageRef` and the release
739
+ * id itself. Both are checked, and a disagreement is an error rather than a
740
+ * silent preference — booting the wrong revision under a release id is exactly
741
+ * the failure the immutable-tag contract exists to prevent (ADR 0017).
742
+ */
743
+ export function resolveRollbackLaneImage(input: {
744
+ app: string;
745
+ releaseId: string;
746
+ registeredImageRef?: string | null;
747
+ }): RollbackLaneImageResolution {
748
+ const prefix = `${FLY_REGISTRY_HOST}/${input.app}:git-`;
749
+ const registeredImageRef = input.registeredImageRef?.trim() || null;
750
+ let imageSha: string | null = null;
751
+ if (registeredImageRef) {
752
+ if (!registeredImageRef.startsWith(prefix)) {
753
+ throw new Error(
754
+ `Registered image ${registeredImageRef} is outside ${prefix}<sha>.`,
755
+ );
756
+ }
757
+ const candidate = registeredImageRef.slice(prefix.length).toLowerCase();
758
+ if (!GIT_SHA_RE.test(candidate)) {
759
+ throw new Error(
760
+ `Registered image ${registeredImageRef} does not carry a git sha tag.`,
761
+ );
762
+ }
763
+ imageSha = candidate;
764
+ }
765
+ const releaseSha =
766
+ MAIN_RELEASE_ID_SHA_RE.exec(input.releaseId)?.[1]?.toLowerCase() ?? null;
767
+ if (
768
+ imageSha !== null &&
769
+ releaseSha !== null &&
770
+ !imageSha.startsWith(releaseSha) &&
771
+ !releaseSha.startsWith(imageSha)
772
+ ) {
773
+ throw new Error(
774
+ `Registered image sha ${imageSha} disagrees with release id ${input.releaseId}. ` +
775
+ 'Refusing to guess which revision this lane ran.',
776
+ );
777
+ }
778
+ const sha = [imageSha, releaseSha].find(
779
+ (candidate): candidate is string =>
780
+ candidate !== null && FULL_GIT_SHA_RE.test(candidate),
781
+ );
782
+ if (!sha) {
783
+ throw new Error(
784
+ `Cannot determine a 40-hex revision for release ${input.releaseId} ` +
785
+ `(registry image: ${registeredImageRef ?? 'none'}). ` +
786
+ 'A lane image can only be rebuilt from its exact recorded revision.',
787
+ );
788
+ }
789
+ return { sha, imageRef: laneImageRef(input.app, sha), registeredImageRef };
790
+ }
791
+
792
+ export type RollbackLaneImagePlan = RollbackLaneImageResolution & {
793
+ action: 'reuse' | 'rebuild';
794
+ };
795
+
796
+ /**
797
+ * Decide whether a rollback must rebuild the lane image. lane handoff retires the
798
+ * prior lane within minutes of a release, and a retired lane's image can have
799
+ * been reaped from the registry; an absent tag is a rebuild, never a refusal.
800
+ */
801
+ export function planRollbackLaneImage(input: {
802
+ app: string;
803
+ releaseId: string;
804
+ registeredImageRef?: string | null;
805
+ manifestPresent: boolean;
806
+ }): RollbackLaneImagePlan {
807
+ const resolution = resolveRollbackLaneImage(input);
808
+ return {
809
+ ...resolution,
810
+ action: input.manifestPresent ? 'reuse' : 'rebuild',
811
+ };
812
+ }
813
+
814
+ /**
815
+ * Legacy argument shape retained for CLI compatibility. New callers route
816
+ * through ensure-runtime-image.ts, which content-addresses source inputs
817
+ * before giving the release its immutable `git-<sha>` tag.
818
+ *
819
+ * `--config <absurd-worker.toml>` is required alongside `--dockerfile`: without it,
820
+ * flyctl resolves the dockerfile path relative to the wrong root ("dockerfile not
821
+ * found") and its config-verification step fails on a machine-less app ("could not
822
+ * create a fly.toml from any machines"). Passing the lane's app config fixes both.
823
+ */
824
+ export function pushImageArgs(input: {
825
+ app: string;
826
+ sha: string;
827
+ releaseId: string;
828
+ }): string[] {
829
+ return [
830
+ 'deploy',
831
+ '--app',
832
+ input.app,
833
+ '--config',
834
+ ABSURD_WORKER_CONFIG,
835
+ '--build-only',
836
+ '--push',
837
+ '--image-label',
838
+ `git-${input.sha}`,
839
+ '--dockerfile',
840
+ DOCKERFILE_ABSURD,
841
+ '--remote-only',
842
+ ];
843
+ }
844
+
845
+ async function cmdPushImage(
846
+ flags: Record<string, string | boolean>,
847
+ ): Promise<void> {
848
+ const app = resolveApp(flags);
849
+ const sha = requireSha(flags);
850
+ const releaseId = requireReleaseId(flags);
851
+ console.info(
852
+ `[fly-absurd-lane] push-image app=${app} sha=${sha} release=${releaseId}`,
853
+ );
854
+ runBun([
855
+ 'scripts/ensure-runtime-image.ts',
856
+ '--kind',
857
+ 'worker',
858
+ '--app',
859
+ app,
860
+ '--config',
861
+ ABSURD_WORKER_CONFIG,
862
+ '--dockerfile',
863
+ DOCKERFILE_ABSURD,
864
+ '--sha',
865
+ sha,
866
+ ]);
867
+ console.info(`[fly-absurd-lane] pushed ${laneImageRef(app, sha)}`);
868
+ }
869
+
870
+ /**
871
+ * Report whether a rollback can boot the lane's registered image as-is, or
872
+ * whether that tag is gone and must be rebuilt from the recorded revision.
873
+ * Probing goes through the deploy path's own registry probe so the two cannot
874
+ * disagree. The rebuild itself is not done here: it must run from a checkout of
875
+ * the recorded revision, which the caller owns.
876
+ */
877
+ async function cmdPlanImage(
878
+ flags: Record<string, string | boolean>,
879
+ ): Promise<void> {
880
+ const app = resolveApp(flags);
881
+ const releaseId = requireReleaseId(flags);
882
+ const registeredImageRef =
883
+ typeof flags['image-ref'] === 'string' ? flags['image-ref'] : null;
884
+ const resolution = resolveRollbackLaneImage({
885
+ app,
886
+ releaseId,
887
+ registeredImageRef,
888
+ });
889
+ authenticateFlyRegistry();
890
+ const plan = planRollbackLaneImage({
891
+ app,
892
+ releaseId,
893
+ registeredImageRef,
894
+ manifestPresent: runtimeImageManifestExists(resolution.imageRef),
895
+ });
896
+ console.info(
897
+ `[fly-absurd-lane] plan-image release=${releaseId} image=${plan.imageRef} ` +
898
+ `sha=${plan.sha} action=${plan.action}`,
899
+ );
900
+ if (plan.action === 'rebuild') {
901
+ console.info(
902
+ `[fly-absurd-lane] ${plan.imageRef} is absent from the registry; ` +
903
+ `rebuild it from ${plan.sha} before booting this lane.`,
904
+ );
905
+ }
906
+ const githubOutput = process.env.GITHUB_OUTPUT?.trim();
907
+ if (githubOutput) {
908
+ appendFileSync(
909
+ githubOutput,
910
+ `action=${plan.action}\nsha=${plan.sha}\nimage=${plan.imageRef}\n`,
911
+ );
912
+ }
913
+ }
914
+
915
+ const sleep = (ms: number): Promise<void> =>
916
+ new Promise((resolve) => setTimeout(resolve, ms));
917
+
918
+ function isImageManifestPropagationError(error: unknown): boolean {
919
+ if (!(error instanceof Error)) return false;
920
+ return (
921
+ error.message.includes('failed to get manifest') &&
922
+ error.message.includes('MANIFEST_UNKNOWN')
923
+ );
924
+ }
925
+
926
+ async function createMachineAfterImagePropagation(input: {
927
+ client: FlyMachinesClient;
928
+ config: Record<string, unknown>;
929
+ wait: (ms: number) => Promise<void>;
930
+ retryDelaysMs: readonly number[];
931
+ }): Promise<FlyMachine> {
932
+ for (let attempt = 0; ; attempt += 1) {
933
+ try {
934
+ return await input.client.createMachine(input.config);
935
+ } catch (error) {
936
+ const retryDelayMs = input.retryDelaysMs[attempt];
937
+ if (
938
+ retryDelayMs === undefined ||
939
+ !isImageManifestPropagationError(error)
940
+ ) {
941
+ throw error;
942
+ }
943
+ console.info(
944
+ `[fly-absurd-lane] image manifest has not propagated; retrying Machine create in ${retryDelayMs}ms ` +
945
+ `(attempt ${attempt + 2}/${input.retryDelaysMs.length + 1})`,
946
+ );
947
+ await input.wait(retryDelayMs);
948
+ }
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Boot K machines for a lane, then poll until they are all healthy
954
+ * (`machineIsHealthyForBoot`: started, plus all checks passing only when the
955
+ * machine declares checks). Loud timeout: if the wait ceiling lapses before every
956
+ * machine is healthy, throw with the last observed states so CI fails with a real
957
+ * diagnostic instead of a silent partial boot.
958
+ */
959
+ export async function bootLane(input: {
960
+ client: FlyMachinesClient;
961
+ app: string;
962
+ releaseId: string;
963
+ sha: string;
964
+ runtimeSurfaceHash?: string;
965
+ count: number;
966
+ region?: string;
967
+ timeoutMs?: number;
968
+ pollIntervalMs?: number;
969
+ imagePropagationRetryDelaysMs?: readonly number[];
970
+ now?: () => number;
971
+ wait?: (ms: number) => Promise<void>;
972
+ reuseExisting?: boolean;
973
+ rollbackHoldUntilMs?: number;
974
+ rollbackHoldScalerQuiescenceMs?: number;
975
+ }): Promise<FlyMachine[]> {
976
+ const now = input.now ?? (() => Date.now());
977
+ const wait = input.wait ?? sleep;
978
+ const timeoutMs = input.timeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
979
+ const pollIntervalMs = input.pollIntervalMs ?? DEFAULT_BOOT_POLL_INTERVAL_MS;
980
+ const imagePropagationRetryDelaysMs =
981
+ input.imagePropagationRetryDelaysMs ??
982
+ DEFAULT_IMAGE_PROPAGATION_RETRY_DELAYS_MS;
983
+
984
+ const expectedImage = laneImageRef(input.app, input.sha);
985
+ const reusableStates = new Set([
986
+ 'started',
987
+ 'created',
988
+ 'starting',
989
+ 'replacing',
990
+ 'restarting',
991
+ 'stopped',
992
+ ]);
993
+ const statePriority = (machine: FlyMachine): number => {
994
+ if (machineIsHealthyForBoot(machine)) return 0;
995
+ if (machine.state === 'started') return 1;
996
+ if (machine.state === 'stopped') return 3;
997
+ return 2;
998
+ };
999
+ const reused = input.reuseExisting
1000
+ ? (await input.client.machinesInLane(input.releaseId))
1001
+ .filter(
1002
+ (machine) =>
1003
+ machine.config?.image === expectedImage &&
1004
+ (!input.runtimeSurfaceHash ||
1005
+ machine.config?.metadata?.[RUNTIME_SURFACE_METADATA_KEY] ===
1006
+ input.runtimeSurfaceHash) &&
1007
+ (input.rollbackHoldUntilMs !== undefined ||
1008
+ machine.config.env?.[DRAIN_EXIT_ENV] !== '1') &&
1009
+ reusableStates.has(machine.state ?? ''),
1010
+ )
1011
+ .sort((left, right) => statePriority(left) - statePriority(right))
1012
+ .slice(0, input.count)
1013
+ : [];
1014
+ if (input.rollbackHoldUntilMs) {
1015
+ for (const machine of reused) {
1016
+ const current = await input.client.getMachine(machine.id);
1017
+ await input.client.updateMachine(
1018
+ machine.id,
1019
+ buildRollbackHoldUpdateConfig(current, input.rollbackHoldUntilMs),
1020
+ );
1021
+ }
1022
+ // A scaler cycle may have planned stops from a pre-hold snapshot. Let one
1023
+ // full scaler interval quiesce, then re-read and restart from current state.
1024
+ await wait(input.rollbackHoldScalerQuiescenceMs ?? 16_000);
1025
+ }
1026
+ for (const machine of reused) {
1027
+ const current = input.rollbackHoldUntilMs
1028
+ ? await input.client.getMachine(machine.id)
1029
+ : machine;
1030
+ if (current.state === 'stopped') {
1031
+ await input.client.startMachine(machine.id);
1032
+ console.info(
1033
+ `[fly-absurd-lane] restarted machine ${machine.id} for release ${input.releaseId}`,
1034
+ );
1035
+ }
1036
+ }
1037
+ const createCount = input.count - reused.length;
1038
+ const created: FlyMachine[] = [];
1039
+ for (let index = 0; index < createCount; index += 1) {
1040
+ const machine = await createMachineAfterImagePropagation({
1041
+ client: input.client,
1042
+ config: buildBootMachineConfig({
1043
+ app: input.app,
1044
+ releaseId: input.releaseId,
1045
+ sha: input.sha,
1046
+ runtimeSurfaceHash: input.runtimeSurfaceHash,
1047
+ region: input.region,
1048
+ rollbackHoldUntilMs: input.rollbackHoldUntilMs,
1049
+ }),
1050
+ wait,
1051
+ retryDelaysMs: imagePropagationRetryDelaysMs,
1052
+ });
1053
+ console.info(
1054
+ `[fly-absurd-lane] created machine ${machine.id} (${index + 1}/${createCount}) for release ${input.releaseId}`,
1055
+ );
1056
+ created.push(machine);
1057
+ }
1058
+
1059
+ const deadline = now() + timeoutMs;
1060
+ const ids = new Set([...reused, ...created].map((machine) => machine.id));
1061
+ for (;;) {
1062
+ const laneMachines = (
1063
+ await input.client.machinesInLane(input.releaseId)
1064
+ ).filter((machine) => ids.has(machine.id));
1065
+ const healthy = laneMachines.filter(machineIsHealthyForBoot);
1066
+ if (healthy.length >= input.count) {
1067
+ const checkless = healthy.filter(
1068
+ (machine) => (machine.checks ?? []).length === 0,
1069
+ );
1070
+ if (checkless.length > 0) {
1071
+ console.info(
1072
+ `[fly-absurd-lane] release ${input.releaseId}: ${checkless.length}/${healthy.length} healthy machines had no machine-level checks ` +
1073
+ `(Machines-API creates do not inherit the app [checks]); treating started state as healthy.`,
1074
+ );
1075
+ }
1076
+ console.info(
1077
+ `[fly-absurd-lane] release ${input.releaseId}: ${healthy.length}/${input.count} machines started + healthy`,
1078
+ );
1079
+ return laneMachines;
1080
+ }
1081
+ if (now() >= deadline) {
1082
+ const states = laneMachines
1083
+ .map((machine) => `${machine.id}=${machine.state ?? 'unknown'}`)
1084
+ .join(', ');
1085
+ throw new Error(
1086
+ `Lane ${input.releaseId} did not reach ${input.count} healthy machines within ` +
1087
+ `${timeoutMs}ms (last: ${states || 'none'}).`,
1088
+ );
1089
+ }
1090
+ console.info(
1091
+ `[fly-absurd-lane] waiting for lane ${input.releaseId}: healthy=${healthy.length}/${input.count}`,
1092
+ );
1093
+ await wait(pollIntervalMs);
1094
+ }
1095
+ }
1096
+
1097
+ async function cmdBoot(flags: Record<string, string | boolean>): Promise<void> {
1098
+ const app = resolveApp(flags);
1099
+ const releaseId = requireReleaseId(flags);
1100
+ const sha = requireSha(flags);
1101
+ const count = requireCount(flags);
1102
+ const region = typeof flags.region === 'string' ? flags.region : undefined;
1103
+ const reuseExisting = flags['reuse-existing'] === true;
1104
+ const runtimeSurfaceHash =
1105
+ typeof flags['runtime-surface-hash'] === 'string'
1106
+ ? flags['runtime-surface-hash']
1107
+ : undefined;
1108
+ if (runtimeSurfaceHash && !/^[a-f0-9]{64}$/.test(runtimeSurfaceHash)) {
1109
+ throw new Error(
1110
+ '--runtime-surface-hash must be a lowercase SHA-256 hex digest.',
1111
+ );
1112
+ }
1113
+ const rawRollbackHoldUntilMs = flags['rollback-hold-until-ms'];
1114
+ const rollbackHoldUntilMs =
1115
+ typeof rawRollbackHoldUntilMs === 'string'
1116
+ ? Number(rawRollbackHoldUntilMs)
1117
+ : undefined;
1118
+ if (
1119
+ rollbackHoldUntilMs !== undefined &&
1120
+ (!Number.isSafeInteger(rollbackHoldUntilMs) ||
1121
+ rollbackHoldUntilMs <= Date.now())
1122
+ ) {
1123
+ throw new Error(
1124
+ '--rollback-hold-until-ms must be a future Unix timestamp in milliseconds.',
1125
+ );
1126
+ }
1127
+ const client = new FlyMachinesClient(
1128
+ app,
1129
+ requireFlyToken(),
1130
+ globalThis.fetch as FetchLike,
1131
+ );
1132
+ await bootLane({
1133
+ client,
1134
+ app,
1135
+ releaseId,
1136
+ sha,
1137
+ runtimeSurfaceHash,
1138
+ count,
1139
+ region,
1140
+ reuseExisting,
1141
+ rollbackHoldUntilMs,
1142
+ });
1143
+ }
1144
+
1145
+ export function formatMachineTable(machines: readonly FlyMachine[]): string {
1146
+ if (machines.length === 0) return '(no machines)';
1147
+ const header = ['ID', 'RELEASE', 'STATE', 'REGION', 'CHECKS', 'DRAIN'];
1148
+ const rows = machines.map((machine) => [
1149
+ machine.id,
1150
+ machine.config?.metadata?.[RELEASE_METADATA_KEY] ?? '-',
1151
+ machine.state ?? '-',
1152
+ machine.region ?? '-',
1153
+ machineChecksPassing(machine)
1154
+ ? 'passing'
1155
+ : (machine.checks ?? []).length === 0
1156
+ ? 'none'
1157
+ : 'not-passing',
1158
+ machine.config?.env?.[DRAIN_EXIT_ENV] === '1' ? 'yes' : 'no',
1159
+ ]);
1160
+ const widths = header.map((_, column) =>
1161
+ Math.max(header[column]!.length, ...rows.map((row) => row[column]!.length)),
1162
+ );
1163
+ const line = (cells: string[]) =>
1164
+ cells.map((cell, column) => cell.padEnd(widths[column]!)).join(' ');
1165
+ return [line(header), ...rows.map(line)].join('\n');
1166
+ }
1167
+
1168
+ async function cmdList(flags: Record<string, string | boolean>): Promise<void> {
1169
+ const app = resolveApp(flags);
1170
+ const client = new FlyMachinesClient(
1171
+ app,
1172
+ requireFlyToken(),
1173
+ globalThis.fetch as FetchLike,
1174
+ );
1175
+ const release = typeof flags.release === 'string' ? flags.release : null;
1176
+ const machines = release
1177
+ ? await client.machinesInLane(release)
1178
+ : await client.listMachines();
1179
+ if (flags.json === true) {
1180
+ console.log(JSON.stringify(machines, null, 2));
1181
+ } else {
1182
+ console.log(formatMachineTable(machines));
1183
+ }
1184
+ }
1185
+
1186
+ export async function drainLane(input: {
1187
+ client: FlyMachinesClient;
1188
+ releaseId: string;
1189
+ ifPresent?: boolean;
1190
+ /** Test seams; production callers use the bounded defaults above. */
1191
+ timeoutMs?: number;
1192
+ pollIntervalMs?: number;
1193
+ now?: () => number;
1194
+ wait?: (ms: number) => Promise<void>;
1195
+ }): Promise<FlyMachine[]> {
1196
+ const now = input.now ?? (() => Date.now());
1197
+ const wait = input.wait ?? sleep;
1198
+ const timeoutMs = input.timeoutMs ?? DEFAULT_DRAIN_CONVERGENCE_TIMEOUT_MS;
1199
+ const pollIntervalMs =
1200
+ input.pollIntervalMs ?? DEFAULT_DRAIN_CONVERGENCE_POLL_INTERVAL_MS;
1201
+ const machines = await input.client.machinesInLane(input.releaseId);
1202
+ if (machines.length === 0) {
1203
+ if (input.ifPresent) {
1204
+ console.info(
1205
+ `[fly-absurd-lane] no machines found for lane ${input.releaseId}; ` +
1206
+ '--if-present treats this as a no-op.',
1207
+ );
1208
+ return [];
1209
+ }
1210
+ throw new Error(
1211
+ `No machines found for lane ${input.releaseId}; nothing to drain.`,
1212
+ );
1213
+ }
1214
+ for (const machine of machines) {
1215
+ const current = await input.client.getMachine(machine.id);
1216
+ await input.client.updateMachine(
1217
+ machine.id,
1218
+ buildDrainUpdateConfig(current),
1219
+ );
1220
+ console.info(
1221
+ `[fly-absurd-lane] drained machine ${machine.id} (release ${input.releaseId}); ` +
1222
+ `${DRAIN_EXIT_ENV}=1 set, worker exits after its quiet window.`,
1223
+ );
1224
+ }
1225
+
1226
+ // Fly accepts a Machine update before a subsequent lane list is guaranteed
1227
+ // to show the replacement config. The production retire verb makes its
1228
+ // safety decision from that list, so accepting the update response alone
1229
+ // leaves a real drain -> retire race. Wait only for the observable
1230
+ // precondition (the worker may remain started while it finishes its quiet
1231
+ // window); never wait for a parked durable run to wake.
1232
+ const deadline = now() + timeoutMs;
1233
+ let lastObserved = 'no post-drain Machine listing read';
1234
+ for (;;) {
1235
+ const observed = await input.client.machinesInLane(input.releaseId);
1236
+ const live = observed.filter((machine) => !machineIsRetirable(machine));
1237
+ lastObserved =
1238
+ observed
1239
+ .map(
1240
+ (machine) =>
1241
+ `${machine.id}=${machine.state ?? 'unknown'}:${machine.config?.env?.[DRAIN_EXIT_ENV] === '1' ? 'draining' : 'not-draining'}`,
1242
+ )
1243
+ .join(', ') || 'no Machines';
1244
+ if (live.length === 0) return observed;
1245
+ if (now() >= deadline) {
1246
+ throw new Error(
1247
+ `Drain of lane ${input.releaseId} was accepted but did not converge within ${timeoutMs}ms. ` +
1248
+ `Still active: ${live.map((machine) => machine.id).join(', ')}. Observed: ${lastObserved}.`,
1249
+ );
1250
+ }
1251
+ await wait(pollIntervalMs);
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * Remove the exact Machines created for a release that failed before its
1257
+ * runtime pointer could move. This is deliberately not the normal `retire`
1258
+ * path: a pre-pointer candidate was never admitted, so it has no queue to
1259
+ * drain and no registry row to retire. The release controller is the only
1260
+ * caller; it invokes this exclusively before its first public effect.
1261
+ */
1262
+ export async function discardPreCommitCandidateLane(input: {
1263
+ client: Pick<FlyMachinesClient, 'machinesInLane' | 'deleteMachine'>;
1264
+ releaseId: string;
1265
+ runtimeSurfaceHash: string;
1266
+ }): Promise<FlyMachine[]> {
1267
+ const machines = await input.client.machinesInLane(input.releaseId);
1268
+ const unexpected = machines.filter(
1269
+ (machine) =>
1270
+ machine.config?.metadata?.[RELEASE_METADATA_KEY] !== input.releaseId ||
1271
+ machine.config?.metadata?.[RUNTIME_SURFACE_METADATA_KEY] !==
1272
+ input.runtimeSurfaceHash,
1273
+ );
1274
+ if (unexpected.length > 0) {
1275
+ throw new Error(
1276
+ `Refuse to discard ${unexpected.length} candidate Machine(s) for release ${input.releaseId}: release or runtime-surface metadata does not match.`,
1277
+ );
1278
+ }
1279
+ for (const machine of machines) {
1280
+ await input.client.deleteMachine(machine.id, true);
1281
+ console.info(
1282
+ `[fly-absurd-lane] discarded pre-commit candidate machine ${machine.id} (release ${input.releaseId}).`,
1283
+ );
1284
+ }
1285
+ return machines;
1286
+ }
1287
+
1288
+ async function cmdDrain(
1289
+ flags: Record<string, string | boolean>,
1290
+ ): Promise<void> {
1291
+ const app = resolveApp(flags);
1292
+ const releaseId = requireReleaseId(flags);
1293
+ const client = new FlyMachinesClient(
1294
+ app,
1295
+ requireFlyToken(),
1296
+ globalThis.fetch as FetchLike,
1297
+ );
1298
+ await drainLane({
1299
+ client,
1300
+ releaseId,
1301
+ ifPresent: flags['if-present'] === true,
1302
+ });
1303
+ }
1304
+
1305
+ /**
1306
+ * Queue-emptiness gate. The authoritative "is this lane's queue drained?" check
1307
+ * is an app-side admin endpoint owned by another agent (it can read the
1308
+ * runs_<releaseId> queue depth). If DEEPLINE_ABSURD_ADMIN_URL + token are set we
1309
+ * call it; otherwise we refuse to destroy unless --force is passed, printing the
1310
+ * manual check. Loud, never a silent skip.
1311
+ */
1312
+ export type QueueGateResult =
1313
+ | {
1314
+ ok: true;
1315
+ source: 'admin-endpoint';
1316
+ queued: number;
1317
+ basis: 'empty' | 'adopted-parked';
1318
+ }
1319
+ | { ok: false; source: 'admin-endpoint'; queued: number }
1320
+ | { ok: false; source: 'no-endpoint' };
1321
+
1322
+ export async function checkLaneQueueEmpty(input: {
1323
+ releaseId: string;
1324
+ fetchImpl: FetchLike;
1325
+ adminUrl?: string;
1326
+ adminToken?: string;
1327
+ protectionBypassToken?: string;
1328
+ }): Promise<QueueGateResult> {
1329
+ if (!input.adminUrl || !input.adminToken) {
1330
+ return { ok: false, source: 'no-endpoint' };
1331
+ }
1332
+ const url = `${input.adminUrl.replace(/\/+$/, '')}/api/v2/internal/runtime/lane-queue-depth?release=${encodeURIComponent(input.releaseId)}`;
1333
+ const response = await input.fetchImpl(url, {
1334
+ method: 'GET',
1335
+ headers: adminRequestHeaders({
1336
+ adminToken: input.adminToken,
1337
+ protectionBypassToken: input.protectionBypassToken,
1338
+ }),
1339
+ });
1340
+ if (!response.ok) {
1341
+ throw new Error(
1342
+ `Lane queue-depth admin check failed: ${response.status} ${await response.text().catch(() => '')}`,
1343
+ );
1344
+ }
1345
+ const body = ((await readJsonResponseBody({
1346
+ response,
1347
+ method: 'GET',
1348
+ url,
1349
+ })) ?? {}) as {
1350
+ queued?: number;
1351
+ claiming?: unknown;
1352
+ emptyExceptAdoptableParked?: unknown;
1353
+ adoptableParkedRuns?: unknown;
1354
+ };
1355
+ const queued = typeof body.queued === 'number' ? body.queued : Number.NaN;
1356
+ if (!Number.isFinite(queued)) {
1357
+ throw new Error(
1358
+ 'Lane queue-depth admin response did not include a numeric queued count.',
1359
+ );
1360
+ }
1361
+ if (queued === 0) {
1362
+ return { ok: true, source: 'admin-endpoint', queued, basis: 'empty' };
1363
+ }
1364
+ // A lane that has stopped claiming retires without waiting for its parked
1365
+ // durable waits: the newest lane adopts those wakes (lane handoff). Retirement
1366
+ // deletes machines and flips the registry row; the queue tables the adopting
1367
+ // lane claims from are dropped later, by the scaler, behind its own checks.
1368
+ if (
1369
+ body.claiming === false &&
1370
+ body.emptyExceptAdoptableParked === true &&
1371
+ typeof body.adoptableParkedRuns === 'number' &&
1372
+ body.adoptableParkedRuns > 0
1373
+ ) {
1374
+ console.info(
1375
+ `[fly-absurd-lane] lane ${input.releaseId} retires with ` +
1376
+ `${body.adoptableParkedRuns} parked durable wait(s) adopted by the ` +
1377
+ `newest lane; nothing on this lane is claimable or running.`,
1378
+ );
1379
+ return {
1380
+ ok: true,
1381
+ source: 'admin-endpoint',
1382
+ queued,
1383
+ basis: 'adopted-parked',
1384
+ };
1385
+ }
1386
+ return { ok: false, source: 'admin-endpoint', queued };
1387
+ }
1388
+
1389
+ /**
1390
+ * Flip a registered lane's claiming switch (lane handoff).
1391
+ *
1392
+ * `stop-claiming` is the release controller's demotion step: from that write
1393
+ * the newest lane may adopt this lane's parked durable waits, so drain and
1394
+ * retirement no longer wait for a sleeping run to wake. `resume-claiming`
1395
+ * re-arms a lane that is coming back (rollback, aborted retirement).
1396
+ */
1397
+ export async function setLaneClaiming(input: {
1398
+ releaseId: string;
1399
+ claiming: boolean;
1400
+ environment?: 'preview' | 'production';
1401
+ fetchImpl: FetchLike;
1402
+ adminUrl?: string;
1403
+ adminToken?: string;
1404
+ protectionBypassToken?: string;
1405
+ }): Promise<void> {
1406
+ if (!input.adminUrl || !input.adminToken) {
1407
+ throw new Error(
1408
+ `Cannot change claiming for lane ${input.releaseId}: no admin endpoint ` +
1409
+ '(set DEEPLINE_ABSURD_ADMIN_URL + DEEPLINE_INTERNAL_TOKEN).',
1410
+ );
1411
+ }
1412
+ const url = `${input.adminUrl.replace(/\/+$/, '')}/api/v2/internal/runtime/releases`;
1413
+ const response = await input.fetchImpl(url, {
1414
+ method: 'POST',
1415
+ headers: adminRequestHeaders({
1416
+ adminToken: input.adminToken,
1417
+ protectionBypassToken: input.protectionBypassToken,
1418
+ contentType: true,
1419
+ }),
1420
+ body: JSON.stringify({
1421
+ action: input.claiming ? 'resume_claiming' : 'stop_claiming',
1422
+ environment: input.environment ?? 'production',
1423
+ schedulerBackend: 'absurd',
1424
+ releaseId: input.releaseId,
1425
+ }),
1426
+ });
1427
+ if (!response.ok) {
1428
+ throw new Error(
1429
+ `Runtime release ${input.claiming ? 'resume_claiming' : 'stop_claiming'} failed: ` +
1430
+ `${response.status} ${await response.text().catch(() => '')}`,
1431
+ );
1432
+ }
1433
+ console.info(
1434
+ `[fly-absurd-lane] lane ${input.releaseId} claiming=${input.claiming}.`,
1435
+ );
1436
+ }
1437
+
1438
+ async function transitionRuntimeReleaseRetirement(input: {
1439
+ releaseId: string;
1440
+ environment?: 'preview' | 'production';
1441
+ fetchImpl: FetchLike;
1442
+ adminUrl?: string;
1443
+ adminToken?: string;
1444
+ protectionBypassToken?: string;
1445
+ action: 'begin_retire' | 'abort_retire' | 'commit_retire' | 'retire';
1446
+ }): Promise<{ retiring: boolean; deleting: boolean; retired: boolean }> {
1447
+ if (!input.adminUrl || !input.adminToken) {
1448
+ throw new Error(
1449
+ `Cannot retire registry release ${input.releaseId}: no admin endpoint ` +
1450
+ '(set DEEPLINE_ABSURD_ADMIN_URL + DEEPLINE_INTERNAL_TOKEN).',
1451
+ );
1452
+ }
1453
+ const url = `${input.adminUrl.replace(/\/+$/, '')}/api/v2/internal/runtime/releases`;
1454
+ const response = await input.fetchImpl(url, {
1455
+ method: 'POST',
1456
+ headers: adminRequestHeaders({
1457
+ adminToken: input.adminToken,
1458
+ protectionBypassToken: input.protectionBypassToken,
1459
+ contentType: true,
1460
+ }),
1461
+ body: JSON.stringify({
1462
+ action: input.action,
1463
+ environment: input.environment ?? 'production',
1464
+ schedulerBackend: 'absurd',
1465
+ releaseId: input.releaseId,
1466
+ }),
1467
+ });
1468
+ if (!response.ok) {
1469
+ throw new Error(
1470
+ `Runtime release registry ${input.action} failed: ${response.status} ${await response.text().catch(() => '')}`,
1471
+ );
1472
+ }
1473
+ const body = ((await readJsonResponseBody({
1474
+ response,
1475
+ method: 'POST',
1476
+ url,
1477
+ })) ?? {}) as {
1478
+ retiring?: unknown;
1479
+ deleting?: unknown;
1480
+ retired?: unknown;
1481
+ };
1482
+ return {
1483
+ retiring: body.retiring === true,
1484
+ deleting: body.deleting === true,
1485
+ retired: body.retired === true,
1486
+ };
1487
+ }
1488
+
1489
+ /**
1490
+ * `registered: false` means the registry authoritatively has no row for this
1491
+ * release. That is a different answer from "registered, status unreadable",
1492
+ * and retirement branches on the difference: only the registry can own a lane's
1493
+ * retirement, so a lane it has never heard of is not a lane this verb retires.
1494
+ */
1495
+ export type RuntimeReleaseRegistryStatus = Readonly<{
1496
+ registered: boolean;
1497
+ status: string | null;
1498
+ }>;
1499
+
1500
+ async function readRuntimeReleaseRetirementStatus(input: {
1501
+ releaseId: string;
1502
+ environment?: 'preview' | 'production';
1503
+ fetchImpl: FetchLike;
1504
+ adminUrl: string;
1505
+ adminToken: string;
1506
+ protectionBypassToken?: string;
1507
+ }): Promise<RuntimeReleaseRegistryStatus> {
1508
+ const url = new URL(
1509
+ '/api/v2/internal/runtime/releases',
1510
+ input.adminUrl.replace(/\/+$/, '') + '/',
1511
+ );
1512
+ url.searchParams.set('environment', input.environment ?? 'production');
1513
+ url.searchParams.set('schedulerBackend', 'absurd');
1514
+ url.searchParams.set('releaseId', input.releaseId);
1515
+ const response = await input.fetchImpl(url.toString(), {
1516
+ method: 'GET',
1517
+ headers: adminRequestHeaders({
1518
+ adminToken: input.adminToken,
1519
+ protectionBypassToken: input.protectionBypassToken,
1520
+ }),
1521
+ });
1522
+ if (!response.ok) {
1523
+ throw new Error(
1524
+ `Runtime release registry status failed: ${response.status} ${await response.text().catch(() => '')}`,
1525
+ );
1526
+ }
1527
+ const body = ((await readJsonResponseBody({
1528
+ response,
1529
+ method: 'GET',
1530
+ url: url.toString(),
1531
+ })) ?? {}) as {
1532
+ release?: { status?: unknown } | null;
1533
+ };
1534
+ const release = body.release ?? null;
1535
+ return {
1536
+ registered: release !== null && typeof release === 'object',
1537
+ status: typeof release?.status === 'string' ? release.status : null,
1538
+ };
1539
+ }
1540
+
1541
+ export async function beginRuntimeReleaseRetirement(input: {
1542
+ releaseId: string;
1543
+ environment?: 'preview' | 'production';
1544
+ fetchImpl: FetchLike;
1545
+ adminUrl?: string;
1546
+ adminToken?: string;
1547
+ protectionBypassToken?: string;
1548
+ }): Promise<{ retiring: boolean; deleting: boolean; retired: boolean }> {
1549
+ return await transitionRuntimeReleaseRetirement({
1550
+ ...input,
1551
+ action: 'begin_retire',
1552
+ });
1553
+ }
1554
+
1555
+ export async function abortRuntimeReleaseRetirement(input: {
1556
+ releaseId: string;
1557
+ environment?: 'preview' | 'production';
1558
+ fetchImpl: FetchLike;
1559
+ adminUrl?: string;
1560
+ adminToken?: string;
1561
+ protectionBypassToken?: string;
1562
+ }): Promise<void> {
1563
+ await transitionRuntimeReleaseRetirement({
1564
+ ...input,
1565
+ action: 'abort_retire',
1566
+ });
1567
+ }
1568
+
1569
+ export async function commitRuntimeReleaseRetirement(input: {
1570
+ releaseId: string;
1571
+ environment?: 'preview' | 'production';
1572
+ fetchImpl: FetchLike;
1573
+ adminUrl?: string;
1574
+ adminToken?: string;
1575
+ protectionBypassToken?: string;
1576
+ }): Promise<{ retiring: boolean; deleting: boolean; retired: boolean }> {
1577
+ return await transitionRuntimeReleaseRetirement({
1578
+ ...input,
1579
+ action: 'commit_retire',
1580
+ });
1581
+ }
1582
+
1583
+ export async function markRuntimeReleaseRetired(input: {
1584
+ releaseId: string;
1585
+ environment?: 'preview' | 'production';
1586
+ fetchImpl: FetchLike;
1587
+ adminUrl?: string;
1588
+ adminToken?: string;
1589
+ protectionBypassToken?: string;
1590
+ }): Promise<void> {
1591
+ await transitionRuntimeReleaseRetirement({ ...input, action: 'retire' });
1592
+ }
1593
+
1594
+ export async function retireLane(input: {
1595
+ client: FlyMachinesClient;
1596
+ releaseId: string;
1597
+ force: boolean;
1598
+ fetchImpl: FetchLike;
1599
+ adminUrl?: string;
1600
+ adminToken?: string;
1601
+ protectionBypassToken?: string;
1602
+ scalerQuiescenceMs?: number;
1603
+ }): Promise<FlyMachine[]> {
1604
+ const machines = await input.client.machinesInLane(input.releaseId);
1605
+ if (machines.length === 0) {
1606
+ console.info(
1607
+ `[fly-absurd-lane] lane ${input.releaseId} has no machines; finalizing registry retirement.`,
1608
+ );
1609
+ }
1610
+
1611
+ if (!input.adminUrl || !input.adminToken) {
1612
+ // Preserve the useful local/operator diagnostic when registry state cannot
1613
+ // be queried. With an endpoint present, the durable deleting/retired
1614
+ // tombstone is consulted first below and overrides this ordinary gate.
1615
+ const live = machines.filter((machine) => !machineIsRetirable(machine));
1616
+ if (live.length > 0 && !input.force) {
1617
+ const ids = live.map((machine) => machine.id).join(', ');
1618
+ throw new Error(
1619
+ `Lane ${input.releaseId} still has active (non-drained, non-stopped) machines: ${ids}. ` +
1620
+ `Drain the lane first, or pass --force.`,
1621
+ );
1622
+ }
1623
+ throw new Error(
1624
+ `Cannot retire lane ${input.releaseId}: no admin endpoint ` +
1625
+ '(set DEEPLINE_ABSURD_ADMIN_URL + DEEPLINE_INTERNAL_TOKEN).',
1626
+ );
1627
+ }
1628
+
1629
+ const registry = await readRuntimeReleaseRetirementStatus({
1630
+ releaseId: input.releaseId,
1631
+ fetchImpl: input.fetchImpl,
1632
+ adminUrl: input.adminUrl,
1633
+ adminToken: input.adminToken,
1634
+ protectionBypassToken: input.protectionBypassToken,
1635
+ });
1636
+ if (!registry.registered) {
1637
+ // The registry is the only owner of a lane's retirement lifecycle. With no
1638
+ // row and no Machines there is nothing left to retire: an operator (or an
1639
+ // earlier attempt of this same command) already reclaimed it, and saying so
1640
+ // is the correct, idempotent answer rather than a failure.
1641
+ if (machines.length === 0) {
1642
+ console.info(
1643
+ `[fly-absurd-lane] lane ${input.releaseId} has no machines and no runtime-release ` +
1644
+ 'registry row; nothing to retire.',
1645
+ );
1646
+ return [];
1647
+ }
1648
+ // Machines with no registry row were never admitted, so `retire` is the
1649
+ // wrong verb for them: its gates read registry state this lane does not
1650
+ // have. The release-aware scaler owns unregistered Machines.
1651
+ throw new Error(
1652
+ `Lane ${input.releaseId} has ${machines.length} machine(s) but no runtime-release ` +
1653
+ 'registry row, so it was never admitted and cannot be retired. The release-aware ' +
1654
+ 'scaler reclaims unregistered Machines after its registration grace.',
1655
+ );
1656
+ }
1657
+ const cleanupOnly =
1658
+ registry.status === 'deleting' || registry.status === 'retired';
1659
+ if (!cleanupOnly) {
1660
+ // Ordinary preconditions must not mutate the registry. A failed drain-first
1661
+ // check leaves rollback and admission untouched.
1662
+ const live = machines.filter((machine) => !machineIsRetirable(machine));
1663
+ if (live.length > 0 && !input.force) {
1664
+ const ids = live.map((machine) => machine.id).join(', ');
1665
+ throw new Error(
1666
+ `Lane ${input.releaseId} still has active (non-drained, non-stopped) machines: ${ids}. ` +
1667
+ `Drain the lane first, or pass --force.`,
1668
+ );
1669
+ }
1670
+ }
1671
+
1672
+ const retirementInput = {
1673
+ releaseId: input.releaseId,
1674
+ fetchImpl: input.fetchImpl,
1675
+ adminUrl: input.adminUrl,
1676
+ adminToken: input.adminToken,
1677
+ protectionBypassToken: input.protectionBypassToken,
1678
+ };
1679
+ const ensureEmptyOrReopen = async () => {
1680
+ const gate = await checkLaneQueueEmpty(retirementInput);
1681
+ if (gate.source === 'no-endpoint') {
1682
+ throw new Error(
1683
+ `Cannot verify lane ${input.releaseId} queue is empty: no admin endpoint ` +
1684
+ `(set DEEPLINE_ABSURD_ADMIN_URL + DEEPLINE_INTERNAL_TOKEN). Manually confirm ` +
1685
+ `${laneQueueName(input.releaseId)} is empty.`,
1686
+ );
1687
+ }
1688
+ if (!gate.ok) {
1689
+ // This is safe before begin (repairs a prior partial abort) and after
1690
+ // begin (reopens descendants when a run raced the first empty check).
1691
+ await abortRuntimeReleaseRetirement(retirementInput);
1692
+ throw new Error(
1693
+ `Lane ${input.releaseId} queue ${laneQueueName(input.releaseId)} is not empty ` +
1694
+ `(${gate.queued} queued). Retirement was aborted and admission reopened.`,
1695
+ );
1696
+ }
1697
+ return gate;
1698
+ };
1699
+ if (!cleanupOnly) await ensureEmptyOrReopen();
1700
+
1701
+ const retirement = await beginRuntimeReleaseRetirement(retirementInput);
1702
+ if (!cleanupOnly && !retirement.deleting && !retirement.retired) {
1703
+ // Recheck after the admission fence. Any child that linearized before the
1704
+ // fence is now visible; aborting advances the epoch and reopens the lane.
1705
+ await ensureEmptyOrReopen();
1706
+ console.info(
1707
+ `[fly-absurd-lane] lane ${input.releaseId} queue empty (admin-verified).`,
1708
+ );
1709
+ await commitRuntimeReleaseRetirement(retirementInput);
1710
+ } else {
1711
+ console.info(
1712
+ `[fly-absurd-lane] registry release ${input.releaseId} is already ${retirement.retired ? 'retired' : 'deleting'}; cleaning remaining machines.`,
1713
+ );
1714
+ }
1715
+
1716
+ const destroyed: FlyMachine[] = [];
1717
+ const destroyedIds = new Set<string>();
1718
+ const deleteCurrentMachines = async () => {
1719
+ const current = await input.client.machinesInLane(input.releaseId);
1720
+ for (const machine of current) {
1721
+ await input.client.deleteMachine(machine.id, true);
1722
+ console.info(
1723
+ `[fly-absurd-lane] destroyed machine ${machine.id} (release ${input.releaseId}).`,
1724
+ );
1725
+ if (!destroyedIds.has(machine.id)) {
1726
+ destroyedIds.add(machine.id);
1727
+ destroyed.push(machine);
1728
+ }
1729
+ }
1730
+ };
1731
+ await deleteCurrentMachines();
1732
+ const quiescenceMs = input.scalerQuiescenceMs ?? 16_000;
1733
+ if (quiescenceMs > 0) {
1734
+ await new Promise((resolve) => setTimeout(resolve, quiescenceMs));
1735
+ }
1736
+ await deleteCurrentMachines();
1737
+ if (!retirement.retired) {
1738
+ await markRuntimeReleaseRetired(retirementInput);
1739
+ console.info(
1740
+ `[fly-absurd-lane] registry release ${input.releaseId} marked retired.`,
1741
+ );
1742
+ }
1743
+ return destroyed;
1744
+ }
1745
+
1746
+ async function cmdRetire(
1747
+ flags: Record<string, string | boolean>,
1748
+ ): Promise<void> {
1749
+ const app = resolveApp(flags);
1750
+ const releaseId = requireReleaseId(flags);
1751
+ const force = flags.force === true;
1752
+ const client = new FlyMachinesClient(
1753
+ app,
1754
+ requireFlyToken(),
1755
+ globalThis.fetch as FetchLike,
1756
+ );
1757
+ await retireLane({
1758
+ client,
1759
+ releaseId,
1760
+ force,
1761
+ fetchImpl: globalThis.fetch as FetchLike,
1762
+ adminUrl: process.env.DEEPLINE_ABSURD_ADMIN_URL?.trim() || undefined,
1763
+ adminToken: process.env.DEEPLINE_INTERNAL_TOKEN?.trim() || undefined,
1764
+ protectionBypassToken:
1765
+ process.env.VERCEL_PROTECTION_BYPASS_TOKEN?.trim() || undefined,
1766
+ });
1767
+ }
1768
+
1769
+ async function cmdSetClaiming(
1770
+ flags: Record<string, string | boolean>,
1771
+ claiming: boolean,
1772
+ ): Promise<void> {
1773
+ const releaseId = requireReleaseId(flags);
1774
+ await setLaneClaiming({
1775
+ releaseId,
1776
+ claiming,
1777
+ environment:
1778
+ typeof flags.environment === 'string' && flags.environment === 'preview'
1779
+ ? 'preview'
1780
+ : 'production',
1781
+ fetchImpl: globalThis.fetch as FetchLike,
1782
+ adminUrl: process.env.DEEPLINE_ABSURD_ADMIN_URL?.trim() || undefined,
1783
+ adminToken: process.env.DEEPLINE_INTERNAL_TOKEN?.trim() || undefined,
1784
+ protectionBypassToken:
1785
+ process.env.VERCEL_PROTECTION_BYPASS_TOKEN?.trim() || undefined,
1786
+ });
1787
+ }
1788
+
1789
+ const USAGE = `fly-absurd-lane <command> [flags]
1790
+
1791
+ Commands:
1792
+ push-image --sha <sha> --release <id> [--app <app>]
1793
+ plan-image --release <id> [--image-ref <ref>] [--app <app>]
1794
+ boot --release <id> --sha <sha> --count <K> [--reuse-existing] [--rollback-hold-until-ms <ms>] [--app <app>] [--region <r>]
1795
+ list [--release <id>] [--json] [--app <app>]
1796
+ drain --release <id> [--if-present] [--app <app>]
1797
+ stop-claiming --release <id> [--environment production|preview]
1798
+ resume-claiming --release <id> [--environment production|preview]
1799
+ retire --release <id> [--force] [--app <app>]
1800
+
1801
+ Env:
1802
+ FLY_API_TOKEN (required) Fly Machines API token.
1803
+ DEEPLINE_ABSURD_ADMIN_URL (retire, *-claiming) app base url.
1804
+ DEEPLINE_INTERNAL_TOKEN (retire, *-claiming) bearer token.
1805
+ VERCEL_PROTECTION_BYPASS_TOKEN
1806
+ (optional) required when DEEPLINE_ABSURD_ADMIN_URL
1807
+ is an immutable Vercel deployment URL behind
1808
+ Deployment Protection, which otherwise answers with
1809
+ an HTML challenge instead of the JSON route.
1810
+
1811
+ stop-claiming hands this lane's parked durable waits to the newest lane, so
1812
+ drain and retire no longer wait for a sleeping run to wake (lane handoff). It is
1813
+ refused for the active lane. resume-claiming re-arms a lane that is coming back.
1814
+ `;
1815
+
1816
+ export async function main(argv: readonly string[]): Promise<void> {
1817
+ const { command, flags } = parseArgs(argv);
1818
+ switch (command) {
1819
+ case 'push-image':
1820
+ return cmdPushImage(flags);
1821
+ case 'plan-image':
1822
+ return cmdPlanImage(flags);
1823
+ case 'boot':
1824
+ return cmdBoot(flags);
1825
+ case 'list':
1826
+ return cmdList(flags);
1827
+ case 'drain':
1828
+ return cmdDrain(flags);
1829
+ case 'stop-claiming':
1830
+ return cmdSetClaiming(flags, false);
1831
+ case 'resume-claiming':
1832
+ return cmdSetClaiming(flags, true);
1833
+ case 'retire':
1834
+ return cmdRetire(flags);
1835
+ case '':
1836
+ case 'help':
1837
+ case '--help':
1838
+ case '-h':
1839
+ console.log(USAGE);
1840
+ return;
1841
+ default:
1842
+ throw new Error(`Unknown command: ${command}\n\n${USAGE}`);
1843
+ }
1844
+ }