@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.
Files changed (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +18 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  126. package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,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
+ }