@vzn/vx 0.0.15 → 0.0.18

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 (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path'
2
2
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
3
- import { parseSize, UserError, xxh3hex } from '../util/index.js'
3
+ import { MAX_TIMEOUT_MS, UserError, xxh3hex } from '../util/index.js'
4
+ import { evaluateConfigFresh } from './config-eval.js'
5
+ import { configEvalKey, configEvalKeyFromClosure, type ConfigEvalStore } from './config-cache.js'
4
6
 
5
7
  const WORKSPACE_CONFIG_FILENAMES = [
6
8
  'vx.workspace.ts',
@@ -9,6 +11,15 @@ const WORKSPACE_CONFIG_FILENAMES = [
9
11
  'vx.workspace.mjs',
10
12
  ]
11
13
 
14
+ /** Project configs already loaded in this process, by absolute path. */
15
+ const loadedConfigs = new Set<string>()
16
+
17
+ function assertDefaultObject(mod: unknown, kind: string, configPath: string): void {
18
+ if (!mod || typeof mod !== 'object') {
19
+ throw new UserError(`${kind} config at ${configPath} did not export a default object`)
20
+ }
21
+ }
22
+
12
23
  // Bun has native TS / ESM execution — no transpiler dep needed. We fold
13
24
  // a short content hash into the import URL as a cache-bust key so that:
14
25
  // same content → same URL → Bun's module cache hits (fast)
@@ -21,32 +32,184 @@ async function loadDefaultExport(
21
32
  configPath: string,
22
33
  kind: string,
23
34
  fresh = false,
35
+ bytes?: Uint8Array,
24
36
  ): Promise<unknown> {
25
- const bytes = await Bun.file(configPath).bytes()
37
+ bytes ??= await Bun.file(configPath).bytes()
26
38
  // `fresh` opts out of module-cache reuse entirely: `vx lock` and
27
39
  // `vx lock --check` must observe the CURRENT environment, and the
28
40
  // content-hash bust would replay an evaluation made under earlier
29
41
  // env values when the file bytes are unchanged in this process.
30
42
  const bust = fresh ? `${xxh3hex(bytes)}-${Bun.randomUUIDv7()}` : xxh3hex(bytes)
31
- const ns = (await import(`${configPath}?vx-bust=${bust}`)) as { default?: unknown }
32
- const mod = ns?.default
33
- if (!mod || typeof mod !== 'object') {
34
- throw new UserError(`${kind} config at ${configPath} did not export a default object`)
43
+ let ns: { default?: unknown }
44
+ try {
45
+ ns = (await import(`${configPath}?vx-bust=${bust}`)) as { default?: unknown }
46
+ } catch (err) {
47
+ throw unresolvedImportError(err, configPath, kind) ?? err
35
48
  }
49
+ const mod = ns?.default
50
+ assertDefaultObject(mod, kind, configPath)
36
51
  return mod
37
52
  }
38
53
 
54
+ /**
55
+ * A config whose import cannot be resolved is the user's to fix, and the
56
+ * usual cause has one answer: the workspace runs the vx binary and never
57
+ * installed `@vzn/vx`, which its own `vx.workspace.ts` imports. Bun's
58
+ * resolver error also carries the module-cache bust query; the user
59
+ * gets the file they wrote.
60
+ */
61
+ export function unresolvedImportError(
62
+ err: unknown,
63
+ configPath: string,
64
+ kind: string,
65
+ ): UserError | null {
66
+ if (!(err instanceof Error) || err.name !== 'ResolveMessage') return null
67
+ const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(err.message)?.[1]
68
+ const what =
69
+ spec === undefined ? err.message.replace(/\?vx-bust=\S+/g, '') : `cannot find '${spec}'`
70
+ const hint =
71
+ spec?.startsWith('@vzn/vx') === true ? `; install it in the workspace: bun add -d @vzn/vx` : ''
72
+ return new UserError(`${kind} config ${configPath}: ${what}${hint}`)
73
+ }
74
+
75
+ export interface LoadProjectConfigOptions {
76
+ /** Observe the CURRENT environment: no module-cache reuse, no eval cache. */
77
+ fresh?: boolean
78
+ /**
79
+ * Serve a provably-pure config from its cached evaluation (see
80
+ * config-cache.ts) and store a fresh one for next time. Off for `fresh`.
81
+ */
82
+ evalCache?: { store: ConfigEvalStore; workspaceFingerprint: string }
83
+ }
84
+
85
+ /**
86
+ * Load many configs at once: every file's bytes and cache key in parallel,
87
+ * ONE store lookup for all keys, then only the misses are evaluated (in the
88
+ * order given, so a failure names the first broken file the way a
89
+ * one-by-one load did). A single-path load is the one-element case.
90
+ */
91
+ export async function loadProjectConfigs(
92
+ configPaths: readonly string[],
93
+ opts?: LoadProjectConfigOptions,
94
+ ): Promise<ProjectConfig[]> {
95
+ const evalCache = opts?.fresh === true ? undefined : opts?.evalCache
96
+ const store = evalCache?.store
97
+ const hashFile = store?.hashFile?.bind(store)
98
+ // The warm fast path: a config whose ordered closure the store remembers
99
+ // is keyed from per-file identities (a stat each, no read, no scan); the
100
+ // slow path below reads, gates and scans, and indexes the closure for
101
+ // next time when every import is explicit.
102
+ const closures =
103
+ hashFile !== undefined && store?.getConfigClosures !== undefined
104
+ ? store.getConfigClosures(configPaths)
105
+ : new Map<string, string[]>()
106
+ const prepared = await Promise.all(
107
+ configPaths.map(async (configPath) => {
108
+ const closure = closures.get(configPath)
109
+ if (closure !== undefined && evalCache !== undefined && hashFile !== undefined) {
110
+ const fastKey = await configEvalKeyFromClosure({
111
+ closure,
112
+ hashFile,
113
+ workspaceFingerprint: evalCache.workspaceFingerprint,
114
+ })
115
+ if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
116
+ }
117
+ const bytes = await Bun.file(configPath).bytes()
118
+ const keyed =
119
+ evalCache === undefined
120
+ ? null
121
+ : await configEvalKey({
122
+ configPath,
123
+ bytes,
124
+ workspaceFingerprint: evalCache.workspaceFingerprint,
125
+ ...(hashFile !== undefined ? { hashFile } : {}),
126
+ })
127
+ return {
128
+ configPath,
129
+ bytes,
130
+ cacheKey: keyed?.key ?? null,
131
+ indexed: false,
132
+ closure: keyed !== null && keyed.indexable ? keyed.closure : undefined,
133
+ }
134
+ }),
135
+ )
136
+ let hits = new Map<string, string>()
137
+ if (evalCache !== undefined) {
138
+ const keys = prepared.map((p) => p.cacheKey).filter((k): k is string => k !== null)
139
+ if (keys.length > 0) {
140
+ if (evalCache.store.getConfigEvals !== undefined) {
141
+ hits = evalCache.store.getConfigEvals(keys)
142
+ } else {
143
+ for (const k of keys) {
144
+ const hit = evalCache.store.getConfigEval(k)
145
+ if (hit !== null) hits.set(k, hit)
146
+ }
147
+ }
148
+ }
149
+ }
150
+ const out: ProjectConfig[] = []
151
+ for (const entry of prepared) {
152
+ const { configPath, cacheKey } = entry
153
+ const hit = cacheKey === null ? undefined : hits.get(cacheKey)
154
+ // Stored AFTER validation, so a hit needs none; the key covers every
155
+ // byte the evaluation could have read.
156
+ if (hit !== undefined) {
157
+ out.push(JSON.parse(hit) as ProjectConfig)
158
+ continue
159
+ }
160
+ // A fast key that missed: the closure is stale or the file changed.
161
+ // Take the slow path for this one config, which re-indexes it.
162
+ let bytes = entry.bytes
163
+ let closure = entry.closure
164
+ let key = cacheKey
165
+ if (entry.indexed) {
166
+ bytes = await Bun.file(configPath).bytes()
167
+ const keyed = await configEvalKey({
168
+ configPath,
169
+ bytes,
170
+ workspaceFingerprint: evalCache!.workspaceFingerprint,
171
+ ...(hashFile !== undefined ? { hashFile } : {}),
172
+ })
173
+ key = keyed?.key ?? null
174
+ closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
175
+ const slowHit = key === null ? null : (store!.getConfigEval(key) ?? null)
176
+ if (slowHit !== null) {
177
+ out.push(JSON.parse(slowHit) as ProjectConfig)
178
+ if (closure !== undefined) store!.putConfigClosure?.(configPath, closure)
179
+ continue
180
+ }
181
+ }
182
+ // A REPEAT load in this process re-evaluates in a worker, because the
183
+ // bust above cannot reach the config's import closure — see
184
+ // config-eval.ts. A FIRST load keeps the in-process import, so the
185
+ // single `vx run` hot path never pays for a worker.
186
+ const repeat = loadedConfigs.has(configPath)
187
+ loadedConfigs.add(configPath)
188
+ const mod = repeat
189
+ ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
190
+ throw unresolvedImportError(err, configPath, 'Project') ?? err
191
+ })
192
+ : await loadDefaultExport(configPath, 'Project', opts?.fresh === true, bytes!)
193
+ assertDefaultObject(mod, 'Project', configPath)
194
+ // Validation runs HERE, on whichever object we ended up with, so a
195
+ // malformed config reports the identical UserError whether it was
196
+ // evaluated in-process or in a worker.
197
+ validateProjectConfig(mod as ProjectConfig, configPath)
198
+ if (key !== null) {
199
+ evalCache!.store.putConfigEval(key, JSON.stringify(mod))
200
+ if (closure !== undefined) evalCache!.store.putConfigClosure?.(configPath, closure)
201
+ }
202
+ out.push(mod as ProjectConfig)
203
+ }
204
+ return out
205
+ }
206
+
39
207
  export async function loadProjectConfig(
40
208
  configPath: string,
41
- opts?: { fresh?: boolean },
209
+ opts?: LoadProjectConfigOptions,
42
210
  ): Promise<ProjectConfig> {
43
- const mod = (await loadDefaultExport(
44
- configPath,
45
- 'Project',
46
- opts?.fresh === true,
47
- )) as ProjectConfig
48
- validateProjectConfig(mod, configPath)
49
- return mod
211
+ const [config] = await loadProjectConfigs([configPath], opts)
212
+ return config!
50
213
  }
51
214
 
52
215
  /**
@@ -91,6 +254,7 @@ function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
91
254
  ) {
92
255
  throw new UserError(`${configPath}: \`timeout\` must be a positive integer (milliseconds)`)
93
256
  }
257
+ assertTimeoutInRange(config.timeout, `${configPath}: \`timeout\``)
94
258
  }
95
259
  if (config.plugins !== undefined) {
96
260
  if (!Array.isArray(config.plugins)) {
@@ -105,44 +269,78 @@ function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
105
269
  setup?: unknown
106
270
  backend?: unknown
107
271
  cache?: unknown
272
+ executor?: unknown
108
273
  telemetry?: unknown
109
- eventSink?: unknown
274
+ config?: unknown
275
+ project?: unknown
276
+ graph?: unknown
277
+ key?: unknown
278
+ schedule?: unknown
279
+ commands?: unknown
110
280
  teardown?: unknown
111
281
  }
112
282
  if (typeof plug.name !== 'string' || plug.name.length === 0) {
113
283
  throw new UserError(`${configPath}: \`plugins[${i}].name\` must be a non-empty string`)
114
284
  }
115
- for (const cap of [
285
+ // The whole-run `backend` seam was REMOVED on 2026-08-23 with vx
286
+ // cloud: a run always executes in the `vx run` process. Nothing
287
+ // consults `.backend` any more, so leaving it in `caps` below meant a
288
+ // plugin declaring ONLY `backend` — a third-party one written against
289
+ // the old API, say — validated as "contributes a capability" and was
290
+ // then silently ignored. Refuse it by name and point at the seam that
291
+ // replaced it; a silent no-op is the failure this validation exists
292
+ // to prevent.
293
+ if (plug.backend !== undefined) {
294
+ throw new UserError(
295
+ `${configPath}: \`plugins[${i}].backend\` is no longer a capability — the whole-run ` +
296
+ `backend seam was removed in 2026-08. Use \`executor\` to change where a single ` +
297
+ `task's command runs (see docs/architecture.md § plugin capabilities).`,
298
+ )
299
+ }
300
+ const caps = [
301
+ 'config',
302
+ 'project',
303
+ 'graph',
304
+ 'key',
305
+ 'schedule',
116
306
  'setup',
117
- 'backend',
118
307
  'cache',
308
+ 'executor',
119
309
  'telemetry',
120
- 'eventSink',
121
310
  'teardown',
122
- ] as const) {
311
+ ] as const
312
+ for (const cap of caps) {
123
313
  if (plug[cap] !== undefined && typeof plug[cap] !== 'function') {
124
314
  throw new UserError(`${configPath}: \`plugins[${i}].${cap}\` must be a function`)
125
315
  }
126
316
  }
317
+ if (plug.commands !== undefined) {
318
+ if (plug.commands === null || typeof plug.commands !== 'object') {
319
+ throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
320
+ }
321
+ for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
322
+ const c = cmd as { description?: unknown; run?: unknown } | null
323
+ if (
324
+ c === null ||
325
+ typeof c !== 'object' ||
326
+ typeof c.run !== 'function' ||
327
+ typeof c.description !== 'string'
328
+ ) {
329
+ throw new UserError(
330
+ `${configPath}: \`plugins[${i}].commands.${verb}\` must be { description: string, run: function }`,
331
+ )
332
+ }
333
+ }
334
+ }
127
335
  // A plugin must contribute at least one capability or lifecycle hook
128
336
  // — an empty `{ name }` object is a no-op authoring mistake.
129
- if (
130
- plug.setup === undefined &&
131
- plug.backend === undefined &&
132
- plug.cache === undefined &&
133
- plug.telemetry === undefined &&
134
- plug.eventSink === undefined &&
135
- plug.teardown === undefined
136
- ) {
337
+ if (caps.every((cap) => plug[cap] === undefined) && plug.commands === undefined) {
137
338
  throw new UserError(
138
- `${configPath}: \`plugins[${i}]\` must contribute at least one of setup/backend/cache/telemetry/eventSink`,
339
+ `${configPath}: \`plugins[${i}]\` must contribute at least one of ${[...caps, 'commands'].join('/')}`,
139
340
  )
140
341
  }
141
342
  }
142
343
  }
143
- if (config.predictive !== undefined && typeof config.predictive !== 'boolean') {
144
- throw new UserError(`${configPath}: \`predictive\` must be a boolean`)
145
- }
146
344
  }
147
345
 
148
346
  /**
@@ -156,14 +354,18 @@ function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
156
354
  export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
157
355
  const tasks = config.tasks
158
356
  if (tasks === undefined) return
159
- if (typeof tasks !== 'object' || tasks === null) {
160
- throw new UserError(`${configPath}: \`tasks\` must be an object`)
357
+ if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
358
+ // An ARRAY of task objects reads as valid to `typeof`, and
359
+ // `Object.entries` then yields a task literally named "0" — a config
360
+ // that loads, runs nothing the author asked for, and never says why.
361
+ throw new UserError(`${configPath}: \`tasks\` must be an object keyed by task name`)
161
362
  }
162
363
  for (const [name, task] of Object.entries(tasks)) {
163
364
  const where = `${configPath}: tasks.${name}`
164
365
  if (!task || typeof task !== 'object') {
165
366
  throw new UserError(`${where} must be an object`)
166
367
  }
368
+ assertKnownFields(task, TASK_FIELDS, where)
167
369
  const exec = (task as { exec?: unknown }).exec
168
370
  const dependsOn = (task as { dependsOn?: unknown }).dependsOn
169
371
  const cache = (task as { cache?: unknown }).cache
@@ -171,6 +373,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
171
373
  if (typeof exec !== 'object' || exec === null) {
172
374
  throw new UserError(`${where}.exec must be an object with a \`command\` string`)
173
375
  }
376
+ assertKnownFields(exec, EXEC_FIELDS, `${where}.exec`)
174
377
  const command = (exec as { command?: unknown }).command
175
378
  if (typeof command !== 'string' || command.length === 0) {
176
379
  throw new UserError(`${where}.exec.command must be a non-empty string`)
@@ -180,6 +383,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
180
383
  if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
181
384
  throw new UserError(`${where}.exec.timeout must be a positive integer (milliseconds)`)
182
385
  }
386
+ assertTimeoutInRange(timeout, `${where}.exec.timeout`)
183
387
  }
184
388
  const retries = (exec as { retries?: unknown }).retries
185
389
  if (retries !== undefined) {
@@ -187,15 +391,60 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
187
391
  throw new UserError(`${where}.exec.retries must be a non-negative integer`)
188
392
  }
189
393
  }
394
+ const remote = (exec as { remote?: unknown }).remote
395
+ if (remote !== undefined && typeof remote !== 'boolean' && remote !== 'only') {
396
+ throw new UserError(`${where}.exec.remote must be a boolean or 'only' (or omitted)`)
397
+ }
190
398
  const resources = (exec as { resources?: unknown }).resources
191
399
  if (resources !== undefined) {
192
400
  validateResources(resources, `${where}.exec.resources`)
193
401
  }
402
+ const env = (exec as { env?: unknown }).env
403
+ if (env !== undefined) {
404
+ if (typeof env !== 'object' || env === null) {
405
+ throw new UserError(`${where}.exec.env must be an object (or omitted)`)
406
+ }
407
+ const passThrough = (env as { passThrough?: unknown }).passThrough
408
+ if (passThrough !== undefined) {
409
+ // A non-array here reaches `buildIsolatedEnv`'s `for (const name of
410
+ // passThrough)` — a number throws "not iterable" mid-run, a string
411
+ // silently char-iterates. Fail loud at load with a config pointer.
412
+ if (
413
+ !Array.isArray(passThrough) ||
414
+ passThrough.some((n) => typeof n !== 'string' || n.length === 0)
415
+ ) {
416
+ throw new UserError(
417
+ `${where}.exec.env.passThrough must be an array of non-empty env var names`,
418
+ )
419
+ }
420
+ }
421
+ const define = (env as { define?: unknown }).define
422
+ if (define !== undefined) {
423
+ if (typeof define !== 'object' || define === null || Array.isArray(define)) {
424
+ throw new UserError(
425
+ `${where}.exec.env.define must be an object of name:value string pairs`,
426
+ )
427
+ }
428
+ for (const [k, val] of Object.entries(define as Record<string, unknown>)) {
429
+ if (typeof val !== 'string') {
430
+ throw new UserError(`${where}.exec.env.define.${k} must be a string`)
431
+ }
432
+ }
433
+ }
434
+ }
194
435
  const persistent = (exec as { persistent?: unknown }).persistent
195
436
  if (persistent !== undefined) {
196
437
  if (typeof persistent !== 'object' || persistent === null) {
197
438
  throw new UserError(`${where}.exec.persistent must be an object (or omitted)`)
198
439
  }
440
+ // The one nested object that had no unknown-key check while every
441
+ // sibling did — and the failure it let through is the quiet kind:
442
+ // `readWhen` (typo) leaves `readyWhen` undefined, so the task is
443
+ // ready the moment it spawns instead of when its server actually
444
+ // listens, and the dependents that start too early fail in a way
445
+ // that points at the user's code rather than at their config.
446
+
447
+ assertKnownFields(persistent, PERSISTENT_FIELDS, `${where}.exec.persistent`)
199
448
  const readyWhen = (persistent as { readyWhen?: unknown }).readyWhen
200
449
  if (readyWhen !== undefined && typeof readyWhen !== 'string') {
201
450
  throw new UserError(`${where}.exec.persistent.readyWhen must be a string regex`)
@@ -245,11 +494,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
245
494
  if (typeof cache !== 'object' || cache === null) {
246
495
  throw new UserError(`${where}.cache must be an object when present`)
247
496
  }
497
+ assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
248
498
  const inputs = (cache as { inputs?: unknown }).inputs
249
499
  const outputs = (cache as { outputs?: unknown }).outputs
250
500
  if (!inputs || typeof inputs !== 'object') {
251
501
  throw new UserError(`${where}.cache.inputs is required when \`cache\` is set`)
252
502
  }
503
+ assertKnownFields(inputs, CACHE_INPUT_FIELDS, `${where}.cache.inputs`)
253
504
  if (!Array.isArray((inputs as { files?: unknown }).files)) {
254
505
  throw new UserError(`${where}.cache.inputs.files must be an array of glob strings`)
255
506
  }
@@ -281,6 +532,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
281
532
  if (!outputs || typeof outputs !== 'object') {
282
533
  throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
283
534
  }
535
+ assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
284
536
  const outFiles = (outputs as { files?: unknown }).files
285
537
  if (!Array.isArray(outFiles)) {
286
538
  throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
@@ -299,6 +551,20 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
299
551
  `outputs must be project-relative globs`,
300
552
  )
301
553
  }
554
+ if (hasParentSegment(g)) {
555
+ throw new UserError(
556
+ `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
557
+ `outputs must stay within the project (a glob that escapes the project dir would ` +
558
+ `let cleanOutputs delete files outside it)`,
559
+ )
560
+ }
561
+ if (g.startsWith('!')) {
562
+ throw new UserError(
563
+ `${where}.cache.outputs.files: negation is not supported (got "${g}") — ` +
564
+ `unlike inputs, output globs are never split on '!', so this is read as a literal ` +
565
+ `path beginning with '!' and matches nothing. List the outputs you DO produce.`,
566
+ )
567
+ }
302
568
  }
303
569
  // Same for inputs.files.
304
570
  for (const g of (inputs as { files: unknown[] }).files) {
@@ -311,6 +577,43 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
311
577
  `inputs must be project-relative globs`,
312
578
  )
313
579
  }
580
+ if (hasParentSegment(g)) {
581
+ throw new UserError(
582
+ `${where}.cache.inputs.files: '..' path segments are not allowed (got "${g}") — ` +
583
+ `inputs must be project-relative (a '..' glob silently matches nothing; ` +
584
+ `use cache.inputs.workspaceFiles for workspace-root-relative inputs)`,
585
+ )
586
+ }
587
+ assertNotDoubleNegated(g, `${where}.cache.inputs.files`)
588
+ }
589
+ // A list of ONLY negations selects nothing at all. `resolveFiles`
590
+ // builds the file set from the POSITIVE globs and uses the `!` entries
591
+ // purely to subtract, so with no positive pattern it returns `[]` — the
592
+ // task folds ZERO file inputs and its key stops moving with its source,
593
+ // which is a stale hit waiting to happen.
594
+ //
595
+ // Every gitignore-trained reader parses `['!**/*.spec.ts']` as
596
+ // "everything except specs", and Turbo makes exactly this a hard config
597
+ // error for the same reason. Refusing costs nothing: such a config was
598
+ // already silently broken, so no working key changes.
599
+ assertNotNegationOnly((inputs as { files: string[] }).files, `${where}.cache.inputs.files`)
600
+ // The only CacheInputs field that reached the run unvalidated: a
601
+ // non-string entry crashes deep in `filterUpstreamHashes` /
602
+ // `parseDependencySpec` with a raw TypeError naming neither the task
603
+ // nor the config. Entry SYNTAX is already checked downstream (a
604
+ // `DependencySpecError` becomes a UserError naming the task) — this
605
+ // only pins the shape.
606
+ const taskFilters = (inputs as { tasks?: unknown }).tasks
607
+ if (taskFilters !== undefined) {
608
+ if (
609
+ !Array.isArray(taskFilters) ||
610
+ taskFilters.some((s) => typeof s !== 'string' || s.length === 0)
611
+ ) {
612
+ throw new UserError(
613
+ `${where}.cache.inputs.tasks must be an array of non-empty strings ` +
614
+ `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name', '*', '^*', '!name')`,
615
+ )
616
+ }
314
617
  }
315
618
  for (const field of ['runtime', 'workspaceRuntime'] as const) {
316
619
  const list = (inputs as Record<string, unknown>)[field]
@@ -326,11 +629,11 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
326
629
  // never absolute (they're workspace-root-relative by definition).
327
630
  const wsInputs = (inputs as { workspaceFiles?: unknown }).workspaceFiles
328
631
  if (wsInputs !== undefined) {
329
- validateWorkspaceGlobs(wsInputs, `${where}.cache.inputs.workspaceFiles`)
632
+ validateWorkspaceGlobs(wsInputs, `${where}.cache.inputs.workspaceFiles`, true)
330
633
  }
331
634
  const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
332
635
  if (wsOutputs !== undefined) {
333
- validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`)
636
+ validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
334
637
  }
335
638
  }
336
639
  const sandbox = (task as { sandbox?: unknown }).sandbox
@@ -338,7 +641,139 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
338
641
  }
339
642
  }
340
643
 
341
- function validateWorkspaceGlobs(v: unknown, where: string): void {
644
+ // Known fields per config level, mirroring the interfaces in src/config.ts.
645
+ // Unknown keys are REJECTED rather than silently dropped: a typo in a
646
+ // cache-key field (`workspaceFile`, `task`, `timeoutMs`) is otherwise
647
+ // discarded, so the task hashes as if the field were never written and vx
648
+ // serves a stale artifact — the same reasoning `exec.resources` and
649
+ // `sandbox` already encode. A new field must be added here deliberately.
650
+ const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache', 'sandbox'])
651
+ const EXEC_FIELDS = new Set([
652
+ 'command',
653
+ 'env',
654
+ 'timeout',
655
+ 'retries',
656
+ 'resources',
657
+ 'persistent',
658
+ 'remote',
659
+ ])
660
+ const PERSISTENT_FIELDS = new Set(['readyWhen'])
661
+ const CACHE_FIELDS = new Set(['inputs', 'outputs'])
662
+ const CACHE_INPUT_FIELDS = new Set([
663
+ 'files',
664
+ 'workspaceFiles',
665
+ 'env',
666
+ 'tasks',
667
+ 'runtime',
668
+ 'workspaceRuntime',
669
+ ])
670
+ const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
671
+
672
+ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
673
+ for (const key of Object.keys(value)) {
674
+ if (!allowed.has(key)) {
675
+ throw new UserError(
676
+ `${where} has unknown field "${key}". Allowed: ${[...allowed].sort().join(', ')}`,
677
+ )
678
+ }
679
+ }
680
+ }
681
+
682
+ /**
683
+ * True when a glob contains a `..` PATH SEGMENT (`../x`, `a/../b`, `x/..`), which
684
+ * escapes its base dir — `foo..bar` / `a..b` inside a filename are fine. A
685
+ * leading negation marker is stripped first so `!../x` is caught too. Used to
686
+ * keep output globs inside the project and workspace globs inside the workspace
687
+ * root: `cleanOutputs` rm()s resolved output paths before every run, and
688
+ * `Bun.Glob.scan` follows `..` out of its cwd, so a `..` glob is a data-loss
689
+ * vector (delete files outside the project / above the repo root).
690
+ */
691
+ /**
692
+ * A millisecond delay past `MAX_TIMEOUT_MS` does not mean "effectively never" —
693
+ * `setTimeout` silently reduces it to 1 ms, so the task is killed the instant it
694
+ * spawns and reported `failed`. That is the exact inverse of the declaration,
695
+ * and the only clue is a `TimeoutOverflowWarning` on stderr.
696
+ *
697
+ * Refused rather than clamped, because this is a value the user WROTE and reads
698
+ * back: silently substituting ~24.8 days for the 317 years they asked for would
699
+ * trade one surprise for a quieter one. Costs nothing — such a config never
700
+ * worked, it killed the task in milliseconds, so refusing reports a state that
701
+ * already existed.
702
+ */
703
+ function assertTimeoutInRange(ms: number, where: string): void {
704
+ if (ms <= MAX_TIMEOUT_MS) return
705
+ throw new UserError(
706
+ `${where}: ${ms} ms exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days). ` +
707
+ `Timers larger than this do NOT mean "no limit" — the platform reduces them to 1 ms, so ` +
708
+ `the task would be killed the moment it starts. Omit \`timeout\` for no limit.`,
709
+ )
710
+ }
711
+
712
+ function hasParentSegment(glob: string): boolean {
713
+ const g = glob.startsWith('!') ? glob.slice(1) : glob
714
+ return g.split('/').some((seg) => seg === '..')
715
+ }
716
+
717
+ /**
718
+ * Refuse a non-empty glob list made up ENTIRELY of negations.
719
+ *
720
+ * The resolvers build their file set from the POSITIVE globs and use `!`
721
+ * entries only to subtract, so with no positive pattern they return `[]` —
722
+ * the task folds zero file inputs and its cache key stops moving with its
723
+ * source. That is a stale hit, and it is silent.
724
+ *
725
+ * Refusing is free: such a config was ALREADY selecting nothing, so no
726
+ * working task's key changes and no CACHE_VERSION bump is owed.
727
+ */
728
+ /**
729
+ * `!!x` INVERTS the input set instead of double-negating it.
730
+ *
731
+ * The resolver strips ONE `!` and hands the remainder to `new Bun.Glob()` —
732
+ * which applies its OWN leading-`!` negation. So `!!vendor/**` becomes the
733
+ * exclude glob `Glob('!vendor/**')`, and that matches every path EXCEPT
734
+ * vendor's. Everything else is therefore excluded and the task folds ONLY
735
+ * `vendor/**`: its own source drops out of the cache key entirely, which is a
736
+ * permanent stale hit.
737
+ *
738
+ * Neither neighbouring guard catches it — `assertNotNegationOnly` sees the
739
+ * positive glob and is satisfied, and `hasParentSegment` strips one `!` before
740
+ * splitting so `!!../x` shows no `..` segment.
741
+ *
742
+ * Refused rather than reinterpreted: `!!` has no coherent meaning here.
743
+ * Negation in vx is subtraction from what a positive glob matched, not
744
+ * gitignore's ordered re-inclusion, so there is nothing for a second `!` to
745
+ * undo. Costs nothing to refuse — such a config already folds the wrong set,
746
+ * so no working cache key changes.
747
+ */
748
+ function assertNotDoubleNegated(glob: string, where: string): void {
749
+ if (!glob.startsWith('!!')) return
750
+ const inner = glob.slice(2)
751
+ throw new UserError(
752
+ `${where}: '!!' is not a double negation (got "${glob}") — it INVERTS the set. ` +
753
+ `One '!' is stripped and the remainder is compiled as a glob, which applies its own ` +
754
+ `leading-'!' negation, so this excludes everything EXCEPT ${JSON.stringify(inner)} and ` +
755
+ `the task folds only that. Use ${JSON.stringify(`!${inner}`)} to subtract it, or ` +
756
+ `${JSON.stringify(inner)} to include it.`,
757
+ )
758
+ }
759
+
760
+ function assertNotNegationOnly(globs: readonly string[], where: string): void {
761
+ if (globs.length === 0) return
762
+ if (globs.some((g) => !g.startsWith('!'))) return
763
+ throw new UserError(
764
+ `${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(globs)}) — ` +
765
+ `negations only subtract from the files a positive glob already matched, so this task ` +
766
+ `would fold zero file inputs and its cache key would stop tracking its source. ` +
767
+ `Add the positive glob you meant, e.g. ['**/*', ${JSON.stringify(globs[0])}].`,
768
+ )
769
+ }
770
+
771
+ /**
772
+ * `negation: false` for OUTPUT globs — `resolveOutputs` /
773
+ * `resolveWorkspaceOutputs` never split on `!`, so such an entry is read as a
774
+ * literal path starting with `!` and matches nothing.
775
+ */
776
+ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): void {
342
777
  if (!Array.isArray(v)) {
343
778
  throw new UserError(`${where} must be an array of glob strings`)
344
779
  }
@@ -352,7 +787,22 @@ function validateWorkspaceGlobs(v: unknown, where: string): void {
352
787
  `entries are workspace-root-relative globs`,
353
788
  )
354
789
  }
