@coreplane/switchboard 1.237.0 → 1.239.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 (30) hide show
  1. package/dist/assets/deploy/cloudflare-resident/worker.ts +40 -12
  2. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +8 -1
  3. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +337 -44
  4. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +19 -0
  5. package/dist/assets/deploy/secrets.manifest.json +3 -3
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +82 -0
  10. package/dist/assets/src/execution/seedPlan.ts +267 -0
  11. package/dist/assets/src/execution/snapshotRetention.ts +62 -0
  12. package/dist/assets/src/mcp/registry.ts +9 -1
  13. package/dist/assets/web/dist/.vite/manifest.json +27 -27
  14. package/dist/assets/web/dist/assets/{ResidentDetailPage-Dc23v_8v.js → ResidentDetailPage-CrfCd8fx.js} +1 -1
  15. package/dist/assets/web/dist/assets/{ResidentsIndexPage-TR5D1TRR.js → ResidentsIndexPage-BdW0k2h3.js} +1 -1
  16. package/dist/assets/web/dist/assets/{RunFoldRow-mkUj7WEN.js → RunFoldRow-CbTd9SiS.js} +1 -1
  17. package/dist/assets/web/dist/assets/{RunRoutePage-CQI17__g.js → RunRoutePage-br0H-8Pz.js} +3 -3
  18. package/dist/assets/web/dist/assets/{RunsIndexPage-yIP4PlKP.js → RunsIndexPage-Bnzb15zH.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ScheduledPage-CdCVyNbh.js → ScheduledPage-NLhjzHAz.js} +1 -1
  20. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +1 -0
  21. package/dist/assets/web/dist/assets/{StatusDot-DmNHX6am.js → StatusDot-DkXALvKf.js} +1 -1
  22. package/dist/assets/web/dist/assets/{Tooltip-qVnJTgSt.js → Tooltip-DtLIyi5P.js} +1 -1
  23. package/dist/assets/web/dist/assets/{UnitRoutePage-BcTeH7m2.js → UnitRoutePage-DGXukEAH.js} +1 -1
  24. package/dist/assets/web/dist/assets/{dist-TAGawIfM.js → dist-DtA7JiH5.js} +1 -1
  25. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +1 -0
  26. package/dist/assets/web/dist/assets/{main-mUd_TJjH.js → main-Dht0in4v.js} +2 -2
  27. package/dist/cli.js +452 -51
  28. package/package.json +1 -1
  29. package/dist/assets/web/dist/assets/SettingsPage-DuA8JpLU.js +0 -1
  30. package/dist/assets/web/dist/assets/main-csihqEj5.css +0 -1
@@ -227,6 +227,7 @@ import {
227
227
  type FetchRecord,
228
228
  type SnapshotStamp,
229
229
  } from "../../src/execution/residentStepPlan.js";
230
+ import { backupIdsOf, RETIRED_SNAPSHOT_KEY, rotateSnapshots } from "../../src/execution/snapshotRetention.js";
230
231
  import {
231
232
  DF_FREE_ARGV,
232
233
  DISK_FULL_FREE_KIB,
@@ -2621,7 +2622,7 @@ export class ResidentDO extends Sandbox<Env> {
2621
2622
  [UPDATED_KEY]: new Date(systemClock()).toISOString(),
2622
2623
  [DEADLINE_AT_KEY]: systemClock() + provisioningTimeoutMs,
2623
2624
  });
