@treeseed/sdk 0.10.27 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +207 -6
  2. package/dist/capacity-provider.d.ts +3 -1
  3. package/dist/capacity-provider.js +25 -5
  4. package/dist/control-plane.d.ts +1 -0
  5. package/dist/control-plane.js +38 -13
  6. package/dist/db/market-schema.d.ts +8860 -6172
  7. package/dist/db/market-schema.js +108 -0
  8. package/dist/db/node-sqlite.js +7 -2
  9. package/dist/hosting/apps.d.ts +12 -0
  10. package/dist/hosting/apps.js +107 -0
  11. package/dist/hosting/builtins.d.ts +25 -0
  12. package/dist/hosting/builtins.js +791 -0
  13. package/dist/hosting/contracts.d.ts +207 -0
  14. package/dist/hosting/contracts.js +0 -0
  15. package/dist/hosting/graph.d.ts +192 -0
  16. package/dist/hosting/graph.js +1106 -0
  17. package/dist/hosting/index.d.ts +4 -0
  18. package/dist/hosting/index.js +4 -0
  19. package/dist/index.d.ts +11 -4
  20. package/dist/index.js +71 -7
  21. package/dist/managed-dependencies.js +1 -2
  22. package/dist/market-client.d.ts +63 -3
  23. package/dist/market-client.js +83 -11
  24. package/dist/operations/services/bootstrap-runner.d.ts +3 -1
  25. package/dist/operations/services/bootstrap-runner.js +22 -2
  26. package/dist/operations/services/config-runtime.d.ts +10 -5
  27. package/dist/operations/services/config-runtime.js +209 -66
  28. package/dist/operations/services/deploy.d.ts +70 -7
  29. package/dist/operations/services/deploy.js +579 -64
  30. package/dist/operations/services/deployment-readiness.d.ts +30 -0
  31. package/dist/operations/services/deployment-readiness.js +175 -0
  32. package/dist/operations/services/git-workflow.d.ts +2 -1
  33. package/dist/operations/services/git-workflow.js +9 -3
  34. package/dist/operations/services/github-actions-verification.d.ts +1 -0
  35. package/dist/operations/services/github-actions-verification.js +1 -0
  36. package/dist/operations/services/github-api.js +1 -1
  37. package/dist/operations/services/github-automation.d.ts +1 -1
  38. package/dist/operations/services/github-automation.js +4 -3
  39. package/dist/operations/services/github-credentials.d.ts +13 -0
  40. package/dist/operations/services/github-credentials.js +58 -0
  41. package/dist/operations/services/hosted-service-checks.d.ts +63 -0
  42. package/dist/operations/services/hosted-service-checks.js +327 -0
  43. package/dist/operations/services/hub-provider-launch.js +3 -3
  44. package/dist/operations/services/live-hosted-service-checks.d.ts +25 -0
  45. package/dist/operations/services/live-hosted-service-checks.js +350 -0
  46. package/dist/operations/services/managed-host-security.js +1 -1
  47. package/dist/operations/services/operations-runner-smoke.d.ts +30 -0
  48. package/dist/operations/services/operations-runner-smoke.js +180 -0
  49. package/dist/operations/services/package-adapters.d.ts +95 -0
  50. package/dist/operations/services/package-adapters.js +288 -0
  51. package/dist/operations/services/package-reference-policy.d.ts +1 -0
  52. package/dist/operations/services/package-reference-policy.js +15 -2
  53. package/dist/operations/services/project-platform.d.ts +80 -22
  54. package/dist/operations/services/project-platform.js +49 -8
  55. package/dist/operations/services/project-web-monitor.js +26 -4
  56. package/dist/operations/services/railway-api.d.ts +88 -5
  57. package/dist/operations/services/railway-api.js +626 -35
  58. package/dist/operations/services/railway-deploy.d.ts +46 -40
  59. package/dist/operations/services/railway-deploy.js +261 -293
  60. package/dist/operations/services/release-candidate.d.ts +19 -0
  61. package/dist/operations/services/release-candidate.js +375 -38
  62. package/dist/operations/services/repository-save-orchestrator.d.ts +3 -1
  63. package/dist/operations/services/repository-save-orchestrator.js +279 -66
  64. package/dist/operations/services/runtime-tools.d.ts +1 -0
  65. package/dist/operations/services/runtime-tools.js +10 -9
  66. package/dist/operations/services/template-registry.js +14 -7
  67. package/dist/operations/services/verification-cache.d.ts +25 -0
  68. package/dist/operations/services/verification-cache.js +71 -0
  69. package/dist/operations/services/workspace-dependency-mode.js +9 -1
  70. package/dist/operations/services/workspace-save.js +1 -1
  71. package/dist/operations/services/workspace-tools.js +2 -1
  72. package/dist/platform/contracts.d.ts +32 -1
  73. package/dist/platform/deploy-config.js +73 -8
  74. package/dist/platform/env.yaml +163 -35
  75. package/dist/platform/environment.d.ts +1 -0
  76. package/dist/platform/environment.js +74 -5
  77. package/dist/platform/plugin.d.ts +9 -0
  78. package/dist/platform-operation-store.js +2 -2
  79. package/dist/platform-operations.js +1 -1
  80. package/dist/reconcile/bootstrap-systems.js +2 -2
  81. package/dist/reconcile/builtin-adapters.js +372 -189
  82. package/dist/reconcile/contracts.d.ts +9 -5
  83. package/dist/reconcile/desired-state.d.ts +1 -0
  84. package/dist/reconcile/desired-state.js +5 -5
  85. package/dist/reconcile/engine.d.ts +5 -2
  86. package/dist/reconcile/engine.js +53 -32
  87. package/dist/reconcile/index.d.ts +2 -0
  88. package/dist/reconcile/index.js +2 -0
  89. package/dist/reconcile/live-acceptance.d.ts +79 -0
  90. package/dist/reconcile/live-acceptance.js +1615 -0
  91. package/dist/reconcile/platform.d.ts +104 -0
  92. package/dist/reconcile/platform.js +100 -0
  93. package/dist/reconcile/state.js +4 -4
  94. package/dist/reconcile/units.js +2 -2
  95. package/dist/scripts/deployment-readiness.js +20 -0
  96. package/dist/scripts/generate-treedx-openapi-types.js +186 -0
  97. package/dist/scripts/operations-runner-smoke.js +16 -0
  98. package/dist/scripts/release-verify.js +4 -1
  99. package/dist/scripts/template-catalog.test.js +7 -7
  100. package/dist/scripts/tenant-workflow-action.js +10 -1
  101. package/dist/sdk-types.d.ts +172 -5
  102. package/dist/sdk-types.js +28 -3
  103. package/dist/sdk.d.ts +35 -24
  104. package/dist/sdk.js +186 -17
  105. package/dist/template-launch-requirements.js +9 -0
  106. package/dist/treedx/adapters.d.ts +6 -0
  107. package/dist/treedx/adapters.js +36 -0
  108. package/dist/treedx/client.d.ts +222 -0
  109. package/dist/treedx/client.js +871 -0
  110. package/dist/treedx/errors.d.ts +13 -0
  111. package/dist/treedx/errors.js +17 -0
  112. package/dist/treedx/federated-client.d.ts +27 -0
  113. package/dist/treedx/federated-client.js +158 -0
  114. package/dist/treedx/generated/openapi-types.d.ts +3558 -0
  115. package/dist/treedx/generated/openapi-types.js +0 -0
  116. package/dist/treedx/graph-adapter.d.ts +33 -0
  117. package/dist/treedx/graph-adapter.js +156 -0
  118. package/dist/treedx/index.d.ts +14 -0
  119. package/dist/treedx/index.js +48 -0
  120. package/dist/treedx/market-integration.d.ts +27 -0
  121. package/dist/treedx/market-integration.js +131 -0
  122. package/dist/treedx/ports.d.ts +166 -0
  123. package/dist/treedx/ports.js +231 -0
  124. package/dist/treedx/query-adapter.d.ts +19 -0
  125. package/dist/treedx/query-adapter.js +62 -0
  126. package/dist/treedx/registry-client.d.ts +11 -0
  127. package/dist/treedx/registry-client.js +19 -0
  128. package/dist/treedx/repository-adapter.d.ts +45 -0
  129. package/dist/treedx/repository-adapter.js +308 -0
  130. package/dist/treedx/sdk-integration.d.ts +27 -0
  131. package/dist/treedx/sdk-integration.js +63 -0
  132. package/dist/treedx/types.d.ts +1084 -0
  133. package/dist/treedx/types.js +8 -0
  134. package/dist/treedx/workspace-adapter.d.ts +27 -0
  135. package/dist/treedx/workspace-adapter.js +65 -0
  136. package/dist/treedx-backends.d.ts +218 -0
  137. package/dist/treedx-backends.js +632 -0
  138. package/dist/treedx-client.d.ts +86 -0
  139. package/dist/treedx-client.js +175 -0
  140. package/dist/treeseed/template-catalog/catalog.fixture.json +497 -138
  141. package/dist/workflow/operations.d.ts +119 -13
  142. package/dist/workflow/operations.js +309 -53
  143. package/dist/workflow-state.d.ts +13 -0
  144. package/dist/workflow-state.js +43 -26
  145. package/dist/workflow-support.d.ts +11 -3
  146. package/dist/workflow-support.js +67 -3
  147. package/dist/workflow.d.ts +5 -0
  148. package/drizzle/market/0004_treedx_market_integration.sql +99 -0
  149. package/package.json +34 -3
  150. package/templates/github/deploy-web.workflow.yml +39 -6
  151. package/dist/treeseed/template-catalog/templates/starter-basic/template/astro.config.d.ts +0 -3
  152. package/dist/treeseed/template-catalog/templates/starter-basic/template/astro.config.ts +0 -6
  153. package/dist/treeseed/template-catalog/templates/starter-basic/template/package.json +0 -35
  154. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/api/server.js +0 -4
  155. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/config.yaml +0 -65
  156. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/decisions/adopt-initial-proposal-loop.mdx +0 -22
  157. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/empty/.gitkeep +0 -1
  158. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/knowledge/handbook/index.mdx +0 -11
  159. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/pages/welcome.mdx +0 -11
  160. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/people/starter-steward.mdx +0 -11
  161. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/proposals/establish-initial-proposal-loop.mdx +0 -17
  162. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content.config.d.ts +0 -1
  163. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content.config.ts +0 -3
  164. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/env.yaml +0 -1
  165. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/manifest.yaml +0 -26
  166. package/dist/treeseed/template-catalog/templates/starter-basic/template/treeseed.site.yaml +0 -74
  167. package/dist/treeseed/template-catalog/templates/starter-basic/template/tsconfig.json +0 -9
  168. package/dist/treeseed/template-catalog/templates/starter-basic/template.config.json +0 -103
@@ -0,0 +1,1106 @@
1
+ import { loadTreeseedDeployConfig } from "../platform/deploy-config.js";
2
+ import { loadTreeseedPlugins } from "../platform/plugins/runtime.js";
3
+ import { randomBytes } from "node:crypto";
4
+ import { resolve } from "node:path";
5
+ import { createPersistentDeployTarget } from "../operations/services/deploy.js";
6
+ import { collectTreeseedConfigSeedValues } from "../operations/services/config-runtime.js";
7
+ import {
8
+ configuredRailwayServices,
9
+ deployRailwayService
10
+ } from "../operations/services/railway-deploy.js";
11
+ import {
12
+ deployRailwayServiceInstance,
13
+ deleteRailwayService,
14
+ ensureRailwayEnvironment,
15
+ ensureRailwayGeneratedServiceDomain,
16
+ ensureRailwayProject,
17
+ ensureRailwayService,
18
+ ensureRailwayServiceInstanceConfiguration,
19
+ ensureRailwayServiceVolume,
20
+ listRailwayVariables,
21
+ listRailwayServices,
22
+ normalizeRailwayEnvironmentName,
23
+ resolveRailwayWorkspaceContext,
24
+ updateRailwayServiceName,
25
+ upsertRailwayVariables
26
+ } from "../operations/services/railway-api.js";
27
+ import { createTreeseedCanonicalReconcileReport } from "../reconcile/index.js";
28
+ import { reconcileTreeseedTarget } from "../reconcile/index.js";
29
+ import { discoverTreeseedApplications, findTreeseedApplication } from "./apps.js";
30
+ import {
31
+ createDefaultHostAdapters,
32
+ createDefaultHostingProfiles,
33
+ createDefaultServiceTypeAdapters,
34
+ redactSensitiveConfig,
35
+ sanitizedUnitConfig,
36
+ summarizePlacementStatus
37
+ } from "./builtins.js";
38
+ const ENVIRONMENT_NAMES = {
39
+ local: "local",
40
+ staging: "staging",
41
+ prod: "production"
42
+ };
43
+ const PLACEMENT_LABELS = {
44
+ web: "Site Hosting",
45
+ api: "API Runtime",
46
+ database: "Database",
47
+ "knowledge-library": "Knowledge Library",
48
+ "runner-capacity": "Runner Capacity",
49
+ repository: "Repository",
50
+ "content-storage": "Content Storage",
51
+ email: "Email",
52
+ operations: "Operations",
53
+ custom: "Custom"
54
+ };
55
+ function mergeRecord(...records) {
56
+ return Object.assign({}, ...records.filter(Boolean));
57
+ }
58
+ function asPluginRecord(value) {
59
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
60
+ }
61
+ function normalizeEnvironment(value) {
62
+ return value === "prod" || value === "production" ? "prod" : value === "staging" ? "staging" : "local";
63
+ }
64
+ function indexedName(baseName, index) {
65
+ return `${baseName.replace(/-\d+$/u, "").replace(/-\d{2}$/u, "")}-${String(Math.max(1, index)).padStart(2, "0")}`;
66
+ }
67
+ function publicTreeDxNodePool(config) {
68
+ const nodePool = config.publicTreeDxFederation?.railway?.nodePool ?? {};
69
+ const bootstrapCount = Math.max(1, Number.parseInt(String(nodePool.bootstrapCount ?? 1), 10) || 1);
70
+ const maxNodes = Math.max(bootstrapCount, Number.parseInt(String(nodePool.maxNodes ?? 4), 10) || 4);
71
+ return { bootstrapCount, maxNodes };
72
+ }
73
+ function treeDxSecretBase() {
74
+ return randomBytes(48).toString("base64url");
75
+ }
76
+ function serviceKeyPlacement(serviceKey) {
77
+ if (serviceKey === "api") return "api";
78
+ if (serviceKey === "treeseedDatabase") return "database";
79
+ if (serviceKey === "operationsRunner") return "runner-capacity";
80
+ if (/runner|capacity/iu.test(serviceKey)) return "runner-capacity";
81
+ if (/database|postgres|db/iu.test(serviceKey)) return "database";
82
+ if (/email|smtp/iu.test(serviceKey)) return "email";
83
+ return "operations";
84
+ }
85
+ function serviceKeyType(serviceKey, service) {
86
+ if (serviceKey === "treeseedDatabase" || service.railway?.resourceType === "postgres") return "relational-database";
87
+ if (serviceKey === "operationsRunner" || /runner/iu.test(serviceKey)) return "runner-pool";
88
+ if (Array.isArray(service.railway?.schedule) || typeof service.railway?.schedule === "string") return "scheduled-job";
89
+ if (serviceKey === "api") return "container-api";
90
+ return service.railway?.volumeMountPath ? "stateful-container" : "container-api";
91
+ }
92
+ function collectPluginHostingContributions(input) {
93
+ const plugins = loadTreeseedPlugins(input.deployConfig);
94
+ const context = {
95
+ projectRoot: input.tenantRoot,
96
+ tenantConfig: void 0,
97
+ deployConfig: input.deployConfig,
98
+ pluginConfig: {}
99
+ };
100
+ const hostAdapters = {};
101
+ const serviceTypeAdapters = {};
102
+ const profiles = [];
103
+ for (const entry of plugins) {
104
+ const pluginContext = { ...context, pluginConfig: entry.config ?? {} };
105
+ const contribution = entry.plugin.hosting;
106
+ const resolved = typeof contribution === "function" ? contribution(pluginContext) : contribution;
107
+ if (!resolved || typeof resolved !== "object") continue;
108
+ Object.assign(hostAdapters, asPluginRecord(resolved.hostAdapters));
109
+ Object.assign(serviceTypeAdapters, asPluginRecord(resolved.serviceTypeAdapters));
110
+ const contributedProfiles = Array.isArray(resolved.profiles) ? resolved.profiles : [];
111
+ profiles.push(...contributedProfiles.filter(Boolean));
112
+ }
113
+ return {
114
+ hostAdapters,
115
+ serviceTypeAdapters,
116
+ profiles
117
+ };
118
+ }
119
+ function marketProjectGroup(environment, config) {
120
+ const railwayProjectName = Object.values(config.services ?? {}).map((service) => service && typeof service === "object" ? service.railway?.projectName : null).find((value) => typeof value === "string" && value.trim());
121
+ const projectName = railwayProjectName ?? config.slug ?? "treeseed-api";
122
+ return {
123
+ id: "treeseed-control-plane",
124
+ label: "Treeseed control plane",
125
+ hostId: environment === "local" ? "local-process" : "railway",
126
+ environments: {
127
+ local: { projectName: `${projectName}-local`, environmentName: "local" },
128
+ staging: { projectName, environmentName: "staging" },
129
+ prod: { projectName, environmentName: "production" }
130
+ },
131
+ metadata: { stableProjectName: projectName }
132
+ };
133
+ }
134
+ function publicTreeDxProjectGroup(environment, config) {
135
+ const apiProjectName = marketProjectGroup(environment, config).environments.staging?.projectName ?? config.slug ?? "treeseed-api";
136
+ return {
137
+ id: "public-treedx-federation",
138
+ label: "Public TreeDX federation",
139
+ hostId: environment === "local" ? "local-docker" : "railway",
140
+ environments: {
141
+ local: { projectName: `${apiProjectName}-local`, environmentName: "local" },
142
+ staging: { projectName: apiProjectName, environmentName: "staging" },
143
+ prod: { projectName: apiProjectName, environmentName: "production" }
144
+ },
145
+ metadata: {
146
+ publicFederation: true,
147
+ ownedByAppProject: "api",
148
+ isolation: "railway-service-volume-domain"
149
+ }
150
+ };
151
+ }
152
+ function privateTreeDxProjectGroup(teamId = "{teamId}") {
153
+ return {
154
+ id: "private-team-treedx",
155
+ label: "Private team TreeDX",
156
+ hostId: "railway",
157
+ environments: {
158
+ staging: { projectName: `treeseed-team-${teamId}-treedx`, environmentName: "staging" },
159
+ prod: { projectName: `treeseed-team-${teamId}-treedx`, environmentName: "production" }
160
+ },
161
+ metadata: { transferable: true, privateTeam: true }
162
+ };
163
+ }
164
+ function buildProfileFromDeployConfig(input) {
165
+ const config = input.deployConfig;
166
+ const environment = input.environment;
167
+ const services = [];
168
+ const projectGroups = [
169
+ marketProjectGroup(environment, config),
170
+ publicTreeDxProjectGroup(environment, config),
171
+ privateTreeDxProjectGroup()
172
+ ];
173
+ if (config.surfaces?.web && config.surfaces.web.enabled !== false) {
174
+ services.push({
175
+ id: "web",
176
+ label: "Site Hosting",
177
+ serviceType: "web-site",
178
+ placement: "web",
179
+ projectGroupId: environment === "local" ? void 0 : "treeseed-control-plane",
180
+ config: {
181
+ rootDir: config.surfaces?.web?.rootDir ?? ".",
182
+ publicBaseUrl: config.surfaces?.web?.environments?.[environment]?.baseUrl ?? config.surfaces?.web?.publicBaseUrl ?? null,
183
+ domain: config.surfaces?.web?.environments?.[environment]?.domain ?? null,
184
+ cache: config.surfaces?.web?.cache ?? null,
185
+ cloudflare: config.cloudflare ? {
186
+ workerName: config.cloudflare.workerName ?? null,
187
+ pages: config.cloudflare.pages ?? null
188
+ } : null
189
+ },
190
+ environments: {
191
+ local: { hostId: "local-process", config: { hotReload: true, baseUrl: config.surfaces?.web?.localBaseUrl ?? "http://127.0.0.1:4321" } },
192
+ staging: { hostId: "cloudflare", projectGroupId: "treeseed-control-plane" },
193
+ prod: { hostId: "cloudflare", projectGroupId: "treeseed-control-plane" }
194
+ }
195
+ });
196
+ }
197
+ for (const [serviceKey, serviceValue] of Object.entries(config.services ?? {})) {
198
+ const service = serviceValue;
199
+ if (!service || service.enabled === false) continue;
200
+ const serviceType = serviceKeyType(serviceKey, service);
201
+ const placement = serviceKeyPlacement(serviceKey);
202
+ const defaultProjectGroup = service.provider === "railway" || service.railway ? "treeseed-control-plane" : void 0;
203
+ services.push({
204
+ id: serviceKey,
205
+ label: placement === "runner-capacity" ? "Runner Capacity" : serviceKey === "api" ? "API Runtime" : serviceKey,
206
+ serviceType,
207
+ placement,
208
+ projectGroupId: defaultProjectGroup,
209
+ config: {
210
+ rootDir: service.railway?.rootDir ?? service.rootDir ?? ".",
211
+ buildCommand: service.railway?.buildCommand ?? null,
212
+ startCommand: service.railway?.startCommand ?? null,
213
+ healthcheckPath: service.railway?.healthcheckPath ?? null,
214
+ runtimeMode: service.railway?.runtimeMode ?? null,
215
+ volumeMountPath: service.railway?.volumeMountPath ?? null,
216
+ runnerPool: service.railway?.runnerPool ?? null,
217
+ resourceType: service.railway?.resourceType ?? null,
218
+ serviceName: service.railway?.serviceName ?? null,
219
+ serviceTargets: service.railway?.serviceTargets ?? null
220
+ },
221
+ secretRefs: serviceKey === "treeseedDatabase" ? ["TREESEED_DATABASE_URL"] : [],
222
+ variableRefs: serviceKey === "operationsRunner" ? ["TREESEED_PLATFORM_RUNNER_ID", "TREESEED_PLATFORM_RUNNER_DATA_DIR", "TREESEED_PLATFORM_RUNNER_ENVIRONMENT"] : [],
223
+ environments: {
224
+ local: {
225
+ hostId: serviceType === "relational-database" || serviceType === "runner-pool" || service.railway?.volumeMountPath ? "local-docker" : "local-process",
226
+ projectGroupId: void 0,
227
+ config: service.environments?.local ?? {}
228
+ },
229
+ staging: {
230
+ hostId: service.provider ?? "railway",
231
+ projectGroupId: defaultProjectGroup,
232
+ config: service.environments?.staging ?? {}
233
+ },
234
+ prod: {
235
+ hostId: service.provider ?? "railway",
236
+ projectGroupId: defaultProjectGroup,
237
+ config: service.environments?.prod ?? {}
238
+ }
239
+ }
240
+ });
241
+ }
242
+ if (config.cloudflare?.r2) {
243
+ services.push({
244
+ id: "content-storage",
245
+ label: "Content Storage",
246
+ serviceType: "object-store",
247
+ placement: "content-storage",
248
+ config: {
249
+ bucketName: config.cloudflare.r2.bucketName ?? null,
250
+ manifestKeyTemplate: config.cloudflare.r2.manifestKeyTemplate ?? null,
251
+ previewRootTemplate: config.cloudflare.r2.previewRootTemplate ?? null
252
+ },
253
+ environments: {
254
+ local: { hostId: "local-docker" },
255
+ staging: { hostId: "cloudflare" },
256
+ prod: { hostId: "cloudflare" }
257
+ }
258
+ });
259
+ }
260
+ if (config.smtp?.enabled === true) {
261
+ services.push({
262
+ id: "email",
263
+ label: "Email",
264
+ serviceType: "email-relay",
265
+ placement: "email",
266
+ secretRefs: ["SMTP_PASSWORD"],
267
+ environments: {
268
+ local: { hostId: "smtp" },
269
+ staging: { hostId: "smtp" },
270
+ prod: { hostId: "smtp" }
271
+ }
272
+ });
273
+ }
274
+ if (config.hosting?.kind === "treeseed_control_plane") {
275
+ const treeDxNodePool = publicTreeDxNodePool(config);
276
+ const treeDxNodeUnits = Array.from({ length: treeDxNodePool.bootstrapCount }, (_, offset) => {
277
+ const nodeIndex = offset + 1;
278
+ const serviceName = indexedName("public-treedx-node", nodeIndex);
279
+ return {
280
+ id: serviceName,
281
+ label: `Public TreeDX node ${String(nodeIndex).padStart(2, "0")}`,
282
+ serviceType: "treedx-node",
283
+ placement: "knowledge-library",
284
+ projectGroupId: "public-treedx-federation",
285
+ config: {
286
+ image: "treeseed/treedx",
287
+ imageTagRef: "TREESEED_PUBLIC_TREEDX_IMAGE_REF",
288
+ serviceName,
289
+ volumeName: `${serviceName}-volume`,
290
+ volumeMountPath: "/data",
291
+ runtimeMode: "replicated",
292
+ environmentVariables: {
293
+ PHX_SERVER: "true",
294
+ PORT: "4000",
295
+ TREEDX_DATA_DIR: "/data",
296
+ TREEDX_AUTH_MODE: "connected",
297
+ TREEDX_AUTH_VERIFIER: "hs256_dev",
298
+ TREEDX_ALLOW_DEV_VERIFIER_IN_PROD: "true",
299
+ TREEDX_EXEC_BACKEND: "container_sandbox",
300
+ TREEDX_FEDERATION_MODE: "connected_library",
301
+ TREEDX_JWT_AUDIENCE: "treedx-public-federation",
302
+ TREEDX_JWT_ISSUER: "https://api.treeseed.local/treedx",
303
+ TREESEED_TREEDX_SCOPE: "public_federation"
304
+ }
305
+ },
306
+ variableRefs: [
307
+ "TREESEED_PUBLIC_TREEDX_IMAGE_REF",
308
+ "PHX_HOST",
309
+ "PHX_SERVER",
310
+ "PORT",
311
+ "TREEDX_DATA_DIR",
312
+ "TREEDX_AUTH_MODE",
313
+ "TREEDX_AUTH_VERIFIER",
314
+ "TREEDX_ALLOW_DEV_VERIFIER_IN_PROD",
315
+ "TREEDX_EXEC_BACKEND",
316
+ "TREEDX_FEDERATION_MODE",
317
+ "TREEDX_JWT_AUDIENCE",
318
+ "TREEDX_JWT_ISSUER",
319
+ "TREESEED_TREEDX_SCOPE"
320
+ ],
321
+ secretRefs: ["SECRET_KEY_BASE", "TREESEED_TREEDX_ADMIN_TOKEN", "TREEDX_JWT_HS256_SECRET"],
322
+ environments: {
323
+ local: { hostId: "local-docker", projectGroupId: "public-treedx-federation" },
324
+ staging: { hostId: "railway", projectGroupId: "public-treedx-federation" },
325
+ prod: { hostId: "railway", projectGroupId: "public-treedx-federation" }
326
+ },
327
+ metadata: {
328
+ publicFederation: true,
329
+ defaultNode: nodeIndex === 1,
330
+ nodeIndex,
331
+ maxNodes: treeDxNodePool.maxNodes,
332
+ retainVolumeOnScaleDown: true
333
+ }
334
+ };
335
+ });
336
+ services.push(
337
+ {
338
+ id: "public-treedx-federation",
339
+ label: "Public TreeDX federation",
340
+ serviceType: "treedx-federation",
341
+ placement: "knowledge-library",
342
+ projectGroupId: "public-treedx-federation",
343
+ dependencies: treeDxNodeUnits.map((unit) => unit.id),
344
+ config: {
345
+ projectName: marketProjectGroup(environment, config).environments.staging?.projectName ?? config.slug ?? "treeseed-api",
346
+ isolation: "same API Railway project, separate service, volume, and domain",
347
+ federationMode: "connected_library",
348
+ nodePool: treeDxNodePool
349
+ },
350
+ environments: {
351
+ local: { hostId: "local-docker", projectGroupId: "public-treedx-federation" },
352
+ staging: { hostId: "railway", projectGroupId: "public-treedx-federation" },
353
+ prod: { hostId: "railway", projectGroupId: "public-treedx-federation" }
354
+ },
355
+ metadata: { publicFederation: true, nodeCount: treeDxNodePool.bootstrapCount, maxNodes: treeDxNodePool.maxNodes }
356
+ },
357
+ ...treeDxNodeUnits
358
+ );
359
+ }
360
+ return {
361
+ id: `${config.slug}-compiled`,
362
+ label: `${config.name} hosting profile`,
363
+ services,
364
+ projectGroups,
365
+ metadata: {
366
+ source: "treeseed.site.yaml",
367
+ environment
368
+ }
369
+ };
370
+ }
371
+ function assertCapabilityBinding(unit) {
372
+ const hostCapabilities = new Set(unit.host.capabilities.filter((capability) => capability.environments.includes(unit.environment)).map((capability) => capability.id));
373
+ const missing = unit.requiredCapabilities.filter((capability) => !hostCapabilities.has(capability));
374
+ if (missing.length > 0) {
375
+ throw new Error(`Hosting unit "${unit.id}" cannot bind ${unit.serviceType.id} to host "${unit.host.id}" in ${unit.environment}; missing capabilities: ${missing.join(", ")}.`);
376
+ }
377
+ }
378
+ function orderUnits(units) {
379
+ const remaining = new Map(units.map((unit) => [unit.id, unit]));
380
+ const ordered = [];
381
+ while (remaining.size > 0) {
382
+ const ready = [...remaining.values()].filter((unit) => unit.dependencies.every((dependency) => !remaining.has(dependency) || ordered.some((orderedUnit) => orderedUnit.id === dependency)));
383
+ if (ready.length === 0) {
384
+ throw new Error(`Hosting graph contains a dependency cycle: ${[...remaining.keys()].join(", ")}.`);
385
+ }
386
+ for (const unit of ready) {
387
+ remaining.delete(unit.id);
388
+ ordered.push(unit);
389
+ }
390
+ }
391
+ return ordered;
392
+ }
393
+ function createUnit(service, environment, hosts, serviceTypes, projectGroups, application) {
394
+ const serviceType = serviceTypes[service.serviceType];
395
+ if (!serviceType) {
396
+ throw new Error(`Unknown hosting service type "${service.serviceType}" for service "${service.id}".`);
397
+ }
398
+ const binding = service.environments?.[environment];
399
+ if (binding?.enabled === false) return null;
400
+ const hostId = binding?.hostId ?? serviceType.defaultHostByEnvironment?.[environment];
401
+ if (!hostId) {
402
+ throw new Error(`Hosting service "${service.id}" does not define a host binding for ${environment}.`);
403
+ }
404
+ const host = hosts[hostId];
405
+ if (!host) {
406
+ throw new Error(`Unknown hosting host "${hostId}" for service "${service.id}".`);
407
+ }
408
+ const projectGroupId = binding?.projectGroupId ?? service.projectGroupId;
409
+ const projectGroup = projectGroupId ? projectGroups[projectGroupId] ?? null : null;
410
+ const unitId = application && application.relativeRoot !== "." && service.id === "web" ? application.id : service.id;
411
+ const unit = {
412
+ id: unitId,
413
+ label: service.label,
414
+ serviceType,
415
+ placement: service.placement ?? serviceType.placement,
416
+ host,
417
+ environment,
418
+ projectGroup,
419
+ dependencies: service.dependencies ?? [],
420
+ requiredCapabilities: serviceType.requiredCapabilities,
421
+ config: redactSensitiveConfig({
422
+ ...service.config ?? {},
423
+ ...binding?.config ?? {}
424
+ }),
425
+ secretRefs: service.secretRefs ?? [],
426
+ variableRefs: service.variableRefs ?? [],
427
+ metadata: redactSensitiveConfig(service.metadata ?? {}),
428
+ application
429
+ };
430
+ assertCapabilityBinding(unit);
431
+ return unit;
432
+ }
433
+ function summarizePlacements(units) {
434
+ const grouped = /* @__PURE__ */ new Map();
435
+ for (const unit of units) {
436
+ grouped.set(unit.placement, [...grouped.get(unit.placement) ?? [], unit]);
437
+ }
438
+ return [...grouped.entries()].map(([placement, placementUnits]) => ({
439
+ placement,
440
+ label: PLACEMENT_LABELS[placement] ?? placement,
441
+ serviceIds: placementUnits.map((unit) => unit.id),
442
+ hostIds: [...new Set(placementUnits.map((unit) => unit.host.id))],
443
+ status: summarizePlacementStatus(placementUnits.map(() => "pending")),
444
+ advanced: false
445
+ }));
446
+ }
447
+ function normalizeFilterValues(values) {
448
+ return new Set((values ?? []).map((value) => value.trim()).filter(Boolean));
449
+ }
450
+ function filterHostingUnits(units, filter) {
451
+ const serviceIds = normalizeFilterValues(filter?.serviceIds);
452
+ const placements = normalizeFilterValues(filter?.placements);
453
+ const hosts = normalizeFilterValues(filter?.hosts);
454
+ if (serviceIds.size === 0 && placements.size === 0 && hosts.size === 0) return units;
455
+ const allServiceIds = new Set(units.map((unit) => unit.id));
456
+ const missingServices = [...serviceIds].filter((serviceId) => !allServiceIds.has(serviceId));
457
+ if (missingServices.length > 0) {
458
+ throw new Error(`Unknown hosting service id${missingServices.length === 1 ? "" : "s"}: ${missingServices.join(", ")}.`);
459
+ }
460
+ return units.filter((unit) => (serviceIds.size === 0 || serviceIds.has(unit.id)) && (placements.size === 0 || placements.has(unit.placement)) && (hosts.size === 0 || hosts.has(unit.host.id)));
461
+ }
462
+ function compileSingleTreeseedHostingGraph(input, application) {
463
+ const environment = normalizeEnvironment(input.environment);
464
+ const deployConfig = input.deployConfig ?? loadTreeseedDeployConfig(resolve(input.tenantRoot, "treeseed.site.yaml"));
465
+ const pluginContributions = collectPluginHostingContributions({ ...input, deployConfig, environment });
466
+ const hosts = mergeRecord(createDefaultHostAdapters(), pluginContributions.hostAdapters, input.hostAdapters);
467
+ const serviceTypes = mergeRecord(createDefaultServiceTypeAdapters(), pluginContributions.serviceTypeAdapters, input.serviceTypeAdapters);
468
+ const compiledProfile = buildProfileFromDeployConfig({ ...input, deployConfig, environment });
469
+ const profiles = [
470
+ ...createDefaultHostingProfiles(),
471
+ ...pluginContributions.profiles,
472
+ compiledProfile,
473
+ ...input.profiles ?? []
474
+ ];
475
+ const projectGroups = Object.fromEntries(
476
+ profiles.flatMap((profile) => profile.projectGroups ?? []).map((group) => [group.id, group])
477
+ );
478
+ const services = profiles.flatMap((profile) => profile.services);
479
+ const applicationInfo = application ? {
480
+ id: application.id,
481
+ root: application.root,
482
+ relativeRoot: application.relativeRoot,
483
+ configPath: application.configPath,
484
+ roles: application.roles
485
+ } : void 0;
486
+ const units = filterHostingUnits(orderUnits(services.map((service) => createUnit(service, environment, hosts, serviceTypes, projectGroups, applicationInfo)).filter((unit) => Boolean(unit))), input.filter);
487
+ return {
488
+ tenantRoot: input.tenantRoot,
489
+ environment,
490
+ deployConfig,
491
+ applications: application ? [application] : void 0,
492
+ hosts,
493
+ serviceTypes,
494
+ profiles,
495
+ projectGroups,
496
+ units,
497
+ placements: summarizePlacements(units),
498
+ warnings: []
499
+ };
500
+ }
501
+ function mergeTreeseedHostingGraphs(input, applications) {
502
+ const environment = normalizeEnvironment(input.environment);
503
+ const graphs = applications.map((application) => compileSingleTreeseedHostingGraph({
504
+ ...input,
505
+ tenantRoot: application.root,
506
+ deployConfig: application.config,
507
+ filter: void 0
508
+ }, application));
509
+ const rootGraph = graphs.find((graph) => graph.tenantRoot === resolve(input.tenantRoot)) ?? graphs[0];
510
+ const hosts = mergeRecord(...graphs.map((graph) => graph.hosts), input.hostAdapters);
511
+ const serviceTypes = mergeRecord(...graphs.map((graph) => graph.serviceTypes), input.serviceTypeAdapters);
512
+ const projectGroups = mergeRecord(...graphs.map((graph) => graph.projectGroups));
513
+ const profiles = graphs.flatMap((graph) => graph.profiles);
514
+ const units = filterHostingUnits(orderUnits(graphs.flatMap((graph) => graph.units)), input.filter);
515
+ return {
516
+ tenantRoot: resolve(input.tenantRoot),
517
+ environment,
518
+ deployConfig: rootGraph.deployConfig,
519
+ applications,
520
+ hosts,
521
+ serviceTypes,
522
+ profiles,
523
+ projectGroups,
524
+ units,
525
+ placements: summarizePlacements(units),
526
+ warnings: graphs.flatMap((graph) => graph.warnings)
527
+ };
528
+ }
529
+ function compileTreeseedHostingGraph(input) {
530
+ if (input.deployConfig) {
531
+ return compileSingleTreeseedHostingGraph(input);
532
+ }
533
+ const tenantRoot = resolve(input.tenantRoot);
534
+ if (input.appId) {
535
+ const application = findTreeseedApplication(tenantRoot, input.appId);
536
+ if (!application) {
537
+ throw new Error(`Unknown Treeseed application "${input.appId}".`);
538
+ }
539
+ return compileSingleTreeseedHostingGraph({
540
+ ...input,
541
+ tenantRoot: application.root,
542
+ deployConfig: application.config
543
+ }, application);
544
+ }
545
+ const applications = discoverTreeseedApplications(tenantRoot);
546
+ if (applications.length > 1) {
547
+ return mergeTreeseedHostingGraphs(input, applications);
548
+ }
549
+ return compileSingleTreeseedHostingGraph(input, applications[0]);
550
+ }
551
+ async function planTreeseedHostingGraph(input) {
552
+ const graph = compileTreeseedHostingGraph(input);
553
+ const units = [];
554
+ for (const unit of graph.units) {
555
+ const observed = await unit.host.refresh({ environment: graph.environment, unit, graph, dryRun: input.dryRun !== false });
556
+ const plan = await unit.host.diff({ environment: graph.environment, unit, graph, observed, dryRun: input.dryRun !== false });
557
+ const verification = await unit.host.verify({ environment: graph.environment, unit, graph, observed, dryRun: input.dryRun !== false });
558
+ units.push({ unit, observed, plan, verification });
559
+ }
560
+ return {
561
+ environment: graph.environment,
562
+ dryRun: input.dryRun !== false,
563
+ units,
564
+ placements: graph.placements,
565
+ warnings: graph.warnings
566
+ };
567
+ }
568
+ function railwayReconcileSystemsForUnits(units) {
569
+ const systems = /* @__PURE__ */ new Set();
570
+ for (const unit of units) {
571
+ if (unit.host.id !== "railway") continue;
572
+ if (unit.placement === "api" || unit.id === "api" || unit.serviceType.id === "container-api") {
573
+ systems.add("api");
574
+ }
575
+ if (unit.placement === "runner-capacity" || unit.id === "operationsRunner" || unit.serviceType.id === "runner-pool") {
576
+ systems.add("agents");
577
+ }
578
+ if (unit.placement === "database" && units.some((candidate) => candidate.id === "api" || candidate.placement === "api")) {
579
+ systems.add("api");
580
+ }
581
+ }
582
+ return [...systems];
583
+ }
584
+ function railwayDeployServiceKeysForUnits(units) {
585
+ const keys = /* @__PURE__ */ new Set();
586
+ for (const unit of units) {
587
+ if (unit.host.id !== "railway") continue;
588
+ if (unit.id === "api" || unit.placement === "api" || unit.serviceType.id === "container-api") {
589
+ keys.add("api");
590
+ }
591
+ if (unit.id === "operationsRunner" || unit.placement === "runner-capacity" || unit.serviceType.id === "runner-pool") {
592
+ keys.add("operationsRunner");
593
+ }
594
+ }
595
+ return keys;
596
+ }
597
+ function railwayEnvForHostingApply(input, graph) {
598
+ const seedValues = collectTreeseedConfigSeedValues(input.tenantRoot, graph.environment);
599
+ return {
600
+ ...process.env,
601
+ ...seedValues
602
+ };
603
+ }
604
+ async function deploySelectedRailwayServices(input, graph) {
605
+ const selectedKeys = railwayDeployServiceKeysForUnits(graph.units);
606
+ if (selectedKeys.size === 0 || graph.environment === "local") {
607
+ return [];
608
+ }
609
+ const env = railwayEnvForHostingApply(input, graph);
610
+ const services = configuredRailwayServices(graph.tenantRoot, graph.environment).filter((service) => selectedKeys.has(service.key));
611
+ const results = [];
612
+ for (const service of services) {
613
+ results.push(await deployRailwayService(graph.tenantRoot, service, {
614
+ env,
615
+ prefix: {
616
+ scope: graph.environment,
617
+ system: service.key === "api" ? "api" : "agents",
618
+ task: `${service.key}-railway-deploy`,
619
+ stage: "deploy"
620
+ }
621
+ }));
622
+ }
623
+ return results;
624
+ }
625
+ function valueFromUnitConfig(unit, key) {
626
+ const config = unit.config && typeof unit.config === "object" ? unit.config : {};
627
+ const value = config[key];
628
+ return typeof value === "string" && value.trim() ? value.trim() : null;
629
+ }
630
+ function traceHostingRailway(env, stage, message) {
631
+ if (env.TREESEED_RECONCILE_TRACE === "1" || process.env.TREESEED_RECONCILE_TRACE === "1") {
632
+ console.error(`[trsd][hosting][railway][${stage}] ${message}`);
633
+ }
634
+ }
635
+ async function treeDxStage(env, stage, task) {
636
+ traceHostingRailway(env, `treedx:${stage}:start`, stage);
637
+ try {
638
+ const result = await task();
639
+ traceHostingRailway(env, `treedx:${stage}:done`, stage);
640
+ return result;
641
+ } catch (error) {
642
+ const message = error instanceof Error ? error.message : String(error ?? "");
643
+ throw new Error(`Public TreeDX Railway reconcile failed during ${stage}: ${message}`);
644
+ }
645
+ }
646
+ async function reconcilePublicTreeDxUnits(input, graph) {
647
+ if (graph.environment === "local") {
648
+ return [];
649
+ }
650
+ const nodeUnits = graph.units.filter((unit) => unit.id.startsWith("public-treedx-node-") && unit.host.id === "railway");
651
+ if (nodeUnits.length === 0) {
652
+ return [];
653
+ }
654
+ const env = railwayEnvForHostingApply(input, graph);
655
+ const workspace = await resolveRailwayWorkspaceContext({ env });
656
+ const results = [];
657
+ for (const unit of nodeUnits) {
658
+ const projectName2 = unit.projectGroup?.environments?.[graph.environment]?.projectName || String(env.TREESEED_PUBLIC_TREEDX_RAILWAY_PROJECT_NAME ?? "").trim() || "treeseed-api";
659
+ const environmentName2 = normalizeRailwayEnvironmentName(unit.projectGroup?.environments?.[graph.environment]?.environmentName) || ENVIRONMENT_NAMES[graph.environment];
660
+ const configuredImage = valueFromUnitConfig(unit, "image") ?? "treeseed/treedx";
661
+ const imageRef = String(env.TREESEED_PUBLIC_TREEDX_IMAGE_REF ?? "").trim() || (configuredImage.includes(":") ? configuredImage : `${configuredImage}:latest`);
662
+ const serviceName = valueFromUnitConfig(unit, "serviceName") ?? unit.id;
663
+ const volumeName = valueFromUnitConfig(unit, "volumeName") ?? `${serviceName}-volume`;
664
+ const mountPath = valueFromUnitConfig(unit, "volumeMountPath") ?? "/data";
665
+ const deploymentRegion = String(env.TREESEED_PUBLIC_TREEDX_RAILWAY_REGION ?? env.TREESEED_RAILWAY_STATEFUL_REGION ?? "us-west2").trim();
666
+ const ensuredProject2 = await treeDxStage(env, "project", () => ensureRailwayProject({
667
+ projectName: projectName2,
668
+ defaultEnvironmentName: environmentName2,
669
+ workspace: workspace.name,
670
+ env
671
+ }));
672
+ const ensuredEnvironment = await treeDxStage(env, "environment", () => ensureRailwayEnvironment({
673
+ projectId: ensuredProject2.project.id,
674
+ environmentName: environmentName2,
675
+ env
676
+ }));
677
+ const ensuredService = await treeDxStage(env, "service", async () => {
678
+ const services2 = await listRailwayServices({ projectId: ensuredProject2.project.id, env });
679
+ const existing = services2.find((service) => service.name === serviceName) ?? null;
680
+ const legacy = unit.metadata.nodeIndex === 1 ? services2.find((service) => service.name === "public-treedx-node") ?? null : null;
681
+ if (!existing && legacy) {
682
+ const renamed = await updateRailwayServiceName({ serviceId: legacy.id, name: serviceName, env });
683
+ return { service: renamed, created: false, adopted: true };
684
+ }
685
+ return ensureRailwayService({
686
+ projectId: ensuredProject2.project.id,
687
+ environmentId: ensuredEnvironment.environment.id,
688
+ serviceName,
689
+ imageRef,
690
+ env
691
+ });
692
+ });
693
+ const instance = await treeDxStage(env, "instance", () => ensureRailwayServiceInstanceConfiguration({
694
+ serviceId: ensuredService.service.id,
695
+ environmentId: ensuredEnvironment.environment.id,
696
+ healthcheckPath: "/api/v1/health",
697
+ healthcheckTimeoutSeconds: 120,
698
+ runtimeMode: "replicated",
699
+ deploymentRegion,
700
+ env
701
+ }));
702
+ const currentVariables = await treeDxStage(env, "variables:observe", () => listRailwayVariables({
703
+ projectId: ensuredProject2.project.id,
704
+ environmentId: ensuredEnvironment.environment.id,
705
+ serviceId: ensuredService.service.id,
706
+ env
707
+ }).catch(() => ({})));
708
+ await treeDxStage(env, "variables", () => upsertRailwayVariables({
709
+ projectId: ensuredProject2.project.id,
710
+ environmentId: ensuredEnvironment.environment.id,
711
+ serviceId: ensuredService.service.id,
712
+ variables: {
713
+ TREESEED_PUBLIC_TREEDX_IMAGE_REF: imageRef,
714
+ PHX_HOST: `${serviceName}.railway.app`,
715
+ PHX_SERVER: "true",
716
+ PORT: "4000",
717
+ TREEDX_DATA_DIR: mountPath,
718
+ TREEDX_AUTH_MODE: "connected",
719
+ TREEDX_AUTH_VERIFIER: "hs256_dev",
720
+ TREEDX_ALLOW_DEV_VERIFIER_IN_PROD: "true",
721
+ TREEDX_EXEC_BACKEND: "container_sandbox",
722
+ TREEDX_FEDERATION_MODE: "connected_library",
723
+ TREEDX_JWT_AUDIENCE: "treedx-public-federation",
724
+ TREEDX_JWT_ISSUER: `https://${serviceName}.railway.app/treedx`,
725
+ TREESEED_TREEDX_SCOPE: "public_federation",
726
+ ...typeof currentVariables.SECRET_KEY_BASE === "string" && currentVariables.SECRET_KEY_BASE.trim() ? {} : { SECRET_KEY_BASE: treeDxSecretBase() },
727
+ ...typeof currentVariables.TREEDX_JWT_HS256_SECRET === "string" && currentVariables.TREEDX_JWT_HS256_SECRET.trim() ? {} : { TREEDX_JWT_HS256_SECRET: treeDxSecretBase() },
728
+ ...typeof env.TREESEED_TREEDX_ADMIN_TOKEN === "string" && env.TREESEED_TREEDX_ADMIN_TOKEN.trim() ? { TREESEED_TREEDX_ADMIN_TOKEN: env.TREESEED_TREEDX_ADMIN_TOKEN } : {}
729
+ },
730
+ env
731
+ }));
732
+ const volume = await treeDxStage(env, "volume", () => ensureRailwayServiceVolume({
733
+ projectId: ensuredProject2.project.id,
734
+ environmentId: ensuredEnvironment.environment.id,
735
+ serviceId: ensuredService.service.id,
736
+ name: volumeName,
737
+ mountPath,
738
+ env
739
+ }));
740
+ const domain = await treeDxStage(env, "domain", () => ensureRailwayGeneratedServiceDomain({
741
+ projectId: ensuredProject2.project.id,
742
+ environmentId: ensuredEnvironment.environment.id,
743
+ serviceId: ensuredService.service.id,
744
+ env
745
+ }));
746
+ await treeDxStage(env, "variables:domain", () => upsertRailwayVariables({
747
+ projectId: ensuredProject2.project.id,
748
+ environmentId: ensuredEnvironment.environment.id,
749
+ serviceId: ensuredService.service.id,
750
+ variables: {
751
+ PHX_HOST: domain.domain.domain,
752
+ TREEDX_JWT_ISSUER: `https://${domain.domain.domain}/treedx`
753
+ },
754
+ env
755
+ }));
756
+ const deployment = await treeDxStage(env, "deploy", () => deployRailwayServiceInstance({
757
+ serviceId: ensuredService.service.id,
758
+ environmentId: ensuredEnvironment.environment.id,
759
+ env
760
+ }));
761
+ results.push({
762
+ status: "ready",
763
+ locators: {
764
+ hostId: "railway",
765
+ projectGroupId: unit.projectGroup?.id ?? null,
766
+ projectName: projectName2,
767
+ serviceName,
768
+ domain: domain.domain.domain
769
+ },
770
+ state: {
771
+ unitId: unit.id,
772
+ serviceType: unit.serviceType.id,
773
+ placement: unit.placement,
774
+ projectId: ensuredProject2.project.id,
775
+ environmentId: ensuredEnvironment.environment.id,
776
+ serviceId: ensuredService.service.id,
777
+ imageRef,
778
+ volumeMountPath: volume.instance?.mountPath ?? mountPath,
779
+ healthcheckPath: instance.instance.healthcheckPath,
780
+ deploymentId: deployment.deploymentId
781
+ },
782
+ warnings: []
783
+ });
784
+ }
785
+ const firstUnit = nodeUnits[0];
786
+ const desiredIndexes = new Set(nodeUnits.map((unit) => Number(unit.metadata.nodeIndex ?? 1)).filter((index) => Number.isFinite(index) && index > 0));
787
+ const projectName = firstUnit.projectGroup?.environments?.[graph.environment]?.projectName || String(env.TREESEED_PUBLIC_TREEDX_RAILWAY_PROJECT_NAME ?? "").trim() || "treeseed-api";
788
+ const environmentName = normalizeRailwayEnvironmentName(firstUnit.projectGroup?.environments?.[graph.environment]?.environmentName) || ENVIRONMENT_NAMES[graph.environment];
789
+ const ensuredProject = await treeDxStage(env, "scale-down-project", () => ensureRailwayProject({
790
+ projectName,
791
+ defaultEnvironmentName: environmentName,
792
+ workspace: workspace.name,
793
+ env
794
+ }));
795
+ await treeDxStage(env, "scale-down-environment", () => ensureRailwayEnvironment({
796
+ projectId: ensuredProject.project.id,
797
+ environmentName,
798
+ env
799
+ }));
800
+ const services = await treeDxStage(env, "scale-down-services", () => listRailwayServices({ projectId: ensuredProject.project.id, env }));
801
+ for (const service of services) {
802
+ const match = /^public-treedx-node-(\d{2,})$/u.exec(service.name);
803
+ const index = match ? Number.parseInt(match[1], 10) : null;
804
+ const staleSingleton = service.name === "public-treedx-node" && desiredIndexes.has(1);
805
+ if (index !== null && !desiredIndexes.has(index) || staleSingleton) {
806
+ await treeDxStage(env, `scale-down-service:${service.name}`, () => deleteRailwayService({ serviceId: service.id, env }));
807
+ results.push({
808
+ status: "ready",
809
+ locators: {
810
+ hostId: "railway",
811
+ projectGroupId: firstUnit.projectGroup?.id ?? null,
812
+ projectName,
813
+ serviceName: service.name
814
+ },
815
+ state: {
816
+ unitId: service.name,
817
+ action: "delete",
818
+ retainedResources: [{
819
+ kind: "volume",
820
+ name: `${service.name}-volume`,
821
+ reason: "Stateful TreeDX volumes are retained across scale-down for later reclaim."
822
+ }]
823
+ },
824
+ warnings: [`Destroyed scaled-down TreeDX service ${service.name}; retained ${service.name}-volume.`]
825
+ });
826
+ }
827
+ }
828
+ return results;
829
+ }
830
+ async function applyTreeseedHostingGraph(input) {
831
+ const plan = await planTreeseedHostingGraph(input);
832
+ const graph = compileTreeseedHostingGraph(input);
833
+ const selectedSystems = railwayReconcileSystemsForUnits(graph.units);
834
+ const usesDefaultRailwayAdapter = !input.hostAdapters?.railway && !(Array.isArray(input.profiles) && input.profiles.length > 0);
835
+ if (!plan.dryRun && selectedSystems.length > 0 && usesDefaultRailwayAdapter) {
836
+ await reconcileTreeseedTarget({
837
+ tenantRoot: graph.tenantRoot,
838
+ target: createPersistentDeployTarget(graph.environment),
839
+ systems: selectedSystems,
840
+ env: railwayEnvForHostingApply(input, graph)
841
+ });
842
+ await reconcilePublicTreeDxUnits(input, graph);
843
+ await deploySelectedRailwayServices(input, graph);
844
+ }
845
+ const results = [];
846
+ for (const entry of plan.units) {
847
+ const unit = graph.units.find((candidate) => candidate.id === entry.unit.id) ?? entry.unit;
848
+ const result = await unit.host.apply({ environment: graph.environment, unit, graph, plan: entry.plan, dryRun: plan.dryRun });
849
+ const verification = await unit.host.verify({ environment: graph.environment, unit, graph, observed: result, dryRun: plan.dryRun });
850
+ results.push({ unit, plan: entry.plan, result, verification });
851
+ }
852
+ return {
853
+ environment: plan.environment,
854
+ dryRun: plan.dryRun,
855
+ selectedApps: [...new Set(graph.units.map((unit) => unit.application?.id).filter((value) => Boolean(value)))],
856
+ selectedSystems,
857
+ skippedSystems: ["web", "data", "github"].filter((system) => !selectedSystems.includes(system)).map((system) => ({ system, reason: selectedSystems.length > 0 ? "Not selected by hosting app filter." : "No Railway reconciliation selected." })),
858
+ transport: selectedSystems.length > 0 ? {
859
+ railway: {
860
+ reconcile: "api",
861
+ deploy: process.env.TREESEED_RAILWAY_DEPLOY_TRANSPORT === "cli-fallback" ? "cli-fallback" : "api"
862
+ }
863
+ } : void 0,
864
+ results,
865
+ placements: plan.placements,
866
+ warnings: plan.warnings
867
+ };
868
+ }
869
+ function serializeHostingUnit(unit) {
870
+ return sanitizedUnitConfig(unit);
871
+ }
872
+ function canonicalActionKind(value) {
873
+ const allowed = /* @__PURE__ */ new Set(["noop", "create", "update", "replace", "delete", "adopt", "rename", "reattach", "retain", "taint", "blocked"]);
874
+ return typeof value === "string" && allowed.has(value) ? value : "noop";
875
+ }
876
+ function canonicalHostingNode(unit, value) {
877
+ return {
878
+ id: unit.id,
879
+ provider: unit.host.id,
880
+ type: unit.serviceType.id,
881
+ owner: unit.application?.id ?? null,
882
+ environment: unit.environment,
883
+ spec: serializeHostingUnit(unit),
884
+ state: value,
885
+ locators: {
886
+ hostId: unit.host.id,
887
+ projectGroupId: unit.projectGroup?.id ?? null,
888
+ serviceTypeId: unit.serviceType.id
889
+ },
890
+ metadata: {
891
+ placement: unit.placement,
892
+ logicalName: unit.logicalName
893
+ }
894
+ };
895
+ }
896
+ function canonicalHostingDrift(unit, entries, fallbackReason) {
897
+ const rawEntries = Array.isArray(entries) ? entries : [];
898
+ if (rawEntries.length === 0) return [];
899
+ return rawEntries.map((entry, index) => ({
900
+ id: `${unit.id}:drift:${index + 1}`,
901
+ resourceId: unit.id,
902
+ severity: "blocking",
903
+ reason: typeof entry === "string" ? entry : fallbackReason,
904
+ provider: unit.host.id,
905
+ type: unit.serviceType.id,
906
+ observed: entry
907
+ }));
908
+ }
909
+ function canonicalHostingPostcondition(unit, verification) {
910
+ const issues = [
911
+ ...Array.isArray(verification.issues) ? verification.issues.map(String) : [],
912
+ ...Array.isArray(verification.checks) ? verification.checks.flatMap((check) => {
913
+ if (!check || typeof check !== "object") return [];
914
+ const maybeIssues = check.issues;
915
+ return Array.isArray(maybeIssues) ? maybeIssues.map(String) : [];
916
+ }) : []
917
+ ];
918
+ return {
919
+ id: `${unit.id}:verified`,
920
+ resourceId: unit.id,
921
+ description: `Live postconditions pass for ${unit.logicalName}.`,
922
+ source: "sdk",
923
+ required: true,
924
+ ok: verification.verified === true,
925
+ issues,
926
+ observed: verification
927
+ };
928
+ }
929
+ function hostingPlanReason(plan, prefix) {
930
+ return plan.reasons?.length ? plan.reasons.join("; ") : `${prefix} ${String(plan.action ?? "noop")}.`;
931
+ }
932
+ function canonicalHostingReportFromPlan(plan) {
933
+ const desiredGraph = plan.units.map((entry) => canonicalHostingNode(entry.unit));
934
+ const observedGraph = plan.units.map((entry) => canonicalHostingNode(entry.unit, entry.observed));
935
+ const diff = plan.units.flatMap((entry) => [
936
+ ...entry.plan.action && entry.plan.action !== "noop" ? [{
937
+ id: `${entry.unit.id}:diff`,
938
+ resourceId: entry.unit.id,
939
+ severity: canonicalActionKind(entry.plan.action) === "blocked" ? "blocking" : "info",
940
+ reason: hostingPlanReason(entry.plan, "Planned"),
941
+ provider: entry.unit.host.id,
942
+ type: entry.unit.serviceType.id,
943
+ expected: serializeHostingUnit(entry.unit),
944
+ observed: entry.observed
945
+ }] : [],
946
+ ...canonicalHostingDrift(entry.unit, entry.plan.blockedDrift, "Blocked provider drift.")
947
+ ]);
948
+ const providerLimitations = plan.units.flatMap((entry) => canonicalHostingDrift(entry.unit, entry.plan.providerLimitations, "Provider limitation."));
949
+ const actions = plan.units.map((entry) => ({
950
+ id: `${entry.unit.id}:${entry.plan.action ?? "noop"}`,
951
+ kind: canonicalActionKind(entry.plan.action),
952
+ resourceId: entry.unit.id,
953
+ reason: hostingPlanReason(entry.plan, "Planned"),
954
+ provider: entry.unit.host.id,
955
+ type: entry.unit.serviceType.id,
956
+ before: entry.observed,
957
+ after: serializeHostingUnit(entry.unit)
958
+ }));
959
+ return createTreeseedCanonicalReconcileReport({
960
+ desiredGraph,
961
+ observedGraph,
962
+ stateGraph: [],
963
+ diff,
964
+ actions,
965
+ postconditions: plan.units.map((entry) => canonicalHostingPostcondition(entry.unit, entry.verification)),
966
+ selectedResources: plan.units.map((entry) => entry.unit.id),
967
+ skippedResources: [],
968
+ blockedDrift: diff.filter((entry) => entry.severity === "blocking"),
969
+ providerLimitations,
970
+ retainedResources: plan.units.flatMap((entry) => (entry.plan.retainedResources ?? []).map((resource, index) => ({
971
+ id: `${entry.unit.id}:retained:${index + 1}`,
972
+ provider: entry.unit.host.id,
973
+ type: "retained-resource",
974
+ owner: entry.unit.application?.id ?? null,
975
+ state: resource
976
+ }))),
977
+ liveVerification: {
978
+ ok: plan.units.every((entry) => entry.verification.verified === true),
979
+ source: "hosting-plan",
980
+ issues: plan.units.filter((entry) => entry.verification.verified !== true).map((entry) => `${entry.unit.id}: verification did not pass`)
981
+ }
982
+ });
983
+ }
984
+ function canonicalHostingReportFromApplyResult(result) {
985
+ const desiredGraph = result.results.map((entry) => canonicalHostingNode(entry.unit));
986
+ const observedGraph = result.results.map((entry) => canonicalHostingNode(entry.unit, entry.result));
987
+ const diff = result.results.flatMap((entry) => [
988
+ ...entry.plan.action && entry.plan.action !== "noop" ? [{
989
+ id: `${entry.unit.id}:diff`,
990
+ resourceId: entry.unit.id,
991
+ severity: canonicalActionKind(entry.plan.action) === "blocked" ? "blocking" : "info",
992
+ reason: hostingPlanReason(entry.plan, "Applied"),
993
+ provider: entry.unit.host.id,
994
+ type: entry.unit.serviceType.id,
995
+ expected: serializeHostingUnit(entry.unit),
996
+ observed: entry.result
997
+ }] : [],
998
+ ...canonicalHostingDrift(entry.unit, entry.plan.blockedDrift, "Blocked provider drift.")
999
+ ]);
1000
+ const providerLimitations = result.results.flatMap((entry) => canonicalHostingDrift(entry.unit, entry.plan.providerLimitations, "Provider limitation."));
1001
+ const actions = result.results.map((entry) => ({
1002
+ id: `${entry.unit.id}:${entry.plan.action ?? "noop"}`,
1003
+ kind: canonicalActionKind(entry.plan.action),
1004
+ resourceId: entry.unit.id,
1005
+ reason: hostingPlanReason(entry.plan, "Applied"),
1006
+ provider: entry.unit.host.id,
1007
+ type: entry.unit.serviceType.id,
1008
+ before: entry.result,
1009
+ after: serializeHostingUnit(entry.unit)
1010
+ }));
1011
+ return createTreeseedCanonicalReconcileReport({
1012
+ desiredGraph,
1013
+ observedGraph,
1014
+ stateGraph: [],
1015
+ diff,
1016
+ actions,
1017
+ postconditions: result.results.map((entry) => canonicalHostingPostcondition(entry.unit, entry.verification)),
1018
+ selectedResources: result.results.map((entry) => entry.unit.id),
1019
+ skippedResources: result.skippedSystems.map((entry) => ({ id: entry.system, reason: entry.reason })),
1020
+ blockedDrift: diff.filter((entry) => entry.severity === "blocking"),
1021
+ providerLimitations,
1022
+ retainedResources: result.results.flatMap((entry) => (entry.plan.retainedResources ?? []).map((resource, index) => ({
1023
+ id: `${entry.unit.id}:retained:${index + 1}`,
1024
+ provider: entry.unit.host.id,
1025
+ type: "retained-resource",
1026
+ owner: entry.unit.application?.id ?? null,
1027
+ state: resource
1028
+ }))),
1029
+ liveVerification: {
1030
+ ok: result.results.every((entry) => entry.verification.verified === true),
1031
+ source: "hosting-apply",
1032
+ issues: result.results.filter((entry) => entry.verification.verified !== true).map((entry) => `${entry.unit.id}: verification did not pass after apply`)
1033
+ }
1034
+ });
1035
+ }
1036
+ function serializeHostingPlan(plan) {
1037
+ const selectedSystems = railwayReconcileSystemsForUnits(plan.units.map((entry) => entry.unit));
1038
+ const canonical = canonicalHostingReportFromPlan(plan);
1039
+ return {
1040
+ environment: plan.environment,
1041
+ dryRun: plan.dryRun,
1042
+ ...canonical,
1043
+ selectedApps: [...new Set(plan.units.map((entry) => entry.unit.application?.id).filter((value) => Boolean(value)))],
1044
+ selectedSystems,
1045
+ skippedSystems: ["web", "data", "github"].filter((system) => !selectedSystems.includes(system)).map((system) => ({ system, reason: selectedSystems.length > 0 ? "Not selected by hosting app filter." : "No Railway reconciliation selected." })),
1046
+ transport: selectedSystems.length > 0 ? {
1047
+ railway: {
1048
+ reconcile: "api",
1049
+ deploy: process.env.TREESEED_RAILWAY_DEPLOY_TRANSPORT === "cli-fallback" ? "cli-fallback" : "api"
1050
+ }
1051
+ } : void 0,
1052
+ placements: plan.placements,
1053
+ units: plan.units.map((entry) => ({
1054
+ unit: serializeHostingUnit(entry.unit),
1055
+ desired: serializeHostingUnit(entry.unit),
1056
+ observed: entry.observed,
1057
+ diff: entry.plan,
1058
+ actions: entry.plan.actions ?? [entry.plan.action],
1059
+ retainedResources: entry.plan.retainedResources ?? [],
1060
+ blockedDrift: entry.plan.blockedDrift ?? [],
1061
+ providerLimitations: entry.plan.providerLimitations ?? [],
1062
+ plan: entry.plan,
1063
+ verification: entry.verification
1064
+ })),
1065
+ warnings: plan.warnings
1066
+ };
1067
+ }
1068
+ function serializeHostingApplyResult(result) {
1069
+ const canonical = canonicalHostingReportFromApplyResult(result);
1070
+ return {
1071
+ environment: result.environment,
1072
+ dryRun: result.dryRun,
1073
+ ...canonical,
1074
+ selectedApps: result.selectedApps ?? [],
1075
+ selectedSystems: result.selectedSystems ?? [],
1076
+ skippedSystems: result.skippedSystems ?? [],
1077
+ transport: result.transport,
1078
+ placements: result.placements,
1079
+ results: result.results.map((entry) => ({
1080
+ unit: serializeHostingUnit(entry.unit),
1081
+ desired: serializeHostingUnit(entry.unit),
1082
+ observed: entry.result,
1083
+ diff: entry.plan,
1084
+ actions: entry.plan.actions ?? [entry.plan.action],
1085
+ retainedResources: entry.plan.retainedResources ?? [],
1086
+ blockedDrift: entry.plan.blockedDrift ?? [],
1087
+ providerLimitations: entry.plan.providerLimitations ?? [],
1088
+ plan: entry.plan,
1089
+ result: entry.result,
1090
+ verification: entry.verification
1091
+ })),
1092
+ warnings: result.warnings
1093
+ };
1094
+ }
1095
+ function hostingEnvironmentLabel(environment) {
1096
+ return ENVIRONMENT_NAMES[environment];
1097
+ }
1098
+ export {
1099
+ applyTreeseedHostingGraph,
1100
+ compileTreeseedHostingGraph,
1101
+ hostingEnvironmentLabel,
1102
+ planTreeseedHostingGraph,
1103
+ serializeHostingApplyResult,
1104
+ serializeHostingPlan,
1105
+ serializeHostingUnit
1106
+ };