@coreplane/switchboard 1.238.0 → 1.240.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 (155) hide show
  1. package/dist/assets/config/config.example.yaml +6 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
  3. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +223 -65
  5. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +8 -1
  6. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +337 -44
  7. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +19 -0
  8. package/dist/assets/deploy/secrets.manifest.json +4 -4
  9. package/dist/assets/package-lock.json +207 -3
  10. package/dist/assets/package.json +2 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/agents/registry.ts +82 -0
  13. package/dist/assets/src/core/authz/actor.ts +33 -2
  14. package/dist/assets/src/core/authz/policy.ts +6 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +12 -1
  16. package/dist/assets/src/core/coordinator/driver.ts +13 -0
  17. package/dist/assets/src/core/costs.ts +1114 -0
  18. package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
  19. package/dist/assets/src/core/delivery.ts +4 -11
  20. package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
  21. package/dist/assets/src/core/ingressTokens.ts +27 -13
  22. package/dist/assets/src/core/runLedger/types.ts +3 -0
  23. package/dist/assets/src/core/runRecord.ts +10 -3
  24. package/dist/assets/src/core/schedules.ts +2 -1
  25. package/dist/assets/src/core/ship/coordinator.ts +70 -0
  26. package/dist/assets/src/core/snapshotAge.ts +30 -0
  27. package/dist/assets/src/core/stateWorkerRef.ts +41 -0
  28. package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
  29. package/dist/assets/src/execution/residentRefresh.ts +25 -3
  30. package/dist/assets/src/execution/restoreWaiters.ts +61 -0
  31. package/dist/assets/src/execution/seedPlan.ts +267 -0
  32. package/dist/assets/src/mcp/registry.ts +7 -0
  33. package/dist/assets/web/dist/.vite/manifest.json +507 -427
  34. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +1 -0
  35. package/dist/assets/web/dist/assets/CostsPage-BD3w5j_S.js +2 -0
  36. package/dist/assets/web/dist/assets/DeliveryPage-BtvzKA1m.js +1 -0
  37. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +2 -0
  38. package/dist/assets/web/dist/assets/NotFoundPage-DwOX1HaZ.js +1 -0
  39. package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-CUAOh5d9.js} +1 -1
  40. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3KUlYy2.js +1 -0
  41. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +1 -0
  42. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +6 -0
  43. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +1 -0
  44. package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-BnkvCBrM.js} +1 -1
  45. package/dist/assets/web/dist/assets/ScheduledPage-C1y9jgAk.js +1 -0
  46. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +1 -0
  47. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
  48. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +1 -0
  49. package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-CiByEtoR.js} +1 -1
  50. package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DyRxk8A8.js} +1 -1
  51. package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +1 -0
  52. package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DAQ_1T1v.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D4kxRVp1.js} +1 -1
  54. package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-Dy07l64y.js} +1 -1
  55. package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DZIbTlc7.js} +1 -1
  56. package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-CmSXWX3l.js} +1 -1
  57. package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-ggzVdJLK.js} +1 -1
  58. package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-Bo1wktsG.js} +1 -1
  59. package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-DJUmtKM2.js} +1 -1
  60. package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-BZWOVz4C.js} +1 -1
  61. package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Dtk39yMw.js} +1 -1
  62. package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CGf_-Zoj.js} +1 -1
  63. package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-CX2fcL4u.js} +1 -1
  64. package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
  65. package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-D8TaC961.js} +2 -2
  66. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
  67. package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BYS8FtgQ.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-fhUpi3nO.js} +1 -1
  69. package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-DAQNiwsN.js} +1 -1
  70. package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-CWzEys6U.js} +1 -1
  71. package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
  72. package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
  73. package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-9rY3p-Rw.js} +1 -1
  74. package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-tY7hy0nE.js} +1 -1
  75. package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-CTrLv5JF.js} +1 -1
  76. package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BRJbiDfq.js} +1 -1
  77. package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-B-MtVLh2.js} +1 -1
  78. package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-BtIkOypQ.js} +1 -1
  79. package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-CoOH6gfa.js} +1 -1
  80. package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-DgrRu-GP.js} +1 -1
  81. package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-DZaaI7ca.js} +1 -1
  82. package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-DgHvT-qZ.js} +1 -1
  83. package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-CvAd51yz.js} +1 -1
  84. package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CXGqAfzx.js} +1 -1
  85. package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
  86. package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-Cse3JGb0.js} +1 -1
  87. package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-CIQStIXF.js} +1 -1
  88. package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-WbH8s7E3.js} +1 -1
  89. package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-DzyWEVKC.js} +1 -1
  90. package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-CrT-zCa-.js} +1 -1
  91. package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-lHjpjXMQ.js} +1 -1
  92. package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-WTwYCHW6.js} +1 -1
  93. package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-Bqpk9H7E.js} +1 -1
  94. package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-LdXk1r3m.js} +1 -1
  95. package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-h_y2A5KD.js} +1 -1
  96. package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-CY7bF2_p.js} +1 -1
  97. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  98. package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-oQ7XVZOY.js} +1 -1
  99. package/dist/assets/web/dist/assets/main-CGO6fQ8k.js +28 -0
  100. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +1 -0
  101. package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-zv_qZ4QV.js} +1 -1
  102. package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-Dl4kL_YK.js} +1 -1
  103. package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-CAHCZj4F.js} +1 -1
  104. package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BOtmwCJH.js} +1 -1
  105. package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-DspQk3HQ.js} +1 -1
  106. package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CAXmvVl0.js} +1 -1
  107. package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-CMUx4VyM.js} +1 -1
  108. package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-B55xVJTv.js} +1 -1
  109. package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-DsNKCCRk.js} +1 -1
  110. package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-DbhP0VvO.js} +1 -1
  111. package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BCpE6P-p.js} +1 -1
  112. package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-BllsjiN4.js} +1 -1
  113. package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-CO5Rpq9V.js} +1 -1
  114. package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-C2A4OgQq.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-Butzoyql.js} +1 -1
  116. package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-PEUwJBpf.js} +1 -1
  117. package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
  118. package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-3Yhq0khE.js} +1 -1
  119. package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-04Hkb7m3.js} +1 -1
  120. package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-BnId73cP.js} +1 -1
  121. package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-BKwdc0ch.js} +1 -1
  122. package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +9 -0
  123. package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-BPPtNkay.js} +1 -1
  124. package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CDPChcYP.js} +1 -1
  125. package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-B15DIQ-z.js} +1 -1
  126. package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-D-rp_AiB.js} +1 -1
  127. package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-BnVDCD7p.js} +1 -1
  128. package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-cpujdyAR.js} +1 -1
  129. package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-D1vO4L72.js} +1 -1
  130. package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-oQGnQGRc.js} +1 -1
  131. package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-CKSFpXiH.js} +1 -1
  132. package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-DKHFo8BF.js} +1 -1
  133. package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-BstokpiS.js} +1 -1
  134. package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-DpCp0_Sz.js} +1 -1
  135. package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-y5d5wUb1.js} +1 -1
  136. package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-Br2hx5Ax.js} +1 -1
  137. package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-Bc8Cobap.js} +1 -1
  138. package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-LOITy_I1.js} +1 -1
  139. package/dist/cli.js +2586 -1603
  140. package/package.json +1 -1
  141. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
  142. package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
  143. package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
  144. package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
  145. package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
  146. package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
  147. package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
  148. package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
  149. package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
  150. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
  151. package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
  152. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
  153. package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
  154. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
  155. package/dist/assets/web/dist/assets/main-Dht0in4v.js +0 -28
