@treeseed/sdk 0.12.53 → 0.12.55

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.
@@ -83,7 +83,8 @@ function publicTreeDxSourcePolicy(input, config, launchEnv) {
83
83
  imageRef,
84
84
  imageTagRef: "TREESEED_PUBLIC_TREEDX_IMAGE_REF"
85
85
  };
86
- assertApiRailwaySourcePolicy(input.environment, { key: "public-treedx-node-01", serviceName: "public-treedx-node-01", ...policy2 });
86
+ const serviceName = input.environment === "prod" ? "public-treedx-node-production-01" : "public-treedx-node-01";
87
+ assertApiRailwaySourcePolicy(input.environment, { key: serviceName, serviceName, ...policy2 });
87
88
  return policy2;
88
89
  }
89
90
  const policy = {
@@ -174,7 +175,9 @@ function resolveRailwayServiceSourceRoot(input, serviceKey, service) {
174
175
  function railwaySourcePolicy(input, serviceKey, service, imageRef) {
175
176
  const configuredSource = service.railway?.source && typeof service.railway.source === "object" && !Array.isArray(service.railway.source) ? service.railway.source : {};
176
177
  const configuredMode = typeof service.railway?.sourceMode === "string" ? service.railway.sourceMode : null;
177
- const serviceName = service.railway?.serviceName ?? null;
178
+ const baseServiceName = service.railway?.serviceName ?? null;
179
+ const configuredEnvironmentServiceName = service.environments?.[input.environment]?.railwayServiceName;
180
+ const serviceName = typeof configuredEnvironmentServiceName === "string" && configuredEnvironmentServiceName.trim() ? configuredEnvironmentServiceName.trim() : input.environment === "prod" && ["api", "operationsRunner"].includes(serviceKey) && baseServiceName ? `${baseServiceName}-production` : baseServiceName;
178
181
  const repository = typeof service.railway?.sourceRepo === "string" ? service.railway.sourceRepo : typeof configuredSource.repository === "string" ? configuredSource.repository : typeof configuredSource.repo === "string" ? configuredSource.repo : readPackageRepository(resolveRailwayServiceSourceRoot(input, serviceKey, service)) ?? readPackageRepository(input.tenantRoot) ?? apiRailwayDefaultSourceRepo({ key: serviceKey, serviceName });
179
182
  const dockerfilePath = service.railway?.dockerfilePath ?? apiRailwayDefaultDockerfilePath({ key: serviceKey, serviceName });
180
183
  const apiPackageSourceEligible = ["api", "operationsRunner"].includes(serviceKey);
@@ -248,14 +251,18 @@ function marketProjectGroup(environment, config) {
248
251
  const apiService = config.services?.api && typeof config.services.api === "object" ? config.services.api : null;
249
252
  const railwayProjectName = typeof apiService?.railway?.projectName === "string" && apiService.railway.projectName.trim() ? apiService.railway.projectName.trim() : void 0;
250
253
  const projectName = railwayProjectName ?? config.slug ?? "treeseed-api";
254
+ const environmentProjectName = (scope) => {
255
+ const configured = apiService?.environments?.[scope]?.railwayProjectName;
256
+ return typeof configured === "string" && configured.trim() ? configured.trim() : projectName;
257
+ };
251
258
  return {
252
259
  id: "treeseed-control-plane",
253
260
  label: "Treeseed control plane",
254
261
  hostId: environment === "local" ? "local-process" : "railway",
255
262
  environments: {
256
263
  local: { projectName: `${projectName}-local`, environmentName: "local" },
257
- staging: { projectName, environmentName: "staging" },
258
- prod: { projectName, environmentName: "production" }
264
+ staging: { projectName: environmentProjectName("staging"), environmentName: "staging" },
265
+ prod: { projectName: environmentProjectName("prod"), environmentName: "production" }
259
266
  },
260
267
  metadata: { stableProjectName: projectName }
261
268
  };
@@ -377,6 +384,9 @@ function buildProfileFromDeployConfig(input) {
377
384
  const imageRefEnv = railwayImageRefEnvForService(serviceKey);
378
385
  const imageRef = service.railway?.imageRef ?? (input.environment === "prod" && imageRefEnv ? launchEnv[imageRefEnv] ?? null : null) ?? defaultRailwayImageRefForService(serviceKey, input.environment) ?? null;
379
386
  const sourcePolicy = railwaySourcePolicy(input, serviceKey, service, imageRef);
387
+ const environmentConfig = service.environments?.[input.environment];
388
+ const baseServiceName = service.railway?.serviceName ?? null;
389
+ const effectiveServiceName = environmentConfig?.railwayServiceName ?? (input.environment === "prod" && ["api", "operationsRunner"].includes(serviceKey) && baseServiceName ? `${baseServiceName}-production` : baseServiceName);
380
390
  services.push({
381
391
  id: serviceKey,
382
392
  label: placement === "runner-capacity" ? "Runner Capacity" : serviceKey === "api" ? "API Runtime" : serviceKey,
@@ -400,7 +410,7 @@ function buildProfileFromDeployConfig(input) {
400
410
  volumeMountPath: service.railway?.volumeMountPath ?? null,
401
411
  runnerPool: service.railway?.runnerPool ?? null,
402
412
  resourceType: service.railway?.resourceType ?? null,
403
- serviceName: service.railway?.serviceName ?? null,
413
+ serviceName: effectiveServiceName,
404
414
  serviceTargets: service.railway?.serviceTargets ?? null
405
415
  },
406
416
  secretRefs: serviceKey === "treeseedDatabase" ? ["TREESEED_DATABASE_URL"] : [],
@@ -462,7 +472,7 @@ function buildProfileFromDeployConfig(input) {
462
472
  const treeDxSourcePolicy = publicTreeDxSourcePolicy(input, config, launchEnv);
463
473
  const treeDxNodeUnits = Array.from({ length: treeDxNodePool.bootstrapCount }, (_, offset) => {
464
474
  const nodeIndex = offset + 1;
465
- const serviceName = indexedName("public-treedx-node", nodeIndex);
475
+ const serviceName = indexedName(input.environment === "prod" ? "public-treedx-node-production" : "public-treedx-node", nodeIndex);
466
476
  return {
467
477
  id: serviceName,
468
478
  label: `Public TreeDX node ${String(nodeIndex).padStart(2, "0")}`,
@@ -143,7 +143,8 @@ function collectTreeseedDeploymentReadiness(options) {
143
143
  checks.push(check("hosting:api:healthcheckPath", api.config?.healthcheckPath, "/healthz", "API healthcheck path is /healthz.", "Set services.api.railway.healthcheckPath to /healthz.", "healthcheckPath"));
144
144
  }
145
145
  if (railwayApi) {
146
- checks.push(check("railway-config:api:serviceName", railwayApi.serviceName, "treeseed-api", "Railway API service uses the canonical name.", "Set services.api.railway.serviceName to treeseed-api.", "serviceName"));
146
+ const expectedServiceName = environment === "prod" ? "treeseed-api-production" : "treeseed-api";
147
+ checks.push(check("railway-config:api:serviceName", railwayApi.serviceName, expectedServiceName, "Railway API service uses the environment-isolated canonical name.", `Set the API service identity to ${expectedServiceName} for ${environment}.`, "serviceName"));
147
148
  checks.push(check("railway-config:api:rootDirectory", relRoot(tenantRoot, railwayApi.rootDir), expectedApiRailwayRoot, "Railway API effective rootDirectory points at the API app.", "Set services.api.railway.rootDir relative to the owning API manifest.", "rootDirectory"));
148
149
  }
149
150
  if (!runner) {
@@ -176,7 +177,8 @@ function collectTreeseedDeploymentReadiness(options) {
176
177
  checks.push(check("hosting:operationsRunner:volumeMountPath", runner.config?.volumeMountPath, "/data", "Runner volume mount path is stable.", "Set services.operationsRunner.railway.volumeMountPath to /data.", "volumeMountPath"));
177
178
  }
178
179
  if (railwayRunner) {
179
- checks.push(check("railway-config:operationsRunner:serviceName", railwayRunner.serviceName, "treeseed-api-operations-runner-01", "Railway runner service instance uses the canonical name.", "Set the runner service instance name to treeseed-api-operations-runner-01.", "serviceName"));
180
+ const expectedServiceName = environment === "prod" ? "treeseed-api-operations-runner-production-01" : "treeseed-api-operations-runner-01";
181
+ checks.push(check("railway-config:operationsRunner:serviceName", railwayRunner.serviceName, expectedServiceName, "Railway runner service instance uses the environment-isolated canonical name.", `Set the runner service identity to ${expectedServiceName} for ${environment}.`, "serviceName"));
180
182
  checks.push(check("railway-config:operationsRunner:rootDirectory", relRoot(tenantRoot, railwayRunner.rootDir), expectedRunnerRailwayRoot, "Railway runner effective rootDirectory points at the API app.", "Set services.operationsRunner.railway.rootDir relative to the owning API manifest.", "rootDirectory"));
181
183
  }
182
184
  if (!database) {
@@ -166,6 +166,15 @@ export declare function ensureRemoteBranchFromBase(repoDir: any, branchName: any
166
166
  };
167
167
  export declare function deleteLocalBranch(repoDir: any, branchName: any): void;
168
168
  export declare function deleteRemoteBranch(repoDir: any, branchName: any): boolean;
169
+ export declare function deleteRemoteBranchIfMerged(repoDir: any, branchName: any, targetBranch: any, expectedHead: any, options?: {
170
+ fetch?: boolean;
171
+ }): boolean;
172
+ export declare function inspectMergedRemoteTaskBranches(repoDir: any): {
173
+ branch: string;
174
+ head: string;
175
+ current: boolean;
176
+ mergedInto: string | null;
177
+ }[];
169
178
  export declare function mergeCurrentBranchIntoStaging(cwd: any, featureBranch: any): {
170
179
  repoDir: string;
171
180
  targetBranch: string;
@@ -202,7 +211,9 @@ export declare function squashMergeBranchIntoStaging(cwd: any, featureBranch: an
202
211
  export declare function currentManagedBranch(cwd?: any): string;
203
212
  export declare function isTaskBranch(branchName: any): boolean;
204
213
  export declare function assertFeatureBranch(cwd?: any): string;
205
- export declare function listTaskBranches(repoDir: any): {
214
+ export declare function listTaskBranches(repoDir: any, options?: {
215
+ fetch?: boolean;
216
+ }): {
206
217
  name: string;
207
218
  head: string;
208
219
  lastCommitDate: string;
@@ -564,6 +564,39 @@ function deleteRemoteBranch(repoDir, branchName) {
564
564
  runGit(["push", "origin", "--delete", branchName], { cwd: repoDir });
565
565
  return true;
566
566
  }
567
+ function deleteRemoteBranchIfMerged(repoDir, branchName, targetBranch, expectedHead, options = {}) {
568
+ if (!remoteBranchExists(repoDir, branchName)) return false;
569
+ if (options.fetch !== false) fetchOrigin(repoDir);
570
+ const observedHead = remoteHeadCommit(repoDir, branchName);
571
+ if (!expectedHead || observedHead !== expectedHead) {
572
+ throw new Error(`Refusing to delete origin/${branchName}: expected ${expectedHead || "(missing)"}, observed ${observedHead || "(missing)"}.`);
573
+ }
574
+ const targetHead = remoteHeadCommit(repoDir, targetBranch);
575
+ if (!targetHead) {
576
+ throw new Error(`Refusing to delete origin/${branchName}: origin/${targetBranch} is missing.`);
577
+ }
578
+ const ancestry = runGitAllowFailure(["merge-base", "--is-ancestor", observedHead, targetHead], { cwd: repoDir });
579
+ if (ancestry.status !== 0) {
580
+ throw new Error(`Refusing to delete origin/${branchName}: ${observedHead} is not merged into origin/${targetBranch} (${targetHead}).`);
581
+ }
582
+ ensureWritableOrigin(repoDir);
583
+ runGit(["push", `--force-with-lease=refs/heads/${branchName}:${expectedHead}`, "origin", "--delete", branchName], { cwd: repoDir });
584
+ return true;
585
+ }
586
+ function inspectMergedRemoteTaskBranches(repoDir) {
587
+ fetchOrigin(repoDir);
588
+ const targets = [STAGING_BRANCH, PRODUCTION_BRANCH].map((branch) => ({ branch, head: remoteHeadCommit(repoDir, branch) })).filter((target) => Boolean(target.head));
589
+ return listTaskBranches(repoDir, { fetch: false }).filter((branch) => branch.remote).map((branch) => {
590
+ const head = remoteHeadCommit(repoDir, branch.name);
591
+ const mergedTarget = head ? targets.find((target) => runGitAllowFailure(["merge-base", "--is-ancestor", head, target.head], { cwd: repoDir }).status === 0) : void 0;
592
+ return {
593
+ branch: branch.name,
594
+ head,
595
+ current: branch.current,
596
+ mergedInto: mergedTarget?.branch ?? null
597
+ };
598
+ });
599
+ }
567
600
  function mergeCurrentBranchIntoStaging(cwd, featureBranch) {
568
601
  return squashMergeBranchIntoStaging(cwd, featureBranch, `stage: ${featureBranch}`);
569
602
  }
@@ -635,10 +668,12 @@ function assertFeatureBranch(cwd = workspaceRoot()) {
635
668
  function gitLines(repoDir, args) {
636
669
  return runGit(args, { cwd: repoDir, capture: true }).split("\n").map((line) => line.trim()).filter(Boolean);
637
670
  }
638
- function listTaskBranches(repoDir) {
639
- try {
640
- runGit(["fetch", "origin"], { cwd: repoDir, capture: true });
641
- } catch {
671
+ function listTaskBranches(repoDir, options = {}) {
672
+ if (options.fetch !== false) {
673
+ try {
674
+ runGit(["fetch", "origin"], { cwd: repoDir, capture: true });
675
+ } catch {
676
+ }
642
677
  }
643
678
  const local = new Set(
644
679
  gitLines(repoDir, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]).filter(isTaskBranch)
@@ -649,7 +684,7 @@ function listTaskBranches(repoDir) {
649
684
  const current = currentBranch(repoDir);
650
685
  const branches = [.../* @__PURE__ */ new Set([...local, ...remote])].sort((left, right) => left.localeCompare(right));
651
686
  return branches.map((branchName) => {
652
- const ref = local.has(branchName) ? branchName : `origin/${branchName}`;
687
+ const ref = local.has(branchName) ? `refs/heads/${branchName}` : `refs/remotes/origin/${branchName}`;
653
688
  return {
654
689
  name: branchName,
655
690
  head: runGit(["rev-parse", ref], { cwd: repoDir, capture: true }).trim(),
@@ -780,12 +815,14 @@ export {
780
815
  currentManagedBranch,
781
816
  deleteLocalBranch,
782
817
  deleteRemoteBranch,
818
+ deleteRemoteBranchIfMerged,
783
819
  ensureLocalBranchTracking,
784
820
  ensureRemoteBranchFromBase,
785
821
  fetchOrigin,
786
822
  gitWorkflowRoot,
787
823
  headCommit,
788
824
  inspectDetachedHeadRepair,
825
+ inspectMergedRemoteTaskBranches,
789
826
  isTaskBranch,
790
827
  listTaskBranches,
791
828
  mergeBranchDownIntoFeature,
@@ -127,6 +127,11 @@ export declare function skippedGitHubActionsGate(gate: GitHubActionsWorkflowGate
127
127
  cached: boolean;
128
128
  };
129
129
  export declare function formatGitHubActionsGateFailure(gate: GitHubActionsWorkflowGate, result: Record<string, unknown>): string;
130
+ export declare function isRetryableGitHubActionsSetupFailure(result: Record<string, unknown>): boolean;
131
+ export declare function rerunGitHubActionsFailedJobs(result: Record<string, unknown>, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): Promise<{
132
+ repository: string;
133
+ runId: number;
134
+ }>;
130
135
  export declare function createGitHubActionsGateProgressReporter(gate: GitHubActionsWorkflowGate, options?: {
131
136
  operation?: string;
132
137
  now?: () => number;
@@ -433,6 +433,28 @@ Failed jobs: ${failedJobs.join(", ")}` : "";
433
433
  Inspect with: gh run view ${runId} --repo ${repository} --log-failed` : "";
434
434
  return `${gate.name} ${gate.workflow} completed with conclusion ${String(result.conclusion ?? "unknown")} in ${repository}.${url}${jobLine}${command}`;
435
435
  }
436
+ function isRetryableGitHubActionsSetupFailure(result) {
437
+ const failedJobs = Array.isArray(result.failedJobs) ? result.failedJobs : [];
438
+ return failedJobs.length > 0 && failedJobs.every((job) => {
439
+ const steps = Array.isArray(job.steps) ? job.steps : [];
440
+ return steps.length === 1 && steps[0]?.name === "Set up job" && steps[0]?.conclusion === "failure";
441
+ });
442
+ }
443
+ async function rerunGitHubActionsFailedJobs(result, env = process.env) {
444
+ const repository = String(result.repository ?? "");
445
+ const runId = Number(result.runId);
446
+ if (!repository || !Number.isSafeInteger(runId) || runId <= 0) {
447
+ throw new Error("Cannot retry GitHub Actions setup failure without a repository and run ID.");
448
+ }
449
+ const { owner, name: repo } = parseGitHubRepositorySlug(repository);
450
+ const client = createGitHubApiClient({ env });
451
+ await client.request("POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs", {
452
+ owner,
453
+ repo,
454
+ run_id: runId
455
+ });
456
+ return { repository, runId };
457
+ }
436
458
  function formatElapsed(seconds) {
437
459
  const safe = Math.max(0, Math.round(seconds));
438
460
  if (safe < 60) return `${safe}s`;
@@ -560,6 +582,8 @@ export {
560
582
  createGitHubActionsGateProgressReporter,
561
583
  formatGitHubActionsGateFailure,
562
584
  inspectGitHubActionsVerification,
585
+ isRetryableGitHubActionsSetupFailure,
586
+ rerunGitHubActionsFailedJobs,
563
587
  skippedGitHubActionsGate,
564
588
  waitForGitHubActionsGate
565
589
  };
@@ -59,7 +59,7 @@ export declare function waitForRailwayManagedDeploymentsSettled(tenantRoot: any,
59
59
  };
60
60
  message: string;
61
61
  }>;
62
- export declare function configuredRailwayServices(tenantRoot: any, scope: any, envOverlay?: {}): unknown[];
62
+ export declare function configuredRailwayServices(tenantRoot: any, scope: any, envOverlay?: {}, options?: {}): unknown[];
63
63
  export declare function configuredRailwayScheduledJobs(tenantRoot: any, scope: any, { phase }?: {
64
64
  phase?: string | undefined;
65
65
  }): any[];
@@ -641,8 +641,9 @@ query TreeseedRailwayDeploymentStatus($projectId: String!) {
641
641
  });
642
642
  return payload.data?.project ?? null;
643
643
  }
644
- function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot, envOverlay = {}) {
644
+ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot, envOverlay = {}, options = {}) {
645
645
  const normalizedScope = normalizeScope(scope);
646
+ const identityOnly = options.identityOnly === true;
646
647
  const imageRefKeys = [
647
648
  "TREESEED_API_IMAGE_REF",
648
649
  "TREESEED_OPERATIONS_RUNNER_IMAGE_REF",
@@ -683,7 +684,9 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
683
684
  service.environments?.[normalizedScope]?.railwayEnvironment
684
685
  );
685
686
  const publicBaseUrl = service.environments?.[normalizedScope]?.baseUrl ?? service.publicBaseUrl ?? (serviceKey === "api" ? configuredApiPublicBaseUrl(deployConfig, normalizedScope) : null);
686
- const configuredServiceName = service.railway?.serviceName ?? (serviceKey === "workerRunner" ? deriveRailwayWorkerRunnerServiceName(identity.deploymentKey) : `${identity.deploymentKey}-${railwayServiceNameSuffix(serviceKey)}`);
687
+ const environmentConfig = service.environments?.[normalizedScope];
688
+ const baseServiceName = service.railway?.serviceName ?? (serviceKey === "workerRunner" ? deriveRailwayWorkerRunnerServiceName(identity.deploymentKey) : `${identity.deploymentKey}-${railwayServiceNameSuffix(serviceKey)}`);
689
+ const configuredServiceName = environmentConfig?.railwayServiceName ?? (normalizedScope === "prod" && ["api", "operationsRunner"].includes(serviceKey) ? `${serviceKey === "operationsRunner" ? String(baseServiceName).replace(/-\d+$/u, "") : baseServiceName}-production` : baseServiceName);
687
690
  const configuredRunnerPool = service.railway?.runnerPool && typeof service.railway.runnerPool === "object" ? service.railway.runnerPool : null;
688
691
  const runnerPool = serviceKey === "operationsRunner" || serviceKey === "capacityProviderRunner" ? {
689
692
  bootstrapCount: Math.max(1, Number.parseInt(String(configuredRunnerPool?.bootstrapCount ?? (serviceKey === "capacityProviderRunner" ? 1 : OPERATIONS_RUNNER_BOOTSTRAP_COUNT)), 10) || (serviceKey === "capacityProviderRunner" ? 1 : OPERATIONS_RUNNER_BOOTSTRAP_COUNT)),
@@ -700,13 +703,20 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
700
703
  const configuredImageRefEnv = service.railway?.imageRefEnv ?? railwayImageRefEnvForService(serviceKey);
701
704
  const canUseImageRefEnv = normalizedScope === "prod" || serviceKey === "capacityProviderManager" || serviceKey === "capacityProviderRunner";
702
705
  const imageRef = service.railway?.imageRef ?? (canUseImageRefEnv && configuredImageRefEnv ? envValue(configuredImageRefEnv, imageRefEnv) || null : null) ?? defaultRailwayImageRef(serviceKey, normalizedScope, imageRefEnv);
703
- const sourcePolicy = resolveRailwayServiceSourcePolicy({
706
+ const sourcePolicy = identityOnly ? {
707
+ sourceMode: normalizedScope === "prod" ? "image" : "git",
708
+ sourceRepo: null,
709
+ sourceBranch: null,
710
+ sourceCommit: null,
711
+ sourceRootDirectory: null
712
+ } : resolveRailwayServiceSourcePolicy({
704
713
  tenantRoot,
705
714
  scope: normalizedScope,
706
715
  serviceKey,
707
716
  service,
708
717
  serviceRoot,
709
- imageRef
718
+ imageRef,
719
+ serviceName
710
720
  });
711
721
  const resolvedImageRef = sourcePolicy.sourceMode === "image" ? imageRef : null;
712
722
  return {
@@ -716,7 +726,7 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
716
726
  serviceConfig: service,
717
727
  scope: normalizedScope,
718
728
  projectId: service.railway?.projectId ?? null,
719
- projectName: service.railway?.projectName ?? identity.deploymentKey,
729
+ projectName: environmentConfig?.railwayProjectName ?? service.railway?.projectName ?? identity.deploymentKey,
720
730
  serviceId: service.railway?.serviceId ?? null,
721
731
  serviceName,
722
732
  runnerId: serviceKey === "operationsRunner" || serviceKey === "capacityProviderRunner" ? serviceName : null,
@@ -757,11 +767,12 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
757
767
  hostingKind,
758
768
  application,
759
769
  imageRefEnv,
760
- workspaceRoot: machineConfigRoot
770
+ workspaceRoot: machineConfigRoot,
771
+ identityOnly
761
772
  })
762
773
  ];
763
774
  }
764
- function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig, identity, hostingKind, application, imageRefEnv, workspaceRoot }) {
775
+ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig, identity, hostingKind, application, imageRefEnv, workspaceRoot, identityOnly = false }) {
765
776
  if (deployConfig.hosting?.kind !== "treeseed_control_plane") {
766
777
  return [];
767
778
  }
@@ -786,7 +797,7 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
786
797
  const baseImageRef = envValue("TREESEED_PUBLIC_TREEDX_IMAGE_REF", imageRefEnv) || "treeseed/treedx";
787
798
  return Array.from({ length: bootstrapCount }, (_, offset) => {
788
799
  const index = offset + 1;
789
- const serviceName = `${PUBLIC_TREEDX_NODE_SERVICE_KEY_PREFIX}${String(index).padStart(2, "0")}`;
800
+ const serviceName = scope === "prod" ? `public-treedx-node-production-${String(index).padStart(2, "0")}` : `${PUBLIC_TREEDX_NODE_SERVICE_KEY_PREFIX}${String(index).padStart(2, "0")}`;
790
801
  const service = {
791
802
  key: serviceName,
792
803
  serviceName,
@@ -800,7 +811,9 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
800
811
  buildCommand: sourceMode === "git" ? railway.buildCommand ?? null : null,
801
812
  startCommand: sourceMode === "git" ? railway.startCommand ?? null : null
802
813
  };
803
- assertApiRailwaySourcePolicy(scope, service);
814
+ if (!identityOnly) {
815
+ assertApiRailwaySourcePolicy(scope, service);
816
+ }
804
817
  return {
805
818
  key: service.key,
806
819
  instanceKey: serviceName,
@@ -893,11 +906,11 @@ function headCommitSafe(cwd) {
893
906
  return null;
894
907
  }
895
908
  }
896
- function resolveRailwayServiceSourcePolicy({ tenantRoot, scope, serviceKey, service, serviceRoot, imageRef }) {
909
+ function resolveRailwayServiceSourcePolicy({ tenantRoot, scope, serviceKey, service, serviceRoot, imageRef, serviceName: effectiveServiceName }) {
897
910
  const configuredMode = typeof service.railway?.sourceMode === "string" ? service.railway.sourceMode : null;
898
911
  const configuredSource = service.railway?.source && typeof service.railway.source === "object" && !Array.isArray(service.railway.source) ? service.railway.source : {};
899
912
  const configuredRepo = typeof service.railway?.sourceRepo === "string" ? service.railway.sourceRepo : typeof configuredSource.repository === "string" ? configuredSource.repository : typeof configuredSource.repo === "string" ? configuredSource.repo : null;
900
- const serviceName = service.railway?.serviceName ?? null;
913
+ const serviceName = effectiveServiceName ?? service.railway?.serviceName ?? null;
901
914
  const packageRepository = configuredRepo ?? readTreeseedPackageRepository(serviceRoot) ?? readTreeseedPackageRepository(tenantRoot) ?? apiRailwayDefaultSourceRepo({ key: serviceKey, serviceName });
902
915
  const dockerfilePath = service.railway?.dockerfilePath ?? apiRailwayDefaultDockerfilePath({ key: serviceKey, serviceName });
903
916
  const apiPackageSourceEligible = ["api", "operationsRunner"].includes(serviceKey);
@@ -954,9 +967,9 @@ function resolveRailwayCapacityProviderRoot(tenantRoot, service) {
954
967
  );
955
968
  return found ?? resolve(tenantRoot, "packages", "agent");
956
969
  }
957
- function configuredRailwayServices(tenantRoot, scope, envOverlay = {}) {
970
+ function configuredRailwayServices(tenantRoot, scope, envOverlay = {}, options = {}) {
958
971
  const deployConfig = loadCliDeployConfig(tenantRoot);
959
- const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, null, tenantRoot, envOverlay);
972
+ const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, null, tenantRoot, envOverlay, options);
960
973
  const nested = discoverTreeseedApplications(tenantRoot).filter((application) => application.root !== resolve(tenantRoot)).flatMap((application) => configuredRailwayServicesForConfig(
961
974
  application.root,
962
975
  scope,
@@ -967,7 +980,8 @@ function configuredRailwayServices(tenantRoot, scope, envOverlay = {}) {
967
980
  relativeRoot: application.relativeRoot
968
981
  },
969
982
  tenantRoot,
970
- envOverlay
983
+ envOverlay,
984
+ options
971
985
  ));
972
986
  return [...direct, ...nested];
973
987
  }
@@ -1,7 +1,7 @@
1
1
  function isApiRailwaySourcePolicyService(service) {
2
2
  const key = String(service.key ?? "").trim();
3
3
  const serviceName = String(service.serviceName ?? "").trim();
4
- return key.startsWith("public-treedx-node-") || serviceName === "treeseed-api" || /^treeseed-api-operations-runner-\d+$/u.test(serviceName) || /^public-treedx-node-\d+$/u.test(serviceName);
4
+ return key.startsWith("public-treedx-node-") || /^treeseed-api(?:-production)?$/u.test(serviceName) || /^treeseed-api-operations-runner(?:-production)?-\d+$/u.test(serviceName) || /^public-treedx-node(?:-production)?-\d+$/u.test(serviceName);
5
5
  }
6
6
  function isImmutableRailwayImageRef(value) {
7
7
  const imageRef = typeof value === "string" ? value.trim() : "";
@@ -11,15 +11,15 @@ function isImmutableRailwayImageRef(value) {
11
11
  }
12
12
  function apiRailwayDefaultSourceRepo(service) {
13
13
  const serviceName = String(service.serviceName ?? "").trim();
14
- if (serviceName === "treeseed-api" || /^treeseed-api-operations-runner-\d+$/u.test(serviceName)) return "treeseed-ai/api";
15
- if (/^public-treedx-node-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "treeseed-ai/treedx";
14
+ if (String(service.key ?? "") === "api" || String(service.key ?? "") === "operationsRunner" || /^treeseed-api(?:-production)?$/u.test(serviceName) || /^treeseed-api-operations-runner(?:-production)?-\d+$/u.test(serviceName)) return "treeseed-ai/api";
15
+ if (/^public-treedx-node(?:-production)?-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "treeseed-ai/treedx";
16
16
  return null;
17
17
  }
18
18
  function apiRailwayDefaultDockerfilePath(service) {
19
19
  const serviceName = String(service.serviceName ?? "").trim();
20
- if (serviceName === "treeseed-api") return "/Dockerfile.api";
21
- if (/^treeseed-api-operations-runner-\d+$/u.test(serviceName)) return "/Dockerfile.operations-runner";
22
- if (/^public-treedx-node-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "/Dockerfile";
20
+ if (String(service.key ?? "") === "api" || /^treeseed-api(?:-production)?$/u.test(serviceName)) return "/Dockerfile.api";
21
+ if (String(service.key ?? "") === "operationsRunner" || /^treeseed-api-operations-runner(?:-production)?-\d+$/u.test(serviceName)) return "/Dockerfile.operations-runner";
22
+ if (/^public-treedx-node(?:-production)?-\d+$/u.test(serviceName) || String(service.key ?? "").startsWith("public-treedx-node-")) return "/Dockerfile";
23
23
  return null;
24
24
  }
25
25
  function assertApiRailwaySourcePolicy(scope, service) {
@@ -28,6 +28,7 @@ function assertApiRailwaySourcePolicy(scope, service) {
28
28
  const label = service.serviceName ?? service.key ?? "Railway service";
29
29
  if (normalizedScope === "staging") {
30
30
  const issues = [
31
+ /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? "serviceName must use the staging-only identity" : null,
31
32
  service.sourceMode === "git" ? null : "sourceMode must be git",
32
33
  service.imageRef ? "imageRef must be empty" : null,
33
34
  service.sourceRepo ? null : "sourceRepo must be set",
@@ -42,6 +43,7 @@ function assertApiRailwaySourcePolicy(scope, service) {
42
43
  }
43
44
  if (normalizedScope === "prod") {
44
45
  const issues = [
46
+ /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? null : "serviceName must use a production-only identity",
45
47
  service.sourceMode === "image" ? null : "sourceMode must be image",
46
48
  isImmutableRailwayImageRef(service.imageRef) ? null : "imageRef must be an immutable released image tag",
47
49
  service.sourceRepo ? "sourceRepo must be empty" : null,
@@ -212,6 +212,8 @@ export interface TreeseedManagedServiceEnvironmentConfig {
212
212
  baseUrl?: string;
213
213
  domain?: string;
214
214
  railwayEnvironment?: string;
215
+ railwayProjectName?: string;
216
+ railwayServiceName?: string;
215
217
  }
216
218
  export type TreeseedLocalRuntimeMode = 'auto' | 'provider' | 'local';
217
219
  export interface TreeseedLocalRuntimeConfig {
@@ -332,7 +332,9 @@ function parseServiceEnvironmentConfig(value, label) {
332
332
  return {
333
333
  baseUrl: optionalString(record.baseUrl),
334
334
  domain: optionalString(record.domain),
335
- railwayEnvironment: optionalString(record.railwayEnvironment)
335
+ railwayEnvironment: optionalString(record.railwayEnvironment),
336
+ railwayProjectName: optionalString(record.railwayProjectName),
337
+ railwayServiceName: optionalString(record.railwayServiceName)
336
338
  };
337
339
  }
338
340
  function parseLocalRuntimeConfig(value, label) {
@@ -3657,6 +3657,24 @@ function configuredRailwayProjectSyncGroups(input, scope, serviceKeys) {
3657
3657
  }
3658
3658
  return [...grouped.values()];
3659
3659
  }
3660
+ function configuredRailwaySiblingResourceNames(input, scope, projectName) {
3661
+ const siblingScope = scope === "prod" ? "staging" : scope === "staging" ? "prod" : null;
3662
+ if (!siblingScope) return [];
3663
+ const siblingServices = configuredRailwayServices(
3664
+ input.context.tenantRoot,
3665
+ siblingScope,
3666
+ resolveReconcileEnvironmentValues(input, siblingScope),
3667
+ { identityOnly: true }
3668
+ ).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service)).filter((service) => service.projectName === projectName);
3669
+ const database = configuredRailwayIacDatabase(input, siblingServices);
3670
+ return [.../* @__PURE__ */ new Set([
3671
+ ...siblingServices.flatMap((service) => [
3672
+ service.serviceName,
3673
+ ...service.volumeMountPath ? [`${service.serviceName}-volume`] : []
3674
+ ]),
3675
+ ...database ? [database.serviceName, `${database.serviceName}-volume`] : []
3676
+ ])];
3677
+ }
3660
3678
  function railwayIacServiceInput(input, sync, service, scope) {
3661
3679
  if (scope === "staging" && service.imageRef) {
3662
3680
  throw new Error(`Railway staging service ${service.serviceName} must build from Git source, not Docker image ${service.imageRef}.`);
@@ -3931,11 +3949,13 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3931
3949
  })),
3932
3950
  database: databaseForIac
3933
3951
  };
3952
+ const siblingResourceNames = configuredRailwaySiblingResourceNames(input, scope, project.name);
3934
3953
  const {
3935
3954
  applyRailwayIacProject,
3936
3955
  cleanupRailwayIacRender,
3937
3956
  planRailwayIacProject,
3938
3957
  renderRailwayIacProject,
3958
+ selectRailwayIacRetainedResources,
3939
3959
  validateRailwayIacChangeSet
3940
3960
  } = await import("./providers/railway-iac.js");
3941
3961
  let effectiveIacInput = iacInput;
@@ -3986,6 +4006,33 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3986
4006
  ]))
3987
4007
  });
3988
4008
  }
4009
+ const retainedResources = selectRailwayIacRetainedResources(plan, siblingResourceNames);
4010
+ if (retainedResources.length > 0) {
4011
+ traceRailwayReconcile(
4012
+ topology.env,
4013
+ "sync:iac-retain-sibling-environment",
4014
+ `${project.name}/${environment.name}:${retainedResources.map((resource) => resource.name).join(",")}`
4015
+ );
4016
+ cleanupRailwayIacRender(rendered);
4017
+ effectiveIacInput = { ...effectiveIacInput, retainedResources };
4018
+ rendered = renderRailwayIacProject(effectiveIacInput);
4019
+ plan = await planRailwayIacProject(effectiveIacInput, rendered);
4020
+ if (!plan.ok) {
4021
+ const diagnostics = plan.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
4022
+ throw new Error(`Railway IaC plan failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
4023
+ }
4024
+ validation = validateRailwayIacChangeSet(plan.changeSet, {
4025
+ services: rendered.serviceNames,
4026
+ volumes: rendered.volumeNames,
4027
+ database: rendered.databaseName,
4028
+ scope,
4029
+ serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null])),
4030
+ serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [
4031
+ service.serviceName,
4032
+ service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4033
+ ]))
4034
+ });
4035
+ }
3989
4036
  if (!validation.ok) {
3990
4037
  throw new Error(`Railway IaC plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
3991
4038
  }
@@ -1,4 +1,4 @@
1
- import { type RailwayChangeSet, type RailwayIacApplyResponse, type RailwayIacPlanResponse } from 'railway/iac';
1
+ import { type RailwayChangeSet, type RailwayIacApplyResponse, type RailwayIacPlanResponse, type ResourceNode } from 'railway/iac';
2
2
  export type TreeseedRailwayIacService = {
3
3
  key: string;
4
4
  serviceName: string;
@@ -37,6 +37,7 @@ export type TreeseedRailwayIacProjectInput = {
37
37
  railwayApiUrl?: string | null;
38
38
  services: TreeseedRailwayIacService[];
39
39
  database: TreeseedRailwayIacDatabase | null;
40
+ retainedResources?: ResourceNode[];
40
41
  region?: string | null;
41
42
  };
42
43
  export type TreeseedRailwayIacRenderResult = {
@@ -47,6 +48,7 @@ export type TreeseedRailwayIacRenderResult = {
47
48
  serviceNames: string[];
48
49
  volumeNames: string[];
49
50
  databaseName: string | null;
51
+ retainedResourceNames: string[];
50
52
  source: string;
51
53
  };
52
54
  export type RailwayIacValidationResult = {
@@ -56,6 +58,7 @@ export type RailwayIacValidationResult = {
56
58
  allowedDrift: string[];
57
59
  };
58
60
  export declare function renderRailwayIacProject(input: TreeseedRailwayIacProjectInput): TreeseedRailwayIacRenderResult;
61
+ export declare function selectRailwayIacRetainedResources(plan: Pick<RailwayIacPlanResponse, 'changeSet' | 'currentGraph'>, allowedNames: Iterable<string>): ResourceNode[];
59
62
  export declare function validateRailwayIacChangeSet(changeSet: RailwayChangeSet | undefined, desiredNames: {
60
63
  services: string[];
61
64
  volumes: string[];
@@ -1,6 +1,8 @@
1
1
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { runRailwayIac } from "railway/iac";
3
+ import {
4
+ runRailwayIac
5
+ } from "railway/iac";
4
6
  import { assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "../../operations/services/railway-source-policy.js";
5
7
  function js(value) {
6
8
  return JSON.stringify(value);
@@ -115,6 +117,16 @@ function renderRailwayIacProject(input) {
115
117
  const volumeNames = [];
116
118
  const databaseVariableName = input.database ? "db" : null;
117
119
  const databaseEnvName = input.database?.environmentVariable ?? null;
120
+ const desiredResourceNames = /* @__PURE__ */ new Set([
121
+ ...input.services.map((service) => service.serviceName),
122
+ ...input.services.filter((service) => service.volumeMountPath).map((service) => `${service.serviceName}-volume`),
123
+ ...input.database ? [input.database.serviceName, `${input.database.serviceName}-volume`] : []
124
+ ]);
125
+ const retainedResources = (input.retainedResources ?? []).filter((resource) => !desiredResourceNames.has(resource.name));
126
+ if (retainedResources.length > 0) {
127
+ declarations.push(` const retainedResources = ${js(retainedResources)};`);
128
+ resources.push("...retainedResources");
129
+ }
118
130
  if (input.database) {
119
131
  const postgresVolumeName = `${input.database.serviceName}-volume`;
120
132
  const postgresMountPath = input.database.mountPath?.trim() || "/var/lib/postgresql/data";
@@ -205,9 +217,15 @@ ${declarations.join("\n")}
205
217
  serviceNames: input.services.map((service) => service.serviceName),
206
218
  volumeNames,
207
219
  databaseName: input.database?.serviceName ?? null,
220
+ retainedResourceNames: retainedResources.map((resource) => resource.name),
208
221
  source
209
222
  };
210
223
  }
224
+ function selectRailwayIacRetainedResources(plan, allowedNames) {
225
+ 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));
228
+ }
211
229
  function changeName(change) {
212
230
  return String(change?.resource?.name ?? change?.previous?.name ?? change?.address ?? change?.path ?? "");
213
231
  }
@@ -322,5 +340,6 @@ export {
322
340
  cleanupRailwayIacRender,
323
341
  planRailwayIacProject,
324
342
  renderRailwayIacProject,
343
+ selectRailwayIacRetainedResources,
325
344
  validateRailwayIacChangeSet
326
345
  };
@@ -9,7 +9,7 @@ import { type TreeseedReconcileResult } from '../reconcile/index.js';
9
9
  import { type TreeseedWorkflowRunCommand, type TreeseedWorkflowRunJournal } from './runs.js';
10
10
  import { type TreeseedWorkflowMode } from './session.js';
11
11
  import { type TreeseedManagedRepository } from '../operations/services/managed-repositories.js';
12
- import type { TreeseedCloseInput, TreeseedCiInput, TreeseedConfigInput, TreeseedDestroyInput, TreeseedExportInput, TreeseedReleaseCandidateInput, TreeseedReleaseInput, TreeseedRecoverInput, TreeseedResumeInput, TreeseedSaveInput, TreeseedStageInput, TreeseedSwitchInput, TreeseedTaskBranchMetadata, TreeseedUpdateInput, TreeseedWorkflowContext, TreeseedWorkflowCiMode, TreeseedWorkflowDevInput, TreeseedWorkflowExecutionMode, TreeseedWorkflowOperationId, TreeseedWorkflowResult, TreeseedWorkflowWorktreeMode, TreeseedReleaseCandidateMode, TreeseedProofInput } from '../workflow.js';
12
+ import type { TreeseedCloseInput, TreeseedCiInput, TreeseedConfigInput, TreeseedDestroyInput, TreeseedExportInput, TreeseedReleaseCandidateInput, TreeseedReleaseInput, TreeseedRecoverInput, TreeseedResumeInput, TreeseedSaveInput, TreeseedStageInput, TreeseedSwitchInput, TreeseedTaskBranchMetadata, TreeseedTasksInput, TreeseedUpdateInput, TreeseedWorkflowContext, TreeseedWorkflowCiMode, TreeseedWorkflowDevInput, TreeseedWorkflowExecutionMode, TreeseedWorkflowOperationId, TreeseedWorkflowResult, TreeseedWorkflowWorktreeMode, TreeseedReleaseCandidateMode, TreeseedProofInput } from '../workflow.js';
13
13
  type WorkflowWrite = NonNullable<TreeseedWorkflowContext['write']>;
14
14
  type WorkflowStatePayload = ReturnType<typeof resolveTreeseedWorkflowState>;
15
15
  type ReleaseCandidateMode = TreeseedReleaseCandidateMode;
@@ -137,7 +137,7 @@ export declare function destroyTreeseedBranchPreview(input: {
137
137
  }>;
138
138
  export declare function workflowStatus(helpers: WorkflowOperationHelpers, input?: TreeseedWorkflowStatusOptions): Promise<TreeseedWorkflowResult<import("../workflow-state.js").TreeseedWorkflowState>>;
139
139
  export declare function workflowCi(helpers: WorkflowOperationHelpers, input?: TreeseedCiInput): Promise<TreeseedWorkflowResult<TreeseedCiResult>>;
140
- export declare function workflowTasks(helpers: WorkflowOperationHelpers): Promise<TreeseedWorkflowResult<{
140
+ export declare function workflowTasks(helpers: WorkflowOperationHelpers, input?: TreeseedTasksInput): Promise<TreeseedWorkflowResult<{
141
141
  tasks: TreeseedTaskBranchMetadata[];
142
142
  workstreams: Array<{
143
143
  id: string;
@@ -517,14 +517,9 @@ export declare function workflowSave(helpers: WorkflowOperationHelpers, input: T
517
517
  rootRepo: import("../operations/services/repository-save-orchestrator.js").RepositorySavePlanRepo;
518
518
  blockers: string[];
519
519
  autoResumeCandidate: {
520
- runId: string;
521
- branch: string | null;
522
- failure: {
523
- code: string;
524
- message: string;
525
- details: Record<string, unknown> | null;
526
- at: string;
527
- } | null;
520
+ runId: any;
521
+ branch: any;
522
+ failure: any;
528
523
  } | null;
529
524
  workspaceLinks: import("../operations/services/workspace-dependency-mode.js").WorkspaceDependencyModeReport;
530
525
  sceneArtifacts: "full" | "screenshots";
@@ -650,14 +645,9 @@ export declare function workflowClose(helpers: WorkflowOperationHelpers, input:
650
645
  branchName: string | null;
651
646
  message: string;
652
647
  autoResumeCandidate: {
653
- runId: string;
654
- branch: string | null;
655
- failure: {
656
- code: string;
657
- message: string;
658
- details: Record<string, unknown> | null;
659
- at: string;
660
- } | null;
648
+ runId: any;
649
+ branch: any;
650
+ failure: any;
661
651
  } | null;
662
652
  } & {
663
653
  finalState?: WorkflowStatePayload;
@@ -688,14 +678,9 @@ export declare function workflowClose(helpers: WorkflowOperationHelpers, input:
688
678
  rootRepo: import("../operations/services/repository-save-orchestrator.js").RepositorySavePlanRepo;
689
679
  blockers: string[];
690
680
  autoResumeCandidate: {
691
- runId: string;
692
- branch: string | null;
693
- failure: {
694
- code: string;
695
- message: string;
696
- details: Record<string, unknown> | null;
697
- at: string;
698
- } | null;
681
+ runId: any;
682
+ branch: any;
683
+ failure: any;
699
684
  } | null;
700
685
  workspaceLinks: import("../operations/services/workspace-dependency-mode.js").WorkspaceDependencyModeReport;
701
686
  sceneArtifacts: "full" | "screenshots";
@@ -901,14 +886,9 @@ export declare function workflowStage(helpers: WorkflowOperationHelpers, input:
901
886
  phases: string[];
902
887
  blockers: string[];
903
888
  autoResumeCandidate: {
904
- runId: string;
905
- branch: string | null;
906
- failure: {
907
- code: string;
908
- message: string;
909
- details: Record<string, unknown> | null;
910
- at: string;
911
- } | null;
889
+ runId: any;
890
+ branch: any;
891
+ failure: any;
912
892
  } | null;
913
893
  legacyMutationPathDisabled: boolean;
914
894
  } & {
@@ -61,6 +61,8 @@ import {
61
61
  createDeprecatedTaskTag,
62
62
  deleteLocalBranch,
63
63
  deleteRemoteBranch,
64
+ deleteRemoteBranchIfMerged,
65
+ inspectMergedRemoteTaskBranches,
64
66
  ensureLocalBranchTracking,
65
67
  gitWorkflowRoot,
66
68
  headCommit,
@@ -80,6 +82,8 @@ import { resolveGitHubCredentialForRepository } from "../operations/services/git
80
82
  import {
81
83
  formatGitHubActionsGateFailure,
82
84
  inspectGitHubActionsVerification,
85
+ isRetryableGitHubActionsSetupFailure,
86
+ rerunGitHubActionsFailedJobs,
83
87
  skippedGitHubActionsGate,
84
88
  waitForGitHubActionsGate
85
89
  } from "../operations/services/github-actions-verification.js";
@@ -459,11 +463,21 @@ async function waitForWorkflowGates(operation, gates, ciMode, options = {}) {
459
463
  continue;
460
464
  }
461
465
  }
462
- const result = await waitForGitHubActionsGate(gateWithTimeout, {
466
+ const gateEnv = githubWorkflowGateEnv(options.root, gateWithTimeout);
467
+ let result = await waitForGitHubActionsGate(gateWithTimeout, {
463
468
  operation,
464
- env: githubWorkflowGateEnv(options.root, gateWithTimeout),
469
+ env: gateEnv,
465
470
  onProgress: options.onProgress
466
471
  });
472
+ if (result.status === "completed" && result.conclusion !== "success" && isRetryableGitHubActionsSetupFailure(result)) {
473
+ const retry = await rerunGitHubActionsFailedJobs(result, gateEnv);
474
+ options.onProgress?.(`[${operation}][gate][${gateWithTimeout.name}] Retrying GitHub-hosted setup failure once for run ${retry.runId}.`);
475
+ result = await waitForGitHubActionsGate(gateWithTimeout, {
476
+ operation,
477
+ env: gateEnv,
478
+ onProgress: options.onProgress
479
+ });
480
+ }
467
481
  const normalized = {
468
482
  name: gateWithTimeout.name,
469
483
  ...result,
@@ -1765,6 +1779,24 @@ function findAutoResumableTaskRun(root, command, branch) {
1765
1779
  if (!branch) return null;
1766
1780
  return listInterruptedWorkflowRuns(root).find((journal) => journal.command === command && journal.resumable && journal.session.branchName === branch) ?? null;
1767
1781
  }
1782
+ function rejectImplicitWorkflowResume(operation, journal) {
1783
+ if (!journal) return;
1784
+ workflowError(
1785
+ operation,
1786
+ "resume_unavailable",
1787
+ `Treeseed ${operation} found interrupted run ${journal.runId} for this branch and will not auto-resume recorded inputs. Run \`trsd resume ${journal.runId}\` to continue it, or \`trsd recover --obsolete ${journal.runId} --reason "superseded by a fresh ${operation}"\` before starting a new ${operation}.`,
1788
+ {
1789
+ details: {
1790
+ recovery: {
1791
+ resumable: true,
1792
+ runId: journal.runId,
1793
+ resumeCommand: `trsd resume ${journal.runId}`,
1794
+ obsoleteCommand: `trsd recover --obsolete ${journal.runId} --reason "superseded by a fresh ${operation}"`
1795
+ }
1796
+ }
1797
+ }
1798
+ );
1799
+ }
1768
1800
  function stringRecord(value) {
1769
1801
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1770
1802
  }
@@ -3446,8 +3478,37 @@ async function workflowCi(helpers, input = {}) {
3446
3478
  }
3447
3479
  });
3448
3480
  }
3449
- async function workflowTasks(helpers) {
3450
- return withContextEnv(helpers.context.env, () => createTasksResult(helpers.cwd()));
3481
+ async function workflowTasks(helpers, input = {}) {
3482
+ return withContextEnv(helpers.context.env, () => {
3483
+ const cwd = helpers.cwd();
3484
+ if (!input.cleanupMerged) return createTasksResult(cwd);
3485
+ const live = input.cleanupMerged === "live";
3486
+ const repos = [
3487
+ { name: "@treeseed/market", dir: repoRoot(cwd) },
3488
+ ...checkedOutStagePromotionRepos(cwd).map((repo) => ({ name: repo.name, dir: repo.dir }))
3489
+ ];
3490
+ const branchCleanup = repos.map((repo) => {
3491
+ const branches = inspectMergedRemoteTaskBranches(repo.dir).map((branch) => {
3492
+ if (branch.current) {
3493
+ return { ...branch, status: "preserved", reason: "branch is currently checked out" };
3494
+ }
3495
+ if (!branch.head || !branch.mergedInto) {
3496
+ return { ...branch, status: "preserved", reason: "branch is not merged into staging or main" };
3497
+ }
3498
+ if (!live) {
3499
+ return { ...branch, status: "planned", reason: `exact head is merged into ${branch.mergedInto}` };
3500
+ }
3501
+ deleteRemoteBranchIfMerged(repo.dir, branch.branch, branch.mergedInto, branch.head, { fetch: false });
3502
+ return { ...branch, status: "deleted", reason: `exact head was merged into ${branch.mergedInto}` };
3503
+ });
3504
+ return { repository: repo.name, path: repo.dir, branches };
3505
+ });
3506
+ return buildWorkflowResult("tasks", cwd, { tasks: [], workstreams: [], branchCleanup }, {
3507
+ executionMode: live ? "execute" : "plan",
3508
+ includeFinalState: false,
3509
+ summary: live ? "Merged remote task branches were cleaned safely." : "Merged remote task branch cleanup plan ready."
3510
+ });
3511
+ });
3451
3512
  }
3452
3513
  async function workflowConfig(helpers, input = {}) {
3453
3514
  try {
@@ -4464,7 +4525,8 @@ async function workflowSave(helpers, input) {
4464
4525
  const executionMode = normalizeExecutionMode(input);
4465
4526
  const explicitResumeRunId = helpers.context.workflow?.resumeRunId ?? null;
4466
4527
  const autoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableSaveRun(root, branch) : null;
4467
- const planAutoResumeRun = executionMode === "plan" ? findAutoResumableSaveRun(root, branch) : null;
4528
+ rejectImplicitWorkflowResume("save", autoResumeRun);
4529
+ const planAutoResumeRun = null;
4468
4530
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
4469
4531
  const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "save", effectiveInput);
4470
4532
  const message = String(effectiveInput.message ?? "").trim();
@@ -4914,7 +4976,8 @@ async function workflowClose(helpers, input) {
4914
4976
  const session = resolveTreeseedWorkflowSession(root);
4915
4977
  const explicitResumeRunId = helpers.context.workflow?.resumeRunId ?? null;
4916
4978
  const autoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableTaskRun(root, "close", session.branchName) : null;
4917
- const planAutoResumeRun = executionMode === "plan" ? findAutoResumableTaskRun(root, "close", session.branchName) : null;
4979
+ rejectImplicitWorkflowResume("close", autoResumeRun);
4980
+ const planAutoResumeRun = null;
4918
4981
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
4919
4982
  const message = ensureMessage("close", effectiveInput.message, "a close reason");
4920
4983
  if (executionMode === "plan") {
@@ -5153,8 +5216,8 @@ function internalPackageDependencies(repoPath) {
5153
5216
  return [...names].sort();
5154
5217
  }
5155
5218
  function normalizeStageCleanupMode(input) {
5156
- if (input.cleanupMode === "success" || input.deleteBranch === true) return "success";
5157
- return "manual";
5219
+ if (input.cleanupMode === "manual" || input.deleteBranch === false) return "manual";
5220
+ return "success";
5158
5221
  }
5159
5222
  function stageCandidateManifestPath(root, runId) {
5160
5223
  return {
@@ -5347,7 +5410,7 @@ function cleanupStageSourceBranches(root, branchName, manifest) {
5347
5410
  for (const repo of checkedOutStagePromotionRepos(root)) {
5348
5411
  const manifestRepo = manifest.packages.find((entry) => entry.name === repo.name);
5349
5412
  if (!manifestRepo) continue;
5350
- const remoteDeleted = deleteRemoteBranch(repo.dir, branchName);
5413
+ const remoteDeleted = deleteRemoteBranchIfMerged(repo.dir, branchName, STAGING_BRANCH, manifestRepo.commit);
5351
5414
  if ((currentBranch(repo.dir) || null) === branchName) {
5352
5415
  syncBranchWithOrigin(repo.dir, STAGING_BRANCH);
5353
5416
  }
@@ -5363,7 +5426,7 @@ function cleanupStageSourceBranches(root, branchName, manifest) {
5363
5426
  });
5364
5427
  }
5365
5428
  const gitRoot = repoRoot(root);
5366
- const rootRemoteDeleted = deleteRemoteBranch(gitRoot, branchName);
5429
+ const rootRemoteDeleted = deleteRemoteBranchIfMerged(gitRoot, branchName, STAGING_BRANCH, manifest.root.commit);
5367
5430
  const managedWorktree = managedWorkflowWorktreeMetadata(root);
5368
5431
  const worktreeCleanup = managedWorktree ? removeManagedWorkflowWorktree(root, { deleteBranch: false }) : { removed: false, reason: "not-managed" };
5369
5432
  const branchDeletionRoot = managedWorktree?.primaryRoot ? repoRoot(managedWorktree.primaryRoot) : gitRoot;
@@ -5395,8 +5458,9 @@ async function workflowStage(helpers, input) {
5395
5458
  const session = resolveTreeseedWorkflowSession(root);
5396
5459
  const explicitResumeRunId = helpers.context.workflow?.resumeRunId ?? null;
5397
5460
  const rawAutoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableTaskRun(root, "stage", session.branchName) : null;
5461
+ rejectImplicitWorkflowResume("stage", rawAutoResumeRun);
5398
5462
  const autoResumeRun = rawAutoResumeRun?.steps.some((step) => step.id === "preflight") ? rawAutoResumeRun : null;
5399
- const planAutoResumeRun = executionMode === "plan" ? findAutoResumableTaskRun(root, "stage", session.branchName) : null;
5463
+ const planAutoResumeRun = null;
5400
5464
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
5401
5465
  const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "stage", effectiveInput);
5402
5466
  const message = ensureMessage("stage", effectiveInput.message, "a resolution message");
@@ -36,6 +36,9 @@ export type TreeseedReleaseCandidateVerifyDriver = 'auto' | 'local' | 'action';
36
36
  export type TreeseedStageVerifyMode = 'action' | 'local' | 'none';
37
37
  export type TreeseedStageCiMode = 'off' | 'hosted';
38
38
  export type TreeseedStageCleanupMode = 'success' | 'manual';
39
+ export type TreeseedTasksInput = {
40
+ cleanupMerged?: 'plan' | 'live';
41
+ };
39
42
  export type TreeseedWorkflowContext = {
40
43
  cwd?: string;
41
44
  env?: NodeJS.ProcessEnv;
@@ -298,12 +301,14 @@ export declare class TreeseedWorkflowSdk {
298
301
  execute(operation: TreeseedWorkflowOperationId, input?: Record<string, unknown>): Promise<TreeseedWorkflowResult<import("./workflow-state.js").TreeseedWorkflowState> | TreeseedWorkflowResult<TreeseedCiResult> | TreeseedWorkflowResult<{
299
302
  tasks: TreeseedTaskBranchMetadata[];
300
303
  workstreams: TreeseedWorkflowWorkstreamSummary[];
304
+ branchCleanup?: unknown;
301
305
  }> | TreeseedWorkflowResult<Record<string, unknown>>>;
302
306
  status(input?: TreeseedWorkflowStatusOptions): Promise<TreeseedWorkflowResult<ReturnType<typeof resolveTreeseedWorkflowState>>>;
303
307
  ci(input?: TreeseedCiInput): Promise<TreeseedWorkflowResult<TreeseedCiResult>>;
304
- tasks(): Promise<TreeseedWorkflowResult<{
308
+ tasks(input?: TreeseedTasksInput): Promise<TreeseedWorkflowResult<{
305
309
  tasks: TreeseedTaskBranchMetadata[];
306
310
  workstreams: TreeseedWorkflowWorkstreamSummary[];
311
+ branchCleanup?: unknown;
307
312
  }>>;
308
313
  config(input?: TreeseedConfigInput): Promise<TreeseedWorkflowResult>;
309
314
  switchTask(input: TreeseedSwitchInput): Promise<TreeseedWorkflowResult>;
package/dist/workflow.js CHANGED
@@ -49,7 +49,7 @@ class TreeseedWorkflowSdk {
49
49
  case "ci":
50
50
  return this.ci(input);
51
51
  case "tasks":
52
- return this.tasks();
52
+ return this.tasks(input);
53
53
  case "config":
54
54
  return this.config(input);
55
55
  case "switch":
@@ -88,8 +88,8 @@ class TreeseedWorkflowSdk {
88
88
  async ci(input = {}) {
89
89
  return workflowCi(this.helpers(), input);
90
90
  }
91
- async tasks() {
92
- return workflowTasks(this.helpers());
91
+ async tasks(input = {}) {
92
+ return workflowTasks(this.helpers(), input);
93
93
  }
94
94
  async config(input = {}) {
95
95
  return workflowConfig(this.helpers(), input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.12.53",
3
+ "version": "0.12.55",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {