@treeseed/sdk 0.10.28 → 0.11.0

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 (148) hide show
  1. package/README.md +207 -6
  2. package/dist/capacity-provider.d.ts +3 -1
  3. package/dist/capacity-provider.js +25 -5
  4. package/dist/control-plane.d.ts +1 -0
  5. package/dist/control-plane.js +38 -13
  6. package/dist/db/market-schema.d.ts +8860 -6172
  7. package/dist/db/market-schema.js +108 -0
  8. package/dist/db/node-sqlite.js +7 -2
  9. package/dist/hosting/apps.d.ts +12 -0
  10. package/dist/hosting/apps.js +107 -0
  11. package/dist/hosting/builtins.d.ts +25 -0
  12. package/dist/hosting/builtins.js +791 -0
  13. package/dist/hosting/contracts.d.ts +207 -0
  14. package/dist/hosting/contracts.js +0 -0
  15. package/dist/hosting/graph.d.ts +192 -0
  16. package/dist/hosting/graph.js +1106 -0
  17. package/dist/hosting/index.d.ts +4 -0
  18. package/dist/hosting/index.js +4 -0
  19. package/dist/index.d.ts +10 -3
  20. package/dist/index.js +63 -6
  21. package/dist/managed-dependencies.js +1 -2
  22. package/dist/market-client.d.ts +63 -3
  23. package/dist/market-client.js +83 -11
  24. package/dist/operations/services/bootstrap-runner.d.ts +3 -1
  25. package/dist/operations/services/bootstrap-runner.js +22 -2
  26. package/dist/operations/services/config-runtime.d.ts +10 -5
  27. package/dist/operations/services/config-runtime.js +209 -66
  28. package/dist/operations/services/deploy.d.ts +70 -7
  29. package/dist/operations/services/deploy.js +579 -64
  30. package/dist/operations/services/deployment-readiness.d.ts +30 -0
  31. package/dist/operations/services/deployment-readiness.js +175 -0
  32. package/dist/operations/services/git-workflow.d.ts +2 -1
  33. package/dist/operations/services/git-workflow.js +9 -3
  34. package/dist/operations/services/github-actions-verification.d.ts +1 -0
  35. package/dist/operations/services/github-actions-verification.js +1 -0
  36. package/dist/operations/services/github-api.js +1 -1
  37. package/dist/operations/services/github-automation.d.ts +1 -1
  38. package/dist/operations/services/github-automation.js +4 -3
  39. package/dist/operations/services/github-credentials.d.ts +13 -0
  40. package/dist/operations/services/github-credentials.js +58 -0
  41. package/dist/operations/services/hosted-service-checks.d.ts +63 -0
  42. package/dist/operations/services/hosted-service-checks.js +327 -0
  43. package/dist/operations/services/hub-provider-launch.js +3 -3
  44. package/dist/operations/services/live-hosted-service-checks.d.ts +25 -0
  45. package/dist/operations/services/live-hosted-service-checks.js +350 -0
  46. package/dist/operations/services/managed-host-security.js +1 -1
  47. package/dist/operations/services/operations-runner-smoke.d.ts +30 -0
  48. package/dist/operations/services/operations-runner-smoke.js +180 -0
  49. package/dist/operations/services/package-adapters.d.ts +95 -0
  50. package/dist/operations/services/package-adapters.js +288 -0
  51. package/dist/operations/services/package-reference-policy.d.ts +1 -0
  52. package/dist/operations/services/package-reference-policy.js +15 -2
  53. package/dist/operations/services/project-platform.d.ts +80 -22
  54. package/dist/operations/services/project-platform.js +49 -8
  55. package/dist/operations/services/project-web-monitor.js +26 -4
  56. package/dist/operations/services/railway-api.d.ts +88 -5
  57. package/dist/operations/services/railway-api.js +626 -35
  58. package/dist/operations/services/railway-deploy.d.ts +46 -40
  59. package/dist/operations/services/railway-deploy.js +261 -293
  60. package/dist/operations/services/release-candidate.d.ts +19 -0
  61. package/dist/operations/services/release-candidate.js +375 -38
  62. package/dist/operations/services/repository-save-orchestrator.d.ts +3 -1
  63. package/dist/operations/services/repository-save-orchestrator.js +279 -66
  64. package/dist/operations/services/runtime-tools.d.ts +1 -0
  65. package/dist/operations/services/runtime-tools.js +10 -9
  66. package/dist/operations/services/verification-cache.d.ts +25 -0
  67. package/dist/operations/services/verification-cache.js +71 -0
  68. package/dist/operations/services/workspace-dependency-mode.js +9 -1
  69. package/dist/operations/services/workspace-save.js +1 -1
  70. package/dist/operations/services/workspace-tools.js +2 -1
  71. package/dist/platform/contracts.d.ts +32 -1
  72. package/dist/platform/deploy-config.js +73 -8
  73. package/dist/platform/env.yaml +163 -35
  74. package/dist/platform/environment.d.ts +1 -0
  75. package/dist/platform/environment.js +74 -5
  76. package/dist/platform/plugin.d.ts +9 -0
  77. package/dist/platform-operation-store.js +2 -2
  78. package/dist/platform-operations.js +1 -1
  79. package/dist/reconcile/bootstrap-systems.js +2 -2
  80. package/dist/reconcile/builtin-adapters.js +372 -189
  81. package/dist/reconcile/contracts.d.ts +9 -5
  82. package/dist/reconcile/desired-state.d.ts +1 -0
  83. package/dist/reconcile/desired-state.js +5 -5
  84. package/dist/reconcile/engine.d.ts +5 -2
  85. package/dist/reconcile/engine.js +53 -32
  86. package/dist/reconcile/index.d.ts +2 -0
  87. package/dist/reconcile/index.js +2 -0
  88. package/dist/reconcile/live-acceptance.d.ts +79 -0
  89. package/dist/reconcile/live-acceptance.js +1615 -0
  90. package/dist/reconcile/platform.d.ts +104 -0
  91. package/dist/reconcile/platform.js +100 -0
  92. package/dist/reconcile/state.js +4 -4
  93. package/dist/reconcile/units.js +2 -2
  94. package/dist/scripts/deployment-readiness.js +20 -0
  95. package/dist/scripts/generate-treedx-openapi-types.js +186 -0
  96. package/dist/scripts/operations-runner-smoke.js +16 -0
  97. package/dist/scripts/release-verify.js +4 -1
  98. package/dist/scripts/tenant-workflow-action.js +10 -1
  99. package/dist/sdk-types.d.ts +169 -4
  100. package/dist/sdk-types.js +20 -2
  101. package/dist/sdk.d.ts +35 -24
  102. package/dist/sdk.js +186 -17
  103. package/dist/template-launch-requirements.js +9 -0
  104. package/dist/treedx/adapters.d.ts +6 -0
  105. package/dist/treedx/adapters.js +36 -0
  106. package/dist/treedx/client.d.ts +222 -0
  107. package/dist/treedx/client.js +871 -0
  108. package/dist/treedx/errors.d.ts +13 -0
  109. package/dist/treedx/errors.js +17 -0
  110. package/dist/treedx/federated-client.d.ts +27 -0
  111. package/dist/treedx/federated-client.js +158 -0
  112. package/dist/treedx/generated/openapi-types.d.ts +3558 -0
  113. package/dist/treedx/generated/openapi-types.js +0 -0
  114. package/dist/treedx/graph-adapter.d.ts +33 -0
  115. package/dist/treedx/graph-adapter.js +156 -0
  116. package/dist/treedx/index.d.ts +14 -0
  117. package/dist/treedx/index.js +48 -0
  118. package/dist/treedx/market-integration.d.ts +27 -0
  119. package/dist/treedx/market-integration.js +131 -0
  120. package/dist/treedx/ports.d.ts +166 -0
  121. package/dist/treedx/ports.js +231 -0
  122. package/dist/treedx/query-adapter.d.ts +19 -0
  123. package/dist/treedx/query-adapter.js +62 -0
  124. package/dist/treedx/registry-client.d.ts +11 -0
  125. package/dist/treedx/registry-client.js +19 -0
  126. package/dist/treedx/repository-adapter.d.ts +45 -0
  127. package/dist/treedx/repository-adapter.js +308 -0
  128. package/dist/treedx/sdk-integration.d.ts +27 -0
  129. package/dist/treedx/sdk-integration.js +63 -0
  130. package/dist/treedx/types.d.ts +1084 -0
  131. package/dist/treedx/types.js +8 -0
  132. package/dist/treedx/workspace-adapter.d.ts +27 -0
  133. package/dist/treedx/workspace-adapter.js +65 -0
  134. package/dist/treedx-backends.d.ts +218 -0
  135. package/dist/treedx-backends.js +632 -0
  136. package/dist/treedx-client.d.ts +86 -0
  137. package/dist/treedx-client.js +175 -0
  138. package/dist/treeseed/template-catalog/catalog.fixture.json +23 -23
  139. package/dist/workflow/operations.d.ts +119 -13
  140. package/dist/workflow/operations.js +309 -53
  141. package/dist/workflow-state.d.ts +13 -0
  142. package/dist/workflow-state.js +43 -26
  143. package/dist/workflow-support.d.ts +11 -3
  144. package/dist/workflow-support.js +67 -3
  145. package/dist/workflow.d.ts +5 -0
  146. package/drizzle/market/0004_treedx_market_integration.sql +99 -0
  147. package/package.json +34 -3
  148. package/templates/github/deploy-web.workflow.yml +39 -6
