@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,95 @@
|
|
|
1
|
+
/** The worktree clean-check as ONE spawn, kept pure and
|
|
2
|
+
* dependency-free so it is unit-testable from src/ and imported by the
|
|
3
|
+
* resident Worker (deploy/cloudflare-resident/worker.ts
|
|
4
|
+
* `worktreeCleanliness`) like residentDepCache — the tested code IS the
|
|
5
|
+
* shipped code.
|
|
6
|
+
*
|
|
7
|
+
* Background: the check used to be three sequential container spawns per
|
|
8
|
+
* binding (`test -d`, `su … git status --porcelain`, `su … git rev-list
|
|
9
|
+
* --count HEAD --not --remotes`), and `isIdle()` ran it serially per live
|
|
10
|
+
* binding — every refresh cycle's idle gate paid 3×N process round-trips.
|
|
11
|
+
* The three probes fold into one `sh -c` script; the DECISION semantics are
|
|
12
|
+
* unchanged and encoded in `parseWorktreeCleanliness`:
|
|
13
|
+
* - no `.git` dir → clean ("worktree missing (disk recycled)" — nothing
|
|
14
|
+
* to preserve);
|
|
15
|
+
* - a git probe failing → NOT clean ("never destroy work on a guess"),
|
|
16
|
+
* reason `clean-check failed: <first error line>`;
|
|
17
|
+
* - tracked changes or unpushed commits → NOT clean, both counts named;
|
|
18
|
+
* - else clean.
|
|
19
|
+
*
|
|
20
|
+
* Security posture, preserved exactly: the git probes run AS THE
|
|
21
|
+
* THREAD USER via `su` — the worktree is thread-owned, root git in it would
|
|
22
|
+
* be refused by safe.directory and would be the repo-local-config execution
|
|
23
|
+
* vector safe.directory exists to block. Only the `test -d` runs as the
|
|
24
|
+
* caller (root), same as before.
|
|
25
|
+
*
|
|
26
|
+
* Output is tagged lines (the readRefreshDisk idiom): parsing keys on the
|
|
27
|
+
* tag, never line position, so a su/PAM banner cannot shift a field. */
|
|
28
|
+
|
|
29
|
+
import { shellQuote } from "./shellQuote.js";
|
|
30
|
+
|
|
31
|
+
/** Build the one-spawn probe script. Run it via `sh -c` as root with the
|
|
32
|
+
* usual GIT_TERMINAL_PROMPT=0 injection; feed the result to
|
|
33
|
+
* `parseWorktreeCleanliness`. */
|
|
34
|
+
export function worktreeCleanlinessScript(worktreePath: string, user: string): string {
|
|
35
|
+
const gitDir = shellQuote(`${worktreePath}/.git`);
|
|
36
|
+
const wt = shellQuote(worktreePath);
|
|
37
|
+
// The inner script (as the thread user) captures each probe's stdout and
|
|
38
|
+
// stderr separately — stderr via a temp file, first non-empty line kept —
|
|
39
|
+
// so a stderr warning on a SUCCESSFUL probe can never inflate the change
|
|
40
|
+
// count, and the failure reason stays the first stderr line exactly as the
|
|
41
|
+
// old per-probe code reported it.
|
|
42
|
+
const inner = [
|
|
43
|
+
`t=$(mktemp) || { echo gitrc=1; echo 'giterr=mktemp failed'; exit 0; }`,
|
|
44
|
+
`cd ${wt} 2>"$t" || { echo gitrc=1; printf 'giterr=%s\\n' "$(grep -m 1 . "$t" || echo 'cd failed')"; rm -f "$t"; exit 0; }`,
|
|
45
|
+
`s_out=$(git status --porcelain 2>"$t"); s_rc=$?; s_err=$(grep -m 1 . "$t" || true)`,
|
|
46
|
+
`a_out=$(git rev-list --count HEAD --not --remotes 2>"$t"); a_rc=$?; a_err=$(grep -m 1 . "$t" || true)`,
|
|
47
|
+
`rm -f "$t"`,
|
|
48
|
+
`if [ "$s_rc" -ne 0 ] || [ "$a_rc" -ne 0 ]; then`,
|
|
49
|
+
` echo gitrc=1`,
|
|
50
|
+
` if [ -n "$s_err" ]; then printf 'giterr=%s\\n' "$s_err"`,
|
|
51
|
+
` elif [ -n "$a_err" ]; then printf 'giterr=%s\\n' "$a_err"`,
|
|
52
|
+
` else echo 'giterr=git exited non-zero'; fi`,
|
|
53
|
+
` exit 0`,
|
|
54
|
+
`fi`,
|
|
55
|
+
`echo gitrc=0`,
|
|
56
|
+
`printf 'changes=%s\\n' "$(printf '%s' "$s_out" | grep -c .)"`,
|
|
57
|
+
`printf 'unpushed=%s\\n' "$a_out"`,
|
|
58
|
+
].join("\n");
|
|
59
|
+
return [
|
|
60
|
+
`if ! test -d ${gitDir}; then echo present=no; exit 0; fi`,
|
|
61
|
+
`echo present=yes`,
|
|
62
|
+
`su -s /bin/bash ${shellQuote(user)} -c ${shellQuote(inner)}`,
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WorktreeCleanliness {
|
|
67
|
+
clean: boolean;
|
|
68
|
+
reason?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Decide from the script's tagged output. Unknown (missing/failed tags,
|
|
72
|
+
* non-zero exit, timeout) counts as NOT clean — never destroy work on a
|
|
73
|
+
* guess — matching the pre-fold decision exactly. */
|
|
74
|
+
export function parseWorktreeCleanliness(r: {
|
|
75
|
+
stdout: string;
|
|
76
|
+
stderr: string;
|
|
77
|
+
exitCode: number;
|
|
78
|
+
timedOut: boolean;
|
|
79
|
+
}): WorktreeCleanliness {
|
|
80
|
+
const tags = new Map<string, string>();
|
|
81
|
+
for (const line of r.stdout.split("\n")) {
|
|
82
|
+
const m = /^(present|gitrc|giterr|changes|unpushed)=(.*)$/.exec(line.trim());
|
|
83
|
+
if (m && !tags.has(m[1])) tags.set(m[1], m[2].trim());
|
|
84
|
+
}
|
|
85
|
+
if (tags.get("present") === "no") return { clean: true, reason: "worktree missing (disk recycled)" };
|
|
86
|
+
if (r.timedOut || r.exitCode !== 0 || tags.get("gitrc") !== "0") {
|
|
87
|
+
const why = tags.get("giterr") || (r.stderr || "git exited non-zero").trim().split("\n")[0];
|
|
88
|
+
return { clean: false, reason: `clean-check failed: ${why}` };
|
|
89
|
+
}
|
|
90
|
+
const changes = Number(tags.get("changes")) || 0;
|
|
91
|
+
const unpushed = Number(tags.get("unpushed")) || 0;
|
|
92
|
+
if (changes > 0 || unpushed > 0)
|
|
93
|
+
return { clean: false, reason: `dirty: ${changes} uncommitted change(s), ${unpushed} unpushed commit(s)` };
|
|
94
|
+
return { clean: true };
|
|
95
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** The per-exec credential-refresh decision of the resident Worker
|
|
2
|
+
* (deploy/cloudflare-resident/worker.ts `execThreadImpl`), kept pure and
|
|
3
|
+
* dependency-free so it is unit-testable from src/ and imported across
|
|
4
|
+
* packages by the Worker (like residentDetach/shellQuote) — the tested code
|
|
5
|
+
* IS the shipped code.
|
|
6
|
+
*
|
|
7
|
+
* Background: attach writes a 1-hour GitHub App installation token
|
|
8
|
+
* into `<worktree>/.git/github-credentials` and points git's `store` helper
|
|
9
|
+
* at it. Nothing used to refresh it during a run, so a push more than ~60
|
|
10
|
+
* minutes after attach got 401 — and git's `store` helper ERASES a rejected
|
|
11
|
+
* credential from its file, leaving the agent staring at an empty file. The
|
|
12
|
+
* Worker now asks this function before every writable `/exec` and re-mints
|
|
13
|
+
* (cached per slug) + rewrites the file when it says so.
|
|
14
|
+
*
|
|
15
|
+
* The refresh is driven by the TOKEN'S OWN EXPIRY, not the file's age.
|
|
16
|
+
* `mintRepoScopedToken` caches per repository slug, so a token minted for an
|
|
17
|
+
* earlier thread — with only a few minutes of life left — could be written
|
|
18
|
+
* for a brand-new attach and, keyed on file age alone, read "fresh" for ~45
|
|
19
|
+
* minutes while every writable exec in between ran on a dead token (the first
|
|
20
|
+
* git write 401s, `store` blanks the file, and only the NEXT exec refreshed).
|
|
21
|
+
* Now the binding records the token's `expiresAtMs`, and this predicate
|
|
22
|
+
* refreshes once the token is within `CREDENTIAL_EXPIRY_MARGIN_MS` of expiry
|
|
23
|
+
* — so a writable exec never starts on a token that cannot outlive it. */
|
|
24
|
+
|
|
25
|
+
import { BASH_TIMEOUT_MAX_MS } from "./bashTimeout.js";
|
|
26
|
+
|
|
27
|
+
/** Backstop for bindings that predate `tokenExpiresAtMs`: re-mint
|
|
28
|
+
* this long after the file was written when the token's real expiry is
|
|
29
|
+
* unknown — comfortably before the 60-minute token expiry. New bindings carry
|
|
30
|
+
* the expiry and take the margin path below instead. */
|
|
31
|
+
export const CREDENTIAL_REFRESH_AFTER_MS = 45 * 60_000;
|
|
32
|
+
|
|
33
|
+
/** Refresh once the token is within this much of expiry, so a writable exec
|
|
34
|
+
* never starts on a token that cannot outlive the command it is about to run.
|
|
35
|
+
* Sized like the sandbox's per-command credential margin: the longest
|
|
36
|
+
* single exec is `BASH_TIMEOUT_MAX_MS` (20 min), plus slack for the mint +
|
|
37
|
+
* file write and clock skew ⇒ 25 min. This is also `mintRepoScopedToken`'s
|
|
38
|
+
* cache serve threshold, so the cache and this predicate agree: a token the
|
|
39
|
+
* cache would still hand out is a token this predicate would not refresh. */
|
|
40
|
+
export const CREDENTIAL_EXPIRY_MARGIN_MS = BASH_TIMEOUT_MAX_MS + 5 * 60_000;
|
|
41
|
+
|
|
42
|
+
export type CredentialRefreshReason =
|
|
43
|
+
/** The token is within `CREDENTIAL_EXPIRY_MARGIN_MS` of its expiry (or past it). */
|
|
44
|
+
| "expiring"
|
|
45
|
+
/** No token expiry recorded (binding predates the field) and the file is
|
|
46
|
+
* older than `CREDENTIAL_REFRESH_AFTER_MS`, or its write time is unknown. */
|
|
47
|
+
| "stale"
|
|
48
|
+
/** The file exists with zero bytes — git's `store` helper erased a rejected token. */
|
|
49
|
+
| "empty"
|
|
50
|
+
/** No file at all (never written on this tree, or removed). */
|
|
51
|
+
| "missing";
|
|
52
|
+
|
|
53
|
+
export function shouldRefreshThreadCredentials(input: {
|
|
54
|
+
/** When the binding's credential file was last written; null when the
|
|
55
|
+
* binding predates the field. Only consulted on the file-age backstop. */
|
|
56
|
+
writtenAtMs: number | null;
|
|
57
|
+
/** When the written token expires (epoch ms); null/undefined when the
|
|
58
|
+
* binding predates the field — then the file-age backstop applies. */
|
|
59
|
+
tokenExpiresAtMs?: number | null;
|
|
60
|
+
nowMs: number;
|
|
61
|
+
/** Size of `<worktree>/.git/github-credentials`; null when the file is absent. */
|
|
62
|
+
fileBytes: number | null;
|
|
63
|
+
/** Read-only bindings (item 50) never carry credentials — never refresh. */
|
|
64
|
+
readonly: boolean;
|
|
65
|
+
}): { refresh: boolean; reason: CredentialRefreshReason | null } {
|
|
66
|
+
const { writtenAtMs, tokenExpiresAtMs, nowMs, fileBytes, readonly } = input;
|
|
67
|
+
if (readonly) return { refresh: false, reason: null };
|
|
68
|
+
if (fileBytes === null) return { refresh: true, reason: "missing" };
|
|
69
|
+
if (fileBytes === 0) return { refresh: true, reason: "empty" };
|
|
70
|
+
// Known expiry: drive the refresh off the token's own life, not the
|
|
71
|
+
// file's age. Replaces the file-age heuristic for any binding that records it.
|
|
72
|
+
if (tokenExpiresAtMs != null) {
|
|
73
|
+
if (nowMs > tokenExpiresAtMs - CREDENTIAL_EXPIRY_MARGIN_MS) return { refresh: true, reason: "expiring" };
|
|
74
|
+
return { refresh: false, reason: null };
|
|
75
|
+
}
|
|
76
|
+
// Backstop for a binding written before the expiry was recorded: fall back to
|
|
77
|
+
// file age so an old binding still refreshes.
|
|
78
|
+
if (writtenAtMs === null || nowMs - writtenAtMs > CREDENTIAL_REFRESH_AFTER_MS)
|
|
79
|
+
return { refresh: true, reason: "stale" };
|
|
80
|
+
return { refresh: false, reason: null };
|
|
81
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/** Per-directory mechanism for the resident's dep/build cache
|
|
2
|
+
* (docs/reference/specs/resident-repos.md item 18), kept pure and dependency-free so it
|
|
3
|
+
* is unit-testable from src/ and imported by the resident Worker
|
|
4
|
+
* (deploy/cloudflare-resident/worker.ts `materializeThreadDeps`) like
|
|
5
|
+
* residentReadonly / residentHead — the tested code IS the shipped code.
|
|
6
|
+
*
|
|
7
|
+
* Background: every cached dir used to be
|
|
8
|
+
* hardlink-copied (`cp -al`) from the warm checkout with the FILE inodes
|
|
9
|
+
* left worker1-owned and stripped of group/world write — right for
|
|
10
|
+
* node_modules, which a thread only ever reads, and the whole point of the
|
|
11
|
+
* tamper-proofing (a thread must never mutate bytes the warm checkout or a
|
|
12
|
+
* peer tree sees). But the review agent is told to RUN the project's build,
|
|
13
|
+
* and `/op build` runs it too; compilers rewrite `dist/**` in place
|
|
14
|
+
* (open+truncate through the existing inode), so with a hardlinked `dist/`
|
|
15
|
+
* the build died with EACCES and the agent reported the tree as read-only.
|
|
16
|
+
* Build outputs are therefore materialized as plain copies: fresh inodes
|
|
17
|
+
* fully owned by the thread user, overwritable, and still isolated from the
|
|
18
|
+
* warm checkout (a copy shares nothing). */
|
|
19
|
+
|
|
20
|
+
import { shellQuote } from "./shellQuote.js";
|
|
21
|
+
|
|
22
|
+
export const DEP_CACHE_DIRS = ["node_modules", "dist", "build", "out", ".next"] as const;
|
|
23
|
+
export type DepCacheDir = (typeof DEP_CACHE_DIRS)[number];
|
|
24
|
+
|
|
25
|
+
/** How one cached dir is brought into a thread tree. `hardlink` = `cp -al`
|
|
26
|
+
* (shared worker1-owned read-only inodes; falls back to `copy` when the
|
|
27
|
+
* hardlink fails, e.g. cross-device). `copy` = `cp -R` + `chown -R` (fresh
|
|
28
|
+
* thread-owned inodes). */
|
|
29
|
+
export type DepCacheMaterialization = "hardlink" | "copy";
|
|
30
|
+
|
|
31
|
+
export function depCacheMaterialization(dir: DepCacheDir): DepCacheMaterialization {
|
|
32
|
+
return dir === "node_modules" ? "hardlink" : "copy";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// -- the per-attach plan ----------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** What `materializeThreadDeps` does for one tree: `seed` = bring the cached
|
|
38
|
+
* dirs in from the warm checkout (node_modules hardlinked, per
|
|
39
|
+
* depCacheMaterialization); `install` = run the command table's install in the
|
|
40
|
+
* tree afterwards. A lockfile-diverged thread gets BOTH — the install then
|
|
41
|
+
* reconciles only the delta on top of the seed, because npm/pnpm replace a
|
|
42
|
+
* changed package with fresh inodes and can never write through a shared one
|
|
43
|
+
* (the seed's file inodes are worker1-owned with group/world write stripped).
|
|
44
|
+
* Before this the diverged case installed from an empty tree: gigabytes
|
|
45
|
+
* projected and minutes on the 1 vCPU it shares with the refresh cycle, long
|
|
46
|
+
* enough for the bot's /attach to die first. No install command
|
|
47
|
+
* (item 52's package.json-less table) leaves a diverged tree unseeded: a seed
|
|
48
|
+
* nothing can reconcile would be the WRONG deps presented as ready. */
|
|
49
|
+
export interface ThreadDepsPlan {
|
|
50
|
+
seed: boolean;
|
|
51
|
+
install: boolean;
|
|
52
|
+
why: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function planThreadDeps(input: {
|
|
56
|
+
hasDeps: boolean;
|
|
57
|
+
threadLockKey: string;
|
|
58
|
+
warmLockKey: string;
|
|
59
|
+
installCmd: string | undefined;
|
|
60
|
+
}): ThreadDepsPlan {
|
|
61
|
+
if (input.hasDeps) return { seed: false, install: false, why: "reused tree — cache already in place" };
|
|
62
|
+
if (input.threadLockKey === input.warmLockKey) {
|
|
63
|
+
return {
|
|
64
|
+
seed: true,
|
|
65
|
+
install: false,
|
|
66
|
+
why: "lockfile matches the warm checkout — shared cache, nothing to reconcile",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (input.installCmd === undefined) {
|
|
70
|
+
return {
|
|
71
|
+
seed: false,
|
|
72
|
+
install: false,
|
|
73
|
+
why: "lockfile differs and the command table has no install — nothing to reconcile with",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
seed: true,
|
|
78
|
+
install: true,
|
|
79
|
+
why: "lockfile differs from the warm checkout — seeded from the shared cache, install reconciles the delta",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `deps` field on the attach answer. `reconcile` = seeded then installed
|
|
84
|
+
* (the seed's own mechanism is secondary: the install decided the tree's
|
|
85
|
+
* contents); otherwise the seed's mechanism, or `none`. */
|
|
86
|
+
export type ThreadDepsMechanism = DepCacheMaterialization | "reconcile" | "none";
|
|
87
|
+
|
|
88
|
+
export function threadDepsMechanism(input: {
|
|
89
|
+
seeded: DepCacheMaterialization | "none";
|
|
90
|
+
installed: boolean;
|
|
91
|
+
}): ThreadDepsMechanism {
|
|
92
|
+
return input.installed ? "reconcile" : input.seeded;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Inside a HARDLINKED node_modules, the paths tools rewrite in place and so
|
|
96
|
+
* must be real copies (fresh thread-owned inodes) rather than shared
|
|
97
|
+
* read-only inodes — the same EACCES class as the build dirs, one level
|
|
98
|
+
* down. Packages themselves are immutable after install; the mutable parts
|
|
99
|
+
* are tool-managed: every TOP-LEVEL dot entry of node_modules (`.cache` —
|
|
100
|
+
* babel-loader/eslint/prettier/webpack; `.vite` + `.vitest` — vite's dep
|
|
101
|
+
* optimizer and vitest's results file; `.prisma` — the client `prisma
|
|
102
|
+
* generate` rewrites during a build; `.bin` — shims some installers
|
|
103
|
+
* regenerate; `.package-lock.json` — npm's hidden lockfile) plus any
|
|
104
|
+
* `.cache` directory nested deeper (a package's own on-disk cache, e.g.
|
|
105
|
+
* `node_modules/<loader>/.cache`). Scoped packages (`@scope`) are not dot
|
|
106
|
+
* entries. Nested dot dirs OTHER than `.cache` (a vendored `.github`, a
|
|
107
|
+
* package's `.bin`) stay shared: they are package content, not caches.
|
|
108
|
+
*
|
|
109
|
+
* The ONE top-level dot entry that is package content, not a cache, is
|
|
110
|
+
* `.pnpm` — pnpm's virtual store, where every installed package's files
|
|
111
|
+
* actually live (the top-level entries are symlinks into it). It is
|
|
112
|
+
* immutable after install, exactly like the packages it holds, so it stays
|
|
113
|
+
* hardlinked; a `.cache` nested inside it is still a cache and still copied.
|
|
114
|
+
* Swapping it for a plain copy makes every thread tree of a pnpm workspace a
|
|
115
|
+
* full copy of its dependencies (gigabytes, minutes per fresh attach) — one
|
|
116
|
+
* thread can fill a resident's disk that way.
|
|
117
|
+
*
|
|
118
|
+
* `mutableCacheFindArgv` is the exact `find` the Worker runs to enumerate
|
|
119
|
+
* those paths; `mutableCachePaths` turns its output lines into the list to
|
|
120
|
+
* replace, dropping anything already covered by a listed ancestor (the
|
|
121
|
+
* ancestor copy brings its subtree along) and anything outside the root. */
|
|
122
|
+
export function mutableCacheFindArgv(nodeModulesDir: string): string[] {
|
|
123
|
+
// `-path "<root>/.*"` (find's -path glob does not treat "/" specially)
|
|
124
|
+
// matches every top-level dot entry AND its descendants — the descendants
|
|
125
|
+
// are dropped by mutableCachePaths' ancestor rule. Deliberately not
|
|
126
|
+
// `-maxdepth`: GNU find applies -maxdepth GLOBALLY even inside parentheses
|
|
127
|
+
// (with a warning), which would cap the nested .cache search at depth 1.
|
|
128
|
+
return [
|
|
129
|
+
"find",
|
|
130
|
+
nodeModulesDir,
|
|
131
|
+
"-mindepth",
|
|
132
|
+
"1",
|
|
133
|
+
"(",
|
|
134
|
+
"-path",
|
|
135
|
+
`${nodeModulesDir}/.*`,
|
|
136
|
+
"-o",
|
|
137
|
+
"-type",
|
|
138
|
+
"d",
|
|
139
|
+
"-name",
|
|
140
|
+
".cache",
|
|
141
|
+
")",
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Top-level dot entries of node_modules that hold PACKAGE CONTENT rather than
|
|
146
|
+
* a tool's cache — shared like any package, never copied per thread. */
|
|
147
|
+
const PACKAGE_STORE_ENTRIES: ReadonlySet<string> = new Set([".pnpm"]);
|
|
148
|
+
|
|
149
|
+
export function mutableCachePaths(nodeModulesDir: string, findOutputLines: readonly string[]): string[] {
|
|
150
|
+
const root = nodeModulesDir.replace(/\/+$/, "");
|
|
151
|
+
const candidates = findOutputLines
|
|
152
|
+
.map((l) => l.trim())
|
|
153
|
+
.filter((l) => l !== "" && l.startsWith(`${root}/`))
|
|
154
|
+
.filter((l) => {
|
|
155
|
+
const rel = l.slice(root.length + 1);
|
|
156
|
+
const parts = rel.split("/");
|
|
157
|
+
if (parts.length === 1) return parts[0].startsWith(".") && !PACKAGE_STORE_ENTRIES.has(parts[0]);
|
|
158
|
+
return parts[parts.length - 1] === ".cache";
|
|
159
|
+
})
|
|
160
|
+
.sort((a, b) => a.length - b.length);
|
|
161
|
+
const kept: string[] = [];
|
|
162
|
+
for (const c of candidates) {
|
|
163
|
+
if (kept.some((k) => c.startsWith(`${k}/`))) continue;
|
|
164
|
+
kept.push(c);
|
|
165
|
+
}
|
|
166
|
+
// Preserve the find's own order among the kept paths for readable logs.
|
|
167
|
+
const order = new Map(findOutputLines.map((l, i) => [l.trim(), i] as const));
|
|
168
|
+
return kept.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What the attach answer's `deps` field reports after each dir is
|
|
172
|
+
* materialized. `node_modules` is the dependency cache the field exists to
|
|
173
|
+
* describe (`hardlink` = warm shared cache, `copy` = cp -al fell back), so
|
|
174
|
+
* it decides whenever the warm checkout has one; build-dir copies are the
|
|
175
|
+
* norm and must not mask that signal — they only fill in when nothing else
|
|
176
|
+
* has. `none` in = nothing materialized yet. */
|
|
177
|
+
export function foldDepsMechanism(
|
|
178
|
+
prev: DepCacheMaterialization | "none",
|
|
179
|
+
dir: DepCacheDir,
|
|
180
|
+
used: DepCacheMaterialization | "none",
|
|
181
|
+
): DepCacheMaterialization | "none" {
|
|
182
|
+
if (dir === "node_modules") return used;
|
|
183
|
+
return prev === "none" ? used : prev;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// One-fork materialization
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
//
|
|
190
|
+
// materializeThreadDeps used to spawn per dir: `test -d`, `test -e`, the
|
|
191
|
+
// `cp -al`/`cp -R`, a full `find -type d -exec chown` walk, a SECOND full
|
|
192
|
+
// `find -type f -exec chmod` walk, the mutable-cache `find` listing, then
|
|
193
|
+
// rm/cp/chown per mutable path — ~25 container round-trips for five dirs,
|
|
194
|
+
// all while holding the mirror mutex (which every concurrent attach and the
|
|
195
|
+
// refresh cycle queue behind). The same work now runs as TWO forks:
|
|
196
|
+
// `depCacheScript` handles all five dirs (per-dir gating, mechanism,
|
|
197
|
+
// permissions in ONE combined find walk, the mutable-cache listing) and
|
|
198
|
+
// emits one tagged line per materialized dir plus the raw listing; the DO
|
|
199
|
+
// parses with `parseDepCacheScriptOutput`, filters the listing through the
|
|
200
|
+
// unchanged `mutableCachePaths`, and `mutableCacheSwapScript` performs every
|
|
201
|
+
// swap in the second fork. The resulting ownership/permission state is
|
|
202
|
+
// byte-identical to the per-spawn version (the security posture is in the
|
|
203
|
+
// ownership and mode bits — see each block's comment); only the fork count
|
|
204
|
+
// changed.
|
|
205
|
+
|
|
206
|
+
/** What the DO reads back from one script run. `failedStep` carries the
|
|
207
|
+
* `err=` tag a failing block emitted (the old per-spawn StepError names:
|
|
208
|
+
* deps-perms — the combined chown+harden walk, formerly deps-chown +
|
|
209
|
+
* deps-harden — deps-mutable-list, deps-copy, deps-copy-chown, and the swap
|
|
210
|
+
* script's deps-mutable-rm/copy/chown). */
|
|
211
|
+
export interface DepCacheScriptParse {
|
|
212
|
+
mech: DepCacheMaterialization | "none";
|
|
213
|
+
/** Raw `find` output for the hardlinked node_modules (the exact
|
|
214
|
+
* `mutableCacheFindArgv` shape), for `mutableCachePaths`. */
|
|
215
|
+
mutableListing: string[];
|
|
216
|
+
failedStep: string | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Build the one-fork materialization script over every DEP_CACHE_DIRS entry.
|
|
220
|
+
* Per dir, exactly the old per-spawn behavior:
|
|
221
|
+
* - src missing or dst present → skip (no line emitted, mechanism unfolded);
|
|
222
|
+
* - hardlink dirs (node_modules): `cp -al`, then ONE find walk chowning
|
|
223
|
+
* DIRECTORIES to the thread user and stripping group/world write from the
|
|
224
|
+
* shared FILE inodes (`( -type d -exec chown … + ) -o ( -type f ( -perm
|
|
225
|
+
* -g+w -o -perm -o+w ) -exec chmod go-w … + )` — the -o short-circuits on
|
|
226
|
+
* the first alternative's always-true `-exec +`, so dirs get the chown and
|
|
227
|
+
* only files reach the perm test: the union of the two old walks, one
|
|
228
|
+
* traversal); then the mutable-cache listing (`mutableCacheFindArgv`,
|
|
229
|
+
* emitted as `mutable=` lines). `cp -al` failure → rm + plain-copy
|
|
230
|
+
* fallback, same as before;
|
|
231
|
+
* - copy dirs: `cp -R` + `chown -Rh` (never dereference a planted symlink). */
|
|
232
|
+
export function depCacheScript(
|
|
233
|
+
checkoutDir: string,
|
|
234
|
+
worktree: string,
|
|
235
|
+
user: string,
|
|
236
|
+
/** `nodeModulesSrc`: the deps store entry's node_modules (item 59) — the
|
|
237
|
+
* immutable source every view hardlinks from; the checkout stays the
|
|
238
|
+
* source for the build dirs (they are build output at the checkout's sha).
|
|
239
|
+
* Omitted → the checkout's own node_modules, the pre-store behavior. */
|
|
240
|
+
opts: { nodeModulesSrc?: string } = {},
|
|
241
|
+
): string {
|
|
242
|
+
const owner = shellQuote(`${user}:${user}`);
|
|
243
|
+
const blocks = DEP_CACHE_DIRS.map((dir) => {
|
|
244
|
+
const src = shellQuote(
|
|
245
|
+
dir === "node_modules" && opts.nodeModulesSrc ? opts.nodeModulesSrc : `${checkoutDir}/${dir}`,
|
|
246
|
+
);
|
|
247
|
+
const dst = shellQuote(`${worktree}/${dir}`);
|
|
248
|
+
const copyFallback = [
|
|
249
|
+
` cp -R ${src} ${dst} || { echo err=deps-copy; exit 1; }`,
|
|
250
|
+
` chown -Rh ${owner} ${dst} || { echo err=deps-copy-chown; exit 1; }`,
|
|
251
|
+
` echo 'dir:${dir}=copy'`,
|
|
252
|
+
];
|
|
253
|
+
if (depCacheMaterialization(dir) !== "hardlink") {
|
|
254
|
+
return [`if test -d ${src} && ! test -e ${dst}; then`, ...copyFallback, `fi`].join("\n");
|
|
255
|
+
}
|
|
256
|
+
const walk = `find ${dst} \\( -type d -exec chown ${owner} {} + \\) -o \\( -type f \\( -perm -g+w -o -perm -o+w \\) -exec chmod go-w {} + \\)`;
|
|
257
|
+
const listing = mutableCacheFindArgv(`${worktree}/${dir}`).map(shellQuote).join(" ");
|
|
258
|
+
return [
|
|
259
|
+
`if test -d ${src} && ! test -e ${dst}; then`,
|
|
260
|
+
` if cp -al ${src} ${dst}; then`,
|
|
261
|
+
` ${walk} || { echo err=deps-perms; exit 1; }`,
|
|
262
|
+
` if ! mlist=$(${listing}); then echo err=deps-mutable-list; exit 1; fi`,
|
|
263
|
+
` if [ -n "$mlist" ]; then printf '%s\\n' "$mlist" | sed 's/^/mutable=/'; fi`,
|
|
264
|
+
` echo 'dir:${dir}=hardlink'`,
|
|
265
|
+
` else`,
|
|
266
|
+
` rm -rf ${dst}`,
|
|
267
|
+
...copyFallback,
|
|
268
|
+
` fi`,
|
|
269
|
+
`fi`,
|
|
270
|
+
].join("\n");
|
|
271
|
+
});
|
|
272
|
+
return blocks.join("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function parseDepCacheScriptOutput(stdout: string): DepCacheScriptParse {
|
|
276
|
+
let mech: DepCacheMaterialization | "none" = "none";
|
|
277
|
+
const mutableListing: string[] = [];
|
|
278
|
+
let failedStep: string | null = null;
|
|
279
|
+
for (const raw of stdout.split("\n")) {
|
|
280
|
+
const line = raw.trim();
|
|
281
|
+
let m: RegExpExecArray | null;
|
|
282
|
+
if ((m = /^dir:([^=]+)=(hardlink|copy)$/.exec(line))) {
|
|
283
|
+
if ((DEP_CACHE_DIRS as readonly string[]).includes(m[1])) {
|
|
284
|
+
mech = foldDepsMechanism(mech, m[1] as DepCacheDir, m[2] as DepCacheMaterialization);
|
|
285
|
+
}
|
|
286
|
+
} else if ((m = /^mutable=(.+)$/.exec(line))) {
|
|
287
|
+
mutableListing.push(m[1]);
|
|
288
|
+
} else if ((m = /^err=(.+)$/.exec(line))) {
|
|
289
|
+
failedStep ??= m[1];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { mech, mutableListing, failedStep };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The per-path swaps for a hardlinked node_modules' tool-managed entries
|
|
296
|
+
* (`mutableCachePaths` output), all in one fork: `rm -rf` the shared
|
|
297
|
+
* subtree, `cp -R` the warm checkout's matching subpath (fresh inodes),
|
|
298
|
+
* `chown -Rh` to the thread user (-h: a postinstall-planted symlink is
|
|
299
|
+
* re-owned as a LINK, never followed to an out-of-tree target). Same steps,
|
|
300
|
+
* same order, same flags as the old per-spawn loop. */
|
|
301
|
+
export function mutableCacheSwapScript(
|
|
302
|
+
srcRoot: string,
|
|
303
|
+
dstRoot: string,
|
|
304
|
+
user: string,
|
|
305
|
+
paths: readonly string[],
|
|
306
|
+
): string {
|
|
307
|
+
const owner = shellQuote(`${user}:${user}`);
|
|
308
|
+
const root = dstRoot.replace(/\/+$/, "");
|
|
309
|
+
const lines: string[] = [];
|
|
310
|
+
for (const p of paths) {
|
|
311
|
+
const rel = p.slice(root.length);
|
|
312
|
+
lines.push(`rm -rf ${shellQuote(p)} || { echo err=deps-mutable-rm; exit 1; }`);
|
|
313
|
+
lines.push(`cp -R ${shellQuote(`${srcRoot}${rel}`)} ${shellQuote(p)} || { echo err=deps-mutable-copy; exit 1; }`);
|
|
314
|
+
// cp copies mode bits: a store entry's files are owner-read-only (item 59,
|
|
315
|
+
// hardened so no consumer can write through the shared inodes), and a
|
|
316
|
+
// cache the tree's own tools must rewrite in place has to be writable.
|
|
317
|
+
lines.push(`chmod -R u+w ${shellQuote(p)} || { echo err=deps-mutable-chmod; exit 1; }`);
|
|
318
|
+
lines.push(`chown -Rh ${owner} ${shellQuote(p)} || { echo err=deps-mutable-chown; exit 1; }`);
|
|
319
|
+
}
|
|
320
|
+
return lines.join("\n");
|
|
321
|
+
}
|