@treeseed/sdk 0.12.59 → 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 -247
  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 +6 -6
  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 +56 -14
  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 +5 -0
  22. package/dist/operations/services/railway-deploy.js +47 -75
  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 +524 -680
  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 +148 -0
  35. package/dist/reconcile/providers/railway-iac.js +294 -18
  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 +25 -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,9 +1,160 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import {
4
4
  runRailwayIac
5
5
  } from "railway/iac";
6
6
  import { assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "../../operations/services/railway-source-policy.js";
7
+ async function waitForRailwayVolumeAdoptionResources({
8
+ load,
9
+ serviceName,
10
+ volumeId,
11
+ attempts = 12,
12
+ intervalMs = 2500,
13
+ sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
14
+ }) {
15
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
16
+ const current = await load();
17
+ const service = current.services.find((candidate) => candidate.name === serviceName) ?? null;
18
+ const volume = current.volumes.find((candidate) => candidate.id === volumeId) ?? null;
19
+ if (service && volume) return { service, volume, services: current.services, volumes: current.volumes, attempt };
20
+ if (attempt < attempts) await sleep(intervalMs);
21
+ }
22
+ return null;
23
+ }
24
+ async function waitForRailwayServices({
25
+ load,
26
+ serviceNames,
27
+ attempts = 12,
28
+ intervalMs = 2500,
29
+ sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
30
+ }) {
31
+ const expected = [...new Set(serviceNames)];
32
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
33
+ const services = await load();
34
+ const observed = new Set(services.map((service) => service.name).filter(Boolean));
35
+ if (expected.every((name) => observed.has(name))) return { services, attempt };
36
+ if (attempt < attempts) await sleep(intervalMs);
37
+ }
38
+ return null;
39
+ }
40
+ async function waitForRailwayVolumeName({
41
+ load,
42
+ volumeId,
43
+ expectedName,
44
+ attempts = 12,
45
+ intervalMs = 2500,
46
+ sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
47
+ }) {
48
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
49
+ const volume = (await load()).find((candidate) => candidate.id === volumeId) ?? null;
50
+ if (volume?.name === expectedName) return { volume, attempt };
51
+ if (attempt < attempts) await sleep(intervalMs);
52
+ }
53
+ return null;
54
+ }
55
+ async function waitForRailwayVolumeDetachment({
56
+ load,
57
+ volumeId,
58
+ environmentId,
59
+ serviceId,
60
+ attempts = 12,
61
+ intervalMs = 2500,
62
+ sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
63
+ }) {
64
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
65
+ const volume = (await load()).find((candidate) => candidate.id === volumeId) ?? null;
66
+ const attached = volume?.instances?.some(
67
+ (instance) => instance.environmentId === environmentId && instance.serviceId === serviceId
68
+ ) ?? false;
69
+ if (!attached) return { volume, attempt };
70
+ if (attempt < attempts) await sleep(intervalMs);
71
+ }
72
+ return null;
73
+ }
74
+ async function waitForRailwayServiceAbsence({
75
+ load,
76
+ serviceId,
77
+ attempts = 12,
78
+ intervalMs = 2500,
79
+ sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
80
+ }) {
81
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
82
+ if (!(await load()).some((service) => service.id === serviceId)) return { attempt };
83
+ if (attempt < attempts) await sleep(intervalMs);
84
+ }
85
+ return null;
86
+ }
87
+ const STALE_RAILWAY_IAC_RENDER_AGE_MS = 15 * 60 * 1e3;
88
+ function cleanupStaleRailwayIacRenders(tenantRoot, now = Date.now()) {
89
+ const tempParent = resolve(tenantRoot, ".treeseed", "tmp");
90
+ let entries;
91
+ try {
92
+ entries = readdirSync(tempParent);
93
+ } catch {
94
+ return [];
95
+ }
96
+ const removed = [];
97
+ for (const entry of entries.filter((name) => name.startsWith("railway-iac-"))) {
98
+ const path = resolve(tempParent, entry);
99
+ try {
100
+ if (now - statSync(path).mtimeMs < STALE_RAILWAY_IAC_RENDER_AGE_MS) continue;
101
+ rmSync(path, { recursive: true, force: true });
102
+ removed.push(path);
103
+ } catch {
104
+ }
105
+ }
106
+ return removed;
107
+ }
108
+ function railwayIacApplyFailure(response) {
109
+ const diagnostics = (response.diagnostics ?? []).map((entry) => String(entry.message ?? "").trim()).filter(Boolean);
110
+ if (!response.ok) return diagnostics.join("; ") || "Railway IaC planning failed before apply.";
111
+ const changes = response.changeSet?.changes ?? [];
112
+ if (changes.length === 0) return null;
113
+ if (!response.applyResult) return "Railway IaC returned no apply result for a non-empty change set.";
114
+ const successfulStatuses = /* @__PURE__ */ new Set(["APPLIED", "COMPLETED", "SUCCESS", "SUCCEEDED"]);
115
+ const applyStatus = String(response.applyResult.status ?? "").trim().toUpperCase();
116
+ const failedChanges = (response.applyResult.changes ?? []).filter((change) => !successfulStatuses.has(String(change.status ?? "").trim().toUpperCase())).map((change) => `${change.path ?? change.kind}: ${change.status || "unknown"}`);
117
+ const applyDiagnostics = (response.applyResult.diagnostics ?? []).map((entry) => typeof entry === "string" ? entry : JSON.stringify(entry)).filter(Boolean);
118
+ if (!successfulStatuses.has(applyStatus) || failedChanges.length > 0 || applyDiagnostics.length > 0) {
119
+ return [
120
+ `apply status ${applyStatus || "unknown"}`,
121
+ ...failedChanges,
122
+ ...diagnostics,
123
+ ...applyDiagnostics
124
+ ].join("; ");
125
+ }
126
+ return null;
127
+ }
128
+ async function runRailwayIacWithRateLimitRetry(run, {
129
+ delaysMs = [15e3, 45e3, 9e4],
130
+ sleep = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
131
+ onRetry,
132
+ onWait
133
+ } = {}) {
134
+ for (let attempt = 1; ; attempt += 1) {
135
+ try {
136
+ const result = await run();
137
+ if (result && typeof result === "object" && "ok" in result && result.ok === false) {
138
+ const diagnostics = Array.isArray(result.diagnostics) ? result.diagnostics.map((entry) => typeof entry === "string" ? entry : String(entry?.message ?? "")).filter(Boolean).join("; ") : "";
139
+ if (/fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|HTTP\s+429|rate[ -]?limit/iu.test(diagnostics)) {
140
+ throw new Error(diagnostics || "Railway IaC transport failed.");
141
+ }
142
+ }
143
+ return result;
144
+ } catch (error) {
145
+ const delayMs = delaysMs[attempt - 1];
146
+ if (delayMs === void 0 || !/fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|aborted|HTTP\s+429|rate[ -]?limit/iu.test(String(error))) throw error;
147
+ onRetry?.(attempt + 1, delayMs, error);
148
+ let remainingMs = delayMs;
149
+ while (remainingMs > 0) {
150
+ const sliceMs = Math.min(15e3, remainingMs);
151
+ await sleep(sliceMs);
152
+ remainingMs -= sliceMs;
153
+ if (remainingMs > 0) onWait?.(attempt + 1, remainingMs);
154
+ }
155
+ }
156
+ }
157
+ }
7
158
  function js(value) {
8
159
  return JSON.stringify(value);
9
160
  }
