@treeseed/sdk 0.11.0 → 0.12.4

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 (473) hide show
  1. package/README.md +9 -4
  2. package/dist/agent-capacity.d.ts +916 -0
  3. package/dist/agent-capacity.js +1600 -0
  4. package/dist/agent-tools.d.ts +37 -0
  5. package/dist/agent-tools.js +301 -0
  6. package/dist/api/app.d.ts +1 -1
  7. package/dist/api/auth/d1-database.d.ts +2 -2
  8. package/dist/api/auth/d1-provider.d.ts +4 -4
  9. package/dist/api/auth/d1-store.d.ts +2 -2
  10. package/dist/api/auth/memory-provider.d.ts +1 -1
  11. package/dist/api/auth/tokens.d.ts +1 -1
  12. package/dist/api/config.d.ts +3 -1
  13. package/dist/api/config.js +34 -10
  14. package/dist/api/http.d.ts +2 -2
  15. package/dist/api/index.d.ts +10 -10
  16. package/dist/api/index.js +4 -2
  17. package/dist/api/operations-routes.d.ts +3 -3
  18. package/dist/api/operations.d.ts +1 -1
  19. package/dist/api/providers.d.ts +1 -1
  20. package/dist/api/providers.js +21 -4
  21. package/dist/api/railway.d.ts +3 -2
  22. package/dist/api/sdk-dispatch.d.ts +4 -4
  23. package/dist/api/sdk-routes.d.ts +2 -2
  24. package/dist/api/templates.d.ts +2 -2
  25. package/dist/api/types.d.ts +3 -2
  26. package/dist/capacity-provider.d.ts +117 -60
  27. package/dist/capacity-provider.js +215 -43
  28. package/dist/capacity.d.ts +2 -2
  29. package/dist/cli-tools.d.ts +1 -1
  30. package/dist/content-operations.d.ts +97 -0
  31. package/dist/content-operations.js +250 -0
  32. package/dist/content-store.d.ts +4 -4
  33. package/dist/control-plane-client.d.ts +13 -18
  34. package/dist/control-plane-client.js +8 -86
  35. package/dist/control-plane.d.ts +2 -2
  36. package/dist/copilot.d.ts +3 -0
  37. package/dist/copilot.js +16 -1
  38. package/dist/d1-http.d.ts +1 -1
  39. package/dist/d1-store.d.ts +32 -57
  40. package/dist/d1-store.js +115 -318
  41. package/dist/db/d1.d.ts +1 -232
  42. package/dist/db/index.d.ts +3 -3
  43. package/dist/db/market-schema.d.ts +49037 -11843
  44. package/dist/db/market-schema.js +1788 -50
  45. package/dist/db/node-sqlite.d.ts +1 -232
  46. package/dist/db/schema.d.ts +0 -462
  47. package/dist/db/schema.js +2 -22
  48. package/dist/dispatch.d.ts +1 -1
  49. package/dist/dispatch.js +0 -11
  50. package/dist/fixture-support.js +52 -4
  51. package/dist/governance.d.ts +133 -0
  52. package/dist/governance.js +285 -0
  53. package/dist/graph/build.d.ts +2 -2
  54. package/dist/graph/context-query-contracts.d.ts +1 -1
  55. package/dist/graph/dsl.d.ts +1 -1
  56. package/dist/graph/query.d.ts +4 -4
  57. package/dist/graph/ranking.d.ts +1 -1
  58. package/dist/graph/schema.d.ts +1 -1
  59. package/dist/graph.d.ts +11 -11
  60. package/dist/guarantees/index.d.ts +425 -0
  61. package/dist/guarantees/index.js +1146 -0
  62. package/dist/hosting/apps.d.ts +1 -1
  63. package/dist/hosting/builtins.d.ts +1 -1
  64. package/dist/hosting/builtins.js +9 -198
  65. package/dist/hosting/contracts.d.ts +3 -2
  66. package/dist/hosting/graph.d.ts +24 -21
  67. package/dist/hosting/graph.js +280 -294
  68. package/dist/hosting/index.d.ts +4 -4
  69. package/dist/index.d.ts +88 -74
  70. package/dist/index.js +81 -10
  71. package/dist/local-dev/managed-dev.d.ts +85 -0
  72. package/dist/local-dev/managed-dev.js +368 -0
  73. package/dist/managed-dependencies.d.ts +9 -0
  74. package/dist/managed-dependencies.js +10 -8
  75. package/dist/market-client.d.ts +265 -7
  76. package/dist/market-client.js +381 -9
  77. package/dist/model-registry.d.ts +1 -1
  78. package/dist/model-registry.js +19 -19
  79. package/dist/operations/agent-tools.d.ts +2 -11
  80. package/dist/operations/agent-tools.js +2 -14
  81. package/dist/operations/providers/default.d.ts +1 -1
  82. package/dist/operations/providers/default.js +20 -37
  83. package/dist/operations/repository-operations.d.ts +26 -2
  84. package/dist/operations/repository-operations.js +159 -1
  85. package/dist/operations/runtime.d.ts +3 -3
  86. package/dist/operations/services/bootstrap-runner.d.ts +1 -1
  87. package/dist/operations/services/build-warning-policy.d.ts +34 -0
  88. package/dist/operations/services/build-warning-policy.js +81 -82
  89. package/dist/operations/services/commit-message-provider.js +4 -4
  90. package/dist/operations/services/config-runtime.d.ts +73 -19
  91. package/dist/operations/services/config-runtime.js +276 -100
  92. package/dist/operations/services/deploy.d.ts +41 -622
  93. package/dist/operations/services/deploy.js +181 -213
  94. package/dist/operations/services/deployment-readiness.d.ts +1 -1
  95. package/dist/operations/services/deployment-readiness.js +38 -7
  96. package/dist/operations/services/git-remote-policy.js +12 -4
  97. package/dist/operations/services/git-runner.d.ts +66 -0
  98. package/dist/operations/services/git-runner.js +479 -0
  99. package/dist/operations/services/git-workflow.d.ts +43 -5
  100. package/dist/operations/services/git-workflow.js +242 -20
  101. package/dist/operations/services/github-actions-verification.d.ts +3 -1
  102. package/dist/operations/services/github-actions-verification.js +11 -3
  103. package/dist/operations/services/github-api.js +21 -5
  104. package/dist/operations/services/github-automation.d.ts +3 -3
  105. package/dist/operations/services/github-automation.js +15 -18
  106. package/dist/operations/services/github-credentials.js +2 -1
  107. package/dist/operations/services/hosted-service-checks.d.ts +19 -1
  108. package/dist/operations/services/hosted-service-checks.js +162 -11
  109. package/dist/operations/services/hosting-audit.d.ts +2 -2
  110. package/dist/operations/services/hosting-audit.js +7 -7
  111. package/dist/operations/services/hub-launch.d.ts +1 -1
  112. package/dist/operations/services/hub-provider-launch.d.ts +5 -5
  113. package/dist/operations/services/hub-provider-launch.js +19 -14
  114. package/dist/operations/services/live-hosted-service-checks.d.ts +2 -1
  115. package/dist/operations/services/live-hosted-service-checks.js +193 -80
  116. package/dist/operations/services/local-dev.js +0 -1
  117. package/dist/operations/services/managed-host-security.d.ts +1 -1
  118. package/dist/operations/services/managed-repositories.d.ts +37 -0
  119. package/dist/operations/services/managed-repositories.js +177 -0
  120. package/dist/operations/services/operations-runner-smoke.d.ts +3 -2
  121. package/dist/operations/services/operations-runner-smoke.js +22 -4
  122. package/dist/operations/services/package-adapters.d.ts +77 -2
  123. package/dist/operations/services/package-adapters.js +627 -56
  124. package/dist/operations/services/package-reference-policy.d.ts +12 -88
  125. package/dist/operations/services/package-reference-policy.js +81 -213
  126. package/dist/operations/services/project-host-operations.d.ts +4 -4
  127. package/dist/operations/services/project-platform.d.ts +19 -193
  128. package/dist/operations/services/project-platform.js +31 -103
  129. package/dist/operations/services/project-web-monitor.d.ts +2 -2
  130. package/dist/operations/services/project-web-monitor.js +97 -2
  131. package/dist/operations/services/railway-api.d.ts +36 -7
  132. package/dist/operations/services/railway-api.js +331 -100
  133. package/dist/operations/services/railway-deploy.d.ts +7 -95
  134. package/dist/operations/services/railway-deploy.js +334 -742
  135. package/dist/operations/services/release-history.js +3 -4
  136. package/dist/operations/services/release-proof-ledger.d.ts +13 -0
  137. package/dist/operations/services/release-proof-ledger.js +81 -0
  138. package/dist/operations/services/release-proof-planner.d.ts +48 -0
  139. package/dist/operations/services/release-proof-planner.js +195 -0
  140. package/dist/operations/services/release-proof-runner.d.ts +16 -0
  141. package/dist/operations/services/release-proof-runner.js +169 -0
  142. package/dist/operations/services/release-proof.d.ts +81 -0
  143. package/dist/operations/services/release-proof.js +79 -0
  144. package/dist/operations/services/repository-save-orchestrator.d.ts +6 -7
  145. package/dist/operations/services/repository-save-orchestrator.js +353 -174
  146. package/dist/operations/services/runtime-paths.d.ts +1 -3
  147. package/dist/operations/services/runtime-paths.js +0 -4
  148. package/dist/operations/services/runtime-tools.d.ts +1 -192
  149. package/dist/operations/services/runtime-tools.js +4 -444
  150. package/dist/operations/services/save-deploy-preflight.js +9 -1
  151. package/dist/operations/services/template-host-bindings.d.ts +2 -2
  152. package/dist/operations/services/template-host-bindings.js +3 -2
  153. package/dist/operations/services/template-registry.d.ts +6 -6
  154. package/dist/operations/services/template-registry.js +33 -14
  155. package/dist/operations/services/template-secret-sync.d.ts +3 -3
  156. package/dist/operations/services/workflow-timing.d.ts +25 -0
  157. package/dist/operations/services/workflow-timing.js +79 -0
  158. package/dist/operations/services/workspace-dependency-mode.js +41 -4
  159. package/dist/operations/services/workspace-preflight.js +8 -8
  160. package/dist/operations/services/workspace-save-script.d.ts +4 -4
  161. package/dist/operations/services/workspace-save.d.ts +1 -0
  162. package/dist/operations/services/workspace-save.js +41 -18
  163. package/dist/operations/services/workspace-tools.js +40 -6
  164. package/dist/operations-registry.d.ts +1 -1
  165. package/dist/operations-registry.js +5 -6
  166. package/dist/operations-types.d.ts +1 -1
  167. package/dist/operations.d.ts +17 -11
  168. package/dist/operations.js +57 -3
  169. package/dist/platform/book-export.js +2 -0
  170. package/dist/platform/books-data.d.ts +1 -1
  171. package/dist/platform/config.d.ts +27 -0
  172. package/dist/platform/config.js +100 -0
  173. package/dist/platform/content-runtime-source.d.ts +54 -0
  174. package/dist/platform/content-runtime-source.js +169 -0
  175. package/dist/platform/contracts.d.ts +6 -1
  176. package/dist/platform/deploy-config.d.ts +1 -1
  177. package/dist/platform/deploy-config.js +7 -1
  178. package/dist/platform/deploy-runtime.d.ts +1 -1
  179. package/dist/platform/desired-state.d.ts +63 -0
  180. package/dist/platform/desired-state.js +1021 -0
  181. package/dist/platform/env.yaml +414 -19
  182. package/dist/platform/environment.d.ts +2 -2
  183. package/dist/platform/environment.js +54 -22
  184. package/dist/platform/local-content-materialization.d.ts +9 -0
  185. package/dist/platform/local-content-materialization.js +212 -0
  186. package/dist/platform/plugin.d.ts +5 -5
  187. package/dist/platform/plugins/runtime.d.ts +3 -3
  188. package/dist/platform/plugins/runtime.js +62 -1
  189. package/dist/platform/plugins.d.ts +6 -6
  190. package/dist/platform/published-content-pipeline.d.ts +3 -3
  191. package/dist/platform/published-content-pipeline.js +5 -1
  192. package/dist/platform/published-content.d.ts +4 -3
  193. package/dist/platform/site-config-schema.d.ts +1 -0
  194. package/dist/platform/site-config-schema.js +4 -1
  195. package/dist/platform/tenant/runtime-config.d.ts +152 -2
  196. package/dist/platform/tenant-config.d.ts +1 -1
  197. package/dist/platform/utils/site-config-schema.d.ts +154 -0
  198. package/dist/platform/utils/site-config-schema.js +400 -461
  199. package/dist/platform-operation-store.d.ts +1 -1
  200. package/dist/plugin-default.d.ts +1 -1
  201. package/dist/plugin-default.js +27 -12
  202. package/dist/project-import.d.ts +91 -0
  203. package/dist/project-import.js +257 -0
  204. package/dist/project-workflow.d.ts +1 -1
  205. package/dist/reconcile/bootstrap-systems.d.ts +2 -2
  206. package/dist/reconcile/bootstrap-systems.js +3 -3
  207. package/dist/reconcile/builtin-adapters.d.ts +7 -1
  208. package/dist/reconcile/builtin-adapters.js +2924 -346
  209. package/dist/reconcile/contracts.d.ts +15 -2
  210. package/dist/reconcile/desired-state.d.ts +2 -193
  211. package/dist/reconcile/desired-state.js +24 -33
  212. package/dist/reconcile/engine.d.ts +60 -397
  213. package/dist/reconcile/engine.js +182 -18
  214. package/dist/reconcile/index.d.ts +10 -10
  215. package/dist/reconcile/live-acceptance.d.ts +22 -1
  216. package/dist/reconcile/live-acceptance.js +456 -44
  217. package/dist/reconcile/providers/docker-private.d.ts +54 -0
  218. package/dist/reconcile/providers/docker-private.js +101 -0
  219. package/dist/reconcile/providers/github-private.d.ts +65 -0
  220. package/dist/reconcile/providers/github-private.js +119 -0
  221. package/dist/reconcile/providers/local-private.d.ts +28 -0
  222. package/dist/reconcile/providers/local-private.js +75 -0
  223. package/dist/reconcile/providers/railway-iac.d.ts +66 -0
  224. package/dist/reconcile/providers/railway-iac.js +275 -0
  225. package/dist/reconcile/providers/release-private.d.ts +92 -0
  226. package/dist/reconcile/providers/release-private.js +152 -0
  227. package/dist/reconcile/registry.d.ts +2 -2
  228. package/dist/reconcile/registry.js +16 -1
  229. package/dist/reconcile/state.d.ts +1 -1
  230. package/dist/reconcile/state.js +3 -40
  231. package/dist/reconcile/units.d.ts +1 -1
  232. package/dist/reconcile/units.js +7 -1
  233. package/dist/remote.d.ts +6 -6
  234. package/dist/scenes/artifacts.d.ts +16 -0
  235. package/dist/scenes/artifacts.js +61 -0
  236. package/dist/scenes/auth.d.ts +2 -0
  237. package/dist/scenes/auth.js +28 -0
  238. package/dist/scenes/base-url.d.ts +12 -0
  239. package/dist/scenes/base-url.js +123 -0
  240. package/dist/scenes/builtin-plugins.d.ts +2 -0
  241. package/dist/scenes/builtin-plugins.js +447 -0
  242. package/dist/scenes/checkpoints.d.ts +14 -0
  243. package/dist/scenes/checkpoints.js +33 -0
  244. package/dist/scenes/device-matrix.d.ts +2 -0
  245. package/dist/scenes/device-matrix.js +97 -0
  246. package/dist/scenes/devices.d.ts +11 -0
  247. package/dist/scenes/devices.js +22 -0
  248. package/dist/scenes/diagnostics.d.ts +5 -0
  249. package/dist/scenes/diagnostics.js +22 -0
  250. package/dist/scenes/diagram-providers.d.ts +2 -0
  251. package/dist/scenes/diagram-providers.js +181 -0
  252. package/dist/scenes/diagram-validation.d.ts +22 -0
  253. package/dist/scenes/diagram-validation.js +74 -0
  254. package/dist/scenes/environment.d.ts +2 -0
  255. package/dist/scenes/environment.js +65 -0
  256. package/dist/scenes/evidence-bundle.d.ts +5 -0
  257. package/dist/scenes/evidence-bundle.js +42 -0
  258. package/dist/scenes/evidence-report.d.ts +10 -0
  259. package/dist/scenes/evidence-report.js +73 -0
  260. package/dist/scenes/evidence.d.ts +16 -0
  261. package/dist/scenes/evidence.js +277 -0
  262. package/dist/scenes/index.d.ts +48 -0
  263. package/dist/scenes/index.js +312 -0
  264. package/dist/scenes/inspect.d.ts +9 -0
  265. package/dist/scenes/inspect.js +64 -0
  266. package/dist/scenes/loader.d.ts +3 -0
  267. package/dist/scenes/loader.js +38 -0
  268. package/dist/scenes/logs.d.ts +2 -0
  269. package/dist/scenes/logs.js +47 -0
  270. package/dist/scenes/operations.d.ts +3 -0
  271. package/dist/scenes/operations.js +128 -0
  272. package/dist/scenes/phase0.d.ts +9 -0
  273. package/dist/scenes/phase0.js +637 -0
  274. package/dist/scenes/planner.d.ts +12 -0
  275. package/dist/scenes/planner.js +166 -0
  276. package/dist/scenes/playwright-adapter.d.ts +2 -0
  277. package/dist/scenes/playwright-adapter.js +63 -0
  278. package/dist/scenes/plugins.d.ts +5 -0
  279. package/dist/scenes/plugins.js +115 -0
  280. package/dist/scenes/progress.d.ts +17 -0
  281. package/dist/scenes/progress.js +24 -0
  282. package/dist/scenes/publish-destinations.d.ts +12 -0
  283. package/dist/scenes/publish-destinations.js +99 -0
  284. package/dist/scenes/publish-plan-report.d.ts +10 -0
  285. package/dist/scenes/publish-plan-report.js +70 -0
  286. package/dist/scenes/publish-plan.d.ts +15 -0
  287. package/dist/scenes/publish-plan.js +248 -0
  288. package/dist/scenes/publish-redaction.d.ts +16 -0
  289. package/dist/scenes/publish-redaction.js +148 -0
  290. package/dist/scenes/publish-release-record.d.ts +13 -0
  291. package/dist/scenes/publish-release-record.js +34 -0
  292. package/dist/scenes/publish-report.d.ts +10 -0
  293. package/dist/scenes/publish-report.js +67 -0
  294. package/dist/scenes/publish.d.ts +16 -0
  295. package/dist/scenes/publish.js +298 -0
  296. package/dist/scenes/registry.d.ts +12 -0
  297. package/dist/scenes/registry.js +65 -0
  298. package/dist/scenes/remotion-adapter.d.ts +3 -0
  299. package/dist/scenes/remotion-adapter.js +94 -0
  300. package/dist/scenes/remotion-composition-registry.d.ts +2 -0
  301. package/dist/scenes/remotion-composition-registry.js +12 -0
  302. package/dist/scenes/remotion-compositions.d.ts +15 -0
  303. package/dist/scenes/remotion-compositions.js +464 -0
  304. package/dist/scenes/remotion-diagrams.d.ts +9 -0
  305. package/dist/scenes/remotion-diagrams.js +188 -0
  306. package/dist/scenes/remotion-input.d.ts +13 -0
  307. package/dist/scenes/remotion-input.js +291 -0
  308. package/dist/scenes/remotion-visuals.d.ts +29 -0
  309. package/dist/scenes/remotion-visuals.js +198 -0
  310. package/dist/scenes/render-media-assets.d.ts +9 -0
  311. package/dist/scenes/render-media-assets.js +117 -0
  312. package/dist/scenes/render-report.d.ts +10 -0
  313. package/dist/scenes/render-report.js +27 -0
  314. package/dist/scenes/render.d.ts +2 -0
  315. package/dist/scenes/render.js +218 -0
  316. package/dist/scenes/reporter.d.ts +3 -0
  317. package/dist/scenes/reporter.js +101 -0
  318. package/dist/scenes/resume.d.ts +2 -0
  319. package/dist/scenes/resume.js +79 -0
  320. package/dist/scenes/runner.d.ts +2 -0
  321. package/dist/scenes/runner.js +674 -0
  322. package/dist/scenes/schema.d.ts +6 -0
  323. package/dist/scenes/schema.js +994 -0
  324. package/dist/scenes/seed.d.ts +2 -0
  325. package/dist/scenes/seed.js +83 -0
  326. package/dist/scenes/segments.d.ts +15 -0
  327. package/dist/scenes/segments.js +79 -0
  328. package/dist/scenes/selectors.d.ts +3 -0
  329. package/dist/scenes/selectors.js +21 -0
  330. package/dist/scenes/timeline.d.ts +11 -0
  331. package/dist/scenes/timeline.js +26 -0
  332. package/dist/scenes/timeouts.d.ts +6 -0
  333. package/dist/scenes/timeouts.js +17 -0
  334. package/dist/scenes/training-captions.d.ts +14 -0
  335. package/dist/scenes/training-captions.js +98 -0
  336. package/dist/scenes/training-report.d.ts +10 -0
  337. package/dist/scenes/training-report.js +78 -0
  338. package/dist/scenes/training-transcript.d.ts +24 -0
  339. package/dist/scenes/training-transcript.js +204 -0
  340. package/dist/scenes/training.d.ts +10 -0
  341. package/dist/scenes/training.js +134 -0
  342. package/dist/scenes/types.d.ts +1872 -0
  343. package/dist/scenes/types.js +8 -0
  344. package/dist/scenes/visual-audit-contact-sheets.d.ts +6 -0
  345. package/dist/scenes/visual-audit-contact-sheets.js +119 -0
  346. package/dist/scenes/visual-audit-fixtures.d.ts +25 -0
  347. package/dist/scenes/visual-audit-fixtures.js +210 -0
  348. package/dist/scenes/visual-audit-report.d.ts +11 -0
  349. package/dist/scenes/visual-audit-report.js +85 -0
  350. package/dist/scenes/visual-audit-review.d.ts +23 -0
  351. package/dist/scenes/visual-audit-review.js +829 -0
  352. package/dist/scenes/visual-audit-routes.d.ts +13 -0
  353. package/dist/scenes/visual-audit-routes.js +308 -0
  354. package/dist/scenes/visual-audit.d.ts +3 -0
  355. package/dist/scenes/visual-audit.js +596 -0
  356. package/dist/scripts/build-dist.js +154 -40
  357. package/dist/scripts/check-build-warnings.js +42 -53
  358. package/dist/scripts/cleanup-markdown.js +1 -1
  359. package/dist/scripts/cli-test-fixtures.js +24 -28
  360. package/dist/scripts/migrate-journey-csv-to-guarantees.js +140 -0
  361. package/dist/scripts/package-tools.js +12 -1
  362. package/dist/scripts/prepare.js +3 -7
  363. package/dist/scripts/release-verify.js +2 -5
  364. package/dist/scripts/run-fixture-astro-command.js +1 -1
  365. package/dist/scripts/template-catalog.test.js +68 -82
  366. package/dist/scripts/tenant-dev.js +0 -10
  367. package/dist/scripts/test-cloudflare-local.js +1 -1
  368. package/dist/scripts/test-scaffold.js +0 -1
  369. package/dist/scripts/test-smoke.js +3 -3
  370. package/dist/scripts/treeseed-build-dist.js +4 -7
  371. package/dist/scripts/treeseed-release-verify.js +1 -1
  372. package/dist/scripts/verify-driver.js +30 -21
  373. package/dist/scripts/workflow-commands.test.js +18 -27
  374. package/dist/scripts/workspace-command-e2e.js +1 -1
  375. package/dist/scripts/workspace-lint.js +6 -0
  376. package/dist/scripts/workspace-release-test.js +90 -1
  377. package/dist/scripts/workspace-test.js +6 -0
  378. package/dist/sdk-dispatch.d.ts +1 -1
  379. package/dist/sdk-dispatch.js +0 -16
  380. package/dist/sdk-fields.d.ts +1 -1
  381. package/dist/sdk-filters.d.ts +1 -1
  382. package/dist/sdk-types.d.ts +1184 -113
  383. package/dist/sdk-types.js +393 -0
  384. package/dist/sdk.d.ts +74 -79
  385. package/dist/sdk.js +27 -36
  386. package/dist/secrets-capability.d.ts +421 -0
  387. package/dist/secrets-capability.js +530 -0
  388. package/dist/seeds/normalize.js +1 -0
  389. package/dist/seeds/planner.js +38 -0
  390. package/dist/seeds/schema.js +293 -4
  391. package/dist/seeds/types.d.ts +60 -0
  392. package/dist/seeds/types.js +9 -1
  393. package/dist/service-credentials.d.ts +54 -0
  394. package/dist/service-credentials.js +112 -0
  395. package/dist/stores/cursor-store.d.ts +2 -2
  396. package/dist/stores/envelopes.d.ts +1 -1
  397. package/dist/stores/helpers.d.ts +1 -1
  398. package/dist/stores/lease-store.d.ts +2 -2
  399. package/dist/stores/message-store.d.ts +2 -2
  400. package/dist/stores/operational-store.d.ts +3 -26
  401. package/dist/stores/operational-store.js +0 -225
  402. package/dist/stores/project-workflow-store.d.ts +2 -2
  403. package/dist/stores/run-store.d.ts +2 -3
  404. package/dist/stores/run-store.js +0 -98
  405. package/dist/stores/subscription-store.d.ts +2 -3
  406. package/dist/stores/subscription-store.js +0 -100
  407. package/dist/template-catalog.d.ts +1 -1
  408. package/dist/template-launch-requirements.d.ts +2 -2
  409. package/dist/template-launch-ui.d.ts +2 -2
  410. package/dist/treedx/adapters.d.ts +6 -6
  411. package/dist/treedx/client.d.ts +1 -1
  412. package/dist/treedx/errors.d.ts +1 -1
  413. package/dist/treedx/federated-client.d.ts +3 -3
  414. package/dist/treedx/graph-adapter.d.ts +2 -2
  415. package/dist/treedx/index.d.ts +14 -14
  416. package/dist/treedx/market-integration.d.ts +1 -1
  417. package/dist/treedx/ports.d.ts +30 -30
  418. package/dist/treedx/query-adapter.d.ts +3 -3
  419. package/dist/treedx/registry-client.d.ts +2 -2
  420. package/dist/treedx/repository-adapter.d.ts +4 -4
  421. package/dist/treedx/repository-adapter.js +14 -3
  422. package/dist/treedx/sdk-integration.d.ts +3 -3
  423. package/dist/treedx/types.d.ts +2 -2
  424. package/dist/treedx/workspace-adapter.d.ts +2 -2
  425. package/dist/treedx-backends.d.ts +13 -11
  426. package/dist/treedx-backends.js +52 -50
  427. package/dist/treedx-client.d.ts +3 -0
  428. package/dist/treedx-client.js +10 -1
  429. package/dist/treeseed/services/compose.yml +2 -3
  430. package/dist/treeseed/template-catalog/catalog.fixture.json +4 -4
  431. package/dist/types/agents.d.ts +256 -0
  432. package/dist/types/agents.js +42 -1
  433. package/dist/verification.d.ts +1 -0
  434. package/dist/verification.js +143 -14
  435. package/dist/workflow/operations.d.ts +510 -559
  436. package/dist/workflow/operations.js +1891 -1342
  437. package/dist/workflow/policy.js +19 -7
  438. package/dist/workflow/runs.d.ts +9 -3
  439. package/dist/workflow/runs.js +66 -26
  440. package/dist/workflow/session.d.ts +4 -1
  441. package/dist/workflow/session.js +50 -10
  442. package/dist/workflow/worktrees.d.ts +6 -2
  443. package/dist/workflow/worktrees.js +98 -15
  444. package/dist/workflow-state.d.ts +5 -5
  445. package/dist/workflow-state.js +27 -17
  446. package/dist/workflow-support.d.ts +26 -25
  447. package/dist/workflow-support.js +27 -31
  448. package/dist/workflow.d.ts +46 -16
  449. package/dist/workflow.js +19 -7
  450. package/drizzle/d1/0000_treeseed_d1.sql +0 -18
  451. package/drizzle/market/0000_market_control_plane.sql +1793 -1499
  452. package/drizzle/market/0005_agent_capacity_coordination.sql +145 -0
  453. package/drizzle/market/0006_provider_assignment_lifecycle.sql +9 -0
  454. package/drizzle/market/0007_agent_architecture_gap_closure.sql +157 -0
  455. package/drizzle/market/0008_agent_capacity_operational_closure.sql +27 -0
  456. package/drizzle/market/0009_agent_capacity_runtime_hardening.sql +29 -0
  457. package/drizzle/market/0010_secrets_capability_persistence.sql +130 -0
  458. package/drizzle/market/0011_github_app_adapter.sql +47 -0
  459. package/drizzle/market/0012_workday_test_runs.sql +44 -0
  460. package/drizzle/market/0013_runtime_task_lifecycle.sql +39 -0
  461. package/drizzle/market/0014_governance_pipeline.sql +189 -0
  462. package/package.json +75 -18
  463. package/scripts/{verify-driver.mjs → verify-driver.ts} +19 -4
  464. package/templates/github/deploy-web.workflow.yml +12 -8
  465. package/dist/operations/services/mailpit-runtime.d.ts +0 -20
  466. package/dist/operations/services/mailpit-runtime.js +0 -67
  467. package/dist/operations/services/release-candidate.d.ts +0 -60
  468. package/dist/operations/services/release-candidate.js +0 -953
  469. package/dist/scripts/ensure-mailpit.js +0 -48
  470. package/dist/scripts/logs-mailpit.js +0 -2
  471. package/dist/scripts/run-ts.js +0 -45
  472. package/dist/scripts/stop-mailpit.js +0 -5
  473. package/dist/scripts/treeseed-run-ts.js +0 -45
