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