@vzn/vx 0.0.18 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { UserError, xxh3hex } from '../util/index.js'
|
|
4
|
+
import { validateProjectConfig, validateWorkspace } from './config-schema.js'
|
|
5
|
+
import { beginEvalRound, evaluateConfigFresh } from './config-eval.js'
|
|
6
|
+
import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
|
|
5
7
|
import { configEvalKey, configEvalKeyFromClosure, type ConfigEvalStore } from './config-cache.js'
|
|
8
|
+
import { readOnce } from './load-reads.js'
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
// The validator lives in config-schema.ts; re-exported so a reader that
|
|
11
|
+
// reaches the loader for it (the tests do) keeps working.
|
|
12
|
+
export { validateProjectConfig }
|
|
13
|
+
|
|
14
|
+
/** The workspace config's filenames at the root, in lookup order. `vx watch` re-runs on an edit to whichever exists. */
|
|
15
|
+
export const WORKSPACE_CONFIG_FILENAMES = [
|
|
8
16
|
'vx.workspace.ts',
|
|
9
17
|
'vx.workspace.mts',
|
|
10
18
|
'vx.workspace.js',
|
|
@@ -20,6 +28,80 @@ function assertDefaultObject(mod: unknown, kind: string, configPath: string): vo
|
|
|
20
28
|
}
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The source a load already holds, by the specifier it imports it under.
|
|
33
|
+
* Bun's loader would open and read a project config a second time; the
|
|
34
|
+
* `vx-config-bytes` plugin hands it this instead, so the evaluation runs
|
|
35
|
+
* exactly the bytes the eval-cache key and the import gate saw. It is also
|
|
36
|
+
* the cheaper load: 1,000 configs imported this way took 80–100 ms against
|
|
37
|
+
* 180 ms read by Bun (measured 2026-09-24).
|
|
38
|
+
*
|
|
39
|
+
* Handed over as a string: `onLoad` reads a byte array as Latin-1, and a
|
|
40
|
+
* `ü` in a description came back as `ü`. So only UTF-8 is served, and
|
|
41
|
+
* only ESM (`hasEsmExport`): source handed to `onLoad` is always evaluated
|
|
42
|
+
* as a module, and a CommonJS config's `module.exports` would vanish. Any
|
|
43
|
+
* other config takes Bun's own path, which reads the file and decides its
|
|
44
|
+
* format as it always did. The two paths carry different queries because
|
|
45
|
+
* `onLoad` cannot decline a path it matched.
|
|
46
|
+
*
|
|
47
|
+
* The workspace config is never served. Proving it ESM is the parser's
|
|
48
|
+
* first use in a warm run, ~0.27 ms, and serving the one file saves less
|
|
49
|
+
* than that: `loadWorkspaceConfig` measured 0.3 ms slower served (40
|
|
50
|
+
* interleaved runs, 2026-09-24). Bun reads it a second time instead.
|
|
51
|
+
*/
|
|
52
|
+
//
|
|
53
|
+
// Held by the bytes' hash, not the specifier: Bun hands `onLoad` the path
|
|
54
|
+
// it RESOLVED, which is the real path, so a config reached through a
|
|
55
|
+
// symlinked directory (macOS's `/var` → `/private/var`, every temp root
|
|
56
|
+
// there) came back under a key nobody set, and every such load failed.
|
|
57
|
+
// Equal hashes are equal sources, so two loads of the same bytes share an
|
|
58
|
+
// entry; `uses` keeps it until the last of them has imported.
|
|
59
|
+
const heldSources = new Map<string, { source: string; uses: number }>()
|
|
60
|
+
let serving = false
|
|
61
|
+
|
|
62
|
+
const HELD_QUERY = /\?vx-held=([0-9a-f]+)$/
|
|
63
|
+
|
|
64
|
+
function serveHeldSources(): void {
|
|
65
|
+
if (serving) return
|
|
66
|
+
serving = true
|
|
67
|
+
Bun.plugin({
|
|
68
|
+
name: 'vx-config-bytes',
|
|
69
|
+
setup(build) {
|
|
70
|
+
build.onLoad({ filter: HELD_QUERY }, (args) => ({
|
|
71
|
+
contents: heldSources.get(HELD_QUERY.exec(args.path)![1]!)!.source,
|
|
72
|
+
loader: /\.[cm]?ts\?/.test(args.path) ? 'ts' : 'js',
|
|
73
|
+
}))
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function holdSource(hash: string, source: string): void {
|
|
79
|
+
const held = heldSources.get(hash)
|
|
80
|
+
if (held === undefined) heldSources.set(hash, { source, uses: 1 })
|
|
81
|
+
else held.uses++
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function releaseSource(hash: string): void {
|
|
85
|
+
const held = heldSources.get(hash)!
|
|
86
|
+
if (--held.uses === 0) heldSources.delete(hash)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true })
|
|
90
|
+
|
|
91
|
+
/** `bytes` as the source `onLoad` may stand in for Bun's read with, or null (see `heldSources`). */
|
|
92
|
+
function servableSource(bytes: Uint8Array, loader: 'ts' | 'js'): string | null {
|
|
93
|
+
let source: string
|
|
94
|
+
try {
|
|
95
|
+
source = utf8.decode(bytes)
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
return hasEsmExport(source, loader) ? source : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** vx's module-cache query, which no user wrote: stripped from anything shown to them. */
|
|
103
|
+
const BUST_QUERY = /\?vx-(?:bust|held)=[^'"\s]*/g
|
|
104
|
+
|
|
23
105
|
// Bun has native TS / ESM execution — no transpiler dep needed. We fold
|
|
24
106
|
// a short content hash into the import URL as a cache-bust key so that:
|
|
25
107
|
// same content → same URL → Bun's module cache hits (fast)
|
|
@@ -31,20 +113,33 @@ function assertDefaultObject(mod: unknown, kind: string, configPath: string): vo
|
|
|
31
113
|
async function loadDefaultExport(
|
|
32
114
|
configPath: string,
|
|
33
115
|
kind: string,
|
|
34
|
-
|
|
35
|
-
bytes?: Uint8Array,
|
|
116
|
+
bytes: Uint8Array,
|
|
36
117
|
): Promise<unknown> {
|
|
37
|
-
bytes
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
118
|
+
refuseUnprovidedImports(bytes, configPath, kind)
|
|
119
|
+
// No random bust for `vx lock`: a project config reaches this import
|
|
120
|
+
// only on its FIRST load in the process, so nothing is cached under the
|
|
121
|
+
// URL yet, and a repeat load (the one that could replay an evaluation
|
|
122
|
+
// made under earlier env values) re-evaluates in a worker instead
|
|
123
|
+
// (`loadedConfigs`, item 678).
|
|
124
|
+
const source =
|
|
125
|
+
kind === 'Project' ? servableSource(bytes, /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js') : null
|
|
126
|
+
const hash = xxh3hex(bytes)
|
|
127
|
+
const specifier = `${configPath}?vx-${source !== null ? 'held' : 'bust'}=${hash}`
|
|
128
|
+
if (source !== null) {
|
|
129
|
+
serveHeldSources()
|
|
130
|
+
holdSource(hash, source)
|
|
131
|
+
}
|
|
43
132
|
let ns: { default?: unknown }
|
|
44
133
|
try {
|
|
45
|
-
ns = (await import(
|
|
134
|
+
ns = (await import(specifier)) as { default?: unknown }
|
|
46
135
|
} catch (err) {
|
|
47
|
-
|
|
136
|
+
// A served module's frames name its specifier; the user wrote the path.
|
|
137
|
+
if (err instanceof Error && err.stack !== undefined) {
|
|
138
|
+
err.stack = err.stack.replaceAll(specifier, configPath).replace(BUST_QUERY, '')
|
|
139
|
+
}
|
|
140
|
+
throw configLoadError(err, configPath, kind) ?? err
|
|
141
|
+
} finally {
|
|
142
|
+
if (source !== null) releaseSource(hash)
|
|
48
143
|
}
|
|
49
144
|
const mod = ns?.default
|
|
50
145
|
assertDefaultObject(mod, kind, configPath)
|
|
@@ -52,28 +147,87 @@ async function loadDefaultExport(
|
|
|
52
147
|
}
|
|
53
148
|
|
|
54
149
|
/**
|
|
55
|
-
* A
|
|
56
|
-
*
|
|
57
|
-
* installed
|
|
58
|
-
*
|
|
59
|
-
*
|
|
150
|
+
* A bare import nothing above the config provides is refused BEFORE the
|
|
151
|
+
* evaluation: left to Bun, a workspace with no `node_modules` would have
|
|
152
|
+
* the package auto-installed from the registry first (config-imports.ts),
|
|
153
|
+
* and a config must never download. The message keeps the shape Bun's own
|
|
154
|
+
* refusal has, with the remedy.
|
|
60
155
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
156
|
+
function refuseUnprovidedImports(bytes: Uint8Array, configPath: string, kind: string): void {
|
|
157
|
+
const loader = /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js'
|
|
158
|
+
const missing = unprovidedBareImports(
|
|
159
|
+
new TextDecoder().decode(bytes),
|
|
160
|
+
path.dirname(configPath),
|
|
161
|
+
loader,
|
|
162
|
+
)
|
|
163
|
+
if (missing.length === 0) return
|
|
164
|
+
throw new UserError(
|
|
165
|
+
`${kind} config ${configPath}: cannot find '${missing[0]}' — no node_modules above the config provides it; install the workspace's dependencies first`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Where a transpile error was found. Bun's `BuildMessage` carries it as
|
|
171
|
+
* `position`; the config worker forwards the same three fields (see
|
|
172
|
+
* config-eval.ts) so the repeat path names the line too.
|
|
173
|
+
*/
|
|
174
|
+
interface BuildPosition {
|
|
175
|
+
file?: string
|
|
176
|
+
line?: number
|
|
177
|
+
column?: number
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Turn the two errors Bun's own loader throws into user errors naming the
|
|
182
|
+
* file the user wrote; every other throw is the config's own and already
|
|
183
|
+
* carries its location in its stack, so it passes through unchanged.
|
|
184
|
+
*
|
|
185
|
+
* `ResolveMessage` — an import that cannot be resolved. The usual cause has
|
|
186
|
+
* one answer: the workspace runs the vx binary and never installed `@vzn/vx`,
|
|
187
|
+
* which its own `vx.workspace.ts` imports. Bun's message also carries the
|
|
188
|
+
* module-cache bust query; the user gets the file they wrote.
|
|
189
|
+
*
|
|
190
|
+
* `BuildMessage` — a syntax or transpile error. Its message alone (`Expected
|
|
191
|
+
* "}" but found end of file`) names no file at all, and the file is not
|
|
192
|
+
* always the config: a preset the config imports fails the same way, so the
|
|
193
|
+
* location is taken from the error's position, with the config as the
|
|
194
|
+
* fallback.
|
|
195
|
+
*/
|
|
196
|
+
export function configLoadError(err: unknown, configPath: string, kind: string): UserError | null {
|
|
197
|
+
// Matched on SHAPE, not `instanceof Error`. Bun's `BuildMessage` is not an
|
|
198
|
+
// Error subclass on every build — on 1.3.11 its prototype chain is
|
|
199
|
+
// `BuildMessage → Object` — and an `instanceof` guard there hands the user
|
|
200
|
+
// a raw transpile object for a missing brace in their own config, which is
|
|
201
|
+
// the defect `isFsRefusal` exists to prevent one layer down. The two names
|
|
202
|
+
// below are Bun's own and nothing else answers to them, so this is
|
|
203
|
+
// narrower than it looks: every other throw still passes through (2026-09-20).
|
|
204
|
+
if (err === null || typeof err !== 'object') return null
|
|
205
|
+
const { name, message } = err as { name?: unknown; message?: unknown }
|
|
206
|
+
if (typeof message !== 'string') return null
|
|
207
|
+
if (name === 'ResolveMessage') {
|
|
208
|
+
const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(message)?.[1]
|
|
209
|
+
const what = spec === undefined ? message.replace(BUST_QUERY, '') : `cannot find '${spec}'`
|
|
210
|
+
const hint =
|
|
211
|
+
spec?.startsWith('@vzn/vx') === true
|
|
212
|
+
? `; install it in the workspace: bun add -d @vzn/vx`
|
|
213
|
+
: ''
|
|
214
|
+
return new UserError(`${kind} config ${configPath}: ${what}${hint}`)
|
|
215
|
+
}
|
|
216
|
+
if (name === 'BuildMessage') {
|
|
217
|
+
const pos = (err as { position?: BuildPosition | null }).position ?? {}
|
|
218
|
+
const file = typeof pos.file === 'string' && pos.file.length > 0 ? pos.file : configPath
|
|
219
|
+
const at =
|
|
220
|
+
typeof pos.line === 'number'
|
|
221
|
+
? `:${pos.line}${typeof pos.column === 'number' ? `:${pos.column}` : ''}`
|
|
222
|
+
: ''
|
|
223
|
+
const where = file === configPath ? `${configPath}${at}` : `${configPath} (in ${file}${at})`
|
|
224
|
+
return new UserError(`${kind} config ${where}: ${message}`)
|
|
225
|
+
}
|
|
226
|
+
return null
|
|
73
227
|
}
|
|
74
228
|
|
|
75
229
|
export interface LoadProjectConfigOptions {
|
|
76
|
-
/** Observe the CURRENT environment: no
|
|
230
|
+
/** Observe the CURRENT environment: no eval cache, even when `evalCache` is passed. */
|
|
77
231
|
fresh?: boolean
|
|
78
232
|
/**
|
|
79
233
|
* Serve a provably-pure config from its cached evaluation (see
|
|
@@ -95,6 +249,11 @@ export async function loadProjectConfigs(
|
|
|
95
249
|
const evalCache = opts?.fresh === true ? undefined : opts?.evalCache
|
|
96
250
|
const store = evalCache?.store
|
|
97
251
|
const hashFile = store?.hashFile?.bind(store)
|
|
252
|
+
const hashBytes = store?.hashBytes?.bind(store)
|
|
253
|
+
// The slow path keys from the bytes it already holds; `hashFile` there
|
|
254
|
+
// memoised every closure file one autocommit upsert at a time (item 615).
|
|
255
|
+
const slowKeyHash =
|
|
256
|
+
hashBytes !== undefined ? { hashBytes } : hashFile !== undefined ? { hashFile } : {}
|
|
98
257
|
// The warm fast path: a config whose ordered closure the store remembers
|
|
99
258
|
// is keyed from per-file identities (a stat each, no read, no scan); the
|
|
100
259
|
// slow path below reads, gates and scans, and indexes the closure for
|
|
@@ -103,13 +262,29 @@ export async function loadProjectConfigs(
|
|
|
103
262
|
hashFile !== undefined && store?.getConfigClosures !== undefined
|
|
104
263
|
? store.getConfigClosures(configPaths)
|
|
105
264
|
: new Map<string, string[]>()
|
|
265
|
+
// Every indexed closure's files identified in ONE batch, then keyed from
|
|
266
|
+
// the map; a file the batch could not stat has no identity, which makes
|
|
267
|
+
// that config's fast key miss and sends it down the slow path exactly as
|
|
268
|
+
// a throwing per-file `hashFile` did.
|
|
269
|
+
let fastHashFile = hashFile
|
|
270
|
+
if (closures.size > 0 && store?.hashFiles !== undefined) {
|
|
271
|
+
const files = new Set<string>()
|
|
272
|
+
for (const closure of closures.values()) for (const f of closure) files.add(f)
|
|
273
|
+
const identities = await store.hashFiles([...files])
|
|
274
|
+
fastHashFile = (file: string): Promise<string> => {
|
|
275
|
+
const id = identities.get(file)
|
|
276
|
+
return id === undefined
|
|
277
|
+
? Promise.reject(new Error(`no identity for ${file}`))
|
|
278
|
+
: Promise.resolve(id)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
106
281
|
const prepared = await Promise.all(
|
|
107
282
|
configPaths.map(async (configPath) => {
|
|
108
283
|
const closure = closures.get(configPath)
|
|
109
|
-
if (closure !== undefined && evalCache !== undefined &&
|
|
284
|
+
if (closure !== undefined && evalCache !== undefined && fastHashFile !== undefined) {
|
|
110
285
|
const fastKey = await configEvalKeyFromClosure({
|
|
111
286
|
closure,
|
|
112
|
-
hashFile,
|
|
287
|
+
hashFile: fastHashFile,
|
|
113
288
|
workspaceFingerprint: evalCache.workspaceFingerprint,
|
|
114
289
|
})
|
|
115
290
|
if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
|
|
@@ -122,7 +297,7 @@ export async function loadProjectConfigs(
|
|
|
122
297
|
configPath,
|
|
123
298
|
bytes,
|
|
124
299
|
workspaceFingerprint: evalCache.workspaceFingerprint,
|
|
125
|
-
...
|
|
300
|
+
...slowKeyHash,
|
|
126
301
|
})
|
|
127
302
|
return {
|
|
128
303
|
configPath,
|
|
@@ -148,58 +323,89 @@ export async function loadProjectConfigs(
|
|
|
148
323
|
}
|
|
149
324
|
}
|
|
150
325
|
const out: ProjectConfig[] = []
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
326
|
+
// What the round learned, written ONCE at the end: one transaction per
|
|
327
|
+
// table where each evaluation was its own (1,000 configs cold: 100 ms of
|
|
328
|
+
// autocommit inserts against 5, item 615). Written in `finally`, so a
|
|
329
|
+
// config that fails validation costs the next attempt only its own
|
|
330
|
+
// evaluation.
|
|
331
|
+
const evals: Array<readonly [string, string]> = []
|
|
332
|
+
const learnedClosures: Array<readonly [string, readonly string[]]> = []
|
|
333
|
+
// One worker for every repeat load in this round, however many there are.
|
|
334
|
+
const endRound = beginEvalRound()
|
|
335
|
+
try {
|
|
336
|
+
for (const entry of prepared) {
|
|
337
|
+
const { configPath, cacheKey } = entry
|
|
338
|
+
const hit = cacheKey === null ? undefined : hits.get(cacheKey)
|
|
339
|
+
// Stored AFTER validation, so a hit needs none; the key covers every
|
|
340
|
+
// byte the evaluation could have read.
|
|
341
|
+
if (hit !== undefined) {
|
|
342
|
+
out.push(JSON.parse(hit) as ProjectConfig)
|
|
179
343
|
continue
|
|
180
344
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
345
|
+
// A fast key that missed: the closure is stale or the file changed.
|
|
346
|
+
// Take the slow path for this one config, which re-indexes it.
|
|
347
|
+
let bytes = entry.bytes
|
|
348
|
+
let closure = entry.closure
|
|
349
|
+
let key = cacheKey
|
|
350
|
+
if (entry.indexed) {
|
|
351
|
+
bytes = await Bun.file(configPath).bytes()
|
|
352
|
+
const keyed = await configEvalKey({
|
|
353
|
+
configPath,
|
|
354
|
+
bytes,
|
|
355
|
+
workspaceFingerprint: evalCache!.workspaceFingerprint,
|
|
356
|
+
...slowKeyHash,
|
|
191
357
|
})
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
358
|
+
key = keyed?.key ?? null
|
|
359
|
+
closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
|
|
360
|
+
const slowHit = key === null ? null : (store!.getConfigEval(key) ?? null)
|
|
361
|
+
if (slowHit !== null) {
|
|
362
|
+
out.push(JSON.parse(slowHit) as ProjectConfig)
|
|
363
|
+
if (closure !== undefined) learnedClosures.push([configPath, closure])
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// A REPEAT load in this process re-evaluates in a worker, because the
|
|
368
|
+
// bust above cannot reach the config's import closure — see
|
|
369
|
+
// config-eval.ts. A FIRST load keeps the in-process import, so the
|
|
370
|
+
// single `vx run` hot path never pays for a worker.
|
|
371
|
+
const repeat = loadedConfigs.has(configPath)
|
|
372
|
+
loadedConfigs.add(configPath)
|
|
373
|
+
if (repeat) refuseUnprovidedImports(bytes!, configPath, 'Project')
|
|
374
|
+
const mod = repeat
|
|
375
|
+
? await evaluateConfigFresh(configPath).catch((err: unknown) => {
|
|
376
|
+
throw configLoadError(err, configPath, 'Project') ?? err
|
|
377
|
+
})
|
|
378
|
+
: await loadDefaultExport(configPath, 'Project', bytes!)
|
|
379
|
+
assertDefaultObject(mod, 'Project', configPath)
|
|
380
|
+
// Validation runs HERE, on whichever object we ended up with, so a
|
|
381
|
+
// malformed config reports the identical UserError whether it was
|
|
382
|
+
// evaluated in-process or in a worker.
|
|
383
|
+
validateProjectConfig(mod as ProjectConfig, configPath)
|
|
384
|
+
const json = JSON.stringify(mod)
|
|
385
|
+
if (key !== null) {
|
|
386
|
+
evals.push([key, json])
|
|
387
|
+
if (closure !== undefined) learnedClosures.push([configPath, closure])
|
|
388
|
+
}
|
|
389
|
+
// A tree of its own, as a hit and the lock hand out. The module object
|
|
390
|
+
// shares what the config shares: one preset's task in two configs, one
|
|
391
|
+
// `exec` in two tasks. A `project` hook that edits in place then edited
|
|
392
|
+
// them all, so a cold run ran `echo P +plug +plug` where the warm run
|
|
393
|
+
// and `--frozen` ran `echo P +plug`, under another key (item 967). A
|
|
394
|
+
// config is JSON data (config-schema.ts), so the copy loses nothing.
|
|
395
|
+
out.push(JSON.parse(json) as ProjectConfig)
|
|
396
|
+
}
|
|
397
|
+
} finally {
|
|
398
|
+
endRound()
|
|
399
|
+
if (store !== undefined) {
|
|
400
|
+
if (evals.length > 0) {
|
|
401
|
+
if (store.putConfigEvals !== undefined) store.putConfigEvals(evals)
|
|
402
|
+
else for (const [k, json] of evals) store.putConfigEval(k, json)
|
|
403
|
+
}
|
|
404
|
+
if (learnedClosures.length > 0 && store.putConfigClosure !== undefined) {
|
|
405
|
+
if (store.putConfigClosures !== undefined) store.putConfigClosures(learnedClosures)
|
|
406
|
+
else for (const [p, files] of learnedClosures) store.putConfigClosure(p, files)
|
|
407
|
+
}
|
|
201
408
|
}
|
|
202
|
-
out.push(mod as ProjectConfig)
|
|
203
409
|
}
|
|
204
410
|
return out
|
|
205
411
|
}
|
|
@@ -219,738 +425,12 @@ export async function loadProjectConfig(
|
|
|
219
425
|
* a `UserError` on malformed input.
|
|
220
426
|
*/
|
|
221
427
|
export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig | null> {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
if (!configPath) return null
|
|
230
|
-
const mod = (await loadDefaultExport(configPath, 'Workspace')) as WorkspaceConfig
|
|
231
|
-
validateWorkspace(mod, configPath)
|
|
232
|
-
return mod
|
|
233
|
-
}
|
|
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
|
-
)
|
|
428
|
+
for (const configPath of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
|
|
429
|
+
const bytes = await readOnce(undefined, configPath)
|
|
430
|
+
if (bytes === null) continue
|
|
431
|
+
const mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
|
|
432
|
+
validateWorkspace(mod, configPath)
|
|
433
|
+
return mod
|
|
955
434
|
}
|
|
435
|
+
return null
|
|
956
436
|
}
|