@@ -59,16 +210,14 @@ function buildConfig(service) {
59
210
  }
60
211
  return null;
61
212
  }
62
- function deployConfig(service, region) {
213
+ function deployConfig(service) {
63
214
  const runtimeMode = String(service.runtimeMode ?? "").trim();
64
215
  const deploy = {
65
216
  ...service.startCommand ? { startCommand: service.startCommand } : {},
66
217
  ...service.healthcheckPath ? { healthcheckPath: service.healthcheckPath } : {},
67
218
  ...service.healthcheckTimeoutSeconds ? { healthcheckTimeout: service.healthcheckTimeoutSeconds } : {},
68
- ...runtimeMode === "serverless" ? { sleepApplication: true } : {},
69
- ...runtimeMode === "service" || runtimeMode === "replicated" ? { sleepApplication: false } : {},
70
- ...service.volumeMountPath ? { requiredMountPath: service.volumeMountPath } : {},
71
- region
219
+ ...typeof service.numReplicas === "number" ? { numReplicas: service.numReplicas } : {},
220
+ ...runtimeMode === "serverless" ? { sleepApplication: true } : {}
72
221
  };
73
222
  return Object.keys(deploy).length > 0 ? deploy : null;
74
223
  }
@@ -105,10 +254,96 @@ function normalizeIacScope(input) {
105
254
  const environmentName = String(input.environmentName ?? "").trim().toLowerCase();
106
255
  return environmentName === "production" || environmentName === "prod" ? "prod" : environmentName === "staging" ? "staging" : "local";
107
256
  }
257
+ function activeObservedVolumeInstances(volume) {
258
+ return volume.instances.filter((instance) => {
259
+ const state = String(instance.state ?? "READY").toUpperCase();
260
+ return instance.isPendingDeletion !== true && !(typeof instance.deletedAt === "string" && instance.deletedAt.trim()) && state !== "DELETING" && state !== "DELETED";
261
+ });
262
+ }
263
+ function findRailwayPendingVolumeNameCollisions(input) {
264
+ const serviceIdByName = new Map((input.liveServices ?? []).map((service) => [service.name, service.id]));
265
+ return input.services.flatMap((service) => {
266
+ if (!service.volumeMountPath) return [];
267
+ const canonicalVolumeName = `${service.serviceName}-volume`;
268
+ const desiredServiceId = serviceIdByName.get(service.serviceName) ?? null;
269
+ return input.volumes.filter((volume) => volume.name === canonicalVolumeName || String(volume.name ?? "").startsWith("pending-delete-") && Boolean(desiredServiceId) && volume.instances.some((instance) => instance.serviceId === desiredServiceId)).filter((volume) => volume.instances.length > 0 && activeObservedVolumeInstances(volume).length === 0).map((volume) => ({
270
+ serviceName: service.serviceName,
271
+ volumeId: volume.id,
272
+ canonicalVolumeName,
273
+ mountPath: service.volumeMountPath,
274
+ serviceId: volume.instances.find((instance) => instance.serviceId)?.serviceId ?? null
275
+ }));
276
+ });
277
+ }
278
+ function resolveRailwayIacVolumeBindings(input) {
279
+ const bindings = [];
280
+ const blockedReasons = [];
281
+ const serviceIdByName = new Map(input.liveServices.map((service) => [service.name, service.id]));
282
+ for (const service of input.services.filter((candidate) => Boolean(candidate.volumeMountPath))) {
283
+ const canonicalVolumeName = `${service.serviceName}-volume`;
284
+ const desiredServiceId = serviceIdByName.get(service.serviceName) ?? null;
285
+ const canonical = input.volumes.flatMap(
286
+ (volume) => activeObservedVolumeInstances(volume).filter((instance) => instance.environmentId === input.environmentId && volume.name === canonicalVolumeName).map((instance) => ({ volume, instance }))
287
+ );
288
+ const candidatesByVolume = new Map(canonical.map((candidate) => [candidate.volume.id, candidate]));
289
+ if (candidatesByVolume.size > 1) {
290
+ blockedReasons.push(`${service.serviceName}: ${candidatesByVolume.size} active volumes are viable in environment ${input.environmentId}; refusing ambiguous stateful volume ownership.`);
291
+ continue;
292
+ }
293
+ const selected = candidatesByVolume.values().next().value;
294
+ if (!selected?.volume.id || !selected.volume.name) {
295
+ const pendingCanonicalCollision = input.volumes.some(
296
+ (volume) => (volume.name === canonicalVolumeName || String(volume.name ?? "").startsWith("pending-delete-") && Boolean(desiredServiceId) && volume.instances.some((instance) => instance.serviceId === desiredServiceId)) && volume.instances.length > 0 && activeObservedVolumeInstances(volume).length === 0
297
+ );
298
+ if (pendingCanonicalCollision) continue;
299
+ continue;
300
+ }
301
+ bindings.push({
302
+ serviceName: service.serviceName,
303
+ volumeId: selected.volume.id,
304
+ volumeName: selected.volume.name,
305
+ canonicalVolumeName,
306
+ mode: "canonical",
307
+ reason: selected.instance.serviceId === desiredServiceId ? "existing desired-service attachment" : "active canonical volume"
308
+ });
309
+ }
310
+ return { bindings, blockedReasons };
311
+ }
312
+ function detachRetainedRailwayVolumeBindings(resources, bindings) {
313
+ const movedVolumeNames = new Set(bindings.map((binding) => binding.volumeName));
314
+ return resources.map((resource) => {
315
+ if (resource.type !== "service" && resource.type !== "database") return resource;
316
+ const attachments = Object.fromEntries(Object.entries(resource.volumeAttachments ?? {}).filter(([, attachment]) => !movedVolumeNames.has(String(attachment.volume).replace(/^volume\./u, ""))));
317
+ const volumeMounts = Object.fromEntries(Object.entries(resource.volumeMounts ?? {}).filter(([volumeId]) => !bindings.some((binding) => binding.volumeId === volumeId)));
318
+ let deploy = resource.deploy;
319
+ if (deploy && Object.keys(attachments).length === 0 && Object.keys(volumeMounts).length === 0) {
320
+ const { requiredMountPath: _requiredMountPath, ...deployWithoutMountRequirement } = deploy;
321
+ deploy = Object.keys(deployWithoutMountRequirement).length > 0 ? deployWithoutMountRequirement : void 0;
322
+ }
323
+ const { volumeAttachments: _attachments, volumeMounts: _volumeMounts, ...retained } = resource;
324
+ return {
325
+ ...retained,
326
+ ...Object.keys(attachments).length > 0 ? { volumeAttachments: attachments } : {},
327
+ ...Object.keys(volumeMounts).length > 0 ? { volumeMounts } : {},
328
+ ...deploy ? { deploy } : {}
329
+ };
330
+ });
331
+ }
332
+ function detachRetainedRailwayCustomDomains(resources, domains) {
333
+ const selected = new Set(domains);
334
+ return resources.map((resource) => {
335
+ if (resource.type !== "service" || selected.size === 0) return resource;
336
+ const customDomains = Object.fromEntries(Object.entries(resource.networking?.customDomains ?? {}).filter(([domain]) => !selected.has(domain)));
337
+ if (Object.keys(customDomains).length === Object.keys(resource.networking?.customDomains ?? {}).length) return resource;
338
+ const networking = { ...resource.networking ?? {}, customDomains };
339
+ return { ...resource, networking };
340
+ });
341
+ }
108
342
  function renderRailwayIacProject(input) {
109
343
  const scope = normalizeIacScope(input);
110
344
  const region = input.region?.trim() || "us-east4-eqdc4a";
111
345
  const tempParent = resolve(input.tenantRoot, ".treeseed", "tmp");
346
+ cleanupStaleRailwayIacRenders(input.tenantRoot);
112
347
  mkdirSync(tempParent, { recursive: true });
113
348
  const tempDir = mkdtempSync(resolve(tempParent, "railway-iac-"));
114
349
  const filePath = resolve(tempDir, "railway.mjs");
@@ -175,23 +410,30 @@ function renderRailwayIacProject(input) {
175
410
  `env: ${renderServiceEnv(service, databaseVariableName, databaseEnvName)}`
176
411
  ];
177
412
  const build = buildConfig(service);
178
- const deploy = deployConfig(service, region);
413
+ const deploy = deployConfig(service);
179
414
  if (build) entries.push(`build: ${js(build)}`);
180
415
  if (deploy) entries.push(`deploy: ${js(deploy)}`);
416
+ entries.push(`regions: ${js({ [region]: 1 })}`);
417
+ if ((service.customDomains?.length ?? 0) > 0) {
418
+ entries.push(`networking: ${js({
419
+ customDomains: Object.fromEntries(service.customDomains.map((domain) => [domain, {}]))
420
+ })}`);
421
+ }
181
422
  if (service.volumeMountPath) {
182
- const volumeName = `${service.serviceName}-volume`;
423
+ const volumeName = service.volumeName?.trim() || `${service.serviceName}-volume`;
424
+ const volumeAddress = service.volumeAddress?.trim() || null;
183
425
  const volumeVar = id("vol", index);
184
426
  const volumeMounts = [
185
427
  ...(service.detachVolumeIds ?? []).map((volumeId) => `${js(volumeId)}: null`),
186
428
  `${js(service.volumeMountPath)}: ${volumeVar}`
187
429
  ];
188
430
  volumeNames.push(volumeName);
189
- declarations.push(` const ${volumeVar} = volume(${js(volumeName)}, ${js({
431
+ declarations.push(` const ${volumeVar} = ${volumeAddress ? "Object.assign(" : ""}volume(${js(volumeName)}, ${js({
190
432
  region,
191
433
  sizeMB: 5e4,
192
434
  allowOnlineResize: true,
193
435
  alerts: { usage: { 80: {}, 95: {}, 100: {} } }
194
- })});`);
436
+ })})${volumeAddress ? `, { address: ${js(volumeAddress)} })` : ""};`);
195
437
  entries.push(`volumeMounts: { ${volumeMounts.join(", ")} }`);
196
438
  resources.push(volumeVar);
197
439
  }
@@ -223,11 +465,16 @@ ${declarations.join("\n")}
223
465
  }