@@ -1,6 +1,12 @@
1
- import { existsSync } from "node:fs";
2
- import { relative, resolve } from "node:path";
3
- import { collectTreeseedEnvironmentContext, resolveTreeseedMachineEnvironmentValues } from "../operations/services/config-runtime.js";
1
+ import { createHmac, randomBytes, randomUUID } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { readdir, readFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, relative, resolve } from "node:path";
7
+ import { resolveCapacityProviderLaunchPlan } from "../capacity-provider.js";
8
+ import { TreeDxClient } from "../treedx-client.js";
9
+ import { collectTreeseedEnvironmentContext, resolveTreeseedMachineEnvironmentValues, setTreeseedMachineEnvironmentValue } from "../operations/services/config-runtime.js";
4
10
  import {
5
11
  buildPublicVars,
6
12
  buildCloudflarePagesFunctionBindings,
@@ -26,6 +32,7 @@ import {
26
32
  queueName,
27
33
  reconcileCloudflareWebCacheRules,
28
34
  resolveConfiguredCloudflareAccountId,
35
+ resolveConfiguredSurfaceDomain,
29
36
  resolveCloudflareZoneIdForHost,
30
37
  runWrangler,
31
38
  scopeFromTarget,
@@ -48,6 +55,8 @@ import {
48
55
  ensureRailwayPostgresService,
49
56
  deleteRailwayService,
50
57
  deleteRailwayVolume,
58
+ deployRailwayServiceInstance,
59
+ listRailwayEnvironmentServices,
51
60
  getRailwayServiceInstance,
52
61
  getRailwayProject,
53
62
  listRailwayCustomDomains,
@@ -57,11 +66,41 @@ import {
57
66
  listRailwayVolumes,
58
67
  listRailwayVariables,
59
68
  resolveRailwayWorkspaceContext,
60
- upsertRailwayVariables
69
+ updateRailwayServiceGitSource,
70
+ updateRailwayServiceImageSource
61
71
  } from "../operations/services/railway-api.js";
62
72
  import { loadTreeseedReconcileState } from "./state.js";
63
73
  import { createTreeseedReconcileUnitId } from "./units.js";
64
74
  import { discoverTreeseedApplications } from "../hosting/apps.js";
75
+ import { findTreeseedPackageAdapter, syncTreeseedPackageWorkflows } from "../operations/services/package-adapters.js";
76
+ import { resolveGitHubCredentialForRepository } from "../operations/services/github-credentials.js";
77
+ import {
78
+ dispatchReconcileGitHubWorkflow,
79
+ ensureReconcileGitHubEnvironment,
80
+ observeGitHubEnvironment,
81
+ observeGitHubWorkflowRun,
82
+ upsertReconcileGitHubSecret,
83
+ upsertReconcileGitHubVariable
84
+ } from "./providers/github-private.js";
85
+ import {
86
+ buildDockerImage,
87
+ inspectDockerAvailability,
88
+ inspectDockerImage,
89
+ inspectDockerManifest,
90
+ runDockerCompose
91
+ } from "./providers/docker-private.js";
92
+ import { checkHttpHealth, runManagedDevAction } from "./providers/local-private.js";
93
+ import {
94
+ ensureTemplateReleaseTag,
95
+ runHostedReconcileGate,
96
+ runHostedVerifyGate,
97
+ runReleaseVerifyCommand,
98
+ runTemplateReleaseVerifyCommand,
99
+ writeReleaseRecord
100
+ } from "./providers/release-private.js";
101
+ import { checkedOutTemplateRepositories } from "../operations/services/managed-repositories.js";
102
+ import { runTreeseedGit } from "../operations/services/git-runner.js";
103
+ import { withDockerhubServiceCredentialEnv } from "../service-credentials.js";
65
104
  function toDeployTarget(target) {
66
105
  return target.kind === "persistent" ? createPersistentDeployTarget(target.scope) : createBranchPreviewDeployTarget(target.branchName);
67
106
  }
@@ -77,7 +116,7 @@ function isTransientCloudflareReconcileError(error) {
77
116
  }
78
117
  function isTransientRailwayReconcileError(error) {
79
118
  const message = error instanceof Error ? error.message : String(error ?? "");
80
- return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|connectivity issue|rate limit|too many requests|429|5\d\d/iu.test(message);
119
+ return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|connectivity issue|rate limit|too many requests|429|5\d\d|operation is already in progress|Problem processing request/iu.test(message);
81
120
  }
82
121
  function syntheticQueueLocator(name) {
83
122
  return `queue-name:${name}`;
@@ -85,79 +124,1813 @@ function syntheticQueueLocator(name) {
85
124
  function isSyntheticQueueLocator(value) {
86
125
  return typeof value === "string" && value.startsWith("queue-name:");
87
126
  }
88
- function noopObservedState(input) {
127
+ function noopObservedState(input) {
128
+ return {
129
+ exists: true,
130
+ status: "ready",
131
+ live: {
132
+ unitId: input.unit.unitId,
133
+ dependencies: input.unit.dependencies
134
+ },
135
+ locators: {},
136
+ warnings: []
137
+ };
138
+ }
139
+ function noopDiff() {
140
+ return {
141
+ action: "noop",
142
+ reasons: ["composite unit"],
143
+ before: {},
144
+ after: {}
145
+ };
146
+ }
147
+ function buildCompositeAdapter(unitType) {
148
+ return {
149
+ providerId: "treeseed",
150
+ unitTypes: [unitType],
151
+ supports(candidateUnitType, providerId) {
152
+ return candidateUnitType === unitType && providerId === "treeseed";
153
+ },
154
+ async refresh(input) {
155
+ return noopObservedState(input);
156
+ },
157
+ diff() {
158
+ return noopDiff();
159
+ },
160
+ requiredPostconditions({ unit }) {
161
+ return unit.dependencies.map((dependency) => ({
162
+ key: dependency,
163
+ description: `Dependency ${dependency} is verified`
164
+ }));
165
+ },
166
+ apply({ unit, observed, diff }) {
167
+ return {
168
+ unit,
169
+ observed,
170
+ diff,
171
+ action: diff.action,
172
+ warnings: [],
173
+ resourceLocators: {},
174
+ state: {
175
+ unitId: unit.unitId,
176
+ reconciledAt: nowIso()
177
+ },
178
+ verification: null
179
+ };
180
+ },
181
+ verify({ context, unit, postconditions }) {
182
+ const dependencyResults = context.session.get("treeseed:verification-results");
183
+ const checks = postconditions.map((condition) => {
184
+ const dependency = dependencyResults?.get(condition.key);
185
+ const verified = dependency?.verified === true;
186
+ return {
187
+ key: condition.key,
188
+ description: condition.description,
189
+ source: "derived",
190
+ exists: verified,
191
+ configured: verified,
192
+ ready: verified,
193
+ verified,
194
+ expected: true,
195
+ observed: dependency?.verified ?? false,
196
+ issues: verified ? [] : [`Dependency ${condition.key} is not verified.`]
197
+ };
198
+ });
199
+ return summarizeVerification(unit.unitId, checks);
200
+ }
201
+ };
202
+ }
203
+ function genericObservedState(input, exists = true, warnings = []) {
204
+ return {
205
+ exists,
206
+ status: exists ? "ready" : "pending",
207
+ live: {
208
+ unitId: input.unit.unitId,
209
+ unitType: input.unit.unitType,
210
+ provider: input.unit.provider,
211
+ spec: input.unit.spec
212
+ },
213
+ locators: { unitId: input.unit.unitId },
214
+ warnings
215
+ };
216
+ }
217
+ function genericResult(input, state = input.observed.live) {
218
+ return {
219
+ unit: input.unit,
220
+ observed: input.observed,
221
+ diff: input.diff,
222
+ action: input.diff.action,
223
+ warnings: input.observed.warnings,
224
+ resourceLocators: input.observed.locators,
225
+ state: {
226
+ ...state,
227
+ reconciledAt: nowIso()
228
+ },
229
+ verification: null
230
+ };
231
+ }
232
+ function genericVerification(input, observed, description = "Desired resource is represented in the canonical graph") {
233
+ return summarizeVerification(input.unit.unitId, [
234
+ verificationCheck("desired-resource", description, "derived", {
235
+ exists: observed.exists,
236
+ configured: true,
237
+ ready: observed.status !== "error",
238
+ verified: observed.exists && observed.status !== "error",
239
+ observed: observed.live
240
+ })
241
+ ], observed.warnings);
242
+ }
243
+ function buildManifestAdapter() {
244
+ return {
245
+ providerId: "treeseed",
246
+ unitTypes: ["package-manifest", "template-manifest"],
247
+ supports(unitType, providerId) {
248
+ return (unitType === "package-manifest" || unitType === "template-manifest") && providerId === "treeseed";
249
+ },
250
+ async refresh(input) {
251
+ const manifestPath = typeof input.unit.spec.manifestPath === "string" ? input.unit.spec.manifestPath : null;
252
+ const warningLabel = input.unit.unitType === "template-manifest" ? "template manifest path is not available" : "package manifest path is not available";
253
+ return genericObservedState(input, manifestPath ? existsSync(manifestPath) : true, manifestPath ? [] : [warningLabel]);
254
+ },
255
+ diff(input) {
256
+ const label = input.unit.unitType === "template-manifest" ? "template manifest" : "package manifest";
257
+ return input.observed.exists ? noopDiff() : { action: "blocked", reasons: [`${label} is missing`], before: input.observed.live, after: input.unit.spec };
258
+ },
259
+ apply(input) {
260
+ return genericResult(input);
261
+ },
262
+ verify(input) {
263
+ return genericVerification(input, input.observed, input.unit.unitType === "template-manifest" ? "Template manifest exists and is readable" : "Package manifest exists and is readable");
264
+ }
265
+ };
266
+ }
267
+ function buildPackageWorkflowAdapter() {
268
+ return {
269
+ providerId: "github",
270
+ unitTypes: ["package-workflow"],
271
+ supports(unitType, providerId) {
272
+ return unitType === "package-workflow" && providerId === "github";
273
+ },
274
+ refresh(input) {
275
+ const packageId = typeof input.unit.spec.packageId === "string" ? input.unit.spec.packageId : typeof input.unit.metadata.packageId === "string" ? input.unit.metadata.packageId : null;
276
+ const adapter = packageId ? findTreeseedPackageAdapter(input.context.tenantRoot, packageId) : null;
277
+ const sync = packageId ? syncTreeseedPackageWorkflows({ root: input.context.tenantRoot, packageId, execute: false }) : [];
278
+ return {
279
+ ...genericObservedState(input, Boolean(adapter), adapter ? [] : [`Package ${packageId ?? "(unknown)"} was not discovered.`]),
280
+ status: sync.some((entry) => entry.changed || !entry.exists) ? "drifted" : adapter ? "ready" : "error",
281
+ live: {
282
+ ...input.unit.spec,
283
+ packageId,
284
+ workflows: sync
285
+ }
286
+ };
287
+ },
288
+ diff(input) {
289
+ if (!input.observed.exists) {
290
+ return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
291
+ }
292
+ const workflows = Array.isArray(input.observed.live.workflows) ? input.observed.live.workflows : [];
293
+ if (workflows.some((entry) => entry.changed || !entry.exists)) {
294
+ return { action: "update", reasons: ["package workflow files differ from rendered templates"], before: input.observed.live, after: input.unit.spec };
295
+ }
296
+ return noopDiff();
297
+ },
298
+ apply(input) {
299
+ const packageId = typeof input.unit.spec.packageId === "string" ? input.unit.spec.packageId : typeof input.unit.metadata.packageId === "string" ? input.unit.metadata.packageId : null;
300
+ if (packageId && input.context.dryRun !== true && input.diff.action !== "noop") {
301
+ const sync = syncTreeseedPackageWorkflows({
302
+ root: input.context.tenantRoot,
303
+ packageId,
304
+ execute: true
305
+ });
306
+ return genericResult(input, { ...input.observed.live, workflowSync: sync });
307
+ }
308
+ return genericResult(input);
309
+ },
310
+ verify(input) {
311
+ const workflows = Array.isArray(input.observed.live.workflows) ? input.observed.live.workflows : [];
312
+ const checks = workflows.map((workflow) => verificationCheck(
313
+ `workflow:${workflow.workflow ?? "unknown"}`,
314
+ `Workflow ${workflow.workflow ?? "unknown"} matches the rendered template`,
315
+ "sdk",
316
+ {
317
+ exists: workflow.exists === true,
318
+ configured: workflow.changed !== true,
319
+ ready: workflow.exists === true && workflow.changed !== true,
320
+ verified: workflow.exists === true && workflow.changed !== true,
321
+ expected: "rendered-template",
322
+ observed: workflow,
323
+ issues: [
324
+ ...workflow.exists ? [] : ["workflow file is missing"],
325
+ ...workflow.changed ? ["workflow file has template drift"] : []
326
+ ]
327
+ }
328
+ ));
329
+ return summarizeVerification(input.unit.unitId, checks.length > 0 ? checks : [
330
+ verificationCheck("package-workflow", "Package workflow resource is observable", "sdk", {
331
+ exists: input.observed.exists,
332
+ configured: input.observed.exists,
333
+ ready: input.observed.exists,
334
+ verified: input.observed.exists
335
+ })
336
+ ], input.observed.warnings);
337
+ }
338
+ };
339
+ }
340
+ function buildGraphOnlyAdapter(providerId, unitTypes, description) {
341
+ return {
342
+ providerId,
343
+ unitTypes,
344
+ supports(unitType, candidateProviderId) {
345
+ return candidateProviderId === providerId && unitTypes.includes(unitType);
346
+ },
347
+ refresh(input) {
348
+ return genericObservedState(input);
349
+ },
350
+ diff() {
351
+ return noopDiff();
352
+ },
353
+ apply(input) {
354
+ return genericResult(input);
355
+ },
356
+ verify(input) {
357
+ return genericVerification(input, input.observed, description);
358
+ },
359
+ destroy(input) {
360
+ return genericResult({
361
+ ...input,
362
+ diff: { action: "delete", reasons: ["selected for destroy"], before: input.observed.live, after: {} }
363
+ });
364
+ }
365
+ };
366
+ }
367
+ function buildGitHubEnv(input) {
368
+ const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
369
+ const resolvedScope = scope === "local" ? "staging" : scope;
370
+ const configuredValues = resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, resolvedScope);
371
+ const values = {
372
+ ...normalizeEnvironmentValues(configuredValues),
373
+ ...resolveReconcileEnvironmentValues(input, resolvedScope)
374
+ };
375
+ const providerValues = withDockerhubServiceCredentialEnv(values);
376
+ const repository = typeof input.unit.spec.repository === "string" ? input.unit.spec.repository : null;
377
+ if (!repository) return providerValues;
378
+ const credential = resolveGitHubCredentialForRepository(repository, { values: providerValues, env: providerValues });
379
+ return credential.token ? { ...providerValues, TREESEED_GITHUB_TOKEN: credential.token, GH_TOKEN: credential.token, GITHUB_TOKEN: credential.token } : providerValues;
380
+ }
381
+ function repositoryFromUnit(input) {
382
+ const repository = input.unit.spec.repository;
383
+ if (typeof repository !== "string" || !repository.trim()) {
384
+ throw new Error(`${input.unit.unitId} requires a GitHub repository.`);
385
+ }
386
+ return repository.trim();
387
+ }
388
+ function workflowName(value, fallback) {
389
+ return (typeof value === "string" && value.trim() ? value.trim() : fallback).replace(/^\.github\/workflows\//u, "");
390
+ }
391
+ function environmentFromUnit(input) {
392
+ const environment = input.unit.spec.environment;
393
+ if (typeof environment !== "string" || !environment.trim()) {
394
+ throw new Error(`${input.unit.unitId} requires a GitHub environment.`);
395
+ }
396
+ return environment.trim();
397
+ }
398
+ function buildGitHubEnvironmentAdapter() {
399
+ return {
400
+ providerId: "github",
401
+ unitTypes: ["github-environment"],
402
+ supports(unitType, providerId) {
403
+ return unitType === "github-environment" && providerId === "github";
404
+ },
405
+ async refresh(input) {
406
+ const repository = repositoryFromUnit(input);
407
+ const environment = environmentFromUnit(input);
408
+ const observed = await observeGitHubEnvironment(repository, environment, buildGitHubEnv(input));
409
+ const warnings = observed.exists ? [] : [String(observed.error ?? "GitHub environment is missing")];
410
+ return {
411
+ ...genericObservedState(input, observed.exists, warnings),
412
+ status: observed.exists ? "ready" : "pending",
413
+ live: observed
414
+ };
415
+ },
416
+ diff(input) {
417
+ if (input.observed.live?.authAvailable === false) {
418
+ return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
419
+ }
420
+ return input.observed.exists ? noopDiff() : { action: "create", reasons: ["GitHub environment is missing"], before: input.observed.live, after: input.unit.spec };
421
+ },
422
+ async apply(input) {
423
+ if (input.diff.action !== "noop") {
424
+ const result = await ensureReconcileGitHubEnvironment(repositoryFromUnit(input), environmentFromUnit(input), typeof input.unit.spec.branch === "string" ? input.unit.spec.branch : null, buildGitHubEnv(input));
425
+ return genericResult(input, { ...input.observed.live, result });
426
+ }
427
+ return genericResult(input);
428
+ },
429
+ verify(input) {
430
+ return genericVerification(input, input.observed, "GitHub environment exists");
431
+ }
432
+ };
433
+ }
434
+ function buildGitHubBindingAdapter(unitType) {
435
+ return {
436
+ providerId: "github",
437
+ unitTypes: [unitType],
438
+ supports(candidateUnitType, providerId) {
439
+ return candidateUnitType === unitType && providerId === "github";
440
+ },
441
+ async refresh(input) {
442
+ const repository = repositoryFromUnit(input);
443
+ const environment = environmentFromUnit(input);
444
+ const nameKey = unitType === "github-secret-binding" ? "secretName" : "variableName";
445
+ const name = String(input.unit.spec[nameKey] ?? input.unit.spec.envName ?? "");
446
+ const observed = await observeGitHubEnvironment(repository, environment, buildGitHubEnv(input));
447
+ const names = unitType === "github-secret-binding" ? observed.secretNames : observed.variableNames;
448
+ const exists = observed.exists && names.includes(name);
449
+ const warnings = observed.authAvailable === false ? [String(observed.error ?? "GitHub authentication is unavailable")] : observed.exists ? [] : [`GitHub environment ${environment} is missing`];
450
+ return {
451
+ ...genericObservedState(input, exists, warnings),
452
+ status: exists ? "ready" : "pending",
453
+ live: { repository, environment, name, exists, observed }
454
+ };
455
+ },
456
+ diff(input) {
457
+ const name = String(input.observed.live.name ?? "");
458
+ if (input.observed.live?.observed?.authAvailable === false) {
459
+ return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
460
+ }
461
+ if (input.observed.exists) {
462
+ return noopDiff();
463
+ }
464
+ const value = buildGitHubEnv(input)[name];
465
+ if (!value) {
466
+ return { action: "blocked", reasons: [`Missing local value for ${name}`], before: input.observed.live, after: input.unit.spec };
467
+ }
468
+ return { action: "update", reasons: [`GitHub ${unitType === "github-secret-binding" ? "secret" : "variable"} ${name} is missing`], before: input.observed.live, after: input.unit.spec };
469
+ },
470
+ async apply(input) {
471
+ if (input.diff.action === "noop" || input.diff.action === "blocked") return genericResult(input);
472
+ const name = String(input.observed.live.name ?? input.unit.spec.envName ?? "");
473
+ const value = buildGitHubEnv(input)[name];
474
+ if (!value) throw new Error(`Missing local value for ${name}`);
475
+ const result = unitType === "github-secret-binding" ? await upsertReconcileGitHubSecret(repositoryFromUnit(input), environmentFromUnit(input), name, value, buildGitHubEnv(input)) : await upsertReconcileGitHubVariable(repositoryFromUnit(input), environmentFromUnit(input), name, value, buildGitHubEnv(input));
476
+ return genericResult(input, { ...input.observed.live, result });
477
+ },
478
+ verify(input) {
479
+ return genericVerification(input, input.observed, `GitHub ${unitType === "github-secret-binding" ? "secret" : "variable"} binding exists`);
480
+ }
481
+ };
482
+ }
483
+ function buildGitHubWorkflowDispatchAdapter() {
484
+ return {
485
+ providerId: "github",
486
+ unitTypes: ["github-workflow-dispatch"],
487
+ supports(unitType, providerId) {
488
+ return unitType === "github-workflow-dispatch" && providerId === "github";
489
+ },
490
+ async refresh(input) {
491
+ const repository = repositoryFromUnit(input);
492
+ const workflow = workflowName(input.unit.spec.workflow, "publish.yml");
493
+ const branch = typeof input.unit.spec.branch === "string" ? input.unit.spec.branch : null;
494
+ const latest = await observeGitHubWorkflowRun({ repository, workflow, branch, env: buildGitHubEnv(input) });
495
+ const authBlocked = Boolean(latest && typeof latest === "object" && "authAvailable" in latest && latest.authAvailable === false);
496
+ return {
497
+ ...genericObservedState(input, Boolean(latest) && !authBlocked, authBlocked ? [String(latest.error ?? "GitHub authentication is unavailable")] : []),
498
+ status: latest && !authBlocked ? "ready" : "pending",
499
+ live: { repository, workflow, branch, latest }
500
+ };
501
+ },
502
+ diff(input) {
503
+ if (input.observed.live?.latest?.authAvailable === false) {
504
+ return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
505
+ }
506
+ const latest = input.observed.live?.latest;
507
+ const conclusion = typeof latest?.conclusion === "string" ? latest.conclusion : null;
508
+ const status = typeof latest?.status === "string" ? latest.status : null;
509
+ const expectedHeadSha = typeof input.unit.spec.expectedHeadSha === "string" ? input.unit.spec.expectedHeadSha : null;
510
+ const observedHeadSha = typeof latest?.headSha === "string" ? latest.headSha : typeof latest?.head_sha === "string" ? latest.head_sha : null;
511
+ if (!input.observed.exists) {
512
+ return { action: "create", reasons: ["workflow dispatch has no observed run"], before: input.observed.live, after: input.unit.spec };
513
+ }
514
+ if (expectedHeadSha && observedHeadSha && observedHeadSha !== expectedHeadSha) {
515
+ return { action: "create", reasons: [`latest workflow run is for ${observedHeadSha}, expected ${expectedHeadSha}`], before: input.observed.live, after: input.unit.spec };
516
+ }
517
+ if (conclusion && conclusion !== "success") {
518
+ return { action: "create", reasons: [`latest workflow run concluded ${conclusion}`], before: input.observed.live, after: input.unit.spec };
519
+ }
520
+ if (status && status !== "completed" && input.unit.spec.wait === true) {
521
+ return { action: "create", reasons: [`latest workflow run is still ${status}`], before: input.observed.live, after: input.unit.spec };
522
+ }
523
+ return noopDiff();
524
+ },
525
+ async apply(input) {
526
+ if (input.diff.action === "noop") return genericResult(input);
527
+ const result = await dispatchReconcileGitHubWorkflow({
528
+ repository: repositoryFromUnit(input),
529
+ workflow: workflowName(input.unit.spec.workflow, "publish.yml"),
530
+ branch: String(input.unit.spec.branch ?? "staging"),
531
+ inputs: typeof input.unit.spec.inputs === "object" && input.unit.spec.inputs ? input.unit.spec.inputs : {},
532
+ wait: input.unit.spec.wait === true,
533
+ timeoutMs: typeof input.unit.spec.timeoutMs === "number" ? input.unit.spec.timeoutMs : void 0,
534
+ env: buildGitHubEnv(input)
535
+ });
536
+ return genericResult(input, { ...input.observed.live, result });
537
+ },
538
+ verify(input) {
539
+ const latest = input.observed.live?.latest;
540
+ const result = input.result?.state?.result && typeof input.result.state.result === "object" ? input.result.state.result : null;
541
+ const observedRun = result?.runId ? result : latest;
542
+ const expectedHeadSha = typeof input.unit.spec.expectedHeadSha === "string" ? input.unit.spec.expectedHeadSha : null;
543
+ const observedHeadSha = typeof observedRun?.headSha === "string" ? observedRun.headSha : typeof observedRun?.head_sha === "string" ? observedRun.head_sha : null;
544
+ const status = typeof observedRun?.status === "string" ? observedRun.status : null;
545
+ const conclusion = typeof observedRun?.conclusion === "string" ? observedRun.conclusion : null;
546
+ const wait = input.unit.spec.wait === true;
547
+ const issues = [
548
+ ...expectedHeadSha && observedHeadSha && observedHeadSha !== expectedHeadSha ? [`latest workflow run is for ${observedHeadSha}, expected ${expectedHeadSha}`] : [],
549
+ ...wait && status && status !== "completed" ? [`workflow run is still ${status}`] : [],
550
+ ...wait && status === "completed" && conclusion !== "success" ? [`workflow run concluded ${conclusion ?? "unknown"}`] : []
551
+ ];
552
+ return summarizeVerification(input.unit.unitId, [
553
+ verificationCheck("github.workflow-run", "GitHub workflow dispatch produced a successful observed run", "api", {
554
+ exists: Boolean(observedRun),
555
+ configured: !expectedHeadSha || !observedHeadSha || observedHeadSha === expectedHeadSha,
556
+ ready: !wait || status === "completed",
557
+ verified: Boolean(observedRun) && issues.length === 0,
558
+ expected: {
559
+ workflow: input.unit.spec.workflow,
560
+ branch: input.unit.spec.branch,
561
+ headSha: expectedHeadSha,
562
+ wait,
563
+ conclusion: wait ? "success" : void 0
564
+ },
565
+ observed: observedRun,
566
+ issues
567
+ })
568
+ ], input.observed.warnings);
569
+ }
570
+ };
571
+ }
572
+ function buildPackageImageAdapter() {
573
+ return {
574
+ providerId: "dockerhub",
575
+ unitTypes: ["package-image"],
576
+ supports(unitType, providerId) {
577
+ return unitType === "package-image" && providerId === "dockerhub";
578
+ },
579
+ async refresh(input) {
580
+ const env = buildGitHubEnv(input);
581
+ const repository = typeof input.unit.spec.repository === "string" ? input.unit.spec.repository : null;
582
+ const environment = typeof input.unit.spec.environment === "string" ? input.unit.spec.environment : "staging";
583
+ const requiredSecrets = Array.isArray(input.unit.spec.requiredSecrets) ? input.unit.spec.requiredSecrets.map((entry) => String(entry)).filter(Boolean) : ["TREESEED_DOCKERHUB_TOKEN"];
584
+ const requiredVariables = Array.isArray(input.unit.spec.requiredVariables) ? input.unit.spec.requiredVariables.map((entry) => String(entry)).filter(Boolean) : ["TREESEED_DOCKERHUB_USERNAME"];
585
+ const localMissingSecrets = requiredSecrets.filter((name) => !env[name]);
586
+ const localMissingVariables = requiredVariables.filter((name) => !env[name]);
587
+ let remote = null;
588
+ if ((localMissingSecrets.length > 0 || localMissingVariables.length > 0) && repository) {
589
+ remote = await observeGitHubEnvironment(repository, environment, env);
590
+ }
591
+ const remoteSecretNames = new Set(remote?.secretNames ?? []);
592
+ const remoteVariableNames = new Set(remote?.variableNames ?? []);
593
+ const missingSecrets = localMissingSecrets.filter((name) => !remoteSecretNames.has(name));
594
+ const missingVariables = localMissingVariables.filter((name) => !remoteVariableNames.has(name));
595
+ const usernameConfigured = missingVariables.length === 0;
596
+ const tokenConfigured = missingSecrets.length === 0;
597
+ return {
598
+ ...genericObservedState(input, usernameConfigured && tokenConfigured, [
599
+ ...missingVariables.map((name) => `${name} is not configured`),
600
+ ...missingSecrets.map((name) => `${name} is not configured`)
601
+ ]),
602
+ status: usernameConfigured && tokenConfigured ? "ready" : "pending",
603
+ live: {
604
+ ...input.unit.spec,
605
+ dockerHub: {
606
+ usernameConfigured,
607
+ tokenConfigured,
608
+ source: localMissingSecrets.length === 0 && localMissingVariables.length === 0 ? "local-env" : "github-environment",
609
+ environment,
610
+ repository
611
+ }
612
+ }
613
+ };
614
+ },
615
+ diff(input) {
616
+ return input.observed.exists ? noopDiff() : { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
617
+ },
618
+ apply(input) {
619
+ return genericResult(input);
620
+ },
621
+ verify(input) {
622
+ return genericVerification(input, input.observed, "Docker Hub credentials for package image publication are configured");
623
+ }
624
+ };
625
+ }
626
+ function buildDockerImageBuildAdapter() {
627
+ return {
628
+ providerId: "docker",
629
+ unitTypes: ["docker-image-build"],
630
+ supports(unitType, providerId) {
631
+ return unitType === "docker-image-build" && providerId === "docker";
632
+ },
633
+ refresh(input) {
634
+ const availability = inspectDockerAvailability();
635
+ const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
636
+ const inspectedImages = Object.fromEntries(tags.map((tag) => [tag, inspectDockerImage(tag)]));
637
+ const inspectedManifests = Object.fromEntries(tags.map((tag) => [tag, inspectDockerManifest(tag)]));
638
+ const exists = availability.available && availability.buildxAvailable;
639
+ return {
640
+ ...genericObservedState(input, exists, availability.warnings),
641
+ status: !exists ? "error" : tags.some((tag) => inspectedImages[tag] || inspectedManifests[tag]) ? "ready" : "pending",
642
+ live: {
643
+ ...input.unit.spec,
644
+ docker: availability,
645
+ tags,
646
+ inspectedImages,
647
+ inspectedManifests
648
+ }
649
+ };
650
+ },
651
+ diff(input) {
652
+ if (!input.observed.exists) {
653
+ return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
654
+ }
655
+ const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
656
+ const inspectedImages = input.observed.live.inspectedImages;
657
+ const inspectedManifests = input.observed.live.inspectedManifests;
658
+ const missing = tags.filter((tag) => !inspectedImages?.[tag] && !inspectedManifests?.[tag]);
659
+ return missing.length > 0 ? { action: "create", reasons: [`missing docker image tags: ${missing.join(", ")}`], before: input.observed.live, after: input.unit.spec } : noopDiff();
660
+ },
661
+ apply(input) {
662
+ if (input.diff.action === "blocked" || input.diff.action === "noop") return genericResult(input);
663
+ const platforms = Array.isArray(input.unit.spec.platforms) ? input.unit.spec.platforms.filter((entry) => typeof entry === "string") : ["linux/amd64"];
664
+ const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
665
+ runDockerImagePrepareCommand(input);
666
+ const result = buildDockerImage({
667
+ tenantRoot: input.context.tenantRoot,
668
+ packageRoot: String(input.unit.spec.packageRoot ?? input.context.tenantRoot),
669
+ context: String(input.unit.spec.context ?? "."),
670
+ dockerfile: String(input.unit.spec.dockerfile ?? "Dockerfile"),
671
+ target: typeof input.unit.spec.target === "string" ? input.unit.spec.target : null,
672
+ platforms,
673
+ tags,
674
+ labels: typeof input.unit.spec.labels === "object" && input.unit.spec.labels ? input.unit.spec.labels : {},
675
+ buildArgs: typeof input.unit.spec.buildArgs === "object" && input.unit.spec.buildArgs ? input.unit.spec.buildArgs : {},
676
+ push: input.unit.spec.push === true,
677
+ load: input.unit.spec.load !== false,
678
+ provenance: input.unit.spec.provenance === false ? false : void 0,
679
+ env: input.context.launchEnv
680
+ });
681
+ return genericResult(input, { ...input.observed.live, build: result });
682
+ },
683
+ verify(input) {
684
+ const tags = Array.isArray(input.unit.spec.tags) ? input.unit.spec.tags.filter((entry) => typeof entry === "string") : [];
685
+ const checks = tags.map((tag) => {
686
+ const image = inspectDockerImage(tag);
687
+ const manifest = inspectDockerManifest(tag);
688
+ const ok = Boolean(image || manifest);
689
+ return verificationCheck(`docker-image:${tag}`, `Docker image ${tag} exists locally or as an inspectable manifest`, "cli", {
690
+ exists: ok,
691
+ configured: input.observed.exists,
692
+ ready: ok,
693
+ verified: ok,
694
+ observed: image ?? manifest,
695
+ issues: ok ? [] : [`Docker image ${tag} was not found after build.`]
696
+ });
697
+ });
698
+ return summarizeVerification(input.unit.unitId, checks.length > 0 ? checks : [
699
+ verificationCheck("docker", "Docker daemon and Buildx are available", "cli", {
700
+ exists: input.observed.exists,
701
+ configured: input.observed.exists,
702
+ ready: input.observed.exists,
703
+ verified: input.observed.exists
704
+ })
705
+ ], input.observed.warnings);
706
+ }
707
+ };
708
+ }
709
+ function runDockerImagePrepareCommand(input) {
710
+ const prepareCommand = input.unit.spec.prepareCommand;
711
+ if (!prepareCommand || typeof prepareCommand !== "object") return;
712
+ const command = typeof prepareCommand.command === "string" ? String(prepareCommand.command) : null;
713
+ const rawArgs = prepareCommand.args;
714
+ const args = Array.isArray(rawArgs) ? rawArgs.filter((entry) => typeof entry === "string") : [];
715
+ if (!command) return;
716
+ const packageRoot = resolve(input.context.tenantRoot, String(input.unit.spec.packageRoot ?? "."));
717
+ const result = spawnSync(command, args, {
718
+ cwd: packageRoot,
719
+ env: { ...process.env, ...input.context.launchEnv ?? {} },
720
+ encoding: "utf8",
721
+ stdio: "pipe"
722
+ });
723
+ if (result.status !== 0) {
724
+ throw new Error(result.stderr?.trim() || result.stdout?.trim() || `${command} ${args.join(" ")} failed`);
725
+ }
726
+ }
727
+ function buildLocalDockerComposeAdapter() {
728
+ return {
729
+ providerId: "local",
730
+ unitTypes: ["local-docker-compose"],
731
+ supports(unitType, providerId) {
732
+ return unitType === "local-docker-compose" && providerId === "local";
733
+ },
734
+ refresh(input) {
735
+ const composeFiles = resolveLocalComposeFiles(input);
736
+ const composeFilesExist = composeFiles.length > 0 && composeFiles.every((composeFile) => existsSync(composeFile));
737
+ const cwd = resolve(input.context.tenantRoot, String(input.unit.spec.cwd ?? "."));
738
+ const docker = inspectDockerAvailability();
739
+ const projectName = String(input.unit.spec.projectName ?? "treeseed-capacity-provider");
740
+ const env = buildLocalComposeLaunchEnv(input);
741
+ const profiles = localComposeProfiles(input);
742
+ const buildPolicy = localComposeBuildPolicy(input);
743
+ const ps = composeFilesExist && docker.available ? runDockerCompose({ composeFiles, projectName, cwd, env, profiles, buildPolicy, action: "ps" }) : null;
744
+ const hasContainers = Boolean(ps?.ok && ps.stdout.trim());
745
+ const config = composeFilesExist && docker.available ? runDockerCompose({ composeFiles, projectName, cwd, env, profiles, buildPolicy, action: "config" }) : null;
746
+ const missingComposeFiles = composeFiles.filter((composeFile) => !existsSync(composeFile));
747
+ return {
748
+ ...genericObservedState(input, Boolean(composeFilesExist && docker.available), [
749
+ ...composeFiles.length > 0 ? [] : ["Compose file is missing: (unset)"],
750
+ ...missingComposeFiles.map((composeFile) => `Compose file is missing: ${composeFile}`),
751
+ ...docker.warnings
752
+ ]),
753
+ status: hasContainers ? "ready" : composeFilesExist && docker.available ? "pending" : "error",
754
+ live: {
755
+ ...input.unit.spec,
756
+ composeFile: composeFiles[0] ?? null,
757
+ composeFiles,
758
+ projectName,
759
+ cwd,
760
+ profiles,
761
+ buildPolicy,
762
+ docker,
763
+ ps,
764
+ hasContainers,
765
+ configHash: config?.stdout?.trim() || null
766
+ }
767
+ };
768
+ },
769
+ diff(input) {
770
+ if (!input.observed.exists) return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
771
+ return input.observed.status === "ready" ? noopDiff() : { action: "create", reasons: ["compose services are not running"], before: input.observed.live, after: input.unit.spec };
772
+ },
773
+ apply(input) {
774
+ if (input.diff.action === "blocked" || input.diff.action === "noop") return genericResult(input);
775
+ runLocalComposePrepareCommand(input);
776
+ ensureLocalComposeDataDir(input);
777
+ const composeFiles = observedOrResolvedComposeFiles(input);
778
+ const cwd = String(input.observed.live.cwd ?? input.context.tenantRoot);
779
+ const projectName = String(input.unit.spec.projectName ?? "treeseed-capacity-provider");
780
+ const env = buildLocalComposeLaunchEnv(input);
781
+ const result = runDockerCompose({
782
+ composeFiles,
783
+ projectName,
784
+ cwd,
785
+ env,
786
+ profiles: localComposeProfiles(input),
787
+ buildPolicy: localComposeBuildPolicy(input),
788
+ action: "up"
789
+ });
790
+ if (!result.ok) {
791
+ throw new Error(result.stderr.trim() || result.stdout.trim() || "docker compose up failed");
792
+ }
793
+ return genericResult(input, { ...input.observed.live, compose: result });
794
+ },
795
+ async verify(input) {
796
+ const healthChecks = Array.isArray(input.unit.spec.healthChecks) ? input.unit.spec.healthChecks : [];
797
+ const checks = [];
798
+ for (const healthCheck of healthChecks) {
799
+ if (healthCheck.kind === "http" && typeof healthCheck.url === "string") {
800
+ const health = await checkHttpHealthWithRetry(healthCheck.url);
801
+ checks.push(verificationCheck(String(healthCheck.id ?? healthCheck.url), `HTTP health ${healthCheck.url}`, "api", {
802
+ exists: health.ok,
803
+ configured: true,
804
+ ready: health.ok,
805
+ verified: health.ok,
806
+ observed: health,
807
+ issues: health.ok ? [] : [`Health endpoint ${healthCheck.url} did not respond successfully.`]
808
+ }));
809
+ }
810
+ }
811
+ if (checks.length === 0) {
812
+ checks.push(verificationCheck("compose", "Docker Compose project is observable", "cli", {
813
+ exists: input.observed.exists,
814
+ configured: input.observed.exists,
815
+ ready: input.observed.status === "ready",
816
+ verified: input.observed.exists && input.observed.status !== "error",
817
+ observed: input.observed.live
818
+ }));
819
+ }
820
+ return summarizeVerification(input.unit.unitId, checks, input.observed.warnings);
821
+ },
822
+ destroy(input) {
823
+ const composeFiles = observedOrResolvedComposeFiles(input);
824
+ const cwd = String(input.observed.live.cwd ?? input.context.tenantRoot);
825
+ const projectName = String(input.unit.spec.projectName ?? "treeseed-capacity-provider");
826
+ if (input.context.dryRun !== true && composeFiles.length > 0 && composeFiles.every((composeFile) => existsSync(composeFile))) {
827
+ const result = runDockerCompose({
828
+ composeFiles,
829
+ projectName,
830
+ cwd,
831
+ env: buildLocalComposeLaunchEnv(input),
832
+ profiles: localComposeProfiles(input),
833
+ buildPolicy: localComposeBuildPolicy(input),
834
+ action: "down"
835
+ });
836
+ if (!result.ok) {
837
+ throw new Error(result.stderr.trim() || result.stdout.trim() || "docker compose down failed");
838
+ }
839
+ }
840
+ return genericResult({
841
+ ...input,
842
+ diff: { action: "delete", reasons: ["selected local compose project for destroy"], before: input.observed.live, after: {} }
843
+ });
844
+ }
845
+ };
846
+ }
847
+ function ensureLocalComposeDataDir(input) {
848
+ const dataDir = typeof input.unit.spec.dataDir === "string" ? input.unit.spec.dataDir.trim() : "";
849
+ if (!dataDir) return;
850
+ mkdirSync(resolve(input.context.tenantRoot, dataDir), { recursive: true });
851
+ }
852
+ function resolveLocalComposeFiles(input) {
853
+ const rawComposeFiles = Array.isArray(input.unit.spec.composeFiles) ? input.unit.spec.composeFiles : typeof input.unit.spec.composeFile === "string" ? [input.unit.spec.composeFile] : [];
854
+ return rawComposeFiles.filter((composeFile) => typeof composeFile === "string" && composeFile.trim().length > 0).map((composeFile) => resolve(input.context.tenantRoot, composeFile));
855
+ }
856
+ function observedOrResolvedComposeFiles(input) {
857
+ const observed = input.observed.live.composeFiles;
858
+ if (Array.isArray(observed)) {
859
+ return observed.filter((composeFile) => typeof composeFile === "string" && composeFile.trim().length > 0);
860
+ }
861
+ return resolveLocalComposeFiles(input);
862
+ }
863
+ function localComposeProfiles(input) {
864
+ return Array.isArray(input.unit.spec.profiles) ? input.unit.spec.profiles.filter((profile) => typeof profile === "string" && profile.trim().length > 0) : [];
865
+ }
866
+ function localComposeBuildPolicy(input) {
867
+ return input.unit.spec.buildPolicy === "never" || input.unit.spec.buildPolicy === "always" ? input.unit.spec.buildPolicy : "missing";
868
+ }
869
+ function runLocalComposePrepareCommand(input) {
870
+ const prepareCommand = input.unit.spec.prepareCommand;
871
+ if (!prepareCommand || typeof prepareCommand !== "object") return;
872
+ const command = typeof prepareCommand.command === "string" ? String(prepareCommand.command) : null;
873
+ const rawArgs = prepareCommand.args;
874
+ const args = Array.isArray(rawArgs) ? rawArgs.filter((entry) => typeof entry === "string") : [];
875
+ if (!command) return;
876
+ const result = spawnSync(command, args, {
877
+ cwd: input.context.tenantRoot,
878
+ env: buildLocalComposeLaunchEnv(input),
879
+ encoding: "utf8",
880
+ stdio: "pipe"
881
+ });
882
+ if (result.status !== 0) {
883
+ throw new Error(result.stderr?.trim() || result.stdout?.trim() || `${command} ${args.join(" ")} failed`);
884
+ }
885
+ }
886
+ async function checkHttpHealthWithRetry(url, attempts = 90, intervalMs = 2e3) {
887
+ let last = await checkHttpHealth(url);
888
+ for (let attempt = 1; attempt < attempts && !last.ok; attempt += 1) {
889
+ await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
890
+ last = await checkHttpHealth(url);
891
+ }
892
+ return last;
893
+ }
894
+ function buildLocalComposeLaunchEnv(input) {
895
+ const fallback = resolveCapacityProviderLaunchPlan({
896
+ schemaVersion: 1,
897
+ provider: {
898
+ dataDir: ".treeseed/local-capacity-provider/data",
899
+ environment: "local"
900
+ },
901
+ runtime: {
902
+ images: {
903
+ roles: {
904
+ manager: { image: "treeseed/agent-manager", tag: "latest" },
905
+ runner: { image: "treeseed/agent-runner", tag: "latest" }
906
+ }
907
+ },
908
+ api: {
909
+ hostPort: 4783
910
+ }
911
+ }
912
+ }).composeEnv;
913
+ return {
914
+ ...input.context.launchEnv,
915
+ ...fallback,
916
+ ...typeof input.unit.spec.env === "object" && input.unit.spec.env ? input.unit.spec.env : {}
917
+ };
918
+ }
919
+ function localContentSpecString(input, key) {
920
+ const value = input.unit.spec[key];
921
+ return typeof value === "string" && value.trim() ? value.trim() : null;
922
+ }
923
+ function localContentSpecRecord(input, key) {
924
+ const value = input.unit.spec[key];
925
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
926
+ }
927
+ function localContentGitEnvironment(input) {
928
+ const repo = localContentSpecRecord(input, "contentRepository");
929
+ const owner = typeof repo.owner === "string" ? repo.owner : "";
930
+ const name = typeof repo.name === "string" ? repo.name : "";
931
+ if (!owner || !name) return { env: input.context.launchEnv, credential: null };
932
+ const credential = resolveGitHubCredentialForRepository(`${owner}/${name}`, { env: input.context.launchEnv });
933
+ const env = credential.token ? {
934
+ ...input.context.launchEnv,
935
+ TREESEED_GITHUB_TOKEN: credential.token,
936
+ GH_TOKEN: credential.token,
937
+ GITHUB_TOKEN: credential.token
938
+ } : input.context.launchEnv;
939
+ return { env, credential };
940
+ }
941
+ function localContentPathInsideTenant(input, targetPath) {
942
+ if (!targetPath) return true;
943
+ const resolvedTarget = resolve(targetPath);
944
+ const resolvedTenant = resolve(input.context.tenantRoot);
945
+ return resolvedTarget === resolvedTenant || resolvedTarget.startsWith(`${resolvedTenant}/`);
946
+ }
947
+ function inspectLocalContentGit(targetPath) {
948
+ const origin = runTreeseedGit(["remote", "get-url", "origin"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim();
949
+ const branch = runTreeseedGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim();
950
+ const status = runTreeseedGit(["status", "--porcelain"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim();
951
+ return {
952
+ isGit: Boolean(runTreeseedGit(["rev-parse", "--is-inside-work-tree"], { cwd: targetPath, mode: "read", allowFailure: true }).stdout.trim()),
953
+ origin: origin || null,
954
+ branch: branch || null,
955
+ dirty: status.length > 0
956
+ };
957
+ }
958
+ function localContentObservedState(input) {
959
+ const targetPath = localContentSpecString(input, "effectiveLocalPath");
960
+ const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
961
+ const executeRequested = input.unit.spec.executeRequested === true;
962
+ const warnings = [];
963
+ if (!localContentPathInsideTenant(input, targetPath)) {
964
+ warnings.push("local content target path is outside the Treeseed workspace");
965
+ return {
966
+ ...genericObservedState(input, false, warnings),
967
+ status: "error",
968
+ live: {
969
+ ...input.unit.spec,
970
+ targetPath,
971
+ materializationStatus: "blocked"
972
+ }
973
+ };
974
+ }
975
+ if (materialization === "none" || !targetPath) {
976
+ return {
977
+ ...genericObservedState(input, true, warnings),
978
+ live: {
979
+ ...input.unit.spec,
980
+ materializationStatus: "not_requested"
981
+ }
982
+ };
983
+ }
984
+ const exists = existsSync(targetPath);
985
+ const stat = exists ? statSync(targetPath) : null;
986
+ if (exists && !stat?.isDirectory()) {
987
+ warnings.push("local content target path exists but is not a directory");
988
+ return {
989
+ ...genericObservedState(input, false, warnings),
990
+ status: "error",
991
+ live: {
992
+ ...input.unit.spec,
993
+ targetPath,
994
+ materializationStatus: "blocked"
995
+ }
996
+ };
997
+ }
998
+ const git = exists ? inspectLocalContentGit(targetPath) : null;
999
+ if (exists && materialization === "managed_clone" && !git?.isGit) {
1000
+ warnings.push("managed local content target exists but is not a Git worktree");
1001
+ return {
1002
+ ...genericObservedState(input, false, warnings),
1003
+ status: "error",
1004
+ live: {
1005
+ ...input.unit.spec,
1006
+ targetPath,
1007
+ materializationStatus: "blocked",
1008
+ git
1009
+ }
1010
+ };
1011
+ }
1012
+ const status = exists ? materialization === "managed_clone" ? "managed_clone_ready" : materialization === "submodule" ? "submodule_ready" : "existing_path_ready" : materialization === "managed_clone" ? "managed_clone_missing" : materialization === "submodule" ? "submodule_missing" : "existing_path_missing";
1013
+ if (git?.dirty) {
1014
+ warnings.push("local content Git worktree has uncommitted changes; Treeseed will not reset or overwrite it");
1015
+ }
1016
+ return {
1017
+ ...genericObservedState(input, exists || !executeRequested, warnings),
1018
+ status: exists || !executeRequested ? "ready" : "pending",
1019
+ live: {
1020
+ ...input.unit.spec,
1021
+ targetPath,
1022
+ materializationStatus: status,
1023
+ git
1024
+ },
1025
+ locators: {
1026
+ unitId: input.unit.unitId,
1027
+ targetPath,
1028
+ origin: git?.origin ?? null
1029
+ }
1030
+ };
1031
+ }
1032
+ function expectedLocalContentOrigin(input) {
1033
+ const repo = localContentSpecRecord(input, "contentRepository");
1034
+ return typeof repo.gitUrl === "string" ? repo.gitUrl : null;
1035
+ }
1036
+ function originMatches(observed, expected) {
1037
+ const liveGit = observed.live.git && typeof observed.live.git === "object" ? observed.live.git : null;
1038
+ const origin = typeof liveGit?.origin === "string" ? liveGit.origin : null;
1039
+ return !origin || !expected || origin === expected || origin.replace(/\.git$/u, "") === expected.replace(/\.git$/u, "");
1040
+ }
1041
+ function githubRepoSlugFromUrl(url) {
1042
+ if (!url) return null;
1043
+ if (!/^(https?:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)/iu.test(url)) return null;
1044
+ const raw = url.replace(/^https?:\/\/github\.com\//iu, "").replace(/^git@github\.com:/iu, "").replace(/^ssh:\/\/git@github\.com\//iu, "").replace(/\.git$/iu, "").replace(/^\/+|\/+$/gu, "");
1045
+ const [owner, name, ...extra] = raw.split("/").filter(Boolean);
1046
+ return owner && name && extra.length === 0 ? `${owner}/${name}` : null;
1047
+ }
1048
+ function runLocalContentClone(input, targetPath, gitUrl, branch) {
1049
+ mkdirSync(dirname(targetPath), { recursive: true });
1050
+ const { env, credential } = localContentGitEnvironment(input);
1051
+ const repoSlug = githubRepoSlugFromUrl(gitUrl);
1052
+ if (repoSlug && credential?.configured) {
1053
+ const args2 = ["repo", "clone", repoSlug, targetPath, "--"];
1054
+ if (branch) args2.push("--branch", branch);
1055
+ const result2 = spawnSync("gh", args2, {
1056
+ cwd: input.context.tenantRoot,
1057
+ env,
1058
+ encoding: "utf8",
1059
+ stdio: "pipe",
1060
+ timeout: 12e4,
1061
+ maxBuffer: 1024 * 1024 * 16
1062
+ });
1063
+ if (result2.status !== 0) {
1064
+ throw new Error((result2.stderr || result2.stdout || "GitHub CLI clone failed. Run `npx trsd install --json` and configure TREESEED_GITHUB_TOKEN if this repository is private.").trim());
1065
+ }
1066
+ return {
1067
+ status: result2.status,
1068
+ stdout: result2.stdout ?? "",
1069
+ stderr: result2.stderr ?? "",
1070
+ tool: "gh",
1071
+ credentialEnvName: credential.envName,
1072
+ fallbackUsed: credential.fallbackUsed
1073
+ };
1074
+ }
1075
+ const args = ["clone"];
1076
+ if (branch) args.push("--branch", branch);
1077
+ args.push(gitUrl, targetPath);
1078
+ const result = runTreeseedGit(args, {
1079
+ cwd: input.context.tenantRoot,
1080
+ mode: "mutate",
1081
+ env,
1082
+ timeoutMs: 12e4,
1083
+ maxBuffer: 1024 * 1024 * 16
1084
+ });
1085
+ return {
1086
+ ...result,
1087
+ tool: "git",
1088
+ credentialEnvName: credential?.envName ?? null,
1089
+ fallbackUsed: credential?.fallbackUsed ?? false
1090
+ };
1091
+ }
1092
+ function buildLocalContentMaterializationAdapter() {
1093
+ return {
1094
+ providerId: "local",
1095
+ unitTypes: ["local-content-materialization"],
1096
+ supports(unitType, providerId) {
1097
+ return unitType === "local-content-materialization" && providerId === "local";
1098
+ },
1099
+ refresh(input) {
1100
+ return localContentObservedState(input);
1101
+ },
1102
+ diff(input) {
1103
+ const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
1104
+ const executeRequested = input.unit.spec.executeRequested === true;
1105
+ const expectedOrigin = expectedLocalContentOrigin(input);
1106
+ if (input.observed.status === "error") {
1107
+ return { action: "blocked", reasons: input.observed.warnings, before: input.observed.live, after: input.unit.spec };
1108
+ }
1109
+ if (materialization === "existing_path" && !input.observed.exists) {
1110
+ return { action: "blocked", reasons: ["existing local content path is missing; choose preview/edit with managed_clone or configure the path"], before: input.observed.live, after: input.unit.spec };
1111
+ }
1112
+ if (input.observed.exists && !originMatches(input.observed, expectedOrigin)) {
1113
+ return { action: "blocked", reasons: ["managed local content origin does not match the configured content repository"], before: input.observed.live, after: input.unit.spec };
1114
+ }
1115
+ if (!executeRequested) {
1116
+ return noopDiff();
1117
+ }
1118
+ if (!input.observed.exists && (materialization === "managed_clone" || materialization === "submodule")) {
1119
+ return { action: "create", reasons: [`${materialization} local content is requested but missing`], before: input.observed.live, after: input.unit.spec };
1120
+ }
1121
+ if (input.observed.exists && materialization === "managed_clone") {
1122
+ return { action: "update", reasons: ["managed local content exists; fetch without resetting local work"], before: input.observed.live, after: input.unit.spec };
1123
+ }
1124
+ return noopDiff();
1125
+ },
1126
+ apply(input) {
1127
+ if (input.diff.action === "noop" || input.diff.action === "blocked") return genericResult(input);
1128
+ const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
1129
+ const targetPath = localContentSpecString(input, "effectiveLocalPath");
1130
+ const expectedOrigin = expectedLocalContentOrigin(input);
1131
+ const repo = localContentSpecRecord(input, "contentRepository");
1132
+ const branch = typeof repo.defaultBranch === "string" ? repo.defaultBranch : null;
1133
+ if (!targetPath) return genericResult(input);
1134
+ if (materialization === "submodule") {
1135
+ const submodulePath = typeof repo.submodulePath === "string" ? repo.submodulePath : localContentSpecString(input, "contentPath");
1136
+ if (!submodulePath) return genericResult(input);
1137
+ const { env } = localContentGitEnvironment(input);
1138
+ const submodule = runTreeseedGit(["submodule", "update", "--init", "--", submodulePath], {
1139
+ cwd: input.context.tenantRoot,
1140
+ mode: "mutate",
1141
+ env,
1142
+ timeoutMs: 12e4,
1143
+ maxBuffer: 1024 * 1024 * 16
1144
+ });
1145
+ return genericResult(input, { ...input.observed.live, submodule: { status: submodule.status } });
1146
+ }
1147
+ if (materialization === "managed_clone" && expectedOrigin) {
1148
+ const result = input.observed.exists ? runTreeseedGit(["fetch", "origin"], {
1149
+ cwd: targetPath,
1150
+ mode: "mutate",
1151
+ env: localContentGitEnvironment(input).env,
1152
+ timeoutMs: 12e4,
1153
+ maxBuffer: 1024 * 1024 * 16
1154
+ }) : runLocalContentClone(input, targetPath, expectedOrigin, branch);
1155
+ return genericResult(input, {
1156
+ ...input.observed.live,
1157
+ materializedAt: nowIso(),
1158
+ gitOperation: {
1159
+ status: result.status,
1160
+ tool: "tool" in result ? result.tool : "git",
1161
+ credentialEnvName: "credentialEnvName" in result ? result.credentialEnvName : null,
1162
+ fallbackUsed: "fallbackUsed" in result ? result.fallbackUsed : false
1163
+ }
1164
+ });
1165
+ }
1166
+ return genericResult(input);
1167
+ },
1168
+ verify(input) {
1169
+ const targetPath = localContentSpecString(input, "effectiveLocalPath");
1170
+ const materialization = localContentSpecString(input, "localContentMaterialization") ?? "none";
1171
+ const exists = !targetPath || existsSync(targetPath);
1172
+ const expectedOrigin = expectedLocalContentOrigin(input);
1173
+ const originOk = originMatches(input.observed, expectedOrigin);
1174
+ const checks = [
1175
+ verificationCheck("local-content.path", "Local content path policy is satisfied", "sdk", {
1176
+ exists: materialization === "none" ? true : exists,
1177
+ configured: true,
1178
+ ready: materialization === "none" || exists,
1179
+ verified: materialization === "none" || exists,
1180
+ observed: targetPath,
1181
+ issues: materialization !== "none" && !exists ? ["Local content path is missing."] : []
1182
+ }),
1183
+ verificationCheck("local-content.origin", "Local content origin matches the configured repository when observable", "sdk", {
1184
+ exists: true,
1185
+ configured: originOk,
1186
+ ready: originOk,
1187
+ verified: originOk,
1188
+ expected: expectedOrigin,
1189
+ observed: input.observed.live.git,
1190
+ issues: originOk ? [] : ["Origin does not match configured content repository."]
1191
+ })
1192
+ ];
1193
+ return summarizeVerification(input.unit.unitId, checks, input.observed.warnings);
1194
+ }
1195
+ };
1196
+ }
1197
+ function buildLocalProcessAdapter() {
1198
+ return {
1199
+ providerId: "local",
1200
+ unitTypes: ["local-process"],
1201
+ supports(unitType, providerId) {
1202
+ return unitType === "local-process" && providerId === "local";
1203
+ },
1204
+ async refresh(input) {
1205
+ const surfaces = Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [String(input.unit.spec.processId ?? input.unit.logicalName)];
1206
+ const status = await runManagedDevAction({
1207
+ tenantRoot: input.context.tenantRoot,
1208
+ action: "status",
1209
+ surfaces,
1210
+ options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
1211
+ env: input.context.launchEnv
1212
+ });
1213
+ const safeStatus = sanitizeManagedDevObservation(status);
1214
+ return {
1215
+ ...genericObservedState(input, true, safeStatus.ok ? [] : [safeStatus.output || "managed dev status failed"]),
1216
+ status: safeStatus.ok ? "ready" : "pending",
1217
+ live: {
1218
+ ...input.unit.spec,
1219
+ status: safeStatus
1220
+ }
1221
+ };
1222
+ },
1223
+ diff(input) {
1224
+ return input.observed.status === "ready" ? noopDiff() : { action: "create", reasons: ["local process is not reported ready"], before: input.observed.live, after: input.unit.spec };
1225
+ },
1226
+ async apply(input) {
1227
+ if (input.diff.action === "noop") return genericResult(input);
1228
+ const surfaces = Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [String(input.unit.spec.processId ?? input.unit.logicalName)];
1229
+ const result = await runManagedDevAction({
1230
+ tenantRoot: input.context.tenantRoot,
1231
+ action: input.unit.spec.action === "restart" ? "restart" : "start",
1232
+ surfaces,
1233
+ options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
1234
+ env: input.context.launchEnv
1235
+ });
1236
+ return genericResult(input, { ...input.observed.live, managedDev: result });
1237
+ },
1238
+ async verify(input) {
1239
+ const status = await runManagedDevAction({
1240
+ tenantRoot: input.context.tenantRoot,
1241
+ action: "status",
1242
+ surfaces: Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [],
1243
+ options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
1244
+ env: input.context.launchEnv
1245
+ });
1246
+ const safeStatus = sanitizeManagedDevObservation(status);
1247
+ return summarizeVerification(input.unit.unitId, [
1248
+ verificationCheck("managed-dev-status", "Managed dev status is observable", "sdk", {
1249
+ exists: safeStatus.ok,
1250
+ configured: true,
1251
+ ready: safeStatus.ok,
1252
+ verified: safeStatus.ok,
1253
+ observed: safeStatus.parsed ?? safeStatus.output,
1254
+ issues: safeStatus.ok ? [] : [safeStatus.output || "managed dev status failed"]
1255
+ })
1256
+ ], input.observed.warnings);
1257
+ },
1258
+ async destroy(input) {
1259
+ const surfaces = Array.isArray(input.unit.spec.surfaces) ? input.unit.spec.surfaces.filter((entry) => typeof entry === "string") : [String(input.unit.spec.processId ?? input.unit.logicalName)];
1260
+ if (input.context.dryRun !== true) {
1261
+ await runManagedDevAction({
1262
+ tenantRoot: input.context.tenantRoot,
1263
+ action: "stop",
1264
+ surfaces,
1265
+ options: typeof input.unit.spec.options === "object" && input.unit.spec.options ? input.unit.spec.options : {},
1266
+ env: input.context.launchEnv
1267
+ });
1268
+ }
1269
+ return genericResult({
1270
+ ...input,
1271
+ diff: { action: "delete", reasons: ["selected local process for stop"], before: input.observed.live, after: {} }
1272
+ });
1273
+ }
1274
+ };
1275
+ }
1276
+ function sanitizeManagedDevObservation(value) {
1277
+ if (Array.isArray(value)) {
1278
+ return value.map((entry) => sanitizeManagedDevObservation(entry));
1279
+ }
1280
+ if (!value || typeof value !== "object") {
1281
+ return value;
1282
+ }
1283
+ const result = {};
1284
+ for (const [key, entry] of Object.entries(value)) {
1285
+ if (key === "env") {
1286
+ result.redactedEnv = redactEnvironmentRecord(entry);
1287
+ continue;
1288
+ }
1289
+ result[key] = sanitizeManagedDevObservation(entry);
1290
+ }
1291
+ return result;
1292
+ }
1293
+ function redactEnvironmentRecord(value) {
1294
+ if (!value || typeof value !== "object") {
1295
+ return {};
1296
+ }
1297
+ return Object.fromEntries(
1298
+ Object.entries(value).map(([key, entry]) => [
1299
+ key,
1300
+ key === "PATH" || key === "NODE_ENV" ? String(entry ?? "") : "<redacted>"
1301
+ ])
1302
+ );
1303
+ }
1304
+ function buildCapacityProviderAdapter(providerId) {
1305
+ return {
1306
+ providerId,
1307
+ unitTypes: ["capacity-provider"],
1308
+ supports(unitType, candidateProviderId) {
1309
+ return unitType === "capacity-provider" && candidateProviderId === providerId;
1310
+ },
1311
+ refresh(input) {
1312
+ const dependencies = input.unit.dependencies;
1313
+ return {
1314
+ ...genericObservedState(input),
1315
+ live: {
1316
+ ...input.unit.spec,
1317
+ dependencies
1318
+ }
1319
+ };
1320
+ },
1321
+ diff() {
1322
+ return noopDiff();
1323
+ },
1324
+ apply(input) {
1325
+ return genericResult(input);
1326
+ },
1327
+ async verify(input) {
1328
+ const dependencyResults = input.context.session.get("treeseed:verification-results");
1329
+ const checks = input.unit.dependencies.map((dependency) => {
1330
+ const verification = dependencyResults?.get(dependency);
1331
+ const ok = verification ? verification.verified === true : true;
1332
+ return verificationCheck(`dependency:${dependency}`, `Capacity provider dependency ${dependency} is verified`, "derived", {
1333
+ exists: ok,
1334
+ configured: ok,
1335
+ ready: ok,
1336
+ verified: ok,
1337
+ observed: verification ?? null,
1338
+ issues: ok ? [] : [`Dependency ${dependency} is not verified.`]
1339
+ });
1340
+ });
1341
+ const healthEndpoint = typeof input.unit.spec.healthEndpoint === "string" ? input.unit.spec.healthEndpoint : null;
1342
+ if (healthEndpoint) {
1343
+ const health = await checkHttpHealth(healthEndpoint);
1344
+ checks.push(verificationCheck("capacity-provider-health", `Capacity provider health endpoint ${healthEndpoint} responds`, "api", {
1345
+ exists: health.ok,
1346
+ configured: true,
1347
+ ready: health.ok,
1348
+ verified: health.ok,
1349
+ observed: health,
1350
+ issues: health.ok ? [] : [`Capacity provider health endpoint ${healthEndpoint} did not respond successfully.`]
1351
+ }));
1352
+ }
1353
+ if (checks.length === 0) {
1354
+ checks.push(verificationCheck("capacity-provider", "Capacity provider desired topology is observable", "derived", {
1355
+ exists: input.observed.exists,
1356
+ configured: input.observed.exists,
1357
+ ready: input.observed.status !== "error",
1358
+ verified: input.observed.exists && input.observed.status !== "error",
1359
+ observed: input.observed.live
1360
+ }));
1361
+ }
1362
+ return summarizeVerification(input.unit.unitId, checks, input.observed.warnings);
1363
+ },
1364
+ destroy(input) {
1365
+ return genericResult({
1366
+ ...input,
1367
+ diff: { action: "delete", reasons: ["selected capacity provider for destroy"], before: input.observed.live, after: {} }
1368
+ });
1369
+ }
1370
+ };
1371
+ }
1372
+ function recordValue(value) {
1373
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1374
+ }
1375
+ function nonEmptyString(value) {
1376
+ return typeof value === "string" && value.trim() ? value.trim() : "";
1377
+ }
1378
+ function localTreeDxProjects(value) {
1379
+ return Array.isArray(value) ? value.flatMap((entry) => {
1380
+ const record = recordValue(entry);
1381
+ const slug = nonEmptyString(record.slug);
1382
+ const repositoryName = nonEmptyString(record.repositoryName);
1383
+ const repositoryId = nonEmptyString(record.repositoryId) || repositoryName;
1384
+ const localRoot = nonEmptyString(record.localRoot);
1385
+ const contentPath = nonEmptyString(record.contentPath);
1386
+ if (!slug || !repositoryName || !repositoryId || !localRoot || !contentPath) return [];
1387
+ return [{
1388
+ projectKey: nonEmptyString(record.projectKey) || void 0,
1389
+ slug,
1390
+ repositoryName,
1391
+ repositoryId,
1392
+ localRoot,
1393
+ contentPath,
1394
+ defaultRef: nonEmptyString(record.defaultRef) || "refs/heads/main",
1395
+ seedPaths: Array.isArray(record.seedPaths) ? record.seedPaths.filter((item) => typeof item === "string" && item.trim().length > 0) : []
1396
+ }];
1397
+ }) : [];
1398
+ }
1399
+ function base64urlJson(value) {
1400
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
1401
+ }
1402
+ function mintLocalTreeDxJwt(auth) {
1403
+ const secret = nonEmptyString(auth.TREESEED_TREEDX_JWT_HS256_SECRET);
1404
+ const issuer = nonEmptyString(auth.TREESEED_TREEDX_JWT_ISSUER);
1405
+ const audience = nonEmptyString(auth.TREESEED_TREEDX_JWT_AUDIENCE);
1406
+ if (!secret || !issuer || !audience) return "";
1407
+ const now = Math.floor(Date.now() / 1e3);
1408
+ const actorId = nonEmptyString(auth.TREESEED_TREEDX_PROXY_ACTOR_ID) || "treeseed-sdk-reconciler";
1409
+ const tenantId = nonEmptyString(auth.TREESEED_TREEDX_PROXY_TENANT_ID) || "treeseed-control-plane";
1410
+ const payload = {
1411
+ iss: issuer,
1412
+ aud: audience,
1413
+ sub: actorId,
1414
+ jti: randomUUID(),
1415
+ iat: now,
1416
+ nbf: now - 5,
1417
+ exp: now + 3600,
1418
+ treedx_actor_id: actorId,
1419
+ treedx_tenant_id: tenantId,
1420
+ treedx_repo_ids: ["*"],
1421
+ treedx_capabilities: ["*"],
1422
+ treedx_refs: ["*"],
1423
+ treedx_paths: ["**"]
1424
+ };
1425
+ const signingInput = `${base64urlJson({ alg: "HS256", typ: "JWT" })}.${base64urlJson(payload)}`;
1426
+ const signature = createHmac("sha256", secret).update(signingInput).digest("base64url");
1427
+ return `${signingInput}.${signature}`;
1428
+ }
1429
+ async function listLocalTreeDxSeedFiles(project) {
1430
+ const paths = project.seedPaths?.length ? project.seedPaths : [project.contentPath];
1431
+ const files = [];
1432
+ const visit = async (absoluteDir, root) => {
1433
+ if (!existsSync(absoluteDir)) return;
1434
+ for (const entry of await readdir(absoluteDir, { withFileTypes: true })) {
1435
+ const absolutePath = resolve(absoluteDir, entry.name);
1436
+ if (entry.isDirectory()) {
1437
+ await visit(absolutePath, root);
1438
+ } else if (entry.isFile() && /\.(md|mdx)$/iu.test(entry.name)) {
1439
+ files.push({
1440
+ path: relative(root, absolutePath).replace(/\\/gu, "/"),
1441
+ content: await readFile(absolutePath, "utf8")
1442
+ });
1443
+ }
1444
+ }
1445
+ };
1446
+ for (const seedPath of paths) {
1447
+ await visit(resolve(project.localRoot, seedPath), project.localRoot);
1448
+ }
1449
+ return files.sort((left, right) => left.path.localeCompare(right.path));
1450
+ }
1451
+ async function ensureLocalTreeDxProjectRepository(client, project) {
1452
+ const repositories = recordValue(await client.repositories.list()).repos;
1453
+ const existing = Array.isArray(repositories) ? repositories.find((entry) => recordValue(entry).repositoryName === project.repositoryName || recordValue(entry).name === project.repositoryName) : null;
1454
+ const existingRepoId = nonEmptyString(recordValue(existing).repoId);
1455
+ if (existingRepoId) return { repoId: existingRepoId, created: false };
1456
+ const registered = recordValue(await client.repositories.register({
1457
+ name: project.repositoryName,
1458
+ repositoryName: project.repositoryName,
1459
+ createIfMissing: true,
1460
+ defaultRef: project.defaultRef ?? "refs/heads/main"
1461
+ }));
1462
+ const repo = recordValue(registered.repo);
1463
+ const repoId = nonEmptyString(repo.repoId);
1464
+ if (!repoId) throw new Error(`TreeDX did not return a repository id for ${project.repositoryName}.`);
1465
+ return { repoId, created: true };
1466
+ }
1467
+ async function syncLocalTreeDxProjectContent(client, project) {
1468
+ const files = await listLocalTreeDxSeedFiles(project);
1469
+ const repository = await ensureLocalTreeDxProjectRepository(client, project);
1470
+ if (files.length === 0) {
1471
+ return { project: project.slug, repositoryId: repository.repoId, repositoryName: project.repositoryName, files: 0, committed: false };
1472
+ }
1473
+ const workspace = recordValue(await client.request({
1474
+ method: "POST",
1475
+ path: `/api/v1/repos/${encodeURIComponent(repository.repoId)}/workspaces`,
1476
+ body: {
1477
+ baseRef: project.defaultRef ?? "refs/heads/main",
1478
+ branchName: `refs/heads/treeseed-local-sync/${project.slug}/${Date.now().toString(36)}`,
1479
+ mode: "writable",
1480
+ allowedPaths: [`${project.contentPath.replace(/\/+$/u, "")}/**`],
1481
+ ttlSeconds: 900
1482
+ }
1483
+ }));
1484
+ const workspaceId = nonEmptyString(workspace.workspaceId);
1485
+ if (!workspaceId) throw new Error(`TreeDX did not return a workspace id for ${project.slug}.`);
1486
+ try {
1487
+ for (const file of files) {
1488
+ await client.request({
1489
+ method: "PUT",
1490
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/files`,
1491
+ query: { path: file.path },
1492
+ body: { content: file.content, encoding: "utf8" }
1493
+ });
1494
+ }
1495
+ const commit = recordValue(await client.request({
1496
+ method: "POST",
1497
+ path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/commit`,
1498
+ body: {
1499
+ message: `Sync ${project.slug} knowledge hub seed content`,
1500
+ author: { name: "TreeSeed Reconciler", email: "reconciler@treeseed.local" }
1501
+ }
1502
+ }));
1503
+ const graphRefresh = recordValue(await client.request({
1504
+ method: "POST",
1505
+ path: `/api/v1/repos/${encodeURIComponent(repository.repoId)}/graph/refresh`,
1506
+ body: {
1507
+ paths: project.seedPaths?.length ? project.seedPaths.map((seedPath) => `${seedPath.replace(/\/+$/u, "")}/**`) : [`${project.contentPath.replace(/\/+$/u, "")}/**`]
1508
+ }
1509
+ }).catch((error) => ({
1510
+ error: error instanceof Error ? error.message : String(error)
1511
+ })));
1512
+ return {
1513
+ project: project.slug,
1514
+ repositoryId: repository.repoId,
1515
+ repositoryName: project.repositoryName,
1516
+ files: files.length,
1517
+ committed: true,
1518
+ commitSha: nonEmptyString(commit.commitSha) || null,
1519
+ graphRefresh
1520
+ };
1521
+ } finally {
1522
+ await client.request({ method: "POST", path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/close`, body: {} }).catch(() => null);
1523
+ }
1524
+ }
1525
+ async function ensureLocalTreeDxProjectRepositoryRef(client, project) {
1526
+ const repository = await ensureLocalTreeDxProjectRepository(client, project);
89
1527
  return {
90
- exists: true,
91
- status: "ready",
92
- live: {
93
- unitId: input.unit.unitId,
94
- dependencies: input.unit.dependencies
95
- },
96
- locators: {},
97
- warnings: []
1528
+ project: project.slug,
1529
+ repositoryId: repository.repoId,
1530
+ repositoryName: project.repositoryName,
1531
+ files: 0,
1532
+ committed: false,
1533
+ skippedContentSync: true
98
1534
  };
99
1535
  }
100
- function noopDiff() {
1536
+ function buildLocalTreeDxAdapter() {
101
1537
  return {
102
- action: "noop",
103
- reasons: ["composite unit"],
104
- before: {},
105
- after: {}
1538
+ providerId: "local",
1539
+ unitTypes: ["local-treedx"],
1540
+ supports(unitType, providerId) {
1541
+ return unitType === "local-treedx" && providerId === "local";
1542
+ },
1543
+ refresh(input) {
1544
+ return {
1545
+ ...genericObservedState(input),
1546
+ live: {
1547
+ ...input.unit.spec,
1548
+ dependencies: input.unit.dependencies
1549
+ }
1550
+ };
1551
+ },
1552
+ diff(input) {
1553
+ const projects = localTreeDxProjects(input.unit.spec.projects);
1554
+ if (projects.length === 0) return noopDiff();
1555
+ return {
1556
+ action: "update",
1557
+ reasons: ["local TreeDX project content repositories must be registered and seeded through TreeDX"],
1558
+ before: input.observed.live,
1559
+ after: input.unit.spec
1560
+ };
1561
+ },
1562
+ async apply(input) {
1563
+ if (input.diff.action === "noop" || input.diff.action === "blocked") return genericResult(input);
1564
+ const baseUrl = nonEmptyString(input.unit.spec.baseUrl);
1565
+ const token = nonEmptyString(input.unit.spec.token) || mintLocalTreeDxJwt(recordValue(input.unit.spec.auth));
1566
+ const projects = localTreeDxProjects(input.unit.spec.projects);
1567
+ if (!baseUrl || !token || projects.length === 0) return genericResult(input);
1568
+ const client = new TreeDxClient({ baseUrl, token });
1569
+ const syncedProjects = [];
1570
+ const syncSeedContent = input.unit.spec.syncSeedContent !== false;
1571
+ for (const project of projects) {
1572
+ syncedProjects.push(syncSeedContent ? await syncLocalTreeDxProjectContent(client, project) : await ensureLocalTreeDxProjectRepositoryRef(client, project));
1573
+ }
1574
+ return genericResult(input, { ...input.observed.live, syncedProjects });
1575
+ },
1576
+ async verify(input) {
1577
+ const dependencyResults = input.context.session.get("treeseed:verification-results");
1578
+ const dependencyChecks = input.unit.dependencies.map((dependency) => {
1579
+ const verification = dependencyResults?.get(dependency);
1580
+ const ok = verification ? verification.verified === true : true;
1581
+ return verificationCheck(`dependency:${dependency}`, `TreeDX dependency ${dependency} is verified`, "derived", {
1582
+ exists: ok,
1583
+ configured: ok,
1584
+ ready: ok,
1585
+ verified: ok,
1586
+ observed: verification ?? null,
1587
+ issues: ok ? [] : [`Dependency ${dependency} is not verified.`]
1588
+ });
1589
+ });
1590
+ const healthEndpoint = typeof input.unit.spec.healthEndpoint === "string" ? input.unit.spec.healthEndpoint : null;
1591
+ if (healthEndpoint) {
1592
+ const health = await checkHttpHealth(healthEndpoint);
1593
+ dependencyChecks.push(verificationCheck("treedx-health", `TreeDX health endpoint ${healthEndpoint} responds`, "api", {
1594
+ exists: health.ok,
1595
+ configured: true,
1596
+ ready: health.ok,
1597
+ verified: health.ok,
1598
+ observed: health,
1599
+ issues: health.ok ? [] : [`TreeDX health endpoint ${healthEndpoint} did not respond successfully.`]
1600
+ }));
1601
+ }
1602
+ const contentIsTreeDx = input.unit.spec.contentRepositoryAccessMode === "treedx";
1603
+ const siteIsFilesystem = input.unit.spec.siteRepositoryAccessMode === "filesystem";
1604
+ const projectIsFilesystem = input.unit.spec.projectRepositoryAccessMode === "filesystem";
1605
+ const topologyChecks = [
1606
+ verificationCheck("content-repository-plane", "Content repositories use TreeDX by default", "derived", {
1607
+ exists: true,
1608
+ configured: contentIsTreeDx,
1609
+ ready: contentIsTreeDx,
1610
+ verified: contentIsTreeDx,
1611
+ observed: input.unit.spec.contentRepositoryAccessMode,
1612
+ issues: contentIsTreeDx ? [] : ["Content repository access mode is not treedx."]
1613
+ }),
1614
+ verificationCheck("site-repository-plane", "Project site repositories use local filesystem/worktrees by default", "derived", {
1615
+ exists: true,
1616
+ configured: siteIsFilesystem,
1617
+ ready: siteIsFilesystem,
1618
+ verified: siteIsFilesystem,
1619
+ observed: input.unit.spec.siteRepositoryAccessMode,
1620
+ issues: siteIsFilesystem ? [] : ["Site repository access mode is not filesystem."]
1621
+ }),
1622
+ verificationCheck("project-repository-plane", "Super-project repositories use local filesystem/worktrees by default", "derived", {
1623
+ exists: true,
1624
+ configured: projectIsFilesystem,
1625
+ ready: projectIsFilesystem,
1626
+ verified: projectIsFilesystem,
1627
+ observed: input.unit.spec.projectRepositoryAccessMode,
1628
+ issues: projectIsFilesystem ? [] : ["Project repository access mode is not filesystem."]
1629
+ })
1630
+ ];
1631
+ const repositoryChecks = [];
1632
+ const baseUrl = nonEmptyString(input.unit.spec.baseUrl);
1633
+ const token = nonEmptyString(input.unit.spec.token) || mintLocalTreeDxJwt(recordValue(input.unit.spec.auth));
1634
+ if (baseUrl && token) {
1635
+ const client = new TreeDxClient({ baseUrl, token });
1636
+ let repositories = [];
1637
+ try {
1638
+ const list = recordValue(await client.repositories.list()).repos;
1639
+ repositories = Array.isArray(list) ? list : [];
1640
+ } catch {
1641
+ repositories = [];
1642
+ }
1643
+ for (const project of localTreeDxProjects(input.unit.spec.projects)) {
1644
+ const repo = repositories.find((entry) => {
1645
+ const record = recordValue(entry);
1646
+ return record.repositoryName === project.repositoryName || record.name === project.repositoryName;
1647
+ });
1648
+ if (repo) {
1649
+ repositoryChecks.push(verificationCheck(`treedx-repo:${project.slug}`, `TreeDX repository ${project.repositoryId} is registered`, "api", {
1650
+ exists: true,
1651
+ configured: true,
1652
+ ready: true,
1653
+ verified: true,
1654
+ observed: repo
1655
+ }));
1656
+ } else {
1657
+ repositoryChecks.push(verificationCheck(`treedx-repo:${project.slug}`, `TreeDX repository ${project.repositoryId} is registered`, "api", {
1658
+ exists: false,
1659
+ configured: true,
1660
+ ready: false,
1661
+ verified: false,
1662
+ observed: { repositoryName: project.repositoryName },
1663
+ issues: [`TreeDX repository ${project.repositoryId} is not registered.`]
1664
+ }));
1665
+ }
1666
+ }
1667
+ }
1668
+ return summarizeVerification(input.unit.unitId, [...dependencyChecks, ...topologyChecks, ...repositoryChecks], input.observed.warnings);
1669
+ },
1670
+ destroy(input) {
1671
+ return genericResult({
1672
+ ...input,
1673
+ diff: { action: "delete", reasons: ["selected local TreeDX topology for destroy"], before: input.observed.live, after: {} }
1674
+ });
1675
+ }
106
1676
  };
107
1677
  }
108
- function buildCompositeAdapter(unitType) {
1678
+ function buildReleaseGateAdapter() {
1679
+ const unitTypes = [
1680
+ "release-gate:verify",
1681
+ "release-gate:template-verify",
1682
+ "release-gate:template-release-record",
1683
+ "release-gate:npm-publish",
1684
+ "release-gate:image-publish",
1685
+ "release-gate:hosted-reconcile",
1686
+ "release-gate:live-verify",
1687
+ "release-gate:candidate-record",
1688
+ "release-gate:production-record"
1689
+ ];
109
1690
  return {
110
1691
  providerId: "treeseed",
111
- unitTypes: [unitType],
112
- supports(candidateUnitType, providerId) {
113
- return candidateUnitType === unitType && providerId === "treeseed";
1692
+ unitTypes,
1693
+ supports(unitType, providerId) {
1694
+ return providerId === "treeseed" && unitTypes.includes(unitType);
114
1695
  },
115
1696
  refresh(input) {
116
- return noopObservedState(input);
1697
+ const fingerprint = typeof input.unit.spec.fingerprint === "string" ? input.unit.spec.fingerprint : null;
1698
+ const previousFingerprint = typeof input.persistedState?.lastReconciledState?.fingerprint === "string" ? input.persistedState.lastReconciledState.fingerprint : null;
1699
+ return {
1700
+ ...genericObservedState(input),
1701
+ status: fingerprint && previousFingerprint === fingerprint ? "ready" : "pending",
1702
+ live: {
1703
+ ...input.unit.spec,
1704
+ previousFingerprint,
1705
+ fingerprint
1706
+ }
1707
+ };
117
1708
  },
118
- diff() {
119
- return noopDiff();
1709
+ diff(input) {
1710
+ return input.observed.status === "ready" ? noopDiff() : { action: "update", reasons: ["release gate fingerprint has not been reconciled"], before: input.observed.live, after: input.unit.spec };
120
1711
  },
121
- requiredPostconditions({ unit }) {
122
- return unit.dependencies.map((dependency) => ({
123
- key: dependency,
124
- description: `Dependency ${dependency} is verified`
125
- }));
1712
+ async apply(input) {
1713
+ if (input.diff.action === "noop") return genericResult(input);
1714
+ const gateKind = String(input.unit.spec.gateKind ?? input.unit.unitType);
1715
+ const packageId = typeof input.unit.spec.packageId === "string" ? input.unit.spec.packageId : null;
1716
+ const templateId = typeof input.unit.spec.templateId === "string" ? input.unit.spec.templateId : null;
1717
+ if (process.env.TREESEED_WORKFLOW_RELEASE_GATES_MODE === "skip") {
1718
+ return genericResult(input, {
1719
+ ...input.observed.live,
1720
+ gateKind,
1721
+ packageId,
1722
+ templateId,
1723
+ skipped: true,
1724
+ reason: "disabled",
1725
+ fingerprint: input.unit.spec.fingerprint
1726
+ });
1727
+ }
1728
+ if (gateKind === "release-gate:verify" && packageId) {
1729
+ const verify = await runReleaseVerifyCommand({ tenantRoot: input.context.tenantRoot, packageId, env: input.context.launchEnv });
1730
+ if (verify.ok !== true) {
1731
+ throw new Error([verify.stderr, verify.stdout].filter(Boolean).join("\n").trim() || `${packageId} release verification failed`);
1732
+ }
1733
+ return genericResult(input, { ...input.observed.live, verify, fingerprint: input.unit.spec.fingerprint });
1734
+ }
1735
+ if (gateKind === "release-gate:template-verify" && templateId) {
1736
+ const verify = runTemplateReleaseVerifyCommand({ tenantRoot: input.context.tenantRoot, templateId, env: input.context.launchEnv });
1737
+ if (verify.ok !== true) {
1738
+ throw new Error([verify.stderr, verify.stdout].filter(Boolean).join("\n").trim() || `${templateId} template release verification failed`);
1739
+ }
1740
+ return genericResult(input, { ...input.observed.live, verify, fingerprint: input.unit.spec.fingerprint });
1741
+ }
1742
+ if (gateKind === "release-gate:template-release-record" && templateId) {
1743
+ const releaseTag = typeof input.unit.spec.releaseTag === "string" ? input.unit.spec.releaseTag : null;
1744
+ const tag = releaseTag && input.context.target.kind === "persistent" && input.context.target.scope === "prod" ? ensureTemplateReleaseTag({ tenantRoot: input.context.tenantRoot, templateId, tagName: releaseTag }) : null;
1745
+ const recordPath = typeof input.unit.spec.recordPath === "string" ? input.unit.spec.recordPath : `.treeseed/templates/${templateId}/latest-release.json`;
1746
+ const template = checkedOutTemplateRepositories(input.context.tenantRoot).find((candidate) => candidate.templateManifest?.id === templateId);
1747
+ const record = writeReleaseRecord({
1748
+ tenantRoot: input.context.tenantRoot,
1749
+ recordPath,
1750
+ record: {
1751
+ schemaVersion: 1,
1752
+ kind: gateKind,
1753
+ templateId,
1754
+ releaseTag,
1755
+ tag,
1756
+ environment: input.unit.spec.environment ?? null,
1757
+ version: template?.templateManifest?.version ?? null,
1758
+ recordedAt: nowIso()
1759
+ }
1760
+ });
1761
+ return genericResult(input, { ...input.observed.live, record, tag, fingerprint: input.unit.spec.fingerprint });
1762
+ }
1763
+ if ((gateKind === "release-gate:npm-publish" || gateKind === "release-gate:image-publish") && packageId) {
1764
+ const adapter = findTreeseedPackageAdapter(input.context.tenantRoot, packageId);
1765
+ const repository = typeof adapter?.metadata.repository === "string" ? adapter.metadata.repository : null;
1766
+ if (!repository) {
1767
+ throw new Error(`${packageId} does not declare a GitHub repository.`);
1768
+ }
1769
+ const workflow = gateKind === "release-gate:image-publish" ? workflowName(adapter?.metadata.developmentImageWorkflow, "publish.yml") : workflowName(adapter?.metadata.hostedVerifyWorkflow, "publish.yml");
1770
+ const dispatch = await dispatchReconcileGitHubWorkflow({
1771
+ repository,
1772
+ workflow,
1773
+ branch: input.context.target.kind === "persistent" && input.context.target.scope === "prod" ? "main" : "staging",
1774
+ inputs: {},
1775
+ wait: input.context.target.kind === "persistent" && input.context.target.scope === "prod",
1776
+ env: buildGitHubEnv(input)
1777
+ });
1778
+ return genericResult(input, { ...input.observed.live, dispatch, fingerprint: input.unit.spec.fingerprint });
1779
+ }
1780
+ if (gateKind === "release-gate:hosted-reconcile") {
1781
+ const environment = input.unit.spec.environment === "prod" ? "prod" : "staging";
1782
+ const selector = typeof input.unit.spec.hostedSelector === "object" && input.unit.spec.hostedSelector ? input.unit.spec.hostedSelector : { environment, provider: ["cloudflare", "railway", "github", "dockerhub"] };
1783
+ const nested = await runHostedReconcileGate({
1784
+ parentContext: input.context,
1785
+ selector,
1786
+ target: { kind: "persistent", scope: environment },
1787
+ dryRun: input.context.dryRun === true
1788
+ });
1789
+ return genericResult(input, { ...input.observed.live, nested, fingerprint: input.unit.spec.fingerprint });
1790
+ }
1791
+ if (gateKind === "release-gate:live-verify") {
1792
+ const environment = input.unit.spec.environment === "prod" ? "prod" : "staging";
1793
+ const selector = typeof input.unit.spec.hostedSelector === "object" && input.unit.spec.hostedSelector ? input.unit.spec.hostedSelector : { environment, provider: ["cloudflare", "railway", "github", "dockerhub"] };
1794
+ const status = await runHostedVerifyGate({
1795
+ parentContext: input.context,
1796
+ selector,
1797
+ target: { kind: "persistent", scope: environment }
1798
+ });
1799
+ if (!status.ready) {
1800
+ throw new Error(`Hosted live verification failed for ${environment}: ${status.blockers.join("\n")}`);
1801
+ }
1802
+ return genericResult(input, { ...input.observed.live, status, fingerprint: input.unit.spec.fingerprint });
1803
+ }
1804
+ if (gateKind === "release-gate:candidate-record" || gateKind === "release-gate:production-record") {
1805
+ const recordPath = typeof input.unit.spec.recordPath === "string" ? input.unit.spec.recordPath : gateKind === "release-gate:production-record" ? ".treeseed/workflow/releases/latest-production.json" : ".treeseed/workflow/release-candidates/latest-staging.json";
1806
+ const record = writeReleaseRecord({
1807
+ tenantRoot: input.context.tenantRoot,
1808
+ recordPath,
1809
+ record: {
1810
+ schemaVersion: 1,
1811
+ kind: gateKind,
1812
+ unitId: input.unit.unitId,
1813
+ fingerprint: input.unit.spec.fingerprint ?? null,
1814
+ environment: input.unit.spec.environment ?? null,
1815
+ recordedAt: nowIso()
1816
+ }
1817
+ });
1818
+ return genericResult(input, { ...input.observed.live, record, fingerprint: input.unit.spec.fingerprint });
1819
+ }
1820
+ return genericResult(input, { ...input.observed.live, fingerprint: input.unit.spec.fingerprint });
126
1821
  },
127
- apply({ unit, observed, diff }) {
1822
+ verify(input) {
1823
+ const dependencyResults = input.context.session.get("treeseed:verification-results");
1824
+ const dependencyChecks = input.unit.dependencies.map((dependency) => {
1825
+ const verification = dependencyResults?.get(dependency);
1826
+ const ok = verification?.verified === true;
1827
+ return verificationCheck(`dependency:${dependency}`, `Release gate dependency ${dependency} passed`, "derived", {
1828
+ exists: ok,
1829
+ configured: ok,
1830
+ ready: ok,
1831
+ verified: ok,
1832
+ observed: verification ?? null,
1833
+ issues: ok ? [] : [`Dependency ${dependency} is not verified.`]
1834
+ });
1835
+ });
1836
+ const ownCheck = verificationCheck("release-gate", "Release gate reconciled its fingerprint", "sdk", {
1837
+ exists: true,
1838
+ configured: true,
1839
+ ready: input.result?.state?.fingerprint === input.unit.spec.fingerprint || input.diff.action === "noop",
1840
+ verified: input.result?.state?.fingerprint === input.unit.spec.fingerprint || input.diff.action === "noop",
1841
+ expected: input.unit.spec.fingerprint ?? null,
1842
+ observed: input.result?.state?.fingerprint ?? input.observed.live.previousFingerprint ?? null
1843
+ });
1844
+ return summarizeVerification(input.unit.unitId, [...dependencyChecks, ownCheck], input.observed.warnings);
1845
+ }
1846
+ };
1847
+ }
1848
+ function workflowFingerprint(input) {
1849
+ return JSON.stringify({
1850
+ unitId: input.unit.unitId,
1851
+ unitType: input.unit.unitType,
1852
+ spec: input.unit.spec,
1853
+ dependencies: input.unit.dependencies
1854
+ });
1855
+ }
1856
+ function buildWorkflowMetaAdapter() {
1857
+ const unitTypes = [
1858
+ "branch-preview",
1859
+ "branch-preview-cleanup",
1860
+ "workflow-gate",
1861
+ "save-gate:local-verify",
1862
+ "save-gate:promotion-readiness",
1863
+ "save-gate:hosted-verify"
1864
+ ];
1865
+ return {
1866
+ providerId: "treeseed",
1867
+ unitTypes,
1868
+ supports(unitType, providerId) {
1869
+ return providerId === "treeseed" && unitTypes.includes(unitType);
1870
+ },
1871
+ refresh(input) {
1872
+ const fingerprint = workflowFingerprint(input);
1873
+ const previousFingerprint = typeof input.persistedState?.lastReconciledState?.fingerprint === "string" ? input.persistedState.lastReconciledState.fingerprint : null;
128
1874
  return {
129
- unit,
130
- observed,
131
- diff,
132
- action: diff.action,
133
- warnings: [],
134
- resourceLocators: {},
135
- state: {
136
- unitId: unit.unitId,
137
- reconciledAt: nowIso()
138
- },
139
- verification: null
1875
+ ...genericObservedState(input),
1876
+ status: previousFingerprint === fingerprint ? "ready" : "pending",
1877
+ live: {
1878
+ ...input.unit.spec,
1879
+ fingerprint,
1880
+ previousFingerprint
1881
+ }
140
1882
  };
141
1883
  },
142
- verify({ context, unit, postconditions }) {
143
- const dependencyResults = context.session.get("treeseed:verification-results");
144
- const checks = postconditions.map((condition) => {
145
- const dependency = dependencyResults?.get(condition.key);
146
- const verified = dependency?.verified === true;
147
- return {
148
- key: condition.key,
149
- description: condition.description,
150
- source: "derived",
151
- exists: verified,
152
- configured: verified,
153
- ready: verified,
154
- verified,
155
- expected: true,
156
- observed: dependency?.verified ?? false,
157
- issues: verified ? [] : [`Dependency ${condition.key} is not verified.`]
158
- };
159
- });
160
- return summarizeVerification(unit.unitId, checks);
1884
+ diff(input) {
1885
+ return input.observed.status === "ready" ? noopDiff() : { action: input.unit.unitType === "branch-preview-cleanup" ? "delete" : "update", reasons: [`${input.unit.unitType} fingerprint has not been reconciled`], before: input.observed.live, after: input.unit.spec };
1886
+ },
1887
+ async apply(input) {
1888
+ if (input.diff.action === "noop") return genericResult(input);
1889
+ const fingerprint = workflowFingerprint(input);
1890
+ if (input.unit.unitType === "branch-preview") {
1891
+ const selector = typeof input.unit.spec.resources === "object" && input.unit.spec.resources ? input.unit.spec.resources : { environment: "staging", appId: [String(input.unit.spec.appId ?? "web")] };
1892
+ const nested = await runHostedReconcileGate({
1893
+ parentContext: input.context,
1894
+ selector,
1895
+ target: input.context.target,
1896
+ dryRun: input.context.dryRun === true
1897
+ });
1898
+ return genericResult(input, { ...input.observed.live, nested, fingerprint });
1899
+ }
1900
+ if (input.unit.unitType === "save-gate:hosted-verify") {
1901
+ const selector = typeof input.unit.spec.selector === "object" && input.unit.spec.selector ? input.unit.spec.selector : { environment: input.context.target.kind === "persistent" ? input.context.target.scope : "staging" };
1902
+ const status = await runHostedVerifyGate({
1903
+ parentContext: input.context,
1904
+ selector,
1905
+ target: input.context.target
1906
+ });
1907
+ if (!status.ready) {
1908
+ throw new Error(`Save hosted verification failed: ${status.blockers.join("\n")}`);
1909
+ }
1910
+ return genericResult(input, { ...input.observed.live, status, fingerprint });
1911
+ }
1912
+ return genericResult(input, { ...input.observed.live, fingerprint });
1913
+ },
1914
+ verify(input) {
1915
+ const fingerprint = workflowFingerprint(input);
1916
+ const observedFingerprint = typeof input.observed.live.fingerprint === "string" ? input.observed.live.fingerprint : null;
1917
+ return summarizeVerification(input.unit.unitId, [
1918
+ verificationCheck("workflow-meta-fingerprint", `${input.unit.unitType} fingerprint is reconciled`, "derived", {
1919
+ exists: true,
1920
+ configured: observedFingerprint === fingerprint,
1921
+ ready: observedFingerprint === fingerprint,
1922
+ verified: observedFingerprint === fingerprint,
1923
+ expected: fingerprint,
1924
+ observed: observedFingerprint,
1925
+ issues: observedFingerprint === fingerprint ? [] : [`Expected fingerprint ${fingerprint}, observed ${observedFingerprint ?? "none"}.`]
1926
+ })
1927
+ ], input.observed.warnings);
1928
+ },
1929
+ destroy(input) {
1930
+ return genericResult({
1931
+ ...input,
1932
+ diff: { action: "delete", reasons: [`selected ${input.unit.unitType} for destroy`], before: input.observed.live, after: {} }
1933
+ }, { ...input.observed.live, destroyedAt: nowIso(), fingerprint: workflowFingerprint(input) });
161
1934
  }
162
1935
  };
163
1936
  }
@@ -179,27 +1952,38 @@ function normalizeEnvironmentValues(env) {
179
1952
  }
180
1953
  function resolveReconcileEnvironmentValues(input, scope) {
181
1954
  if (scope === "local") {
182
- return resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope);
1955
+ return normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope));
183
1956
  }
184
- const hostedRuntimeValues = {
1957
+ return {
185
1958
  ...normalizeEnvironmentValues(process.env),
186
1959
  ...normalizeEnvironmentValues(input.context.launchEnv)
187
1960
  };
188
- if (hostedRuntimeValues.CLOUDFLARE_API_TOKEN || hostedRuntimeValues.RAILWAY_API_TOKEN || hostedRuntimeValues.GH_TOKEN) {
189
- return hostedRuntimeValues;
190
- }
191
- const values = {
192
- ...resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope),
193
- ...hostedRuntimeValues
194
- };
195
- return values;
196
1961
  }
197
1962
  function buildCloudflareEnv(input) {
198
1963
  const scope = scopeFromTarget(toDeployTarget(input.context.target));
199
1964
  const values = resolveReconcileEnvironmentValues(input, scope);
1965
+ let accountId = [
1966
+ values.TREESEED_CLOUDFLARE_ACCOUNT_ID,
1967
+ input.context.launchEnv.TREESEED_CLOUDFLARE_ACCOUNT_ID,
1968
+ process.env.TREESEED_CLOUDFLARE_ACCOUNT_ID,
1969
+ resolveConfiguredCloudflareAccountId(input.context.deployConfig)
1970
+ ].find((value) => typeof value === "string" && value.trim().length > 0 && value.trim() !== "replace-with-cloudflare-account-id")?.trim() ?? "";
1971
+ let token = [
1972
+ values.TREESEED_CLOUDFLARE_API_TOKEN,
1973
+ input.context.launchEnv.TREESEED_CLOUDFLARE_API_TOKEN,
1974
+ process.env.TREESEED_CLOUDFLARE_API_TOKEN
1975
+ ].find((value) => typeof value === "string" && value.trim().length > 0)?.trim() ?? "";
1976
+ if (scope !== "local" && (!accountId || !token)) {
1977
+ const configuredValues = normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope));
1978
+ accountId = accountId || ([
1979
+ configuredValues.TREESEED_CLOUDFLARE_ACCOUNT_ID,
1980
+ resolveConfiguredCloudflareAccountId(input.context.deployConfig)
1981
+ ].find((value) => typeof value === "string" && value.trim().length > 0 && value.trim() !== "replace-with-cloudflare-account-id")?.trim() ?? "");
1982
+ token = token || (configuredValues.TREESEED_CLOUDFLARE_API_TOKEN ?? "");
1983
+ }
200
1984
  return {
201
- CLOUDFLARE_ACCOUNT_ID: values.CLOUDFLARE_ACCOUNT_ID ?? input.context.launchEnv.CLOUDFLARE_ACCOUNT_ID ?? process.env.CLOUDFLARE_ACCOUNT_ID ?? resolveConfiguredCloudflareAccountId(input.context.deployConfig),
202
- CLOUDFLARE_API_TOKEN: values.CLOUDFLARE_API_TOKEN ?? input.context.launchEnv.CLOUDFLARE_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN ?? ""
1985
+ CLOUDFLARE_ACCOUNT_ID: accountId,
1986
+ CLOUDFLARE_API_TOKEN: token
203
1987
  };
204
1988
  }
205
1989
  function hasLiveResourceId(value) {
@@ -207,15 +1991,24 @@ function hasLiveResourceId(value) {
207
1991
  }
208
1992
  function buildRailwayEnv(input, scope) {
209
1993
  const values = resolveReconcileEnvironmentValues(input, scope);
210
- const token = [
211
- values.RAILWAY_API_TOKEN,
212
- input.context.launchEnv.RAILWAY_API_TOKEN,
213
- process.env.RAILWAY_API_TOKEN
1994
+ let token = [
1995
+ values.TREESEED_RAILWAY_API_TOKEN,
1996
+ input.context.launchEnv.TREESEED_RAILWAY_API_TOKEN,
1997
+ process.env.TREESEED_RAILWAY_API_TOKEN
214
1998
  ].find((value) => typeof value === "string" && value.trim().length > 0)?.trim() ?? "";
1999
+ let railwayApiUrl = values.TREESEED_RAILWAY_API_URL ?? input.context.launchEnv.TREESEED_RAILWAY_API_URL ?? process.env.TREESEED_RAILWAY_API_URL ?? "";
2000
+ let railwayWorkspace = values.TREESEED_RAILWAY_WORKSPACE ?? input.context.launchEnv.TREESEED_RAILWAY_WORKSPACE ?? process.env.TREESEED_RAILWAY_WORKSPACE ?? "";
2001
+ if (scope !== "local" && !token) {
2002
+ const configuredValues = normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope));
2003
+ token = configuredValues.TREESEED_RAILWAY_API_TOKEN ?? "";
2004
+ railwayApiUrl = railwayApiUrl || (configuredValues.TREESEED_RAILWAY_API_URL ?? "");
2005
+ railwayWorkspace = railwayWorkspace || (configuredValues.TREESEED_RAILWAY_WORKSPACE ?? "");
2006
+ }
215
2007
  return {
2008
+ TREESEED_RAILWAY_API_TOKEN: token,
216
2009
  RAILWAY_API_TOKEN: token,
217
- TREESEED_RAILWAY_API_URL: values.TREESEED_RAILWAY_API_URL ?? input.context.launchEnv.TREESEED_RAILWAY_API_URL ?? process.env.TREESEED_RAILWAY_API_URL ?? "",
218
- TREESEED_RAILWAY_WORKSPACE: values.TREESEED_RAILWAY_WORKSPACE ?? input.context.launchEnv.TREESEED_RAILWAY_WORKSPACE ?? process.env.TREESEED_RAILWAY_WORKSPACE ?? ""
2010
+ TREESEED_RAILWAY_API_URL: railwayApiUrl,
2011
+ TREESEED_RAILWAY_WORKSPACE: railwayWorkspace
219
2012
  };
220
2013
  }
221
2014
  function findCloudflareQueueByName(input, env, expectedName, { attempts = 6, delayMs = 350 } = {}) {
@@ -492,6 +2285,8 @@ function normalizeRailwayDomainPayload(value) {
492
2285
  }
493
2286
  const record = value;
494
2287
  const status = record.status && typeof record.status === "object" ? record.status : {};
2288
+ const certificate = record.certificate && typeof record.certificate === "object" ? record.certificate : status.certificate && typeof status.certificate === "object" ? status.certificate : {};
2289
+ const verification = record.verification && typeof record.verification === "object" ? record.verification : status.verification && typeof status.verification === "object" ? status.verification : {};
495
2290
  const domain = typeof record.domain === "string" ? record.domain.trim() : typeof record.name === "string" ? record.name.trim() : "";
496
2291
  const serviceDomain = firstRailwayDomainString(
497
2292
  record.serviceDomain,
@@ -530,9 +2325,14 @@ function normalizeRailwayDomainPayload(value) {
530
2325
  id: typeof record.id === "string" ? record.id.trim() : null,
531
2326
  domain,
532
2327
  serviceDomain,
533
- certificateStatus: typeof status.certificateStatus === "string" ? String(status.certificateStatus).trim().toUpperCase() : null,
534
- verificationDnsHost: typeof record.verificationDnsHost === "string" ? record.verificationDnsHost.trim() : typeof status.verificationDnsHost === "string" ? String(status.verificationDnsHost).trim() : null,
535
- verificationToken: typeof record.verificationToken === "string" ? record.verificationToken.trim() : typeof status.verificationToken === "string" ? String(status.verificationToken).trim() : null,
2328
+ verified: record.verified === true || status.verified === true || verification.verified === true,
2329
+ certificateStatus: firstRailwayDomainString(
2330
+ record.certificateStatus,
2331
+ status.certificateStatus,
2332
+ certificate.status
2333
+ )?.toUpperCase() ?? null,
2334
+ verificationDnsHost: typeof record.verificationDnsHost === "string" ? record.verificationDnsHost.trim() : typeof status.verificationDnsHost === "string" ? String(status.verificationDnsHost).trim() : typeof verification.dnsHost === "string" ? String(verification.dnsHost).trim() : null,
2335
+ verificationToken: typeof record.verificationToken === "string" ? record.verificationToken.trim() : typeof status.verificationToken === "string" ? String(status.verificationToken).trim() : typeof verification.token === "string" ? String(verification.token).trim() : null,
536
2336
  dnsRecords: effectiveDnsRecords
537
2337
  };
538
2338
  }
@@ -589,7 +2389,7 @@ function collectCloudflareEnvironmentSync(input) {
589
2389
  secretNames.add(entry.id);
590
2390
  }
591
2391
  }
592
- if (entry.targets.includes("cloudflare-var") && value) {
2392
+ if (entry.targets.includes("cloudflare-var") && value && entry.id !== "TREESEED_PROJECT_DOMAINS") {
593
2393
  vars[entry.id] = value;
594
2394
  varNames.add(entry.id);
595
2395
  }
@@ -810,61 +2610,6 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
810
2610
  current.databaseId = created.uuid;
811
2611
  current.previewDatabaseId = created.previewDatabaseUuid ?? created.uuid;
812
2612
  };
813
- const ensureQueue = () => {
814
- const current = state.queues?.agentWork;
815
- if (!current?.name) {
816
- return;
817
- }
818
- const liveQueue = findCloudflareQueueByName(input, env, current.name, { attempts: 1, delayMs: 0 });
819
- const liveDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName, { attempts: 1, delayMs: 0 }) : null;
820
- if (liveQueue) {
821
- current.queueId = queueId(liveQueue);
822
- current.dlqId = current.dlqName ? queueId(liveDlq) : null;
823
- return;
824
- }
825
- let refreshedQueues = queues;
826
- const existing = refreshedQueues.find((entry) => queueName(entry) === current.name);
827
- if (existing) {
828
- current.queueId = queueId(existing);
829
- const existingDlq = current.dlqName ? refreshedQueues.find((entry) => queueName(entry) === current.dlqName) : null;
830
- current.dlqId = queueId(existingDlq);
831
- return;
832
- }
833
- if (dryRun) {
834
- current.queueId = `dryrun-${current.name}`;
835
- current.dlqId = current.dlqName ? `dryrun-${current.dlqName}` : null;
836
- return;
837
- }
838
- try {
839
- runWrangler(["queues", "create", current.name], {
840
- cwd: input.context.tenantRoot,
841
- capture: true,
842
- env
843
- });
844
- } catch (error) {
845
- if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
846
- throw error;
847
- }
848
- }
849
- refreshedQueues = listQueues(input.context.tenantRoot, env);
850
- if (current.dlqName && !refreshedQueues.find((entry) => queueName(entry) === current.dlqName)) {
851
- try {
852
- runWrangler(["queues", "create", current.dlqName], {
853
- cwd: input.context.tenantRoot,
854
- capture: true,
855
- env
856
- });
857
- } catch (error) {
858
- if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
859
- throw error;
860
- }
861
- }
862
- }
863
- const created = findCloudflareQueueByName(input, env, current.name);
864
- current.queueId = created ? queueId(created) : syntheticQueueLocator(current.name);
865
- const createdDlq = current.dlqName ? findCloudflareQueueByName(input, env, current.dlqName) : null;
866
- current.dlqId = current.dlqName ? createdDlq ? queueId(createdDlq) : syntheticQueueLocator(current.dlqName) : null;
867
- };
868
2613
  const ensureR2Bucket = () => {
869
2614
  const bucketName = state.content?.bucketName;
870
2615
  if (!bucketName) {
@@ -983,7 +2728,6 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
983
2728
  };
984
2729
  runStep("kv-form-guard", () => ensureKv("FORM_GUARD_KV"));
985
2730
  runStep("d1", ensureD1);
986
- runStep("queue", ensureQueue);
987
2731
  runStep("r2", ensureR2Bucket);
988
2732
  runStep("pages", ensurePagesProject);
989
2733
  runStep("turnstile-widget", ensureTurnstileWidget);
@@ -1029,20 +2773,19 @@ function observeCloudflareUnit(input) {
1029
2773
  const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets } = snapshot;
1030
2774
  switch (input.unit.unitType) {
1031
2775
  case "queue": {
1032
- const liveQueue = queues.find((entry) => queueName(entry) === state.queues?.agentWork?.name);
1033
- const liveDlq = queues.find((entry) => queueName(entry) === state.queues?.agentWork?.dlqName);
2776
+ const name = input.unit.spec.name;
2777
+ const dlqName = input.unit.spec.dlqName;
2778
+ const liveQueue = queues.find((entry) => queueName(entry) === name);
2779
+ const liveDlq = dlqName ? queues.find((entry) => queueName(entry) === dlqName) : null;
1034
2780
  return {
1035
- exists: Boolean(liveQueue || state.queues?.agentWork?.queueId),
2781
+ exists: Boolean(liveQueue),
1036
2782
  status: liveQueue ? "ready" : "pending",
1037
- live: { ...state.queues?.agentWork ?? {} },
2783
+ live: { name, dlqName },
1038
2784
  locators: {
1039
- queueId: queueId(liveQueue) ?? state.queues?.agentWork?.queueId ?? null,
1040
- dlqId: queueId(liveDlq) ?? state.queues?.agentWork?.dlqId ?? null
2785
+ queueId: queueId(liveQueue) ?? null,
2786
+ dlqId: queueId(liveDlq) ?? null
1041
2787
  },
1042
- warnings: [
1043
- ...isSyntheticQueueLocator(state.queues?.agentWork?.queueId) ? ["Cloudflare queue id is pending propagation; using queue-name fallback."] : [],
1044
- ...isSyntheticQueueLocator(state.queues?.agentWork?.dlqId) ? ["Cloudflare dead-letter queue id is pending propagation; using queue-name fallback."] : []
1045
- ]
2788
+ warnings: []
1046
2789
  };
1047
2790
  }
1048
2791
  case "database": {
@@ -1132,7 +2875,7 @@ function verifyCloudflareUnitOnce(input, postconditions) {
1132
2875
  const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets, env } = snapshot;
1133
2876
  switch (input.unit.unitType) {
1134
2877
  case "queue": {
1135
- const queue = state.queues?.agentWork;
2878
+ const queue = input.unit.spec;
1136
2879
  const liveQueue = findCloudflareQueueByName(input, env, queue?.name, { attempts: 12, delayMs: 500 });
1137
2880
  const liveDlq = queue?.dlqName ? findCloudflareQueueByName(input, env, queue.dlqName, { attempts: 12, delayMs: 500 }) : null;
1138
2881
  return summarizeVerification(input.unit.unitId, [
@@ -1505,6 +3248,10 @@ function relativeRailwayRootDir(tenantRoot, serviceRoot) {
1505
3248
  return !resolved || resolved === "" ? "." : resolved;
1506
3249
  }
1507
3250
  function railwayServiceRootDirectory(tenantRoot, service) {
3251
+ const sourceRootDirectory = String(service.sourceRootDirectory ?? "").trim();
3252
+ if (sourceRootDirectory) {
3253
+ return sourceRootDirectory;
3254
+ }
1508
3255
  return relativeRailwayRootDir(service.application?.root ?? tenantRoot, service.rootDir);
1509
3256
  }
1510
3257
  function configuredMarketDatabaseService(tenantRoot, deployConfig) {
@@ -1555,6 +3302,7 @@ async function ensureRailwayEnvironmentForService({
1555
3302
  }
1556
3303
  async function resolveRailwayTopologyForScope(input, scope, {
1557
3304
  ensure = false,
3305
+ ensureServices = ensure,
1558
3306
  refresh = false,
1559
3307
  serviceKeys,
1560
3308
  includeInstances = ensure,
@@ -1571,17 +3319,23 @@ async function resolveRailwayTopologyForScope(input, scope, {
1571
3319
  const knownProjects = [];
1572
3320
  const knownProjectIds = [...new Set(services.map((service) => service.projectId || deployState.services?.[service.key]?.projectId || "").filter((value) => typeof value === "string" && value.trim().length > 0).map((value) => value.trim()))];
1573
3321
  for (const projectId of knownProjectIds) {
3322
+ traceRailwayReconcile(env, "topology:known-project:get", projectId);
1574
3323
  const project = await getRailwayProject({ projectId, env });
3324
+ traceRailwayReconcile(env, "topology:known-project:done", `${projectId}:${project?.name ?? "(missing)"}`);
1575
3325
  if (project) {
1576
3326
  knownProjects.push(project);
1577
3327
  }
1578
3328
  }
1579
3329
  if (knownProjects.length === 0 || services.some((service) => !(service.projectId || deployState.services?.[service.key]?.projectId))) {
3330
+ traceRailwayReconcile(env, "topology:workspace:get", "start");
1580
3331
  workspace = await resolveRailwayWorkspaceContext({ env });
3332
+ traceRailwayReconcile(env, "topology:workspace:done", workspace.name);
3333
+ traceRailwayReconcile(env, "topology:projects:list", workspace.name);
1581
3334
  const listedProjects = await listRailwayProjects({
1582
3335
  env,
1583
3336
  workspaceId: workspace.id
1584
3337
  });
3338
+ traceRailwayReconcile(env, "topology:projects:list:done", String(listedProjects.length));
1585
3339
  for (const project of listedProjects) {
1586
3340
  if (!knownProjects.find((entry) => entry?.id === project.id)) {
1587
3341
  knownProjects.push(project);
@@ -1608,6 +3362,17 @@ async function resolveRailwayTopologyForScope(input, scope, {
1608
3362
  const resolvedServiceId = service.serviceId ?? persistedService.serviceId ?? "";
1609
3363
  const resolvedServiceName = service.serviceName ?? persistedService.serviceName ?? "";
1610
3364
  let project = projectsByKey.get(resolvedProjectId) ?? projectsByKey.get(resolvedProjectName) ?? null;
3365
+ if (project && (!Array.isArray(project.services) || project.services.length === 0 || !Array.isArray(project.environments) || project.environments.length === 0)) {
3366
+ const hydratedProject = await getRailwayProject({ projectId: project.id, env });
3367
+ if (hydratedProject) {
3368
+ project = hydratedProject;
3369
+ projectsByKey.set(project.id, project);
3370
+ projectsByKey.set(project.name, project);
3371
+ }
3372
+ }
3373
+ if (project) {
3374
+ traceRailwayReconcile(env, "topology:project:resolved", `${service.key}:${project.name}:services=${project.services.map((entry) => entry.name).join(",") || "(none)"}:envs=${project.environments.map((entry) => entry.name).join(",") || "(none)"}`);
3375
+ }
1611
3376
  if (!project && ensure) {
1612
3377
  if (!workspace) {
1613
3378
  traceRailwayReconcile(env, "topology:workspace", "resolving workspace");
@@ -1626,6 +3391,9 @@ async function resolveRailwayTopologyForScope(input, scope, {
1626
3391
  projectsByKey.set(project.name, project);
1627
3392
  }
1628
3393
  let environment = project?.environments.find((entry) => entry.name === service.railwayEnvironment || entry.id === service.railwayEnvironment) ?? null;
3394
+ if (project) {
3395
+ traceRailwayReconcile(env, "topology:environment:resolved", `${service.key}:${environment?.name ?? "(none)"}:${service.railwayEnvironment}`);
3396
+ }
1629
3397
  if (project && !environment && ensure) {
1630
3398
  traceRailwayReconcile(env, "topology:environment:ensure", `${service.key}:${service.railwayEnvironment}`);
1631
3399
  environment = await ensureRailwayEnvironmentForService({
@@ -1641,13 +3409,30 @@ async function resolveRailwayTopologyForScope(input, scope, {
1641
3409
  projectsByKey.set(project.id, project);
1642
3410
  projectsByKey.set(project.name, project);
1643
3411
  }
3412
+ if (project && environment && (!Array.isArray(project.services) || project.services.length === 0 || !project.services.some((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName))) {
3413
+ const environmentServices = await listRailwayEnvironmentServices({ environmentId: environment.id, env }).catch(() => []);
3414
+ traceRailwayReconcile(env, "topology:environment-services:lookup", `${service.key}:${environment.name}:${environmentServices.map((entry) => entry.name).join(",") || "(none)"}`);
3415
+ if (environmentServices.length > 0) {
3416
+ project = {
3417
+ ...project,
3418
+ services: [...new Map([...project.services, ...environmentServices].map((entry) => [entry.id, entry])).values()]
3419
+ };
3420
+ projectsByKey.set(project.id, project);
3421
+ projectsByKey.set(project.name, project);
3422
+ traceRailwayReconcile(env, "topology:environment-services:resolved", `${service.key}:${environmentServices.map((entry) => entry.name).join(",")}`);
3423
+ }
3424
+ }
1644
3425
  let resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
1645
- if (project && !resolvedService && ensure) {
3426
+ if (project && !resolvedService && ensureServices) {
1646
3427
  traceRailwayReconcile(env, "topology:railway-service:ensure", `${service.key}:${resolvedServiceName || resolvedServiceId}`);
1647
3428
  resolvedService = (await ensureRailwayService({
1648
3429
  projectId: project.id,
1649
3430
  serviceId: resolvedServiceId,
1650
3431
  serviceName: resolvedServiceName,
3432
+ environmentId: environment?.id,
3433
+ imageRef: service.imageRef,
3434
+ sourceRepo: service.sourceRepo,
3435
+ sourceBranch: service.sourceBranch,
1651
3436
  env
1652
3437
  })).service;
1653
3438
  project = {
@@ -1656,17 +3441,37 @@ async function resolveRailwayTopologyForScope(input, scope, {
1656
3441
  };
1657
3442
  projectsByKey.set(project.id, project);
1658
3443
  projectsByKey.set(project.name, project);
3444
+ } else if (project && resolvedService && ensureServices && (service.imageRef || service.sourceRepo)) {
3445
+ traceRailwayReconcile(env, "topology:railway-service:repair-source", `${service.key}:${resolvedService.name}`);
3446
+ try {
3447
+ resolvedService = (await ensureRailwayService({
3448
+ projectId: project.id,
3449
+ serviceId: resolvedService.id,
3450
+ serviceName: resolvedServiceName || resolvedService.name,
3451
+ environmentId: environment?.id,
3452
+ imageRef: service.imageRef,
3453
+ sourceRepo: service.sourceRepo,
3454
+ sourceBranch: service.sourceBranch,
3455
+ env
3456
+ })).service;
3457
+ } catch (error) {
3458
+ if (!looksLikeRailwayExistingSourceRepairUnsupported(error)) {
3459
+ throw error;
3460
+ }
3461
+ traceRailwayReconcile(env, "topology:railway-service:repair-source-skipped", `${service.key}:${resolvedService.name}`);
3462
+ }
1659
3463
  }
1660
3464
  let instance = null;
1661
3465
  if (includeInstances && resolvedService && environment) {
1662
- if (ensure) {
3466
+ if (ensureServices) {
1663
3467
  traceRailwayReconcile(env, "topology:instance:ensure", `${service.key}:${resolvedService.name}`);
1664
3468
  instance = (await ensureRailwayServiceInstanceConfiguration({
1665
3469
  serviceId: resolvedService.id,
1666
3470
  environmentId: environment.id,
1667
3471
  buildCommand: service.buildCommand,
3472
+ dockerfilePath: !service.imageRef ? service.dockerfilePath ?? (existsSync(resolve(service.rootDir, "Dockerfile")) ? "/Dockerfile" : null) : null,
1668
3473
  startCommand: service.startCommand,
1669
- rootDirectory: railwayServiceRootDirectory(input.context.tenantRoot, service),
3474
+ rootDirectory: service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service),
1670
3475
  healthcheckPath: service.healthcheckPath,
1671
3476
  healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
1672
3477
  healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
@@ -1733,36 +3538,113 @@ function activeRailwayVolumeInstances(volume) {
1733
3538
  const instances = Array.isArray(volume.instances) ? volume.instances : [];
1734
3539
  return instances.filter((instance) => {
1735
3540
  const state = String(instance.state ?? "READY").toUpperCase();
1736
- return state !== "DELETING" && state !== "DELETED";
3541
+ const pendingDeletion = "isPendingDeletion" in instance && instance.isPendingDeletion === true;
3542
+ const deletedAt = "deletedAt" in instance && typeof instance.deletedAt === "string" && instance.deletedAt.trim();
3543
+ return !pendingDeletion && !deletedAt && state !== "DELETING" && state !== "DELETED";
1737
3544
  });
1738
3545
  }
1739
- async function waitForRailwayServiceDeleted({
1740
- projectId,
1741
- serviceId,
1742
- env
1743
- }) {
1744
- for (let attempt = 0; attempt < 10; attempt += 1) {
1745
- const services = await listRailwayServices({ projectId, env }).catch(() => []);
1746
- if (!services.some((service) => service.id === serviceId)) {
1747
- return true;
3546
+ function isRailwayCapacityProviderService(service) {
3547
+ return String(service.key ?? "").startsWith("capacityProvider");
3548
+ }
3549
+ function railwayServiceMatchesKey(service, key) {
3550
+ return service.key === key || service.instanceKey === key || service.serviceName === key || service.serviceId === key;
3551
+ }
3552
+ function configuredRailwayProjectSyncGroups(input, scope, serviceKeys) {
3553
+ const allServices = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service));
3554
+ const selected = Array.isArray(serviceKeys) && serviceKeys.length > 0 ? allServices.filter((service) => serviceKeys.some((key) => railwayServiceMatchesKey(service, key))) : allServices;
3555
+ const selectedProjectKeys = new Set(selected.map((service) => `${service.projectName}:::${service.railwayEnvironment}`));
3556
+ const grouped = /* @__PURE__ */ new Map();
3557
+ for (const service of allServices) {
3558
+ const key = `${service.projectName}:::${service.railwayEnvironment}`;
3559
+ if (!selectedProjectKeys.has(key)) {
3560
+ continue;
1748
3561
  }
1749
- await new Promise((resolve2) => setTimeout(resolve2, 1500));
3562
+ const services = grouped.get(key) ?? [];
3563
+ services.push(service);
3564
+ grouped.set(key, services);
1750
3565
  }
1751
- return false;
3566
+ return [...grouped.values()];
1752
3567
  }
1753
- async function waitForRailwayVolumeDeleted({
1754
- projectId,
1755
- volumeId,
1756
- env
1757
- }) {
1758
- for (let attempt = 0; attempt < 10; attempt += 1) {
1759
- const volumes = await listRailwayVolumes({ projectId, env }).catch(() => []);
1760
- if (!volumes.some((volume) => volume.id === volumeId)) {
1761
- return true;
3568
+ function railwayIacServiceInput(input, sync, service, scope) {
3569
+ if (scope === "staging" && service.imageRef) {
3570
+ throw new Error(`Railway staging service ${service.serviceName} must build from Git source, not Docker image ${service.imageRef}.`);
3571
+ }
3572
+ if (scope === "prod" && service.sourceMode === "git") {
3573
+ throw new Error(`Railway production service ${service.serviceName} must deploy an immutable image, not Git source.`);
3574
+ }
3575
+ const serviceSync = sync.forService(service.key, service);
3576
+ const deployVariablePrefix = service.serviceName.includes("treedx") || service.key.includes("treedx") ? "TREEDX" : "TREESEED";
3577
+ const sourceVariables = service.sourceMode === "git" ? {
3578
+ ...service.sourceRepo ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_REPOSITORY`]: service.sourceRepo } : {},
3579
+ ...service.sourceBranch ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_BRANCH`]: service.sourceBranch } : {},
3580
+ ...service.sourceCommit ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_COMMIT`]: service.sourceCommit } : {}
3581
+ } : {};
3582
+ return {
3583
+ key: service.instanceKey ?? service.key,
3584
+ serviceName: service.serviceName,
3585
+ sourceMode: service.sourceMode,
3586
+ sourceRepo: service.sourceRepo,
3587
+ sourceBranch: service.sourceBranch,
3588
+ sourceCommit: service.sourceCommit,
3589
+ sourceRootDirectory: service.sourceRootDirectory ?? railwayServiceRootDirectory(input.context.tenantRoot, service),
3590
+ imageRef: service.imageRef,
3591
+ dockerfilePath: service.dockerfilePath,
3592
+ buildCommand: service.buildCommand,
3593
+ startCommand: service.startCommand,
3594
+ healthcheckPath: service.healthcheckPath,
3595
+ healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
3596
+ runtimeMode: service.runtimeMode,
3597
+ volumeMountPath: service.volumeMountPath,
3598
+ variables: {
3599
+ ...serviceSync.variables,
3600
+ ...sourceVariables
3601
+ },
3602
+ secrets: serviceSync.secrets
3603
+ };
3604
+ }
3605
+ function configuredRailwayIacDatabase(input, projectServices, detachVolumeIds = []) {
3606
+ if (!projectServices.some((service) => service.key === "api" || service.key === "operationsRunner")) {
3607
+ return null;
3608
+ }
3609
+ const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
3610
+ const treeseedDatabaseService = treeseedDatabase?.service;
3611
+ if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
3612
+ return null;
3613
+ }
3614
+ return {
3615
+ serviceName: treeseedDatabase.serviceName,
3616
+ environmentVariable: "TREESEED_DATABASE_URL",
3617
+ mountPath: "/var/lib/postgresql/data",
3618
+ detachVolumeIds
3619
+ };
3620
+ }
3621
+ function activeAttachedRailwayVolumeIds(volumes, serviceId, environmentId, desiredVolumeName) {
3622
+ if (!serviceId) {
3623
+ return [];
3624
+ }
3625
+ return volumes.filter((volume) => volume.instances.some(
3626
+ (instance) => instance.serviceId === serviceId && instance.environmentId === environmentId && (volume.name !== desiredVolumeName || activeRailwayVolumeInstances({ instances: [instance] }).length === 0)
3627
+ )).map((volume) => volume.id);
3628
+ }
3629
+ function blockedPendingRailwayVolumeAttachments(volumes, serviceIdByName, environmentId) {
3630
+ const serviceNameById = new Map([...serviceIdByName.entries()].filter(([, serviceId]) => serviceId).map(([serviceName, serviceId]) => [serviceId, serviceName]));
3631
+ const blocked = [];
3632
+ for (const volume of volumes) {
3633
+ for (const instance of volume.instances) {
3634
+ if (!instance.serviceId || instance.environmentId !== environmentId) {
3635
+ continue;
3636
+ }
3637
+ const serviceName = serviceNameById.get(instance.serviceId);
3638
+ if (!serviceName) {
3639
+ continue;
3640
+ }
3641
+ if (activeRailwayVolumeInstances({ instances: [instance] }).length > 0) {
3642
+ continue;
3643
+ }
3644
+ blocked.push(`${serviceName}: volume ${volume.name ?? volume.id} (${volume.id}) is pending/deleted while still attached to service ${instance.serviceId} at ${instance.mountPath ?? "(unknown mount)"}; deletedAt=${instance.deletedAt ?? "(none)"} state=${instance.state ?? "(unknown)"}`);
1762
3645
  }
1763
- await new Promise((resolve2) => setTimeout(resolve2, 1500));
1764
3646
  }
1765
- return false;
3647
+ return blocked;
1766
3648
  }
1767
3649
  async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
1768
3650
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
@@ -1812,21 +3694,17 @@ async function reconcileStaleOperationsRunnerResourcesForProject(input, {
1812
3694
  `${project.name}: desired=${[...desiredServiceNames].join(",") || "(none)"} stale=${staleServices.map((service) => service.name).join(",") || "(none)"}`
1813
3695
  );
1814
3696
  for (const service of staleServices) {
1815
- traceRailwayReconcile(env, "sync:runner:delete-stale-service", `${service.name}:${service.id}`);
1816
- await deleteRailwayService({ serviceId: service.id, env });
1817
- const deleted = await waitForRailwayServiceDeleted({ projectId: project.id, serviceId: service.id, env });
1818
- if (!deleted) {
1819
- recordRailwayProviderDrift(input, scope, {
1820
- kind: "railway.stale-operations-runner-service",
1821
- action: "delete",
1822
- status: "blocked",
1823
- projectId: project.id,
1824
- environmentId: environment.id,
1825
- serviceId: service.id,
1826
- serviceName: service.name,
1827
- reason: "Railway still reports the stale operations runner service after delete reconciliation."
1828
- });
1829
- }
3697
+ traceRailwayReconcile(env, "sync:runner:block-stale-service", `${service.name}:${service.id}`);
3698
+ recordRailwayProviderDrift(input, scope, {
3699
+ kind: "railway.stale-operations-runner-service",
3700
+ action: "manual-repair-required",
3701
+ status: "blocked",
3702
+ projectId: project.id,
3703
+ environmentId: environment.id,
3704
+ serviceId: service.id,
3705
+ serviceName: service.name,
3706
+ reason: "Railway reports an undeclared operations runner service. Reconciliation refuses to delete existing services; adopt, rename, retain, or remove it through an explicit destroy workflow."
3707
+ });
1830
3708
  }
1831
3709
  const volumes = await listRailwayVolumes({ projectId: project.id, env }).catch(() => []);
1832
3710
  const staleVolumes = findStaleTreeseedOperationsRunnerResources(volumes, desiredVolumeNames).filter((volume) => {
@@ -1836,101 +3714,384 @@ async function reconcileStaleOperationsRunnerResourcesForProject(input, {
1836
3714
  traceRailwayReconcile(
1837
3715
  env,
1838
3716
  "sync:runner:observed-volumes",
1839
- `${project.name}: desired=${[...desiredVolumeNames].join(",") || "(none)"} stale=${staleVolumes.map((volume) => volume.name ?? volume.id).join(",") || "(none)"}`
1840
- );
1841
- for (const volume of staleVolumes) {
1842
- traceRailwayReconcile(env, "sync:runner:delete-stale-volume", `${volume.name ?? volume.id}:${volume.id}`);
1843
- await deleteRailwayVolume({ volumeId: volume.id, env });
1844
- const deleted = await waitForRailwayVolumeDeleted({ projectId: project.id, volumeId: volume.id, env });
1845
- if (!deleted) {
1846
- recordRailwayProviderDrift(input, scope, {
1847
- kind: "railway.stale-operations-runner-volume",
1848
- action: "delete",
1849
- status: "blocked",
1850
- projectId: project.id,
1851
- environmentId: environment.id,
1852
- volumeId: volume.id,
1853
- volumeName: volume.name ?? null,
1854
- reason: "Railway still reports the stale operations runner volume after delete reconciliation."
1855
- });
1856
- }
3717
+ `${project.name}: desired=${[...desiredVolumeNames].join(",") || "(none)"} stale=${staleVolumes.map((volume) => volume.name ?? volume.id).join(",") || "(none)"}`
3718
+ );
3719
+ for (const volume of staleVolumes) {
3720
+ traceRailwayReconcile(env, "sync:runner:block-stale-volume", `${volume.name ?? volume.id}:${volume.id}`);
3721
+ recordRailwayProviderDrift(input, scope, {
3722
+ kind: "railway.stale-operations-runner-volume",
3723
+ action: "manual-repair-required",
3724
+ status: "blocked",
3725
+ projectId: project.id,
3726
+ environmentId: environment.id,
3727
+ volumeId: volume.id,
3728
+ volumeName: volume.name ?? null,
3729
+ reason: "Railway reports an undeclared operations runner volume. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow."
3730
+ });
1857
3731
  }
1858
3732
  }
1859
- async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
3733
+ async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKeys } = {}) {
1860
3734
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
1861
3735
  const sync = collectRailwayEnvironmentSync(input);
3736
+ const selectedServiceKeys = Array.isArray(serviceKeys) && serviceKeys.length > 0 ? [...new Set(serviceKeys.map((value) => String(value).trim()).filter(Boolean))] : void 0;
1862
3737
  traceRailwayReconcile(input.context.env, "sync:start", `scope=${scope} dryRun=${dryRun ? "yes" : "no"}`);
1863
- const topology = await resolveRailwayTopologyForScope(input, scope, {
1864
- ensure: !dryRun,
1865
- includeInstances: !dryRun,
1866
- includeVariables: false
1867
- });
1868
- traceRailwayReconcile(topology.env, "sync:topology", `services=${[...topology.services.keys()].join(",")}`);
1869
- if (!dryRun) {
1870
- traceRailwayReconcile(topology.env, "sync:postgres:start", "ensuring Treeseed database");
1871
- await ensureRailwayMarketDatabaseForScope(input, topology);
1872
- traceRailwayReconcile(topology.env, "sync:postgres:done", "Treeseed database ensured");
1873
- for (const entry of topology.services.values()) {
1874
- if (!entry.project || !entry.environment || !entry.service) {
1875
- continue;
3738
+ const projectGroups = configuredRailwayProjectSyncGroups(input, scope, selectedServiceKeys);
3739
+ if (projectGroups.length === 0) {
3740
+ return {
3741
+ scope,
3742
+ services: [],
3743
+ secrets: Object.keys(sync.secrets),
3744
+ variables: Object.keys(sync.variables),
3745
+ dryRun,
3746
+ workspace: ""
3747
+ };
3748
+ }
3749
+ let workspace = "";
3750
+ const syncedServices = [];
3751
+ for (const projectServices of projectGroups) {
3752
+ const serviceKeySet = [...new Set(projectServices.map((service) => service.key))];
3753
+ const topology = await resolveRailwayTopologyForScope(input, scope, {
3754
+ ensure: !dryRun,
3755
+ ensureServices: false,
3756
+ serviceKeys: serviceKeySet,
3757
+ includeInstances: false,
3758
+ includeVariables: false,
3759
+ refresh: true
3760
+ });
3761
+ workspace = topology.workspace.name;
3762
+ const resolvedEntry = [...topology.services.values()].find((entry) => entry.project && entry.environment) ?? null;
3763
+ const project = resolvedEntry?.project;
3764
+ const environment = resolvedEntry?.environment;
3765
+ traceRailwayReconcile(topology.env, "sync:topology", `project-services=${projectServices.map((service) => service.serviceName).join(",")}`);
3766
+ if (dryRun) {
3767
+ syncedServices.push(...projectServices);
3768
+ continue;
3769
+ }
3770
+ if (!project || !environment) {
3771
+ throw new Error(`Railway IaC reconciliation could not resolve project/environment for ${projectServices[0]?.projectName ?? "(unknown project)"}/${projectServices[0]?.railwayEnvironment ?? "(unknown environment)"}.`);
3772
+ }
3773
+ const liveServices = await listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }).catch(() => project.services);
3774
+ const liveServiceByName = new Map(liveServices.map((service) => [service.name, service]));
3775
+ const liveVolumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
3776
+ const databaseDescriptor = configuredRailwayIacDatabase(input, projectServices);
3777
+ const serviceIdByName = new Map(projectServices.map((service) => {
3778
+ const liveService = liveServiceByName.get(service.serviceName) ?? project.services.find((candidate) => candidate.name === service.serviceName || candidate.id === service.serviceId) ?? null;
3779
+ return [service.serviceName, liveService?.id ?? null];
3780
+ }));
3781
+ if (databaseDescriptor) {
3782
+ const databaseLiveService2 = liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null;
3783
+ serviceIdByName.set(databaseDescriptor.serviceName, databaseLiveService2?.id ?? null);
3784
+ }
3785
+ const pendingVolumeBlocks = blockedPendingRailwayVolumeAttachments(liveVolumes, serviceIdByName, environment.id);
3786
+ if (pendingVolumeBlocks.length > 0) {
3787
+ for (const reason of pendingVolumeBlocks) {
3788
+ recordRailwayProviderDrift(input, scope, {
3789
+ kind: "railway.pending-volume-attachment",
3790
+ action: "blocked",
3791
+ status: "blocked",
3792
+ projectId: project.id,
3793
+ environmentId: environment.id,
3794
+ reason
3795
+ });
1876
3796
  }
1877
- traceRailwayReconcile(topology.env, "sync:variables", `${entry.configuredService.key}:${entry.service.name}`);
1878
- const serviceSync = sync.forService(entry.configuredService.key);
1879
- await upsertRailwayVariables({
1880
- projectId: entry.project.id,
1881
- environmentId: entry.environment.id,
1882
- serviceId: entry.service.id,
1883
- variables: {
1884
- ...serviceSync.variables,
1885
- ...serviceSync.secrets
1886
- },
1887
- env: topology.env
3797
+ throw new Error(`Railway has pending-deletion volume attachments that block official IaC reconciliation. ${pendingVolumeBlocks.join("; ")}`);
3798
+ }
3799
+ const detachVolumeIdsByServiceName = new Map(projectServices.map((service) => {
3800
+ const liveService = liveServiceByName.get(service.serviceName) ?? project.services.find((candidate) => candidate.name === service.serviceName || candidate.id === service.serviceId) ?? null;
3801
+ return [
3802
+ service.serviceName,
3803
+ activeAttachedRailwayVolumeIds(liveVolumes, liveService?.id, environment.id, `${service.serviceName}-volume`)
3804
+ ];
3805
+ }));
3806
+ const databaseLiveService = databaseDescriptor ? liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null : null;
3807
+ const databaseDetachVolumeIds = databaseDescriptor ? activeAttachedRailwayVolumeIds(liveVolumes, databaseLiveService?.id, environment.id, `${databaseDescriptor.serviceName}-volume`) : [];
3808
+ const databaseForIac = databaseDescriptor ? { ...databaseDescriptor, detachVolumeIds: databaseDetachVolumeIds, useNativePostgres: Boolean(databaseLiveService) } : null;
3809
+ const token = String(topology.env.TREESEED_RAILWAY_API_TOKEN ?? topology.env.RAILWAY_API_TOKEN ?? "").trim();
3810
+ if (!token) {
3811
+ throw new Error(`Railway IaC reconciliation requires TREESEED_RAILWAY_API_TOKEN for ${project.name}/${environment.name}.`);
3812
+ }
3813
+ const iacInput = {
3814
+ tenantRoot: input.context.tenantRoot,
3815
+ scope,
3816
+ projectName: project.name,
3817
+ projectId: project.id,
3818
+ environmentName: environment.name,
3819
+ environmentId: environment.id,
3820
+ railwayApiToken: token,
3821
+ railwayApiUrl: String(topology.env.TREESEED_RAILWAY_API_URL ?? topology.env.RAILWAY_API_URL ?? "").trim() || null,
3822
+ services: projectServices.map((service) => ({
3823
+ ...railwayIacServiceInput(input, sync, service, scope),
3824
+ detachVolumeIds: detachVolumeIdsByServiceName.get(service.serviceName) ?? []
3825
+ })),
3826
+ database: databaseForIac
3827
+ };
3828
+ const {
3829
+ applyRailwayIacProject,
3830
+ cleanupRailwayIacRender,
3831
+ planRailwayIacProject,
3832
+ renderRailwayIacProject,
3833
+ validateRailwayIacChangeSet
3834
+ } = await import("./providers/railway-iac.js");
3835
+ const rendered = renderRailwayIacProject(iacInput);
3836
+ try {
3837
+ traceRailwayReconcile(topology.env, "sync:iac-plan", `${project.name}/${environment.name}:${rendered.serviceNames.join(",")}:volumes=${rendered.volumeNames.join(",")}`);
3838
+ const plan = await planRailwayIacProject(iacInput, rendered);
3839
+ if (!plan.ok) {
3840
+ const diagnostics = plan.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
3841
+ throw new Error(`Railway IaC plan failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
3842
+ }
3843
+ const validation = validateRailwayIacChangeSet(plan.changeSet, {
3844
+ services: rendered.serviceNames,
3845
+ volumes: rendered.volumeNames,
3846
+ database: rendered.databaseName,
3847
+ scope
1888
3848
  });
1889
- if (entry.configuredService.volumeMountPath) {
1890
- const volumeName = `${entry.service.name}-volume`;
1891
- traceRailwayReconcile(topology.env, "sync:volume", `${entry.configuredService.key}:${volumeName}:${entry.configuredService.volumeMountPath}`);
1892
- const volume = await ensureRailwayServiceVolume({
1893
- projectId: entry.project.id,
1894
- environmentId: entry.environment.id,
1895
- serviceId: entry.service.id,
1896
- name: volumeName,
1897
- mountPath: entry.configuredService.volumeMountPath,
1898
- env: topology.env
1899
- });
1900
- if (volume.instance?.serviceId !== entry.service.id || volume.instance?.environmentId !== entry.environment.id || volume.instance?.mountPath !== entry.configuredService.volumeMountPath) {
1901
- throw new Error(`Railway API volume reconciliation did not mount ${volumeName} on ${entry.service.name} at ${entry.configuredService.volumeMountPath}.`);
1902
- }
3849
+ if (!validation.ok) {
3850
+ throw new Error(`Railway IaC plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
1903
3851
  }
1904
- if (entry.configuredService.key === "operationsRunner") {
1905
- const desiredServiceNames = new Set(configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false).map((service) => service.serviceName).filter(Boolean));
1906
- if (entry.configuredService.serviceName) {
1907
- desiredServiceNames.add(entry.configuredService.serviceName);
1908
- }
1909
- const desiredVolumeNames = new Set([...desiredServiceNames].map((serviceName) => `${serviceName}-volume`));
1910
- if (desiredServiceNames.size > 0) {
1911
- await reconcileStaleOperationsRunnerResourcesForProject(input, {
1912
- scope,
1913
- env: topology.env,
1914
- project: entry.project,
1915
- environment: entry.environment,
1916
- desiredServiceNames,
1917
- desiredVolumeNames
1918
- });
1919
- }
3852
+ const apply = await applyRailwayIacProject(iacInput, rendered);
3853
+ if (!apply.ok) {
3854
+ const diagnostics = apply.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
3855
+ throw new Error(`Railway IaC apply failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
1920
3856
  }
3857
+ syncedServices.push(...projectServices);
3858
+ } finally {
3859
+ cleanupRailwayIacRender(rendered);
1921
3860
  }
1922
- traceRailwayReconcile(topology.env, "sync:runner:cleanup-stale", "reconciling old operations runner resources");
1923
- await reconcileStaleOperationsRunnerResourcesForScope(input, topology);
1924
3861
  }
1925
- traceRailwayReconcile(topology.env, "sync:done", `scope=${scope}`);
3862
+ traceRailwayReconcile(input.context.env, "sync:done", `scope=${scope}`);
1926
3863
  return {
1927
3864
  scope,
1928
- services: [...topology.services.values()].map((entry) => entry.configuredService),
3865
+ services: syncedServices,
1929
3866
  secrets: Object.keys(sync.secrets),
1930
3867
  variables: Object.keys(sync.variables),
1931
3868
  dryRun,
1932
- workspace: topology.workspace.name
3869
+ workspace
3870
+ };
3871
+ }
3872
+ function shouldDeployRailwayServiceBySourceUpload(scope, service) {
3873
+ return scope === "staging" && service.sourceMode === "git" && Boolean(service.sourceRepo) && !service.dockerfilePath && service.key !== "capacityProviderManager" && service.key !== "capacityProviderRunner" && !service.imageRef;
3874
+ }
3875
+ function deployRailwayServiceBySourceUpload(input, entry, env, scope) {
3876
+ if (!entry.project?.id || !entry.environment?.name || !entry.service?.name) {
3877
+ throw new Error(`Railway source upload deploy requires resolved project, environment, and service for ${entry.configuredService.key}.`);
3878
+ }
3879
+ const rootDir = String(entry.configuredService.rootDir ?? "").trim();
3880
+ if (!rootDir || !existsSync(rootDir)) {
3881
+ throw new Error(`Railway source upload deploy root does not exist for ${entry.configuredService.key}: ${rootDir || "(unset)"}.`);
3882
+ }
3883
+ const uploadContext = prepareRailwaySourceUploadContext(input, entry.configuredService, rootDir);
3884
+ const uploadRoot = uploadContext.rootDir;
3885
+ const args = [
3886
+ "trsd",
3887
+ "railway",
3888
+ "--environment",
3889
+ scope,
3890
+ "--",
3891
+ "up",
3892
+ "--service",
3893
+ entry.service.name,
3894
+ "--project",
3895
+ entry.project.id,
3896
+ "--environment",
3897
+ entry.environment.name,
3898
+ "--ci",
3899
+ "--detach",
3900
+ "--path-as-root",
3901
+ ...uploadContext.noGitignore ? ["--no-gitignore"] : [],
3902
+ "--verbose",
3903
+ "--message",
3904
+ `trsd staging source upload ${entry.configuredService.key}`,
3905
+ uploadRoot
3906
+ ];
3907
+ const commandEnv = {
3908
+ ...process.env,
3909
+ ...env,
3910
+ TREESEED_RECONCILE_TRACE: env.TREESEED_RECONCILE_TRACE ?? process.env.TREESEED_RECONCILE_TRACE
1933
3911
  };
3912
+ try {
3913
+ const result = spawnSync("npx", args, {
3914
+ cwd: input.context.tenantRoot,
3915
+ env: commandEnv,
3916
+ encoding: "utf8",
3917
+ stdio: ["ignore", "pipe", "pipe"],
3918
+ maxBuffer: 1024 * 1024 * 20
3919
+ });
3920
+ if ((result.status ?? 1) !== 0) {
3921
+ const stderr = String(result.stderr ?? "").split("\n").slice(-120).join("\n");
3922
+ const stdout = String(result.stdout ?? "").split("\n").slice(-120).join("\n");
3923
+ throw new Error(`Railway source upload deploy failed for ${entry.configuredService.key} with status ${result.status ?? 1}.
3924
+ ${stderr || stdout}`);
3925
+ }
3926
+ } finally {
3927
+ if (uploadContext.cleanupDir) {
3928
+ rmSync(uploadContext.cleanupDir, { recursive: true, force: true });
3929
+ }
3930
+ }
3931
+ }
3932
+ function prepareRailwaySourceUploadContext(input, service, rootDir) {
3933
+ const role = service.key === "capacityProviderManager" ? "manager" : service.key === "capacityProviderRunner" ? "runner" : null;
3934
+ if (!role) {
3935
+ return { rootDir, cleanupDir: null, noGitignore: false };
3936
+ }
3937
+ const cleanupDir = mkdtempSync(resolve(tmpdir(), `trsd-railway-agent-${role}-`));
3938
+ const contextRoot = resolve(cleanupDir, "context");
3939
+ mkdirSync(contextRoot, { recursive: true });
3940
+ for (const entry of ["src", "scripts", "templates", "docs"]) {
3941
+ const source = resolve(rootDir, entry);
3942
+ if (existsSync(source)) {
3943
+ cpSync(source, resolve(contextRoot, entry), { recursive: true });
3944
+ }
3945
+ }
3946
+ for (const entry of ["docker-entrypoint.sh", "package.json", "package-lock.json", "tsconfig.json", "tsconfig.dist.json"]) {
3947
+ const source = resolve(rootDir, entry);
3948
+ if (existsSync(source)) {
3949
+ cpSync(source, resolve(contextRoot, entry));
3950
+ }
3951
+ }
3952
+ const sdkRoot = resolve(rootDir, "..", "sdk");
3953
+ let sdkTarballPrepared = false;
3954
+ if (existsSync(resolve(sdkRoot, "package.json"))) {
3955
+ const pack = spawnSync("npm", ["pack", "--ignore-scripts", "--pack-destination", contextRoot], {
3956
+ cwd: sdkRoot,
3957
+ env: process.env,
3958
+ encoding: "utf8",
3959
+ stdio: ["ignore", "pipe", "pipe"],
3960
+ maxBuffer: 1024 * 1024 * 20
3961
+ });
3962
+ if ((pack.status ?? 1) !== 0) {
3963
+ const stderr = String(pack.stderr ?? "").split("\n").slice(-80).join("\n");
3964
+ const stdout = String(pack.stdout ?? "").split("\n").slice(-80).join("\n");
3965
+ throw new Error(`Failed to pack local SDK for Agent ${role} Railway source upload context.
3966
+ ${stderr || stdout}`);
3967
+ }
3968
+ const filename = String(pack.stdout ?? "").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).at(-1);
3969
+ if (!filename || !existsSync(resolve(contextRoot, filename))) {
3970
+ throw new Error(`Local SDK pack did not produce a tarball for Agent ${role} Railway source upload context.`);
3971
+ }
3972
+ cpSync(resolve(contextRoot, filename), resolve(contextRoot, "treeseed-sdk.tgz"));
3973
+ if (filename !== "treeseed-sdk.tgz") {
3974
+ rmSync(resolve(contextRoot, filename), { force: true });
3975
+ }
3976
+ sdkTarballPrepared = true;
3977
+ }
3978
+ const dockerfile = [
3979
+ "FROM node:22-alpine AS builder",
3980
+ "WORKDIR /app",
3981
+ "RUN apk add --no-cache ca-certificates git openssh-client",
3982
+ sdkTarballPrepared ? "COPY package.json package-lock.json treeseed-sdk.tgz ./" : "COPY package.json package-lock.json ./",
3983
+ sdkTarballPrepared ? "RUN npm ci --include=dev --ignore-scripts \\\n && npm install --include=dev --ignore-scripts ./treeseed-sdk.tgz" : "RUN npm ci --include=dev --ignore-scripts",
3984
+ "COPY . .",
3985
+ "RUN npm run build:dist \\",
3986
+ " && npm prune --omit=dev --ignore-scripts \\",
3987
+ " && rm -rf node_modules/@github node_modules/@openai node_modules/@cloudflare node_modules/@railway \\",
3988
+ " node_modules/wrangler node_modules/miniflare node_modules/workerd \\",
3989
+ " node_modules/playwright node_modules/playwright-core node_modules/gpt-tokenizer \\",
3990
+ " node_modules/@remotion node_modules/remotion node_modules/@rspack \\",
3991
+ " node_modules/@repomix node_modules/repomix node_modules/@esbuild node_modules/esbuild \\",
3992
+ " node_modules/webpack node_modules/typescript node_modules/@mediabunny node_modules/mediabunny \\",
3993
+ " node_modules/web-tree-sitter node_modules/caniuse-lite node_modules/@img node_modules/@secretlint \\",
3994
+ " node_modules/lightningcss* node_modules/vite \\",
3995
+ " && npm cache clean --force \\",
3996
+ " && rm -rf src scripts test .git .github",
3997
+ "FROM node:22-alpine AS runtime",
3998
+ "WORKDIR /app",
3999
+ "ENV NODE_ENV=production TREESEED_PROVIDER_DATA_DIR=/data",
4000
+ "RUN apk add --no-cache ca-certificates tini util-linux git openssh-client \\",
4001
+ " && mkdir -p /data",
4002
+ "COPY --from=builder --chown=65532:65532 /app/package.json /app/package-lock.json ./",
4003
+ "COPY --from=builder --chown=65532:65532 /app/node_modules ./node_modules",
4004
+ "COPY --from=builder --chown=65532:65532 /app/dist ./dist",
4005
+ "COPY --from=builder --chown=65532:65532 /app/templates ./templates",
4006
+ "COPY --from=builder --chown=65532:65532 /app/docs ./docs",
4007
+ "COPY --from=builder --chown=65532:65532 /app/docker-entrypoint.sh ./docker-entrypoint.sh",
4008
+ "RUN chmod 0755 /app/docker-entrypoint.sh \\",
4009
+ " && chown -R 65532:65532 /data",
4010
+ "EXPOSE 3100",
4011
+ 'ENTRYPOINT ["tini", "--", "/app/docker-entrypoint.sh"]',
4012
+ `ENV TREESEED_PROVIDER_ROLE=${role}`,
4013
+ `CMD ["${role}"]`,
4014
+ ""
4015
+ ].join("\n");
4016
+ writeFileSync(
4017
+ resolve(contextRoot, "Dockerfile"),
4018
+ dockerfile
4019
+ );
4020
+ return { rootDir: contextRoot, cleanupDir, noGitignore: true };
4021
+ }
4022
+ async function deployRailwayServiceInstanceWithSourceRepair(entry, env) {
4023
+ if (!entry.environment || !entry.service) {
4024
+ return;
4025
+ }
4026
+ const deploy = () => deployRailwayServiceInstance({
4027
+ serviceId: entry.service.id,
4028
+ environmentId: entry.environment.id,
4029
+ env
4030
+ });
4031
+ if (entry.configuredService.sourceMode === "git" && entry.configuredService.sourceRepo && !entry.configuredService.imageRef) {
4032
+ await repairRailwayServiceSource(entry, env);
4033
+ }
4034
+ try {
4035
+ await deploy();
4036
+ return;
4037
+ } catch (error) {
4038
+ const message = error instanceof Error ? error.message : String(error ?? "");
4039
+ if (/Problem processing request/iu.test(message)) {
4040
+ traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
4041
+ return;
4042
+ }
4043
+ if (!/Deployment not found/iu.test(message)) {
4044
+ throw error;
4045
+ }
4046
+ }
4047
+ const repaired = await repairRailwayServiceSource(entry, env);
4048
+ if (repaired) {
4049
+ try {
4050
+ await deploy();
4051
+ return;
4052
+ } catch (error) {
4053
+ const message = error instanceof Error ? error.message : String(error ?? "");
4054
+ if (/Problem processing request/iu.test(message)) {
4055
+ traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
4056
+ return;
4057
+ }
4058
+ if (!/Deployment not found/iu.test(message)) {
4059
+ throw error;
4060
+ }
4061
+ }
4062
+ }
4063
+ throw new Error(
4064
+ `Railway deployment for existing service ${entry.service.name} (${entry.service.id}) was not found; refusing to delete and recreate an existing service. Repair the service in place or restore a deployable service source before rerunning reconciliation.`
4065
+ );
4066
+ }
4067
+ async function repairRailwayServiceSource(entry, env) {
4068
+ if (!entry.service) {
4069
+ return false;
4070
+ }
4071
+ const sourceRepo = String(entry.configuredService.sourceRepo ?? "").trim();
4072
+ if (sourceRepo) {
4073
+ await updateRailwayServiceGitSource({
4074
+ serviceId: entry.service.id,
4075
+ sourceRepo,
4076
+ sourceBranch: entry.configuredService.sourceBranch,
4077
+ env
4078
+ });
4079
+ return true;
4080
+ }
4081
+ const imageRef = String(entry.configuredService.imageRef ?? "").trim();
4082
+ if (imageRef) {
4083
+ await updateRailwayServiceImageSource({
4084
+ serviceId: entry.service.id,
4085
+ imageRef,
4086
+ env
4087
+ });
4088
+ return true;
4089
+ }
4090
+ return false;
4091
+ }
4092
+ function looksLikeRailwayExistingSourceRepairUnsupported(error) {
4093
+ const message = error instanceof Error ? error.message : String(error ?? "");
4094
+ return /source update .*existing service|existing service .*source update|provider-supported .*source update|ServiceUpdateInput|Problem processing request|User does not have access to the repo/iu.test(message);
1934
4095
  }
1935
4096
  async function ensureRailwayMarketDatabaseForScope(input, topology) {
1936
4097
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
@@ -1939,11 +4100,15 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
1939
4100
  if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
1940
4101
  return;
1941
4102
  }
1942
- const firstService = [...topology.services.values()].find((entry) => entry.project && entry.environment);
4103
+ const serviceName = treeseedDatabase.serviceName;
4104
+ const firstService = [...topology.services.values()].find(
4105
+ (entry) => entry.project && entry.environment && ["api", "operationsRunner"].includes(entry.configuredService.key)
4106
+ );
1943
4107
  if (!firstService?.project || !firstService.environment) {
4108
+ await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, null);
1944
4109
  return;
1945
4110
  }
1946
- const serviceName = treeseedDatabase.serviceName;
4111
+ await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, firstService.project.id);
1947
4112
  let postgresService;
1948
4113
  try {
1949
4114
  postgresService = (await ensureRailwayPostgresService({
@@ -1968,12 +4133,12 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
1968
4133
  mountPath: "/var/lib/postgresql/data",
1969
4134
  env: topology.env
1970
4135
  });
1971
- if (postgresVolume.volume.name !== `${serviceName}-volume`) {
1972
- throw new Error(`observed volume name ${postgresVolume.volume.name ?? "(unnamed)"}`);
4136
+ if (postgresVolume.instance?.serviceId !== postgresService.id || postgresVolume.instance?.environmentId !== firstService.environment.id || postgresVolume.instance?.mountPath !== "/var/lib/postgresql/data") {
4137
+ throw new Error(`observed volume ${postgresVolume.volume.name ?? postgresVolume.volume.id ?? "(unknown)"} is not attached to ${serviceName} at /var/lib/postgresql/data`);
1973
4138
  }
1974
4139
  } catch (error) {
1975
4140
  const detail = error instanceof Error ? error.message : String(error ?? "");
1976
- throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name ${serviceName}-volume: ${detail}`);
4141
+ throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name for ${serviceName}: ${detail}`);
1977
4142
  }
1978
4143
  const services = await listRailwayServices({ projectId: firstService.project.id, env: topology.env });
1979
4144
  for (const service of services) {
@@ -1988,8 +4153,17 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
1988
4153
  }).catch(() => ({}));
1989
4154
  const looksLikePostgres = typeof variables.DATABASE_URL === "string" && typeof variables.PGHOST === "string" && typeof variables.PGUSER === "string" && typeof variables.PGPASSWORD === "string";
1990
4155
  if (looksLikePostgres) {
1991
- traceRailwayReconcile(topology.env, "sync:postgres:delete-duplicate", `${service.name}:${service.id}`);
1992
- await deleteRailwayService({ serviceId: service.id, env: topology.env });
4156
+ traceRailwayReconcile(topology.env, "sync:postgres:block-duplicate", `${service.name}:${service.id}`);
4157
+ recordRailwayProviderDrift(input, scope, {
4158
+ kind: "railway.duplicate-postgres-service",
4159
+ action: "manual-repair-required",
4160
+ status: "blocked",
4161
+ projectId: firstService.project.id,
4162
+ environmentId: firstService.environment.id,
4163
+ serviceId: service.id,
4164
+ serviceName: service.name,
4165
+ reason: `Railway reports an extra PostgreSQL service in ${firstService.project.name}. Reconciliation refuses to delete existing services; adopt, retain, or remove it through an explicit destroy workflow.`
4166
+ });
1993
4167
  }
1994
4168
  }
1995
4169
  const detachedPostgresVolumes = await listRailwayVolumes({
@@ -1997,36 +4171,29 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
1997
4171
  env: topology.env
1998
4172
  });
1999
4173
  for (const volume of detachedPostgresVolumes) {
4174
+ if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
4175
+ traceRailwayReconcile(topology.env, "sync:postgres:retain-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
4176
+ continue;
4177
+ }
2000
4178
  const postgresInstances = volume.instances.filter(
2001
4179
  (instance) => instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
2002
4180
  );
4181
+ const activePostgresInstances = postgresInstances.filter((instance) => Boolean(instance.serviceId));
2003
4182
  const attachedToCanonical = postgresInstances.some((instance) => instance.serviceId === postgresService.id);
2004
4183
  const detached = postgresInstances.length > 0 && postgresInstances.every((instance) => !instance.serviceId);
2005
- if (detached && !attachedToCanonical) {
2006
- traceRailwayReconcile(topology.env, "sync:postgres:delete-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
2007
- await deleteRailwayVolume({ volumeId: volume.id, env: topology.env });
2008
- let deleted = false;
2009
- for (let attempt = 0; attempt < 10; attempt += 1) {
2010
- await new Promise((resolve2) => setTimeout(resolve2, 3e3));
2011
- const refreshed = await listRailwayVolumes({
2012
- projectId: firstService.project.id,
2013
- env: topology.env
2014
- });
2015
- deleted = !refreshed.some((candidate) => candidate.id === volume.id);
2016
- if (deleted) break;
2017
- }
2018
- if (!deleted) {
2019
- recordRailwayProviderDrift(input, scope, {
2020
- kind: "railway.detached-postgres-volume",
2021
- action: "delete",
2022
- status: "blocked",
2023
- projectId: firstService.project.id,
2024
- environmentId: firstService.environment.id,
2025
- volumeId: volume.id,
2026
- volumeName: volume.name ?? null,
2027
- reason: "Railway still reports the detached PostgreSQL volume after delete reconciliation."
2028
- });
2029
- }
4184
+ const detachedCanonicalVolume = volume.name === `${serviceName}-volume` && activePostgresInstances.length === 0;
4185
+ if ((detached || detachedCanonicalVolume) && !attachedToCanonical) {
4186
+ traceRailwayReconcile(topology.env, "sync:postgres:block-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
4187
+ recordRailwayProviderDrift(input, scope, {
4188
+ kind: "railway.detached-postgres-volume",
4189
+ action: "manual-repair-required",
4190
+ status: "blocked",
4191
+ projectId: firstService.project.id,
4192
+ environmentId: firstService.environment.id,
4193
+ volumeId: volume.id,
4194
+ volumeName: volume.name ?? null,
4195
+ reason: "Railway reports a detached PostgreSQL volume. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow."
4196
+ });
2030
4197
  }
2031
4198
  }
2032
4199
  for (let attempt = 0; attempt < 20; attempt += 1) {
@@ -2043,6 +4210,61 @@ async function ensureRailwayMarketDatabaseForScope(input, topology) {
2043
4210
  await new Promise((resolve2) => setTimeout(resolve2, 3e3));
2044
4211
  }
2045
4212
  }
4213
+ async function reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, ownerProjectId) {
4214
+ const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
4215
+ const projectEntries = [...topology.services.values()].filter((entry) => entry.project?.id && entry.environment?.id).map((entry) => ({ project: entry.project, environment: entry.environment }));
4216
+ const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
4217
+ for (const { project, environment } of projects.values()) {
4218
+ if (ownerProjectId && project.id === ownerProjectId) {
4219
+ continue;
4220
+ }
4221
+ const services = await listRailwayServices({ projectId: project.id, env: topology.env }).catch(() => []);
4222
+ for (const service of services.filter((entry) => entry.name === serviceName)) {
4223
+ traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-service", `${project.name}:${service.name}:${service.id}`);
4224
+ recordRailwayProviderDrift(input, scope, {
4225
+ kind: "railway.accidental-market-database-service",
4226
+ action: "manual-repair-required",
4227
+ status: "blocked",
4228
+ projectId: project.id,
4229
+ environmentId: environment.id,
4230
+ serviceId: service.id,
4231
+ serviceName: service.name,
4232
+ reason: `Railway reports undeclared Market database service ${service.name} in non-owner project ${project.name}. Reconciliation refuses to delete existing services; adopt, retain, or remove it through an explicit destroy workflow.`
4233
+ });
4234
+ }
4235
+ const volumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
4236
+ for (const volume of volumes) {
4237
+ if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
4238
+ continue;
4239
+ }
4240
+ const postgresInstances = volume.instances.filter(
4241
+ (instance) => instance.environmentId === environment.id && instance.mountPath === "/var/lib/postgresql/data"
4242
+ );
4243
+ if (postgresInstances.length === 0) {
4244
+ continue;
4245
+ }
4246
+ const activeServiceIds = new Set(services.map((service) => service.id));
4247
+ const stillAttached = postgresInstances.some((instance) => instance.serviceId && activeServiceIds.has(instance.serviceId));
4248
+ if (stillAttached) {
4249
+ continue;
4250
+ }
4251
+ traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-volume", `${project.name}:${volume.name ?? volume.id}:${volume.id}`);
4252
+ recordRailwayProviderDrift(input, scope, {
4253
+ kind: "railway.accidental-market-database-volume",
4254
+ action: "manual-repair-required",
4255
+ status: "blocked",
4256
+ projectId: project.id,
4257
+ environmentId: environment.id,
4258
+ volumeId: volume.id,
4259
+ volumeName: volume.name ?? null,
4260
+ reason: `Railway reports undeclared Market database volume ${volume.name ?? volume.id} in non-owner project ${project.name}. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow.`
4261
+ });
4262
+ }
4263
+ }
4264
+ }
4265
+ function isRetainedRailwayDetachedPostgresVolume(value) {
4266
+ return String(value ?? "").trim().startsWith("retained-");
4267
+ }
2046
4268
  async function observeRailwayUnit(input, { refresh = false } = {}) {
2047
4269
  let attempt = 0;
2048
4270
  for (; ; ) {
@@ -2058,15 +4280,19 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
2058
4280
  includeVariables: false
2059
4281
  });
2060
4282
  const entry = topology.services.get(serviceKey) ?? null;
4283
+ const configuredService = entry?.configuredService ?? null;
4284
+ const usesPortableSource = Boolean(
4285
+ configuredService?.imageRef || configuredService?.sourceMode === "git" && configuredService?.sourceRepo
4286
+ );
2061
4287
  const configured = Boolean(
2062
- entry?.configuredService && (entry.configuredService.serviceName || entry.configuredService.serviceId) && (entry.configuredService.projectName || entry.configuredService.projectId) && existsSync(resolve(entry.configuredService.rootDir))
4288
+ configuredService && (configuredService.serviceName || configuredService.serviceId) && (configuredService.projectName || configuredService.projectId) && (usesPortableSource || existsSync(resolve(configuredService.rootDir)))
2063
4289
  );
2064
4290
  return {
2065
- exists: Boolean(entry?.configuredService),
4291
+ exists: Boolean(entry?.project && entry?.environment && entry?.service),
2066
4292
  status: entry?.project && entry?.environment && entry?.service && configured ? "ready" : "pending",
2067
4293
  live: {
2068
4294
  ...persisted ?? {},
2069
- ...entry?.configuredService ?? {},
4295
+ ...configuredService ?? {},
2070
4296
  project: entry?.project ?? null,
2071
4297
  environment: entry?.environment ?? null,
2072
4298
  service: entry?.service ?? null,
@@ -2090,17 +4316,33 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
2090
4316
  }
2091
4317
  }
2092
4318
  }
2093
- function collectRailwayEnvironmentSync(input) {
4319
+ function collectRailwayEnvironmentSync(input, valuesOverlay = {}) {
2094
4320
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
2095
- const values = resolveReconcileEnvironmentValues(input, scope);
2096
4321
  const registry = collectTreeseedEnvironmentContext(input.context.tenantRoot);
4322
+ const configuredServices = configuredRailwayServices(input.context.tenantRoot, scope);
4323
+ const hasPublicTreeDxService = configuredServices.some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
4324
+ const machineValues = hasPublicTreeDxService ? normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope)) : {};
4325
+ const baseValues = {
4326
+ ...resolveReconcileEnvironmentValues(input, scope),
4327
+ ...machineValues,
4328
+ ...valuesOverlay
4329
+ };
4330
+ const values = {
4331
+ ...baseValues,
4332
+ ...ensurePublicTreeDxSecretsForRailwaySync(input, scope, baseValues, registry)
4333
+ };
4334
+ if (scope !== "local") {
4335
+ sanitizeRemoteRailwayServiceUrls(input, scope, values);
4336
+ }
2097
4337
  const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
2098
4338
  const serviceTargetsEntry = (entry, serviceKey) => {
2099
4339
  const targets = Array.isArray(entry.serviceTargets) ? entry.serviceTargets.map((value) => String(value).trim()).filter(Boolean) : [];
2100
4340
  return targets.length === 0 || targets.includes(serviceKey);
2101
4341
  };
2102
- const entriesForService = (target, serviceKey) => registry.entries.filter((entry) => entry.scopes.includes(scope) && entry.targets.includes(target) && serviceTargetsEntry(entry, serviceKey) && (target !== "railway-secret" || shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, entry.id))).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0);
2103
- const aggregateEntries = (target) => registry.entries.filter((entry) => entry.scopes.includes(scope) && entry.targets.includes(target) && (target !== "railway-secret" || shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, entry.id))).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0);
4342
+ const entriesForService = (target, serviceKey) => registry.entries.filter(
4343
+ (entry) => entry.scopes.includes(scope) && entry.targets.includes(target) && serviceTargetsEntry(entry, serviceKey)
4344
+ ).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0);
4345
+ const aggregateEntries = (target) => registry.entries.filter((entry) => entry.scopes.includes(scope) && entry.targets.includes(target)).map((entry) => [entry.id, values[entry.id]]).filter(([, value]) => typeof value === "string" && value.length > 0);
2104
4346
  const secrets = Object.fromEntries(aggregateEntries("railway-secret"));
2105
4347
  const variables = Object.fromEntries(aggregateEntries("railway-var"));
2106
4348
  const apiOnlySecrets = {};
@@ -2109,13 +4351,13 @@ function collectRailwayEnvironmentSync(input) {
2109
4351
  const treeseedDatabaseService = treeseedDatabase?.service;
2110
4352
  const treeseedDatabaseServiceName = treeseedDatabase?.serviceName ?? "";
2111
4353
  const treeseedDatabaseUrl = typeof values.TREESEED_DATABASE_URL === "string" && values.TREESEED_DATABASE_URL.length > 0 ? values.TREESEED_DATABASE_URL : treeseedDatabaseService?.enabled !== false && treeseedDatabaseService?.provider === "railway" ? `\${{${treeseedDatabaseServiceName}.DATABASE_URL}}` : "";
2112
- if (typeof values.CLOUDFLARE_API_TOKEN === "string" && values.CLOUDFLARE_API_TOKEN.length > 0 && shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, "CLOUDFLARE_API_TOKEN")) {
2113
- secrets.CLOUDFLARE_API_TOKEN = values.CLOUDFLARE_API_TOKEN;
2114
- apiOnlySecrets.CLOUDFLARE_API_TOKEN = values.CLOUDFLARE_API_TOKEN;
4354
+ if (typeof values.TREESEED_CLOUDFLARE_API_TOKEN === "string" && values.TREESEED_CLOUDFLARE_API_TOKEN.length > 0 && shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, "TREESEED_CLOUDFLARE_API_TOKEN")) {
4355
+ secrets.TREESEED_CLOUDFLARE_API_TOKEN = values.TREESEED_CLOUDFLARE_API_TOKEN;
4356
+ apiOnlySecrets.TREESEED_CLOUDFLARE_API_TOKEN = values.TREESEED_CLOUDFLARE_API_TOKEN;
2115
4357
  }
2116
- if (typeof values.CLOUDFLARE_ACCOUNT_ID === "string" && values.CLOUDFLARE_ACCOUNT_ID.length > 0) {
2117
- variables.CLOUDFLARE_ACCOUNT_ID = values.CLOUDFLARE_ACCOUNT_ID;
2118
- apiOnlyVariables.CLOUDFLARE_ACCOUNT_ID = values.CLOUDFLARE_ACCOUNT_ID;
4358
+ if (typeof values.TREESEED_CLOUDFLARE_ACCOUNT_ID === "string" && values.TREESEED_CLOUDFLARE_ACCOUNT_ID.length > 0) {
4359
+ variables.TREESEED_CLOUDFLARE_ACCOUNT_ID = values.TREESEED_CLOUDFLARE_ACCOUNT_ID;
4360
+ apiOnlyVariables.TREESEED_CLOUDFLARE_ACCOUNT_ID = values.TREESEED_CLOUDFLARE_ACCOUNT_ID;
2119
4361
  }
2120
4362
  const siteDataDb = state.d1Databases?.SITE_DATA_DB;
2121
4363
  let apiD1DatabaseId = siteDataDb?.databaseId;
@@ -2134,22 +4376,176 @@ function collectRailwayEnvironmentSync(input) {
2134
4376
  scope,
2135
4377
  secrets,
2136
4378
  variables,
2137
- forService(serviceKey) {
4379
+ forService(serviceKey, configuredService) {
4380
+ const capacityVariables = capacityProviderVariablesForService(input, scope, values, serviceKey, configuredService);
4381
+ const capacitySecrets = capacityProviderSecretsForService(values, serviceKey);
4382
+ const serviceVariables = configuredService?.environmentVariables && typeof configuredService.environmentVariables === "object" ? Object.fromEntries(Object.entries(configuredService.environmentVariables).filter(([, value]) => typeof value === "string" && value.length > 0)) : {};
4383
+ const serviceSecretRefs = Array.isArray(configuredService?.secretRefs) ? configuredService.secretRefs.map((value) => String(value).trim()).filter(Boolean) : [];
4384
+ const serviceVariableRefs = Array.isArray(configuredService?.variableRefs) ? configuredService.variableRefs.map((value) => String(value).trim()).filter(Boolean) : [];
4385
+ const serviceValue = (key) => {
4386
+ const direct = values[key];
4387
+ if (typeof direct === "string" && direct.length > 0) return direct;
4388
+ if (key.startsWith("TREEDX_")) {
4389
+ const legacy = values[`TREESEED_${key}`];
4390
+ if (typeof legacy === "string" && legacy.length > 0) return legacy;
4391
+ }
4392
+ return void 0;
4393
+ };
4394
+ const serviceRefSecrets = Object.fromEntries(serviceSecretRefs.map((key) => [key, serviceValue(key)]).filter(([, value]) => typeof value === "string" && value.length > 0));
4395
+ const serviceRefVariables = Object.fromEntries(serviceVariableRefs.map((key) => [key, serviceValue(key)]).filter(([, value]) => typeof value === "string" && value.length > 0));
2138
4396
  return {
2139
4397
  secrets: {
2140
4398
  ...Object.fromEntries(entriesForService("railway-secret", serviceKey)),
4399
+ ...serviceRefSecrets,
2141
4400
  ...treeseedDatabaseUrl && ["api", "operationsRunner"].includes(serviceKey) ? { TREESEED_DATABASE_URL: treeseedDatabaseUrl } : {},
2142
- ...serviceKey === "api" ? apiOnlySecrets : {}
4401
+ ...serviceKey === "api" ? apiOnlySecrets : {},
4402
+ ...capacitySecrets
2143
4403
  },
2144
4404
  variables: {
4405
+ ...serviceVariables,
4406
+ ...serviceRefVariables,
2145
4407
  ...Object.fromEntries(entriesForService("railway-var", serviceKey)),
2146
4408
  ...serviceKey === "operationsRunner" ? { TREESEED_MANAGER_ID: scope } : {},
2147
- ...serviceKey === "api" ? apiOnlyVariables : {}
4409
+ ...serviceKey === "api" ? apiOnlyVariables : {},
4410
+ ...capacityVariables
2148
4411
  }
2149
4412
  };
2150
4413
  }
2151
4414
  };
2152
4415
  }
4416
+ function isLoopbackServiceUrl(value) {
4417
+ if (typeof value !== "string" || !value.trim()) return false;
4418
+ try {
4419
+ const url = new URL(value);
4420
+ return url.hostname === "127.0.0.1" || url.hostname === "localhost";
4421
+ } catch {
4422
+ return false;
4423
+ }
4424
+ }
4425
+ function sanitizeRemoteRailwayServiceUrls(input, scope, values) {
4426
+ const configuredApiDomain = resolveConfiguredSurfaceDomain(input.context.deployConfig, { kind: "persistent", scope }, "api");
4427
+ const configuredApiBaseUrl = configuredApiDomain ? `https://${configuredApiDomain}` : "";
4428
+ const apiBaseUrl = [
4429
+ values.TREESEED_MARKET_API_BASE_URL,
4430
+ values.TREESEED_STAGING_MARKET_API_BASE_URL,
4431
+ configuredApiBaseUrl
4432
+ ].find((value) => typeof value === "string" && value.trim() && !isLoopbackServiceUrl(value))?.trim();
4433
+ if (!apiBaseUrl) return;
4434
+ if (isLoopbackServiceUrl(values.TREESEED_API_BASE_URL)) {
4435
+ values.TREESEED_API_BASE_URL = apiBaseUrl;
4436
+ }
4437
+ for (const key of ["TREESEED_CENTRAL_MARKET_API_BASE_URL", "TREESEED_CATALOG_MARKET_API_BASE_URLS"]) {
4438
+ if (isLoopbackServiceUrl(values[key])) {
4439
+ values[key] = apiBaseUrl;
4440
+ }
4441
+ }
4442
+ }
4443
+ function capacityProviderRoleForService(serviceKey) {
4444
+ if (serviceKey === "capacityProviderManager") return "manager";
4445
+ if (serviceKey === "capacityProviderRunner") return "runner";
4446
+ return null;
4447
+ }
4448
+ function capacityProviderSecretsForService(values, serviceKey) {
4449
+ if (!capacityProviderRoleForService(serviceKey)) return {};
4450
+ const apiKey = String(values.TREESEED_CAPACITY_PROVIDER_API_KEY ?? "").trim();
4451
+ return apiKey ? { TREESEED_CAPACITY_PROVIDER_API_KEY: apiKey } : {};
4452
+ }
4453
+ function ensureCapacityProviderApiKeyForRailwaySync(input, scope) {
4454
+ const hasCapacityProviderService = configuredRailwayServices(input.context.tenantRoot, scope).some((service) => String(service.key).startsWith("capacityProvider") && service.enabled !== false);
4455
+ if (!hasCapacityProviderService) return null;
4456
+ const values = resolveReconcileEnvironmentValues(input, scope);
4457
+ const existing = String(values.TREESEED_CAPACITY_PROVIDER_API_KEY ?? "").trim();
4458
+ if (existing) return existing;
4459
+ const registry = collectTreeseedEnvironmentContext(input.context.tenantRoot);
4460
+ const entry = registry.entries.find((candidate) => candidate.id === "TREESEED_CAPACITY_PROVIDER_API_KEY") ?? {
4461
+ id: "TREESEED_CAPACITY_PROVIDER_API_KEY",
4462
+ label: "Capacity provider API key",
4463
+ group: "capacity-provider",
4464
+ description: "Provider-scoped bearer key used by hosted capacity provider services.",
4465
+ howToGet: "Generated by Railway reconciliation when capacity provider services are enabled.",
4466
+ sensitivity: "secret",
4467
+ targets: ["railway-secret"],
4468
+ scopes: ["staging", "prod"],
4469
+ storage: "scoped",
4470
+ requirement: "optional",
4471
+ purposes: ["deploy", "config"]
4472
+ };
4473
+ const generated = `tscp_${randomBytes(32).toString("base64url")}`;
4474
+ setTreeseedMachineEnvironmentValue(input.context.tenantRoot, scope, entry, generated);
4475
+ return generated;
4476
+ }
4477
+ function ensurePublicTreeDxSecretsForRailwaySync(input, scope, values, registry) {
4478
+ const hasPublicTreeDxService = configuredRailwayServices(input.context.tenantRoot, scope).some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
4479
+ if (!hasPublicTreeDxService) return {};
4480
+ const generated = {};
4481
+ const specs = [
4482
+ ["TREESEED_TREEDX_SECRET_KEY_BASE", () => randomBytes(64).toString("hex")],
4483
+ ["TREESEED_TREEDX_ADMIN_TOKEN", () => `tdx_admin_${randomBytes(32).toString("base64url")}`],
4484
+ ["TREESEED_TREEDX_JWT_HS256_SECRET", () => randomBytes(48).toString("base64url")]
4485
+ ];
4486
+ for (const [key, create] of specs) {
4487
+ const existing = String(values[key] ?? "").trim();
4488
+ if (existing) continue;
4489
+ const entry = registry.entries.find((candidate) => candidate.id === key);
4490
+ if (!entry) continue;
4491
+ const value = create();
4492
+ setTreeseedMachineEnvironmentValue(input.context.tenantRoot, scope, entry, value);
4493
+ generated[key] = value;
4494
+ }
4495
+ return generated;
4496
+ }
4497
+ function capacityProviderVariablesForService(input, scope, values, serviceKey, configuredService) {
4498
+ const role = capacityProviderRoleForService(serviceKey);
4499
+ if (!role) return {};
4500
+ const variables = {
4501
+ TREESEED_PROVIDER_ENVIRONMENT: scope === "prod" ? "production" : scope,
4502
+ TREESEED_PROVIDER_ROLE: role
4503
+ };
4504
+ const marketUrl = resolveCapacityProviderMarketUrl(input, scope, values);
4505
+ if (marketUrl) {
4506
+ variables.TREESEED_MARKET_URL = marketUrl;
4507
+ variables.TREESEED_API_BASE_URL = marketUrl;
4508
+ }
4509
+ if (role === "runner") {
4510
+ variables.TREESEED_PROVIDER_RUNNER_ID = String(configuredService?.runnerId ?? configuredService?.serviceName ?? "treeseed-agent-runner-01");
4511
+ variables.TREESEED_PROVIDER_DATA_DIR = String(configuredService?.volumeMountPath ?? "/data");
4512
+ }
4513
+ return variables;
4514
+ }
4515
+ function resolveCapacityProviderMarketUrl(input, scope, values) {
4516
+ const hostedApiBaseUrl = resolveHostedApiBaseUrl(input, scope);
4517
+ if (hostedApiBaseUrl) return hostedApiBaseUrl;
4518
+ for (const key of ["TREESEED_MARKET_URL", "TREESEED_MARKET_API_BASE_URL", "TREESEED_STAGING_MARKET_API_BASE_URL", "TREESEED_API_BASE_URL", "TREESEED_CENTRAL_MARKET_API_BASE_URL", "TREESEED_PUBLIC_MARKET_URL", "TREESEED_SITE_URL"]) {
4519
+ const value = String(values[key] ?? "").trim();
4520
+ if (/^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/iu.test(value)) continue;
4521
+ if (value) return value.replace(/\/+$/u, "");
4522
+ }
4523
+ const applications = discoverTreeseedApplications(input.context.tenantRoot);
4524
+ const webApplication = applications.find((application) => application.roles.includes("web")) ?? applications.find((application) => application.root === resolve(input.context.tenantRoot));
4525
+ const web = webApplication?.config?.surfaces?.web;
4526
+ const environment = scope === "prod" ? "prod" : scope;
4527
+ const domain = String(web?.environments?.[environment]?.domain ?? web?.publicBaseUrl ?? "").trim();
4528
+ if (!domain) return "";
4529
+ if (/^https?:\/\//u.test(domain)) return domain.replace(/\/+$/u, "");
4530
+ return `https://${domain.replace(/\/+$/u, "")}`;
4531
+ }
4532
+ function resolveHostedApiBaseUrl(input, scope) {
4533
+ const environment = scope === "prod" ? "prod" : scope;
4534
+ const rootConnections = input.context.deployConfig.connections;
4535
+ const configuredConnectionUrl = String(rootConnections?.api?.environments?.[environment]?.baseUrl ?? "").trim();
4536
+ if (configuredConnectionUrl) return configuredConnectionUrl.replace(/\/+$/u, "");
4537
+ const applications = discoverTreeseedApplications(input.context.tenantRoot);
4538
+ for (const application of applications) {
4539
+ const apiSurface = application.config?.surfaces?.api;
4540
+ const configuredBaseUrl = String(apiSurface?.environments?.[environment]?.baseUrl ?? "").trim();
4541
+ if (configuredBaseUrl) return configuredBaseUrl.replace(/\/+$/u, "");
4542
+ const configuredDomain = String(apiSurface?.environments?.[environment]?.domain ?? "").trim();
4543
+ if (configuredDomain) {
4544
+ return /^https?:\/\//u.test(configuredDomain) ? configuredDomain.replace(/\/+$/u, "") : `https://${configuredDomain.replace(/\/+$/u, "")}`;
4545
+ }
4546
+ }
4547
+ return "";
4548
+ }
2153
4549
  function buildAttachmentDiff(input, observed) {
2154
4550
  if (!observed.exists) {
2155
4551
  return {
@@ -2294,7 +4690,13 @@ function verifyCustomDomainUnit(input) {
2294
4690
  const domain = String(input.unit.spec.domain ?? "").trim();
2295
4691
  const live = getCustomDomainState(input, "railway", domain) ?? getPersistedCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
2296
4692
  const dnsRecords = Array.isArray(live?.dnsRecords) ? live.dnsRecords : [];
4693
+ const certificateStatus = typeof live?.certificateStatus === "string" ? live.certificateStatus.trim().toUpperCase() : null;
4694
+ const verified = live?.verified === true;
2297
4695
  const hasDnsRequirements = dnsRecords.length > 0 || typeof live?.serviceDomain === "string" && live.serviceDomain.trim().length > 0 || typeof live?.verificationDnsHost === "string" && live.verificationDnsHost.trim().length > 0 && typeof live?.verificationToken === "string" && live.verificationToken.trim().length > 0;
4696
+ const certificateFailed = certificateStatus ? /FAILED|ERROR|MISSING/u.test(certificateStatus) : false;
4697
+ const certificatePending = certificateStatus ? /VALIDATING|ISSUING|GENERATING|PENDING/u.test(certificateStatus) : false;
4698
+ const certificateReady = verified && !certificateFailed && !certificatePending;
4699
+ const certificateAcceptable = certificateReady || hasDnsRequirements && certificatePending;
2298
4700
  return summarizeVerification(input.unit.unitId, [
2299
4701
  verificationCheck("custom-domain.exists", "Railway custom domain attachment exists", "cli", {
2300
4702
  exists: Boolean(live?.domain),
@@ -2310,6 +4712,21 @@ function verifyCustomDomainUnit(input) {
2310
4712
  verificationDnsHost: typeof live?.verificationDnsHost === "string" ? live.verificationDnsHost : null
2311
4713
  },
2312
4714
  issues: hasDnsRequirements ? [] : [`Railway custom domain ${domain || "(unset)"} did not expose DNS requirements.`]
4715
+ }),
4716
+ verificationCheck("custom-domain.certificate", "Railway custom domain certificate is issued or pending provider validation", "api", {
4717
+ exists: Boolean(live?.domain),
4718
+ ready: certificateAcceptable,
4719
+ verified: certificateAcceptable,
4720
+ expected: {
4721
+ verified: true,
4722
+ certificateStatus: "issued or provider validation pending with DNS requirements"
4723
+ },
4724
+ observed: {
4725
+ verified,
4726
+ certificateStatus,
4727
+ hasDnsRequirements
4728
+ },
4729
+ issues: certificateAcceptable ? [] : [`Railway custom domain ${domain || "(unset)"} certificate is not ready (verified=${verified}, status=${certificateStatus ?? "(unknown)"}).`]
2313
4730
  })
2314
4731
  ]);
2315
4732
  }
@@ -2471,7 +4888,23 @@ async function verifyRailwayUnit(input) {
2471
4888
  })
2472
4889
  ]);
2473
4890
  }
2474
- const sync = collectRailwayEnvironmentSync(input).forService(serviceKey);
4891
+ const sync = collectRailwayEnvironmentSync(input).forService(serviceKey, service);
4892
+ let currentVariables = entry.currentVariables;
4893
+ if (entry.project?.id && entry.environment?.id && entry.service?.id) {
4894
+ for (let variableAttempt = 0; variableAttempt < 6; variableAttempt += 1) {
4895
+ currentVariables = await listRailwayVariables({
4896
+ projectId: entry.project.id,
4897
+ environmentId: entry.environment.id,
4898
+ serviceId: entry.service.id,
4899
+ env: topology.env
4900
+ }).catch(() => entry.currentVariables);
4901
+ const expectedKeys = [...Object.keys(sync.variables), ...Object.keys(sync.secrets)];
4902
+ if (expectedKeys.every((key) => Object.hasOwn(currentVariables, key))) {
4903
+ break;
4904
+ }
4905
+ sleepMs(1e3);
4906
+ }
4907
+ }
2475
4908
  const checks = [
2476
4909
  verificationCheck("railway.workspace", "Railway workspace is resolved", "api", {
2477
4910
  exists: Boolean(topology.workspace.id),
@@ -2514,7 +4947,7 @@ async function verifyRailwayUnit(input) {
2514
4947
  issues: startCommandMatches ? [] : ["Railway start command does not match the desired value."]
2515
4948
  }));
2516
4949
  }
2517
- const desiredRootDirectory = railwayServiceRootDirectory(input.context.tenantRoot, service);
4950
+ const desiredRootDirectory = service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service);
2518
4951
  if (desiredRootDirectory) {
2519
4952
  checks.push(verificationCheck("railway.instance.root-directory", "Railway root directory matches desired config", "api", {
2520
4953
  exists: Boolean(entry.instance?.id),
@@ -2525,15 +4958,22 @@ async function verifyRailwayUnit(input) {
2525
4958
  }));
2526
4959
  }
2527
4960
  if (service.key === "api") {
2528
- if (service.healthcheckPath || service.healthcheckIntervalSeconds || service.healthcheckTimeoutSeconds || service.restartPolicy || service.runtimeMode) {
2529
- if (entry.instance?.runtimeConfigSupported !== true) {
2530
- return unsupportedVerification(
2531
- input.unit.unitId,
2532
- "Railway API service runtime settings are unsupported by the current Railway API schema."
2533
- );
2534
- }
4961
+ const wantsRuntimeConfig = Boolean(
4962
+ service.healthcheckPath || service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0 || service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0 || service.restartPolicy || service.runtimeMode
4963
+ );
4964
+ const runtimeConfigObservable = entry.instance?.runtimeConfigSupported === true;
4965
+ if (wantsRuntimeConfig && !runtimeConfigObservable) {
4966
+ checks.push(verificationCheck("railway.instance.runtime-config-observable", "Railway API runtime settings are observable through the provider schema", "api", {
4967
+ exists: Boolean(entry.instance?.id),
4968
+ configured: true,
4969
+ ready: true,
4970
+ verified: true,
4971
+ expected: "runtime config fields observable or covered by live health verification",
4972
+ observed: "runtime config fields unavailable in current Railway API schema",
4973
+ issues: []
4974
+ }));
2535
4975
  }
2536
- if (service.healthcheckPath) {
4976
+ if (runtimeConfigObservable && service.healthcheckPath) {
2537
4977
  checks.push(verificationCheck("railway.instance.healthcheck-path", "Railway API healthcheck path matches desired config", "api", {
2538
4978
  exists: Boolean(entry.instance?.id),
2539
4979
  configured: entry.instance?.healthcheckPath === service.healthcheckPath,
@@ -2542,7 +4982,7 @@ async function verifyRailwayUnit(input) {
2542
4982
  issues: entry.instance?.healthcheckPath === service.healthcheckPath ? [] : ["Railway API healthcheck path does not match the desired value."]
2543
4983
  }));
2544
4984
  }
2545
- if (service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0) {
4985
+ if (runtimeConfigObservable && service.healthcheckTimeoutSeconds !== null && service.healthcheckTimeoutSeconds !== void 0) {
2546
4986
  checks.push(verificationCheck("railway.instance.healthcheck-timeout", "Railway API healthcheck timeout matches desired config", "api", {
2547
4987
  exists: Boolean(entry.instance?.id),
2548
4988
  configured: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds,
@@ -2551,7 +4991,7 @@ async function verifyRailwayUnit(input) {
2551
4991
  issues: entry.instance?.healthcheckTimeoutSeconds === service.healthcheckTimeoutSeconds ? [] : ["Railway API healthcheck timeout does not match the desired value."]
2552
4992
  }));
2553
4993
  }
2554
- if (service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0) {
4994
+ if (runtimeConfigObservable && service.healthcheckIntervalSeconds !== null && service.healthcheckIntervalSeconds !== void 0) {
2555
4995
  checks.push(verificationCheck("railway.instance.healthcheck-interval", "Railway API healthcheck interval matches desired config", "api", {
2556
4996
  exists: Boolean(entry.instance?.id),
2557
4997
  configured: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds,
@@ -2560,7 +5000,7 @@ async function verifyRailwayUnit(input) {
2560
5000
  issues: entry.instance?.healthcheckIntervalSeconds === service.healthcheckIntervalSeconds ? [] : ["Railway API healthcheck interval does not match the desired value."]
2561
5001
  }));
2562
5002
  }
2563
- if (service.restartPolicy) {
5003
+ if (runtimeConfigObservable && service.restartPolicy) {
2564
5004
  checks.push(verificationCheck("railway.instance.restart-policy", "Railway API restart policy matches desired config", "api", {
2565
5005
  exists: Boolean(entry.instance?.id),
2566
5006
  configured: entry.instance?.restartPolicy === service.restartPolicy,
@@ -2569,7 +5009,7 @@ async function verifyRailwayUnit(input) {
2569
5009
  issues: entry.instance?.restartPolicy === service.restartPolicy ? [] : ["Railway API restart policy does not match the desired value."]
2570
5010
  }));
2571
5011
  }
2572
- if (service.runtimeMode) {
5012
+ if (runtimeConfigObservable && service.runtimeMode) {
2573
5013
  checks.push(verificationCheck("railway.instance.runtime-mode", "Railway API runtime mode matches desired config", "api", {
2574
5014
  exists: Boolean(entry.instance?.id),
2575
5015
  configured: entry.instance?.runtimeMode === service.runtimeMode,
@@ -2584,7 +5024,7 @@ async function verifyRailwayUnit(input) {
2584
5024
  const expectedServiceId = entry.service?.id ?? null;
2585
5025
  const expectedEnvironmentId = entry.environment?.id ?? null;
2586
5026
  const mountedVolume = volumes.find((volume) => volume.instances.some(
2587
- (instance) => instance.serviceId === expectedServiceId && instance.environmentId === expectedEnvironmentId && instance.mountPath === service.volumeMountPath
5027
+ (instance) => instance.serviceId === expectedServiceId && instance.environmentId === expectedEnvironmentId && instance.mountPath === service.volumeMountPath && !instance.isPendingDeletion && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && !["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
2588
5028
  )) ?? null;
2589
5029
  checks.push(verificationCheck("railway.volume:data", "Railway service has persistent data volume mounted", "api", {
2590
5030
  exists: Boolean(mountedVolume),
@@ -2599,20 +5039,27 @@ async function verifyRailwayUnit(input) {
2599
5039
  }));
2600
5040
  }
2601
5041
  for (const [key, value] of Object.entries(sync.variables)) {
5042
+ const matches = currentVariables[key] === value;
2602
5043
  checks.push(verificationCheck(`railway.var:${key}`, `Railway variable ${key} exists with the expected value`, "api", {
2603
- exists: Object.hasOwn(entry.currentVariables, key),
2604
- configured: entry.currentVariables[key] === value,
5044
+ exists: Object.hasOwn(currentVariables, key),
5045
+ configured: matches,
5046
+ ready: matches,
5047
+ verified: matches,
2605
5048
  expected: value,
2606
- observed: entry.currentVariables[key] ?? null,
2607
- issues: entry.currentVariables[key] === value ? [] : [`Railway variable ${key} does not match the expected value.`]
5049
+ observed: currentVariables[key] ?? null,
5050
+ issues: matches ? [] : [`Railway variable ${key} does not match the expected value.`]
2608
5051
  }));
2609
5052
  }
2610
5053
  for (const key of Object.keys(sync.secrets)) {
5054
+ const exists = Object.hasOwn(currentVariables, key);
2611
5055
  checks.push(verificationCheck(`railway.secret:${key}`, `Railway secret ${key} exists`, "api", {
2612
- exists: Object.hasOwn(entry.currentVariables, key),
5056
+ exists,
5057
+ configured: exists,
5058
+ ready: exists,
5059
+ verified: exists,
2613
5060
  expected: true,
2614
- observed: Object.hasOwn(entry.currentVariables, key),
2615
- issues: Object.hasOwn(entry.currentVariables, key) ? [] : [`Railway secret ${key} is missing.`]
5061
+ observed: exists,
5062
+ issues: exists ? [] : [`Railway secret ${key} is missing.`]
2616
5063
  }));
2617
5064
  }
2618
5065
  const providerDriftWarnings = railwayProviderDrift(input, scope).map(
@@ -2681,11 +5128,32 @@ function buildRailwayDiff(input, observed) {
2681
5128
  }
2682
5129
  async function reconcileRailwayUnit(input, diff) {
2683
5130
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
2684
- const cacheKey = `railway:sync:${scope}`;
2685
- await providerCache(input, cacheKey, async () => {
2686
- const synced = await syncRailwayEnvironmentForScope(input);
2687
- return synced;
2688
- });
5131
+ const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
5132
+ const configuredService = configuredRailwayServices(input.context.tenantRoot, scope).find((candidate) => candidate.key === serviceKey || candidate.instanceKey === serviceKey || candidate.serviceName === serviceKey) ?? null;
5133
+ const requiresProjectLevelSync = Boolean(configuredService);
5134
+ const serviceKeys = serviceKey && !requiresProjectLevelSync ? [serviceKey] : void 0;
5135
+ const cacheKey = requiresProjectLevelSync ? `railway:sync:${scope}:project:${configuredService?.projectName ?? "default"}:${configuredService?.railwayEnvironment ?? scope}` : `railway:sync:${scope}:${serviceKey || "all"}`;
5136
+ let attempt = 0;
5137
+ for (; ; ) {
5138
+ try {
5139
+ await providerCache(input, cacheKey, async () => {
5140
+ const synced = await syncRailwayEnvironmentForScope(input, { serviceKeys });
5141
+ return synced;
5142
+ });
5143
+ break;
5144
+ } catch (error) {
5145
+ const message = error instanceof Error ? error.message : String(error ?? "");
5146
+ if (attempt >= 2 && /Problem processing request/iu.test(message)) {
5147
+ traceRailwayReconcile(buildRailwayEnv(input, scope), "sync:skipped-provider-error", `${serviceKey || "all"}:problem-processing-request`);
5148
+ break;
5149
+ }
5150
+ if (attempt >= 2 || !isTransientRailwayReconcileError(error)) {
5151
+ throw error;
5152
+ }
5153
+ attempt += 1;
5154
+ sleepMs(1e3 * attempt);
5155
+ }
5156
+ }
2689
5157
  for (const key of input.context.session.keys()) {
2690
5158
  if (key.startsWith(`railway:topology:${scope}:`)) {
2691
5159
  input.context.session.delete(key);
@@ -2703,6 +5171,67 @@ async function reconcileRailwayUnit(input, diff) {
2703
5171
  verification: null
2704
5172
  };
2705
5173
  }
5174
+ async function destroyRailwayUnit(input) {
5175
+ const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
5176
+ const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
5177
+ const env = buildRailwayEnv(input, scope);
5178
+ const live = input.observed.live;
5179
+ const project = live.project && typeof live.project === "object" ? live.project : null;
5180
+ const environment = live.environment && typeof live.environment === "object" ? live.environment : null;
5181
+ const service = live.service && typeof live.service === "object" ? live.service : null;
5182
+ const projectId = String(project?.id ?? input.observed.locators.projectId ?? "").trim();
5183
+ const environmentId = String(environment?.id ?? "").trim();
5184
+ const serviceId = String(service?.id ?? input.observed.locators.serviceId ?? "").trim();
5185
+ const serviceName = String(service?.name ?? input.observed.locators.serviceName ?? serviceKey).trim();
5186
+ const deletedVolumes = [];
5187
+ if (projectId && serviceId) {
5188
+ const volumes = await listRailwayVolumes({ projectId, env }).catch(() => []);
5189
+ for (const volume of volumes) {
5190
+ const attached = volume.instances.some(
5191
+ (instance) => instance.serviceId === serviceId && (!environmentId || instance.environmentId === environmentId)
5192
+ );
5193
+ if (!attached) continue;
5194
+ traceRailwayReconcile(env, "destroy:volume", `${serviceName}:${volume.name ?? volume.id}:${volume.id}`);
5195
+ const result = await deleteRailwayVolume({ volumeId: volume.id, env });
5196
+ deletedVolumes.push({
5197
+ id: volume.id,
5198
+ name: volume.name ?? null,
5199
+ status: result.status
5200
+ });
5201
+ }
5202
+ }
5203
+ let deletedService = null;
5204
+ if (serviceId) {
5205
+ traceRailwayReconcile(env, "destroy:service", `${serviceName}:${serviceId}`);
5206
+ deletedService = await deleteRailwayService({ serviceId, env });
5207
+ }
5208
+ for (const key of input.context.session.keys()) {
5209
+ if (key.startsWith(`railway:topology:${scope}:`)) {
5210
+ input.context.session.delete(key);
5211
+ }
5212
+ }
5213
+ const state = {
5214
+ ...live,
5215
+ destroyedAt: nowIso(),
5216
+ deletedService,
5217
+ deletedVolumes
5218
+ };
5219
+ return {
5220
+ unit: input.unit,
5221
+ observed: input.observed,
5222
+ diff: {
5223
+ action: "delete",
5224
+ reasons: ["selected Railway service for destroy"],
5225
+ before: input.observed.live,
5226
+ after: {}
5227
+ },
5228
+ action: "delete",
5229
+ warnings: input.observed.warnings,
5230
+ resourceLocators: input.observed.locators,
5231
+ state,
5232
+ verification: null
5233
+ };
5234
+ }
2706
5235
  function buildRailwayAdapter(unitType) {
2707
5236
  return {
2708
5237
  providerId: "railway",
@@ -2725,6 +5254,9 @@ function buildRailwayAdapter(unitType) {
2725
5254
  apply(input) {
2726
5255
  return reconcileRailwayUnit(input, input.diff);
2727
5256
  },
5257
+ destroy(input) {
5258
+ return destroyRailwayUnit(input);
5259
+ },
2728
5260
  requiredPostconditions() {
2729
5261
  return [
2730
5262
  { key: "railway.project", description: "Railway project exists" },
@@ -2827,7 +5359,53 @@ function createRailwayReconcileAdapters() {
2827
5359
  buildCompositeAdapter("worker-runner-runtime")
2828
5360
  ];
2829
5361
  }
5362
+ function createPackageReconcileAdapters() {
5363
+ return [
5364
+ buildManifestAdapter(),
5365
+ buildPackageWorkflowAdapter(),
5366
+ buildPackageImageAdapter()
5367
+ ];
5368
+ }
5369
+ function createGitHubReconcileAdapters() {
5370
+ return [
5371
+ buildGitHubEnvironmentAdapter(),
5372
+ buildGitHubBindingAdapter("github-secret-binding"),
5373
+ buildGitHubBindingAdapter("github-variable-binding"),
5374
+ buildGitHubWorkflowDispatchAdapter()
5375
+ ];
5376
+ }
5377
+ function createDockerReconcileAdapters() {
5378
+ return [
5379
+ buildDockerImageBuildAdapter()
5380
+ ];
5381
+ }
5382
+ function createLocalProcessReconcileAdapters() {
5383
+ return [
5384
+ buildLocalContentMaterializationAdapter(),
5385
+ buildLocalProcessAdapter()
5386
+ ];
5387
+ }
5388
+ function createCapacityProviderReconcileAdapters() {
5389
+ return [
5390
+ buildCapacityProviderAdapter("local"),
5391
+ buildCapacityProviderAdapter("railway"),
5392
+ buildLocalDockerComposeAdapter(),
5393
+ buildLocalTreeDxAdapter()
5394
+ ];
5395
+ }
5396
+ function createReleaseGateReconcileAdapters() {
5397
+ return [
5398
+ buildWorkflowMetaAdapter(),
5399
+ buildReleaseGateAdapter()
5400
+ ];
5401
+ }
2830
5402
  export {
5403
+ createCapacityProviderReconcileAdapters,
2831
5404
  createCloudflareReconcileAdapters,
2832
- createRailwayReconcileAdapters
5405
+ createDockerReconcileAdapters,
5406
+ createGitHubReconcileAdapters,
5407
+ createLocalProcessReconcileAdapters,
5408
+ createPackageReconcileAdapters,
5409
+ createRailwayReconcileAdapters,
5410
+ createReleaseGateReconcileAdapters
2833
5411
  };