@treeseed/sdk 0.12.11 → 0.12.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guarantees/index.d.ts +2 -0
- package/dist/guarantees/index.js +4 -0
- package/dist/hosting/builtins.js +34 -20
- package/dist/hosting/contracts.d.ts +1 -0
- package/dist/hosting/graph.js +2 -1
- package/dist/operations/providers/default.js +1 -0
- package/dist/operations/services/live-hosted-service-checks.js +78 -0
- package/dist/operations/services/local-cleanup.d.ts +29 -0
- package/dist/operations/services/local-cleanup.js +62 -0
- package/dist/operations/services/operations-runner-smoke.js +2 -1
- package/dist/operations/services/railway-api.d.ts +4 -1
- package/dist/operations/services/railway-api.js +12 -9
- package/dist/operations/services/railway-deploy.d.ts +1 -1
- package/dist/operations/services/railway-deploy.js +8 -6
- package/dist/operations-registry.js +1 -0
- package/dist/reconcile/builtin-adapters.js +33 -6
- package/dist/scenes/device-matrix.js +1 -0
- package/dist/scenes/devices.js +20 -2
- package/dist/scenes/runner.js +2 -1
- package/dist/scenes/types.d.ts +2 -0
- package/dist/workflow/operations.d.ts +26 -0
- package/dist/workflow/operations.js +76 -14
- package/dist/workflow/runs.js +87 -2
- package/dist/workflow-support.d.ts +1 -0
- package/dist/workflow-support.js +4 -0
- package/dist/workflow.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/guarantees/index.js
CHANGED
|
@@ -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
|
}
|
package/dist/hosting/builtins.js
CHANGED
|
@@ -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:
|
|
42
|
-
verified
|
|
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;
|
package/dist/hosting/graph.js
CHANGED
|
@@ -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 ?
|
|
338
|
+
const imageRef = service.railway?.imageRef ?? (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
|
|
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[];
|
|
@@ -611,13 +611,14 @@ query TreeseedRailwayDeploymentStatus($projectId: String!) {
|
|
|
611
611
|
});
|
|
612
612
|
return payload.data?.project ?? null;
|
|
613
613
|
}
|
|
614
|
-
function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot) {
|
|
614
|
+
function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot, envOverlay = {}) {
|
|
615
615
|
const normalizedScope = normalizeScope(scope);
|
|
616
616
|
const imageRefKeys = [
|
|
617
617
|
"TREESEED_API_IMAGE_REF",
|
|
618
618
|
"TREESEED_OPERATIONS_RUNNER_IMAGE_REF",
|
|
619
619
|
"TREESEED_AGENT_MANAGER_IMAGE_REF",
|
|
620
|
-
"TREESEED_AGENT_RUNNER_IMAGE_REF"
|
|
620
|
+
"TREESEED_AGENT_RUNNER_IMAGE_REF",
|
|
621
|
+
"TREESEED_PUBLIC_TREEDX_IMAGE_REF"
|
|
621
622
|
];
|
|
622
623
|
let machineEnv = {};
|
|
623
624
|
try {
|
|
@@ -625,7 +626,7 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
|
|
|
625
626
|
} catch {
|
|
626
627
|
machineEnv = {};
|
|
627
628
|
}
|
|
628
|
-
const imageRefEnv = { ...process.env, ...machineEnv };
|
|
629
|
+
const imageRefEnv = { ...process.env, ...machineEnv, ...envOverlay };
|
|
629
630
|
let identity;
|
|
630
631
|
try {
|
|
631
632
|
identity = resolveTreeseedResourceIdentity(deployConfig, createPersistentDeployTarget(normalizedScope));
|
|
@@ -882,9 +883,9 @@ function resolveRailwayCapacityProviderRoot(tenantRoot, service) {
|
|
|
882
883
|
);
|
|
883
884
|
return found ?? resolve(tenantRoot, "packages", "agent");
|
|
884
885
|
}
|
|
885
|
-
function configuredRailwayServices(tenantRoot, scope) {
|
|
886
|
+
function configuredRailwayServices(tenantRoot, scope, envOverlay = {}) {
|
|
886
887
|
const deployConfig = loadCliDeployConfig(tenantRoot);
|
|
887
|
-
const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig);
|
|
888
|
+
const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, null, tenantRoot, envOverlay);
|
|
888
889
|
const nested = discoverTreeseedApplications(tenantRoot).filter((application) => application.root !== resolve(tenantRoot)).flatMap((application) => configuredRailwayServicesForConfig(
|
|
889
890
|
application.root,
|
|
890
891
|
scope,
|
|
@@ -894,7 +895,8 @@ function configuredRailwayServices(tenantRoot, scope) {
|
|
|
894
895
|
root: application.root,
|
|
895
896
|
relativeRoot: application.relativeRoot
|
|
896
897
|
},
|
|
897
|
-
tenantRoot
|
|
898
|
+
tenantRoot,
|
|
899
|
+
envOverlay
|
|
898
900
|
));
|
|
899
901
|
return [...direct, ...nested];
|
|
900
902
|
}
|
|
@@ -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 =
|
|
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 =
|
|
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;
|
|
@@ -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 =
|
|
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 = {
|
|
@@ -4451,7 +4468,7 @@ function capacityProviderSecretsForService(values, serviceKey) {
|
|
|
4451
4468
|
return apiKey ? { TREESEED_CAPACITY_PROVIDER_API_KEY: apiKey } : {};
|
|
4452
4469
|
}
|
|
4453
4470
|
function ensureCapacityProviderApiKeyForRailwaySync(input, scope) {
|
|
4454
|
-
const hasCapacityProviderService =
|
|
4471
|
+
const hasCapacityProviderService = configuredRailwayServicesForInput(input, scope).some((service) => String(service.key).startsWith("capacityProvider") && service.enabled !== false);
|
|
4455
4472
|
if (!hasCapacityProviderService) return null;
|
|
4456
4473
|
const values = resolveReconcileEnvironmentValues(input, scope);
|
|
4457
4474
|
const existing = String(values.TREESEED_CAPACITY_PROVIDER_API_KEY ?? "").trim();
|
|
@@ -4475,7 +4492,7 @@ function ensureCapacityProviderApiKeyForRailwaySync(input, scope) {
|
|
|
4475
4492
|
return generated;
|
|
4476
4493
|
}
|
|
4477
4494
|
function ensurePublicTreeDxSecretsForRailwaySync(input, scope, values, registry) {
|
|
4478
|
-
const hasPublicTreeDxService =
|
|
4495
|
+
const hasPublicTreeDxService = configuredRailwayServicesForInput(input, scope).some((service) => service.key === "public-treedx-node-01" && service.enabled !== false);
|
|
4479
4496
|
if (!hasPublicTreeDxService) return {};
|
|
4480
4497
|
const generated = {};
|
|
4481
4498
|
const specs = [
|
|
@@ -4937,6 +4954,16 @@ async function verifyRailwayUnit(input) {
|
|
|
4937
4954
|
issues: entry.instance?.id ? [] : [`Railway service instance for ${service.serviceName ?? service.key} in ${service.railwayEnvironment} is missing.`]
|
|
4938
4955
|
})
|
|
4939
4956
|
];
|
|
4957
|
+
if (service.sourceMode === "image") {
|
|
4958
|
+
const hasImageRef = typeof service.imageRef === "string" && service.imageRef.trim().length > 0;
|
|
4959
|
+
checks.push(verificationCheck("railway.instance.image-ref", "Railway image source has an immutable image reference", "sdk", {
|
|
4960
|
+
exists: hasImageRef,
|
|
4961
|
+
configured: hasImageRef,
|
|
4962
|
+
expected: service.imageRefEnv ? `${service.imageRefEnv}=<image>:<tag>` : "<image>:<tag>",
|
|
4963
|
+
observed: service.imageRef ?? null,
|
|
4964
|
+
issues: hasImageRef ? [] : [`Railway production service ${service.serviceName ?? service.key} is configured for image deployment but no image reference was resolved.`]
|
|
4965
|
+
}));
|
|
4966
|
+
}
|
|
4940
4967
|
if (service.startCommand) {
|
|
4941
4968
|
const startCommandMatches = railwayStartCommandMatches(serviceKey, entry.instance?.startCommand, service.startCommand);
|
|
4942
4969
|
checks.push(verificationCheck("railway.instance.start-command", "Railway start command matches desired config", "api", {
|
|
@@ -5129,7 +5156,7 @@ function buildRailwayDiff(input, observed) {
|
|
|
5129
5156
|
async function reconcileRailwayUnit(input, diff) {
|
|
5130
5157
|
const scope = input.context.target.kind === "persistent" ? input.context.target.scope : "staging";
|
|
5131
5158
|
const serviceKey = String(input.unit.metadata.serviceKey ?? "").trim();
|
|
5132
|
-
const configuredService =
|
|
5159
|
+
const configuredService = configuredRailwayServicesForInput(input, scope).find((candidate) => candidate.key === serviceKey || candidate.instanceKey === serviceKey || candidate.serviceName === serviceKey) ?? null;
|
|
5133
5160
|
const requiresProjectLevelSync = Boolean(configuredService);
|
|
5134
5161
|
const serviceKeys = serviceKey && !requiresProjectLevelSync ? [serviceKey] : void 0;
|
|
5135
5162
|
const cacheKey = requiresProjectLevelSync ? `railway:sync:${scope}:project:${configuredService?.projectName ?? "default"}:${configuredService?.railwayEnvironment ?? scope}` : `railway:sync:${scope}:${serviceKey || "all"}`;
|
package/dist/scenes/devices.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
package/dist/scenes/runner.js
CHANGED
|
@@ -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 });
|
package/dist/scenes/types.d.ts
CHANGED
|
@@ -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
|
|
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) ?? {};
|
|
@@ -2455,7 +2483,10 @@ async function fetchJsonForArtifact(url) {
|
|
|
2455
2483
|
const timeout = setTimeout(() => controller.abort(), 2e4);
|
|
2456
2484
|
try {
|
|
2457
2485
|
const response = await fetch(url, {
|
|
2458
|
-
headers: {
|
|
2486
|
+
headers: {
|
|
2487
|
+
accept: "application/json",
|
|
2488
|
+
"user-agent": "treeseed-release-verifier/1.0 (https://treeseed.dev)"
|
|
2489
|
+
},
|
|
2459
2490
|
signal: controller.signal
|
|
2460
2491
|
});
|
|
2461
2492
|
let json = null;
|
|
@@ -4019,6 +4050,7 @@ async function workflowSave(helpers, input) {
|
|
|
4019
4050
|
const autoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableSaveRun(root, branch) : null;
|
|
4020
4051
|
const planAutoResumeRun = executionMode === "plan" ? findAutoResumableSaveRun(root, branch) : null;
|
|
4021
4052
|
const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
|
|
4053
|
+
const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "save", effectiveInput);
|
|
4022
4054
|
const message = String(effectiveInput.message ?? "").trim();
|
|
4023
4055
|
const saveLane = normalizeSaveLane(effectiveInput.lane);
|
|
4024
4056
|
const saveCiMode = normalizeSaveCiMode(effectiveInput.ciMode, branch, saveLane);
|
|
@@ -4075,6 +4107,8 @@ async function workflowSave(helpers, input) {
|
|
|
4075
4107
|
failure: planAutoResumeRun.failure
|
|
4076
4108
|
} : null,
|
|
4077
4109
|
workspaceLinks,
|
|
4110
|
+
sceneArtifacts: normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts),
|
|
4111
|
+
localCleanup,
|
|
4078
4112
|
ciMode: saveCiMode,
|
|
4079
4113
|
lane: saveLane,
|
|
4080
4114
|
verifyMode: effectiveInput.verifyMode ?? "fast",
|
|
@@ -4854,6 +4888,7 @@ async function workflowStage(helpers, input) {
|
|
|
4854
4888
|
const autoResumeRun = rawAutoResumeRun?.steps.some((step) => step.id === "preflight") ? rawAutoResumeRun : null;
|
|
4855
4889
|
const planAutoResumeRun = executionMode === "plan" ? findAutoResumableTaskRun(root, "stage", session.branchName) : null;
|
|
4856
4890
|
const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
|
|
4891
|
+
const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "stage", effectiveInput);
|
|
4857
4892
|
const message = ensureMessage("stage", effectiveInput.message, "a resolution message");
|
|
4858
4893
|
if (effectiveInput.verifyDeployedResources === true) {
|
|
4859
4894
|
workflowError("stage", "validation_failed", "Stage no longer verifies deployed resources. Promote refs with stage, then run staging release/hosting verification separately.");
|
|
@@ -4881,6 +4916,8 @@ async function workflowStage(helpers, input) {
|
|
|
4881
4916
|
cleanupMode,
|
|
4882
4917
|
updateFrom,
|
|
4883
4918
|
waitForStaging: ciMode === "hosted",
|
|
4919
|
+
sceneArtifacts: normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts),
|
|
4920
|
+
localCleanup,
|
|
4884
4921
|
applicationSelection,
|
|
4885
4922
|
plan,
|
|
4886
4923
|
phases: plan.phases,
|
|
@@ -5123,13 +5160,15 @@ ${currentBlockers.map((entry) => `- ${entry}`).join("\n")}`, {
|
|
|
5123
5160
|
}
|
|
5124
5161
|
async function runReleaseGateReconcileFacade(operation, helpers, root, target, input, extraPayload = {}) {
|
|
5125
5162
|
const executionMode = normalizeExecutionMode(input);
|
|
5163
|
+
const reconcileEnv = { ...helpers.context.env, ...input.releaseImageRefs ?? {} };
|
|
5164
|
+
const includeHostedReleaseGates = input.includeHostedReleaseGates === true;
|
|
5126
5165
|
const selector = {
|
|
5127
5166
|
environment: target.kind === "persistent" ? target.scope : "staging",
|
|
5128
5167
|
resourceKind: ["release-gate"],
|
|
5129
5168
|
provider: ["treeseed"]
|
|
5130
5169
|
};
|
|
5131
5170
|
const desiredGraph = compileTreeseedDesiredResourceGraph({ tenantRoot: root, target });
|
|
5132
|
-
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"));
|
|
5133
5172
|
const rawUnitIds = new Set(rawUnits.map((unit) => unit.unitId));
|
|
5134
5173
|
const units = rawUnits.map((unit) => ({
|
|
5135
5174
|
...unit,
|
|
@@ -5142,7 +5181,7 @@ async function runReleaseGateReconcileFacade(operation, helpers, root, target, i
|
|
|
5142
5181
|
const plan = await planTreeseedReconciliation({
|
|
5143
5182
|
tenantRoot: root,
|
|
5144
5183
|
target,
|
|
5145
|
-
env:
|
|
5184
|
+
env: reconcileEnv,
|
|
5146
5185
|
units,
|
|
5147
5186
|
selector: unitSelector,
|
|
5148
5187
|
write: (line) => helpers.write(`[${operation}][reconcile] ${line}`, "stderr")
|
|
@@ -5165,7 +5204,7 @@ ${blockers.join("\n")}`, {
|
|
|
5165
5204
|
const result = executionMode === "execute" ? await reconcileTreeseedTarget({
|
|
5166
5205
|
tenantRoot: root,
|
|
5167
5206
|
target,
|
|
5168
|
-
env:
|
|
5207
|
+
env: reconcileEnv,
|
|
5169
5208
|
units,
|
|
5170
5209
|
selector: unitSelector,
|
|
5171
5210
|
dryRun: input.execute !== true,
|
|
@@ -5177,6 +5216,8 @@ ${blockers.join("\n")}`, {
|
|
|
5177
5216
|
target,
|
|
5178
5217
|
executionMode,
|
|
5179
5218
|
verifyDeployedResources: input.verifyDeployedResources === true,
|
|
5219
|
+
releaseImageRefs: input.releaseImageRefs ?? {},
|
|
5220
|
+
includeHostedReleaseGates,
|
|
5180
5221
|
desiredGraph,
|
|
5181
5222
|
units: units.map((unit) => ({
|
|
5182
5223
|
unitId: unit.unitId,
|
|
@@ -5217,6 +5258,7 @@ async function workflowRelease(helpers, input) {
|
|
|
5217
5258
|
...autoResumeRun.input,
|
|
5218
5259
|
ciMode: input.ciMode ?? autoResumeRun.input.ciMode
|
|
5219
5260
|
} : input;
|
|
5261
|
+
const localCleanup = maybeRunLocalWorkflowCleanup(helpers, root, "release", effectiveInput);
|
|
5220
5262
|
const level = effectiveInput.bump ?? "patch";
|
|
5221
5263
|
const ciMode = normalizeCiMode(effectiveInput.ciMode, "release");
|
|
5222
5264
|
const packageSelection = session.packageSelection;
|
|
@@ -5249,6 +5291,8 @@ async function workflowRelease(helpers, input) {
|
|
|
5249
5291
|
ciMode,
|
|
5250
5292
|
level,
|
|
5251
5293
|
fresh: input.fresh === true,
|
|
5294
|
+
sceneArtifacts: normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts),
|
|
5295
|
+
localCleanup,
|
|
5252
5296
|
freshArchivedRuns: [],
|
|
5253
5297
|
autoResumeCandidate: planAutoResumeRun ? {
|
|
5254
5298
|
runId: planAutoResumeRun.runId,
|
|
@@ -5342,7 +5386,8 @@ ${blockers.join("\n")}`, {
|
|
|
5342
5386
|
{ kind: "persistent", scope: "prod" },
|
|
5343
5387
|
{
|
|
5344
5388
|
execute: true,
|
|
5345
|
-
verifyDeployedResources: effectiveInput.verifyDeployedResources
|
|
5389
|
+
verifyDeployedResources: effectiveInput.verifyDeployedResources,
|
|
5390
|
+
releaseImageRefs: productionReleaseImageRefEnv(selectedVersions)
|
|
5346
5391
|
},
|
|
5347
5392
|
{
|
|
5348
5393
|
...releaseBasePayload,
|
|
@@ -5472,7 +5517,7 @@ ${rendered}`);
|
|
|
5472
5517
|
onProgress: (line, stream) => helpers.write(line, stream)
|
|
5473
5518
|
}).then((workflowGates) => ({ workflowGates })));
|
|
5474
5519
|
const publishedArtifacts = await executeJournalStep(root, workflowRun.runId, "verify-published-artifacts", () => verifyPublishedReleaseArtifacts(selectedVersions));
|
|
5475
|
-
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)));
|
|
5476
5521
|
const backMerge = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => {
|
|
5477
5522
|
const packageBackMerges = checkedOutWorkspacePackageRepos(root).filter((pkg) => selectedPackageSet.has(pkg.name)).map((pkg) => backMergeProductionIntoStaging(pkg.dir, pkg.name, releaseAdminMessage({
|
|
5478
5523
|
subject: `release: back-merge ${PRODUCTION_BRANCH} into ${STAGING_BRANCH}`,
|
|
@@ -5611,7 +5656,22 @@ async function workflowRecover(helpers, input = {}) {
|
|
|
5611
5656
|
try {
|
|
5612
5657
|
return await withContextEnv(helpers.context.env, async () => {
|
|
5613
5658
|
const root = resolveProjectRootOrThrow("recover", helpers.cwd());
|
|
5614
|
-
const
|
|
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;
|
|
5615
5675
|
const journals = listWorkflowRunJournals(root);
|
|
5616
5676
|
const session = resolveTreeseedWorkflowSession(root);
|
|
5617
5677
|
const currentHeads = Object.fromEntries(
|
|
@@ -5682,6 +5742,8 @@ async function workflowRecover(helpers, input = {}) {
|
|
|
5682
5742
|
root,
|
|
5683
5743
|
{
|
|
5684
5744
|
lock,
|
|
5745
|
+
locks: locks.map((entry) => ({ scope: entry.scope, ...entry.inspection })),
|
|
5746
|
+
clearedStaleLocks,
|
|
5685
5747
|
interruptedRuns,
|
|
5686
5748
|
staleRuns,
|
|
5687
5749
|
obsoleteRuns,
|
package/dist/workflow/runs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
const WORKFLOW_CONTROL_DIR = ".treeseed/workflow";
|
|
@@ -226,6 +226,80 @@ function writeWorkflowRunJournal(root, journal) {
|
|
|
226
226
|
function readWorkflowRunJournal(root, runId) {
|
|
227
227
|
return safeJsonParse(workflowRunPath(root, runId));
|
|
228
228
|
}
|
|
229
|
+
function jsonStringField(source, key) {
|
|
230
|
+
const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"`, "u");
|
|
231
|
+
const match = source.match(pattern);
|
|
232
|
+
if (!match) return null;
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(`"${match[1]}"`);
|
|
235
|
+
} catch {
|
|
236
|
+
return match[1];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function jsonBooleanField(source, key) {
|
|
240
|
+
const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`, "u");
|
|
241
|
+
const match = source.match(pattern);
|
|
242
|
+
return match ? match[1] === "true" : null;
|
|
243
|
+
}
|
|
244
|
+
function readFileEnds(path, bytes) {
|
|
245
|
+
const fd = openSync(path, "r");
|
|
246
|
+
try {
|
|
247
|
+
const stat = fstatSync(fd);
|
|
248
|
+
const headLength = Math.min(bytes, stat.size);
|
|
249
|
+
const tailLength = Math.min(bytes, stat.size);
|
|
250
|
+
const head = Buffer.alloc(headLength);
|
|
251
|
+
const tail = Buffer.alloc(tailLength);
|
|
252
|
+
readSync(fd, head, 0, headLength, 0);
|
|
253
|
+
readSync(fd, tail, 0, tailLength, Math.max(0, stat.size - tailLength));
|
|
254
|
+
return { size: stat.size, head: head.toString("utf8"), tail: tail.toString("utf8") };
|
|
255
|
+
} finally {
|
|
256
|
+
closeSync(fd);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function archivedWorkflowRunSummary(path) {
|
|
260
|
+
const { head, tail } = readFileEnds(path, 128 * 1024);
|
|
261
|
+
if (!tail.includes('"archivedAt"')) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const runId = jsonStringField(head, "runId");
|
|
265
|
+
const command = jsonStringField(head, "command");
|
|
266
|
+
const executionMode = jsonStringField(head, "executionMode");
|
|
267
|
+
const status = jsonStringField(head, "status");
|
|
268
|
+
const createdAt = jsonStringField(head, "createdAt");
|
|
269
|
+
const updatedAt = jsonStringField(head, "updatedAt");
|
|
270
|
+
const archivedAt = jsonStringField(tail, "archivedAt");
|
|
271
|
+
const classifiedAt = jsonStringField(tail, "classifiedAt") ?? archivedAt;
|
|
272
|
+
if (!runId || !command || !executionMode || !status || !createdAt || !updatedAt || !archivedAt || !classifiedAt) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: 1,
|
|
277
|
+
kind: "treeseed.workflow.run",
|
|
278
|
+
runId,
|
|
279
|
+
command,
|
|
280
|
+
executionMode,
|
|
281
|
+
status,
|
|
282
|
+
createdAt,
|
|
283
|
+
updatedAt,
|
|
284
|
+
resumable: jsonBooleanField(head, "resumable") ?? false,
|
|
285
|
+
input: {},
|
|
286
|
+
session: {
|
|
287
|
+
root: "",
|
|
288
|
+
mode: "root-only",
|
|
289
|
+
branchName: null,
|
|
290
|
+
repos: []
|
|
291
|
+
},
|
|
292
|
+
steps: [],
|
|
293
|
+
failure: null,
|
|
294
|
+
result: null,
|
|
295
|
+
classification: {
|
|
296
|
+
state: "obsolete",
|
|
297
|
+
reasons: ["workflow run was archived"],
|
|
298
|
+
classifiedAt,
|
|
299
|
+
archivedAt
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
229
303
|
function updateWorkflowRunJournal(root, runId, updater) {
|
|
230
304
|
const current = readWorkflowRunJournal(root, runId);
|
|
231
305
|
if (!current) {
|
|
@@ -480,7 +554,18 @@ function listWorkflowRunJournalsForScope(root, scope) {
|
|
|
480
554
|
if (!existsSync(runsDir)) {
|
|
481
555
|
return [];
|
|
482
556
|
}
|
|
483
|
-
return readdirSync(runsDir).filter((entry) => entry.endsWith(".json")).map((entry) =>
|
|
557
|
+
return readdirSync(runsDir).filter((entry) => entry.endsWith(".json")).map((entry) => {
|
|
558
|
+
const path = resolve(runsDir, entry);
|
|
559
|
+
try {
|
|
560
|
+
if (statSync(path).size > 5 * 1024 * 1024) {
|
|
561
|
+
const archivedSummary = archivedWorkflowRunSummary(path);
|
|
562
|
+
if (archivedSummary) return archivedSummary;
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
return safeJsonParse(path);
|
|
568
|
+
}).filter((entry) => entry != null).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
484
569
|
}
|
|
485
570
|
function listWorkflowRunJournals(root) {
|
|
486
571
|
const local = listWorkflowRunJournalsForScope(root, "worktree");
|
|
@@ -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';
|
package/dist/workflow-support.js
CHANGED
|
@@ -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,
|
package/dist/workflow.d.ts
CHANGED
|
@@ -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
|
};
|