@coreplane/switchboard 0.0.0 → 1.18.1

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 (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
  126. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "{{script}}",
4
+ "main": "worker.ts",
5
+ "compatibility_date": "2026-08-01",
6
+ // The installation's Cloudflare account (deploy/profile.json `account`) — every Worker deploys to it.
7
+ "account_id": "{{account}}",
8
+ // Reachable only with the MEMORY_TOKEN bearer secret (plus an unauthenticated
9
+ // GET /healthz wake ping). Custom domain on the installation's zone so the
10
+ // bot config (memory.worker.baseUrl) has a stable URL.
11
+ "workers_dev": false,
12
+ "routes": [{ "pattern": "{{hostname}}", "custom_domain": true }],
13
+ "observability": { "enabled": true },
14
+ "durable_objects": {
15
+ // One MemoryDO per scopeKey (the DO name IS the scope key, e.g.
16
+ // "org:<organization>") — the scope partition is the object boundary, so a
17
+ // scope's rows live in one SQLite database and cross-scope reads are
18
+ // impossible by construction.
19
+ "bindings": [
20
+ { "name": "MEMORY", "class_name": "MemoryDO" },
21
+ // ONE ScheduleDO (named "schedules"): every cron firing the bot's Worker
22
+ // shim makes, for the /runs Scheduled panel (docs/reference/specs/live-view.md).
23
+ { "name": "SCHEDULES", "class_name": "ScheduleDO" },
24
+ // One RunHistoryDO per store key (`runs:default`): persistent run history
25
+ // (docs/reference/specs/run-history.md) — runs + run_events + the retention policy.
26
+ { "name": "RUNS", "class_name": "RunHistoryDO" },
27
+ // ONE ConfigDO (named "config"): the bot's runtime config documents — the
28
+ // chat-set `overrides` (docs/reference/specs/routing-and-config.md item 12) — versioned.
29
+ { "name": "CONFIG", "class_name": "ConfigDO" },
30
+ // One RunTranscriptDO per LIVE run (the DO name is the run id): the raw
31
+ // transcript a resumed run continues from (docs/reference/specs/run-history.md item
32
+ // 32). Kept apart from the history object so transcript bytes never queue
33
+ // behind an admission decision; cleared at finish.
34
+ { "name": "RUN_TRANSCRIPTS", "class_name": "RunTranscriptDO" }
35
+ ]
36
+ },
37
+ // SQLite-backed DOs (durable across restarts — AGENTS.md invariant 6).
38
+ "migrations": [
39
+ { "tag": "v1", "new_sqlite_classes": ["MemoryDO"] },
40
+ { "tag": "v2", "new_sqlite_classes": ["FrictionDO"] },
41
+ { "tag": "v3", "new_sqlite_classes": ["ScheduleDO"] },
42
+ { "tag": "v4", "new_sqlite_classes": ["RunHistoryDO"] },
43
+ { "tag": "v5", "new_sqlite_classes": ["ConfigDO"] },
44
+ { "tag": "v6", "new_sqlite_classes": ["RunTranscriptDO"] },
45
+ // The friction ledger is read from run history (RunHistoryDO carries every
46
+ // run's diagnosis); the FrictionDO that once mirrored those diagnoses is
47
+ // retired with its rows — docs/reference/specs/self-improvement.md item 1.
48
+ { "tag": "v7", "deleted_classes": ["FrictionDO"] }
49
+ ]
50
+ }
@@ -0,0 +1,91 @@
1
+ # Resident container image: the @cloudflare/sandbox base (Ubuntu 22.04; already
2
+ # ships git, curl, jq, Node 24, npm, bun, cloudflared) plus GitHub tooling and
3
+ # a pool of unprivileged users.
4
+ # The tag MUST match the @cloudflare/sandbox version in package.json exactly
5
+ # (no 'latest' tag exists on the @next line) — bump both together.
6
+ FROM docker.io/cloudflare/sandbox:0.13.0-next.751.1
7
+
8
+ # Git identity only. The engine never uses the gh CLI (ops run git directly,
9
+ # tokens are minted via the GitHub REST API in the Worker/DO, and per-attach
10
+ # credentials use a repo-local `store` helper), so gh is deliberately NOT
11
+ # installed here — matching the resident system prompts and the tests that pin
12
+ # that claim. No system credential.helper is configured; each worktree sets its
13
+ # own per-attach helper.
14
+ RUN git config --system user.name "switchboard-resident" \
15
+ && git config --system user.email "switchboard-resident@users.noreply.github.com"
16
+
17
+ # The container is FOUR vCPUs shared by up to 16 threads (wrangler.jsonc).
18
+ # Node test runners size their worker pools from the CPU count the kernel
19
+ # reports — the HOST's, not the cgroup's — so an unconfigured `vitest`/`jest`
20
+ # run forks a dozen workers and thrashes (seen live on the 1 vCPU resident: a
21
+ # resident pinned at 100% CPU, 2.2 GiB). The runner pins stay at ONE worker per
22
+ # thread: the cores are for concurrent threads, not for one thread's test
23
+ # parallelism, and a repo's own config or CLI flags still win. The libuv pool
24
+ # is one thread per vCPU (it is per Node process; more would oversubscribe the
25
+ # cores once several threads run). These defaults inherit into every thread's
26
+ # `su … -c` shell (su without `-` keeps the environment). CI=1 makes runners
27
+ # non-interactive (no watch mode, no TTY progress spinners in captured
28
+ # output). The heap cap keeps one runaway process to an eighth of the 12 GiB
29
+ # so its neighbours keep theirs.
30
+ ENV CI=1 \
31
+ VITEST_MAX_WORKERS=1 VITEST_MAX_THREADS=1 VITEST_MAX_FORKS=1 VITEST_MIN_THREADS=1 VITEST_MIN_FORKS=1 \
32
+ UV_THREADPOOL_SIZE=4 \
33
+ NODE_OPTIONS=--max-old-space-size=1536
34
+
35
+ # pnpm + yarn: the base ships Node 24 + npm + bun but neither pnpm nor yarn, so a
36
+ # repo whose onboard table is detected as pnpm or yarn (resident-repos item 52 —
37
+ # every manager `detectCommands` can emit must exist here, or the table fails
38
+ # deterministically at provision) couldn't be provisioned (install → build).
39
+ # corepack is NOT on this base's PATH (unlike the older sandbox base), so both
40
+ # come from npm at build time. yarn here is classic (1.x); a Yarn Berry repo
41
+ # bootstraps through it via the `yarnPath` release it commits under
42
+ # `.yarn/releases`.
43
+ #
44
+ # EXACT versions, enforced by src/deploy/imagePins.test.ts. This line once read
45
+ # `pnpm@latest yarn@latest`, which made the pnpm major a property of the last
46
+ # image build: one rebuild moved it 10 -> 11 unannounced, and pnpm 11 no
47
+ # longer reads `package.json`'s `pnpm` field (overrides,
48
+ # patchedDependencies, onlyBuiltDependencies), so a repo keeping its settings
49
+ # there fails `--frozen-lockfile` with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
50
+ # pnpm is pinned to the 10 line ON PURPOSE: 10.x reads that field, and pnpm
51
+ # >=10 self-manages `packageManager`, so a repo pinning 11/12 still gets its
52
+ # own version — the pin is a floor, not a ceiling. Which means the version
53
+ # baked here is NOT necessarily the one that runs: a repo with a
54
+ # `packageManager` field downloads that version on first install (a repo
55
+ # pinning pnpm@10.10.0 gets exactly that), so provisioning needs the registry,
56
+ # not just the image. The grep asserts the pin took, so a resolution surprise
57
+ # fails the build instead of a provision.
58
+ RUN npm install -g pnpm@10.34.5 yarn@1.22.22 \
59
+ && pnpm --version | grep -qx '10\.34\.5' \
60
+ && yarn --version | grep -qx '1\.22\.22'
61
+
62
+ # squashfs-tools (`unsquashfs`): the SDK's presigned restore does not extract
63
+ # an archive, it MOUNTS it (squashfuse + fuse-overlayfs at the handle's dir)
64
+ # and leaves the `.sqsh` under /var/backups — resident-repos item 61. The
65
+ # resident extracts the archive onto its ext4 disk instead (the SDK's own
66
+ # local-dev method) so the mirror, the checkout and the deps store stay one
67
+ # filesystem: `rm -rf` works, hardlink views work, `du -x` is right. The
68
+ # Worker falls back to `cp -a` out of the mount while an older image lacks it.
69
+ # The build asserts the binary with the probe the extract script uses at run
70
+ # time (`command -v`): `unsquashfs -version` exits 1 on squashfs-tools 4.5 (this
71
+ # base) when no filesystem is named, and failed the 1.2.0 image build that way.
72
+ RUN apt-get update \
73
+ && apt-get install -y --no-install-recommends squashfs-tools \
74
+ && rm -rf /var/lib/apt/lists/* \
75
+ && command -v unsquashfs >/dev/null
76
+
77
+ # Agent commands never run as root (repo code runs unprivileged, one user per
78
+ # thread — docs/reference/specs/resident-repos.md). The SDK's exec has no user/uid option
79
+ # (verified against 0.13.0-next.751.1), so the container server itself runs as
80
+ # root and per-command demotion is done with `su -s /bin/bash workerN -c ...`
81
+ # (verified live: exec via su lands at uid 200N). Provision a pool of
82
+ # unprivileged users — one per resident cap slot — and shut the escalation
83
+ # paths back up:
84
+ # - sudo: not installed in the base image; purge defensively in case a later
85
+ # base bump adds it.
86
+ # - su back to root: root's password is locked, so workerN -> root fails.
87
+ # `su` itself must stay (it is the demotion mechanism, used only by root).
88
+ RUN set -eux; \
89
+ for i in $(seq 1 17); do useradd -m -u "$((2000 + i))" -s /bin/bash "worker$i"; chmod 700 "/home/worker$i"; done; \
90
+ if dpkg -s sudo >/dev/null 2>&1; then apt-get remove -y --purge sudo; fi; \
91
+ passwd -l root
@@ -0,0 +1,287 @@
1
+ // Residency garbage collection — the PURE decision logic, kept free of
2
+ // the Sandbox SDK and DO storage so it runs under plain-Node vitest
3
+ // (gc.test.ts). worker.ts feeds it what it observed (mirror refs, the GitHub
4
+ // pulls list, tree cleanliness, live views) and acts on the answers.
5
+ //
6
+ // Two mechanisms:
7
+ // 1. Event-triggered reclamation: after every refresh-cycle `fetch --prune`,
8
+ // each live thread binding's ref is classified — branch gone from the
9
+ // mirror, or its PR merged/closed on GitHub — and a finished ref's
10
+ // worktree is evicted right away instead of waiting out the idle TTL.
11
+ // This is a POLL folded into the existing alarm, not a webhook: the
12
+ // GitHub App is configured with webhooks OFF (events: [], no hook
13
+ // config), and the fetch that already runs every cycle carries the
14
+ // "branch deleted" signal for free.
15
+ // 2. Resident-level LRU eviction: an over-cap onboard with
16
+ // `evictColdest:true` (admin opt-in, default off) offboards the coldest
17
+ // eligible warm resident to make room instead of answering 429.
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // 1. Reclamation
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** What GitHub says about a head branch's pull requests. */
24
+ export interface PullSummary {
25
+ number: number;
26
+ state: "open" | "closed";
27
+ merged: boolean;
28
+ }
29
+
30
+ /** The fate of a thread's bound ref, as far as one refresh cycle can tell. */
31
+ export type RefFate =
32
+ | "gone" // the branch no longer exists in the mirror after `fetch --prune`
33
+ | "merged" // no open PR; the latest PR for the head was merged
34
+ | "closed" // no open PR; PRs exist but none merged
35
+ | "open" // a PR is still open — work in progress
36
+ | "no-pr" // branch exists, no PR ever opened for it
37
+ | "unknown"; // GitHub could not be asked (unreachable / non-200 / unparsable)
38
+
39
+ /** Defensive parse of `GET /repos/{o}/{r}/pulls?head=…&state=all`: a non-array
40
+ * is null (an error object must never read as "no PRs"), malformed elements
41
+ * are dropped rather than guessed at. */
42
+ export function parsePullsBody(body: unknown): PullSummary[] | null {
43
+ if (!Array.isArray(body)) return null;
44
+ const out: PullSummary[] = [];
45
+ for (const el of body) {
46
+ if (!el || typeof el !== "object") continue;
47
+ const { number, state, merged_at } = el as Record<string, unknown>;
48
+ if (typeof number !== "number" || (state !== "open" && state !== "closed")) continue;
49
+ out.push({ number, state, merged: typeof merged_at === "string" && merged_at.length > 0 });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** Parse `git for-each-ref --format='%(refname:short)' refs/heads/` output
55
+ * into the set of branches the mirror holds — ONE spawn feeding every
56
+ * binding's "branch gone?" membership test in the reclamation pass, instead
57
+ * of a `rev-parse --verify` container round-trip per ref. */
58
+ export function parseRefListing(stdout: string): Set<string> {
59
+ return new Set(
60
+ stdout
61
+ .split("\n")
62
+ .map((l) => l.trim())
63
+ .filter((l) => l !== ""),
64
+ );
65
+ }
66
+
67
+ /** Collapse a head's PR list into one fate. An open PR always wins (the
68
+ * branch is still in play); otherwise merged beats closed. */
69
+ export function pullsFate(pulls: readonly PullSummary[]): Extract<RefFate, "merged" | "closed" | "open" | "no-pr"> {
70
+ if (pulls.length === 0) return "no-pr";
71
+ if (pulls.some((p) => p.state === "open")) return "open";
72
+ if (pulls.some((p) => p.merged)) return "merged";
73
+ return "closed";
74
+ }
75
+
76
+ export interface ReclaimInput {
77
+ fate: RefFate;
78
+ /** The resident's default branch is never a finished ref. */
79
+ isDefaultRef: boolean;
80
+ /** Thread exec/read/write currently running on this binding. */
81
+ busy: number;
82
+ /** Tree cleanliness as the thread user; `null` = the runtime is down, so
83
+ * the tree is already gone with the disk and there is nothing to preserve. */
84
+ clean: boolean | null;
85
+ }
86
+
87
+ /** The PR that decided the fate (for the audit trail): the open one, else the
88
+ * merged one, else the newest closed one; null when there is none. */
89
+ export function decisivePull(pulls: readonly PullSummary[]): PullSummary | null {
90
+ return pulls.find((p) => p.state === "open") ?? pulls.find((p) => p.merged) ?? pulls[0] ?? null;
91
+ }
92
+
93
+ export type ReclaimWhy =
94
+ | "default-ref"
95
+ | "pr-open"
96
+ | "no-pr"
97
+ | "fate-unknown"
98
+ | "busy"
99
+ | "re-attached"
100
+ | "dirty"
101
+ | Extract<RefFate, "gone" | "merged" | "closed">;
102
+
103
+ /** Evict this binding now? A finished ref (gone/merged/closed) is reclaimed
104
+ * only when nothing runs on it and its tree is provably clean — a merged PR
105
+ * can still have unpushed local commits, and destroying work on a signal is
106
+ * exactly what this must never do. Keeps are named so the pass is auditable. */
107
+ export function reclaimDecision(input: ReclaimInput): { reclaim: boolean; why: ReclaimWhy } {
108
+ if (input.isDefaultRef) return { reclaim: false, why: "default-ref" };
109
+ switch (input.fate) {
110
+ case "open":
111
+ return { reclaim: false, why: "pr-open" };
112
+ case "no-pr":
113
+ return { reclaim: false, why: "no-pr" };
114
+ case "unknown":
115
+ return { reclaim: false, why: "fate-unknown" };
116
+ }
117
+ if (input.busy > 0) return { reclaim: false, why: "busy" };
118
+ if (input.clean === false) return { reclaim: false, why: "dirty" };
119
+ return { reclaim: true, why: input.fate };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // 1b. Test overrides — lowering the effective cap / LRU floor for live checks
124
+ // ---------------------------------------------------------------------------
125
+ //
126
+ // Why this exists: the over-cap behavior (429, `rejected[]`, the eviction and
127
+ // its 1 h floor) is only reachable when the registry is FULL, and the
128
+ // production cap (RESIDENT_CAP) is sized for the team's real fleet. Proving
129
+ // item 46 the first time needed two deploys (cap 8→2, then →6) plus an hour of
130
+ // clock time for the floor — and becomes impossible once six real residents
131
+ // exist, because lowering the compiled cap below the fleet size would refuse
132
+ // the team's own onboards. This is the resident's usual fault-injection
133
+ // pattern (backdate-thread, force-down, force-onboarding …) applied to the
134
+ // two limits: an admin-only debug op stores an override in the registry DO;
135
+ // the registry enforces min(override, constant).
136
+ //
137
+ // Guard rails, by construction:
138
+ // - admin scope only (the /debug op is not in READ_DEBUG_OPS);
139
+ // - an override can only LOWER a limit — never above the compiled constant,
140
+ // so it can never become a back door past wrangler's max_instances;
141
+ // - deploy-scoped: the record carries the build marker it was set under and
142
+ // is ignored by any other build, so a forgotten test cap cannot outlive
143
+ // the session that set it;
144
+ // - visible: GET /residents reports the effective `cap` plus `capDefault`
145
+ // and the active `testOverrides`, so a dashboard never mistakes a test
146
+ // cap for the real one.
147
+
148
+ /** The compiled limits the overrides may lower. */
149
+ export interface LimitDefaults {
150
+ cap: number;
151
+ floorS: number;
152
+ }
153
+
154
+ /** What the registry DO stores. `build` is the identity of the deploy that
155
+ * wrote it (`buildId`: the commit plus that build's timestamp); a different
156
+ * build ignores the record. */
157
+ export interface StoredTestOverrides {
158
+ cap?: number;
159
+ floorS?: number;
160
+ setAt: string;
161
+ build: string;
162
+ }
163
+
164
+ export type ParsedTestOverrides =
165
+ { overrides: { cap?: number; floorS?: number } } | { clear: true } | { error: string };
166
+
167
+ /** Parse the `/debug {"op":"set-test-overrides", cap?, floorS?}` body.
168
+ * Neither field → clear. Each present field must be an integer within
169
+ * [1, cap] / [0, floorS] of the compiled defaults — overrides only lower. */
170
+ export function parseTestOverrides(body: Record<string, unknown>, defaults: LimitDefaults): ParsedTestOverrides {
171
+ const out: { cap?: number; floorS?: number } = {};
172
+ if (body.cap !== undefined) {
173
+ if (typeof body.cap !== "number" || !Number.isInteger(body.cap) || body.cap < 1 || body.cap > defaults.cap) {
174
+ return {
175
+ error: `cap must be an integer between 1 and ${defaults.cap} (the compiled RESIDENT_CAP); overrides only lower it`,
176
+ };
177
+ }
178
+ out.cap = body.cap;
179
+ }
180
+ if (body.floorS !== undefined) {
181
+ if (
182
+ typeof body.floorS !== "number" ||
183
+ !Number.isInteger(body.floorS) ||
184
+ body.floorS < 0 ||
185
+ body.floorS > defaults.floorS
186
+ ) {
187
+ return {
188
+ error: `floorS must be an integer between 0 and ${defaults.floorS} (the compiled LRU_FLOOR_S); overrides only lower it`,
189
+ };
190
+ }
191
+ out.floorS = body.floorS;
192
+ }
193
+ if (out.cap === undefined && out.floorS === undefined) return { clear: true };
194
+ return { overrides: out };
195
+ }
196
+
197
+ export interface EffectiveLimits extends LimitDefaults {
198
+ /** The record in force, or null when none (or a stale one) applies. */
199
+ override: StoredTestOverrides | null;
200
+ /** Set when a stored record was ignored, naming why. */
201
+ ignored?: string;
202
+ }
203
+
204
+ /** The limits the registry enforces right now: the compiled defaults, lowered
205
+ * by an override written under THIS build. Values are clamped to the
206
+ * defaults even when stored (a later deploy may have lowered the constant). */
207
+ export function effectiveLimits(
208
+ stored: StoredTestOverrides | undefined,
209
+ build: string,
210
+ defaults: LimitDefaults,
211
+ ): EffectiveLimits {
212
+ if (!stored) return { ...defaults, override: null };
213
+ if (stored.build !== build) return { ...defaults, override: null, ignored: `stale-build ${stored.build}` };
214
+ return {
215
+ cap: Math.min(defaults.cap, stored.cap ?? defaults.cap),
216
+ floorS: Math.min(defaults.floorS, stored.floorS ?? defaults.floorS),
217
+ override: stored,
218
+ };
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // 2. LRU eviction
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /** One resident as the LRU picker sees it: registry record + live view.
226
+ * `state: "unknown"` / `inFlight: null` model a live view that failed. */
227
+ export interface ResidentView {
228
+ resource: string;
229
+ state: string;
230
+ inFlight: number | null;
231
+ onboardedAt: string;
232
+ provisionedAt: string | null;
233
+ threads: ReadonlyArray<{ lastAttachAt: string; evicted: boolean; user: string }>;
234
+ }
235
+
236
+ export interface EvictionPick {
237
+ candidate: { resource: string; lastActivityAt: string } | null;
238
+ rejected: Array<{ resource: string; why: string }>;
239
+ }
240
+
241
+ /** The newest thing that happened on the resident: an attach (evicted
242
+ * bindings count — the run happened) or, never attached, provisioning. */
243
+ export function lastActivityAt(view: ResidentView): string {
244
+ let latest = view.provisionedAt ?? view.onboardedAt;
245
+ for (const t of view.threads) if (t.lastAttachAt > latest) latest = t.lastAttachAt;
246
+ return latest;
247
+ }
248
+
249
+ /** Choose the coldest resident that is safe to offboard: `warm`, nothing in
250
+ * flight, no LIVE worktree (a live tree may hold uncommitted work), and its
251
+ * last activity older than `floorMs` (so a repo used minutes ago is never
252
+ * evicted to make room). Coldest = oldest last activity; ties on name. */
253
+ export function pickEvictionCandidate(views: readonly ResidentView[], nowMs: number, floorMs: number): EvictionPick {
254
+ const rejected: EvictionPick["rejected"] = [];
255
+ const eligible: Array<{ resource: string; lastActivityAt: string }> = [];
256
+ for (const v of views) {
257
+ if (v.state !== "warm") {
258
+ rejected.push({ resource: v.resource, why: `state ${v.state}` });
259
+ continue;
260
+ }
261
+ if (v.inFlight === null) {
262
+ rejected.push({ resource: v.resource, why: "in-flight count unknown" });
263
+ continue;
264
+ }
265
+ if (v.inFlight > 0) {
266
+ rejected.push({ resource: v.resource, why: `${v.inFlight} operation(s) in flight` });
267
+ continue;
268
+ }
269
+ const live = v.threads.filter((t) => !t.evicted && t.user).length;
270
+ if (live > 0) {
271
+ rejected.push({ resource: v.resource, why: `${live} live worktree(s)` });
272
+ continue;
273
+ }
274
+ const last = lastActivityAt(v);
275
+ const idleMs = nowMs - Date.parse(last);
276
+ if (!(idleMs >= floorMs)) {
277
+ rejected.push({
278
+ resource: v.resource,
279
+ why: `active ${Math.round(idleMs / 60_000)}m ago (floor ${Math.round(floorMs / 60_000)}m)`,
280
+ });
281
+ continue;
282
+ }
283
+ eligible.push({ resource: v.resource, lastActivityAt: last });
284
+ }
285
+ eligible.sort((a, b) => a.lastActivityAt.localeCompare(b.lastActivityAt) || a.resource.localeCompare(b.resource));
286
+ return { candidate: eligible[0] ?? null, rejected };
287
+ }
@@ -0,0 +1,11 @@
1
+ // The one Node API the resident Worker uses (docs/reference/specs/tracing.md item 19):
2
+ // `AsyncLocalStorage` scopes a request's step trace to that request. Workers
3
+ // provide it under `nodejs_compat`; the Worker's tsconfig types only
4
+ // `@cloudflare/workers-types`, so the two members used are declared here
5
+ // rather than pulling all of `@types/node` into the Worker's own check.
6
+ declare module "node:async_hooks" {
7
+ export class AsyncLocalStorage<T> {
8
+ run<R>(store: T, callback: () => R): R;
9
+ getStore(): T | undefined;
10
+ }
11
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "switchboard-resident-worker",
3
+ "private": true,
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=22"
7
+ },
8
+ "scripts": {
9
+ "check:image": "docker build --quiet .",
10
+ "deploy": "node preflight.mjs && node ../bin/build-stamp.mjs",
11
+ "preflight": "node preflight.mjs",
12
+ "dev": "wrangler dev",
13
+ "typecheck": "tsc --noEmit -p tsconfig.json",
14
+ "test": "vitest run",
15
+ "verify": "npm --prefix ../.. run --silent deploy:gen && npm run typecheck && npm test",
16
+ "typegen": "wrangler types",
17
+ "tail": "wrangler tail",
18
+ "secrets": "npm --prefix ../.. run --silent cli -- deploy secrets resident"
19
+ },
20
+ "dependencies": {
21
+ "@cloudflare/sandbox": "0.13.0-next.751.1"
22
+ },
23
+ "devDependencies": {
24
+ "@cloudflare/workers-types": "^5.20260904.1",
25
+ "typescript": "^5.9.3",
26
+ "vitest": "^5.0.0",
27
+ "wrangler": "^4.129.0"
28
+ }
29
+ }