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