@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,10 +1,16 @@
1
1
  import path from 'node:path'
2
2
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
3
- import { MAX_TIMEOUT_MS, UserError, xxh3hex } from '../util/index.js'
3
+ import { UserError, xxh3hex } from '../util/index.js'
4
+ import { validateProjectConfig, validateWorkspace } from './config-schema.js'
4
5
  import { evaluateConfigFresh } from './config-eval.js'
5
6
  import { configEvalKey, configEvalKeyFromClosure, type ConfigEvalStore } from './config-cache.js'
6
7
 
7
- const WORKSPACE_CONFIG_FILENAMES = [
8
+ // The validator lives in config-schema.ts; re-exported so a reader that
9
+ // reaches the loader for it (the tests do) keeps working.
10
+ export { validateProjectConfig }
11
+
12
+ /** The workspace config's filenames at the root, in lookup order. `vx watch` re-runs on an edit to whichever exists. */
13
+ export const WORKSPACE_CONFIG_FILENAMES = [
8
14
  'vx.workspace.ts',
9
15
  'vx.workspace.mts',
10
16
  'vx.workspace.js',
@@ -44,7 +50,7 @@ async function loadDefaultExport(
44
50
  try {
45
51
  ns = (await import(`${configPath}?vx-bust=${bust}`)) as { default?: unknown }
46
52
  } catch (err) {
47
- throw unresolvedImportError(err, configPath, kind) ?? err
53
+ throw configLoadError(err, configPath, kind) ?? err
48
54
  }
49
55
  const mod = ns?.default
50
56
  assertDefaultObject(mod, kind, configPath)
@@ -52,24 +58,55 @@ async function loadDefaultExport(
52
58
  }
53
59
 
54
60
  /**
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.
61
+ * Where a transpile error was found. Bun's `BuildMessage` carries it as
62
+ * `position`; the config worker forwards the same three fields (see
63
+ * config-eval.ts) so the repeat path names the line too.
60
64
  */
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}`)
65
+ interface BuildPosition {
66
+ file?: string
67
+ line?: number
68
+ column?: number
69
+ }
70
+
71
+ /**
72
+ * Turn the two errors Bun's own loader throws into user errors naming the
73
+ * file the user wrote; every other throw is the config's own and already
74
+ * carries its location in its stack, so it passes through unchanged.
75
+ *
76
+ * `ResolveMessage` — an import that cannot be resolved. The usual cause has
77
+ * one answer: the workspace runs the vx binary and never installed `@vzn/vx`,
78
+ * which its own `vx.workspace.ts` imports. Bun's message also carries the
79
+ * module-cache bust query; the user gets the file they wrote.
80
+ *
81
+ * `BuildMessage` — a syntax or transpile error. Its message alone (`Expected
82
+ * "}" but found end of file`) names no file at all, and the file is not
83
+ * always the config: a preset the config imports fails the same way, so the
84
+ * location is taken from the error's position, with the config as the
85
+ * fallback.
86
+ */
87
+ export function configLoadError(err: unknown, configPath: string, kind: string): UserError | null {
88
+ if (!(err instanceof Error)) return null
89
+ if (err.name === 'ResolveMessage') {
90
+ const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(err.message)?.[1]
91
+ const what =
92
+ spec === undefined ? err.message.replace(/\?vx-bust=\S+/g, '') : `cannot find '${spec}'`
93
+ const hint =
94
+ spec?.startsWith('@vzn/vx') === true
95
+ ? `; install it in the workspace: bun add -d @vzn/vx`
96
+ : ''
97
+ return new UserError(`${kind} config ${configPath}: ${what}${hint}`)
98
+ }
99
+ if (err.name === 'BuildMessage') {
100
+ const pos = (err as { position?: BuildPosition | null }).position ?? {}
101
+ const file = typeof pos.file === 'string' && pos.file.length > 0 ? pos.file : configPath
102
+ const at =
103
+ typeof pos.line === 'number'
104
+ ? `:${pos.line}${typeof pos.column === 'number' ? `:${pos.column}` : ''}`
105
+ : ''
106
+ const where = file === configPath ? `${configPath}${at}` : `${configPath} (in ${file}${at})`
107
+ return new UserError(`${kind} config ${where}: ${err.message}`)
108
+ }
109
+ return null
73
110
  }
74
111
 
75
112
  export interface LoadProjectConfigOptions {
@@ -103,13 +140,29 @@ export async function loadProjectConfigs(
103
140
  hashFile !== undefined && store?.getConfigClosures !== undefined
104
141
  ? store.getConfigClosures(configPaths)
105
142
  : new Map<string, string[]>()
143
+ // Every indexed closure's files identified in ONE batch, then keyed from
144
+ // the map; a file the batch could not stat has no identity, which makes
145
+ // that config's fast key miss and sends it down the slow path exactly as
146
+ // a throwing per-file `hashFile` did.
147
+ let fastHashFile = hashFile
148
+ if (closures.size > 0 && store?.hashFiles !== undefined) {
149
+ const files = new Set<string>()
150
+ for (const closure of closures.values()) for (const f of closure) files.add(f)
151
+ const identities = await store.hashFiles([...files])
152
+ fastHashFile = (file: string): Promise<string> => {
153
+ const id = identities.get(file)
154
+ return id === undefined
155
+ ? Promise.reject(new Error(`no identity for ${file}`))
156
+ : Promise.resolve(id)
157
+ }
158
+ }
106
159
  const prepared = await Promise.all(
107
160
  configPaths.map(async (configPath) => {
108
161
  const closure = closures.get(configPath)
109
- if (closure !== undefined && evalCache !== undefined && hashFile !== undefined) {
162
+ if (closure !== undefined && evalCache !== undefined && fastHashFile !== undefined) {
110
163
  const fastKey = await configEvalKeyFromClosure({
111
164
  closure,
112
- hashFile,
165
+ hashFile: fastHashFile,
113
166
  workspaceFingerprint: evalCache.workspaceFingerprint,
114
167
  })
115
168
  if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
@@ -187,7 +240,7 @@ export async function loadProjectConfigs(
187
240
  loadedConfigs.add(configPath)
188
241
  const mod = repeat
189
242
  ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
190
- throw unresolvedImportError(err, configPath, 'Project') ?? err
243
+ throw configLoadError(err, configPath, 'Project') ?? err
191
244
  })
192
245
  : await loadDefaultExport(configPath, 'Project', opts?.fresh === true, bytes!)
193
246
  assertDefaultObject(mod, 'Project', configPath)
@@ -231,726 +284,3 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
231
284
  validateWorkspace(mod, configPath)
232
285
  return mod
233
286
  }
234
-
235
- function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
236
- if (config.concurrency !== undefined) {
237
- if (
238
- typeof config.concurrency !== 'number' ||
239
- !Number.isFinite(config.concurrency) ||
240
- config.concurrency < 1 ||
241
- !Number.isInteger(config.concurrency)
242
- ) {
243
- throw new UserError(`${configPath}: \`concurrency\` must be a positive integer`)
244
- }
245
- }
246
- if (config.cacheDir !== undefined && typeof config.cacheDir !== 'string') {
247
- throw new UserError(`${configPath}: \`cacheDir\` must be a string`)
248
- }
249
- if (config.timeout !== undefined) {
250
- if (
251
- typeof config.timeout !== 'number' ||
252
- !Number.isInteger(config.timeout) ||
253
- config.timeout <= 0
254
- ) {
255
- throw new UserError(`${configPath}: \`timeout\` must be a positive integer (milliseconds)`)
256
- }
257
- assertTimeoutInRange(config.timeout, `${configPath}: \`timeout\``)
258
- }
259
- if (config.plugins !== undefined) {
260
- if (!Array.isArray(config.plugins)) {
261
- throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
262
- }
263
- for (const [i, p] of config.plugins.entries()) {
264
- if (p === null || typeof p !== 'object') {
265
- throw new UserError(`${configPath}: \`plugins[${i}]\` must be an object`)
266
- }
267
- const plug = p as {
268
- name?: unknown
269
- setup?: unknown
270
- backend?: unknown
271
- cache?: unknown
272
- executor?: unknown
273
- telemetry?: unknown
274
- config?: unknown
275
- project?: unknown
276
- graph?: unknown
277
- key?: unknown
278
- schedule?: unknown
279
- commands?: unknown
280
- teardown?: unknown
281
- }
282
- if (typeof plug.name !== 'string' || plug.name.length === 0) {
283
- throw new UserError(`${configPath}: \`plugins[${i}].name\` must be a non-empty string`)
284
- }
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',
306
- 'setup',
307
- 'cache',
308
- 'executor',
309
- 'telemetry',
310
- 'teardown',
311
- ] as const
312
- for (const cap of caps) {
313
- if (plug[cap] !== undefined && typeof plug[cap] !== 'function') {
314
- throw new UserError(`${configPath}: \`plugins[${i}].${cap}\` must be a function`)
315
- }
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
- }
335
- // A plugin must contribute at least one capability or lifecycle hook
336
- // — an empty `{ name }` object is a no-op authoring mistake.
337
- if (caps.every((cap) => plug[cap] === undefined) && plug.commands === undefined) {
338
- throw new UserError(
339
- `${configPath}: \`plugins[${i}]\` must contribute at least one of ${[...caps, 'commands'].join('/')}`,
340
- )
341
- }
342
- }
343
- }
344
- }
345
-
346
- /**
347
- * Runtime validation for the user-authored config. TypeScript checks
348
- * shape at edit-time, but `vx run` may load configs that were never
349
- * typechecked (plain .js, or TS with errors ignored). Catch the worst
350
- * shape problems early with a clear message rather than letting them
351
- * crash deeper in the orchestrator. Also applied to configs loaded
352
- * back from `vx-lock.json` (a hand-editable file — same boundary).
353
- */
354
- export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
355
- const tasks = config.tasks
356
- if (tasks === undefined) return
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`)
362
- }
363
- for (const [name, task] of Object.entries(tasks)) {
364
- const where = `${configPath}: tasks.${name}`
365
- if (!task || typeof task !== 'object') {
366
- throw new UserError(`${where} must be an object`)
367
- }
368
- assertKnownFields(task, TASK_FIELDS, where)
369
- const exec = (task as { exec?: unknown }).exec
370
- const dependsOn = (task as { dependsOn?: unknown }).dependsOn
371
- const cache = (task as { cache?: unknown }).cache
372
- if (exec !== undefined) {
373
- if (typeof exec !== 'object' || exec === null) {
374
- throw new UserError(`${where}.exec must be an object with a \`command\` string`)
375
- }
376
- assertKnownFields(exec, EXEC_FIELDS, `${where}.exec`)
377
- const command = (exec as { command?: unknown }).command
378
- if (typeof command !== 'string' || command.length === 0) {
379
- throw new UserError(`${where}.exec.command must be a non-empty string`)
380
- }
381
- const timeout = (exec as { timeout?: unknown }).timeout
382
- if (timeout !== undefined) {
383
- if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
384
- throw new UserError(`${where}.exec.timeout must be a positive integer (milliseconds)`)
385
- }
386
- assertTimeoutInRange(timeout, `${where}.exec.timeout`)
387
- }
388
- const retries = (exec as { retries?: unknown }).retries
389
- if (retries !== undefined) {
390
- if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0) {
391
- throw new UserError(`${where}.exec.retries must be a non-negative integer`)
392
- }
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
- }
398
- const resources = (exec as { resources?: unknown }).resources
399
- if (resources !== undefined) {
400
- validateResources(resources, `${where}.exec.resources`)
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
- }
435
- const persistent = (exec as { persistent?: unknown }).persistent
436
- if (persistent !== undefined) {
437
- if (typeof persistent !== 'object' || persistent === null) {
438
- throw new UserError(`${where}.exec.persistent must be an object (or omitted)`)
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`)
448
- const readyWhen = (persistent as { readyWhen?: unknown }).readyWhen
449
- if (readyWhen !== undefined && typeof readyWhen !== 'string') {
450
- throw new UserError(`${where}.exec.persistent.readyWhen must be a string regex`)
451
- }
452
- if (cache !== undefined) {
453
- throw new UserError(
454
- `${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
455
- `don't terminate, so there's no exit to cache`,
456
- )
457
- }
458
- if (retries !== undefined) {
459
- throw new UserError(
460
- `${where}: \`retries\` is not allowed on a persistent task — persistent tasks ` +
461
- `don't terminate, so there's no failed exit to retry`,
462
- )
463
- }
464
- }
465
- } else {
466
- // Group task: no exec, just dependencies. Must declare something to
467
- // depend on, otherwise the task is a literal no-op with nothing to
468
- // chain (almost certainly a config mistake).
469
- if (dependsOn === undefined) {
470
- throw new UserError(
471
- `${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
472
- `(group tasks exist to chain dependencies)`,
473
- )
474
- }
475
- if (cache !== undefined) {
476
- throw new UserError(
477
- `${where}: \`cache\` requires \`exec\` — a group task has nothing to cache`,
478
- )
479
- }
480
- }
481
- const description = (task as { description?: unknown }).description
482
- if (description !== undefined && typeof description !== 'string') {
483
- throw new UserError(`${where}.description must be a string`)
484
- }
485
- if (dependsOn !== undefined) {
486
- if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
487
- throw new UserError(
488
- `${where}.dependsOn must be an array of strings ` +
489
- `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
490
- )
491
- }
492
- }
493
- if (cache !== undefined) {
494
- if (typeof cache !== 'object' || cache === null) {
495
- throw new UserError(`${where}.cache must be an object when present`)
496
- }
497
- assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
498
- const inputs = (cache as { inputs?: unknown }).inputs
499
- const outputs = (cache as { outputs?: unknown }).outputs
500
- if (!inputs || typeof inputs !== 'object') {
501
- throw new UserError(`${where}.cache.inputs is required when \`cache\` is set`)
502
- }
503
- assertKnownFields(inputs, CACHE_INPUT_FIELDS, `${where}.cache.inputs`)
504
- if (!Array.isArray((inputs as { files?: unknown }).files)) {
505
- throw new UserError(`${where}.cache.inputs.files must be an array of glob strings`)
506
- }
507
- const envList = (inputs as { env?: unknown }).env
508
- if (envList !== undefined) {
509
- if (
510
- !Array.isArray(envList) ||
511
- envList.some((s) => typeof s !== 'string' || s.length === 0)
512
- ) {
513
- throw new UserError(
514
- `${where}.cache.inputs.env must be an array of non-empty env var names`,
515
- )
516
- }
517
- for (const name of envList as string[]) {
518
- // Reject wildcards explicitly so users don't silently miss
519
- // env vars they thought they were tracking. Turbo supports
520
- // `VERCEL_*` expansion; vx requires the literal names. If
521
- // we add expansion later it'll be additive — until then,
522
- // surface the footgun instead of returning '' for the
523
- // literal env name `'VERCEL_*'`.
524
- if (/[*?[\]]/.test(name)) {
525
- throw new UserError(
526
- `${where}.cache.inputs.env: wildcards in env names are not supported ` +
527
- `(got "${name}") — list explicit env var names instead`,
528
- )
529
- }
530
- }
531
- }
532
- if (!outputs || typeof outputs !== 'object') {
533
- throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
534
- }
535
- assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
536
- const outFiles = (outputs as { files?: unknown }).files
537
- if (!Array.isArray(outFiles)) {
538
- throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
539
- }
540
- // Reject zero-length strings and absolute paths up front.
541
- // Both reach `resolveOutputs` as undefined behavior; the error
542
- // surfaces deep inside the glob resolver with no line pointing
543
- // at the user's config. Fail loud at load time.
544
- for (const g of outFiles as unknown[]) {
545
- if (typeof g !== 'string' || g.length === 0) {
546
- throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
547
- }
548
- if (g.startsWith('/')) {
549
- throw new UserError(
550
- `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
551
- `outputs must be project-relative globs`,
552
- )
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
- }
568
- }
569
- // Same for inputs.files.
570
- for (const g of (inputs as { files: unknown[] }).files) {
571
- if (typeof g !== 'string' || g.length === 0) {
572
- throw new UserError(`${where}.cache.inputs.files must be an array of non-empty strings`)
573
- }
574
- if (g.startsWith('/')) {
575
- throw new UserError(
576
- `${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
577
- `inputs must be project-relative globs`,
578
- )
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
- }
617
- }
618
- for (const field of ['runtime', 'workspaceRuntime'] as const) {
619
- const list = (inputs as Record<string, unknown>)[field]
620
- if (list !== undefined) {
621
- if (!Array.isArray(list) || list.some((s) => typeof s !== 'string' || s.length === 0)) {
622
- throw new UserError(
623
- `${where}.cache.inputs.${field} must be an array of non-empty shell command strings`,
624
- )
625
- }
626
- }
627
- }
628
- // workspaceFiles mirror the files validation: non-empty strings,
629
- // never absolute (they're workspace-root-relative by definition).
630
- const wsInputs = (inputs as { workspaceFiles?: unknown }).workspaceFiles
631
- if (wsInputs !== undefined) {
632
- validateWorkspaceGlobs(wsInputs, `${where}.cache.inputs.workspaceFiles`, true)
633
- }
634
- const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
635
- if (wsOutputs !== undefined) {
636
- validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
637
- }
638
- }
639
- const sandbox = (task as { sandbox?: unknown }).sandbox
640
- if (sandbox !== undefined) validateSandbox(sandbox, where, exec !== undefined)
641
- }
642
- }
643
-
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 {
777
- if (!Array.isArray(v)) {
778
- throw new UserError(`${where} must be an array of glob strings`)
779
- }
780
- for (const g of v as unknown[]) {
781
- if (typeof g !== 'string' || g.length === 0) {
782
- throw new UserError(`${where} must be an array of non-empty strings`)
783
- }
784
- if (g.startsWith('/') || g.startsWith('!/')) {
785
- throw new UserError(
786
- `${where}: absolute paths are not allowed (got "${g}") — ` +
787
- `entries are workspace-root-relative globs`,
788
- )
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)
804
- }
805
- if (negation) assertNotNegationOnly(v as string[], where)
806
- }
807
-
808
- const SANDBOX_PATH_FIELDS = ['allowRead', 'allowWrite'] as const
809
- const SANDBOX_BOOL_FIELDS = [
810
- 'allowGitConfig',
811
- 'allowPty',
812
- 'enableWeakerNestedSandbox',
813
- 'enableWeakerNetworkIsolation',
814
- ] as const
815
- const SANDBOX_NETWORK_PATH_FIELDS = [
816
- 'allowedDomains',
817
- 'deniedDomains',
818
- 'allowUnixSockets',
819
- 'allowMachLookup',
820
- ] as const
821
- const SANDBOX_NETWORK_BOOL_FIELDS = ['allowAllUnixSockets', 'allowLocalBinding'] as const
822
- const SANDBOX_FIELDS = new Set<string>([
823
- ...SANDBOX_PATH_FIELDS,
824
- ...SANDBOX_BOOL_FIELDS,
825
- 'network',
826
- 'ignoreViolations',
827
- ])
828
- const SANDBOX_NETWORK_FIELDS = new Set<string>([
829
- ...SANDBOX_NETWORK_PATH_FIELDS,
830
- ...SANDBOX_NETWORK_BOOL_FIELDS,
831
- ])
832
-
833
- /**
834
- * Validate a `sandbox: {...}` block. The field set mirrors the user-
835
- * facing surface of SandboxConfig in src/config.ts; this is the only
836
- * place we enforce shape at runtime (TS users get compile-time checks
837
- * already).
838
- */
839
- function validateSandbox(sandbox: unknown, where: string, hasExec: boolean): void {
840
- if (typeof sandbox !== 'object' || sandbox === null || Array.isArray(sandbox)) {
841
- throw new UserError(
842
- `${where}.sandbox must be an object (e.g. \`{}\` for the baseline, or ` +
843
- `\`{ allowRead: [...], network: true }\`)`,
844
- )
845
- }
846
- if (!hasExec) {
847
- throw new UserError(`${where}.sandbox requires \`exec\` — a group task has nothing to wrap`)
848
- }
849
- for (const key of Object.keys(sandbox as object)) {
850
- if (!SANDBOX_FIELDS.has(key)) {
851
- throw new UserError(
852
- `${where}.sandbox.${key} is not a known field. Allowed: ` +
853
- `${[...SANDBOX_FIELDS].sort().join(', ')}`,
854
- )
855
- }
856
- }
857
- const obj = sandbox as Record<string, unknown>
858
- for (const field of SANDBOX_PATH_FIELDS) {
859
- if (obj[field] === undefined) continue
860
- assertPathArray(obj[field], `${where}.sandbox.${field}`)
861
- }
862
- for (const field of SANDBOX_BOOL_FIELDS) {
863
- if (obj[field] !== undefined && typeof obj[field] !== 'boolean') {
864
- throw new UserError(`${where}.sandbox.${field} must be a boolean`)
865
- }
866
- }
867
- const network = obj.network
868
- if (network !== undefined && typeof network !== 'boolean') {
869
- if (typeof network !== 'object' || network === null || Array.isArray(network)) {
870
- throw new UserError(
871
- `${where}.sandbox.network must be a boolean or an object (allowedDomains, ` +
872
- `deniedDomains, allowUnixSockets, allowAllUnixSockets, allowLocalBinding, allowMachLookup)`,
873
- )
874
- }
875
- for (const key of Object.keys(network)) {
876
- if (!SANDBOX_NETWORK_FIELDS.has(key)) {
877
- throw new UserError(
878
- `${where}.sandbox.network.${key} is not a known field. Allowed: ` +
879
- `${[...SANDBOX_NETWORK_FIELDS].sort().join(', ')}`,
880
- )
881
- }
882
- }
883
- const netObj = network as Record<string, unknown>
884
- for (const field of SANDBOX_NETWORK_PATH_FIELDS) {
885
- if (netObj[field] === undefined) continue
886
- assertStringArray(netObj[field], `${where}.sandbox.network.${field}`)
887
- }
888
- for (const field of SANDBOX_NETWORK_BOOL_FIELDS) {
889
- if (netObj[field] !== undefined && typeof netObj[field] !== 'boolean') {
890
- throw new UserError(`${where}.sandbox.network.${field} must be a boolean`)
891
- }
892
- }
893
- }
894
- const ignoreViolations = obj.ignoreViolations
895
- if (ignoreViolations !== undefined) {
896
- if (
897
- typeof ignoreViolations !== 'object' ||
898
- ignoreViolations === null ||
899
- Array.isArray(ignoreViolations)
900
- ) {
901
- throw new UserError(
902
- `${where}.sandbox.ignoreViolations must be a record mapping command patterns to arrays of paths`,
903
- )
904
- }
905
- for (const [k, v] of Object.entries(ignoreViolations)) {
906
- assertStringArray(v, `${where}.sandbox.ignoreViolations[${JSON.stringify(k)}]`)
907
- }
908
- }
909
- }
910
-
911
- const RESOURCES_FIELDS = new Set(['cpus', 'memory', 'image'])
912
-
913
- function validateResources(resources: unknown, where: string): void {
914
- if (typeof resources !== 'object' || resources === null || Array.isArray(resources)) {
915
- throw new UserError(`${where} must be an object (e.g. \`{ cpus: 2, memory: 2048 }\`)`)
916
- }
917
- for (const key of Object.keys(resources as object)) {
918
- if (!RESOURCES_FIELDS.has(key)) {
919
- throw new UserError(
920
- `${where} has unknown field "${key}". Allowed: ${[...RESOURCES_FIELDS].sort().join(', ')}`,
921
- )
922
- }
923
- }
924
- const { cpus, memory, image } = resources as {
925
- cpus?: unknown
926
- memory?: unknown
927
- image?: unknown
928
- }
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)`)
940
- }
941
- }
942
-
943
- function assertStringArray(v: unknown, where: string): void {
944
- if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
945
- throw new UserError(`${where} must be an array of non-empty strings`)
946
- }
947
- }
948
-
949
- function assertPathArray(v: unknown, where: string): void {
950
- assertStringArray(v, where)
951
- if ((v as string[]).some((s) => /[*?[\]]/.test(s))) {
952
- throw new UserError(
953
- `${where} entries must be path prefixes (no globs — bwrap on Linux can't enforce them)`,
954
- )
955
- }
956
- }