@treeseed/sdk 0.12.44 → 0.12.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/guarantees/index.d.ts +83 -0
  2. package/dist/guarantees/index.js +688 -86
  3. package/dist/hosting/graph.js +40 -6
  4. package/dist/operations/services/git-workflow.d.ts +16 -1
  5. package/dist/operations/services/git-workflow.js +65 -5
  6. package/dist/operations/services/github-api.js +0 -19
  7. package/dist/operations/services/hosted-service-checks.js +17 -1
  8. package/dist/operations/services/live-hosted-service-checks.d.ts +4 -0
  9. package/dist/operations/services/live-hosted-service-checks.js +8 -5
  10. package/dist/operations/services/local-cleanup.js +3 -7
  11. package/dist/operations/services/package-adapters.d.ts +14 -0
  12. package/dist/operations/services/package-adapters.js +36 -3
  13. package/dist/operations/services/package-artifacts.d.ts +37 -0
  14. package/dist/operations/services/package-artifacts.js +99 -0
  15. package/dist/operations/services/railway-deploy.js +78 -18
  16. package/dist/operations/services/railway-source-policy.d.ts +19 -0
  17. package/dist/operations/services/railway-source-policy.js +66 -0
  18. package/dist/operations/services/repository-save-orchestrator.js +88 -19
  19. package/dist/operations/services/workspace-dependency-mode.js +4 -0
  20. package/dist/platform/desired-state.js +3 -3
  21. package/dist/reconcile/builtin-adapters.js +10 -2
  22. package/dist/reconcile/providers/railway-iac.d.ts +2 -0
  23. package/dist/reconcile/providers/railway-iac.js +31 -3
  24. package/dist/reconcile/providers/release-private.d.ts +10 -0
  25. package/dist/reconcile/providers/release-private.js +45 -1
  26. package/dist/scenes/builtin-plugins.js +36 -5
  27. package/dist/scenes/device-matrix.js +2 -0
  28. package/dist/scenes/environment.js +1 -1
  29. package/dist/scenes/runner.js +28 -16
  30. package/dist/scenes/schema.js +31 -2
  31. package/dist/scenes/types.d.ts +25 -2
  32. package/dist/scenes/visual-audit-fixtures.js +9 -3
  33. package/dist/workflow/operations.d.ts +27 -92
  34. package/dist/workflow/operations.js +252 -144
  35. package/dist/workflow/runs.d.ts +1 -0
  36. package/dist/workflow/runs.js +57 -0
  37. package/dist/workflow-support.d.ts +1 -0
  38. package/dist/workflow-support.js +8 -0
  39. package/dist/workflow.d.ts +2 -0
  40. package/package.json +4 -1
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { runRailwayIac } from "railway/iac";
4
+ import { assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "../../operations/services/railway-source-policy.js";
4
5
  function js(value) {
5
6
  return JSON.stringify(value);
6
7
  }
@@ -97,7 +98,13 @@ function renderPostgresEnv() {
97
98
  ["RAILWAY_DEPLOYMENT_DRAINING_SECONDS", js("60")]
98
99
  ]);
99
100
  }
101
+ function normalizeIacScope(input) {
102
+ if (input.scope === "prod" || input.scope === "staging") return input.scope;
103
+ const environmentName = String(input.environmentName ?? "").trim().toLowerCase();
104
+ return environmentName === "production" || environmentName === "prod" ? "prod" : environmentName === "staging" ? "staging" : "local";
105
+ }
100
106
  function renderRailwayIacProject(input) {
107
+ const scope = normalizeIacScope(input);
101
108
  const region = input.region?.trim() || "us-east4-eqdc4a";
102
109
  const tempParent = resolve(input.tenantRoot, ".treeseed", "tmp");
103
110
  mkdirSync(tempParent, { recursive: true });
@@ -145,6 +152,7 @@ function renderRailwayIacProject(input) {
145
152
  }
146
153
  }
