@treeseed/sdk 0.12.60 → 0.12.61

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 (44) hide show
  1. package/dist/guarantees/index.js +59 -56
  2. package/dist/hosting/contracts.d.ts +0 -19
  3. package/dist/hosting/graph.d.ts +1 -86
  4. package/dist/hosting/graph.js +96 -245
  5. package/dist/local-dev/managed-dev.js +30 -8
  6. package/dist/managed-dependencies.d.ts +3 -0
  7. package/dist/managed-dependencies.js +294 -20
  8. package/dist/operations/services/deploy.js +5 -5
  9. package/dist/operations/services/deployment-readiness.js +5 -4
  10. package/dist/operations/services/git-runner.d.ts +2 -0
  11. package/dist/operations/services/git-runner.js +23 -2
  12. package/dist/operations/services/hosted-service-checks.js +28 -0
  13. package/dist/operations/services/live-hosted-service-checks.js +51 -15
  14. package/dist/operations/services/local-cleanup.d.ts +1 -0
  15. package/dist/operations/services/local-cleanup.js +28 -9
  16. package/dist/operations/services/package-adapters.js +3 -3
  17. package/dist/operations/services/railway-api.d.ts +72 -28
  18. package/dist/operations/services/railway-api.js +321 -876
  19. package/dist/operations/services/railway-cli.d.ts +47 -0
  20. package/dist/operations/services/railway-cli.js +142 -0
  21. package/dist/operations/services/railway-deploy.d.ts +2 -2
  22. package/dist/operations/services/railway-deploy.js +36 -91
  23. package/dist/operations/services/railway-source-policy.d.ts +6 -0
  24. package/dist/operations/services/railway-source-policy.js +52 -9
  25. package/dist/operations/services/repository-save-orchestrator.d.ts +2 -0
  26. package/dist/operations/services/repository-save-orchestrator.js +45 -14
  27. package/dist/operations-types.d.ts +3 -1
  28. package/dist/platform/contracts.d.ts +1 -0
  29. package/dist/platform/deploy-config.js +2 -1
  30. package/dist/reconcile/builtin-adapters.js +519 -684
  31. package/dist/reconcile/desired-state.js +5 -3
  32. package/dist/reconcile/engine.js +34 -28
  33. package/dist/reconcile/live-acceptance.js +2 -11
  34. package/dist/reconcile/providers/railway-iac.d.ts +147 -0
  35. package/dist/reconcile/providers/railway-iac.js +289 -16
  36. package/dist/scenes/runner.js +11 -11
  37. package/dist/scripts/build-dist.js +22 -0
  38. package/dist/workflow/operations.d.ts +12 -0
  39. package/dist/workflow/operations.js +265 -90
  40. package/dist/workflow/runs.d.ts +4 -0
  41. package/dist/workflow/runs.js +4 -0
  42. package/dist/workflow-support.d.ts +1 -1
  43. package/dist/workflow-support.js +3 -1
  44. package/package.json +1 -2
@@ -1,8 +1,7 @@
1
1
  import { createHmac, randomBytes, randomUUID } from "node:crypto";
2
2
  import { spawnSync } from "node:child_process";
3
- import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, statSync } from "node:fs";
4
4
  import { readdir, readFile } from "node:fs/promises";
5
- import { tmpdir } from "node:os";
6
5
  import { dirname, relative, resolve } from "node:path";
7
6
  import { resolveCapacityProviderLaunchPlan } from "../capacity-provider.js";
8
7
  import { TreeDxClient } from "../treedx-client.js";
@@ -43,22 +42,23 @@ import {
43
42
  configuredRailwayServices,
44
43
  findStaleTreeseedOperationsRunnerResources,
45
44
  isTreeseedOperationsRunnerResourceName,
46
- railwayLegacyAliasMigrationPolicy,
45
+ railwayObsoleteAliasCleanupPolicy,
47
46
  validateRailwayDeployPrerequisites
48
47
  } from "../operations/services/railway-deploy.js";
49
48
  import { shouldExposeManagedHostRuntimeSecret } from "../operations/services/managed-host-security.js";
