@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
@@ -34,10 +34,7 @@ import {
34
34
  } from "../operations/services/deploy.js";
35
35
  import {
36
36
  configuredRailwayServices,
37
- deriveRailwayMarketOperationsRunnerVolumeName,
38
- ensureRailwayProjectContext,
39
- ensureRailwayServiceVolumeWithCliFallback,
40
- runRailway,
37
+ findStaleTreeseedOperationsRunnerResources,
41
38
  validateRailwayDeployPrerequisites
42
39
  } from "../operations/services/railway-deploy.js";
43
40
  import { shouldExposeManagedHostRuntimeSecret } from "../operations/services/managed-host-security.js";
@@ -47,19 +44,24 @@ import {
47
44
  ensureRailwayProject,
48
45
  ensureRailwayService,
49
46
  ensureRailwayServiceInstanceConfiguration,
47
+ ensureRailwayServiceVolume,
48
+ ensureRailwayPostgresService,
49
+ deleteRailwayService,
50
+ deleteRailwayVolume,
50
51
  getRailwayServiceInstance,
51
52
  getRailwayProject,
52
53
  listRailwayCustomDomains,
53
54
  listRailwayEnvironments,
54
55
  listRailwayProjects,
56
+ listRailwayServices,
55
57
  listRailwayVolumes,
56
58
  listRailwayVariables,
57
59
  resolveRailwayWorkspaceContext,
58
- updateRailwayServiceName,
59
60
  upsertRailwayVariables
60
61
  } from "../operations/services/railway-api.js";
61
62
  import { loadTreeseedReconcileState } from "./state.js";
62
63
  import { createTreeseedReconcileUnitId } from "./units.js";
64
+ import { discoverTreeseedApplications } from "../hosting/apps.js";
63
65
  function toDeployTarget(target) {
64
66
  return target.kind === "persistent" ? createPersistentDeployTarget(target.scope) : createBranchPreviewDeployTarget(target.branchName);
65
67
  }
@@ -103,28 +105,6 @@ function noopDiff() {
103
105
  after: {}
104
106
  };
105
107
  }