@@ -25,6 +25,7 @@ import {
25
25
  Sandbox,
26
26
  StaleProcessHandleError,
27
27
  getSandbox,
28
+ type DirectoryBackup,
28
29
  type SandboxCommand,
29
30
  } from "@cloudflare/sandbox";
30
31
  import { createExtensionProcessSandbox } from "@cloudflare/sandbox/extensions";
@@ -63,6 +64,34 @@ import {
63
64
  thrownText,
64
65
  } from "../../src/execution/sandboxErrors.js";
65
66
  import { injectedBuildStamp } from "../../src/deploy/buildStamp.js";
67
+ import { backupTransferMode } from "../../src/execution/residentBackupTransfer.js";
68
+ import {
69
+ judgeRestoreProgress,
70
+ RESTORE_POLL_MS,
71
+ restoreArchivePath,
72
+ type RestoreSample,
73
+ } from "../../src/execution/residentRefresh.js";
74
+ import {
75
+ extractRestoreScript,
76
+ restoreMountDir,
77
+ unmountAllRestoresScript,
78
+ } from "../../src/execution/residentRestoreExtract.js";
79
+ import {
80
+ isBackupMissing,
81
+ parseSeed,
82
+ SEED_CHECKOUT_DIR,
83
+ SEED_DEPS_STAGING_DIR,
84
+ SEED_FIXUP_TIMEOUT_MS,
85
+ SEED_MARKER,
86
+ SEED_RESTORE_MAX_MS,
87
+ SEED_ABANDONED_RESTORE_WAIT_MS,
88
+ seedFixupScript,
89
+ seedMarkerText,
90
+ type SandboxSeed,
91
+ type SeedAnswer,
92
+ type SeedStep,
93
+ } from "../../src/execution/seedPlan.js";
94
+ import { shellQuote } from "../../src/execution/shellQuote.js";
66
95
  import { classifyError } from "../../src/core/trace/classify.js";
