@treeseed/sdk 0.10.22 → 0.10.24

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 (47) hide show
  1. package/dist/db/market-schema.js +3 -2
  2. package/dist/market-client.d.ts +4 -0
  3. package/dist/market-client.js +6 -0
  4. package/dist/operations/providers/default.js +26 -4
  5. package/dist/operations/repository-operations.js +6 -2
  6. package/dist/operations/services/bootstrap-runner.d.ts +5 -1
  7. package/dist/operations/services/bootstrap-runner.js +34 -5
  8. package/dist/operations/services/config-runtime.d.ts +2 -1
  9. package/dist/operations/services/deploy.d.ts +18 -1
  10. package/dist/operations/services/deploy.js +176 -24
  11. package/dist/operations/services/github-automation.d.ts +10 -1
  12. package/dist/operations/services/github-automation.js +18 -4
  13. package/dist/operations/services/hosting-audit.d.ts +2 -1
  14. package/dist/operations/services/hosting-audit.js +12 -1
  15. package/dist/operations/services/hub-launch.d.ts +1 -0
  16. package/dist/operations/services/hub-launch.js +1 -0
  17. package/dist/operations/services/hub-provider-launch.d.ts +9 -0
  18. package/dist/operations/services/hub-provider-launch.js +140 -40
  19. package/dist/operations/services/managed-host-security.d.ts +1 -1
  20. package/dist/operations/services/managed-host-security.js +4 -1
  21. package/dist/operations/services/project-platform.d.ts +25 -0
  22. package/dist/operations/services/project-platform.js +91 -23
  23. package/dist/operations/services/railway-api.js +2 -1
  24. package/dist/operations/services/railway-deploy.d.ts +32 -2
  25. package/dist/operations/services/railway-deploy.js +94 -27
  26. package/dist/operations/services/template-registry.js +33 -3
  27. package/dist/platform/contracts.d.ts +1 -0
  28. package/dist/platform/deploy-config.js +8 -1
  29. package/dist/platform/deploy-runtime.js +1 -0
  30. package/dist/platform/environment.d.ts +1 -1
  31. package/dist/platform/environment.js +1 -1
  32. package/dist/reconcile/builtin-adapters.js +155 -25
  33. package/dist/reconcile/contracts.d.ts +1 -1
  34. package/dist/reconcile/desired-state.js +17 -1
  35. package/dist/reconcile/engine.d.ts +2 -0
  36. package/dist/reconcile/engine.js +58 -3
  37. package/dist/reconcile/units.js +1 -0
  38. package/dist/sdk-types.d.ts +1 -1
  39. package/dist/sdk-types.js +2 -0
  40. package/dist/timing.d.ts +20 -0
  41. package/dist/timing.js +73 -0
  42. package/dist/treeseed/template-catalog/catalog.fixture.json +150 -0
  43. package/dist/workflow/operations.d.ts +2 -0
  44. package/drizzle/market/0000_market_control_plane.sql +3 -3
  45. package/drizzle/market/0003_project_team_slug_unique.sql +4 -0
  46. package/package.json +1 -1
  47. package/templates/github/deploy-web.workflow.yml +4 -0
@@ -9,7 +9,8 @@ import {
9
9
  } from "./plugins/constants.js";
10
10
  const deployConfigFieldAliases = {
11
11
  siteUrl: { key: "siteUrl", aliases: ["site_url"] },
12
- contactEmail: { key: "contactEmail", aliases: ["contact_email"] }
12
+ contactEmail: { key: "contactEmail", aliases: ["contact_email"] },
13
+ projectRoot: { key: "projectRoot", aliases: ["project_root"] }
13
14
  };