224
466
  function selectRailwayIacRetainedResources(plan, allowedNames) {
225
467
  const allowed = new Set(allowedNames);
226
- const deletedAllowedNames = new Set((plan.changeSet?.changes ?? []).filter((change) => change.kind === "resource.delete").map((change) => changeName(change)).filter((name) => allowed.has(name)));
227
- return (plan.currentGraph?.resources ?? []).filter((resource) => deletedAllowedNames.has(resource.name));
468
+ return (plan.currentGraph?.resources ?? []).filter((resource) => allowed.has(resource.name));
228
469
  }
229
470
  function changeName(change) {
230
- return String(change?.resource?.name ?? change?.previous?.name ?? change?.address ?? change?.path ?? "");
471
+ const directName = String(change?.resource?.name ?? change?.previous?.name ?? "").trim();
472
+ if (directName) return directName;
473
+ const location = String(change?.address ?? change?.path ?? "").trim();
474
+ const pathMatch = /(?:^|\.)?(?:resources\.)?(?:service|database|volume)\.([^\.\s]+)/u.exec(location);
475
+ if (pathMatch?.[1]) return pathMatch[1];
476
+ const summaryMatch = /\b(?:service|database|volume)\s+([^\s]+)/iu.exec(String(change?.summary ?? ""));
477
+ return summaryMatch?.[1] ?? location;
231
478
  }