147
154
  input.services.forEach((service, index) => {
155
+ assertApiRailwaySourcePolicy(scope, service);
148
156
  const serviceVar = id("svc", index);
149
157
  const invalidVariables = validateGeneratedVariables(service);
150
158
  if (invalidVariables.length > 0) {
@@ -232,7 +240,15 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
232
240
  const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
233
241
  for (const change of changeSet?.changes ?? []) {
234
242
  const name = changeName(change);
235
- const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[name.replace(/^(service|database)\./u, "")] ?? null;
243
+ const serviceName = name.replace(/^(service|database)\./u, "");
244
+ const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[serviceName] ?? null;
245
+ const sourceRef = desiredNames.serviceSourceRefs?.[name] ?? desiredNames.serviceSourceRefs?.[serviceName] ?? null;
246
+ const sourceChanged = change.kind === "resource.update" && isRailwaySourceChange(change);
247
+ const imageSourceChange = sourceChanged && isRailwayImageSourceChange(change);
248
+ const gitSourceChange = sourceChanged && isRailwayGitSourceChange(change);
249
+ const desiredGitSource = sourceMode === "git" && typeof sourceRef === "string" && sourceRef.startsWith("github:");
250
+ const desiredImageSource = sourceMode === "image" && typeof sourceRef === "string" && sourceRef.startsWith("image:");
251
+ const apiPolicyService = isApiRailwaySourcePolicyService({ serviceName });
236
252
  if (change.kind === "resource.delete") {
237
253
  destructiveChanges.push(change.summary);
238
254
  blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only updates or creates resources. Use the explicit destroy workflow for deletions.`);
@@ -240,12 +256,24 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
240
256
  blockedReasons.push(`Railway IaC plan would delete desired resource ${name}.`);
241
257
  }
242
258
  }
243
- if (desiredNames.scope === "staging" && change.kind === "resource.update" && isRailwayImageSourceChange(change) && (!sourceMode || sourceMode === "image")) {
259
+ if (desiredNames.scope === "staging" && sourceChanged && apiPolicyService && sourceMode === "git" && !gitSourceChange && !desiredGitSource) {
260
+ blockedReasons.push(`Railway IaC plan would change staging API resource ${name} source without confirming a GitHub source.`);
261
+ }
262
+ if (desiredNames.scope === "staging" && sourceChanged && imageSourceChange && !(apiPolicyService && sourceMode === "git" && (gitSourceChange || desiredGitSource))) {
244
263
  blockedReasons.push(`Railway IaC plan would switch staging resource ${name} to an image source.`);
245
264
  }
246
- if (desiredNames.scope === "prod" && change.kind === "resource.update" && isRailwayGitSourceChange(change) && (!sourceMode || sourceMode === "git")) {
265
+ if (desiredNames.scope === "staging" && sourceChanged && (!sourceMode || sourceMode === "image")) {
266
+ blockedReasons.push(`Railway IaC plan would apply an image-backed desired source to staging resource ${name}.`);
267
+ }
268
+ if (desiredNames.scope === "prod" && sourceChanged && apiPolicyService && sourceMode === "image" && !imageSourceChange && !desiredImageSource) {
269
+ blockedReasons.push(`Railway IaC plan would change production API resource ${name} source without confirming an image source.`);
270
+ }
271
+ if (desiredNames.scope === "prod" && sourceChanged && gitSourceChange) {
247
272
  blockedReasons.push(`Railway IaC plan would switch production resource ${name} to a Git source.`);
248
273
  }
274
+ if (desiredNames.scope === "prod" && sourceChanged && (!sourceMode || sourceMode === "git")) {
275
+ blockedReasons.push(`Railway IaC plan would apply a Git-backed desired source to production resource ${name}.`);
276
+ }
249
277
  }
250
278
  return {
251
279
  ok: blockedReasons.length === 0,
@@ -11,6 +11,7 @@ export declare function runReleaseVerifyCommand(input: {
11
11
  status?: undefined;
12
12
  signal?: undefined;
13
13
  command?: undefined;
14
+ dependencies?: undefined;
14
15
  stdout?: undefined;
15
16
  stderr?: undefined;
16
17
  } | {
@@ -18,6 +19,15 @@ export declare function runReleaseVerifyCommand(input: {
18
19
  status: number | null;
19
20
  signal: NodeJS.Signals | null;
20
21
  command: import("../../index.js").TreeseedPackageCommand;
22
+ dependencies: {
23
+ status: "not-applicable";
24
+ } | {
25
+ status: "ready";
26
+ } | {
27
+ status: "restored";
28
+ } | {
29
+ status: "workspace-linked";
30
+ };
21
31
  stdout: string;
22
32
  stderr: string;
23
33
  skipped?: undefined;
@@ -1,9 +1,43 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { findTreeseedPackageAdapter } from "../../operations/services/package-adapters.js";
5
5
  import { checkedOutTemplateRepositories } from "../../operations/services/managed-repositories.js";
6
6
  import { runTreeseedGitText } from "../../operations/services/git-runner.js";
7
+ import { ensureLocalWorkspaceLinks } from "../../operations/services/workspace-dependency-mode.js";
8
+ function ensureReleaseVerifyDependencies(input) {
9
+ if (!existsSync(resolve(input.packageDir, "package.json")) || !existsSync(resolve(input.packageDir, "package-lock.json"))) {
10
+ return { status: "not-applicable" };
11
+ }
12
+ const env = { ...process.env, ...input.env ?? {} };
13
+ const inspection = spawnSync("npm", ["ls", "--depth=0", "--workspaces=false"], {
14
+ cwd: input.packageDir,
15
+ env,
16
+ encoding: "utf8"
17
+ });
18
+ if (inspection.status === 0) return { status: "ready" };
19
+ input.onProgress?.("Package dependencies are incomplete; restoring the standalone lockfile installation.");
20
+ const install = spawnSync("npm", [
21
+ "ci",
22
+ "--ignore-scripts",
23
+ "--workspaces=false",
24
+ "--no-audit",
25
+ "--no-fund"
26
+ ], {
27
+ cwd: input.packageDir,
28
+ env,
29
+ encoding: "utf8"
30
+ });
31
+ if (install.status !== 0) {
32
+ throw new Error([
33
+ "Standalone package dependency hydration failed before release verification.",
34
+ install.stderr,
35
+ install.stdout
36
+ ].filter(Boolean).join("\n").trim());
37
+ }
38
+ ensureLocalWorkspaceLinks(input.tenantRoot, { env: input.env });
39
+ return { status: "restored" };
40
+ }
7
41
  async function runReleaseVerifyCommand(input) {
8
42
  const adapter = findTreeseedPackageAdapter(input.tenantRoot, input.packageId);
9
43
  if (!adapter) {
@@ -17,6 +51,15 @@ async function runReleaseVerifyCommand(input) {
17
51
  reason: `${input.packageId} has no release verify command.`
18
52
  };
19
53
  }
54
+ const dependencies = adapter.capabilities.localOnly ? (() => {
55
+ ensureLocalWorkspaceLinks(input.tenantRoot, { env: input.env });
56
+ return { status: "workspace-linked" };
57
+ })() : ensureReleaseVerifyDependencies({
58
+ tenantRoot: input.tenantRoot,
59
+ packageDir: adapter.dir,
60
+ env: input.env,
61
+ onProgress: input.onProgress
62
+ });
20
63
  const renderedCommand = [command.command, ...command.args].join(" ");
21
64
  input.onProgress?.(`Running ${input.packageId} release verification: ${renderedCommand}`);
22
65
  const started = Date.now();
@@ -53,6 +96,7 @@ async function runReleaseVerifyCommand(input) {
53
96
  status: result.status,
54
97
  signal: result.signal,
55
98
  command,
99
+ dependencies,
56
100
  stdout,
57
101
  stderr
58
102
  };
@@ -15,6 +15,19 @@ function mailpitApiUrl(mailpitUrl, pathname) {
15
15
  function stringValue(value) {
16
16
  return typeof value === "string" ? value : "";
17
17
  }
18
+ function shortRuntimeHash(value) {
19
+ let hash = 2166136261;
20
+ for (let index = 0; index < value.length; index += 1) {
21
+ hash ^= value.charCodeAt(index);
22
+ hash = Math.imul(hash, 16777619);
23
+ }
24
+ return (hash >>> 0).toString(36).padStart(7, "0").slice(0, 10);
25
+ }
26
+ function sceneRuntimeValue(value, context) {
27
+ const runSlug = context.runId.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 48);
28
+ const runShort = shortRuntimeHash(context.runId);
29
+ return value.replace(/\{\{\s*runId\s*\}\}/gu, context.runId).replace(/\{\{\s*runSlug\s*\}\}/gu, runSlug).replace(/\{\{\s*runShort\s*\}\}/gu, runShort);
30
+ }
18
31
  function mailpitMessageId(value) {
19
32
  if (!value || typeof value !== "object") return "";
20
33
  const record = value;
@@ -57,6 +70,16 @@ function extractConfirmationUrl(body) {
57
70
  const relative = body.match(/(?:\/auth\/confirm-email\?[^"' <>\n]+|\/team-invites\/[^"' <>\n]+\/accept)/u)?.[0];
58
71
  return relative ?? null;
59
72
  }
73
+ function resolveMailpitConfirmationUrl(confirmationUrl, context) {
74
+ try {
75
+ const parsed = new URL(confirmationUrl);
76
+ if (parsed.pathname === "/auth/confirm-email" || /^\/team-invites\/[^/]+\/accept$/u.test(parsed.pathname)) {
77
+ return context.resolveUrl(`${parsed.pathname}${parsed.search}${parsed.hash}`);
78
+ }
79
+ } catch {
80
+ }
81
+ return confirmationUrl.startsWith("/") ? context.resolveUrl(confirmationUrl) : confirmationUrl;
82
+ }
60
83
  async function sleep(ms) {
61
84
  await new Promise((resolve) => setTimeout(resolve, ms));
62
85
  }
@@ -68,7 +91,11 @@ async function navigateScenePage(page, url) {
68
91
  let lastError = null;
69
92
  for (let attempt = 1; attempt <= 3; attempt += 1) {
70
93
  try {
71
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
94
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
95
+ const status = response?.status();
96
+ if (typeof status === "number" && status >= 400) {
97
+ throw sceneErrorDiagnostic("scene.navigation_http_error", `Navigation to ${response?.url() ?? url} returned HTTP ${status}.`, "workflow.action.goto");
98
+ }
72
99
  return;
73
100
  } catch (error) {
74
101
  lastError = error;
@@ -76,7 +103,7 @@ async function navigateScenePage(page, url) {
76
103
  await sleep(500 * attempt);
77
104
  }
78
105
  }
79
- throw lastError instanceof Error ? lastError : new Error(String(lastError ?? `Navigation failed for ${url}`));
106
+ throw lastError && typeof lastError === "object" && "code" in lastError ? lastError : lastError instanceof Error ? lastError : new Error(String(lastError ?? `Navigation failed for ${url}`));
80
107
  }
81
108
  async function assertionReport(kind, action, selector) {
82
109
  const startedAt = /* @__PURE__ */ new Date();
@@ -132,7 +159,7 @@ function createBuiltInTreeseedScenePlugins() {
132
159
  if (!("fill" in action)) return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.invalid_action", "Expected fill action.", "workflow.action.fill")] };
133
160
  const locator = context.resolveSelector(action.fill);
134
161
  await locator.waitFor({ state: "visible", timeout: 1e4 });
135
- await locator.fill(action.fill.value);
162
+ await locator.fill(sceneRuntimeValue(action.fill.value, context));
136
163
  return { ok: true, diagnostics: [] };
137
164
  }
138
165
  },
@@ -208,7 +235,11 @@ function createBuiltInTreeseedScenePlugins() {
208
235
  summary: "Confirm the latest local Mailpit email by navigating the browser to its confirmation link.",
209
236
  async run({ action, step, context }) {
210
237
  if (!("mailpitConfirmLatest" in action)) return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.invalid_action", "Expected mailpitConfirmLatest action.", `workflow.${step.id}.action.mailpitConfirmLatest`)] };
211
- const { mailpitUrl, email, subjectIncludes, displayInboxSeconds, displayMessageSeconds } = action.mailpitConfirmLatest;
238
+ const raw = action.mailpitConfirmLatest;
239
+ const mailpitUrl = sceneRuntimeValue(raw.mailpitUrl, context);
240
+ const email = sceneRuntimeValue(raw.email, context);
241
+ const subjectIncludes = raw.subjectIncludes ? sceneRuntimeValue(raw.subjectIncludes, context) : void 0;
242
+ const { displayInboxSeconds, displayMessageSeconds } = raw;
212
243
  try {
213
244
  const listResponse = await fetch(mailpitApiUrl(mailpitUrl, "/api/v1/messages"));
214
245
  if (!listResponse.ok) {
@@ -233,7 +264,7 @@ function createBuiltInTreeseedScenePlugins() {
233
264
  if (!confirmationUrl) {
234
265
  return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.mailpit_confirm_link_not_found", `No confirmation link was found in Mailpit message ${id}.`, `workflow.${step.id}.action.mailpitConfirmLatest`)] };
235
266
  }
236
- const resolvedUrl = confirmationUrl.startsWith("/") ? context.resolveUrl(confirmationUrl) : confirmationUrl;
267
+ const resolvedUrl = resolveMailpitConfirmationUrl(confirmationUrl, context);
237
268
  const mailpitBase = mailpitUrl.endsWith("/") ? mailpitUrl : `${mailpitUrl}/`;
238
269
  if (displayInboxSeconds && displayInboxSeconds > 0) {
239
270
  const search = new URL("search", mailpitBase);
@@ -63,6 +63,8 @@ async function runTreeseedSceneDeviceMatrix(input) {
63
63
  scene: input.scene,
64
64
  environment: input.environment,
65
65
  device,
66
+ browser: input.browser,
67
+ authRole: input.authRole,
66
68
  record: input.record,
67
69
  artifactMode: input.artifactMode,
68
70
  mode: input.mode,
@@ -39,7 +39,7 @@ async function prepareTreeseedSceneEnvironment(input) {
39
39
  baseUrl = healthUrl(existing);
40
40
  } else {
41
41
  try {
42
- const result = await startTreeseedManagedDev({ cwd: input.projectRoot, surfaces: "web,api,operations-runner", webRuntime: "local", env: input.env });
42
+ const result = await startTreeseedManagedDev({ cwd: input.projectRoot, surfaces: "web,api", webRuntime: "local", env: input.env });
43
43
  instances = result.instances;
44
44
  const web = result.instances.find((entry) => entry.surface === "web" || entry.id === "web");
45
45
  started = Boolean(web && running(web));
@@ -1,4 +1,4 @@
1
- import { copyFileSync, writeFileSync } from "node:fs";
1
+ import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { sceneActionKind, sceneExpectationKinds } from "./schema.js";
4
4
  import { planTreeseedScene, validateTreeseedScene } from "./planner.js";
@@ -126,6 +126,7 @@ function planForInput(input, validation) {
126
126
  }
127
127
  const scene = validation.scene;
128
128
  const environment = input.environment ?? scene.target.environment;
129
+ const browser = input.browser ?? scene.target.browser;
129
130
  const workflowSteps = scene.workflow.map((step) => ({
130
131
  id: step.id,
131
132
  title: step.title,
@@ -145,7 +146,7 @@ function planForInput(input, validation) {
145
146
  title: scene.title,
146
147
  environment,
147
148
  baseUrl: scene.target.baseUrl,
148
- browser: scene.target.browser,
149
+ browser,
149
150
  viewport: scene.target.viewport,
150
151
  workflowSteps,
151
152
  enabledActions: actionIds,
@@ -174,6 +175,20 @@ function planForInput(input, validation) {
174
175
  function canContinueAfterFailure(scene, step) {
175
176
  return scene.runtime.mode === "demo" || scene.runtime.mode === "training" || step.demoOnly === true || step.continueOnFailure === true || scene.runtime.failure.continueOnFailure === true;
176
177
  }
178
+ function sceneWithRunOverrides(scene, input) {
179
+ if (!input.authRole) return scene;
180
+ return {
181
+ ...scene,
182
+ setup: {
183
+ ...scene.setup,
184
+ auth: {
185
+ ...scene.setup.auth ?? {},
186
+ required: input.authRole !== "anonymous",
187
+ role: scene.setup.auth?.role ?? input.authRole
188
+ }
189
+ }
190
+ };
191
+ }
177
192
  async function runTreeseedScene(input) {
178
193
  const startedAt = now();
179
194
  const validation = validationForInput(input);
@@ -188,7 +203,8 @@ async function runTreeseedScene(input) {
188
203
  diagnostics: validation.diagnostics
189
204
  });
190
205
  }
191
- const scene = validation.scene;
206
+ const scene = sceneWithRunOverrides(validation.scene, input);
207
+ const browser = input.browser ?? scene.target.browser;
192
208
  const deviceResolution = resolveTreeseedSceneDeviceProfile({ scene, device: input.device });
193
209
  if (!deviceResolution.profile && deviceResolution.diagnostics.some((entry) => entry.severity === "error")) {
194
210
  return reportFromBlock({
@@ -197,7 +213,7 @@ async function runTreeseedScene(input) {
197
213
  runId: input.runId ?? null,
198
214
  startedAt,
199
215
  environment: input.environment ?? scene.target.environment,
200
- browser: scene.target.browser,
216
+ browser,
201
217
  diagnostics: deviceResolution.diagnostics
202
218
  });
203
219
  }
@@ -214,7 +230,7 @@ async function runTreeseedScene(input) {
214
230
  runId: input.runId ?? null,
215
231
  startedAt,
216
232
  environment: plan.environment,
217
- browser: scene.target.browser,
233
+ browser,
218
234
  device,
219
235
  diagnostics: plan.diagnostics
220
236
  });
@@ -263,7 +279,7 @@ async function runTreeseedScene(input) {
263
279
  runId: paths.runId,
264
280
  startedAt,
265
281
  environment: plan.environment,
266
- browser: scene.target.browser,
282
+ browser,
267
283
  device,
268
284
  diagnostics: [...plan.diagnostics, ...setupDiagnostics],
269
285
  artifacts,
@@ -289,7 +305,7 @@ async function runTreeseedScene(input) {
289
305
  runId: paths.runId,
290
306
  startedAt,
291
307
  environment: plan.environment,
292
- browser: scene.target.browser,
308
+ browser,
293
309
  device,
294
310
  diagnostics: [...plan.diagnostics, ...setupDiagnostics, ...baseUrl.diagnostics],
295
311
  artifacts,
@@ -338,7 +354,7 @@ async function runTreeseedScene(input) {
338
354
  let sessionClosed = false;
339
355
  try {
340
356
  session = await adapter.launch({
341
- browser: scene.target.browser,
357
+ browser,
342
358
  viewport: capture.viewport,
343
359
  videoSize: capture.videoSize,
344
360
  recordVideoDir: videoDir,
@@ -380,7 +396,7 @@ async function runTreeseedScene(input) {
380
396
  timeline.push("network", entry, currentStepId);
381
397
  });
382
398
  if (tracePath) await session.startTracing?.();
383
- if (scene.setup.auth?.role && scene.setup.auth.role !== "anonymous") {
399
+ if (scene.setup.auth?.role && scene.setup.auth.role !== "anonymous" && scene.setup.auth.seedOnly !== true) {
384
400
  const signInDiagnostics = await signInTreeseedSceneVisualAuditRole({
385
401
  page: session.page,
386
402
  baseUrl: baseUrl.baseUrl,
@@ -446,6 +462,7 @@ async function runTreeseedScene(input) {
446
462
  projectRoot: input.projectRoot,
447
463
  scene,
448
464
  environment: plan.environment,
465
+ runId: paths.runId,
449
466
  session,
450
467
  baseUrl: baseUrl.baseUrl,
451
468
  timeline,
@@ -507,15 +524,10 @@ async function runTreeseedScene(input) {
507
524
  await session.page.screenshot({ path: viewportScreenshotPath, fullPage: false });
508
525
  artifacts.viewportScreenshotPaths?.push(viewportScreenshotPath);
509
526
  timeline.push("screenshot.viewport", { path: viewportScreenshotPath }, step.id);
510
- if (screenshotPath && recordingVideo) {
511
- copyFileSync(viewportScreenshotPath, screenshotPath);
512
- artifacts.screenshotPaths.push(screenshotPath);
513
- timeline.push("screenshot", { path: screenshotPath, captureKind: "viewport-copy" }, step.id);
514
- }
515
527
  } catch {
516
528
  }
517
529
  }
518
- if (screenshotPath && !recordingVideo) {
530
+ if (screenshotPath) {
519
531
  try {
520
532
  await session.page.screenshot({ path: screenshotPath, fullPage: true });
521
533
  artifacts.screenshotPaths.push(screenshotPath);
@@ -637,7 +649,7 @@ async function runTreeseedScene(input) {
637
649
  durationMs: duration(startedAt, finishedAt),
638
650
  environment: plan.environment,
639
651
  baseUrl: baseUrl.baseUrl,
640
- browser: scene.target.browser,
652
+ browser,
641
653
  device,
642
654
  capture,
643
655
  workflowStatus,
@@ -6,7 +6,7 @@ import {
6
6
  TREESEED_SCENE_SCHEMA_VERSION
7
7
  } from "./types.js";
8
8
  const FILESYSTEM_SAFE_SCENE_ID = /^[a-z0-9][a-z0-9._-]*$/u;
9
- const TOP_LEVEL_FIELDS = /* @__PURE__ */ new Set(["schemaVersion", "id", "title", "description", "audience", "mode", "target", "devices", "setup", "artifacts", "workflow", "chapters", "overlays", "diagrams", "render", "runtime", "training", "visualAudit", "xScenario"]);
9
+ const TOP_LEVEL_FIELDS = /* @__PURE__ */ new Set(["schemaVersion", "id", "title", "description", "audience", "journey", "mode", "target", "devices", "setup", "artifacts", "workflow", "chapters", "overlays", "diagrams", "render", "runtime", "training", "visualAudit", "xScenario"]);
10
10
  const FILESYSTEM_SAFE_CHECKPOINT_ID = /^[a-z0-9][a-z0-9._-]*$/u;
11
11
  const DIAGRAM_PLACEMENTS = ["overlay", "interstitial", "standalone"];
12
12
  const CAPTION_FORMATS = ["vtt", "srt"];
@@ -108,6 +108,34 @@ function stringArrayField(record, field, path, diagnostics) {
108
108
  });
109
109
  return strings;
110
110
  }
111
+ function stateRefArray(record, field, path, diagnostics) {
112
+ const value = arrayField(record, field, path, diagnostics);
113
+ if (!value) return void 0;
114
+ const refs = [];
115
+ value.forEach((entry, index) => {
116
+ const entryPath = `${path}.${field}[${index}]`;
117
+ if (!isRecord(entry)) {
118
+ diagnostics.push(sceneErrorDiagnostic("scene.invalid_state_ref", `Expected ${field} entry to be an object.`, entryPath));
119
+ return;
120
+ }
121
+ refs.push({ key: requireString(entry, "key", entryPath, diagnostics), kind: requireString(entry, "kind", entryPath, diagnostics) });
122
+ });
123
+ return refs;
124
+ }
125
+ function parseJourney(record, diagnostics) {
126
+ const journey = objectField(record, "journey", "manifest", diagnostics);
127
+ if (!journey) return void 0;
128
+ const kind = optionalString(journey, "kind");
129
+ if (kind && !["service", "page", "visual-audit"].includes(kind)) diagnostics.push(sceneErrorDiagnostic("scene.invalid_journey_kind", `Unsupported journey kind: ${kind}.`, "journey.kind"));
130
+ return {
131
+ kind: kind === "page" || kind === "visual-audit" ? kind : "service",
132
+ proves: stringArrayField(journey, "proves", "journey", diagnostics),
133
+ minimumSteps: positiveNumberField(journey, "minimumSteps", void 0, "journey", diagnostics),
134
+ requiresInteractiveAction: booleanField(journey, "requiresInteractiveAction", false, "journey", diagnostics),
135
+ producesState: stateRefArray(journey, "producesState", "journey", diagnostics),
136
+ consumesState: stateRefArray(journey, "consumesState", "journey", diagnostics)
137
+ };
138
+ }
111
139
  function enumArrayField(record, field, allowed, defaultValue, path, diagnostics) {
112
140
  const value = record[field];
113
141
  if (value === void 0) return defaultValue;
@@ -379,7 +407,7 @@ function parseSetup(value, targetEnvironment, diagnostics) {
379
407
  const dev = objectField(record, "dev", "setup", diagnostics);
380
408
  if (dev) setup.dev = { required: booleanField(dev, "required", false, "setup.dev", diagnostics), command: optionalString(dev, "command"), reuseExisting: booleanField(dev, "reuseExisting", true, "setup.dev", diagnostics) };
381
409
  const auth = objectField(record, "auth", "setup", diagnostics);
382
- if (auth) setup.auth = { profile: optionalString(auth, "profile"), required: booleanField(auth, "required", false, "setup.auth", diagnostics), ...optionalString(auth, "role") ? { role: optionalString(auth, "role") } : {} };
410
+ if (auth) setup.auth = { profile: optionalString(auth, "profile"), required: booleanField(auth, "required", false, "setup.auth", diagnostics), seedOnly: booleanField(auth, "seedOnly", false, "setup.auth", diagnostics), ...optionalString(auth, "role") ? { role: optionalString(auth, "role") } : {} };
383
411
  const seed = objectField(record, "seed", "setup", diagnostics);
384
412
  if (seed) {
385
413
  const environments = (arrayField(seed, "environments", "setup.seed", diagnostics) ?? [targetEnvironment]).map((entry, index) => parseEnvironment(entry, `setup.seed.environments[${index}]`, diagnostics, targetEnvironment));
@@ -970,6 +998,7 @@ function parseTreeseedSceneManifest(value, diagnostics) {
970
998
  title,
971
999
  description: optionalString(value, "description"),
972
1000
  audience: stringArrayField(value, "audience", "manifest", diagnostics),
1001
+ journey: parseJourney(value, diagnostics),
973
1002
  mode,
974
1003
  target,
975
1004
  devices,
@@ -99,6 +99,7 @@ export type TreeseedSceneSetup = {
99
99
  profile?: string;
100
100
  required: boolean;
101
101
  role?: TreeseedSceneVisualAuditRole;
102
+ seedOnly?: boolean;
102
103
  };
103
104
  seed?: {
104
105
  name?: string;
@@ -393,6 +394,20 @@ export type TreeseedSceneManifest = {
393
394
  title: string;
394
395
  description?: string;
395
396
  audience: string[];
397
+ journey?: {
398
+ kind: 'service' | 'page' | 'visual-audit';
399
+ proves?: string[];
400
+ minimumSteps?: number;
401
+ requiresInteractiveAction?: boolean;
402
+ producesState?: Array<{
403
+ key: string;
404
+ kind: string;
405
+ }>;
406
+ consumesState?: Array<{
407
+ key: string;
408
+ kind: string;
409
+ }>;
410
+ };
396
411
  mode: TreeseedSceneMode;
397
412
  target: TreeseedSceneTarget;
398
413
  devices: TreeseedSceneDeviceConfig;
@@ -469,6 +484,8 @@ export type TreeseedSceneRunOptions = {
469
484
  scene: string | TreeseedSceneManifest;
470
485
  environment?: TreeseedSceneEnvironment;
471
486
  device?: TreeseedSceneDeviceProfileId;
487
+ browser?: TreeseedSceneBrowser;
488
+ authRole?: TreeseedSceneVisualAuditRole;
472
489
  record?: boolean;
473
490
  artifactMode?: 'full' | 'screenshots';
474
491
  runId?: string;
@@ -491,6 +508,8 @@ export type TreeseedSceneDeviceMatrixOptions = {
491
508
  scene: string;
492
509
  environment?: TreeseedSceneEnvironment;
493
510
  devices?: TreeseedSceneDeviceProfileId[];
511
+ browser?: TreeseedSceneBrowser;
512
+ authRole?: TreeseedSceneVisualAuditRole;
494
513
  record?: boolean;
495
514
  artifactMode?: 'full' | 'screenshots';
496
515
  mode?: TreeseedSceneExecutionMode;
@@ -967,6 +986,7 @@ export type TreeseedSceneOperationWaitOptions = {
967
986
  projectRoot: string;
968
987
  scene: TreeseedSceneManifest;
969
988
  environment: TreeseedSceneEnvironment;
989
+ runId: string;
970
990
  baseUrl: string;
971
991
  spec: TreeseedSceneOperationWaitSpec;
972
992
  linkedOperationIds?: string[];
@@ -1812,9 +1832,12 @@ export type TreeseedSceneLocator = {
1812
1832
  };
1813
1833
  export type TreeseedScenePage = {
1814
1834
  goto(url: string, options?: {
1815
- waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
1835
+ waitUntil?: 'load' | 'domcontentLoaded' | 'domcontentloaded' | 'networkidle';
1816
1836
  timeout?: number;
1817
- }): Promise<void>;
1837
+ }): Promise<{
1838
+ status(): number;
1839
+ url(): string;
1840
+ } | null | undefined>;
1818
1841
  url(): string;
1819
1842
  locator(selector: string): TreeseedSceneLocator;
1820
1843
  getByTestId(testId: string): TreeseedSceneLocator;
@@ -52,10 +52,11 @@ function configuredValue(input, name) {
52
52
  }
53
53
  }
54
54
  function serviceHeaders(input) {
55
+ const localDevServiceSecret = input?.environment === "local" ? "treeseed-web-service-dev-secret" : null;
55
56
  return {
56
57
  "content-type": "application/json",
57
58
  "x-treeseed-service-id": configuredValue(input, "TREESEED_API_WEB_SERVICE_ID") ?? configuredValue(input, "TREESEED_WEB_SERVICE_ID") ?? "web",
58
- "x-treeseed-service-secret": configuredValue(input, "TREESEED_API_WEB_SERVICE_SECRET") ?? configuredValue(input, "TREESEED_WEB_SERVICE_SECRET") ?? "treeseed-web-service-dev-secret"
59
+ "x-treeseed-service-secret": process.env.TREESEED_API_WEB_SERVICE_SECRET?.trim() ?? process.env.TREESEED_WEB_SERVICE_SECRET?.trim() ?? localDevServiceSecret ?? configuredValue(input, "TREESEED_API_WEB_SERVICE_SECRET") ?? configuredValue(input, "TREESEED_WEB_SERVICE_SECRET") ?? "treeseed-web-service-dev-secret"
59
60
  };
60
61
  }
61
62
  async function sleep(ms) {
@@ -123,12 +124,13 @@ async function seedVisualAuditFixtures(input) {
123
124
  async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
124
125
  const diagnostics = [];
125
126
  const roles = [...new Set(input.roles)].filter((role) => role !== "anonymous");
126
- diagnostics.push(...await seedVisualAuditFixtures({
127
+ const seedDiagnostics = await seedVisualAuditFixtures({
127
128
  baseUrl: input.baseUrl,
128
129
  roles,
129
130
  projectRoot: input.projectRoot,
130
131
  environment: input.environment
131
- }));
132
+ });
133
+ let roleSetupFailed = false;
132
134
  for (const role of roles) {
133
135
  const user = treeseedSceneVisualAuditUserForRole(role);
134
136
  if (!user) continue;
@@ -138,6 +140,7 @@ async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
138
140
  continue;
139
141
  } catch (error) {
140
142
  if (!isAuthFailure(error)) {
143
+ roleSetupFailed = true;
141
144
  diagnostics.push(sceneWarningDiagnostic(
142
145
  "scene.visual_audit_fixture_unavailable",
143
146
  `Visual audit fixture setup for ${role} failed against ${input.baseUrl}: ${error instanceof Error ? error.message : String(error ?? "local fixture API is unavailable")}. Authenticated screenshots require the local API and database to be healthy.`,
@@ -160,12 +163,14 @@ async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
160
163
  if (payload.confirmationToken) {
161
164
  await client.confirmWebEmail({ token: payload.confirmationToken });
162
165
  } else if (payload.confirmationRequired) {
166
+ roleSetupFailed = true;
163
167
  diagnostics.push(sceneWarningDiagnostic("scene.visual_audit_fixture_unavailable", `Visual audit fixture user ${user.email} requires email confirmation, but the local API did not return a confirmation token.`, "roles"));
164
168
  }
165
169
  } catch (error) {
166
170
  try {
167
171
  await client.webSignIn({ login: user.email, password: user.password });
168
172
  } catch {
173
+ roleSetupFailed = true;
169
174
  diagnostics.push(sceneWarningDiagnostic(
170
175
  "scene.visual_audit_fixture_unavailable",
171
176
  `Visual audit fixture setup for ${role} failed against ${input.baseUrl}: ${error instanceof Error ? error.message : String(error ?? "local fixture API is unavailable")}. Authenticated screenshots require the local API and database to be healthy.`,
@@ -174,6 +179,7 @@ async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
174
179
  }
175
180
  }
176
181
  }
182
+ if (roleSetupFailed) diagnostics.unshift(...seedDiagnostics);
177
183
  return diagnostics;
178
184
  }
179
185
  async function signInTreeseedSceneVisualAuditRole(input) {