14
15
  const hostingFieldAliases = {
15
16
  kind: { key: "kind", aliases: ["kind"] },
@@ -547,6 +548,7 @@ function parseDeployConfig(raw) {
547
548
  slug: expectString(parsed.slug, "slug"),
548
549
  siteUrl: expectString(parsed.siteUrl, "siteUrl"),
549
550
  contactEmail: expectString(parsed.contactEmail, "contactEmail"),
551
+ projectRoot: optionalString(parsed.projectRoot),
550
552
  hosting: compatibilityHosting,
551
553
  hub,
552
554
  runtime,
@@ -630,10 +632,15 @@ function loadTreeseedDeployConfig(configPath = "treeseed.site.yaml") {
630
632
  function loadTreeseedDeployConfigFromPath(resolvedConfigPath) {
631
633
  const tenantRoot = dirname(resolvedConfigPath);
632
634
  const parsed = parseDeployConfig(readFileSync(resolvedConfigPath, "utf8"));
635
+ const projectRoot = parsed.projectRoot ? resolve(tenantRoot, parsed.projectRoot) : tenantRoot;
633
636
  Object.defineProperty(parsed, "__tenantRoot", {
634
637
  value: tenantRoot,
635
638
  enumerable: false
636
639
  });
640
+ Object.defineProperty(parsed, "__projectRoot", {
641
+ value: projectRoot,
642
+ enumerable: false
643
+ });
637
644
  Object.defineProperty(parsed, "__configPath", {
638
645
  value: resolvedConfigPath,
639
646
  enumerable: false
@@ -6,6 +6,7 @@ function defaultDeployConfig() {
6
6
  slug: "treeseed-site",
7
7
  siteUrl: "https://example.com",
8
8
  contactEmail: "contact@example.com",
9
+ projectRoot: ".",
9
10
  cloudflare: {
10
11
  accountId: "",
11
12
  workerName: "treeseed-site"
@@ -1,7 +1,7 @@
1
1
  import type { TreeseedDeployConfig, TreeseedTenantConfig } from './contracts.ts';
2
2
  import { type LoadedTreeseedPluginEntry } from './plugins.ts';
3
3
  export declare const TREESEED_ENVIRONMENT_SCOPES: readonly ["local", "staging", "prod"];
4
- export declare const TREESEED_ENVIRONMENT_REQUIREMENTS: readonly ["required", "conditional", "optional"];
4
+ export declare const TREESEED_ENVIRONMENT_REQUIREMENTS: readonly ["required", "conditional", "optional", "generated"];
5
5
  export declare const TREESEED_ENVIRONMENT_TARGETS: readonly ["local-runtime", "local-cloudflare", "github-secret", "github-variable", "cloudflare-secret", "cloudflare-var", "railway-secret", "railway-var", "config-file"];
6
6
  export declare const TREESEED_ENVIRONMENT_PURPOSES: readonly ["dev", "save", "deploy", "destroy", "config"];
7
7
  export declare const TREESEED_ENVIRONMENT_SENSITIVITY: readonly ["secret", "plain", "derived"];
@@ -8,7 +8,7 @@ import { loadTreeseedDeployConfig } from "./deploy-config.js";
8
8
  import { loadTreeseedPlugins } from "./plugins.js";
9
9
  import { loadTreeseedManifest } from "./tenant-config.js";
10
10
  const TREESEED_ENVIRONMENT_SCOPES = ["local", "staging", "prod"];
11
- const TREESEED_ENVIRONMENT_REQUIREMENTS = ["required", "conditional", "optional"];
11
+ const TREESEED_ENVIRONMENT_REQUIREMENTS = ["required", "conditional", "optional", "generated"];
12
12
  const TREESEED_ENVIRONMENT_TARGETS = [
13
13
  "local-runtime",
14
14
  "local-cloudflare",
@@ -7,9 +7,11 @@ import {
7
7
  buildProvisioningSummary,
8
8
  buildSecretMap,
9
9
  cloudflareApiRequest,
10
+ createTurnstileWidget,
10
11
  createBranchPreviewDeployTarget,
11
12
  createPersistentDeployTarget,
12
13
  destroyCloudflareResources,
14
+ getTurnstileWidget,
13
15
  hasProvisionedCloudflareResources,
14
16
  isWranglerAlreadyExistsError,
15
17
  listD1Databases,
@@ -17,6 +19,7 @@ import {
17
19
  listPagesProjects,
18
20
  listQueues,
19
21
  listR2Buckets,
22
+ listTurnstileWidgets,
20
23
  mergeCloudflarePagesDeploymentConfig,
21
24
  loadDeployState,
22
25
  queueId,
@@ -26,6 +29,7 @@ import {
26
29
  resolveCloudflareZoneIdForHost,
27
30
  runWrangler,
28
31
  scopeFromTarget,
32
+ updateTurnstileWidget,
29
33
  writeDeployState
30
34
  } from "../operations/services/deploy.js";
31
35
  import {
@@ -33,7 +37,6 @@ import {
33
37
  deriveRailwayMarketOperationsRunnerVolumeName,
34
38
  ensureRailwayProjectContext,
35
39
  ensureRailwayServiceVolumeWithCliFallback,
36
- listRailwayServiceVolumesWithCli,
37
40
  runRailway,
38
41
  validateRailwayDeployPrerequisites
39
42
  } from "../operations/services/railway-deploy.js";
@@ -293,6 +296,30 @@ function getCloudflareKvById(env, namespaceId) {
293
296
  );
294
297
  return payload?.result ?? null;
295
298
  }
299
+ function normalizeTurnstileDomains(value) {
300
+ return [...new Set((Array.isArray(value) ? value : []).map((entry) => typeof entry === "string" ? entry.trim() : "").filter(Boolean))].sort();
301
+ }
302
+ function turnstileDomainsEqual(left, right) {
303
+ return JSON.stringify(normalizeTurnstileDomains(left)) === JSON.stringify(normalizeTurnstileDomains(right));
304
+ }
305
+ function mergeTurnstileWidget(...widgets) {
306
+ const merged = {};
307
+ for (const widget of widgets) {
308
+ if (!widget) continue;
309
+ for (const [key, value] of Object.entries(widget)) {
310
+ if (value === void 0 || value === null) continue;
311
+ if (key === "domains" && !Array.isArray(value)) continue;
312
+ merged[key] = value;
313
+ }
314
+ }
315
+ return Object.keys(merged).length > 0 ? merged : null;
316
+ }
317
+ function findTurnstileWidget(widgets, current, desiredName) {
318
+ return widgets.find((entry) => {
319
+ if (!entry || typeof entry !== "object") return false;
320
+ return current?.sitekey && entry.sitekey === current.sitekey || desiredName && entry.name === desiredName;
321
+ });
322
+ }
296
323
  function listCloudflareQueuesViaApi(env) {
297
324
  const accountId = env.CLOUDFLARE_ACCOUNT_ID;
298
325
  if (!accountId) {
@@ -314,7 +341,8 @@ function cloudflareObservationSnapshot(input, forceRefresh = false) {
314
341
  d1Databases: listD1Databases(input.context.tenantRoot, env),
315
342
  queues: listCloudflareQueuesViaApi(env),
316
343
  buckets: listR2Buckets(input.context.tenantRoot, env),
317
- pagesProjects: listPagesProjects(input.context.tenantRoot, env)
344
+ pagesProjects: listPagesProjects(input.context.tenantRoot, env),
345
+ turnstileWidgets: listTurnstileWidgets(input.context.tenantRoot, env)
318
346
  };
319
347
  }, forceRefresh);
320
348
  }
@@ -591,7 +619,10 @@ function collectCloudflareEnvironmentSync(input) {
591
619
  const generatedSecrets = buildSecretMap(input.context.deployConfig, state);
592
620
  const publicVars = buildPublicVars(input.context.deployConfig, { target });
593
621
  const secrets = {};
594
- const vars = { ...publicVars };
622
+ const generatedTurnstileSiteKey = typeof state.turnstileWidgets?.formGuard?.sitekey === "string" ? state.turnstileWidgets.formGuard.sitekey : "";
623
+ const vars = {
624
+ ...publicVars
625
+ };
595
626
  const secretNames = /* @__PURE__ */ new Set();
596
627
  const varNames = new Set(Object.keys(publicVars));
597
628
  for (const entry of registry.entries) {
@@ -611,8 +642,13 @@ function collectCloudflareEnvironmentSync(input) {
611
642
  varNames.add(entry.id);
612
643
  }
613
644
  }
645
+ if (generatedTurnstileSiteKey) {
646
+ vars.TREESEED_PUBLIC_TURNSTILE_SITE_KEY = generatedTurnstileSiteKey;
647
+ varNames.add("TREESEED_PUBLIC_TURNSTILE_SITE_KEY");
648
+ }
614
649
  for (const [key, value] of Object.entries(generatedSecrets)) {
615
- if (typeof value === "string" && value.length > 0 && shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, key)) {
650
+ const exposeRuntimeSecret = key === "TREESEED_TURNSTILE_SECRET_KEY" || shouldExposeManagedHostRuntimeSecret(input.context.deployConfig, key);
651
+ if (typeof value === "string" && value.length > 0 && exposeRuntimeSecret) {
616
652
  secrets[key] = value;
617
653
  secretNames.add(key);
618
654
  }
@@ -735,6 +771,7 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
735
771
  const queues = dryRun ? [] : listQueues(input.context.tenantRoot, env);
736
772
  const buckets = dryRun ? [] : listR2Buckets(input.context.tenantRoot, env);
737
773
  const pagesProjects = dryRun ? [] : listPagesProjects(input.context.tenantRoot, env);
774
+ const turnstileWidgets = dryRun ? [] : listTurnstileWidgets(input.context.tenantRoot, env);
738
775
  const runStep = (label, fn) => {
739
776
  try {
740
777
  return fn();
@@ -945,11 +982,59 @@ function reconcileCloudflareTarget(input, { dryRun = false } = {}) {
945
982
  }
946
983
  current.url = `https://${current.projectName}.pages.dev`;
947
984
  };
985
+ const ensureTurnstileWidget = () => {
986
+ if (deployConfig.turnstile?.enabled !== true) {
987
+ return;
988
+ }
989
+ const current = state.turnstileWidgets?.formGuard;
990
+ if (!current?.name) {
991
+ return;
992
+ }
993
+ const pagesHost = state.pages?.url ? new URL(state.pages.url).hostname : null;
994
+ const desiredDomains = normalizeTurnstileDomains([
995
+ ...Array.isArray(current.domains) ? current.domains : [],
996
+ pagesHost
997
+ ]);
998
+ current.domains = desiredDomains;
999
+ current.mode = "managed";
1000
+ current.managed = true;
1001
+ const existing = findTurnstileWidget(turnstileWidgets, current, current.name);
1002
+ if (dryRun) {
1003
+ current.sitekey = current.sitekey ?? `dryrun-${current.name}-sitekey`;
1004
+ current.secret = current.secret ?? `dryrun-${current.name}-secret`;
1005
+ current.lastSyncedAt = nowIso();
1006
+ return;
1007
+ }
1008
+ if (existing?.sitekey) {
1009
+ const needsUpdate = existing.name !== current.name || existing.mode !== "managed" || !turnstileDomainsEqual(existing.domains, desiredDomains);
1010
+ const updated = needsUpdate ? updateTurnstileWidget(env, String(existing.sitekey), {
1011
+ name: current.name,
1012
+ domains: desiredDomains,
1013
+ mode: "managed"
1014
+ }) : existing;
1015
+ current.sitekey = String(updated?.sitekey ?? existing.sitekey);
1016
+ current.secret = String(updated?.secret ?? current.secret ?? "");
1017
+ current.lastSyncedAt = nowIso();
1018
+ return;
1019
+ }
1020
+ const created = createTurnstileWidget(env, {
1021
+ name: current.name,
1022
+ domains: desiredDomains,
1023
+ mode: "managed"
1024
+ });
1025
+ if (!created?.sitekey || !created?.secret) {
1026
+ throw new Error(`Unable to resolve created Turnstile widget keys for ${current.name}.`);
1027
+ }
1028
+ current.sitekey = String(created.sitekey);
1029
+ current.secret = String(created.secret);
1030
+ current.lastSyncedAt = nowIso();
1031
+ };
948
1032
  runStep("kv-form-guard", () => ensureKv("FORM_GUARD_KV"));
949
1033
  runStep("d1", ensureD1);
950
1034
  runStep("queue", ensureQueue);
951
1035
  runStep("r2", ensureR2Bucket);
952
1036
  runStep("pages", ensurePagesProject);
1037
+ runStep("turnstile-widget", ensureTurnstileWidget);
953
1038
  runStep("web-cache", () => reconcileCloudflareWebCacheRules(input.context.tenantRoot, deployConfig, state, target, { dryRun, env }));
954
1039
  state.readiness.configured = true;
955
1040
  state.readiness.provisioned = hasProvisionedCloudflareResources(state);
@@ -989,7 +1074,7 @@ function syncCloudflareSecretsForTarget(input, { dryRun = false } = {}) {
989
1074
  }
990
1075
  function observeCloudflareUnit(input) {
991
1076
  const snapshot = cloudflareObservationSnapshot(input);
992
- const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects } = snapshot;
1077
+ const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets } = snapshot;
993
1078
  switch (input.unit.unitType) {
994
1079
  case "queue": {
995
1080
  const liveQueue = queues.find((entry) => queueName(entry) === state.queues?.agentWork?.name);
@@ -1042,6 +1127,17 @@ function observeCloudflareUnit(input) {
1042
1127
  warnings: []
1043
1128
  };
1044
1129
  }
1130
+ case "turnstile-widget": {
1131
+ const current = state.turnstileWidgets?.formGuard ?? {};
1132
+ const liveWidget = findTurnstileWidget(turnstileWidgets, current, input.unit.spec.name);
1133
+ return {
1134
+ exists: Boolean(liveWidget?.sitekey || current?.sitekey),
1135
+ status: liveWidget?.sitekey ? "ready" : "pending",
1136
+ live: { ...current, ...liveWidget ?? {} },
1137
+ locators: { sitekey: String(liveWidget?.sitekey ?? current?.sitekey ?? "") || null },
1138
+ warnings: []
1139
+ };
1140
+ }
1045
1141
  case "pages-project": {
1046
1142
  const liveProject = pagesProjects.find((entry) => entry?.name === state.pages?.projectName);
1047
1143
  return {
@@ -1081,7 +1177,7 @@ function verifyCloudflareUnitOnce(input, postconditions) {
1081
1177
  ]);
1082
1178
  }
1083
1179
  const snapshot = cloudflareObservationSnapshot(input, true);
1084
- const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, env } = snapshot;
1180
+ const { state, kvNamespaces, d1Databases, queues, buckets, pagesProjects, turnstileWidgets, env } = snapshot;
1085
1181
  switch (input.unit.unitType) {
1086
1182
  case "queue": {
1087
1183
  const queue = state.queues?.agentWork;
@@ -1149,6 +1245,49 @@ function verifyCloudflareUnitOnce(input, postconditions) {
1149
1245
  })
1150
1246
  ]);
1151
1247
  }
1248
+ case "turnstile-widget": {
1249
+ const current = state.turnstileWidgets?.formGuard ?? {};
1250
+ const cachedLive = findTurnstileWidget(turnstileWidgets, current, input.unit.spec.name);
1251
+ const refreshedListedLive = findTurnstileWidget(
1252
+ listTurnstileWidgets(input.context.tenantRoot, env),
1253
+ current,
1254
+ input.unit.spec.name
1255
+ );
1256
+ const refreshedLive = current.sitekey ? getTurnstileWidget(env, String(current.sitekey)) : null;
1257
+ const live = mergeTurnstileWidget(
1258
+ cachedLive,
1259
+ refreshedListedLive,
1260
+ refreshedLive
1261
+ );
1262
+ const pagesHost = state.pages?.url ? new URL(state.pages.url).hostname : null;
1263
+ const desiredDomains = normalizeTurnstileDomains([
1264
+ ...Array.isArray(input.unit.spec.domains) ? input.unit.spec.domains : [],
1265
+ ...Array.isArray(current.domains) ? current.domains : [],
1266
+ pagesHost
1267
+ ]);
1268
+ return summarizeVerification(input.unit.unitId, [
1269
+ verificationCheck("turnstile.exists", "Turnstile widget exists by name and sitekey", "api", {
1270
+ exists: Boolean(live?.sitekey),
1271
+ expected: input.unit.spec.name ?? null,
1272
+ observed: live ? { name: live.name, sitekey: live.sitekey } : null,
1273
+ issues: live?.sitekey ? [] : [`Cloudflare Turnstile widget ${String(input.unit.spec.name ?? "(unset)")} was not found after reconcile.`]
1274
+ }),
1275
+ verificationCheck("turnstile.mode", "Turnstile widget mode is managed", "api", {
1276
+ exists: Boolean(live?.sitekey),
1277
+ configured: live?.mode === "managed",
1278
+ expected: "managed",
1279
+ observed: live?.mode ?? null,
1280
+ issues: live?.mode === "managed" ? [] : ["Turnstile widget mode does not match managed."]
1281
+ }),
1282
+ verificationCheck("turnstile.domains", "Turnstile widget domains match desired config", "api", {
1283
+ exists: Boolean(live?.sitekey),
1284
+ configured: turnstileDomainsEqual(live?.domains, desiredDomains),
1285
+ expected: desiredDomains,
1286
+ observed: normalizeTurnstileDomains(live?.domains),
1287
+ issues: turnstileDomainsEqual(live?.domains, desiredDomains) ? [] : ["Turnstile widget domains do not match desired config."]
1288
+ })
1289
+ ]);
1290
+ }
1152
1291
  case "content-store": {
1153
1292
  const bucketName = state.content?.bucketName;
1154
1293
  const live = buckets.find((entry) => entry?.name === bucketName);
@@ -1344,6 +1483,12 @@ function buildCloudflareAdapter(unitType) {
1344
1483
  { key: "kv.exists", description: "KV namespace exists by title and id" },
1345
1484
  { key: "kv.binding", description: "KV binding matches desired config" }
1346
1485
  ];
1486
+ case "turnstile-widget":
1487
+ return [
1488
+ { key: "turnstile.exists", description: "Turnstile widget exists by name and sitekey" },
1489
+ { key: "turnstile.mode", description: "Turnstile widget mode is managed" },
1490
+ { key: "turnstile.domains", description: "Turnstile widget domains match desired config" }
1491
+ ];
1347
1492
  case "content-store":
1348
1493
  return [
1349
1494
  { key: "r2.exists", description: "R2 bucket exists by name" },
@@ -1621,7 +1766,7 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false } = {}) {
1621
1766
  preferCli: entry.configuredService.key === "marketOperationsRunner",
1622
1767
  env: topology.env
1623
1768
  });
1624
- if (!volume.instance?.serviceId) {
1769
+ if (volume.instance?.serviceId !== entry.service.id || volume.instance?.environmentId !== entry.environment.id || volume.instance?.mountPath !== entry.configuredService.volumeMountPath) {
1625
1770
  ensureRailwayProjectContext(entry.configuredService, { env: topology.env, capture: true });
1626
1771
  let attachMessage = "";
1627
1772
  let attached = false;
@@ -2258,25 +2403,9 @@ async function verifyRailwayUnit(input) {
2258
2403
  const volumes = entry.project?.id ? await listRailwayVolumes({ projectId: entry.project.id, env: topology.env }) : [];
2259
2404
  const expectedServiceId = entry.service?.id ?? null;
2260
2405
  const expectedEnvironmentId = entry.environment?.id ?? null;
2261
- let mountedVolume = volumes.find((volume) => volume.instances.some(
2406
+ const mountedVolume = volumes.find((volume) => volume.instances.some(
2262
2407
  (instance) => instance.serviceId === expectedServiceId && instance.environmentId === expectedEnvironmentId && instance.mountPath === service.volumeMountPath
2263
2408
  )) ?? null;
2264
- let mountedVolumeSource = "api";
2265
- if (!mountedVolume && serviceKey === "marketOperationsRunner" && expectedServiceId && expectedEnvironmentId) {
2266
- ensureRailwayProjectContext(service, { env: topology.env, capture: true });
2267
- const cliVolumes = listRailwayServiceVolumesWithCli({
2268
- cwd: service.rootDir,
2269
- serviceId: expectedServiceId,
2270
- environmentId: expectedEnvironmentId,
2271
- name: deriveRailwayMarketOperationsRunnerVolumeName(entry.service?.name ?? service.serviceName ?? service.key, entry.environment?.name ?? service.railwayEnvironment),
2272
- mountPath: service.volumeMountPath,
2273
- env: topology.env
2274
- });
2275
- mountedVolume = cliVolumes.find((volume) => volume.instances.some(
2276
- (instance) => instance.serviceId === expectedServiceId && instance.environmentId === expectedEnvironmentId && instance.mountPath === service.volumeMountPath
2277
- )) ?? null;
2278
- mountedVolumeSource = mountedVolume ? "cli" : "api";
2279
- }
2280
2409
  checks.push(verificationCheck("railway.volume:data", "Railway service has persistent data volume mounted", "api", {
2281
2410
  exists: Boolean(mountedVolume),
2282
2411
  configured: Boolean(mountedVolume),
@@ -2284,7 +2413,7 @@ async function verifyRailwayUnit(input) {
2284
2413
  observed: mountedVolume ? {
2285
2414
  name: mountedVolume.name,
2286
2415
  mountPath: service.volumeMountPath,
2287
- source: mountedVolumeSource
2416
+ source: "api"
2288
2417
  } : null,
2289
2418
  issues: mountedVolume ? [] : [`Railway service ${service.serviceName ?? service.key} is missing a persistent volume mounted at ${service.volumeMountPath}.`]
2290
2419
  }));
@@ -2494,6 +2623,7 @@ function createCloudflareReconcileAdapters() {
2494
2623
  buildCloudflareAdapter("database"),
2495
2624
  buildCloudflareAdapter("content-store"),
2496
2625
  buildCloudflareAdapter("kv-form-guard"),
2626
+ buildCloudflareAdapter("turnstile-widget"),
2497
2627
  buildCloudflareAdapter("pages-project"),
2498
2628
  buildCloudflareAdapter("edge-worker"),
2499
2629
  buildCustomDomainAdapter("custom-domain:web", "cloudflare"),
@@ -3,7 +3,7 @@ export type TreeseedReconcileProviderId = string;
3
3
  export type TreeseedReconcileActionKind = 'noop' | 'create' | 'update' | 'reuse' | 'drift_correct' | 'destroy';
4
4
  export type TreeseedReconcileStatusKind = 'pending' | 'ready' | 'drifted' | 'error';
5
5
  export type TreeseedReconcileVerificationSource = 'cli' | 'api' | 'sdk' | 'derived';
6
- export type TreeseedReconcileUnitType = 'web-ui' | 'api-runtime' | 'market-operations-runner-runtime' | 'workday-manager-runtime' | 'worker-runner-runtime' | 'edge-worker' | 'content-store' | 'queue' | 'database' | 'kv-form-guard' | 'pages-project' | 'custom-domain:web' | 'custom-domain:api' | 'dns-record' | 'railway-service:api' | 'railway-service:market-operations-runner' | 'railway-service:workday-manager' | 'railway-service:worker-runner';
6
+ export type TreeseedReconcileUnitType = 'web-ui' | 'api-runtime' | 'market-operations-runner-runtime' | 'workday-manager-runtime' | 'worker-runner-runtime' | 'edge-worker' | 'content-store' | 'queue' | 'database' | 'kv-form-guard' | 'turnstile-widget' | 'pages-project' | 'custom-domain:web' | 'custom-domain:api' | 'dns-record' | 'railway-service:api' | 'railway-service:market-operations-runner' | 'railway-service:workday-manager' | 'railway-service:worker-runner';
7
7
  export type TreeseedReconcileTarget = {
8
8
  kind: 'persistent';
9
9
  scope: 'local' | 'staging' | 'prod';
@@ -76,6 +76,22 @@ function deriveTreeseedDesiredUnits({
76
76
  secrets: {},
77
77
  metadata: { bootstrapSystem: "web" }
78
78
  });
79
+ const turnstileWidgetId = legacyState.turnstileWidgets?.formGuard?.name && deployConfig.turnstile?.enabled === true ? add({
80
+ unitId: createTreeseedReconcileUnitId("turnstile-widget", legacyState.turnstileWidgets.formGuard.name),
81
+ unitType: "turnstile-widget",
82
+ provider: "cloudflare",
83
+ identity,
84
+ target,
85
+ logicalName: legacyState.turnstileWidgets.formGuard.name,
86
+ dependencies: [],
87
+ spec: {
88
+ name: legacyState.turnstileWidgets.formGuard.name,
89
+ domains: legacyState.turnstileWidgets.formGuard.domains ?? [],
90
+ mode: "managed"
91
+ },
92
+ secrets: {},
93
+ metadata: { bootstrapSystem: "web" }
94
+ }) : null;
79
95
  const contentStoreId = add({
80
96
  unitId: createTreeseedReconcileUnitId("content-store", legacyState.content.bucketName ?? deployConfig.slug),
81
97
  unitType: "content-store",
@@ -118,7 +134,7 @@ function deriveTreeseedDesiredUnits({
118
134
  identity,
119
135
  target,
120
136
  logicalName: legacyState.workerName,
121
- dependencies: [queueId, databaseId, formGuardKvId, contentStoreId, pagesProjectId],
137
+ dependencies: [queueId, databaseId, formGuardKvId, ...turnstileWidgetId ? [turnstileWidgetId] : [], contentStoreId, pagesProjectId],
122
138
  spec: {
123
139
  workerName: legacyState.workerName
124
140
  },
@@ -1,5 +1,6 @@
1
1
  import type { TreeseedObservedUnitState, TreeseedReconcilePlan, TreeseedReconcileResult, TreeseedReconcileStateRecord, TreeseedReconcileTarget, TreeseedUnitVerificationResult } from './contracts.ts';
2
2
  import { type TreeseedRunnableBootstrapSystem } from './bootstrap-systems.ts';
3
+ import { type TreeseedTimingEntry } from '../timing.ts';
3
4
  export declare function observeTreeseedUnits({ tenantRoot, target, env, systems, write, }: {
4
5
  tenantRoot: string;
5
6
  target: TreeseedReconcileTarget;
@@ -417,6 +418,7 @@ export declare function reconcileTreeseedTarget({ tenantRoot, target, env, syste
417
418
  plans: TreeseedReconcilePlan[];
418
419
  results: TreeseedReconcileResult[];
419
420
  state: TreeseedReconcileStateRecord;
421
+ timings: TreeseedTimingEntry[];
420
422
  }>;
421
423
  export declare function destroyTreeseedTargetUnits({ tenantRoot, target, env, write, }: {
422
424
  tenantRoot: string;
@@ -3,6 +3,7 @@ import { deriveTreeseedDesiredUnits } from "./desired-state.js";
3
3
  import { ensureTreeseedPersistedUnitState, desiredUnitSpecHash, loadTreeseedReconcileState, updateTreeseedPersistedUnitState, writeTreeseedReconcileState } from "./state.js";
4
4
  import { reverseTopologicallySortedUnits, topologicallySortDesiredUnits } from "./units.js";
5
5
  import { filterTreeseedDesiredUnitsByBootstrapSystems } from "./bootstrap-systems.js";
6
+ import { elapsedMs, formatDurationMs } from "../timing.js";
6
7
  function nowIso() {
7
8
  return (/* @__PURE__ */ new Date()).toISOString();
8
9
  }
@@ -209,7 +210,9 @@ async function reconcileTreeseedTarget({
209
210
  const context = createRunContext(tenantRoot, target, env, write);
210
211
  const results = [];
211
212
  const verificationMap = /* @__PURE__ */ new Map();
213
+ const timingEntries = [];
212
214
  context.session.set("treeseed:verification-results", verificationMap);
215
+ context.session.set("treeseed:timings", timingEntries);
213
216
  const planByUnitId = new Map(planned.plans.map((plan) => [plan.unit.unitId, plan]));
214
217
  let persistChain = Promise.resolve();
215
218
  const persistVerifiedResult = async (persisted, verifiedResult) => {
@@ -221,20 +224,43 @@ async function reconcileTreeseedTarget({
221
224
  };
222
225
  await runByDependencyLevel(topologicallySortDesiredUnits(planned.units), async (unit) => {
223
226
  const plan = planByUnitId.get(unit.unitId);
224
- write?.(`Reconciling ${plan.unit.provider}:${plan.unit.unitType}...`);
227
+ const unitTiming = {
228
+ name: `reconcile:${plan.unit.provider}:${plan.unit.unitType}:${plan.unit.logicalName}`,
229
+ durationMs: 0,
230
+ status: "running",
231
+ children: [],
232
+ metadata: { unitId: plan.unit.unitId, action: plan.diff.action }
233
+ };
234
+ const unitStartMs = performance.now();
235
+ timingEntries.push(unitTiming);
236
+ write?.(`Reconciling ${plan.unit.provider}:${plan.unit.unitType} (${plan.unit.logicalName})...`);
225
237
  const adapter = registry.get(plan.unit.unitType, plan.unit.provider);
226
238
  const persisted = ensureTreeseedPersistedUnitState(planned.state, plan.unit);
227
239
  try {
240
+ const stageStartMs = performance.now();
228
241
  await Promise.resolve(adapter.validate?.({
229
242
  context,
230
243
  unit: plan.unit,
231
244
  persistedState: persisted
232
245
  }));
246
+ unitTiming.children?.push({
247
+ name: `${unitTiming.name}:validate`,
248
+ durationMs: elapsedMs(stageStartMs),
249
+ status: "success"
250
+ });
233
251
  } catch (error) {
252
+ unitTiming.children?.push({
253
+ name: `${unitTiming.name}:validate`,
254
+ durationMs: elapsedMs(unitStartMs),
255
+ status: "failed"
256
+ });
257
+ unitTiming.durationMs = elapsedMs(unitStartMs);
258
+ unitTiming.status = "failed";
234
259
  wrapAdapterFailure("validate", plan.unit.provider, plan.unit.unitType, plan.unit.unitId, error);
235
260
  }
236
261
  let result;
237
262
  try {
263
+ const stageStartMs = performance.now();
238
264
  result = await Promise.resolve(adapter.reconcile({
239
265
  context,
240
266
  unit: plan.unit,
@@ -242,10 +268,22 @@ async function reconcileTreeseedTarget({
242
268
  observed: plan.observed,
243
269
  diff: plan.diff
244
270
  }));
271
+ unitTiming.children?.push({
272
+ name: `${unitTiming.name}:reconcile`,
273
+ durationMs: elapsedMs(stageStartMs),
274
+ status: "success"
275
+ });
245
276
  } catch (error) {
277
+ unitTiming.children?.push({
278
+ name: `${unitTiming.name}:reconcile`,
279
+ durationMs: elapsedMs(unitStartMs),
280
+ status: "failed"
281
+ });
282
+ unitTiming.durationMs = elapsedMs(unitStartMs);
283
+ unitTiming.status = "failed";
246
284
  wrapAdapterFailure("reconcile", plan.unit.provider, plan.unit.unitType, plan.unit.unitId, error);
247
285
  }
248
- write?.(`Verifying ${plan.unit.provider}:${plan.unit.unitType}...`);
286
+ write?.(`Verifying ${plan.unit.provider}:${plan.unit.unitType} (${plan.unit.logicalName})...`);
249
287
  const postconditions = await Promise.resolve(adapter.requiredPostconditions?.({
250
288
  context,
251
289
  unit: plan.unit,
@@ -253,6 +291,7 @@ async function reconcileTreeseedTarget({
253
291
  }) ?? []);
254
292
  let verification;
255
293
  try {
294
+ const stageStartMs = performance.now();
256
295
  verification = await Promise.resolve(adapter.verify({
257
296
  context,
258
297
  unit: plan.unit,
@@ -262,7 +301,19 @@ async function reconcileTreeseedTarget({
262
301
  result,
263
302
  postconditions
264
303
  }));
304
+ unitTiming.children?.push({
305
+ name: `${unitTiming.name}:verify`,
306
+ durationMs: elapsedMs(stageStartMs),
307
+ status: verification.verified ? "success" : "failed"
308
+ });
265
309
  } catch (error) {
310
+ unitTiming.children?.push({
311
+ name: `${unitTiming.name}:verify`,
312
+ durationMs: elapsedMs(unitStartMs),
313
+ status: "failed"
314
+ });
315
+ unitTiming.durationMs = elapsedMs(unitStartMs);
316
+ unitTiming.status = "failed";
266
317
  wrapAdapterFailure("verify", plan.unit.provider, plan.unit.unitType, plan.unit.unitId, error);
267
318
  }
268
319
  const verifiedResult = {
@@ -274,6 +325,9 @@ async function reconcileTreeseedTarget({
274
325
  ]
275
326
  };
276
327
  verificationMap.set(plan.unit.unitId, verification);
328
+ unitTiming.durationMs = elapsedMs(unitStartMs);
329
+ unitTiming.status = verification.verified ? "success" : "failed";
330
+ write?.(`Finished ${plan.unit.provider}:${plan.unit.unitType} (${plan.unit.logicalName}) in ${formatDurationMs(unitTiming.durationMs)}.`);
277
331
  if (!verification.verified) {
278
332
  await persistVerifiedResult(persisted, verifiedResult);
279
333
  throw new Error(`Treeseed reconcile verification failed for ${plan.unit.provider}:${plan.unit.unitType} (${plan.unit.unitId}): ${formatVerificationFailure(verification)}`);
@@ -287,7 +341,8 @@ async function reconcileTreeseedTarget({
287
341
  units: planned.units,
288
342
  plans: planned.plans,
289
343
  results,
290
- state: planned.state
344
+ state: planned.state,
345
+ timings: timingEntries
291
346
  };
292
347
  }
293
348
  async function destroyTreeseedTargetUnits({
@@ -9,6 +9,7 @@ const TRESEED_RECONCILE_UNIT_TYPES = [
9
9
  "queue",
10
10
  "database",
11
11
  "kv-form-guard",
12
+ "turnstile-widget",
12
13
  "pages-project",
13
14
  "custom-domain:web",
14
15
  "custom-domain:api",
@@ -19,7 +19,7 @@ export declare const PROJECT_WEB_DEPLOYMENT_ACTIONS: readonly ["deploy_web", "pu
19
19
  export declare const PROJECT_DEPLOYMENT_ENVIRONMENTS: readonly ["staging", "prod"];
20
20
  export declare const PROJECT_DEPLOYMENT_STATUSES: readonly ["pending", "queued", "claimed", "dispatching", "running", "monitoring", "succeeded", "failed", "cancelled", "timed_out"];
21
21
  export declare const PROJECT_INFRA_RESOURCE_PROVIDERS: readonly ["cloudflare", "railway", "github", "market"];
22
- export declare const PROJECT_INFRA_RESOURCE_KINDS: readonly ["pages", "worker", "r2", "d1", "queue", "dlq", "railway_project", "railway_service", "railway_schedule"];
22
+ export declare const PROJECT_INFRA_RESOURCE_KINDS: readonly ["pages", "worker", "kv", "turnstile-widget", "r2", "d1", "queue", "dlq", "railway_project", "railway_service", "railway_schedule"];
23
23
  export declare const AGENT_POOL_STATUSES: readonly ["pending", "active", "degraded", "offline"];
24
24
  export type SdkBuiltinModelName = (typeof SDK_MODEL_NAMES)[number];
25
25
  export type SdkModelName = SdkBuiltinModelName | (string & {});
package/dist/sdk-types.js CHANGED
@@ -48,6 +48,8 @@ const PROJECT_INFRA_RESOURCE_PROVIDERS = ["cloudflare", "railway", "github", "ma
48
48
  const PROJECT_INFRA_RESOURCE_KINDS = [
49
49
  "pages",
50
50
  "worker",
51
+ "kv",
52
+ "turnstile-widget",
51
53
  "r2",
52
54
  "d1",
53
55
  "queue",
@@ -0,0 +1,20 @@
1
+ export type TreeseedTimingEntry = {
2
+ name: string;
3
+ durationMs: number;
4
+ status?: string;
5
+ metadata?: Record<string, unknown>;
6
+ children?: TreeseedTimingEntry[];
7
+ };
8
+ export declare function nowMs(): number;
9
+ export declare function elapsedMs(startMs: number): number;
10
+ export declare function formatDurationMs(durationMs: number): string;
11
+ export declare function summarizeSlowestTimings(entries: TreeseedTimingEntry[], limit?: number): TreeseedTimingEntry[];
12
+ export declare function flattenTimings(entries: TreeseedTimingEntry[]): TreeseedTimingEntry[];
13
+ export declare function formatTimingSummary(entries: TreeseedTimingEntry[], { title, limit }?: {
14
+ title?: string | undefined;
15
+ limit?: number | undefined;
16
+ }): string;
17
+ export declare function formatTimingMarkdown(entries: TreeseedTimingEntry[], { title, limit }?: {
18
+ title?: string | undefined;
19
+ limit?: number | undefined;
20
+ }): string;