790
+ if (hasParentSegment(g)) {
791
+ throw new UserError(
792
+ `${where}: '..' path segments are not allowed (got "${g}") — ` +
793
+ `entries are workspace-root-relative and must stay within the workspace root`,
794
+ )
795
+ }
796
+ if (!negation && g.startsWith('!')) {
797
+ throw new UserError(
798
+ `${where}: negation is not supported (got "${g}") — ` +
799
+ `unlike inputs, output globs are never split on '!', so this is read as a literal ` +
800
+ `path beginning with '!' and matches nothing. List the outputs you DO produce.`,
801
+ )
802
+ }
803
+ if (negation) assertNotDoubleNegated(g, where)
355
804
  }
805
+ if (negation) assertNotNegationOnly(v as string[], where)
356
806
  }
357
807
 
358
808
  const SANDBOX_PATH_FIELDS = ['allowRead', 'allowWrite'] as const
@@ -458,12 +908,11 @@ function validateSandbox(sandbox: unknown, where: string, hasExec: boolean): voi
458
908
  }
459
909
  }
460
910
 
461
- const RESOURCES_FIELDS = new Set(['cpus', 'memory'])
462
- const PERCENT_RE = /^\d+(\.\d+)?%$/
911
+ const RESOURCES_FIELDS = new Set(['cpus', 'memory', 'image'])
463
912
 
