@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.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- 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
|
+
}
|