@treeseed/sdk 0.12.12 → 0.12.14

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.
@@ -259,6 +259,7 @@ export type TreeseedGuaranteeSceneExecutionInput = {
259
259
  };
260
260
  scenePath: string;
261
261
  record?: boolean;
262
+ artifactMode?: 'full' | 'screenshots';
262
263
  device?: string;
263
264
  };
264
265
  export type TreeseedGuaranteeSceneExecutor = (input: TreeseedGuaranteeSceneExecutionInput) => Promise<TreeseedGuaranteeVerifierExecutionResult>;
@@ -397,6 +398,7 @@ export declare function runTreeseedGuarantees(input: {
397
398
  includePlanned?: boolean;
398
399
  failOnSkippedReleaseGuarantees?: boolean;
399
400
  record?: boolean;
401
+ sceneArtifacts?: 'full' | 'screenshots';
400
402
  device?: string;
401
403
  evidenceTarget?: 'local' | 'ci' | 'release';
402
404
  sceneExecutor?: TreeseedGuaranteeSceneExecutor;
@@ -791,6 +791,7 @@ async function defaultTreeseedGuaranteeSceneExecutor(input) {
791
791
  scene: input.scenePath,
792
792
  environment: input.environment,
793
793
  record: input.record,
794
+ artifactMode: input.artifactMode,
794
795
  mode: "acceptance",
795
796
  devices
796
797
  });
@@ -807,6 +808,7 @@ async function defaultTreeseedGuaranteeSceneExecutor(input) {
807
808
  environment: input.environment,
808
809
  device: input.device ?? devices[0],
809
810
  record: input.record,
811
+ artifactMode: input.artifactMode,
810
812
  mode: "acceptance"
811
813
  });
812
814
  return {
@@ -913,6 +915,7 @@ async function runGuaranteeSteps(input) {
913
915
  guarantee: input.guarantee,
914
916
  scenePath,
915
917
  record: input.record ?? false,
918
+ artifactMode: input.sceneArtifacts,
916
919
  device: input.device
917
920
  }));
918
921
  }
@@ -1052,6 +1055,7 @@ async function runTreeseedGuarantees(input) {
1052
1055
  verifierExecutor: input.verifierExecutor ?? defaultTreeseedGuaranteeVerifierExecutor,
1053
1056
  verifierCache,
1054
1057
  record: input.record,
1058
+ sceneArtifacts: input.sceneArtifacts,
1055
1059
  device: input.device
1056
1060
  }));
1057
1061
  }
