@vzn/vx 0.0.635 → 0.0.637
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 +47 -19
- package/docs/caching.md +222 -210
- package/docs/cli.md +699 -664
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +31 -32
- package/docs/schema.md +418 -202
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +13 -4
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -61
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +123 -144
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +191 -71
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +25 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +192 -70
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +43 -28
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +114 -73
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +127 -88
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +251 -241
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +22 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -21,12 +21,8 @@ import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../
|
|
|
21
21
|
import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Per-run memoization caches for
|
|
25
|
-
*
|
|
26
|
-
* - `packageJson`: keyed by absolute projectDir. Every task in a
|
|
27
|
-
* project re-reads the same `package.json`; without this, a
|
|
28
|
-
* monorepo with N projects × M tasks per project does N×M reads
|
|
29
|
-
* of the same bytes.
|
|
24
|
+
* Per-run memoization caches for what doesn't change across tasks within
|
|
25
|
+
* one run:
|
|
30
26
|
* - `projectFiles`: keyed by the project and its input DECLARATION.
|
|
31
27
|
* Tasks of one project that declare the same inputs and outputs
|
|
32
28
|
* resolve the same file list; see `ProjectFilesCache`.
|
|
@@ -35,11 +31,10 @@ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
|
35
31
|
* JSON.stringify + xxh3 of it is deterministic. WeakMap so
|
|
36
32
|
* entries free when the orchestrator is done.
|
|
37
33
|
*
|
|
38
|
-
*
|
|
39
|
-
* fresh when
|
|
34
|
+
* The whole memo is optional — the helpers fall back to computing
|
|
35
|
+
* fresh when it is missing.
|
|
40
36
|
*/
|
|
41
37
|
export interface HashCache {
|
|
42
|
-
packageJson: Map<string, Promise<string>>
|
|
43
38
|
taskConfig: WeakMap<TaskConfig, string>
|
|
44
39
|
runtime: Map<string, Promise<string>>
|
|
45
40
|
workspaceRuntime: Map<string, Promise<string>>
|
|
@@ -49,7 +44,6 @@ export interface HashCache {
|
|
|
49
44
|
|
|
50
45
|
export function createHashCache(): HashCache {
|
|
51
46
|
return {
|
|
52
|
-
packageJson: new Map(),
|
|
53
47
|
taskConfig: new WeakMap(),
|
|
54
48
|
runtime: new Map(),
|
|
55
49
|
workspaceRuntime: new Map(),
|
|
@@ -76,7 +70,6 @@ export interface ComputeHashArgs {
|
|
|
76
70
|
node: TaskNode
|
|
77
71
|
upstream: TaskOutcome[]
|
|
78
72
|
workspaceRoot: string
|
|
79
|
-
workspaceFingerprint: string
|
|
80
73
|
cache: CacheLayer
|
|
81
74
|
forwardArgs?: readonly string[] | undefined
|
|
82
75
|
nestedProjectDirs: string[]
|
|
@@ -100,8 +93,7 @@ export interface ComputeHashArgs {
|
|
|
100
93
|
|
|
101
94
|
/**
|
|
102
95
|
* Resolve every input the cache key depends on (file content, env
|
|
103
|
-
* values, upstream hashes, config bytes,
|
|
104
|
-
* forwardArgs) and ask the cache layer to combine them. Used by both
|
|
96
|
+
* values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
|
|
105
97
|
* `executeTask` for the real run and `plan()` for `--dry-run` /
|
|
106
98
|
* `--graph` previews.
|
|
107
99
|
*
|
|
@@ -161,22 +153,12 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
161
153
|
? []
|
|
162
154
|
: [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
|
|
163
155
|
)
|
|
164
|
-
// The package.json digest is a per-run memo that may predate this
|
|
165
|
-
// describe, so it is dated from the enumeration, the earliest a run
|
|
166
|
-
// learns anything.
|
|
167
156
|
const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
|
|
168
157
|
const facts: InputFact[] = sorted.map((f, i) => ({
|
|
169
158
|
path: f,
|
|
170
159
|
digest: digests[i]!,
|
|
171
160
|
since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
|
|
172
161
|
}))
|
|
173
|
-
if (input.projectPackageJsonHash !== '') {
|
|
174
|
-
facts.push({
|
|
175
|
-
path: path.join(args.node.projectDir, 'package.json'),
|
|
176
|
-
digest: input.projectPackageJsonHash,
|
|
177
|
-
since: indexedAt,
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
162
|
// Groups expanded: this list describes the INPUT ROOT, not the key, and a
|
|
181
163
|
// group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
|
|
182
164
|
// Sorted by hash so the list — and any action digest derived from it — is
|
|
@@ -220,9 +202,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
220
202
|
get upstream() {
|
|
221
203
|
return (upstream ??= listUpstream())
|
|
222
204
|
},
|
|
223
|
-
packageJsonDigest: input.projectPackageJsonHash,
|
|
224
205
|
configDigest: input.taskConfigHash,
|
|
225
|
-
workspaceFingerprint: input.workspaceFingerprint,
|
|
226
206
|
},
|
|
227
207
|
}
|
|
228
208
|
}
|
|
@@ -238,15 +218,17 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
238
218
|
* input changed and changed BACK while the command ran matched its digest
|
|
239
219
|
* again, and the entry saved the other content's output under the key (item
|
|
240
220
|
* 1015). A write before it — an upstream's — is judged by content, as before.
|
|
241
|
-
* `
|
|
242
|
-
*
|
|
243
|
-
*
|
|
221
|
+
* `byBytes` (`inputStability.strategy: 'mutable'`) judges a write during
|
|
222
|
+
* the command by content too, when it answers true for that file and its
|
|
223
|
+
* ctime: the task says the same bytes are its own identical rewrite
|
|
224
|
+
* (tsdown's package.json), not an edit undone, unless another command that
|
|
225
|
+
* may write the file ran until then (command-windows.ts).
|
|
244
226
|
*/
|
|
245
227
|
export async function movedInput(
|
|
246
228
|
facts: readonly InputFact[],
|
|
247
229
|
cache: CacheLayer,
|
|
248
230
|
commandFrom?: number,
|
|
249
|
-
|
|
231
|
+
byBytes?: (file: string, ctimeMs: number) => boolean,
|
|
250
232
|
): Promise<string | undefined> {
|
|
251
233
|
const suspects: InputFact[] = []
|
|
252
234
|
for (const f of facts) {
|
|
@@ -264,7 +246,7 @@ export async function movedInput(
|
|
|
264
246
|
// an even one, FAT's tick), one during the command included
|
|
265
247
|
// (`racyWindowMs`).
|
|
266
248
|
if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
|
|
267
|
-
if (
|
|
249
|
+
if (byBytes?.(f.path, ctimeMs) !== true) return f.path
|
|
268
250
|
suspects.push(f)
|
|
269
251
|
continue
|
|
270
252
|
}
|
|
@@ -344,12 +326,6 @@ async function resolveKeyInput(
|
|
|
344
326
|
}
|
|
345
327
|
}
|
|
346
328
|
const taskConfigHash = hashTaskConfig(cfg, args.hashCache)
|
|
347
|
-
const projectPackageJsonHash = await hashProjectPackageJson(
|
|
348
|
-
args.node.projectDir,
|
|
349
|
-
args.cache,
|
|
350
|
-
args.hashCache,
|
|
351
|
-
fileHashes,
|
|
352
|
-
)
|
|
353
329
|
|
|
354
330
|
// Args after `--` reach a requested command; the default build (a keyed
|
|
355
331
|
// group) runs none, and folded, they moved every key that folds its own.
|
|
@@ -359,7 +335,6 @@ async function resolveKeyInput(
|
|
|
359
335
|
const input: CacheKeyInput = {
|
|
360
336
|
taskId: args.node.id,
|
|
361
337
|
taskConfigHash,
|
|
362
|
-
projectPackageJsonHash,
|
|
363
338
|
envValues: resolved.envValues,
|
|
364
339
|
runtimeValues: resolved.runtimeValues,
|
|
365
340
|
workspaceRuntimeValues: resolved.workspaceRuntimeValues,
|
|
@@ -368,7 +343,6 @@ async function resolveKeyInput(
|
|
|
368
343
|
upstreamHashes,
|
|
369
344
|
upstreamIds,
|
|
370
345
|
upstreamGraft,
|
|
371
|
-
workspaceFingerprint: args.workspaceFingerprint,
|
|
372
346
|
forwardArgs: effectiveForwardArgs,
|
|
373
347
|
...(fileHashes !== undefined ? { fileHashes } : {}),
|
|
374
348
|
...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
|
|
@@ -462,48 +436,3 @@ export function computeGroupHash(upstream: TaskOutcome[]): string {
|
|
|
462
436
|
.join('|')
|
|
463
437
|
return xxh3hex(`group|${ids}`)
|
|
464
438
|
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Hash the project's `package.json` bytes. Folded into every task's
|
|
468
|
-
* cache key in that project so dep changes (devDependencies, scripts,
|
|
469
|
-
* version bumps) invalidate the project's tasks even when
|
|
470
|
-
* `cache.inputs.files` is narrow and doesn't cover the file.
|
|
471
|
-
*
|
|
472
|
-
* Matches Turbo and Nx's "implicit dependencies" behavior. Returns
|
|
473
|
-
* '' for the edge case of a project without a package.json (impossible
|
|
474
|
-
* in practice — workspace discovery requires one).
|
|
475
|
-
*
|
|
476
|
-
* Two optimizations: routed through `Cache.hashFile` so the mtime+size
|
|
477
|
-
* fast path applies (an unchanged pkg.json takes a stat + SQLite SELECT
|
|
478
|
-
* instead of a full file read). And memoized within-run per projectDir
|
|
479
|
-
* so a monorepo with 100 projects × 3 tasks each does 100 lookups, not
|
|
480
|
-
* 300.
|
|
481
|
-
*/
|
|
482
|
-
async function hashProjectPackageJson(
|
|
483
|
-
projectDir: string,
|
|
484
|
-
cache: CacheLayer,
|
|
485
|
-
hashCache?: HashCache,
|
|
486
|
-
fileHashes?: ReadonlyMap<string, string>,
|
|
487
|
-
): Promise<string> {
|
|
488
|
-
const cached = hashCache?.packageJson.get(projectDir)
|
|
489
|
-
if (cached !== undefined) return cached
|
|
490
|
-
const promise = doHashProjectPackageJson(projectDir, cache, fileHashes)
|
|
491
|
-
hashCache?.packageJson.set(projectDir, promise)
|
|
492
|
-
return promise
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async function doHashProjectPackageJson(
|
|
496
|
-
projectDir: string,
|
|
497
|
-
cache: CacheLayer,
|
|
498
|
-
fileHashes?: ReadonlyMap<string, string>,
|
|
499
|
-
): Promise<string> {
|
|
500
|
-
const filePath = path.join(projectDir, 'package.json')
|
|
501
|
-
// Clean tracked package.json: its index OID is already in hand —
|
|
502
|
-
// no exists probe, no stat, no SQLite.
|
|
503
|
-
const oid = fileHashes?.get(filePath)
|
|
504
|
-
if (oid !== undefined) return oid
|
|
505
|
-
if (!(await Bun.file(filePath).exists())) return ''
|
|
506
|
-
// Route through the cache layer's mtime+size fast path so the
|
|
507
|
-
// typical re-run sees a stat + SQLite lookup instead of a file read.
|
|
508
|
-
return cache.hashFile(filePath)
|
|
509
|
-
}
|
|
@@ -191,11 +191,11 @@ export interface TaskTelemetry {
|
|
|
191
191
|
/** Executor-reported placement (a worker id) — absent for local runs.
|
|
192
192
|
* Additive-optional, no schema bump (the `attempts` precedent). */
|
|
193
193
|
where?: string
|
|
194
|
-
/** `'deferred'` when the outputs stayed remote (`--download=none`).
|
|
194
|
+
/** `'deferred'` when the outputs stayed remote (`--cache-download=none`).
|
|
195
195
|
* Additive-optional, same no-bump precedent as `where`. */
|
|
196
196
|
outputs?: 'deferred'
|
|
197
197
|
/** Total attempts when the task RETRIED (>1) — set only when `exec.retries`
|
|
198
|
-
* / `--
|
|
198
|
+
* / `--retries` produced more than one attempt. A retried-then-passed task is
|
|
199
199
|
* flaky by definition; this is the telemetry-side flaky signal. */
|
|
200
200
|
attempts?: number
|
|
201
201
|
/**
|
|
@@ -340,7 +340,7 @@ export interface RunSummaryRecord {
|
|
|
340
340
|
exitOk: boolean
|
|
341
341
|
tasks: readonly TaskTelemetry[]
|
|
342
342
|
/**
|
|
343
|
-
* The run's stages as `
|
|
343
|
+
* The run's stages as `VX_DEBUG_TIMING` marks them (startup, load configs,
|
|
344
344
|
* classify + probe, run graph, …), each a wall window in epoch ms. The
|
|
345
345
|
* stages ahead of the run lock end before `startedAt`. Additive.
|
|
346
346
|
*/
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Every vx setting, once: its path is its `.vx/workspace` key, and its
|
|
2
|
+
// environment variable and flag are derived from that path. Parsing,
|
|
3
|
+
// validation, `vx info`'s settings view and the docs' settings tables all
|
|
4
|
+
// read this list (`tests/settings.test.ts` holds src and docs to it).
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
machineParallelism,
|
|
8
|
+
MAX_TIMEOUT_MS,
|
|
9
|
+
parseDecimalInt,
|
|
10
|
+
parseDuration,
|
|
11
|
+
parseSize,
|
|
12
|
+
UserError,
|
|
13
|
+
} from './util/index.js'
|
|
14
|
+
|
|
15
|
+
export type SettingKind =
|
|
16
|
+
| { readonly type: 'int'; readonly min: number }
|
|
17
|
+
| { readonly type: 'concurrency' }
|
|
18
|
+
// `zero`: 0 is a value ("wait for nothing not already done"), not a typo.
|
|
19
|
+
| { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
|
|
20
|
+
| { readonly type: 'size' }
|
|
21
|
+
| { readonly type: 'bool' }
|
|
22
|
+
| { readonly type: 'enum'; readonly values: readonly string[] }
|
|
23
|
+
| { readonly type: 'string' }
|
|
24
|
+
|
|
25
|
+
export interface Setting {
|
|
26
|
+
/** The `.vx/workspace` path (`run.timeout`); env and flag derive from it. */
|
|
27
|
+
readonly path: string
|
|
28
|
+
readonly kind: SettingKind
|
|
29
|
+
/** The built-in, as the docs print it. */
|
|
30
|
+
readonly default: string
|
|
31
|
+
/** One line for the docs table. */
|
|
32
|
+
readonly doc: string
|
|
33
|
+
/** The verb whose flag sets it (`vx run --timeout`); none → env and config only. */
|
|
34
|
+
readonly flag?: { readonly verb: 'run' | 'watch'; readonly name: string }
|
|
35
|
+
/** The task field that overrides it, and beats every run-level source. */
|
|
36
|
+
readonly task?: string
|
|
37
|
+
/** False: read before `.vx/workspace` loads, so flag and env only. */
|
|
38
|
+
readonly config?: false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
|
|
42
|
+
|
|
43
|
+
export const SETTINGS: readonly Setting[] = [
|
|
44
|
+
{
|
|
45
|
+
path: 'run.concurrency',
|
|
46
|
+
kind: { type: 'concurrency' },
|
|
47
|
+
default: 'the cores this process may use',
|
|
48
|
+
doc: "Tasks at once: a positive integer, or a share of the cores (`'50%'`).",
|
|
49
|
+
flag: { verb: 'run', name: '--concurrency' },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
path: 'run.schedule',
|
|
53
|
+
kind: { type: 'enum', values: SCHEDULES },
|
|
54
|
+
default: 'most-work',
|
|
55
|
+
doc: 'Which ready task starts first when there are more than workers.',
|
|
56
|
+
flag: { verb: 'run', name: '--schedule' },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: 'run.affectedBase',
|
|
60
|
+
kind: { type: 'string' },
|
|
61
|
+
default: 'origin/HEAD, then the trunk, else HEAD~1',
|
|
62
|
+
doc: 'The git ref a bare `--affected` compares with.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
path: 'run.branch',
|
|
66
|
+
kind: { type: 'string' },
|
|
67
|
+
default: "git's branch, then the CI provider's",
|
|
68
|
+
doc: 'The branch a run records in its history and forecasts from. Flag and env only: a committed branch would be wrong on every other branch.',
|
|
69
|
+
flag: { verb: 'run', name: '--branch' },
|
|
70
|
+
config: false,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
path: 'run.timeout',
|
|
74
|
+
kind: { type: 'duration', timer: true },
|
|
75
|
+
default: 'none',
|
|
76
|
+
doc: "A task's time limit, for tasks without their own `exec.timeout` (`'30m'`).",
|
|
77
|
+
flag: { verb: 'run', name: '--timeout' },
|
|
78
|
+
task: 'exec.timeout',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
path: 'run.retries',
|
|
82
|
+
kind: { type: 'int', min: 0 },
|
|
83
|
+
default: '0',
|
|
84
|
+
doc: 'Re-runs of a failed task, for tasks without their own `exec.retries`.',
|
|
85
|
+
flag: { verb: 'run', name: '--retries' },
|
|
86
|
+
task: 'exec.retries',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
path: 'run.continue',
|
|
90
|
+
kind: { type: 'enum', values: ['never', 'deps-ok', 'always'] },
|
|
91
|
+
default: 'deps-ok',
|
|
92
|
+
doc: 'What a failed task takes down: `never` stops the run, `deps-ok` skips its dependents, `always` runs them too.',
|
|
93
|
+
flag: { verb: 'run', name: '--continue' },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
path: 'run.outputLogs',
|
|
97
|
+
kind: { type: 'enum', values: ['full', 'errors-only', 'hash-only', 'none'] },
|
|
98
|
+
default: '`full` on CI, else by what the run selects',
|
|
99
|
+
doc: 'Which task output the run prints. Chosen before the workspace loads, so flag and env only.',
|
|
100
|
+
flag: { verb: 'run', name: '--output-logs' },
|
|
101
|
+
config: false,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
path: 'run.killGrace',
|
|
105
|
+
kind: { type: 'duration', timer: true },
|
|
106
|
+
default: '2s',
|
|
107
|
+
doc: 'How long a task that ignores SIGTERM gets before SIGKILL.',
|
|
108
|
+
flag: { verb: 'run', name: '--kill-grace' },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
path: 'run.teardownTimeout',
|
|
112
|
+
kind: { type: 'duration', timer: true, zero: true },
|
|
113
|
+
default: '3s',
|
|
114
|
+
doc: "The bound on each plugin's end-of-run flush and teardown.",
|
|
115
|
+
flag: { verb: 'run', name: '--teardown-timeout' },
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
path: 'run.readyNotice',
|
|
119
|
+
kind: { type: 'duration', timer: true },
|
|
120
|
+
default: '10s',
|
|
121
|
+
doc: 'How long a persistent task may take to match `readyWhen` before vx says it waits.',
|
|
122
|
+
flag: { verb: 'run', name: '--ready-notice' },
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
path: 'run.rules.exclusiveOutputs',
|
|
126
|
+
kind: { type: 'bool' },
|
|
127
|
+
default: 'true',
|
|
128
|
+
doc: 'Refuse two tasks whose outputs overlap even when an edge orders them.',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
path: 'run.rules.upfrontKeys',
|
|
132
|
+
kind: { type: 'bool' },
|
|
133
|
+
default: 'true',
|
|
134
|
+
doc: "Refuse a task whose inputs can match another task's outputs.",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
path: 'cache.dir',
|
|
138
|
+
kind: { type: 'string' },
|
|
139
|
+
default: '.vx/cache, entries in ~/.vx/<repo>/cache',
|
|
140
|
+
doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
|
|
141
|
+
flag: { verb: 'run', name: '--cache-dir' },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
path: 'cache.retention.maxSize',
|
|
145
|
+
kind: { type: 'size' },
|
|
146
|
+
default: 'none',
|
|
147
|
+
doc: "Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`).",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
path: 'cache.retention.olderThan',
|
|
151
|
+
kind: { type: 'duration' },
|
|
152
|
+
default: 'none',
|
|
153
|
+
doc: "Evict entries unused for this long at each run's end (`'30d'`).",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
path: 'cache.policy',
|
|
157
|
+
kind: { type: 'string' },
|
|
158
|
+
default: 'every layer reads and writes',
|
|
159
|
+
doc: "Which layers a run reads and writes (`'local:rw,remote:r'`); `--no-cache` and `--force` apply on top.",
|
|
160
|
+
flag: { verb: 'run', name: '--cache-policy' },
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
path: 'cache.scope',
|
|
164
|
+
kind: { type: 'string' },
|
|
165
|
+
default: 'none (the default keys)',
|
|
166
|
+
doc: "Where cache writes land (`'pr-123'`): reads try the default key, then the scope's.",
|
|
167
|
+
flag: { verb: 'run', name: '--cache-scope' },
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
path: 'cache.download',
|
|
171
|
+
kind: { type: 'enum', values: ['all', 'toplevel', 'none'] },
|
|
172
|
+
default: 'all',
|
|
173
|
+
doc: 'Which remote hits land on disk: every one, only the requested tasks, or none.',
|
|
174
|
+
flag: { verb: 'run', name: '--cache-download' },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
path: 'cache.inputStability.strategy',
|
|
178
|
+
kind: { type: 'enum', values: ['immutable', 'mutable', 'unchecked'] },
|
|
179
|
+
default: 'immutable',
|
|
180
|
+
doc: 'What a write to an input during the command means for the save, for tasks without their own.',
|
|
181
|
+
task: 'cache.inputStability.strategy',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
path: 'cache.inputStability.severity',
|
|
185
|
+
kind: { type: 'enum', values: ['warn', 'error'] },
|
|
186
|
+
default: 'warn',
|
|
187
|
+
doc: 'Whether a skipped save warns or fails the task, for tasks without their own.',
|
|
188
|
+
task: 'cache.inputStability.severity',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
path: 'cache.outputs.clean',
|
|
192
|
+
kind: { type: 'bool' },
|
|
193
|
+
default: 'true',
|
|
194
|
+
doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
|
|
195
|
+
task: 'cache.outputs.clean',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
path: 'lock.reviewFiles',
|
|
199
|
+
kind: { type: 'bool' },
|
|
200
|
+
default: 'true',
|
|
201
|
+
doc: "`vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it.",
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
path: 'watch.poll',
|
|
205
|
+
kind: { type: 'bool' },
|
|
206
|
+
default: 'false',
|
|
207
|
+
doc: 'Poll for changes from the start instead of the OS watcher.',
|
|
208
|
+
flag: { verb: 'watch', name: '--poll' },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
path: 'config.workerTimeout',
|
|
212
|
+
kind: { type: 'duration', timer: true },
|
|
213
|
+
default: '30s',
|
|
214
|
+
doc: 'How long one config evaluation may take.',
|
|
215
|
+
config: false,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
path: 'debug.timing',
|
|
219
|
+
kind: { type: 'bool' },
|
|
220
|
+
default: 'false',
|
|
221
|
+
doc: "Print the run's stage timings to stderr.",
|
|
222
|
+
config: false,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
path: 'debug.dumpable',
|
|
226
|
+
kind: { type: 'bool' },
|
|
227
|
+
default: 'false',
|
|
228
|
+
doc: 'Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env.',
|
|
229
|
+
config: false,
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
/** The `.vx/workspace` groups that hold settings; `plugins` is the other key. */
|
|
234
|
+
export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
|
|
235
|
+
|
|
236
|
+
const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
|
|
237
|
+
|
|
238
|
+
export function setting(path: string): Setting {
|
|
239
|
+
return BY_PATH.get(path)!
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `run.killGrace` → `VX_RUN_KILL_GRACE`. */
|
|
243
|
+
export function envName(path: string): string {
|
|
244
|
+
return `VX_${path
|
|
245
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
246
|
+
.replace(/\./g, '_')
|
|
247
|
+
.toUpperCase()}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** What a value of this kind looks like, for an error. */
|
|
251
|
+
export function kindHint(kind: SettingKind): string {
|
|
252
|
+
switch (kind.type) {
|
|
253
|
+
case 'int':
|
|
254
|
+
return kind.min === 0 ? 'a non-negative integer' : `an integer ≥ ${kind.min}`
|
|
255
|
+
case 'concurrency':
|
|
256
|
+
return "a positive integer, or a share of the cores like '50%'"
|
|
257
|
+
case 'duration':
|
|
258
|
+
// A timer past 2^31-1 ms fires at once, so "no limit" is no value.
|
|
259
|
+
return kind.timer === true
|
|
260
|
+
? "a duration like '500ms', '30s' or '5m', at most 24 days"
|
|
261
|
+
: "a duration like '45s', '12h' or '30d'"
|
|
262
|
+
case 'size':
|
|
263
|
+
return "a size like '500MB' or '10GB'"
|
|
264
|
+
case 'bool':
|
|
265
|
+
return 'true or false'
|
|
266
|
+
case 'enum':
|
|
267
|
+
return `one of ${kind.values.map((v) => `'${v}'`).join(', ')}`
|
|
268
|
+
case 'string':
|
|
269
|
+
return 'a non-blank string'
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A positive decimal integer, or `<n>%` of the cores this process may use
|
|
275
|
+
* (`50%` on eight cores is 4, never below 1; over 100% is allowed for
|
|
276
|
+
* I/O-bound work; a container's CPU quota caps the count the way it caps
|
|
277
|
+
* the default), resolved here so the run sees one number and the summary
|
|
278
|
+
* says what it ran with.
|
|
279
|
+
*/
|
|
280
|
+
export function parseConcurrency(v: unknown, cpus = machineParallelism()): number | null {
|
|
281
|
+
if (typeof v !== 'string') return null
|
|
282
|
+
const pct = /^(\d+)%$/.exec(v)
|
|
283
|
+
if (pct) {
|
|
284
|
+
const n = parseDecimalInt(pct[1]!)
|
|
285
|
+
if (n === null || n < 1) return null
|
|
286
|
+
return Math.max(1, Math.round((Math.max(1, cpus) * n) / 100))
|
|
287
|
+
}
|
|
288
|
+
const n = parseDecimalInt(v)
|
|
289
|
+
return n === null || n < 1 ? null : n
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A setting's value, or null when `raw` is not one. Durations come back in
|
|
294
|
+
* ms, sizes in bytes and a share of the cores as a count; the rest as typed.
|
|
295
|
+
*/
|
|
296
|
+
export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
|
|
297
|
+
switch (kind.type) {
|
|
298
|
+
case 'int': {
|
|
299
|
+
const n =
|
|
300
|
+
typeof raw === 'number' ? raw : typeof raw === 'string' ? parseDecimalInt(raw) : null
|
|
301
|
+
return n !== null && Number.isSafeInteger(n) && n >= kind.min ? n : null
|
|
302
|
+
}
|
|
303
|
+
case 'concurrency':
|
|
304
|
+
return parseConcurrency(typeof raw === 'number' ? String(raw) : raw)
|
|
305
|
+
case 'duration': {
|
|
306
|
+
const n = typeof raw === 'string' ? parseDuration(raw) : null
|
|
307
|
+
if (n === null || (n === 0 && kind.zero !== true)) return null
|
|
308
|
+
return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
|
|
309
|
+
}
|
|
310
|
+
case 'size':
|
|
311
|
+
return typeof raw === 'string' ? parseSize(raw) : null
|
|
312
|
+
case 'bool':
|
|
313
|
+
if (typeof raw === 'boolean') return raw
|
|
314
|
+
return raw === 'true' || raw === '1' ? true : raw === 'false' || raw === '0' ? false : null
|
|
315
|
+
case 'enum':
|
|
316
|
+
return typeof raw === 'string' && kind.values.includes(raw) ? raw : null
|
|
317
|
+
case 'string':
|
|
318
|
+
return typeof raw === 'string' && raw.trim() !== '' ? raw : null
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Why `raw` from `where` (a variable, a flag, a config field) is refused
|
|
324
|
+
* for the setting at `path`; `got` is `raw` as the message quotes it. A
|
|
325
|
+
* bare number for a duration names the unit it lacks, and a timer past the
|
|
326
|
+
* platform's range says it would fire at once rather than never.
|
|
327
|
+
*/
|
|
328
|
+
export function settingRefusal(
|
|
329
|
+
path: string,
|
|
330
|
+
where: string,
|
|
331
|
+
raw: unknown,
|
|
332
|
+
got = String(raw),
|
|
333
|
+
): string {
|
|
334
|
+
const { kind } = setting(path)
|
|
335
|
+
if (kind.type === 'duration') {
|
|
336
|
+
const n =
|
|
337
|
+
typeof raw === 'number'
|
|
338
|
+
? raw
|
|
339
|
+
: typeof raw === 'string' && /^\d+$/.test(raw)
|
|
340
|
+
? Number(raw)
|
|
341
|
+
: null
|
|
342
|
+
if (n !== null && Number.isSafeInteger(n) && n > 0) {
|
|
343
|
+
return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
|
|
344
|
+
}
|
|
345
|
+
const ms = typeof raw === 'string' ? parseDuration(raw) : null
|
|
346
|
+
if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
|
|
347
|
+
return (
|
|
348
|
+
`${where} of ${got} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days): ` +
|
|
349
|
+
'a longer timer fires at once, it does not mean "no limit"' +
|
|
350
|
+
(path === 'run.timeout' ? ' — omit it for no limit' : '')
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return `${where} must be ${kindHint(kind)} (got ${got})`
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A setting's environment value, parsed; undefined when unset or empty. */
|
|
358
|
+
export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env): unknown {
|
|
359
|
+
const { kind } = setting(path)
|
|
360
|
+
const name = envName(path)
|
|
361
|
+
const raw = env[name]
|
|
362
|
+
if (raw === undefined || raw === '') return undefined
|
|
363
|
+
const v = parseSettingValue(kind, raw)
|
|
364
|
+
if (v === null) throw new UserError(settingRefusal(path, name, raw), 'VX_E_USAGE')
|
|
365
|
+
return v
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Validate every set setting variable once, so a deep reader can trust what it reads. */
|
|
369
|
+
export function checkSettingsEnv(env: NodeJS.ProcessEnv = process.env): void {
|
|
370
|
+
for (const s of SETTINGS) settingEnv(s.path, env)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Where a resolved setting came from. */
|
|
374
|
+
export type SettingSource = 'flag' | 'env' | 'workspace' | 'default'
|
|
375
|
+
|
|
376
|
+
/** `config` at `path` (`run.timeout` → `config.run?.timeout`). */
|
|
377
|
+
export function atPath(config: unknown, path: string): unknown {
|
|
378
|
+
let v: unknown = config
|
|
379
|
+
for (const k of path.split('.')) {
|
|
380
|
+
if (v === null || typeof v !== 'object') return undefined
|
|
381
|
+
v = (v as Record<string, unknown>)[k]
|
|
382
|
+
}
|
|
383
|
+
return v
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A run-level setting: the flag's value, else the environment's, else the
|
|
388
|
+
* workspace's, parsed; undefined with its default. A task override is
|
|
389
|
+
* applied where the task runs, above all three.
|
|
390
|
+
*/
|
|
391
|
+
export function resolveSetting(
|
|
392
|
+
path: string,
|
|
393
|
+
workspace: unknown,
|
|
394
|
+
flag?: unknown,
|
|
395
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
396
|
+
): { value: unknown; source: SettingSource } {
|
|
397
|
+
if (flag !== undefined) return { value: flag, source: 'flag' }
|
|
398
|
+
const e = settingEnv(path, env)
|
|
399
|
+
if (e !== undefined) return { value: e, source: 'env' }
|
|
400
|
+
const w = setting(path).config === false ? undefined : atPath(workspace, path)
|
|
401
|
+
if (w !== undefined)
|
|
402
|
+
return { value: parseSettingValue(setting(path).kind, w), source: 'workspace' }
|
|
403
|
+
return { value: undefined, source: 'default' }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** `run.rules`, each from its variable, else the workspace; unset ones stay on. */
|
|
407
|
+
export function runRules(workspace: unknown): {
|
|
408
|
+
exclusiveOutputs?: boolean
|
|
409
|
+
upfrontKeys?: boolean
|
|
410
|
+
} {
|
|
411
|
+
const out: { exclusiveOutputs?: boolean; upfrontKeys?: boolean } = {}
|
|
412
|
+
for (const rule of ['exclusiveOutputs', 'upfrontKeys'] as const) {
|
|
413
|
+
const { value } = resolveSetting(`run.rules.${rule}`, workspace)
|
|
414
|
+
if (value !== undefined) out[rule] = value as boolean
|
|
415
|
+
}
|
|
416
|
+
return out
|
|
417
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class UserError extends Error {
|
|
|
26
26
|
|
|
27
27
|
// A refusal that opens with the config that caused it: the schema's
|
|
28
28
|
// `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
|
|
29
|
-
const CONFIG_REFUSAL = /^\/\S*\/vx\.
|
|
29
|
+
const CONFIG_REFUSAL = /^\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The stable code of anything `bin.ts` catches. A UserError's own, but
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every task gets these (`exec/env.ts`), so `hideEnvFromTasks` leaves
|
|
3
|
+
* them. Exported so the schema doc's enumeration of it can be PINNED against the
|
|
4
|
+
* real list: a hand-copied allowlist is how the two drift, and a reader
|
|
5
|
+
* asking "what does my build script actually see?" is asking a
|
|
6
|
+
* security-shaped question that a stale list answers wrongly.
|
|
7
|
+
*/
|
|
8
|
+
export const ESSENTIAL_ENV: readonly string[] = [
|
|
9
|
+
'PATH',
|
|
10
|
+
'HOME',
|
|
11
|
+
'SHELL',
|
|
12
|
+
'USER',
|
|
13
|
+
'LOGNAME',
|
|
14
|
+
'TMPDIR',
|
|
15
|
+
'TEMP',
|
|
16
|
+
'TMP',
|
|
17
|
+
'LANG',
|
|
18
|
+
'LC_ALL',
|
|
19
|
+
'LC_CTYPE',
|
|
20
|
+
'TERM',
|
|
21
|
+
'COLORTERM',
|
|
22
|
+
'FORCE_COLOR',
|
|
23
|
+
'NO_COLOR',
|
|
24
|
+
'CI',
|
|
25
|
+
'NODE_OPTIONS',
|
|
26
|
+
// Where corepack and pnpm keep their own binaries, as HOME is: a machine
|
|
27
|
+
// path, never task output. Without them a corepack shim re-downloads its
|
|
28
|
+
// manager (offline or sandboxed: fails) and pnpm loses its global dir.
|
|
29
|
+
'COREPACK_HOME',
|
|
30
|
+
'PNPM_HOME',
|
|
31
|
+
]
|