@moretea-labs/forge 1.5.0-rc.1

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 (903) hide show
  1. package/AGENTS.md +120 -0
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.md +120 -0
  4. package/CODE_OF_CONDUCT.md +23 -0
  5. package/CONTRIBUTING.md +77 -0
  6. package/LICENSE +22 -0
  7. package/NOTICE +9 -0
  8. package/README.en.md +5 -0
  9. package/README.es.md +12 -0
  10. package/README.fr.md +12 -0
  11. package/README.ja.md +12 -0
  12. package/README.md +132 -0
  13. package/README.zh-CN.md +84 -0
  14. package/SECURITY.md +50 -0
  15. package/SKILL.md +431 -0
  16. package/SUPPORT.md +31 -0
  17. package/THIRD_PARTY_NOTICES.md +18 -0
  18. package/agents/openai.yaml +4 -0
  19. package/assets/AGENTS.md +68 -0
  20. package/assets/CLAUDE.md +68 -0
  21. package/assets/branding/README.md +10 -0
  22. package/assets/branding/forge-controller-flow.mmd +8 -0
  23. package/assets/hooks/AGENTS.md +75 -0
  24. package/assets/hooks/CLAUDE.md +75 -0
  25. package/assets/hooks/anti-simplification.sh +12 -0
  26. package/assets/hooks/changelog-guard.sh +80 -0
  27. package/assets/hooks/codex.hooks.template.json +60 -0
  28. package/assets/hooks/first-principles-guard.sh +72 -0
  29. package/assets/hooks/hook-input.sh +618 -0
  30. package/assets/hooks/lib/session-state.sh +106 -0
  31. package/assets/hooks/lib/workflow-state.sh +1730 -0
  32. package/assets/hooks/post-bash.sh +215 -0
  33. package/assets/hooks/post-edit-guard.sh +256 -0
  34. package/assets/hooks/post-tool-observer.sh +99 -0
  35. package/assets/hooks/pre-edit-guard.sh +240 -0
  36. package/assets/hooks/prompt-guard.sh +1077 -0
  37. package/assets/hooks/run-hook.sh +67 -0
  38. package/assets/hooks/security-sentinel.sh +115 -0
  39. package/assets/hooks/session-start-context.sh +593 -0
  40. package/assets/hooks/settings.template.json +60 -0
  41. package/assets/hooks/stop-orchestrator.sh +171 -0
  42. package/assets/hooks/subagent-return-channel-guard.sh +107 -0
  43. package/assets/hooks/worktree-guard.sh +39 -0
  44. package/assets/initializer-question-pack.v1.json +132 -0
  45. package/assets/initializer-question-pack.v1.schema.json +46 -0
  46. package/assets/initializer-question-pack.v2.json +174 -0
  47. package/assets/initializer-question-pack.v3.json +206 -0
  48. package/assets/initializer-question-pack.v3.schema.json +92 -0
  49. package/assets/initializer-question-pack.v4.json +557 -0
  50. package/assets/initializer-question-pack.v4.schema.json +169 -0
  51. package/assets/partials/01-header.partial.md +10 -0
  52. package/assets/partials/02-iron-rules.partial.md +30 -0
  53. package/assets/partials/03-philosophy.partial.md +32 -0
  54. package/assets/partials/04-project-structure.partial.md +56 -0
  55. package/assets/partials/05-workflow.partial.md +79 -0
  56. package/assets/partials/06-cloudflare.partial.md +20 -0
  57. package/assets/partials/07-footer.partial.md +38 -0
  58. package/assets/partials/08-orchestration.partial.md +49 -0
  59. package/assets/partials/09-compact-instructions.partial.md +11 -0
  60. package/assets/partials/_assembly-order.md +40 -0
  61. package/assets/partials-agents/01-header.partial.md +8 -0
  62. package/assets/partials-agents/02-operating-mode.partial.md +44 -0
  63. package/assets/partials-agents/03-orchestration.partial.md +45 -0
  64. package/assets/partials-agents/04-task-protocol.partial.md +69 -0
  65. package/assets/partials-agents/05-coding-constraints.partial.md +20 -0
  66. package/assets/partials-agents/06-quality-safety.partial.md +23 -0
  67. package/assets/partials-agents/07-cloudflare.partial.md +9 -0
  68. package/assets/partials-agents/08-deep-docs.partial.md +32 -0
  69. package/assets/plan-map.json +301 -0
  70. package/assets/plugin-registry.v1.json +8 -0
  71. package/assets/project-structures/ai-native-collaborative-editor.txt +25 -0
  72. package/assets/project-structures/ai-native-product-copilot.txt +21 -0
  73. package/assets/project-structures/ai-native-runtime-console.txt +25 -0
  74. package/assets/project-structures/ai-native-sidecar-kernel.txt +19 -0
  75. package/assets/project-structures/astro-ssr.txt +25 -0
  76. package/assets/project-structures/expo-nativewind.txt +52 -0
  77. package/assets/project-structures/monorepo.txt +63 -0
  78. package/assets/project-structures/remix.txt +50 -0
  79. package/assets/project-structures/tanstack-start-workers.txt +51 -0
  80. package/assets/project-structures/vite-tanstack.txt +58 -0
  81. package/assets/reference-configs/AGENTS.md +13 -0
  82. package/assets/reference-configs/CLAUDE.md +13 -0
  83. package/assets/reference-configs/agentic-development-flow.md +94 -0
  84. package/assets/reference-configs/ai-workflows.md +14 -0
  85. package/assets/reference-configs/changelog-versioning.md +13 -0
  86. package/assets/reference-configs/coding-standards.md +14 -0
  87. package/assets/reference-configs/development-protocol.md +21 -0
  88. package/assets/reference-configs/document-generation.md +38 -0
  89. package/assets/reference-configs/evaluator-rubric.md +19 -0
  90. package/assets/reference-configs/external-tooling.md +374 -0
  91. package/assets/reference-configs/git-strategy.md +14 -0
  92. package/assets/reference-configs/global-working-rules.md +64 -0
  93. package/assets/reference-configs/handoff-protocol.md +38 -0
  94. package/assets/reference-configs/harness-overview.md +124 -0
  95. package/assets/reference-configs/heartbeat-triage.md +57 -0
  96. package/assets/reference-configs/hook-operations.md +96 -0
  97. package/assets/reference-configs/release-deploy.md +40 -0
  98. package/assets/reference-configs/spa-day-protocol.md +14 -0
  99. package/assets/reference-configs/sprint-contracts.md +89 -0
  100. package/assets/reference-configs/workflow-orchestration.md +14 -0
  101. package/assets/skill-hooks.json +41 -0
  102. package/assets/skill-version.json +189 -0
  103. package/assets/skills/claude-review/SKILL.md +231 -0
  104. package/assets/skills/codex-review/SKILL.md +103 -0
  105. package/assets/templates/AGENTS.md +13 -0
  106. package/assets/templates/CLAUDE.md +13 -0
  107. package/assets/templates/architecture.template.md +80 -0
  108. package/assets/templates/brief.template.md +38 -0
  109. package/assets/templates/contract.template.md +104 -0
  110. package/assets/templates/decisions.template.md +85 -0
  111. package/assets/templates/factor-factory/factor-backtest-report.template.md +36 -0
  112. package/assets/templates/factor-factory/factor-hypothesis.template.md +30 -0
  113. package/assets/templates/factor-factory/factor-registry.template.json +5 -0
  114. package/assets/templates/gitignore.template +53 -0
  115. package/assets/templates/guides/metro-esm-gotchas.template.md +46 -0
  116. package/assets/templates/helpers/architecture-event.ts +822 -0
  117. package/assets/templates/helpers/architecture-queue.sh +647 -0
  118. package/assets/templates/helpers/archive-architecture-request.sh +244 -0
  119. package/assets/templates/helpers/archive-workflow.sh +295 -0
  120. package/assets/templates/helpers/capability-config.ts +394 -0
  121. package/assets/templates/helpers/capability-resolver.ts +505 -0
  122. package/assets/templates/helpers/capture-plan.sh +335 -0
  123. package/assets/templates/helpers/check-agent-tooling.sh +1533 -0
  124. package/assets/templates/helpers/check-architecture-sync.sh +287 -0
  125. package/assets/templates/helpers/check-brain-manifest.sh +255 -0
  126. package/assets/templates/helpers/check-context-files.sh +49 -0
  127. package/assets/templates/helpers/check-deploy-sql-order.sh +117 -0
  128. package/assets/templates/helpers/check-skill-version.ts +193 -0
  129. package/assets/templates/helpers/check-task-sync.sh +65 -0
  130. package/assets/templates/helpers/check-task-workflow.sh +1002 -0
  131. package/assets/templates/helpers/codex-handoff-resume.sh +269 -0
  132. package/assets/templates/helpers/context-contract-sync.sh +516 -0
  133. package/assets/templates/helpers/contract-run.ts +465 -0
  134. package/assets/templates/helpers/contract-worktree.sh +725 -0
  135. package/assets/templates/helpers/ensure-task-workflow.sh +1218 -0
  136. package/assets/templates/helpers/harness-trace-grade.sh +150 -0
  137. package/assets/templates/helpers/heartbeat-triage.sh +361 -0
  138. package/assets/templates/helpers/inspect-project-state.ts +280 -0
  139. package/assets/templates/helpers/maintenance-triage.sh +167 -0
  140. package/assets/templates/helpers/migrate-project-template.sh +54 -0
  141. package/assets/templates/helpers/migrate-workflow-docs.ts +413 -0
  142. package/assets/templates/helpers/new-plan.sh +183 -0
  143. package/assets/templates/helpers/new-spec.sh +50 -0
  144. package/assets/templates/helpers/new-sprint.sh +49 -0
  145. package/assets/templates/helpers/plan-to-todo.sh +799 -0
  146. package/assets/templates/helpers/prepare-codex-handoff.sh +203 -0
  147. package/assets/templates/helpers/prepare-handoff.sh +81 -0
  148. package/assets/templates/helpers/refresh-current-status.sh +469 -0
  149. package/assets/templates/helpers/select-agent-context-blocks.sh +66 -0
  150. package/assets/templates/helpers/ship-worktrees.sh +561 -0
  151. package/assets/templates/helpers/sprint-backlog.sh +837 -0
  152. package/assets/templates/helpers/summarize-failures.sh +125 -0
  153. package/assets/templates/helpers/switch-plan.sh +153 -0
  154. package/assets/templates/helpers/sync-brain-docs.sh +381 -0
  155. package/assets/templates/helpers/verify-contract.sh +705 -0
  156. package/assets/templates/helpers/verify-sprint.sh +680 -0
  157. package/assets/templates/helpers/workflow-contract.ts +202 -0
  158. package/assets/templates/helpers/workstream-sync.sh +263 -0
  159. package/assets/templates/implementation-notes.template.md +37 -0
  160. package/assets/templates/packages.template.md +45 -0
  161. package/assets/templates/plan.template.md +77 -0
  162. package/assets/templates/prd.template.md +165 -0
  163. package/assets/templates/research.template.md +25 -0
  164. package/assets/templates/review.template.md +78 -0
  165. package/assets/templates/spec.template.md +31 -0
  166. package/assets/templates/sprint.template.md +69 -0
  167. package/assets/templates/tech-stack.template.md +57 -0
  168. package/assets/versions.json +77 -0
  169. package/assets/workflow-contract.v1.json +671 -0
  170. package/bin/forge-desktop-helper.mjs +204 -0
  171. package/bin/forge-hook.mjs +32 -0
  172. package/bin/forge-runtime-service.mjs +34 -0
  173. package/bin/forge-runtime.mjs +30 -0
  174. package/bin/forge.mjs +32 -0
  175. package/docs/CHANGELOG.md +978 -0
  176. package/docs/README.md +72 -0
  177. package/docs/README.zh-CN.md +58 -0
  178. package/docs/architecture/chatgpt-handoff-facade.md +393 -0
  179. package/docs/architecture/current/README.md +158 -0
  180. package/docs/architecture/current/approved-target-architecture.zh-CN.md +1501 -0
  181. package/docs/architecture/current/architecture-invariants.md +331 -0
  182. package/docs/architecture/current/automation-and-schedule-engine.md +453 -0
  183. package/docs/architecture/current/codegraph-context-provider.md +226 -0
  184. package/docs/architecture/current/control-plane-authority-inventory.md +82 -0
  185. package/docs/architecture/current/controller-repository-decoupling.md +148 -0
  186. package/docs/architecture/current/dispatch-and-agent-strategy.md +354 -0
  187. package/docs/architecture/current/entity-model.md +619 -0
  188. package/docs/architecture/current/external-plugin-governance.md +396 -0
  189. package/docs/architecture/current/failure-recovery.md +471 -0
  190. package/docs/architecture/current/governance.md +213 -0
  191. package/docs/architecture/current/human-interaction-plane.md +87 -0
  192. package/docs/architecture/current/implementation-status.md +128 -0
  193. package/docs/architecture/current/job-and-run-lifecycle.md +413 -0
  194. package/docs/architecture/current/local-workspace-target-grants.md +70 -0
  195. package/docs/architecture/current/migration-roadmap.md +105 -0
  196. package/docs/architecture/current/multi-repository-execution.md +371 -0
  197. package/docs/architecture/current/operations-runbook.md +45 -0
  198. package/docs/architecture/current/peer-supercontroller-runtime.md +32 -0
  199. package/docs/architecture/current/personal-assistant-plugin-baseline.md +207 -0
  200. package/docs/architecture/current/repository-access-modes.md +102 -0
  201. package/docs/architecture/current/route-policy.md +15 -0
  202. package/docs/architecture/current/route-session-concurrency.md +85 -0
  203. package/docs/architecture/current/runtime-architecture-simplification.md +214 -0
  204. package/docs/architecture/current/runtime-directory-map.md +49 -0
  205. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +85 -0
  206. package/docs/architecture/current/scheduler-and-resource-claims.md +432 -0
  207. package/docs/architecture/current/session-aware-execution-and-authorization.md +77 -0
  208. package/docs/architecture/current/system-overview.md +144 -0
  209. package/docs/architecture/current/target-requirements-traceability.md +202 -0
  210. package/docs/architecture/current/thin-harness-governance-policy.md +32 -0
  211. package/docs/architecture/current/thin-harness-v1.md +313 -0
  212. package/docs/architecture/current/verification-and-release-gates.md +391 -0
  213. package/docs/architecture/decisions/20260801-controller-home-sqlite-state.md +100 -0
  214. package/docs/architecture/decisions/20260803-canonical-skill-surface.md +40 -0
  215. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +53 -0
  216. package/docs/architecture/diagrams/.gitkeep +1 -0
  217. package/docs/architecture/diagrams/agentic-dev-plugin-architecture.html +186 -0
  218. package/docs/architecture/domains/.gitkeep +0 -0
  219. package/docs/architecture/domains/public-surface.md +30 -0
  220. package/docs/architecture/domains/runtime-harness.md +33 -0
  221. package/docs/architecture/domains/verification.md +33 -0
  222. package/docs/architecture/domains/workflow-engine.md +30 -0
  223. package/docs/architecture/global-hook-runtime.md +162 -0
  224. package/docs/architecture/history.md +38 -0
  225. package/docs/architecture/index.md +114 -0
  226. package/docs/architecture/ios-semantic-automation-provider-v2.md +166 -0
  227. package/docs/architecture/modules/.gitkeep +0 -0
  228. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +24 -0
  229. package/docs/architecture/requests/.gitkeep +1 -0
  230. package/docs/architecture/requests/archive/2026/20260528-154740-ai-hooks-ai-hooks-hook-input-sh.md +59 -0
  231. package/docs/architecture/requests/archive/2026/20260528-154816-assets-hooks-assets-hooks-hook-input-sh.md +59 -0
  232. package/docs/architecture/requests/archive/2026/20260528-161851-ai-harness-workflow-contract-json-ai-harness-workflow-contract-json.md +59 -0
  233. package/docs/architecture/requests/archive/2026/20260528-161853-scripts-check-task-workflow-sh-scripts-check-task-workflow-sh.md +59 -0
  234. package/docs/architecture/requests/archive/2026/20260528-161911-assets-workflow-contract-v1-json-assets-workflow-contract-v1-json.md +59 -0
  235. package/docs/architecture/requests/archive/2026/20260528-222416-ai-hooks-ai-hooks-hook-input-sh.md +59 -0
  236. package/docs/architecture/requests/archive/2026/20260528-222444-ai-hooks-ai-hooks-pre-edit-guard-sh.md +59 -0
  237. package/docs/architecture/requests/archive/2026/20260528-222457-ai-hooks-ai-hooks-pre-edit-guard-sh.md +59 -0
  238. package/docs/architecture/requests/archive/2026/20260528-222648-ai-hooks-ai-hooks-pre-edit-guard-sh.md +59 -0
  239. package/docs/architecture/requests/archive/2026/20260528-222705-ai-hooks-ai-hooks-worktree-guard-sh.md +59 -0
  240. package/docs/architecture/requests/archive/2026/20260528-222726-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  241. package/docs/architecture/requests/archive/2026/20260528-222727-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  242. package/docs/architecture/requests/archive/2026/20260528-224736-ai-hooks-ai-hooks-hook-input-sh.md +59 -0
  243. package/docs/architecture/requests/archive/2026/20260528-233259-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  244. package/docs/architecture/requests/archive/2026/20260528-233335-assets-hooks-assets-hooks-prompt-guard-sh.md +59 -0
  245. package/docs/architecture/requests/archive/2026/20260528-233456-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  246. package/docs/architecture/requests/archive/2026/20260528-233457-assets-hooks-assets-hooks-prompt-guard-sh.md +59 -0
  247. package/docs/architecture/requests/archive/2026/20260528-233637-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  248. package/docs/architecture/requests/archive/2026/20260528-233638-assets-hooks-assets-hooks-prompt-guard-sh.md +59 -0
  249. package/docs/architecture/requests/archive/2026/20260529-000322-root-package-json.md +59 -0
  250. package/docs/architecture/requests/archive/2026/20260529-004238-ai-hooks-ai-hooks-lib-workflow-state-sh.md +59 -0
  251. package/docs/architecture/requests/archive/2026/20260529-020654-ai-harness-policy-json-ai-harness-policy-json.md +63 -0
  252. package/docs/architecture/requests/archive/2026/20260529-094236-ai-hooks-ai-hooks-prompt-guard-sh.md +60 -0
  253. package/docs/architecture/requests/archive/2026/20260529-094323-assets-hooks-assets-hooks-prompt-guard-sh.md +60 -0
  254. package/docs/architecture/requests/archive/2026/20260529-094331-ai-hooks-ai-hooks-prompt-guard-sh.md +60 -0
  255. package/docs/architecture/requests/archive/2026/20260529-094339-assets-hooks-assets-hooks-prompt-guard-sh.md +60 -0
  256. package/docs/architecture/requests/archive/2026/20260529-094430-assets-hooks-assets-hooks-session-start-context-sh.md +60 -0
  257. package/docs/architecture/requests/archive/2026/20260529-094446-ai-hooks-ai-hooks-session-start-context-sh.md +60 -0
  258. package/docs/architecture/requests/archive/2026/20260613-002040-runtime-harness-hook-adapters.md +68 -0
  259. package/docs/architecture/requests/archive/2026/root.md +68 -0
  260. package/docs/architecture/requests/archive/2026/runtime-harness-hook-adapters.md +76 -0
  261. package/docs/architecture/requests/archive/2026/verification-evals-checks.md +68 -0
  262. package/docs/architecture/requests/archive/2026/workflow-engine-contract-assets.md +69 -0
  263. package/docs/architecture/reviews/2026-07-11-access-surface-review.md +99 -0
  264. package/docs/architecture/snapshots/.gitkeep +1 -0
  265. package/docs/forge-autonomous-goal-loop.md +7 -0
  266. package/docs/forge-chatgpt-browser-engine.md +11 -0
  267. package/docs/forge-chatgpt-mcp-setup.md +239 -0
  268. package/docs/forge-chatgpt-supervised-automation.md +11 -0
  269. package/docs/forge-github-issue-launcher.md +7 -0
  270. package/docs/forge-plugin-management.md +95 -0
  271. package/docs/forge-provider-configuration.md +7 -0
  272. package/docs/forge-runtime-self-healing-loop.md +7 -0
  273. package/docs/images/forge-banner-cn.svg +1 -0
  274. package/docs/images/forge-banner.svg +1 -0
  275. package/docs/images/forge-controller-flow.svg +1 -0
  276. package/docs/images/image.png +0 -0
  277. package/docs/images/repo-harness-gptpro-cn.png +0 -0
  278. package/docs/images/repo-harness-gptpro.png +0 -0
  279. package/docs/images/repo-harness-hook-carrot.png +0 -0
  280. package/docs/operations/chatgpt-personal-assistant-runtime.md +83 -0
  281. package/docs/operations/checkout-route-integrity.md +22 -0
  282. package/docs/operations/controller-browser-plugin.md +299 -0
  283. package/docs/operations/controller-desktop-plugin.md +112 -0
  284. package/docs/operations/controller-performance-and-502.md +126 -0
  285. package/docs/operations/controller-reliability-runbook.md +134 -0
  286. package/docs/operations/environment-hardening.md +50 -0
  287. package/docs/operations/features.md +45 -0
  288. package/docs/operations/features.zh-CN.md +45 -0
  289. package/docs/operations/github-repository.md +60 -0
  290. package/docs/operations/gmail-assistant-routines.md +186 -0
  291. package/docs/operations/homebrew.md +26 -0
  292. package/docs/operations/local-system-assistant.md +46 -0
  293. package/docs/operations/local-system-assistant.zh-CN.md +46 -0
  294. package/docs/operations/mcp-tool-exposure.md +58 -0
  295. package/docs/operations/mobile-intents-shortcuts.md +117 -0
  296. package/docs/operations/open-source-release-hygiene.md +31 -0
  297. package/docs/operations/personal-assistant-operator-guide.md +61 -0
  298. package/docs/operations/platform-support.md +62 -0
  299. package/docs/operations/platform-support.zh-CN.md +61 -0
  300. package/docs/operations/provider-configuration.md +31 -0
  301. package/docs/operations/releasing.md +86 -0
  302. package/docs/operations/releasing.zh-CN.md +86 -0
  303. package/docs/operations/route-session-concurrency-baseline.json +893 -0
  304. package/docs/operations/route-session-concurrency-baseline.md +94 -0
  305. package/docs/operations/runtime-performance-diagnostics.md +14 -0
  306. package/docs/operations/sqlite-control-plane-recovery.md +65 -0
  307. package/docs/operations/standalone-disaster-recovery.md +136 -0
  308. package/docs/operations/troubleshooting.md +72 -0
  309. package/docs/operations/troubleshooting.zh-CN.md +70 -0
  310. package/docs/public-usage-guide.md +33 -0
  311. package/docs/public-usage-guide.zh-CN.md +33 -0
  312. package/docs/tutorials/01-install-and-start.md +93 -0
  313. package/docs/tutorials/01-install-and-start.zh-CN.md +93 -0
  314. package/docs/tutorials/02-connect-chatgpt.md +82 -0
  315. package/docs/tutorials/02-connect-chatgpt.zh-CN.md +80 -0
  316. package/docs/tutorials/03-first-repository-task.md +57 -0
  317. package/docs/tutorials/03-first-repository-task.zh-CN.md +57 -0
  318. package/docs/tutorials/README.md +13 -0
  319. package/docs/tutorials/README.zh-CN.md +13 -0
  320. package/docs/wiki/Architecture.md +85 -0
  321. package/docs/wiki/Core-Concepts.md +27 -0
  322. package/docs/wiki/Home.md +36 -0
  323. package/docs/wiki/Implementation.md +172 -0
  324. package/docs/wiki/Installation.md +50 -0
  325. package/docs/wiki/Integrations.md +29 -0
  326. package/docs/wiki/Operations.md +26 -0
  327. package/docs/wiki/Quick-Start.md +46 -0
  328. package/docs/wiki/Releases-and-Upgrades.md +34 -0
  329. package/docs/wiki/Runtime-Architecture.md +50 -0
  330. package/docs/wiki/Security-Model.md +31 -0
  331. package/docs/wiki/Troubleshooting.md +38 -0
  332. package/docs/wiki/Work-Lifecycle.md +31 -0
  333. package/docs/wiki/_Sidebar.md +21 -0
  334. package/install.ps1 +48 -0
  335. package/install.sh +66 -0
  336. package/package.json +156 -0
  337. package/scripts/activate-source-baseline.ts +335 -0
  338. package/scripts/architecture-event.ts +822 -0
  339. package/scripts/architecture-queue.sh +647 -0
  340. package/scripts/archive-architecture-request.sh +244 -0
  341. package/scripts/archive-workflow.sh +295 -0
  342. package/scripts/assemble-template.ts +1172 -0
  343. package/scripts/benchmark-controller-hotpath.ts +212 -0
  344. package/scripts/benchmark-controller-round2.ts +139 -0
  345. package/scripts/benchmark-ios-agent-device.ts +514 -0
  346. package/scripts/benchmark-route-session-concurrency.ts +714 -0
  347. package/scripts/benchmark-thin-harness-gateway-ab.ts +632 -0
  348. package/scripts/benchmark-thin-harness-mcp-e2e.ts +479 -0
  349. package/scripts/benchmark-thin-harness.ts +251 -0
  350. package/scripts/bootstrap-runtime-maintenance-recovery.sh +183 -0
  351. package/scripts/canary-global-hook.sh +263 -0
  352. package/scripts/capability-config.ts +394 -0
  353. package/scripts/capability-resolver.ts +505 -0
  354. package/scripts/capture-plan.sh +335 -0
  355. package/scripts/check-agent-tooling.sh +1549 -0
  356. package/scripts/check-architecture-sync.sh +373 -0
  357. package/scripts/check-brain-manifest.sh +255 -0
  358. package/scripts/check-ci.sh +16 -0
  359. package/scripts/check-context-files.sh +49 -0
  360. package/scripts/check-deploy-sql-order.sh +117 -0
  361. package/scripts/check-mcp-compatibility.ts +93 -0
  362. package/scripts/check-npm-release.sh +32 -0
  363. package/scripts/check-open-source-tracked-surface.sh +146 -0
  364. package/scripts/check-package-identity.mjs +117 -0
  365. package/scripts/check-platform-support.mjs +79 -0
  366. package/scripts/check-public-docs.sh +105 -0
  367. package/scripts/check-public-release-surface.sh +19 -0
  368. package/scripts/check-release-published.sh +57 -0
  369. package/scripts/check-release-readiness.sh +43 -0
  370. package/scripts/check-release-version.mjs +74 -0
  371. package/scripts/check-runtime-architecture.mjs +593 -0
  372. package/scripts/check-runtime-env.mjs +89 -0
  373. package/scripts/check-skill-version.ts +193 -0
  374. package/scripts/check-tarball-install-smoke.sh +66 -0
  375. package/scripts/check-task-sync.sh +65 -0
  376. package/scripts/check-task-workflow.sh +1002 -0
  377. package/scripts/check-third-party-notices.mjs +36 -0
  378. package/scripts/codex-handoff-resume.sh +269 -0
  379. package/scripts/complete-control-plane-cutover.ts +126 -0
  380. package/scripts/context-contract-sync.sh +516 -0
  381. package/scripts/contract-run.ts +465 -0
  382. package/scripts/contract-worktree.sh +725 -0
  383. package/scripts/controller-ngrok-rotation.sh +678 -0
  384. package/scripts/create-project-dirs.sh +282 -0
  385. package/scripts/ensure-codegraph.sh +16 -0
  386. package/scripts/ensure-task-workflow.sh +1218 -0
  387. package/scripts/export-requirement-portfolio.ts +28 -0
  388. package/scripts/factor-lab-check.sh +73 -0
  389. package/scripts/factor-lab-new.sh +96 -0
  390. package/scripts/factor-lab-promote.sh +86 -0
  391. package/scripts/factor-lab-reject.sh +68 -0
  392. package/scripts/harness-trace-grade.sh +150 -0
  393. package/scripts/heartbeat-triage.sh +361 -0
  394. package/scripts/hook-dispatch-diet-report.ts +234 -0
  395. package/scripts/hook-shim.sh +93 -0
  396. package/scripts/init-project.sh +505 -0
  397. package/scripts/initializer-question-pack.ts +200 -0
  398. package/scripts/inspect-project-state.ts +280 -0
  399. package/scripts/install-desktop-operator-registration.ts +45 -0
  400. package/scripts/install-standalone-recovery.ts +74 -0
  401. package/scripts/lib/AGENTS.md +13 -0
  402. package/scripts/lib/CLAUDE.md +13 -0
  403. package/scripts/lib/controller-home-env.sh +69 -0
  404. package/scripts/lib/project-init-lib.sh +2759 -0
  405. package/scripts/load-standalone-recovery.sh +17 -0
  406. package/scripts/loop-engine-cutover-gate.ts +324 -0
  407. package/scripts/maintenance-triage.sh +167 -0
  408. package/scripts/migrate-project-template.sh +1189 -0
  409. package/scripts/migrate-requirement-portfolio.ts +86 -0
  410. package/scripts/migrate-workflow-docs.ts +413 -0
  411. package/scripts/new-plan.sh +183 -0
  412. package/scripts/new-spec.sh +50 -0
  413. package/scripts/new-sprint.sh +49 -0
  414. package/scripts/open-source-audit-allowlist.txt +46 -0
  415. package/scripts/package-source-archive.sh +147 -0
  416. package/scripts/plan-to-todo.sh +799 -0
  417. package/scripts/prepare-codex-handoff.sh +203 -0
  418. package/scripts/prepare-handoff.sh +81 -0
  419. package/scripts/public-release-files.txt +39 -0
  420. package/scripts/public-release.ts +174 -0
  421. package/scripts/publish-release-tarball.sh +27 -0
  422. package/scripts/refresh-current-status.sh +469 -0
  423. package/scripts/refresh-ios-physical-runtime.ts +196 -0
  424. package/scripts/repair-executable-modes.ts +8 -0
  425. package/scripts/rollout-all-registered-repos.sh +7 -0
  426. package/scripts/route-nl-vs-ts-eval.ts +679 -0
  427. package/scripts/run-bun-test-file.ts +182 -0
  428. package/scripts/run-governed-gate.ts +140 -0
  429. package/scripts/run-mobile-monitor-bridge.ts +71 -0
  430. package/scripts/run-skill-evals.ts +1022 -0
  431. package/scripts/run-skill-hook.ts +244 -0
  432. package/scripts/run-supervised-command.ts +24 -0
  433. package/scripts/run-tests-portable.sh +20 -0
  434. package/scripts/select-agent-context-blocks.sh +66 -0
  435. package/scripts/setup-plugins.sh +43 -0
  436. package/scripts/ship-worktrees.sh +561 -0
  437. package/scripts/smoke-browser-cdp-bridge.ts +93 -0
  438. package/scripts/smoke-chatgpt-supervised-automation.ts +208 -0
  439. package/scripts/smoke-ios-physical-device.ts +114 -0
  440. package/scripts/smoke-mcp-http-runtime.ts +216 -0
  441. package/scripts/smoke-mcp-tool-surface.ts +117 -0
  442. package/scripts/smoke-runtime-recovery.ts +220 -0
  443. package/scripts/smoke-schedule-engine.ts +283 -0
  444. package/scripts/sprint-backlog.sh +837 -0
  445. package/scripts/stage-runtime-release.ts +38 -0
  446. package/scripts/summarize-failures.sh +125 -0
  447. package/scripts/switch-plan.sh +153 -0
  448. package/scripts/sync-brain-docs.sh +381 -0
  449. package/scripts/sync-codex-installed-copies.sh +183 -0
  450. package/scripts/test-governance.ts +75 -0
  451. package/scripts/verify-contract.sh +705 -0
  452. package/scripts/verify-forge-runtime.sh +31 -0
  453. package/scripts/verify-sprint.sh +680 -0
  454. package/scripts/windows-paths.d.mts +7 -0
  455. package/scripts/windows-paths.mjs +28 -0
  456. package/scripts/workflow-contract.ts +206 -0
  457. package/scripts/workstream-sync.sh +263 -0
  458. package/src/cli/agent-jobs/executable-resolver.ts +289 -0
  459. package/src/cli/agent-jobs/executor-health.ts +252 -0
  460. package/src/cli/agent-jobs/integration.ts +963 -0
  461. package/src/cli/agent-jobs/job-manager.ts +2030 -0
  462. package/src/cli/agent-jobs/types.ts +219 -0
  463. package/src/cli/agent-jobs/worker-lifecycle.ts +288 -0
  464. package/src/cli/chatgpt-browser/bind-server.ts +504 -0
  465. package/src/cli/chatgpt-browser/binding.ts +140 -0
  466. package/src/cli/chatgpt-browser/bridge-extension.ts +221 -0
  467. package/src/cli/chatgpt-browser/bridge-provider.ts +268 -0
  468. package/src/cli/chatgpt-browser/engine.ts +569 -0
  469. package/src/cli/chatgpt-browser/file-policy.ts +158 -0
  470. package/src/cli/chatgpt-browser/native-provider.ts +553 -0
  471. package/src/cli/chatgpt-browser/oracle-provider.ts +437 -0
  472. package/src/cli/chatgpt-browser/prompt-assembler.ts +29 -0
  473. package/src/cli/chatgpt-browser/session-store.ts +301 -0
  474. package/src/cli/chatgpt-browser/types.ts +172 -0
  475. package/src/cli/commands/adopt-plan.ts +186 -0
  476. package/src/cli/commands/brain-root.ts +132 -0
  477. package/src/cli/commands/brain.ts +609 -0
  478. package/src/cli/commands/capability-context.ts +613 -0
  479. package/src/cli/commands/chatgpt.ts +424 -0
  480. package/src/cli/commands/controller.ts +680 -0
  481. package/src/cli/commands/docs.ts +103 -0
  482. package/src/cli/commands/doctor.ts +476 -0
  483. package/src/cli/commands/global-runtime.ts +323 -0
  484. package/src/cli/commands/hook.ts +34 -0
  485. package/src/cli/commands/init-hook.ts +870 -0
  486. package/src/cli/commands/init.ts +829 -0
  487. package/src/cli/commands/install.ts +86 -0
  488. package/src/cli/commands/mcp.ts +315 -0
  489. package/src/cli/commands/migrate.ts +154 -0
  490. package/src/cli/commands/plugin.ts +74 -0
  491. package/src/cli/commands/prompt-guard-decision.ts +238 -0
  492. package/src/cli/commands/recovery.ts +667 -0
  493. package/src/cli/commands/repository.ts +224 -0
  494. package/src/cli/commands/run.ts +23 -0
  495. package/src/cli/commands/runtime.ts +191 -0
  496. package/src/cli/commands/security.ts +440 -0
  497. package/src/cli/commands/status.ts +137 -0
  498. package/src/cli/commands/tools.ts +167 -0
  499. package/src/cli/controller/check-runner.ts +1022 -0
  500. package/src/cli/controller/codex-continuation.ts +153 -0
  501. package/src/cli/controller/completion-backlog.ts +380 -0
  502. package/src/cli/controller/completion-orchestrator.ts +1807 -0
  503. package/src/cli/controller/completion-target.ts +96 -0
  504. package/src/cli/controller/composite-operations.ts +196 -0
  505. package/src/cli/controller/composite-result.ts +130 -0
  506. package/src/cli/controller/context-pack.ts +652 -0
  507. package/src/cli/controller/execution-completion.ts +175 -0
  508. package/src/cli/controller/execution-policy.ts +392 -0
  509. package/src/cli/controller/governance.ts +594 -0
  510. package/src/cli/controller/issue-store.ts +1421 -0
  511. package/src/cli/controller/launch-agents.ts +410 -0
  512. package/src/cli/controller/legacy-issue-cutover.ts +415 -0
  513. package/src/cli/controller/lifecycle-authority.ts +16 -0
  514. package/src/cli/controller/migrated-issue-decision.ts +237 -0
  515. package/src/cli/controller/operational-plan.ts +226 -0
  516. package/src/cli/controller/postcondition.ts +134 -0
  517. package/src/cli/controller/progress.ts +656 -0
  518. package/src/cli/controller/project-state.ts +147 -0
  519. package/src/cli/controller/requirement-portfolio-export.ts +146 -0
  520. package/src/cli/controller/requirement-portfolio-migration.ts +613 -0
  521. package/src/cli/controller/run-evidence.ts +43 -0
  522. package/src/cli/controller/runtime-config.ts +92 -0
  523. package/src/cli/controller/stuck-state-migration.ts +396 -0
  524. package/src/cli/controller/task-ledger.ts +632 -0
  525. package/src/cli/controller/task-status-resolver.ts +403 -0
  526. package/src/cli/controller/types.ts +273 -0
  527. package/src/cli/controller/work-mode.ts +186 -0
  528. package/src/cli/controller/worklog.ts +201 -0
  529. package/src/cli/diagnostic-entry.ts +39 -0
  530. package/src/cli/editing/edit-session.ts +1606 -0
  531. package/src/cli/editing/executable-modes.ts +70 -0
  532. package/src/cli/github/github.ts +274 -0
  533. package/src/cli/github/plugin.ts +345 -0
  534. package/src/cli/github/session.ts +104 -0
  535. package/src/cli/hook/prompt-guard-decision.ts +238 -0
  536. package/src/cli/hook/prompt-intents.ts +534 -0
  537. package/src/cli/hook/route-registry.ts +107 -0
  538. package/src/cli/hook/runtime.ts +303 -0
  539. package/src/cli/hook/state-snapshot.ts +388 -0
  540. package/src/cli/hook-entry.ts +51 -0
  541. package/src/cli/index.ts +574 -0
  542. package/src/cli/installer/managed-entries.ts +78 -0
  543. package/src/cli/installer/shared.ts +65 -0
  544. package/src/cli/installer/targets/claude.ts +136 -0
  545. package/src/cli/installer/targets/codex.ts +174 -0
  546. package/src/cli/installer/targets/registry.ts +28 -0
  547. package/src/cli/installer/types.ts +72 -0
  548. package/src/cli/local-bridge/connector-freshness.ts +529 -0
  549. package/src/cli/local-bridge/console-view-models.ts +324 -0
  550. package/src/cli/local-bridge/dashboard.ts +1312 -0
  551. package/src/cli/local-bridge/facade-api.ts +1877 -0
  552. package/src/cli/local-bridge/job-store.ts +1451 -0
  553. package/src/cli/local-bridge/mobile-intents.ts +344 -0
  554. package/src/cli/local-bridge/mobile-monitor.ts +209 -0
  555. package/src/cli/local-bridge/server.ts +3183 -0
  556. package/src/cli/local-bridge/types.ts +176 -0
  557. package/src/cli/mcp/access-mode.ts +132 -0
  558. package/src/cli/mcp/access-tools.ts +387 -0
  559. package/src/cli/mcp/audit.ts +32 -0
  560. package/src/cli/mcp/auth.ts +402 -0
  561. package/src/cli/mcp/instructions.ts +33 -0
  562. package/src/cli/mcp/legacy-context.ts +16 -0
  563. package/src/cli/mcp/legacy-tool-service.ts +5516 -0
  564. package/src/cli/mcp/multi-repository.ts +423 -0
  565. package/src/cli/mcp/oauth.ts +226 -0
  566. package/src/cli/mcp/paths.ts +134 -0
  567. package/src/cli/mcp/policy.ts +230 -0
  568. package/src/cli/mcp/proxy-env.ts +62 -0
  569. package/src/cli/mcp/readiness-projection.ts +24 -0
  570. package/src/cli/mcp/redaction.ts +21 -0
  571. package/src/cli/mcp/repo.ts +24 -0
  572. package/src/cli/mcp/repository-tools.ts +1295 -0
  573. package/src/cli/mcp/server.ts +341 -0
  574. package/src/cli/mcp/setup.ts +793 -0
  575. package/src/cli/mcp/tool-capability-cache.ts +87 -0
  576. package/src/cli/mcp/tools.ts +9 -0
  577. package/src/cli/mcp/toolset-names.ts +70 -0
  578. package/src/cli/mcp/toolset-selection.ts +41 -0
  579. package/src/cli/mcp/toolset.ts +300 -0
  580. package/src/cli/mcp/transports/AGENTS.md +39 -0
  581. package/src/cli/mcp/transports/CLAUDE.md +39 -0
  582. package/src/cli/mcp/transports/http.ts +1234 -0
  583. package/src/cli/mcp/transports/session-registry.ts +428 -0
  584. package/src/cli/mcp/transports/stdio.ts +8 -0
  585. package/src/cli/mcp/types.ts +41 -0
  586. package/src/cli/mcp/umbrella-tools.ts +117 -0
  587. package/src/cli/repo-adoption/reclaim-runtime.ts +681 -0
  588. package/src/cli/repositories/branch-name-policy.ts +57 -0
  589. package/src/cli/repositories/command-classifier.ts +600 -0
  590. package/src/cli/repositories/command-executor.ts +1066 -0
  591. package/src/cli/repositories/command-normalization.ts +53 -0
  592. package/src/cli/repositories/command-scope.ts +264 -0
  593. package/src/cli/repositories/controller-home.ts +88 -0
  594. package/src/cli/repositories/entity-migration.ts +199 -0
  595. package/src/cli/repositories/ephemeral-workspace.ts +62 -0
  596. package/src/cli/repositories/git-command-executor.ts +301 -0
  597. package/src/cli/repositories/goal-registry.ts +239 -0
  598. package/src/cli/repositories/identity.ts +64 -0
  599. package/src/cli/repositories/local-project-onboarding.ts +539 -0
  600. package/src/cli/repositories/locks.ts +262 -0
  601. package/src/cli/repositories/registry.ts +1057 -0
  602. package/src/cli/repositories/runtime-storage.ts +411 -0
  603. package/src/cli/repositories/safe-patch.ts +421 -0
  604. package/src/cli/repositories/selected-path-actions.ts +341 -0
  605. package/src/cli/repositories/structured-git.ts +257 -0
  606. package/src/cli/repositories/types.ts +155 -0
  607. package/src/cli/repositories/umbrella.ts +134 -0
  608. package/src/cli/repositories/workbench.ts +140 -0
  609. package/src/cli/repositories/worktree-storage.ts +137 -0
  610. package/src/cli/repository/inspector.ts +637 -0
  611. package/src/cli/repository/session-cache.ts +453 -0
  612. package/src/cli/runtime/helper-runner.ts +172 -0
  613. package/src/cli/runtime-invocation.ts +318 -0
  614. package/src/cli/tools/codegraph.ts +676 -0
  615. package/src/cli/v81-entry.ts +29 -0
  616. package/src/core/adoption/gitignore-plan.ts +127 -0
  617. package/src/core/adoption/helper-wrapper-plan.ts +159 -0
  618. package/src/core/adoption/manifest-templates.ts +60 -0
  619. package/src/core/adoption/modes.ts +7 -0
  620. package/src/core/adoption/operations.ts +126 -0
  621. package/src/core/adoption/plan.ts +149 -0
  622. package/src/core/adoption/render.ts +70 -0
  623. package/src/core/adoption/rollback.ts +62 -0
  624. package/src/core/adoption/summary.ts +43 -0
  625. package/src/core/adoption/workflow-contract-asset.ts +14 -0
  626. package/src/core/adoption/workflow-contract-plan.ts +31 -0
  627. package/src/effects/fs-transaction.ts +540 -0
  628. package/src/effects/git-executable.ts +59 -0
  629. package/src/effects/managed-block.ts +86 -0
  630. package/src/effects/path-safety.ts +47 -0
  631. package/src/effects/process-runner.ts +192 -0
  632. package/src/runtime/assistant/action-proposals.ts +279 -0
  633. package/src/runtime/assistant/intent.ts +395 -0
  634. package/src/runtime/assistant/model-provider.ts +460 -0
  635. package/src/runtime/assistant/openapi.ts +223 -0
  636. package/src/runtime/assistant/policy.ts +89 -0
  637. package/src/runtime/assistant/readiness.ts +258 -0
  638. package/src/runtime/assistant/routine-runtime.ts +477 -0
  639. package/src/runtime/assistant/schedule-binding.ts +171 -0
  640. package/src/runtime/assistant/standing-grants.ts +375 -0
  641. package/src/runtime/assistant/store.ts +243 -0
  642. package/src/runtime/assistant/types.ts +135 -0
  643. package/src/runtime/context/codegraph-read-provider.ts +347 -0
  644. package/src/runtime/context/codegraph-sidecar.cjs +207 -0
  645. package/src/runtime/control-plane/dispatch-priority.ts +67 -0
  646. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +151 -0
  647. package/src/runtime/control-plane/execution/execution-identity.ts +456 -0
  648. package/src/runtime/control-plane/execution/session-store.ts +175 -0
  649. package/src/runtime/control-plane/execution/validation.ts +117 -0
  650. package/src/runtime/control-plane/execution/verification-evidence.ts +191 -0
  651. package/src/runtime/control-plane/execution/work-handle-store.ts +225 -0
  652. package/src/runtime/control-plane/execution/work-prepare-request-store.ts +159 -0
  653. package/src/runtime/control-plane/execution/work-task-receipt.ts +445 -0
  654. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +821 -0
  655. package/src/runtime/control-plane/facade/capability-registry.ts +279 -0
  656. package/src/runtime/control-plane/facade/check-normalization.ts +233 -0
  657. package/src/runtime/control-plane/facade/codex-delegation.ts +196 -0
  658. package/src/runtime/control-plane/facade/controller-session-store.ts +199 -0
  659. package/src/runtime/control-plane/facade/facade-result.ts +41 -0
  660. package/src/runtime/control-plane/facade/goal-workloop-access.ts +177 -0
  661. package/src/runtime/control-plane/facade/goal-workloop.ts +1216 -0
  662. package/src/runtime/control-plane/facade/handoff-inbox-store.ts +221 -0
  663. package/src/runtime/control-plane/facade/index.ts +16 -0
  664. package/src/runtime/control-plane/facade/operation-digest.ts +528 -0
  665. package/src/runtime/control-plane/facade/plan-contract-store.ts +403 -0
  666. package/src/runtime/control-plane/facade/policy-gate.ts +149 -0
  667. package/src/runtime/control-plane/facade/requirement-board.ts +280 -0
  668. package/src/runtime/control-plane/facade/self-healing-loop.ts +555 -0
  669. package/src/runtime/control-plane/facade/suggested-actions.ts +108 -0
  670. package/src/runtime/control-plane/facade/types.ts +729 -0
  671. package/src/runtime/control-plane/facade/work-admission-policy.ts +93 -0
  672. package/src/runtime/control-plane/facade/work-continuation.ts +104 -0
  673. package/src/runtime/control-plane/facade/work-contract-store.ts +1118 -0
  674. package/src/runtime/control-plane/facade/work-task-projection.ts +66 -0
  675. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +365 -0
  676. package/src/runtime/control-plane/global-scheduler/scheduler.ts +920 -0
  677. package/src/runtime/control-plane/global-scheduler/wake-signal.ts +89 -0
  678. package/src/runtime/control-plane/goal-loop/config-facade.ts +700 -0
  679. package/src/runtime/control-plane/goal-loop/config-store.ts +570 -0
  680. package/src/runtime/control-plane/goal-loop/config-types.ts +213 -0
  681. package/src/runtime/control-plane/goal-loop/executor-router.ts +109 -0
  682. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +149 -0
  683. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +304 -0
  684. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +1001 -0
  685. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +160 -0
  686. package/src/runtime/control-plane/goal-loop/index.ts +13 -0
  687. package/src/runtime/control-plane/goal-loop/local-tools.ts +290 -0
  688. package/src/runtime/control-plane/goal-loop/policy-gates.ts +203 -0
  689. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +93 -0
  690. package/src/runtime/control-plane/goal-loop/provider-registry.ts +545 -0
  691. package/src/runtime/control-plane/goal-loop/types.ts +378 -0
  692. package/src/runtime/control-plane/governance/access-policy.ts +147 -0
  693. package/src/runtime/control-plane/governance/authorization.ts +195 -0
  694. package/src/runtime/control-plane/governance/external-effects.ts +43 -0
  695. package/src/runtime/control-plane/launcher/thin-launcher.ts +97 -0
  696. package/src/runtime/control-plane/persistence/metadata-payload-policy.ts +53 -0
  697. package/src/runtime/control-plane/persistence/requirement-store.ts +241 -0
  698. package/src/runtime/control-plane/persistence/sqlite-store.ts +539 -0
  699. package/src/runtime/control-plane/release-retention.ts +447 -0
  700. package/src/runtime/control-plane/repo-actor/actor.ts +181 -0
  701. package/src/runtime/control-plane/repo-actor/registry.ts +20 -0
  702. package/src/runtime/control-plane/routing/index.ts +1 -0
  703. package/src/runtime/control-plane/routing/route-policy.ts +379 -0
  704. package/src/runtime/control-plane/runtime-cleanup.ts +727 -0
  705. package/src/runtime/control-plane/runtime-generation.ts +509 -0
  706. package/src/runtime/control-plane/runtime-pid-ownership.ts +11 -0
  707. package/src/runtime/control-plane/runtime-status-client.ts +90 -0
  708. package/src/runtime/control-plane/startup-recovery.ts +198 -0
  709. package/src/runtime/diagnostics/connectivity-observability.ts +475 -0
  710. package/src/runtime/diagnostics/mcp-timing.ts +64 -0
  711. package/src/runtime/diagnostics/performance.ts +379 -0
  712. package/src/runtime/diagnostics/process-facade.ts +350 -0
  713. package/src/runtime/diagnostics/read-only-tool.ts +226 -0
  714. package/src/runtime/evidence/artifact-store.ts +154 -0
  715. package/src/runtime/evidence/event-ledger.ts +80 -0
  716. package/src/runtime/evidence/evidence-store.ts +68 -0
  717. package/src/runtime/evidence/process-check-receipt.ts +43 -0
  718. package/src/runtime/evidence/result-store.ts +209 -0
  719. package/src/runtime/evidence/sensitive-output.ts +353 -0
  720. package/src/runtime/execution/jobs/child-reference.ts +124 -0
  721. package/src/runtime/execution/jobs/legacy-adapter.ts +22 -0
  722. package/src/runtime/execution/jobs/receipt-recovery.ts +119 -0
  723. package/src/runtime/execution/jobs/receipt-store.ts +243 -0
  724. package/src/runtime/execution/jobs/store.ts +737 -0
  725. package/src/runtime/execution/jobs/timeouts.ts +109 -0
  726. package/src/runtime/execution/jobs/types.ts +230 -0
  727. package/src/runtime/execution/jobs/wait.ts +45 -0
  728. package/src/runtime/execution/process-runtime/canary.ts +26 -0
  729. package/src/runtime/execution/process-runtime/check-facade.ts +272 -0
  730. package/src/runtime/execution/process-runtime/check-receipt.ts +185 -0
  731. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +60 -0
  732. package/src/runtime/execution/process-runtime/command-facade.ts +321 -0
  733. package/src/runtime/execution/process-runtime/gc.ts +171 -0
  734. package/src/runtime/execution/process-runtime/index.ts +9 -0
  735. package/src/runtime/execution/process-runtime/interactive-admission.ts +23 -0
  736. package/src/runtime/execution/process-runtime/process-runner-entry.ts +466 -0
  737. package/src/runtime/execution/process-runtime/resource-claims.ts +440 -0
  738. package/src/runtime/execution/process-runtime/runtime.ts +1816 -0
  739. package/src/runtime/execution/process-runtime/store.ts +687 -0
  740. package/src/runtime/execution/process-runtime/types.ts +352 -0
  741. package/src/runtime/execution/thin-harness/async-process.ts +181 -0
  742. package/src/runtime/execution/thin-harness/batch-executor.ts +671 -0
  743. package/src/runtime/execution/thin-harness/execution-router.ts +700 -0
  744. package/src/runtime/execution/thin-harness/fast-executor.ts +1263 -0
  745. package/src/runtime/execution/thin-harness/fast-receipt.ts +235 -0
  746. package/src/runtime/execution/thin-harness/fingerprint-worker.ts +191 -0
  747. package/src/runtime/execution/thin-harness/index.ts +13 -0
  748. package/src/runtime/execution/thin-harness/latency-trace.ts +107 -0
  749. package/src/runtime/execution/thin-harness/lightweight-lanes.ts +745 -0
  750. package/src/runtime/execution/thin-harness/mutation-gate.ts +578 -0
  751. package/src/runtime/execution/thin-harness/proposal-store.ts +409 -0
  752. package/src/runtime/execution/thin-harness/request-ledger.ts +595 -0
  753. package/src/runtime/execution/thin-harness/search-worker.ts +114 -0
  754. package/src/runtime/execution/thin-harness/types.ts +313 -0
  755. package/src/runtime/execution/thin-harness/workspace-savepoint.ts +333 -0
  756. package/src/runtime/execution/workers/executor.ts +687 -0
  757. package/src/runtime/execution/workers/ownership.ts +94 -0
  758. package/src/runtime/execution/workers/worker-entry.ts +277 -0
  759. package/src/runtime/gateway/mcp/execution-tools.ts +2009 -0
  760. package/src/runtime/gateway/mcp/persisted-check-process.ts +255 -0
  761. package/src/runtime/gateway/mcp/process-tools.ts +230 -0
  762. package/src/runtime/gateway/mcp/resource-policy.ts +195 -0
  763. package/src/runtime/gateway/mcp/router.ts +1088 -0
  764. package/src/runtime/gateway/mcp/runtime-tools.ts +5994 -0
  765. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +219 -0
  766. package/src/runtime/health/evaluator.ts +474 -0
  767. package/src/runtime/health/index.ts +2 -0
  768. package/src/runtime/health/operational-view.ts +198 -0
  769. package/src/runtime/maintenance/cleanup.ts +419 -0
  770. package/src/runtime/model-clients/deepseek-adapter.ts +312 -0
  771. package/src/runtime/model-clients/index.ts +2 -0
  772. package/src/runtime/model-clients/types.ts +101 -0
  773. package/src/runtime/observability/gateway-contention-metrics.ts +129 -0
  774. package/src/runtime/observability/route-session-benchmark.ts +110 -0
  775. package/src/runtime/personal-assistant/gmail-triage-manager.ts +231 -0
  776. package/src/runtime/personal-assistant/reporting-runtime.ts +344 -0
  777. package/src/runtime/personal-assistant/triage-runtime.ts +748 -0
  778. package/src/runtime/plugins/app-store-connect-adapter.ts +868 -0
  779. package/src/runtime/plugins/browser-adapter.ts +2812 -0
  780. package/src/runtime/plugins/browser-automation-service.ts +46 -0
  781. package/src/runtime/plugins/browser-handoff-host.ts +304 -0
  782. package/src/runtime/plugins/browser-handoff.ts +371 -0
  783. package/src/runtime/plugins/browser-macos-bridge.ts +865 -0
  784. package/src/runtime/plugins/browser-node-bridge-host.ts +64 -0
  785. package/src/runtime/plugins/browser-node-bridge.ts +249 -0
  786. package/src/runtime/plugins/desktop-adapter.ts +374 -0
  787. package/src/runtime/plugins/desktop-operator-registration.ts +418 -0
  788. package/src/runtime/plugins/errors.ts +39 -0
  789. package/src/runtime/plugins/execution-origin.ts +26 -0
  790. package/src/runtime/plugins/external-adapter.ts +322 -0
  791. package/src/runtime/plugins/external-registration.ts +308 -0
  792. package/src/runtime/plugins/external-unix-socket-probe.cjs +79 -0
  793. package/src/runtime/plugins/external-unix-socket.ts +238 -0
  794. package/src/runtime/plugins/first-party-registry.ts +37 -0
  795. package/src/runtime/plugins/github-adapter.ts +266 -0
  796. package/src/runtime/plugins/gmail-adapter.ts +729 -0
  797. package/src/runtime/plugins/google-calendar-adapter.ts +468 -0
  798. package/src/runtime/plugins/google-shared.ts +578 -0
  799. package/src/runtime/plugins/google-tasks-adapter.ts +535 -0
  800. package/src/runtime/plugins/interaction-session.ts +252 -0
  801. package/src/runtime/plugins/ios/agent-device-capabilities.ts +258 -0
  802. package/src/runtime/plugins/ios/agent-device-failures.ts +95 -0
  803. package/src/runtime/plugins/ios/agent-device-provider.ts +63 -0
  804. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +268 -0
  805. package/src/runtime/plugins/ios/app-adapters.ts +122 -0
  806. package/src/runtime/plugins/ios-adapter.ts +509 -0
  807. package/src/runtime/plugins/ios-agent-device.ts +2311 -0
  808. package/src/runtime/plugins/ios-physical-device.ts +955 -0
  809. package/src/runtime/plugins/local-system-adapter.ts +1016 -0
  810. package/src/runtime/plugins/macos-capability-broker.ts +73 -0
  811. package/src/runtime/plugins/managed-process-adapter.ts +366 -0
  812. package/src/runtime/plugins/resend-adapter.ts +506 -0
  813. package/src/runtime/plugins/store.ts +1025 -0
  814. package/src/runtime/plugins/types.ts +138 -0
  815. package/src/runtime/projections/controller-context.ts +596 -0
  816. package/src/runtime/projections/git-status-sampler.ts +84 -0
  817. package/src/runtime/projections/invalidation.ts +250 -0
  818. package/src/runtime/projections/materialized-view.ts +694 -0
  819. package/src/runtime/recovery/actions.ts +158 -0
  820. package/src/runtime/recovery/audit.ts +38 -0
  821. package/src/runtime/recovery/classifier.ts +163 -0
  822. package/src/runtime/recovery/index.ts +9 -0
  823. package/src/runtime/recovery/local-jobs-repair.ts +393 -0
  824. package/src/runtime/recovery/maintenance-executor.ts +822 -0
  825. package/src/runtime/recovery/patch-handoff.ts +50 -0
  826. package/src/runtime/recovery/probe.ts +308 -0
  827. package/src/runtime/recovery/store.ts +35 -0
  828. package/src/runtime/recovery/types.ts +175 -0
  829. package/src/runtime/release/identity.ts +101 -0
  830. package/src/runtime/release/release-gate.ts +137 -0
  831. package/src/runtime/resources/claims/conflicts.ts +205 -0
  832. package/src/runtime/resources/index.ts +1 -0
  833. package/src/runtime/resources/leases/store.ts +539 -0
  834. package/src/runtime/resources/leases/types.ts +50 -0
  835. package/src/runtime/resources/ownership.ts +72 -0
  836. package/src/runtime/root/controller-services.ts +42 -0
  837. package/src/runtime/root/entry.ts +70 -0
  838. package/src/runtime/root/gateway-adapter.ts +46 -0
  839. package/src/runtime/root/local-bridge.ts +72 -0
  840. package/src/runtime/root/mcp-transport.ts +198 -0
  841. package/src/runtime/root/ownership.ts +94 -0
  842. package/src/runtime/root/readiness.ts +69 -0
  843. package/src/runtime/root/release-manifest.ts +147 -0
  844. package/src/runtime/root/release-materialize.ts +509 -0
  845. package/src/runtime/root/release-store.ts +236 -0
  846. package/src/runtime/root/runtime.ts +318 -0
  847. package/src/runtime/root/scheduler.ts +58 -0
  848. package/src/runtime/root/service-runner-entry.ts +20 -0
  849. package/src/runtime/root/service-runner.ts +69 -0
  850. package/src/runtime/root/service.ts +291 -0
  851. package/src/runtime/root/status.ts +127 -0
  852. package/src/runtime/root/types.ts +112 -0
  853. package/src/runtime/root/write-fence.ts +309 -0
  854. package/src/runtime/safe-tooling/browser-safe.ts +133 -0
  855. package/src/runtime/safe-tooling/external-filesystem.ts +304 -0
  856. package/src/runtime/safe-tooling/google-credential-store.ts +95 -0
  857. package/src/runtime/safe-tooling/google-oauth-broker.ts +286 -0
  858. package/src/runtime/safe-tooling/index.ts +11 -0
  859. package/src/runtime/safe-tooling/ios-development.ts +1039 -0
  860. package/src/runtime/safe-tooling/job-summary.ts +361 -0
  861. package/src/runtime/safe-tooling/plugin-summary.ts +53 -0
  862. package/src/runtime/safe-tooling/resend-oauth.ts +387 -0
  863. package/src/runtime/safe-tooling/review-artifacts.ts +133 -0
  864. package/src/runtime/safe-tooling/types.ts +103 -0
  865. package/src/runtime/safe-tooling/workspace-auth.ts +116 -0
  866. package/src/runtime/shared/bounded-child-supervisor.ts +235 -0
  867. package/src/runtime/shared/idempotent-retry.ts +128 -0
  868. package/src/runtime/shared/json-files.ts +43 -0
  869. package/src/runtime/shared/local-bridge-surface.ts +231 -0
  870. package/src/runtime/shared/managed-env.ts +154 -0
  871. package/src/runtime/shared/node-ts-loader.mjs +50 -0
  872. package/src/runtime/shared/process-environment.ts +97 -0
  873. package/src/runtime/shared/process-identity.ts +72 -0
  874. package/src/runtime/shared/process-tree.ts +227 -0
  875. package/src/runtime/shared/response-budget.ts +246 -0
  876. package/src/runtime/standalone-recovery/core.ts +2275 -0
  877. package/src/runtime/standalone-recovery/entry.ts +686 -0
  878. package/src/runtime/standalone-recovery/http-transport.ts +317 -0
  879. package/src/runtime/standalone-recovery/installer.ts +747 -0
  880. package/src/runtime/standalone-recovery/release.ts +241 -0
  881. package/src/runtime/watchdog/workflow-watchdog.ts +118 -0
  882. package/src/runtime/workflow/campaigns/cleanup.ts +263 -0
  883. package/src/runtime/workflow/campaigns/engine.ts +112 -0
  884. package/src/runtime/workflow/campaigns/normalize.ts +100 -0
  885. package/src/runtime/workflow/campaigns/review.ts +315 -0
  886. package/src/runtime/workflow/campaigns/store.ts +526 -0
  887. package/src/runtime/workflow/campaigns/supervisor.ts +112 -0
  888. package/src/runtime/workflow/campaigns/types.ts +290 -0
  889. package/src/runtime/workflow/campaigns/workspace-agent.ts +155 -0
  890. package/src/runtime/workflow/campaigns/workspace.ts +200 -0
  891. package/src/runtime/workflow/findings/store.ts +165 -0
  892. package/src/runtime/workflow/findings/types.ts +36 -0
  893. package/src/runtime/workflow/portfolio/engine.ts +20 -0
  894. package/src/runtime/workflow/portfolio/store.ts +185 -0
  895. package/src/runtime/workflow/portfolio/types.ts +29 -0
  896. package/src/runtime/workflow/schedules/engine.ts +620 -0
  897. package/src/runtime/workflow/schedules/settlement.ts +144 -0
  898. package/src/runtime/workflow/schedules/store.ts +417 -0
  899. package/src/runtime/workflow/schedules/types.ts +124 -0
  900. package/src/runtime/workspace-targets/index.ts +24 -0
  901. package/src/runtime/workspace-targets/target-grant.ts +721 -0
  902. package/src/testing/test-governance.ts +626 -0
  903. package/src/version.ts +21 -0