@@ -36,28 +36,42 @@ function defaultPlan(input) {
36
36
  function defaultVerify(input) {
37
37
  const hostCapabilities = new Set(input.unit.host.capabilities.filter((capability) => capability.environments.includes(input.environment)).map((capability) => capability.id));
38
38
  const missing = input.unit.requiredCapabilities.filter((capability) => !hostCapabilities.has(capability));
39
+ const checks = [
40
+ {
41
+ key: "host-capabilities",
42
+ label: "Host supports required capabilities",
43
+ ok: missing.length === 0,
44
+ expected: input.unit.requiredCapabilities,
45
+ observed: [...hostCapabilities],
46
+ issues: missing.map((capability) => `Missing host capability: ${capability}`)
47
+ },
48
+ {
49
+ key: "secrets-redacted",
50
+ label: "Secrets are represented by references only",
51
+ ok: !JSON.stringify(input.unit.config).match(/(token|secret|password|key)\s*[:=]\s*[^",}]+/iu),
52
+ expected: "secretRefs",
53
+ observed: input.unit.secretRefs,
54
+ issues: []
55
+ }
56
+ ];
57
+ if (input.unit.host.id === "railway" && input.environment === "prod" && unitConfig(input).sourceMode === "image") {
58
+ const imageRef = unitConfig(input).imageRef;
59
+ const hasImageRef = typeof imageRef === "string" && imageRef.trim().length > 0;
60
+ checks.push({
61
+ key: "railway-image-ref",
62
+ label: "Production Railway service uses an immutable image reference",
63
+ ok: hasImageRef,
64
+ expected: unitConfig(input).imageRefEnv ? `${unitConfig(input).imageRefEnv}=<image>:<tag>` : "<image>:<tag>",
65
+ observed: imageRef ?? null,
66
+ issues: hasImageRef ? [] : [`Production Railway service ${unitConfig(input).serviceName ?? input.unit.id} is image-backed but no image reference was resolved.`]
67
+ });
68
+ }
69
+ const verified = checks.every((check) => check.ok);
39
70
  return {
40
71
  unitId: input.unit.id,
41
- status: missing.length === 0 ? input.observed.status : "blocked",
42
- verified: missing.length === 0,
43
- checks: [
44
- {
45
- key: "host-capabilities",
46
- label: "Host supports required capabilities",
47
- ok: missing.length === 0,
48
- expected: input.unit.requiredCapabilities,
49
- observed: [...hostCapabilities],
50
- issues: missing.map((capability) => `Missing host capability: ${capability}`)
51
- },
52
- {
53
- key: "secrets-redacted",
54
- label: "Secrets are represented by references only",
55
- ok: !JSON.stringify(input.unit.config).match(/(token|secret|password|key)\s*[:=]\s*[^",}]+/iu),
56
- expected: "secretRefs",
57
- observed: input.unit.secretRefs,
58
- issues: []
59
- }
60
- ],
72
+ status: verified ? input.observed.status : "blocked",
73
+ verified,
74
+ checks,
61
75
  warnings: []
62
76
  };
63
77
  }
@@ -89,6 +89,7 @@ export interface TreeseedApplicationHostingProfile {
89
89
  export interface TreeseedHostingGraphInput {
90
90
  tenantRoot: string;
91
91
  environment: TreeseedHostingEnvironment;
92
+ env?: Record<string, string | undefined>;
92
93
  configRoot?: string;
93
94
  appId?: string;
94
95
  deployConfig?: TreeseedDeployConfig;
@@ -302,6 +302,7 @@ function buildProfileFromDeployConfig(input) {
302
302
  } catch {
303
303
  railwayImageRefEnv = {};
304
304
  }
305
+ const launchEnv = mergeRecord(process.env, railwayImageRefEnv, input.env);
305
306
  if (config.surfaces?.web && config.surfaces.web.enabled !== false) {
306
307
  services.push({
307
308
  id: "web",
@@ -334,7 +335,7 @@ function buildProfileFromDeployConfig(input) {
334
335
  const configuredRailwayProjectName = typeof service.railway?.projectName === "string" && service.railway.projectName.trim() ? service.railway.projectName.trim() : null;
335
336
  const defaultProjectGroup = service.provider === "railway" || service.railway ? String(serviceKey).startsWith("capacityProvider") && configuredRailwayProjectName ? capacityProviderProjectGroupId(configuredRailwayProjectName) : "treeseed-control-plane" : void 0;
336
337
  const imageRefEnv = railwayImageRefEnvForService(serviceKey);
337
- const imageRef = service.railway?.imageRef ?? (imageRefEnv ? railwayImageRefEnv[imageRefEnv] ?? process.env[imageRefEnv] : null) ?? defaultRailwayImageRefForService(serviceKey, input.environment) ?? null;
338
+ const imageRef = service.railway?.imageRef ?? (input.environment === "prod" && imageRefEnv ? launchEnv[imageRefEnv] ?? null : null) ?? defaultRailwayImageRefForService(serviceKey, input.environment) ?? null;
338
339
  const sourcePolicy = railwaySourcePolicy(input, serviceKey, service, imageRef);
339
340
  services.push({
340
341
  id: serviceKey,
@@ -1191,6 +1191,7 @@ class DefaultTreeseedOperationsProvider {
1191
1191
  new DoctorOperation("doctor"),
1192
1192
  new InstallOperation("install"),
1193
1193
  new ToolsOperation("tools"),
1194
+ new WorkflowOperation("cleanup"),
1194
1195
  new AuthLoginOperation("auth:login"),
1195
1196
  new AuthLogoutOperation("auth:logout"),
1196
1197
  new AuthWhoAmIOperation("auth:whoami"),
@@ -125,6 +125,73 @@ function selectedServiceKeySet(options) {
125
125
  function serviceIsSelected(selected, serviceKey) {
126
126
  return selected.size === 0 || selected.has(serviceKey);
127
127
  }
128
+ function treeseedDatabaseDescriptors(tenantRoot, options) {
129
+ const descriptors = [];
130
+ const rootConfig = loadTreeseedPlatformConfig({ tenantRoot, environment: options.target, env: process.env }).deployConfig;
131
+ const candidates = [
132
+ { applicationId: "web", applicationRoot: tenantRoot, config: rootConfig },
133
+ ...discoverTreeseedApplications(tenantRoot).map((application) => ({
134
+ applicationId: application.id,
135
+ applicationRoot: application.root,
136
+ config: application.config
137
+ }))
138
+ ];
139
+ for (const candidate of candidates) {
140
+ if (options.appId && options.appId !== candidate.applicationId) continue;
141
+ const service = candidate.config.services?.treeseedDatabase;
142
+ if (!service || service.enabled === false || service.provider !== "railway" || service.railway?.resourceType !== "postgres") {
143
+ continue;
144
+ }
145
+ const serviceName = typeof service.railway?.serviceName === "string" && service.railway.serviceName.trim() ? service.railway.serviceName.trim() : `${candidate.config.slug ?? "treeseed-api"}-postgres`;
146
+ descriptors.push({
147
+ applicationId: candidate.applicationId,
148
+ applicationRoot: candidate.applicationRoot,
149
+ serviceName
150
+ });
151
+ }
152
+ return descriptors;
153
+ }
154
+ async function verifyRailwayPostgresTopology(input) {
155
+ const ownerService = input.configuredServices.find(
156
+ (service) => ["api", "operationsRunner"].includes(service.key) && (!input.descriptor.applicationId || service.application?.id === input.descriptor.applicationId)
157
+ );
158
+ if (!ownerService) {
159
+ input.issues.push(`${input.descriptor.serviceName}: no Railway API or operations runner service is configured to own the database.`);
160
+ return;
161
+ }
162
+ const project = ownerService.projectId ? findByName(input.projects, ownerService.projectId) : findByName(input.projects, ownerService.projectName);
163
+ if (!project?.id) {
164
+ input.issues.push(`${input.descriptor.serviceName}: Railway project ${ownerService.projectName} was not found.`);
165
+ return;
166
+ }
167
+ const environments = await listRailwayEnvironments({ projectId: project.id, env: input.options.env, fetchImpl: input.options.fetchImpl });
168
+ const environment = findByName(environments, ownerService.railwayEnvironment);
169
+ if (!environment?.id) {
170
+ input.issues.push(`${input.descriptor.serviceName}: Railway environment ${ownerService.railwayEnvironment} was not found.`);
171
+ return;
172
+ }
173
+ const [services, volumes] = await Promise.all([
174
+ listRailwayServices({ projectId: project.id, env: input.options.env, fetchImpl: input.options.fetchImpl }),
175
+ listRailwayVolumes({ projectId: project.id, env: input.options.env, fetchImpl: input.options.fetchImpl }).catch(() => [])
176
+ ]);
177
+ const postgresService = findByName(services, input.descriptor.serviceName);
178
+ if (!postgresService?.id) {
179
+ input.issues.push(`${input.descriptor.serviceName}: canonical Railway PostgreSQL service was not found.`);
180
+ }
181
+ const volumeName = `${input.descriptor.serviceName}-volume`;
182
+ const canonicalVolume = volumes.find((volume) => volume.name === volumeName) ?? null;
183
+ if (!canonicalVolume) {
184
+ input.issues.push(`${volumeName}: canonical Railway PostgreSQL volume was not found.`);
185
+ return;
186
+ }
187
+ const activeInstances = activeRailwayVolumeInstances(canonicalVolume);
188
+ const attachedToPostgres = postgresService?.id ? activeInstances.some(
189
+ (instance) => instance.serviceId === postgresService.id && instance.environmentId === environment.id && instance.mountPath === "/var/lib/postgresql/data"
190
+ ) : false;
191
+ if (!attachedToPostgres) {
192
+ input.issues.push(`${volumeName}: canonical Railway PostgreSQL volume is not attached to ${input.descriptor.serviceName} at /var/lib/postgresql/data (states=${railwayVolumeInstanceStates(canonicalVolume)}).`);
193
+ }
194
+ }
128
195
  function resolveLiveProviderEnv(options) {
129
196
  let launchValues = {};
130
197
  try {
@@ -152,7 +219,15 @@ async function collectRailwayObservations(options) {
152
219
  const workspace = await resolveRailwayWorkspaceContext({ env: options.env, fetchImpl: options.fetchImpl });
153
220
  const projects = await listRailwayProjects({ workspaceId: workspace.id, env: options.env, fetchImpl: options.fetchImpl });
154
221
  const configuredServices = configuredRailwayServices(options.tenantRoot, options.target).filter((entry) => !options.appId || entry.application?.id === options.appId).filter((entry) => serviceIsSelected(selectedServiceKeys, entry.key));
222
+ if (selectedServiceKeys.size === 0 || selectedServiceKeys.has("api") || selectedServiceKeys.has("operationsRunner")) {
223
+ for (const descriptor of treeseedDatabaseDescriptors(options.tenantRoot, options)) {
224
+ await verifyRailwayPostgresTopology({ descriptor, configuredServices, projects, options, issues });
225
+ }
226
+ }
155
227
  for (const service of configuredServices) {
228
+ if (options.target === "prod" && service.sourceMode === "image" && !service.imageRef) {
229
+ issues.push(`${service.serviceName}: production Railway service is configured for image deployment but no immutable image ref is resolved.`);
230
+ }
156
231
  const project = service.projectId ? findByName(projects, service.projectId) : findByName(projects, service.projectName);
157
232
  if (!project?.id) {
158
233
  issues.push(`${service.serviceName}: Railway project ${service.projectName} was not found.`);
@@ -172,6 +247,9 @@ async function collectRailwayObservations(options) {
172
247
  if (isRetainedDetachedRailwayVolume(volume.name)) {
173
248
  continue;
174
249
  }
250
+ if (String(volume.name ?? "").endsWith("-postgres-volume") && activeRailwayVolumeInstances(volume).length === 0) {
251
+ issues.push(`${volume.name ?? volume.id}: detached PostgreSQL volume remains in Railway project ${project.name} (states=${railwayVolumeInstanceStates(volume)}).`);
252
+ }
175
253
  const detachedPostgresInstances = activeRailwayVolumeInstances(volume).filter(
176
254
  (instance2) => instance2.environmentId === environment.id && instance2.mountPath === "/var/lib/postgresql/data" && !instance2.serviceId
177
255
  );
@@ -0,0 +1,29 @@
1
+ export type TreeseedLocalCleanupMode = 'standard' | 'aggressive';
2
+ export type TreeseedLocalCleanupAction = {
3
+ id: string;
4
+ kind: 'directory' | 'docker' | 'npm-cache';
5
+ path?: string;
6
+ command?: string[];
7
+ status: 'removed' | 'skipped' | 'failed';
8
+ beforeBytes?: number;
9
+ afterBytes?: number;
10
+ exitCode?: number | null;
11
+ error?: string;
12
+ };
13
+ export type TreeseedLocalCleanupReport = {
14
+ ok: boolean;
15
+ mode: TreeseedLocalCleanupMode;
16
+ root: string;
17
+ startedAt: string;
18
+ completedAt: string;
19
+ beforeBytes: number;
20
+ afterBytes: number;
21
+ reclaimedBytes: number;
22
+ actions: TreeseedLocalCleanupAction[];
23
+ };
24
+ export declare function runTreeseedLocalCleanup(input: {
25
+ root: string;
26
+ mode?: TreeseedLocalCleanupMode;
27
+ docker?: boolean;
28
+ npmCache?: boolean;
29
+ }): TreeseedLocalCleanupReport;
@@ -0,0 +1,62 @@
1
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ function directoryBytes(path) {
5
+ if (!existsSync(path)) return 0;
6
+ const stat = statSync(path, { throwIfNoEntry: false });
7
+ if (!stat) return 0;
8
+ if (!stat.isDirectory()) return stat.size;
9
+ let total = stat.size;
10
+ for (const entry of readdirSync(path)) total += directoryBytes(join(path, entry));
11
+ return total;
12
+ }
13
+ function removeDirectory(root, relativePath) {
14
+ const path = join(root, relativePath);
15
+ const beforeBytes = directoryBytes(path);
16
+ if (!existsSync(path)) return { id: relativePath, kind: "directory", path, status: "skipped", beforeBytes: 0, afterBytes: 0 };
17
+ try {
18
+ rmSync(path, { recursive: true, force: true });
19
+ return { id: relativePath, kind: "directory", path, status: "removed", beforeBytes, afterBytes: directoryBytes(path) };
20
+ } catch (error) {
21
+ return { id: relativePath, kind: "directory", path, status: "failed", beforeBytes, afterBytes: directoryBytes(path), error: error instanceof Error ? error.message : String(error) };
22
+ }
23
+ }
24
+ function runCleanupCommand(id, kind, command, cwd) {
25
+ const result = spawnSync(command[0], command.slice(1), { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 * 16 });
26
+ const exitCode = result.status ?? null;
27
+ return {
28
+ id,
29
+ kind,
30
+ command,
31
+ status: exitCode === 0 ? "removed" : "failed",
32
+ exitCode,
33
+ ...exitCode === 0 ? {} : { error: (result.stderr || result.stdout || result.error?.message || "cleanup command failed").trim() }
34
+ };
35
+ }
36
+ function runTreeseedLocalCleanup(input) {
37
+ const root = resolve(input.root);
38
+ const mode = input.mode ?? "standard";
39
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
40
+ const beforeBytes = directoryBytes(join(root, ".treeseed"));
41
+ const actions = [];
42
+ const directoryTargets = mode === "aggressive" ? [
43
+ ".treeseed/tmp",
44
+ ".treeseed/cache",
45
+ ".treeseed/scenes/runs",
46
+ ".treeseed/scenes/matrix",
47
+ ".treeseed/scenes/render",
48
+ ".treeseed/guarantees/runs",
49
+ ".treeseed/guarantees/release",
50
+ ".treeseed/generated/release-evidence",
51
+ ".treeseed/release/evidence"
52
+ ] : [".treeseed/tmp", ".treeseed/cache", ".treeseed/scenes/render"];
53
+ for (const target of directoryTargets) actions.push(removeDirectory(root, target));
54
+ if (input.docker !== false && mode === "aggressive") actions.push(runCleanupCommand("docker-system-prune", "docker", ["docker", "system", "prune", "--all", "--volumes", "--force"], root));
55
+ if (input.npmCache !== false) actions.push(runCleanupCommand("npm-cache-clean", "npm-cache", ["npm", "cache", "clean", "--force"], root));
56
+ const afterBytes = directoryBytes(join(root, ".treeseed"));
57
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
58
+ return { ok: actions.every((entry) => entry.status !== "failed"), mode, root, startedAt, completedAt, beforeBytes, afterBytes, reclaimedBytes: Math.max(0, beforeBytes - afterBytes), actions };
59
+ }
60
+ export {
61
+ runTreeseedLocalCleanup
62
+ };
@@ -115,7 +115,8 @@ async function runTreeseedOperationsRunnerSmoke(options) {
115
115
  const serviceSecret = options.serviceSecret ?? localServiceSecret ?? value("TREESEED_ACCEPTANCE_SERVICE_SECRET", values, env) ?? value("TREESEED_WEB_SERVICE_SECRET", values, env) ?? value("TREESEED_API_WEB_SERVICE_SECRET", values, env);
116
116
  const timings = [];
117
117
  const fetchImpl = options.fetchImpl ?? fetch;
118
- const timeoutMs = Math.max(1e3, Math.floor(options.timeoutMs ?? (options.environment === "prod" ? 12e4 : 9e4)));
118
+ const defaultTimeoutMs = options.environment === "local" ? 9e4 : 6e5;
119
+ const timeoutMs = Math.max(1e3, Math.floor(options.timeoutMs ?? defaultTimeoutMs));
119
120
  const pollMs = Math.max(1e3, Math.floor(options.pollMs ?? 3e3));
120
121
  if (!serviceSecret) {
121
122
  return failure(baseUrl, options.environment, ["Missing API service credential for runner smoke."], timings);
@@ -287,7 +287,7 @@ export declare function getRailwayServiceInstance({ serviceId, environmentId, en
287
287
  sleepApplication: null;
288
288
  runtimeConfigSupported: false;
289
289
  }>;
290
- export declare function ensureRailwayServiceInstanceConfiguration({ serviceId, environmentId, buildCommand, dockerfilePath, railwayConfigFile, startCommand, cronSchedule, rootDirectory, healthcheckPath, healthcheckTimeoutSeconds, healthcheckIntervalSeconds, restartPolicy, runtimeMode, deploymentRegion, env, fetchImpl, settleAttempts, settleDelayMs, }: {
290
+ export declare function ensureRailwayServiceInstanceConfiguration({ serviceId, environmentId, buildCommand, dockerfilePath, railwayConfigFile, startCommand, cronSchedule, rootDirectory, healthcheckPath, healthcheckTimeoutSeconds, healthcheckIntervalSeconds, restartPolicy, runtimeMode, deploymentRegion, clearSourceConfiguration, env, fetchImpl, settleAttempts, settleDelayMs, }: {
291
291
  serviceId: string;
292
292
  environmentId: string;
293
293
  buildCommand?: string | null;
@@ -302,6 +302,7 @@ export declare function ensureRailwayServiceInstanceConfiguration({ serviceId, e
302
302
  restartPolicy?: string | null;
303
303
  runtimeMode?: string | null;
304
304
  deploymentRegion?: string | null;
305
+ clearSourceConfiguration?: boolean;
305
306
  env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
306
307
  fetchImpl?: typeof fetch;
307
308
  settleAttempts?: number;
@@ -343,6 +344,8 @@ export declare function ensureRailwayServiceInstanceConfiguration({ serviceId, e
343
344
  instance: {
344
345
  id: string;
345
346
  buildCommand: string | null;
347
+ dockerfilePath: string | null;
348
+ railwayConfigFile: string | null;
346
349
  startCommand: string | null;
347
350
  cronSchedule: string | null;
348
351
  rootDirectory: string | null;
@@ -1476,6 +1476,7 @@ async function ensureRailwayServiceInstanceConfiguration({
1476
1476
  restartPolicy,
1477
1477
  runtimeMode,
1478
1478
  deploymentRegion,
1479
+ clearSourceConfiguration = false,
1479
1480
  env = process.env,
1480
1481
  fetchImpl = fetch,
1481
1482
  settleAttempts = 60,
@@ -1518,7 +1519,7 @@ async function ensureRailwayServiceInstanceConfiguration({
1518
1519
  if (desired.restartPolicy !== null) {
1519
1520
  throw new Error("Railway service instance restart policies are unsupported by the current Railway API schema.");
1520
1521
  }
1521
- const drifted = desired.buildCommand !== null && desired.buildCommand !== current.buildCommand || desired.dockerfilePath !== null && desired.dockerfilePath !== current.dockerfilePath || desired.railwayConfigFile !== null && desired.railwayConfigFile !== current.railwayConfigFile || desired.startCommand !== null && desired.startCommand !== current.startCommand || desired.cronSchedule !== null && desired.cronSchedule !== current.cronSchedule || desired.rootDirectory !== null && desired.rootDirectory !== current.rootDirectory || desired.healthcheckPath !== null && desired.healthcheckPath !== current.healthcheckPath || desired.healthcheckTimeoutSeconds !== null && desired.healthcheckTimeoutSeconds !== current.healthcheckTimeoutSeconds || desired.runtimeMode !== null && desired.runtimeMode !== current.runtimeMode || desired.deploymentRegion !== null;
1522
+ const drifted = (desired.buildCommand !== null || clearSourceConfiguration) && desired.buildCommand !== current.buildCommand || (desired.dockerfilePath !== null || clearSourceConfiguration) && desired.dockerfilePath !== current.dockerfilePath || (desired.railwayConfigFile !== null || clearSourceConfiguration) && desired.railwayConfigFile !== current.railwayConfigFile || (desired.startCommand !== null || clearSourceConfiguration) && desired.startCommand !== current.startCommand || desired.cronSchedule !== null && desired.cronSchedule !== current.cronSchedule || (desired.rootDirectory !== null || clearSourceConfiguration) && desired.rootDirectory !== current.rootDirectory || desired.healthcheckPath !== null && desired.healthcheckPath !== current.healthcheckPath || desired.healthcheckTimeoutSeconds !== null && desired.healthcheckTimeoutSeconds !== current.healthcheckTimeoutSeconds || desired.runtimeMode !== null && desired.runtimeMode !== current.runtimeMode || desired.deploymentRegion !== null;
1522
1523
  if (!drifted) {
1523
1524
  return { instance: current, updated: false };
1524
1525
  }
@@ -1538,12 +1539,12 @@ mutation TreeseedRailwayServiceInstanceUpdateLegacy($serviceId: String!, $enviro
1538
1539
  serviceId,
1539
1540
  environmentId,
1540
1541
  input: {
1541
- ...desired.buildCommand !== null ? { buildCommand: desired.buildCommand } : {},
1542
- ...desired.dockerfilePath !== null ? { dockerfilePath: desired.dockerfilePath } : {},
1543
- ...desired.railwayConfigFile !== null ? { railwayConfigFile: desired.railwayConfigFile } : {},
1544
- ...desired.startCommand !== null ? { startCommand: desired.startCommand } : {},
1542
+ ...desired.buildCommand !== null || clearSourceConfiguration && current.buildCommand !== null ? { buildCommand: desired.buildCommand } : {},
1543
+ ...desired.dockerfilePath !== null || clearSourceConfiguration && current.dockerfilePath !== null ? { dockerfilePath: desired.dockerfilePath } : {},
1544
+ ...desired.railwayConfigFile !== null || clearSourceConfiguration && current.railwayConfigFile !== null ? { railwayConfigFile: desired.railwayConfigFile } : {},
1545
+ ...desired.startCommand !== null || clearSourceConfiguration && current.startCommand !== null ? { startCommand: desired.startCommand } : {},
1545
1546
  ...desired.cronSchedule !== null ? { cronSchedule: desired.cronSchedule } : {},
1546
- ...desired.rootDirectory !== null ? { rootDirectory: desired.rootDirectory } : {},
1547
+ ...desired.rootDirectory !== null || clearSourceConfiguration && current.rootDirectory !== null ? { rootDirectory: desired.rootDirectory } : {},
1547
1548
  ...desired.healthcheckPath !== null ? { healthcheckPath: desired.healthcheckPath } : {},
1548
1549
  ...desired.healthcheckTimeoutSeconds !== null ? { healthcheckTimeout: desired.healthcheckTimeoutSeconds } : {},
1549
1550
  ...desired.sleepApplication !== null ? { sleepApplication: desired.sleepApplication } : {},
@@ -1572,7 +1573,7 @@ mutation TreeseedRailwayServiceInstanceUpdateLegacy($serviceId: String!, $enviro
1572
1573
  env,
1573
1574
  fetchImpl
1574
1575
  });
1575
- if (!serviceInstanceDrifted(instance, desired) || attempt >= settleAttempts) {
1576
+ if (!serviceInstanceDrifted(instance, desired, clearSourceConfiguration) || attempt >= settleAttempts) {
1576
1577
  break;
1577
1578
  }
1578
1579
  await new Promise((resolve) => setTimeout(resolve, settleDelayMs));
@@ -1581,6 +1582,8 @@ mutation TreeseedRailwayServiceInstanceUpdateLegacy($serviceId: String!, $enviro
1581
1582
  instance: {
1582
1583
  id: instance.id || current.id,
1583
1584
  buildCommand: instance.buildCommand,
1585
+ dockerfilePath: instance.dockerfilePath,
1586
+ railwayConfigFile: instance.railwayConfigFile,
1584
1587
  startCommand: instance.startCommand,
1585
1588
  cronSchedule: instance.cronSchedule,
1586
1589
  rootDirectory: instance.rootDirectory,
@@ -1595,8 +1598,8 @@ mutation TreeseedRailwayServiceInstanceUpdateLegacy($serviceId: String!, $enviro
1595
1598
  updated: true
1596
1599
  };
1597
1600
  }
1598
- function serviceInstanceDrifted(current, desired) {
1599
- return desired.buildCommand !== null && desired.buildCommand !== current.buildCommand || desired.startCommand !== null && desired.startCommand !== current.startCommand || desired.cronSchedule !== null && desired.cronSchedule !== current.cronSchedule || desired.rootDirectory !== null && desired.rootDirectory !== current.rootDirectory || desired.healthcheckPath !== null && desired.healthcheckPath !== current.healthcheckPath || desired.healthcheckTimeoutSeconds !== null && desired.healthcheckTimeoutSeconds !== current.healthcheckTimeoutSeconds || desired.runtimeMode !== null && desired.runtimeMode !== current.runtimeMode;
1601
+ function serviceInstanceDrifted(current, desired, clearSourceConfiguration = false) {
1602
+ return (desired.buildCommand !== null || clearSourceConfiguration) && desired.buildCommand !== current.buildCommand || (desired.dockerfilePath !== null && desired.dockerfilePath !== void 0 || clearSourceConfiguration) && (desired.dockerfilePath ?? null) !== current.dockerfilePath || (desired.railwayConfigFile !== null && desired.railwayConfigFile !== void 0 || clearSourceConfiguration) && (desired.railwayConfigFile ?? null) !== current.railwayConfigFile || (desired.startCommand !== null || clearSourceConfiguration) && desired.startCommand !== current.startCommand || desired.cronSchedule !== null && desired.cronSchedule !== current.cronSchedule || (desired.rootDirectory !== null || clearSourceConfiguration) && desired.rootDirectory !== current.rootDirectory || desired.healthcheckPath !== null && desired.healthcheckPath !== current.healthcheckPath || desired.healthcheckTimeoutSeconds !== null && desired.healthcheckTimeoutSeconds !== current.healthcheckTimeoutSeconds || desired.runtimeMode !== null && desired.runtimeMode !== current.runtimeMode;
1600
1603
  }
1601
1604
  async function listRailwayVariables({
1602
1605
  projectId,
@@ -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): unknown[];
62
+ export declare function configuredRailwayServices(tenantRoot: any, scope: any, envOverlay?: {}): unknown[];
63
63
  export declare function configuredRailwayScheduledJobs(tenantRoot: any, scope: any, { phase }?: {
64
64
  phase?: string | undefined;
65
65
  }): any[];
@@ -81,6 +81,9 @@ function railwayImageRefEnvForService(serviceKey) {
81
81
  return null;
82
82
  }
83
83
  function defaultRailwayImageRef(serviceKey, scope = "staging", env = process.env) {
84
+ if (normalizeScope(scope) !== "prod" && serviceKey !== "capacityProviderManager" && serviceKey !== "capacityProviderRunner") {
85
+ return null;
86
+ }
84
87
  if (serviceKey === "api") {
85
88
  return envValue("TREESEED_API_IMAGE_REF", env) || null;
86
89
  }
@@ -611,13 +614,14 @@ query TreeseedRailwayDeploymentStatus($projectId: String!) {
611
614
  });
612
615
  return payload.data?.project ?? null;
613
616
  }
614
- function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot) {
617
+ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot, envOverlay = {}) {
615
618
  const normalizedScope = normalizeScope(scope);
616
619
  const imageRefKeys = [
617
620
  "TREESEED_API_IMAGE_REF",
618
621
  "TREESEED_OPERATIONS_RUNNER_IMAGE_REF",
619
622
  "TREESEED_AGENT_MANAGER_IMAGE_REF",
620
- "TREESEED_AGENT_RUNNER_IMAGE_REF"
623
+ "TREESEED_AGENT_RUNNER_IMAGE_REF",
624
+ "TREESEED_PUBLIC_TREEDX_IMAGE_REF"
621
625
  ];
622
626
  let machineEnv = {};
623
627
  try {
@@ -625,7 +629,7 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
625
629
  } catch {
626
630
  machineEnv = {};
627
631
  }
628
- const imageRefEnv = { ...process.env, ...machineEnv };
632
+ const imageRefEnv = { ...process.env, ...machineEnv, ...envOverlay };
629
633
  let identity;
630
634
  try {
631
635
  identity = resolveTreeseedResourceIdentity(deployConfig, createPersistentDeployTarget(normalizedScope));
@@ -667,7 +671,8 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
667
671
  const runnerIndex = offset + 1;
668
672
  const serviceName = serviceKey === "operationsRunner" ? deriveRailwayOperationsRunnerServiceName(configuredServiceName, runnerIndex) : serviceKey === "capacityProviderRunner" ? deriveRailwayCapacityProviderRunnerServiceName(configuredServiceName, runnerIndex) : configuredServiceName;
669
673
  const configuredImageRefEnv = service.railway?.imageRefEnv ?? railwayImageRefEnvForService(serviceKey);
670
- const imageRef = service.railway?.imageRef ?? (configuredImageRefEnv ? envValue(configuredImageRefEnv, imageRefEnv) || null : null) ?? defaultRailwayImageRef(serviceKey, normalizedScope, imageRefEnv);
674
+ const canUseImageRefEnv = normalizedScope === "prod" || serviceKey === "capacityProviderManager" || serviceKey === "capacityProviderRunner";
675
+ const imageRef = service.railway?.imageRef ?? (canUseImageRefEnv && configuredImageRefEnv ? envValue(configuredImageRefEnv, imageRefEnv) || null : null) ?? defaultRailwayImageRef(serviceKey, normalizedScope, imageRefEnv);
671
676
  const sourcePolicy = resolveRailwayServiceSourcePolicy({
672
677
  tenantRoot,
673
678
  scope: normalizedScope,
@@ -882,9 +887,9 @@ function resolveRailwayCapacityProviderRoot(tenantRoot, service) {
882
887
  );
883
888
  return found ?? resolve(tenantRoot, "packages", "agent");
884
889
  }
885
- function configuredRailwayServices(tenantRoot, scope) {
890
+ function configuredRailwayServices(tenantRoot, scope, envOverlay = {}) {
886
891
  const deployConfig = loadCliDeployConfig(tenantRoot);
887
- const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig);
892
+ const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, null, tenantRoot, envOverlay);
888
893
  const nested = discoverTreeseedApplications(tenantRoot).filter((application) => application.root !== resolve(tenantRoot)).flatMap((application) => configuredRailwayServicesForConfig(
889
894
  application.root,
890
895
  scope,
@@ -894,7 +899,8 @@ function configuredRailwayServices(tenantRoot, scope) {
894
899
  root: application.root,
895
900
  relativeRoot: application.relativeRoot
896
901
  },
897
- tenantRoot
902
+ tenantRoot,
903
+ envOverlay
898
904
  ));
899
905
  return [...direct, ...nested];
900
906
  }
@@ -22,6 +22,7 @@ const TRESEED_OPERATION_SPECS = [
22
22
  operation({ id: "workspace.doctor", name: "doctor", aliases: [], group: "Validation", summary: "Diagnose Treeseed tooling, auth, and workflow readiness.", description: "Collect doctor-style diagnostics for workspace readiness and optional safe repairs.", provider: "default", related: ["status", "config"] }),
23
23
  operation({ id: "workspace.install", name: "install", aliases: [], group: "Utilities", summary: "Install Treeseed-managed local dependencies.", description: "Install or repair Treeseed-managed CLI dependencies including GitHub CLI, Wrangler, Railway, Copilot, and optional gh-act support.", provider: "default", related: ["config", "doctor"] }),
24
24
  operation({ id: "workspace.tools", name: "tools", aliases: [], group: "Utilities", summary: "Report Treeseed-managed executable locations.", description: "Inspect Treeseed-managed executable paths, invocation commands, cache roots, and GitHub CLI authentication state without installing or mutating tools.", provider: "default", related: ["install", "doctor"] }),
25
+ operation({ id: "workspace.cleanup", name: "cleanup", aliases: [], group: "Utilities", summary: "Prune local generated artifacts and package caches before long verification.", description: "Remove local Treeseed temporary/cache directories, generated scene and release evidence, npm cache, and optional Docker images and volumes.", provider: "default", related: ["save", "stage", "release"] }),
25
26
  operation({ id: "auth.login", name: "auth:login", aliases: [], group: "Validation", summary: "Authenticate against the configured Treeseed API.", description: "Start the device login flow against the active Treeseed API host and persist the returned session locally.", provider: "default", related: ["auth:check", "auth:whoami", "auth:logout"] }),
26
27
  operation({ id: "auth.logout", name: "auth:logout", aliases: [], group: "Validation", summary: "Clear locally stored Treeseed API credentials.", description: "Remove the persisted local device-flow session for the active Treeseed API host.", provider: "default", related: ["auth:login", "auth:whoami"] }),
27
28
  operation({ id: "auth.whoami", name: "auth:whoami", aliases: [], group: "Validation", summary: "Inspect the active Treeseed API identity.", description: "Use the persisted local remote session to query the active Treeseed API principal.", provider: "default", related: ["auth:login", "status"] }),
@@ -3477,6 +3477,7 @@ async function resolveRailwayTopologyForScope(input, scope, {
3477
3477
  healthcheckIntervalSeconds: service.healthcheckIntervalSeconds,
3478
3478
  restartPolicy: service.restartPolicy,
3479
3479
  runtimeMode: service.runtimeMode,
3480
+ clearSourceConfiguration: service.sourceMode === "image" || Boolean(service.imageRef),
3480
3481
  env
3481
3482
  })).instance;
3482
3483
  } else {
@@ -3534,6 +3535,15 @@ function railwayProviderDrift(input, scope) {
3534
3535
  const current = input.context.session.get(railwayDriftSessionKey(scope));
3535
3536
  return Array.isArray(current) ? current : [];
3536
3537
  }
3538
+ function configuredRailwayServicesForInput(input, scope) {
3539
+ return configuredRailwayServices(input.context.tenantRoot, scope, resolveReconcileEnvironmentValues(input, scope));
3540
+ }
3541
+ function assertNoBlockedRailwayProviderDrift(input, scope) {
3542
+ const blocked = railwayProviderDrift(input, scope).filter((drift) => drift.status === "blocked" || drift.action === "blocked" || drift.action === "manual-repair-required");
3543
+ if (blocked.length === 0) return;
3544
+ const reasons = blocked.map((drift) => String(drift.reason ?? drift.kind ?? "unknown Railway drift"));
3545
+ throw new Error(`Railway provider drift blocks reconciliation: ${reasons.join("; ")}`);
3546
+ }
3537
3547
  function activeRailwayVolumeInstances(volume) {
3538
3548
  const instances = Array.isArray(volume.instances) ? volume.instances : [];
3539
3549
  return instances.filter((instance) => {
@@ -3550,7 +3560,7 @@ function railwayServiceMatchesKey(service, key) {
3550
3560
  return service.key === key || service.instanceKey === key || service.serviceName === key || service.serviceId === key;
3551
3561
  }
3552
3562
  function configuredRailwayProjectSyncGroups(input, scope, serviceKeys) {
3553
- const allServices = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service));
3563
+ const allServices = configuredRailwayServicesForInput(input, scope).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service));
3554
3564
  const selected = Array.isArray(serviceKeys) && serviceKeys.length > 0 ? allServices.filter((service) => serviceKeys.some((key) => railwayServiceMatchesKey(service, key))) : allServices;
3555
3565
  const selectedProjectKeys = new Set(selected.map((service) => `${service.projectName}:::${service.railwayEnvironment}`));
3556
3566
  const grouped = /* @__PURE__ */ new Map();
@@ -3572,6 +3582,9 @@ function railwayIacServiceInput(input, sync, service, scope) {
3572
3582
  if (scope === "prod" && service.sourceMode === "git") {
3573
3583
  throw new Error(`Railway production service ${service.serviceName} must deploy an immutable image, not Git source.`);
3574
3584
  }
3585
+ if (scope === "prod" && service.sourceMode === "image" && !service.imageRef) {
3586
+ throw new Error(`Railway production service ${service.serviceName} must deploy an immutable image, but no image reference was resolved.`);
3587
+ }
3575
3588
  const serviceSync = sync.forService(service.key, service);
3576
3589
  const deployVariablePrefix = service.serviceName.includes("treedx") || service.key.includes("treedx") ? "TREEDX" : "TREESEED";
3577
3590
  const sourceVariables = service.sourceMode === "git" ? {
@@ -3648,7 +3661,7 @@ function blockedPendingRailwayVolumeAttachments(volumes, serviceIdByName, enviro
3648
3661
  }
3649
3662
  async function reconcileStaleOperationsRunnerResourcesForScope(input, topology) {
3650
3663
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
3651
- const desiredRunners = configuredRailwayServices(input.context.tenantRoot, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false);
3664
+ const desiredRunners = configuredRailwayServicesForInput(input, scope).filter((service) => service.key === "operationsRunner").filter((service) => service.enabled !== false);
3652
3665
  const desiredServiceNames = new Set(desiredRunners.map((service) => service.serviceName).filter(Boolean));
3653
3666
  const desiredVolumeNames = new Set([...desiredServiceNames].map((serviceName) => `${serviceName}-volume`));
3654
3667
  if (desiredServiceNames.size === 0) {
@@ -3763,6 +3776,9 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKe
3763
3776
  const project = resolvedEntry?.project;
3764
3777
  const environment = resolvedEntry?.environment;
3765
3778
  traceRailwayReconcile(topology.env, "sync:topology", `project-services=${projectServices.map((service) => service.serviceName).join(",")}`);
3779
+ for (const service of projectServices) {
3780
+ railwayIacServiceInput(input, sync, service, scope);
3781
+ }
3766
3782
  if (dryRun) {
3767
3783
  syncedServices.push(...projectServices);
3768
3784
  continue;
@@ -3805,7 +3821,7 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKe
3805
3821
  }));
3806
3822
  const databaseLiveService = databaseDescriptor ? liveServiceByName.get(databaseDescriptor.serviceName) ?? project.services.find((candidate) => candidate.name === databaseDescriptor.serviceName) ?? null : null;
3807
3823
  const databaseDetachVolumeIds = databaseDescriptor ? activeAttachedRailwayVolumeIds(liveVolumes, databaseLiveService?.id, environment.id, `${databaseDescriptor.serviceName}-volume`) : [];
3808
- const databaseForIac = databaseDescriptor ? { ...databaseDescriptor, detachVolumeIds: databaseDetachVolumeIds, useNativePostgres: Boolean(databaseLiveService) } : null;
3824
+ const databaseForIac = databaseDescriptor ? { ...databaseDescriptor, detachVolumeIds: databaseDetachVolumeIds, useNativePostgres: false } : null;
3809
3825
  const token = String(topology.env.TREESEED_RAILWAY_API_TOKEN ?? topology.env.RAILWAY_API_TOKEN ?? "").trim();
3810
3826
  if (!token) {
3811
3827
  throw new Error(`Railway IaC reconciliation requires TREESEED_RAILWAY_API_TOKEN for ${project.name}/${environment.name}.`);
@@ -3854,6 +3870,7 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKe
3854
3870
  const diagnostics = apply.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
3855
3871
  throw new Error(`Railway IaC apply failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
3856
3872
  }
3873
+ assertNoBlockedRailwayProviderDrift(input, scope);
3857
3874
  syncedServices.push(...projectServices);
3858
3875
  } finally {
3859
3876
  cleanupRailwayIacRender(rendered);
@@ -4319,7 +4336,7 @@ async function observeRailwayUnit(input, { refresh = false } = {}) {
4319
4336
  function collectRailwayEnvironmentSync(input, valuesOverlay = {}) {
4320
4337
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
4321
4338
  const registry = collectTreeseedEnvironmentContext(input.context.tenantRoot);
4322
- const configuredServices = configuredRailwayServices(input.context.tenantRoot, scope);
4339
+ const configuredServices = configuredRailwayServicesForInput(input, scope);
4323
4340
  const hasPublicTreeDxService = configuredServices.some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
4324
4341
  const machineValues = hasPublicTreeDxService ? normalizeEnvironmentValues(resolveTreeseedMachineEnvironmentValues(input.context.tenantRoot, scope)) : {};
4325
4342
  const baseValues = {
@@ -4405,7 +4422,12 @@ function collectRailwayEnvironmentSync(input, valuesOverlay = {}) {
4405
4422
  ...serviceVariables,
4406
4423
  ...serviceRefVariables,
4407
4424
  ...Object.fromEntries(entriesForService("railway-var", serviceKey)),
4408
- ...serviceKey === "operationsRunner" ? { TREESEED_MANAGER_ID: scope } : {},
4425
+ ...serviceKey === "operationsRunner" ? {
4426
+ TREESEED_MANAGER_ID: scope,
4427
+ TREESEED_PLATFORM_RUNNER_ID: configuredService?.runnerId ?? configuredService?.serviceName ?? "treeseed-api-operations-runner-01",
4428
+ TREESEED_PLATFORM_RUNNER_DATA_DIR: configuredService?.volumeMountPath ?? "/data",
4429
+ TREESEED_PLATFORM_RUNNER_ENVIRONMENT: scope === "prod" ? "production" : scope
4430
+ } : {},
4409
4431
  ...serviceKey === "api" ? apiOnlyVariables : {},
4410
4432
  ...capacityVariables
4411
4433
  }
@@ -4451,7 +4473,7 @@ function capacityProviderSecretsForService(values, serviceKey) {
4451
4473
  return apiKey ? { TREESEED_CAPACITY_PROVIDER_API_KEY: apiKey } : {};
4452
4474
  }
4453
4475
  function ensureCapacityProviderApiKeyForRailwaySync(input, scope) {
4454
- const hasCapacityProviderService = configuredRailwayServices(input.context.tenantRoot, scope).some((service) => String(service.key).startsWith("capacityProvider") && service.enabled !== false);
4476
+ const hasCapacityProviderService = configuredRailwayServicesForInput(input, scope).some((service) => String(service.key).startsWith("capacityProvider") && service.enabled !== false);
4455
4477
  if (!hasCapacityProviderService) return null;
4456
4478
  const values = resolveReconcileEnvironmentValues(input, scope);
4457
4479
  const existing = String(values.TREESEED_CAPACITY_PROVIDER_API_KEY ?? "").trim();
@@ -4475,7 +4497,7 @@ function ensureCapacityProviderApiKeyForRailwaySync(input, scope) {
4475
4497
  return generated;
4476
4498
  }
4477
4499
  function ensurePublicTreeDxSecretsForRailwaySync(input, scope, values, registry) {
4478
- const hasPublicTreeDxService = configuredRailwayServices(input.context.tenantRoot, scope).some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
4500
+ const hasPublicTreeDxService = configuredRailwayServicesForInput(input, scope).some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
4479
4501
  if (!hasPublicTreeDxService) return {};
4480
4502
  const generated = {};
4481
4503
  const specs = [
@@ -4937,6 +4959,16 @@ async function verifyRailwayUnit(input) {
4937
4959
  issues: entry.instance?.id ? [] : [`Railway service instance for ${service.serviceName ?? service.key} in ${service.railwayEnvironment} is missing.`]
4938
4960
  })
4939
4961
  ];
4962
+ if (service.sourceMode === "image") {
4963
+ const hasImageRef = typeof service.imageRef === "string" && service.imageRef.trim().length > 0;
4964
+ checks.push(verificationCheck("railway.instance.image-ref", "Railway image source has an immutable image reference", "sdk", {
4965
+ exists: hasImageRef,
4966
+ configured: hasImageRef,
4967
+ expected: service.imageRefEnv ? `${service.imageRefEnv}=<image>:<tag>` : "<image>:<tag>",
4968
+ observed: service.imageRef ?? null,
4969
+ issues: hasImageRef ? [] : [`Railway production service ${service.serviceName ?? service.key} is configured for image deployment but no image reference was resolved.`]
4970
+ }));
4971
+ }
4940
4972
  if (service.startCommand) {
4941
4973
  const startCommandMatches = railwayStartCommandMatches(serviceKey, entry.instance?.startCommand, service.startCommand);
4942
4974
  checks.push(verificationCheck("railway.instance.start-command", "Railway start command matches desired config", "api", {
@@ -5129,7 +5161,7 @@ function buildRailwayDiff(input, observed) {
5129
5161
  async function reconcileRailwayUnit(input, diff) {
5130
5162
  const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
5131
5163
  const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
5132
- const configuredService = configuredRailwayServices(input.context.tenantRoot, scope).find((candidate) => candidate.key === serviceKey || candidate.instanceKey === serviceKey || candidate.serviceName === serviceKey) ?? null;
5164
+ const configuredService = configuredRailwayServicesForInput(input, scope).find((candidate) => candidate.key === serviceKey || candidate.instanceKey === serviceKey || candidate.serviceName === serviceKey) ?? null;
5133
5165
  const requiresProjectLevelSync = Boolean(configuredService);
5134
5166
  const serviceKeys = serviceKey && !requiresProjectLevelSync ? [serviceKey] : void 0;
5135
5167
  const cacheKey = requiresProjectLevelSync ? `railway:sync:${scope}:project:${configuredService?.projectName ?? "default"}:${configuredService?.railwayEnvironment ?? scope}` : `railway:sync:${scope}:${serviceKey || "all"}`;
@@ -1,7 +1,8 @@
1
1
  import type { TreeseedDesiredUnit, TreeseedReconcileTarget } from './contracts.js';
2
- export declare function deriveTreeseedDesiredUnits({ tenantRoot, target, }: {
2
+ export declare function deriveTreeseedDesiredUnits({ tenantRoot, target, env, }: {
3
3
  tenantRoot: string;
4
4
  target: TreeseedReconcileTarget;
5
+ env?: NodeJS.ProcessEnv;
5
6
  }): {
6
7
  deployConfig: import("../platform/contracts.js").TreeseedDeployConfig;
7
8
  legacyState: any;
@@ -26,9 +26,10 @@ function isPublicTreeDxNodeServiceKey(serviceKey) {
26
26
  }
27
27
  function deriveTreeseedDesiredUnits({
28
28
  tenantRoot,
29
- target
29
+ target,
30
+ env = process.env
30
31
  }) {
31
- const deployConfig = loadTreeseedPlatformConfig({ tenantRoot, environment: target.kind === "persistent" ? target.scope : "staging", env: process.env }).deployConfig;
32
+ const deployConfig = loadTreeseedPlatformConfig({ tenantRoot, environment: target.kind === "persistent" ? target.scope : "staging", env }).deployConfig;
32
33
  const legacyState = loadDeployState(tenantRoot, deployConfig, { target });
33
34
  const identity = legacyState.identity ?? resolveTreeseedResourceIdentity(deployConfig, target);
34
35
  const units = [];
@@ -27,7 +27,7 @@ function formatVerificationFailure(verification) {
27
27
  return details.length > 0 ? `Verification failed (${details.join(" | ")})` : "Verification failed.";
28
28
  }
29
29
  function createRunContext(tenantRoot, target, launchEnv, write, dryRun = false, session) {
30
- const { deployConfig } = deriveTreeseedDesiredUnits({ tenantRoot, target });
30
+ const { deployConfig } = deriveTreeseedDesiredUnits({ tenantRoot, target, env: launchEnv });
31
31
  return {
32
32
  tenantRoot,
33
33
  target,
@@ -199,7 +199,7 @@ async function refreshTreeseedUnits({
199
199
  units: explicitUnits,
200
200
  write
201
201
  }) {
202
- const derived = deriveTreeseedDesiredUnits({ tenantRoot, target });
202
+ const derived = deriveTreeseedDesiredUnits({ tenantRoot, target, env });
203
203
  const baseUnits = explicitUnits ?? filterTreeseedDesiredUnitsByBootstrapSystems(derived.units, systems);
204
204
  const units = filterUnitsBySelector(baseUnits, selector);
205
205
  const deployConfig = derived.deployConfig;
@@ -64,6 +64,7 @@ async function runTreeseedSceneDeviceMatrix(input) {
64
64
  environment: input.environment,
65
65
  device,
66
66
  record: input.record,
67
+ artifactMode: input.artifactMode,
67
68
  mode: input.mode,
68
69
  timestamp: input.timestamp,
69
70
  runId: `${matrixId}-${device}`,
@@ -1,12 +1,30 @@
1
1
  import { sceneErrorDiagnostic } from "./diagnostics.js";
2
2
  import { defaultTreeseedSceneDeviceConfig } from "./schema.js";
3
+ const LEGACY_DEVICE_PROFILE_ALIASES = {
4
+ desktop_chromium: "desktop",
5
+ desktop_firefox: "desktop",
6
+ desktop_webkit: "desktop",
7
+ tablet_chromium: "tablet",
8
+ tablet_firefox: "tablet",
9
+ tablet_webkit: "tablet",
10
+ mobile_chromium: "mobile",
11
+ mobile_firefox: "mobile",
12
+ mobile_webkit: "mobile"
13
+ };
3
14
  function listTreeseedSceneDeviceProfiles(scene) {
4
- return scene.devices?.profiles?.length ? scene.devices.profiles : defaultTreeseedSceneDeviceConfig().profiles;
15
+ const sceneProfiles = scene.devices?.profiles ?? [];
16
+ if (sceneProfiles.length === 0) return defaultTreeseedSceneDeviceConfig().profiles;
17
+ const seen = new Set(sceneProfiles.map((entry) => entry.id));
18
+ return [
19
+ ...sceneProfiles,
20
+ ...defaultTreeseedSceneDeviceConfig().profiles.filter((entry) => !seen.has(entry.id))
21
+ ];
5
22
  }
6
23
  function resolveTreeseedSceneDeviceProfile(input) {
7
24
  const profiles = listTreeseedSceneDeviceProfiles(input.scene);
8
25
  const selected = input.device ?? input.scene.devices?.defaultProfile ?? "desktop";
9
- const profile = profiles.find((entry) => entry.id === selected) ?? null;
26
+ const normalized = LEGACY_DEVICE_PROFILE_ALIASES[selected] ?? selected;
27
+ const profile = profiles.find((entry) => entry.id === normalized) ?? (selected in LEGACY_DEVICE_PROFILE_ALIASES ? profiles.find((entry) => entry.id === input.scene.devices?.defaultProfile) ?? profiles[0] ?? null : null);
10
28
  if (!profile) {
11
29
  return {
12
30
  profile: null,
@@ -221,8 +221,9 @@ async function runTreeseedScene(input) {
221
221
  }
222
222
  const paths = plan.artifactPaths;
223
223
  ensureTreeseedSceneRunDirectories(paths);
224
+ const screenshotOnlyArtifacts = input.artifactMode === "screenshots";
224
225
  const tracePath = scene.artifacts.trace ? join(paths.playwrightRoot, "trace.zip") : null;
225
- const videoDir = input.record || scene.artifacts.video ? join(paths.playwrightRoot, "videos") : null;
226
+ const videoDir = !screenshotOnlyArtifacts && (input.record || scene.artifacts.video) ? join(paths.playwrightRoot, "videos") : null;
226
227
  const recordingVideo = Boolean(videoDir);
227
228
  const capture = resolveCapture({ scene, device, runtimeMode: runtime.mode, recording: Boolean(videoDir) });
228
229
  const artifacts = createTreeseedSceneRunArtifacts({ paths, playwrightTracePath: tracePath });
@@ -470,6 +470,7 @@ export type TreeseedSceneRunOptions = {
470
470
  environment?: TreeseedSceneEnvironment;
471
471
  device?: TreeseedSceneDeviceProfileId;
472
472
  record?: boolean;
473
+ artifactMode?: 'full' | 'screenshots';
473
474
  runId?: string;
474
475
  timestamp?: string;
475
476
  browserAdapter?: TreeseedSceneBrowserAdapter;
@@ -491,6 +492,7 @@ export type TreeseedSceneDeviceMatrixOptions = {
491
492
  environment?: TreeseedSceneEnvironment;
492
493
  devices?: TreeseedSceneDeviceProfileId[];
493
494
  record?: boolean;
495
+ artifactMode?: 'full' | 'screenshots';
494
496
  mode?: TreeseedSceneExecutionMode;
495
497
  timestamp?: string;
496
498
  browserAdapter?: TreeseedSceneBrowserAdapter;
@@ -525,6 +525,8 @@ export declare function workflowSave(helpers: WorkflowOperationHelpers, input: T
525
525
  } | null;
526
526
  } | null;
527
527
  workspaceLinks: import("../operations/services/workspace-dependency-mode.js").WorkspaceDependencyModeReport;
528
+ sceneArtifacts: "full" | "screenshots";
529
+ localCleanup: import("../workflow-support.js").TreeseedLocalCleanupReport | null;
528
530
  ciMode: "hosted" | "off";
529
531
  lane: string;
530
532
  verifyMode: "action-first" | "local-only" | import("../workflow.js").TreeseedWorkflowVerifyMode;
@@ -690,6 +692,8 @@ export declare function workflowClose(helpers: WorkflowOperationHelpers, input:
690
692
  } | null;
691
693
  } | null;
692
694
  workspaceLinks: import("../operations/services/workspace-dependency-mode.js").WorkspaceDependencyModeReport;
695
+ sceneArtifacts: "full" | "screenshots";
696
+ localCleanup: import("../workflow-support.js").TreeseedLocalCleanupReport | null;
693
697
  ciMode: "hosted" | "off";
694
698
  lane: string;
695
699
  verifyMode: "action-first" | "local-only" | import("../workflow.js").TreeseedWorkflowVerifyMode;
@@ -869,6 +873,8 @@ export declare function workflowStage(helpers: WorkflowOperationHelpers, input:
869
873
  cleanupMode: StageCleanupMode;
870
874
  updateFrom: string;
871
875
  waitForStaging: boolean;
876
+ sceneArtifacts: "full" | "screenshots";
877
+ localCleanup: import("../workflow-support.js").TreeseedLocalCleanupReport | null;
872
878
  applicationSelection: WorkflowApplicationSelection;
873
879
  plan: {
874
880
  schemaVersion: 1;
@@ -903,6 +909,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
903
909
  target: TreeseedReconcileTarget;
904
910
  executionMode: TreeseedWorkflowExecutionMode;
905
911
  verifyDeployedResources: boolean;
912
+ releaseImageRefs: Record<string, string>;
913
+ includeHostedReleaseGates: boolean;
906
914
  desiredGraph: import("../index.js").TreeseedDesiredResourceGraph;
907
915
  units: {
908
916
  unitId: string;
@@ -945,6 +953,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
945
953
  ciMode: "hosted" | "off";
946
954
  level: "patch" | "major" | "minor";
947
955
  fresh: boolean;
956
+ sceneArtifacts: "full" | "screenshots";
957
+ localCleanup: import("../workflow-support.js").TreeseedLocalCleanupReport | null;
948
958
  autoResumeCandidate: {
949
959
  runId: string;
950
960
  branch: string | null;
@@ -1182,6 +1192,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1182
1192
  ciMode: "hosted" | "off";
1183
1193
  level: "patch" | "major" | "minor";
1184
1194
  fresh: boolean;
1195
+ sceneArtifacts: "full" | "screenshots";
1196
+ localCleanup: import("../workflow-support.js").TreeseedLocalCleanupReport | null;
1185
1197
  freshArchivedRuns: never[];
1186
1198
  autoResumeCandidate: {
1187
1199
  runId: string;
@@ -1241,6 +1253,20 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1241
1253
  export declare function workflowResume(helpers: WorkflowOperationHelpers, input: TreeseedResumeInput): Promise<any>;
1242
1254
  export declare function workflowRecover(helpers: WorkflowOperationHelpers, input?: TreeseedRecoverInput): Promise<TreeseedWorkflowResult<{
1243
1255
  lock: import("./runs.js").TreeseedWorkflowLockInspection;
1256
+ locks: {
1257
+ lock: import("./runs.js").TreeseedWorkflowLockRecord | null;
1258
+ active: boolean;
1259
+ stale: boolean;
1260
+ staleReason: string | null;
1261
+ scope: "worktree" | "shared";
1262
+ }[];
1263
+ clearedStaleLocks: {
1264
+ scope: "worktree" | "shared";
1265
+ runId: string;
1266
+ command: TreeseedWorkflowRunCommand;
1267
+ staleReason: string | null;
1268
+ removed: boolean;
1269
+ }[];
1244
1270
  interruptedRuns: {
1245
1271
  runId: string;
1246
1272
  command: TreeseedWorkflowRunCommand;
@@ -158,6 +158,7 @@ import {
158
158
  resolveTreeseedWorkflowSession
159
159
  } from "./session.js";
160
160
  import { checkedOutManagedWorkflowRepos } from "../operations/services/managed-repositories.js";
161
+ import { runTreeseedLocalCleanup } from "../operations/services/local-cleanup.js";
161
162
  import {
162
163
  classifyTreeseedBranchRole,
163
164
  resolveTreeseedWorkflowPaths
@@ -326,6 +327,14 @@ function normalizeSaveLane(lane) {
326
327
  const value = lane ?? process.env.TREESEED_SAVE_LANE;
327
328
  return value === "promotion" ? "promotion" : "fast";
328
329
  }
330
+ function normalizeSceneArtifactsMode(value) {
331
+ return value === "screenshots" ? "screenshots" : "full";
332
+ }
333
+ function maybeRunLocalWorkflowCleanup(helpers, root, operation, input) {
334
+ if (normalizeExecutionMode(input) === "plan" || input.skipCleanup === true) return null;
335
+ helpers.write(`Treeseed ${operation} cleanup: pruning local caches, generated evidence, npm cache, and Docker artifacts before long verification.`, "stderr");
336
+ return runTreeseedLocalCleanup({ root, mode: "aggressive" });
337
+ }
329
338
  function normalizeSaveCiMode(mode, branch, lane = "fast") {
330
339
  if (mode === "hosted" || mode === "off") return mode;
331
340
  if (lane === "promotion") return branch === STAGING_BRANCH || branch === PRODUCTION_BRANCH ? "hosted" : "off";
@@ -524,8 +533,14 @@ function selectorFromWorkflowHostingGraph(graph) {
524
533
  }))]
525
534
  };
526
535
  }
527
- async function reconcileSaveHostedEnvironment(root, environment, helpers, workflowRunId, operation = "save") {
528
- const graph = compileTreeseedHostingGraph({ tenantRoot: root, environment });
536
+ async function reconcileSaveHostedEnvironment(root, environment, helpers, workflowRunId, operation = "save", envOverlay = {}) {
537
+ const target = createPersistentDeployTarget(environment);
538
+ const env = {
539
+ ...helpers.context.env,
540
+ ...collectTreeseedConfigSeedValues(root, environment, helpers.context.env),
541
+ ...envOverlay
542
+ };
543
+ const graph = compileTreeseedHostingGraph({ tenantRoot: root, environment, env });
529
544
  const selector = selectorFromWorkflowHostingGraph(graph);
530
545
  if (process.env.TREESEED_WORKFLOW_HOSTED_RECONCILE_MODE === "skip") {
531
546
  return {
@@ -542,11 +557,6 @@ async function reconcileSaveHostedEnvironment(root, environment, helpers, workfl
542
557
  }))
543
558
  };
544
559
  }
545
- const target = createPersistentDeployTarget(environment);
546
- const env = {
547
- ...helpers.context.env,
548
- ...collectTreeseedConfigSeedValues(root, environment, helpers.context.env)
549
- };
550
560
  const reconcileSession = /* @__PURE__ */ new Map([["workflowRunId", workflowRunId]]);
551
561
  helpers.write(`[${operation}][workflow] Reconciling ${environment} hosted deployments for ${graph.units.length} selected resources.`);
552
562
  const reconcile = await reconcileTreeseedTarget({
@@ -605,6 +615,24 @@ ${liveFailures.join("\n")}`, {
605
615
  liveVerification: live
606
616
  };
607
617
  }
618
+ function productionReleaseImageRefEnv(selectedVersions) {
619
+ const refs = {};
620
+ const apiVersion = selectedVersions.get("@treeseed/api");
621
+ if (apiVersion) {
622
+ refs.TREESEED_API_IMAGE_REF = `treeseed/api:${apiVersion}`;
623
+ refs.TREESEED_OPERATIONS_RUNNER_IMAGE_REF = `treeseed/op-runner:${apiVersion}`;
624
+ }
625
+ const agentVersion = selectedVersions.get("@treeseed/agent");
626
+ if (agentVersion) {
627
+ refs.TREESEED_AGENT_MANAGER_IMAGE_REF = `treeseed/agent-manager:${agentVersion}`;
628
+ refs.TREESEED_AGENT_RUNNER_IMAGE_REF = `treeseed/agent-runner:${agentVersion}`;
629
+ }
630
+ const treedxVersion = selectedVersions.get("treedx") ?? selectedVersions.get("@treeseed/treedx");
631
+ if (treedxVersion) {
632
+ refs.TREESEED_PUBLIC_TREEDX_IMAGE_REF = `treeseed/treedx:${treedxVersion}`;
633
+ }
634
+ return refs;
635
+ }
608
636
  function recordHostedDeploymentStatesFromRootGates(root, rootRelease, workflowGates) {
609
637
  const gates = Array.isArray(workflowGates) ? workflowGates.map((gate) => stringRecord(gate)).filter((gate) => Boolean(gate)) : [];
610
638
  const releaseRecord = stringRecord(rootRelease) ?? {};
@@ -4022,6 +4050,7 @@ async function workflowSave(helpers, input) {
4022
4050
  const autoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableSaveRun(root, branch) : null;
4023
4051
  const planAutoResumeRun = executionMode === "plan" ? findAutoResumableSaveRun(root, branch) : null;
4024
4052
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
4053
+ const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "save", effectiveInput);
4025
4054
  const message = String(effectiveInput.message ?? "").trim();
4026
4055
  const saveLane = normalizeSaveLane(effectiveInput.lane);
4027
4056
  const saveCiMode = normalizeSaveCiMode(effectiveInput.ciMode, branch, saveLane);
@@ -4078,6 +4107,8 @@ async function workflowSave(helpers, input) {
4078
4107
  failure: planAutoResumeRun.failure
4079
4108
  } : null,
4080
4109
  workspaceLinks,
4110
+ sceneArtifacts: normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts),
4111
+ localCleanup,
4081
4112
  ciMode: saveCiMode,
4082
4113
  lane: saveLane,
4083
4114
  verifyMode: effectiveInput.verifyMode ?? "fast",
@@ -4857,6 +4888,7 @@ async function workflowStage(helpers, input) {
4857
4888
  const autoResumeRun = rawAutoResumeRun?.steps.some((step) => step.id === "preflight") ? rawAutoResumeRun : null;
4858
4889
  const planAutoResumeRun = executionMode === "plan" ? findAutoResumableTaskRun(root, "stage", session.branchName) : null;
4859
4890
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
4891
+ const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "stage", effectiveInput);
4860
4892
  const message = ensureMessage("stage", effectiveInput.message, "a resolution message");
4861
4893
  if (effectiveInput.verifyDeployedResources === true) {
4862
4894
  workflowError("stage", "validation_failed", "Stage no longer verifies deployed resources. Promote refs with stage, then run staging release/hosting verification separately.");
@@ -4884,6 +4916,8 @@ async function workflowStage(helpers, input) {
4884
4916
  cleanupMode,
4885
4917
  updateFrom,
4886
4918
  waitForStaging: ciMode === "hosted",
4919
+ sceneArtifacts: normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts),
4920
+ localCleanup,
4887
4921
  applicationSelection,
4888
4922
  plan,
4889
4923
  phases: plan.phases,
@@ -5126,13 +5160,15 @@ ${currentBlockers.map((entry) => `- ${entry}`).join("\n")}`, {
5126
5160
  }
5127
5161
  async function runReleaseGateReconcileFacade(operation, helpers, root, target, input, extraPayload = {}) {
5128
5162
  const executionMode = normalizeExecutionMode(input);
5163
+ const reconcileEnv = { ...helpers.context.env, ...input.releaseImageRefs ?? {} };
5164
+ const includeHostedReleaseGates = input.includeHostedReleaseGates === true;
5129
5165
  const selector = {
5130
5166
  environment: target.kind === "persistent" ? target.scope : "staging",
5131
5167
  resourceKind: ["release-gate"],
5132
5168
  provider: ["treeseed"]
5133
5169
  };
5134
5170
  const desiredGraph = compileTreeseedDesiredResourceGraph({ tenantRoot: root, target });
5135
- const rawUnits = compileTreeseedDesiredUnitsFromGraph(desiredGraph).filter((unit) => unit.provider === "treeseed" && (unit.unitType === "package-manifest" || unit.unitType.startsWith("release-gate:")) || unit.provider === "github" && (unit.unitType === "github-environment" || unit.unitType === "github-secret-binding" || unit.unitType === "github-variable-binding"));
5171
+ const rawUnits = compileTreeseedDesiredUnitsFromGraph(desiredGraph).filter((unit) => unit.provider === "treeseed" && (unit.unitType === "package-manifest" || unit.unitType.startsWith("release-gate:")) && (includeHostedReleaseGates || unit.unitType !== "release-gate:hosted-reconcile" && unit.unitType !== "release-gate:live-verify") || unit.provider === "github" && (unit.unitType === "github-environment" || unit.unitType === "github-secret-binding" || unit.unitType === "github-variable-binding"));
5136
5172
  const rawUnitIds = new Set(rawUnits.map((unit) => unit.unitId));
5137
5173
  const units = rawUnits.map((unit) => ({
5138
5174
  ...unit,
@@ -5145,7 +5181,7 @@ async function runReleaseGateReconcileFacade(operation, helpers, root, target, i
5145
5181
  const plan = await planTreeseedReconciliation({
5146
5182
  tenantRoot: root,
5147
5183
  target,
5148
- env: helpers.context.env,
5184
+ env: reconcileEnv,
5149
5185
  units,
5150
5186
  selector: unitSelector,
5151
5187
  write: (line) => helpers.write(`[${operation}][reconcile] ${line}`, "stderr")
@@ -5168,7 +5204,7 @@ ${blockers.join("\n")}`, {
5168
5204
  const result = executionMode === "execute" ? await reconcileTreeseedTarget({
5169
5205
  tenantRoot: root,
5170
5206
  target,
5171
- env: helpers.context.env,
5207
+ env: reconcileEnv,
5172
5208
  units,
5173
5209
  selector: unitSelector,
5174
5210
  dryRun: input.execute !== true,
@@ -5180,6 +5216,8 @@ ${blockers.join("\n")}`, {
5180
5216
  target,
5181
5217
  executionMode,
5182
5218
  verifyDeployedResources: input.verifyDeployedResources === true,
5219
+ releaseImageRefs: input.releaseImageRefs ?? {},
5220
+ includeHostedReleaseGates,
5183
5221
  desiredGraph,
5184
5222
  units: units.map((unit) => ({
5185
5223
  unitId: unit.unitId,
@@ -5220,6 +5258,7 @@ async function workflowRelease(helpers, input) {
5220
5258
  ...autoResumeRun.input,
5221
5259
  ciMode: input.ciMode ?? autoResumeRun.input.ciMode
5222
5260
  } : input;
5261
+ const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "release", effectiveInput);
5223
5262
  const level = effectiveInput.bump ?? "patch";
5224
5263
  const ciMode = normalizeCiMode(effectiveInput.ciMode, "release");
5225
5264
  const packageSelection = session.packageSelection;
@@ -5252,6 +5291,8 @@ async function workflowRelease(helpers, input) {
5252
5291
  ciMode,
5253
5292
  level,
5254
5293
  fresh: input.fresh === true,
5294
+ sceneArtifacts: normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts),
5295
+ localCleanup,
5255
5296
  freshArchivedRuns: [],
5256
5297
  autoResumeCandidate: planAutoResumeRun ? {
5257
5298
  runId: planAutoResumeRun.runId,
@@ -5345,7 +5386,8 @@ ${blockers.join("\n")}`, {
5345
5386
  { kind: "persistent", scope: "prod" },
5346
5387
  {
5347
5388
  execute: true,
5348
- verifyDeployedResources: effectiveInput.verifyDeployedResources
5389
+ verifyDeployedResources: effectiveInput.verifyDeployedResources,
5390
+ releaseImageRefs: productionReleaseImageRefEnv(selectedVersions)
5349
5391
  },
5350
5392
  {
5351
5393
  ...releaseBasePayload,
@@ -5475,7 +5517,7 @@ ${rendered}`);
5475
5517
  onProgress: (line, stream) => helpers.write(line, stream)
5476
5518
  }).then((workflowGates) => ({ workflowGates })));
5477
5519
  const publishedArtifacts = await executeJournalStep(root, workflowRun.runId, "verify-published-artifacts", () => verifyPublishedReleaseArtifacts(selectedVersions));
5478
- const productionHosting = await executeJournalStep(root, workflowRun.runId, "production-hosting", () => reconcileSaveHostedEnvironment(root, "prod", helpers, workflowRun.runId, "release"));
5520
+ const productionHosting = await executeJournalStep(root, workflowRun.runId, "production-hosting", () => reconcileSaveHostedEnvironment(root, "prod", helpers, workflowRun.runId, "release", productionReleaseImageRefEnv(selectedVersions)));
5479
5521
  const backMerge = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => {
5480
5522
  const packageBackMerges = checkedOutWorkspacePackageRepos(root).filter((pkg) => selectedPackageSet.has(pkg.name)).map((pkg) => backMergeProductionIntoStaging(pkg.dir, pkg.name, releaseAdminMessage({
5481
5523
  subject: `release: back-merge ${PRODUCTION_BRANCH} into ${STAGING_BRANCH}`,
@@ -5614,7 +5656,22 @@ async function workflowRecover(helpers, input = {}) {
5614
5656
  try {
5615
5657
  return await withContextEnv(helpers.context.env, async () => {
5616
5658
  const root = resolveProjectRootOrThrow("recover", helpers.cwd());
5617
- const lock = inspectWorkflowLock(root, { scope: "worktree" });
5659
+ const initialLocks = ["worktree", "shared"].map((scope) => ({
5660
+ scope,
5661
+ inspection: inspectWorkflowLock(root, { scope })
5662
+ }));
5663
+ const clearedStaleLocks = initialLocks.filter((entry) => entry.inspection.stale && entry.inspection.lock?.runId).map((entry) => ({
5664
+ scope: entry.scope,
5665
+ runId: entry.inspection.lock.runId,
5666
+ command: entry.inspection.lock.command,
5667
+ staleReason: entry.inspection.staleReason,
5668
+ removed: releaseWorkflowLock(root, entry.inspection.lock.runId)
5669
+ }));
5670
+ const locks = ["worktree", "shared"].map((scope) => ({
5671
+ scope,
5672
+ inspection: inspectWorkflowLock(root, { scope })
5673
+ }));
5674
+ const lock = locks.find((entry) => entry.inspection.active)?.inspection ?? locks.find((entry) => entry.inspection.stale)?.inspection ?? locks[0].inspection;
5618
5675
  const journals = listWorkflowRunJournals(root);
5619
5676
  const session = resolveTreeseedWorkflowSession(root);
5620
5677
  const currentHeads = Object.fromEntries(
@@ -5685,6 +5742,8 @@ async function workflowRecover(helpers, input = {}) {
5685
5742
  root,
5686
5743
  {
5687
5744
  lock,
5745
+ locks: locks.map((entry) => ({ scope: entry.scope, ...entry.inspection })),
5746
+ clearedStaleLocks,
5688
5747
  interruptedRuns,
5689
5748
  staleRuns,
5690
5749
  obsoleteRuns,
@@ -5,6 +5,7 @@ export { collectTreeseedHostedServiceChecks, type TreeseedHostedServiceCheck, ty
5
5
  export { collectTreeseedDeploymentReadiness, formatTreeseedReadinessReport, type TreeseedDeploymentReadinessCheck, type TreeseedDeploymentReadinessReport, type TreeseedDeploymentReadinessStatus, } from './operations/services/deployment-readiness.js';
6
6
  export { collectTreeseedLiveHostedServiceChecks, type TreeseedLiveHostedServiceCheckOptions, type TreeseedLiveHostedServiceCheckReport, } from './operations/services/live-hosted-service-checks.js';
7
7
  export { runTreeseedOperationsRunnerSmoke, type TreeseedOperationsRunnerSmokeOptions, type TreeseedOperationsRunnerSmokeReport, } from './operations/services/operations-runner-smoke.js';
8
+ export { runTreeseedLocalCleanup, type TreeseedLocalCleanupAction, type TreeseedLocalCleanupMode, type TreeseedLocalCleanupReport, } from './operations/services/local-cleanup.js';
8
9
  export { readTreeseedVerificationCache, treeseedVerificationCacheKey, writeTreeseedVerificationCache, type TreeseedVerificationCacheEntry, } from './operations/services/verification-cache.js';
9
10
  export { assertDeploymentInitialized, cleanupDestroyedState, createBranchPreviewDeployTarget, createPersistentDeployTarget, deployTargetLabel, destroyCloudflareResources, ensureGeneratedWranglerConfig, finalizeDeploymentState, loadDeployState, printDeploySummary, printDestroySummary, provisionCloudflareResources, runRemoteD1Migrations, syncCloudflareSecrets, validateDeployPrerequisites, validateDestroyPrerequisites, } from './operations/services/deploy.js';
10
11
  export { assertCleanWorktree, assertFeatureBranch, branchExists, checkoutBranch, createFeatureBranchFromStaging, currentManagedBranch, deleteLocalBranch, deleteRemoteBranch, ensureLocalBranchTracking, gitWorkflowRoot, listTaskBranches, mergeCurrentBranchIntoStaging, mergeStagingIntoMain, prepareReleaseBranches, PRODUCTION_BRANCH, pushBranch, remoteBranchExists, STAGING_BRANCH, syncBranchWithOrigin, waitForStagingAutomation, } from './operations/services/git-workflow.js';
@@ -58,6 +58,9 @@ import {
58
58
  import {
59
59
  runTreeseedOperationsRunnerSmoke
60
60
  } from "./operations/services/operations-runner-smoke.js";
61
+ import {
62
+ runTreeseedLocalCleanup
63
+ } from "./operations/services/local-cleanup.js";
61
64
  import {
62
65
  readTreeseedVerificationCache,
63
66
  treeseedVerificationCacheKey,
@@ -349,6 +352,7 @@ export {
349
352
  runTreeseedGit,
350
353
  runTreeseedGitBatch,
351
354
  runTreeseedHostingAudit,
355
+ runTreeseedLocalCleanup,
352
356
  runTreeseedOperationsRunnerSmoke,
353
357
  runTreeseedPackageImageWorkflow,
354
358
  runWorkspaceReleasePreflight,
@@ -119,6 +119,8 @@ export type TreeseedSaveInput = {
119
119
  workspaceLinks?: 'auto' | 'off';
120
120
  releaseCandidate?: TreeseedReleaseCandidateMode;
121
121
  verifyDeployedResources?: boolean;
122
+ skipCleanup?: boolean;
123
+ sceneArtifacts?: 'full' | 'screenshots';
122
124
  plan?: boolean;
123
125
  dryRun?: boolean;
124
126
  };
@@ -175,6 +177,8 @@ export type TreeseedStageInput = {
175
177
  worktreeMode?: TreeseedWorkflowWorktreeMode;
176
178
  workspaceLinks?: 'auto' | 'off';
177
179
  verifyDeployedResources?: boolean;
180
+ skipCleanup?: boolean;
181
+ sceneArtifacts?: 'full' | 'screenshots';
178
182
  plan?: boolean;
179
183
  dryRun?: boolean;
180
184
  };
@@ -258,6 +262,8 @@ export type TreeseedReleaseInput = {
258
262
  workspaceLinks?: 'auto' | 'off';
259
263
  verifyDeployedResources?: boolean;
260
264
  fresh?: boolean;
265
+ skipCleanup?: boolean;
266
+ sceneArtifacts?: 'full' | 'screenshots';
261
267
  plan?: boolean;
262
268
  dryRun?: boolean;
263
269
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {