@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,569 @@
1
+ // The sandbox half of an `ExecuteRequest`: what a task may read and write,
2
+ // where denials are reported, and the filesystem groundwork a bind needs
3
+ // (bwrap cannot bind a path that does not exist). Shared by the cached path
4
+ // (through the executor) and the persistent path (spawned in execute-task).
5
+
6
+ import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
7
+ import { homedir } from 'node:os'
8
+ import path from 'node:path'
9
+ import type { ExecConfig } from '../config.js'
10
+ import {
11
+ bindableWrites,
12
+ initSandbox,
13
+ probeSandbox,
14
+ punchWalls,
15
+ resolveSandboxConfig,
16
+ type ResolvedSandboxConfig,
17
+ thrownReason,
18
+ toRealPath,
19
+ type ExecuteRequest,
20
+ isMountableLiteral,
21
+ type SandboxViolation,
22
+ } from '../exec/index.js'
23
+ import type { TaskNode } from '../graph/index.js'
24
+ import { grantPrefix, UserError } from '../util/index.js'
25
+ import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
26
+
27
+ /**
28
+ * Arm the sandbox runtime for a run, lazily: only when at least one task
29
+ * opts in through its `sandbox: {...}` block. A task that needs sandboxing
30
+ * on a platform without one is a hard error, so it never silently runs
31
+ * unsandboxed. Returns whether the runtime was armed — the run resets it
32
+ * at the end (`resetSandbox`), or the next run inits on top of stale
33
+ * proxy state.
34
+ */
35
+ export interface SandboxArmer {
36
+ /**
37
+ * Probe availability and start the runtime, once; every later call
38
+ * shares the same promise. Throws the `sandbox not available` UserError
39
+ * when the platform cannot host it.
40
+ */
41
+ arm(): Promise<void>
42
+ /** True once `arm()` has completed — the run then owns a runtime to reset. */
43
+ readonly armed: boolean
44
+ }
45
+
46
+ /**
47
+ * What SRT's run-wide `initialize()` is armed with, folded from every
48
+ * sandboxed task in the graph — or `null` when the run has none.
49
+ *
50
+ * Split out of `prepareSandbox` (pure motion) because it is the only
51
+ * part of the arming that can be READ without starting a sandbox: the
52
+ * call itself is observable solely through a live runtime, so the values
53
+ * it carries had no witness of any kind and three separate widenings of
54
+ * them survived a whole-suite mutation sweep (item 537).
55
+ */
56
+ export interface SandboxRunUnion {
57
+ /** Domains the run's filtering proxy will accept, in declaration order. */
58
+ domains: string[]
59
+ /** Whether SRT's all-or-nothing `socket(AF_UNIX)` filter is lifted for the run. */
60
+ unixSockets: boolean
61
+ /** Whether EVERY sandboxed task accepts the weaker nested profile. */
62
+ weakerNested: boolean
63
+ }
64
+
65
+ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | null {
66
+ const sandboxed = [...nodes].filter((n) => n.config.exec?.sandbox !== undefined)
67
+ if (sandboxed.length === 0) return null
68
+ const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
69
+ const domains = new Set<string>()
70
+ let unixSockets = false
71
+ for (const n of sandboxed) {
72
+ const allow = n.config.exec?.sandbox?.allow
73
+ const net = allow?.network
74
+ // `network: true` is deliberately absent from the union: it SKIPS the
75
+ // proxy rather than going through it, so folding it in as `*` would
76
+ // widen the allowlist every OTHER task in the run is filtered against.
77
+ if (Array.isArray(net)) for (const d of net) domains.add(d)
78
+ const sockets = allow?.unixSockets
79
+ if (sockets === true || (Array.isArray(sockets) && sockets.length > 0)) unixSockets = true
80
+ const lb = allow?.localBinding
81
+ if (Array.isArray(lb) && lb.length > 0 && process.platform === 'linux') unixSockets = true
82
+ }
83
+ return { domains: [...domains], unixSockets, weakerNested }
84
+ }
85
+
86
+ /**
87
+ * Prepare the sandbox for a run WITHOUT starting it. Starting is
88
+ * `arm()`, and it happens on the first task that actually executes inside
89
+ * a sandbox — not up front. Up front, every run of a sandboxed workspace
90
+ * paid the probe (a sandboxed `true` through the runtime, ~300–400 ms on
91
+ * Linux, the runtime module's own load included) even when every task was
92
+ * a cache hit and nothing executed; measured 2026-09-10 on this repo's
93
+ * own warm gate: `classify + probe` 288 ms of a 798 ms run. A hit needs
94
+ * no sandbox, so a hit pays nothing.
95
+ *
96
+ * The domain union is computed here from every sandboxed node, because
97
+ * SRT runs ONE filtering proxy per run and checks every request against
98
+ * the allowlist given to `initialize()` — never the per-call one
99
+ * (`sandbox-manager.js` 0.0.75). A task that declares no domains still
100
+ * reaches nothing: its profile is not given the proxy's port at all.
101
+ * The unix-socket allowance is per run the same way: SRT's Linux seccomp
102
+ * filter on `socket(AF_UNIX)` is all-or-nothing and read at
103
+ * `initialize()`, so a task declaring `unixSockets`, or a `localBinding`
104
+ * port list (its bridge is a unix socket the task's side creates), lifts
105
+ * it for the run. macOS keeps per-task precision through vx's own rules.
106
+ */
107
+ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
108
+ const union = sandboxRunUnion(nodes)
109
+ if (union === null) return null
110
+ const { domains, unixSockets, weakerNested } = union
111
+ let pending: Promise<void> | undefined
112
+ let armed = false
113
+ return {
114
+ get armed() {
115
+ return armed
116
+ },
117
+ arm() {
118
+ pending ??= (async () => {
119
+ try {
120
+ const avail = await probeSandbox({ weakerNested })
121
+ if (!avail.available) throw new UserError(`sandbox not available: ${avail.reason}`)
122
+ await initSandbox({
123
+ allowedDomains: domains,
124
+ ...(unixSockets ? { allowAllUnixSockets: true } : {}),
125
+ })
126
+ } catch (err) {
127
+ // A throw from the runtime itself (its bridge needs socat, which
128
+ // the dependency check does not cover) gets the same one-line
129
+ // verdict as a refused probe, not an internal error with a stack.
130
+ if (err instanceof UserError) throw err
131
+ throw new UserError(`sandbox not available: ${thrownReason(err)}`)
132
+ }
133
+ armed = true
134
+ })()
135
+ return pending
136
+ },
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The sandbox half of an `ExecuteRequest` for one task, shared by the
142
+ * cached path (through the executor) and the persistent path (spawned here).
143
+ *
144
+ * The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
145
+ * are two different questions: `cache.inputs` says what INVALIDATES the
146
+ * task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
147
+ * coupled them in both directions — a declaration added for caching
148
+ * silently widened the sandbox, and a path the task needed had to be
149
+ * laundered through the cache key to get it. A sandboxed task declares
150
+ * its own reads and writes.
151
+ *
152
+ * `node_modules` is the one grant core still makes: it is where the task's
153
+ * own PATH gets its `.bin` entries, and denying it made the sandbox
154
+ * unusable for anything that imports a dependency (`bun build --compile`
155
+ * died with only `error: An unknown error occurred (Unexpected)`; owner
156
+ * call 2026-09-04). Declaring `cache` may NARROW that grant, never widen
157
+ * anyone's (2026-09-24): `keyed` is the set of project directories the
158
+ * task's key answers for (keyed-projects.ts), and `undefined` for a task
159
+ * with no `cache`, which has no key to be stale.
160
+ */
161
+ export async function sandboxRequestFor(
162
+ node: TaskNode,
163
+ sandbox: NonNullable<ExecConfig['sandbox']>,
164
+ workspaceRoot: string,
165
+ keyed: ReadonlySet<string> | undefined,
166
+ /** The projects nested in this one (their dirs): a wall its grants do not reach. */
167
+ nested: readonly string[] = [],
168
+ ): Promise<SandboxRequest> {
169
+ const depDirs = [
170
+ path.join(node.projectDir, 'node_modules'),
171
+ path.join(workspaceRoot, 'node_modules'),
172
+ ]
173
+ // A workspace dependency is a SYMLINK in `node_modules` pointing at a
174
+ // sibling project, so granting `node_modules` grants a link whose
175
+ // target is outside it. That target is a dependency, not a reach-out:
176
+ // no project config should have to name a sibling to import what its
177
+ // own `package.json` depends on (owner, 2026-09-05) — when the key
178
+ // moves with it.
179
+ const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
180
+ depDirs.push(...links.granted)
181
+ // bwrap cannot --bind a path that does not exist: the bind silently
182
+ // becomes a no-op and writes to it appear to succeed but never land.
183
+ // Pre-create what the task said it will write — after the grants are
184
+ // resolved, which refuses one that leaves the project through a link
185
+ // before anything is created along it (item 1003).
186
+ const config = wallOff(resolveSandboxConfig(sandbox, node.projectDir), workspaceRoot, [
187
+ ...nested,
188
+ path.join(workspaceRoot, '.git'),
189
+ path.join(workspaceRoot, '.vx'),
190
+ ])
191
+ const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
192
+ const request: NonNullable<ExecuteRequest['sandbox']> = {
193
+ // Only what the task declared, plus node_modules. Write paths are
194
+ // readable too: a task that writes `dist/x` expects to read it back
195
+ // (`tsc --incremental` re-reads .tsbuildinfo).
196
+ baseAllowRead: depDirs,
197
+ // Enforcement anchors at the WORKSPACE ROOT: a task may not leave
198
+ // its project, so every sibling and every root file is denied.
199
+ // Reporting is a different question — see `reportWithin` below.
200
+ baseDenyRead: [workspaceRoot],
201
+ // …but only denials INSIDE the project are worth reporting. A task
202
+ // bumping into the wall is the sandbox working, not a finding: the
203
+ // walk `bun build --compile` makes from `/` down to its cwd lists
204
+ // every directory on the way (traced 2026-09-05) and no config can
205
+ // declare that away. What DOES matter is an undeclared touch of the
206
+ // project's own files — that is the one that breaks the cache key,
207
+ // because the key folds this project's inputs.
208
+ reportWithin: node.projectDir,
209
+ // …and of a dependency withheld above: the task reached for it
210
+ // through its own `node_modules`, and that read is the stale hit the
211
+ // withholding exists to stop, not the wall.
212
+ reportLinked: links.withheld.map((w) => w.dir),
213
+ config,
214
+ }
215
+ return { sandbox: request, placeholders, withheld: links.withheld }
216
+ }
217
+
218
+ /**
219
+ * The sandbox half of the request, plus the empty files vx created so a
220
+ * literal write grant had something to bind (`placeholders`). They are
221
+ * vx's, not the task's, until the task writes them: `sweepPlaceholders`
222
+ * takes back the ones it never touched. `withheld` names the linked
223
+ * dependencies a cached task was denied, for the hint a denial under one
224
+ * earns (`withheldLinkLine`).
225
+ */
226
+ export interface SandboxRequest {
227
+ sandbox: NonNullable<ExecuteRequest['sandbox']>
228
+ placeholders: Placeholder[]
229
+ withheld: WithheldLink[]
230
+ }
231
+
232
+ /** An empty file vx created for a bind, and its mtime at creation. */
233
+ export interface Placeholder {
234
+ path: string
235
+ mtimeMs: number
236
+ }
237
+
238
+ /** A workspace link whose target the task's key does not answer for. */
239
+ export interface WithheldLink {
240
+ /** The canonical target, inside the workspace root. */
241
+ dir: string
242
+ /** The name it is installed under (`@x/ui`). */
243
+ name: string
244
+ /** The target and the link, workspace-relative POSIX, for the hint. */
245
+ target: string
246
+ link: string
247
+ }
248
+
249
+ /**
250
+ * Where the workspace links in `dirs` actually point, split into what the
251
+ * task is granted and what it is not.
252
+ *
253
+ * One level deep, plus one level inside a `@scope/` directory — the shape
254
+ * a package manager writes. Anything already inside a granted directory
255
+ * is dropped; what is left is a sibling project's real path.
256
+ *
257
+ * A link to the task's OWN project, or to a directory holding it, is
258
+ * dropped too: npm and Yarn classic link every workspace package at the
259
+ * root, the task's own included, and granting that target handed the
260
+ * project back whole whatever its `allow.read` said — an undeclared read
261
+ * of its own file ran unreported, and an edit to it was a stale hit.
262
+ *
263
+ * With `keyed` given (a cached task), a target inside the workspace root
264
+ * is granted only when it IS a keyed project's directory; every other one
265
+ * is withheld, since an edit there would not move the key. A target
266
+ * outside the root lies outside the deny anchor, so granting it is a no-op
267
+ * either way. Every side of every comparison is canonical: a target always
268
+ * is, and a directory under a root reached through a link (macOS's `/var`)
269
+ * is not.
270
+ */
271
+ async function linkedDeps(
272
+ dirs: readonly string[],
273
+ projectDir: string,
274
+ workspaceRoot: string,
275
+ keyed: ReadonlySet<string> | undefined,
276
+ ): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
277
+ const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
278
+ const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
279
+ const found: Array<[target: string, link: string, name: string]> = []
280
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
281
+ for (const e of entries) {
282
+ const full = path.join(dir, e.name)
283
+ if (e.isSymbolicLink()) {
284
+ const target = await realpath(full).catch(() => undefined)
285
+ if (target !== undefined && !dirs.some((d) => within(target, d)) && !within(self, target)) {
286
+ found.push([target, full, scope + e.name])
287
+ }
288
+ } else if (scope === '' && e.isDirectory() && e.name.startsWith('@')) {
289
+ found.push(...(await scan(full, `${e.name}/`)))
290
+ }
291
+ }
292
+ return found
293
+ }
294
+ // Merged in `dirs` order, so the link a hint names is the project's own
295
+ // when both have one.
296
+ const targets = new Map<string, { link: string; name: string }>()
297
+ for (const found of await Promise.all(dirs.map((d) => scan(d, '')))) {
298
+ for (const [target, link, name] of found) {
299
+ if (!targets.has(target)) targets.set(target, { link, name })
300
+ }
301
+ }
302
+ if (keyed === undefined) return { granted: [...targets.keys()], withheld: [] }
303
+ const keyedDirs = new Set(await Promise.all([...keyed].map((d) => realpath(d))))
304
+ const granted: string[] = []
305
+ const withheld: WithheldLink[] = []
306
+ for (const [target, { link, name }] of targets) {
307
+ if (!within(target, root) || keyedDirs.has(target)) granted.push(target)
308
+ else
309
+ withheld.push({
310
+ dir: target,
311
+ name,
312
+ target: posixRel(root, target),
313
+ link: posixRel(workspaceRoot, link),
314
+ })
315
+ }
316
+ return { granted, withheld }
317
+ }
318
+
319
+ /**
320
+ * The line a task denied a withheld dependency gets beside the denial:
321
+ * what it reached, through which link, and the two ways to make the key
322
+ * answer for it. Added only when a violation already sits under the
323
+ * target, so it never reddens a pass.
324
+ */
325
+ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
326
+ return (
327
+ `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key folds no task of ` +
328
+ `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
329
+ `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
330
+ `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
331
+ `\`cache.inputs.workspaceFiles\`).`
332
+ )
333
+ }
334
+
335
+ /** The withheld dependencies a reported denial lies under: each earns its hint. */
336
+ export function reachedWithheld(
337
+ withheld: readonly WithheldLink[],
338
+ violations: readonly SandboxViolation[],
339
+ ): WithheldLink[] {
340
+ return withheld.filter((w) =>
341
+ violations.some((v) => v.path !== undefined && within(v.path, w.dir)),
342
+ )
343
+ }
344
+
345
+ /** `p` relative to `from`, with `/` separators. */
346
+ function posixRel(from: string, p: string): string {
347
+ return path.relative(from, p).split(path.sep).join('/')
348
+ }
349
+
350
+ /**
351
+ * `p` is `dir` or below it — by path, so a sibling sharing `dir`'s name
352
+ * prefix is not. `/` already ends in the separator, and a link to it
353
+ * holds every project.
354
+ */
355
+ function within(p: string, dir: string): boolean {
356
+ return p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
357
+ }
358
+
359
+ /**
360
+ * Ensure each declared write path exists on the host as either an empty
361
+ * file (a literal path) or a directory (a glob's static prefix, or a
362
+ * literal ending in `/`) so bwrap's --bind can find a real fs entry to
363
+ * mount. Without this, writes inside the sandbox to a non-existent
364
+ * allowWrite path silently disappear (bwrap creates a tmpfs that
365
+ * evaporates on exit).
366
+ *
367
+ * A literal that names nothing yet is a FILE — `dist/vx` for `bun build
368
+ * --outfile dist/vx` — and the task that meant a directory (`write:
369
+ * ['dist']`, then `mkdir -p dist`) met "File exists" from its own tool,
370
+ * with the empty file left behind for every later run to meet again
371
+ * (its `dist/**` clean matches nothing under a file; 2026-09-16). So a
372
+ * directory is spelled `dist/`, and the files created here are returned
373
+ * so the caller can take back the ones the task never wrote.
374
+ *
375
+ * `cleanOutputs` ran just before this in the cache-enabled path, so
376
+ * we know any stale content was wiped; what's left is to materialize
377
+ * the empty skeleton.
378
+ */
379
+ async function prepareOutputsForBind(
380
+ projectDir: string,
381
+ outputs: readonly string[],
382
+ ): Promise<Placeholder[]> {
383
+ const placeholders: Placeholder[] = []
384
+ for (const g of outputs) {
385
+ const hasWildcard = !isMountableLiteral(g)
386
+ // A grant OUTSIDE the project is the user's own path — never joined
387
+ // onto the project dir, which would create a literal `~` there. Its
388
+ // directory is still created when the grant is a glob, because then
389
+ // the static prefix is unambiguously a directory and bwrap cannot
390
+ // bind one that does not exist: `~/.bun/install/cache/**` on a runner
391
+ // that has never populated it silently granted nothing, and the task
392
+ // failed with the tool's own confusing message (`bun build --compile`
393
+ // reported a network error for an unwritable cache; 2026-09-05).
394
+ if (path.isAbsolute(g) || g.startsWith('~')) {
395
+ if (!hasWildcard) continue
396
+ const abs = expandHome(grantPrefix(g))
397
+ await mkdir(abs, { recursive: true }).catch(() => undefined)
398
+ continue
399
+ }
400
+ if (hasWildcard || g.endsWith('/')) {
401
+ const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
402
+ await mkdir(abs, { recursive: true })
403
+ } else {
404
+ const abs = path.join(projectDir, g)
405
+ // Whatever is already there is what the task meant — a grant on the
406
+ // project dir itself is a directory, and touching it as a file is
407
+ // an EISDIR, not a missing bind. `lstat`, and an exclusive create: a
408
+ // dangling link at the grant made vx, unsandboxed, create the empty
409
+ // file at the link's target in another project (item 1003).
410
+ if (await lstat(abs).catch(() => undefined)) continue
411
+ await mkdir(path.dirname(abs), { recursive: true })
412
+ await writeFile(abs, '', { flag: 'wx' })
413
+ placeholders.push({ path: abs, mtimeMs: (await lstat(abs)).mtimeMs })
414
+ }
415
+ }
416
+ return placeholders
417
+ }
418
+
419
+ /**
420
+ * The walls a project's grants stop at: its nested projects, the
421
+ * repository and vx's directory (item 1010). A read grant around one is
422
+ * punched (`punchWalls`); a write grant whose bind would hold one — a file
423
+ * grant binds its directory on Linux, so `write: ['out.txt']` in a root
424
+ * project bound the whole workspace writable, `.git` included — is
425
+ * refused, as there is no writable bind that leaves a wall out.
426
+ */
427
+ function wallOff(
428
+ config: ResolvedSandboxConfig,
429
+ workspaceRoot: string,
430
+ raw: readonly string[],
431
+ ): ResolvedSandboxConfig {
432
+ const walls = raw.map(toRealPath)
433
+ const home = toRealPath(workspaceRoot)
434
+ for (const bind of bindableWrites(config.allowWrite)) {
435
+ // A bind outside the workspace is the user's own path (`/tmp/x`,
436
+ // `~/.cache/y`), spelled there on purpose; only one inside is judged.
437
+ if (bind !== home && !bind.startsWith(home + path.sep)) continue
438
+ const wall = walls.find((w) => w === bind || w.startsWith(bind + path.sep))
439
+ if (wall === undefined) continue
440
+ throw new UserError(
441
+ `exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
442
+ `project's directory, the repository or vx's own. A file grant binds its directory; ` +
443
+ `grant a directory of the task's own instead, such as "dist/".`,
444
+ )
445
+ }
446
+ return { ...config, allowRead: config.allowRead.flatMap((r) => punchWalls(r, walls)) }
447
+ }
448
+
449
+ /**
450
+ * Remove the placeholder files the task never wrote — still empty, mtime
451
+ * untouched — and return their paths. What the task wrote is its output
452
+ * and stays; what it did not is vx's own litter, and litter under a
453
+ * declared output would be archived as the task's (an empty `dist/vx`
454
+ * saved as a build) or, as a file where the task wanted a directory,
455
+ * would fail every later run the same way.
456
+ */
457
+ export async function sweepPlaceholders(placeholders: readonly Placeholder[]): Promise<string[]> {
458
+ const untouched: string[] = []
459
+ for (const p of placeholders) {
460
+ // `lstat`: a link the task put in the placeholder's place is its
461
+ // output, never vx's litter to remove (item 1003).
462
+ const st = await lstat(p.path).catch(() => undefined)
463
+ if (st === undefined || !st.isFile() || st.size !== 0 || st.mtimeMs !== p.mtimeMs) continue
464
+ await rm(p.path, { force: true })
465
+ untouched.push(p.path)
466
+ }
467
+ return untouched
468
+ }
469
+
470
+ /**
471
+ * One sweep, however many askers, each getting the SAME list.
472
+ *
473
+ * A failed persistent task has two askers — the child's exit handler and
474
+ * the readiness failure that reports the hint — and a second
475
+ * `sweepPlaceholders` cannot serve the later one: the first one's `rm`
476
+ * already happened, so it finds nothing and names nothing. Collecting
477
+ * both lists and unioning them does not close it either, because a sweep
478
+ * still between its `rm` and its return has published nothing yet, and
479
+ * the union is then empty on both sides. Sharing the one promise is what
480
+ * makes the answer independent of who asked first.
481
+ */
482
+ export function placeholderSweeper(placeholders: readonly Placeholder[]): () => Promise<string[]> {
483
+ let once: Promise<string[]> | undefined
484
+ return () => (once ??= sweepPlaceholders(placeholders))
485
+ }
486
+
487
+ /**
488
+ * The line a failed task gets for a placeholder it never wrote. Not a
489
+ * diagnosis — the task may have died before its first write — but the
490
+ * one clue to the trap: a grant that meant a directory is bound as a
491
+ * file, and the task's own `mkdir` says only "File exists". Added when
492
+ * the task already failed and the sandbox reported nothing else, so it
493
+ * never reddens a pass and never buries a real denial.
494
+ */
495
+ export function untouchedPlaceholderLine(projectDir: string, placeholder: string): string {
496
+ const rel = path.relative(projectDir, placeholder).split(path.sep).join('/')
497
+ return (
498
+ `vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
499
+ `file, which the task never wrote (removed again). If the task creates a directory there ` +
500
+ `("File exists" from its own mkdir), spell the grant \`${rel}/\` — a literal without the ` +
501
+ `slash is a file.`
502
+ )
503
+ }
504
+
505
+ /** `~/x` against the user's home; anything else unchanged. */
506
+ function expandHome(p: string): string {
507
+ return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p
508
+ }
509
+
510
+ /** Where a command may have written: nothing a key reads, its own project, or any project. */
511
+ export type WriteReach = 'none' | 'project' | 'workspace'
512
+
513
+ /**
514
+ * Where a task that ran a command may have written files no declaration
515
+ * names — the files whose run-start facts (the git snapshot, its index
516
+ * OIDs, the `package.json` digest) a later key must not reuse (item 743).
517
+ *
518
+ * A cached task declares its outputs and is held to them. A task with no
519
+ * `cache` block can declare none, so it may have written anywhere
520
+ * `commandWriteReach` says. An unsandboxed write into another project is
521
+ * out of contract, as it is for a cached task.
522
+ */
523
+ export function undeclaredWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
524
+ if (node.config.cache !== undefined) return 'none'
525
+ return commandWriteReach(node, workspaceRoot)
526
+ }
527
+
528
+ /**
529
+ * Where a task's command may write at all: anywhere in its own project
530
+ * (`'project'`), unless a sandbox bounds it — no write grant writes nothing
531
+ * a key reads (`'none'`), and a grant that leaves the project for elsewhere
532
+ * in the workspace reaches every project (`'workspace'`). A grant outside
533
+ * the workspace reaches no input. A cached task's reach is where it may
534
+ * rewrite its own inputs in place (a formatter), which the stability gate
535
+ * reads (stable-keys.ts).
536
+ */
537
+ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
538
+ const exec = node.config.exec
539
+ if (exec === undefined) return 'none'
540
+ if (exec.sandbox === undefined) return 'project'
541
+ let reach: 'none' | 'project' = 'none'
542
+ for (const grant of exec.sandbox.allow?.write ?? []) {
543
+ const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
544
+ if (within(abs, node.projectDir)) reach = 'project'
545
+ else if (within(abs, workspaceRoot) || within(workspaceRoot, abs)) return 'workspace'
546
+ }
547
+ return reach
548
+ }
549
+
550
+ /**
551
+ * May this task's command rewrite a file the workspace fingerprint folds
552
+ * (a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
553
+ * an unsandboxed task may only when the root is its own project (`pnpm
554
+ * install` without `--frozen-lockfile` in a root task); a sandboxed one
555
+ * when a write grant covers one. Anyone else writing there crosses a
556
+ * project boundary, out of contract.
557
+ */
558
+ export function mayWriteFingerprint(node: TaskNode, workspaceRoot: string): boolean {
559
+ const exec = node.config.exec
560
+ if (exec === undefined) return false
561
+ if (exec.sandbox === undefined) return node.projectDir === workspaceRoot
562
+ for (const grant of exec.sandbox.allow?.write ?? []) {
563
+ const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
564
+ for (const f of WORKSPACE_FINGERPRINT_FILES) {
565
+ if (within(path.join(workspaceRoot, f), abs)) return true
566
+ }
567
+ }
568
+ return false
569
+ }
@@ -0,0 +1,44 @@
1
+ // The save lane: a miss's cache save runs OFF the execution slot. The
2
+ // slot is CPU-shaped (`--concurrency`) and the save is not — pack, write,
3
+ // rename and one index transaction, ~2.5 ms of mostly I/O per one-file
4
+ // artifact against ~5 ms of execution on the 1,000-project bench
5
+ // (2026-09-10) — so holding the slot for it serialized I/O behind CPU.
6
+ // A bounded number run at once (memory: each pack holds an artifact's
7
+ // bytes); the scheduler finishes a task only once its save has settled
8
+ // (`settledOf`), so a run ends with every save in the cache; and a save
9
+ // that fails degrades to a miss next time, said once — the task's work
10
+ // ran.
11
+
12
+ export interface SaveLane {
13
+ /**
14
+ * Start `save` now if the lane has room, else when one finishes. Resolves
15
+ * once the save has settled — a failure is reported and resolves too —
16
+ * for the one reader that must wait for the entry: a duplicate of the
17
+ * same task in another run (admission's in-flight join).
18
+ */
19
+ defer(save: () => Promise<void>): Promise<void>
20
+ }
21
+
22
+ export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
23
+ const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
24
+ const running = new Set<Promise<void>>()
25
+ const start = (save: () => Promise<void>, settle: () => void): void => {
26
+ const p = save()
27
+ .catch(onError)
28
+ .then(() => {
29
+ running.delete(p)
30
+ settle()
31
+ const next = queue.shift()
32
+ if (next !== undefined) start(next.save, next.settle)
33
+ })
34
+ running.add(p)
35
+ }
36
+ return {
37
+ defer(save) {
38
+ return new Promise<void>((settle) => {
39
+ if (running.size < cap) start(save, settle)
40
+ else queue.push({ save, settle })
41
+ })
42
+ },
43
+ }
44
+ }