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