@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.
- package/dist/hosting/graph.js +16 -6
- package/dist/operations/services/deployment-readiness.js +4 -2
- package/dist/operations/services/git-workflow.d.ts +12 -1
- package/dist/operations/services/git-workflow.js +42 -5
- package/dist/operations/services/github-actions-verification.d.ts +5 -0
- package/dist/operations/services/github-actions-verification.js +24 -0
- package/dist/operations/services/railway-deploy.d.ts +1 -1
- package/dist/operations/services/railway-deploy.js +28 -14
- package/dist/operations/services/railway-source-policy.js +8 -6
- package/dist/platform/contracts.d.ts +2 -0
- package/dist/platform/deploy-config.js +3 -1
- package/dist/reconcile/builtin-adapters.js +47 -0
- package/dist/reconcile/providers/railway-iac.d.ts +4 -1
- package/dist/reconcile/providers/railway-iac.js +20 -1
- package/dist/workflow/operations.d.ts +14 -34
- package/dist/workflow/operations.js +75 -11
- package/dist/workflow.d.ts +6 -1
- package/dist/workflow.js +3 -3
- package/package.json +1 -1
package/dist/hosting/graph.js
CHANGED
|
@@ -83,7 +83,8 @@ function publicTreeDxSourcePolicy(input, config, launchEnv) {
|
|
|
83
83
|
imageRef,
|
|
84
84
|
imageTagRef: "TREESEED_PUBLIC_TREEDX_IMAGE_REF"
|
|
85
85
|
};
|
|
86
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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-") ||
|
|
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 (
|
|
15
|
-
if (/^public-treedx-node
|
|
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 (
|
|
21
|
-
if (/^treeseed-api-operations-runner
|
|
22
|
-
if (/^public-treedx-node
|
|
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 {
|
|
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:
|
|
521
|
-
branch:
|
|
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:
|
|
654
|
-
branch:
|
|
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:
|
|
692
|
-
branch:
|
|
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:
|
|
905
|
-
branch:
|
|
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
|
|
466
|
+
const gateEnv = githubWorkflowGateEnv(options.root, gateWithTimeout);
|
|
467
|
+
let result = await waitForGitHubActionsGate(gateWithTimeout, {
|
|
463
468
|
operation,
|
|
464
|
-
env:
|
|
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, () =>
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
5157
|
-
return "
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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");
|
package/dist/workflow.d.ts
CHANGED
|
@@ -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);
|