106
- function parseRailwayJsonPayload(output) {
107
- const text = typeof output === "string" ? output.trim() : "";
108
- if (!text) {
109
- return null;
110
- }
111
- try {
112
- return JSON.parse(text);
113
- } catch {
114
- }
115
- const lines = text.split(/\r?\n/u);
116
- for (let index = lines.length - 1; index >= 0; index -= 1) {
117
- const candidate = lines.slice(index).join("\n").trim();
118
- if (!candidate.startsWith("{") && !candidate.startsWith("[")) {
119
- continue;
120
- }
121
- try {
122
- return JSON.parse(candidate);
123
- } catch {
124
- }
125
- }
126
- return null;
127
- }
128
108
  function buildCompositeAdapter(unitType) {
129
109
  return {
130
110
  providerId: "treeseed",
@@ -132,10 +112,10 @@ function buildCompositeAdapter(unitType) {
132
112
  supports(candidateUnitType, providerId) {
133
113
  return candidateUnitType === unitType && providerId === "treeseed";
134
114
  },
135
- observe(input) {
115
+ refresh(input) {
136
116
  return noopObservedState(input);
137
117
  },
138
- plan() {
118
+ diff() {
139
119
  return noopDiff();
140
120
  },
141
121
  requiredPostconditions({ unit }) {
@@ -144,7 +124,7 @@ function buildCompositeAdapter(unitType) {
144
124
  description: `Dependency ${dependency} is verified`
145
125
  }));
146
126
  },
147
- reconcile({ unit, observed, diff }) {
127
+ apply({ unit, observed, diff }) {
148
128
  return {
149
129
  unit,
150
130
  observed,
@@ -329,7 +309,7 @@ function listCloudflareQueuesViaApi(env) {
329
309
  return Array.isArray(payload?.result) ? payload.result : [];
330
310
  }
331
311
  function cloudflareObservationSnapshot(input, forceRefresh = false) {
332
- const cacheKey = `cloudflare:observe:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
312
+ const cacheKey = `cloudflare:refresh:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
333
313
  return providerCache(input, cacheKey, () => {
334
314
  const target = toDeployTarget(input.context.target);
335
315
  const env = buildCloudflareEnv(input);
@@ -557,58 +537,30 @@ function normalizeRailwayDomainPayload(value) {
557
537
  };
558
538
  }
559
539
  async function ensureRailwayCustomDomain(input, service, domain, env, identifiers) {
560
- if (identifiers?.projectId && identifiers?.environmentId && identifiers?.serviceId) {
561
- const ensured = await ensureRailwayCustomDomainViaApi({
562
- projectId: identifiers.projectId,
563
- environmentId: identifiers.environmentId,
564
- serviceId: identifiers.serviceId,
565
- domain,
566
- env
567
- });
568
- if (ensured.domain) {
569
- return ensured.domain;
570
- }
540
+ if (!identifiers?.projectId || !identifiers.environmentId || !identifiers.serviceId) {
541
+ throw new Error(`Railway custom domain ${domain} cannot be reconciled without project, environment, and service ids.`);
571
542
  }
572
- ensureRailwayProjectContext(service, { env, capture: true });
573
- const result = runRailway(["domain", domain, "--service", service.serviceName ?? service.serviceId, "--json"], {
574
- cwd: service.rootDir,
575
- capture: true,
576
- allowFailure: true,
543
+ const existing = await listRailwayCustomDomains({
544
+ projectId: identifiers.projectId,
545
+ environmentId: identifiers.environmentId,
546
+ serviceId: identifiers.serviceId,
577
547
  env
578
548
  });
579
- const output = `${result.stderr ?? ""}
580
- ${result.stdout ?? ""}`;
581
- if (identifiers?.projectId && identifiers?.environmentId && identifiers?.serviceId) {
582
- const refreshed = await listRailwayCustomDomains({
583
- projectId: identifiers.projectId,
584
- environmentId: identifiers.environmentId,
585
- serviceId: identifiers.serviceId,
586
- env
587
- });
588
- const matched = refreshed.find((entry) => entry.domain === domain) ?? null;
589
- if (matched) {
590
- return matched;
591
- }
592
- }
593
- if (result.status !== 0 && !/already exists|already assigned|taken|has already been taken|not available/iu.test(output)) {
594
- throw new Error(result.stderr?.trim() || result.stdout?.trim() || `railway domain ${domain} failed`);
595
- }
596
- let parsedJson = {};
597
- if (result.stdout?.trim()) {
598
- try {
599
- parsedJson = JSON.parse(result.stdout);
600
- } catch {
601
- parsedJson = {};
602
- }
603
- }
604
- const parsed = normalizeRailwayDomainPayload(parsedJson);
605
- return parsed ?? {
606
- id: null,
549
+ const matched = existing.find((entry) => entry.domain === domain) ?? null;
550
+ if (matched) {
551
+ return matched;
552
+ }
553
+ const ensured = await ensureRailwayCustomDomainViaApi({
554
+ projectId: identifiers.projectId,
555
+ environmentId: identifiers.environmentId,
556
+ serviceId: identifiers.serviceId,
607
557
  domain,
608
- serviceDomain: null,
609
- certificateStatus: null,
610
- dnsRecords: []
611
- };
558
+ env
559
+ });
560
+ if (!ensured.domain) {
561
+ throw new Error(`Railway API custom domain reconciliation did not return ${domain}.`);
562
+ }
563
+ return ensured.domain;
612
564
  }
613
565
  function collectCloudflareEnvironmentSync(input) {
614
566
  const target = toDeployTarget(input.context.target);
@@ -1417,16 +1369,27 @@ function buildCloudflareDiff(input, observed) {
1417
1369
  after: input.unit.spec
1418
1370
  };
1419
1371
  }
1372
+ if (input.unit.unitType === "pages-project") {
1373
+ const verification = verifyCloudflareUnit(input, []);
1374
+ if (verification.supported && !verification.verified) {
1375
+ return {
1376
+ action: "update",
1377
+ reasons: [...verification.missing, ...verification.drifted],
1378
+ before: observed.live,
1379
+ after: input.unit.spec
1380
+ };
1381
+ }
1382
+ }
1420
1383
  const locatorValues = Object.values(observed.locators).filter(Boolean);
1421
1384
  return {
1422
- action: locatorValues.length > 0 ? "reuse" : "update",
1385
+ action: locatorValues.length > 0 ? "noop" : "update",
1423
1386
  reasons: locatorValues.length > 0 ? ["resource already present"] : ["resource partially configured"],
1424
1387
  before: observed.live,
1425
1388
  after: input.unit.spec
1426
1389
  };
1427
1390
  }
1428
1391
  function reconcileCloudflareUnit(input, diff) {
1429
- const cacheKey = `cloudflare:reconcile:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
1392
+ const cacheKey = `cloudflare:apply:${input.unit.target.kind === "persistent" ? input.unit.target.scope : input.unit.target.branchName}`;
1430
1393
  const { state } = providerCache(input, cacheKey, () => {
1431
1394
  let attempt = 0;
1432
1395
  for (; ; ) {
@@ -1449,7 +1412,7 @@ function reconcileCloudflareUnit(input, diff) {
1449
1412
  unit: input.unit,
1450
1413
  observed: refreshed,
1451
1414
  diff,
1452
- action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
1415
+ action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
1453
1416
  warnings: refreshed.warnings,
1454
1417
  resourceLocators: refreshed.locators,
1455
1418
  state: input.unit.unitType === "edge-worker" ? { workerName: state.workerName, lastDeployedUrl: state.lastDeployedUrl ?? null } : refreshed.live
@@ -1462,7 +1425,7 @@ function buildCloudflareAdapter(unitType) {
1462
1425
  supports(candidateUnitType, providerId) {
1463
1426
  return providerId === "cloudflare" && candidateUnitType === unitType;
1464
1427
  },
1465
- observe(input) {
1428
+ refresh(input) {
1466
1429
  return observeCloudflareUnit(input);
1467
1430
  },
1468
1431
  requiredPostconditions(input) {
@@ -1507,10 +1470,10 @@ function buildCloudflareAdapter(unitType) {
1507
1470
  return [];
1508
1471
  }
1509
1472
  },
1510
- plan(input) {
1473
+ diff(input) {
1511
1474
  return buildCloudflareDiff(input, input.observed);
1512
1475
  },
1513
- reconcile(input) {
1476
+ apply(input) {
1514
1477
  return reconcileCloudflareUnit(input, input.diff);
1515
1478
  },
1516
1479
  verify(input) {
@@ -1523,12 +1486,12 @@ function buildCloudflareAdapter(unitType) {
1523
1486
  unit: input.unit,
1524
1487
  observed: input.observed,
1525
1488
  diff: {
1526
- action: "destroy",
1489
+ action: "delete",
1527
1490
  reasons: ["target destroyed"],
1528
1491
  before: input.observed.live,
1529
1492
  after: {}
1530
1493
  },
1531
- action: "destroy",
1494
+ action: "delete",
1532
1495
  warnings: [],
1533
1496
  resourceLocators: {},
1534
1497
  state: {},
@@ -1541,6 +1504,27 @@ function relativeRailwayRootDir(tenantRoot, serviceRoot) {
1541
1504
  const resolved = relative(tenantRoot, serviceRoot).replace(/\\/gu, "/");
1542
1505
  return !resolved || resolved === "" ? "." : resolved;
1543
1506
  }
1507
+ function railwayServiceRootDirectory(tenantRoot, service) {
1508
+ return relativeRailwayRootDir(service.application?.root ?? tenantRoot, service.rootDir);
1509
+ }
1510
+ function configuredMarketDatabaseService(tenantRoot, deployConfig) {
1511
+ if (deployConfig.services?.treeseedDatabase) {
1512
+ return treeseedDatabaseDescriptor(deployConfig.services.treeseedDatabase, deployConfig.slug);
1513
+ }
1514
+ for (const application of discoverTreeseedApplications(tenantRoot)) {
1515
+ const service = application.config.services?.treeseedDatabase;
1516
+ if (service) {
1517
+ return treeseedDatabaseDescriptor(service, application.config.slug);
1518
+ }
1519
+ }
1520
+ return null;
1521
+ }
1522
+ function treeseedDatabaseDescriptor(service, slug) {
1523
+ return {
1524
+ service,
1525
+ serviceName: typeof service.railway?.serviceName === "string" && service.railway.serviceName.trim() ? service.railway.serviceName.trim() : `${slug ?? "treeseed-api"}-postgres`
1526
+ };
1527
+ }
1544
1528
  async function ensureRailwayEnvironmentForService({
1545
1529
  service,
1546
1530
  project,
@@ -1559,16 +1543,6 @@ async function ensureRailwayEnvironmentForService({
1559
1543
  throw error;
1560
1544
  }
1561
1545
  }
1562
- ensureRailwayProjectContext({
1563
- ...service,
1564
- projectId: project.id,
1565
- projectName: project.name ?? service.projectName,
1566
- railwayEnvironment: environmentName
1567
- }, {
1568
- env,
1569
- allowFailure: true,
1570
- capture: true
1571
- });
1572
1546
  for (let attempt = 0; attempt < 12; attempt += 1) {
1573
1547
  const environments = await listRailwayEnvironments({ projectId: project.id, env });
1574
1548
  const existing = environments.find((environment) => environment.name === environmentName || environment.id === environmentName) ?? null;
@@ -1577,7 +1551,7 @@ async function ensureRailwayEnvironmentForService({
1577
1551
  }
1578
1552
  await new Promise((resolve2) => setTimeout(resolve2, 2500));
1579
1553
  }
1580
- throw new Error(`Railway environment ${environmentName} was created through the CLI fallback but was not visible through the Railway API.`);
1554
+ throw new Error(`Railway API environment provisioning failed for ${project.name ?? service.projectName ?? project.id}/${environmentName}.`);
1581
1555
  }
1582
1556
  async function resolveRailwayTopologyForScope(input, scope, {
1583
1557
  ensure = false,
@@ -1592,6 +1566,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
1592
1566
  const env = buildRailwayEnv(input, scope);
1593
1567
  const deployState = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
1594
1568
  const services = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => normalizedServiceKeys.includes("__all__") || normalizedServiceKeys.includes(service.key));
1569
+ traceRailwayReconcile(env, "topology:start", `scope=${scope} ensure=${ensure ? "yes" : "no"} services=${services.map((service) => service.key).join(",")}`);
1595
1570
  let workspace = null;
1596
1571
  const knownProjects = [];
1597
1572
  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()))];
@@ -1626,6 +1601,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
1626
1601
  }
1627
1602
  const resolvedServices = /* @__PURE__ */ new Map();
1628
1603
  for (const service of services) {
1604
+ traceRailwayReconcile(env, "topology:service:start", service.key);
1629
1605
  const persistedService = deployState.services?.[service.key] ?? {};
1630
1606
  const resolvedProjectId = service.projectId ?? persistedService.projectId ?? "";
1631
1607
  const resolvedProjectName = service.projectName ?? persistedService.projectName ?? "";
@@ -1634,8 +1610,10 @@ async function resolveRailwayTopologyForScope(input, scope, {
1634
1610
  let project = projectsByKey.get(resolvedProjectId) ?? projectsByKey.get(resolvedProjectName) ?? null;
1635
1611
  if (!project && ensure) {
1636
1612
  if (!workspace) {
1613
+ traceRailwayReconcile(env, "topology:workspace", "resolving workspace");
1637
1614
  workspace = await resolveRailwayWorkspaceContext({ env });
1638
1615
  }
1616
+ traceRailwayReconcile(env, "topology:project:ensure", `${service.key}:${resolvedProjectName || resolvedProjectId}`);
1639
1617
  const ensuredProject = await ensureRailwayProject({
1640
1618
  projectId: resolvedProjectId,
1641
1619
  projectName: resolvedProjectName,
@@ -1649,6 +1627,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
1649
1627
  }
1650
1628
  let environment = project?.environments.find((entry) => entry.name === service.railwayEnvironment || entry.id === service.railwayEnvironment) ?? null;
1651
1629
  if (project && !environment && ensure) {
1630
+ traceRailwayReconcile(env, "topology:environment:ensure", `${service.key}:${service.railwayEnvironment}`);
1652
1631
  environment = await ensureRailwayEnvironmentForService({
1653
1632
  service,
1654
1633
  project,
@@ -1664,6 +1643,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
1664
1643
  }
1665
1644
  let resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
1666
1645
  if (project && !resolvedService && ensure) {
1646
+ traceRailwayReconcile(env, "topology:railway-service:ensure", `${service.key}:${resolvedServiceName || resolvedServiceId}`);
1667
1647
  resolvedService = (await ensureRailwayService({
1668
1648
  projectId: project.id,
1669
1649
  serviceId: resolvedServiceId,
@@ -1680,12 +1660,13 @@ async function resolveRailwayTopologyForScope(input, scope, {
1680
1660
  let instance = null;
1681
1661
  if (includeInstances && resolvedService && environment) {
1682
1662
  if (ensure) {
1663
+ traceRailwayReconcile(env, "topology:instance:ensure", `${service.key}:${resolvedService.name}`);
1683
1664
  instance = (await ensureRailwayServiceInstanceConfiguration({
1684
1665
  serviceId: resolvedService.id,
1685
1666
  environmentId: environment.id,
1686
1667
  buildCommand: service.buildCommand,
1687
1668
  startCommand: service.startCommand,
1688
- rootDirectory: relativeRailwayRootDir(input.context.tenantRoot, service.rootDir),
1669
+ rootDirectory: railwayServiceRootDirectory(input.context.tenantRoot, service),
1689
1670
  healthcheckPath: service.healthcheckPath,
1690
1671
  healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
1691
1672
  healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
@@ -1715,7 +1696,9 @@ async function resolveRailwayTopologyForScope(input, scope, {
1715
1696
  instance,
1716
1697
  currentVariables
1717
1698
  });
1699
+ traceRailwayReconcile(env, "topology:service:done", service.key);
1718
1700
  }
1701
+ traceRailwayReconcile(env, "topology:done", `scope=${scope}`);
1719
1702
  return {
1720
1703
  scope,
1721
1704
  env,
@@ -1727,20 +1710,171 @@ async function resolveRailwayTopologyForScope(input, scope, {
1727
1710
  };
1728
1711
  }, refresh);
1729
1712
  }
1713
+ function traceRailwayReconcile(env, stage, message) {
1714
+ if (env?.TREESEED_RECONCILE_TRACE === "1" || process.env.TREESEED_RECONCILE_TRACE === "1") {
1715
+ console.error(`[trsd][railway][${stage}] ${message}`);
1716
+ }
1717
+ }
1718
+ function railwayDriftSessionKey(scope) {
1719
+ return `railway:provider-drift:${scope}`;
1720
+ }
1721
+ function recordRailwayProviderDrift(input, scope, drift) {
1722
+ const key = railwayDriftSessionKey(scope);
1723
+ const current = input.context.session.get(key);
1724
+ const entries = Array.isArray(current) ? current : [];
1725
+ entries.push(drift);
1726
+ input.context.session.set(key, entries);
1727
+ }
1728
+ function railwayProviderDrift(input, scope) {
1729
+ const current = input.context.session.get(railwayDriftSessionKey(scope));
1730
+ return Array.isArray(current) ? current : [];
1731
+ }
1732
+ function activeRailwayVolumeInstances(volume) {
1733
+ const instances = Array.isArray(volume.instances) ? volume.instances : [];
1734
+ return instances.filter((instance) => {
1735
+ const state = String(instance.state ?? "READY").toUpperCase();
1736
+ return state !== "DELETING" && state !== "DELETED";
1737
+ });
1738
+ }
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;
1748
+ }
1749
+ await new Promise((resolve2) => setTimeout(resolve2, 1500));
1750
+ }
1751
+ return false;
1752
+ }
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;
1762
+ }
1763
+ await new Promise((resolve2) => setTimeout(resolve2, 1500));
1764
+ }
1765
+ return false;
1766
+ }
1767
+ async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
1768
+ const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
1769
+ const desiredRunners = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false);
1770
+ const desiredServiceNames = new Set(desiredRunners.map((service) => service.serviceName).filter(Boolean));
1771
+ const desiredVolumeNames = new Set([...desiredServiceNames].map((serviceName) => `${serviceName}-volume`));
1772
+ if (desiredServiceNames.size === 0) {
1773
+ traceRailwayReconcile(topology.env, "sync:runner:cleanup-skip", "no desired operations runner service names");
1774
+ return;
1775
+ }
1776
+ const projectEntries = [...topology.services.values()].filter((entry) => entry.project?.id && entry.environment?.id).map((entry) => ({ project: entry.project, environment: entry.environment }));
1777
+ traceRailwayReconcile(
1778
+ topology.env,
1779
+ "sync:runner:cleanup-projects",
1780
+ projectEntries.map((entry) => `${entry.project.name}:${entry.environment.name}`).join(",") || "(none)"
1781
+ );
1782
+ const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
1783
+ for (const { project, environment } of projects.values()) {
1784
+ await reconcileStaleOperationsRunnerResourcesForProject(input, {
1785
+ scope,
1786
+ env: topology.env,
1787
+ project,
1788
+ environment,
1789
+ desiredServiceNames,
1790
+ desiredVolumeNames
1791
+ });
1792
+ }
1793
+ }
1794
+ async function reconcileStaleOperationsRunnerResourcesForProject(input, {
1795
+ scope,
1796
+ env,
1797
+ project,
1798
+ environment,
1799
+ desiredServiceNames,
1800
+ desiredVolumeNames
1801
+ }) {
1802
+ if (desiredServiceNames.size === 0 || desiredVolumeNames.size === 0) {
1803
+ traceRailwayReconcile(env, "sync:runner:delete-skip", `${project.name}: no desired runner names; refusing to prune operations runner resources`);
1804
+ return;
1805
+ }
1806
+ const services = await listRailwayServices({ projectId: project.id, env });
1807
+ const desiredServiceIds = new Set(services.filter((service) => desiredServiceNames.has(service.name)).map((service) => service.id));
1808
+ const staleServices = findStaleTreeseedOperationsRunnerResources(services, desiredServiceNames);
1809
+ traceRailwayReconcile(
1810
+ env,
1811
+ "sync:runner:observed-services",
1812
+ `${project.name}: desired=${[...desiredServiceNames].join(",") || "(none)"} stale=${staleServices.map((service) => service.name).join(",") || "(none)"}`
1813
+ );
1814
+ 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
+ }
1830
+ }
1831
+ const volumes = await listRailwayVolumes({ projectId: project.id, env }).catch(() => []);
1832
+ const staleVolumes = findStaleTreeseedOperationsRunnerResources(volumes, desiredVolumeNames).filter((volume) => {
1833
+ const activeInstances = activeRailwayVolumeInstances(volume);
1834
+ return volume.instances.length === 0 || activeInstances.length > 0;
1835
+ }).filter((volume) => activeRailwayVolumeInstances(volume).every((instance) => !desiredServiceIds.has(instance.serviceId ?? "")));
1836
+ traceRailwayReconcile(
1837
+ env,
1838
+ "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
+ }
1857
+ }
1858
+ }
1730
1859
  async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
1731
1860
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
1732
1861
  const sync = collectRailwayEnvironmentSync(input);
1862
+ traceRailwayReconcile(input.context.env, "sync:start", `scope=${scope} dryRun=${dryRun ? "yes" : "no"}`);
1733
1863
  const topology = await resolveRailwayTopologyForScope(input, scope, {
1734
1864
  ensure: !dryRun,
1735
1865
  includeInstances: !dryRun,
1736
1866
  includeVariables: false
1737
1867
  });
1868
+ traceRailwayReconcile(topology.env, "sync:topology", `services=${[...topology.services.keys()].join(",")}`);
1738
1869
  if (!dryRun) {
1870
+ traceRailwayReconcile(topology.env, "sync:postgres:start", "ensuring Treeseed database");
1739
1871
  await ensureRailwayMarketDatabaseForScope(input, topology);
1872
+ traceRailwayReconcile(topology.env, "sync:postgres:done", "Treeseed database ensured");
1740
1873
  for (const entry of topology.services.values()) {
1741
1874
  if (!entry.project || !entry.environment || !entry.service) {
1742
1875
  continue;
1743
1876
  }
1877
+ traceRailwayReconcile(topology.env, "sync:variables", `${entry.configuredService.key}:${entry.service.name}`);
1744
1878
  const serviceSync = sync.forService(entry.configuredService.key);
1745
1879
  await upsertRailwayVariables({
1746
1880
  projectId: entry.project.id,
@@ -1753,60 +1887,42 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
1753
1887
  env: topology.env
1754
1888
  });
1755
1889
  if (entry.configuredService.volumeMountPath) {
1756
- const volumeName = entry.configuredService.key === "marketOperationsRunner" ? deriveRailwayMarketOperationsRunnerVolumeName(entry.service.name, entry.environment.name) : `${entry.service.name}-volume`;
1757
- const volume = await ensureRailwayServiceVolumeWithCliFallback({
1758
- tenantRoot: input.context.tenantRoot,
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({
1759
1893
  projectId: entry.project.id,
1760
1894
  environmentId: entry.environment.id,
1761
- environmentName: entry.environment.name,
1762
1895
  serviceId: entry.service.id,
1763
- serviceName: entry.service.name,
1764
1896
  name: volumeName,
1765
1897
  mountPath: entry.configuredService.volumeMountPath,
1766
- preferCli: entry.configuredService.key === "marketOperationsRunner",
1767
1898
  env: topology.env
1768
1899
  });
1769
1900
  if (volume.instance?.serviceId !== entry.service.id || volume.instance?.environmentId !== entry.environment.id || volume.instance?.mountPath !== entry.configuredService.volumeMountPath) {
1770
- ensureRailwayProjectContext(entry.configuredService, { env: topology.env, capture: true });
1771
- let attachMessage = "";
1772
- let attached = false;
1773
- for (let attempt = 0; attempt < 5; attempt += 1) {
1774
- const attachResult = runRailway([
1775
- "volume",
1776
- "--service",
1777
- entry.service.id,
1778
- "attach",
1779
- "--volume",
1780
- volume.volume.id,
1781
- "--yes",
1782
- "--json"
1783
- ], {
1784
- cwd: entry.configuredService.rootDir,
1785
- capture: true,
1786
- allowFailure: true,
1787
- env: topology.env
1788
- });
1789
- if ((attachResult.status ?? 1) === 0) {
1790
- attached = true;
1791
- break;
1792
- }
1793
- attachMessage = attachResult.stderr?.trim() || attachResult.stdout?.trim() || "";
1794
- if (/already mounted/iu.test(attachMessage)) {
1795
- attached = true;
1796
- break;
1797
- }
1798
- if (!/volume .*not found|not found|does not exist/iu.test(attachMessage)) {
1799
- break;
1800
- }
1801
- sleepMs(2e3 * (attempt + 1));
1802
- }
1803
- if (!attached) {
1804
- throw new Error(attachMessage || `Railway volume attach failed for ${entry.service.name}.`);
1805
- }
1901
+ throw new Error(`Railway API volume reconciliation did not mount ${volumeName} on ${entry.service.name} at ${entry.configuredService.volumeMountPath}.`);
1902
+ }
1903
+ }
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
+ });
1806
1919
  }
1807
1920
  }
1808
1921
  }
1922
+ traceRailwayReconcile(topology.env, "sync:runner:cleanup-stale", "reconciling old operations runner resources");
1923
+ await reconcileStaleOperationsRunnerResourcesForScope(input, topology);
1809
1924
  }
1925
+ traceRailwayReconcile(topology.env, "sync:done", `scope=${scope}`);
1810
1926
  return {
1811
1927
  scope,
1812
1928
  services: [...topology.services.values()].map((entry) => entry.configuredService),
@@ -1817,38 +1933,101 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
1817
1933
  };
1818
1934
  }
1819
1935
  async function ensureRailwayMarketDatabaseForScope(input, topology) {
1820
- const marketDatabaseService = input.context.deployConfig.services?.marketDatabase;
1821
- if (!marketDatabaseService || marketDatabaseService.enabled === false || marketDatabaseService.provider !== "railway" || marketDatabaseService.railway?.resourceType !== "postgres") {
1936
+ const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
1937
+ const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
1938
+ const treeseedDatabaseService = treeseedDatabase?.service;
1939
+ if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
1822
1940
  return;
1823
1941
  }
1824
1942
  const firstService = [...topology.services.values()].find((entry) => entry.project && entry.environment);
1825
1943
  if (!firstService?.project || !firstService.environment) {
1826
1944
  return;
1827
1945
  }
1828
- const baseName = typeof marketDatabaseService.railway?.serviceName === "string" && marketDatabaseService.railway.serviceName.trim() ? marketDatabaseService.railway.serviceName.trim() : `${input.context.deployConfig.slug ?? "treeseed-market"}-postgres`;
1829
- const serviceName = `${baseName.replace(/-(staging|prod|production)$/u, "")}-${topology.scope === "prod" ? "prod" : topology.scope}`;
1830
- let postgresService = firstService.project.services.find((entry) => entry.name === serviceName || entry.id === serviceName) ?? null;
1946
+ const serviceName = treeseedDatabase.serviceName;
1947
+ let postgresService;
1948
+ try {
1949
+ postgresService = (await ensureRailwayPostgresService({
1950
+ projectId: firstService.project.id,
1951
+ environmentId: firstService.environment.id,
1952
+ serviceName,
1953
+ env: topology.env
1954
+ })).service;
1955
+ } catch (error) {
1956
+ const detail = error instanceof Error ? error.message : String(error ?? "");
1957
+ throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. ${detail}`);
1958
+ }
1831
1959
  if (!postgresService) {
1832
- ensureRailwayProjectContext(firstService.configuredService, { env: topology.env, capture: true });
1833
- const addResult = runRailway(["add", "--database", "postgres", "--json"], {
1834
- cwd: firstService.configuredService.rootDir,
1835
- capture: true,
1836
- allowFailure: true,
1960
+ throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. No service was returned.`);
1961
+ }
1962
+ try {
1963
+ const postgresVolume = await ensureRailwayServiceVolume({
1964
+ projectId: firstService.project.id,
1965
+ environmentId: firstService.environment.id,
1966
+ serviceId: postgresService.id,
1967
+ name: `${serviceName}-volume`,
1968
+ mountPath: "/var/lib/postgresql/data",
1837
1969
  env: topology.env
1838
1970
  });
1839
- if ((addResult.status ?? 1) !== 0) {
1840
- throw new Error(addResult.stderr?.trim() || addResult.stdout?.trim() || `Railway database provisioning failed for ${serviceName}.`);
1971
+ if (postgresVolume.volume.name !== `${serviceName}-volume`) {
1972
+ throw new Error(`observed volume name ${postgresVolume.volume.name ?? "(unnamed)"}`);
1841
1973
  }
1842
- const payload = parseRailwayJsonPayload(addResult.stdout);
1843
- const createdServiceId = typeof payload?.serviceId === "string" && payload.serviceId.trim() ? payload.serviceId.trim() : "";
1844
- if (!createdServiceId) {
1845
- throw new Error(`Railway database provisioning did not return a service id for ${serviceName}.`);
1974
+ } catch (error) {
1975
+ const detail = error instanceof Error ? error.message : String(error ?? "");
1976
+ throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name ${serviceName}-volume: ${detail}`);
1977
+ }
1978
+ const services = await listRailwayServices({ projectId: firstService.project.id, env: topology.env });
1979
+ for (const service of services) {
1980
+ if (service.id === postgresService.id || service.name === serviceName) {
1981
+ continue;
1846
1982
  }
1847
- postgresService = await updateRailwayServiceName({
1848
- serviceId: createdServiceId,
1849
- name: serviceName,
1983
+ const variables = await listRailwayVariables({
1984
+ projectId: firstService.project.id,
1985
+ environmentId: firstService.environment.id,
1986
+ serviceId: service.id,
1850
1987
  env: topology.env
1851
- });
1988
+ }).catch(() => ({}));
1989
+ const looksLikePostgres = typeof variables.DATABASE_URL === "string" && typeof variables.PGHOST === "string" && typeof variables.PGUSER === "string" && typeof variables.PGPASSWORD === "string";
1990
+ if (looksLikePostgres) {
1991
+ traceRailwayReconcile(topology.env, "sync:postgres:delete-duplicate", `${service.name}:${service.id}`);
1992
+ await deleteRailwayService({ serviceId: service.id, env: topology.env });
1993
+ }
1994
+ }
1995
+ const detachedPostgresVolumes = await listRailwayVolumes({
1996
+ projectId: firstService.project.id,
1997
+ env: topology.env
1998
+ });
1999
+ for (const volume of detachedPostgresVolumes) {
2000
+ const postgresInstances = volume.instances.filter(
2001
+ (instance) => instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
2002
+ );
2003
+ const attachedToCanonical = postgresInstances.some((instance) => instance.serviceId === postgresService.id);
2004
+ 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
+ }
2030
+ }
1852
2031
  }
1853
2032
  for (let attempt = 0; attempt < 20; attempt += 1) {
1854
2033
  const existingVolumes = await listRailwayVolumes({
@@ -1926,10 +2105,10 @@ function collectRailwayEnvironmentSync(input) {
1926
2105
  const variables = Object.fromEntries(aggregateEntries("railway-var"));
1927
2106
  const apiOnlySecrets = {};
1928
2107
  const apiOnlyVariables = {};
1929
- const marketDatabaseService = input.context.deployConfig.services?.marketDatabase;
1930
- const marketDatabaseBaseName = typeof marketDatabaseService?.railway?.serviceName === "string" && marketDatabaseService.railway.serviceName.trim() ? marketDatabaseService.railway.serviceName.trim() : `${input.context.deployConfig.slug ?? "treeseed-market"}-postgres`;
1931
- const marketDatabaseServiceName = `${marketDatabaseBaseName.replace(/-(staging|prod|production)$/u, "")}-${scope === "prod" ? "prod" : scope}`;
1932
- const marketDatabaseUrl = typeof values.TREESEED_MARKET_DATABASE_URL === "string" && values.TREESEED_MARKET_DATABASE_URL.length > 0 ? values.TREESEED_MARKET_DATABASE_URL : marketDatabaseService?.enabled !== false && marketDatabaseService?.provider === "railway" ? `\${{${marketDatabaseServiceName}.DATABASE_URL}}` : "";
2108
+ const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
2109
+ const treeseedDatabaseService = treeseedDatabase?.service;
2110
+ const treeseedDatabaseServiceName = treeseedDatabase?.serviceName ?? "";
2111
+ 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}}` : "";
1933
2112
  if (typeof values.CLOUDFLARE_API_TOKEN === "string" && values.CLOUDFLARE_API_TOKEN.length > 0 && shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, "CLOUDFLARE_API_TOKEN")) {
1934
2113
  secrets.CLOUDFLARE_API_TOKEN = values.CLOUDFLARE_API_TOKEN;
1935
2114
  apiOnlySecrets.CLOUDFLARE_API_TOKEN = values.CLOUDFLARE_API_TOKEN;
@@ -1948,8 +2127,8 @@ function collectRailwayEnvironmentSync(input) {
1948
2127
  variables.TREESEED_API_D1_DATABASE_ID = apiD1DatabaseId;
1949
2128
  apiOnlyVariables.TREESEED_API_D1_DATABASE_ID = apiD1DatabaseId;
1950
2129
  }
1951
- if (marketDatabaseUrl) {
1952
- secrets.TREESEED_MARKET_DATABASE_URL = marketDatabaseUrl;
2130
+ if (treeseedDatabaseUrl) {
2131
+ secrets.TREESEED_DATABASE_URL = treeseedDatabaseUrl;
1953
2132
  }
1954
2133
  return {
1955
2134
  scope,
@@ -1959,11 +2138,12 @@ function collectRailwayEnvironmentSync(input) {
1959
2138
  return {
1960
2139
  secrets: {
1961
2140
  ...Object.fromEntries(entriesForService("railway-secret", serviceKey)),
1962
- ...marketDatabaseUrl && ["api", "marketOperationsRunner"].includes(serviceKey) ? { TREESEED_MARKET_DATABASE_URL: marketDatabaseUrl } : {},
2141
+ ...treeseedDatabaseUrl && ["api", "operationsRunner"].includes(serviceKey) ? { TREESEED_DATABASE_URL: treeseedDatabaseUrl } : {},
1963
2142
  ...serviceKey === "api" ? apiOnlySecrets : {}
1964
2143
  },
1965
2144
  variables: {
1966
2145
  ...Object.fromEntries(entriesForService("railway-var", serviceKey)),
2146
+ ...serviceKey === "operationsRunner" ? { TREESEED_MANAGER_ID: scope } : {},
1967
2147
  ...serviceKey === "api" ? apiOnlyVariables : {}
1968
2148
  }
1969
2149
  };
@@ -1980,7 +2160,7 @@ function buildAttachmentDiff(input, observed) {
1980
2160
  };
1981
2161
  }
1982
2162
  return {
1983
- action: observed.status === "ready" ? "reuse" : "update",
2163
+ action: observed.status === "ready" ? "noop" : "update",
1984
2164
  reasons: observed.status === "ready" ? ["attachment already present"] : ["attachment requires update"],
1985
2165
  before: observed.live,
1986
2166
  after: input.unit.spec
@@ -2186,7 +2366,7 @@ async function reconcileCustomDomainUnit(input, diff) {
2186
2366
  unit: input.unit,
2187
2367
  observed,
2188
2368
  diff,
2189
- action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
2369
+ action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
2190
2370
  warnings: observed.warnings,
2191
2371
  resourceLocators: observed.locators,
2192
2372
  state,
@@ -2223,7 +2403,7 @@ async function reconcileCustomDomainUnit(input, diff) {
2223
2403
  unit: input.unit,
2224
2404
  observed,
2225
2405
  diff,
2226
- action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
2406
+ action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
2227
2407
  warnings: observed.warnings,
2228
2408
  resourceLocators: observed.locators,
2229
2409
  state,
@@ -2251,7 +2431,7 @@ function reconcileDnsRecordUnit(input, diff) {
2251
2431
  unit: input.unit,
2252
2432
  observed,
2253
2433
  diff,
2254
- action: diff.action === "create" || diff.action === "update" ? "drift_correct" : diff.action,
2434
+ action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
2255
2435
  warnings: observed.warnings,
2256
2436
  resourceLocators: observed.locators,
2257
2437
  state: {
@@ -2334,7 +2514,7 @@ async function verifyRailwayUnit(input) {
2334
2514
  issues: startCommandMatches ? [] : ["Railway start command does not match the desired value."]
2335
2515
  }));
2336
2516
  }
2337
- const desiredRootDirectory = relativeRailwayRootDir(input.context.tenantRoot, service.rootDir);
2517
+ const desiredRootDirectory = railwayServiceRootDirectory(input.context.tenantRoot, service);
2338
2518
  if (desiredRootDirectory) {
2339
2519
  checks.push(verificationCheck("railway.instance.root-directory", "Railway root directory matches desired config", "api", {
2340
2520
  exists: Boolean(entry.instance?.id),
@@ -2435,7 +2615,10 @@ async function verifyRailwayUnit(input) {
2435
2615
  issues: Object.hasOwn(entry.currentVariables, key) ? [] : [`Railway secret ${key} is missing.`]
2436
2616
  }));
2437
2617
  }
2438
- const verification = summarizeVerification(input.unit.unitId, checks);
2618
+ const providerDriftWarnings = railwayProviderDrift(input, scope).map(
2619
+ (drift) => `Railway provider drift remains unresolved: ${String(drift.reason ?? drift.kind ?? "unknown drift")}`
2620
+ );
2621
+ const verification = summarizeVerification(input.unit.unitId, checks, providerDriftWarnings);
2439
2622
  if (!verification.verified && attempt < 12 && railwayVerificationMaySettle(verification)) {
2440
2623
  attempt += 1;
2441
2624
  sleepMs(5e3);
@@ -2460,7 +2643,7 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
2460
2643
  if (observed === expected) {
2461
2644
  return true;
2462
2645
  }
2463
- if (serviceKey !== "marketOperationsRunner") {
2646
+ if (serviceKey !== "operationsRunner") {
2464
2647
  return false;
2465
2648
  }
2466
2649
  const normalizedObserved = String(observed ?? "").trim().replace(/\s+/gu, " ");
@@ -2469,7 +2652,7 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
2469
2652
  return true;
2470
2653
  }
2471
2654
  const allowedInlineEnv = [
2472
- "TREESEED_MARKET_ID",
2655
+ "TREESEED_MANAGER_ID",
2473
2656
  "TREESEED_PLATFORM_RUNNER_ID",
2474
2657
  "TREESEED_PLATFORM_RUNNER_DATA_DIR",
2475
2658
  "TREESEED_PLATFORM_RUNNER_ENVIRONMENT"
@@ -2490,7 +2673,7 @@ function buildRailwayDiff(input, observed) {
2490
2673
  };
2491
2674
  }
2492
2675
  return {
2493
- action: observed.status === "ready" ? "reuse" : "update",
2676
+ action: observed.status === "ready" ? "noop" : "update",
2494
2677
  reasons: observed.status === "ready" ? ["service already configured"] : ["service requires configuration sync"],
2495
2678
  before: observed.live,
2496
2679
  after: input.unit.spec
@@ -2513,7 +2696,7 @@ async function reconcileRailwayUnit(input, diff) {
2513
2696
  unit: input.unit,
2514
2697
  observed: refreshed,
2515
2698
  diff,
2516
- action: diff.action === "update" || diff.action === "create" ? "drift_correct" : diff.action,
2699
+ action: diff.action === "update" || diff.action === "create" ? "update" : diff.action,
2517
2700
  warnings: refreshed.warnings,
2518
2701
  resourceLocators: refreshed.locators,
2519
2702
  state: refreshed.live,
@@ -2533,13 +2716,13 @@ function buildRailwayAdapter(unitType) {
2533
2716
  env: buildRailwayEnv(input, scope)
2534
2717
  });
2535
2718
  },
2536
- observe(input) {
2719
+ refresh(input) {
2537
2720
  return observeRailwayUnit(input);
2538
2721
  },
2539
- plan(input) {
2722
+ diff(input) {
2540
2723
  return buildRailwayDiff(input, input.observed);
2541
2724
  },
2542
- reconcile(input) {
2725
+ apply(input) {
2543
2726
  return reconcileRailwayUnit(input, input.diff);
2544
2727
  },
2545
2728
  requiredPostconditions() {
@@ -2569,7 +2752,7 @@ function buildCustomDomainAdapter(unitType, providerId) {
2569
2752
  });
2570
2753
  }
2571
2754
  },
2572
- observe(input) {
2755
+ refresh(input) {
2573
2756
  return observeCustomDomainUnit(input);
2574
2757
  },
2575
2758
  requiredPostconditions() {
@@ -2578,10 +2761,10 @@ function buildCustomDomainAdapter(unitType, providerId) {
2578
2761
  ...providerId === "railway" ? [{ key: "custom-domain.dns-requirements", description: "Custom domain exposes DNS requirements" }] : []
2579
2762
  ];
2580
2763
  },
2581
- plan(input) {
2764
+ diff(input) {
2582
2765
  return buildAttachmentDiff(input, input.observed);
2583
2766
  },
2584
- reconcile(input) {
2767
+ apply(input) {
2585
2768
  return reconcileCustomDomainUnit(input, input.diff);
2586
2769
  },
2587
2770
  verify(input) {
@@ -2596,7 +2779,7 @@ function buildDnsRecordAdapter() {
2596
2779
  supports(candidateUnitType, providerId) {
2597
2780
  return candidateUnitType === "dns-record" && providerId === "cloudflare-dns";
2598
2781
  },
2599
- observe(input) {
2782
+ refresh(input) {
2600
2783
  return observeDnsRecordUnit(input);
2601
2784
  },
2602
2785
  requiredPostconditions(input) {
@@ -2606,10 +2789,10 @@ function buildDnsRecordAdapter() {
2606
2789
  description: `DNS record ${record.type} ${record.name} matches the desired value`
2607
2790
  }));
2608
2791
  },
2609
- plan(input) {
2792
+ diff(input) {
2610
2793
  return buildAttachmentDiff(input, input.observed);
2611
2794
  },
2612
- reconcile(input) {
2795
+ apply(input) {
2613
2796
  return reconcileDnsRecordUnit(input, input.diff);
2614
2797
  },
2615
2798
  verify(input) {
@@ -2634,12 +2817,12 @@ function createCloudflareReconcileAdapters() {
2634
2817
  function createRailwayReconcileAdapters() {
2635
2818
  return [
2636
2819
  buildRailwayAdapter("railway-service:api"),
2637
- buildRailwayAdapter("railway-service:market-operations-runner"),
2820
+ buildRailwayAdapter("railway-service:operations-runner"),
2638
2821
  buildRailwayAdapter("railway-service:workday-manager"),
2639
2822
  buildRailwayAdapter("railway-service:worker-runner"),
2640
2823
  buildCustomDomainAdapter("custom-domain:api", "railway"),
2641
2824
  buildCompositeAdapter("api-runtime"),
2642
- buildCompositeAdapter("market-operations-runner-runtime"),
2825
+ buildCompositeAdapter("operations-runner-runtime"),
2643
2826
  buildCompositeAdapter("workday-manager-runtime"),
2644
2827
  buildCompositeAdapter("worker-runner-runtime")
2645
2828
  ];