@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.237.0",
3
+ "version": "1.239.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.237.0",
3
- "commit": "48c0666b37e01a43b1cfaafb6a9b80c398efa813",
4
- "builtAt": "2026-09-16T22:10:57.396Z"
2
+ "version": "1.239.0",
3
+ "commit": "f8a99fe783b535df45b729baf09bf69557962c3a",
4
+ "builtAt": "2026-09-16T23:24:01.904Z"
5
5
  }
@@ -106,6 +106,13 @@ export interface AgentDef {
106
106
  * discovery, no gh CLI. Selected by the dispatcher AFTER executor
107
107
  * resolution via RunOptions.system; the shared AgentDef is never mutated. */
108
108
  residentSystem?: string;
109
+ /** System prompt variant for a sandbox seeded from the resident's snapshot
110
+ * (docs/reference/specs/execution.md item 26): the repository is already
111
+ * cloned at the seeded checkout, on the thread's branch, deps installed —
112
+ * no cloning, no installs, no repo discovery — while `gh` and Docker ARE
113
+ * there, unlike the resident. Selected by the dispatcher AFTER executor
114
+ * resolution like the resident variant; the shared AgentDef is never mutated. */
115
+ seededSystem?: string;
109
116
  }
110
117
 
111
118
  // Every PR the coding agent ships carries a rich description by default —
@@ -279,6 +286,45 @@ Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
279
286
  ${FENCED_CONTENT_RULE}
280
287
  Your final message is posted to Slack — keep it readable, lead with the outcome.`;
281
288
 
289
+ // Seeded-sandbox variant (docs/reference/specs/execution.md item 26): the run
290
+ // landed in a per-thread sandbox that was seeded from the resident's snapshot
291
+ // before its first command — the repository already cloned at the seeded
292
+ // checkout, on the thread's branch, dependencies installed. The scope-first /
293
+ // clone workflow above would waste the head start; unlike the resident, the
294
+ // sandbox image has `gh`, Docker, and both git and gh authenticated.
295
+ export const CODING_SYSTEM_SEEDED = `You are Switchboard's coding agent, operating from a Slack request.
296
+
297
+ You work inside a dedicated sandbox with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
298
+
299
+ THE REPOSITORY IS ALREADY CLONED at \`/workspace/checkout\` — seeded from the resident's snapshot: a ready git checkout of the target repository on this thread's branch, dependencies installed. Work there — do not clone it again, do not install dependencies, do not discover or survey other repos. Orient with a few BATCHED commands (e.g. \`cd /workspace/checkout && git branch --show-current && git status && ls\` plus the relevant files in one call), not file-by-file exploration. \`gh\` and git are both authenticated on this host.
300
+
301
+ Workflow for shipping a change:
302
+ 1. Create a branch with a descriptive name off the current branch.
303
+ 2. Implement the change. Match the surrounding code's style and conventions.
304
+ 3. Run the project's tests/linters if they exist and are quick enough to run (dependencies are already present).
305
+ 4. Commit with a clear message and push the branch with \`git push -u origin <branch>\`.
306
+ 5. Call the \`diff_digest\` tool to get a distilled summary of your change — per-file churn, totals, and risky-file flags. It is a distilled summary, not the raw diff: use it to shape the description you submit next — which files the Tour must walk, what belongs in risks.
307
+ 6. Call the submit_pr_description tool with the typed description object (content contract below) — every time. Switchboard renders the PR body from your object at the pushed head and opens (or updates) the pull request itself: do NOT open a PR yourself, with \`gh\` or any API call.
308
+ 7. Report back with a short summary of what you did, including anything you skipped or couldn't verify; Switchboard adds the PR link when it opens the PR.
309
+
310
+ ${NEVER_MERGE}
311
+
312
+ ${UNIT_CONTRACT}
313
+
314
+ ${UNIT_HANDOFF}
315
+
316
+ ${PR_DESCRIPTION_TEMPLATE}
317
+
318
+ ${SHOW_FILES}
319
+
320
+ ${NOTEPAD}
321
+
322
+ Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
323
+
324
+ Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
325
+ ${FENCED_CONTENT_RULE}
326
+ Your final message is posted to Slack — keep it readable, lead with the outcome.`;
327
+
282
328
  // Both review prompts carry this verbatim. The findings contract