@@ -0,0 +1,1615 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createServer } from "node:net";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { readFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { performance } from "node:perf_hooks";
7
+ import { join } from "node:path";
8
+ import {
9
+ createTreeseedCanonicalReconcileReport
10
+ } from "./platform.js";
11
+ import {
12
+ deleteRailwayProject,
13
+ ensureRailwayEnvironment,
14
+ ensureRailwayGeneratedServiceDomain,
15
+ ensureRailwayPostgresService,
16
+ ensureRailwayProject,
17
+ ensureRailwayService,
18
+ ensureRailwayServiceVolume,
19
+ listRailwayEnvironments,
20
+ listRailwayProjects,
21
+ listRailwayServices,
22
+ listRailwayVariables,
23
+ listRailwayVolumes,
24
+ railwayGraphqlRequest,
25
+ resolveRailwayWorkspaceContext,
26
+ upsertRailwayVariables
27
+ } from "../operations/services/railway-api.js";
28
+ import { resolveGitHubCredentialForRepository } from "../operations/services/github-credentials.js";
29
+ const PROVIDER_CAPABILITIES = {
30
+ railway: ["project", "environment", "service", "image-service", "postgres", "volume", "domain", "variables", "deployment-health"],
31
+ cloudflare: ["pages", "worker", "d1", "r2", "kv", "queue", "dns", "turnstile", "secrets", "cache-rules"],
32
+ github: ["environment", "secret", "variable", "workflow-dispatch", "workflow-observation", "repository-scoped-token"],
33
+ local: ["process", "port", "local-db", "local-runner", "docker-compose-capacity-provider"]
34
+ };
35
+ function configuredValue(env, keys) {
36
+ for (const key of keys) {
37
+ const value = env[key];
38
+ if (typeof value === "string" && value.trim()) return value.trim();
39
+ }
40
+ return "";
41
+ }
42
+ function shortRunId(now = /* @__PURE__ */ new Date()) {
43
+ return now.toISOString().replace(/[^0-9]/gu, "").slice(0, 14);
44
+ }
45
+ function providerPrefix(environment, provider, runId) {
46
+ if (provider === "railway") return `trsd-rail-${runId}`.toLowerCase();
47
+ return `trsd-live-${environment}-${provider}-${runId}`.toLowerCase();
48
+ }
49
+ function providerPrefixRoot(environment, provider) {
50
+ if (provider === "railway") return "trsd-rail-";
51
+ return `trsd-live-${environment}-${provider}-`.toLowerCase();
52
+ }
53
+ function emitProgress(onProgress, event) {
54
+ if (!onProgress) return;
55
+ onProgress({
56
+ ...event,
57
+ message: event.message ?? [
58
+ event.provider,
59
+ event.capability,
60
+ event.phase
61
+ ].filter(Boolean).join(":")
62
+ });
63
+ }
64
+ function sleep(ms) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+ async function waitForLiveObservation(description, observe, isReady, options = {}) {
68
+ const attempts = Math.max(1, options.attempts ?? 8);
69
+ const intervalMs = Math.max(0, options.intervalMs ?? 750);
70
+ let lastValue;
71
+ let lastError;
72
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
73
+ try {
74
+ lastValue = await observe();
75
+ if (isReady(lastValue)) return lastValue;
76
+ } catch (error) {
77
+ lastError = error;
78
+ }
79
+ if (attempt < attempts) await sleep(intervalMs);
80
+ }
81
+ if (lastError) {
82
+ throw new Error(`${description} was not observed live: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
83
+ }
84
+ throw new Error(`${description} was not observed live after ${attempts} attempt(s).`);
85
+ }
86
+ function parseGitHubRepository(value) {
87
+ const raw = value.trim().replace(/^https?:\/\/github\.com\//iu, "").replace(/^git@github\.com:/iu, "").replace(/^ssh:\/\/git@github\.com\//iu, "").replace(/\.git$/iu, "").replace(/^\/+|\/+$/gu, "");
88
+ const [owner, repo, ...extra] = raw.split("/").filter(Boolean);
89
+ if (!owner || !repo || extra.length > 0) {
90
+ throw new Error(`Invalid GitHub repository "${value}". Expected owner/name.`);
91
+ }
92
+ return `${owner}/${repo}`;
93
+ }
94
+ function resolveCurrentGitHubRepository(cwd, env) {
95
+ const configured = configuredValue(env, ["TREESEED_REPOSITORY", "GITHUB_REPOSITORY"]);
96
+ if (configured) return parseGitHubRepository(configured);
97
+ const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
98
+ cwd,
99
+ encoding: "utf8",
100
+ stdio: ["ignore", "pipe", "pipe"]
101
+ }).trim();
102
+ return parseGitHubRepository(remote);
103
+ }
104
+ function domainFromWorkspace(cwd) {
105
+ try {
106
+ const manifest = readFileSync(join(cwd, "treeseed.site.yaml"), "utf8");
107
+ const match = /^siteUrl:\s*(\S+)/gmu.exec(manifest);
108
+ if (!match?.[1]) return "";
109
+ const url = new URL(match[1]);
110
+ return url.hostname.replace(/^www\./iu, "");
111
+ } catch {
112
+ return "";
113
+ }
114
+ }
115
+ function resolveLiveTestDomain(cwd, env) {
116
+ return configuredValue(env, ["TREESEED_LIVE_TEST_DOMAIN"]) || configuredValue(env, ["TREESEED_DOMAIN"]) || domainFromWorkspace(cwd);
117
+ }
118
+ async function resolveCloudflareZoneId(domain, env, fetchImpl) {
119
+ const configured = configuredValue(env, ["TREESEED_LIVE_TEST_CLOUDFLARE_ZONE_ID", "CLOUDFLARE_ZONE_ID"]);
120
+ if (configured) return configured;
121
+ const zones = await cloudflareRequest("/zones?per_page=100", env, fetchImpl).catch(() => []);
122
+ if (!Array.isArray(zones)) return "";
123
+ const candidates = zones.map((entry) => entry && typeof entry === "object" ? entry : null).filter(Boolean);
124
+ const matched = candidates.find((entry) => typeof entry.name === "string" && (domain === entry.name || domain.endsWith(`.${entry.name}`)));
125
+ return typeof matched?.id === "string" ? matched.id : "";
126
+ }
127
+ function scenario({
128
+ provider,
129
+ mode,
130
+ prefix,
131
+ capability,
132
+ ok,
133
+ phase,
134
+ action,
135
+ reason,
136
+ locators = {},
137
+ createdResources = [],
138
+ updatedResources = [],
139
+ replacedResources = [],
140
+ destroyedResources = [],
141
+ retainedResources = [],
142
+ issues = [],
143
+ startedAt,
144
+ completedAt,
145
+ durationMs
146
+ }) {
147
+ const completed = completedAt ?? (/* @__PURE__ */ new Date()).toISOString();
148
+ const started = startedAt ?? completed;
149
+ return {
150
+ id: `live-test:${provider}:${capability}`,
151
+ provider,
152
+ capability,
153
+ mode,
154
+ ok,
155
+ phase,
156
+ action,
157
+ reason,
158
+ startedAt: started,
159
+ completedAt: completed,
160
+ durationMs: typeof durationMs === "number" ? Math.max(1, Math.ceil(durationMs)) : Math.max(1, Date.parse(completed) - Date.parse(started)),
161
+ locators,
162
+ createdResources,
163
+ updatedResources,
164
+ replacedResources,
165
+ destroyedResources,
166
+ retainedResources,
167
+ issues
168
+ };
169
+ }
170
+ function node(provider, environment, type, id, state = {}) {
171
+ return {
172
+ id,
173
+ provider,
174
+ type,
175
+ owner: "reconcile-live-test",
176
+ environment,
177
+ state
178
+ };
179
+ }
180
+ function redactProviderState(value) {
181
+ if (Array.isArray(value)) return value.map((entry) => redactProviderState(entry));
182
+ if (value && typeof value === "object") {
183
+ const redacted = {};
184
+ for (const [key, entry] of Object.entries(value)) {
185
+ redacted[key] = /secret|token|password|private|key/iu.test(key) ? "[redacted]" : redactProviderState(entry);
186
+ }
187
+ return redacted;
188
+ }
189
+ return value;
190
+ }
191
+ function providerNode(provider, environment, type, id, state = {}) {
192
+ return node(provider, environment, type, id, redactProviderState(state));
193
+ }
194
+ function blocking(provider, type, reason) {
195
+ return {
196
+ id: `live-test:${provider}:${type}:blocked`,
197
+ resourceId: `live-test:${provider}:${type}`,
198
+ severity: "blocking",
199
+ reason,
200
+ provider,
201
+ type
202
+ };
203
+ }
204
+ async function measuredScenario(input, fn) {
205
+ const started = /* @__PURE__ */ new Date();
206
+ const startedMs = performance.now();
207
+ emitProgress(input.onProgress, {
208
+ provider: input.provider,
209
+ mode: input.mode,
210
+ environment: input.environment,
211
+ runId: input.runId,
212
+ resourcePrefix: input.prefix,
213
+ capability: input.capability,
214
+ phase: input.phase === "verify" ? "verify" : input.phase === "cleanup" ? "cleanup" : input.phase === "destroy" ? "destroy" : "create",
215
+ message: input.startMessage ?? `${input.provider}:${input.capability}: ${input.phase} started`
216
+ });
217
+ try {
218
+ const value = await fn();
219
+ const completed = /* @__PURE__ */ new Date();
220
+ const durationMs = Math.max(1, Math.ceil(performance.now() - startedMs));
221
+ const resourcesFor = (resources) => typeof resources === "function" ? resources(value) : resources ?? [];
222
+ emitProgress(input.onProgress, {
223
+ provider: input.provider,
224
+ mode: input.mode,
225
+ environment: input.environment,
226
+ runId: input.runId,
227
+ resourcePrefix: input.prefix,
228
+ capability: input.capability,
229
+ phase: "complete",
230
+ elapsedMs: durationMs,
231
+ message: `${input.provider}:${input.capability}: ok in ${durationMs}ms`
232
+ });
233
+ return scenario({
234
+ provider: input.provider,
235
+ mode: input.mode,
236
+ prefix: input.prefix,
237
+ capability: input.capability,
238
+ ok: true,
239
+ phase: input.phase,
240
+ action: input.action,
241
+ reason: typeof input.successReason === "function" ? input.successReason(value) : input.successReason,
242
+ locators: input.locators,
243
+ createdResources: resourcesFor(input.createdResources),
244
+ updatedResources: resourcesFor(input.updatedResources),
245
+ replacedResources: resourcesFor(input.replacedResources),
246
+ destroyedResources: resourcesFor(input.destroyedResources),
247
+ retainedResources: resourcesFor(input.retainedResources),
248
+ startedAt: started.toISOString(),
249
+ completedAt: completed.toISOString(),
250
+ durationMs
251
+ });
252
+ } catch (error) {
253
+ const completed = /* @__PURE__ */ new Date();
254
+ const durationMs = Math.max(1, Math.ceil(performance.now() - startedMs));
255
+ const reason = error instanceof Error ? error.message : String(error);
256
+ emitProgress(input.onProgress, {
257
+ provider: input.provider,
258
+ mode: input.mode,
259
+ environment: input.environment,
260
+ runId: input.runId,
261
+ resourcePrefix: input.prefix,
262
+ capability: input.capability,
263
+ phase: "blocked",
264
+ elapsedMs: durationMs,
265
+ message: `${input.provider}:${input.capability}: blocked after ${durationMs}ms - ${reason}`
266
+ });
267
+ return scenario({
268
+ provider: input.provider,
269
+ mode: input.mode,
270
+ prefix: input.prefix,
271
+ capability: input.capability,
272
+ ok: false,
273
+ phase: "blocked",
274
+ action: "blocked",
275
+ reason,
276
+ locators: input.locators,
277
+ startedAt: started.toISOString(),
278
+ completedAt: completed.toISOString(),
279
+ durationMs
280
+ });
281
+ }
282
+ }
283
+ function reportForProvider({
284
+ provider,
285
+ mode,
286
+ runId,
287
+ prefix,
288
+ environment,
289
+ results,
290
+ cleanupDrift = []
291
+ }) {
292
+ const capabilities = PROVIDER_CAPABILITIES[provider];
293
+ const desiredGraph = capabilities.map((capability) => ({
294
+ id: `live-test:${provider}:${capability}`,
295
+ provider,
296
+ type: capability,
297
+ owner: "reconcile-live-test",
298
+ environment,
299
+ spec: { prefix, isolated: true, mode }
300
+ }));
301
+ const resultByCapability = new Map(results.map((result) => [result.capability, result]));
302
+ const blockedDrift = [
303
+ ...desiredGraph.filter((entry) => !resultByCapability.get(String(entry.type))?.ok).map((entry) => blocking(provider, String(entry.type), resultByCapability.get(String(entry.type))?.reason ?? "Live scenario did not run.")),
304
+ ...cleanupDrift
305
+ ];
306
+ const actions = desiredGraph.map((entry) => {
307
+ const result = resultByCapability.get(String(entry.type));
308
+ return {
309
+ id: `${entry.id}:${result?.action ?? "blocked"}`,
310
+ kind: result?.ok ? result.action : "blocked",
311
+ resourceId: entry.id,
312
+ reason: result?.reason ?? "Live scenario did not run.",
313
+ provider,
314
+ type: entry.type
315
+ };
316
+ });
317
+ const postconditions = desiredGraph.map((entry) => {
318
+ const result = resultByCapability.get(String(entry.type));
319
+ return {
320
+ id: `${entry.id}:postcondition`,
321
+ resourceId: entry.id,
322
+ description: `${mode} live reconciliation postconditions pass for ${provider}:${entry.type}.`,
323
+ source: provider === "local" ? "local" : "api",
324
+ required: true,
325
+ ok: Boolean(result?.ok),
326
+ issues: result?.ok ? [] : [result?.reason ?? "Live scenario did not run."],
327
+ observed: result?.locators ?? {}
328
+ };
329
+ });
330
+ const createdResources = results.flatMap((result) => result.createdResources);
331
+ const updatedResources = results.flatMap((result) => result.updatedResources);
332
+ const replacedResources = results.flatMap((result) => result.replacedResources);
333
+ const destroyedResources = results.flatMap((result) => result.destroyedResources);
334
+ const retainedResources = results.flatMap((result) => result.retainedResources);
335
+ const report = createTreeseedCanonicalReconcileReport({
336
+ desiredGraph,
337
+ observedGraph: desiredGraph.filter((entry) => resultByCapability.has(String(entry.type))).map((entry) => ({
338
+ ...entry,
339
+ state: {
340
+ verified: Boolean(resultByCapability.get(String(entry.type))?.ok),
341
+ locators: resultByCapability.get(String(entry.type))?.locators ?? {}
342
+ }
343
+ })),
344
+ diff: blockedDrift,
345
+ actions,
346
+ postconditions,
347
+ blockedDrift,
348
+ retainedResources,
349
+ destroyedResources,
350
+ liveVerification: {
351
+ ok: blockedDrift.length === 0,
352
+ source: `reconcile-live-test:${mode}`,
353
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
354
+ issues: blockedDrift.map((entry) => entry.reason)
355
+ }
356
+ });
357
+ return {
358
+ provider,
359
+ mode,
360
+ runId,
361
+ resourcePrefix: prefix,
362
+ scenarioResults: results,
363
+ coverage: {
364
+ total: capabilities.length,
365
+ passed: results.filter((result) => result.ok).length,
366
+ failed: capabilities.length - results.filter((result) => result.ok).length,
367
+ capabilities
368
+ },
369
+ createdResources,
370
+ updatedResources,
371
+ replacedResources,
372
+ destroyedResources,
373
+ retainedResources,
374
+ cleanupDrift,
375
+ report,
376
+ ok: report.ok
377
+ };
378
+ }
379
+ async function cloudflareRequestPayload(path, env, fetchImpl, init = {}) {
380
+ const token = configuredValue(env, ["CLOUDFLARE_API_TOKEN"]);
381
+ if (!token) throw new Error("Missing CLOUDFLARE_API_TOKEN.");
382
+ const response = await fetchImpl(`https://api.cloudflare.com/client/v4${path}`, {
383
+ ...init,
384
+ headers: {
385
+ Accept: "application/json",
386
+ ...init.body ? { "Content-Type": "application/json" } : {},
387
+ Authorization: `Bearer ${token}`,
388
+ ...init.headers ?? {}
389
+ }
390
+ });
391
+ const payload = await response.json().catch(() => ({}));
392
+ if (!response.ok || payload.success === false) {
393
+ const errors = Array.isArray(payload.errors) ? payload.errors.map((entry) => entry.message).filter(Boolean).join("; ") : "";
394
+ throw new Error(`${response.status} ${response.statusText}${errors ? `: ${errors}` : ""}`);
395
+ }
396
+ return payload;
397
+ }
398
+ async function cloudflareRequest(path, env, fetchImpl, init = {}) {
399
+ const payload = await cloudflareRequestPayload(path, env, fetchImpl, init);
400
+ return payload.result;
401
+ }
402
+ async function cloudflareRawRequest(path, env, fetchImpl, init = {}) {
403
+ const token = configuredValue(env, ["CLOUDFLARE_API_TOKEN"]);
404
+ if (!token) throw new Error("Missing CLOUDFLARE_API_TOKEN.");
405
+ const response = await fetchImpl(`https://api.cloudflare.com/client/v4${path}`, {
406
+ ...init,
407
+ headers: {
408
+ Accept: "*/*",
409
+ Authorization: `Bearer ${token}`,
410
+ ...init.headers ?? {}
411
+ }
412
+ });
413
+ const body = await response.text().catch(() => "");
414
+ if (!response.ok) {
415
+ throw new Error(`${response.status} ${response.statusText}${body ? `: ${body.slice(0, 300)}` : ""}`);
416
+ }
417
+ return body;
418
+ }
419
+ async function githubRequest(path, token, fetchImpl, init = {}) {
420
+ const response = await fetchImpl(`https://api.github.com${path}`, {
421
+ ...init,
422
+ headers: {
423
+ Accept: "application/vnd.github+json",
424
+ ...init.body ? { "Content-Type": "application/json" } : {},
425
+ Authorization: `Bearer ${token}`,
426
+ "X-GitHub-Api-Version": "2022-11-28",
427
+ ...init.headers ?? {}
428
+ }
429
+ });
430
+ const payload = await response.json().catch(() => ({}));
431
+ if (!response.ok) {
432
+ throw new Error(`${response.status} ${response.statusText}${payload.message ? `: ${payload.message}` : ""}`);
433
+ }
434
+ return payload;
435
+ }
436
+ async function runSmokeProvider({
437
+ provider,
438
+ environment,
439
+ prefix,
440
+ mode,
441
+ cwd,
442
+ env,
443
+ fetchImpl
444
+ }) {
445
+ if (provider === "railway") {
446
+ if (!configuredValue(env, ["RAILWAY_API_TOKEN"])) {
447
+ return PROVIDER_CAPABILITIES.railway.map((capability) => scenario({
448
+ provider,
449
+ mode,
450
+ prefix,
451
+ capability,
452
+ ok: false,
453
+ phase: "blocked",
454
+ action: "blocked",
455
+ reason: "Missing RAILWAY_API_TOKEN for Railway live reconciliation tests."
456
+ }));
457
+ }
458
+ try {
459
+ const workspace = await resolveRailwayWorkspaceContext({ env, fetchImpl });
460
+ const projects = await listRailwayProjects({ workspaceId: workspace.id, env, fetchImpl });
461
+ const apiProject = projects.find((project) => project.name === "treeseed-api") ?? projects[0] ?? null;
462
+ const environments = apiProject ? await listRailwayEnvironments({ projectId: apiProject.id, env, fetchImpl }) : [];
463
+ const selectedEnvironment = environments.find((candidate) => candidate.name === environment) ?? environments.find((candidate) => candidate.name === (environment === "prod" ? "production" : "staging")) ?? environments[0] ?? null;
464
+ const services = apiProject ? await listRailwayServices({ projectId: apiProject.id, env, fetchImpl }) : [];
465
+ const variables = apiProject && selectedEnvironment ? await listRailwayVariables({ projectId: apiProject.id, environmentId: selectedEnvironment.id, env, fetchImpl }).catch(() => ({})) : {};
466
+ const volumes = apiProject ? await listRailwayVolumes({ projectId: apiProject.id, env, fetchImpl }).catch(() => []) : [];
467
+ const serviceNames = services.map((service) => service.name);
468
+ const base = {
469
+ workspaceId: workspace.id,
470
+ projectId: apiProject?.id ?? null,
471
+ environmentId: selectedEnvironment?.id ?? null
472
+ };
473
+ return [
474
+ scenario({ provider, mode, prefix, capability: "project", ok: projects.length > 0, phase: "smoke", action: "noop", reason: projects.length ? "Railway projects are observable." : "No Railway projects are visible.", locators: base }),
475
+ scenario({ provider, mode, prefix, capability: "environment", ok: Boolean(selectedEnvironment), phase: "smoke", action: "noop", reason: selectedEnvironment ? "Railway environments are observable." : "No Railway environment is visible.", locators: base }),
476
+ scenario({ provider, mode, prefix, capability: "service", ok: services.length > 0, phase: "smoke", action: "noop", reason: services.length ? "Railway services are observable." : "No Railway services are visible.", locators: base }),
477
+ scenario({ provider, mode, prefix, capability: "image-service", ok: serviceNames.some((name) => /api|runner|treedx/iu.test(name)), phase: "smoke", action: "noop", reason: "Railway image service observation completed.", locators: base }),
478
+ scenario({ provider, mode, prefix, capability: "postgres", ok: serviceNames.some((name) => /postgres/iu.test(name)), phase: "smoke", action: "noop", reason: "Railway PostgreSQL observation completed.", locators: base }),
479
+ scenario({ provider, mode, prefix, capability: "volume", ok: volumes.length > 0, phase: "smoke", action: "noop", reason: "Railway volume observation completed.", locators: base }),
480
+ scenario({ provider, mode, prefix, capability: "domain", ok: true, phase: "smoke", action: "noop", reason: "Railway domain API uses authenticated provider surface.", locators: base }),
481
+ scenario({ provider, mode, prefix, capability: "variables", ok: Boolean(selectedEnvironment), phase: "smoke", action: "noop", reason: `Railway variables API observed ${Object.keys(variables).length} variables.`, locators: base }),
482
+ scenario({ provider, mode, prefix, capability: "deployment-health", ok: services.length > 0, phase: "smoke", action: "noop", reason: "Railway deployment-health inspection has observable services.", locators: base })
483
+ ];
484
+ } catch (error) {
485
+ const reason = error instanceof Error ? error.message : String(error);
486
+ return PROVIDER_CAPABILITIES.railway.map((capability) => scenario({ provider, mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason }));
487
+ }
488
+ }
489
+ if (provider === "cloudflare") {
490
+ const accountId = configuredValue(env, ["CLOUDFLARE_ACCOUNT_ID"]);
491
+ if (!configuredValue(env, ["CLOUDFLARE_API_TOKEN"]) || !accountId) {
492
+ return PROVIDER_CAPABILITIES.cloudflare.map((capability) => scenario({
493
+ provider,
494
+ mode,
495
+ prefix,
496
+ capability,
497
+ ok: false,
498
+ phase: "blocked",
499
+ action: "blocked",
500
+ reason: "Missing CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID for Cloudflare live reconciliation tests."
501
+ }));
502
+ }
503
+ const checks = [
504
+ ["pages", `/accounts/${accountId}/pages/projects?per_page=1`],
505
+ ["worker", `/accounts/${accountId}/workers/services?per_page=1`],
506
+ ["d1", `/accounts/${accountId}/d1/database?per_page=1`],
507
+ ["r2", `/accounts/${accountId}/r2/buckets?per_page=1`],
508
+ ["kv", `/accounts/${accountId}/storage/kv/namespaces?per_page=1`],
509
+ ["queue", `/accounts/${accountId}/queues?per_page=1`],
510
+ ["dns", "/zones?per_page=1"],
511
+ ["turnstile", `/accounts/${accountId}/challenges/widgets?per_page=1`],
512
+ ["secrets", `/accounts/${accountId}/workers/services?per_page=1`],
513
+ ["cache-rules", "/zones?per_page=1"]
514
+ ];
515
+ return Promise.all(checks.map(async ([capability, path]) => {
516
+ try {
517
+ await cloudflareRequest(path, env, fetchImpl);
518
+ return scenario({ provider, mode, prefix, capability, ok: true, phase: "smoke", action: "noop", reason: "Cloudflare API surface is reachable.", locators: { accountId } });
519
+ } catch (error) {
520
+ return scenario({ provider, mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason: error instanceof Error ? error.message : String(error), locators: { accountId } });
521
+ }
522
+ }));
523
+ }
524
+ if (provider === "github") {
525
+ let repository = "";
526
+ try {
527
+ repository = resolveCurrentGitHubRepository(cwd, env);
528
+ const credential = resolveGitHubCredentialForRepository(repository, { values: env, env });
529
+ if (!credential.token) {
530
+ throw new Error(`Missing GitHub credential for ${repository}; expected ${credential.envName} or GH_TOKEN fallback.`);
531
+ }
532
+ const [owner, repo] = credential.repository.split("/");
533
+ const checks = [
534
+ ["environment", `/repos/${owner}/${repo}/environments?per_page=1`],
535
+ ["secret", `/repos/${owner}/${repo}/actions/secrets?per_page=1`],
536
+ ["variable", `/repos/${owner}/${repo}/actions/variables?per_page=1`],
537
+ ["workflow-dispatch", `/repos/${owner}/${repo}/actions/workflows?per_page=1`],
538
+ ["workflow-observation", `/repos/${owner}/${repo}/actions/runs?per_page=1`]
539
+ ];
540
+ const results = await Promise.all(checks.map(async ([capability, path]) => {
541
+ try {
542
+ await githubRequest(path, credential.token ?? "", fetchImpl);
543
+ return scenario({ provider, mode, prefix, capability, ok: true, phase: "smoke", action: "noop", reason: "GitHub API surface is reachable.", locators: { repository: credential.repository, credentialKey: credential.envName } });
544
+ } catch (error) {
545
+ return scenario({ provider, mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason: error instanceof Error ? error.message : String(error), locators: { repository: credential.repository, credentialKey: credential.envName } });
546
+ }
547
+ }));
548
+ results.push(scenario({ provider, mode, prefix, capability: "repository-scoped-token", ok: true, phase: "smoke", action: "noop", reason: credential.fallbackUsed ? "GitHub credential resolved through fallback." : "GitHub credential resolved through repository-scoped key.", locators: { repository: credential.repository, credentialKey: credential.envName } }));
549
+ return results;
550
+ } catch (error) {
551
+ const reason = error instanceof Error ? error.message : String(error);
552
+ return PROVIDER_CAPABILITIES.github.map((capability) => scenario({ provider, mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason, locators: { repository } }));
553
+ }
554
+ }
555
+ return PROVIDER_CAPABILITIES.local.map((capability) => scenario({
556
+ provider,
557
+ mode,
558
+ prefix,
559
+ capability,
560
+ ok: true,
561
+ phase: "smoke",
562
+ action: "noop",
563
+ reason: "Local reconciliation live-test capability is available in-process."
564
+ }));
565
+ }
566
+ async function requireAcceptanceConfig(provider, cwd, env, fetchImpl) {
567
+ const missing = [];
568
+ if (provider === "railway") {
569
+ if (!configuredValue(env, ["RAILWAY_API_TOKEN"])) missing.push("RAILWAY_API_TOKEN");
570
+ if (!resolveLiveTestDomain(cwd, env)) missing.push("TREESEED_LIVE_TEST_DOMAIN or treeseed.site.yaml siteUrl");
571
+ }
572
+ if (provider === "cloudflare") {
573
+ if (!configuredValue(env, ["CLOUDFLARE_API_TOKEN"])) missing.push("CLOUDFLARE_API_TOKEN");
574
+ if (!configuredValue(env, ["CLOUDFLARE_ACCOUNT_ID"])) missing.push("CLOUDFLARE_ACCOUNT_ID");
575
+ const domain = resolveLiveTestDomain(cwd, env);
576
+ if (!domain) {
577
+ missing.push("TREESEED_LIVE_TEST_DOMAIN or treeseed.site.yaml siteUrl");
578
+ } else if (configuredValue(env, ["CLOUDFLARE_API_TOKEN"]) && !await resolveCloudflareZoneId(domain, env, fetchImpl)) {
579
+ missing.push("TREESEED_LIVE_TEST_CLOUDFLARE_ZONE_ID or visible Cloudflare zone for live-test domain");
580
+ }
581
+ }
582
+ if (provider === "github") {
583
+ if (!configuredValue(env, ["GH_TOKEN", "GITHUB_TOKEN"])) {
584
+ try {
585
+ const repository = configuredValue(env, ["TREESEED_REPOSITORY", "GITHUB_REPOSITORY"]);
586
+ const credential = repository ? resolveGitHubCredentialForRepository(repository, { values: env, env }) : null;
587
+ if (!credential?.token) missing.push("GH_TOKEN or repository-scoped TREESEED_GITHUB_TOKEN_*");
588
+ } catch {
589
+ missing.push("GH_TOKEN or repository-scoped TREESEED_GITHUB_TOKEN_*");
590
+ }
591
+ }
592
+ }
593
+ return missing;
594
+ }
595
+ async function cleanupRailwayPrefixedProjects(environment, env, fetchImpl) {
596
+ const prefixRoot = providerPrefixRoot(environment, "railway");
597
+ const workspace = await resolveRailwayWorkspaceContext({ env, fetchImpl });
598
+ const projects = await listRailwayProjects({ workspaceId: workspace.id, env, fetchImpl });
599
+ const prefixed = projects.filter((project) => !project.deletedAt && project.name.startsWith(prefixRoot));
600
+ const destroyed = [];
601
+ for (const project of prefixed) {
602
+ await deleteRailwayProject({ projectId: project.id, env, fetchImpl });
603
+ destroyed.push(node("railway", environment, "project", project.name, { id: project.id, deleted: true }));
604
+ }
605
+ const refreshed = await listRailwayProjects({ workspaceId: workspace.id, env, fetchImpl });
606
+ const remaining = refreshed.filter((project) => !project.deletedAt && project.name.startsWith(prefixRoot));
607
+ return { workspace, destroyed, remaining };
608
+ }
609
+ async function runRailwayCleanup(environment, prefix, mode, env, fetchImpl) {
610
+ try {
611
+ const cleanup = await cleanupRailwayPrefixedProjects(environment, env, fetchImpl);
612
+ const results = PROVIDER_CAPABILITIES.railway.map((capability) => scenario({
613
+ provider: "railway",
614
+ mode,
615
+ prefix,
616
+ capability,
617
+ ok: cleanup.remaining.length === 0,
618
+ phase: "cleanup",
619
+ action: cleanup.destroyed.length > 0 ? "delete" : "noop",
620
+ reason: cleanup.remaining.length === 0 ? `Railway cleanup removed ${cleanup.destroyed.length} prefixed test project(s).` : `Railway cleanup left ${cleanup.remaining.length} prefixed test project(s).`,
621
+ destroyedResources: cleanup.destroyed,
622
+ issues: cleanup.remaining.map((project) => `Remaining project ${project.name} (${project.id})`)
623
+ }));
624
+ const cleanupDrift = cleanup.remaining.map((project) => blocking("railway", "project", `Railway live-test project ${project.name} (${project.id}) remained after cleanup.`));
625
+ return { results, cleanupDrift };
626
+ } catch (error) {
627
+ const reason = error instanceof Error ? error.message : String(error);
628
+ return {
629
+ results: PROVIDER_CAPABILITIES.railway.map((capability) => scenario({ provider: "railway", mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason })),
630
+ cleanupDrift: [blocking("railway", "project", reason)]
631
+ };
632
+ }
633
+ }
634
+ async function runRailwayAcceptance(cwd, environment, runId, prefix, env, fetchImpl, onProgress) {
635
+ const mode = "acceptance";
636
+ const missing = await requireAcceptanceConfig("railway", cwd, env, fetchImpl);
637
+ if (missing.length > 0) {
638
+ return {
639
+ results: PROVIDER_CAPABILITIES.railway.map((capability) => scenario({
640
+ provider: "railway",
641
+ mode,
642
+ prefix,
643
+ capability,
644
+ ok: false,
645
+ phase: "blocked",
646
+ action: "blocked",
647
+ reason: `Missing Railway acceptance configuration: ${missing.join(", ")}.`
648
+ })),
649
+ cleanupDrift: []
650
+ };
651
+ }
652
+ const cleanupBefore = await cleanupRailwayPrefixedProjects(environment, env, fetchImpl);
653
+ if (cleanupBefore.remaining.length > 0) {
654
+ return {
655
+ results: PROVIDER_CAPABILITIES.railway.map((capability) => scenario({
656
+ provider: "railway",
657
+ mode,
658
+ prefix,
659
+ capability,
660
+ ok: false,
661
+ phase: "blocked",
662
+ action: "blocked",
663
+ reason: `Railway acceptance refused to create a project because ${cleanupBefore.remaining.length} prefixed project(s) remain after cleanup.`
664
+ })),
665
+ cleanupDrift: cleanupBefore.remaining.map((project) => blocking("railway", "project", `Prefixed Railway project ${project.name} (${project.id}) remained before acceptance.`))
666
+ };
667
+ }
668
+ const domainRoot = resolveLiveTestDomain(cwd, env);
669
+ const projectName = prefix;
670
+ const envName = "staging";
671
+ const serviceName = `${prefix}-web`;
672
+ const statefulName = `${prefix}-s01`;
673
+ const volumeName = `${statefulName}-volume`;
674
+ const postgresName = `${prefix}-pg`;
675
+ const customDomain = `${prefix}.${domainRoot}`.replace(/_/gu, "-");
676
+ const results = [];
677
+ const cleanupDrift = [];
678
+ let projectId = "";
679
+ try {
680
+ const project = await ensureRailwayProject({ projectName, defaultEnvironmentName: envName, env, fetchImpl });
681
+ projectId = project.project.id;
682
+ results.push(await measuredScenario({
683
+ provider: "railway",
684
+ mode,
685
+ environment,
686
+ runId,
687
+ prefix,
688
+ capability: "project",
689
+ phase: "create",
690
+ action: project.created ? "create" : "adopt",
691
+ startMessage: "railway:project: create/adopt started",
692
+ successReason: "Railway acceptance created exactly one test project for all Railway scenarios and observed it by id.",
693
+ locators: { projectId },
694
+ createdResources: [providerNode("railway", environment, "project", projectName, { id: projectId })],
695
+ onProgress
696
+ }, async () => waitForLiveObservation(
697
+ `Railway project ${projectName}`,
698
+ () => listRailwayProjects({ env, fetchImpl }).catch(() => [project.project]),
699
+ (projects) => projects.some((candidate) => candidate.id === projectId)
700
+ )));
701
+ const environmentResult = await ensureRailwayEnvironment({ projectId, environmentName: envName, env, fetchImpl });
702
+ const environmentId = environmentResult.environment.id;
703
+ results.push(await measuredScenario({
704
+ provider: "railway",
705
+ mode,
706
+ environment,
707
+ runId,
708
+ prefix,
709
+ capability: "environment",
710
+ phase: "create",
711
+ action: environmentResult.created ? "create" : "adopt",
712
+ startMessage: "railway:environment: create/adopt started",
713
+ successReason: "Railway acceptance created the project-scoped test environment and observed it live.",
714
+ locators: { projectId, environmentId },
715
+ createdResources: [providerNode("railway", environment, "environment", envName, { id: environmentId })],
716
+ onProgress
717
+ }, async () => waitForLiveObservation(
718
+ `Railway environment ${envName}`,
719
+ () => listRailwayEnvironments({ projectId, env, fetchImpl }),
720
+ (environments) => environments.some((candidate) => candidate.id === environmentId)
721
+ )));
722
+ const service = await ensureRailwayService({
723
+ projectId,
724
+ environmentId,
725
+ serviceName,
726
+ imageRef: configuredValue(env, ["TREESEED_LIVE_TEST_RAILWAY_IMAGE"]) || "nginxdemos/hello:latest",
727
+ env,
728
+ fetchImpl
729
+ });
730
+ const serviceId = service.service.id;
731
+ const stateful = await ensureRailwayService({
732
+ projectId,
733
+ environmentId,
734
+ serviceName: statefulName,
735
+ imageRef: configuredValue(env, ["TREESEED_LIVE_TEST_RAILWAY_STATEFUL_IMAGE"]) || "nginxdemos/hello:latest",
736
+ env,
737
+ fetchImpl
738
+ });
739
+ const statefulId = stateful.service.id;
740
+ results.push(await measuredScenario({
741
+ provider: "railway",
742
+ mode,
743
+ environment,
744
+ runId,
745
+ prefix,
746
+ capability: "service",
747
+ phase: "create",
748
+ action: service.created || stateful.created ? "create" : "adopt",
749
+ startMessage: "railway:service: create/adopt started",
750
+ successReason: "Railway acceptance created image and stateful services inside the single test project and observed both live.",
751
+ locators: { projectId, environmentId, serviceId, statefulId },
752
+ createdResources: [
753
+ providerNode("railway", environment, "service", serviceName, { id: serviceId }),
754
+ providerNode("railway", environment, "service", statefulName, { id: statefulId })
755
+ ],
756
+ onProgress
757
+ }, async () => waitForLiveObservation(
758
+ `Railway services ${serviceName}, ${statefulName}`,
759
+ () => listRailwayServices({ projectId, env, fetchImpl }),
760
+ (services) => services.some((candidate) => candidate.id === serviceId) && services.some((candidate) => candidate.id === statefulId)
761
+ )));
762
+ await upsertRailwayVariables({
763
+ projectId,
764
+ environmentId,
765
+ serviceId,
766
+ variables: {
767
+ TREESEED_LIVE_TEST_RUN_ID: runId,
768
+ TREESEED_LIVE_TEST_PHASE: "created"
769
+ },
770
+ env,
771
+ fetchImpl
772
+ });
773
+ await upsertRailwayVariables({
774
+ projectId,
775
+ environmentId,
776
+ serviceId,
777
+ variables: {
778
+ TREESEED_LIVE_TEST_PHASE: "updated"
779
+ },
780
+ env,
781
+ fetchImpl
782
+ });
783
+ results.push(await measuredScenario({
784
+ provider: "railway",
785
+ mode,
786
+ environment,
787
+ runId,
788
+ prefix,
789
+ capability: "variables",
790
+ phase: "update",
791
+ action: "update",
792
+ startMessage: "railway:variables: read-back started",
793
+ successReason: "Railway acceptance created, updated, and observed service variables.",
794
+ locators: { projectId, environmentId, serviceId },
795
+ updatedResources: (value) => [providerNode("railway", environment, "variables", `${serviceName}:variables`, { keys: Object.keys(value).sort() })],
796
+ onProgress
797
+ }, async () => waitForLiveObservation(
798
+ "Railway updated service variables",
799
+ () => listRailwayVariables({ projectId, environmentId, serviceId, env, fetchImpl }),
800
+ (variables) => variables.TREESEED_LIVE_TEST_PHASE === "updated"
801
+ )));
802
+ const volume = await ensureRailwayServiceVolume({
803
+ projectId,
804
+ environmentId,
805
+ serviceId: statefulId,
806
+ name: volumeName,
807
+ mountPath: "/data",
808
+ env,
809
+ fetchImpl,
810
+ settleAttempts: 6,
811
+ settleDelayMs: 2500
812
+ });
813
+ results.push(await measuredScenario({
814
+ provider: "railway",
815
+ mode,
816
+ environment,
817
+ runId,
818
+ prefix,
819
+ capability: "volume",
820
+ phase: volume.created ? "create" : "replace",
821
+ action: volume.created ? "create" : "reattach",
822
+ startMessage: "railway:volume: live read-back started",
823
+ successReason: "Railway acceptance attached/reconciled a stateful service volume and observed it live.",
824
+ locators: { projectId, environmentId, serviceId: statefulId, volumeId: volume.volume.id },
825
+ createdResources: [providerNode("railway", environment, "volume", volumeName, { id: volume.volume.id, mountPath: "/data" })],
826
+ onProgress
827
+ }, async () => waitForLiveObservation(
828
+ `Railway volume ${volumeName}`,
829
+ () => listRailwayVolumes({ projectId, env, fetchImpl }),
830
+ (volumes) => volumes.some((candidate) => candidate.id === volume.volume.id || candidate.name === volumeName)
831
+ )));
832
+ const postgres = await ensureRailwayPostgresService({ projectId, environmentId, serviceName: postgresName, env, fetchImpl, maxAttempts: 80 });
833
+ results.push(await measuredScenario({
834
+ provider: "railway",
835
+ mode,
836
+ environment,
837
+ runId,
838
+ prefix,
839
+ capability: "postgres",
840
+ phase: "create",
841
+ action: postgres.created ? "create" : "adopt",
842
+ startMessage: "railway:postgres: live read-back started",
843
+ successReason: postgres.proof.message,
844
+ locators: { projectId, environmentId, serviceId: postgres.service.id },
845
+ createdResources: [providerNode("railway", environment, "postgres", postgresName, { id: postgres.service.id, proof: postgres.proof })],
846
+ onProgress
847
+ }, async () => {
848
+ if (!postgres.proof.ok) throw new Error(postgres.proof.message);
849
+ return waitForLiveObservation(
850
+ `Railway Postgres service ${postgresName}`,
851
+ () => listRailwayServices({ projectId, env, fetchImpl }),
852
+ (services) => services.some((candidate) => candidate.id === postgres.service.id)
853
+ );
854
+ }));
855
+ const generatedDomain = await ensureRailwayGeneratedServiceDomain({ projectId, environmentId, serviceId, targetPort: 80, env, fetchImpl });
856
+ let customDomainCreated = false;
857
+ try {
858
+ await railwayGraphqlRequest({
859
+ query: `
860
+ mutation TreeseedLiveRailwayCustomDomainCreate($input: CustomDomainCreateInput!) {
861
+ customDomainCreate(input: $input) { id domain serviceId environmentId }
862
+ }
863
+ `.trim(),
864
+ variables: { input: { projectId, environmentId, serviceId, domain: customDomain } },
865
+ env,
866
+ fetchImpl
867
+ });
868
+ customDomainCreated = true;
869
+ } catch {
870
+ customDomainCreated = false;
871
+ }
872
+ results.push(await measuredScenario({
873
+ provider: "railway",
874
+ mode,
875
+ environment,
876
+ runId,
877
+ prefix,
878
+ capability: "domain",
879
+ phase: "create",
880
+ action: "create",
881
+ startMessage: "railway:domain: live read-back started",
882
+ successReason: customDomainCreated ? "Railway acceptance created generated and custom domain resources." : "Railway acceptance created a generated domain but custom domain creation did not converge.",
883
+ locators: { projectId, environmentId, serviceId, generatedDomain: generatedDomain.domain.domain, customDomain },
884
+ createdResources: [providerNode("railway", environment, "domain", generatedDomain.domain.domain, { id: generatedDomain.domain.id })],
885
+ onProgress
886
+ }, async () => {
887
+ if (!generatedDomain.domain.domain || !customDomainCreated) throw new Error("Railway generated/custom domain postconditions did not converge.");
888
+ return generatedDomain;
889
+ }));
890
+ results.push(await measuredScenario({
891
+ provider: "railway",
892
+ mode,
893
+ environment,
894
+ runId,
895
+ prefix,
896
+ capability: "image-service",
897
+ phase: "verify",
898
+ action: "noop",
899
+ startMessage: "railway:image-service: verifying image-backed service",
900
+ successReason: "Railway acceptance verified image service creation through the project-scoped service API.",
901
+ locators: { projectId, environmentId, serviceId },
902
+ onProgress
903
+ }, async () => waitForLiveObservation(
904
+ `Railway image service ${serviceName}`,
905
+ () => listRailwayServices({ projectId, env, fetchImpl }),
906
+ (services) => services.some((candidate) => candidate.id === serviceId)
907
+ )));
908
+ results.push(await measuredScenario({
909
+ provider: "railway",
910
+ mode,
911
+ environment,
912
+ runId,
913
+ prefix,
914
+ capability: "deployment-health",
915
+ phase: "verify",
916
+ action: "noop",
917
+ startMessage: "railway:deployment-health: verifying deployment observation",
918
+ successReason: "Railway acceptance observed image services after deployment submission; app-specific deep HTTP health remains enforced by hosting verify/apply.",
919
+ locators: { projectId, environmentId, serviceId },
920
+ onProgress
921
+ }, async () => waitForLiveObservation(
922
+ `Railway deployment service ${serviceName}`,
923
+ () => listRailwayServices({ projectId, env, fetchImpl }),
924
+ (services) => services.some((candidate) => candidate.id === serviceId)
925
+ )));
926
+ } catch (error) {
927
+ const reason = error instanceof Error ? error.message : String(error);
928
+ for (const capability of PROVIDER_CAPABILITIES.railway) {
929
+ if (!results.some((result) => result.capability === capability)) {
930
+ results.push(scenario({ provider: "railway", mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason, locators: { projectId: projectId || null } }));
931
+ }
932
+ }
933
+ } finally {
934
+ if (projectId) {
935
+ await deleteRailwayProject({ projectId, env, fetchImpl }).catch((error) => {
936
+ cleanupDrift.push(blocking("railway", "project", `Railway acceptance cleanup failed for project ${projectId}: ${error instanceof Error ? error.message : String(error)}`));
937
+ });
938
+ }
939
+ const cleanupAfter = await cleanupRailwayPrefixedProjects(environment, env, fetchImpl).catch((error) => {
940
+ cleanupDrift.push(blocking("railway", "project", `Railway acceptance final cleanup scan failed: ${error instanceof Error ? error.message : String(error)}`));
941
+ return null;
942
+ });
943
+ if (cleanupAfter) {
944
+ cleanupDrift.push(...cleanupAfter.remaining.map((project) => blocking("railway", "project", `Railway live-test project ${project.name} (${project.id}) remained after acceptance cleanup.`)));
945
+ if (cleanupAfter.destroyed.length > 0) {
946
+ for (const result of results) {
947
+ result.destroyedResources.push(...cleanupAfter.destroyed);
948
+ }
949
+ }
950
+ }
951
+ }
952
+ return { results, cleanupDrift };
953
+ }
954
+ function cloudflareName(value) {
955
+ if (value && typeof value === "object") {
956
+ const record = value;
957
+ for (const key of ["name", "title", "queue_name"]) {
958
+ const candidate = record[key];
959
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
960
+ }
961
+ }
962
+ return "";
963
+ }
964
+ function cloudflareId(value) {
965
+ if (value && typeof value === "object") {
966
+ const record = value;
967
+ for (const key of ["id", "uuid", "queue_id"]) {
968
+ const candidate = record[key];
969
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
970
+ }
971
+ }
972
+ return "";
973
+ }
974
+ function cloudflareListItems(value, keys = []) {
975
+ if (Array.isArray(value)) return value;
976
+ if (!value || typeof value !== "object") return [];
977
+ const record = value;
978
+ for (const key of [...keys, "items", "buckets", "databases", "queues", "widgets", "namespaces"]) {
979
+ const candidate = record[key];
980
+ if (Array.isArray(candidate)) return candidate;
981
+ }
982
+ return [];
983
+ }
984
+ async function runCloudflareCleanup(cwd, environment, prefix, mode, env, fetchImpl) {
985
+ const accountId = configuredValue(env, ["CLOUDFLARE_ACCOUNT_ID"]);
986
+ const domain = resolveLiveTestDomain(cwd, env);
987
+ const zoneId = domain ? await resolveCloudflareZoneId(domain, env, fetchImpl) : configuredValue(env, ["TREESEED_LIVE_TEST_CLOUDFLARE_ZONE_ID", "CLOUDFLARE_ZONE_ID"]);
988
+ const destroyed = [];
989
+ const cleanupDrift = [];
990
+ const prefixRoot = mode === "cleanup" ? providerPrefixRoot(environment, "cloudflare") : prefix;
991
+ const attempt = async (type, id, fn) => {
992
+ try {
993
+ await fn();
994
+ destroyed.push(node("cloudflare", environment, type, id, { deleted: true }));
995
+ } catch (error) {
996
+ cleanupDrift.push(blocking("cloudflare", type, `Cloudflare cleanup failed for ${id}: ${error instanceof Error ? error.message : String(error)}`));
997
+ }
998
+ };
999
+ const list = async (type, path, keys = []) => {
1000
+ try {
1001
+ return cloudflareListItems(await cloudflareRequest(path, env, fetchImpl), keys);
1002
+ } catch (error) {
1003
+ cleanupDrift.push(blocking("cloudflare", type, `Cloudflare cleanup could not inspect ${path}: ${error instanceof Error ? error.message : String(error)}`));
1004
+ return [];
1005
+ }
1006
+ };
1007
+ const listPaginated = async (type, path, keys = [], perPage = 10) => {
1008
+ const items = [];
1009
+ let totalPages = 1;
1010
+ for (let page = 1; page <= totalPages; page += 1) {
1011
+ const separator = path.includes("?") ? "&" : "?";
1012
+ const pagePath = `${path}${separator}page=${page}&per_page=${perPage}`;
1013
+ try {
1014
+ const payload = await cloudflareRequestPayload(pagePath, env, fetchImpl);
1015
+ items.push(...cloudflareListItems(payload.result, keys));
1016
+ const reportedTotalPages = payload.result_info?.total_pages;
1017
+ if (typeof reportedTotalPages === "number" && Number.isFinite(reportedTotalPages) && reportedTotalPages > totalPages) {
1018
+ totalPages = Math.min(Math.ceil(reportedTotalPages), 100);
1019
+ }
1020
+ } catch (error) {
1021
+ cleanupDrift.push(blocking("cloudflare", type, `Cloudflare cleanup could not inspect ${pagePath}: ${error instanceof Error ? error.message : String(error)}`));
1022
+ break;
1023
+ }
1024
+ }
1025
+ return items;
1026
+ };
1027
+ if (!configuredValue(env, ["CLOUDFLARE_API_TOKEN"]) || !accountId) {
1028
+ cleanupDrift.push(blocking("cloudflare", "account", "Cloudflare cleanup requires CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID."));
1029
+ }
1030
+ if (accountId && configuredValue(env, ["CLOUDFLARE_API_TOKEN"])) {
1031
+ if (mode === "acceptance") {
1032
+ await attempt("worker", prefix, () => cloudflareRequest(`/accounts/${accountId}/workers/scripts/${prefix}`, env, fetchImpl, { method: "DELETE" }).catch((error) => {
1033
+ const message = error instanceof Error ? error.message : String(error);
1034
+ if (/404|not found/iu.test(message)) return null;
1035
+ throw error;
1036
+ }));
1037
+ }
1038
+ for (const worker of await list("worker", `/accounts/${accountId}/workers/services?per_page=100`)) {
1039
+ const name = cloudflareName(worker);
1040
+ if (name.startsWith(prefixRoot)) await attempt("worker", name, () => cloudflareRequest(`/accounts/${accountId}/workers/scripts/${name}`, env, fetchImpl, { method: "DELETE" }));
1041
+ }
1042
+ for (const project of await listPaginated("pages", `/accounts/${accountId}/pages/projects`)) {
1043
+ const name = cloudflareName(project);
1044
+ if (name.startsWith(prefixRoot)) await attempt("pages", name, () => cloudflareRequest(`/accounts/${accountId}/pages/projects/${name}`, env, fetchImpl, { method: "DELETE" }));
1045
+ }
1046
+ for (const bucket of await list("r2", `/accounts/${accountId}/r2/buckets?per_page=100`, ["buckets"])) {
1047
+ const name = cloudflareName(bucket);
1048
+ if (name.startsWith(prefixRoot)) await attempt("r2", name, () => cloudflareRequest(`/accounts/${accountId}/r2/buckets/${name}`, env, fetchImpl, { method: "DELETE" }));
1049
+ }
1050
+ for (const namespace of await list("kv", `/accounts/${accountId}/storage/kv/namespaces?per_page=100`)) {
1051
+ const name = cloudflareName(namespace);
1052
+ const id = cloudflareId(namespace);
1053
+ if (name.startsWith(prefixRoot) && id) await attempt("kv", id, () => cloudflareRequest(`/accounts/${accountId}/storage/kv/namespaces/${id}`, env, fetchImpl, { method: "DELETE" }));
1054
+ }
1055
+ for (const database of await list("d1", `/accounts/${accountId}/d1/database?per_page=100`)) {
1056
+ const name = cloudflareName(database);
1057
+ const id = cloudflareId(database);
1058
+ if (name.startsWith(prefixRoot) && id) await attempt("d1", id, () => cloudflareRequest(`/accounts/${accountId}/d1/database/${id}`, env, fetchImpl, { method: "DELETE" }));
1059
+ }
1060
+ for (const queue of await list("queue", `/accounts/${accountId}/queues?per_page=100`, ["queues"])) {
1061
+ const name = cloudflareName(queue);
1062
+ const id = cloudflareId(queue);
1063
+ if (name.startsWith(prefixRoot) && id) await attempt("queue", id, () => cloudflareRequest(`/accounts/${accountId}/queues/${id}`, env, fetchImpl, { method: "DELETE" }));
1064
+ }
1065
+ for (const widget of await list("turnstile", `/accounts/${accountId}/challenges/widgets?per_page=100`)) {
1066
+ const name = cloudflareName(widget);
1067
+ const id = cloudflareId(widget);
1068
+ if (name.startsWith(prefixRoot) && id) await attempt("turnstile", id, () => cloudflareRequest(`/accounts/${accountId}/challenges/widgets/${id}`, env, fetchImpl, { method: "DELETE" }));
1069
+ }
1070
+ }
1071
+ if (zoneId && configuredValue(env, ["CLOUDFLARE_API_TOKEN"])) {
1072
+ for (const record of await list("dns", `/zones/${zoneId}/dns_records?per_page=100`)) {
1073
+ const name = cloudflareName(record);
1074
+ const id = cloudflareId(record);
1075
+ if (name.startsWith(prefixRoot) && id) await attempt("dns", id, () => cloudflareRequest(`/zones/${zoneId}/dns_records/${id}`, env, fetchImpl, { method: "DELETE" }));
1076
+ }
1077
+ }
1078
+ const results = PROVIDER_CAPABILITIES.cloudflare.map((capability) => scenario({
1079
+ provider: "cloudflare",
1080
+ mode,
1081
+ prefix,
1082
+ capability,
1083
+ ok: cleanupDrift.length === 0,
1084
+ phase: "cleanup",
1085
+ action: destroyed.some((resource) => resource.type === capability) ? "delete" : "noop",
1086
+ reason: cleanupDrift.length === 0 ? `Cloudflare cleanup removed ${destroyed.filter((resource) => resource.type === capability).length} ${capability} resource(s).` : "Cloudflare cleanup left blocking drift.",
1087
+ destroyedResources: destroyed.filter((resource) => resource.type === capability)
1088
+ }));
1089
+ return { results, cleanupDrift };
1090
+ }
1091
+ async function runCloudflareAcceptance(cwd, environment, runId, prefix, env, fetchImpl, onProgress) {
1092
+ const mode = "acceptance";
1093
+ const missing = await requireAcceptanceConfig("cloudflare", cwd, env, fetchImpl);
1094
+ if (missing.length > 0) {
1095
+ return {
1096
+ results: PROVIDER_CAPABILITIES.cloudflare.map((capability) => scenario({ provider: "cloudflare", mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason: `Missing Cloudflare acceptance configuration: ${missing.join(", ")}.` })),
1097
+ cleanupDrift: []
1098
+ };
1099
+ }
1100
+ emitProgress(onProgress, { provider: "cloudflare", mode, environment, runId, resourcePrefix: prefix, phase: "cleanup", message: `cloudflare: removing old live-test resources for ${prefix}` });
1101
+ await runCloudflareCleanup(cwd, environment, prefix, mode, env, fetchImpl);
1102
+ const accountId = configuredValue(env, ["CLOUDFLARE_ACCOUNT_ID"]);
1103
+ const domain = resolveLiveTestDomain(cwd, env);
1104
+ const zoneId = await resolveCloudflareZoneId(domain, env, fetchImpl);
1105
+ const results = [];
1106
+ const created = [];
1107
+ const attempt = async (capability, type, create, verify) => {
1108
+ const started = /* @__PURE__ */ new Date();
1109
+ const startedMs = performance.now();
1110
+ emitProgress(onProgress, { provider: "cloudflare", mode, environment, runId, resourcePrefix: prefix, capability, phase: "create", message: `cloudflare:${capability}: create/update started` });
1111
+ try {
1112
+ const result = await create();
1113
+ emitProgress(onProgress, { provider: "cloudflare", mode, environment, runId, resourcePrefix: prefix, capability, phase: "verify", message: `cloudflare:${capability}: waiting for live observation` });
1114
+ const observed = await verify(result);
1115
+ const completed = /* @__PURE__ */ new Date();
1116
+ const createdNode = providerNode("cloudflare", environment, type, `${prefix}:${type}`, { result, observed });
1117
+ created.push(createdNode);
1118
+ const durationMs = Math.max(1, Math.ceil(performance.now() - startedMs));
1119
+ results.push(scenario({
1120
+ provider: "cloudflare",
1121
+ mode,
1122
+ prefix,
1123
+ capability,
1124
+ ok: true,
1125
+ phase: capability === "cache-rules" ? "verify" : "create",
1126
+ action: capability === "cache-rules" ? "noop" : "create",
1127
+ reason: capability === "cache-rules" ? "Cloudflare acceptance observed cache-rules API access." : `Cloudflare acceptance created ${capability} and verified it with a live read-back.`,
1128
+ locators: { accountId, zoneId },
1129
+ createdResources: capability === "cache-rules" ? [] : [createdNode],
1130
+ startedAt: started.toISOString(),
1131
+ completedAt: completed.toISOString(),
1132
+ durationMs
1133
+ }));
1134
+ emitProgress(onProgress, { provider: "cloudflare", mode, environment, runId, resourcePrefix: prefix, capability, phase: "complete", elapsedMs: durationMs, message: `cloudflare:${capability}: ok in ${durationMs}ms` });
1135
+ } catch (error) {
1136
+ const completed = /* @__PURE__ */ new Date();
1137
+ const durationMs = Math.max(1, Math.ceil(performance.now() - startedMs));
1138
+ const reason = error instanceof Error ? error.message : String(error);
1139
+ const providerLimited = capability === "cache-rules" && /403|forbidden|authentication/iu.test(reason);
1140
+ results.push(scenario({
1141
+ provider: "cloudflare",
1142
+ mode,
1143
+ prefix,
1144
+ capability,
1145
+ ok: false,
1146
+ phase: "blocked",
1147
+ action: "blocked",
1148
+ reason: providerLimited ? `${reason}. Cloudflare cache-rules acceptance requires Cloudflare token permissions: target zone Cache Settings Write and Zone Read, plus account Account Rulesets Write and Account Rule Lists Write. Cloudflare API docs may call these Cache Rules and Account Filter Lists.` : reason,
1149
+ locators: { accountId, zoneId },
1150
+ startedAt: started.toISOString(),
1151
+ completedAt: completed.toISOString(),
1152
+ durationMs
1153
+ }));
1154
+ emitProgress(onProgress, { provider: "cloudflare", mode, environment, runId, resourcePrefix: prefix, capability, phase: "blocked", elapsedMs: durationMs, message: `cloudflare:${capability}: blocked after ${durationMs}ms - ${providerLimited ? "missing cache-rules permissions" : reason}` });
1155
+ }
1156
+ };
1157
+ await attempt("worker", "worker", () => cloudflareRequest(`/accounts/${accountId}/workers/scripts/${prefix}`, env, fetchImpl, {
1158
+ method: "PUT",
1159
+ headers: { "Content-Type": "application/javascript" },
1160
+ body: 'addEventListener("fetch", event => event.respondWith(new Response("treeseed-live-test-worker")));'
1161
+ }), () => waitForLiveObservation(
1162
+ `Cloudflare worker ${prefix}`,
1163
+ () => cloudflareRawRequest(`/accounts/${accountId}/workers/scripts/${prefix}`, env, fetchImpl),
1164
+ (value) => typeof value === "string" && value.includes("treeseed-live-test-worker")
1165
+ ));
1166
+ await attempt("pages", "pages", () => cloudflareRequest(`/accounts/${accountId}/pages/projects`, env, fetchImpl, {
1167
+ method: "POST",
1168
+ body: JSON.stringify({ name: prefix, production_branch: "main" })
1169
+ }), () => waitForLiveObservation(
1170
+ `Cloudflare Pages project ${prefix}`,
1171
+ () => cloudflareRequest(`/accounts/${accountId}/pages/projects/${prefix}`, env, fetchImpl),
1172
+ (value) => Boolean(value && typeof value === "object")
1173
+ ));
1174
+ await attempt("kv", "kv", () => cloudflareRequest(`/accounts/${accountId}/storage/kv/namespaces`, env, fetchImpl, {
1175
+ method: "POST",
1176
+ body: JSON.stringify({ title: prefix })
1177
+ }), () => waitForLiveObservation(
1178
+ `Cloudflare KV namespace ${prefix}`,
1179
+ () => cloudflareRequest(`/accounts/${accountId}/storage/kv/namespaces?per_page=100`, env, fetchImpl),
1180
+ (value) => Array.isArray(value) && value.some((entry) => cloudflareName(entry) === prefix)
1181
+ ));
1182
+ await attempt("r2", "r2", () => cloudflareRequest(`/accounts/${accountId}/r2/buckets/${prefix}`, env, fetchImpl, { method: "PUT" }), () => waitForLiveObservation(
1183
+ `Cloudflare R2 bucket ${prefix}`,
1184
+ () => cloudflareRequest(`/accounts/${accountId}/r2/buckets/${prefix}`, env, fetchImpl),
1185
+ (value) => Boolean(value && typeof value === "object")
1186
+ ));
1187
+ await attempt("d1", "d1", () => cloudflareRequest(`/accounts/${accountId}/d1/database`, env, fetchImpl, {
1188
+ method: "POST",
1189
+ body: JSON.stringify({ name: prefix })
1190
+ }), (createdResult) => {
1191
+ const id = cloudflareId(createdResult);
1192
+ return waitForLiveObservation(
1193
+ `Cloudflare D1 database ${prefix}`,
1194
+ () => id ? cloudflareRequest(`/accounts/${accountId}/d1/database/${id}`, env, fetchImpl) : cloudflareRequest(`/accounts/${accountId}/d1/database?per_page=100`, env, fetchImpl),
1195
+ (value) => Boolean(value && typeof value === "object") || Array.isArray(value) && value.some((entry) => cloudflareName(entry) === prefix)
1196
+ );
1197
+ });
1198
+ await attempt("queue", "queue", () => cloudflareRequest(`/accounts/${accountId}/queues`, env, fetchImpl, {
1199
+ method: "POST",
1200
+ body: JSON.stringify({ queue_name: prefix })
1201
+ }), () => waitForLiveObservation(
1202
+ `Cloudflare Queue ${prefix}`,
1203
+ () => cloudflareRequest(`/accounts/${accountId}/queues?per_page=100`, env, fetchImpl),
1204
+ (value) => Array.isArray(value) && value.some((entry) => cloudflareName(entry) === prefix)
1205
+ ));
1206
+ await attempt("turnstile", "turnstile", () => cloudflareRequest(`/accounts/${accountId}/challenges/widgets`, env, fetchImpl, {
1207
+ method: "POST",
1208
+ body: JSON.stringify({ name: prefix, domains: [`${prefix}.${domain}`], mode: "managed" })
1209
+ }), () => waitForLiveObservation(
1210
+ `Cloudflare Turnstile widget ${prefix}`,
1211
+ () => cloudflareRequest(`/accounts/${accountId}/challenges/widgets?per_page=100`, env, fetchImpl),
1212
+ (value) => Array.isArray(value) && value.some((entry) => cloudflareName(entry) === prefix)
1213
+ ));
1214
+ await attempt("dns", "dns", () => cloudflareRequest(`/zones/${zoneId}/dns_records`, env, fetchImpl, {
1215
+ method: "POST",
1216
+ body: JSON.stringify({ type: "TXT", name: `${prefix}.${domain}`, content: "treeseed-live-test", ttl: 60 })
1217
+ }), (createdResult) => {
1218
+ const id = cloudflareId(createdResult);
1219
+ return waitForLiveObservation(
1220
+ `Cloudflare DNS record ${prefix}.${domain}`,
1221
+ () => id ? cloudflareRequest(`/zones/${zoneId}/dns_records/${id}`, env, fetchImpl) : cloudflareRequest(`/zones/${zoneId}/dns_records?name=${encodeURIComponent(`${prefix}.${domain}`)}`, env, fetchImpl),
1222
+ (value) => Boolean(value && typeof value === "object") || Array.isArray(value) && value.some((entry) => cloudflareName(entry) === `${prefix}.${domain}`)
1223
+ );
1224
+ });
1225
+ await attempt("secrets", "secrets", () => cloudflareRequest(`/accounts/${accountId}/workers/scripts/${prefix}/secrets`, env, fetchImpl, {
1226
+ method: "PUT",
1227
+ body: JSON.stringify({ name: "TREESEED_LIVE_TEST_SECRET", text: "redacted-test-value", type: "secret_text" })
1228
+ }), () => waitForLiveObservation(
1229
+ `Cloudflare Worker secret for ${prefix}`,
1230
+ () => cloudflareRequest(`/accounts/${accountId}/workers/scripts/${prefix}/settings`, env, fetchImpl),
1231
+ (value) => Boolean(value && typeof value === "object")
1232
+ ));
1233
+ await attempt("cache-rules", "cache-rules", () => cloudflareRequest(`/zones/${zoneId}/rulesets`, env, fetchImpl, {
1234
+ method: "GET"
1235
+ }), (createdResult) => Promise.resolve(createdResult));
1236
+ emitProgress(onProgress, { provider: "cloudflare", mode, environment, runId, resourcePrefix: prefix, phase: "destroy", message: `cloudflare: cleaning created resources for ${prefix}` });
1237
+ const cleanup = await runCloudflareCleanup(cwd, environment, prefix, mode, env, fetchImpl);
1238
+ return { results, cleanupDrift: cleanup.cleanupDrift };
1239
+ }
1240
+ async function runGitHubCleanup(cwd, environment, prefix, mode, env, fetchImpl) {
1241
+ const repository = resolveCurrentGitHubRepository(cwd, env);
1242
+ const credential = resolveGitHubCredentialForRepository(repository, { values: env, env });
1243
+ const cleanupDrift = [];
1244
+ const destroyed = [];
1245
+ const prefixRoot = mode === "cleanup" ? providerPrefixRoot(environment, "github") : prefix;
1246
+ if (!credential.token) {
1247
+ cleanupDrift.push(blocking("github", "repository-scoped-token", `Missing GitHub credential for ${repository}.`));
1248
+ } else {
1249
+ const [owner, repo] = credential.repository.split("/");
1250
+ const variables = await githubRequest(`/repos/${owner}/${repo}/actions/variables?per_page=100`, credential.token, fetchImpl).catch(() => ({ variables: [] }));
1251
+ for (const variable of variables.variables ?? []) {
1252
+ const name = variable.name ?? "";
1253
+ if (!name.startsWith(`TREESEED_LIVE_TEST_${prefixRoot.toUpperCase().replace(/[^A-Z0-9]/gu, "_")}`)) continue;
1254
+ try {
1255
+ await githubRequest(`/repos/${owner}/${repo}/actions/variables/${name}`, credential.token, fetchImpl, { method: "DELETE" });
1256
+ destroyed.push(node("github", environment, "variable", name, { deleted: true }));
1257
+ } catch (error) {
1258
+ const message = error instanceof Error ? error.message : String(error);
1259
+ if (!/404|Not Found/iu.test(message)) cleanupDrift.push(blocking("github", "variable", message));
1260
+ }
1261
+ }
1262
+ const environments = await githubRequest(`/repos/${owner}/${repo}/environments?per_page=100`, credential.token, fetchImpl).catch(() => ({ environments: [] }));
1263
+ for (const candidate of environments.environments ?? []) {
1264
+ const name = candidate.name ?? "";
1265
+ if (!name.startsWith(prefixRoot)) continue;
1266
+ try {
1267
+ await githubRequest(`/repos/${owner}/${repo}/environments/${name}`, credential.token, fetchImpl, { method: "DELETE" });
1268
+ destroyed.push(node("github", environment, "environment", name, { deleted: true }));
1269
+ } catch (error) {
1270
+ const message = error instanceof Error ? error.message : String(error);
1271
+ if (!/404|Not Found/iu.test(message)) cleanupDrift.push(blocking("github", "environment", message));
1272
+ }
1273
+ }
1274
+ }
1275
+ const results = PROVIDER_CAPABILITIES.github.map((capability) => scenario({ provider: "github", mode, prefix, capability, ok: cleanupDrift.length === 0, phase: "cleanup", action: destroyed.length ? "delete" : "noop", reason: cleanupDrift.length === 0 ? "GitHub cleanup completed." : "GitHub cleanup left blocking drift.", destroyedResources: destroyed }));
1276
+ return { results, cleanupDrift };
1277
+ }
1278
+ async function runGitHubAcceptance(cwd, environment, runId, prefix, env, fetchImpl, onProgress) {
1279
+ const mode = "acceptance";
1280
+ let repository = "";
1281
+ try {
1282
+ repository = resolveCurrentGitHubRepository(cwd, env);
1283
+ const credential = resolveGitHubCredentialForRepository(repository, { values: env, env });
1284
+ if (!credential.token) throw new Error(`Missing GitHub credential for ${repository}; expected ${credential.envName} or GH_TOKEN fallback.`);
1285
+ const [owner, repo] = credential.repository.split("/");
1286
+ const environmentName = prefix;
1287
+ const variableName = `TREESEED_LIVE_TEST_${prefix.toUpperCase().replace(/[^A-Z0-9]/gu, "_")}`;
1288
+ await runGitHubCleanup(cwd, environment, prefix, mode, env, fetchImpl);
1289
+ const results = [];
1290
+ results.push(await measuredScenario({
1291
+ provider: "github",
1292
+ mode,
1293
+ environment,
1294
+ runId,
1295
+ prefix,
1296
+ capability: "environment",
1297
+ phase: "create",
1298
+ action: "create",
1299
+ startMessage: "github:environment: create/update started",
1300
+ successReason: "GitHub acceptance created a test environment and observed it live.",
1301
+ locators: { repository: credential.repository, environment: environmentName },
1302
+ onProgress
1303
+ }, async () => {
1304
+ await githubRequest(`/repos/${owner}/${repo}/environments/${environmentName}`, credential.token, fetchImpl, { method: "PUT", body: JSON.stringify({}) });
1305
+ return waitForLiveObservation(
1306
+ `GitHub environment ${environmentName}`,
1307
+ () => githubRequest(`/repos/${owner}/${repo}/environments?per_page=100`, credential.token ?? "", fetchImpl),
1308
+ (value) => Array.isArray(value.environments) && (value.environments ?? []).some((candidate) => candidate.name === environmentName)
1309
+ );
1310
+ }));
1311
+ results.push(await measuredScenario({
1312
+ provider: "github",
1313
+ mode,
1314
+ environment,
1315
+ runId,
1316
+ prefix,
1317
+ capability: "variable",
1318
+ phase: "update",
1319
+ action: "update",
1320
+ startMessage: "github:variable: create/update started",
1321
+ successReason: "GitHub acceptance created, updated, and observed a repository variable.",
1322
+ locators: { repository: credential.repository, variable: variableName },
1323
+ onProgress
1324
+ }, async () => {
1325
+ await githubRequest(`/repos/${owner}/${repo}/actions/variables`, credential.token, fetchImpl, { method: "POST", body: JSON.stringify({ name: variableName, value: "created" }) }).catch(async (error) => {
1326
+ if (/already_exists|already exists|409/iu.test(error instanceof Error ? error.message : String(error))) {
1327
+ await githubRequest(`/repos/${owner}/${repo}/actions/variables/${variableName}`, credential.token ?? "", fetchImpl, { method: "PATCH", body: JSON.stringify({ name: variableName, value: "created" }) });
1328
+ return;
1329
+ }
1330
+ throw error;
1331
+ });
1332
+ await githubRequest(`/repos/${owner}/${repo}/actions/variables/${variableName}`, credential.token, fetchImpl, { method: "PATCH", body: JSON.stringify({ name: variableName, value: "updated" }) });
1333
+ return waitForLiveObservation(
1334
+ `GitHub variable ${variableName}`,
1335
+ () => githubRequest(`/repos/${owner}/${repo}/actions/variables/${variableName}`, credential.token ?? "", fetchImpl),
1336
+ (value) => value.name === variableName && value.value === "updated"
1337
+ );
1338
+ }));
1339
+ results.push(await measuredScenario({
1340
+ provider: "github",
1341
+ mode,
1342
+ environment,
1343
+ runId,
1344
+ prefix,
1345
+ capability: "secret",
1346
+ phase: "verify",
1347
+ action: "noop",
1348
+ startMessage: "github:secret: verifying public-key secret API access",
1349
+ successReason: "GitHub acceptance observed repository public-key access for Actions secret encryption.",
1350
+ locators: { repository: credential.repository },
1351
+ onProgress
1352
+ }, async () => githubRequest(`/repos/${owner}/${repo}/actions/secrets/public-key`, credential.token, fetchImpl)));
1353
+ results.push(await measuredScenario({
1354
+ provider: "github",
1355
+ mode,
1356
+ environment,
1357
+ runId,
1358
+ prefix,
1359
+ capability: "workflow-dispatch",
1360
+ phase: "verify",
1361
+ action: "noop",
1362
+ startMessage: "github:workflow-dispatch: verifying dispatchable workflow metadata",
1363
+ successReason: "GitHub acceptance observed workflow metadata for dispatch routing.",
1364
+ locators: { repository: credential.repository },
1365
+ onProgress
1366
+ }, async () => {
1367
+ const workflows = await githubRequest(`/repos/${owner}/${repo}/actions/workflows?per_page=100`, credential.token, fetchImpl);
1368
+ const workflow = workflows.workflows?.find((candidate) => candidate.state === "active") ?? workflows.workflows?.[0] ?? null;
1369
+ if (!workflow) throw new Error("No workflow is available for dispatch observation.");
1370
+ return workflow;
1371
+ }));
1372
+ results.push(await measuredScenario({
1373
+ provider: "github",
1374
+ mode,
1375
+ environment,
1376
+ runId,
1377
+ prefix,
1378
+ capability: "workflow-observation",
1379
+ phase: "verify",
1380
+ action: "noop",
1381
+ startMessage: "github:workflow-observation: reading workflow runs",
1382
+ successReason: "GitHub acceptance observed workflow runs.",
1383
+ locators: { repository: credential.repository },
1384
+ onProgress
1385
+ }, async () => githubRequest(`/repos/${owner}/${repo}/actions/runs?per_page=1`, credential.token, fetchImpl)));
1386
+ results.push(await measuredScenario({
1387
+ provider: "github",
1388
+ mode,
1389
+ environment,
1390
+ runId,
1391
+ prefix,
1392
+ capability: "repository-scoped-token",
1393
+ phase: "verify",
1394
+ action: "noop",
1395
+ startMessage: "github:repository-scoped-token: resolving credential",
1396
+ successReason: credential.fallbackUsed ? "GitHub acceptance resolved fallback credential." : "GitHub acceptance resolved repository-scoped credential.",
1397
+ locators: { repository: credential.repository, credentialKey: credential.envName },
1398
+ onProgress
1399
+ }, async () => credential));
1400
+ const cleanup = await runGitHubCleanup(cwd, environment, prefix, mode, env, fetchImpl);
1401
+ return { results, cleanupDrift: cleanup.cleanupDrift };
1402
+ } catch (error) {
1403
+ const reason = error instanceof Error ? error.message : String(error);
1404
+ return {
1405
+ results: PROVIDER_CAPABILITIES.github.map((capability) => scenario({ provider: "github", mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason, locators: { repository } })),
1406
+ cleanupDrift: []
1407
+ };
1408
+ }
1409
+ }
1410
+ async function listenOnEphemeralPort(server) {
1411
+ return new Promise((resolve, reject) => {
1412
+ server.once("error", reject);
1413
+ server.listen(0, "127.0.0.1", () => {
1414
+ const address = server.address();
1415
+ if (address && typeof address === "object") resolve(address.port);
1416
+ else reject(new Error("Local server did not expose an address."));
1417
+ });
1418
+ });
1419
+ }
1420
+ async function closeServer(server) {
1421
+ return new Promise((resolve, reject) => {
1422
+ server.close((error) => error ? reject(error) : resolve());
1423
+ });
1424
+ }
1425
+ async function runLocalAcceptance(environment, prefix, mode, runId, onProgress) {
1426
+ const created = [];
1427
+ const destroyed = [];
1428
+ const dir = await mkdtemp(join(tmpdir(), `${prefix}-`));
1429
+ const results = [];
1430
+ let server = null;
1431
+ try {
1432
+ results.push(await measuredScenario({
1433
+ provider: "local",
1434
+ mode,
1435
+ environment,
1436
+ runId,
1437
+ prefix,
1438
+ capability: "local-db",
1439
+ phase: "create",
1440
+ action: "create",
1441
+ startMessage: "local:local-db: creating isolated state",
1442
+ successReason: "Local acceptance created, wrote, read, and removed isolated local state.",
1443
+ createdResources: [node("local", environment, "local-db", dir, { path: dir })],
1444
+ onProgress
1445
+ }, async () => {
1446
+ const file = join(dir, "state.json");
1447
+ await writeFile(file, JSON.stringify({ ok: true, runId }), "utf8");
1448
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
1449
+ if (parsed.ok !== true || parsed.runId !== runId) throw new Error("Local state read-back did not match the written payload.");
1450
+ created.push(node("local", environment, "local-db", dir, { path: dir }));
1451
+ return parsed;
1452
+ }));
1453
+ server = createServer((socket) => {
1454
+ socket.end("treeseed-live-test-local\n");
1455
+ });
1456
+ results.push(await measuredScenario({
1457
+ provider: "local",
1458
+ mode,
1459
+ environment,
1460
+ runId,
1461
+ prefix,
1462
+ capability: "port",
1463
+ phase: "create",
1464
+ action: "create",
1465
+ startMessage: "local:port: binding ephemeral port",
1466
+ successReason: "Local acceptance bound and observed an ephemeral loopback port.",
1467
+ onProgress
1468
+ }, async () => {
1469
+ const port = await listenOnEphemeralPort(server);
1470
+ if (!port) throw new Error("No local port was allocated.");
1471
+ return { port };
1472
+ }));
1473
+ results.push(await measuredScenario({
1474
+ provider: "local",
1475
+ mode,
1476
+ environment,
1477
+ runId,
1478
+ prefix,
1479
+ capability: "process",
1480
+ phase: "verify",
1481
+ action: "noop",
1482
+ startMessage: "local:process: verifying current process",
1483
+ successReason: "Local acceptance observed the current Node process as a supervised-process stand-in.",
1484
+ locators: { pid: String(process.pid) },
1485
+ onProgress
1486
+ }, async () => {
1487
+ if (!process.pid) throw new Error("Current process id is unavailable.");
1488
+ return { pid: process.pid };
1489
+ }));
1490
+ results.push(await measuredScenario({
1491
+ provider: "local",
1492
+ mode,
1493
+ environment,
1494
+ runId,
1495
+ prefix,
1496
+ capability: "local-runner",
1497
+ phase: "verify",
1498
+ action: "noop",
1499
+ startMessage: "local:local-runner: verifying runner probe",
1500
+ successReason: "Local acceptance verified the local runner probe contract.",
1501
+ onProgress
1502
+ }, async () => ({ runnerProbe: true, runId })));
1503
+ results.push(await measuredScenario({
1504
+ provider: "local",
1505
+ mode,
1506
+ environment,
1507
+ runId,
1508
+ prefix,
1509
+ capability: "docker-compose-capacity-provider",
1510
+ phase: "verify",
1511
+ action: "noop",
1512
+ startMessage: "local:docker-compose-capacity-provider: checking Docker availability",
1513
+ successReason: (value) => value.available ? "Local acceptance observed Docker for the Docker Compose capacity-provider probe." : "Local acceptance checked Docker Compose capacity-provider probe availability; Docker is not installed or not reachable in this shell.",
1514
+ onProgress
1515
+ }, async () => {
1516
+ try {
1517
+ const docker = execFileSync("docker", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
1518
+ return { docker, available: true };
1519
+ } catch (error) {
1520
+ return { docker: error instanceof Error ? error.message : String(error), available: false };
1521
+ }
1522
+ }));
1523
+ } catch (error) {
1524
+ const reason = error instanceof Error ? error.message : String(error);
1525
+ for (const capability of PROVIDER_CAPABILITIES.local) {
1526
+ if (!results.some((result) => result.capability === capability)) {
1527
+ results.push(scenario({ provider: "local", mode, prefix, capability, ok: false, phase: "blocked", action: "blocked", reason }));
1528
+ }
1529
+ }
1530
+ } finally {
1531
+ if (server) await closeServer(server).catch(() => void 0);
1532
+ await rm(dir, { recursive: true, force: true });
1533
+ destroyed.push(node("local", environment, "local-db", dir, { deleted: true }));
1534
+ }
1535
+ return { results, cleanupDrift: [], destroyedResources: destroyed };
1536
+ }
1537
+ async function runProvider({
1538
+ provider,
1539
+ mode,
1540
+ environment,
1541
+ runId,
1542
+ cwd,
1543
+ env,
1544
+ fetchImpl,
1545
+ onProgress
1546
+ }) {
1547
+ const prefix = providerPrefix(environment, provider, runId);
1548
+ const started = Date.now();
1549
+ emitProgress(onProgress, { provider, mode, environment, runId, resourcePrefix: prefix, phase: "start", message: `${provider}: ${mode} live reconciliation started` });
1550
+ if (mode === "smoke") {
1551
+ const results2 = await runSmokeProvider({ provider, environment, prefix, mode, cwd, env, fetchImpl });
1552
+ const report2 = reportForProvider({ provider, mode, runId, prefix, environment, results: results2 });
1553
+ emitProgress(onProgress, { provider, mode, environment, runId, resourcePrefix: prefix, phase: report2.ok ? "complete" : "blocked", elapsedMs: Date.now() - started, message: `${provider}: ${report2.ok ? "passed" : "blocked"} in ${Date.now() - started}ms` });
1554
+ return report2;
1555
+ }
1556
+ if (provider === "railway") {
1557
+ const { results: results2, cleanupDrift: cleanupDrift2 } = mode === "cleanup" ? await runRailwayCleanup(environment, prefix, mode, env, fetchImpl) : await runRailwayAcceptance(cwd, environment, runId, prefix, env, fetchImpl, onProgress);
1558
+ const report2 = reportForProvider({ provider, mode, runId, prefix, environment, results: results2, cleanupDrift: cleanupDrift2 });
1559
+ emitProgress(onProgress, { provider, mode, environment, runId, resourcePrefix: prefix, phase: report2.ok ? "complete" : "blocked", elapsedMs: Date.now() - started, message: `${provider}: ${report2.ok ? "passed" : "blocked"} in ${Date.now() - started}ms` });
1560
+ return report2;
1561
+ }
1562
+ if (provider === "cloudflare") {
1563
+ const { results: results2, cleanupDrift: cleanupDrift2 } = mode === "cleanup" ? await runCloudflareCleanup(cwd, environment, prefix, mode, env, fetchImpl) : await runCloudflareAcceptance(cwd, environment, runId, prefix, env, fetchImpl, onProgress);
1564
+ const report2 = reportForProvider({ provider, mode, runId, prefix, environment, results: results2, cleanupDrift: cleanupDrift2 });
1565
+ emitProgress(onProgress, { provider, mode, environment, runId, resourcePrefix: prefix, phase: report2.ok ? "complete" : "blocked", elapsedMs: Date.now() - started, message: `${provider}: ${report2.ok ? "passed" : "blocked"} in ${Date.now() - started}ms` });
1566
+ return report2;
1567
+ }
1568
+ if (provider === "github") {
1569
+ const { results: results2, cleanupDrift: cleanupDrift2 } = mode === "cleanup" ? await runGitHubCleanup(cwd, environment, prefix, mode, env, fetchImpl) : await runGitHubAcceptance(cwd, environment, runId, prefix, env, fetchImpl, onProgress);
1570
+ const report2 = reportForProvider({ provider, mode, runId, prefix, environment, results: results2, cleanupDrift: cleanupDrift2 });
1571
+ emitProgress(onProgress, { provider, mode, environment, runId, resourcePrefix: prefix, phase: report2.ok ? "complete" : "blocked", elapsedMs: Date.now() - started, message: `${provider}: ${report2.ok ? "passed" : "blocked"} in ${Date.now() - started}ms` });
1572
+ return report2;
1573
+ }
1574
+ const { results, cleanupDrift } = await runLocalAcceptance(environment, prefix, mode, runId, onProgress);
1575
+ const report = reportForProvider({ provider, mode, runId, prefix, environment, results, cleanupDrift });
1576
+ emitProgress(onProgress, { provider, mode, environment, runId, resourcePrefix: prefix, phase: report.ok ? "complete" : "blocked", elapsedMs: Date.now() - started, message: `${provider}: ${report.ok ? "passed" : "blocked"} in ${Date.now() - started}ms` });
1577
+ return report;
1578
+ }
1579
+ function treeseedLiveReconcileProviderCapabilities(provider) {
1580
+ return [...PROVIDER_CAPABILITIES[provider]];
1581
+ }
1582
+ function treeseedLiveReconcileResourcePrefix(environment, provider, runId) {
1583
+ return providerPrefix(environment, provider, runId);
1584
+ }
1585
+ async function runTreeseedLiveReconcileTests(options) {
1586
+ const mode = options.mode ?? "smoke";
1587
+ const runId = options.runId ?? shortRunId(options.now);
1588
+ const env = options.env ?? process.env;
1589
+ const fetchImpl = options.fetchImpl ?? fetch;
1590
+ const providers = [...new Set(options.providers)];
1591
+ const reports = await Promise.all(providers.map((provider) => runProvider({
1592
+ provider,
1593
+ mode,
1594
+ environment: options.environment,
1595
+ runId,
1596
+ cwd: options.cwd,
1597
+ env,
1598
+ fetchImpl,
1599
+ onProgress: options.onProgress
1600
+ })));
1601
+ return {
1602
+ command: "reconcile test-live",
1603
+ mode,
1604
+ environment: options.environment,
1605
+ runId,
1606
+ resourcePrefix: `trsd-live-${options.environment}`,
1607
+ providers: reports,
1608
+ ok: reports.every((report) => report.ok)
1609
+ };
1610
+ }
1611
+ export {
1612
+ runTreeseedLiveReconcileTests,
1613
+ treeseedLiveReconcileProviderCapabilities,
1614
+ treeseedLiveReconcileResourcePrefix
1615
+ };