@repo-os/repoos 0.5.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- package/package.json +91 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-reload for `repoos serve` (task 0066).
|
|
3
|
+
*
|
|
4
|
+
* A long-lived serve process loads compiled JS from dist/ at startup and never
|
|
5
|
+
* reloads, so after a task merges to main and `bun run build` writes a new
|
|
6
|
+
* dist/, the running server keeps serving the OLD build — the live API/UI
|
|
7
|
+
* silently diverge from main until a human restarts. This manager closes that
|
|
8
|
+
* gap:
|
|
9
|
+
*
|
|
10
|
+
* - On boot it captures the build hash from dist/.build-info.json. If a newer
|
|
11
|
+
* build already landed on disk, it reloads immediately (stale-boot
|
|
12
|
+
* self-heal).
|
|
13
|
+
* - It watches dist/.build-info.json (fs.watch on the dist dir, plus a
|
|
14
|
+
* low-frequency hash poll as a platform-proof fallback). A hash CHANGE
|
|
15
|
+
* schedules a reload — the same hash mechanism the CLI staleness guard uses,
|
|
16
|
+
* never mtimes or timers.
|
|
17
|
+
* - A reload no longer defers for agent turns (0214): agent child stdout/stderr
|
|
18
|
+
* is durable (per-task log file in .repoos/agent-logs/), so the replacement
|
|
19
|
+
* server re-attaches to still-running children via the durable registry
|
|
20
|
+
* (.repoos/agents.json). A restart proceeds immediately regardless of how
|
|
21
|
+
* many agent turns are in flight.
|
|
22
|
+
* - A close-out (0143) is a harder deferral: the pipeline itself runs builds
|
|
23
|
+
* and checks, so reloading under it would kill the server mid-close-out.
|
|
24
|
+
* While the close-out lock is held, a new build is PARKED instead — the UI
|
|
25
|
+
* is told a new version is available and the reload fires only when the
|
|
26
|
+
* user asks for it (POST /api/server/restart). A reload already in flight
|
|
27
|
+
* when a close-out begins is aborted at handover so the pipeline is never
|
|
28
|
+
* killed.
|
|
29
|
+
* - Reload = spawn a replacement `repoos serve` on the same host/port
|
|
30
|
+
* (REPOOS_RELOAD=1 + a per-reload handshake secret), confirm it is up by
|
|
31
|
+
* polling /api/health for the handshake, then exit cleanly. The replacement
|
|
32
|
+
* retries its bind while the parent holds the port; the parent releases its
|
|
33
|
+
* listener (a brief graceful drain) so the replacement can bind, then exits
|
|
34
|
+
* only after the replacement is confirmed ready. If the replacement never
|
|
35
|
+
* becomes ready the parent re-binds and keeps serving — no outage.
|
|
36
|
+
* SO_REUSEPORT is attempted by the replacement for a zero-downtime handoff
|
|
37
|
+
* on platforms that support it; where it is unsupported (ENOTSUP on macOS,
|
|
38
|
+
* the platform this repo runs on) the drain path is used instead.
|
|
39
|
+
*
|
|
40
|
+
* Zero runtime deps: node:fs / node:path / node:http / node:child_process.
|
|
41
|
+
*/
|
|
42
|
+
import { spawn } from "node:child_process";
|
|
43
|
+
import { randomBytes } from "node:crypto";
|
|
44
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
45
|
+
import http from "node:http";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
import { readBuildStamp } from "../core/build.js";
|
|
49
|
+
import { isPortListening } from "./serve-reaper.js";
|
|
50
|
+
/** True when running from src/ (dev mode) rather than compiled dist/. */
|
|
51
|
+
export function isDevBuild() {
|
|
52
|
+
const pathSep = process.platform === "win32" ? "\\\\" : "/";
|
|
53
|
+
return fileURLToPath(import.meta.url).includes(`${pathSep}src${pathSep}`);
|
|
54
|
+
}
|
|
55
|
+
/** Read the build hash from dist/.build-info.json, or null when absent. */
|
|
56
|
+
export function readBuildHash(root) {
|
|
57
|
+
const file = join(root, "dist", ".build-info.json");
|
|
58
|
+
try {
|
|
59
|
+
const info = JSON.parse(readFileSync(file, "utf8"));
|
|
60
|
+
return typeof info.hash === "string" && info.hash ? info.hash : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read the build timestamp from dist/.build-stamp.json, or null. Kept separate
|
|
68
|
+
* from the hash marker so `.build-info.json` stays byte-identical across
|
|
69
|
+
* rebuilds of identical source — see readBuildStamp in core/build.ts.
|
|
70
|
+
*/
|
|
71
|
+
export function readBuildAt(root) {
|
|
72
|
+
return readBuildStamp(root);
|
|
73
|
+
}
|
|
74
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
75
|
+
const DEFAULT_POLL_MS = 5000;
|
|
76
|
+
const DEFAULT_RETRY_MS = 2000;
|
|
77
|
+
const DEFAULT_GRACE_MS = 2000;
|
|
78
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
79
|
+
/** Sustained-health window before a replacement handoff is confirmed. */
|
|
80
|
+
const DEFAULT_CONFIRM_MS = 750;
|
|
81
|
+
const RELOAD_POLL_MS = 150;
|
|
82
|
+
const REBIND_TIMEOUT_MS = 5000;
|
|
83
|
+
const WATCH_DEBOUNCE_MS = 60;
|
|
84
|
+
/**
|
|
85
|
+
* Backoff after a failed handoff (#0271 incident): a replacement that fails
|
|
86
|
+
* to become ready gets retried on the very next poll tick with no cooldown.
|
|
87
|
+
* If builds keep landing (several close-outs in quick succession) that
|
|
88
|
+
* becomes an unbounded retry loop — a full server boot, every ~5s, forever —
|
|
89
|
+
* which is what actually took the control plane down (29 failed attempts
|
|
90
|
+
* back to back). Doubles per consecutive failure, capped, and resets on the
|
|
91
|
+
* next successful handoff.
|
|
92
|
+
*/
|
|
93
|
+
const BACKOFF_BASE_MS = 10_000;
|
|
94
|
+
const BACKOFF_MAX_MS = 5 * 60_000;
|
|
95
|
+
/** GET a URL and JSON.parse it; resolves null on any failure (never throws). */
|
|
96
|
+
function probeJson(url) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const req = http.get(url, (res) => {
|
|
99
|
+
let data = "";
|
|
100
|
+
res.setEncoding("utf8");
|
|
101
|
+
res.on("data", (c) => {
|
|
102
|
+
data += c;
|
|
103
|
+
});
|
|
104
|
+
res.on("end", () => {
|
|
105
|
+
try {
|
|
106
|
+
resolve(JSON.parse(data));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
resolve(null);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
req.on("error", () => resolve(null));
|
|
114
|
+
req.setTimeout(2000, () => {
|
|
115
|
+
req.destroy();
|
|
116
|
+
resolve(null);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export class ReloadManager {
|
|
121
|
+
options;
|
|
122
|
+
loadedHash;
|
|
123
|
+
fsWatcher = null;
|
|
124
|
+
debounceTimer = null;
|
|
125
|
+
pollTimer = null;
|
|
126
|
+
retryTimer = null;
|
|
127
|
+
reloading = false;
|
|
128
|
+
pending = false;
|
|
129
|
+
stopped = false;
|
|
130
|
+
child = null;
|
|
131
|
+
childExited = false;
|
|
132
|
+
/** Whether we released our own HTTP listener for the replacement to bind. */
|
|
133
|
+
drained = false;
|
|
134
|
+
/** Consecutive failed handoffs. Reset on the next successful one. */
|
|
135
|
+
consecutiveFailures = 0;
|
|
136
|
+
/** Automatic (non-manual) reloads are refused until this time, post-failure. */
|
|
137
|
+
backoffUntil = 0;
|
|
138
|
+
/**
|
|
139
|
+
* Hash of a build parked by a close-out (0143). Never auto-reloaded — it
|
|
140
|
+
* waits for the user to trigger POST /api/server/restart.
|
|
141
|
+
*/
|
|
142
|
+
buildAvailableHash = null;
|
|
143
|
+
/** On-disk build timestamp of the parked build (dist/.build-info.json). */
|
|
144
|
+
buildAvailableAt = null;
|
|
145
|
+
constructor(options) {
|
|
146
|
+
this.options = options;
|
|
147
|
+
this.loadedHash = options.loadedHash;
|
|
148
|
+
}
|
|
149
|
+
get enabled() {
|
|
150
|
+
return this.options.enabled !== false;
|
|
151
|
+
}
|
|
152
|
+
/** True while the manager is actively handing over to a replacement process. */
|
|
153
|
+
get isReloading() {
|
|
154
|
+
return this.reloading;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The build parked by a close-out (0143), if any — hash plus its on-disk
|
|
158
|
+
* build timestamp (dist/.build-info.json generatedAt). Exposed so /api/health
|
|
159
|
+
* can report it and a (re)connecting UI can surface the "new build available"
|
|
160
|
+
* notice without ever having seen the one-shot build.available SSE event.
|
|
161
|
+
* Null while nothing is parked.
|
|
162
|
+
*/
|
|
163
|
+
get buildAvailable() {
|
|
164
|
+
return this.buildAvailableHash === null
|
|
165
|
+
? null
|
|
166
|
+
: { hash: this.buildAvailableHash, buildAt: this.buildAvailableAt };
|
|
167
|
+
}
|
|
168
|
+
/** Begin watching dist/.build-info.json (fs.watch + fallback hash poll). */
|
|
169
|
+
start() {
|
|
170
|
+
if (!this.enabled)
|
|
171
|
+
return;
|
|
172
|
+
this.startWatching();
|
|
173
|
+
this.pollTimer = setInterval(() => this.onPoll(), this.options.pollMs ?? DEFAULT_POLL_MS);
|
|
174
|
+
this.pollTimer.unref?.();
|
|
175
|
+
}
|
|
176
|
+
/** Stop watching and cancel timers. Idempotent. */
|
|
177
|
+
stop() {
|
|
178
|
+
this.stopped = true;
|
|
179
|
+
this.clearTimers();
|
|
180
|
+
if (this.fsWatcher) {
|
|
181
|
+
try {
|
|
182
|
+
this.fsWatcher.close();
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
this.fsWatcher = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Stale-boot self-heal: if a newer build landed while we were starting,
|
|
192
|
+
* reload immediately (deferred while an agent runs). The replacement child
|
|
193
|
+
* (REPOOS_RELOAD=1) skips this — it boots fresh on the current build.
|
|
194
|
+
*/
|
|
195
|
+
bootSelfHeal() {
|
|
196
|
+
if (this.stopped || !this.enabled)
|
|
197
|
+
return;
|
|
198
|
+
if (this.options.isReplacement)
|
|
199
|
+
return;
|
|
200
|
+
const current = readBuildHash(this.options.root);
|
|
201
|
+
if (current !== null && this.loadedHash !== null && current !== this.loadedHash) {
|
|
202
|
+
void this.requestReload("stale boot");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Public trigger used by the watcher, the boot self-heal, and
|
|
207
|
+
* POST /api/server/restart. Fires the reload when stale and idle; defers
|
|
208
|
+
* while an agent turn runs; reports not-stale when nothing is out of date.
|
|
209
|
+
*
|
|
210
|
+
* `manual` (POST /api/server/restart, a human explicitly asking) bypasses
|
|
211
|
+
* the post-failure backoff below — automatic triggers (the poll, boot
|
|
212
|
+
* self-heal) respect it so a run of failed handoffs doesn't retry every
|
|
213
|
+
* ~5s forever (#0271 incident).
|
|
214
|
+
*/
|
|
215
|
+
requestReload(reason, opts = {}) {
|
|
216
|
+
if (!this.enabled)
|
|
217
|
+
return { state: "not-stale", reason: "auto-reload disabled" };
|
|
218
|
+
if (this.stopped)
|
|
219
|
+
return { state: "not-stale", reason: "server is shutting down" };
|
|
220
|
+
if (this.reloading)
|
|
221
|
+
return { state: "reloading", reason };
|
|
222
|
+
const running = this.options.isBusy();
|
|
223
|
+
if (!opts.manual && Date.now() < this.backoffUntil) {
|
|
224
|
+
return {
|
|
225
|
+
state: "deferred",
|
|
226
|
+
running,
|
|
227
|
+
reason: `backing off after ${this.consecutiveFailures} failed reload attempt(s) — retrying automatically at ${new Date(this.backoffUntil).toISOString()}, or POST /api/server/restart to retry now`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// Close-out deferral (0143): the close-out pipeline merges the branch and
|
|
231
|
+
// runs build/screenshots/check, all of which would be killed if the server
|
|
232
|
+
// reloaded itself mid-flight. Park the new build and surface it to the UI —
|
|
233
|
+
// the user reloads when they choose (POST /api/server/restart).
|
|
234
|
+
if (this.options.closingOut()) {
|
|
235
|
+
const current = readBuildHash(this.options.root);
|
|
236
|
+
if (this.loadedHash !== null && current !== null && current !== this.loadedHash) {
|
|
237
|
+
this.parkBuild(current);
|
|
238
|
+
}
|
|
239
|
+
return { state: "deferred", running, reason };
|
|
240
|
+
}
|
|
241
|
+
// Agent-turn deferral removed (0214): in-flight agent children are now
|
|
242
|
+
// durable — stdout/stderr writes to a per-task log file and the new server
|
|
243
|
+
// re-attaches via adoptRunningAgents(). The restart proceeds immediately.
|
|
244
|
+
const current = readBuildHash(this.options.root);
|
|
245
|
+
if (this.loadedHash === null || current === null || current === this.loadedHash) {
|
|
246
|
+
return { state: "not-stale", reason };
|
|
247
|
+
}
|
|
248
|
+
void this.reload(reason);
|
|
249
|
+
return { state: "reloading", reason };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The SSE event funnel calls this so a drained runner unblocks a deferred
|
|
253
|
+
* reload. `review` events count: an agent review is a spawned turn too, and
|
|
254
|
+
* one finishing can be what makes the server idle.
|
|
255
|
+
*/
|
|
256
|
+
onEvent(e) {
|
|
257
|
+
if (e.type === "agent.exited" || e.type === "review")
|
|
258
|
+
this.tryFirePending();
|
|
259
|
+
}
|
|
260
|
+
onPoll() {
|
|
261
|
+
if (this.stopped || this.reloading)
|
|
262
|
+
return;
|
|
263
|
+
const current = readBuildHash(this.options.root);
|
|
264
|
+
if (current === null || this.loadedHash === null || current === this.loadedHash)
|
|
265
|
+
return;
|
|
266
|
+
// A close-out landed a new build on disk: park it for the user instead of
|
|
267
|
+
// reloading under the pipeline that is orchestrating the close-out. This
|
|
268
|
+
// runs even while a busy-deferral is armed — the park supersedes it.
|
|
269
|
+
if (this.options.closingOut()) {
|
|
270
|
+
this.parkBuild(current);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (this.pending)
|
|
274
|
+
return;
|
|
275
|
+
// A build parked by a finished close-out is not auto-reloadable — only a
|
|
276
|
+
// fresh hash beyond it (a normal dev build) triggers live reload.
|
|
277
|
+
if (current === this.buildAvailableHash)
|
|
278
|
+
return;
|
|
279
|
+
void this.requestReload("build changed (poll)");
|
|
280
|
+
}
|
|
281
|
+
tryFirePending() {
|
|
282
|
+
if (!this.pending || this.reloading || this.stopped || this.options.isBusy() !== 0)
|
|
283
|
+
return;
|
|
284
|
+
// A close-out started while the reload was deferred: the build it produces
|
|
285
|
+
// must wait for the user, not auto-fire on drain.
|
|
286
|
+
if (this.options.closingOut()) {
|
|
287
|
+
this.pending = false;
|
|
288
|
+
this.clearRetry();
|
|
289
|
+
const current = readBuildHash(this.options.root);
|
|
290
|
+
if (current !== null && this.loadedHash !== null && current !== this.loadedHash) {
|
|
291
|
+
this.parkBuild(current);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this.pending = false;
|
|
296
|
+
this.clearRetry();
|
|
297
|
+
void this.reload("deferred reload: agents drained");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Park a build that landed during a close-out: it is served only once the
|
|
301
|
+
* user triggers a reload. Idempotent per hash. Supersedes any armed
|
|
302
|
+
* busy-deferral so an eventual drain does not auto-fire it.
|
|
303
|
+
*/
|
|
304
|
+
parkBuild(hash) {
|
|
305
|
+
if (this.buildAvailableHash === hash)
|
|
306
|
+
return;
|
|
307
|
+
this.buildAvailableHash = hash;
|
|
308
|
+
this.buildAvailableAt = readBuildAt(this.options.root);
|
|
309
|
+
if (this.pending) {
|
|
310
|
+
this.pending = false;
|
|
311
|
+
this.clearRetry();
|
|
312
|
+
}
|
|
313
|
+
this.options.onBuildAvailable?.(hash);
|
|
314
|
+
}
|
|
315
|
+
startWatching() {
|
|
316
|
+
const dir = join(this.options.root, "dist");
|
|
317
|
+
if (!existsSync(dir))
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
this.fsWatcher = watch(dir, (_event, filename) => {
|
|
321
|
+
if (filename && filename.toString() !== ".build-info.json")
|
|
322
|
+
return;
|
|
323
|
+
if (this.debounceTimer)
|
|
324
|
+
clearTimeout(this.debounceTimer);
|
|
325
|
+
this.debounceTimer = setTimeout(() => {
|
|
326
|
+
this.debounceTimer = null;
|
|
327
|
+
this.onPoll();
|
|
328
|
+
}, WATCH_DEBOUNCE_MS);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// fs.watch unavailable on the platform — the hash poll covers us.
|
|
333
|
+
this.fsWatcher = null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async reload(reason) {
|
|
337
|
+
if (this.reloading || this.stopped)
|
|
338
|
+
return;
|
|
339
|
+
this.reloading = true;
|
|
340
|
+
this.pending = false;
|
|
341
|
+
this.clearRetry();
|
|
342
|
+
const entry = this.options.cliEntry();
|
|
343
|
+
if (!entry) {
|
|
344
|
+
this.reloading = false;
|
|
345
|
+
this.log("reload: could not locate the repoos CLI — staying on this build");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const secret = randomBytes(16).toString("hex");
|
|
349
|
+
this.childExited = false;
|
|
350
|
+
this.drained = false;
|
|
351
|
+
let child;
|
|
352
|
+
try {
|
|
353
|
+
child = spawn(process.execPath, [entry, "serve", "--port", String(this.options.port), "--host", this.options.host], {
|
|
354
|
+
cwd: this.options.root,
|
|
355
|
+
// A replacement can start server-owned children (notably previews)
|
|
356
|
+
// before a failed handover is detected. Give it its own process group
|
|
357
|
+
// so failure cleanup can terminate that whole replacement tree rather
|
|
358
|
+
// than leaving descendants alive under an otherwise-live ancestor.
|
|
359
|
+
// Without this, the periodic orphan sweep cannot safely classify the
|
|
360
|
+
// leftovers: their immediate parent is still alive.
|
|
361
|
+
detached: process.platform !== "win32",
|
|
362
|
+
// The replacement must inherit the durable streams of the process
|
|
363
|
+
// supervisor (for example nohup's server.out). Pipes belong to this
|
|
364
|
+
// old process; once it hands over and exits, a later console write
|
|
365
|
+
// in the replacement can hit EPIPE and leave the control port down.
|
|
366
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
367
|
+
env: { ...process.env, REPOOS_RELOAD: "1", REPOOS_RELOAD_SECRET: secret },
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
this.reloading = false;
|
|
372
|
+
this.log(`reload: could not spawn the replacement: ${err.message}`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.child = child;
|
|
376
|
+
child.on("error", (err) => {
|
|
377
|
+
this.childExited = true;
|
|
378
|
+
this.log(`reload: replacement pid ${child.pid ?? "unknown"} spawn error: ${err.message}`);
|
|
379
|
+
});
|
|
380
|
+
child.on("exit", (code, signal) => {
|
|
381
|
+
this.childExited = true;
|
|
382
|
+
this.log(`reload: replacement pid ${child.pid ?? "unknown"} exited (code=${code ?? "null"}, signal=${signal ?? "none"})`);
|
|
383
|
+
});
|
|
384
|
+
this.log(`reload: spawning replacement pid ${child.pid ?? "unknown"} on ${this.options.host}:${this.options.port} (${reason})`);
|
|
385
|
+
const confirmed = await this.waitForReplacement(secret);
|
|
386
|
+
// #0096: a confirmed handshake is only trustworthy when the replacement
|
|
387
|
+
// process is still alive at the moment of handover. If it died during the
|
|
388
|
+
// sustained-health window or right at confirmation, we must NOT log
|
|
389
|
+
// "replacement is up" or exit — we re-bind and keep serving instead.
|
|
390
|
+
if (confirmed && !this.childExited && this.options.closingOut()) {
|
|
391
|
+
// A close-out started while the replacement was warming up. Handing over
|
|
392
|
+
// now would kill the server mid-pipeline (0143), so abort the reload:
|
|
393
|
+
// kill the replacement, re-bind, and park the new build for the user.
|
|
394
|
+
// `reloading` stays true until cleanup finishes — see the failure branch
|
|
395
|
+
// below for why resetting it earlier is unsafe (#leak).
|
|
396
|
+
await this.killChild();
|
|
397
|
+
const rebound = await this.tryRebind();
|
|
398
|
+
this.reloading = false;
|
|
399
|
+
const current = readBuildHash(this.options.root);
|
|
400
|
+
if (current !== null && this.loadedHash !== null && current !== this.loadedHash) {
|
|
401
|
+
this.parkBuild(current);
|
|
402
|
+
}
|
|
403
|
+
this.log(`reload: handover aborted — close-out in progress${rebound === "bound" ? "" : ` — ${rebound} (server may be down)`}`);
|
|
404
|
+
}
|
|
405
|
+
else if (confirmed && !this.childExited) {
|
|
406
|
+
// A real handoff: whatever run of failures preceded it is over.
|
|
407
|
+
this.consecutiveFailures = 0;
|
|
408
|
+
this.backoffUntil = 0;
|
|
409
|
+
this.stopped = true;
|
|
410
|
+
child.unref?.();
|
|
411
|
+
this.log(`reload: replacement is up on ${this.options.host}:${this.options.port} — handing over`);
|
|
412
|
+
await this.options.onReloadConfirmed();
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// #leak: `reloading` must stay true for the whole cleanup, not just the
|
|
416
|
+
// spawn+wait phase. Resetting it before killChild()/tryRebind() complete
|
|
417
|
+
// reopens the reload() re-entry guard mid-cleanup — a poll tick or
|
|
418
|
+
// fs.watch event firing in that window starts a NEW reload(), which
|
|
419
|
+
// overwrites `this.child` with the new attempt before this failed
|
|
420
|
+
// attempt's killChild() reads it. killChild() then kills the WRONG
|
|
421
|
+
// (newer) child while the original failed replacement leaks forever,
|
|
422
|
+
// unmanaged. Under load (many poll ticks racing a slow cleanup) this
|
|
423
|
+
// fires repeatedly and accumulates orphaned `repoos serve` processes.
|
|
424
|
+
await this.killChild();
|
|
425
|
+
const rebound = await this.tryRebind();
|
|
426
|
+
this.reloading = false;
|
|
427
|
+
// Backoff (#0271 incident): a genuine failure to become ready — not a
|
|
428
|
+
// close-out abort, that's handled in the branch above — arms a cooldown
|
|
429
|
+
// so the next AUTOMATIC trigger (poll tick, boot self-heal) doesn't
|
|
430
|
+
// retry in ~5s. Doubles per consecutive failure, capped at
|
|
431
|
+
// BACKOFF_MAX_MS; a manual POST /api/server/restart still bypasses it.
|
|
432
|
+
this.consecutiveFailures++;
|
|
433
|
+
const backoffMs = Math.min(BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), BACKOFF_MAX_MS);
|
|
434
|
+
this.backoffUntil = Date.now() + backoffMs;
|
|
435
|
+
this.log(`reload: replacement failed to become ready${rebound === "bound" ? "" : ` — ${rebound} (server may be down)`}` +
|
|
436
|
+
` — backing off ${Math.round(backoffMs / 1000)}s (${this.consecutiveFailures} consecutive failure${this.consecutiveFailures === 1 ? "" : "s"})`);
|
|
437
|
+
this.options.onReloadFailed?.(rebound === "bound"
|
|
438
|
+
? "replacement did not become ready"
|
|
439
|
+
: `replacement failed and ${rebound}`);
|
|
440
|
+
}
|
|
441
|
+
this.child = null;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Poll /api/health for the replacement's handshake until it answers or the
|
|
445
|
+
* window closes. While the parent holds the port the replacement cannot bind
|
|
446
|
+
* (no SO_REUSEPORT sharing on this platform), so after a short grace we
|
|
447
|
+
* release our own listener — the drain window — and keep polling. Any answer
|
|
448
|
+
* from the parent itself has no handshake, so a spurious self-poll never
|
|
449
|
+
* confirms a replacement.
|
|
450
|
+
*
|
|
451
|
+
* A single handshake answer is NOT enough (#0096): the replacement must stay
|
|
452
|
+
* healthy for the confirm window so a briefly-up-then-dying replacement is
|
|
453
|
+
* treated as a failed reload — the parent re-binds and keeps serving rather
|
|
454
|
+
* than handing over to a process that will leave the port listenerless.
|
|
455
|
+
*/
|
|
456
|
+
async waitForReplacement(secret) {
|
|
457
|
+
const deadline = Date.now() + (this.options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);
|
|
458
|
+
const graceUntil = Date.now() + (this.options.graceMs ?? DEFAULT_GRACE_MS);
|
|
459
|
+
const confirmMs = this.options.confirmMs ?? DEFAULT_CONFIRM_MS;
|
|
460
|
+
const url = `http://${this.options.host}:${this.options.port}/api/health?reload=${secret}`;
|
|
461
|
+
let firstConfirmedAt = 0;
|
|
462
|
+
while (Date.now() < deadline) {
|
|
463
|
+
if (this.childExited)
|
|
464
|
+
return false;
|
|
465
|
+
const body = await probeJson(url);
|
|
466
|
+
if (body && body.reloadHandshake === true) {
|
|
467
|
+
if (firstConfirmedAt === 0)
|
|
468
|
+
firstConfirmedAt = Date.now();
|
|
469
|
+
if (Date.now() - firstConfirmedAt >= confirmMs)
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
if (!this.drained && (body === null || Date.now() >= graceUntil)) {
|
|
473
|
+
this.drained = true;
|
|
474
|
+
await this.options.stopListening();
|
|
475
|
+
}
|
|
476
|
+
await sleep(RELOAD_POLL_MS);
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
async killChild() {
|
|
481
|
+
const child = this.child;
|
|
482
|
+
if (!child || this.childExited)
|
|
483
|
+
return;
|
|
484
|
+
const pid = child.pid;
|
|
485
|
+
const killTree = (signal) => {
|
|
486
|
+
// A detached Unix child is the leader of a new process group. A negative
|
|
487
|
+
// PID signals the group, including any previews it created while trying
|
|
488
|
+
// to boot. Windows has no compatible process-group signal API, so retain
|
|
489
|
+
// Node's direct-child behaviour there.
|
|
490
|
+
if (process.platform !== "win32" && pid && pid > 0) {
|
|
491
|
+
try {
|
|
492
|
+
process.kill(-pid, signal);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// The group may already be gone; direct signalling below preserves
|
|
497
|
+
// the existing best-effort cleanup semantics.
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
child.kill(signal);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
/* already gone */
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
try {
|
|
508
|
+
killTree("SIGTERM");
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return; // already gone
|
|
512
|
+
}
|
|
513
|
+
await sleep(400);
|
|
514
|
+
try {
|
|
515
|
+
child.kill(0);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return; // exited on SIGTERM
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
killTree("SIGKILL");
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
/* ignore */
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Re-bind the listener after an aborted reload so the old process keeps
|
|
529
|
+
* serving. Returns `"bound"` when the listener is back, or a reason string
|
|
530
|
+
* describing why it could not be recovered.
|
|
531
|
+
*
|
|
532
|
+
* #0284: a bind attempt by an unrelated `repoos serve` landing in our drain
|
|
533
|
+
* window can steal the port out from under us — we released the listener in
|
|
534
|
+
* waitForReplacement, it grabbed it, and now we can never re-bind (each
|
|
535
|
+
* attempt EADDRINUSEs). Distinguishing that from a transient failure lets
|
|
536
|
+
* the old process surface a loud, actionable error instead of silently
|
|
537
|
+
* looping as a listenerless zombie. We probe the port once the retry window
|
|
538
|
+
* closes: if *something else* is listening there now, the port was stolen
|
|
539
|
+
* rather than merely not-yet-freed.
|
|
540
|
+
*/
|
|
541
|
+
async tryRebind() {
|
|
542
|
+
if (!this.drained)
|
|
543
|
+
return "bound"; // we never released the listener
|
|
544
|
+
const deadline = Date.now() + (this.options.rebindTimeoutMs ?? REBIND_TIMEOUT_MS);
|
|
545
|
+
while (Date.now() < deadline) {
|
|
546
|
+
try {
|
|
547
|
+
await this.options.listen();
|
|
548
|
+
return "bound";
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
await sleep(200);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const probeHost = this.options.host === "0.0.0.0" ? "127.0.0.1" : this.options.host;
|
|
555
|
+
const stolen = await isPortListening(this.options.port, probeHost);
|
|
556
|
+
return stolen
|
|
557
|
+
? `port ${this.options.port} was stolen by another process during the reload drain`
|
|
558
|
+
: `could not re-bind port ${this.options.port} within ${this.options.rebindTimeoutMs ?? REBIND_TIMEOUT_MS}ms`;
|
|
559
|
+
}
|
|
560
|
+
armRetry() {
|
|
561
|
+
if (this.retryTimer)
|
|
562
|
+
return;
|
|
563
|
+
this.retryTimer = setInterval(() => this.tryFirePending(), this.options.retryMs ?? DEFAULT_RETRY_MS);
|
|
564
|
+
this.retryTimer.unref?.();
|
|
565
|
+
}
|
|
566
|
+
clearRetry() {
|
|
567
|
+
if (this.retryTimer) {
|
|
568
|
+
clearInterval(this.retryTimer);
|
|
569
|
+
this.retryTimer = null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
clearTimers() {
|
|
573
|
+
if (this.pollTimer) {
|
|
574
|
+
clearInterval(this.pollTimer);
|
|
575
|
+
this.pollTimer = null;
|
|
576
|
+
}
|
|
577
|
+
if (this.debounceTimer) {
|
|
578
|
+
clearTimeout(this.debounceTimer);
|
|
579
|
+
this.debounceTimer = null;
|
|
580
|
+
}
|
|
581
|
+
this.clearRetry();
|
|
582
|
+
}
|
|
583
|
+
log(msg) {
|
|
584
|
+
this.options.log?.(msg);
|
|
585
|
+
}
|
|
586
|
+
}
|