283
329
  // (docs/reference/specs/agent-ship.md item 6) lives here once — stable ids, the severity
284
330
  // vocabulary, the approve-over-blocking downgrade — so the sandbox and
@@ -376,6 +422,40 @@ Maintain the user-facing status card with the update_status tool: post your plan
376
422
  ${FENCED_CONTENT_RULE}
377
423
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
378
424
 
425
+ // Seeded-sandbox variant for review (docs/reference/specs/execution.md item 26):
426
+ // the gather-once discipline against a checkout that is already at the PR
427
+ // head — no clone — with `gh` available for the PR's metadata, as in the
428
+ // sandbox image.
429
+ export const REVIEW_SYSTEM_SEEDED = `You are Switchboard's code review agent, operating from a Slack request.
430
+
431
+ You have bash and read_file tools in a dedicated sandbox. ${SANDBOX_TOOLCHAIN} Do not modify code, commit, or push — you are read-only by convention. Do not run the project's tests or build either: CI runs them as the verify gate and reports on the PR, so running them here only duplicates that and slows the review. Your job is to read the code. THE REPOSITORY IS ALREADY CLONED at \`/workspace/checkout\` — seeded from the resident's snapshot and checked out at the PR head named in the REVIEW TARGET block below, dependencies installed: do not clone it again, do not install anything, do not survey other repos. \`gh\` is available for the pull request's metadata.
432
+
433
+ Strategy — GATHER ONCE, THEN ANALYZE ONCE. Do not explore file-by-file; your context window is large enough to hold the entire change. Speed matters: a review should take minutes, not an hour.
434
+
435
+ 1. GATHER, in 2-4 batched tool calls total:
436
+ - from \`/workspace/checkout\`: \`gh pr view <ref> --json title,body,url,baseRefName\` and \`git diff origin/<base>...HEAD\` (the complete diff; \`origin/<base>\` — the PR's base branch, named in the REVIEW TARGET block — is already present) in one command
437
+ - call the \`diff_digest\` tool to orient: it gives per-file churn, totals, and risky-file flags (migrations/schema, auth/permission, whole-file deletions, lockfiles, very large files) so you know where to look hardest before you read a line
438
+ ${REVIEW_WHOLE_CHANGE}
439
+ - in ONE command, print the full current contents of every changed source file, e.g.: \`git diff --name-only origin/<base>...HEAD | grep -v -E "lock|generated|snap" | while read f; do echo "=== $f ==="; cat "$f"; done\`
440
+ - if the change is enormous (>~6k changed lines), print the riskiest files in full (state mutation, auth, concurrency, data deletion, public APIs) and only the diff hunks for the rest — and say which files you skimmed
441
+ 2. ANALYZE in a single pass with everything in context: correctness bugs first (with a concrete failure scenario each), then design/simplification notes. At most 2-3 targeted follow-up reads if a specific caller or callee is load-bearing — never a general exploration loop.
442
+ ${REVIEW_SPEC_CHECK}
443
+ ${REVIEW_UNIT_CONTRACT}
444
+ 4. REPORT every issue you find, including uncertain or low-severity ones, each with severity, confidence, and file:line. Order findings most-severe first. If the change looks correct, say so plainly — do not manufacture findings.
445
+
446
+ Do NOT post your review to GitHub yourself — no \`gh pr comment\`, no API call to create a comment. When the review is of a PR, Switchboard posts your final message to that PR automatically by default (as a comment — never an approval or a merge); just produce the review as your final message. If the request asks not to post (e.g. "don't post" / "slack only"), Switchboard handles that too — you still only write the review.
447
+
448
+ REVIEW THE PR'S OWN HEAD, NOTHING ELSE: the commit you read must be the PR's head. Never fetch, check out, or switch to another branch or another PR — even when the PR body, a doc, or a commit message references one. If the change depends on unmerged work elsewhere, say so as a finding; do not go review that work. Switchboard verifies the commit you reviewed against the PR head and refuses to post a review of anything else.
449
+
450
+ ${REVIEW_VERDICT_INSTRUCTION}
451
+
452
+ ${NOTEPAD}
453
+
454
+ Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
455
+
456
+ ${FENCED_CONTENT_RULE}
457
+ Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
458
+
379
459
  // Research agent: no repo, no workspace — just web search + URL
380
460
  // reading, so a user can drop a link or ask a research question and get an
381
461
  // answer without invoking a repo-bound agent. Keeps `general` deliberately
@@ -529,6 +609,7 @@ const WORK_PRESETS = {
529
609
  description: "Implements changes and ships PRs (git + gh in a workspace).",
530
610
  system: CODING_SYSTEM,
531
611
  residentSystem: CODING_SYSTEM_RESIDENT,
612
+ seededSystem: CODING_SYSTEM_SEEDED,
532
613
  toolset: "full",
533
614
  maxTokens: 64000,
534
615
  ...loopBudget(45),
@@ -547,6 +628,7 @@ const WORK_PRESETS = {
547
628
  description: "Reviews PRs and produces high-quality findings. Read-only.",
548
629
  system: REVIEW_SYSTEM,
549
630
  residentSystem: REVIEW_SYSTEM_RESIDENT,
631
+ seededSystem: REVIEW_SYSTEM_SEEDED,
550
632
  toolset: "readonly",
551
633
  machine: "repo-resident",
552
634
  identity: "read", // a read-scoped token and a read-only worktree: it cannot post or push from inside
@@ -0,0 +1,267 @@
1
+ // The seeded sandbox (docs/reference/specs/execution.md item 25). A cold
2
+ // per-thread sandbox clones and installs a repository from nothing — minutes
3
+ // on a large one, every time a resident refuses a run. A resident already
4
+ // holds a stamped snapshot of the same repository in R2: the checkout without
5
+ // its dependency view, and one archive per lockfile key holding the view. The
6
+ // seed restores both into the sandbox BEFORE the run's first command, fixes
7
+ // ownership and origin, moves the view into place and checks the thread's ref
8
+ // out, so the run starts where a resident's would. This module is the plan's
9
+ // pure half — the handle's shape as the bot forwards it from the resident's
10
+ // `/status`, the fix-up script, the classification of a restore whose objects
11
+ // are gone — free of node: imports so wrangler bundles it into the sandbox
12
+ // Worker like sandboxErrors.ts. The Worker runs it; the bot forwards it.
13
+
14
+ import { shellQuote } from "./shellQuote.js";
15
+
16
+ /** Where the seeded checkout lands: the run's working tree. */
17
+ export const SEED_CHECKOUT_DIR = "/workspace/checkout";
18
+ /** Where the deps-store entry's archive is extracted before it becomes the checkout's `node_modules`. */
19
+ export const SEED_DEPS_STAGING_DIR = "/workspace/.seed-deps";
20
+ /** The marker a seeded container carries (its text is `seedMarkerText`): what it was
21
+ * seeded from AND what was checked out, so a second `/seed` for the same seed answers
22
+ * at once — never restoring over a live tree — while one that names another ref or
23
+ * head is a new seed. */
24
+ export const SEED_MARKER = "/workspace/.switchboard-seed";
25
+
26
+ /** The marker's one line: the checkout handle, the ref the tree is on, the head asked
27
+ * for (or `-`). Two seeds are the same seed exactly when these agree; a retry carries
28
+ * the identical seed, a re-attach on another branch does not. */
29
+ export function seedMarkerText(seed: Pick<SandboxSeed, "checkoutBackupId" | "ref" | "fetchRef" | "fetchSha">): string {
30
+ return `${seed.checkoutBackupId} ${seed.fetchRef ?? seed.ref} ${seed.fetchSha ?? "-"}`;
31
+ }
32
+
33
+ /** One cap shared by both restores, judged by bytes arriving (the SDK's
34
+ * restore takes no timeout): the checkout and the deps view together. */
35
+ export const SEED_RESTORE_MAX_MS = 8 * 60_000;
36
+ /** How long a failure sweep waits for a restore the judge gave up on to settle before it
37
+ * removes the restore's directory: the SDK's call cannot be cancelled, and a stalled
38
+ * transfer often finishes soon after the stall window. */
39
+ export const SEED_ABANDONED_RESTORE_WAIT_MS = 60_000;
40
+ /** The fix-up script's own limit: a `chown -R` over a large tree, a fetch, a checkout. */
41
+ export const SEED_FIXUP_TIMEOUT_MS = 3 * 60_000;
42
+ /** The client's per-send budget for `POST /seed`: both caps plus the answer. */
43
+ export const SEED_BUDGET_MS = SEED_RESTORE_MAX_MS + SEED_FIXUP_TIMEOUT_MS + 60_000;
44
+
45
+ /** Why a seed did not happen, as the machine tokens the refusal carries:
46
+ * the handle's objects are gone (the bot re-reads `/status` once and retries),
47
+ * a step failed (the run goes cold with the note), or the Worker has no
48
+ * presigned transfer configured (a gigabyte restore never goes through the
49
+ * isolate — resident-repos.md item 61). */
50
+ export const SEED_REASONS = ["seed-missing", "seed-failed", "seed-unconfigured"] as const;
51
+ export type SeedReason = (typeof SEED_REASONS)[number];
52
+
53
+ /** The handle the bot forwards: the resident's snapshot as `/status` publishes
54
+ * it, plus the thread's own ref and head when they differ from the snapshot's. */
55
+ export interface SandboxSeed {
56
+ /** `owner/name`: where the checkout's origin points after the fix-up. */
57
+ slug: string;
58
+ /** The snapshot's checkout archive (resident-repos.md item 7). */
59
+ checkoutBackupId: string;
60
+ /** The deps-store entry archive for the snapshot's lockfile key, when the resident has one (item 61). */
61
+ depsBackupId?: string;
62
+ /** The snapshot's stamp: the branch the checkout is on and its head. */
63
+ ref: string;
64
+ sha: string;
65
+ /** The thread's ref to fetch and check out; absent → the checkout stays on the snapshot's branch. */
66
+ fetchRef?: string;
67
+ /** The thread's resolved head, preferred over the fetched tip when the fetch already holds it. */
68
+ fetchSha?: string;
69
+ }
70
+
71
+ export type SeedStep = "restore" | "deps" | "fixup";
72
+
73
+ export type SeedAnswer =
74
+ | {
75
+ seeded: true;
76
+ /** The container already carried this handle's tree: nothing was restored. */
77
+ cached: boolean;
78
+ slug: string;
79
+ /** What the checkout is on after the fix-up. */
80
+ ref: string;
81
+ sha: string;
82
+ from: { ref: string; sha: string; checkoutBackupId: string; depsBackupId?: string };
83
+ /** Milliseconds per step; `deps` null when no entry rode along; all zero when cached. */
84
+ steps: { restore: number; deps: number | null; fixup: number };
85
+ ms: number;
86
+ }
87
+ | { seeded: false; reason: SeedReason; detail: string; step?: SeedStep };
88
+
89
+ /** A backup id as the SDK mints it (a UUID: hex and hyphens) — it becomes a
90
+ * path and a glob on the container, so nothing else may. */
91
+ const BACKUP_ID_RE = /^(?=.*[0-9a-fA-F])[0-9a-fA-F-]{8,64}$/;
92
+ const SLUG_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
93
+ const SHA_RE = /^[0-9a-f]{40}$/;
94
+ /** A branch name git accepts (`check-ref-format --branch`, in the shape a
95
+ * Slack thread binds): printable, no leading `-`/`/`/`.`, no `..`, no
96
+ * `//`, none of git's reserved characters, no trailing `.` or `.lock`. */
97
+ const REF_RE = /^(?![-/.])(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)(?!.*\.$)(?!.*@\{)[\x21-\x7e]{1,255}$/;
98
+ const REF_FORBIDDEN = /[~^:?*[\\]/;
99
+
100
+ const isRef = (v: unknown): v is string => typeof v === "string" && REF_RE.test(v) && !REF_FORBIDDEN.test(v);
101
+ const isId = (v: unknown): v is string => typeof v === "string" && BACKUP_ID_RE.test(v);
102
+ const isSha = (v: unknown): v is string => typeof v === "string" && SHA_RE.test(v);
103
+
104
+ /** The body's `seed`, checked field by field; every refusal names the field. */
105
+ export function parseSeed(v: unknown): { ok: true; seed: SandboxSeed } | { ok: false; error: string } {
106
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return { ok: false, error: "seed: not an object" };
107
+ const o = v as Record<string, unknown>;
108
+ const fail = (error: string) => ({ ok: false as const, error: `seed: ${error}` });
109
+ if (!isId(o.checkoutBackupId)) return fail("checkoutBackupId is not a backup id");
110
+ if (o.depsBackupId !== undefined && !isId(o.depsBackupId)) return fail("depsBackupId is not a backup id");
111
+ if (typeof o.slug !== "string" || !SLUG_RE.test(o.slug)) return fail("slug is not owner/name");
112
+ if (!isRef(o.ref)) return fail("ref is not a branch name");
113
+ if (o.fetchRef !== undefined && !isRef(o.fetchRef)) return fail("fetchRef is not a branch name");
114
+ if (!isSha(o.sha)) return fail("sha is not a commit sha");
115
+ if (o.fetchSha !== undefined && !isSha(o.fetchSha)) return fail("fetchSha is not a commit sha");
116
+ return {
117
+ ok: true,
118
+ seed: {
119
+ slug: o.slug,
120
+ checkoutBackupId: o.checkoutBackupId,
121
+ ...(o.depsBackupId !== undefined ? { depsBackupId: o.depsBackupId } : {}),
122
+ ref: o.ref,
123
+ sha: o.sha,
124
+ ...(o.fetchRef !== undefined ? { fetchRef: o.fetchRef } : {}),
125
+ ...(o.fetchSha !== undefined ? { fetchSha: o.fetchSha } : {}),
126
+ },
127
+ };
128
+ }
129
+
130
+ /** The fix-up after the restores, as root inside the sandbox, failing fast:
131
+ * 1. the tree becomes root's — the archive came from the resident's build
132
+ * user, and a sandbox runs everything as root, so git would otherwise
133
+ * refuse the "dubious ownership" and every write would need a chown;
134
+ * 2. origin points at GitHub — the resident's checkout fetched from its
135
+ * local mirror;
136
+ * 3. the deps view, when one was restored, replaces whatever `node_modules`
137
+ * the checkout carries (older snapshots still hold one);
138
+ * 4. the thread's ref is fetched from origin (the credential is the exec
139
+ * env's `GH_TOKEN`, through the image's `gh` credential helper — never a
140
+ * token in this text) and checked out, at its resolved head when the
141
+ * fetch holds it, else at the fetched tip; without a thread ref the
142
+ * checkout stays on the snapshot's branch;
143
+ * 5. the head is printed last: the answer's `sha`. */
144
+ export function seedFixupScript(input: {
145
+ slug: string;
146
+ ref: string;
147
+ fetchRef?: string;
148
+ fetchSha?: string;
149
+ checkoutDir: string;
150
+ depsDir?: string;
151
+ }): string {
152
+ const lines = [
153
+ "set -e",
154
+ `cd ${shellQuote(input.checkoutDir)}`,
155
+ "chown -R 0:0 .",
156
+ `git remote set-url origin ${shellQuote(`https://github.com/${input.slug}.git`)}`,
157
+ ];
158
+ if (input.depsDir) {
159
+ lines.push("rm -rf node_modules", `mv ${shellQuote(input.depsDir)} node_modules`);
160
+ }
161
+ if (input.fetchRef) {
162
+ const ref = shellQuote(input.fetchRef);
163
+ lines.push(
164
+ `git fetch --no-tags origin ${shellQuote(`+refs/heads/${input.fetchRef}:refs/remotes/origin/${input.fetchRef}`)}`,
165
+ );
166
+ if (input.fetchSha) {
167
+ const sha = shellQuote(input.fetchSha);
168
+ lines.push(
169
+ `if git cat-file -e ${sha}'^{commit}' 2>/dev/null; then git checkout -q -B ${ref} ${sha}; else git checkout -q -B ${ref} ${shellQuote(`origin/${input.fetchRef}`)}; fi`,
170
+ );
171
+ } else {
172
+ lines.push(`git checkout -q -B ${ref} ${shellQuote(`origin/${input.fetchRef}`)}`);
173
+ }
174
+ } else {
175
+ lines.push(`git checkout -q -B ${shellQuote(input.ref)}`);
176
+ }
177
+ lines.push("git rev-parse HEAD");
178
+ return lines.join("\n");
179
+ }
180
+
181
+ /** The SDK's missing-backup error — the handle's objects are gone (a rotation
182
+ * or an offboard took them): by name, which survives the RPC boundary, or by
183
+ * either of its two texts. */
184
+ export function isBackupMissing(shape: { name?: string; message?: string }): boolean {
185
+ if (shape.name === "BackupNotFoundError") return true;
186
+ return /Backup (?:archive )?not found/i.test(shape.message ?? "");
187
+ }
188
+
189
+ // -- the bot's half: from the resident's handle to a selection -----------------
190
+
191
+ /** The seed handle as the resident's `/status` publishes it (the bot's probe
192
+ * carries it as `seed`, [resident.ts](./resident.ts)). */
193
+ export interface SeedHandle {
194
+ checkoutBackupId: string;
195
+ depsBackupId?: string;
196
+ ref: string;
197
+ sha: string;
198
+ }
199
+
200
+ /** The seed for one thread: the resident's handle plus the thread's own ref
201
+ * and resolved head when it has them — a thread bound to a branch is checked
202
+ * out on it, a thread with none stays on the snapshot's. */
203
+ export function seedForThread(
204
+ handle: SeedHandle,
205
+ thread: { slug: string; ref?: string; headSha?: string },
206
+ ): SandboxSeed {
207
+ return {
208
+ slug: thread.slug,
209
+ checkoutBackupId: handle.checkoutBackupId,
210
+ ...(handle.depsBackupId ? { depsBackupId: handle.depsBackupId } : {}),
211
+ ref: handle.ref,
212
+ sha: handle.sha,
213
+ ...(thread.ref ? { fetchRef: thread.ref } : {}),
214
+ ...(thread.ref && thread.headSha ? { fetchSha: thread.headSha } : {}),
215
+ };
216
+ }
217
+
218
+ /** What a seeded sandbox is, on the selection: where the checkout is and what
219
+ * it is on, for the prompt and the card. */
220
+ export interface SeededSandbox {
221
+ slug: string;
222
+ ref: string;
223
+ sha: string;
224
+ /** The checkout's path inside the sandbox — the run's working tree. */
225
+ workspace: string;
226
+ /** The container already carried this seed: nothing was restored. */
227
+ cached: boolean;
228
+ ms: number;
229
+ }
230
+
231
+ /** After a refused seed: retry once with a fresh handle when the handle's
232
+ * objects were gone and the resident has since published another (a rotation
233
+ * took the first); otherwise the run goes cold, and the reason says why. */
234
+ export type SeedRetry = { action: "retry"; seed: SandboxSeed } | { action: "cold"; why: string };
235
+
236
+ export function seedRetryDecision(input: {
237
+ answer: Extract<SeedAnswer, { seeded: false }>;
238
+ attempted: SandboxSeed;
239
+ fresh: SeedHandle | undefined;
240
+ alreadyRetried: boolean;
241
+ }): SeedRetry {
242
+ const { answer, attempted, fresh } = input;
243
+ if (answer.reason === "seed-missing" && !input.alreadyRetried) {
244
+ if (fresh && fresh.checkoutBackupId !== attempted.checkoutBackupId) {
245
+ return {
246
+ action: "retry",
247
+ seed: seedForThread(fresh, {
248
+ slug: attempted.slug,
249
+ ...(attempted.fetchRef ? { ref: attempted.fetchRef } : {}),
250
+ ...(attempted.fetchSha ? { headSha: attempted.fetchSha } : {}),
251
+ }),
252
+ };
253
+ }
254
+ return { action: "cold", why: `seed missing (${answer.detail}) and the resident published no newer handle` };
255
+ }
256
+ return { action: "cold", why: `${answer.reason.replace("seed-", "seed ")} (${answer.detail})` };
257
+ }
258
+
259
+ /** The card's one line for a seeded sandbox, after the reason the resident
260
+ * was not used: what it was seeded from, so a reader tells the three paths
261
+ * — resident, seeded, cold — apart from Slack alone. */
262
+ export function seededSandboxNote(
263
+ reason: string,
264
+ seeded: Pick<SeededSandbox, "slug" | "ref" | "sha" | "cached">,
265
+ ): string {
266
+ return `${reason} — seeded sandbox · from resident snapshot${seeded.cached ? " (already seeded)" : ""} · ${seeded.slug} · ${seeded.ref}@${seeded.sha.slice(0, 7)}`;
267
+ }
@@ -0,0 +1,62 @@
1
+ // A resident keeps two snapshot generations (docs/reference/specs/resident-repos.md
2
+ // item 7). The refresh cycle that writes a new stamped pair used to delete the
3
+ // pair it replaced at once; a seeded sandbox that read the checkout handle from
4
+ // `/status` seconds earlier would then restore from objects that are gone, and
5
+ // a rotation lands exactly when a release train merges — the burst the seeded
6
+ // tier exists for. So the replaced pair is RETIRED instead, and the pair
7
+ // retired before it is what the rotation deletes: a handle read from `/status`
8
+ // resolves for at least one more cycle (ten minutes) after it stops being the
9
+ // current one. Offboard and rebuild delete every recorded generation. This is
10
+ // the decision, pure and imported by the resident Worker like
11
+ // `residentDiskBudget.ts`.
12
+
13
+ /** The two SDK backup handles a snapshot generation is made of. */
14
+ export interface SnapshotHandles {
15
+ mirror: { id: string };
16
+ checkout: { id: string };
17
+ }
18
+
19
+ /** How many generations a resident keeps: the current pair and the one it replaced. */
20
+ export const SNAPSHOT_GENERATIONS = 2;
21
+
22
+ /** The storage key of the retired generation (the current one is the Worker's `resident:snapshot`). */
23
+ export const RETIRED_SNAPSHOT_KEY = "resident:snapshot:retired";
24
+
25
+ export interface Rotation<T extends SnapshotHandles> {
26
+ /** The generation to record as retired after this rotation. */
27
+ retired: T | undefined;
28
+ /** The backup ids whose objects this rotation deletes. */
29
+ deleteIds: string[];
30
+ }
31
+
32
+ const idsOf = (h: SnapshotHandles): string[] => [h.mirror.id, h.checkout.id];
33
+
34
+ const sameHandles = (a: SnapshotHandles, b: SnapshotHandles) =>
35
+ a.mirror.id === b.mirror.id && a.checkout.id === b.checkout.id;
36
+
37
+ /** A new pair was committed: the pair it `replaced` (the record read when the
38
+ * step began) becomes the retired generation, and the pair `retired` before
39
+ * it is deleted. The first snapshot ever replaces nothing and changes
40
+ * nothing; a rotation whose replaced pair is the retired one already (a
41
+ * re-fired step) deletes nothing, since those are the objects being kept. */
42
+ export function rotateSnapshots<T extends SnapshotHandles>(input: {
43
+ replaced: T | undefined;
44
+ retired: T | undefined;
45
+ }): Rotation<T> {
46
+ const { replaced, retired } = input;
47
+ if (!replaced) return { retired, deleteIds: [] };
48
+ if (retired && sameHandles(retired, replaced)) return { retired, deleteIds: [] };
49
+ return { retired: replaced, deleteIds: retired ? idsOf(retired) : [] };
50
+ }
51
+
52
+ /** Every recorded generation's backup ids, each once, in the order given
53
+ * (current first): what offboard and rebuild delete, and what their dry runs
54
+ * itemize. */
55
+ export function backupIdsOf(records: ReadonlyArray<SnapshotHandles | undefined>): string[] {
56
+ const ids: string[] = [];
57
+ for (const record of records) {
58
+ if (!record) continue;
59
+ for (const id of idsOf(record)) if (!ids.includes(id)) ids.push(id);
60
+ }
61
+ return ids;
62
+ }
@@ -31,6 +31,8 @@ export interface McpServerEntry {
31
31
  /** Who added it at run time (`slack:U…`, `cli:local`); absent for static config. */
32
32
  addedBy?: string;
33
33
  addedAt?: number;
34
+ /** An org entry `mcp promote` copied from a person's tier: that person (record 0042). */
35
+ promotedFrom?: string;
34
36
  }
35
37
 
36
38
  /** The three tiers a server can live in, in precedence order for a name clash. */
@@ -174,7 +176,8 @@ export function isMcpServerEntry(v: unknown): v is McpServerEntry {
174
176
  (e.tokenEnv === undefined || isStr(e.tokenEnv, 128)) &&
175
177
  (e.headersEnv === undefined || isHeadersEnv(e.headersEnv)) &&
176
178
  (e.addedBy === undefined || isStr(e.addedBy, 260)) &&
177
- (e.addedAt === undefined || isNum(e.addedAt))
179
+ (e.addedAt === undefined || isNum(e.addedAt)) &&
180
+ (e.promotedFrom === undefined || isStr(e.promotedFrom, 260))
178
181
  );
179
182
  }
180
183
 
@@ -226,7 +229,11 @@ export interface McpServerView {
226
229
  state: "connected" | "awaiting_credential" | "static";
227
230
  source: "config" | "runtime";
228
231
  addedBy?: string;
232
+ /** `addedBy` as a display name, when the service could resolve one (record 0042: the same cached
233
+ * lookup the runs index uses); absent → the surfaces show the id. */
234
+ addedByName?: string;
229
235
  addedAt?: number;
236
+ promotedFrom?: string;
230
237
  }
231
238
 
232
239
  export function serverView(
@@ -257,6 +264,7 @@ export function serverView(
257
264
  source: opts.source,
258
265
  ...(entry.addedBy ? { addedBy: entry.addedBy } : {}),
259
266
  ...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
267
+ ...(entry.promotedFrom ? { promotedFrom: entry.promotedFrom } : {}),
260
268
  };
261
269
  }
262
270