@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
package/src/config.ts CHANGED
@@ -14,13 +14,34 @@ export interface WorkspaceConfig {
14
14
  */
15
15
  timeout?: number
16
16
  /**
17
- * Plugins registered for this workspace. Each plugin is an
18
- * in-process subscriber on the run event bus, installed once per
19
- * `vx run`. See `docs/design/extension-protocol-2026-06.md` §5.
17
+ * Evict from the local cache at the end of every run that writes to it,
18
+ * by the same policy `vx cache prune` takes as flags: entries not used
19
+ * for `olderThan` (`30d`, `12h`), then least-recently-used entries
20
+ * until the cache is under `maxSize` (`10G`, `500MB`). Either or both.
21
+ * Omitted → nothing is evicted unless you run `vx cache prune`. Never
22
+ * folded into a cache key.
23
+ */
24
+ cacheRetention?: { olderThan?: string; maxSize?: string }
25
+ /**
26
+ * Plugins registered for this workspace, consulted in this order once
27
+ * per `vx run`: each fills any of the pipeline stages and capabilities
28
+ * below, and core's own executor and cache store sit at the tail of
29
+ * every list. See `docs/design/pipeline-2026-09.md`.
20
30
  */
21
31
  plugins?: readonly Plugin[]
22
32
  }
23
33
 
34
+ /**
35
+ * The runtime brand `definePlugin` stamps on a plugin: the name of the
36
+ * package it was defined in. The workspace loader accepts a plugin only
37
+ * when this is present and equals `name`, so a plugin's name is its
38
+ * package name and nothing else — no field to set, no override. A
39
+ * registry symbol on purpose: a plugin package imports its own copy of
40
+ * `@vzn/vx` beside a compiled binary's, and the two copies must stamp
41
+ * and check the same key.
42
+ */
43
+ export const PLUGIN_PACKAGE: unique symbol = Symbol.for('vx.plugin.package')
44
+
24
45
  /**
25
46
  * Structural plugin shape — any subset of the pipeline stages
26
47
  * (`config`/`project`/`graph`/`key`/`schedule`), run-level capabilities
@@ -32,20 +53,60 @@ export interface WorkspaceConfig {
32
53
  * before consulting capabilities.
33
54
  */
34
55
  export interface Plugin {
56
+ /** The package name — set by `definePlugin`, never by the plugin. */
35
57
  readonly name: string
36
58
  config?(workspace: unknown, ctx: unknown): unknown
37
59
  project?(config: unknown, ctx: unknown): unknown
38
60
  graph?(nodes: unknown, ctx: unknown): unknown
39
61
  key?(task: unknown, ctx: unknown): unknown
40
62
  schedule?(nodes: unknown, ctx: unknown): unknown
63
+ admit?(task: unknown, ctx: unknown): unknown
41
64
  readonly commands?: Readonly<Record<string, unknown>>
42
65
  cache?(ctx: unknown): unknown
43
66
  executor?(ctx: unknown): unknown
44
67
  telemetry?(ctx: unknown): unknown
45
68
  setup?(ctx: unknown): void | Promise<void>
46
69
  teardown?(): void | Promise<void>
70
+ readonly fingerprint?: unknown
47
71
  }
48
72
 