2624
- await this.ctx.storage.delete([FACTS_KEY, SNAPSHOT_KEY]); // defensive: no stale facts from a past life
2625
+ await this.ctx.storage.delete([FACTS_KEY, SNAPSHOT_KEY, RETIRED_SNAPSHOT_KEY]); // defensive: no stale facts from a past life
2625
2626
  try {
2626
2627
  this.deleteSchedules(PROVISIONING_CALLBACK);
2627
2628
  this.deleteSchedules(PROVISION_RUN_CALLBACK);
@@ -3163,18 +3164,40 @@ export class ResidentDO extends Sandbox<Env> {
3163
3164
 
3164
3165
  /** The cycle's snapshot phase: the stamped pair to R2 and, when this cycle's
3165
3166
  * record stands (not superseded by another writer's), the ready marker and
3166
- * the replaced snapshot's objects swept. Answers whether the record committed. */
3167
+ * the rotation — the replaced pair retired, the pair retired before it
3168
+ * swept (docs/reference/specs/resident-repos.md item 7: two generations, so a
3169
+ * handle a seeded restore read from `/status` still resolves for a cycle).
3170
+ * Answers whether the record committed. */
3167
3171
  private async refreshSnapshot(resource: string, stamp: SnapshotStamp): Promise<boolean> {
3168
3172
  const snapped = await this.snapshot({ resource, stamp });
3169
3173
  const committed = snapped.done || !snapped.superseded;
3170
3174
  if (committed) {
3171
3175
  await this.writeDiskMarkers({ ready: stamp.sha });
3172
- const previous = !snapped.done && !snapped.superseded ? snapped.previous : undefined;
3173
- if (previous) await this.deleteBackupObjects([previous.mirror.id, previous.checkout.id]).catch(() => {});
3176
+ const replaced = !snapped.done && !snapped.superseded ? snapped.previous : undefined;
3177
+ if (replaced) await this.retireSnapshot(replaced);
3174
3178
  }
3175
3179
  return committed;
3176
3180
  }
3177
3181
 
3182
+ /** The rotation's bookkeeping: the retired generation recorded (the storage
3183
+ * write first, so a deletion that fails leaves the record true and the
3184
+ * 1-year R2 TTL as the backstop), then the objects the rotation frees. */
3185
+ private async retireSnapshot(replaced: SnapshotRecord): Promise<void> {
3186
+ const rotation = rotateSnapshots({ replaced, retired: await this.retiredSnapshot() });
3187
+ if (rotation.retired) await this.ctx.storage.put(RETIRED_SNAPSHOT_KEY, rotation.retired);
3188
+ if (rotation.deleteIds.length > 0) await this.deleteBackupObjects(rotation.deleteIds).catch(() => {});
3189
+ }
3190
+
3191
+ private retiredSnapshot(): Promise<SnapshotRecord | undefined> {
3192
+ return this.ctx.storage.get<SnapshotRecord>(RETIRED_SNAPSHOT_KEY);
3193
+ }
3194
+
3195
+ /** The backup ids of every recorded generation — the current pair and the
3196
+ * retired one — for the paths that delete or itemize them all. */
3197
+ private async recordedBackupIds(current: SnapshotRecord | undefined): Promise<string[]> {
3198
+ return backupIdsOf([current, await this.retiredSnapshot()]);
3199
+ }
3200
+
3178
3201
  /** The cycle's completion: the facts to the stamp (the snapshot step moved
3179
3202
  * them in the same write as the record — a wake never sees a half-updated
3180
3203
  * pair; this is the cycle's own bookkeeping on a fresh read, and a
@@ -6990,6 +7013,11 @@ export class ResidentDO extends Sandbox<Env> {
6990
7013
  createdAt: snap.createdAt,
6991
7014
  mirrorBackupId: snap.mirror.id,
6992
7015
  checkoutBackupId: snap.checkout.id,
7016
+ // The seed handle's other half (docs/reference/specs/execution.md item 25):
7017
+ // the deps-store entry archive for the snapshot's own lockfile key,
7018
+ // when one has been taken (item 61) — a seeded sandbox restores it
7019
+ // beside the checkout and skips the install.
7020
+ depsBackupId: (await this.depsBackupRecord(snap.lockfileHash))?.backup.id ?? null,
6993
7021
  }
6994
7022
  : null,
6995
7023
  // The provisioning schedules, the one timer a resident has (item 3).
@@ -7162,6 +7190,7 @@ export class ResidentDO extends Sandbox<Env> {
7162
7190
  };
7163
7191
  }
7164
7192
  const snap = await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY);
7193
+ const recorded = await this.recordedBackupIds(snap);
7165
7194
  const bindings = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
7166
7195
  const plan = {
7167
7196
  resource,
@@ -7178,7 +7207,7 @@ export class ResidentDO extends Sandbox<Env> {
7178
7207
  checkoutBackupId: snap.checkout.id,
7179
7208
  }
7180
7209
  : null,
7181
- backupObjects: snap ? await this.countBackupObjects([snap.mirror.id, snap.checkout.id]) : 0,
7210
+ backupObjects: await this.countBackupObjects(recorded),
7182
7211
  },
7183
7212
  reprovision: { defaultRef, provisioningTimeoutMs },
7184
7213
  keeps: { registryRecord: true as const, threadBindings: bindings.size },
@@ -7189,9 +7218,9 @@ export class ResidentDO extends Sandbox<Env> {
7189
7218
  // and backups/<id>/ lives outside the resident/<resource>/ prefix — this
7190
7219
  // is the only path that can still reach them (same ordering as teardown).
7191
7220
  let backupObjectsDeleted = 0;
7192
- if (snap) {
7221
+ if (recorded.length > 0) {
7193
7222
  try {
7194
- backupObjectsDeleted = await this.deleteBackupObjects([snap.mirror.id, snap.checkout.id]);
7223
+ backupObjectsDeleted = await this.deleteBackupObjects(recorded);
7195
7224
  } catch {
7196
7225
  // best effort — the 1-year R2 TTL is the leak backstop
7197
7226
  }
@@ -7227,8 +7256,7 @@ export class ResidentDO extends Sandbox<Env> {
7227
7256
  this.listSchedules(PROVISIONING_CALLBACK),
7228
7257
  this.listSchedules(PROVISION_RUN_CALLBACK),
7229
7258
  ]);
7230
- const snap = await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY);
7231
- const ids = snap ? [snap.mirror.id, snap.checkout.id] : [];
7259
+ const ids = await this.recordedBackupIds(await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY));
7232
7260
  const bindings = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
7233
7261
  return {
7234
7262
  ...status,
@@ -7256,10 +7284,10 @@ export class ResidentDO extends Sandbox<Env> {
7256
7284
  let backupObjectsDeleted = 0;
7257
7285
  this.deleteSchedules(PROVISIONING_CALLBACK);
7258
7286
  this.deleteSchedules(PROVISION_RUN_CALLBACK);
7259
- const snap = await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY);
7260
- if (snap) {
7287
+ const recorded = await this.recordedBackupIds(await this.ctx.storage.get<SnapshotRecord>(SNAPSHOT_KEY));
7288
+ if (recorded.length > 0) {
7261
7289
  try {
7262
- backupObjectsDeleted = await this.deleteBackupObjects([snap.mirror.id, snap.checkout.id]);
7290
+ backupObjectsDeleted = await this.deleteBackupObjects(recorded);
7263
7291
  } catch (err) {
7264
7292
  errors.push(`backup object deletion failed: ${errMsg(err)}`);
7265
7293
  }
@@ -37,8 +37,15 @@ ENV COMMAND_TIMEOUT_MS=1240000
37
37
  # call. The sandbox is already root with full capabilities inside its own
38
38
  # microVM, so a container a run starts shares that run's blast radius and
39
39
  # nothing more; no privileged flag or capability is added anywhere.
40
+ # squashfs-tools (`unsquashfs`): the seed (docs/reference/specs/execution.md
41
+ # item 25) restores the resident's snapshot with the SDK's presigned restore,
42
+ # which MOUNTS the archive (squashfuse + fuse-overlayfs) instead of extracting
43
+ # it; the Worker extracts it onto the disk the way the resident does
44
+ # (resident-repos item 61) so the checkout is one plain ext4 tree. The build
45
+ # asserts the binary with the probe the extract script uses (`command -v`).
40
46
  RUN apt-get update \
41
- && apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables \
47
+ && apt-get install -y --no-install-recommends git curl ca-certificates docker.io iptables squashfs-tools \
48
+ && command -v unsquashfs >/dev/null \
42
49
  && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
43
50
  -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
44
51
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
@@ -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",
@@ -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
  },
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.237.0",
3
+ "version": "1.239.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.237.0",
9
+ "version": "1.239.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20241,7 +20241,7 @@
20241
20241
  },
20242
20242
  "packages/switchboard": {
20243
20243
  "name": "@coreplane/switchboard",
20244
- "version": "1.237.0",
20244
+ "version": "1.239.0",
20245
20245
  "license": "Apache-2.0",
20246
20246
  "dependencies": {
20247
20247
  "@earendil-works/pi-ai": "0.85.1",