@treeseed/sdk 0.12.44 → 0.12.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/guarantees/index.d.ts +83 -0
  2. package/dist/guarantees/index.js +688 -86
  3. package/dist/hosting/graph.js +40 -6
  4. package/dist/operations/services/git-workflow.d.ts +16 -1
  5. package/dist/operations/services/git-workflow.js +65 -5
  6. package/dist/operations/services/github-api.js +0 -19
  7. package/dist/operations/services/hosted-service-checks.js +17 -1
  8. package/dist/operations/services/live-hosted-service-checks.d.ts +4 -0
  9. package/dist/operations/services/live-hosted-service-checks.js +7 -4
  10. package/dist/operations/services/local-cleanup.js +3 -7
  11. package/dist/operations/services/package-adapters.d.ts +14 -0
  12. package/dist/operations/services/package-adapters.js +36 -3
  13. package/dist/operations/services/package-artifacts.d.ts +37 -0
  14. package/dist/operations/services/package-artifacts.js +99 -0
  15. package/dist/operations/services/railway-deploy.js +78 -18
  16. package/dist/operations/services/railway-source-policy.d.ts +19 -0
  17. package/dist/operations/services/railway-source-policy.js +66 -0
  18. package/dist/operations/services/repository-save-orchestrator.js +12 -5
  19. package/dist/platform/desired-state.js +2 -3
  20. package/dist/reconcile/builtin-adapters.js +10 -2
  21. package/dist/reconcile/providers/railway-iac.d.ts +2 -0
  22. package/dist/reconcile/providers/railway-iac.js +31 -3
  23. package/dist/scenes/builtin-plugins.js +36 -5
  24. package/dist/scenes/device-matrix.js +2 -0
  25. package/dist/scenes/environment.js +1 -1
  26. package/dist/scenes/runner.js +28 -16
  27. package/dist/scenes/schema.js +31 -2
  28. package/dist/scenes/types.d.ts +25 -2
  29. package/dist/scenes/visual-audit-fixtures.js +9 -3
  30. package/dist/workflow/operations.d.ts +27 -92
  31. package/dist/workflow/operations.js +236 -144
  32. package/dist/workflow/runs.d.ts +1 -0
  33. package/dist/workflow/runs.js +57 -0
  34. package/dist/workflow-support.d.ts +1 -0
  35. package/dist/workflow-support.js +8 -0
  36. package/dist/workflow.d.ts +2 -0
  37. package/package.json +4 -1