@@ -0,0 +1,2275 @@
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'crypto';
2
+ import { spawn, spawnSync } from 'child_process';
3
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { basename, dirname, isAbsolute, join, resolve } from 'path';
6
+ import { observeRuntimeStatus } from '../root/status';
7
+ import { ensureForgeRuntimeLaunchAgentContract, forgeRuntimeServicePaths, readForgeRuntimeServiceConfig } from '../root/service';
8
+ import { loadRuntimeReleaseManifest } from '../root/release-manifest';
9
+ import { assertRuntimeReleaseFiles, stageRuntimeReleaseFromCandidateSource, type StagedRuntimeRelease } from '../root/release-materialize';
10
+ import {
11
+ publishRuntimeRelease,
12
+ readRuntimeReleaseAuthority,
13
+ rollbackRuntimeRelease,
14
+ type RuntimePublishedRelease,
15
+ type RuntimeReleaseAuthority,
16
+ } from '../root/release-store';
17
+ import type { RuntimeReleaseManifest } from '../root/types';
18
+ import { createRecoveryHttpTransport, type RecoveryHttpTransport } from './http-transport';
19
+
20
+ /** Standalone recovery reads only canonical Runtime observation and whole-release authority. */
21
+ export interface PublicTunnelServiceConfig {
22
+ platform: 'launchd';
23
+ label: string;
24
+ plistPath?: string;
25
+ minimumFailures?: number;
26
+ minimumFailureDurationMs?: number;
27
+ cooldownMs?: number;
28
+ postRestartVerifyTimeoutMs?: number;
29
+ }
30
+
31
+ export interface PrimaryRuntimeServiceConfig {
32
+ platform: 'launchd';
33
+ minimumFailures?: number;
34
+ minimumFailureDurationMs?: number;
35
+ restartCooldownMs?: number;
36
+ maximumRestartAttempts?: number;
37
+ /** Continuous healthy time required before the same release earns a fresh restart budget. */
38
+ restartBudgetStableDurationMs?: number;
39
+ recoveryCooldownMs?: number;
40
+ postRestartVerifyTimeoutMs?: number;
41
+ }
42
+
43
+ export interface PrimaryConnectorServiceConfig {
44
+ platform: 'launchd';
45
+ label: string;
46
+ plistPath?: string;
47
+ postRestartVerifyTimeoutMs?: number;
48
+ }
49
+
50
+ export interface RecoveryConfig {
51
+ schemaVersion: 1;
52
+ controllerHome: string;
53
+ publicMcpUrl?: string;
54
+ recoveryPublicUrl?: string;
55
+ recoveryTunnelService?: PublicTunnelServiceConfig;
56
+ primaryRuntimeService?: PrimaryRuntimeServiceConfig;
57
+ primaryRuntimeSourceRoot?: string;
58
+ primaryConnectorService?: PrimaryConnectorServiceConfig;
59
+ mainMcpTokenFile?: string;
60
+ expectedToolFingerprint?: string;
61
+ readOnlyTool?: { name: string; arguments?: Record<string, unknown> };
62
+ gateway?: { host: string; port: number; bearerTokenFile: string };
63
+ }
64
+
65
+ interface ReleaseEvidence {
66
+ path: string;
67
+ revision: string;
68
+ artifactIdentity: string;
69
+ manifestSha256: string;
70
+ workerProtocolVersion: number;
71
+ controllerHome?: string;
72
+ releaseAuthorityRevision?: number;
73
+ releaseFencingTokenSha256?: string;
74
+ attestedAt?: string;
75
+ }
76
+
77
+ interface KnownGoodStore {
78
+ schemaVersion: 1;
79
+ releases: ReleaseEvidence[];
80
+ updatedAt: string;
81
+ }
82
+
83
+ export type RecoveryMutationAction =
84
+ | 'attest_known_good'
85
+ | 'rollback_previous'
86
+ | 'restart_primary_runtime'
87
+ | 'recover_primary_runtime'
88
+ | 'activate_runtime_release'
89
+ | 'stage_and_activate_runtime_release'
90
+ | 'restart_primary_connector'
91
+ | 'restart_recovery_gateway'
92
+ | 'repair_public_tunnel';
93
+
94
+ interface RecoveryLock {
95
+ schemaVersion?: 1;
96
+ pid: number;
97
+ instanceId: string;
98
+ processStartTime?: string;
99
+ acquiredAt: string;
100
+ action?: RecoveryMutationAction;
101
+ requestId?: string;
102
+ }
103
+
104
+ interface RecoveryLockIntent {
105
+ action: RecoveryMutationAction;
106
+ requestId?: string;
107
+ }
108
+
109
+ type RecoveryLockAttempt<T> =
110
+ | { acquired: true; value: T }
111
+ | { acquired: false; owner: RecoveryLock };
112
+
113
+ export interface VerifyResult {
114
+ ok: boolean;
115
+ at: string;
116
+ runtime: { ok: boolean; running: boolean; ready: boolean; stale: boolean; reasonCodes: string[] };
117
+ releases: { active?: ReleaseEvidence; previous?: ReleaseEvidence; knownGood?: ReleaseEvidence; coherent: boolean };
118
+ probes: Record<string, { ok: boolean; detail: string; status?: number; value?: unknown }>;
119
+ }
120
+
121
+ export interface VerifyStableRuntimeOptions {
122
+ probeMcpProtocol?: boolean;
123
+ }
124
+
125
+ /** Bounded, release-scoped explanation for a failed watchdog verification. */
126
+ export interface RecoveryWatchdogDiagnosticEvidence {
127
+ fingerprint: string;
128
+ releaseIdentity: string;
129
+ components: Array<'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_tunnel'>;
130
+ failedProbes: Array<{ name: string; component: 'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_tunnel'; detail: string; status?: number }>;
131
+ firstObservedAt: string;
132
+ lastObservedAt: string;
133
+ occurrences: number;
134
+ }
135
+
136
+ interface RecoveryWatchdogDiagnosticStore {
137
+ schemaVersion: 1;
138
+ entries: RecoveryWatchdogDiagnosticEvidence[];
139
+ }
140
+
141
+ export interface RollbackResult {
142
+ ok: boolean;
143
+ noOp?: boolean;
144
+ operationId?: string;
145
+ detail: string;
146
+ verify?: VerifyResult;
147
+ }
148
+
149
+ export interface WatchdogDecision {
150
+ action: 'healthy' | 'degraded' | 'repair_public_tunnel' | 'restart_recovery_gateway' | 'restart_primary_runtime' | 'rollback' | 'recovery_exhausted';
151
+ reason: string;
152
+ }
153
+
154
+ export interface WatchdogState {
155
+ failures: number;
156
+ firstFailureAt?: number;
157
+ rollbackUsed: boolean;
158
+ runtimeRestartAttempts?: number;
159
+ runtimeRestartFailures?: number;
160
+ runtimeRestartLastAttemptAt?: number;
161
+ /** Exact active Runtime release for the persisted restart budget. */
162
+ runtimeRestartBudgetIdentity?: string;
163
+ /** First continuously healthy observation for the active Runtime release. */
164
+ runtimeHealthySince?: number;
165
+ /** First observation that exhausted the active release's automatic recovery budget. */
166
+ runtimeRestartBudgetExhaustedAt?: number;
167
+ runtimeRecoveryFailures?: number;
168
+ runtimeRecoveryLastAttemptAt?: number;
169
+ publicTunnelFailures?: number;
170
+ publicTunnelFirstFailureAt?: number;
171
+ publicTunnelRepairFailures?: number;
172
+ recoveryGatewayRestartUsed?: boolean;
173
+ recoveryReleaseRevision?: string;
174
+ lastFullVerifyAt?: number;
175
+ lastDecision?: WatchdogDecision['action'];
176
+ updatedAt?: string;
177
+ }
178
+
179
+ export interface PublicTunnelRepairResult {
180
+ ok: boolean;
181
+ attempted: boolean;
182
+ noOp?: boolean;
183
+ detail: string;
184
+ serviceLabel?: string;
185
+ serviceTarget?: string;
186
+ verify: VerifyResult;
187
+ localVerify?: VerifyResult;
188
+ }
189
+
190
+ export interface PrimaryRuntimeRestartResult {
191
+ ok: boolean;
192
+ attempted: boolean;
193
+ noOp?: boolean;
194
+ detail: string;
195
+ serviceTarget?: string;
196
+ verify: VerifyResult;
197
+ }
198
+
199
+ export interface PrimaryConnectorRestartResult {
200
+ ok: boolean;
201
+ attempted: boolean;
202
+ noOp?: boolean;
203
+ detail: string;
204
+ serviceTarget?: string;
205
+ verify: VerifyResult;
206
+ }
207
+
208
+ export interface ConfiguredRuntimeActivationResult {
209
+ ok: boolean;
210
+ attempted: boolean;
211
+ noOp?: boolean;
212
+ detail: string;
213
+ staged?: StagedRuntimeRelease;
214
+ activation?: RuntimeReleaseActivationResult;
215
+ }
216
+
217
+ export interface PrimaryRuntimeRecoveryResult {
218
+ ok: boolean;
219
+ attempted: boolean;
220
+ noOp?: boolean;
221
+ detail: string;
222
+ serviceTarget?: string;
223
+ rollback?: RollbackResult;
224
+ verify: VerifyResult;
225
+ }
226
+
227
+ export interface RuntimeReleaseActivationResult {
228
+ ok: boolean;
229
+ attempted: boolean;
230
+ noOp?: boolean;
231
+ detail: string;
232
+ serviceTarget?: string;
233
+ operationId?: string;
234
+ rollback?: RollbackResult;
235
+ verify?: VerifyResult;
236
+ }
237
+
238
+ // This must be a bounded, stable tool that succeeds before a repository has
239
+ // been selected. rh_status is repository-scoped in multi-repository homes, so
240
+ // it can report REPOSITORY_AMBIGUOUS even when the Runtime is healthy.
241
+ const STABLE_RECOVERY_READ_ONLY_TOOL = { name: 'repository_list', arguments: {} } as const;
242
+ const RETIRED_RECOVERY_READ_ONLY_TOOLS = new Set(['controller_context', 'controller_ready']);
243
+
244
+ function normalizeRecoveryReadOnlyTool(
245
+ input: RecoveryConfig['readOnlyTool'] | undefined,
246
+ ): NonNullable<RecoveryConfig['readOnlyTool']> {
247
+ if (!input || RETIRED_RECOVERY_READ_ONLY_TOOLS.has(input.name)) {
248
+ return { name: STABLE_RECOVERY_READ_ONLY_TOOL.name, arguments: { ...STABLE_RECOVERY_READ_ONLY_TOOL.arguments } };
249
+ }
250
+ return input;
251
+ }
252
+
253
+ const DEFAULT_CONFIG: Omit<RecoveryConfig, 'controllerHome'> = {
254
+ schemaVersion: 1,
255
+ readOnlyTool: { name: STABLE_RECOVERY_READ_ONLY_TOOL.name, arguments: { ...STABLE_RECOVERY_READ_ONLY_TOOL.arguments } },
256
+ primaryRuntimeService: { platform: 'launchd' },
257
+ };
258
+
259
+ function json<T>(path: string): T | undefined {
260
+ try { return JSON.parse(readFileSync(path, 'utf8')) as T; } catch { return undefined; }
261
+ }
262
+
263
+ function writeJson(path: string, value: unknown): void {
264
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
265
+ const temporary = `${path}.${process.pid}.tmp`;
266
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
267
+ try { chmodSync(temporary, 0o600); } catch { /* best effort */ }
268
+ renameSync(temporary, path);
269
+ }
270
+
271
+ function recoveryRoot(config: RecoveryConfig): string { return join(resolve(config.controllerHome), 'recovery'); }
272
+ function statePath(config: RecoveryConfig): string { return join(recoveryRoot(config), 'state', 'known-good.json'); }
273
+ function lockPath(config: RecoveryConfig): string { return join(recoveryRoot(config), 'locks', 'operation.lock'); }
274
+ function auditPath(config: RecoveryConfig): string { return join(recoveryRoot(config), 'audit', 'recovery.jsonl'); }
275
+ function watchdogDiagnosticPath(config: RecoveryConfig): string { return join(recoveryRoot(config), 'state', 'watchdog-diagnostics.json'); }
276
+ function quarantinePath(config: RecoveryConfig): string { return join(recoveryRoot(config), 'state', 'quarantine.json'); }
277
+ function publicTunnelRepairStatePath(config: RecoveryConfig): string { return join(recoveryRoot(config), 'state', 'public-tunnel-repair.json'); }
278
+ function watchdogStatePath(config: RecoveryConfig): string { return join(recoveryRoot(config), 'state', 'watchdog.json'); }
279
+ export function loadWatchdogState(config: RecoveryConfig): WatchdogState {
280
+ const state = json<WatchdogState>(watchdogStatePath(config));
281
+ if (!state || !Number.isInteger(state.failures) || state.failures < 0 || typeof state.rollbackUsed !== 'boolean') {
282
+ return { failures: 0, rollbackUsed: false, publicTunnelFailures: 0, publicTunnelRepairFailures: 0 };
283
+ }
284
+ return state;
285
+ }
286
+
287
+ export function saveWatchdogState(config: RecoveryConfig, state: WatchdogState): WatchdogState {
288
+ const persisted = { ...state, updatedAt: new Date().toISOString() };
289
+ writeJson(watchdogStatePath(config), persisted);
290
+ return persisted;
291
+ }
292
+
293
+ function configuredRecoveryTunnel(config: RecoveryConfig): PublicTunnelServiceConfig | undefined {
294
+ return config.recoveryTunnelService;
295
+ }
296
+
297
+ function configuredRecoveryPublicUrl(config: RecoveryConfig): string | undefined {
298
+ return config.recoveryPublicUrl;
299
+ }
300
+
301
+ export const STANDALONE_RECOVERY_REQUIRED_RELEASE_FILES = [
302
+ 'manifest.json',
303
+ 'forge-runtime.mjs',
304
+ ] as const;
305
+
306
+ export function recoveryConfigPath(controllerHome: string): string {
307
+ return join(resolve(controllerHome), 'recovery', 'config', 'recovery.json');
308
+ }
309
+
310
+ export function loadRecoveryConfig(controllerHome: string, explicit?: string): RecoveryConfig {
311
+ const configPath = explicit ?? recoveryConfigPath(controllerHome);
312
+ const loaded = json<Partial<RecoveryConfig> & Record<string, unknown>>(configPath) ?? {};
313
+ const readOnlyTool = normalizeRecoveryReadOnlyTool(loaded.readOnlyTool);
314
+ const config: RecoveryConfig = {
315
+ ...DEFAULT_CONFIG,
316
+ schemaVersion: 1,
317
+ controllerHome: resolve(typeof loaded.controllerHome === 'string' ? loaded.controllerHome : controllerHome),
318
+ ...(typeof loaded.publicMcpUrl === 'string' ? { publicMcpUrl: loaded.publicMcpUrl } : {}),
319
+ ...(typeof loaded.recoveryPublicUrl === 'string' ? { recoveryPublicUrl: loaded.recoveryPublicUrl } : {}),
320
+ ...(loaded.recoveryTunnelService ? { recoveryTunnelService: loaded.recoveryTunnelService } : {}),
321
+ ...(loaded.primaryRuntimeService ? { primaryRuntimeService: loaded.primaryRuntimeService } : {}),
322
+ ...(typeof loaded.primaryRuntimeSourceRoot === 'string' ? { primaryRuntimeSourceRoot: resolve(loaded.primaryRuntimeSourceRoot) } : {}),
323
+ ...(loaded.primaryConnectorService ? { primaryConnectorService: loaded.primaryConnectorService } : {}),
324
+ ...(typeof loaded.mainMcpTokenFile === 'string' ? { mainMcpTokenFile: loaded.mainMcpTokenFile } : {}),
325
+ ...(typeof loaded.expectedToolFingerprint === 'string' ? { expectedToolFingerprint: loaded.expectedToolFingerprint } : {}),
326
+ readOnlyTool,
327
+ ...(loaded.gateway ? { gateway: loaded.gateway } : {}),
328
+ };
329
+ if (!config.controllerHome) throw new Error('RECOVERY_CONTROLLER_HOME_REQUIRED');
330
+ return config;
331
+ }
332
+
333
+ export function createRecoveryConfig(controllerHome: string, input?: Partial<RecoveryConfig>): RecoveryConfig {
334
+ const config = loadRecoveryConfig(controllerHome);
335
+ const next: RecoveryConfig = {
336
+ ...config,
337
+ ...input,
338
+ schemaVersion: 1,
339
+ controllerHome: resolve(controllerHome),
340
+ readOnlyTool: normalizeRecoveryReadOnlyTool(input?.readOnlyTool ?? config.readOnlyTool),
341
+ };
342
+ writeJson(recoveryConfigPath(controllerHome), next);
343
+ return next;
344
+ }
345
+
346
+ function releaseEvidence(
347
+ controllerHome: string,
348
+ release: RuntimePublishedRelease | undefined,
349
+ authority: RuntimeReleaseAuthority | undefined,
350
+ ): ReleaseEvidence | undefined {
351
+ if (!release || !authority) return undefined;
352
+ return {
353
+ path: release.manifestPath,
354
+ revision: release.releaseId,
355
+ artifactIdentity: release.artifactIdentity,
356
+ manifestSha256: release.manifestSha256,
357
+ workerProtocolVersion: release.workerProtocolVersion,
358
+ controllerHome: resolve(controllerHome),
359
+ releaseAuthorityRevision: authority.revision,
360
+ releaseFencingTokenSha256: createHash('sha256').update(authority.fencingToken).digest('hex'),
361
+ };
362
+ }
363
+
364
+ function releaseAuthority(config: RecoveryConfig): RuntimeReleaseAuthority | undefined {
365
+ return readRuntimeReleaseAuthority(config.controllerHome);
366
+ }
367
+
368
+ function activeAuthorityRelease(config: RecoveryConfig): ReleaseEvidence | undefined {
369
+ const authority = releaseAuthority(config);
370
+ return releaseEvidence(config.controllerHome, authority?.active, authority);
371
+ }
372
+
373
+ function previousAuthorityRelease(config: RecoveryConfig): ReleaseEvidence | undefined {
374
+ const authority = releaseAuthority(config);
375
+ return releaseEvidence(config.controllerHome, authority?.previous, authority);
376
+ }
377
+
378
+ function knownGoodEvidence(config: RecoveryConfig, entry: ReleaseEvidence | undefined): ReleaseEvidence | undefined {
379
+ if (!entry || !entry.attestedAt || !entry.releaseFencingTokenSha256 || typeof entry.controllerHome !== 'string') return undefined;
380
+ if (resolve(entry.controllerHome) !== resolve(config.controllerHome)) return undefined;
381
+ const authority = releaseAuthority(config);
382
+ const candidates = [
383
+ releaseEvidence(config.controllerHome, authority?.active, authority),
384
+ releaseEvidence(config.controllerHome, authority?.previous, authority),
385
+ ].filter((item): item is ReleaseEvidence => Boolean(item));
386
+ const matched = candidates.find((release) =>
387
+ release.path === entry.path
388
+ && release.revision === entry.revision
389
+ && release.artifactIdentity === entry.artifactIdentity
390
+ && release.manifestSha256 === entry.manifestSha256
391
+ && release.workerProtocolVersion === entry.workerProtocolVersion,
392
+ );
393
+ return matched ? entry : undefined;
394
+ }
395
+
396
+ function knownGood(config: RecoveryConfig): KnownGoodStore {
397
+ return json<KnownGoodStore>(statePath(config)) ?? { schemaVersion: 1, releases: [], updatedAt: new Date(0).toISOString() };
398
+ }
399
+
400
+ function matchingKnownGood(config: RecoveryConfig, release: ReleaseEvidence | undefined): ReleaseEvidence | undefined {
401
+ if (!release) return undefined;
402
+ return knownGoodEvidence(config, knownGood(config).releases.find((entry) =>
403
+ entry.revision === release.revision && entry.manifestSha256 === release.manifestSha256 && entry.path === release.path,
404
+ ));
405
+ }
406
+
407
+
408
+ function audit(config: RecoveryConfig, event: string, detail: Record<string, unknown>): void {
409
+ const line = JSON.stringify({ at: new Date().toISOString(), event, detail });
410
+ mkdirSync(dirname(auditPath(config)), { recursive: true, mode: 0o700 });
411
+ writeFileSync(auditPath(config), `${line}\n`, { encoding: 'utf8', mode: 0o600, flag: 'a' });
412
+ }
413
+
414
+ const WATCHDOG_DIAGNOSTIC_LIMIT = 32;
415
+
416
+ function watchdogProbeComponent(name: string): RecoveryWatchdogDiagnosticEvidence['components'][number] {
417
+ if (name === 'runtime_status') return 'runtime';
418
+ if (name === 'active_gateway') return 'gateway';
419
+ if (name === 'recovery_gateway') return 'recovery_gateway';
420
+ if (name === 'recovery_external_http') return 'recovery_tunnel';
421
+ return 'public_mcp';
422
+ }
423
+
424
+ function persistWatchdogDiagnosticEvidence(config: RecoveryConfig, result: VerifyResult): RecoveryWatchdogDiagnosticEvidence | undefined {
425
+ if (result.ok) return undefined;
426
+ const failedProbes = Object.entries(result.probes)
427
+ .filter(([, probe]) => !probe.ok)
428
+ .map(([name, probe]) => ({
429
+ name,
430
+ component: watchdogProbeComponent(name),
431
+ detail: probe.detail.slice(0, 240),
432
+ ...(probe.status === undefined ? {} : { status: probe.status }),
433
+ }));
434
+ if (!result.runtime.ok && !failedProbes.some((probe) => probe.name === 'runtime_status')) {
435
+ failedProbes.unshift({ name: 'runtime_readiness', component: 'runtime', detail: result.runtime.reasonCodes.join(', ') || 'runtime is not ready' });
436
+ }
437
+ const active = result.releases.active;
438
+ const releaseIdentity = [active?.revision, active?.artifactIdentity, active?.manifestSha256].filter(Boolean).join(':') || 'unresolved-release';
439
+ const fingerprint = createHash('sha256').update(JSON.stringify({ releaseIdentity, failedProbes })).digest('hex').slice(0, 24);
440
+ const observedAt = result.at;
441
+ const previous = json<RecoveryWatchdogDiagnosticStore>(watchdogDiagnosticPath(config));
442
+ const entries = Array.isArray(previous?.entries) ? previous.entries : [];
443
+ const existing = entries.find((entry) => entry.fingerprint === fingerprint);
444
+ const evidence: RecoveryWatchdogDiagnosticEvidence = existing
445
+ ? { ...existing, lastObservedAt: observedAt, occurrences: existing.occurrences + 1 }
446
+ : {
447
+ fingerprint,
448
+ releaseIdentity,
449
+ components: [...new Set(failedProbes.map((probe) => probe.component))],
450
+ failedProbes,
451
+ firstObservedAt: observedAt,
452
+ lastObservedAt: observedAt,
453
+ occurrences: 1,
454
+ };
455
+ writeJson(watchdogDiagnosticPath(config), {
456
+ schemaVersion: 1,
457
+ entries: [evidence, ...entries.filter((entry) => entry.fingerprint !== fingerprint)].slice(0, WATCHDOG_DIAGNOSTIC_LIMIT),
458
+ } satisfies RecoveryWatchdogDiagnosticStore);
459
+ return evidence;
460
+ }
461
+
462
+ function processStartTime(pid: number): string | undefined {
463
+ const result = spawnSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], {
464
+ encoding: 'utf8',
465
+ timeout: 2_000,
466
+ maxBuffer: 4_096,
467
+ });
468
+ if (result.status !== 0 || typeof result.stdout !== 'string') return undefined;
469
+ return result.stdout.trim() || undefined;
470
+ }
471
+
472
+ function recoveryLockOwnerAlive(lock: RecoveryLock): boolean {
473
+ if (!pidAlive(lock.pid)) return false;
474
+ if (!lock.processStartTime) return true;
475
+ const observed = processStartTime(lock.pid);
476
+ return observed === undefined || observed === lock.processStartTime;
477
+ }
478
+
479
+ function recoveryBusyDetail(owner: RecoveryLock): string {
480
+ return `Recovery mutation already in progress: action=${owner.action ?? 'unknown'} request=${owner.requestId ?? 'unknown'} pid=${owner.pid}`;
481
+ }
482
+
483
+ async function withLock<T>(
484
+ config: RecoveryConfig,
485
+ intent: RecoveryLockIntent,
486
+ action: (lock: RecoveryLock) => Promise<T>,
487
+ ): Promise<RecoveryLockAttempt<T>> {
488
+ const path = lockPath(config);
489
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
490
+ const lock: RecoveryLock = {
491
+ schemaVersion: 1,
492
+ pid: process.pid,
493
+ instanceId: randomUUID(),
494
+ processStartTime: processStartTime(process.pid),
495
+ acquiredAt: new Date().toISOString(),
496
+ action: intent.action,
497
+ ...(intent.requestId ? { requestId: intent.requestId } : {}),
498
+ };
499
+
500
+ for (let attempt = 0; attempt < 2; attempt += 1) {
501
+ let fd: number | undefined;
502
+ let acquired = false;
503
+ try {
504
+ try {
505
+ fd = openSync(path, 'wx', 0o600);
506
+ writeFileSync(fd, JSON.stringify(lock));
507
+ acquired = true;
508
+ } catch (error) {
509
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
510
+ const existing = json<RecoveryLock>(path);
511
+ if (!existing || !Number.isInteger(existing.pid) || typeof existing.instanceId !== 'string') {
512
+ throw new Error('RECOVERY_OPERATION_LOCK_UNCERTAIN');
513
+ }
514
+ if (recoveryLockOwnerAlive(existing)) return { acquired: false, owner: existing };
515
+ const latest = json<RecoveryLock>(path);
516
+ if (!latest || latest.instanceId !== existing.instanceId) {
517
+ if (attempt === 1) return { acquired: false, owner: latest ?? existing };
518
+ continue;
519
+ }
520
+ if (recoveryLockOwnerAlive(latest)) return { acquired: false, owner: latest };
521
+ try { writeFileSync(`${path}.stale-${Date.now()}-${existing.instanceId}`, readFileSync(path)); } catch { /* evidence best effort */ }
522
+ rmSync(path, { force: true });
523
+ continue;
524
+ }
525
+ return { acquired: true, value: await action(lock) };
526
+ } finally {
527
+ if (fd !== undefined) closeSync(fd);
528
+ if (acquired) {
529
+ const current = json<RecoveryLock>(path);
530
+ if (current?.instanceId === lock.instanceId) rmSync(path, { force: true });
531
+ }
532
+ }
533
+ }
534
+ throw new Error('RECOVERY_OPERATION_LOCK_BUSY');
535
+ }
536
+
537
+ export async function runtimeStatus(config: RecoveryConfig) {
538
+ return observeRuntimeStatus(config.controllerHome);
539
+ }
540
+
541
+ async function probe(transport: RecoveryHttpTransport, url: string, timeoutMs = 4_000): Promise<{ ok: boolean; detail: string; status?: number }> {
542
+ const controller = new AbortController();
543
+ const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), timeoutMs);
544
+ try {
545
+ const response = await transport.request({ url, headers: { accept: 'application/json' }, timeoutMs, signal: controller.signal });
546
+ return { ok: response.ok, detail: `HTTP ${response.status}`, status: response.status };
547
+ } catch (error) {
548
+ return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'request failed' };
549
+ } finally { clearTimeout(timer); }
550
+ }
551
+
552
+ async function probeExternalMcp(transport: RecoveryHttpTransport, url: string): Promise<{ ok: boolean; detail: string; status?: number }> {
553
+ const controller = new AbortController();
554
+ const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), 4_000);
555
+ try {
556
+ // The MCP transport is POST-based. A GET on the MCP path returns 404 on
557
+ // some gateway implementations, so probe with an initialize request and
558
+ // accept the unauthenticated OAuth Bearer challenge.
559
+ const response = await transport.request({
560
+ url,
561
+ method: 'POST',
562
+ headers: { accept: 'application/json, text/event-stream', 'content-type': 'application/json' },
563
+ body: JSON.stringify({
564
+ jsonrpc: '2.0',
565
+ id: 0,
566
+ method: 'initialize',
567
+ params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'forge-standalone-recovery', version: '1' } },
568
+ }),
569
+ timeoutMs: 4_000,
570
+ signal: controller.signal,
571
+ });
572
+ const challenge = response.headers['www-authenticate'] ?? '';
573
+ const ok = response.ok || (response.status === 401 && /\bBearer\b/i.test(challenge));
574
+ return { ok, detail: ok && response.status === 401 ? 'HTTP 401 OAuth challenge' : `HTTP ${response.status}`, status: response.status };
575
+ } catch (error) {
576
+ return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'request failed' };
577
+ } finally { clearTimeout(timer); }
578
+ }
579
+
580
+ function mainToken(config: RecoveryConfig): string | undefined {
581
+ const candidate = config.mainMcpTokenFile ?? join(config.controllerHome, 'mcp', 'mcp.tokens.json');
582
+ const parsed = json<{ bearerToken?: unknown }>(candidate);
583
+ return typeof parsed?.bearerToken === 'string' && parsed.bearerToken.length >= 24 ? parsed.bearerToken : undefined;
584
+ }
585
+
586
+ interface RecoveryMcpCallResult {
587
+ ok: boolean;
588
+ payload?: Record<string, unknown>;
589
+ detail: string;
590
+ sessionId?: string;
591
+ }
592
+
593
+ function parseRecoveryMcpPayload(text: string, contentType: string): Record<string, unknown> | undefined {
594
+ const trimmed = text.trim();
595
+ if (!trimmed) return undefined;
596
+ if (contentType.includes('text/event-stream')) {
597
+ const data = trimmed
598
+ .split(/\r?\n/)
599
+ .filter((line) => line.startsWith('data:'))
600
+ .map((line) => line.slice(5).trim())
601
+ .find((line) => line && line !== '[DONE]');
602
+ if (!data) return undefined;
603
+ try { return JSON.parse(data) as Record<string, unknown>; } catch { return undefined; }
604
+ }
605
+ try { return JSON.parse(trimmed) as Record<string, unknown>; } catch { return undefined; }
606
+ }
607
+
608
+ async function mcpCall(
609
+ transport: RecoveryHttpTransport,
610
+ url: string,
611
+ token: string,
612
+ id: number | undefined,
613
+ method: string,
614
+ params?: Record<string, unknown>,
615
+ sessionId?: string,
616
+ ): Promise<RecoveryMcpCallResult> {
617
+ const controller = new AbortController();
618
+ const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), 8_000);
619
+ try {
620
+ const response = await transport.request({
621
+ url, method: 'POST', timeoutMs: 8_000, signal: controller.signal,
622
+ headers: {
623
+ authorization: `Bearer ${token}`,
624
+ 'content-type': 'application/json',
625
+ accept: 'application/json, text/event-stream',
626
+ ...(sessionId ? { 'mcp-session-id': sessionId } : {}),
627
+ },
628
+ body: JSON.stringify({ jsonrpc: '2.0', ...(id === undefined ? {} : { id }), method, ...(params ? { params } : {}) }),
629
+ });
630
+ const payload = parseRecoveryMcpPayload(response.body, response.headers['content-type'] ?? '');
631
+ const returnedSessionId = response.headers['mcp-session-id']?.trim() || sessionId;
632
+ return {
633
+ ok: response.ok && !payload?.error,
634
+ payload,
635
+ detail: `HTTP ${response.status}`,
636
+ ...(returnedSessionId ? { sessionId: returnedSessionId } : {}),
637
+ };
638
+ } catch (error) { return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'MCP request failed' }; } finally { clearTimeout(timer); }
639
+ }
640
+
641
+ async function closeRecoveryMcpSession(transport: RecoveryHttpTransport, url: string, token: string, sessionId: string): Promise<{ ok: boolean; detail: string }> {
642
+ const controller = new AbortController();
643
+ const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), 5_000);
644
+ try {
645
+ const response = await transport.request({
646
+ url,
647
+ method: 'DELETE',
648
+ timeoutMs: 5_000,
649
+ signal: controller.signal,
650
+ headers: {
651
+ authorization: `Bearer ${token}`,
652
+ accept: 'application/json, text/event-stream',
653
+ 'mcp-session-id': sessionId,
654
+ },
655
+ });
656
+ return { ok: response.ok, detail: `HTTP ${response.status}` };
657
+ } catch (error) {
658
+ return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'MCP session close failed' };
659
+ } finally {
660
+ clearTimeout(timer);
661
+ }
662
+ }
663
+
664
+ function runtimeEndpoint(config: RecoveryConfig): string | undefined {
665
+ return observeRuntimeStatus(config.controllerHome).snapshot?.endpoint;
666
+ }
667
+
668
+ function runtimeHealthEndpoint(endpoint: string): string {
669
+ const url = new URL(endpoint);
670
+ // The canonical Runtime serves the whole-Runtime readiness probe at /ready.
671
+ url.pathname = '/ready';
672
+ url.search = '';
673
+ url.hash = '';
674
+ return url.toString();
675
+ }
676
+
677
+ async function probeMcp(config: RecoveryConfig, transport: RecoveryHttpTransport): Promise<Record<string, { ok: boolean; detail: string; value?: unknown }>> {
678
+ const url = config.publicMcpUrl ?? runtimeEndpoint(config);
679
+ if (!url) return { mcp_initialize: { ok: false, detail: 'canonical Runtime MCP endpoint is unavailable' } };
680
+ const token = mainToken(config);
681
+ if (!token) return { mcp_initialize: { ok: false, detail: 'main MCP probe credential is unavailable' } };
682
+ const initialized = await mcpCall(transport, url, token, 1, 'initialize', {
683
+ protocolVersion: '2025-06-18',
684
+ capabilities: {},
685
+ clientInfo: { name: 'standalone-recovery', version: '1' },
686
+ });
687
+ if (!initialized.ok || !initialized.sessionId) {
688
+ return {
689
+ mcp_initialize: {
690
+ ok: false,
691
+ detail: initialized.ok ? `${initialized.detail}; session id missing` : initialized.detail,
692
+ },
693
+ };
694
+ }
695
+ const sessionId = initialized.sessionId;
696
+ const acknowledged = await mcpCall(transport, url, token, undefined, 'notifications/initialized', undefined, sessionId);
697
+ if (!acknowledged.ok) {
698
+ const closed = await closeRecoveryMcpSession(transport, url, token, sessionId);
699
+ return {
700
+ mcp_initialize: { ok: true, detail: initialized.detail },
701
+ mcp_initialized_notification: { ok: false, detail: acknowledged.detail },
702
+ mcp_session_close: { ok: closed.ok, detail: closed.detail },
703
+ };
704
+ }
705
+ const listed = await mcpCall(transport, url, token, 2, 'tools/list', undefined, sessionId);
706
+ const tools = Array.isArray((listed.payload?.result as { tools?: unknown } | undefined)?.tools)
707
+ ? ((listed.payload!.result as { tools: Array<{ name?: unknown }> }).tools)
708
+ : [];
709
+ const names = tools.map((tool) => typeof tool.name === 'string' ? tool.name : '').filter(Boolean).sort();
710
+ const fingerprint = createHash('sha256').update(names.join('\n')).digest('hex');
711
+ const expected = config.expectedToolFingerprint;
712
+ const expectedMatches = !expected || (expected.length === fingerprint.length && timingSafeEqual(Buffer.from(expected), Buffer.from(fingerprint)));
713
+ const toolListOk = listed.ok && names.length > 0 && expectedMatches;
714
+ const readOnly = normalizeRecoveryReadOnlyTool(config.readOnlyTool);
715
+ const called = toolListOk
716
+ ? await mcpCall(transport, url, token, 3, 'tools/call', { name: readOnly.name, arguments: readOnly.arguments ?? {} }, sessionId)
717
+ : undefined;
718
+ const closed = await closeRecoveryMcpSession(transport, url, token, sessionId);
719
+ return {
720
+ mcp_initialize: { ok: initialized.ok, detail: initialized.detail },
721
+ mcp_initialized_notification: { ok: acknowledged.ok, detail: acknowledged.detail },
722
+ mcp_tools_list: { ok: toolListOk, detail: `${listed.detail}; count=${names.length}; fingerprint=${fingerprint}`, value: { count: names.length, fingerprint } },
723
+ mcp_read_only_call: { ok: Boolean(called?.ok), detail: called?.detail ?? 'tools/list failed' },
724
+ mcp_session_close: { ok: closed.ok, detail: closed.detail },
725
+ };
726
+ }
727
+
728
+ export async function verifyStableRuntime(
729
+ config: RecoveryConfig,
730
+ transport = createRecoveryHttpTransport(config.controllerHome),
731
+ options: VerifyStableRuntimeOptions = {},
732
+ ): Promise<VerifyResult> {
733
+ const observation = observeRuntimeStatus(config.controllerHome);
734
+ const authority = releaseAuthority(config);
735
+ const active = activeAuthorityRelease(config);
736
+ const previous = previousAuthorityRelease(config);
737
+ const known = matchingKnownGood(config, active);
738
+ const probes: VerifyResult['probes'] = {
739
+ runtime_status: {
740
+ ok: observation.running && !observation.stale,
741
+ detail: observation.running
742
+ ? observation.stale ? 'canonical Runtime status is stale' : 'canonical Runtime owner is live'
743
+ : 'canonical Runtime is not running',
744
+ },
745
+ };
746
+ const endpoint = observation.snapshot?.endpoint;
747
+ probes.active_gateway = endpoint
748
+ ? await probe(transport, runtimeHealthEndpoint(endpoint))
749
+ : { ok: false, detail: 'canonical Runtime endpoint is unavailable' };
750
+ if (config.publicMcpUrl) probes.external_mcp_http = await probeExternalMcp(transport, config.publicMcpUrl);
751
+ if (config.gateway) probes.recovery_gateway = await probe(transport, `http://${config.gateway.host}:${config.gateway.port}/health`);
752
+ const recoveryPublicUrl = configuredRecoveryPublicUrl(config);
753
+ if (recoveryPublicUrl) probes.recovery_external_http = await probeExternalMcp(transport, recoveryPublicUrl);
754
+ if (options.probeMcpProtocol !== false) Object.assign(probes, await probeMcp(config, transport));
755
+ const coreChecks = Object.entries(probes)
756
+ .filter(([name]) => !name.startsWith('recovery_'))
757
+ .every(([, entry]) => entry.ok);
758
+ const runtimeHealthy = observation.running && observation.ready && !observation.stale;
759
+ const coherent = Boolean(
760
+ authority
761
+ && active
762
+ && observation.snapshot?.releaseId === active.revision
763
+ && observation.snapshot?.artifactIdentity === active.artifactIdentity
764
+ && authority.active.workerProtocolVersion === active.workerProtocolVersion,
765
+ );
766
+ const ok = Boolean(coreChecks && runtimeHealthy && coherent);
767
+ const result: VerifyResult = {
768
+ ok,
769
+ at: new Date().toISOString(),
770
+ runtime: {
771
+ ok: runtimeHealthy,
772
+ running: observation.running,
773
+ ready: observation.ready,
774
+ stale: observation.stale,
775
+ reasonCodes: [...observation.reasonCodes],
776
+ },
777
+ releases: { active, previous, knownGood: known, coherent },
778
+ probes,
779
+ };
780
+ const diagnosticEvidence = persistWatchdogDiagnosticEvidence(config, result);
781
+ audit(config, 'verify', {
782
+ ok,
783
+ activeRevision: active?.revision,
784
+ previousRevision: previous?.revision,
785
+ coherent,
786
+ ...(diagnosticEvidence ? {
787
+ diagnostic: {
788
+ fingerprint: diagnosticEvidence.fingerprint,
789
+ components: diagnosticEvidence.components,
790
+ failedProbes: diagnosticEvidence.failedProbes,
791
+ occurrences: diagnosticEvidence.occurrences,
792
+ },
793
+ } : {}),
794
+ });
795
+ return result;
796
+ }
797
+ /** Explicitly records evidence only after the full independent verification passed. */
798
+ export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEvidence> {
799
+ const locked = await withLock(config, { action: 'attest_known_good' }, async () => {
800
+ const verified = await verifyStableRuntime(config);
801
+ const authority = releaseAuthority(config);
802
+ const active = verified.releases.active;
803
+ if (
804
+ !verified.ok
805
+ || !authority
806
+ || !active
807
+ || authority.active.releaseId !== active.revision
808
+ || authority.active.artifactIdentity !== active.artifactIdentity
809
+ || authority.active.manifestSha256 !== active.manifestSha256
810
+ || authority.active.workerProtocolVersion !== active.workerProtocolVersion
811
+ ) {
812
+ throw new Error('RECOVERY_KNOWN_GOOD_ATTESTATION_REQUIRES_FULL_VERIFY_AND_RELEASE_AUTHORITY');
813
+ }
814
+ const attested: ReleaseEvidence = {
815
+ ...active,
816
+ controllerHome: resolve(config.controllerHome),
817
+ releaseAuthorityRevision: authority.revision,
818
+ releaseFencingTokenSha256: createHash('sha256').update(authority.fencingToken).digest('hex'),
819
+ attestedAt: new Date().toISOString(),
820
+ };
821
+ const store = knownGood(config);
822
+ const releases = [attested, ...store.releases.filter((entry) => entry.path !== active.path)].slice(0, 8);
823
+ writeJson(statePath(config), { schemaVersion: 1, releases, updatedAt: new Date().toISOString() } satisfies KnownGoodStore);
824
+ audit(config, 'known_good_attested', {
825
+ revision: active.revision,
826
+ artifactIdentity: active.artifactIdentity,
827
+ manifestSha256: active.manifestSha256,
828
+ releaseAuthorityRevision: authority.revision,
829
+ });
830
+ return attested;
831
+ });
832
+ if (!locked.acquired) throw new Error(recoveryBusyDetail(locked.owner));
833
+ return locked.value;
834
+ }
835
+
836
+ function quarantine(config: RecoveryConfig, release: ReleaseEvidence | undefined, reason: string): void {
837
+ if (!release) return;
838
+ const current = json<{ schemaVersion: 1; releases: Array<ReleaseEvidence & { reason: string; at: string }> }>(quarantinePath(config)) ?? { schemaVersion: 1, releases: [] };
839
+ const releases = [{ ...release, reason, at: new Date().toISOString() }, ...current.releases.filter((item) => item.path !== release.path)].slice(0, 32);
840
+ writeJson(quarantinePath(config), { schemaVersion: 1, releases });
841
+ }
842
+
843
+ async function rollbackPreviousLocked(config: RecoveryConfig, reason: string): Promise<RollbackResult> {
844
+ const before = await verifyStableRuntime(config);
845
+ const active = before.releases.active;
846
+ if (matchingKnownGood(config, active)) {
847
+ return { ok: true, noOp: true, detail: 'active whole-Runtime release is already independently attested known-good', verify: before };
848
+ }
849
+ if (before.runtime.running) {
850
+ return {
851
+ ok: false,
852
+ noOp: true,
853
+ detail: 'rollback refused: stop the complete Canonical Runtime before restoring its release and SQLite backup',
854
+ verify: before,
855
+ };
856
+ }
857
+ const target = matchingKnownGood(config, before.releases.previous);
858
+ if (!active || !target) {
859
+ audit(config, 'rollback_refused', {
860
+ reason: 'the atomic previous whole-Runtime release is not independently attested known-good',
861
+ activeRevision: active?.revision,
862
+ });
863
+ return {
864
+ ok: false,
865
+ detail: 'rollback refused: no attested previous whole-Runtime release with a bound SQLite backup is available',
866
+ verify: before,
867
+ };
868
+ }
869
+ const operationId = `recovery-rollback-${Date.now()}-${randomUUID().slice(0, 8)}`;
870
+ try {
871
+ const committed = rollbackRuntimeRelease(config.controllerHome, operationId);
872
+ if (
873
+ committed.active.releaseId !== target.revision
874
+ || committed.active.artifactIdentity !== target.artifactIdentity
875
+ || committed.active.manifestSha256 !== target.manifestSha256
876
+ ) throw new Error('RECOVERY_ROLLBACK_AUTHORITY_MISMATCH');
877
+ quarantine(config, active, 'whole-Runtime rollback completed');
878
+ audit(config, 'rollback_succeeded', {
879
+ operationId,
880
+ reason: reason.slice(0, 500),
881
+ activeRevision: active.revision,
882
+ restoredRevision: target.revision,
883
+ restoredManifestSha256: target.manifestSha256,
884
+ });
885
+ return {
886
+ ok: true,
887
+ operationId,
888
+ detail: 'whole-Runtime release and SQLite backup restored; Canonical Runtime remains stopped until its sole launcher starts it',
889
+ };
890
+ } catch (error) {
891
+ const detail = error instanceof Error ? error.message : 'whole-Runtime rollback failed';
892
+ audit(config, 'rollback_failed', { operationId, activeRevision: active.revision, targetRevision: target.revision, detail });
893
+ return { ok: false, operationId, detail };
894
+ }
895
+ }
896
+
897
+ export async function rollbackPrevious(config: RecoveryConfig, reason = 'standalone recovery'): Promise<RollbackResult> {
898
+ const locked = await withLock(config, { action: 'rollback_previous' }, () => rollbackPreviousLocked(config, reason));
899
+ if (!locked.acquired) return { ok: false, noOp: true, detail: recoveryBusyDetail(locked.owner), verify: await verifyStableRuntime(config) };
900
+ return locked.value;
901
+ }
902
+
903
+ export async function reconnectMain(config: RecoveryConfig): Promise<{ ok: boolean; detail: string; verify: VerifyResult }> {
904
+ // A recovery connector must survive a main failure; this action is therefore
905
+ // intentionally a bounded health/reconnect observation, never a rollout.
906
+ const verified = await verifyStableRuntime(config);
907
+ const publicProbe = verified.probes.external_mcp_http;
908
+ const ok = verified.probes.active_gateway?.ok === true && (publicProbe?.ok === true || publicProbe?.status === 401);
909
+ audit(config, 'reconnect_main', { ok, externalStatus: publicProbe?.status });
910
+ return { ok, detail: ok ? 'canonical Runtime Gateway and primary endpoint are reachable; client session may refresh' : 'primary endpoint remains unavailable; recovery channel remains independent', verify: verified };
911
+ }
912
+
913
+ async function verifyLocalRuntime(
914
+ config: RecoveryConfig,
915
+ options: VerifyStableRuntimeOptions = {},
916
+ ): Promise<VerifyResult> {
917
+ // Do not let an external tunnel outage masquerade as a local MCP failure.
918
+ // Local and public verification intentionally probe the same stable facade
919
+ // tool so Recovery cannot depend on retired atomic tools that are absent from
920
+ // the bounded default MCP surface.
921
+ return verifyStableRuntime({
922
+ ...config,
923
+ publicMcpUrl: undefined,
924
+ recoveryPublicUrl: undefined,
925
+ readOnlyTool: { name: STABLE_RECOVERY_READ_ONLY_TOOL.name, arguments: { ...STABLE_RECOVERY_READ_ONLY_TOOL.arguments } },
926
+ }, createRecoveryHttpTransport(config.controllerHome), options);
927
+ }
928
+
929
+ function isExternalTunnelFailure(config: RecoveryConfig, verified: VerifyResult, localVerify: VerifyResult): boolean {
930
+ const external = verified.probes.recovery_external_http ?? verified.probes.external_mcp_http;
931
+ const localRecoveryGatewayHealthy = localVerify.probes.recovery_gateway?.ok ?? true;
932
+ return Boolean(configuredRecoveryTunnel(config) && configuredRecoveryPublicUrl(config) && localVerify.ok && localRecoveryGatewayHealthy && external?.ok !== true);
933
+ }
934
+
935
+ export const WATCHDOG_RUNTIME_STARTUP_GRACE_MS = 60_000;
936
+ export const WATCHDOG_RUNTIME_RESTART_BUDGET_STABLE_MS = 5 * 60_000;
937
+
938
+ export function watchdogRuntimeStartupGraceMs(
939
+ config: Pick<RecoveryConfig, 'primaryRuntimeService'>,
940
+ ): number {
941
+ const configuredVerifyTimeout = config.primaryRuntimeService?.postRestartVerifyTimeoutMs;
942
+ return Math.max(
943
+ WATCHDOG_RUNTIME_STARTUP_GRACE_MS,
944
+ Number.isFinite(configuredVerifyTimeout) ? Math.max(0, configuredVerifyTimeout ?? 0) : 0,
945
+ );
946
+ }
947
+
948
+ export function runtimeWithinWatchdogStartupGrace(
949
+ input: { running: boolean; stale: boolean; snapshot?: { startedAt?: string } },
950
+ nowMs = Date.now(),
951
+ graceMs = WATCHDOG_RUNTIME_STARTUP_GRACE_MS,
952
+ ): boolean {
953
+ const startedAtMs = input.snapshot?.startedAt ? Date.parse(input.snapshot.startedAt) : Number.NaN;
954
+ return Boolean(
955
+ input.running
956
+ && !input.stale
957
+ && Number.isFinite(startedAtMs)
958
+ && nowMs >= startedAtMs
959
+ && nowMs - startedAtMs < Math.max(0, graceMs),
960
+ );
961
+ }
962
+
963
+ export function runtimeRestartBudgetIdentity(release: Pick<ReleaseEvidence, 'revision' | 'artifactIdentity' | 'manifestSha256'> | undefined): string | undefined {
964
+ if (!release?.revision || !release.artifactIdentity || !release.manifestSha256) return undefined;
965
+ return `${release.revision}:${release.artifactIdentity}:${release.manifestSha256}`;
966
+ }
967
+
968
+ /**
969
+ * Bind primary Runtime restart accounting to the exact immutable Runtime release.
970
+ * A missing identity is a legacy-state migration: retain its counters and bind
971
+ * them to the currently observed release so a watchdog upgrade cannot mint a
972
+ * fresh restart budget for an already failing Runtime.
973
+ */
974
+ export function scopeWatchdogStateToRuntimeRelease(
975
+ state: WatchdogState,
976
+ release: Pick<ReleaseEvidence, 'revision' | 'artifactIdentity' | 'manifestSha256'> | undefined,
977
+ ): WatchdogState {
978
+ const identity = runtimeRestartBudgetIdentity(release);
979
+ if (!identity || state.runtimeRestartBudgetIdentity === identity) return state;
980
+ if (!state.runtimeRestartBudgetIdentity) return { ...state, runtimeRestartBudgetIdentity: identity };
981
+ return {
982
+ ...state,
983
+ failures: 0,
984
+ firstFailureAt: undefined,
985
+ rollbackUsed: false,
986
+ runtimeRestartAttempts: 0,
987
+ runtimeRestartFailures: 0,
988
+ runtimeRestartLastAttemptAt: undefined,
989
+ runtimeHealthySince: undefined,
990
+ runtimeRestartBudgetExhaustedAt: undefined,
991
+ runtimeRecoveryFailures: 0,
992
+ runtimeRecoveryLastAttemptAt: undefined,
993
+ runtimeRestartBudgetIdentity: identity,
994
+ };
995
+ }
996
+
997
+ export function recordWatchdogRuntimeHealthy(
998
+ state: WatchdogState,
999
+ nowMs: number,
1000
+ stableDurationMs = WATCHDOG_RUNTIME_RESTART_BUDGET_STABLE_MS,
1001
+ ): WatchdogState {
1002
+ const runtimeHealthySince = state.runtimeHealthySince ?? nowMs;
1003
+ if (nowMs - runtimeHealthySince < Math.max(0, stableDurationMs)) {
1004
+ return { ...state, runtimeHealthySince };
1005
+ }
1006
+ return {
1007
+ ...state,
1008
+ runtimeHealthySince,
1009
+ runtimeRestartAttempts: 0,
1010
+ runtimeRestartFailures: 0,
1011
+ runtimeRestartLastAttemptAt: undefined,
1012
+ runtimeRestartBudgetExhaustedAt: undefined,
1013
+ };
1014
+ }
1015
+
1016
+ export function watchdogRuntimeRestartBudgetStableMs(
1017
+ config: Pick<RecoveryConfig, 'primaryRuntimeService'>,
1018
+ ): number {
1019
+ const configured = config.primaryRuntimeService?.restartBudgetStableDurationMs;
1020
+ return Number.isFinite(configured)
1021
+ ? Math.max(0, configured ?? 0)
1022
+ : WATCHDOG_RUNTIME_RESTART_BUDGET_STABLE_MS;
1023
+ }
1024
+
1025
+ export function decideWatchdog(input: {
1026
+ failures: number;
1027
+ firstFailureAt?: number;
1028
+ evidenceClasses: string[];
1029
+ activeKnownGood: boolean;
1030
+ previousKnownGood: boolean;
1031
+ rollbackUsed: boolean;
1032
+ recoveryGatewayFailed?: boolean;
1033
+ recoveryGatewayRestartUsed?: boolean;
1034
+ primaryRuntimeFailed?: boolean;
1035
+ runtimeRestartAttempts?: number;
1036
+ runtimeMaximumRestartAttempts?: number;
1037
+ runtimeRestartLastAttemptAt?: number;
1038
+ runtimeRestartCooldownMs?: number;
1039
+ runtimeMinimumFailures?: number;
1040
+ runtimeMinimumFailureDurationMs?: number;
1041
+ runtimeRecoveryLastAttemptAt?: number;
1042
+ runtimeRecoveryCooldownMs?: number;
1043
+ publicTunnelConfigured?: boolean;
1044
+ publicTunnelFailed?: boolean;
1045
+ publicTunnelFailures?: number;
1046
+ publicTunnelFirstFailureAt?: number;
1047
+ publicTunnelRepairFailures?: number;
1048
+ publicTunnelMinimumFailures?: number;
1049
+ publicTunnelMinimumFailureDurationMs?: number;
1050
+ nowMs?: number;
1051
+ }): WatchdogDecision {
1052
+ const now = input.nowMs ?? Date.now();
1053
+ if (input.publicTunnelConfigured && input.publicTunnelFailed) {
1054
+ const failures = input.publicTunnelFailures ?? 0;
1055
+ const minimumFailures = input.publicTunnelMinimumFailures ?? 2;
1056
+ const minimumDuration = input.publicTunnelMinimumFailureDurationMs ?? 5_000;
1057
+ const sustained = input.publicTunnelFirstFailureAt !== undefined && now - input.publicTunnelFirstFailureAt >= minimumDuration;
1058
+ if (failures >= minimumFailures && sustained) return { action: 'repair_public_tunnel', reason: 'local runtime and Recovery Gateway are healthy while the dedicated Recovery public endpoint is unavailable' };
1059
+ return { action: 'degraded', reason: 'Recovery tunnel failure has not yet met the bounded repair threshold' };
1060
+ }
1061
+ if (input.failures === 0) return { action: 'healthy', reason: 'all recovery probes healthy' };
1062
+ const recoveryRestartSustained = input.firstFailureAt !== undefined && now - input.firstFailureAt >= 5_000;
1063
+ if (input.failures >= 2 && recoveryRestartSustained && input.recoveryGatewayFailed && !input.recoveryGatewayRestartUsed) {
1064
+ return { action: 'restart_recovery_gateway', reason: 'the independent Recovery Gateway health endpoint failed after a sustained bounded failure window' };
1065
+ }
1066
+
1067
+ const restartAttempts = input.runtimeRestartAttempts ?? 0;
1068
+ const maximumRestartAttempts = Math.max(1, input.runtimeMaximumRestartAttempts ?? 3);
1069
+ const restartMinimumFailures = Math.max(1, input.runtimeMinimumFailures ?? 2);
1070
+ const restartMinimumDurationMs = Math.max(0, input.runtimeMinimumFailureDurationMs ?? 5_000);
1071
+ const restartSustained = input.firstFailureAt !== undefined && now - input.firstFailureAt >= restartMinimumDurationMs;
1072
+ const restartCooldownMs = Math.max(0, input.runtimeRestartCooldownMs ?? 10_000);
1073
+ const restartCooldownElapsed = input.runtimeRestartLastAttemptAt === undefined || now - input.runtimeRestartLastAttemptAt >= restartCooldownMs;
1074
+ if (
1075
+ input.primaryRuntimeFailed
1076
+ && input.failures >= restartMinimumFailures
1077
+ && restartSustained
1078
+ && restartAttempts < maximumRestartAttempts
1079
+ && restartCooldownElapsed
1080
+ ) {
1081
+ return { action: 'restart_primary_runtime', reason: `canonical Runtime failed sustained verification; attempt bounded whole-Runtime restart ${restartAttempts + 1}/${maximumRestartAttempts}` };
1082
+ }
1083
+
1084
+ const rollbackSustained = input.firstFailureAt !== undefined && now - input.firstFailureAt >= 30_000;
1085
+ const recoveryCooldownMs = Math.max(0, input.runtimeRecoveryCooldownMs ?? 60_000);
1086
+ const recoveryCooldownElapsed = input.runtimeRecoveryLastAttemptAt === undefined || now - input.runtimeRecoveryLastAttemptAt >= recoveryCooldownMs;
1087
+ const independentEvidence = new Set(input.evidenceClasses).size >= 2;
1088
+ if (
1089
+ input.primaryRuntimeFailed
1090
+ && restartAttempts >= maximumRestartAttempts
1091
+ && input.failures >= 6
1092
+ && rollbackSustained
1093
+ && independentEvidence
1094
+ && !input.activeKnownGood
1095
+ && input.previousKnownGood
1096
+ && !input.rollbackUsed
1097
+ && recoveryCooldownElapsed
1098
+ ) {
1099
+ return { action: 'rollback', reason: 'bounded primary Runtime restarts were exhausted and sustained multi-signal evidence permits previous whole-release recovery' };
1100
+ }
1101
+ if (
1102
+ input.primaryRuntimeFailed
1103
+ && restartAttempts >= maximumRestartAttempts
1104
+ && input.failures >= restartMinimumFailures
1105
+ && restartSustained
1106
+ && recoveryCooldownElapsed
1107
+ ) {
1108
+ return { action: 'recovery_exhausted', reason: `automatic restart budget exhausted for the active Runtime release (${restartAttempts}/${maximumRestartAttempts}); holding for rollback eligibility or operator handoff` };
1109
+ }
1110
+ return { action: 'degraded', reason: 'restart or rollback threshold, duration, cooldown, or independent-evidence quorum not met' };
1111
+ }
1112
+
1113
+ export async function diagnose(config: RecoveryConfig): Promise<Record<string, unknown>> {
1114
+ const verified = await verifyStableRuntime(config);
1115
+ return { verified, knownGood: knownGood(config), quarantine: json(quarantinePath(config)) ?? { releases: [] } };
1116
+ }
1117
+
1118
+ export async function listReleases(config: RecoveryConfig): Promise<Record<string, unknown>> {
1119
+ const observation = observeRuntimeStatus(config.controllerHome);
1120
+ return {
1121
+ runtimeRunning: observation.running,
1122
+ runtimeReady: observation.ready,
1123
+ active: activeAuthorityRelease(config),
1124
+ previous: previousAuthorityRelease(config),
1125
+ knownGood: knownGood(config).releases,
1126
+ };
1127
+ }
1128
+
1129
+ interface CommandResult { ok: boolean; status: number | null; stdout: string; stderr: string; }
1130
+ type CommandRunner = (commandName: string, args: string[], timeoutMs?: number) => Promise<CommandResult>;
1131
+ interface LaunchdService { uid: number; domain: string; target: string; label: string; plistPath: string; }
1132
+
1133
+ export interface PublicTunnelRepairDependencies {
1134
+ platform?: NodeJS.Platform;
1135
+ currentUid?: () => Promise<number | undefined>;
1136
+ runCommand?: CommandRunner;
1137
+ verify?: (config: RecoveryConfig) => Promise<VerifyResult>;
1138
+ verifyLocal?: (config: RecoveryConfig) => Promise<VerifyResult>;
1139
+ now?: () => number;
1140
+ sleep?: (ms: number) => Promise<void>;
1141
+ }
1142
+
1143
+ function command(commandName: string, args: string[], timeoutMs = 10_000, options: { cwd?: string; maxOutputBytes?: number } = {}): Promise<CommandResult> {
1144
+ return new Promise((resolveCommand) => {
1145
+ const child = spawn(commandName, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, cwd: options.cwd });
1146
+ const stdout: Buffer[] = [];
1147
+ const stderr: Buffer[] = [];
1148
+ let size = 0;
1149
+ const maxOutputBytes = options.maxOutputBytes ?? 256 * 1024;
1150
+ let settled = false;
1151
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
1152
+ const finish = (result: CommandResult) => {
1153
+ if (settled) return;
1154
+ settled = true;
1155
+ clearTimeout(timeout);
1156
+ if (killTimer) clearTimeout(killTimer);
1157
+ resolveCommand(result);
1158
+ };
1159
+ const signalChild = (signalName: NodeJS.Signals) => {
1160
+ if (!child.pid || child.exitCode != null) return;
1161
+ try { process.kill(child.pid, signalName); } catch { /* Process already exited. */ }
1162
+ };
1163
+ const stop = () => {
1164
+ if (child.exitCode != null) return;
1165
+ signalChild('SIGTERM');
1166
+ killTimer = setTimeout(() => signalChild('SIGKILL'), 1_000);
1167
+ };
1168
+ const timeout = setTimeout(stop, timeoutMs);
1169
+ child.stdout.on('data', (chunk: Buffer) => {
1170
+ size += chunk.length;
1171
+ if (size <= maxOutputBytes) stdout.push(Buffer.from(chunk)); else stop();
1172
+ });
1173
+ child.stderr.on('data', (chunk: Buffer) => {
1174
+ size += chunk.length;
1175
+ if (size <= maxOutputBytes) stderr.push(Buffer.from(chunk)); else stop();
1176
+ });
1177
+ child.once('error', () => finish({ ok: false, status: null, stdout: '', stderr: 'command spawn failed' }));
1178
+ child.once('close', (status) => finish({ ok: status === 0, status, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }));
1179
+ });
1180
+ }
1181
+
1182
+ async function currentUid(): Promise<number | undefined> {
1183
+ if (typeof process.getuid === 'function') return process.getuid();
1184
+ const result = await command('id', ['-u'], 2_000);
1185
+ const value = Number(result.stdout.trim());
1186
+ return result.ok && Number.isInteger(value) ? value : undefined;
1187
+ }
1188
+
1189
+ async function ensureLaunchdServiceStarted(service: LaunchdService, runCommand: CommandRunner = command): Promise<{ ok: boolean; detail: string }> {
1190
+ const printed = await runCommand('launchctl', ['print', service.target], 5_000);
1191
+ if (!printed.ok) {
1192
+ if (!existsSync(service.plistPath)) return { ok: false, detail: `launchd plist is missing: ${service.plistPath}` };
1193
+ const bootstrapped = await runCommand('launchctl', ['bootstrap', service.domain, service.plistPath], 15_000);
1194
+ if (!bootstrapped.ok && !/already|in progress|Input\/output error/i.test(`${bootstrapped.stderr}\n${bootstrapped.stdout}`)) {
1195
+ return { ok: false, detail: `launchd bootstrap failed: ${bootstrapped.stderr || bootstrapped.stdout || bootstrapped.status}` };
1196
+ }
1197
+ const loaded = await runCommand('launchctl', ['print', service.target], 5_000);
1198
+ if (!loaded.ok) {
1199
+ return { ok: false, detail: `launchd bootstrap did not load service: ${loaded.stderr || loaded.stdout || loaded.status}` };
1200
+ }
1201
+ await runCommand('launchctl', ['enable', service.target], 5_000);
1202
+ }
1203
+ const started = await runCommand('launchctl', ['kickstart', '-k', service.target], 15_000);
1204
+ const alreadyInProgress = started.status === 37 || /already|in progress/i.test(`${started.stderr}\n${started.stdout}`);
1205
+ if (!started.ok && !alreadyInProgress) {
1206
+ return { ok: false, detail: `launchd kickstart failed: ${started.stderr || started.stdout || started.status}` };
1207
+ }
1208
+ return { ok: true, detail: service.target };
1209
+ }
1210
+
1211
+ export interface PrimaryRuntimeRecoveryDependencies {
1212
+ platform?: NodeJS.Platform;
1213
+ currentUid?: () => Promise<number | undefined>;
1214
+ runCommand?: CommandRunner;
1215
+ verifyLocal?: (config: RecoveryConfig) => Promise<VerifyResult>;
1216
+ runtimeRunning?: (config: RecoveryConfig) => boolean;
1217
+ now?: () => number;
1218
+ sleep?: (ms: number) => Promise<void>;
1219
+ }
1220
+
1221
+ export interface RuntimeReleaseActivationGuard {
1222
+ /** External caller identity for audit/lock attribution. */
1223
+ requestId?: string;
1224
+ /** Authority snapshot observed when the caller decided to activate; null means the caller observed no authority. */
1225
+ expectedAuthorityRevision?: number | null;
1226
+ /** Active release observed when the caller decided to activate; null means the caller observed no active release. */
1227
+ expectedActiveReleaseId?: string | null;
1228
+ }
1229
+
1230
+ function configuredPrimaryRuntimeService(config: RecoveryConfig): PrimaryRuntimeServiceConfig {
1231
+ return config.primaryRuntimeService ?? { platform: 'launchd' };
1232
+ }
1233
+
1234
+ function primaryRuntimeLaunchdService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
1235
+ const configured = configuredPrimaryRuntimeService(config);
1236
+ if (configured.platform !== 'launchd') return undefined;
1237
+ const paths = forgeRuntimeServicePaths(config.controllerHome);
1238
+ if (!existsSync(paths.installedPlistPath)) return undefined;
1239
+ const domain = `gui/${uid}`;
1240
+ return {
1241
+ uid,
1242
+ domain,
1243
+ target: `${domain}/${paths.label}`,
1244
+ label: paths.label,
1245
+ plistPath: paths.installedPlistPath,
1246
+ };
1247
+ }
1248
+
1249
+ export interface PrimaryConnectorRecoveryDependencies {
1250
+ platform?: NodeJS.Platform;
1251
+ currentUid?: () => Promise<number | undefined>;
1252
+ runCommand?: CommandRunner;
1253
+ verifyLocal?: (config: RecoveryConfig) => Promise<VerifyResult>;
1254
+ reconnect?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string; verify: VerifyResult }>;
1255
+ now?: () => number;
1256
+ sleep?: (ms: number) => Promise<void>;
1257
+ }
1258
+
1259
+ function primaryConnectorLaunchdService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
1260
+ const configured = config.primaryConnectorService;
1261
+ if (!configured || configured.platform !== 'launchd' || !configured.label.trim()) return undefined;
1262
+ const label = configured.label.trim();
1263
+ const plistPath = resolve(configured.plistPath ?? join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`));
1264
+ if (!existsSync(plistPath)) return undefined;
1265
+ const domain = `gui/${uid}`;
1266
+ return { uid, domain, target: `${domain}/${label}`, label, plistPath };
1267
+ }
1268
+
1269
+ export async function restartPrimaryConnector(
1270
+ config: RecoveryConfig,
1271
+ dependencies: PrimaryConnectorRecoveryDependencies = {},
1272
+ ): Promise<PrimaryConnectorRestartResult> {
1273
+ const verifyLocal = dependencies.verifyLocal ?? verifyLocalRuntime;
1274
+ const initialLocal = await verifyLocal(config);
1275
+ if (!initialLocal.ok) {
1276
+ return {
1277
+ ok: false,
1278
+ attempted: false,
1279
+ noOp: true,
1280
+ detail: 'Canonical Runtime must be locally healthy before the primary Connector is restarted',
1281
+ verify: initialLocal,
1282
+ };
1283
+ }
1284
+ if ((dependencies.platform ?? process.platform) !== 'darwin') {
1285
+ return { ok: false, attempted: false, noOp: true, detail: 'primary Connector restart currently requires the configured macOS launchd service', verify: initialLocal };
1286
+ }
1287
+ const uid = await (dependencies.currentUid ?? currentUid)();
1288
+ const service = uid === undefined ? undefined : primaryConnectorLaunchdService(config, uid);
1289
+ if (!service) {
1290
+ return { ok: false, attempted: false, noOp: true, detail: 'primary Connector launchd service is not configured or installed', verify: initialLocal };
1291
+ }
1292
+ const reconnect = dependencies.reconnect ?? reconnectMain;
1293
+ const now = dependencies.now ?? Date.now;
1294
+ const wait = dependencies.sleep ?? sleep;
1295
+ const timeoutMs = config.primaryConnectorService?.postRestartVerifyTimeoutMs ?? 30_000;
1296
+ const locked = await withLock(config, { action: 'restart_primary_connector' }, async () => {
1297
+ const restarted = await ensureLaunchdServiceStarted(service, dependencies.runCommand ?? command);
1298
+ if (!restarted.ok) {
1299
+ audit(config, 'primary_connector_restart_failed', { serviceTarget: service.target, detail: restarted.detail });
1300
+ return { ok: false, attempted: true, detail: restarted.detail, serviceTarget: service.target, verify: initialLocal } satisfies PrimaryConnectorRestartResult;
1301
+ }
1302
+ const deadline = now() + timeoutMs;
1303
+ let observed = await reconnect(config);
1304
+ while (!observed.ok && now() < deadline) {
1305
+ await wait(1_000);
1306
+ observed = await reconnect(config);
1307
+ }
1308
+ audit(config, observed.ok ? 'primary_connector_restart_succeeded' : 'primary_connector_restart_unverified', {
1309
+ serviceTarget: service.target,
1310
+ detail: observed.detail,
1311
+ });
1312
+ return {
1313
+ ok: observed.ok,
1314
+ attempted: true,
1315
+ detail: observed.ok ? 'primary Connector restarted and the public MCP endpoint is reachable' : 'primary Connector restarted but the public MCP endpoint did not recover before timeout',
1316
+ serviceTarget: service.target,
1317
+ verify: observed.verify,
1318
+ } satisfies PrimaryConnectorRestartResult;
1319
+ });
1320
+ if (!locked.acquired) {
1321
+ return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target, verify: initialLocal };
1322
+ }
1323
+ return locked.value;
1324
+ }
1325
+
1326
+ async function waitForPrimaryRuntimeState(input: {
1327
+ config: RecoveryConfig;
1328
+ expectedRunning: boolean;
1329
+ timeoutMs: number;
1330
+ now: () => number;
1331
+ wait: (ms: number) => Promise<void>;
1332
+ runtimeRunning: (config: RecoveryConfig) => boolean;
1333
+ }): Promise<boolean> {
1334
+ const deadline = input.now() + input.timeoutMs;
1335
+ while (input.now() < deadline) {
1336
+ if (input.runtimeRunning(input.config) === input.expectedRunning) return true;
1337
+ await input.wait(500);
1338
+ }
1339
+ return input.runtimeRunning(input.config) === input.expectedRunning;
1340
+ }
1341
+
1342
+ async function waitForLaunchdServiceUnloaded(input: {
1343
+ service: LaunchdService;
1344
+ timeoutMs: number;
1345
+ now: () => number;
1346
+ wait: (ms: number) => Promise<void>;
1347
+ runCommand: CommandRunner;
1348
+ }): Promise<boolean> {
1349
+ const deadline = input.now() + input.timeoutMs;
1350
+ while (input.now() < deadline) {
1351
+ const printed = await input.runCommand('launchctl', ['print', input.service.target], 5_000);
1352
+ if (!printed.ok) return true;
1353
+ await input.wait(250);
1354
+ }
1355
+ const printed = await input.runCommand('launchctl', ['print', input.service.target], 5_000);
1356
+ return !printed.ok;
1357
+ }
1358
+
1359
+ async function waitForPrimaryRuntimePortReleased(input: {
1360
+ config: RecoveryConfig;
1361
+ timeoutMs: number;
1362
+ now: () => number;
1363
+ wait: (ms: number) => Promise<void>;
1364
+ runCommand: CommandRunner;
1365
+ }): Promise<boolean> {
1366
+ const paths = forgeRuntimeServicePaths(input.config.controllerHome);
1367
+ let port: number;
1368
+ try {
1369
+ port = readForgeRuntimeServiceConfig(paths.configPath).port;
1370
+ } catch {
1371
+ return false;
1372
+ }
1373
+ const isListening = async (): Promise<boolean> => {
1374
+ const result = await input.runCommand('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], 3_000);
1375
+ if (result.ok) return result.stdout.trim().length > 0;
1376
+ if (result.status === 1 && result.stderr.trim().length === 0) return false;
1377
+ return true;
1378
+ };
1379
+ const deadline = input.now() + input.timeoutMs;
1380
+ while (input.now() < deadline) {
1381
+ if (!(await isListening())) return true;
1382
+ await input.wait(250);
1383
+ }
1384
+ return !(await isListening());
1385
+ }
1386
+
1387
+ async function verifyPrimaryRuntimeAfterStart(input: {
1388
+ config: RecoveryConfig;
1389
+ timeoutMs: number;
1390
+ now: () => number;
1391
+ wait: (ms: number) => Promise<void>;
1392
+ verifyLocal: (config: RecoveryConfig) => Promise<VerifyResult>;
1393
+ }): Promise<VerifyResult> {
1394
+ const deadline = input.now() + input.timeoutMs;
1395
+ let observed = await input.verifyLocal(input.config);
1396
+ while (!observed.ok && input.now() < deadline) {
1397
+ await input.wait(1_000);
1398
+ observed = await input.verifyLocal(input.config);
1399
+ }
1400
+ return observed;
1401
+ }
1402
+
1403
+ export async function restartPrimaryRuntime(
1404
+ config: RecoveryConfig,
1405
+ dependencies: PrimaryRuntimeRecoveryDependencies = {},
1406
+ ): Promise<PrimaryRuntimeRestartResult> {
1407
+ const verifyLocal = dependencies.verifyLocal ?? verifyLocalRuntime;
1408
+ const initial = await verifyLocal(config);
1409
+ if (initial.ok) return { ok: true, attempted: false, noOp: true, detail: 'Canonical Forge Runtime is already healthy', verify: initial };
1410
+ if ((dependencies.platform ?? process.platform) !== 'darwin') {
1411
+ return { ok: false, attempted: false, noOp: true, detail: 'primary Runtime automatic restart currently requires the configured macOS launchd service', verify: initial };
1412
+ }
1413
+ const uid = await (dependencies.currentUid ?? currentUid)();
1414
+ const service = uid === undefined ? undefined : primaryRuntimeLaunchdService(config, uid);
1415
+ if (!service) return { ok: false, attempted: false, noOp: true, detail: 'primary Forge Runtime launchd service is not installed', verify: initial };
1416
+ const now = dependencies.now ?? Date.now;
1417
+ const wait = dependencies.sleep ?? sleep;
1418
+ const locked = await withLock(config, { action: 'restart_primary_runtime' }, async () => {
1419
+ const before = await verifyLocal(config);
1420
+ if (before.ok) return { ok: true, attempted: false, noOp: true, detail: 'Canonical Forge Runtime recovered before restart', serviceTarget: service.target, verify: before } satisfies PrimaryRuntimeRestartResult;
1421
+ const started = await ensureLaunchdServiceStarted(service, dependencies.runCommand ?? command);
1422
+ if (!started.ok) {
1423
+ audit(config, 'primary_runtime_restart_failed', { serviceTarget: service.target, detail: started.detail });
1424
+ return { ok: false, attempted: true, detail: started.detail, serviceTarget: service.target, verify: before } satisfies PrimaryRuntimeRestartResult;
1425
+ }
1426
+ const after = await verifyPrimaryRuntimeAfterStart({
1427
+ config,
1428
+ timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 30_000,
1429
+ now,
1430
+ wait,
1431
+ verifyLocal,
1432
+ });
1433
+ if (after.ok) {
1434
+ audit(config, 'primary_runtime_restart_succeeded', { serviceTarget: service.target, release: after.releases.active?.revision });
1435
+ return { ok: true, attempted: true, detail: 'Canonical Forge Runtime restarted and passed whole-Runtime verification', serviceTarget: service.target, verify: after } satisfies PrimaryRuntimeRestartResult;
1436
+ }
1437
+ audit(config, 'primary_runtime_restart_unverified', { serviceTarget: service.target, reasonCodes: after.runtime.reasonCodes });
1438
+ return { ok: false, attempted: true, detail: 'Canonical Forge Runtime restarted but did not pass whole-Runtime verification before timeout', serviceTarget: service.target, verify: after } satisfies PrimaryRuntimeRestartResult;
1439
+ });
1440
+ if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target, verify: initial };
1441
+ return locked.value;
1442
+ }
1443
+
1444
+ export async function recoverPrimaryRuntime(
1445
+ config: RecoveryConfig,
1446
+ reason = 'watchdog exhausted bounded primary Runtime restarts',
1447
+ dependencies: PrimaryRuntimeRecoveryDependencies = {},
1448
+ ): Promise<PrimaryRuntimeRecoveryResult> {
1449
+ const verifyLocal = dependencies.verifyLocal ?? verifyLocalRuntime;
1450
+ const initial = await verifyLocal(config);
1451
+ if (initial.ok) return { ok: true, attempted: false, noOp: true, detail: 'Canonical Forge Runtime recovered before rollback', verify: initial };
1452
+ if ((dependencies.platform ?? process.platform) !== 'darwin') {
1453
+ return { ok: false, attempted: false, noOp: true, detail: 'automatic whole-Runtime recovery currently requires the configured macOS launchd service', verify: initial };
1454
+ }
1455
+ const uid = await (dependencies.currentUid ?? currentUid)();
1456
+ const service = uid === undefined ? undefined : primaryRuntimeLaunchdService(config, uid);
1457
+ if (!service) return { ok: false, attempted: false, noOp: true, detail: 'primary Forge Runtime launchd service is not installed', verify: initial };
1458
+ const runCommand = dependencies.runCommand ?? command;
1459
+ const now = dependencies.now ?? Date.now;
1460
+ const wait = dependencies.sleep ?? sleep;
1461
+ const runtimeRunning = dependencies.runtimeRunning ?? ((value: RecoveryConfig) => observeRuntimeStatus(value.controllerHome).running);
1462
+ const locked = await withLock(config, { action: 'recover_primary_runtime' }, async () => {
1463
+ const before = await verifyLocal(config);
1464
+ if (before.ok) return { ok: true, attempted: false, noOp: true, detail: 'Canonical Forge Runtime recovered before rollback', serviceTarget: service.target, verify: before } satisfies PrimaryRuntimeRecoveryResult;
1465
+
1466
+ const stopped = await runCommand('launchctl', ['bootout', service.target], 15_000);
1467
+ const stoppedCleanly = stopped.ok || /not found|no such process|could not find service|service is not loaded/i.test(`${stopped.stderr}\n${stopped.stdout}`);
1468
+ if (!stoppedCleanly) {
1469
+ const detail = `primary Runtime bootout failed: ${stopped.stderr || stopped.stdout || stopped.status}`;
1470
+ audit(config, 'primary_runtime_recovery_stop_failed', { serviceTarget: service.target, detail });
1471
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: before } satisfies PrimaryRuntimeRecoveryResult;
1472
+ }
1473
+ const runtimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
1474
+ if (!runtimeStopped) {
1475
+ const detail = 'primary Runtime owner remained live after bounded launchd bootout';
1476
+ audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target });
1477
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1478
+ }
1479
+ const serviceUnloaded = await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand });
1480
+ if (!serviceUnloaded) {
1481
+ const detail = 'primary Runtime launchd service remained loaded after bounded bootout';
1482
+ audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target, detail });
1483
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1484
+ }
1485
+
1486
+ const portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
1487
+ if (!portReleased) {
1488
+ const detail = 'primary Runtime TCP port remained occupied after bounded launchd bootout';
1489
+ audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target, detail });
1490
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1491
+ }
1492
+
1493
+ const rollback = await rollbackPreviousLocked(config, reason);
1494
+ if (!rollback.ok) {
1495
+ audit(config, 'primary_runtime_recovery_rollback_failed', { serviceTarget: service.target, detail: rollback.detail });
1496
+ return { ok: false, attempted: true, detail: rollback.detail, serviceTarget: service.target, rollback, verify: rollback.verify ?? await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1497
+ }
1498
+
1499
+ const started = await ensureLaunchdServiceStarted(service, runCommand);
1500
+ if (!started.ok) {
1501
+ audit(config, 'primary_runtime_recovery_start_failed', { serviceTarget: service.target, detail: started.detail, rollbackOperationId: rollback.operationId });
1502
+ return { ok: false, attempted: true, detail: started.detail, serviceTarget: service.target, rollback, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1503
+ }
1504
+ const after = await verifyPrimaryRuntimeAfterStart({
1505
+ config,
1506
+ timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 45_000,
1507
+ now,
1508
+ wait,
1509
+ verifyLocal,
1510
+ });
1511
+ if (after.ok) {
1512
+ audit(config, 'primary_runtime_recovery_succeeded', { serviceTarget: service.target, rollbackOperationId: rollback.operationId, restoredRelease: after.releases.active?.revision });
1513
+ return { ok: true, attempted: true, detail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified', serviceTarget: service.target, rollback, verify: after } satisfies PrimaryRuntimeRecoveryResult;
1514
+ }
1515
+
1516
+ await runCommand('launchctl', ['bootout', service.target], 15_000);
1517
+ audit(config, 'primary_runtime_recovery_unverified', { serviceTarget: service.target, rollbackOperationId: rollback.operationId, reasonCodes: after.runtime.reasonCodes });
1518
+ return { ok: false, attempted: true, detail: 'previous whole-Runtime release started but failed verification; service was stopped to prevent a restart loop', serviceTarget: service.target, rollback, verify: after } satisfies PrimaryRuntimeRecoveryResult;
1519
+ });
1520
+ if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target, verify: initial };
1521
+ return locked.value;
1522
+ }
1523
+
1524
+ function canonicalRuntimeManifestValue(value: unknown): unknown {
1525
+ if (Array.isArray(value)) return value.map(canonicalRuntimeManifestValue);
1526
+ if (!value || typeof value !== 'object') return value;
1527
+ return Object.fromEntries(
1528
+ Object.entries(value as Record<string, unknown>)
1529
+ .sort(([left], [right]) => left.localeCompare(right))
1530
+ .map(([key, entry]) => [key, canonicalRuntimeManifestValue(entry)]),
1531
+ );
1532
+ }
1533
+
1534
+ function runtimeBehaviorIdentity(manifestPath: string): string {
1535
+ const parsed = JSON.parse(readFileSync(resolve(manifestPath), 'utf8')) as Record<string, unknown>;
1536
+ const behavior = { ...parsed };
1537
+ // These fields identify a build/release event but do not change the process or
1538
+ // any co-located runtime helper. Every other manifest field participates by
1539
+ // default so newly added sidecars cannot silently bypass restart detection.
1540
+ for (const field of ['releaseId', 'sourceCommit', 'releaseRevision', 'cleanWorkspace', 'createdAt']) {
1541
+ delete behavior[field];
1542
+ }
1543
+ return createHash('sha256').update(JSON.stringify(canonicalRuntimeManifestValue(behavior))).digest('hex');
1544
+ }
1545
+
1546
+ function runtimeBehaviorEquivalent(leftManifestPath: string, rightManifestPath: string): boolean {
1547
+ return runtimeBehaviorIdentity(leftManifestPath) === runtimeBehaviorIdentity(rightManifestPath);
1548
+ }
1549
+
1550
+ function validateRuntimeReleaseCandidate(
1551
+ config: RecoveryConfig,
1552
+ manifestPath: string,
1553
+ ): { manifest: RuntimeReleaseManifest; releaseRoot: string; manifestPath: string } {
1554
+ if (!isAbsolute(manifestPath)) throw new Error('RUNTIME_RELEASE_CANDIDATE_PATH_REQUIRED: an absolute release manifest path is required');
1555
+ if (!existsSync(manifestPath)) throw new Error(`RUNTIME_RELEASE_CANDIDATE_MANIFEST_MISSING: ${manifestPath}`);
1556
+ const resolvedManifestPath = resolve(manifestPath);
1557
+ const manifest = loadRuntimeReleaseManifest(resolvedManifestPath, config.controllerHome);
1558
+ const releaseRoot = dirname(resolvedManifestPath);
1559
+ if (basename(releaseRoot) !== manifest.releaseId) {
1560
+ throw new Error('RUNTIME_RELEASE_CANDIDATE_ID_MISMATCH: manifest releaseId must match the immutable release directory');
1561
+ }
1562
+ const executable = join(releaseRoot, manifest.entrypoint);
1563
+ if (!existsSync(executable)) throw new Error(`RUNTIME_RELEASE_CANDIDATE_ENTRYPOINT_MISSING: ${executable}`);
1564
+ const identity = `sha256:${createHash('sha256').update(readFileSync(executable)).digest('hex')}`;
1565
+ if (identity !== manifest.artifactIdentity) {
1566
+ throw new Error(`RUNTIME_RELEASE_CANDIDATE_ARTIFACT_MISMATCH: expected ${manifest.artifactIdentity} observed ${identity}`);
1567
+ }
1568
+ return { manifest, releaseRoot, manifestPath: resolvedManifestPath };
1569
+ }
1570
+
1571
+ /**
1572
+ * Activate an already staged and validated immutable Runtime release without
1573
+ * depending on the primary Runtime execution plane. The transaction mirrors
1574
+ * recoverPrimaryRuntime: stop the complete canonical service, atomically switch
1575
+ * active/previous whole-release authority (with a local SQLite backup), start
1576
+ * the one Runtime service, require whole-Runtime verification, and on failure
1577
+ * restore the previous whole release and its SQLite backup before restarting.
1578
+ */
1579
+ export async function activateRuntimeRelease(
1580
+ config: RecoveryConfig,
1581
+ candidateManifestPath: string,
1582
+ dependencies: PrimaryRuntimeRecoveryDependencies = {},
1583
+ guard: RuntimeReleaseActivationGuard = {},
1584
+ ): Promise<RuntimeReleaseActivationResult> {
1585
+ let candidate: { manifest: RuntimeReleaseManifest; releaseRoot: string; manifestPath: string };
1586
+ try {
1587
+ candidate = validateRuntimeReleaseCandidate(config, candidateManifestPath);
1588
+ } catch (error) {
1589
+ const detail = error instanceof Error ? error.message : 'runtime release candidate validation failed';
1590
+ audit(config, 'runtime_release_activation_candidate_invalid', { detail, ...(guard.requestId ? { requestId: guard.requestId } : {}) });
1591
+ return { ok: false, attempted: false, noOp: true, detail };
1592
+ }
1593
+ if ((dependencies.platform ?? process.platform) !== 'darwin') {
1594
+ return { ok: false, attempted: false, noOp: true, detail: 'Runtime release activation currently requires the configured macOS launchd service' };
1595
+ }
1596
+ const uid = await (dependencies.currentUid ?? currentUid)();
1597
+ const service = uid === undefined ? undefined : primaryRuntimeLaunchdService(config, uid);
1598
+ if (!service) return { ok: false, attempted: false, noOp: true, detail: 'primary Forge Runtime launchd service is not installed', verify: await verifyStableRuntime(config) };
1599
+ const runCommand = dependencies.runCommand ?? command;
1600
+ const now = dependencies.now ?? Date.now;
1601
+ const wait = dependencies.sleep ?? sleep;
1602
+ const runtimeRunning = dependencies.runtimeRunning ?? ((value: RecoveryConfig) => observeRuntimeStatus(value.controllerHome).running);
1603
+ const verifyLocal = dependencies.verifyLocal ?? verifyLocalRuntime;
1604
+ const operationId = `recovery-activate-runtime-${Date.now()}-${randomUUID().slice(0, 8)}`;
1605
+ const lockRequestId = guard.requestId?.trim() || operationId;
1606
+ const locked = await withLock(config, { action: 'activate_runtime_release', requestId: lockRequestId }, async () => {
1607
+ // Re-read authority only after acquiring the mutation lock. A caller may
1608
+ // have selected its candidate before another activation completed; stale
1609
+ // decisions must fail before bootout/publish rather than overwrite the newer
1610
+ // active release.
1611
+ const current = releaseAuthority(config);
1612
+ const previousActive = current?.active;
1613
+ const expectedRevision = guard.expectedAuthorityRevision;
1614
+ const expectedActiveReleaseId = typeof guard.expectedActiveReleaseId === 'string' ? guard.expectedActiveReleaseId.trim() : guard.expectedActiveReleaseId;
1615
+ if (expectedRevision !== undefined && (expectedRevision === null ? current !== undefined : current?.revision !== expectedRevision)) {
1616
+ const detail = `RUNTIME_RELEASE_ACTIVATION_STALE_BASE: expected authority revision ${expectedRevision}, observed ${current?.revision ?? 'none'}`;
1617
+ audit(config, 'runtime_release_activation_stale_base', {
1618
+ operationId,
1619
+ requestId: lockRequestId,
1620
+ candidateRevision: candidate.manifest.releaseId,
1621
+ expectedAuthorityRevision: expectedRevision,
1622
+ observedAuthorityRevision: current?.revision,
1623
+ expectedActiveReleaseId,
1624
+ observedActiveReleaseId: current?.active.releaseId,
1625
+ });
1626
+ return { ok: false, attempted: false, noOp: true, detail, operationId } satisfies RuntimeReleaseActivationResult;
1627
+ }
1628
+ if (expectedActiveReleaseId !== undefined && (expectedActiveReleaseId === null ? current?.active !== undefined : current?.active.releaseId !== expectedActiveReleaseId)) {
1629
+ const detail = `RUNTIME_RELEASE_ACTIVATION_STALE_BASE: expected active release ${expectedActiveReleaseId}, observed ${current?.active.releaseId ?? 'none'}`;
1630
+ audit(config, 'runtime_release_activation_stale_base', {
1631
+ operationId,
1632
+ requestId: lockRequestId,
1633
+ candidateRevision: candidate.manifest.releaseId,
1634
+ expectedAuthorityRevision: expectedRevision,
1635
+ observedAuthorityRevision: current?.revision,
1636
+ expectedActiveReleaseId,
1637
+ observedActiveReleaseId: current?.active.releaseId,
1638
+ });
1639
+ return { ok: false, attempted: false, noOp: true, detail, operationId } satisfies RuntimeReleaseActivationResult;
1640
+ }
1641
+ if (current && current.active.releaseId === candidate.manifest.releaseId) {
1642
+ return {
1643
+ ok: true,
1644
+ attempted: false,
1645
+ noOp: true,
1646
+ detail: 'requested Runtime release is already the active whole-Runtime release',
1647
+ operationId,
1648
+ verify: await verifyLocal(config),
1649
+ } satisfies RuntimeReleaseActivationResult;
1650
+ }
1651
+ if (current) {
1652
+ if (runtimeBehaviorEquivalent(current.active.manifestPath, candidate.manifestPath)) {
1653
+ const verify = await verifyLocal(config);
1654
+ audit(config, 'runtime_release_activation_behavior_identical', {
1655
+ operationId,
1656
+ requestId: lockRequestId,
1657
+ activeRevision: current.active.releaseId,
1658
+ candidateRevision: candidate.manifest.releaseId,
1659
+ artifactIdentity: candidate.manifest.artifactIdentity,
1660
+ });
1661
+ return {
1662
+ ok: verify.ok,
1663
+ attempted: false,
1664
+ noOp: true,
1665
+ detail: verify.ok
1666
+ ? 'candidate Runtime behavior is identical to the active release; restart skipped'
1667
+ : 'candidate Runtime behavior is identical to the active release, but the active Runtime is unhealthy',
1668
+ operationId,
1669
+ verify,
1670
+ } satisfies RuntimeReleaseActivationResult;
1671
+ }
1672
+ }
1673
+ if (current?.previous?.releaseId === candidate.manifest.releaseId) {
1674
+ const detail = 'RUNTIME_RELEASE_REVERSE_ACTIVATION_REQUIRES_ROLLBACK: activate_runtime_release cannot replace the active release with current.previous; use rollback_previous or recover_primary_runtime';
1675
+ audit(config, 'runtime_release_reverse_activation_rejected', {
1676
+ operationId,
1677
+ requestId: lockRequestId,
1678
+ candidateRevision: candidate.manifest.releaseId,
1679
+ activeRevision: current.active.releaseId,
1680
+ authorityRevision: current.revision,
1681
+ });
1682
+ return { ok: false, attempted: false, noOp: true, detail, operationId } satisfies RuntimeReleaseActivationResult;
1683
+ }
1684
+ const before = await verifyLocal(config);
1685
+ const stopped = await runCommand('launchctl', ['bootout', service.target], 15_000);
1686
+ const stoppedCleanly = stopped.ok || /not found|no such process|could not find service|service is not loaded/i.test(`${stopped.stderr}\n${stopped.stdout}`);
1687
+ if (!stoppedCleanly) {
1688
+ const detail = `primary Runtime bootout failed: ${stopped.stderr || stopped.stdout || stopped.status}`;
1689
+ audit(config, 'runtime_release_activation_stop_failed', { serviceTarget: service.target, operationId, detail });
1690
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1691
+ }
1692
+ const runtimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
1693
+ if (!runtimeStopped) {
1694
+ const detail = 'primary Runtime owner remained live after bounded launchd bootout';
1695
+ audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId });
1696
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1697
+ }
1698
+ const serviceUnloaded = await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand });
1699
+ if (!serviceUnloaded) {
1700
+ const detail = 'primary Runtime launchd service remained loaded after bounded bootout';
1701
+ audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail });
1702
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1703
+ }
1704
+ const portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
1705
+ if (!portReleased) {
1706
+ const detail = 'primary Runtime TCP port remained occupied after bounded launchd bootout';
1707
+ audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail });
1708
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1709
+ }
1710
+ let committed: RuntimeReleaseAuthority;
1711
+ try {
1712
+ committed = publishRuntimeRelease(config.controllerHome, candidate.manifestPath, operationId);
1713
+ } catch (error) {
1714
+ const detail = error instanceof Error ? error.message : 'runtime release authority publish failed';
1715
+ audit(config, 'runtime_release_activation_publish_failed', { serviceTarget: service.target, operationId, detail });
1716
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1717
+ }
1718
+ if (committed.active.releaseId !== candidate.manifest.releaseId || committed.active.artifactIdentity !== candidate.manifest.artifactIdentity) {
1719
+ const detail = 'runtime release activation commit identity mismatch';
1720
+ audit(config, 'runtime_release_activation_commit_mismatch', { serviceTarget: service.target, operationId });
1721
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1722
+ }
1723
+ try {
1724
+ ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
1725
+ } catch (error) {
1726
+ const detail = `runtime launchd stable-release contract update failed: ${error instanceof Error ? error.message : String(error)}`;
1727
+ audit(config, 'runtime_release_activation_launchd_contract_failed', { serviceTarget: service.target, operationId, detail });
1728
+ return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1729
+ }
1730
+ const started = await ensureLaunchdServiceStarted(service, runCommand);
1731
+ let after: VerifyResult;
1732
+ let activationFailureDetail: string | undefined;
1733
+ if (!started.ok) {
1734
+ activationFailureDetail = started.detail;
1735
+ audit(config, 'runtime_release_activation_start_failed', { serviceTarget: service.target, operationId, detail: started.detail });
1736
+ after = await verifyLocal(config);
1737
+ } else {
1738
+ after = await verifyPrimaryRuntimeAfterStart({
1739
+ config,
1740
+ timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
1741
+ now,
1742
+ wait,
1743
+ verifyLocal,
1744
+ });
1745
+ if (after.ok && after.releases.active?.revision === candidate.manifest.releaseId) {
1746
+ audit(config, 'runtime_release_activation_succeeded', { serviceTarget: service.target, operationId, requestId: lockRequestId, activeRevision: after.releases.active?.revision, expectedAuthorityRevision: guard.expectedAuthorityRevision, expectedActiveReleaseId: guard.expectedActiveReleaseId });
1747
+ return { ok: true, attempted: true, detail: 'requested Runtime release activated and passed whole-Runtime verification', serviceTarget: service.target, operationId, verify: after } satisfies RuntimeReleaseActivationResult;
1748
+ }
1749
+ }
1750
+
1751
+ // Activation failed: stop, restore the previous whole release and its SQLite
1752
+ // backup, restart the one service, and require verification again.
1753
+ await runCommand('launchctl', ['bootout', service.target], 15_000);
1754
+ let rollback: RollbackResult;
1755
+ const rollbackRuntimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
1756
+ const rollbackServiceUnloaded = rollbackRuntimeStopped
1757
+ ? await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand })
1758
+ : false;
1759
+ const rollbackPortReleased = rollbackRuntimeStopped && rollbackServiceUnloaded
1760
+ ? await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand })
1761
+ : false;
1762
+ if (!rollbackRuntimeStopped || !rollbackServiceUnloaded || !rollbackPortReleased) {
1763
+ rollback = {
1764
+ ok: false,
1765
+ detail: !rollbackRuntimeStopped
1766
+ ? 'candidate Runtime owner remained live after bounded rollback bootout'
1767
+ : !rollbackServiceUnloaded
1768
+ ? 'candidate Runtime launchd service remained loaded after bounded rollback bootout'
1769
+ : 'candidate Runtime TCP port remained occupied after bounded rollback bootout',
1770
+ };
1771
+ } else {
1772
+ try {
1773
+ const rollbackOperationId = `recovery-activate-runtime-rollback-${Date.now()}-${randomUUID().slice(0, 8)}`;
1774
+ const restored = rollbackRuntimeRelease(config.controllerHome, rollbackOperationId);
1775
+ ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
1776
+ const previousRevision = previousActive?.releaseId ?? '';
1777
+ const previousIdentity = previousActive?.artifactIdentity;
1778
+ if (
1779
+ restored.active.releaseId !== previousRevision
1780
+ || (previousIdentity !== undefined && restored.active.artifactIdentity !== previousIdentity)
1781
+ ) {
1782
+ throw new Error('RECOVERY_RUNTIME_RELEASE_ROLLBACK_AUTHORITY_MISMATCH');
1783
+ }
1784
+ const restarted = await ensureLaunchdServiceStarted(service, runCommand);
1785
+ if (!restarted.ok) {
1786
+ rollback = { ok: false, operationId: rollbackOperationId, detail: `previous release authority restored but service start failed: ${restarted.detail}` };
1787
+ } else {
1788
+ const rolledBack = await verifyPrimaryRuntimeAfterStart({
1789
+ config,
1790
+ timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
1791
+ now,
1792
+ wait,
1793
+ verifyLocal,
1794
+ });
1795
+ if (rolledBack.ok) {
1796
+ rollback = { ok: true, operationId: rollbackOperationId, detail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified', verify: rolledBack };
1797
+ } else {
1798
+ await runCommand('launchctl', ['bootout', service.target], 15_000);
1799
+ rollback = { ok: false, operationId: rollbackOperationId, detail: 'previous whole-Runtime release started but failed verification; service was stopped to prevent a restart loop', verify: rolledBack };
1800
+ }
1801
+ }
1802
+ } catch (error) {
1803
+ rollback = { ok: false, detail: error instanceof Error ? error.message : 'previous whole-Runtime release rollback failed' };
1804
+ }
1805
+ }
1806
+ audit(config, 'runtime_release_activation_failed', {
1807
+ serviceTarget: service.target,
1808
+ operationId,
1809
+ candidateRevision: candidate.manifest.releaseId,
1810
+ rollbackOperationId: rollback.operationId,
1811
+ rollbackOk: rollback.ok,
1812
+ rollbackDetail: rollback.detail,
1813
+ reasonCodes: after.runtime.reasonCodes,
1814
+ });
1815
+ return {
1816
+ ok: false,
1817
+ attempted: true,
1818
+ detail: activationFailureDetail
1819
+ ? `requested Runtime release failed to start; previous release restored: ${activationFailureDetail}`
1820
+ : 'requested Runtime release activated but failed whole-Runtime verification; previous release restored',
1821
+ serviceTarget: service.target,
1822
+ operationId,
1823
+ rollback,
1824
+ verify: after,
1825
+ } satisfies RuntimeReleaseActivationResult;
1826
+ });
1827
+ if (!locked.acquired) {
1828
+ return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target, verify: await verifyStableRuntime(config) };
1829
+ }
1830
+ return locked.value;
1831
+ }
1832
+
1833
+ export interface ConfiguredRuntimeActivationDependencies {
1834
+ stage?: typeof stageRuntimeReleaseFromCandidateSource;
1835
+ activate?: (config: RecoveryConfig, manifestPath: string, guard: RuntimeReleaseActivationGuard) => Promise<RuntimeReleaseActivationResult>;
1836
+ }
1837
+
1838
+ export async function stageAndActivateConfiguredRuntimeRelease(
1839
+ config: RecoveryConfig,
1840
+ dependencies: ConfiguredRuntimeActivationDependencies = {},
1841
+ requestId?: string,
1842
+ ): Promise<ConfiguredRuntimeActivationResult> {
1843
+ const sourceRoot = config.primaryRuntimeSourceRoot?.trim();
1844
+ if (!sourceRoot) {
1845
+ return { ok: false, attempted: false, noOp: true, detail: 'primary Runtime source root is not configured in standalone Recovery' };
1846
+ }
1847
+ // Capture the base before staging. Staging can take long enough for another
1848
+ // activation to win; the later publish must prove this base is still current.
1849
+ const expectedAuthority = releaseAuthority(config);
1850
+ let staged: StagedRuntimeRelease;
1851
+ try {
1852
+ staged = (dependencies.stage ?? stageRuntimeReleaseFromCandidateSource)({ controllerHome: config.controllerHome, sourceRoot });
1853
+ assertRuntimeReleaseFiles(staged);
1854
+ } catch (error) {
1855
+ const detail = error instanceof Error ? error.message : 'Runtime release staging failed';
1856
+ audit(config, 'runtime_release_stage_failed', { sourceRoot, detail });
1857
+ return { ok: false, attempted: false, noOp: true, detail };
1858
+ }
1859
+ const activationGuard: RuntimeReleaseActivationGuard = {
1860
+ ...(requestId?.trim() ? { requestId: requestId.trim() } : {}),
1861
+ expectedAuthorityRevision: expectedAuthority?.revision ?? null,
1862
+ expectedActiveReleaseId: expectedAuthority?.active.releaseId ?? null,
1863
+ };
1864
+ const activation = dependencies.activate
1865
+ ? await dependencies.activate(config, staged.manifestPath, activationGuard)
1866
+ : await activateRuntimeRelease(config, staged.manifestPath, {}, activationGuard);
1867
+ audit(config, activation.ok ? 'runtime_release_stage_and_activate_succeeded' : 'runtime_release_stage_and_activate_failed', {
1868
+ sourceRoot,
1869
+ ...(requestId?.trim() ? { requestId: requestId.trim() } : {}),
1870
+ expectedAuthorityRevision: activationGuard.expectedAuthorityRevision,
1871
+ expectedActiveReleaseId: activationGuard.expectedActiveReleaseId,
1872
+ releaseId: staged.releaseId,
1873
+ sourceCommit: staged.sourceCommit,
1874
+ activationAttempted: activation.attempted,
1875
+ activationDetail: activation.detail,
1876
+ });
1877
+ return {
1878
+ ok: activation.ok,
1879
+ attempted: activation.attempted,
1880
+ noOp: activation.noOp,
1881
+ detail: activation.ok ? 'configured Runtime source was staged as one immutable release and activated through standalone Recovery' : activation.detail,
1882
+ staged,
1883
+ activation,
1884
+ };
1885
+ }
1886
+
1887
+ function recoveryTunnelService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
1888
+ const configured = configuredRecoveryTunnel(config);
1889
+ if (!configured || configured.platform !== 'launchd') return undefined;
1890
+ if (!/^com\.[A-Za-z0-9._-]{1,180}$/.test(configured.label)) return undefined;
1891
+ const plistPath = configured.plistPath;
1892
+ if (plistPath !== undefined && (!isAbsolute(plistPath) || !existsSync(plistPath))) return undefined;
1893
+ return {
1894
+ uid,
1895
+ domain: `gui/${uid}`,
1896
+ target: `gui/${uid}/${configured.label}`,
1897
+ label: configured.label,
1898
+ plistPath: plistPath ?? join(homedir(), 'Library', 'LaunchAgents', `${configured.label}.plist`),
1899
+ };
1900
+ }
1901
+
1902
+ function tunnelRepairAllowed(config: RecoveryConfig, now: number): boolean {
1903
+ const cooldownMs = configuredRecoveryTunnel(config)?.cooldownMs ?? 60_000;
1904
+ const prior = json<{ lastAttemptAt?: unknown }>(publicTunnelRepairStatePath(config));
1905
+ return typeof prior?.lastAttemptAt !== 'number' || now - prior.lastAttemptAt >= cooldownMs;
1906
+ }
1907
+
1908
+ export async function repairPublicTunnel(config: RecoveryConfig, dependencies: PublicTunnelRepairDependencies = {}): Promise<PublicTunnelRepairResult> {
1909
+ const verify = dependencies.verify ?? verifyStableRuntime;
1910
+ const verifyLocal = dependencies.verifyLocal ?? verifyLocalRuntime;
1911
+ const now = dependencies.now ?? Date.now;
1912
+ const wait = dependencies.sleep ?? sleep;
1913
+ const initial = await verify(config);
1914
+ const configured = configuredRecoveryTunnel(config);
1915
+ if (!configured) return { ok: false, attempted: false, noOp: true, detail: 'public tunnel repair is not configured', verify: initial };
1916
+ const localVerify = await verifyLocal(config);
1917
+ if (!isExternalTunnelFailure(config, initial, localVerify)) {
1918
+ return { ok: (initial.probes.recovery_external_http ?? initial.probes.external_mcp_http)?.ok === true, attempted: false, noOp: true, detail: 'Recovery tunnel repair requires a healthy local runtime and failed Recovery external endpoint', verify: initial, localVerify };
1919
+ }
1920
+ if ((dependencies.platform ?? process.platform) !== 'darwin') {
1921
+ return { ok: false, attempted: false, noOp: true, detail: 'public tunnel repair is only supported for explicitly configured launchd services', verify: initial, localVerify };
1922
+ }
1923
+ const uid = await (dependencies.currentUid ?? currentUid)();
1924
+ const service = uid === undefined ? undefined : recoveryTunnelService(config, uid);
1925
+ if (!service) return { ok: false, attempted: false, noOp: true, detail: 'public tunnel launchd configuration is invalid or unavailable', verify: initial, localVerify };
1926
+ if (!tunnelRepairAllowed(config, now())) {
1927
+ return { ok: false, attempted: false, noOp: true, detail: 'public tunnel repair is in cooldown', serviceLabel: service.label, serviceTarget: service.target, verify: initial, localVerify };
1928
+ }
1929
+ const locked = await withLock(config, { action: 'repair_public_tunnel' }, async () => {
1930
+ // Recheck after acquiring ownership so a concurrent watchdog never causes a restart storm.
1931
+ const before = await verify(config);
1932
+ const localBefore = await verifyLocal(config);
1933
+ if (!isExternalTunnelFailure(config, before, localBefore)) {
1934
+ return { ok: (before.probes.recovery_external_http ?? before.probes.external_mcp_http)?.ok === true, attempted: false, noOp: true, detail: 'Recovery tunnel recovered before restart', serviceLabel: service.label, serviceTarget: service.target, verify: before, localVerify: localBefore };
1935
+ }
1936
+ writeJson(publicTunnelRepairStatePath(config), { lastAttemptAt: now(), serviceLabel: service.label });
1937
+ const started = await ensureLaunchdServiceStarted(service, dependencies.runCommand ?? command);
1938
+ if (!started.ok) {
1939
+ audit(config, 'public_tunnel_restart_failed', { serviceLabel: service.label, detail: started.detail });
1940
+ return { ok: false, attempted: true, detail: started.detail, serviceLabel: service.label, serviceTarget: service.target, verify: before, localVerify: localBefore };
1941
+ }
1942
+ const timeoutMs = configured.postRestartVerifyTimeoutMs ?? 20_000;
1943
+ const deadline = now() + timeoutMs;
1944
+ let after = before;
1945
+ while (now() < deadline) {
1946
+ await wait(1_000);
1947
+ after = await verify(config);
1948
+ if ((after.probes.recovery_external_http ?? after.probes.external_mcp_http)?.ok === true) {
1949
+ audit(config, 'public_tunnel_restart_succeeded', { serviceLabel: service.label, serviceTarget: service.target });
1950
+ return { ok: true, attempted: true, detail: 'public tunnel service restarted and external endpoint verified', serviceLabel: service.label, serviceTarget: service.target, verify: after, localVerify: await verifyLocal(config) };
1951
+ }
1952
+ }
1953
+ audit(config, 'public_tunnel_restart_unverified', { serviceLabel: service.label, serviceTarget: service.target });
1954
+ return { ok: false, attempted: true, detail: 'public tunnel service restarted but external endpoint did not recover before timeout', serviceLabel: service.label, serviceTarget: service.target, verify: after, localVerify: await verifyLocal(config) };
1955
+ });
1956
+ if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceLabel: service.label, serviceTarget: service.target, verify: initial, localVerify };
1957
+ return locked.value;
1958
+ }
1959
+
1960
+
1961
+ export interface RecoveryGatewayRestartResult {
1962
+ ok: boolean;
1963
+ attempted: boolean;
1964
+ noOp?: boolean;
1965
+ detail: string;
1966
+ serviceTarget?: string;
1967
+ }
1968
+
1969
+ export interface RecoveryGatewayRestartDependencies {
1970
+ platform?: NodeJS.Platform;
1971
+ currentUid?: () => Promise<number | undefined>;
1972
+ runCommand?: CommandRunner;
1973
+ probeGateway?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
1974
+ now?: () => number;
1975
+ sleep?: (ms: number) => Promise<void>;
1976
+ }
1977
+
1978
+ function recoveryGatewayLaunchdService(config: RecoveryConfig, uid: number): LaunchdService {
1979
+ const label = 'com.moretea.forge-recovery-gateway';
1980
+ const generated = join(recoveryRoot(config), 'launchd', `${label}.plist`);
1981
+ const installed = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
1982
+ return {
1983
+ uid,
1984
+ domain: `gui/${uid}`,
1985
+ target: `gui/${uid}/${label}`,
1986
+ label,
1987
+ plistPath: existsSync(installed) ? installed : generated,
1988
+ };
1989
+ }
1990
+
1991
+ async function probeRecoveryGateway(config: RecoveryConfig): Promise<{ ok: boolean; detail: string }> {
1992
+ if (!config.gateway) return { ok: false, detail: 'Recovery Gateway is not configured' };
1993
+ return probe(createRecoveryHttpTransport(config.controllerHome), `http://${config.gateway.host}:${config.gateway.port}/health`);
1994
+ }
1995
+
1996
+ export async function restartRecoveryGateway(
1997
+ config: RecoveryConfig,
1998
+ dependencies: RecoveryGatewayRestartDependencies = {},
1999
+ ): Promise<RecoveryGatewayRestartResult> {
2000
+ if (!config.gateway) return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway is not configured' };
2001
+ if ((dependencies.platform ?? process.platform) !== 'darwin') return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway launchd restart is only supported on macOS' };
2002
+ const check = dependencies.probeGateway ?? probeRecoveryGateway;
2003
+ const initial = await check(config);
2004
+ if (initial.ok) return { ok: true, attempted: false, noOp: true, detail: 'Recovery Gateway is already healthy' };
2005
+ const uid = await (dependencies.currentUid ?? currentUid)();
2006
+ if (uid === undefined) return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway launchd UID is unavailable' };
2007
+ const service = recoveryGatewayLaunchdService(config, uid);
2008
+ if (!existsSync(service.plistPath)) return { ok: false, attempted: false, noOp: true, detail: `Recovery Gateway launchd plist is missing: ${service.plistPath}`, serviceTarget: service.target };
2009
+ const locked = await withLock(config, { action: 'restart_recovery_gateway' }, async () => {
2010
+ const before = await check(config);
2011
+ if (before.ok) return { ok: true, attempted: false, noOp: true, detail: 'Recovery Gateway recovered before restart', serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2012
+ const started = await ensureLaunchdServiceStarted(service, dependencies.runCommand ?? command);
2013
+ if (!started.ok) {
2014
+ audit(config, 'recovery_gateway_restart_failed', { serviceTarget: service.target, detail: started.detail });
2015
+ return { ok: false, attempted: true, detail: started.detail, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2016
+ }
2017
+ const now = dependencies.now ?? Date.now;
2018
+ const wait = dependencies.sleep ?? sleep;
2019
+ const deadline = now() + 20_000;
2020
+ let observed = before;
2021
+ while (now() < deadline) {
2022
+ await wait(1_000);
2023
+ observed = await check(config);
2024
+ if (observed.ok) {
2025
+ audit(config, 'recovery_gateway_restart_succeeded', { serviceTarget: service.target });
2026
+ return { ok: true, attempted: true, detail: 'Recovery Gateway restarted and its local health endpoint recovered', serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2027
+ }
2028
+ }
2029
+ audit(config, 'recovery_gateway_restart_unverified', { serviceTarget: service.target, detail: observed.detail });
2030
+ return { ok: false, attempted: true, detail: 'Recovery Gateway restarted but did not pass local health verification before timeout', serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2031
+ });
2032
+ if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target };
2033
+ return locked.value;
2034
+ }
2035
+
2036
+ function pidAlive(pid: number | undefined): boolean {
2037
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false;
2038
+ try { process.kill(pid, 0); return true; } catch { return false; }
2039
+ }
2040
+
2041
+ function sleep(ms: number): Promise<void> {
2042
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
2043
+ }
2044
+
2045
+ export function gatewayToken(config: RecoveryConfig): string | undefined {
2046
+ const file = config.gateway?.bearerTokenFile;
2047
+ const parsed = file ? json<{ token?: unknown; expiresAt?: unknown }>(file) : undefined;
2048
+ if (typeof parsed?.expiresAt === 'string' && Date.parse(parsed.expiresAt) <= Date.now()) return undefined;
2049
+ return typeof parsed?.token === 'string' && parsed.token.length >= 32 ? parsed.token : undefined;
2050
+ }
2051
+
2052
+ export function initializeStandaloneRecovery(
2053
+ controllerHome: string,
2054
+ port = 8787,
2055
+ extensions: Partial<Pick<RecoveryConfig, 'publicMcpUrl' | 'recoveryPublicUrl' | 'recoveryTunnelService' | 'primaryRuntimeService' | 'primaryRuntimeSourceRoot' | 'primaryConnectorService' | 'readOnlyTool'>> = {},
2056
+ ): RecoveryConfig {
2057
+ const root = resolve(controllerHome);
2058
+ const tokenPath = join(root, 'recovery', 'config', 'gateway-token.json');
2059
+ if (!existsSync(tokenPath)) {
2060
+ mkdirSync(dirname(tokenPath), { recursive: true, mode: 0o700 });
2061
+ writeFileSync(tokenPath, `${JSON.stringify({ token: randomBytes(32).toString('base64url'), createdAt: new Date().toISOString() })}\n`, { mode: 0o600 });
2062
+ try { chmodSync(tokenPath, 0o600); } catch { /* best effort */ }
2063
+ }
2064
+ return createRecoveryConfig(root, {
2065
+ gateway: { host: '127.0.0.1', port, bearerTokenFile: tokenPath },
2066
+ ...extensions,
2067
+ });
2068
+ }
2069
+
2070
+ export function secureEqual(left: string, right: string): boolean {
2071
+ const a = Buffer.from(left); const b = Buffer.from(right);
2072
+ return a.length === b.length && timingSafeEqual(a, b);
2073
+ }
2074
+
2075
+ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState): Promise<{
2076
+ state: WatchdogState;
2077
+ decision: WatchdogDecision;
2078
+ verify: VerifyResult;
2079
+ rollback?: RollbackResult;
2080
+ publicTunnelRepair?: PublicTunnelRepairResult;
2081
+ recoveryGatewayRestart?: RecoveryGatewayRestartResult;
2082
+ primaryRuntimeRestart?: PrimaryRuntimeRestartResult;
2083
+ primaryRuntimeRecovery?: PrimaryRuntimeRecoveryResult;
2084
+ }> {
2085
+ const now = Date.now();
2086
+ const scopedPrior = scopeWatchdogStateToRuntimeRelease(prior, activeAuthorityRelease(config));
2087
+ const runtimeObservation = observeRuntimeStatus(config.controllerHome);
2088
+ const runtimeStartupGrace = runtimeWithinWatchdogStartupGrace(
2089
+ runtimeObservation,
2090
+ now,
2091
+ watchdogRuntimeStartupGraceMs(config),
2092
+ );
2093
+ // Five-second watchdog ticks must stay cheap while the system is healthy.
2094
+ // Full MCP protocol verification serializes the entire tool surface, so run
2095
+ // it periodically and immediately escalate to it when a local health probe
2096
+ // fails. A live Runtime that was just started gets a bounded grace window so
2097
+ // startup/reconciliation cannot itself trigger a Recovery restart storm.
2098
+ // Explicit verify/attest/release operations remain strict by default.
2099
+ const fullVerifyDue = !runtimeStartupGrace
2100
+ && (scopedPrior.lastFullVerifyAt === undefined || now - scopedPrior.lastFullVerifyAt >= 60_000);
2101
+ let fullVerificationPerformed = fullVerifyDue;
2102
+ let verified = await verifyStableRuntime(
2103
+ config,
2104
+ createRecoveryHttpTransport(config.controllerHome),
2105
+ { probeMcpProtocol: fullVerifyDue },
2106
+ );
2107
+ let localVerify = await verifyLocalRuntime(config, { probeMcpProtocol: fullVerifyDue });
2108
+ if (!localVerify.ok && !fullVerifyDue && !runtimeStartupGrace) {
2109
+ verified = await verifyStableRuntime(config);
2110
+ localVerify = await verifyLocalRuntime(config);
2111
+ fullVerificationPerformed = true;
2112
+ }
2113
+ const lastFullVerifyAt = fullVerificationPerformed ? now : scopedPrior.lastFullVerifyAt;
2114
+ if (runtimeStartupGrace && !localVerify.ok) {
2115
+ const state: WatchdogState = {
2116
+ ...scopedPrior,
2117
+ failures: 0,
2118
+ firstFailureAt: undefined,
2119
+ runtimeHealthySince: undefined,
2120
+ lastFullVerifyAt,
2121
+ lastDecision: 'degraded',
2122
+ };
2123
+ return {
2124
+ state,
2125
+ decision: { action: 'degraded', reason: 'canonical Runtime is within startup grace; restart escalation suppressed' },
2126
+ verify: verified,
2127
+ };
2128
+ }
2129
+ const recoveryHealthy = verified.probes.recovery_gateway?.ok !== false
2130
+ && verified.probes.recovery_external_http?.ok !== false;
2131
+ const primaryRuntimeHealthy = verified.ok && localVerify.ok;
2132
+ if (primaryRuntimeHealthy && recoveryHealthy) {
2133
+ const stable = recordWatchdogRuntimeHealthy(
2134
+ scopeWatchdogStateToRuntimeRelease(scopedPrior, verified.releases.active),
2135
+ now,
2136
+ watchdogRuntimeRestartBudgetStableMs(config),
2137
+ );
2138
+ const state: WatchdogState = {
2139
+ ...stable,
2140
+ failures: 0,
2141
+ firstFailureAt: undefined,
2142
+ runtimeRecoveryFailures: 0,
2143
+ runtimeRecoveryLastAttemptAt: undefined,
2144
+ publicTunnelFailures: 0,
2145
+ publicTunnelFirstFailureAt: undefined,
2146
+ publicTunnelRepairFailures: 0,
2147
+ recoveryGatewayRestartUsed: false,
2148
+ lastFullVerifyAt,
2149
+ lastDecision: 'healthy',
2150
+ };
2151
+ return { state, decision: { action: 'healthy', reason: 'primary runtime and standalone Recovery verification passed' }, verify: verified };
2152
+ }
2153
+ const publicTunnelFailed = isExternalTunnelFailure(config, verified, localVerify);
2154
+ const evidenceClasses = Object.entries(localVerify.probes)
2155
+ .filter(([name, value]) => !name.startsWith('recovery_') && !value.ok)
2156
+ .map(([name]) => name.startsWith('mcp') ? 'mcp' : name.startsWith('active') ? 'gateway' : name);
2157
+ if (!localVerify.runtime.ok) evidenceClasses.push('runtime');
2158
+ const activeKnownGood = Boolean(matchingKnownGood(config, verified.releases.active));
2159
+ const previousKnownGood = Boolean(matchingKnownGood(config, verified.releases.previous));
2160
+ const budgetPrior = primaryRuntimeHealthy
2161
+ ? recordWatchdogRuntimeHealthy(
2162
+ scopeWatchdogStateToRuntimeRelease(scopedPrior, verified.releases.active),
2163
+ now,
2164
+ watchdogRuntimeRestartBudgetStableMs(config),
2165
+ )
2166
+ : scopedPrior;
2167
+ const state: WatchdogState = publicTunnelFailed
2168
+ ? {
2169
+ ...budgetPrior,
2170
+ lastFullVerifyAt,
2171
+ failures: budgetPrior.failures + 1,
2172
+ firstFailureAt: budgetPrior.firstFailureAt ?? now,
2173
+ publicTunnelFailures: (budgetPrior.publicTunnelFailures ?? 0) + 1,
2174
+ publicTunnelFirstFailureAt: budgetPrior.publicTunnelFirstFailureAt ?? now,
2175
+ }
2176
+ : {
2177
+ ...budgetPrior,
2178
+ lastFullVerifyAt,
2179
+ failures: budgetPrior.failures + 1,
2180
+ firstFailureAt: budgetPrior.firstFailureAt ?? now,
2181
+ publicTunnelFailures: 0,
2182
+ publicTunnelFirstFailureAt: undefined,
2183
+ };
2184
+ if (!localVerify.ok) state.runtimeHealthySince = undefined;
2185
+ const primaryConfig = configuredPrimaryRuntimeService(config);
2186
+ const decision = decideWatchdog({
2187
+ ...state,
2188
+ nowMs: now,
2189
+ evidenceClasses,
2190
+ activeKnownGood,
2191
+ previousKnownGood,
2192
+ primaryRuntimeFailed: !localVerify.ok,
2193
+ runtimeMaximumRestartAttempts: primaryConfig.maximumRestartAttempts,
2194
+ runtimeRestartCooldownMs: primaryConfig.restartCooldownMs,
2195
+ runtimeMinimumFailures: primaryConfig.minimumFailures,
2196
+ runtimeMinimumFailureDurationMs: primaryConfig.minimumFailureDurationMs,
2197
+ runtimeRecoveryLastAttemptAt: state.runtimeRecoveryLastAttemptAt,
2198
+ runtimeRecoveryCooldownMs: primaryConfig.recoveryCooldownMs,
2199
+ recoveryGatewayFailed: verified.probes.recovery_gateway?.ok === false,
2200
+ publicTunnelConfigured: Boolean(configuredRecoveryTunnel(config)),
2201
+ publicTunnelFailed,
2202
+ publicTunnelMinimumFailures: configuredRecoveryTunnel(config)?.minimumFailures,
2203
+ publicTunnelMinimumFailureDurationMs: configuredRecoveryTunnel(config)?.minimumFailureDurationMs,
2204
+ });
2205
+ state.lastDecision = decision.action;
2206
+ if (decision.action === 'repair_public_tunnel') {
2207
+ const publicTunnelRepair = await repairPublicTunnel(config);
2208
+ const nextState = publicTunnelRepair.ok
2209
+ ? { ...state, failures: 0, publicTunnelFailures: 0, publicTunnelRepairFailures: 0, firstFailureAt: undefined, publicTunnelFirstFailureAt: undefined }
2210
+ : { ...state, publicTunnelRepairFailures: (state.publicTunnelRepairFailures ?? 0) + 1 };
2211
+ return { state: nextState, decision, verify: verified, publicTunnelRepair };
2212
+ }
2213
+ if (decision.action === 'restart_recovery_gateway') {
2214
+ const recoveryGatewayRestart = await restartRecoveryGateway(config);
2215
+ return { state: { ...state, recoveryGatewayRestartUsed: true }, decision, verify: verified, recoveryGatewayRestart };
2216
+ }
2217
+ if (decision.action === 'restart_primary_runtime') {
2218
+ const primaryRuntimeRestart = await restartPrimaryRuntime(config);
2219
+ const attempts = (state.runtimeRestartAttempts ?? 0) + (primaryRuntimeRestart.attempted ? 1 : 0);
2220
+ const nextState: WatchdogState = primaryRuntimeRestart.ok
2221
+ ? {
2222
+ ...state,
2223
+ failures: 0,
2224
+ firstFailureAt: undefined,
2225
+ runtimeRestartAttempts: attempts,
2226
+ runtimeRestartFailures: state.runtimeRestartFailures ?? 0,
2227
+ runtimeRestartLastAttemptAt: now,
2228
+ runtimeHealthySince: undefined,
2229
+ }
2230
+ : {
2231
+ ...state,
2232
+ runtimeRestartAttempts: attempts,
2233
+ runtimeRestartFailures: (state.runtimeRestartFailures ?? 0) + 1,
2234
+ runtimeRestartLastAttemptAt: now,
2235
+ };
2236
+ return { state: nextState, decision, verify: verified, primaryRuntimeRestart };
2237
+ }
2238
+ if (decision.action === 'rollback') {
2239
+ const primaryRuntimeRecovery = await recoverPrimaryRuntime(config, 'watchdog exhausted bounded primary Runtime restarts with sustained multi-signal failure');
2240
+ const rollbackCommitted = primaryRuntimeRecovery.rollback?.ok === true && primaryRuntimeRecovery.rollback.noOp !== true;
2241
+ return {
2242
+ state: {
2243
+ ...state,
2244
+ rollbackUsed: state.rollbackUsed || rollbackCommitted,
2245
+ runtimeRestartAttempts: rollbackCommitted ? 0 : state.runtimeRestartAttempts,
2246
+ runtimeRestartFailures: rollbackCommitted ? 0 : state.runtimeRestartFailures,
2247
+ runtimeRestartLastAttemptAt: rollbackCommitted ? undefined : state.runtimeRestartLastAttemptAt,
2248
+ runtimeRecoveryFailures: primaryRuntimeRecovery.ok ? 0 : (state.runtimeRecoveryFailures ?? 0) + 1,
2249
+ runtimeRecoveryLastAttemptAt: now,
2250
+ },
2251
+ decision,
2252
+ verify: verified,
2253
+ primaryRuntimeRecovery,
2254
+ ...(primaryRuntimeRecovery.rollback ? { rollback: primaryRuntimeRecovery.rollback } : {}),
2255
+ };
2256
+ }
2257
+ if (decision.action === 'recovery_exhausted') {
2258
+ if (state.runtimeRestartBudgetExhaustedAt === undefined) {
2259
+ audit(config, 'primary_runtime_restart_budget_exhausted', {
2260
+ runtimeRestartBudgetIdentity: state.runtimeRestartBudgetIdentity,
2261
+ attempts: state.runtimeRestartAttempts ?? 0,
2262
+ maximumRestartAttempts: primaryConfig.maximumRestartAttempts ?? 3,
2263
+ activeKnownGood,
2264
+ previousKnownGood,
2265
+ evidenceClasses,
2266
+ });
2267
+ }
2268
+ return {
2269
+ state: { ...state, runtimeRestartBudgetExhaustedAt: state.runtimeRestartBudgetExhaustedAt ?? now },
2270
+ decision,
2271
+ verify: verified,
2272
+ };
2273
+ }
2274
+ return { state, decision, verify: verified };
2275
+ }