@vzn/vx 0.0.16 → 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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,14 +1,31 @@
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 { UserError, xxh3hex } from '../util/index.js'
4
+ import { validateProjectConfig, validateWorkspace } from './config-schema.js'
5
+ import { evaluateConfigFresh } from './config-eval.js'
6
+ import { configEvalKey, configEvalKeyFromClosure, type ConfigEvalStore } from './config-cache.js'
4
7
 
5
- 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 = [
6
14
  'vx.workspace.ts',
7
15
  'vx.workspace.mts',
8
16
  'vx.workspace.js',
9
17
  'vx.workspace.mjs',
10
18
  ]
11
19
 
20
+ /** Project configs already loaded in this process, by absolute path. */
21
+ const loadedConfigs = new Set<string>()
22
+
23
+ function assertDefaultObject(mod: unknown, kind: string, configPath: string): void {
24
+ if (!mod || typeof mod !== 'object') {
25
+ throw new UserError(`${kind} config at ${configPath} did not export a default object`)
26
+ }
27
+ }
28
+
12
29
  // Bun has native TS / ESM execution — no transpiler dep needed. We fold
13
30
  // a short content hash into the import URL as a cache-bust key so that:
14
31
  // same content → same URL → Bun's module cache hits (fast)
@@ -21,492 +38,249 @@ async function loadDefaultExport(
21
38
  configPath: string,
22
39
  kind: string,
23
40
  fresh = false,
41
+ bytes?: Uint8Array,
24
42
  ): Promise<unknown> {
25
- const bytes = await Bun.file(configPath).bytes()
43
+ bytes ??= await Bun.file(configPath).bytes()
26
44
  // `fresh` opts out of module-cache reuse entirely: `vx lock` and
27
45
  // `vx lock --check` must observe the CURRENT environment, and the
28
46
  // content-hash bust would replay an evaluation made under earlier
29
47
  // env values when the file bytes are unchanged in this process.
30
48
  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`)
49
+ let ns: { default?: unknown }
50
+ try {
51
+ ns = (await import(`${configPath}?vx-bust=${bust}`)) as { default?: unknown }
52
+ } catch (err) {
53
+ throw configLoadError(err, configPath, kind) ?? err
35
54
  }
55
+ const mod = ns?.default
56
+ assertDefaultObject(mod, kind, configPath)
36
57
  return mod
37
58
  }
38
59
 
39
- export async function loadProjectConfig(
40
- configPath: string,
41
- opts?: { fresh?: boolean },
42
- ): 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
60
+ /**
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.
64
+ */
65
+ interface BuildPosition {
66
+ file?: string
67
+ line?: number
68
+ column?: number
50
69
  }
51
70
 
52
71
  /**
53
- * Find and load `vx.workspace.{ts,mts,js,mjs}` from the workspace
54
- * root. Returns `null` if no such file exists (the common case;
55
- * the schema is fully optional). Validates the shape and throws
56
- * a `UserError` on malformed input.
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.
57
86
  */
58
- export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig | null> {
59
- let configPath: string | null = null
60
- for (const candidate of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
61
- if (await Bun.file(candidate).exists()) {
62
- configPath = candidate
63
- break
64
- }
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}`)
65
98
  }
66
- if (!configPath) return null
67
- const mod = (await loadDefaultExport(configPath, 'Workspace')) as WorkspaceConfig
68
- validateWorkspace(mod, configPath)
69
- return mod
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
70
110
  }
71
111
 