49
+ import { assertNoRailwaySourceIdentityCollisions } from "../operations/services/railway-source-policy.js";
50
+ import {
51
+ attachRailwayVolumeWithCli,
52
+ detachRailwayVolumeWithCli,
53
+ updateRailwayVolumeWithCli
54
+ } from "../operations/services/railway-cli.js";
50
55
  import {
51
56
  ensureRailwayEnvironment,
52
- ensureRailwayCustomDomain as ensureRailwayCustomDomainViaApi,
53
57
  ensureRailwayProject,
54
- ensureRailwayService,
55
- ensureRailwayServiceInstanceConfiguration,
56
- ensureRailwayServiceVolume,
57
- ensureRailwayPostgresService,
58
+ ensureRailwayCustomDomain,
58
59
  deleteRailwayService,
59
60
  deleteRailwayCustomDomain,
60
61
  deleteRailwayVolume,
61
- deployRailwayServiceInstance,
62
62
  inspectRailwayServiceDeploymentHealth,
63
63
  listRailwayEnvironmentServices,
64
64
  getRailwayServiceInstance,
@@ -70,12 +70,28 @@ import {
70
70
  listRailwayServices,
71
71
  listRailwayVolumes,
72
72
  listRailwayVariables,
73
- resolveRailwayWorkspaceContext,
74
- updateRailwayServiceGitSource,
75
- updateRailwayServiceImageSource
73
+ resolveRailwayWorkspaceContext
76
74
  } from "../operations/services/railway-api.js";
77
75
  import { loadTreeseedReconcileState } from "./state.js";
78
76
  import { createTreeseedReconcileUnitId } from "./units.js";
77
+ import {
78
+ applyRailwayIacProject,
79
+ cleanupRailwayIacRender,
80
+ detachRetainedRailwayCustomDomains,
81
+ detachRetainedRailwayVolumeBindings,
82
+ findRailwayPendingVolumeNameCollisions,
83
+ planRailwayIacProject,
84
+ railwayIacApplyFailure,
85
+ renderRailwayIacProject,
86
+ resolveRailwayIacVolumeBindings,
87
+ waitForRailwayVolumeAdoptionResources,
88
+ waitForRailwayVolumeDetachment,
89
+ waitForRailwayVolumeName,
90
+ waitForRailwayServiceAbsence,
91
+ waitForRailwayServices,
92
+ selectRailwayIacRetainedResources,
93
+ validateRailwayIacChangeSet
94
+ } from "./providers/railway-iac.js";
79
95
  import { discoverTreeseedApplications } from "../hosting/apps.js";
80
96
  import { findTreeseedPackageAdapter, syncTreeseedPackageWorkflows } from "../operations/services/package-adapters.js";
81
97
  import { resolveGitHubCredentialForRepository } from "../operations/services/github-credentials.js";
@@ -2349,6 +2365,8 @@ function normalizeRailwayDomainPayload(value) {
2349
2365
  return {
2350
2366
  id: typeof record.id === "string" ? record.id.trim() : null,
2351
2367
  domain,
2368
+ serviceId: typeof record.serviceId === "string" ? record.serviceId.trim() : null,
2369
+ serviceName: typeof record.serviceName === "string" ? record.serviceName.trim() : null,
2352
2370
  serviceDomain,
2353
2371
  verified: record.verified === true || status.verified === true || verification.verified === true,
2354
2372
  certificateStatus: firstRailwayDomainString(
@@ -2361,94 +2379,29 @@ function normalizeRailwayDomainPayload(value) {
2361
2379
  dnsRecords: effectiveDnsRecords
2362
2380
  };
2363
2381
  }
2364
- async function ensureRailwayCustomDomain(input, service, domain, env, identifiers) {
2365
- if (!identifiers?.projectId || !identifiers.environmentId || !identifiers.serviceId) {
2366
- throw new Error(`Railway custom domain ${domain} cannot be reconciled without project, environment, and service ids.`);
2367
- }
2368
- const existing = await listRailwayCustomDomains({
2369
- projectId: identifiers.projectId,
2370
- environmentId: identifiers.environmentId,
2371
- serviceId: identifiers.serviceId,
2372
- env
2373
- });
2374
- const matched = existing.find((entry) => entry.domain === domain) ?? null;
2375
- if (matched) {
2376
- return matched;
2377
- }
2378
- const environmentServices = await listRailwayEnvironmentServices({
2379
- environmentId: identifiers.environmentId,
2380
- env
2381
- });
2382
- for (const candidate of environmentServices) {
2383
- const candidateServiceId = typeof candidate.id === "string" ? candidate.id.trim() : "";
2384
- if (!candidateServiceId || candidateServiceId === identifiers.serviceId) {
2385
- continue;
2386
- }
2387
- const candidateDomains = await listRailwayCustomDomains({
2388
- projectId: identifiers.projectId,
2389
- environmentId: identifiers.environmentId,
2390
- serviceId: candidateServiceId,
2391
- env
2392
- });
2393
- const staleAttachment = candidateDomains.find((entry) => entry.domain === domain) ?? null;
2394
- if (!staleAttachment) {
2395
- continue;
2396
- }
2397
- if (!staleAttachment.id) {
2398
- throw new Error(`Railway custom domain ${domain} is attached to service ${candidateServiceId}, but Railway did not return its domain id for exact-state reattachment.`);
2399
- }
2400
- await deleteRailwayCustomDomain({ domainId: staleAttachment.id, env });
2401
- let detached = false;
2402
- for (let attempt = 0; attempt < 12; attempt += 1) {
2403
- const remaining = await listRailwayCustomDomains({
2404
- projectId: identifiers.projectId,
2405
- environmentId: identifiers.environmentId,
2406
- serviceId: candidateServiceId,
2407
- env
2408
- });
2409
- if (!remaining.some((entry) => entry.domain === domain)) {
2410
- detached = true;
2411
- break;
2412
- }
2413
- await new Promise((resolve2) => setTimeout(resolve2, 1e3));
2414
- }
2415
- if (!detached) {
2416
- throw new Error(`Railway custom domain ${domain} remained attached to service ${candidateServiceId} after deletion.`);
2417
- }
2418
- break;
2419
- }
2420
- const ensured = await ensureRailwayCustomDomainViaApi({
2421
- projectId: identifiers.projectId,
2422
- environmentId: identifiers.environmentId,
2423
- serviceId: identifiers.serviceId,
2424
- domain,
2425
- env
2426
- });
2427
- if (!ensured.domain) {
2428
- throw new Error(`Railway API custom domain reconciliation did not return ${domain}.`);
2429
- }
2430
- return ensured.domain;
2431
- }
2432
2382
  async function observeRailwayCustomDomainLive(input, domain) {
2433
2383
  if (!domain) {
2434
2384
  return null;
2435
2385
  }
2436
2386
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
2437
2387
  const serviceKey = String(input.unit.metadata.serviceKey ?? "api").trim();
2438
- const topology = await resolveRailwayTopologyForScope(input, scope, {
2439
- refresh: true,
2440
- serviceKeys: [serviceKey],
2388
+ const topology = await resolveRailwayUnitTopology(input, scope, {
2389
+ refresh: false,
2441
2390
  includeInstances: false,
2442
2391
  includeVariables: false
2443
2392
  });
2444
- const entry = topology.services.get(serviceKey) ?? null;
2393
+ const entry = findRailwayTopologyEntry(topology, serviceKey);
2445
2394
  const identifiers = {
2446
2395
  projectId: entry?.project?.id ?? null,
2447
2396
  environmentId: entry?.environment?.id ?? null,
2448
2397
  serviceId: entry?.service?.id ?? null
2449
2398
  };
2450
2399
  if (!identifiers.projectId || !identifiers.environmentId || !identifiers.serviceId) {
2451
- return null;
2400
+ return {
2401
+ domain: null,
2402
+ serviceId: null,
2403
+ serviceName: entry?.configuredService?.serviceName ?? null
2404
+ };
2452
2405
  }
2453
2406
  const [customDomains, serviceDomains] = await Promise.all([
2454
2407
  listRailwayCustomDomains({
@@ -2471,9 +2424,15 @@ async function observeRailwayCustomDomainLive(input, domain) {
2471
2424
  const serviceDomain = serviceDomains.find((entry2) => entry2.kind === "service") ?? null;
2472
2425
  const normalized = normalizeRailwayDomainPayload({
2473
2426
  ...customDomain,
2427
+ serviceId: identifiers.serviceId,
2428
+ serviceName: entry?.configuredService?.serviceName ?? null,
2474
2429
  serviceDomain: serviceDomain?.domain ?? null
2475
2430
  });
2476
- return normalized?.domain ? normalized : customDomain;
2431
+ return normalized?.domain ? normalized : {
2432
+ ...customDomain,
2433
+ serviceId: identifiers.serviceId,
2434
+ serviceName: entry?.configuredService?.serviceName ?? null
2435
+ };
2477
2436
  }
2478
2437
  function railwayCustomDomainHasDnsRequirements(live) {
2479
2438
  if (!live) {
@@ -3421,7 +3380,6 @@ async function ensureRailwayEnvironmentForService({
3421
3380
  }
3422
3381
  async function resolveRailwayTopologyForScope(input, scope, {
3423
3382
  ensure = false,
3424
- ensureServices = ensure,
3425
3383
  refresh = false,
3426
3384
  serviceKeys,
3427
3385
  includeInstances = ensure,
@@ -3549,71 +3507,14 @@ async function resolveRailwayTopologyForScope(input, scope, {
3549
3507
  traceRailwayReconcile(env, "topology:environment-services:resolved", `${service.key}:${environmentServices.map((entry) => entry.name).join(",")}`);
3550
3508
  }
3551
3509
  }
3552
- let resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
3553
- if (project && !resolvedService && ensureServices) {
3554
- traceRailwayReconcile(env, "topology:railway-service:ensure", `${service.key}:${resolvedServiceName || resolvedServiceId}`);
3555
- resolvedService = (await ensureRailwayService({
3556
- projectId: project.id,
3557
- serviceId: resolvedServiceId,
3558
- serviceName: resolvedServiceName,
3559
- environmentId: environment?.id,
3560
- imageRef: service.imageRef,
3561
- sourceRepo: service.sourceRepo,
3562
- sourceBranch: service.sourceBranch,
3563
- env
3564
- })).service;
3565
- project = {
3566
- ...project,
3567
- services: [...project.services.filter((entry) => entry.id !== resolvedService?.id), resolvedService]
3568
- };
3569
- projectsByKey.set(project.id, project);
3570
- projectsByKey.set(project.name, project);
3571
- } else if (project && resolvedService && ensureServices && (service.imageRef || service.sourceRepo)) {
3572
- traceRailwayReconcile(env, "topology:railway-service:repair-source", `${service.key}:${resolvedService.name}`);
3573
- try {
3574
- resolvedService = (await ensureRailwayService({
3575
- projectId: project.id,
3576
- serviceId: resolvedService.id,
3577
- serviceName: resolvedServiceName || resolvedService.name,
3578
- environmentId: environment?.id,
3579
- imageRef: service.imageRef,
3580
- sourceRepo: service.sourceRepo,
3581
- sourceBranch: service.sourceBranch,
3582
- env
3583
- })).service;
3584
- } catch (error) {
3585
- if (!looksLikeRailwayExistingSourceRepairUnsupported(error)) {
3586
- throw error;
3587
- }
3588
- traceRailwayReconcile(env, "topology:railway-service:repair-source-skipped", `${service.key}:${resolvedService.name}`);
3589
- }
3590
- }
3510
+ const resolvedService = project?.services.find((entry) => entry.id === resolvedServiceId || entry.name === resolvedServiceName) ?? null;
3591
3511
  let instance = null;
3592
3512
  if (includeInstances && resolvedService && environment) {
3593
- if (ensureServices) {
3594
- traceRailwayReconcile(env, "topology:instance:ensure", `${service.key}:${resolvedService.name}`);
3595
- instance = (await ensureRailwayServiceInstanceConfiguration({
3596
- serviceId: resolvedService.id,
3597
- environmentId: environment.id,
3598
- buildCommand: service.buildCommand,
3599
- dockerfilePath: !service.imageRef ? service.dockerfilePath ?? (existsSync(resolve(service.rootDir, "Dockerfile")) ? "/Dockerfile" : null) : null,
3600
- startCommand: service.startCommand,
3601
- rootDirectory: service.imageRef || service.sourceMode === "image" ? null : railwayServiceRootDirectory(input.context.tenantRoot, service),
3602
- healthcheckPath: service.healthcheckPath,
3603
- healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
3604
- healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
3605
- restartPolicy: service.restartPolicy,
3606
- runtimeMode: service.runtimeMode,
3607
- clearSourceConfiguration: service.sourceMode === "image" || Boolean(service.imageRef),
3608
- env
3609
- })).instance;
3610
- } else {
3611
- instance = await getRailwayServiceInstance({
3612
- serviceId: resolvedService.id,
3613
- environmentId: environment.id,
3614
- env
3615
- });
3616
- }
3513
+ instance = await getRailwayServiceInstance({
3514
+ serviceId: resolvedService.id,
3515
+ environmentId: environment.id,
3516
+ env
3517
+ });
3617
3518
  }
3618
3519
  const currentVariables = includeVariables && project && environment && resolvedService ? await listRailwayVariables({
3619
3520
  projectId: project.id,
@@ -3621,7 +3522,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
3621
3522
  serviceId: resolvedService.id,
3622
3523
  env
3623
3524
  }) : {};
3624
- resolvedServices.set(service.key, {
3525
+ resolvedServices.set(service.instanceKey ?? service.serviceName ?? service.key, {
3625
3526
  configuredService: service,
3626
3527
  project,
3627
3528
  environment,
@@ -3711,6 +3612,16 @@ function configuredRailwaySiblingResourceNames(input, scope, projectName) {
3711
3612
  resolveReconcileEnvironmentValues(input, siblingScope),
3712
3613
  { identityOnly: true }
3713
3614
  ).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service)).filter((service) => service.projectName === projectName);
3615
+ const currentServices = configuredRailwayServices(
3616
+ input.context.tenantRoot,
3617
+ scope,
3618
+ resolveReconcileEnvironmentValues(input, scope),
3619
+ { identityOnly: true }
3620
+ ).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service)).filter((service) => service.projectName === projectName);
3621
+ assertNoRailwaySourceIdentityCollisions([
3622
+ ...currentServices.map((service) => ({ ...service, environment: scope })),
3623
+ ...siblingServices.map((service) => ({ ...service, environment: siblingScope }))
3624
+ ]);
3714
3625
  const database = configuredRailwayIacDatabase(input, siblingServices);
3715
3626
  return [.../* @__PURE__ */ new Set([
3716
3627
  ...siblingServices.flatMap((service) => [
@@ -3737,6 +3648,14 @@ function railwayIacServiceInput(input, sync, service, scope) {
3737
3648
  ...service.sourceBranch ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_BRANCH`]: service.sourceBranch } : {},
3738
3649
  ...service.sourceCommit ? { [`${deployVariablePrefix}_DEPLOY_SOURCE_COMMIT`]: service.sourceCommit } : {}
3739
3650
  } : {};
3651
+ let customDomain = null;
3652
+ if (service.key === "api" && service.publicBaseUrl) {
3653
+ try {
3654
+ customDomain = new URL(service.publicBaseUrl).hostname || null;
3655
+ } catch {
3656
+ customDomain = null;
3657
+ }
3658
+ }
3740
3659
  return {
3741
3660
  key: service.instanceKey ?? service.key,
3742
3661
  serviceName: service.serviceName,
@@ -3753,6 +3672,7 @@ function railwayIacServiceInput(input, sync, service, scope) {
3753
3672
  healthcheckTimeoutSeconds: service.healthcheckTimeoutSeconds,
3754
3673
  runtimeMode: service.runtimeMode,
3755
3674
  volumeMountPath: service.volumeMountPath,
3675
+ customDomains: customDomain ? [customDomain] : [],
3756
3676
  variables: {
3757
3677
  ...serviceSync.variables,
3758
3678
  ...sourceVariables
@@ -3792,26 +3712,6 @@ function activeAttachedRailwayVolumeIds(volumes, serviceId, environmentId, desir
3792
3712
  (instance) => instance.serviceId === serviceId && instance.environmentId === environmentId && (volume.name !== desiredVolumeName || activeRailwayVolumeInstances({ instances: [instance] }).length === 0)
3793
3713
  )).map((volume) => volume.id);
3794
3714
  }
3795
- function blockedPendingRailwayVolumeAttachments(volumes, serviceIdByName, environmentId) {
3796
- const serviceNameById = new Map([...serviceIdByName.entries()].filter(([, serviceId]) => serviceId).map(([serviceName, serviceId]) => [serviceId, serviceName]));
3797
- const blocked = [];
3798
- for (const volume of volumes) {
3799
- for (const instance of volume.instances) {
3800
- if (!instance.serviceId || instance.environmentId !== environmentId) {
3801
- continue;
3802
- }
3803
- const serviceName = serviceNameById.get(instance.serviceId);
3804
- if (!serviceName) {
3805
- continue;
3806
- }
3807
- if (activeRailwayVolumeInstances({ instances: [instance] }).length > 0) {
3808
- continue;
3809
- }
3810
- blocked.push(`${serviceName}: volume ${volume.name ?? volume.id} (${volume.id}) is pending/deleted while still attached to service ${instance.serviceId} at ${instance.mountPath ?? "(unknown mount)"}; deletedAt=${instance.deletedAt ?? "(none)"} state=${instance.state ?? "(unknown)"}`);
3811
- }
3812
- }
3813
- return blocked;
3814
- }
3815
3715
  async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
3816
3716
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
3817
3717
  const desiredRunners = configuredRailwayServicesForInput(input, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false);
@@ -3917,11 +3817,11 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3917
3817
  }
3918
3818
  let workspace = "";
3919
3819
  const syncedServices = [];
3820
+ const projectPlans = [];
3920
3821
  for (const projectServices of projectGroups) {
3921
3822
  const serviceKeySet = [...new Set(projectServices.map((service) => service.key))];
3922
3823
  const topology = await resolveRailwayTopologyForScope(input, scope, {
3923
3824
  ensure: !planOnly,
3924
- ensureServices: false,
3925
3825
  serviceKeys: serviceKeySet,
3926
3826
  includeInstances: false,
3927
3827
  includeVariables: false,
@@ -3935,46 +3835,29 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3935
3835
  for (const service of projectServices) {
3936
3836
  railwayIacServiceInput(input, sync, service, scope);
3937
3837
  }
3938
- if (planOnly) {
3939
- syncedServices.push(...projectServices);
3940
- continue;
3941
- }
3942
3838
  if (!project || !environment) {
3943
- throw new Error(`Railway IaC reconciliation could not resolve project/environment for ${projectServices[0]?.projectName ?? "(unknown project)"}/${projectServices[0]?.railwayEnvironment ?? "(unknown environment)"}.`);
3839
+ throw new Error(`Railway IaC ${planOnly ? "plan" : "reconciliation"} could not resolve project/environment for ${projectServices[0]?.projectName ?? "(unknown project)"}/${projectServices[0]?.railwayEnvironment ?? "(unknown environment)"}.`);
3944
3840
  }
3945
3841
  const liveServices = await listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }).catch(() => project.services);
3946
3842
  const liveServiceByName = new Map(liveServices.map((service) => [service.name, service]));
3947
3843
  const liveVolumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
3948
3844
  const databaseDescriptor = configuredRailwayIacDatabase(input, projectServices);
3949
- const serviceIdByName = new Map(projectServices.map((service) => {
3950
- const liveService = liveServiceByName.get(service.serviceName) ?? project.services.find((candidate) => candidate.name === service.serviceName || candidate.id === service.serviceId) ?? null;
3951
- return [service.serviceName, liveService?.id ?? null];
3952
- }));
3953
- if (databaseDescriptor) {
3954
- const databaseLiveService2 = liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null;
3955
- serviceIdByName.set(databaseDescriptor.serviceName, databaseLiveService2?.id ?? null);
3956
- }
3957
- const pendingVolumeBlocks = blockedPendingRailwayVolumeAttachments(liveVolumes, serviceIdByName, environment.id);
3958
- if (pendingVolumeBlocks.length > 0) {
3959
- for (const reason of pendingVolumeBlocks) {
3960
- recordRailwayProviderDrift(input, scope, {
3961
- kind: "railway.pending-volume-attachment",
3962
- action: "blocked",
3963
- status: "blocked",
3964
- projectId: project.id,
3965
- environmentId: environment.id,
3966
- reason
3967
- });
3968
- }
3969
- throw new Error(`Railway has pending-deletion volume attachments that block official IaC reconciliation. ${pendingVolumeBlocks.join("; ")}`);
3845
+ const baseIacServices = projectServices.map((service) => railwayIacServiceInput(input, sync, service, scope));
3846
+ const pendingVolumeNameCollisions = findRailwayPendingVolumeNameCollisions({
3847
+ services: baseIacServices,
3848
+ liveServices,
3849
+ volumes: liveVolumes
3850
+ });
3851
+ const volumeBindingResult = resolveRailwayIacVolumeBindings({
3852
+ environmentId: environment.id,
3853
+ services: baseIacServices,
3854
+ liveServices,
3855
+ volumes: liveVolumes
3856
+ });
3857
+ if (volumeBindingResult.blockedReasons.length > 0) {
3858
+ throw new Error(`Railway volume lineage blocks reconciliation: ${volumeBindingResult.blockedReasons.join("; ")}`);
3970
3859
  }
3971
- const detachVolumeIdsByServiceName = new Map(projectServices.map((service) => {
3972
- const liveService = liveServiceByName.get(service.serviceName) ?? project.services.find((candidate) => candidate.name === service.serviceName || candidate.id === service.serviceId) ?? null;
3973
- return [
3974
- service.serviceName,
3975
- activeAttachedRailwayVolumeIds(liveVolumes, liveService?.id, environment.id, `${service.serviceName}-volume`)
3976
- ];
3977
- }));
3860
+ const volumeBindingByServiceName = new Map(volumeBindingResult.bindings.map((binding) => [binding.serviceName, binding]));
3978
3861
  const databaseLiveService = databaseDescriptor ? liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null : null;
3979
3862
  const databaseDetachVolumeIds = databaseDescriptor ? activeAttachedRailwayVolumeIds(liveVolumes, databaseLiveService?.id, environment.id, `${databaseDescriptor.serviceName}-volume`) : [];
3980
3863
  const databaseForIac = databaseDescriptor ? { ...databaseDescriptor, detachVolumeIds: databaseDetachVolumeIds, useNativePostgres: false } : null;
@@ -3991,26 +3874,46 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3991
3874
  environmentId: environment.id,
3992
3875
  railwayApiToken: token,
3993
3876
  railwayApiUrl: String(topology.env.TREESEED_RAILWAY_API_URL ?? topology.env.RAILWAY_API_URL ?? "").trim() || null,
3994
- services: projectServices.map((service) => ({
3995
- ...railwayIacServiceInput(input, sync, service, scope),
3996
- detachVolumeIds: detachVolumeIdsByServiceName.get(service.serviceName) ?? []
3877
+ services: baseIacServices.map((service) => ({
3878
+ ...service,
3879
+ volumeName: volumeBindingByServiceName.get(service.serviceName)?.canonicalVolumeName ?? null,
3880
+ volumeAddress: (() => {
3881
+ const binding = volumeBindingByServiceName.get(service.serviceName);
3882
+ return binding && binding.volumeName !== binding.canonicalVolumeName ? `volume.${binding.volumeName}` : null;
3883
+ })()
3997
3884
  })),
3998
3885
  database: databaseForIac
3999
3886
  };
4000
- const aliasMigration = railwayLegacyAliasMigrationPolicy(scope, projectServices);
4001
- const siblingResourceNames = [
3887
+ const projectEnvironmentServices = await Promise.all(project.environments.map(async (candidate) => ({
3888
+ environment: candidate,
3889
+ services: await listRailwayEnvironmentServices({ environmentId: candidate.id, env: topology.env }).catch(() => [])
3890
+ })));
3891
+ const activeProjectEnvironmentServices = projectEnvironmentServices.flatMap((entry) => entry.services);
3892
+ const siblingEnvironmentName = scope === "prod" ? "staging" : scope === "staging" ? "production" : null;
3893
+ const siblingEnvironmentIds = new Set(project.environments.filter((candidate) => siblingEnvironmentName && candidate.name === siblingEnvironmentName).map((candidate) => candidate.id));
3894
+ const activeSiblingServices = projectEnvironmentServices.filter((entry) => siblingEnvironmentIds.has(entry.environment.id)).flatMap((entry) => entry.services);
3895
+ const activeSiblingServiceIds = new Set(activeSiblingServices.map((service) => service.id));
3896
+ const activeSiblingResourceNames = [
3897
+ ...activeSiblingServices.map((service) => service.name),
3898
+ ...liveVolumes.filter((volume) => activeRailwayVolumeInstances(volume).some((instance) => siblingEnvironmentIds.has(instance.environmentId ?? "") || activeSiblingServiceIds.has(instance.serviceId ?? ""))).map((volume) => volume.name).filter((name) => Boolean(name))
3899
+ ];
3900
+ const aliasCleanup = railwayObsoleteAliasCleanupPolicy(
3901
+ scope,
3902
+ projectServices,
3903
+ project.services.map((service) => service.name),
3904
+ activeProjectEnvironmentServices.map((service) => service.name)
3905
+ );
3906
+ const protectedSiblingResourceNames = [.../* @__PURE__ */ new Set([
4002
3907
  ...configuredRailwaySiblingResourceNames(input, scope, project.name),
4003
- ...aliasMigration.retainedResourceNames
3908
+ ...activeSiblingResourceNames
3909
+ ])];
3910
+ const siblingResourceNames = [
3911
+ ...protectedSiblingResourceNames,
3912
+ ...aliasCleanup.retainedResourceNames
3913
+ ];
3914
+ const allowedResourceDeletions = [
3915
+ ...aliasCleanup.allowedResourceDeletions.filter((name) => !protectedSiblingResourceNames.includes(name))
4004
3916
  ];
4005
- const { allowedResourceDeletions } = aliasMigration;
4006
- const {
4007
- applyRailwayIacProject,
4008
- cleanupRailwayIacRender,
4009
- planRailwayIacProject,
4010
- renderRailwayIacProject,
4011
- selectRailwayIacRetainedResources,
4012
- validateRailwayIacChangeSet
4013
- } = await import("./providers/railway-iac.js");
4014
3917
  let effectiveIacInput = iacInput;
4015
3918
  let rendered = renderRailwayIacProject(effectiveIacInput);
4016
3919
  try {
@@ -4030,7 +3933,8 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
4030
3933
  service.serviceName,
4031
3934
  service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4032
3935
  ])),
4033
- allowedResourceDeletions
3936
+ allowedResourceDeletions,
3937
+ protectedResourceNames: protectedSiblingResourceNames
4034
3938
  });
4035
3939
  if (!validation.ok && effectiveIacInput.database && !effectiveIacInput.database.useNativePostgres && railwayIacPlanDeletesResource(plan.changeSet, effectiveIacInput.database.serviceName)) {
4036
3940
  traceRailwayReconcile(topology.env, "sync:iac-native-postgres-adopt", `${project.name}/${environment.name}:${effectiveIacInput.database.serviceName}`);
@@ -4058,10 +3962,17 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
4058
3962
  service.serviceName,
4059
3963
  service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4060
3964
  ])),
4061
- allowedResourceDeletions
3965
+ allowedResourceDeletions,
3966
+ protectedResourceNames: protectedSiblingResourceNames
4062
3967
  });
4063
3968
  }
4064
- const retainedResources = selectRailwayIacRetainedResources(plan, siblingResourceNames);
3969
+ const retainedResources = detachRetainedRailwayCustomDomains(
3970
+ detachRetainedRailwayVolumeBindings(
3971
+ selectRailwayIacRetainedResources(plan, siblingResourceNames),
3972
+ volumeBindingResult.bindings
3973
+ ),
3974
+ effectiveIacInput.services.flatMap((service) => service.customDomains ?? [])
3975
+ );
4065
3976
  if (retainedResources.length > 0) {
4066
3977
  traceRailwayReconcile(
4067
3978
  topology.env,
@@ -4086,16 +3997,219 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
4086
3997
  service.serviceName,
4087
3998
  service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4088
3999
  ])),
4089
- allowedResourceDeletions
4000
+ allowedResourceDeletions,
4001
+ protectedResourceNames: protectedSiblingResourceNames
4090
4002
  });
4091
4003
  }
4092
4004
  if (!validation.ok) {
4093
4005
  throw new Error(`Railway IaC plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
4094
4006
  }
4007
+ projectPlans.push({
4008
+ projectName: project.name,
4009
+ environmentName: environment.name,
4010
+ changes: (plan.changeSet?.changes ?? []).map((change) => ({
4011
+ kind: String(change.kind ?? ""),
4012
+ path: String(change.path ?? change.address ?? ""),
4013
+ summary: String(change.summary ?? "")
4014
+ })),
4015
+ volumeBindings: volumeBindingResult.bindings
4016
+ });
4017
+ if (planOnly) {
4018
+ syncedServices.push(...projectServices);
4019
+ continue;
4020
+ }
4021
+ for (const collision of pendingVolumeNameCollisions) {
4022
+ const serviceId = liveServiceByName.get(collision.serviceName)?.id ?? collision.serviceId ?? liveServices[0]?.id;
4023
+ if (!serviceId) {
4024
+ throw new Error(`Railway pending volume ${collision.canonicalVolumeName} cannot be released because no environment service is available for CLI scope.`);
4025
+ }
4026
+ const tombstoneName = `pending-delete-${collision.volumeId.slice(0, 8)}`;
4027
+ traceRailwayReconcile(topology.env, "sync:volume:release-pending-name", `${collision.canonicalVolumeName}:${tombstoneName}`);
4028
+ await updateRailwayVolumeWithCli({
4029
+ projectId: project.id,
4030
+ environmentId: environment.id,
4031
+ serviceId,
4032
+ volumeId: collision.volumeId,
4033
+ name: tombstoneName,
4034
+ mountPath: collision.mountPath,
4035
+ env: topology.env
4036
+ });
4037
+ const released = await waitForRailwayVolumeName({
4038
+ volumeId: collision.volumeId,
4039
+ expectedName: tombstoneName,
4040
+ load: () => listRailwayVolumes({ projectId: project.id, env: topology.env }),
4041
+ sleep: async (milliseconds) => {
4042
+ traceRailwayReconcile(topology.env, "sync:volume:release-pending-settle", `${collision.canonicalVolumeName}:${milliseconds}ms`);
4043
+ await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
4044
+ }
4045
+ });
4046
+ if (!released) {
4047
+ throw new Error(`Railway pending volume name ${collision.canonicalVolumeName} remained locked after 12 observations.`);
4048
+ }
4049
+ const desiredServiceId = liveServiceByName.get(collision.serviceName)?.id ?? null;
4050
+ if (desiredServiceId === serviceId && collision.serviceId === desiredServiceId) {
4051
+ traceRailwayReconcile(topology.env, "sync:volume:replace-pending-service", `${collision.serviceName}:${serviceId}`);
4052
+ await deleteRailwayService({
4053
+ projectId: project.id,
4054
+ environmentId: environment.id,
4055
+ serviceId,
4056
+ env: topology.env
4057
+ });
4058
+ const absent = await waitForRailwayServiceAbsence({
4059
+ serviceId,
4060
+ load: () => listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }),
4061
+ sleep: async (milliseconds) => {
4062
+ traceRailwayReconcile(topology.env, "sync:volume:replace-pending-service-settle", `${collision.serviceName}:${milliseconds}ms`);
4063
+ await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
4064
+ }
4065
+ });
4066
+ if (!absent) throw new Error(`Railway service ${collision.serviceName} remained after pending-volume replacement was requested.`);
4067
+ } else {
4068
+ traceRailwayReconcile(topology.env, "sync:volume:release-pending-detach", `${tombstoneName}:${serviceId}`);
4069
+ await detachRailwayVolumeWithCli({
4070
+ projectId: project.id,
4071
+ environmentId: environment.id,
4072
+ serviceId,
4073
+ volumeId: collision.volumeId,
4074
+ env: topology.env
4075
+ });
4076
+ const detached = await waitForRailwayVolumeDetachment({
4077
+ volumeId: collision.volumeId,
4078
+ environmentId: environment.id,
4079
+ serviceId,
4080
+ load: () => listRailwayVolumes({ projectId: project.id, env: topology.env }),
4081
+ sleep: async (milliseconds) => {
4082
+ traceRailwayReconcile(topology.env, "sync:volume:release-pending-detach-settle", `${tombstoneName}:${milliseconds}ms`);
4083
+ await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
4084
+ }
4085
+ });
4086
+ if (!detached) throw new Error(`Railway pending volume ${tombstoneName} remained attached after 12 observations.`);
4087
+ }
4088
+ }
4089
+ if (pendingVolumeNameCollisions.length > 0) {
4090
+ cleanupRailwayIacRender(rendered);
4091
+ rendered = renderRailwayIacProject(effectiveIacInput);
4092
+ plan = await planRailwayIacProject(effectiveIacInput, rendered);
4093
+ if (!plan.ok) {
4094
+ const diagnostics = plan.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
4095
+ throw new Error(`Railway IaC recovery replan failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
4096
+ }
4097
+ validation = validateRailwayIacChangeSet(plan.changeSet, {
4098
+ services: rendered.serviceNames,
4099
+ volumes: rendered.volumeNames,
4100
+ database: rendered.databaseName,
4101
+ scope,
4102
+ serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null])),
4103
+ serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode === "image" ? `image:${service.imageRef ?? ""}` : service.sourceMode === "git" ? `github:${service.sourceRepo ?? ""}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : null])),
4104
+ allowedResourceDeletions,
4105
+ protectedResourceNames: protectedSiblingResourceNames
4106
+ });
4107
+ if (!validation.ok) throw new Error(`Railway IaC recovery plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
4108
+ }
4095
4109
  const apply = await applyRailwayIacProject(effectiveIacInput, rendered);
4096
- if (!apply.ok) {
4097
- const diagnostics = apply.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
4098
- throw new Error(`Railway IaC apply failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
4110
+ const applyFailure = railwayIacApplyFailure(apply);
4111
+ if (applyFailure) throw new Error(`Railway IaC apply failed for ${project.name}/${environment.name}: ${applyFailure}`);
4112
+ for (const binding of volumeBindingResult.bindings) {
4113
+ const desiredConfig = effectiveIacInput.services.find((service) => service.serviceName === binding.serviceName);
4114
+ if (!desiredConfig?.volumeMountPath) {
4115
+ throw new Error(`Railway volume adoption could not resolve ${binding.canonicalVolumeName} and its desired service.`);
4116
+ }
4117
+ const settled = await waitForRailwayVolumeAdoptionResources({
4118
+ serviceName: binding.serviceName,
4119
+ volumeId: binding.volumeId,
4120
+ load: async () => ({
4121
+ services: await listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }),
4122
+ volumes: await listRailwayVolumes({ projectId: project.id, env: topology.env })
4123
+ }),
4124
+ sleep: async (milliseconds) => {
4125
+ traceRailwayReconcile(topology.env, "sync:volume:settle", `${binding.canonicalVolumeName}:${milliseconds}ms`);
4126
+ await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
4127
+ }
4128
+ });
4129
+ if (!settled) {
4130
+ throw new Error(`Railway volume adoption could not resolve ${binding.canonicalVolumeName} and its desired service after 12 observations.`);
4131
+ }
4132
+ const desiredService = settled.service;
4133
+ const liveVolume = settled.volume;
4134
+ const volumesAfterApply = settled.volumes;
4135
+ const activeEnvironmentAttachments = liveVolume.instances.filter(
4136
+ (instance) => instance.environmentId === environment.id && !instance.isPendingDeletion && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && !["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
4137
+ );
4138
+ const nameCollisions = volumesAfterApply.filter(
4139
+ (volume) => volume.id !== liveVolume.id && volume.name === binding.canonicalVolumeName
4140
+ );
4141
+ for (const collision of nameCollisions) {
4142
+ const pendingDeletion = collision.instances.length > 0 && collision.instances.every(
4143
+ (instance) => instance.isPendingDeletion === true || Boolean(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) || ["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
4144
+ );
4145
+ if (!pendingDeletion) {
4146
+ throw new Error(`Railway volume name ${binding.canonicalVolumeName} is owned by active volume ${collision.id}; refusing ambiguous adoption.`);
4147
+ }
4148
+ const tombstoneName = `pending-delete-${collision.id.slice(0, 8)}`;
4149
+ traceRailwayReconcile(topology.env, "sync:volume:rename-tombstone", `${binding.canonicalVolumeName}:${tombstoneName}`);
4150
+ await updateRailwayVolumeWithCli({
4151
+ projectId: project.id,
4152
+ environmentId: environment.id,
4153
+ serviceId: desiredService.id,
4154
+ volumeId: collision.id,
4155
+ name: tombstoneName,
4156
+ mountPath: collision.instances[0]?.mountPath || desiredConfig.volumeMountPath,
4157
+ env: topology.env
4158
+ });
4159
+ }
4160
+ for (const attachment of activeEnvironmentAttachments.filter((instance) => instance.serviceId && instance.serviceId !== desiredService.id)) {
4161
+ traceRailwayReconcile(topology.env, "sync:volume:detach-stale", `${liveVolume.name ?? liveVolume.id}:${attachment.serviceId}`);
4162
+ await detachRailwayVolumeWithCli({
4163
+ projectId: project.id,
4164
+ environmentId: environment.id,
4165
+ serviceId: attachment.serviceId,
4166
+ volumeId: liveVolume.id,
4167
+ env: topology.env
4168
+ });
4169
+ }
4170
+ const desiredAttachment = activeEnvironmentAttachments.find((instance) => instance.serviceId === desiredService.id);
4171
+ if (liveVolume.name !== binding.canonicalVolumeName || desiredAttachment?.mountPath !== desiredConfig.volumeMountPath) {
4172
+ traceRailwayReconcile(topology.env, "sync:volume:update", `${liveVolume.name ?? liveVolume.id}:${binding.canonicalVolumeName}:${desiredConfig.volumeMountPath}`);
4173
+ await updateRailwayVolumeWithCli({
4174
+ projectId: project.id,
4175
+ environmentId: environment.id,
4176
+ serviceId: desiredService.id,
4177
+ volumeId: liveVolume.id,
4178
+ name: binding.canonicalVolumeName,
4179
+ mountPath: desiredConfig.volumeMountPath,
4180
+ env: topology.env
4181
+ });
4182
+ }
4183
+ const refreshedVolume = (await listRailwayVolumes({ projectId: project.id, env: topology.env })).find((volume) => volume.id === liveVolume.id);
4184
+ const attachedAfterUpdate = refreshedVolume?.instances.some(
4185
+ (instance) => instance.environmentId === environment.id && instance.serviceId === desiredService.id && !instance.isPendingDeletion && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && !["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
4186
+ );
4187
+ if (!attachedAfterUpdate) {
4188
+ traceRailwayReconcile(topology.env, "sync:volume:attach", `${binding.canonicalVolumeName}:${desiredService.name}`);
4189
+ await attachRailwayVolumeWithCli({
4190
+ projectId: project.id,
4191
+ environmentId: environment.id,
4192
+ serviceId: desiredService.id,
4193
+ volumeId: liveVolume.id,
4194
+ env: topology.env
4195
+ });
4196
+ }
4197
+ }
4198
+ const appliedServiceObservation = await waitForRailwayServices({
4199
+ serviceNames: effectiveIacInput.services.map((service) => service.serviceName),
4200
+ load: () => listRailwayEnvironmentServices({ environmentId: environment.id, env: topology.env }),
4201
+ sleep: async (milliseconds) => {
4202
+ traceRailwayReconcile(topology.env, "sync:service:settle", `${project.name}/${environment.name}:${milliseconds}ms`);
4203
+ await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
4204
+ }
4205
+ });
4206
+ const appliedServices = appliedServiceObservation?.services ?? [];
4207
+ const appliedServiceByName = new Map(appliedServices.map((service) => [service.name, service]));
4208
+ for (const service of effectiveIacInput.services) {
4209
+ const appliedService = appliedServiceByName.get(service.serviceName);
4210
+ if (!appliedService) {
4211
+ throw new Error(`Railway IaC did not create or retain expected service ${service.serviceName} in ${environment.name}.`);
4212
+ }
4099
4213
  }
4100
4214
  assertNoBlockedRailwayProviderDrift(input, scope);
4101
4215
  syncedServices.push(...projectServices);
@@ -4110,406 +4224,14 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
4110
4224
  secrets: Object.keys(sync.secrets),
4111
4225
  variables: Object.keys(sync.variables),
4112
4226
  planOnly,
4113
- workspace
4227
+ workspace,
4228
+ projectPlans
4114
4229
  };
4115
4230
  }
4116
- function shouldDeployRailwayServiceBySourceUpload(scope, service) {
4117
- return scope === "staging" && service.sourceMode === "git" && Boolean(service.sourceRepo) && !service.dockerfilePath && service.key !== "capacityProviderManager" && service.key !== "capacityProviderRunner" && !service.imageRef;
4118
- }
4119
- function deployRailwayServiceBySourceUpload(input, entry, env, scope) {
4120
- if (!entry.project?.id || !entry.environment?.name || !entry.service?.name) {
4121
- throw new Error(`Railway source upload deploy requires resolved project, environment, and service for ${entry.configuredService.key}.`);
4122
- }
4123
- const rootDir = String(entry.configuredService.rootDir ?? "").trim();
4124
- if (!rootDir || !existsSync(rootDir)) {
4125
- throw new Error(`Railway source upload deploy root does not exist for ${entry.configuredService.key}: ${rootDir || "(unset)"}.`);
4126
- }
4127
- const uploadContext = prepareRailwaySourceUploadContext(input, entry.configuredService, rootDir);
4128
- const uploadRoot = uploadContext.rootDir;
4129
- const args = [
4130
- "trsd",
4131
- "railway",
4132
- "--environment",
4133
- scope,
4134
- "--",
4135
- "up",
4136
- "--service",
4137
- entry.service.name,
4138
- "--project",
4139
- entry.project.id,
4140
- "--environment",
4141
- entry.environment.name,
4142
- "--ci",
4143
- "--detach",
4144
- "--path-as-root",
4145
- ...uploadContext.noGitignore ? ["--no-gitignore"] : [],
4146
- "--verbose",
4147
- "--message",
4148
- `trsd staging source upload ${entry.configuredService.key}`,
4149
- uploadRoot
4150
- ];
4151
- const commandEnv = {
4152
- ...process.env,
4153
- ...env,
4154
- TREESEED_RECONCILE_TRACE: env.TREESEED_RECONCILE_TRACE ?? process.env.TREESEED_RECONCILE_TRACE
4155
- };
4156
- try {
4157
- const result = spawnSync("npx", args, {
4158
- cwd: input.context.tenantRoot,
4159
- env: commandEnv,
4160
- encoding: "utf8",
4161
- stdio: ["ignore", "pipe", "pipe"],
4162
- maxBuffer: 1024 * 1024 * 20
4163
- });
4164
- if ((result.status ?? 1) !== 0) {
4165
- const stderr = String(result.stderr ?? "").split("\n").slice(-120).join("\n");
4166
- const stdout = String(result.stdout ?? "").split("\n").slice(-120).join("\n");
4167
- throw new Error(`Railway source upload deploy failed for ${entry.configuredService.key} with status ${result.status ?? 1}.
4168
- ${stderr || stdout}`);
4169
- }
4170
- } finally {
4171
- if (uploadContext.cleanupDir) {
4172
- rmSync(uploadContext.cleanupDir, { recursive: true, force: true });
4173
- }
4174
- }
4175
- }
4176
- function prepareRailwaySourceUploadContext(input, service, rootDir) {
4177
- const role = service.key === "capacityProviderManager" ? "manager" : service.key === "capacityProviderRunner" ? "runner" : null;
4178
- if (!role) {
4179
- return { rootDir, cleanupDir: null, noGitignore: false };
4180
- }
4181
- const cleanupDir = mkdtempSync(resolve(tmpdir(), `trsd-railway-agent-${role}-`));
4182
- const contextRoot = resolve(cleanupDir, "context");
4183
- mkdirSync(contextRoot, { recursive: true });
4184
- for (const entry of ["src", "scripts", "templates", "docs"]) {
4185
- const source = resolve(rootDir, entry);
4186
- if (existsSync(source)) {
4187
- cpSync(source, resolve(contextRoot, entry), { recursive: true });
4188
- }
4189
- }
4190
- for (const entry of ["docker-entrypoint.sh", "package.json", "package-lock.json", "tsconfig.json", "tsconfig.dist.json"]) {
4191
- const source = resolve(rootDir, entry);
4192
- if (existsSync(source)) {
4193
- cpSync(source, resolve(contextRoot, entry));
4194
- }
4195
- }
4196
- const sdkRoot = resolve(rootDir, "..", "sdk");
4197
- let sdkTarballPrepared = false;
4198
- if (existsSync(resolve(sdkRoot, "package.json"))) {
4199
- const pack = spawnSync("npm", ["pack", "--ignore-scripts", "--pack-destination", contextRoot], {
4200
- cwd: sdkRoot,
4201
- env: process.env,
4202
- encoding: "utf8",
4203
- stdio: ["ignore", "pipe", "pipe"],
4204
- maxBuffer: 1024 * 1024 * 20
4205
- });
4206
- if ((pack.status ?? 1) !== 0) {
4207
- const stderr = String(pack.stderr ?? "").split("\n").slice(-80).join("\n");
4208
- const stdout = String(pack.stdout ?? "").split("\n").slice(-80).join("\n");
4209
- throw new Error(`Failed to pack local SDK for Agent ${role} Railway source upload context.
4210
- ${stderr || stdout}`);
4211
- }
4212
- const filename = String(pack.stdout ?? "").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).at(-1);
4213
- if (!filename || !existsSync(resolve(contextRoot, filename))) {
4214
- throw new Error(`Local SDK pack did not produce a tarball for Agent ${role} Railway source upload context.`);
4215
- }
4216
- cpSync(resolve(contextRoot, filename), resolve(contextRoot, "treeseed-sdk.tgz"));
4217
- if (filename !== "treeseed-sdk.tgz") {
4218
- rmSync(resolve(contextRoot, filename), { force: true });
4219
- }
4220
- sdkTarballPrepared = true;
4221
- }
4222
- const dockerfile = [
4223
- "FROM node:22-alpine AS builder",
4224
- "WORKDIR /app",
4225
- "RUN apk add --no-cache ca-certificates git openssh-client",
4226
- sdkTarballPrepared ? "COPY package.json package-lock.json treeseed-sdk.tgz ./" : "COPY package.json package-lock.json ./",
4227
- sdkTarballPrepared ? "RUN npm ci --include=dev --ignore-scripts \\\n && npm install --include=dev --ignore-scripts ./treeseed-sdk.tgz" : "RUN npm ci --include=dev --ignore-scripts",
4228
- "COPY . .",
4229
- "RUN npm run build:dist \\",
4230
- " && npm prune --omit=dev --ignore-scripts \\",
4231
- " && rm -rf node_modules/@github node_modules/@openai node_modules/@cloudflare node_modules/@railway \\",
4232
- " node_modules/wrangler node_modules/miniflare node_modules/workerd \\",
4233
- " node_modules/playwright node_modules/playwright-core node_modules/gpt-tokenizer \\",
4234
- " node_modules/@remotion node_modules/remotion node_modules/@rspack \\",
4235
- " node_modules/@repomix node_modules/repomix node_modules/@esbuild node_modules/esbuild \\",
4236
- " node_modules/webpack node_modules/typescript node_modules/@mediabunny node_modules/mediabunny \\",
4237
- " node_modules/web-tree-sitter node_modules/caniuse-lite node_modules/@img node_modules/@secretlint \\",
4238
- " node_modules/lightningcss* node_modules/vite \\",
4239
- " && npm cache clean --force \\",
4240
- " && rm -rf src scripts test .git .github",
4241
- "FROM node:22-alpine AS runtime",
4242
- "WORKDIR /app",
4243
- "ENV NODE_ENV=production TREESEED_PROVIDER_DATA_DIR=/data",
4244
- "RUN apk add --no-cache ca-certificates tini util-linux git openssh-client \\",
4245
- " && mkdir -p /data",
4246
- "COPY --from=builder --chown=65532:65532 /app/package.json /app/package-lock.json ./",
4247
- "COPY --from=builder --chown=65532:65532 /app/node_modules ./node_modules",
4248
- "COPY --from=builder --chown=65532:65532 /app/dist ./dist",
4249
- "COPY --from=builder --chown=65532:65532 /app/templates ./templates",
4250
- "COPY --from=builder --chown=65532:65532 /app/docs ./docs",
4251
- "COPY --from=builder --chown=65532:65532 /app/docker-entrypoint.sh ./docker-entrypoint.sh",
4252
- "RUN chmod 0755 /app/docker-entrypoint.sh \\",
4253
- " && chown -R 65532:65532 /data",
4254
- "EXPOSE 3100",
4255
- 'ENTRYPOINT ["tini", "--", "/app/docker-entrypoint.sh"]',
4256
- `ENV TREESEED_PROVIDER_ROLE=${role}`,
4257
- `CMD ["${role}"]`,
4258
- ""
4259
- ].join("\n");
4260
- writeFileSync(
4261
- resolve(contextRoot, "Dockerfile"),
4262
- dockerfile
4263
- );
4264
- return { rootDir: contextRoot, cleanupDir, noGitignore: true };
4265
- }
4266
- async function deployRailwayServiceInstanceWithSourceRepair(entry, env) {
4267
- if (!entry.environment || !entry.service) {
4268
- return;
4269
- }
4270
- const deploy = () => deployRailwayServiceInstance({
4271
- serviceId: entry.service.id,
4272
- environmentId: entry.environment.id,
4273
- env
4274
- });
4275
- if (entry.configuredService.sourceMode === "git" && entry.configuredService.sourceRepo && !entry.configuredService.imageRef) {
4276
- await repairRailwayServiceSource(entry, env);
4277
- }
4278
- try {
4279
- await deploy();
4280
- return;
4281
- } catch (error) {
4282
- const message = error instanceof Error ? error.message : String(error ?? "");
4283
- if (/Problem processing request/iu.test(message)) {
4284
- traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
4285
- return;
4286
- }
4287
- if (!/Deployment not found/iu.test(message)) {
4288
- throw error;
4289
- }
4290
- }
4291
- const repaired = await repairRailwayServiceSource(entry, env);
4292
- if (repaired) {
4293
- try {
4294
- await deploy();
4295
- return;
4296
- } catch (error) {
4297
- const message = error instanceof Error ? error.message : String(error ?? "");
4298
- if (/Problem processing request/iu.test(message)) {
4299
- traceRailwayReconcile(env, "sync:deploy-skipped", `${entry.configuredService.key}:${entry.service.name}:problem-processing-request`);
4300
- return;
4301
- }
4302
- if (!/Deployment not found/iu.test(message)) {
4303
- throw error;
4304
- }
4305
- }
4306
- }
4307
- throw new Error(
4308
- `Railway deployment for existing service ${entry.service.name} (${entry.service.id}) was not found; refusing to delete and recreate an existing service. Repair the service in place or restore a deployable service source before rerunning reconciliation.`
4309
- );
4310
- }
4311
- async function repairRailwayServiceSource(entry, env) {
4312
- if (!entry.service) {
4313
- return false;
4314
- }
4315
- const sourceRepo = String(entry.configuredService.sourceRepo ?? "").trim();
4316
- if (sourceRepo) {
4317
- await updateRailwayServiceGitSource({
4318
- serviceId: entry.service.id,
4319
- sourceRepo,
4320
- sourceBranch: entry.configuredService.sourceBranch,
4321
- env
4322
- });
4323
- return true;
4324
- }
4325
- const imageRef = String(entry.configuredService.imageRef ?? "").trim();
4326
- if (imageRef) {
4327
- await updateRailwayServiceImageSource({
4328
- serviceId: entry.service.id,
4329
- imageRef,
4330
- env
4331
- });
4332
- return true;
4333
- }
4334
- return false;
4335
- }
4336
- function looksLikeRailwayExistingSourceRepairUnsupported(error) {
4337
- const message = error instanceof Error ? error.message : String(error ?? "");
4338
- return /source update .*existing service|existing service .*source update|provider-supported .*source update|ServiceUpdateInput|Problem processing request|User does not have access to the repo/iu.test(message);
4339
- }
4340
- async function ensureRailwayMarketDatabaseForScope(input, topology) {
4341
- const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
4342
- const treeseedDatabase = configuredMarketDatabaseService(input.context.tenantRoot, input.context.deployConfig);
4343
- const treeseedDatabaseService = treeseedDatabase?.service;
4344
- if (!treeseedDatabaseService || treeseedDatabaseService.enabled === false || treeseedDatabaseService.provider !== "railway" || treeseedDatabaseService.railway?.resourceType !== "postgres") {
4345
- return;
4346
- }
4347
- const serviceName = treeseedDatabase.serviceName;
4348
- const firstService = [...topology.services.values()].find(
4349
- (entry) => entry.project && entry.environment && ["api", "operationsRunner"].includes(entry.configuredService.key)
4350
- );
4351
- if (!firstService?.project || !firstService.environment) {
4352
- await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, null);
4353
- return;
4354
- }
4355
- await reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, firstService.project.id);
4356
- let postgresService;
4357
- try {
4358
- postgresService = (await ensureRailwayPostgresService({
4359
- projectId: firstService.project.id,
4360
- environmentId: firstService.environment.id,
4361
- serviceName,
4362
- env: topology.env
4363
- })).service;
4364
- } catch (error) {
4365
- const detail = error instanceof Error ? error.message : String(error ?? "");
4366
- throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. ${detail}`);
4367
- }
4368
- if (!postgresService) {
4369
- throw new Error(`Railway API Postgres provisioning unsupported for ${serviceName}. No service was returned.`);
4370
- }
4371
- try {
4372
- const postgresVolume = await ensureRailwayServiceVolume({
4373
- projectId: firstService.project.id,
4374
- environmentId: firstService.environment.id,
4375
- serviceId: postgresService.id,
4376
- name: `${serviceName}-volume`,
4377
- mountPath: "/var/lib/postgresql/data",
4378
- env: topology.env
4379
- });
4380
- if (postgresVolume.instance?.serviceId !== postgresService.id || postgresVolume.instance?.environmentId !== firstService.environment.id || postgresVolume.instance?.mountPath !== "/var/lib/postgresql/data") {
4381
- throw new Error(`observed volume ${postgresVolume.volume.name ?? postgresVolume.volume.id ?? "(unknown)"} is not attached to ${serviceName} at /var/lib/postgresql/data`);
4382
- }
4383
- } catch (error) {
4384
- const detail = error instanceof Error ? error.message : String(error ?? "");
4385
- throw new Error(`Railway provider limitation while reconciling PostgreSQL volume name for ${serviceName}: ${detail}`);
4386
- }
4387
- const services = await listRailwayServices({ projectId: firstService.project.id, env: topology.env });
4388
- for (const service of services) {
4389
- if (service.id === postgresService.id || service.name === serviceName) {
4390
- continue;
4391
- }
4392
- const variables = await listRailwayVariables({
4393
- projectId: firstService.project.id,
4394
- environmentId: firstService.environment.id,
4395
- serviceId: service.id,
4396
- env: topology.env
4397
- }).catch(() => ({}));
4398
- const looksLikePostgres = typeof variables.DATABASE_URL === "string" && typeof variables.PGHOST === "string" && typeof variables.PGUSER === "string" && typeof variables.PGPASSWORD === "string";
4399
- if (looksLikePostgres) {
4400
- traceRailwayReconcile(topology.env, "sync:postgres:block-duplicate", `${service.name}:${service.id}`);
4401
- recordRailwayProviderDrift(input, scope, {
4402
- kind: "railway.duplicate-postgres-service",
4403
- action: "manual-repair-required",
4404
- status: "blocked",
4405
- projectId: firstService.project.id,
4406
- environmentId: firstService.environment.id,
4407
- serviceId: service.id,
4408
- serviceName: service.name,
4409
- reason: `Railway reports an extra PostgreSQL service in ${firstService.project.name}. Reconciliation refuses to delete existing services; adopt, retain, or remove it through an explicit destroy workflow.`
4410
- });
4411
- }
4412
- }
4413
- const detachedPostgresVolumes = await listRailwayVolumes({
4414
- projectId: firstService.project.id,
4415
- env: topology.env
4416
- });
4417
- for (const volume of detachedPostgresVolumes) {
4418
- if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
4419
- traceRailwayReconcile(topology.env, "sync:postgres:retain-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
4420
- continue;
4421
- }
4422
- const postgresInstances = volume.instances.filter(
4423
- (instance) => instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
4424
- );
4425
- const activePostgresInstances = postgresInstances.filter((instance) => Boolean(instance.serviceId));
4426
- const attachedToCanonical = postgresInstances.some((instance) => instance.serviceId === postgresService.id);
4427
- const detached = postgresInstances.length > 0 && postgresInstances.every((instance) => !instance.serviceId);
4428
- const detachedCanonicalVolume = volume.name === `${serviceName}-volume` && activePostgresInstances.length === 0;
4429
- if ((detached || detachedCanonicalVolume) && !attachedToCanonical) {
4430
- traceRailwayReconcile(topology.env, "sync:postgres:block-detached-volume", `${volume.name ?? volume.id}:${volume.id}`);
4431
- recordRailwayProviderDrift(input, scope, {
4432
- kind: "railway.detached-postgres-volume",
4433
- action: "manual-repair-required",
4434
- status: "blocked",
4435
- projectId: firstService.project.id,
4436
- environmentId: firstService.environment.id,
4437
- volumeId: volume.id,
4438
- volumeName: volume.name ?? null,
4439
- reason: "Railway reports a detached PostgreSQL volume. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow."
4440
- });
4441
- }
4442
- }
4443
- for (let attempt = 0; attempt < 20; attempt += 1) {
4444
- const existingVolumes = await listRailwayVolumes({
4445
- projectId: firstService.project.id,
4446
- env: topology.env
4447
- });
4448
- const attached = existingVolumes.some((volume) => volume.instances.some(
4449
- (instance) => instance.serviceId === postgresService.id && instance.environmentId === firstService.environment?.id && instance.mountPath === "/var/lib/postgresql/data"
4450
- ));
4451
- if (attached) {
4452
- return;
4453
- }
4454
- await new Promise((resolve2) => setTimeout(resolve2, 3e3));
4455
- }
4456
- }
4457
- async function reconcileAccidentalMarketDatabaseServices(input, topology, serviceName, ownerProjectId) {
4458
- const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
4459
- const projectEntries = [...topology.services.values()].filter((entry) => entry.project?.id && entry.environment?.id).map((entry) => ({ project: entry.project, environment: entry.environment }));
4460
- const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
4461
- for (const { project, environment } of projects.values()) {
4462
- if (ownerProjectId && project.id === ownerProjectId) {
4463
- continue;
4464
- }
4465
- const services = await listRailwayServices({ projectId: project.id, env: topology.env }).catch(() => []);
4466
- for (const service of services.filter((entry) => entry.name === serviceName)) {
4467
- traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-service", `${project.name}:${service.name}:${service.id}`);
4468
- recordRailwayProviderDrift(input, scope, {
4469
- kind: "railway.accidental-market-database-service",
4470
- action: "manual-repair-required",
4471
- status: "blocked",
4472
- projectId: project.id,
4473
- environmentId: environment.id,
4474
- serviceId: service.id,
4475
- serviceName: service.name,
4476
- reason: `Railway reports undeclared Market database service ${service.name} in non-owner project ${project.name}. Reconciliation refuses to delete existing services; adopt, retain, or remove it through an explicit destroy workflow.`
4477
- });
4478
- }
4479
- const volumes = await listRailwayVolumes({ projectId: project.id, env: topology.env }).catch(() => []);
4480
- for (const volume of volumes) {
4481
- if (isRetainedRailwayDetachedPostgresVolume(volume.name)) {
4482
- continue;
4483
- }
4484
- const postgresInstances = volume.instances.filter(
4485
- (instance) => instance.environmentId === environment.id && instance.mountPath === "/var/lib/postgresql/data"
4486
- );
4487
- if (postgresInstances.length === 0) {
4488
- continue;
4489
- }
4490
- const activeServiceIds = new Set(services.map((service) => service.id));
4491
- const stillAttached = postgresInstances.some((instance) => instance.serviceId && activeServiceIds.has(instance.serviceId));
4492
- if (stillAttached) {
4493
- continue;
4494
- }
4495
- traceRailwayReconcile(topology.env, "sync:postgres:block-accidental-volume", `${project.name}:${volume.name ?? volume.id}:${volume.id}`);
4496
- recordRailwayProviderDrift(input, scope, {
4497
- kind: "railway.accidental-market-database-volume",
4498
- action: "manual-repair-required",
4499
- status: "blocked",
4500
- projectId: project.id,
4501
- environmentId: environment.id,
4502
- volumeId: volume.id,
4503
- volumeName: volume.name ?? null,
4504
- reason: `Railway reports undeclared Market database volume ${volume.name ?? volume.id} in non-owner project ${project.name}. Reconciliation refuses destructive cleanup during repair; adopt, retain, or remove it through an explicit destroy workflow.`
4505
- });
4506
- }
4507
- }
4508
- }
4509
- function isRetainedRailwayDetachedPostgresVolume(value) {
4510
- return String(value ?? "").trim().startsWith("retained-");
4511
- }
4512
- async function observeRailwayUnit(input, { refresh = false } = {}) {
4231
+ async function observeRailwayUnit(input, {
4232
+ refresh = false,
4233
+ topology: providedTopology
4234
+ } = {}) {
4513
4235
  let attempt = 0;
4514
4236
  for (; ; ) {
4515
4237
  try {
@@ -4517,13 +4239,13 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
4517
4239
  const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
4518
4240
  const state = loadDeployState(input.context.tenantRoot, input.context.deployConfig, { target: toDeployTarget(input.context.target) });
4519
4241
  const persisted = state.services?.[serviceKey] ?? {};
4520
- const topology = await resolveRailwayTopologyForScope(input, scope, {
4242
+ const topology = providedTopology ?? await resolveRailwayUnitTopology(input, scope, {
4521
4243
  refresh,
4522
- serviceKeys: [serviceKey],
4523
4244
  includeInstances: false,
4524
- includeVariables: false
4245
+ includeVariables: false,
4246
+ cacheSuffix: refresh ? "refresh" : "observe"
4525
4247
  });
4526
- const entry = topology.services.get(serviceKey) ?? null;
4248
+ const entry = findRailwayTopologyEntry(topology, railwayUnitServiceIdentity(input));
4527
4249
  const configuredService = entry?.configuredService ?? null;
4528
4250
  const usesPortableSource = Boolean(
4529
4251
  configuredService?.imageRef || configuredService?.sourceMode === "git" && configuredService?.sourceRepo
@@ -4876,8 +4598,7 @@ async function observeCustomDomainUnit(input) {
4876
4598
  }
4877
4599
  case "custom-domain:api": {
4878
4600
  const domain = String(input.unit.spec.domain ?? "").trim();
4879
- const cached = getCustomDomainState(input, "railway", domain);
4880
- const live = (cached && railwayCustomDomainHasDnsRequirements(cached) ? cached : null) ?? await observeRailwayCustomDomainLive(input, domain) ?? cached ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
4601
+ const live = await observeRailwayCustomDomainLive(input, domain);
4881
4602
  if (live?.domain) {
4882
4603
  storeCustomDomainState(input, "railway", domain, live);
4883
4604
  }
@@ -4887,6 +4608,8 @@ async function observeCustomDomainUnit(input) {
4887
4608
  live: live ?? {},
4888
4609
  locators: {
4889
4610
  domain: domain || null,
4611
+ serviceId: typeof live?.serviceId === "string" ? live.serviceId : null,
4612
+ serviceName: typeof live?.serviceName === "string" ? live.serviceName : null,
4890
4613
  serviceDomain: typeof live?.serviceDomain === "string" ? live.serviceDomain : null
4891
4614
  },
4892
4615
  warnings: []
@@ -4938,8 +4661,7 @@ async function verifyCustomDomainUnit(input) {
4938
4661
  }
4939
4662
  case "custom-domain:api": {
4940
4663
  const domain = String(input.unit.spec.domain ?? "").trim();
4941
- const cached = getCustomDomainState(input, "railway", domain);
4942
- const live = (cached && railwayCustomDomainHasDnsRequirements(cached) ? cached : null) ?? await observeRailwayCustomDomainLive(input, domain) ?? cached ?? getPersistedCustomDomainState(input, "railway", domain) ?? input.persistedState?.lastObservedState ?? input.persistedState?.lastReconciledState ?? null;
4664
+ const live = await observeRailwayCustomDomainLive(input, domain);
4943
4665
  const dnsRecords = Array.isArray(live?.dnsRecords) ? live.dnsRecords : [];
4944
4666
  const certificateStatus = typeof live?.certificateStatus === "string" ? live.certificateStatus.trim().toUpperCase() : null;
4945
4667
  const verified = live?.verified === true;
@@ -4955,6 +4677,13 @@ async function verifyCustomDomainUnit(input) {
4955
4677
  observed: typeof live?.domain === "string" ? live.domain : null,
4956
4678
  issues: live?.domain ? [] : [`Railway custom domain ${domain || "(unset)"} is missing.`]
4957
4679
  }),
4680
+ verificationCheck("custom-domain.service", "Railway custom domain is attached to the desired service", "api", {
4681
+ exists: Boolean(live?.serviceId),
4682
+ configured: String(live?.serviceName ?? "") === String(input.unit.spec.serviceName ?? ""),
4683
+ expected: input.unit.spec.serviceName ?? null,
4684
+ observed: live?.serviceName ?? null,
4685
+ issues: String(live?.serviceName ?? "") === String(input.unit.spec.serviceName ?? "") ? [] : [`Railway custom domain ${domain || "(unset)"} is not attached to ${String(input.unit.spec.serviceName ?? "(unset service)")}.`]
4686
+ }),
4958
4687
  verificationCheck("custom-domain.dns-requirements", "Railway custom domain exposes DNS requirements", "api", {
4959
4688
  exists: hasDnsRequirements,
4960
4689
  expected: true,
@@ -5043,30 +4772,72 @@ async function reconcileCustomDomainUnit(input, diff) {
5043
4772
  }
5044
4773
  case "custom-domain:api": {
5045
4774
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
4775
+ const domain = String(input.unit.spec.domain ?? "").trim();
5046
4776
  const serviceKey = String(input.unit.metadata.serviceKey ?? "api").trim();
5047
- const topology = await resolveRailwayTopologyForScope(input, scope, {
5048
- ensure: true,
5049
- serviceKeys: [serviceKey],
4777
+ const topology = await resolveRailwayUnitTopology(input, scope, {
4778
+ refresh: false,
5050
4779
  includeInstances: false,
5051
- includeVariables: false
4780
+ includeVariables: false,
4781
+ cacheSuffix: "post-apply"
5052
4782
  });
5053
- const entry = topology.services.get(serviceKey) ?? null;
5054
- if (!entry?.configuredService) {
5055
- throw new Error(`Railway service ${serviceKey} is not configured for custom domain reconciliation.`);
4783
+ const entry = findRailwayTopologyEntry(topology, serviceKey);
4784
+ if (!entry?.project?.id || !entry.environment?.id || !entry.service?.id || !domain) {
4785
+ throw new Error(`Railway custom domain reconciliation could not resolve ${domain || "(unset domain)"} and its desired service.`);
5056
4786
  }
5057
- const state = await ensureRailwayCustomDomain(
5058
- input,
5059
- entry.configuredService,
5060
- String(input.unit.spec.domain ?? "").trim(),
5061
- topology.env,
5062
- {
5063
- projectId: entry.project?.id ?? null,
5064
- environmentId: entry.environment?.id ?? null,
5065
- serviceId: entry.service?.id ?? null
4787
+ const projectServices = await listRailwayServices({ projectId: entry.project.id, env: topology.env });
4788
+ const detachedServiceIds = [];
4789
+ for (const candidate of projectServices) {
4790
+ if (candidate.id === entry.service.id) continue;
4791
+ const domains = await listRailwayCustomDomains({
4792
+ projectId: entry.project.id,
4793
+ environmentId: entry.environment.id,
4794
+ serviceId: candidate.id,
4795
+ env: topology.env
4796
+ }).catch(() => []);
4797
+ for (const stale of domains.filter((candidateDomain) => candidateDomain.domain === domain)) {
4798
+ if (!stale.id) throw new Error(`Railway custom domain ${domain} is attached to ${candidate.name} but has no provider id for safe transfer.`);
4799
+ traceRailwayReconcile(topology.env, "sync:custom-domain:detach-stale", `${domain}:${candidate.name}`);
4800
+ await deleteRailwayCustomDomain({
4801
+ projectId: entry.project.id,
4802
+ environmentId: entry.environment.id,
4803
+ serviceId: candidate.id,
4804
+ domainId: stale.id,
4805
+ env: topology.env
4806
+ });
4807
+ detachedServiceIds.push(candidate.id);
5066
4808
  }
5067
- );
5068
- storeCustomDomainState(input, "railway", String(input.unit.spec.domain ?? "").trim(), state);
5069
- const observed = await observeCustomDomainUnit(input);
4809
+ }
4810
+ for (const detachedServiceId of detachedServiceIds) {
4811
+ let stillAttached = true;
4812
+ for (let detachAttempt = 0; detachAttempt < 12 && stillAttached; detachAttempt += 1) {
4813
+ const domains = await listRailwayCustomDomains({
4814
+ projectId: entry.project.id,
4815
+ environmentId: entry.environment.id,
4816
+ serviceId: detachedServiceId,
4817
+ env: topology.env
4818
+ });
4819
+ stillAttached = domains.some((candidateDomain) => candidateDomain.domain === domain);
4820
+ if (stillAttached) sleepMs(2e3);
4821
+ }
4822
+ if (stillAttached) {
4823
+ throw new Error(`Railway custom domain ${domain} remained attached to stale service ${detachedServiceId} after deletion.`);
4824
+ }
4825
+ }
4826
+ await ensureRailwayCustomDomain({
4827
+ projectId: entry.project.id,
4828
+ environmentId: entry.environment.id,
4829
+ serviceId: entry.service.id,
4830
+ domain,
4831
+ env: topology.env
4832
+ });
4833
+ let observed = await observeCustomDomainUnit(input);
4834
+ for (let domainAttempt = 0; domainAttempt < 12 && (!observed.exists || observed.locators.serviceId !== entry.service.id); domainAttempt += 1) {
4835
+ sleepMs(2e3);
4836
+ observed = await observeCustomDomainUnit(input);
4837
+ }
4838
+ if (!observed.exists || observed.locators.serviceId !== entry.service.id) {
4839
+ throw new Error(`Railway did not attach custom domain ${domain} to ${entry.service.name}.`);
4840
+ }
5070
4841
  return {
5071
4842
  unit: input.unit,
5072
4843
  observed,
@@ -5074,7 +4845,7 @@ async function reconcileCustomDomainUnit(input, diff) {
5074
4845
  action: diff.action === "create" || diff.action === "update" ? "update" : diff.action,
5075
4846
  warnings: observed.warnings,
5076
4847
  resourceLocators: observed.locators,
5077
- state,
4848
+ state: observed.live,
5078
4849
  verification: null
5079
4850
  };
5080
4851
  }
@@ -5123,13 +4894,13 @@ async function verifyRailwayUnit(input) {
5123
4894
  try {
5124
4895
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
5125
4896
  const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
5126
- const topology = await resolveRailwayTopologyForScope(input, scope, {
5127
- serviceKeys: [serviceKey],
4897
+ const topology = await resolveRailwayUnitTopology(input, scope, {
5128
4898
  includeInstances: true,
5129
4899
  includeVariables: true,
5130
- refresh: true
4900
+ refresh: true,
4901
+ cacheSuffix: "verify"
5131
4902
  });
5132
- const entry = topology.services.get(serviceKey) ?? null;
4903
+ const entry = findRailwayTopologyEntry(topology, railwayUnitServiceIdentity(input));
5133
4904
  const service = entry?.configuredService ?? null;
5134
4905
  if (!service || !entry) {
5135
4906
  return summarizeVerification(input.unit.unitId, [
@@ -5322,19 +5093,28 @@ async function verifyRailwayUnit(input) {
5322
5093
  const volumes = entry.project?.id ? await listRailwayVolumes({ projectId: entry.project.id, env: topology.env }) : [];
5323
5094
  const expectedServiceId = entry.service?.id ?? null;
5324
5095
  const expectedEnvironmentId = entry.environment?.id ?? null;
5325
- const mountedVolume = volumes.find((volume) => volume.instances.some(
5096
+ const mountedVolumes = volumes.filter((volume) => volume.instances.some(
5326
5097
  (instance) => instance.serviceId === expectedServiceId && instance.environmentId === expectedEnvironmentId && instance.mountPath === service.volumeMountPath && !instance.isPendingDeletion && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && !["DELETING", "DELETED"].includes(String(instance.state ?? "").toUpperCase())
5327
- )) ?? null;
5098
+ ));
5099
+ const expectedVolumeName = `${service.serviceName}-volume`;
5100
+ const mountedVolume = mountedVolumes.find((volume) => volume.name === expectedVolumeName) ?? null;
5101
+ const volumeIssues = [
5102
+ mountedVolume ? null : `Railway service ${service.serviceName ?? service.key} is not mounted to canonical volume ${expectedVolumeName} at ${service.volumeMountPath}.`,
5103
+ mountedVolumes.length === 1 ? null : `Railway service ${service.serviceName ?? service.key} has ${mountedVolumes.length} active volumes mounted at ${service.volumeMountPath}; expected exactly one.`
5104
+ ].filter((issue) => Boolean(issue));
5328
5105
  checks.push(verificationCheck("railway.volume:data", "Railway service has persistent data volume mounted", "api", {
5329
- exists: Boolean(mountedVolume),
5330
- configured: Boolean(mountedVolume),
5331
- expected: service.volumeMountPath,
5106
+ exists: mountedVolumes.length > 0,
5107
+ configured: volumeIssues.length === 0,
5108
+ ready: volumeIssues.length === 0,
5109
+ verified: volumeIssues.length === 0,
5110
+ expected: { name: expectedVolumeName, mountPath: service.volumeMountPath, count: 1 },
5332
5111
  observed: mountedVolume ? {
5333
5112
  name: mountedVolume.name,
5334
5113
  mountPath: service.volumeMountPath,
5114
+ count: mountedVolumes.length,
5335
5115
  source: "api"
5336
- } : null,
5337
- issues: mountedVolume ? [] : [`Railway service ${service.serviceName ?? service.key} is missing a persistent volume mounted at ${service.volumeMountPath}.`]
5116
+ } : mountedVolumes.map((volume) => ({ name: volume.name, mountPath: service.volumeMountPath })),
5117
+ issues: volumeIssues
5338
5118
  }));
5339
5119
  }
5340
5120
  for (const [key, value] of Object.entries(sync.variables)) {
@@ -5382,7 +5162,7 @@ async function verifyRailwayUnit(input) {
5382
5162
  }
5383
5163
  function railwayVerificationMaySettle(verification) {
5384
5164
  return verification.checks.some(
5385
- (check) => !check.verified && (check.key === "railway.instance" || check.key.startsWith("railway.instance.") || check.key === "railway.volume:data")
5165
+ (check) => !check.verified && (check.key === "railway.instance" || check.key.startsWith("railway.instance.") || check.key === "railway.service.source-mode" || check.key === "railway.volume:data")
5386
5166
  );
5387
5167
  }
5388
5168
  function railwayStartCommandMatches(serviceKey, observed, expected) {
@@ -5409,13 +5189,54 @@ function railwayStartCommandMatches(serviceKey, observed, expected) {
5409
5189
  }
5410
5190
  return remainder === normalizedExpected;
5411
5191
  }
5412
- function buildRailwayDiff(input, observed) {
5192
+ function railwayProjectServiceKeys(input, scope, configuredService) {
5193
+ return configuredRailwayServicesForInput(input, scope).filter((candidate) => candidate.projectName === configuredService.projectName && candidate.railwayEnvironment === configuredService.railwayEnvironment).map((candidate) => candidate.key);
5194
+ }
5195
+ function railwayUnitServiceIdentity(input) {
5196
+ return String(input.unit.metadata.serviceInstanceKey ?? input.unit.spec.serviceName ?? input.unit.metadata.serviceKey ?? "").trim();
5197
+ }
5198
+ function configuredRailwayServiceForUnit(input, scope) {
5199
+ const identity = railwayUnitServiceIdentity(input);
5200
+ const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
5201
+ return configuredRailwayServicesForInput(input, scope).find((candidate) => candidate.instanceKey === identity || candidate.serviceName === identity || candidate.key === identity || candidate.key === serviceKey) ?? null;
5202
+ }
5203
+ function findRailwayTopologyEntry(topology, identity) {
5204
+ return topology.services.get(identity) ?? [...topology.services.values()].find((entry) => entry.configuredService.instanceKey === identity || entry.configuredService.serviceName === identity || entry.configuredService.key === identity) ?? null;
5205
+ }
5206
+ async function resolveRailwayUnitTopology(input, scope, options) {
5207
+ const configuredService = configuredRailwayServiceForUnit(input, scope);
5208
+ const serviceKeys = configuredService ? railwayProjectServiceKeys(input, scope, configuredService) : [String(input.unit.metadata.serviceKey ?? "").trim()];
5209
+ const projectKey = configuredService ? `${configuredService.projectName}:${configuredService.railwayEnvironment}` : serviceKeys.join(",");
5210
+ const load = () => resolveRailwayTopologyForScope(input, scope, {
5211
+ refresh: options.refresh,
5212
+ serviceKeys,
5213
+ includeInstances: options.includeInstances,
5214
+ includeVariables: options.includeVariables
5215
+ });
5216
+ return options.cacheSuffix ? await providerCache(input, `railway:unit-topology:${scope}:${projectKey}:${options.cacheSuffix}`, load) : await load();
5217
+ }
5218
+ async function buildRailwayDiff(input, observed) {
5219
+ const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
5220
+ const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
5221
+ const configuredService = configuredRailwayServiceForUnit(input, scope);
5222
+ const projectServiceKeys = configuredService ? railwayProjectServiceKeys(input, scope, configuredService) : [];
5223
+ const projectPlan = configuredService ? await providerCache(input, `railway:plan:${scope}:${configuredService.projectName}:${configuredService.railwayEnvironment}`, () => syncRailwayEnvironmentForScope(input, { planOnly: true, serviceKeys: projectServiceKeys })) : null;
5224
+ const plannedChanges = projectPlan?.projectPlans?.flatMap((plan) => plan.changes) ?? [];
5225
+ const planReasons = plannedChanges.map((change) => change.summary || `${change.kind} ${change.path}`);
5413
5226
  if (!observed.exists) {
5414
5227
  return {
5415
5228
  action: "create",
5416
- reasons: ["service missing from configured topology"],
5229
+ reasons: ["service missing from configured topology", ...planReasons],
5417
5230
  before: observed.live,
5418
- after: input.unit.spec
5231
+ after: { ...input.unit.spec, railwayProjectChanges: plannedChanges }
5232
+ };
5233
+ }
5234
+ if (plannedChanges.length > 0) {
5235
+ return {
5236
+ action: "update",
5237
+ reasons: planReasons,
5238
+ before: observed.live,
5239
+ after: { ...input.unit.spec, railwayProjectChanges: plannedChanges }
5419
5240
  };
5420
5241
  }
5421
5242
  return {
@@ -5428,9 +5249,9 @@ function buildRailwayDiff(input, observed) {
5428
5249
  async function reconcileRailwayUnit(input, diff) {
5429
5250
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
5430
5251
  const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
5431
- const configuredService = configuredRailwayServicesForInput(input, scope).find((candidate) => candidate.key === serviceKey || candidate.instanceKey === serviceKey || candidate.serviceName === serviceKey) ?? null;
5252
+ const configuredService = configuredRailwayServiceForUnit(input, scope);
5432
5253
  const requiresProjectLevelSync = Boolean(configuredService);
5433
- const serviceKeys = serviceKey && !requiresProjectLevelSync ? [serviceKey] : void 0;
5254
+ const serviceKeys = configuredService ? railwayProjectServiceKeys(input, scope, configuredService) : serviceKey ? [serviceKey] : void 0;
5434
5255
  const cacheKey = requiresProjectLevelSync ? `railway:sync:${scope}:project:${configuredService?.projectName ?? "default"}:${configuredService?.railwayEnvironment ?? scope}` : `railway:sync:${scope}:${serviceKey || "all"}`;
5435
5256
  let attempt = 0;
5436
5257
  for (; ; ) {
@@ -5441,11 +5262,6 @@ async function reconcileRailwayUnit(input, diff) {
5441
5262
  });
5442
5263
  break;
5443
5264
  } catch (error) {
5444
- const message = error instanceof Error ? error.message : String(error ?? "");
5445
- if (attempt >= 2 && /Problem processing request/iu.test(message)) {
5446
- traceRailwayReconcile(buildRailwayEnv(input, scope), "sync:skipped-provider-error", `${serviceKey || "all"}:problem-processing-request`);
5447
- break;
5448
- }
5449
5265
  if (attempt >= 2 || !isTransientRailwayReconcileError(error)) {
5450
5266
  throw error;
5451
5267
  }
@@ -5453,12 +5269,20 @@ async function reconcileRailwayUnit(input, diff) {
5453
5269
  sleepMs(1e3 * attempt);
5454
5270
  }
5455
5271
  }
5456
- for (const key of input.context.session.keys()) {
5457
- if (key.startsWith(`railway:topology:${scope}:`)) {
5458
- input.context.session.delete(key);
5272
+ const postApplyTopology = await providerCache(input, `${cacheKey}:post-apply-topology`, async () => {
5273
+ for (const key of input.context.session.keys()) {
5274
+ if (key.startsWith(`railway:topology:${scope}:`) || key.startsWith(`railway:unit-topology:${scope}:`)) {
5275
+ input.context.session.delete(key);
5276
+ }
5459
5277
  }
5460
- }
5461
- const refreshed = await observeRailwayUnit(input, { refresh: true });
5278
+ return resolveRailwayUnitTopology(input, scope, {
5279
+ refresh: true,
5280
+ includeInstances: false,
5281
+ includeVariables: false,
5282
+ cacheSuffix: "post-apply"
5283
+ });
5284
+ });
5285
+ const refreshed = await observeRailwayUnit(input, { topology: await postApplyTopology });
5462
5286
  return {
5463
5287
  unit: input.unit,
5464
5288
  observed: refreshed,
@@ -5491,7 +5315,7 @@ async function destroyRailwayUnit(input) {
5491
5315
  );
5492
5316
  if (!attached) continue;
5493
5317
  traceRailwayReconcile(env, "destroy:volume", `${serviceName}:${volume.name ?? volume.id}:${volume.id}`);
5494
- const result = await deleteRailwayVolume({ volumeId: volume.id, env });
5318
+ const result = await deleteRailwayVolume({ projectId, environmentId, volumeId: volume.id, env });
5495
5319
  deletedVolumes.push({
5496
5320
  id: volume.id,
5497
5321
  name: volume.name ?? null,
@@ -5502,7 +5326,7 @@ async function destroyRailwayUnit(input) {
5502
5326
  let deletedService = null;
5503
5327
  if (serviceId) {
5504
5328
  traceRailwayReconcile(env, "destroy:service", `${serviceName}:${serviceId}`);
5505
- deletedService = await deleteRailwayService({ serviceId, env });
5329
+ deletedService = await deleteRailwayService({ projectId, environmentId, serviceId, env });
5506
5330
  }
5507
5331
  for (const key of input.context.session.keys()) {
5508
5332
  if (key.startsWith(`railway:topology:${scope}:`)) {
@@ -5595,8 +5419,19 @@ function buildCustomDomainAdapter(unitType, providerId) {
5595
5419
  diff(input) {
5596
5420
  return buildAttachmentDiff(input, input.observed);
5597
5421
  },
5598
- apply(input) {
5599
- return reconcileCustomDomainUnit(input, input.diff);
5422
+ async apply(input) {
5423
+ let attempt = 0;
5424
+ for (; ; ) {
5425
+ try {
5426
+ return await reconcileCustomDomainUnit(input, input.diff);
5427
+ } catch (error) {
5428
+ if (providerId !== "railway" || attempt >= 2 || !isTransientRailwayReconcileError(error)) throw error;
5429
+ attempt += 1;
5430
+ process.stderr.write(`[trsd][railway][custom-domain] domain=${String(input.unit.spec.domain ?? "")} retry=${attempt}/2
5431
+ `);
5432
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3 * attempt));
5433
+ }
5434
+ }
5600
5435
  },
5601
5436
  verify(input) {
5602
5437
  return verifyCustomDomainUnit(input);