232
479
  function changeFieldText(change) {
233
480
  return [
@@ -255,10 +502,12 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
255
502
  const blockedReasons = [];
256
503
  const destructiveChanges = [];
257
504
  const desired = /* @__PURE__ */ new Set([...desiredNames.services, ...desiredNames.volumes, ...desiredNames.database ? [desiredNames.database] : []]);
505
+ const allowedResourceDeletions = new Set(desiredNames.allowedResourceDeletions ?? []);
506
+ const protectedResourceNames = new Set(desiredNames.protectedResourceNames ?? []);
258
507
  const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
259
508
  for (const change of changeSet?.changes ?? []) {
260
509
  const name = changeName(change);
261
- const serviceName = name.replace(/^(service|database)\./u, "");
510
+ const serviceName = name.replace(/^(service|database|volume)\./u, "");
262
511
  const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[serviceName] ?? null;
263
512
  const sourceRef = desiredNames.serviceSourceRefs?.[name] ?? desiredNames.serviceSourceRefs?.[serviceName] ?? null;
264
513
  const sourceChanged = change.kind === "resource.update" && isRailwaySourceChange(change);
@@ -267,9 +516,14 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
267
516
  const desiredGitSource = sourceMode === "git" && typeof sourceRef === "string" && sourceRef.startsWith("github:");
268
517
  const desiredImageSource = sourceMode === "image" && typeof sourceRef === "string" && sourceRef.startsWith("image:");
269
518
  const apiPolicyService = isApiRailwaySourcePolicyService({ serviceName });
519
+ if (protectedResourceNames.has(name) && (change.kind === "resource.update" || change.kind === "resource.delete")) {
520
+ blockedReasons.push(`Railway IaC plan would ${change.kind === "resource.delete" ? "delete" : "update"} sibling-environment resource ${name}.`);
521
+ }
270
522
  if (change.kind === "resource.delete") {
271
523
  destructiveChanges.push(change.summary);
272
- blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only updates or creates resources. Use the explicit destroy workflow for deletions.`);
524
+ if (!allowedResourceDeletions.has(name) && !allowedResourceDeletions.has(serviceName)) {
525
+ blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only deletes explicitly recognized obsolete aliases. Use the explicit destroy workflow for other deletions.`);
526
+ }
273
527
  if (desired.has(name) && !created.has(name)) {
274
528
  blockedReasons.push(`Railway IaC plan would delete desired resource ${name}.`);
275
529
  }
@@ -301,7 +555,7 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
301
555
  };
302
556
  }
