@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,326 @@
1
+ /** The resident's content-addressed dependency store (docs/reference/specs/resident-repos.md
2
+ * item 59), kept pure and dependency-free so it is unit-testable from
3
+ * src/ and imported by the resident Worker like residentDepCache — the
4
+ * tested code IS the shipped code.
5
+ *
6
+ * Background: dependencies used to be installed IN PLACE and PER
7
+ * CONSUMER — provisioning into the checkout, the refresh cycle into the
8
+ * checkout again on every lockfile change, a thread whose branch lockfile
9
+ * differed from the checkout's into its own gigabyte tree. Three install
10
+ * sites, three budgets, none aware of the others: a refresh cycle whose
11
+ * install outlived its budget spiralled (the next cycle's clean raced the
12
+ * orphaned installer), and an attach could die mid-install; both faults
13
+ * lived in the seams. A resident ran dozens of installs a day for a dozen
14
+ * distinct lockfile keys, because "older than the default branch's lockfile"
15
+ * and "different from it" were the same comparison.
16
+ *
17
+ * Shape: one entry per lockfile key (the hash of the committed
18
+ * lockfile — a pure function of the commit) under DEPS_STORE_DIR, holding
19
+ * the tree's top-level `node_modules` exactly as the install produced it.
20
+ * An entry is IMMUTABLE once complete (`.complete` written last, inside the
21
+ * entry so the atomic rename carries it) — Flyweight: every consumer shares
22
+ * one tree by identity through hardlink views, and nothing ever writes into
23
+ * a completed entry. One primitive materializes a key (hit → the path;
24
+ * in-flight → join that promise; miss → install into a private scratch
25
+ * clone, MOVE its node_modules into a staging dir, rename to the entry,
26
+ * mark). The installer runs OUTSIDE the mirror lock — it reads the mirror's
27
+ * objects through a `--shared` clone and touches no consumer's tree — so a
28
+ * full install no longer holds every attach behind it.
29
+ *
30
+ * Eviction: the store is a cache. Protected keys (the checkout's,
31
+ * every live binding's, every install in flight) never go; among the rest,
32
+ * debris (incomplete, nothing in flight) first, then coldest `.used` first,
33
+ * keeping at most DEPS_STORE_MAX_UNREFERENCED warm spares. */
34
+
35
+ import { shellQuote } from "./shellQuote.js";
36
+
37
+ export const DEPS_STORE_DIR = "/workspace/deps";
38
+
39
+ /** A lockfile key is the lowercase hex sha256 the resident computes; nothing
40
+ * else may become a path segment under the store. */
41
+ const LOCKFILE_KEY_RE = /^[0-9a-f]{64}$/;
42
+
43
+ function assertKey(key: string): void {
44
+ if (!LOCKFILE_KEY_RE.test(key)) throw new Error(`deps store: not a lockfile key: ${JSON.stringify(key)}`);
45
+ }
46
+
47
+ export function depsEntryPath(key: string, storeDir: string = DEPS_STORE_DIR): string {
48
+ assertKey(key);
49
+ return `${storeDir}/${key}`;
50
+ }
51
+
52
+ export function depsCompletePath(key: string, storeDir: string = DEPS_STORE_DIR): string {
53
+ return `${depsEntryPath(key, storeDir)}/.complete`;
54
+ }
55
+
56
+ /** mtime of this file = when a consumer last materialized from the entry
57
+ * (touched on every hit); the LRU clock. */
58
+ export function depsUsedPath(key: string, storeDir: string = DEPS_STORE_DIR): string {
59
+ return `${depsEntryPath(key, storeDir)}/.used`;
60
+ }
61
+
62
+ /** Per-attempt private dirs: the scratch clone the install runs in and the
63
+ * staging dir the finished node_modules moves into. `attempt` is unique per
64
+ * call (a DO reset mid-install leaves one behind as a named leftover). */
65
+ export function depsScratchPath(attempt: string, storeDir: string = DEPS_STORE_DIR): string {
66
+ return `${storeDir}/.scratch-${attempt}`;
67
+ }
68
+
69
+ export function depsStagingPath(key: string, attempt: string, storeDir: string = DEPS_STORE_DIR): string {
70
+ assertKey(key);
71
+ return `${storeDir}/.staging-${key}-${attempt}`;
72
+ }
73
+
74
+ export type DepsMaterializationPlan =
75
+ { action: "hit" } | { action: "join" } | { action: "restore" } | { action: "install" };
76
+
77
+ /** A complete entry wins over everything (a stale in-flight memo after a DO
78
+ * reset must never make a caller wait for an install that is not running);
79
+ * an install already running for the key is joined, never duplicated; a
80
+ * recorded entry backup (item 61) is restored before anything is
81
+ * installed — the container downloads a finished tree instead of building
82
+ * one — and only a key with neither installs. */
83
+ export function planDepsMaterialization(input: {
84
+ complete: boolean;
85
+ inFlight: boolean;
86
+ /** An entry backup is recorded for the key (`depsBackupStorageKey`). */
87
+ backup?: boolean;
88
+ }): DepsMaterializationPlan {
89
+ if (input.complete) return { action: "hit" };
90
+ if (input.inFlight) return { action: "join" };
91
+ if (input.backup) return { action: "restore" };
92
+ return { action: "install" };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Entry backups (item 61): content-addressed snapshots of the store
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** The checkout snapshot leaves out its top-level node_modules: since item 59
100
+ * that directory is a hardlink view of an immutable store entry, and the
101
+ * entry has its own backup (below). Nested node_modules (a workspace package's
102
+ * own) stay in — small, and the view mechanism does not cover them. The
103
+ * pattern is anchored at the archive root (mksquashfs wildcard semantics:
104
+ * a bare name matches only there; `...`-prefixed patterns match anywhere). */
105
+ export const CHECKOUT_SNAPSHOT_EXCLUDES: readonly string[] = ["node_modules"];
106
+
107
+ /** One backup per lockfile key, taken ONCE right after the entry is committed
108
+ * (install or adoption) and never again: the entry is immutable, so its
109
+ * archive is too. Recorded on the DO under this prefix, by key. */
110
+ export const DEPS_BACKUP_KEY_PREFIX = "resident:depsBackup:";
111
+
112
+ export function depsBackupStorageKey(key: string): string {
113
+ assertKey(key);
114
+ return `${DEPS_BACKUP_KEY_PREFIX}${key}`;
115
+ }
116
+
117
+ /** Entry backups are a cache with a long shelf life: a key stays warm for as
118
+ * long as main keeps its lockfile, and the wake path counts on finding the
119
+ * warm key's archive. 180 days; an expired archive fails the restore and the
120
+ * local installer runs, re-recording a fresh backup — never a stranded key.
121
+ * The snapshot handles keep their own TTL (SNAPSHOT_TTL_S in the Worker). */
122
+ export const DEPS_BACKUP_TTL_S = 180 * 24 * 60 * 60;
123
+
124
+ /** After a store sweep: the backups whose entries the sweep just evicted go
125
+ * too — a spare nothing references on disk is a spare nothing will wake
126
+ * into either, and the archive is re-taken on the next install of that key.
127
+ * Never a key still in the store, and never a key without a record. */
128
+ export function depsBackupsToDrop(input: {
129
+ evictedKeys: readonly string[];
130
+ backedUpKeys: readonly string[];
131
+ }): string[] {
132
+ const backedUp = new Set(input.backedUpKeys);
133
+ return input.evictedKeys.filter((k) => backedUp.has(k));
134
+ }
135
+
136
+ /** Distinct keys may install in parallel up to the core count — `nproc`
137
+ * read once per incarnation — never a constant. Two installs on one core
138
+ * each take twice as long, so on today's 1 vCPU this is 1 by arithmetic.
139
+ * Anything unreadable is 1: serial is the safe floor. */
140
+ export function depsInstallSemaphoreSize(nprocStdout: string | null): number {
141
+ const n = Number.parseInt((nprocStdout ?? "").trim(), 10);
142
+ return Number.isFinite(n) && n >= 1 ? n : 1;
143
+ }
144
+
145
+ /** The scratch tree the install runs in: a `--shared` clone (objects via
146
+ * alternates into the mirror — no second copy of history, seconds not
147
+ * minutes) checked out detached at the key's sha. Root creates it; the
148
+ * caller chowns it to the build user before the install. */
149
+ export function depsScratchCloneArgv(input: { mirrorDir: string; scratchDir: string; sha: string }): string[] {
150
+ const { mirrorDir, scratchDir, sha } = input;
151
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`deps store: not a sha: ${JSON.stringify(sha)}`);
152
+ const script = [
153
+ `git clone --shared --no-checkout --quiet ${shellQuote(mirrorDir)} ${shellQuote(scratchDir)}`,
154
+ `git -C ${shellQuote(scratchDir)} checkout --detach --quiet ${sha}`,
155
+ ].join(" && ");
156
+ return ["sh", "-c", script];
157
+ }
158
+
159
+ /** The key of a commit with NO lockfile: `git ls-tree <sha> -- <candidates>`
160
+ * prints nothing, and sha256 of no input is this constant. */
161
+ export const NO_LOCKFILE_KEY = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
162
+
163
+ /** Harden the scratch tree's node_modules before it becomes an entry, as
164
+ * root: every file loses owner write, so a consumer's build fails EACCES on
165
+ * a write through a shared inode instead of mutating every other consumer's
166
+ * tree (item 59). A tree with no node_modules after the install is a failed
167
+ * install when the commit HAS a lockfile (there was something to install)
168
+ * and an empty entry when it has none — a repo whose install command is a
169
+ * no-op (`true`, a terraform tree) keys to NO_LOCKFILE_KEY and gets an empty
170
+ * node_modules, owned by the build user like an installed one, so every
171
+ * consumer's view links an empty directory and nothing else changes. Without
172
+ * the empty case, such a repo's rebuild fails at this step (`find:
173
+ * '…/node_modules': No such file or directory`). */
174
+ export function depsHardenScript(input: {
175
+ scratchDir: string;
176
+ /** chown spec for the created empty directory (`user:group`) — the build user's. */
177
+ owner: string;
178
+ emptyOk: boolean;
179
+ }): string {
180
+ const nm = shellQuote(`${input.scratchDir}/node_modules`);
181
+ const absent = input.emptyOk
182
+ ? `mkdir ${nm} && chown ${input.owner} ${nm}`
183
+ : `echo "install produced no node_modules in ${input.scratchDir}" >&2; exit 1`;
184
+ return [`set -e`, `test -d ${nm} || { ${absent}; }`, `find ${nm} -type f -perm -u+w -exec chmod u-w {} +`].join("\n");
185
+ }
186
+
187
+ /** Commit an install to the store, as root, in the order that makes the entry
188
+ * either absent or complete and never half-there:
189
+ * 1. the scratch tree must hold a node_modules — depsHardenScript ran first
190
+ * and either found one, created the empty one a lockfile-less commit is
191
+ * allowed, or failed; a tree without one here is a caller bug;
192
+ * 2. MOVE it into the staging dir (same filesystem: a rename, not a copy);
193
+ * 3. rename staging → entry — atomic; a racer that finds the entry already
194
+ * complete (another attempt won, or a DO reset re-ran the install) drops
195
+ * its own staging and keeps the winner;
196
+ * 4. write `.complete` LAST, and `.used` so the LRU clock starts;
197
+ * 5. remove the scratch clone. */
198
+ export function depsStoreCommitScript(input: {
199
+ scratchDir: string;
200
+ stagingDir: string;
201
+ entryDir: string;
202
+ completePath: string;
203
+ /** Adoption (the scratch IS the warm checkout — a pre-store disk or a
204
+ * fresh restore): move its node_modules in, but leave the tree itself. */
205
+ keepScratch?: boolean;
206
+ }): string {
207
+ const scratchNm = shellQuote(`${input.scratchDir}/node_modules`);
208
+ const staging = shellQuote(input.stagingDir);
209
+ const stagingNm = shellQuote(`${input.stagingDir}/node_modules`);
210
+ const entry = shellQuote(input.entryDir);
211
+ const complete = shellQuote(input.completePath);
212
+ const used = shellQuote(`${input.entryDir}/.used`);
213
+ return [
214
+ `set -e`,
215
+ `test -d ${scratchNm} || { echo "install produced no node_modules in ${input.scratchDir}" >&2; exit 1; }`,
216
+ `rm -rf ${staging}`,
217
+ `mkdir ${staging}`,
218
+ `mv ${scratchNm} ${stagingNm}`,
219
+ // An entry dir WITHOUT its marker is crash debris (the shell died between
220
+ // the rename and the touch): remove it, or the rename below would nest
221
+ // the new staging inside it and the touch would mark the pair complete.
222
+ `if test -f ${complete}; then rm -rf ${staging}; else rm -rf ${entry}; mv ${staging} ${entry}; fi`,
223
+ `touch ${complete} ${used}`,
224
+ ...(input.keepScratch ? [] : [`rm -rf ${shellQuote(input.scratchDir)}`]),
225
+ ].join("\n");
226
+ }
227
+
228
+ export interface DepsStoreEntry {
229
+ key: string;
230
+ complete: boolean;
231
+ kib: number;
232
+ /** `.used` mtime, epoch seconds; 0 when absent. */
233
+ usedAtS: number;
234
+ }
235
+
236
+ export interface DepsStoreListing {
237
+ entries: DepsStoreEntry[];
238
+ /** `.scratch-*` / `.staging-*` dirs: attempts a DO reset or a failure left behind. */
239
+ leftovers: string[];
240
+ }
241
+
242
+ /** One fork: every entry (a 64-hex dir) as a tagged line with its complete
243
+ * flag, `du -sk` size and `.used` mtime; every dot-dir as a leftover line.
244
+ * A missing store dir is an empty listing, never an error. */
245
+ export function depsStoreListScript(storeDir: string = DEPS_STORE_DIR): string {
246
+ const dir = shellQuote(storeDir);
247
+ return [
248
+ `test -d ${dir} || exit 0`,
249
+ `cd ${dir}`,
250
+ `for e in *; do`,
251
+ ` [ -d "$e" ] || continue`,
252
+ ` case "$e" in *[!0-9a-f]*) continue;; esac`,
253
+ ` [ ${"${#e}"} -eq 64 ] || continue`,
254
+ ` c=0; [ -f "$e/.complete" ] && c=1`,
255
+ ` k=$(du -sk "$e" 2>/dev/null | cut -f1); [ -n "$k" ] || k=0`,
256
+ ` u=$(stat -c %Y "$e/.used" 2>/dev/null); [ -n "$u" ] || u=0`,
257
+ ` echo "entry=$e complete=$c kib=$k used=$u"`,
258
+ `done`,
259
+ `for l in .scratch-* .staging-*; do [ -e "$l" ] && echo "leftover=${storeDir}/$l"; done`,
260
+ `exit 0`,
261
+ ].join("\n");
262
+ }
263
+
264
+ export function parseDepsStoreListing(stdout: string): DepsStoreListing {
265
+ const entries: DepsStoreEntry[] = [];
266
+ const leftovers: string[] = [];
267
+ for (const raw of stdout.split("\n")) {
268
+ const line = raw.trim();
269
+ let m: RegExpExecArray | null;
270
+ if ((m = /^entry=([0-9a-f]{64}) complete=([01]) kib=(\d+) used=(\d+)$/.exec(line))) {
271
+ entries.push({ key: m[1], complete: m[2] === "1", kib: Number(m[3]), usedAtS: Number(m[4]) });
272
+ } else if ((m = /^leftover=(.+)$/.exec(line))) {
273
+ leftovers.push(m[1]);
274
+ }
275
+ }
276
+ return { entries, leftovers };
277
+ }
278
+
279
+ /** Complete unreferenced entries kept as warm spares beyond the protected
280
+ * set. 1: the item-55 sizing (10 hardlinked + 1 installing trees with the
281
+ * reserve on the 16 GB instance) leaves room for about one ~2 GiB entry
282
+ * beyond the checkout's own; a second spare would be paid for in refused
283
+ * attaches. Raise with the instance, never by feel. */
284
+ export const DEPS_STORE_MAX_UNREFERENCED = 1;
285
+
286
+ /** Eviction candidates coldest first. Never a protected key (the checkout's,
287
+ * a live binding's, an install in flight). Debris — an incomplete entry
288
+ * with nothing in flight for it — is always first: it is half an install
289
+ * nobody will finish. */
290
+ export function orderDepsEviction(input: {
291
+ entries: readonly DepsStoreEntry[];
292
+ protectedKeys: ReadonlySet<string>;
293
+ }): DepsStoreEntry[] {
294
+ const candidates = input.entries.filter((e) => !input.protectedKeys.has(e.key));
295
+ const debris = candidates.filter((e) => !e.complete);
296
+ const complete = candidates.filter((e) => e.complete).sort((a, b) => a.usedAtS - b.usedAtS);
297
+ return [...debris, ...complete];
298
+ }
299
+
300
+ export interface DepsEvictionPlan {
301
+ /** Absolute paths to `rm -rf`, in order. Empty → nothing to fork for. */
302
+ remove: string[];
303
+ /** Keys that stay, for the log. */
304
+ keep: string[];
305
+ }
306
+
307
+ export function planDepsEviction(input: {
308
+ entries: readonly DepsStoreEntry[];
309
+ leftovers: readonly string[];
310
+ protectedKeys: ReadonlySet<string>;
311
+ maxUnreferenced?: number;
312
+ storeDir?: string;
313
+ }): DepsEvictionPlan {
314
+ const max = input.maxUnreferenced ?? DEPS_STORE_MAX_UNREFERENCED;
315
+ const storeDir = input.storeDir ?? DEPS_STORE_DIR;
316
+ const ordered = orderDepsEviction({ entries: input.entries, protectedKeys: input.protectedKeys });
317
+ const debris = ordered.filter((e) => !e.complete);
318
+ const spares = ordered.filter((e) => e.complete);
319
+ // Coldest first in `spares`; keep the warmest `max`.
320
+ const evictSpares = spares.slice(0, Math.max(0, spares.length - max));
321
+ const remove = [...debris, ...evictSpares].map((e) => depsEntryPath(e.key, storeDir));
322
+ remove.push(...input.leftovers);
323
+ const removed = new Set([...debris, ...evictSpares].map((e) => e.key));
324
+ const keep = input.entries.filter((e) => !removed.has(e.key)).map((e) => e.key);
325
+ return { remove, keep };
326
+ }
@@ -0,0 +1,48 @@
1
+ /** The force-detach decision of the resident Worker's `detachThread`
2
+ * (deploy/cloudflare-resident/worker.ts), kept pure and dependency-free so
3
+ * it is unit-testable from src/ and imported across packages by the resident
4
+ * Worker (like shellQuote) — the tested code IS the shipped code.
5
+ *
6
+ * Background: a hard stop makes the bot drop its `/exec` fetch
7
+ * and call `/detach {force:true}`, but the command keeps running in the
8
+ * container, so a busy guard alone would keep the pool user until the hourly
9
+ * sweep. Force therefore kills the thread's processes first; a non-force
10
+ * detach keeps the plain busy guard (never yank a tree from under a live
11
+ * command a concurrent run still cares about). */
12
+
13
+ export type ForceDetachPlan =
14
+ /** Nothing in flight — go straight to the eviction path. */
15
+ | { action: "proceed" }
16
+ /** Kept; `reason` is what the caller reports. */
17
+ | { action: "refuse"; reason: string }
18
+ /** Kill every process owned by `user`, then wait for the in-flight count to drain. */
19
+ | { action: "kill"; user: string; inFlight: number };
20
+
21
+ export function planForceDetach(input: {
22
+ force: boolean;
23
+ inFlight: number;
24
+ /** The binding's pool user. */
25
+ user: string;
26
+ /** The Worker's pool (`THREAD_USERS`): the only users a kill may target. */
27
+ poolUsers: readonly string[];
28
+ }): ForceDetachPlan {
29
+ const { force, inFlight, user, poolUsers } = input;
30
+ if (inFlight <= 0) return { action: "proceed" };
31
+ if (!force) return { action: "refuse", reason: busyReason(inFlight) };
32
+ // The kill runs `kill -9 -1` as this user — it must be a pool user and
33
+ // nothing else (never root, never empty, never the build user), or the
34
+ // blast radius is the whole container.
35
+ if (!user || !poolUsers.includes(user)) {
36
+ return { action: "refuse", reason: `${busyReason(inFlight)}; refusing to kill: "${user}" is not a pool user` };
37
+ }
38
+ return { action: "kill", user, inFlight };
39
+ }
40
+
41
+ export function busyReason(inFlight: number): string {
42
+ return `busy: ${inFlight} operation(s) in flight on this thread — kept`;
43
+ }
44
+
45
+ /** After the kill, the in-flight count did not drain within the bound. */
46
+ export function busyAfterKillReason(inFlight: number): string {
47
+ return `busy after kill: ${inFlight} op(s) still in flight — kept`;
48
+ }
@@ -0,0 +1,107 @@
1
+ // A full container disk, named for what it is (docs/reference/specs/resident-repos.md
2
+ // item 54). Pure decisions the resident Worker (deploy/cloudflare-resident/
3
+ // worker.ts) imports; no I/O, no clock — `now` is an input.
4
+ //
5
+ // Why this exists: when a resident's disk fills, the refresh cycle's
6
+ // credential-file write fails with ENOSPC. Recorded as
7
+ // `degraded(github-unreachable: …)` — a reason on the bot's serviceable
8
+ // allow-list — every run attaches, fails at git-setup with git's errno-less
9
+ // `failed to write new configuration file /etc/gitconfig.lock` (exit 4), and
10
+ // falls back cold with a message that reads like a lock bug, and nothing frees
11
+ // the disk. Two facts fix that: the failure is classified `disk-full` (never
12
+ // serviceable, so the bot skips the attach and the card names the disk), and
13
+ // the resident recycles its container — the disk is a cache; the next alarm
14
+ // restores mirror + checkout from R2 — once nothing live would be lost.
15
+
16
+ /** The errno wording tools print for ENOSPC: Node's `ENOSPC` code and libc's
17
+ * strerror text (git, cp, tar, pnpm all pass it through). A message carrying
18
+ * either is decisive on its own. */
19
+ const DISK_FULL_SIGNATURE = /\bENOSPC\b|no space left on device/i;
20
+
21
+ export function isDiskFullMessage(message: string): boolean {
22
+ return DISK_FULL_SIGNATURE.test(message);
23
+ }
24
+
25
+ /** Below this much free space the disk is "full" for the resident's purposes:
26
+ * 128 MiB is less than one checkout of a mid-sized repository (tens of MB)
27
+ * plus git's pack/lock scratch, so a fetch or a `worktree add` cannot
28
+ * complete — waiting for a literal 0 would only change which step dies. The
29
+ * probe is consulted only after a step has already failed, and only when the
30
+ * step's own message did not carry the errno (`git config`'s write_error
31
+ * reports no errno: reproduced on a full Linux tmpfs, exit 4). */
32
+ export const DISK_FULL_FREE_KIB = 128 * 1024;
33
+
34
+ /** POSIX `df` in 1 KiB blocks on the workspace mount: one header, one data row,
35
+ * no locale or column-wrapping surprises (`-P`). */
36
+ export const DF_FREE_ARGV = ["df", "-Pk", "/workspace"] as const;
37
+
38
+ /** The three size columns of `df -Pk <path>` output (1024-blocks, Used,
39
+ * Available), in KiB; `null` when there is no data row or a column is not a
40
+ * number — unknown is never reported as 0. The disk budget
41
+ * (`residentDiskBudget.ts`) samples all three; the disk-full classifier below
42
+ * reads only `free`. */
43
+ export function parseDfKiB(stdout: string): { totalKiB: number; usedKiB: number; freeKiB: number } | null {
44
+ const rows = stdout.split("\n").filter((l) => l.trim() !== "");
45
+ if (rows.length < 2) return null;
46
+ const cols = rows[1].trim().split(/\s+/);
47
+ if (cols.length < 4 || !cols.slice(1, 4).every((c) => /^\d+$/.test(c))) return null;
48
+ return { totalKiB: Number(cols[1]), usedKiB: Number(cols[2]), freeKiB: Number(cols[3]) };
49
+ }
50
+
51
+ /** The "Available" column alone — what the disk-full classifier compares to
52
+ * the floor; `null` when unknown, because 0 would classify every failure as
53
+ * disk-full. */
54
+ export function parseDfFreeKiB(stdout: string): number | null {
55
+ return parseDfKiB(stdout)?.freeKiB ?? null;
56
+ }
57
+
58
+ const DISK_FULL_PREFIX = "disk-full:";
59
+
60
+ /** The `degraded` reason: the step, its own message verbatim, and — when the
61
+ * probe answered — the free space, so the reason carries its evidence. */
62
+ export function diskFullReason(input: { step: string; message: string; freeKiB: number | null }): string {
63
+ const probe = input.freeKiB === null ? "" : ` (/workspace: ${input.freeKiB} KiB free)`;
64
+ return `${DISK_FULL_PREFIX} ${input.step} ${input.message}${probe}`;
65
+ }
66
+
67
+ export function isDiskFullReason(reason: string): boolean {
68
+ return reason.startsWith(DISK_FULL_PREFIX);
69
+ }
70
+
71
+ /** At most one recycle per hour. A disk that fills again within the hour is
72
+ * not garbage — it is a working set the instance disk cannot hold, and a
73
+ * second recycle would only throw away another restore. */
74
+ export const DISK_FULL_RECYCLE_COOLDOWN_MS = 60 * 60_000;
75
+
76
+ export type DiskFullRecovery = { action: "recycle" } | { action: "wait"; why: string };
77
+
78
+ /** Whether a disk-full resident may stop its container now. The disk is a
79
+ * cache, but two things on it are not: work in flight (a recycle kills the
80
+ * process) and a live worktree's uncommitted or unpushed changes (a recycle
81
+ * destroys the tree; the next attach recreates it from the mirror). Both keep
82
+ * the container; so does the cooldown. `treesClean` must be computed as the
83
+ * thread users (never root git in a thread tree) and treated as false when a
84
+ * check could not run — an unreadable tree is kept, never guessed clean. */
85
+ export function planDiskFullRecovery(input: {
86
+ now: number;
87
+ lastRecycleAt?: number;
88
+ inFlight: number;
89
+ treesClean: boolean;
90
+ }): DiskFullRecovery {
91
+ if (input.lastRecycleAt !== undefined && input.now - input.lastRecycleAt < DISK_FULL_RECYCLE_COOLDOWN_MS) {
92
+ const min = Math.round((input.now - input.lastRecycleAt) / 60_000);
93
+ return {
94
+ action: "wait",
95
+ why: `recycled ${min} min ago and the disk filled again — the working set does not fit the instance disk (resize the instance, or offboard a repo)`,
96
+ };
97
+ }
98
+ if (input.inFlight > 0)
99
+ return { action: "wait", why: `${input.inFlight} operation(s) in flight — a recycle would kill them` };
100
+ if (!input.treesClean) {
101
+ return {
102
+ action: "wait",
103
+ why: "a live worktree has (or could not prove it has no) uncommitted or unpushed work — a recycle would destroy it",
104
+ };
105
+ }
106
+ return { action: "recycle" };
107
+ }