@treeseed/sdk 0.12.60 → 0.12.62

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 +441 -105
  40. package/dist/workflow/runs.d.ts +5 -0
  41. package/dist/workflow/runs.js +9 -3
  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 [
@@ -256,6 +503,7 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
256
503
  const destructiveChanges = [];
257
504
  const desired = /* @__PURE__ */ new Set([...desiredNames.services, ...desiredNames.volumes, ...desiredNames.database ? [desiredNames.database] : []]);
258
505
  const allowedResourceDeletions = new Set(desiredNames.allowedResourceDeletions ?? []);
506
+ const protectedResourceNames = new Set(desiredNames.protectedResourceNames ?? []);
259
507
  const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
260
508
  for (const change of changeSet?.changes ?? []) {
261
509
  const name = changeName(change);
@@ -268,6 +516,9 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
268
516
  const desiredGitSource = sourceMode === "git" && typeof sourceRef === "string" && sourceRef.startsWith("github:");
269
517
  const desiredImageSource = sourceMode === "image" && typeof sourceRef === "string" && sourceRef.startsWith("image:");
270
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
+ }
271
522
  if (change.kind === "resource.delete") {
272
523
  destructiveChanges.push(change.summary);
273
524
  if (!allowedResourceDeletions.has(name) && !allowedResourceDeletions.has(serviceName)) {
@@ -304,7 +555,7 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
304
555
  };
305
556
  }
306
557
  async function planRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
307
- return runRailwayIac({
558
+ return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
308
559
  command: "plan",
309
560
  cwd: rendered.tempDir,
310
561
  file: rendered.filePath,
@@ -317,10 +568,15 @@ async function planRailwayIacProject(input, rendered = renderRailwayIacProject(i
317
568
  decryptVariables: false,
318
569
  merge: true
319
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
+ `)
320
576
  });
321
577
  }
322
578
  async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(input)) {
323
- return runRailwayIac({
579
+ return runRailwayIacWithRateLimitRetry(() => runRailwayIac({
324
580
  command: "apply",
325
581
  cwd: rendered.tempDir,
326
582
  file: rendered.filePath,
@@ -333,6 +589,11 @@ async function applyRailwayIacProject(input, rendered = renderRailwayIacProject(
333
589
  decryptVariables: false,
334
590
  merge: true
335
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
+ `)
336
597
  });
337
598
  }
338
599
  function cleanupRailwayIacRender(rendered) {
@@ -341,8 +602,20 @@ function cleanupRailwayIacRender(rendered) {
341
602
  export {
342
603
  applyRailwayIacProject,
343
604
  cleanupRailwayIacRender,
605
+ cleanupStaleRailwayIacRenders,
606
+ detachRetainedRailwayCustomDomains,
607
+ detachRetainedRailwayVolumeBindings,
608
+ findRailwayPendingVolumeNameCollisions,
344
609
  planRailwayIacProject,
610
+ railwayIacApplyFailure,
345
611
  renderRailwayIacProject,
612
+ resolveRailwayIacVolumeBindings,
613
+ runRailwayIacWithRateLimitRetry,
346
614
  selectRailwayIacRetainedResources,
347
- validateRailwayIacChangeSet
615
+ validateRailwayIacChangeSet,
616
+ waitForRailwayServiceAbsence,
617
+ waitForRailwayServices,
618
+ waitForRailwayVolumeAdoptionResources,
619
+ waitForRailwayVolumeDetachment,
620
+ waitForRailwayVolumeName
348
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;