464
913
  function validateResources(resources: unknown, where: string): void {
465
914
  if (typeof resources !== 'object' || resources === null || Array.isArray(resources)) {
466
- throw new UserError(`${where} must be an object (e.g. \`{ cpus: 2, memory: '2GB' }\`)`)
915
+ throw new UserError(`${where} must be an object (e.g. \`{ cpus: 2, memory: 2048 }\`)`)
467
916
  }
468
917
  for (const key of Object.keys(resources as object)) {
469
918
  if (!RESOURCES_FIELDS.has(key)) {
@@ -472,27 +921,22 @@ function validateResources(resources: unknown, where: string): void {
472
921
  )
473
922
  }
474
923
  }
475
- const { cpus, memory } = resources as { cpus?: unknown; memory?: unknown }
476
- if (cpus !== undefined) {
477
- const ok =
478
- typeof cpus === 'number'
479
- ? Number.isFinite(cpus) && cpus >= 0
480
- : typeof cpus === 'string' && PERCENT_RE.test(cpus)
481
- if (!ok) {
482
- throw new UserError(`${where}.cpus must be a non-negative number or a "<n>%" string`)
483
- }
924
+ const { cpus, memory, image } = resources as {
925
+ cpus?: unknown
926
+ memory?: unknown
927
+ image?: unknown
484
928
  }
485
- if (memory !== undefined) {
486
- const ok =
487
- typeof memory === 'number'
488
- ? Number.isInteger(memory) && memory >= 0
489
- : typeof memory === 'string' && (PERCENT_RE.test(memory) || parseSize(memory) !== null)
490
- if (!ok) {
491
- throw new UserError(
492
- `${where}.memory must be a non-negative integer (bytes), ` +
493
- `a size string like "512MB", or a "<n>%" string`,
494
- )
495
- }
929
+ if (cpus !== undefined && (typeof cpus !== 'number' || !Number.isFinite(cpus) || cpus < 0)) {
930
+ throw new UserError(`${where}.cpus must be a non-negative number of CPU cores`)
931
+ }
932
+ if (
933
+ memory !== undefined &&
934
+ (typeof memory !== 'number' || !Number.isInteger(memory) || memory < 0)
935
+ ) {
936
+ throw new UserError(`${where}.memory must be a non-negative integer number of megabytes`)
937
+ }
938
+ if (image !== undefined && (typeof image !== 'string' || image === '')) {
939
+ throw new UserError(`${where}.image must be a non-empty string (or omitted)`)
496
940
  }
497
941
  }
498
942