@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,448 @@
1
+ // Disk as a measured, budgeted resource on a resident (docs/reference/specs/
2
+ // resident-repos.md item 55). Pure decisions the resident Worker (deploy/
3
+ // cloudflare-resident/worker.ts) imports, like residentDisk / residentRefresh:
4
+ // no I/O, no clock — `now` is an input, `df`/`du` output comes in as text.
5
+ //
6
+ // Why this exists: with nothing measuring a resident's disk, ENOSPC is the
7
+ // first signal, and a handful of concurrent thread trees can fill a disk in
8
+ // under an hour. `residentDisk.ts` names a FULL disk after the fact; this
9
+ // module keeps it from filling: every
10
+ // refresh cycle and every attach measures the disk (one `df`, one `du` over
11
+ // the components — hardlinks counted once), the sample is persisted on the
12
+ // resident's live view, and a thread tree is created only when the projected
13
+ // cost fits under the free space minus a reserve — evicting the coldest clean
14
+ // idle trees first, then refusing with `disk-pressure` (the bot falls back to
15
+ // the cold sandbox legibly, like `mirror-busy`). The math is in every refusal.
16
+
17
+ import { DF_FREE_ARGV, parseDfKiB } from "./residentDisk.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Measurement
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** The components of a resident's disk, in KiB, from ONE `du -xsk` invocation
24
+ * (GNU du counts a hardlinked inode once per invocation and charges it to the
25
+ * first argument that reaches it — so argument ORDER decides who pays for
26
+ * shared bytes; `duArgv` fixes the order). `null` = the path was not
27
+ * measured (absent, or du could not read it). */
28
+ export interface DiskParts {
29
+ /** `/workspace/mirror` — the bare mirror (history once). */
30
+ mirror: number | null;
31
+ /** `/workspace/deps` — the deps store (item 59), every installed key; the
32
+ * bytes a hardlinked thread tree shares are charged HERE, never to the thread. */
33
+ deps: number | null;
34
+ /** `/workspace/checkout` minus its node_modules: history + tree + build
35
+ * output — what a hardlink-eligible thread tree costs on its own. */
36
+ checkout: number | null;
37
+ /** threadKey → the UNIQUE bytes of that thread's tree (its own history clone,
38
+ * tree, plain-copied build dirs and mutable caches; an `install` thread's
39
+ * own node_modules). */
40
+ threads: Record<string, number>;
41
+ /** pool user → bytes under `/home/<user>`: a pnpm store or npm cache a
42
+ * thread install left behind, outside the tree the eviction removes. */
43
+ homes: Record<string, number>;
44
+ /** `used − Σ(the above)`: the OS image, /tmp, everything not itemized. */
45
+ other: number;
46
+ }
47
+
48
+ /** One measurement of the resident's disk, persisted on the live view
49
+ * (`live.disk`) and rendered on `/residents`, `repo list`, the watchdog line. */
50
+ export interface DiskSample {
51
+ /** ISO time the measurement was taken. */
52
+ at: string;
53
+ totalKiB: number;
54
+ usedKiB: number;
55
+ freeKiB: number;
56
+ parts: DiskParts;
57
+ }
58
+
59
+ export const DF_SAMPLE_ARGV = DF_FREE_ARGV;
60
+
61
+ /** What the Worker hands `duArgv`/`assembleDiskSample`: the fixed layout plus
62
+ * the live thread trees and pool-user homes to itemize. */
63
+ export interface DiskLayout {
64
+ mirrorDir: string;
65
+ checkoutDir: string;
66
+ /** The deps store (item 59): every installed lockfile key's node_modules,
67
+ * the source every view hardlinks from. Measured BEFORE the checkout and
68
+ * the threads so the shared inodes land on the `deps` term. */
69
+ depsStoreDir: string;
70
+ /** Live bindings: the thread's top-level dir under /workspace/threads (the
71
+ * 700 per-thread dir the eviction removes), keyed for the sample. */
72
+ threads: ReadonlyArray<{ threadKey: string; dir: string }>;
73
+ /** Every pool user's home, live or not — an evicted user's leftover store
74
+ * is exactly what this itemizes. */
75
+ homes: ReadonlyArray<{ user: string; dir: string }>;
76
+ }
77
+
78
+ /** `du -xsk` over every component in the ONE order the accounting needs:
79
+ * mirror first, then the deps store BEFORE the checkout (so the shared
80
+ * node_modules inodes land on `deps` and `checkout` reads as history + tree +
81
+ * build output, the hardlink-eligible thread cost), then each thread tree
82
+ * (charged only what it does not share with the store), then the homes. `-x`
83
+ * stays on the workspace filesystem; `-s` one total per argument; `-k` KiB. */
84
+ export function duArgv(layout: DiskLayout): string[] {
85
+ return [
86
+ "du",
87
+ "-xsk",
88
+ layout.mirrorDir,
89
+ layout.depsStoreDir,
90
+ layout.checkoutDir,
91
+ ...layout.threads.map((t) => t.dir),
92
+ ...layout.homes.map((h) => h.dir),
93
+ ];
94
+ }
95
+
96
+ /** `du -xsk` output → path → KiB. Lines that are not `<digits><tab><path>` are
97
+ * ignored (du writes its errors to stderr, but a caller may hand both). A
98
+ * missing path simply has no line — `null` in the sample, never 0. */
99
+ export function parseDu(stdout: string): Map<string, number> {
100
+ const out = new Map<string, number>();
101
+ for (const line of stdout.split("\n")) {
102
+ const m = /^(\d+)\t(.+)$/.exec(line.trimEnd());
103
+ if (m) out.set(m[2], Number(m[1]));
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Fold the two probes into the persisted sample. `df` is authoritative for
109
+ * total/used/free; the `du` lines itemize `used`, and whatever they do not
110
+ * cover is `other` (never negative — a du that raced a delete is clamped). */
111
+ export function assembleDiskSample(input: {
112
+ at: string;
113
+ df: { totalKiB: number; usedKiB: number; freeKiB: number };
114
+ du: ReadonlyMap<string, number>;
115
+ layout: DiskLayout;
116
+ }): DiskSample {
117
+ const { du, layout } = input;
118
+ const get = (path: string): number | null => du.get(path) ?? null;
119
+ const deps = get(layout.depsStoreDir);
120
+ const parts: DiskParts = {
121
+ mirror: get(layout.mirrorDir),
122
+ deps,
123
+ checkout: get(layout.checkoutDir),
124
+ threads: {},
125
+ homes: {},
126
+ other: 0,
127
+ };
128
+ let itemized = (parts.mirror ?? 0) + (deps ?? 0) + (parts.checkout ?? 0);
129
+ for (const t of layout.threads) {
130
+ const kib = get(t.dir);
131
+ if (kib !== null) {
132
+ parts.threads[t.threadKey] = kib;
133
+ itemized += kib;
134
+ }
135
+ }
136
+ for (const h of layout.homes) {
137
+ const kib = get(h.dir);
138
+ if (kib !== null) {
139
+ parts.homes[h.user] = kib;
140
+ itemized += kib;
141
+ }
142
+ }
143
+ parts.other = Math.max(0, input.df.usedKiB - itemized);
144
+ return { at: input.at, ...input.df, parts };
145
+ }
146
+
147
+ export { parseDfKiB };
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // The budget
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /** The refresh cycle snapshots mirror + checkout to R2 through the Sandbox
154
+ * SDK (`createBackup`); whether it stages a tarball on local disk is
155
+ * SDK-internal, so the budget holds room for one compressed copy of what it
156
+ * archives — the same ratio `instanceSizing.test.ts` sizes the instance with. */
157
+ export const SNAPSHOT_STAGING_RATIO = 0.6;
158
+
159
+ /** The fixed floor under the staging term: at least 1 GiB, or 5 % of the disk
160
+ * when that is more. 1 GiB is a couple of hardlinked thread trees of slack
161
+ * for what no projection sees — git's pack scratch, a `cp -al` fallback to a
162
+ * plain copy, an exec writing under /tmp, `du` racing a write — and it is
163
+ * eight times `residentDisk.ts`'s 128 MiB `disk-full` floor, so admission always refuses
164
+ * well before the failure classifier would have to speak. 5 % keeps the
165
+ * margin proportional on a larger instance (20 GB → 1 GB, the same number
166
+ * today; the fraction is for when the cap moves). */
167
+ export const DISK_FLOOR_MIN_KIB = 1024 * 1024;
168
+ export const DISK_FLOOR_FRACTION = 0.05;
169
+
170
+ export interface DiskReserve {
171
+ stagingKiB: number;
172
+ floorKiB: number;
173
+ totalKiB: number;
174
+ }
175
+
176
+ /** `staging + floor`. Staging is computed from the MEASURED mirror and
177
+ * checkout (deps + rest); an unmeasured part counts as 0 there — the floor
178
+ * still stands, and `projectThreadCostKiB` refuses to project from missing
179
+ * parts, so an unmeasured resident never admits on a guess. */
180
+ export function diskReserveKiB(sample: Pick<DiskSample, "totalKiB" | "parts">): DiskReserve {
181
+ const archived = (sample.parts.mirror ?? 0) + (sample.parts.deps ?? 0) + (sample.parts.checkout ?? 0);
182
+ const stagingKiB = Math.round(archived * SNAPSHOT_STAGING_RATIO);
183
+ const floorKiB = Math.max(DISK_FLOOR_MIN_KIB, Math.round(sample.totalKiB * DISK_FLOOR_FRACTION));
184
+ return { stagingKiB, floorKiB, totalKiB: stagingKiB + floorKiB };
185
+ }
186
+
187
+ /** What creating this thread's tree will cost. `reuse`: the tree already
188
+ * exists with its deps (0). `hardlink`: the committed lockfile matches the
189
+ * warm checkout's, so node_modules is `cp -al` — the tree costs history +
190
+ * tree (the checkout's own non-deps bytes). `install`: the lockfile differs
191
+ * and the thread installs its own node_modules — plus the deps term. */
192
+ export type ThreadCostKind = "reuse" | "hardlink" | "reconcile";
193
+
194
+ /** The share of the checkout's deps a lockfile-diverged thread is projected to
195
+ * write on top of its hardlinked seed: the delta install replaces only the
196
+ * packages whose version differs, so the tree costs a fraction of the deps,
197
+ * not the deps again (before this, such a thread installed from an empty
198
+ * node_modules — the whole deps term projected again, which is what makes a
199
+ * disk refuse the next concurrent review). A bound to
200
+ * re-measure against `du` of live reconciled trees, deliberately above the
201
+ * typical few-package delta so admission errs toward refusing. */
202
+ export const RECONCILE_DEPS_RATIO = 0.25;
203
+
204
+ /** `null` when the parts needed are not measured yet (no sample, or du could
205
+ * not read the checkout) — the caller decides what an unknown cost means
206
+ * (`planDiskAdmission`: admit only while the floor alone is met). */
207
+ export function projectThreadCostKiB(parts: DiskParts, kind: ThreadCostKind): number | null {
208
+ if (kind === "reuse") return 0;
209
+ if (parts.checkout === null) return null;
210
+ if (kind === "hardlink") return parts.checkout;
211
+ if (parts.deps === null) return null;
212
+ return parts.checkout + Math.round(parts.deps * RECONCILE_DEPS_RATIO);
213
+ }
214
+
215
+ /** The disk the resident may use: the physical total, lowered by the record's
216
+ * `diskBudgetMb` when set (an operator's cap under the instance size — the
217
+ * first thing that ever reads the field). Free under the cap is what is
218
+ * left of the cap after `used`, never more than the physical free space. */
219
+ export function effectiveFreeKiB(
220
+ sample: Pick<DiskSample, "totalKiB" | "usedKiB" | "freeKiB">,
221
+ diskBudgetMb: number | undefined,
222
+ ): { capacityKiB: number; freeKiB: number; capped: boolean } {
223
+ const budgetKiB = diskBudgetMb === undefined ? Number.POSITIVE_INFINITY : diskBudgetMb * 1024;
224
+ const capped = budgetKiB < sample.totalKiB;
225
+ const capacityKiB = capped ? budgetKiB : sample.totalKiB;
226
+ const freeKiB = Math.max(0, Math.min(sample.freeKiB, capacityKiB - sample.usedKiB));
227
+ return { capacityKiB, freeKiB, capped };
228
+ }
229
+
230
+ export interface AdmissionMath {
231
+ /** What the tree will cost, or null when unmeasured. */
232
+ projectedKiB: number | null;
233
+ kind: ThreadCostKind;
234
+ /** Free space under the cap (physical free, or the budget's remainder). */
235
+ freeKiB: number;
236
+ capacityKiB: number;
237
+ /** True when `diskBudgetMb` lowered the capacity under the physical disk. */
238
+ capped: boolean;
239
+ reserve: DiskReserve;
240
+ /** `free − reserve − projected`; negative = the shortfall. */
241
+ headroomKiB: number;
242
+ }
243
+
244
+ export type AdmissionVerdict =
245
+ { fits: true; math: AdmissionMath } | { fits: false; math: AdmissionMath; shortfallKiB: number };
246
+
247
+ /** The admission test: `free − reserve ≥ projected`. With an unmeasured
248
+ * projection (`null`) the tree is admitted only while the free space clears
249
+ * the reserve at all — the resident has never measured itself (a fresh
250
+ * container before its first cycle), and refusing every attach until it does
251
+ * would make the gauge a new outage; the refusal text says the cost was
252
+ * unmeasured when it does refuse. */
253
+ export function checkDiskAdmission(input: {
254
+ sample: DiskSample;
255
+ /** A fresher RAW `df` free reading than the sample's (the attach re-probes
256
+ * `df`, cheap, while the du parts come from the last full measurement). */
257
+ freeKiB?: number;
258
+ /** Projected bytes of attaches admitted but not yet on disk (the Worker's
259
+ * in-flight commitments). Deducted ONCE here, from the raw reading — the
260
+ * caller never pre-deducts, so a re-check can never deduct twice. */
261
+ committedKiB?: number;
262
+ diskBudgetMb?: number;
263
+ kind: ThreadCostKind;
264
+ }): AdmissionVerdict {
265
+ const rawFree = Math.max(0, (input.freeKiB ?? input.sample.freeKiB) - (input.committedKiB ?? 0));
266
+ const live = { ...input.sample, freeKiB: rawFree, usedKiB: input.sample.totalKiB - rawFree };
267
+ const { capacityKiB, freeKiB, capped } = effectiveFreeKiB(live, input.diskBudgetMb);
268
+ const reserve = diskReserveKiB(input.sample);
269
+ const projectedKiB = projectThreadCostKiB(input.sample.parts, input.kind);
270
+ const headroomKiB = freeKiB - reserve.totalKiB - (projectedKiB ?? 0);
271
+ const math: AdmissionMath = { projectedKiB, kind: input.kind, freeKiB, capacityKiB, capped, reserve, headroomKiB };
272
+ return headroomKiB >= 0 ? { fits: true, math } : { fits: false, math, shortfallKiB: -headroomKiB };
273
+ }
274
+
275
+ /** The raw free reading to re-check with after an eviction: the re-probe's
276
+ * answer when `df` answered, else the previous RAW reading plus the bytes the
277
+ * evicted tree was measured at (unknown size → nothing added). Always raw —
278
+ * `checkDiskAdmission` deducts the commitments itself. */
279
+ export function rawFreeAfterEviction(
280
+ previousRawFreeKiB: number,
281
+ probe: { freeKiB: number } | null,
282
+ freedKiB: number | null,
283
+ ): number {
284
+ return probe ? probe.freeKiB : previousRawFreeKiB + (freedKiB ?? 0);
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Making room: the coldest clean idle trees first
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /** A tree attached more recently than this is presumed to belong to a run in
292
+ * progress (the bot detaches on finish, so a live binding is either in a run
293
+ * or leaked) and is never evicted for space — between two tool calls its op
294
+ * counter is 0 for seconds at a time, and a run would lose its tree mid-flight
295
+ * (self-healing via `needs:"attach"`, but a needless recreate). 10 min is
296
+ * longer than any gap between tool calls in a run and shorter than the
297
+ * hourly clean-idle release, so the pressure path reaches trees the sweep
298
+ * has not got to yet. */
299
+ export const DISK_EVICT_MIN_IDLE_MS = 10 * 60_000;
300
+
301
+ export interface DiskEvictionCandidate {
302
+ threadKey: string;
303
+ ref: string;
304
+ lastAttachAt: string;
305
+ /** Ops in flight on this binding right now. */
306
+ busy: number;
307
+ isDefaultRef: boolean;
308
+ /** Unique bytes of its tree from the last sample; null when unmeasured. */
309
+ sizeKiB: number | null;
310
+ }
311
+
312
+ /** Why a tree was kept under disk pressure. `other` is the caller's fallback
313
+ * for a keep decided outside `orderEvictionCandidates` (a binding that moved
314
+ * during the check); free text never becomes a token. */
315
+ export type DiskKeepWhy = "busy" | "default-ref" | "recent" | "dirty" | "requesting" | "other";
316
+
317
+ /** Order the live trees for eviction under pressure and name every one that is
318
+ * kept: the requesting thread itself, a busy tree, the default branch (the
319
+ * one most likely re-attached — the same rule as `reclaimDecision`), and a
320
+ * tree attached within `DISK_EVICT_MIN_IDLE_MS` are never candidates; the
321
+ * rest are ordered coldest first (oldest `lastAttachAt`, ties on key).
322
+ * Cleanliness is NOT decided here — it needs the container (as the thread
323
+ * user), so the Worker checks each candidate in this order and keeps a dirty
324
+ * or unreadable one (`dirty`), exactly like the sweep. */
325
+ export function orderEvictionCandidates(input: {
326
+ candidates: readonly DiskEvictionCandidate[];
327
+ now: number;
328
+ requestingThreadKey: string;
329
+ minIdleMs?: number;
330
+ }): {
331
+ order: DiskEvictionCandidate[];
332
+ kept: Array<{ threadKey: string; why: DiskKeepWhy; detail: string }>;
333
+ } {
334
+ const minIdle = input.minIdleMs ?? DISK_EVICT_MIN_IDLE_MS;
335
+ const order: DiskEvictionCandidate[] = [];
336
+ const kept: Array<{ threadKey: string; why: DiskKeepWhy; detail: string }> = [];
337
+ for (const c of input.candidates) {
338
+ if (c.threadKey === input.requestingThreadKey) {
339
+ kept.push({ threadKey: c.threadKey, why: "requesting", detail: "the thread being attached" });
340
+ continue;
341
+ }
342
+ if (c.busy > 0) {
343
+ kept.push({ threadKey: c.threadKey, why: "busy", detail: `${c.busy} operation(s) in flight` });
344
+ continue;
345
+ }
346
+ if (c.isDefaultRef) {
347
+ kept.push({ threadKey: c.threadKey, why: "default-ref", detail: `on the default branch ${c.ref}` });
348
+ continue;
349
+ }
350
+ const idleMs = input.now - Date.parse(c.lastAttachAt);
351
+ if (!(idleMs >= minIdle)) {
352
+ kept.push({
353
+ threadKey: c.threadKey,
354
+ why: "recent",
355
+ detail: `attached ${formatAgo(idleMs)} ago (floor ${formatAgo(minIdle)})`,
356
+ });
357
+ continue;
358
+ }
359
+ order.push(c);
360
+ }
361
+ order.sort((a, b) => a.lastAttachAt.localeCompare(b.lastAttachAt) || a.threadKey.localeCompare(b.threadKey));
362
+ return { order, kept };
363
+ }
364
+
365
+ function formatAgo(ms: number): string {
366
+ if (!Number.isFinite(ms)) return "?";
367
+ const min = Math.round(ms / 60_000);
368
+ return min < 60 ? `${Math.max(0, min)}m` : `${Math.round(min / 60)}h`;
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // The refusal, with the math in it
373
+ // ---------------------------------------------------------------------------
374
+
375
+ export const DISK_PRESSURE_REASON = "disk-pressure";
376
+
377
+ /** `x.y GiB` (two decimals under 10 GiB, one above) — the unit every disk
378
+ * number on `/residents`, `repo list`, the watchdog line and the refusal
379
+ * shares, so the same quantity never reads two ways. */
380
+ export function formatGiB(kib: number | null | undefined): string {
381
+ if (kib === null || kib === undefined || !Number.isFinite(kib)) return "?";
382
+ const gib = kib / (1024 * 1024);
383
+ return `${gib < 10 ? gib.toFixed(2) : gib.toFixed(1)} GiB`;
384
+ }
385
+
386
+ /** `6.5/15.0 GiB (43%)` — the one-line gauge for lists and status lines. */
387
+ export function formatDiskGauge(sample: Pick<DiskSample, "totalKiB" | "usedKiB">): string {
388
+ const pct = sample.totalKiB > 0 ? Math.round((sample.usedKiB / sample.totalKiB) * 100) : 0;
389
+ return `${formatGiB(sample.usedKiB)}/${formatGiB(sample.totalKiB)} (${pct}%)`;
390
+ }
391
+
392
+ /** The `disk-pressure` refusal: the projection (or that it is unmeasured), the
393
+ * free space under the cap, the reserve and its two terms, what was evicted
394
+ * (with the bytes it gave back) and what was kept and why — every number the
395
+ * decision used, so the card, the log and the operator see the same math. */
396
+ /** The refusal the requesting thread sees. It names sizes, counts and keep
397
+ * tokens only (item 62): the trees evicted or kept belong to OTHER threads,
398
+ * and their keys and free-text details stay in the Worker log. */
399
+ export function diskPressureReason(input: {
400
+ verdict: Extract<AdmissionVerdict, { fits: false }>;
401
+ evicted: ReadonlyArray<{ freedKiB: number | null }>;
402
+ kept: ReadonlyArray<{ why: DiskKeepWhy }>;
403
+ }): string {
404
+ const { math } = input.verdict;
405
+ const need =
406
+ math.projectedKiB === null
407
+ ? `a new tree (${math.kind}) of UNMEASURED cost (no du sample yet)`
408
+ : `${formatGiB(math.projectedKiB)} for a new tree (${math.kind})`;
409
+ const cap = math.capped ? ` under the ${formatGiB(math.capacityKiB)} diskBudgetMb cap` : "";
410
+ const parts = [
411
+ `${DISK_PRESSURE_REASON}: need ${need}, but ${formatGiB(math.freeKiB)} free${cap} minus the ${formatGiB(math.reserve.totalKiB)} reserve ` +
412
+ `(snapshot staging ${formatGiB(math.reserve.stagingKiB)} + floor ${formatGiB(math.reserve.floorKiB)}) leaves ${formatGiB(Math.max(0, math.freeKiB - math.reserve.totalKiB))} — short by ${formatGiB(input.verdict.shortfallKiB)}`,
413
+ ];
414
+ if (input.evicted.length > 0) {
415
+ const freed = input.evicted.reduce((a, e) => a + (e.freedKiB ?? 0), 0);
416
+ parts.push(`evicted ${input.evicted.length} idle tree(s) (${formatGiB(freed)} back)`);
417
+ } else parts.push("evicted nothing");
418
+ if (input.kept.length > 0) {
419
+ const counts = new Map<DiskKeepWhy, number>();
420
+ for (const k of input.kept) counts.set(k.why, (counts.get(k.why) ?? 0) + 1);
421
+ parts.push(`kept ${input.kept.length} (${[...counts].map(([why, n]) => `${why} ${n}`).join(", ")})`);
422
+ }
423
+ return parts.join("; ");
424
+ }
425
+
426
+ export function isDiskPressureReason(reason: string): boolean {
427
+ return reason.startsWith(`${DISK_PRESSURE_REASON}:`);
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // What an eviction must also remove: the user's package-manager leftovers
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /** Relative to a pool user's home: where an `install` thread's package manager
435
+ * keeps what is NOT in the tree — pnpm's content-addressable store
436
+ * (`~/.local/share/pnpm/store`, the hardlink source of its node_modules: 0
437
+ * unique bytes while the tree lives, ALL of them once the tree is removed),
438
+ * pnpm's metadata cache and any tool cache (`~/.cache`), npm's tarball cache
439
+ * (`~/.npm`), yarn's and bun's. Removed with the tree on every eviction —
440
+ * the pool user is an arbitrary slot, so a store left behind is almost never
441
+ * reused and would otherwise outlive every thread that filled it (16 users ×
442
+ * a 2.1 GB store = the whole disk). The build user's home (`worker1`) is never
443
+ * touched here: its store is the warm checkout's hardlink source. */
444
+ export const THREAD_USER_CACHE_DIRS = [".local/share/pnpm", ".cache", ".npm", ".yarn", ".bun"] as const;
445
+
446
+ export function threadUserCacheCleanArgv(homeDir: string): string[] {
447
+ return ["rm", "-rf", ...THREAD_USER_CACHE_DIRS.map((d) => `${homeDir}/${d}`)];
448
+ }
@@ -0,0 +1,100 @@
1
+ // Source-side output capping for resident thread commands.
2
+ //
3
+ // The resident DO used to collect a command's ENTIRE stdout/stderr through the
4
+ // sandbox RPC (`proc.output()`) and only then slice to the per-stream cap — a
5
+ // verbose vitest/build run materialized tens of MB inside a 128 MB DO isolate
6
+ // before truncation, which is an OOM (→ `runtime-replaced` mid-run) waiting to
7
+ // happen. This wrapper bounds the streams INSIDE the container: the command's
8
+ // full output goes to two temp files on the container disk (disk is a cache
9
+ // and is recycled freely), and only the capped head of each crosses the
10
+ // RPC.
11
+ //
12
+ // Contract preserved exactly:
13
+ // - exit code is the command's own (`exit $ec` after the heads);
14
+ // - stdout and stderr stay separate streams;
15
+ // - the DO-side char-count slice + `truncated` flag logic is UNCHANGED — the
16
+ // byte cap here is 4×(charCap)+4, and UTF-8 spends at most 4 bytes per char,
17
+ // so whenever the real output held more than charCap chars, the capped bytes
18
+ // still decode to more than charCap chars and the flag stays exact;
19
+ // - a `cd` failure surfaces as before: message on stderr, non-zero exit.
20
+ //
21
+ // Known, accepted edges (named in docs/reference/specs/resident-repos.md item 21):
22
+ // - ANY timeout kill (the SDK's is TERM-based; KILL behaves the same here)
23
+ // ends the wrapper before its own head/cleanup lines run, leaving the two
24
+ // files behind. That is why callers that care about hung-run output pass
25
+ // FIXED file paths (`execCapFiles`) — the DO salvages the capped heads with
26
+ // one follow-up command and cleans up (before this module, `proc.output()`
27
+ // returned whatever streamed pre-kill; with mktemp-only paths a timeout
28
+ // would return nothing at all). Recoverable mode deliberately sets NO EXIT
29
+ // trap — bash runs EXIT traps when TERM ends it, and a cleanup trap deleted
30
+ // the files before recovery could read them;
31
+ // - `mktemp` failing (disk full) exits 125 before the command runs — legible,
32
+ // and a full disk would have failed the command anyway.
33
+
34
+ /** Bytes that guarantee at least `charCap` + 1 UTF-16 chars survive whenever
35
+ * the stream really held more than `charCap` chars (UTF-8 ≤ 4 bytes/char). */
36
+ export function capBytesFor(charCap: number): number {
37
+ return charCap * 4 + 4;
38
+ }
39
+
40
+ /** The fixed stream-file pair for one exec, unguessable via `crypto.randomUUID`
41
+ * (nobody can pre-plant a symlink or reader at the path). Fixed — rather than
42
+ * `mktemp` — so a timeout kill, which skips the wrapper's own head/cleanup
43
+ * lines, leaves files the DO can still find: `recoverCapturedOutput` salvages
44
+ * the capped heads and removes them. */
45
+ export function execCapFiles(): { out: string; err: string } {
46
+ const id = crypto.randomUUID();
47
+ return { out: `/tmp/exec-${id}.out`, err: `/tmp/exec-${id}.err` };
48
+ }
49
+
50
+ /**
51
+ * The `bash -c` body for one thread command with source-side stream caps:
52
+ * run `command` in `cwd` (a subshell, so arbitrary command text cannot escape
53
+ * the redirections), buffer full streams to files, emit only the first
54
+ * `capBytes` of each, preserve the command's exit code. With `files` (the
55
+ * timeout-recoverable mode) the pair comes from `execCapFiles`; without, two
56
+ * `mktemp` files that die with the EXIT trap.
57
+ */
58
+ export function capWrappedCommand(
59
+ cwd: string,
60
+ command: string,
61
+ capBytes: number,
62
+ files?: { out: string; err: string },
63
+ ): string {
64
+ if (!Number.isInteger(capBytes) || capBytes <= 0)
65
+ throw new Error(`capBytes must be a positive integer, got ${capBytes}`);
66
+ return [
67
+ files ? `o=${files.out}` : `o=$(mktemp) || exit 125`,
68
+ files ? `e=${files.err}` : `e=$(mktemp) || exit 125`,
69
+ // Recoverable (fixed-file) mode sets NO trap, deliberately: the sandbox
70
+ // SDK's timeout kill is TERM-based, and bash runs EXIT traps when TERM
71
+ // ends it — a cleanup trap deleted the files BEFORE the caller's recovery
72
+ // leg could salvage them (a salvage under a TERM kill returned empty
73
+ // while a SIGKILL-based test stayed green). Cleanup in this mode is the
74
+ // explicit rm after the heads (normal completion) or the recovery command
75
+ // (any kill). mktemp mode has no recovery reader, so the trap remains the
76
+ // right tool there.
77
+ files ? `:` : `trap 'rm -f "$o" "$e"' EXIT`,
78
+ // The newline before `)` keeps a trailing `#comment` (or an unterminated
79
+ // last line) in the command from swallowing the closing paren.
80
+ `( cd ${cwd} && ${command}`,
81
+ `) >"$o" 2>"$e"`,
82
+ `ec=$?`,
83
+ `head -c ${capBytes} "$o"`,
84
+ `head -c ${capBytes} "$e" >&2`,
85
+ `rm -f "$o" "$e"`,
86
+ `exit $ec`,
87
+ ].join("\n");
88
+ }
89
+
90
+ /** The follow-up command a caller runs AFTER a timeout kill to salvage what the
91
+ * command had written before it died — the same capped heads the wrapper would
92
+ * have emitted — and remove the files. Exit 0 even when the files are gone. */
93
+ export function recoverCapturedOutput(files: { out: string; err: string }, capBytes: number): string {
94
+ return [
95
+ `head -c ${capBytes} -- ${files.out} 2>/dev/null`,
96
+ `head -c ${capBytes} -- ${files.err} >&2 2>/dev/null`,
97
+ `rm -f -- ${files.out} ${files.err}`,
98
+ `exit 0`,
99
+ ].join("\n");
100
+ }
@@ -0,0 +1,85 @@
1
+ /** The "is the mirror fresh enough for this attach?" decision of the resident
2
+ * Worker's `attachThread` (deploy/cloudflare-resident/worker.ts), kept pure
3
+ * and dependency-free so it is unit-testable from src/ and imported across
4
+ * packages by the Worker (like residentReadonly / residentRefresh) — the
5
+ * tested code IS the shipped code.
6
+ *
7
+ * The resident's mirror is fetched on its refresh cycle and, on attach, when
8
+ * the bound ref is missing from it. A ref that exists but is STALE — pushed
9
+ * to since the last cycle — would be cloned at the old tip, and a review of
10
+ * it refused by the reviewed-head guard (agent-review.md item 8) on a head
11
+ * the bot had already resolved (`RepoContext.headSha`). So `/attach` carries
12
+ * that head as `sha`, and a mirror whose ref tip is not that commit is
13
+ * fetched before the clone (docs/reference/specs/resident-repos.md item 51). */
14
+
15
+ export type ParsedWantSha = { sha: string | null } | { error: string };
16
+
17
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
18
+
19
+ /** `/attach` body field `sha` — the commit the caller expects the ref to be at
20
+ * (a PR head). Absent → null (older bots never send it; coding runs have no
21
+ * expected commit); a full 40-hex lowercase sha → itself (a full sha names
22
+ * exactly one commit — no prefix ambiguity); anything else → a 400-shaped
23
+ * error. Validated BEFORE it can become a git argument (P1). */
24
+ export function parseWantSha(value: unknown): ParsedWantSha {
25
+ if (value === undefined) return { sha: null };
26
+ if (typeof value === "string" && FULL_SHA_RE.test(value)) return { sha: value };
27
+ return { error: "sha must be a full 40-character lowercase hex commit when present" };
28
+ }
29
+
30
+ /** The expected head applies to the ref the caller resolved it FOR (`refHint`,
31
+ * the PR's head branch). A thread whose sticky binding is another branch
32
+ * (the binding wins over a differing refHint) can never have that
33
+ * branch's tip at the PR head, so the sha is dropped rather than forcing a
34
+ * fetch on every attach of a permanently mismatched thread. No refHint (a
35
+ * follow-up that named no branch) → the caller means the bound ref. */
36
+ export function wantShaForBinding(input: {
37
+ boundRef: string;
38
+ refHint: string | null;
39
+ wantSha: string | null;
40
+ }): string | null {
41
+ if (input.refHint !== null && input.refHint !== input.boundRef) return null;
42
+ return input.wantSha;
43
+ }
44
+
45
+ /** Whether the attach must fetch the mirror before cloning the thread tree.
46
+ * - the ref is not in the mirror → fetch (the only pre-existing rule);
47
+ * - a `wantSha` was named and the mirror's tip of the ref is not that
48
+ * commit → fetch;
49
+ * - otherwise the mirror is good enough as it stands.
50
+ * A fetch that STILL leaves the tip elsewhere (a push racing this attach, or
51
+ * a force-push) is not this function's concern: the attach proceeds on the
52
+ * fetched tip and reports it, and the reviewed-head guard decides what a
53
+ * review of it may do. */
54
+ export function mirrorNeedsFetch(input: { refExists: boolean; mirrorSha?: string; wantSha: string | null }): boolean {
55
+ if (!input.refExists) return true;
56
+ if (input.wantSha === null) return false;
57
+ return input.mirrorSha !== input.wantSha;
58
+ }
59
+
60
+ /** What the attach checks out, once the mirror is as fresh as it will get. */
61
+ export type AttachTarget =
62
+ /** The bound ref is in the mirror: clone its tip, as always. */
63
+ | { kind: "ref" }
64
+ /** The ref is gone but the commit the caller expects is in the mirror — a
65
+ * merged PR's branch was deleted while `refs/pull/N/head` (a `--mirror`
66
+ * clone carries every ref) still holds its head: check that commit out,
67
+ * detached, instead of falling back to a cold sandbox that clones the same
68
+ * commit itself. */
69
+ | { kind: "sha"; sha: string }
70
+ /** Neither: the attach is refused as `unknown-ref`. */
71
+ | { kind: "unknown-ref" };
72
+
73
+ /** The attach target after the fetch (item 51). The ref wins whenever it
74
+ * exists — a detached tree is only for a ref that is gone; a caller that
75
+ * named no commit, or whose commit the mirror does not hold either, gets the
76
+ * refusal it always got. */
77
+ export function attachTarget(input: {
78
+ refExists: boolean;
79
+ wantSha: string | null;
80
+ commitInMirror: boolean;
81
+ }): AttachTarget {
82
+ if (input.refExists) return { kind: "ref" };
83
+ if (input.wantSha !== null && input.commitInMirror) return { kind: "sha", sha: input.wantSha };
84
+ return { kind: "unknown-ref" };
85
+ }