303
557
  async function planRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
304
- return runRailwayIac({
558
+ return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
305
559
  command: "plan",
306
560
  cwd: rendered.tempDir,
307
561
  file: rendered.filePath,
@@ -314,10 +568,15 @@ async function planRailwayIacProject(input, rendered = renderRailwayIacProject(i
314
568
  decryptVariables: false,
315
569
  merge: true
316
570
  }
571
+ }), {
572
+ onRetry: (attempt, delayMs, error) => process.stderr.write(`[trsd][railway][iac:retry] command=plan attempt=${attempt} waitMs=${delayMs} reason=${error instanceof Error ? error.message : String(error)}
573
+ `),
574
+ onWait: (attempt, remainingMs) => process.stderr.write(`[trsd][railway][iac:retry] command=plan attempt=${attempt} cooldownRemainingMs=${remainingMs}
575
+ `)
317
576
  });
318
577
  }
319
578
  async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
320
- return runRailwayIac({
579
+ return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
321
580
  command: "apply",
322
581
  cwd: rendered.tempDir,
323
582
  file: rendered.filePath,
@@ -330,6 +589,11 @@ async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(
330
589
  decryptVariables: false,
331
590
  merge: true
332
591
  }
592
+ }), {
593
+ onRetry: (attempt, delayMs, error) => process.stderr.write(`[trsd][railway][iac:retry] command=apply attempt=${attempt} waitMs=${delayMs} reason=${error instanceof Error ? error.message : String(error)}
594
+ `),
595
+ onWait: (attempt, remainingMs) => process.stderr.write(`[trsd][railway][iac:retry] command=apply attempt=${attempt} cooldownRemainingMs=${remainingMs}
596
+ `)
333
597
  });