72
- function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
73
- if (config.concurrency !== undefined) {
74
- if (
75
- typeof config.concurrency !== 'number' ||
76
- !Number.isFinite(config.concurrency) ||
77
- config.concurrency < 1 ||
78
- !Number.isInteger(config.concurrency)
79
- ) {
80
- throw new UserError(`${configPath}: \`concurrency\` must be a positive integer`)
81
- }
82
- }
83
- if (config.cacheDir !== undefined && typeof config.cacheDir !== 'string') {
84
- throw new UserError(`${configPath}: \`cacheDir\` must be a string`)
85
- }
86
- if (config.timeout !== undefined) {
87
- if (
88
- typeof config.timeout !== 'number' ||
89
- !Number.isInteger(config.timeout) ||
90
- config.timeout <= 0
91
- ) {
92
- throw new UserError(`${configPath}: \`timeout\` must be a positive integer (milliseconds)`)
93
- }
94
- }
95
- if (config.plugins !== undefined) {
96
- if (!Array.isArray(config.plugins)) {
97
- throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
98
- }
99
- for (const [i, p] of config.plugins.entries()) {
100
- if (p === null || typeof p !== 'object') {
101
- throw new UserError(`${configPath}: \`plugins[${i}]\` must be an object`)
102
- }
103
- const plug = p as {
104
- name?: unknown
105
- setup?: unknown
106
- backend?: unknown
107
- cache?: unknown
108
- telemetry?: unknown
109
- eventSink?: unknown
110
- teardown?: unknown
111
- }
112
- if (typeof plug.name !== 'string' || plug.name.length === 0) {
113
- throw new UserError(`${configPath}: \`plugins[${i}].name\` must be a non-empty string`)
114
- }
115
- for (const cap of [
116
- 'setup',
117
- 'backend',
118
- 'cache',
119
- 'telemetry',
120
- 'eventSink',
121
- 'teardown',
122
- ] as const) {
123
- if (plug[cap] !== undefined && typeof plug[cap] !== 'function') {
124
- throw new UserError(`${configPath}: \`plugins[${i}].${cap}\` must be a function`)
125
- }
126
- }
127
- // A plugin must contribute at least one capability or lifecycle hook
128
- // — 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
- ) {
137
- throw new UserError(
138
- `${configPath}: \`plugins[${i}]\` must contribute at least one of setup/backend/cache/telemetry/eventSink`,
139
- )
140
- }
141
- }
142
- }
143
- if (config.predictive !== undefined && typeof config.predictive !== 'boolean') {
144
- throw new UserError(`${configPath}: \`predictive\` must be a boolean`)
145
- }
112
+ export interface LoadProjectConfigOptions {
113
+ /** Observe the CURRENT environment: no module-cache reuse, no eval cache. */
114
+ fresh?: boolean
115
+ /**
116
+ * Serve a provably-pure config from its cached evaluation (see
117
+ * config-cache.ts) and store a fresh one for next time. Off for `fresh`.
118
+ */
119
+ evalCache?: { store: ConfigEvalStore; workspaceFingerprint: string }
146
120
  }
147
121
 
148
122
  /**
149
- * Runtime validation for the user-authored config. TypeScript checks
150
- * shape at edit-time, but `vx run` may load configs that were never
151
- * typechecked (plain .js, or TS with errors ignored). Catch the worst
152
- * shape problems early with a clear message rather than letting them
153
- * crash deeper in the orchestrator. Also applied to configs loaded
154
- * back from `vx-lock.json` (a hand-editable file — same boundary).
123
+ * Load many configs at once: every file's bytes and cache key in parallel,
124
+ * ONE store lookup for all keys, then only the misses are evaluated (in the
125
+ * order given, so a failure names the first broken file the way a
126
+ * one-by-one load did). A single-path load is the one-element case.
155
127
  */