67
96
  import { systemClock } from "../../src/core/trace/clock.js";
68
97
  import { createTracer } from "../../src/core/trace/tracer.js";
@@ -361,6 +390,201 @@ export class SwitchboardSandbox extends Sandbox<Env> {
361
390
  }
362
391
  }
363
392
 
393
+ /** The seed (docs/reference/specs/execution.md item 25): the resident's
394
+ * checkout snapshot — and the deps-store entry for its lockfile key — restored
395
+ * into this container before the run's first command, then the fix-up
396
+ * (ownership, origin, the deps view in place, the thread's ref checked out).
397
+ * Inside the idle ledger and behind the start gate like every route: the
398
+ * container's start is waited out by the executor, never carried here. */
399
+ async seed(seed: SandboxSeed, envVars: Record<string, string>): Promise<SeedAnswer | WaitAnswer> {
400
+ return this.idle.served(() =>
401
+ this.gate.through(
402
+ () => this.seedNow(seed, envVars),
403
+ (cause) => sandboxStartingAnswer(cause),
404
+ ),
405
+ );
406
+ }
407
+
408
+ private async seedNow(seed: SandboxSeed, envVars: Record<string, string>): Promise<SeedAnswer> {
409
+ const t0 = systemClock();
410
+ const from = {
411
+ ref: seed.ref,
412
+ sha: seed.sha,
413
+ checkoutBackupId: seed.checkoutBackupId,
414
+ ...(seed.depsBackupId ? { depsBackupId: seed.depsBackupId } : {}),
415
+ };
416
+ // Presigned only: the container downloads the archive over a URL this
417
+ // object signs. In local-bucket mode the isolate would pump the bytes and
418
+ // a checkout restore runs to gigabytes (resident-repos item 61).
419
+ const transfer = backupTransferMode(this.env as unknown as Record<string, unknown>);
420
+ if (transfer.mode !== "presigned") {
421
+ return {
422
+ seeded: false,
423
+ reason: "seed-unconfigured",
424
+ detail: `presigned R2 transfer needs ${transfer.missing.join(", ")}`,
425
+ };
426
+ }
427
+ // The marker names the seed this container carries — the handle and the
428
+ // ref and head checked out: the same seed again is the run's second
429
+ // request (a retry, a re-attach), and a restore over the live tree would
430
+ // destroy the run's work; a seed naming another ref is a new seed.
431
+ const marker = await this.runRoot(["cat", SEED_MARKER], 30_000);
432
+ if (marker.exitCode === 0 && marker.stdout.trim() === seedMarkerText(seed)) {
433
+ const head = await this.runRoot(["git", "-C", SEED_CHECKOUT_DIR, "rev-parse", "HEAD"], 30_000);
434
+ return {
435
+ seeded: true,
436
+ cached: true,
437
+ slug: seed.slug,
438
+ ref: seed.fetchRef ?? seed.ref,
439
+ sha: head.exitCode === 0 ? head.stdout.trim() : seed.sha,
440
+ from,
441
+ steps: { restore: 0, deps: null, fixup: 0 },
442
+ ms: systemClock() - t0,
443
+ };
444
+ }
445
+ const deadline = t0 + SEED_RESTORE_MAX_MS;
446
+ const steps = { restore: 0, deps: null as number | null, fixup: 0 };
447
+ let step: SeedStep = "restore";
448
+ try {
449
+ // Nothing of an earlier attempt survives: its mounts, a half tree, a
450
+ // marker for another handle.
451
+ await this.runRoot(["sh", "-c", `${unmountAllRestoresScript()}\n${this.seedSweep()}`], 60_000);
452
+ let t = systemClock();
453
+ await this.restoreSeedInto(seed.checkoutBackupId, SEED_CHECKOUT_DIR, deadline, "checkout");
454
+ steps.restore = systemClock() - t;
455
+ if (seed.depsBackupId) {
456
+ step = "deps";
457
+ t = systemClock();
458
+ await this.restoreSeedInto(seed.depsBackupId, SEED_DEPS_STAGING_DIR, deadline, "deps");
459
+ steps.deps = systemClock() - t;
460
+ }
461
+ step = "fixup";
462
+ t = systemClock();
463
+ const script = seedFixupScript({
464
+ slug: seed.slug,
465
+ ref: seed.ref,
466
+ ...(seed.fetchRef ? { fetchRef: seed.fetchRef } : {}),
467
+ ...(seed.fetchSha ? { fetchSha: seed.fetchSha } : {}),
468
+ checkoutDir: SEED_CHECKOUT_DIR,
469
+ ...(seed.depsBackupId ? { depsDir: SEED_DEPS_STAGING_DIR } : {}),
470
+ });
471
+ // The fix-up's fetch authenticates through the image's credential helper
472
+ // with the exec env's GH_TOKEN — the same channel every command uses.
473
+ const fix = await this.runRoot(["bash", "-c", script], SEED_FIXUP_TIMEOUT_MS, envVars);
474
+ if (fix.exitCode !== 0) throw new Error(`fix-up exited ${fix.exitCode}: ${tail(fix.stderr || fix.stdout)}`);
475
+ steps.fixup = systemClock() - t;
476
+ const sha = fix.stdout.trim().split("\n").at(-1) ?? seed.sha;
477
+ await this.runRoot(
478
+ ["sh", "-c", `printf %s ${shellQuote(seedMarkerText(seed))} > ${shellQuote(SEED_MARKER)}`],
479
+ 30_000,
480
+ );
481
+ const ms = systemClock() - t0;
482
+ console.log(
483
+ JSON.stringify({ event: "sandbox.seeded", slug: seed.slug, ref: seed.fetchRef ?? seed.ref, sha, steps, ms }),
484
+ );
485
+ return { seeded: true, cached: false, slug: seed.slug, ref: seed.fetchRef ?? seed.ref, sha, from, steps, ms };
486
+ } catch (err) {
487
+ const shape = thrownShape(err);
488
+ // A half seed never survives either: the run that follows goes cold
489
+ // and clones into an empty workspace. A restore the verdict gave up on
490
+ // keeps streaming (the SDK's call cannot be cancelled): it is waited
491
+ // for, bounded, before the sweep runs over its directory.
492
+ await this.settlePendingRestores(SEED_ABANDONED_RESTORE_WAIT_MS);
493
+ await this.runRoot(["sh", "-c", `${unmountAllRestoresScript()}\n${this.seedSweep()}`], 60_000).catch(() => {});
494
+ const reason = isBackupMissing(shape) ? "seed-missing" : "seed-failed";
495
+ const detail = `${step}: ${thrownText(shape)}`;
496
+ console.log(
497
+ JSON.stringify({ event: "sandbox.seed-failed", slug: seed.slug, reason, step, detail, ms: systemClock() - t0 }),
498
+ );
499
+ return { seeded: false, reason, detail, step };
500
+ }
501
+ }
502
+
503
+ /** Remove what a seed writes: the checkout, the deps staging tree, the marker. */
504
+ private seedSweep(): string {
505
+ return `rm -rf ${shellQuote(SEED_CHECKOUT_DIR)} ${shellQuote(SEED_DEPS_STAGING_DIR)} ${shellQuote(SEED_MARKER)}`;
506
+ }
507
+
508
+ /** One presigned restore INTO `targetDir` as a plain directory, the way the
509
+ * resident does it (resident-repos item 61): the SDK mounts the archive at
510
+ * a staging sibling, the wait is judged by bytes arriving (the SDK's call
511
+ * takes no timeout) against the seed's one deadline, then the extract
512
+ * script puts a real tree in place and the target appears last. */
513
+ private async restoreSeedInto(id: string, targetDir: string, deadlineMs: number, what: string): Promise<void> {
514
+ const attempt = crypto.randomUUID().slice(0, 8);
515
+ const mountDir = restoreMountDir(targetDir, attempt);
516
+ const backup: DirectoryBackup = { id, dir: mountDir };
517
+ const startedMs = systemClock();
518
+ const restore = this.restoreBackup(backup);
519
+ // Judged below by bytes; a rejection after the judge gave up is never
520
+ // unhandled, and the promise is remembered until it settles so a failure
521
+ // sweep waits for it (bounded) before touching its directory.
522
+ this.pendingRestores.add(restore);
523
+ restore.then(
524
+ () => this.pendingRestores.delete(restore),
525
+ () => this.pendingRestores.delete(restore),
526
+ );
527
+ const samples: RestoreSample[] = [];
528
+ for (;;) {
529
+ const outcome = await Promise.race([
530
+ restore.then(() => "done" as const),
531
+ new Promise<"tick">((r) => setTimeout(() => r("tick"), RESTORE_POLL_MS)),
532
+ ]);
533
+ if (outcome === "done") break;
534
+ samples.push({ atMs: systemClock(), kiB: await this.duKiB([restoreArchivePath(id), mountDir]) });
535
+ const verdict = judgeRestoreProgress({ startedMs, nowMs: systemClock(), samples, deadlineMs });
536
+ if (verdict.verdict !== "wait") throw new Error(`${what} restore ${verdict.verdict}: ${verdict.detail}`);
537
+ }
538
+ const r = await this.runRoot(
539
+ ["sh", "-c", extractRestoreScript({ mountDir, backupId: id, archivePath: restoreArchivePath(id), targetDir })],
540
+ Math.max(60_000, deadlineMs - systemClock()),
541
+ );
542
+ if (r.exitCode !== 0) throw new Error(`${what} extract exited ${r.exitCode}: ${tail(r.stderr || r.stdout)}`);
543
+ }
544
+
545
+ /** Restores this object started that have not settled: what a failure
546
+ * sweep must not race. */
547
+ private readonly pendingRestores = new Set<Promise<unknown>>();
548
+
549
+ /** Wait for every pending restore to settle, at most `maxMs`: a restore the
550
+ * judge called stalled may still finish, and `rm -rf` under a streaming
551
+ * extraction leaves debris the next seed's own sweep has to clear. */
552
+ private async settlePendingRestores(maxMs: number): Promise<void> {
553
+ if (this.pendingRestores.size === 0) return;
554
+ const settled = Promise.allSettled([...this.pendingRestores]);
555
+ await Promise.race([settled, new Promise<void>((r) => setTimeout(r, maxMs))]);
556
+ }
557
+
558
+ /** `du -sk` over the paths, summed, in KiB; null when nothing could be measured. */
559
+ private async duKiB(paths: string[]): Promise<number | null> {
560
+ const r = await this.runRoot(["du", "-sk", ...paths], 30_000);
561
+ let total = 0;
562
+ let seen = false;
563
+ for (const line of r.stdout.split("\n")) {
564
+ const m = /^(\d+)\s/.exec(line);
565
+ if (m) {
566
+ total += Number(m[1]);
567
+ seen = true;
568
+ }
569
+ }
570
+ return seen ? total : null;
571
+ }
572
+
573
+ /** One process as root, collected inside this object — the seed's own
574
+ * commands, outside the /exec shape (no shell-level `timeout`, no WORKDIR). */
575
+ private async runRoot(
576
+ argv: SandboxCommand,
577
+ timeoutMs: number,
578
+ env: Record<string, string> = {},
579
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
580
+ const proc = await createExtensionProcessSandbox(this).exec(argv, {
581
+ env,
582
+ timeout: timeoutMs + SDK_BACKSTOP_MARGIN_MS,
583
+ });
584
+ const out = await proc.output({ encoding: "utf8", timeout: timeoutMs + OUTPUT_WAIT_AFTER_DEADLINE_MS });
585
+ return { stdout: out.stdout, stderr: out.stderr, exitCode: out.timedOut ? 124 : out.exitCode };
586
+ }
587
+
364
588
  /** The named failures, as `/exec` data; anything else is thrown as it came. */
