@vzn/vx 0.0.18 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- 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 +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- 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/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- 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 +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- 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-turbo.ts +0 -401
- 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/schedule-history/index.ts +0 -109
package/src/cli/why.ts
CHANGED
|
@@ -5,20 +5,24 @@
|
|
|
5
5
|
// names the exact cache-key components that differ. Read-only over cache.db —
|
|
6
6
|
// no config evaluation, no re-hash.
|
|
7
7
|
|
|
8
|
-
import { Cache } from '../cache/index.js'
|
|
8
|
+
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
9
|
+
import { seeHelp } from './help.js'
|
|
9
10
|
import { splitTaskId } from '../graph/index.js'
|
|
10
11
|
import {
|
|
11
12
|
cacheKeyDiff,
|
|
12
13
|
explainCacheKeyQuery as explainCacheKey,
|
|
13
14
|
whyDidThisRerunQuery as whyDidThisRerun,
|
|
14
15
|
} from '../orchestrator/index.js'
|
|
15
|
-
import { UserError } from '../util/index.js'
|
|
16
|
-
import { findWorkspaceRoot
|
|
16
|
+
import { nearMatches, UserError } from '../util/index.js'
|
|
17
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
18
|
+
import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
17
19
|
|
|
18
20
|
interface WhyArgs {
|
|
19
21
|
target?: string
|
|
20
22
|
runId?: string
|
|
21
23
|
format: 'pretty' | 'json'
|
|
24
|
+
/** `--cache-dir`: read the history a run with the same flag wrote. */
|
|
25
|
+
cacheDir?: string
|
|
22
26
|
error?: string
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -51,20 +55,37 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
51
55
|
out.format = fv
|
|
52
56
|
continue
|
|
53
57
|
}
|
|
54
|
-
|
|
58
|
+
const cd = parseCacheDirFlag(args, i)
|
|
59
|
+
if (cd !== null) {
|
|
60
|
+
if ('error' in cd) return { ...out, error: cd.error }
|
|
61
|
+
out.cacheDir = cd.cacheDir
|
|
62
|
+
i = cd.next
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('why')}` }
|
|
55
66
|
if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
56
67
|
out.target = a
|
|
57
68
|
}
|
|
58
69
|
return out
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
/**
|
|
73
|
+
* A bare query is a TASK name, so it is matched against the task half of
|
|
74
|
+
* every recorded id (the same rule `vx run` hints with) and the hint is
|
|
75
|
+
* the runnable `project#task`; an anchored query is matched whole.
|
|
76
|
+
*/
|
|
77
|
+
function suggest(query: string, ids: readonly string[]): string {
|
|
78
|
+
let hits: string[]
|
|
79
|
+
if (query.includes('#')) {
|
|
80
|
+
hits = nearMatches(query, ids)
|
|
81
|
+
} else {
|
|
82
|
+
const byTask = new Map<string, string[]>()
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
const task = splitTaskId(id)[1]
|
|
85
|
+
byTask.set(task, [...(byTask.get(task) ?? []), id])
|
|
86
|
+
}
|
|
87
|
+
hits = nearMatches(query, byTask.keys()).flatMap((t) => byTask.get(t) ?? [])
|
|
88
|
+
}
|
|
68
89
|
return hits.length > 0 ? ` — did you mean ${hits.slice(0, 3).join(', ')}?` : ''
|
|
69
90
|
}
|
|
70
91
|
|
|
@@ -75,12 +96,21 @@ function suggest(query: string, candidates: readonly string[]): string {
|
|
|
75
96
|
*/
|
|
76
97
|
function resolveTarget(cache: Cache, target: string): string {
|
|
77
98
|
const db = cache.dbHandle()
|
|
99
|
+
if (target.includes('#')) {
|
|
100
|
+
// An exact id needs one row, and its newest is where a scan from the
|
|
101
|
+
// top of the table finds it first — the full DISTINCT walk below is for
|
|
102
|
+
// the error's suggestions and the bare-name form only.
|
|
103
|
+
const [project, task] = splitTaskId(target)
|
|
104
|
+
const hit = db
|
|
105
|
+
.query('SELECT 1 FROM runs WHERE project = ? AND task = ? ORDER BY id DESC LIMIT 1')
|
|
106
|
+
.get(project, task)
|
|
107
|
+
if (hit !== null) return target
|
|
108
|
+
}
|
|
78
109
|
const pairs = db
|
|
79
110
|
.query('SELECT DISTINCT project, task FROM runs ORDER BY project, task')
|
|
80
111
|
.all() as Array<{ project: string; task: string }>
|
|
81
112
|
const ids = pairs.map((p) => `${p.project}#${p.task}`)
|
|
82
113
|
if (target.includes('#')) {
|
|
83
|
-
if (ids.includes(target)) return target
|
|
84
114
|
throw new UserError(`vx why: no recorded runs for "${target}"${suggest(target, ids)}`)
|
|
85
115
|
}
|
|
86
116
|
const matches = ids.filter((id) => id.endsWith(`#${target}`))
|
|
@@ -117,8 +147,8 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
const root = await findWorkspaceRoot(process.cwd())
|
|
120
|
-
const
|
|
121
|
-
|
|
150
|
+
const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
|
|
151
|
+
noteSchemaReset(cache, warnToStderr)
|
|
122
152
|
try {
|
|
123
153
|
const db = cache.dbHandle()
|
|
124
154
|
const taskId = resolveTarget(cache, parsed.target)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The workspace as every verb must see it: the config with the plugin
|
|
2
|
+
// `config` stage applied, and the cache dir derived from THAT. The stage
|
|
3
|
+
// is documented to shape `cacheDir`; a verb that read the file raw opened
|
|
4
|
+
// a directory the run never used — `vx last` found no runs, `vx cache
|
|
5
|
+
// prune` pruned nothing, `vx watch` ignored the wrong path.
|
|
6
|
+
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import type { WorkspaceConfig } from '../config.js'
|
|
9
|
+
import { UserError } from '../util/index.js'
|
|
10
|
+
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
11
|
+
import { loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
|
|
12
|
+
import type { VxPlugin } from '../orchestrator/index.js'
|
|
13
|
+
import {
|
|
14
|
+
buildPackageGraph,
|
|
15
|
+
computeWorkspaceFingerprint,
|
|
16
|
+
FROZEN_WITHOUT_LOCK,
|
|
17
|
+
type ProjectEntry,
|
|
18
|
+
type ProjectMeta,
|
|
19
|
+
readLockfile,
|
|
20
|
+
resolveCacheDir,
|
|
21
|
+
} from '../workspace/index.js'
|
|
22
|
+
|
|
23
|
+
export interface CliWorkspace {
|
|
24
|
+
workspaceConfig: WorkspaceConfig | null
|
|
25
|
+
plugins: readonly VxPlugin[]
|
|
26
|
+
cacheDir: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const warnToStderr = (message: string): void => {
|
|
30
|
+
process.stderr.write(`${message}\n`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
|
|
34
|
+
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnToStderr)
|
|
35
|
+
return { workspaceConfig, plugins, cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The run path's project-config load (`loadProjects`) for a verb that only
|
|
40
|
+
* reads: the plugin `project` stage applies, and the local cache opens only
|
|
41
|
+
* to serve cached evaluations — a pure config costs a stat, not an
|
|
42
|
+
* evaluation. `scope` is every project or a list of names; no closure, no
|
|
43
|
+
* lock (a verb reads live, as a default run does).
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* `--cache-dir <path>` / `--cache-dir=<path>` for a verb that reads the
|
|
47
|
+
* cache a run wrote — `why`, `last`, `info`, `cache prune`. Same rules as
|
|
48
|
+
* `vx run`'s: a value is required, and the space form refuses a value
|
|
49
|
+
* that looks like a flag (an unquoted empty shell variable would otherwise
|
|
50
|
+
* swallow the next flag as the path). Returns null when `args[i]` is not
|
|
51
|
+
* this flag; `next` is the index of the last argv slot consumed.
|
|
52
|
+
*/
|
|
53
|
+
export function parseCacheDirFlag(
|
|
54
|
+
args: readonly string[],
|
|
55
|
+
i: number,
|
|
56
|
+
): { cacheDir: string; next: number } | { error: string } | null {
|
|
57
|
+
const a = args[i]!
|
|
58
|
+
if (a !== '--cache-dir' && !a.startsWith('--cache-dir=')) return null
|
|
59
|
+
const v = a === '--cache-dir' ? args[i + 1] : a.slice('--cache-dir='.length)
|
|
60
|
+
if (v === undefined || v === '') return { error: '--cache-dir requires a path' }
|
|
61
|
+
if (a === '--cache-dir' && v.startsWith('-')) {
|
|
62
|
+
return { error: `--cache-dir requires a path, got flag: ${v}` }
|
|
63
|
+
}
|
|
64
|
+
return { cacheDir: v, next: a === '--cache-dir' ? i + 1 : i }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The cache directory a reading verb opens: `--cache-dir` resolved against
|
|
69
|
+
* cwd exactly as `vx run` resolves it (prepare.ts), else the workspace's —
|
|
70
|
+
* so `vx run --cache-dir X` and `vx last --cache-dir X` read one history.
|
|
71
|
+
*/
|
|
72
|
+
export async function cliCacheDir(
|
|
73
|
+
workspaceRoot: string,
|
|
74
|
+
override: string | undefined,
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
if (override !== undefined) return path.resolve(process.cwd(), override)
|
|
77
|
+
return (await loadCliWorkspace(workspaceRoot)).cacheDir
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The run flags a verb's staged load must honour to see what the run sees. */
|
|
81
|
+
export interface CliLoadOptions {
|
|
82
|
+
/** `--cache-dir`: where the cached evaluations live. */
|
|
83
|
+
cacheDir?: string
|
|
84
|
+
/** `--frozen`: configs come from `vx-lock.json`, not evaluation. */
|
|
85
|
+
frozen?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function loadCliProjects(
|
|
89
|
+
workspaceRoot: string,
|
|
90
|
+
metas: readonly ProjectMeta[],
|
|
91
|
+
scope: 'all' | readonly string[] = 'all',
|
|
92
|
+
opts: CliLoadOptions = {},
|
|
93
|
+
): Promise<Map<string, ProjectEntry>> {
|
|
94
|
+
const ws = await loadCliWorkspace(workspaceRoot)
|
|
95
|
+
const { plugins } = ws
|
|
96
|
+
// The cached evaluations live where the run's do: a verb given
|
|
97
|
+
// `--cache-dir` must not open (and create) the workspace's default one.
|
|
98
|
+
const cacheDir = opts.cacheDir ?? ws.cacheDir
|
|
99
|
+
// A frozen run reads its configs from the lock; what it SELECTS must be
|
|
100
|
+
// decided from the same configs, or an env-dependent `workspaceFiles`
|
|
101
|
+
// glob could select live what the run then treats otherwise.
|
|
102
|
+
const lock = opts.frozen === true ? await readLockfile(workspaceRoot) : null
|
|
103
|
+
if (opts.frozen === true && lock === null) throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
104
|
+
const cache = new Cache(cacheDir)
|
|
105
|
+
noteSchemaReset(cache, warnToStderr)
|
|
106
|
+
try {
|
|
107
|
+
const loaded = await loadProjects({
|
|
108
|
+
workspaceRoot,
|
|
109
|
+
cacheDir,
|
|
110
|
+
plugins,
|
|
111
|
+
projectMetas: metas,
|
|
112
|
+
packageGraph: buildPackageGraph([...metas]),
|
|
113
|
+
seeds: scope,
|
|
114
|
+
closure: false,
|
|
115
|
+
lock,
|
|
116
|
+
evalCache: {
|
|
117
|
+
store: cache,
|
|
118
|
+
workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot),
|
|
119
|
+
},
|
|
120
|
+
warn: warnToStderr,
|
|
121
|
+
})
|
|
122
|
+
return loaded.projects
|
|
123
|
+
} finally {
|
|
124
|
+
cache.close()
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -14,13 +14,25 @@ export interface WorkspaceConfig {
|
|
|
14
14
|
*/
|
|
15
15
|
timeout?: number
|
|
16
16
|
/**
|
|
17
|
-
* Plugins registered for this workspace
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Plugins registered for this workspace, consulted in this order once
|
|
18
|
+
* per `vx run`: each fills any of the pipeline stages and capabilities
|
|
19
|
+
* below, and core's own executor and cache store sit at the tail of
|
|
20
|
+
* every list. See `docs/design/pipeline-2026-09.md`.
|
|
20
21
|
*/
|
|
21
22
|
plugins?: readonly Plugin[]
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The runtime brand `definePlugin` stamps on a plugin: the name of the
|
|
27
|
+
* package it was defined in. The workspace loader accepts a plugin only
|
|
28
|
+
* when this is present and equals `name`, so a plugin's name is its
|
|
29
|
+
* package name and nothing else — no field to set, no override. A
|
|
30
|
+
* registry symbol on purpose: a plugin package imports its own copy of
|
|
31
|
+
* `@vzn/vx` beside a compiled binary's, and the two copies must stamp
|
|
32
|
+
* and check the same key.
|
|
33
|
+
*/
|
|
34
|
+
export const PLUGIN_PACKAGE: unique symbol = Symbol.for('vx.plugin.package')
|
|
35
|
+
|
|
24
36
|
/**
|
|
25
37
|
* Structural plugin shape — any subset of the pipeline stages
|
|
26
38
|
* (`config`/`project`/`graph`/`key`/`schedule`), run-level capabilities
|
|
@@ -32,20 +44,60 @@ export interface WorkspaceConfig {
|
|
|
32
44
|
* before consulting capabilities.
|
|
33
45
|
*/
|
|
34
46
|
export interface Plugin {
|
|
47
|
+
/** The package name — set by `definePlugin`, never by the plugin. */
|
|
35
48
|
readonly name: string
|
|
36
49
|
config?(workspace: unknown, ctx: unknown): unknown
|
|
37
50
|
project?(config: unknown, ctx: unknown): unknown
|
|
38
51
|
graph?(nodes: unknown, ctx: unknown): unknown
|
|
39
52
|
key?(task: unknown, ctx: unknown): unknown
|
|
40
53
|
schedule?(nodes: unknown, ctx: unknown): unknown
|
|
54
|
+
admit?(task: unknown, ctx: unknown): unknown
|
|
41
55
|
readonly commands?: Readonly<Record<string, unknown>>
|
|
42
56
|
cache?(ctx: unknown): unknown
|
|
43
57
|
executor?(ctx: unknown): unknown
|
|
44
58
|
telemetry?(ctx: unknown): unknown
|
|
45
59
|
setup?(ctx: unknown): void | Promise<void>
|
|
46
60
|
teardown?(): void | Promise<void>
|
|
61
|
+
readonly fingerprint?: unknown
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Every hook a plugin may fill, in pipeline order — THE list. The loader's
|
|
66
|
+
* "must be a function" and "at least one of" checks, the host's stage gate
|
|
67
|
+
* and `vx info`'s seam column all read it, so a stage added here is a stage
|
|
68
|
+
* everywhere: `admit` reached `vx info` a day late (2026-09-12) because
|
|
69
|
+
* that column kept its own copy, and the loader kept a third. The type
|
|
70
|
+
* pin below refuses a list that drifts from `Plugin`'s keys either way.
|
|
71
|
+
*/
|
|
72
|
+
export const PLUGIN_HOOKS = [
|
|
73
|
+
'config',
|
|
74
|
+
'project',
|
|
75
|
+
'graph',
|
|
76
|
+
'key',
|
|
77
|
+
'fingerprint',
|
|
78
|
+
'schedule',
|
|
79
|
+
'admit',
|
|
80
|
+
'executor',
|
|
81
|
+
'cache',
|
|
82
|
+
'telemetry',
|
|
83
|
+
'setup',
|
|
84
|
+
'commands',
|
|
85
|
+
'teardown',
|
|
86
|
+
] as const
|
|
87
|
+
export type PluginHook = (typeof PLUGIN_HOOKS)[number]
|
|
88
|
+
/** The hooks that are functions; `commands` and `fingerprint` are objects. */
|
|
89
|
+
export const PLUGIN_FUNCTION_HOOKS = PLUGIN_HOOKS.filter(
|
|
90
|
+
(h): h is Exclude<PluginHook, 'commands' | 'fingerprint'> =>
|
|
91
|
+
h !== 'commands' && h !== 'fingerprint',
|
|
92
|
+
)
|
|
93
|
+
type PluginHookKeys = Exclude<keyof Plugin, 'name'>
|
|
94
|
+
type HooksNotListed = Exclude<PluginHookKeys, PluginHook>
|
|
95
|
+
type HooksNotOnPlugin = Exclude<PluginHook, PluginHookKeys>
|
|
96
|
+
// Both `never`, or this line does not compile: the list and the type agree.
|
|
97
|
+
const _pluginHooksMatch: [HooksNotListed, HooksNotOnPlugin] extends [never, never] ? true : never =
|
|
98
|
+
true
|
|
99
|
+
void _pluginHooksMatch
|
|
100
|
+
|
|
49
101
|
export interface ProjectConfig {
|
|
50
102
|
/** Tasks declared by this project, keyed by task name. */
|
|
51
103
|
tasks?: Record<string, TaskConfig>
|
|
@@ -94,123 +146,101 @@ export interface TaskConfig {
|
|
|
94
146
|
* runs.
|
|
95
147
|
*/
|
|
96
148
|
cache?: CacheConfig
|
|
97
|
-
/**
|
|
98
|
-
* Sandbox configuration. **Sandbox is opt-in per task.**
|
|
99
|
-
* - omitted entirely → task runs unsandboxed.
|
|
100
|
-
* - `sandbox: {}` → opts in with the minimum baseline (only
|
|
101
|
-
* resolved inputs readable, only static-prefix of outputs
|
|
102
|
-
* writable, network blocked).
|
|
103
|
-
* - `sandbox: { ... }` → opts in with the explicit options below.
|
|
104
|
-
*
|
|
105
|
-
* No inheritance, no workspace defaults, no built-in escapes. The
|
|
106
|
-
* sandbox sees STRICTLY the union of resolved `cache.inputs.files`
|
|
107
|
-
* + this block's `allowRead` (for reads) and the static prefix of
|
|
108
|
-
* `cache.outputs.files` + this block's `allowWrite` (for writes).
|
|
109
|
-
* If a task needs `node_modules` or `/tmp`, declare them here
|
|
110
|
-
* explicitly.
|
|
111
|
-
*
|
|
112
|
-
* **Policy: fail on violation.** If the sandbox detects an
|
|
113
|
-
* undeclared read/write (macOS log monitor) or the child returns
|
|
114
|
-
* non-zero because it couldn't reach a path it expected (Linux
|
|
115
|
-
* structural deny), the task fails. No cache is written for a
|
|
116
|
-
* failed task.
|
|
117
|
-
*
|
|
118
|
-
* Activation requires `exec` (group tasks have no command to wrap)
|
|
119
|
-
* and is silently skipped for persistent tasks (dev servers need
|
|
120
|
-
* unrestricted network + an indefinite process).
|
|
121
|
-
*/
|
|
122
|
-
sandbox?: SandboxConfig
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
152
|
+
* What a sandboxed task may do, declared as CAPABILITIES.
|
|
153
|
+
*
|
|
154
|
+
* One shape for the whole policy. You say what the task is allowed to
|
|
155
|
+
* touch; vx decides how each capability is realised — some become sandbox
|
|
156
|
+
* runtime config, some become rules in the OS policy, and one the platform
|
|
157
|
+
* cannot express is an error rather than a silent no-op. None of that
|
|
158
|
+
* reaches the config.
|
|
129
159
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* - tilde paths → expanded against the user's home (`~/.npmrc`)
|
|
160
|
+
* The baseline is always the task's own declarations: it may read its
|
|
161
|
+
* resolved `cache.inputs.files`, write the prefixes of its
|
|
162
|
+
* `cache.outputs.files`, and reach nothing else. Everything here is on top
|
|
163
|
+
* of that.
|
|
135
164
|
*
|
|
136
|
-
*
|
|
165
|
+
* Paths are project-relative, absolute, or `~`-expanded — and are path
|
|
166
|
+
* PREFIXES, never globs (a glob cannot be enforced by a mount).
|
|
137
167
|
*/
|
|
138
168
|
export interface SandboxConfig {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
* Additional read-allowed paths beyond resolved `cache.inputs.files`.
|
|
142
|
-
* These are unioned with the declared inputs to form the complete
|
|
143
|
-
* allowRead set passed to the sandbox.
|
|
144
|
-
*/
|
|
145
|
-
allowRead?: string[]
|
|
169
|
+
/** Capabilities granted on top of the task's own declarations. */
|
|
170
|
+
allow?: SandboxGrants
|
|
146
171
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* `allowWrite` can write to nowhere — typical for `lint`/`typecheck`.
|
|
172
|
+
* Capabilities taken away. Evaluated before `allow`, so a domain in both
|
|
173
|
+
* is denied.
|
|
150
174
|
*/
|
|
151
|
-
|
|
175
|
+
deny?: SandboxDenials
|
|
152
176
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
*
|
|
170
|
-
* Needed by tasks that interact with a TTY (rare in CI).
|
|
171
|
-
*/
|
|
172
|
-
allowPty?: boolean
|
|
173
|
-
/**
|
|
174
|
-
* Linux only. Allow nested sandboxes (a sandboxed task spawning
|
|
175
|
-
* another sandboxed task). Default `false`.
|
|
177
|
+
* Denials to leave out of the report, in the same shape as `allow`.
|
|
178
|
+
*
|
|
179
|
+
* This does NOT permit anything: the OS still denies the operation, and
|
|
180
|
+
* a command that cannot proceed without it still fails. It says "I know,
|
|
181
|
+
* and it does not affect this task's output" — for a denial you cannot
|
|
182
|
+
* grant away and do not want failing the run.
|
|
183
|
+
*
|
|
184
|
+
* Unlike `allow`, the values are GLOB PATTERNS, and they work the same
|
|
185
|
+
* on every platform: `allow` has to be expressible by the OS sandbox
|
|
186
|
+
* (bwrap mounts a path; it cannot mount a pattern), while `ignore` only
|
|
187
|
+
* has to match a line vx already holds. Relative patterns anchor at the
|
|
188
|
+
* project dir.
|
|
189
|
+
*
|
|
190
|
+
* ```ts
|
|
191
|
+
* ignore: { write: ['*.bun-build'] } // bun --compile stages a random
|
|
192
|
+
* // temp name in the cwd
|
|
193
|
+
* ```
|
|
176
194
|
*/
|
|
177
|
-
|
|
195
|
+
ignore?: SandboxGrants
|
|
178
196
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
197
|
+
* Accept a weaker sandbox when vx itself already runs inside one
|
|
198
|
+
* (a container, another sandbox). Without it, nesting fails fast rather
|
|
199
|
+
* than pretending to confine.
|
|
182
200
|
*/
|
|
183
|
-
|
|
201
|
+
weakerWhenNested?: boolean
|
|
202
|
+
/** Accept weaker network isolation (host proxy) for lower overhead. */
|
|
203
|
+
weakerNetworkIsolation?: boolean
|
|
204
|
+
}
|
|
184
205
|
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
206
|
+
export interface SandboxGrants {
|
|
207
|
+
/** Readable path prefixes, beyond the resolved `cache.inputs.files`. */
|
|
208
|
+
read?: string[]
|
|
209
|
+
/** Writable path prefixes, beyond the `cache.outputs.files` prefixes. */
|
|
210
|
+
write?: string[]
|
|
211
|
+
/**
|
|
212
|
+
* Reachable domains. `true` allows all, `['*.example.com']` a pattern.
|
|
213
|
+
* Omitted means no network at all.
|
|
214
|
+
*/
|
|
215
|
+
network?: true | string[]
|
|
216
|
+
/**
|
|
217
|
+
* Kernel information the task may query, by info type — the name that
|
|
218
|
+
* appears in a denial, e.g. `vfs.disk-space` (every Bun and git process
|
|
219
|
+
* probes free space at startup).
|
|
220
|
+
*/
|
|
221
|
+
systemInfo?: string[]
|
|
222
|
+
/** Unix socket paths the task may connect to. `true` allows any. */
|
|
223
|
+
unixSockets?: true | string[]
|
|
224
|
+
/**
|
|
225
|
+
* Bind and reach localhost ports — a test that boots a server for
|
|
226
|
+
* itself. A port LIST additionally makes those ports reachable from
|
|
227
|
+
* outside the sandbox (a dev server another task or the developer
|
|
228
|
+
* talks to): on Linux, where a sandboxed task lives in its own network
|
|
229
|
+
* namespace, each listed port is bridged out to the host's loopback;
|
|
230
|
+
* on macOS the host already sees them and the list means `true`.
|
|
231
|
+
*/
|
|
232
|
+
localBinding?: boolean | number[]
|
|
233
|
+
/** macOS: Mach service names the task may look up. */
|
|
234
|
+
machLookup?: string[]
|
|
235
|
+
/** Acquire a TTY. Rare outside interactive tools. */
|
|
236
|
+
pty?: boolean
|
|
237
|
+
/** Write to `.git/config`. Most build tools should not. */
|
|
238
|
+
gitConfig?: boolean
|
|
193
239
|
}
|
|
194
240
|
|
|
195
|
-
export interface
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
* `*` (allow all).
|
|
199
|
-
*/
|
|
200
|
-
allowedDomains?: string[]
|
|
201
|
-
/** Domains explicitly blocked, evaluated before allowedDomains. */
|
|
202
|
-
deniedDomains?: string[]
|
|
203
|
-
/** Unix socket paths the task may connect to. */
|
|
204
|
-
allowUnixSockets?: string[]
|
|
205
|
-
/** Allow any Unix socket. Use with care. */
|
|
206
|
-
allowAllUnixSockets?: boolean
|
|
207
|
-
/**
|
|
208
|
-
* Allow binding to local ports (e.g. a test that boots a server on
|
|
209
|
-
* localhost for itself to query).
|
|
210
|
-
*/
|
|
211
|
-
allowLocalBinding?: boolean
|
|
212
|
-
/** macOS only. Mach service names the task may look up. */
|
|
213
|
-
allowMachLookup?: string[]
|
|
241
|
+
export interface SandboxDenials {
|
|
242
|
+
/** Domains blocked even if `allow.network` would admit them. */
|
|
243
|
+
network?: string[]
|
|
214
244
|
}
|
|
215
245
|
|
|
216
246
|
export interface ExecConfig {
|
|
@@ -258,16 +288,6 @@ export interface ExecConfig {
|
|
|
258
288
|
* default, including `retries: 0`.
|
|
259
289
|
*/
|
|
260
290
|
retries?: number
|
|
261
|
-
/**
|
|
262
|
-
* Resource RESERVATIONS for admission control — NOT enforcement: vx
|
|
263
|
-
* does not cgroup-limit, nice, or kill the task; it only decides what
|
|
264
|
-
* to co-schedule so concurrent reservations never exceed the CPU /
|
|
265
|
-
* memory budget. Each axis defaults to 0 = reserve nothing: the task
|
|
266
|
-
* runs subject only to the concurrency-count limit. A pure scheduling
|
|
267
|
-
* hint — the whole object is stripped from the cache key, so tuning a
|
|
268
|
-
* reservation never invalidates a cached result.
|
|
269
|
-
*/
|
|
270
|
-
resources?: ResourcesConfig
|
|
271
291
|
/**
|
|
272
292
|
* Long-running / continuous task (dev server, watcher, daemon).
|
|
273
293
|
* When present, the task is spawned but the runner does NOT wait
|
|
@@ -277,30 +297,29 @@ export interface ExecConfig {
|
|
|
277
297
|
* on ready, not on exit. The orchestrator SIGTERMs every persistent
|
|
278
298
|
* subprocess once the rest of the graph has finished.
|
|
279
299
|
*
|
|
280
|
-
* `cache` is
|
|
281
|
-
*
|
|
282
|
-
* of "done".
|
|
300
|
+
* `cache` is rejected on a persistent task — there's no exit code
|
|
301
|
+
* to cache and the work continues after vx's run notion of "done".
|
|
283
302
|
*/
|
|
284
303
|
persistent?: PersistentConfig
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Per-task resource reservation (see `ExecConfig.resources`). Grouped so
|
|
289
|
-
* a future axis slots in without new top-level `exec` fields; the loader
|
|
290
|
-
* rejects unknown keys.
|
|
291
|
-
*/
|
|
292
|
-
export interface ResourcesConfig {
|
|
293
|
-
/** CPU cores; fractional is allowed (`0.5`). */
|
|
294
|
-
cpus?: number
|
|
295
|
-
/** Megabytes. `memory: 4096` is four gigabytes. */
|
|
296
|
-
memory?: number
|
|
297
304
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
305
|
+
* Confine this task's process: what it may read, write and reach.
|
|
306
|
+
* Nothing is confined unless this is present.
|
|
307
|
+
*
|
|
308
|
+
* It sits in `exec` because it constrains the same spawn `env` and
|
|
309
|
+
* `resources` do — the environment the child gets, the CPU and memory it
|
|
310
|
+
* gets, the filesystem and network it gets. A group task has no process,
|
|
311
|
+
* so the field cannot be reached from one.
|
|
312
|
+
*
|
|
313
|
+
* **Policy: fail on violation.** An undeclared touch of the project's own
|
|
314
|
+
* files fails the task, and a failed task is never cached. Reaching
|
|
315
|
+
* OUTSIDE the project is denied but not reported — that is the wall
|
|
316
|
+
* doing its job, not a finding.
|
|
317
|
+
*
|
|
318
|
+
* A persistent task is confined the same way but never REPORTED on: the
|
|
319
|
+
* violation report reads the trace after the process exits, and a server
|
|
320
|
+
* exits at teardown. Enforced, not reported.
|
|
302
321
|
*/
|
|
303
|
-
|
|
322
|
+
sandbox?: SandboxConfig
|
|
304
323
|
}
|
|
305
324
|
|
|
306
325
|
export interface PersistentConfig {
|
|
@@ -354,8 +373,9 @@ export interface CacheOutputs {
|
|
|
354
373
|
* files anywhere in the workspace, including inside other projects'
|
|
355
374
|
* directories. Deliberate escape hatch — prefer project-relative
|
|
356
375
|
* `files` whenever the task can write inside its own dir. Two tasks
|
|
357
|
-
*
|
|
358
|
-
* vx
|
|
376
|
+
* whose workspace outputs provably overlap are refused at graph build,
|
|
377
|
+
* like overlapping `files` (vx cleans declared outputs before a run
|
|
378
|
+
* and before a restore, so the second would delete the first's).
|
|
359
379
|
*/
|
|
360
380
|
workspaceFiles?: string[]
|
|
361
381
|
}
|
package/src/exec/env.ts
CHANGED
|
@@ -42,6 +42,17 @@ export const ESSENTIAL_ENV: readonly string[] = [
|
|
|
42
42
|
'PATHEXT',
|
|
43
43
|
]
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The two variables vx sets on every task it spawns, over the layers
|
|
47
|
+
* above: the workspace root the run is in and the `project#task` running.
|
|
48
|
+
* `run()` reads them back so a task that shells out to `vx run` in its OWN
|
|
49
|
+
* workspace is refused before it forks without bound (a different
|
|
50
|
+
* workspace — a fixture, a benchmark — is fine). Not in the allowlist:
|
|
51
|
+
* they come from this run, never from the parent environment.
|
|
52
|
+
*/
|
|
53
|
+
export const VX_RUN_WORKSPACE_ENV = 'VX_RUN_WORKSPACE'
|
|
54
|
+
export const VX_RUN_TASK_ENV = 'VX_RUN_TASK'
|
|
55
|
+
|
|
45
56
|
export interface BuildEnvOptions {
|
|
46
57
|
passThrough: readonly string[]
|
|
47
58
|
define: Readonly<Record<string, string>>
|