@coreplane/switchboard 0.0.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +18 -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-D3shEnzl.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.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-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -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,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
|
+
}
|