@@ -0,0 +1,99 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { runTreeseedGitText } from "./git-runner.js";
6
+ function readPackageJson(packageRoot) {
7
+ const path = resolve(packageRoot, "package.json");
8
+ if (!existsSync(path)) throw new Error(`Package artifact build requires ${path}.`);
9
+ return JSON.parse(readFileSync(path, "utf8"));
10
+ }
11
+ function fileDigest(path) {
12
+ const bytes = readFileSync(path);
13
+ return {
14
+ sha256: createHash("sha256").update(bytes).digest("hex"),
15
+ size: bytes.byteLength
16
+ };
17
+ }
18
+ function buildTreeseedPackageArtifact(input) {
19
+ const packageRoot = resolve(input.packageRoot);
20
+ const outputDir = resolve(input.outputDir);
21
+ const pkg = readPackageJson(packageRoot);
22
+ const packageName = typeof pkg.name === "string" ? pkg.name : "";
23
+ const packageVersion = typeof pkg.version === "string" ? pkg.version : "";
24
+ if (!packageName || !packageVersion) throw new Error("Package artifact build requires package name and version.");
25
+ mkdirSync(outputDir, { recursive: true });
26
+ const result = spawnSync("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", outputDir], {
27
+ cwd: packageRoot,
28
+ encoding: "utf8",
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ env: process.env
31
+ });
32
+ if ((result.status ?? 1) !== 0) throw new Error(`npm pack failed for ${packageName}.
33
+ ${result.stderr || result.stdout}`);
34
+ const output = JSON.parse(result.stdout);
35
+ const filename = output[0]?.filename;
36
+ if (!filename) throw new Error(`npm pack did not report an artifact for ${packageName}.`);
37
+ const artifactPath = resolve(outputDir, basename(filename));
38
+ if (!existsSync(artifactPath)) throw new Error(`npm pack artifact is missing: ${artifactPath}`);
39
+ const sourceSha = runTreeseedGitText(["rev-parse", "HEAD"], { cwd: packageRoot, mode: "read" }).trim();
40
+ const digest = fileDigest(artifactPath);
41
+ const manifest = {
42
+ schemaVersion: 1,
43
+ kind: "treeseed.package-artifact",
44
+ packageName,
45
+ packageVersion,
46
+ sourceSha,
47
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
48
+ file: basename(artifactPath),
49
+ sha256: digest.sha256,
50
+ size: digest.size
51
+ };
52
+ const manifestPath = resolve(outputDir, "manifest.json");
53
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
54
+ `, "utf8");
55
+ return { artifactPath, manifestPath, manifest };
56
+ }
57
+ function verifyTreeseedPackageArtifact(input) {
58
+ const manifestPath = resolve(input.manifestPath);
59
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
60
+ if (manifest.schemaVersion !== 1 || manifest.kind !== "treeseed.package-artifact") {
61
+ throw new Error(`Unsupported package artifact manifest: ${manifestPath}`);
62
+ }
63
+ const artifactPath = resolve(input.artifactPath ?? resolve(dirname(manifestPath), manifest.file));
64
+ if (!existsSync(artifactPath)) throw new Error(`Package artifact is missing: ${artifactPath}`);
65
+ const digest = fileDigest(artifactPath);
66
+ if (digest.sha256 !== manifest.sha256 || digest.size !== manifest.size) {
67
+ throw new Error(`Package artifact integrity check failed for ${artifactPath}.`);
68
+ }
69
+ return { ok: true, artifactPath, manifestPath, manifest };
70
+ }
71
+ function hydrateTreeseedPackageArtifacts(input) {
72
+ const artifactsRoot = resolve(input.artifactsRoot);
73
+ const projectRoot = resolve(input.projectRoot);
74
+ const manifests = readdirSync(artifactsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(resolve(artifactsRoot, entry.name, "manifest.json"))).map((entry) => verifyTreeseedPackageArtifact({ manifestPath: resolve(artifactsRoot, entry.name, "manifest.json") })).sort((left, right) => left.manifest.packageName.localeCompare(right.manifest.packageName));
75
+ if (manifests.length === 0) throw new Error(`No package artifact manifests found under ${artifactsRoot}.`);
76
+ for (const entry of manifests) {
77
+ const packagePath = resolve(projectRoot, "node_modules", ...entry.manifest.packageName.split("/"));
78
+ rmSync(packagePath, { recursive: true, force: true });
79
+ mkdirSync(packagePath, { recursive: true });
80
+ const result = spawnSync("tar", ["-xzf", entry.artifactPath, "--strip-components=1", "-C", packagePath], {
81
+ cwd: projectRoot,
82
+ encoding: "utf8",
83
+ stdio: ["ignore", "pipe", "pipe"],
84
+ env: process.env
85
+ });
86
+ if ((result.status ?? 1) !== 0) throw new Error(`Candidate artifact extraction failed for ${entry.manifest.packageName}.
87
+ ${result.stderr || result.stdout}`);
88
+ const installed = readPackageJson(packagePath);
89
+ if (installed.version !== entry.manifest.packageVersion) {
90
+ throw new Error(`Hydrated ${entry.manifest.packageName} version mismatch: expected ${entry.manifest.packageVersion}, observed ${String(installed.version)}.`);
91
+ }
92
+ }
93
+ return { ok: true, projectRoot, artifactsRoot, packages: manifests.map((entry) => entry.manifest) };
94
+ }
95
+ export {
96
+ buildTreeseedPackageArtifact,
97
+ hydrateTreeseedPackageArtifacts,
98
+ verifyTreeseedPackageArtifact
99
+ };
@@ -6,6 +6,7 @@ import { resolveTreeseedMachineEnvironmentValues } from "./config-runtime.js";
6
6
  import { createPersistentDeployTarget, resolveTreeseedResourceIdentity } from "./deploy.js";
7
7
  import { classifyTreeseedGitMode, runTreeseedGitText } from "./git-runner.js";
8
8
  import { discoverTreeseedApplications } from "../../hosting/apps.js";
9
+ import { apiRailwayDefaultDockerfilePath, apiRailwayDefaultSourceRepo, assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "./railway-source-policy.js";
9
10
  import { runPrefixedCommand, sleep } from "./bootstrap-runner.js";
10
11
  import {
11
12
  ensureRailwayEnvironment,
@@ -142,6 +143,15 @@ function configuredEnvValue(env, name) {
142
143
  const value = env?.[name];
143
144
  return typeof value === "string" && value.trim() ? value.trim() : "";
144
145
  }
146
+ function configuredApiPublicBaseUrl(deployConfig, scope) {
147
+ const apiSurface = deployConfig.surfaces?.api;
148
+ if (!apiSurface || typeof apiSurface !== "object") return null;
149
+ const environment = apiSurface.environments?.[scope];
150
+ const configured = environment?.baseUrl ?? environment?.domain ?? (scope === "local" ? apiSurface.localBaseUrl : null) ?? apiSurface.publicBaseUrl ?? null;
151
+ if (typeof configured !== "string" || !configured.trim()) return null;
152
+ const value = configured.trim().replace(/\/+$/u, "");
153
+ return /^https?:\/\//iu.test(value) ? value : `https://${value}`;
154
+ }
145
155
  function railwayDeployTransport(env) {
146
156
  const configured = configuredEnvValue(env, "TREESEED_RAILWAY_DEPLOY_TRANSPORT").toLowerCase();
147
157
  return configured === "cli-fallback" ? "cli-fallback" : "api";
@@ -672,7 +682,7 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
672
682
  normalizedScope,
673
683
  service.environments?.[normalizedScope]?.railwayEnvironment
674
684
  );