156
- export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
157
- const tasks = config.tasks
158
- if (tasks === undefined) return
159
- if (typeof tasks !== 'object' || tasks === null) {
160
- throw new UserError(`${configPath}: \`tasks\` must be an object`)
161
- }
162
- for (const [name, task] of Object.entries(tasks)) {
163
- const where = `${configPath}: tasks.${name}`
164
- if (!task || typeof task !== 'object') {
165
- throw new UserError(`${where} must be an object`)
128
+ export async function loadProjectConfigs(
129
+ configPaths: readonly string[],
130
+ opts?: LoadProjectConfigOptions,
131
+ ): Promise<ProjectConfig[]> {
132
+ const evalCache = opts?.fresh === true ? undefined : opts?.evalCache
133
+ const store = evalCache?.store
134
+ const hashFile = store?.hashFile?.bind(store)
135
+ // The warm fast path: a config whose ordered closure the store remembers
136
+ // is keyed from per-file identities (a stat each, no read, no scan); the
137
+ // slow path below reads, gates and scans, and indexes the closure for
138
+ // next time when every import is explicit.
139
+ const closures =
140
+ hashFile !== undefined && store?.getConfigClosures !== undefined
141
+ ? store.getConfigClosures(configPaths)
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)
166
157
  }
167
- const exec = (task as { exec?: unknown }).exec
168
- const dependsOn = (task as { dependsOn?: unknown }).dependsOn
169
- const cache = (task as { cache?: unknown }).cache
170
- if (exec !== undefined) {
171
- if (typeof exec !== 'object' || exec === null) {
172
- throw new UserError(`${where}.exec must be an object with a \`command\` string`)
173
- }
174
- const command = (exec as { command?: unknown }).command
175
- if (typeof command !== 'string' || command.length === 0) {
176
- throw new UserError(`${where}.exec.command must be a non-empty string`)
177
- }
178
- const timeout = (exec as { timeout?: unknown }).timeout
179
- if (timeout !== undefined) {
180
- if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
181
- throw new UserError(`${where}.exec.timeout must be a positive integer (milliseconds)`)
182
- }
183
- }
184
- const retries = (exec as { retries?: unknown }).retries
185
- if (retries !== undefined) {
186
- if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0) {
187
- throw new UserError(`${where}.exec.retries must be a non-negative integer`)
188
- }
158
+ }
159
+ const prepared = await Promise.all(
160
+ configPaths.map(async (configPath) => {
161
+ const closure = closures.get(configPath)
162
+ if (closure !== undefined && evalCache !== undefined && fastHashFile !== undefined) {
163
+ const fastKey = await configEvalKeyFromClosure({
164
+ closure,
165
+ hashFile: fastHashFile,
166
+ workspaceFingerprint: evalCache.workspaceFingerprint,
167
+ })
168
+ if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
189
169
  }
190
- const resources = (exec as { resources?: unknown }).resources
191
- if (resources !== undefined) {
192
- validateResources(resources, `${where}.exec.resources`)
170
+ const bytes = await Bun.file(configPath).bytes()
171
+ const keyed =
172
+ evalCache === undefined
173
+ ? null
174
+ : await configEvalKey({
175
+ configPath,
176
+ bytes,
177
+ workspaceFingerprint: evalCache.workspaceFingerprint,
178
+ ...(hashFile !== undefined ? { hashFile } : {}),
179
+ })
180
+ return {
181
+ configPath,
182
+ bytes,
183
+ cacheKey: keyed?.key ?? null,
184
+ indexed: false,
185
+ closure: keyed !== null && keyed.indexable ? keyed.closure : undefined,
193
186
  }
194
- const persistent = (exec as { persistent?: unknown }).persistent
195
- if (persistent !== undefined) {
196
- if (typeof persistent !== 'object' || persistent === null) {
197
- throw new UserError(`${where}.exec.persistent must be an object (or omitted)`)
198
- }
199
- const readyWhen = (persistent as { readyWhen?: unknown }).readyWhen
200
- if (readyWhen !== undefined && typeof readyWhen !== 'string') {
201
- throw new UserError(`${where}.exec.persistent.readyWhen must be a string regex`)
202
- }
203
- if (cache !== undefined) {
204
- throw new UserError(
205
- `${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
206
- `don't terminate, so there's no exit to cache`,
207
- )
208
- }
209
- if (retries !== undefined) {
210
- throw new UserError(
211
- `${where}: \`retries\` is not allowed on a persistent task — persistent tasks ` +
212
- `don't terminate, so there's no failed exit to retry`,
213
- )
187
+ }),
188
+ )
189
+ let hits = new Map<string, string>()
190
+ if (evalCache !== undefined) {
191
+ const keys = prepared.map((p) => p.cacheKey).filter((k): k is string => k !== null)
192
+ if (keys.length > 0) {
193
+ if (evalCache.store.getConfigEvals !== undefined) {
194
+ hits = evalCache.store.getConfigEvals(keys)
195
+ } else {
196
+ for (const k of keys) {
197
+ const hit = evalCache.store.getConfigEval(k)
198
+ if (hit !== null) hits.set(k, hit)
214
199
  }
215
200
  }
216
- } else {
217
- // Group task: no exec, just dependencies. Must declare something to
218
- // depend on, otherwise the task is a literal no-op with nothing to
219
- // chain (almost certainly a config mistake).
220
- if (dependsOn === undefined) {
221
- throw new UserError(
222
- `${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
223
- `(group tasks exist to chain dependencies)`,
224
- )
225
- }
226
- if (cache !== undefined) {
227
- throw new UserError(
228
- `${where}: \`cache\` requires \`exec\` — a group task has nothing to cache`,
229
- )
230
- }
231
201
  }
232
- const description = (task as { description?: unknown }).description
233
- if (description !== undefined && typeof description !== 'string') {
234
- throw new UserError(`${where}.description must be a string`)
202
+ }
203
+ const out: ProjectConfig[] = []
204
+ for (const entry of prepared) {
205
+ const { configPath, cacheKey } = entry
206
+ const hit = cacheKey === null ? undefined : hits.get(cacheKey)
207
+ // Stored AFTER validation, so a hit needs none; the key covers every
208
+ // byte the evaluation could have read.
209
+ if (hit !== undefined) {
210
+ out.push(JSON.parse(hit) as ProjectConfig)
211
+ continue
235
212
  }
236
- if (dependsOn !== undefined) {
237
- if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
238
- throw new UserError(
239
- `${where}.dependsOn must be an array of strings ` +
240
- `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
241
- )
213
+ // A fast key that missed: the closure is stale or the file changed.
214
+ // Take the slow path for this one config, which re-indexes it.
215
+ let bytes = entry.bytes
216
+ let closure = entry.closure
217
+ let key = cacheKey
218
+ if (entry.indexed) {
219
+ bytes = await Bun.file(configPath).bytes()
220
+ const keyed = await configEvalKey({
221
+ configPath,
222
+ bytes,
223
+ workspaceFingerprint: evalCache!.workspaceFingerprint,
224
+ ...(hashFile !== undefined ? { hashFile } : {}),
225
+ })
226
+ key = keyed?.key ?? null
227
+ closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
228
+ const slowHit = key === null ? null : (store!.getConfigEval(key) ?? null)
229
+ if (slowHit !== null) {
230
+ out.push(JSON.parse(slowHit) as ProjectConfig)
231
+ if (closure !== undefined) store!.putConfigClosure?.(configPath, closure)
232
+ continue
242
233
  }
243
234
  }
244
- if (cache !== undefined) {
245
- if (typeof cache !== 'object' || cache === null) {
246
- throw new UserError(`${where}.cache must be an object when present`)
247
- }
248
- const inputs = (cache as { inputs?: unknown }).inputs
249
- const outputs = (cache as { outputs?: unknown }).outputs
250
- if (!inputs || typeof inputs !== 'object') {
251
- throw new UserError(`${where}.cache.inputs is required when \`cache\` is set`)
252
- }
253
- if (!Array.isArray((inputs as { files?: unknown }).files)) {
254
- throw new UserError(`${where}.cache.inputs.files must be an array of glob strings`)
255
- }
256
- const envList = (inputs as { env?: unknown }).env
257
- if (envList !== undefined) {
258
- if (
259
- !Array.isArray(envList) ||
260
- envList.some((s) => typeof s !== 'string' || s.length === 0)
261
- ) {
262
- throw new UserError(
263
- `${where}.cache.inputs.env must be an array of non-empty env var names`,
264
- )
265
- }
266
- for (const name of envList as string[]) {
267
- // Reject wildcards explicitly so users don't silently miss
268
- // env vars they thought they were tracking. Turbo supports
269
- // `VERCEL_*` expansion; vx requires the literal names. If
270
- // we add expansion later it'll be additive — until then,
271
- // surface the footgun instead of returning '' for the
272
- // literal env name `'VERCEL_*'`.
273
- if (/[*?[\]]/.test(name)) {
274
- throw new UserError(
275
- `${where}.cache.inputs.env: wildcards in env names are not supported ` +
276
- `(got "${name}") — list explicit env var names instead`,
277
- )
278
- }
279
- }
280
- }
281
- if (!outputs || typeof outputs !== 'object') {
282
- throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
283
- }
284
- const outFiles = (outputs as { files?: unknown }).files
285
- if (!Array.isArray(outFiles)) {
286
- throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
287
- }
288
- // Reject zero-length strings and absolute paths up front.
289
- // Both reach `resolveOutputs` as undefined behavior; the error
290
- // surfaces deep inside the glob resolver with no line pointing
291
- // at the user's config. Fail loud at load time.
292
- for (const g of outFiles as unknown[]) {
293
- if (typeof g !== 'string' || g.length === 0) {
294
- throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
295
- }
296
- if (g.startsWith('/')) {
297
- throw new UserError(
298
- `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
299
- `outputs must be project-relative globs`,
300
- )
301
- }
302
- }
303
- // Same for inputs.files.
304
- for (const g of (inputs as { files: unknown[] }).files) {
305
- if (typeof g !== 'string' || g.length === 0) {
306
- throw new UserError(`${where}.cache.inputs.files must be an array of non-empty strings`)
307
- }
308
- if (g.startsWith('/')) {
309
- throw new UserError(
310
- `${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
311
- `inputs must be project-relative globs`,
312
- )
313
- }
314
- }
315
- for (const field of ['runtime', 'workspaceRuntime'] as const) {
316
- const list = (inputs as Record<string, unknown>)[field]
317
- if (list !== undefined) {
318
- if (!Array.isArray(list) || list.some((s) => typeof s !== 'string' || s.length === 0)) {
319
- throw new UserError(
320
- `${where}.cache.inputs.${field} must be an array of non-empty shell command strings`,
321
- )
322
- }
323
- }
324
- }
325
- // workspaceFiles mirror the files validation: non-empty strings,
326
- // never absolute (they're workspace-root-relative by definition).
327
- const wsInputs = (inputs as { workspaceFiles?: unknown }).workspaceFiles
328
- if (wsInputs !== undefined) {
329
- validateWorkspaceGlobs(wsInputs, `${where}.cache.inputs.workspaceFiles`)
330
- }
331
- const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
332
- if (wsOutputs !== undefined) {
333
- validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`)
334
- }
235
+ // A REPEAT load in this process re-evaluates in a worker, because the
236
+ // bust above cannot reach the config's import closure — see
237
+ // config-eval.ts. A FIRST load keeps the in-process import, so the
238
+ // single `vx run` hot path never pays for a worker.
239
+ const repeat = loadedConfigs.has(configPath)
240
+ loadedConfigs.add(configPath)
241
+ const mod = repeat
242
+ ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
243
+ throw configLoadError(err, configPath, 'Project') ?? err
244
+ })
245
+ : await loadDefaultExport(configPath, 'Project', opts?.fresh === true, bytes!)
246
+ assertDefaultObject(mod, 'Project', configPath)
247
+ // Validation runs HERE, on whichever object we ended up with, so a
248
+ // malformed config reports the identical UserError whether it was
249
+ // evaluated in-process or in a worker.
250
+ validateProjectConfig(mod as ProjectConfig, configPath)
251
+ if (key !== null) {
252
+ evalCache!.store.putConfigEval(key, JSON.stringify(mod))
253
+ if (closure !== undefined) evalCache!.store.putConfigClosure?.(configPath, closure)
335
254
  }
336
- const sandbox = (task as { sandbox?: unknown }).sandbox
337
- if (sandbox !== undefined) validateSandbox(sandbox, where, exec !== undefined)
255
+ out.push(mod as ProjectConfig)
338
256
  }
257
+ return out
339
258
  }
340
259
 
341
- function validateWorkspaceGlobs(v: unknown, where: string): void {
342
- if (!Array.isArray(v)) {
343
- throw new UserError(`${where} must be an array of glob strings`)
344
- }
345
- for (const g of v as unknown[]) {
346
- if (typeof g !== 'string' || g.length === 0) {
347
- throw new UserError(`${where} must be an array of non-empty strings`)
348
- }
349
- if (g.startsWith('/') || g.startsWith('!/')) {
350
- throw new UserError(
351
- `${where}: absolute paths are not allowed (got "${g}") — ` +
352
- `entries are workspace-root-relative globs`,
353
- )
354
- }
355
- }
260
+ export async function loadProjectConfig(
261
+ configPath: string,
262
+ opts?: LoadProjectConfigOptions,
263
+ ): Promise<ProjectConfig> {
264
+ const [config] = await loadProjectConfigs([configPath], opts)
265
+ return config!
356
266
  }
357
267
 
358
- const SANDBOX_PATH_FIELDS = ['allowRead', 'allowWrite'] as const
359
- const SANDBOX_BOOL_FIELDS = [
360
- 'allowGitConfig',
361
- 'allowPty',
362
- 'enableWeakerNestedSandbox',
363
- 'enableWeakerNetworkIsolation',
364
- ] as const
365
- const SANDBOX_NETWORK_PATH_FIELDS = [
366
- 'allowedDomains',
367
- 'deniedDomains',
368
- 'allowUnixSockets',
369
- 'allowMachLookup',
370
- ] as const
371
- const SANDBOX_NETWORK_BOOL_FIELDS = ['allowAllUnixSockets', 'allowLocalBinding'] as const
372
- const SANDBOX_FIELDS = new Set<string>([
373
- ...SANDBOX_PATH_FIELDS,
374
- ...SANDBOX_BOOL_FIELDS,
375
- 'network',
376
- 'ignoreViolations',
377
- ])
378
- const SANDBOX_NETWORK_FIELDS = new Set<string>([
379
- ...SANDBOX_NETWORK_PATH_FIELDS,
380
- ...SANDBOX_NETWORK_BOOL_FIELDS,
381
- ])
382
-
383
268
  /**
384
- * Validate a `sandbox: {...}` block. The field set mirrors the user-
385
- * facing surface of SandboxConfig in src/config.ts; this is the only
386
- * place we enforce shape at runtime (TS users get compile-time checks
387
- * already).
269
+ * Find and load `vx.workspace.{ts,mts,js,mjs}` from the workspace
270
+ * root. Returns `null` if no such file exists (the common case;
271
+ * the schema is fully optional). Validates the shape and throws
272
+ * a `UserError` on malformed input.
388
273
  */
389
- function validateSandbox(sandbox: unknown, where: string, hasExec: boolean): void {
390
- if (typeof sandbox !== 'object' || sandbox === null || Array.isArray(sandbox)) {
391
- throw new UserError(
392
- `${where}.sandbox must be an object (e.g. \`{}\` for the baseline, or ` +
393
- `\`{ allowRead: [...], network: true }\`)`,
394
- )
395
- }
396
- if (!hasExec) {
397
- throw new UserError(`${where}.sandbox requires \`exec\` — a group task has nothing to wrap`)
398
- }
399
- for (const key of Object.keys(sandbox as object)) {
400
- if (!SANDBOX_FIELDS.has(key)) {
401
- throw new UserError(
402
- `${where}.sandbox.${key} is not a known field. Allowed: ` +
403
- `${[...SANDBOX_FIELDS].sort().join(', ')}`,
404
- )
405
- }
406
- }
407
- const obj = sandbox as Record<string, unknown>
408
- for (const field of SANDBOX_PATH_FIELDS) {
409
- if (obj[field] === undefined) continue
410
- assertPathArray(obj[field], `${where}.sandbox.${field}`)
411
- }
412
- for (const field of SANDBOX_BOOL_FIELDS) {
413
- if (obj[field] !== undefined && typeof obj[field] !== 'boolean') {
414
- throw new UserError(`${where}.sandbox.${field} must be a boolean`)
415
- }
416
- }
417
- const network = obj.network
418
- if (network !== undefined && typeof network !== 'boolean') {
419
- if (typeof network !== 'object' || network === null || Array.isArray(network)) {
420
- throw new UserError(
421
- `${where}.sandbox.network must be a boolean or an object (allowedDomains, ` +
422
- `deniedDomains, allowUnixSockets, allowAllUnixSockets, allowLocalBinding, allowMachLookup)`,
423
- )
424
- }
425
- for (const key of Object.keys(network)) {
426
- if (!SANDBOX_NETWORK_FIELDS.has(key)) {
427
- throw new UserError(
428
- `${where}.sandbox.network.${key} is not a known field. Allowed: ` +
429
- `${[...SANDBOX_NETWORK_FIELDS].sort().join(', ')}`,
430
- )
431
- }
432
- }
433
- const netObj = network as Record<string, unknown>
434
- for (const field of SANDBOX_NETWORK_PATH_FIELDS) {
435
- if (netObj[field] === undefined) continue
436
- assertStringArray(netObj[field], `${where}.sandbox.network.${field}`)
437
- }
438
- for (const field of SANDBOX_NETWORK_BOOL_FIELDS) {
439
- if (netObj[field] !== undefined && typeof netObj[field] !== 'boolean') {
440
- throw new UserError(`${where}.sandbox.network.${field} must be a boolean`)
441
- }
442
- }
443
- }
444
- const ignoreViolations = obj.ignoreViolations
445
- if (ignoreViolations !== undefined) {
446
- if (
447
- typeof ignoreViolations !== 'object' ||
448
- ignoreViolations === null ||
449
- Array.isArray(ignoreViolations)
450
- ) {
451
- throw new UserError(
452
- `${where}.sandbox.ignoreViolations must be a record mapping command patterns to arrays of paths`,
453
- )
454
- }
455
- for (const [k, v] of Object.entries(ignoreViolations)) {
456
- assertStringArray(v, `${where}.sandbox.ignoreViolations[${JSON.stringify(k)}]`)
457
- }
458
- }
459
- }
460
-
461
- const RESOURCES_FIELDS = new Set(['cpus', 'memory'])
462
- const PERCENT_RE = /^\d+(\.\d+)?%$/
463
-
464
- function validateResources(resources: unknown, where: string): void {
465
- if (typeof resources !== 'object' || resources === null || Array.isArray(resources)) {
466
- throw new UserError(`${where} must be an object (e.g. \`{ cpus: 2, memory: '2GB' }\`)`)
467
- }
468
- for (const key of Object.keys(resources as object)) {
469
- if (!RESOURCES_FIELDS.has(key)) {
470
- throw new UserError(
471
- `${where} has unknown field "${key}". Allowed: ${[...RESOURCES_FIELDS].sort().join(', ')}`,
472
- )
473
- }
474
- }
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
- }
484
- }
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
- )
274
+ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig | null> {
275
+ let configPath: string | null = null
276
+ for (const candidate of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
277
+ if (await Bun.file(candidate).exists()) {
278
+ configPath = candidate
279
+ break
495
280
  }
496
281
  }
497
- }
498
-
499
- function assertStringArray(v: unknown, where: string): void {
500
- if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
501
- throw new UserError(`${where} must be an array of non-empty strings`)
502
- }
503
- }
504
-
505
- function assertPathArray(v: unknown, where: string): void {
506
- assertStringArray(v, where)
507
- if ((v as string[]).some((s) => /[*?[\]]/.test(s))) {
508
- throw new UserError(
509
- `${where} entries must be path prefixes (no globs — bwrap on Linux can't enforce them)`,
510
- )
511
- }
282
+ if (!configPath) return null
283
+ const mod = (await loadDefaultExport(configPath, 'Workspace')) as WorkspaceConfig
284
+ validateWorkspace(mod, configPath)
285
+ return mod
512
286
  }