334
598
  }
335
599
  function cleanupRailwayIacRender(rendered) {
@@ -338,8 +602,20 @@ function cleanupRailwayIacRender(rendered) {
338
602
  export {
339
603
  applyRailwayIacProject,
340
604
  cleanupRailwayIacRender,
605
+ cleanupStaleRailwayIacRenders,
606
+ detachRetainedRailwayCustomDomains,
607
+ detachRetainedRailwayVolumeBindings,
608
+ findRailwayPendingVolumeNameCollisions,
341
609
  planRailwayIacProject,
610
+ railwayIacApplyFailure,
342
611
  renderRailwayIacProject,
612
+ resolveRailwayIacVolumeBindings,
613
+ runRailwayIacWithRateLimitRetry,
343
614
  selectRailwayIacRetainedResources,
344
- validateRailwayIacChangeSet
615
+ validateRailwayIacChangeSet,
616
+ waitForRailwayServiceAbsence,
617
+ waitForRailwayServices,
618
+ waitForRailwayVolumeAdoptionResources,
619
+ waitForRailwayVolumeDetachment,
620
+ waitForRailwayVolumeName
345
621
  };
@@ -243,10 +243,10 @@ async function runTreeseedScene(input) {
243
243
  const recordingVideo = Boolean(videoDir);
244
244
  const capture = resolveCapture({ scene, device, runtimeMode: runtime.mode, recording: Boolean(videoDir) });
245
245
  const artifacts = createTreeseedSceneRunArtifacts({ paths, playwrightTracePath: tracePath });
246
- writeFileSync(artifacts.consoleLogPath ?? join(paths.playwrightRoot, "console.jsonl"), "", "utf8");
247
- writeFileSync(artifacts.networkLogPath ?? join(paths.playwrightRoot, "network.jsonl"), "", "utf8");
248
- writeFileSync(artifacts.errorsLogPath ?? join(paths.playwrightRoot, "errors.jsonl"), "", "utf8");
249
- if (artifacts.progressPath) writeFileSync(artifacts.progressPath, "", "utf8");
246
+ writeFileSync(artifacts.consoleLogPath, "", "utf8");
247
+ writeFileSync(artifacts.networkLogPath, "", "utf8");
248
+ writeFileSync(artifacts.errorsLogPath, "", "utf8");
249
+ writeFileSync(artifacts.progressPath, "", "utf8");
250
250
  const timeline = createTreeseedSceneTimeline({ sceneId: scene.id, runId: paths.runId, startedAtMs: startedAt.getTime() });
251
251
  const progress = createTreeseedSceneProgress({ sceneId: scene.id, runId: paths.runId, startedAtMs: startedAt.getTime(), progressPath: artifacts.progressPath, onProgress: input.onProgress });
252
252
  progress.push("scene.run.started", { title: scene.title, environment: plan.environment });
@@ -368,15 +368,15 @@ async function runTreeseedScene(input) {
368
368
  if (message.type() !== "error") return;
369
369
  const entry = { message: message.text(), timestamp: now().toISOString(), ...currentStepId ? { stepId: currentStepId } : {} };
370
370
  consoleErrors.push(entry);
371
- if (artifacts.consoleLogPath) appendTreeseedSceneJsonl(artifacts.consoleLogPath, entry);
372
- if (artifacts.errorsLogPath) appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "console", ...entry });
371
+ appendTreeseedSceneJsonl(artifacts.consoleLogPath, entry);
372
+ appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "console", ...entry });
373
373
  timeline.push("console", entry, currentStepId);