675
- const publicBaseUrl = service.environments?.[normalizedScope]?.baseUrl ?? service.publicBaseUrl ?? null;
685
+ const publicBaseUrl = service.environments?.[normalizedScope]?.baseUrl ?? service.publicBaseUrl ?? (serviceKey === "api" ? configuredApiPublicBaseUrl(deployConfig, normalizedScope) : null);
676
686
  const configuredServiceName = service.railway?.serviceName ?? (serviceKey === "workerRunner" ? deriveRailwayWorkerRunnerServiceName(identity.deploymentKey) : `${identity.deploymentKey}-${railwayServiceNameSuffix(serviceKey)}`);
677
687
  const configuredRunnerPool = service.railway?.runnerPool && typeof service.railway.runnerPool === "object" ? service.railway.runnerPool : null;
678
688
  const runnerPool = serviceKey === "operationsRunner" || serviceKey === "capacityProviderRunner" ? {
@@ -721,7 +731,7 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
721
731
  sourceBranch: sourcePolicy.sourceBranch,
722
732
  sourceCommit: sourcePolicy.sourceCommit,
723
733
  sourceRootDirectory: sourcePolicy.sourceRootDirectory,
724
- dockerfilePath: sourcePolicy.sourceMode === "git" ? service.railway?.dockerfilePath ?? null : null,
734
+ dockerfilePath: sourcePolicy.sourceMode === "git" ? service.railway?.dockerfilePath ?? apiRailwayDefaultDockerfilePath({ key: serviceKey, serviceName }) : null,
725
735
  healthcheckPath: service.railway?.healthcheckPath ?? null,
726
736
  healthcheckTimeoutSeconds: service.railway?.healthcheckTimeoutSeconds ?? null,
727
737
  healthcheckIntervalSeconds: service.railway?.healthcheckIntervalSeconds ?? null,
@@ -764,6 +774,9 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
764
774
  const configuredSource = railway.source && typeof railway.source === "object" && !Array.isArray(railway.source) ? railway.source : {};
765
775
  const treeDxRoot = resolve(workspaceRoot ?? tenantRoot, "packages", "treedx");
766
776
  const configuredMode = typeof railway.sourceMode === "string" ? railway.sourceMode : null;
777
+ if (scope === "staging" && configuredMode === "image") {
778
+ throw new Error("public-treedx-node-01: API Railway staging services must use GitHub Dockerfile source builds (configured sourceMode image is not allowed).");
779
+ }
767
780
  const sourceMode = scope === "prod" ? "image" : configuredMode === "git" || configuredMode === "image" ? configuredMode : "git";
768
781
  const repository = typeof railway.sourceRepo === "string" ? railway.sourceRepo : typeof configuredSource.repository === "string" ? configuredSource.repository : typeof configuredSource.repo === "string" ? configuredSource.repo : readTreeseedPackageRepository(treeDxRoot) ?? "treeseed-ai/treedx";
769
782
  const sourceBranch = typeof railway.sourceBranch === "string" ? railway.sourceBranch : typeof configuredSource.branch === "string" ? configuredSource.branch : scope === "staging" ? "staging" : null;
@@ -774,8 +787,22 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
774
787
  return Array.from({ length: bootstrapCount }, (_, offset) => {
775
788
  const index = offset + 1;
776
789
  const serviceName = `${PUBLIC_TREEDX_NODE_SERVICE_KEY_PREFIX}${String(index).padStart(2, "0")}`;
777
- return {
790
+ const service = {
778
791
  key: serviceName,
792
+ serviceName,
793
+ sourceMode,
794
+ sourceRepo: sourceMode === "git" ? repository : null,
795
+ sourceBranch: sourceMode === "git" ? sourceBranch : null,
796
+ sourceCommit: sourceMode === "git" ? typeof railway.sourceCommit === "string" ? railway.sourceCommit : typeof configuredSource.commit === "string" ? configuredSource.commit : headCommitSafe(treeDxRoot) ?? headCommitSafe(tenantRoot) : null,
797
+ sourceRootDirectory: sourceMode === "git" ? sourceRootDirectory : null,
798
+ imageRef: sourceMode === "image" ? baseImageRef : null,
799
+ dockerfilePath: sourceMode === "git" ? railway.dockerfilePath ?? "/Dockerfile" : null,
800
+ buildCommand: sourceMode === "git" ? railway.buildCommand ?? null : null,
801
+ startCommand: sourceMode === "git" ? railway.startCommand ?? null : null
802
+ };
803
+ assertApiRailwaySourcePolicy(scope, service);
804
+ return {
805
+ key: service.key,
779
806
  instanceKey: serviceName,
780
807
  runnerIndex: null,
781
808
  serviceConfig: null,
@@ -783,20 +810,20 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
783
810
  projectId: typeof railway.projectId === "string" ? railway.projectId : null,
784
811
  projectName,
785
812
  serviceId: typeof railway.serviceId === "string" && bootstrapCount === 1 ? railway.serviceId : null,
786
- serviceName,
813
+ serviceName: service.serviceName,
787
814
  runnerId: null,
788
815
  rootDir: treeDxRoot,
789
816
  publicBaseUrl: null,
790
817
  railwayEnvironment,
791
- buildCommand: sourceMode === "git" ? railway.buildCommand ?? null : null,
792
- startCommand: sourceMode === "git" ? railway.startCommand ?? null : null,
793
- imageRef: sourceMode === "image" ? baseImageRef : null,
794
- sourceMode,
795
- sourceRepo: sourceMode === "git" ? repository : null,
796
- sourceBranch: sourceMode === "git" ? sourceBranch : null,
797
- sourceCommit: sourceMode === "git" ? headCommitSafe(treeDxRoot) ?? headCommitSafe(tenantRoot) : null,
798
- sourceRootDirectory: sourceMode === "git" ? sourceRootDirectory : null,
799
- dockerfilePath: sourceMode === "git" ? railway.dockerfilePath ?? "/Dockerfile" : null,
818
+ buildCommand: service.buildCommand,
819
+ startCommand: service.startCommand,
820
+ imageRef: service.imageRef,
821
+ sourceMode: service.sourceMode,
822
+ sourceRepo: service.sourceRepo,
823
+ sourceBranch: service.sourceBranch,
824
+ sourceCommit: service.sourceCommit,
825
+ sourceRootDirectory: service.sourceRootDirectory,
826
+ dockerfilePath: service.dockerfilePath,
800
827
  healthcheckPath: railway.healthcheckPath ?? null,
801
828
  healthcheckTimeoutSeconds: railway.healthcheckTimeoutSeconds ?? null,
802
829
  healthcheckIntervalSeconds: railway.healthcheckIntervalSeconds ?? null,
@@ -870,25 +897,48 @@ function resolveRailwayServiceSourcePolicy({ tenantRoot, scope, serviceKey, serv
870
897
  const configuredMode = typeof service.railway?.sourceMode === "string" ? service.railway.sourceMode : null;
871
898
  const configuredSource = service.railway?.source && typeof service.railway.source === "object" && !Array.isArray(service.railway.source) ? service.railway.source : {};
872
899
  const configuredRepo = typeof service.railway?.sourceRepo === "string" ? service.railway.sourceRepo : typeof configuredSource.repository === "string" ? configuredSource.repository : typeof configuredSource.repo === "string" ? configuredSource.repo : null;
873
- const packageRepository = configuredRepo ?? readTreeseedPackageRepository(serviceRoot) ?? readTreeseedPackageRepository(tenantRoot);
900
+ const serviceName = service.railway?.serviceName ?? null;
901
+ const packageRepository = configuredRepo ?? readTreeseedPackageRepository(serviceRoot) ?? readTreeseedPackageRepository(tenantRoot) ?? apiRailwayDefaultSourceRepo({ key: serviceKey, serviceName });
902
+ const dockerfilePath = service.railway?.dockerfilePath ?? apiRailwayDefaultDockerfilePath({ key: serviceKey, serviceName });
874
903
  const apiPackageSourceEligible = ["api", "operationsRunner"].includes(serviceKey);
904
+ if (scope === "staging" && isApiRailwaySourcePolicyService({ key: serviceKey, serviceName }) && (configuredMode === "image" || service.railway?.imageRef)) {
905
+ throw new Error(`${serviceName ?? serviceKey}: API Railway staging services must use GitHub Dockerfile source builds (configured image source is not allowed).`);
906
+ }
875
907
  const sourceMode = scope === "prod" ? "image" : scope === "staging" && apiPackageSourceEligible ? "git" : configuredMode === "git" || configuredMode === "image" ? configuredMode : imageRef ? "image" : "git";
876
908
  if (sourceMode !== "git") {
877
- return {
909
+ const policy2 = {
878
910
  sourceMode: "image",
879
911
  sourceRepo: null,
880
912
  sourceBranch: null,
881
913
  sourceCommit: null,
882
914
  sourceRootDirectory: null
883
915
  };
916
+ assertApiRailwaySourcePolicy(scope, {
917
+ key: serviceKey,
918
+ serviceName,
919
+ imageRef,
920
+ dockerfilePath: null,
921
+ buildCommand: null,
922
+ startCommand: null,
923
+ ...policy2
924
+ });
925
+ return policy2;
884
926
  }
885
- return {
927
+ const policy = {
886
928
  sourceMode: "git",
887
929
  sourceRepo: packageRepository,
888
930
  sourceBranch: typeof service.railway?.sourceBranch === "string" ? service.railway.sourceBranch : typeof configuredSource.branch === "string" ? configuredSource.branch : scope === "staging" ? "staging" : null,
889
- sourceCommit: headCommitSafe(serviceRoot),
931
+ sourceCommit: typeof service.railway?.sourceCommit === "string" ? service.railway.sourceCommit : typeof configuredSource.commit === "string" ? configuredSource.commit : headCommitSafe(serviceRoot),
890
932
  sourceRootDirectory: typeof service.railway?.sourceRootDirectory === "string" ? service.railway.sourceRootDirectory : typeof configuredSource.rootDirectory === "string" ? configuredSource.rootDirectory : "."
891
933
  };
934
+ assertApiRailwaySourcePolicy(scope, {
935
+ key: serviceKey,
936
+ serviceName,
937
+ imageRef: null,
938
+ dockerfilePath,
939
+ ...policy
940
+ });
941
+ return policy;
892
942
  }
893
943
  function resolveRailwayCapacityProviderRoot(tenantRoot, service) {
894
944
  if (service.railway?.rootDir) {
@@ -1533,6 +1583,17 @@ async function syncRailwayServiceRuntimeConfigurationAfterDeploy(tenantRoot, ser
1533
1583
  serviceId: railwayService.id,
1534
1584
  variables: {
1535
1585
  TREESEED_SKIP_PACKAGE_PREPARE: "1",
1586
+ ...["api", "operationsRunner"].includes(service.key) ? {
1587
+ ...configuredEnvValue(env, "TREESEED_PLATFORM_RUNNER_SECRET") ? {
1588
+ TREESEED_PLATFORM_RUNNER_SECRET: configuredEnvValue(env, "TREESEED_PLATFORM_RUNNER_SECRET")
1589
+ } : {},
1590
+ ...configuredEnvValue(env, "TREESEED_CREDENTIAL_SESSION_SECRET") ? {
1591
+ TREESEED_CREDENTIAL_SESSION_SECRET: configuredEnvValue(env, "TREESEED_CREDENTIAL_SESSION_SECRET")
1592
+ } : {},
1593
+ ...configuredEnvValue(env, "TREESEED_WEB_SERVICE_SECRET") ? {
1594
+ TREESEED_WEB_SERVICE_SECRET: configuredEnvValue(env, "TREESEED_WEB_SERVICE_SECRET")
1595
+ } : {}
1596
+ } : {},
1536
1597
  ...service.sourceMode === "git" ? {
1537
1598
  TREESEED_DEPLOY_SOURCE_MODE: "git",
1538
1599
  ...service.sourceRepo ? { TREESEED_DEPLOY_SOURCE_REPOSITORY: service.sourceRepo } : {},
@@ -1550,7 +1611,6 @@ async function syncRailwayServiceRuntimeConfigurationAfterDeploy(tenantRoot, ser
1550
1611
  TREESEED_MANAGER_ID: normalizeScope(service.scope),
1551
1612
  ...configuredEnvValue(env, "TREESEED_RAILWAY_API_TOKEN") ? { TREESEED_RAILWAY_API_TOKEN: configuredEnvValue(env, "TREESEED_RAILWAY_API_TOKEN") } : {},
1552
1613
  ...configuredEnvValue(env, "TREESEED_RAILWAY_WORKSPACE") ? { TREESEED_RAILWAY_WORKSPACE: configuredEnvValue(env, "TREESEED_RAILWAY_WORKSPACE") } : {},
1553
- ...configuredEnvValue(env, "TREESEED_PLATFORM_RUNNER_SECRET") ? { TREESEED_PLATFORM_RUNNER_SECRET: configuredEnvValue(env, "TREESEED_PLATFORM_RUNNER_SECRET") } : {},
1554
1614
  ...configuredEnvValue(env, "TREESEED_API_BASE_URL") || configuredEnvValue(env, "TREESEED_URL") ? {
1555
1615
  TREESEED_API_BASE_URL: configuredEnvValue(env, "TREESEED_API_BASE_URL") || configuredEnvValue(env, "TREESEED_URL")
1556
1616
  } : {}
@@ -0,0 +1,19 @@
1
+ export type TreeseedRailwaySourcePolicyScope = 'local' | 'staging' | 'prod';
2
+ export type TreeseedRailwaySourcePolicyService = {
3
+ key?: string | null;
4
+ serviceName?: string | null;
5
+ sourceMode?: string | null;
6
+ sourceRepo?: string | null;
7
+ sourceBranch?: string | null;
8
+ sourceCommit?: string | null;
9
+ sourceRootDirectory?: string | null;
10
+ imageRef?: string | null;
11
+ dockerfilePath?: string | null;
12
+ buildCommand?: string | null;
13
+ startCommand?: string | null;
14
+ };
15
+ export declare function isApiRailwaySourcePolicyService(service: TreeseedRailwaySourcePolicyService): boolean;
16
+ export declare function isImmutableRailwayImageRef(value: unknown): boolean;
17
+ export declare function apiRailwayDefaultSourceRepo(service: TreeseedRailwaySourcePolicyService): "treeseed-ai/treedx" | "treeseed-ai/api" | null;
18
+ export declare function apiRailwayDefaultDockerfilePath(service: TreeseedRailwaySourcePolicyService): "/Dockerfile.api" | "/Dockerfile.operations-runner" | "/Dockerfile" | null;
19
+ export declare function assertApiRailwaySourcePolicy(scope: TreeseedRailwaySourcePolicyScope | string, service: TreeseedRailwaySourcePolicyService): void;
@@ -0,0 +1,66 @@
1
+ function isApiRailwaySourcePolicyService(service) {
2
+ const key = String(service.key ?? "").trim();
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);
5
+ }
6
+ function isImmutableRailwayImageRef(value) {
7
+ const imageRef = typeof value === "string" ? value.trim() : "";
8
+ if (!imageRef || !imageRef.includes(":")) return false;
9
+ const tag = imageRef.split(":").pop()?.trim() ?? "";
10
+ return Boolean(tag) && !["latest", "staging", "dev", "local"].includes(tag);
11
+ }
12
+ function apiRailwayDefaultSourceRepo(service) {
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";
16
+ return null;
17
+ }
18
+ function apiRailwayDefaultDockerfilePath(service) {
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";
23
+ return null;
24
+ }
25
+ function assertApiRailwaySourcePolicy(scope, service) {
26
+ if (!isApiRailwaySourcePolicyService(service)) return;
27
+ const normalizedScope = scope === "prod" ? "prod" : scope === "staging" ? "staging" : "local";
28
+ const label = service.serviceName ?? service.key ?? "Railway service";
29
+ if (normalizedScope === "staging") {
30
+ const issues = [
31
+ service.sourceMode === "git" ? null : "sourceMode must be git",
32
+ service.imageRef ? "imageRef must be empty" : null,
33
+ service.sourceRepo ? null : "sourceRepo must be set",
34
+ service.sourceBranch === "staging" ? null : "sourceBranch must be staging",
35
+ service.sourceRootDirectory ? null : "sourceRootDirectory must be set",
36
+ service.dockerfilePath ? null : "dockerfilePath must be set"
37
+ ].filter((issue) => Boolean(issue));
38
+ if (issues.length > 0) {
39
+ throw new Error(`${label}: API Railway staging services must use GitHub Dockerfile source builds (${issues.join("; ")}).`);
40
+ }
41
+ return;
42
+ }
43
+ if (normalizedScope === "prod") {
44
+ const issues = [
45
+ service.sourceMode === "image" ? null : "sourceMode must be image",
46
+ isImmutableRailwayImageRef(service.imageRef) ? null : "imageRef must be an immutable released image tag",
47
+ service.sourceRepo ? "sourceRepo must be empty" : null,
48
+ service.sourceBranch ? "sourceBranch must be empty" : null,
49
+ service.sourceCommit ? "sourceCommit must be empty" : null,
50
+ service.sourceRootDirectory ? "sourceRootDirectory must be empty" : null,
51
+ service.dockerfilePath ? "dockerfilePath must be empty" : null,
52
+ service.buildCommand ? "buildCommand must be empty" : null,
53
+ service.startCommand ? "startCommand must be empty" : null
54
+ ].filter((issue) => Boolean(issue));
55
+ if (issues.length > 0) {
56
+ throw new Error(`${label}: API Railway production services must use released Docker image sources (${issues.join("; ")}).`);
57
+ }
58
+ }
59
+ }
60
+ export {
61
+ apiRailwayDefaultDockerfilePath,
62
+ apiRailwayDefaultSourceRepo,
63
+ assertApiRailwaySourcePolicy,
64
+ isApiRailwaySourcePolicyService,
65
+ isImmutableRailwayImageRef
66
+ };
@@ -117,7 +117,7 @@ function npmCacheForCwd(cwd) {
117
117
  return cacheDir;
118
118
  }
119
119
  function npmWorkflowEnv(env = {}, cwd = process.cwd()) {
120
- const npmCache = env.npm_config_cache ?? env.NPM_CONFIG_CACHE ?? process.env.npm_config_cache ?? process.env.NPM_CONFIG_CACHE ?? npmCacheForCwd(cwd);
120
+ const npmCache = env.npm_config_cache ?? env.NPM_CONFIG_CACHE ?? npmCacheForCwd(cwd);
121
121
  return withShortProcessTempEnv({
122
122
  ...env,
123
123
  NPM_CONFIG_CACHE: npmCache,
@@ -777,7 +777,7 @@ function applyPackageVersion(node, version) {
777
777
  return true;
778
778
  }
779
779
  function shouldSkipNetworkInstall() {
780
- return process.env.TREESEED_SAVE_NPM_INSTALL_MODE === "skip";
780
+ return process.env.TREESEED_SAVE_NPM_INSTALL_MODE !== "allow";
781
781
  }
782
782
  function shouldSkipGitDependencySmoke(options) {
783
783
  return shouldSkipNetworkInstall() || process.env.TREESEED_GIT_DEPENDENCY_SMOKE === "skip" || options?.verifyMode === "skip";
@@ -948,6 +948,11 @@ function lockfileValidationCommand(node, options) {
948
948
  const args = rootWorkspaceInstall ? ["ci", "--ignore-scripts", "--plan"] : ["ci", "--ignore-scripts", "--plan", "--workspaces=false"];
949
949
  return { command: "npm", args };
950
950
  }
951
+ function lockfileValidationTimeoutMs(node, options) {
952
+ const packageJson = node.packageJson ?? (existsSync(resolve(node.path, "package.json")) ? readJson(resolve(node.path, "package.json")) : null);
953
+ const rootWorkspaceInstall = node.path === options.root && Array.isArray(packageJson?.workspaces);
954
+ return rootWorkspaceInstall ? 18e5 : 6e5;
955
+ }
951
956
  async function validateRepositoryLockfile(node, options) {
952
957
  if (!hasNpmLockfile(node.path)) {
953
958
  return { status: "skipped", command: null, issues: [], error: "no npm lockfile" };
@@ -973,7 +978,7 @@ async function validateRepositoryLockfile(node, options) {
973
978
  return { status: "skipped", command: commandText, issues: [], error: "disabled" };
974
979
  }
975
980
  try {
976
- runCapturedCommand(node, options, "lockfile", command, args, { timeoutMs: 6e5, emitOutputOnSuccess: false });
981
+ runCapturedCommand(node, options, "lockfile", command, args, { timeoutMs: lockfileValidationTimeoutMs(node, options), emitOutputOnSuccess: false });
977
982
  const packageCount = npmLockfilePackageCount(node.path);
978
983
  const countText = packageCount === null ? "package-lock entries" : `${packageCount} package${packageCount === 1 ? "" : "s"}`;
979
984
  emitProgress(options, node, "lockfile", `Lockfile validation passed: ${countText} checked, 0 issues.`);
@@ -1124,7 +1129,8 @@ function pullRebaseFromOrigin(node, options, branch) {
1124
1129
  };
1125
1130
  }
1126
1131
  try {
1127
- runCapturedCommand(node, options, "rebase", "git", ["pull", "--rebase", "--recurse-submodules=no", "origin", branch]);
1132
+ runCapturedCommand(node, options, "rebase", "git", ["fetch", "origin", `refs/heads/${branch}:refs/remotes/origin/${branch}`]);
1133
+ runCapturedCommand(node, options, "rebase", "git", ["rebase", `refs/remotes/origin/${branch}`]);
1128
1134
  return {
1129
1135
  remoteBranchExisted: true,
1130
1136
  pulledRebase: true
@@ -1161,7 +1167,7 @@ function pushCurrentBranch(node, options, branch, tagName) {
1161
1167
  async function finishRepositorySavePublish(node, options, state, report, input) {
1162
1168
  const reference = input.reference ?? null;
1163
1169
  const tagName = input.tagName ?? reference?.tagName ?? null;
1164
- const shouldDeferPush = options.deferPushUntilVerified === true && node.id === ".";
1170
+ const shouldDeferPush = options.deferPushUntilVerified === true;
1165
1171
  if (shouldDeferPush) {
1166
1172
  state.deferredPushes.push({
1167
1173
  node,
@@ -1828,6 +1834,7 @@ async function saveOneRepository(node, options, state) {
1828
1834
  return report;
1829
1835
  }
1830
1836
  report.committed = true;
1837
+ report.commitSha = headCommit(node.path);
1831
1838
  const rebase = pullRebaseFromOrigin(node, options, branch);
1832
1839
  const verifyMode = options.verifyMode ?? "action-first";
1833
1840
  if (node.kind === "project" && node.path === options.root && Array.isArray(node.packageJson?.workspaces)) {
@@ -652,9 +652,8 @@ function localDevelopmentResources(tenantRoot, environment, localContent) {
652
652
  dependencies: [composeId],
653
653
  spec: {
654
654
  mode: "local",
655
- roles: ["api", "manager", "runner"],
656
- volumePolicy: "shared-local",
657
- healthEndpoint: "http://127.0.0.1:4783/healthz"
655
+ roles: ["manager", "runner"],
656
+ volumePolicy: "shared-local"
658
657
  },
659
658
  source: { type: "package-adapter", id: "@treeseed/agent" }
660
659
  },
@@ -3952,7 +3952,11 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3952
3952
  volumes: rendered.volumeNames,
3953
3953
  database: rendered.databaseName,
3954
3954
  scope,
3955
- serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null]))
3955
+ serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null])),
3956
+ serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [
3957
+ service.serviceName,
3958
+ service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
3959
+ ]))
3956
3960
  });
3957
3961
  if (!validation.ok && effectiveIacInput.database && !effectiveIacInput.database.useNativePostgres && railwayIacPlanDeletesResource(plan.changeSet, effectiveIacInput.database.serviceName)) {
3958
3962
  traceRailwayReconcile(topology.env, "sync:iac-native-postgres-adopt", `${project.name}/${environment.name}:${effectiveIacInput.database.serviceName}`);
@@ -3975,7 +3979,11 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3975
3979
  volumes: rendered.volumeNames,
3976
3980
  database: rendered.databaseName,
3977
3981
  scope,
3978
- serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null]))
3982
+ serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null])),
3983
+ serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [
3984
+ service.serviceName,
3985
+ service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
3986
+ ]))
3979
3987
  });
3980
3988
  }
3981
3989
  if (!validation.ok) {
@@ -28,6 +28,7 @@ export type TreeseedRailwayIacDatabase = {
28
28
  };
29
29
  export type TreeseedRailwayIacProjectInput = {
30
30
  tenantRoot: string;
31
+ scope?: string | null;
31
32
  projectName: string;
32
33
  projectId: string;
33
34
  environmentName: string;
@@ -61,6 +62,7 @@ export declare function validateRailwayIacChangeSet(changeSet: RailwayChangeSet
61
62
  database: string | null;
62
63
  scope: string;
63
64
  serviceSourceModes?: Record<string, string | null | undefined>;
65
+ serviceSourceRefs?: Record<string, string | null | undefined>;
64
66
  }): RailwayIacValidationResult;
65
67
  export declare function planRailwayIacProject(input: TreeseedRailwayIacProjectInput, rendered?: TreeseedRailwayIacRenderResult): Promise<RailwayIacPlanResponse>;
66
68
  export declare function applyRailwayIacProject(input: TreeseedRailwayIacProjectInput, rendered?: TreeseedRailwayIacRenderResult): Promise<RailwayIacApplyResponse>;
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { runRailwayIac } from "railway/iac";
4
+ import { assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "../../operations/services/railway-source-policy.js";
4
5
  function js(value) {
5
6
  return JSON.stringify(value);
6
7
  }
@@ -97,7 +98,13 @@ function renderPostgresEnv() {
97
98
  ["RAILWAY_DEPLOYMENT_DRAINING_SECONDS", js("60")]
98
99
  ]);
99
100
  }
101
+ function normalizeIacScope(input) {
102
+ if (input.scope === "prod" || input.scope === "staging") return input.scope;
103
+ const environmentName = String(input.environmentName ?? "").trim().toLowerCase();
104
+ return environmentName === "production" || environmentName === "prod" ? "prod" : environmentName === "staging" ? "staging" : "local";
105
+ }
100
106
  function renderRailwayIacProject(input) {
107
+ const scope = normalizeIacScope(input);
101
108
  const region = input.region?.trim() || "us-east4-eqdc4a";
102
109
  const tempParent = resolve(input.tenantRoot, ".treeseed", "tmp");
103
110
  mkdirSync(tempParent, { recursive: true });
@@ -145,6 +152,7 @@ function renderRailwayIacProject(input) {
145
152
  }
146
153
  }
147
154
  input.services.forEach((service, index) => {
155
+ assertApiRailwaySourcePolicy(scope, service);
148
156
  const serviceVar = id("svc", index);
149
157
  const invalidVariables = validateGeneratedVariables(service);
150
158
  if (invalidVariables.length > 0) {
@@ -232,7 +240,15 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
232
240
  const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
233
241
  for (const change of changeSet?.changes ?? []) {
234
242
  const name = changeName(change);
235
- const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[name.replace(/^(service|database)\./u, "")] ?? null;
243
+ const serviceName = name.replace(/^(service|database)\./u, "");
244
+ const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[serviceName] ?? null;
245
+ const sourceRef = desiredNames.serviceSourceRefs?.[name] ?? desiredNames.serviceSourceRefs?.[serviceName] ?? null;
246
+ const sourceChanged = change.kind === "resource.update" && isRailwaySourceChange(change);
247
+ const imageSourceChange = sourceChanged && isRailwayImageSourceChange(change);
248
+ const gitSourceChange = sourceChanged && isRailwayGitSourceChange(change);
249
+ const desiredGitSource = sourceMode === "git" && typeof sourceRef === "string" && sourceRef.startsWith("github:");
250
+ const desiredImageSource = sourceMode === "image" && typeof sourceRef === "string" && sourceRef.startsWith("image:");
251
+ const apiPolicyService = isApiRailwaySourcePolicyService({ serviceName });
236
252
  if (change.kind === "resource.delete") {
237
253
  destructiveChanges.push(change.summary);
238
254
  blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only updates or creates resources. Use the explicit destroy workflow for deletions.`);
@@ -240,12 +256,24 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
240
256
  blockedReasons.push(`Railway IaC plan would delete desired resource ${name}.`);
241
257
  }
242
258
  }
243
- if (desiredNames.scope === "staging" && change.kind === "resource.update" && isRailwayImageSourceChange(change) && (!sourceMode || sourceMode === "image")) {
259
+ if (desiredNames.scope === "staging" && sourceChanged && apiPolicyService && sourceMode === "git" && !gitSourceChange && !desiredGitSource) {
260
+ blockedReasons.push(`Railway IaC plan would change staging API resource ${name} source without confirming a GitHub source.`);
261
+ }
262
+ if (desiredNames.scope === "staging" && sourceChanged && imageSourceChange && !(apiPolicyService && sourceMode === "git" && (gitSourceChange || desiredGitSource))) {
244
263
  blockedReasons.push(`Railway IaC plan would switch staging resource ${name} to an image source.`);
245
264
  }
246
- if (desiredNames.scope === "prod" && change.kind === "resource.update" && isRailwayGitSourceChange(change) && (!sourceMode || sourceMode === "git")) {
265
+ if (desiredNames.scope === "staging" && sourceChanged && (!sourceMode || sourceMode === "image")) {
266
+ blockedReasons.push(`Railway IaC plan would apply an image-backed desired source to staging resource ${name}.`);
267
+ }
268
+ if (desiredNames.scope === "prod" && sourceChanged && apiPolicyService && sourceMode === "image" && !imageSourceChange && !desiredImageSource) {
269
+ blockedReasons.push(`Railway IaC plan would change production API resource ${name} source without confirming an image source.`);
270
+ }
271
+ if (desiredNames.scope === "prod" && sourceChanged && gitSourceChange) {
247
272
  blockedReasons.push(`Railway IaC plan would switch production resource ${name} to a Git source.`);
248
273
  }
274
+ if (desiredNames.scope === "prod" && sourceChanged && (!sourceMode || sourceMode === "git")) {
275
+ blockedReasons.push(`Railway IaC plan would apply a Git-backed desired source to production resource ${name}.`);
276
+ }
249
277
  }
250
278
  return {
251
279
  ok: blockedReasons.length === 0,