365
589
  private execFailure(err: unknown, startedAt: number): ExecFailure {
366
590
  const raw = thrownText(thrownShape(err));
@@ -471,6 +695,11 @@ export class SwitchboardSandbox extends Sandbox<Env> {
471
695
  }
472
696
  }
473
697
 
698
+ /** The last part of a step's output, for a failure's detail. */
699
+ function tail(text: string): string {
700
+ return text.trim().slice(-400);
701
+ }
702
+
474
703
  /** The stderr line an exit 124 carries: the limit, the knob, and the way to
475
704
  * outlive a command (`setsid -f`: every /exec runs under `timeout … bash -c`,
476
705
  * whose process group is reaped when the command returns, so a plain
@@ -486,6 +715,16 @@ function timeoutNote(execTimeoutSecs: number): string {
486
715
  interface Env {
487
716
  Sandbox: DurableObjectNamespace<SwitchboardSandbox>;
488
717
  SANDBOX_TOKEN: string;
718
+ // The seed (docs/reference/specs/execution.md item 25): the resident's cache
719
+ // bucket and the four values the SDK's presigned restore reads — rendered
720
+ // only when the profile has a resident (wrangler.template.jsonc), the keys
721
+ // provisioned by `deploy secrets sandbox`. Any absent → `seed-unconfigured`:
722
+ // a gigabyte restore never goes through the isolate (resident-repos item 61).
723
+ BACKUP_BUCKET?: R2Bucket;
724
+ CLOUDFLARE_ACCOUNT_ID?: string;
725
+ BACKUP_BUCKET_NAME?: string;
726
+ R2_ACCESS_KEY_ID?: string;
727
+ R2_SECRET_ACCESS_KEY?: string;
489
728
  }
490
729
 
491
730
  /** The commit this bundle was built from, injected by the deploy
@@ -513,7 +752,12 @@ export default {
513
752
  // stamp (docs/reference/specs/execution.md item 13). It needs no thread, so it answers
514
753
  // before the X-Thread-Key check — and it is the one GET here.
515
754
  if (request.method === "GET" && new URL(request.url).pathname === "/healthz") {
516
- return json({ ok: true, build: BUILD });
755
+ // `backupTransfer` says whether a seed can run here (presigned) or not (local).
756
+ return json({
757
+ ok: true,
758
+ build: BUILD,
759
+ backupTransfer: backupTransferMode(env as unknown as Record<string, unknown>).mode,
760
+ });
517
761
  }
518
762
  if (request.method !== "POST") return json({ error: "POST only" }, 405);
519
763
 
@@ -554,6 +798,16 @@ export default {
554
798
  request.headers.get("traceparent") ?? undefined,
555
799
  );
556
800
  }
801
+ case "/seed": {
802
+ // The seed (docs/reference/specs/execution.md item 25): the resident's
803
+ // snapshot restored into this thread's sandbox before the run's first
804
+ // command. Streamed like /exec — a restore takes minutes — and every
805
+ // outcome is named in the body, so the executor never reads a seed
806
+ // that did not happen as a dead sandbox.
807
+ const parsed = parseSeed(body.seed);
808
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
809
+ return streamSeed(() => sandbox.seed(parsed.seed, envVars), request.headers.get("traceparent") ?? undefined);
810
+ }
557
811
  case "/read": {
558
812
  const encoding = readEncodingOf(body);
559
813
  if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
@@ -597,9 +851,15 @@ export default {
597
851
  * gone by the time the result is known): a completed command as {stdout,
598
852
  * stderr, exitCode}, a sandbox-enforced timeout as exit 124, and any other
599
853
  * failure as {error} (docs/reference/specs/execution.md item 3). */