374
374
  });
375
375
  session.page.on("requestfailed", (request) => {
376
376
  const entry = { message: request.failure()?.errorText ?? "request failed", timestamp: now().toISOString(), url: request.url(), method: request.method(), ...currentStepId ? { stepId: currentStepId } : {} };
377
377
  networkErrors.push(entry);
378
- if (artifacts.networkLogPath) appendTreeseedSceneJsonl(artifacts.networkLogPath, entry);
379
- if (artifacts.errorsLogPath) appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "network", ...entry });
378
+ appendTreeseedSceneJsonl(artifacts.networkLogPath, entry);
379
+ appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "network", ...entry });
380
380
  timeline.push("network", entry, currentStepId);
381
381
  });
382
382
  session.page.on("response", (response) => {
@@ -391,8 +391,8 @@ async function runTreeseedScene(input) {
391
391
  if (status < 400) return;
392
392
  const entry = { message: `HTTP ${status}`, timestamp: now().toISOString(), url: response.url(), method: response.request().method(), status, ...currentStepId ? { stepId: currentStepId } : {} };
393
393
  networkErrors.push(entry);
394
- if (artifacts.networkLogPath) appendTreeseedSceneJsonl(artifacts.networkLogPath, entry);
395
- if (artifacts.errorsLogPath) appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "network", ...entry });
394
+ appendTreeseedSceneJsonl(artifacts.networkLogPath, entry);
395
+ appendTreeseedSceneJsonl(artifacts.errorsLogPath, { kind: "network", ...entry });
396
396
  timeline.push("network", entry, currentStepId);
397
397
  });
