@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.
- package/LICENSE +201 -0
- package/README.md +17 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- 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
|
+
}
|