600
- function streamExec(run: () => Promise<ExecAnswer | ExecFailure>, traceparent: string | undefined): Response {
854
+ /** HTTP 200 at once, a whitespace heartbeat every 15 s, then exactly one JSON
855
+ * document (docs/reference/specs/execution.md item 3): the shape every long
856
+ * route shares, so no hop ever sees an idle connection. `settle` turns the
857
+ * run's outcome — an answer, or a throw — into that one document. */
858
+ function heartbeatJson(
859
+ run: () => Promise<object>,
860
+ settle: { answered: (answer: object) => object; threw: (err: unknown) => object },
861
+ ): Response {
601
862
  const encoder = new TextEncoder();
602
- const startedAt = systemClock();
603
863
  const stream = new ReadableStream<Uint8Array>({
604
864
  start(controller) {
605
865
  const beat = setInterval(() => {
@@ -618,52 +878,85 @@ function streamExec(run: () => Promise<ExecAnswer | ExecFailure>, traceparent: s
618
878
  // stream already errored/cancelled — nothing left to deliver to
619
879
  }
620
880
  };
621
- run()
622
- .then((answer) => {
623
- if ("error" in answer) {
624
- // A command the sandbox never answered for, named by the Durable
625
- // Object: an infra failure, classified, no message on the span.
626
- const root = execRoot(startedAt, traceparent);
627
- root.fail(classifyError(new Error("sandbox exec failed"), { kind: "infra" }));
628
- root.end("error");
629
- finish(answer);
630
- return;
631
- }
632
- // The command as the Worker's own root (docs/reference/specs/tracing.md item 22).
633
- execRoot(startedAt, traceparent).end(answer.exitCode === 0 ? "ok" : "error", {
634
- exitCode: answer.exitCode,
635
- ...(answer.exitCode === 124 ? { timedOut: true } : {}),
636
- });
637
- finish(answer);
638
- })
639
- .catch((err: unknown) => {
640
- // The Durable Object threw: a failure its own classification did
641
- // not name, seen here after the RPC boundary (name and message kept,
642
- // prototype dropped), so the shared classifiers read the shape.
643
- const root = execRoot(startedAt, traceparent);
644
- root.fail(classifyError(new Error("sandbox exec failed"), { kind: "infra" }));
645
- root.end("error");
646
- const shape = thrownShape(err);
647
- const raw = thrownText(shape);
648
- if (isRuntimeUnreachableError(err)) {
649
- finish(runtimeUnreachableExecAnswer(raw));
650
- return;
651
- }
652
- if (isFleetBusyError(err)) {
653
- finish(fleetBusyExecAnswer(raw));
654
- return;
655
- }
656
- // A recycle the Durable Object did not catch by type: by name, or
657
- // a recycle-shaped text minutes into the attempt (item 9).
658
- const certain = shape.name !== undefined && isRecycleError({ name: shape.name });
659
- const msg = recycledMidCommandMessage(systemClock() - startedAt, raw, certain);
660
- finish({ error: msg, stdout: "", stderr: msg, exitCode: 127 } satisfies ExecFailure);
661
- });
881
+ run().then(
882
+ (answer) => finish(settle.answered(answer)),
883
+ (err: unknown) => finish(settle.threw(err)),
884
+ );
662
885
  },
663
886
  });
664
887
  return new Response(stream, { headers: { "content-type": "application/json" } });
665
888
  }
666
889
 
890
+ function streamExec(run: () => Promise<ExecAnswer | ExecFailure>, traceparent: string | undefined): Response {
891
+ const startedAt = systemClock();
892
+ return heartbeatJson(run, {
893
+ answered: (a) => {
894
+ const answer = a as ExecAnswer | ExecFailure;
895
+ if ("error" in answer) {
896
+ // A command the sandbox never answered for, named by the Durable
897
+ // Object: an infra failure, classified, no message on the span.
898
+ const root = execRoot(startedAt, traceparent);
899
+ root.fail(classifyError(new Error("sandbox exec failed"), { kind: "infra" }));
900
+ root.end("error");
901
+ return answer;
902
+ }
903
+ // The command as the Worker's own root (docs/reference/specs/tracing.md item 22).
904
+ execRoot(startedAt, traceparent).end(answer.exitCode === 0 ? "ok" : "error", {
905
+ exitCode: answer.exitCode,
906
+ ...(answer.exitCode === 124 ? { timedOut: true } : {}),
907
+ });
908
+ return answer;
909
+ },
910
+ threw: (err) => {
911
+ // The Durable Object threw: a failure its own classification did
912
+ // not name, seen here after the RPC boundary (name and message kept,
913
+ // prototype dropped), so the shared classifiers read the shape.
914
+ const root = execRoot(startedAt, traceparent);
915
+ root.fail(classifyError(new Error("sandbox exec failed"), { kind: "infra" }));
916
+ root.end("error");
917
+ const shape = thrownShape(err);
918
+ const raw = thrownText(shape);
919
+ if (isRuntimeUnreachableError(err)) return runtimeUnreachableExecAnswer(raw);
920
+ if (isFleetBusyError(err)) return fleetBusyExecAnswer(raw);
921
+ // A recycle the Durable Object did not catch by type: by name, or
922
+ // a recycle-shaped text minutes into the attempt (item 9).
923
+ const certain = shape.name !== undefined && isRecycleError({ name: shape.name });
924
+ const msg = recycledMidCommandMessage(systemClock() - startedAt, raw, certain);
925
+ return { error: msg, stdout: "", stderr: msg, exitCode: 127 } satisfies ExecFailure;
926
+ },
927
+ });
928
+ }
929
+
930
+ /** `POST /seed`'s answer over the same heartbeat stream, as its own
931
+ * `sandbox.seed` root. A start or a full fleet met at the warm-up keeps its
932
+ * wait token (items 14, 23); a silent control port its name (item 9); any
933
+ * other throw is a `seed-failed` with the text — never a dead sandbox. */
934
+ function streamSeed(run: () => Promise<SeedAnswer | WaitAnswer>, traceparent: string | undefined): Response {
935
+ const startedAt = systemClock();
936
+ const root = () => startAdoptedRoot(tracer, "sandbox.seed", { sinks: traceSinks, startedAt, traceparent });
937
+ return heartbeatJson(run, {
938
+ answered: (a) => {
939
+ const answer = a as SeedAnswer | WaitAnswer;
940
+ if ("seeded" in answer)
941
+ root().end(answer.seeded ? "ok" : "error", { outcome: answer.seeded ? "seeded" : answer.reason });
942
+ return answer;
943
+ },
944
+ threw: (err) => {
945
+ const r = root();
946
+ r.fail(classifyError(new Error("sandbox seed failed"), { kind: "infra" }));
947
+ r.end("error");
948
+ const raw = thrownText(thrownShape(err));
949
+ if (isRuntimeUnreachableError(err)) return runtimeUnreachableAnswer(raw);
950
+ if (isFleetBusyError(err)) return fleetBusyAnswer(raw);
951
+ return { seeded: false, reason: "seed-failed", detail: raw } satisfies SeedAnswer;
952
+ },
953
+ });
954
+ }
955
+
956
+ /** A route's answer while the container starts or the fleet is full: the
957
+ * text and the machine token the executor waits on. */
958
+ type WaitAnswer = ReturnType<typeof sandboxStartingAnswer> | ReturnType<typeof fleetBusyAnswer>;
959
+
667
960
  /** A file route's refusal as the fetch handler answers it: the text, and the
668
961
  * machine token when the Durable Object named one — the executor matches
669
962
  * `reason`, not the text (docs/reference/specs/execution.md items 9 and 14). */
@@ -14,6 +14,25 @@
14
14
  "workers_dev": false,
15
15
  "routes": [{ "pattern": "{{hostname}}", "custom_domain": true }],
16
16
  "observability": { "enabled": true },
17
+ // {{#if resident}}
18
+ // The seed (docs/reference/specs/execution.md item 25): a cold sandbox restores
19
+ // the resident's checkout snapshot and its deps-store entry from the
20
+ // RESIDENT's cache bucket before the run's first command. The restore is
21
+ // presigned — the container downloads the archive over a URL the Durable
22
+ // Object signs, so a gigabyte never crosses the isolate — and the SDK's
23
+ // `requirePresignedURLSupport` reads four values: these two vars and the
24
+ // R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY secrets (deploy/secrets.manifest.json,
25
+ // the resident's own token). With any of them absent the Worker answers
26
+ // `seed-unconfigured` and the run goes cold, as before the seed; /healthz
27
+ // `backupTransfer` says which. The bucket is the resident's, named after its
28
+ // script exactly as the resident's template names it. A profile without a
29
+ // resident has nothing to seed from and renders none of this.
30
+ "vars": {
31
+ "CLOUDFLARE_ACCOUNT_ID": "{{account}}",
32
+ "BACKUP_BUCKET_NAME": "{{resident.script}}-cache"
33
+ },
34
+ "r2_buckets": [{ "binding": "BACKUP_BUCKET", "bucket_name": "{{resident.script}}-cache" }],
35
+ // {{/if}}
17
36
  "containers": [
18
37
  {
19
38
  "class_name": "SwitchboardSandbox",
@@ -38,7 +38,7 @@
38
38
  "name": "SWITCHBOARD_INGRESS_TOKENS",
39
39
  "workers": ["bot"],
40
40
  "optional": true,
41
- "note": "JSON bearer→identity map for POST /ingress and POST /mcp. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer. The `cron` entry fires schedules; a `coordinator` entry (granted `coordinator:step`) is the ship coordinator's, rotated apart from cron."
41
+ "note": "JSON bearer→identity map for POST /ingress and POST /mcp: `{ subject, channel?, email? }` per entry. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer. The `cron` entry fires schedules; a `coordinator` entry (granted `coordinator:step`) is the ship coordinator's, rotated apart from cron. An `email` binds the token to a person: their runs, their grants untouched (authorization.md item 15)."
42
42
  },
43
43
  {
44
44
  "name": "SANDBOX_TOKEN",
@@ -65,13 +65,13 @@
65
65
  },
66
66
  {
67
67
  "name": "R2_ACCESS_KEY_ID",
68
- "workers": ["resident"],
68
+ "workers": ["resident", "sandbox"],
69
69
  "optional": true,
70
- "note": "R2 API token (S3 access key id) scoped Object Read & Write to the resident cache bucket ONLY — with R2_SECRET_ACCESS_KEY and the CLOUDFLARE_ACCOUNT_ID / BACKUP_BUCKET_NAME vars, snapshot bytes travel container↔R2 over presigned URLs and the Durable Object leaves the data path (docs/reference/specs/resident-repos.md item 61). Absent → the SDK's local-bucket mode (the DO pumps the bytes; a 1.16 GB restore exceeds the isolate's memory). Created in the Cloudflare dashboard (R2 → Manage API tokens); rotate = new token, `deploy secrets resident`. /healthz `backupTransfer` says which mode is live."
70
+ "note": "R2 API token (S3 access key id) scoped Object Read & Write to the resident cache bucket ONLY — with R2_SECRET_ACCESS_KEY and the CLOUDFLARE_ACCOUNT_ID / BACKUP_BUCKET_NAME vars, snapshot bytes travel container↔R2 over presigned URLs and the Durable Object leaves the data path (docs/reference/specs/resident-repos.md item 61). Absent → the SDK's local-bucket mode (the DO pumps the bytes; a 1.16 GB restore exceeds the isolate's memory). The sandbox Worker holds the same token for the seed (docs/reference/specs/execution.md item 25): it restores the resident's snapshot from that bucket, presigned only — absent, it answers seed-unconfigured and the run goes cold. Created in the Cloudflare dashboard (R2 → Manage API tokens); rotate = new token, `deploy secrets resident` and `deploy secrets sandbox`. /healthz `backupTransfer` says which mode is live on each."
71
71
  },
72
72
  {
73
73
  "name": "R2_SECRET_ACCESS_KEY",
74
- "workers": ["resident"],
74
+ "workers": ["resident", "sandbox"],
75
75
  "optional": true,
76
76
  "note": "The secret half of R2_ACCESS_KEY_ID (same token). Both or neither."
77
77
  },