398
398
  if (tracePath) await session.startTracing?.();
@@ -673,7 +673,7 @@ async function runTreeseedScene(input) {
673
673
  segments,
674
674
  checkpoints,
675
675
  resumedFrom: null,
676
- progressPath: artifacts.progressPath ?? null,
676
+ progressPath: artifacts.progressPath,
677
677
  warnings: splitDiagnostics(diagnostics, "warning"),
678
678
  blockers: splitDiagnostics(diagnostics, "error"),
679
679
  diagnostics
@@ -36,6 +36,27 @@ function lockOwnerIsRunning() {
36
36
  return code === 'EPERM';
37
37
  }
38
38
  }
39
+ function processIsRunning(pid) {
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ }
44
+ catch (error) {
45
+ const code = typeof error === 'object' && error && 'code' in error ? error.code : null;
46
+ return code === 'EPERM';
47
+ }
48
+ }
49
+ function removeStaleBuildRoots() {
50
+ for (const entry of readdirSync(packageRoot, { withFileTypes: true })) {
51
+ const match = entry.isDirectory() ? /^\.treeseed-dist-build-(\d+)$/u.exec(entry.name) : null;
52
+ if (!match?.[1])
53
+ continue;
54
+ const pid = Number.parseInt(match[1], 10);
55
+ if (pid === process.pid || processIsRunning(pid))
56
+ continue;
57
+ rmSync(resolve(packageRoot, entry.name), { recursive: true, force: true });
58
+ }
59
+ }
39
60
  async function acquireBuildLock() {
40
61
  const startedAt = Date.now();
41
62
  while (true) {
@@ -45,6 +66,7 @@ async function acquireBuildLock() {
45
66
  pid: process.pid,
46
67
  startedAt: new Date().toISOString(),
47
68
  }, null, 2));
69
+ removeStaleBuildRoots();
48
70
  return () => rmSync(buildLockRoot, { recursive: true, force: true });
49
71
  }
50
72
  catch (error) {
@@ -1085,6 +1085,10 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1085
1085
  status: string;
1086
1086
  reason: string;
1087
1087
  attempts?: undefined;
1088
+ } | {
1089
+ status: string;
1090
+ reason: string;
1091
+ attempts: number;
1088
1092
  } | {
1089
1093
  status: string;
1090
1094
  reason: null;
@@ -1144,6 +1148,14 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1144
1148
  cached: boolean;
1145
1149
  }[];
1146
1150
  };
1151
+ apiEnvironmentIsolation: {
1152
+ status: string;
1153
+ order: string[];
1154
+ reports: Record<string, import("../workflow-support.js").TreeseedLiveHostedServiceCheckReport>;
1155
+ } | {
1156
+ status: string;
1157
+ reason: string;
1158
+ };
1147
1159
  productionImageRefs: {
1148
1160
  persisted: Record<string, string>;
1149
1161
  readiness: import("../workflow-support.js").TreeseedDeploymentReadinessReport;