73
+ /**
74
+ * Every hook a plugin may fill, in pipeline order — THE list. The loader's
75
+ * "must be a function" and "at least one of" checks, the host's stage gate
76
+ * and `vx info`'s seam column all read it, so a stage added here is a stage
77
+ * everywhere: `admit` reached `vx info` a day late (2026-09-12) because
78
+ * that column kept its own copy, and the loader kept a third. The type
79
+ * pin below refuses a list that drifts from `Plugin`'s keys either way.
80
+ */
81
+ export const PLUGIN_HOOKS = [
82
+ 'config',
83
+ 'project',
84
+ 'graph',
85
+ 'key',
86
+ 'fingerprint',
87
+ 'schedule',
88
+ 'admit',
89
+ 'executor',
90
+ 'cache',
91
+ 'telemetry',
92
+ 'setup',
93
+ 'commands',
94
+ 'teardown',
95
+ ] as const
96
+ export type PluginHook = (typeof PLUGIN_HOOKS)[number]
97
+ /** The hooks that are functions; `commands` and `fingerprint` are objects. */
98
+ export const PLUGIN_FUNCTION_HOOKS = PLUGIN_HOOKS.filter(
99
+ (h): h is Exclude<PluginHook, 'commands' | 'fingerprint'> =>
100
+ h !== 'commands' && h !== 'fingerprint',
101
+ )
102
+ type PluginHookKeys = Exclude<keyof Plugin, 'name'>
103
+ type HooksNotListed = Exclude<PluginHookKeys, PluginHook>
104
+ type HooksNotOnPlugin = Exclude<PluginHook, PluginHookKeys>
105
+ // Both `never`, or this line does not compile: the list and the type agree.
106
+ const _pluginHooksMatch: [HooksNotListed, HooksNotOnPlugin] extends [never, never] ? true : never =
107
+ true
108
+ void _pluginHooksMatch
109
+
49
110
  export interface ProjectConfig {
50
111
  /** Tasks declared by this project, keyed by task name. */
51
112
  tasks?: Record<string, TaskConfig>
@@ -94,123 +155,129 @@ export interface TaskConfig {
94
155
  * runs.
95
156
  */
96
157
  cache?: CacheConfig
97
- /**
98
- * Sandbox configuration. **Sandbox is opt-in per task.**
99
- * - omitted entirely → task runs unsandboxed.
100
- * - `sandbox: {}` → opts in with the minimum baseline (only
101
- * resolved inputs readable, only static-prefix of outputs
102
- * writable, network blocked).
103
- * - `sandbox: { ... }` → opts in with the explicit options below.
104
- *
105
- * No inheritance, no workspace defaults, no built-in escapes. The
106
- * sandbox sees STRICTLY the union of resolved `cache.inputs.files`
107
- * + this block's `allowRead` (for reads) and the static prefix of
108
- * `cache.outputs.files` + this block's `allowWrite` (for writes).
109
- * If a task needs `node_modules` or `/tmp`, declare them here
110
- * explicitly.
111
- *
112
- * **Policy: fail on violation.** If the sandbox detects an
113
- * undeclared read/write (macOS log monitor) or the child returns
114
- * non-zero because it couldn't reach a path it expected (Linux
115
- * structural deny), the task fails. No cache is written for a
116
- * failed task.
117
- *
118
- * Activation requires `exec` (group tasks have no command to wrap)
119
- * and is silently skipped for persistent tasks (dev servers need
120
- * unrestricted network + an indefinite process).
121
- */
122
- sandbox?: SandboxConfig
123
158
  }
124
159
 
125
160
  /**
126
- * Per-task sandbox config. Mirrors the user-facing surface of
127
- * `@anthropic-ai/sandbox-runtime`'s `SandboxRuntimeConfig`, minus
128
- * deployment-only fields (binary paths, proxy ports, ripgrep config).
161
+ * What a sandboxed task may do, declared as CAPABILITIES.
162
+ *
163
+ * One shape for the whole policy. You say what the task is allowed to
164
+ * touch; vx decides how each capability is realised — some become sandbox
165
+ * runtime config, some become rules in the OS policy, and one the platform
166
+ * cannot express is an error rather than a silent no-op. None of that
167
+ * reaches the config.
129
168
  *
130
- * Path-list fields (`allowRead`, `allowWrite`, `network.allowUnixSockets`)
131
- * accept:
132
- * - relative paths → resolved against the project dir
133
- * - absolute paths → used as-is (`/etc/passwd`, `/tmp`)
134
- * - tilde paths → expanded against the user's home (`~/.npmrc`)
169
+ * The baseline (`sandbox: {}`) grants almost nothing: the task reads
170
+ * nothing, writes nothing and reaches no network — NOT EVEN ITS OWN
171
+ * PROJECT DIRECTORY, which is why `allow: { read: ['.'] }` opens almost
172
+ * every real block. The one grant vx makes for you is dependencies:
173
+ * `node_modules`, and through it the real path of every workspace
174
+ * package linked there.
135
175
  *
136
- * No globs in path lists — bwrap on Linux only accepts path prefixes.
176
+ * `cache` grants no access in either direction: `cache.inputs` says what
177
+ * INVALIDATES a task, `allow` says what it may TOUCH, and deriving one
178
+ * from the other coupled them both ways — a declaration added for
179
+ * caching silently widened the sandbox, and a path the task needed had
180
+ * to be laundered through the cache key to get it (owner, 2026-09-05).
181
+ * A declared `cache.outputs` is NOT a write grant; the request
182
+ * `sandbox-request.ts` builds carries no write of its own and binds
183
+ * `allow.write` alone. This comment claimed the opposite — here and on
184
+ * both grant fields — while the file beside it and `schema.md` both said
185
+ * the truth, and no test read the bare baseline it described (item 443).
186
+ * That case has a test of its own now.
187
+ *
188
+ * Enforcement anchors at the WORKSPACE ROOT, so a task cannot leave its
189
+ * project whatever it declares here. The full account, including what a
190
+ * write grant costs on Linux, is in `docs/schema.md` under
191
+ * `exec.sandbox`.
192
+ *
193
+ * Paths are project-relative, absolute, or `~`-expanded — and are path
194
+ * PREFIXES, never globs (a glob cannot be enforced by a mount).
137
195
  */
138
196
  export interface SandboxConfig {
139
- // === Filesystem ===
197
+ /** Capabilities granted on top of the task's own declarations. */
198
+ allow?: SandboxGrants
140
199
  /**
141
- * Additional read-allowed paths beyond resolved `cache.inputs.files`.
142
- * These are unioned with the declared inputs to form the complete
143
- * allowRead set passed to the sandbox.
200
+ * Capabilities taken away. Evaluated before `allow`, so a domain in both
201
+ * is denied.
144
202
  */
145
- allowRead?: string[]
203
+ deny?: SandboxDenials
146
204
  /**
147
- * Additional write-allowed paths beyond the static prefix of
148
- * `cache.outputs.files`. Tasks with no declared outputs and no extra
149
- * `allowWrite` can write to nowhere — typical for `lint`/`typecheck`.
205
+ * Denials to leave out of the report, in the same shape as `allow`.
206
+ *
207
+ * This does NOT permit anything: the OS still denies the operation, and
208
+ * a command that cannot proceed without it still fails. It says "I know,
209
+ * and it does not affect this task's output" — for a denial you cannot
210
+ * grant away and do not want failing the run.
211
+ *
212
+ * Unlike `allow`, the values are GLOB PATTERNS, and they work the same
213
+ * on every platform: `allow` has to be expressible by the OS sandbox
214
+ * (bwrap mounts a path; it cannot mount a pattern), while `ignore` only
215
+ * has to match a line vx already holds. Relative patterns anchor at the
216
+ * project dir.
217
+ *
218
+ * ```ts
219
+ * ignore: { write: ['*.bun-build'] } // bun --compile stages a random
220
+ * // temp name in the cwd
221
+ * ```
150
222
  */
151
- allowWrite?: string[]
223
+ ignore?: SandboxGrants
152
224
  /**
153
- * Permit writes to `.git/config` files. Default `false` — most build
154
- * tools should not be reconfiguring git.
225
+ * Accept a weaker sandbox when vx itself already runs inside one
226
+ * (a container, another sandbox). Without it, nesting fails fast rather
227
+ * than pretending to confine.
155
228
  */
156
- allowGitConfig?: boolean
229
+ weakerWhenNested?: boolean
230
+ /** Accept weaker network isolation (host proxy) for lower overhead. */
231
+ weakerNetworkIsolation?: boolean
232
+ }
157
233
 
158
- // === Network ===
234
+ export interface SandboxGrants {
159
235
  /**
160
- * Network policy.
161
- * - `false` (default) — block all outbound traffic.
162
- * - `true` — allow all outbound traffic.
163
- * - object — fine-grained control (domains / sockets / etc.).
236
+ * Readable path prefixes, added to the baseline — which is
237
+ * `node_modules`, NOT `cache.inputs.files`. A task that reads its own
238
+ * source in the sandbox says so here.
164
239
  */
165
- network?: boolean | SandboxNetworkConfig
166
-
167
- // === Process behavior ===
240
+ read?: string[]
168
241
  /**
169
- * Allow the task to acquire a pseudo-terminal. Default `false`.
170
- * Needed by tasks that interact with a TTY (rare in CI).
242
+ * Writable path prefixes. The task can write NOTHING without these:
243
+ * declaring `cache.outputs.files` grants no write, and a task whose
244
+ * only declaration is its outputs writes into a tmpfs that evaporates
245
+ * (vx then warns that `cache.outputs` matched no files).
171
246
  */
172
- allowPty?: boolean
247
+ write?: string[]
173
248
  /**
174
- * Linux only. Allow nested sandboxes (a sandboxed task spawning
175
- * another sandboxed task). Default `false`.
249
+ * Reachable domains. `true` allows all, `['*.example.com']` a pattern.
250
+ * Omitted means no network at all.
176
251
  */
177
- enableWeakerNestedSandbox?: boolean
252
+ network?: true | string[]
178
253
  /**
179
- * macOS only. Skip the network namespace; routes traffic via the
180
- * host proxy instead of unsharing networking. Lower overhead but
181
- * weaker isolation. Default `false`.
254
+ * Kernel information the task may query, by info type — the name that
255
+ * appears in a denial, e.g. `vfs.disk-space` (every Bun and git process
256
+ * probes free space at startup).
182
257
  */
183
- enableWeakerNetworkIsolation?: boolean
184
-
185
- // === Violation policy ===
258
+ systemInfo?: string[]
259
+ /** Unix socket paths the task may connect to. `true` allows any. */
260
+ unixSockets?: true | string[]
186
261
  /**
187
- * Map of command-substring → list of paths to ignore violations on
188
- * for that command. Lets you silence known-noisy probes (e.g. a
189
- * compiler that statx's many candidate header paths). Per SRT's
190
- * own ignoreViolations field.
262
+ * Bind and reach localhost ports — a test that boots a server for
263
+ * itself. A port LIST additionally makes those ports reachable from
264
+ * outside the sandbox (a dev server another task or the developer
265
+ * talks to): on Linux, where a sandboxed task lives in its own network
266
+ * namespace, each listed port is bridged out to the host's loopback;
267
+ * on macOS the host already sees them and the list means `true`.
191
268
  */
192
- ignoreViolations?: Record<string, string[]>
269
+ localBinding?: boolean | number[]
270
+ /** macOS: Mach service names the task may look up. */
271
+ machLookup?: string[]
272
+ /** Acquire a TTY. Rare outside interactive tools. */
273
+ pty?: boolean
274
+ /** Write to `.git/config`. Most build tools should not. */
275
+ gitConfig?: boolean
193
276
  }
194
277
 
195
- export interface SandboxNetworkConfig {
196
- /**
197
- * Domain patterns the task may reach. Wildcards: `*.example.com`,
198
- * `*` (allow all).
199
- */
200
- allowedDomains?: string[]
201
- /** Domains explicitly blocked, evaluated before allowedDomains. */
202
- deniedDomains?: string[]
203
- /** Unix socket paths the task may connect to. */
204
- allowUnixSockets?: string[]
205
- /** Allow any Unix socket. Use with care. */
206
- allowAllUnixSockets?: boolean
207
- /**
208
- * Allow binding to local ports (e.g. a test that boots a server on
209
- * localhost for itself to query).
210
- */
211
- allowLocalBinding?: boolean
212
- /** macOS only. Mach service names the task may look up. */
213
- allowMachLookup?: string[]
278
+ export interface SandboxDenials {
279
+ /** Domains blocked even if `allow.network` would admit them. */
280
+ network?: string[]
214
281
  }
215
282
 
216
283
  export interface ExecConfig {
@@ -258,16 +325,6 @@ export interface ExecConfig {
258
325
  * default, including `retries: 0`.
259
326
  */
260
327
  retries?: number
261
- /**
262
- * Resource RESERVATIONS for admission control — NOT enforcement: vx
263
- * does not cgroup-limit, nice, or kill the task; it only decides what
264
- * to co-schedule so concurrent reservations never exceed the CPU /
265
- * memory budget. Each axis defaults to 0 = reserve nothing: the task
266
- * runs subject only to the concurrency-count limit. A pure scheduling
267
- * hint — the whole object is stripped from the cache key, so tuning a
268
- * reservation never invalidates a cached result.
269
- */
270
- resources?: ResourcesConfig
271
328
  /**
272
329
  * Long-running / continuous task (dev server, watcher, daemon).
273
330
  * When present, the task is spawned but the runner does NOT wait
@@ -277,30 +334,29 @@ export interface ExecConfig {
277
334
  * on ready, not on exit. The orchestrator SIGTERMs every persistent
278
335
  * subprocess once the rest of the graph has finished.
279
336
  *
280
- * `cache` is silently ignored for persistent tasks — there's no
281
- * exit code to cache and the work continues after vx's run notion
282
- * of "done".
337
+ * `cache` is rejected on a persistent task — there's no exit code
338
+ * to cache and the work continues after vx's run notion of "done".
283
339
  */
284
340
  persistent?: PersistentConfig
285
- }
286
-
287
- /**
288
- * Per-task resource reservation (see `ExecConfig.resources`). Grouped so
289
- * a future axis slots in without new top-level `exec` fields; the loader
290
- * rejects unknown keys.
291
- */
292
- export interface ResourcesConfig {
293
- /** CPU cores; fractional is allowed (`0.5`). */
294
- cpus?: number
295
- /** Megabytes. `memory: 4096` is four gigabytes. */
296
- memory?: number
297
341
  /**
298
- * The container image this task needs, MATCHED against what a worker
299
- * advertises — never a provisioning instruction, since a distributed
300
- * executor's workers belong to whoever runs the fleet. Ignored by
301
- * executors that do not run containers.
342
+ * Confine this task's process: what it may read, write and reach.
343
+ * Nothing is confined unless this is present.
344
+ *
345
+ * It sits in `exec` because it constrains the same spawn `env` does —
346
+ * the environment the child gets, the filesystem and network it gets.
347
+ * A group task has no process,
348
+ * so the field cannot be reached from one.
349
+ *
350
+ * **Policy: fail on violation.** An undeclared touch of the project's own
351
+ * files fails the task, and a failed task is never cached. Reaching
352
+ * OUTSIDE the project is denied but not reported — that is the wall
353
+ * doing its job, not a finding.
354
+ *
355
+ * A persistent task is confined the same way but never REPORTED on: the
356
+ * violation report reads the trace after the process exits, and a server
357
+ * exits at teardown. Enforced, not reported.
302
358
  */
303
- image?: string
359
+ sandbox?: SandboxConfig
304
360
  }
305
361
 
306
362
  export interface PersistentConfig {
@@ -354,8 +410,9 @@ export interface CacheOutputs {
354
410
  * files anywhere in the workspace, including inside other projects'
355
411
  * directories. Deliberate escape hatch — prefer project-relative
356
412
  * `files` whenever the task can write inside its own dir. Two tasks
357
- * declaring overlapping workspace outputs is user responsibility;
358
- * vx does not police it.
413
+ * whose workspace outputs provably overlap are refused at graph build,
414
+ * like overlapping `files` (vx cleans declared outputs before a run
415
+ * and before a restore, so the second would delete the first's).
359
416
  */
360
417
  workspaceFiles?: string[]
361
418
  }
package/src/exec/env.ts CHANGED
@@ -42,6 +42,17 @@ export const ESSENTIAL_ENV: readonly string[] = [
42
42
  'PATHEXT',
43
43
  ]
44
44
 
45
+ /**
46
+ * The two variables vx sets on every task it spawns, over the layers
47
+ * above: the workspace root the run is in and the `project#task` running.
48
+ * `run()` reads them back so a task that shells out to `vx run` in its OWN
49
+ * workspace is refused before it forks without bound (a different
50
+ * workspace — a fixture, a benchmark — is fine). Not in the allowlist:
51
+ * they come from this run, never from the parent environment.
52
+ */
53
+ export const VX_RUN_WORKSPACE_ENV = 'VX_RUN_WORKSPACE'
54
+ export const VX_RUN_TASK_ENV = 'VX_RUN_TASK'
55
+
45
56
  export interface BuildEnvOptions {
46
57
  passThrough: readonly string[]
47
58
  define: Readonly<Record<string, string>>
@@ -1,9 +1,9 @@
1
1
  // The per-task execution contract. `execute-task.ts` decides WHAT to run
2
2
  // (command, env, sandbox baselines, capture) and hands a fully-resolved
3
- // request here; an executor decides WHERE/HOW the process runs. Core ships
4
- // no executor of its own: `@vzn/vx/plugins/local-executor` is the
5
- // in-process spawn, declared in vx.workspace.ts like any other. Persistent
6
- // tasks (`exec.persistent`) never reach an executor: they are local by
3
+ // request here; an executor decides WHERE/HOW the process runs. Core's own
4
+ // `local-executor.ts` is the in-process spawn, and it sits at the TAIL of
5
+ // every executor list — what a plugin executor declines runs here.
6
+ // Persistent tasks (`exec.persistent`) never reach an executor: they are local by
7
7
  // construction (a worker cannot hand the submitter a listening port) and
8
8
  // stay on `runPersistent`.
9
9
  //
@@ -13,29 +13,33 @@
13
13
 
14
14
  import type { CaptureConfig, RunResult } from './runner.js'
15
15
  import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
16
+ import { UserError } from '../util/index.js'
16
17
 
17
18
  /** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
18
19
  export interface ExecuteSandbox {
19
20
  readonly baseAllowRead: readonly string[]
20
- readonly baseAllowWrite: readonly string[]
21
21
  readonly baseDenyRead: readonly string[]
22
- readonly config: ResolvedSandboxConfig
23
22
  /**
24
- * True when an EMPTY violation store on a CLEAN exit will be read as
25
- * PROOF (`--verify=inputs` treats zero violations as `proven-complete`).
26
- * On macOS the store is fed asynchronously from the unified log, so the
27
- * runtime must pay the bounded settle window even for a 0-exit — a leaky
28
- * task that swallows its own read error exits 0, and without the wait a
29
- * merely-late violation record turns into a false pass of the verify.
23
+ * Only denials on a path under this directory are reported. A denial
24
+ * outside it is still ENFORCED — the task cannot leave its project — but
25
+ * that is the wall doing its job, not a finding to fail a run over.
26
+ */
27
+ readonly reportWithin: string
28
+ /**
29
+ * Canonical directories of the workspace packages core withheld from
30
+ * this task's `node_modules` grant (its key does not answer for them).
31
+ * A denial under one is reported too: the task reached for a dependency,
32
+ * which is the finding, not the wall.
30
33
  */
31
- readonly settleOnCleanExit: boolean
34
+ readonly reportLinked: readonly string[]
35
+ readonly config: ResolvedSandboxConfig
32
36
  }
33
37
 
34
38
  /** One declared input file, as the cache key saw it. */
35
39
  export interface InputFile {
36
40
  /** Workspace-relative POSIX path. */
37
41
  readonly path: string
38
- /** Git blob OID of the WORKTREE bytes (the same digest the key folds). */
42
+ /** Git blob OID of the WORKTREE bytes — for a symlink, of its target string — the same digest the key folds. */
39
43
  readonly digest: string
40
44
  }
41
45
 
@@ -50,8 +54,8 @@ export interface InputFile {
50
54
  export interface TaskInputs {
51
55
  /** Declared `cache.inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
52
56
  readonly files: readonly InputFile[]
53
- /** Declared `cache.inputs.env` names with their resolved values. */
54
- readonly env: ReadonlyArray<{ readonly name: string; readonly value: string }>
57
+ /** Declared `cache.inputs.env` names with their resolved values; `undefined` when unset. */
58
+ readonly env: ReadonlyArray<{ readonly name: string; readonly value: string | undefined }>
55
59
  /** `cache.inputs.runtime` commands and the output that was folded — a toolchain expectation. */
56
60
  readonly runtime: ReadonlyArray<{ readonly command: string; readonly output: string }>
57
61
  /** `cache.inputs.workspaceRuntime`, same shape. */
@@ -169,24 +173,12 @@ export interface TaskPlacement {
169
173
  readonly command: string
170
174
  /**
171
175
  * Must run on this machine: the task is persistent, depends (transitively)
172
- * on a persistent task, or declares `exec.remote: false`. A `remote`
173
- * executor is never offered such a task.
176
+ * on a persistent or sandboxed task, is sandboxed itself, or declares
177
+ * `exec.remote: false`. A `remote` executor is never offered such a task.
174
178
  */
175
179
  readonly pinnedLocal: boolean
176
180
  /** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
177
181
  readonly cacheable: boolean
178
- /**
179
- * `exec.resources` VERBATIM — CPU cores, megabytes, and the image a
180
- * worker must be running for this task to be routed to it. All three are
181
- * requirements MATCHED against what an executor has, never instructions
182
- * to build a machine: a distributed executor's workers belong to whoever
183
- * runs the fleet.
184
- *
185
- * Placement is where this belongs: `exec.resources` is stripped from the
186
- * cache key precisely because it decides WHERE a task fits, never what
187
- * it produces.
188
- */
189
- readonly resources?: Readonly<{ cpus?: number; memory?: number; image?: string }>
190
182
  }
191
183
 
192
184
  export interface TaskExecutor {
@@ -219,6 +211,45 @@ export interface TaskExecutor {
219
211
  execute(req: ExecuteRequest): Promise<ExecuteResult>
220
212
  }
221
213
 
214
+ /**
215
+ * The result an executor resolves with is a plugin's, so its shape is a
216
+ * boundary: a plugin that resolved `{}` met `res.violations` in core and
217
+ * became "internal error in <task>: TypeError …" — vx's crash, the plugin's
218
+ * bug (2026-09-16). Name the executor and the field instead. The factory's
219
+ * output is checked the same way in `resolveExecutors`.
220
+ */
221
+ export function assertExecuteResult(
222
+ executor: string,
223
+ taskId: string,
224
+ res: unknown,
225
+ ): asserts res is ExecuteResult {
226
+ const fail = (what: string): never => {
227
+ throw new UserError(
228
+ `executor '${executor}' returned an invalid result for ${taskId}: ${what} — a plugin bug, not a task failure`,
229
+ )
230
+ }
231
+ if (typeof res !== 'object' || res === null)
232
+ fail(`${res === null ? 'null' : typeof res} (expected an object)`)
233
+ const r = res as Record<string, unknown>
234
+ for (const key of ['exitCode', 'durationMs']) {
235
+ if (typeof r[key] !== 'number') fail(`${key} is ${typeof r[key]} (expected a number)`)
236
+ }
237
+ for (const key of ['stdout', 'stderr']) {
238
+ if (typeof r[key] !== 'string') fail(`${key} is ${typeof r[key]} (expected a string)`)
239
+ }
240
+ if (!Array.isArray(r['violations']))
241
+ fail(`violations is ${typeof r['violations']} (expected an array)`)
242
+ const outputs = r['outputs'] as { kind?: unknown; materialize?: unknown } | undefined
243
+ if (outputs !== undefined) {
244
+ if (typeof outputs !== 'object' || outputs === null) fail('outputs is not an object')
245
+ else if (outputs.kind === 'deferred') {
246
+ if (typeof outputs.materialize !== 'function')
247
+ fail('outputs.kind is deferred without a materialize()')
248
+ } else if (outputs.kind !== 'disk')
249
+ fail(`outputs.kind is ${String(outputs.kind)} (expected disk or deferred)`)
250
+ }
251
+ }
252
+
222
253
  /**
223
254
  * Place one task: the first executor, in declaration order, that may take
224
255
  * it — a `remote` executor is skipped for a `pinnedLocal` task, then
@@ -234,6 +265,6 @@ export function selectExecutor(
234
265
  if (executor.accepts === undefined || executor.accepts(task)) return executor
235
266
  }
236
267
  throw new Error(
237
- `no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Declare localExecutorPlugin() after the executor that declined to run such tasks locally.`,
268
+ `no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Core's local executor accepts every task, so this means it is missing from the list.`,
238
269
  )
239
270
  }
package/src/exec/index.ts CHANGED
@@ -1,25 +1,39 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { buildIsolatedEnv } from './env.js'
4
+ export { buildIsolatedEnv, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from './env.js'
5
5
  export {
6
6
  runCommand,
7
7
  runPersistent,
8
+ PersistentReadyError,
8
9
  shellQuote,
9
10
  signalExitCode,
11
+ exitSignal,
10
12
  type CaptureConfig,
13
+ execWord,
11
14
  } from './runner.js'
12
15
  export {
13
16
  initSandbox,
14
17
  probeSandbox,
18
+ unavailableReason,
15
19
  resetSandbox,
16
20
  resolveSandboxConfig,
17
21
  runSandboxed,
18
- type DeniedCall,
22
+ releaseBridges,
23
+ wrapSandboxedCommand,
19
24
  type ResolvedSandboxConfig,
20
25
  type SandboxViolation,
26
+ dependencyReason,
27
+ socketPathRefusal,
28
+ thrownReason,
21
29
  } from './sandbox-runtime.js'
30
+ export { type DeniedCall } from './sandbox-violations.js'
31
+ export { isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
32
+ export { bindableWrites, punchWalls } from './sandbox-binds.js'
33
+ export { localExecutor } from './local-executor.js'
34
+ export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
22
35
  export {
36
+ assertExecuteResult,
23
37
  selectExecutor,
24
38
  type ExecuteRequest,
25
39
  type ExecuteResult,