@vzn/vx 0.0.650 → 0.0.652
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 +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
|
@@ -18,8 +18,16 @@ import {
|
|
|
18
18
|
notAWorkTree,
|
|
19
19
|
relPosix,
|
|
20
20
|
UserError,
|
|
21
|
+
xxh3hex,
|
|
21
22
|
} from '../util/index.js'
|
|
22
|
-
import { LOCKFILE_NAME, lockKey } from './lockfile.js'
|
|
23
|
+
import { LOCKFILE_NAME, lockEntries, lockKey } from './lockfile.js'
|
|
24
|
+
import {
|
|
25
|
+
DEPENDENCIES_LOCK_NAME,
|
|
26
|
+
type DependenciesLock,
|
|
27
|
+
dependantsOf,
|
|
28
|
+
recordedDigests,
|
|
29
|
+
parseDependenciesLock,
|
|
30
|
+
} from './dependencies-lock.js'
|
|
23
31
|
import { configImportOwners, realpathOr } from './config-imports.js'
|
|
24
32
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
25
33
|
import { bunPatchFiles } from './fingerprint.js'
|
|
@@ -143,6 +151,10 @@ export interface FingerprintClaims {
|
|
|
143
151
|
}): Promise<ReadonlySet<string> | undefined>
|
|
144
152
|
/** The files a claimed file names that its claimant keys too; absent = it reads none. */
|
|
145
153
|
extraFiles?(file: string, bytes: Uint8Array): Promise<readonly string[] | undefined>
|
|
154
|
+
/** Each project's digest of a claimed file now (`FingerprintClaim.digests`); absent or undefined = none. */
|
|
155
|
+
digests?(
|
|
156
|
+
file: string,
|
|
157
|
+
): Promise<{ hash: string; projects: Readonly<Record<string, string>> } | undefined>
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
/**
|
|
@@ -388,6 +400,12 @@ export async function affectedChanges(
|
|
|
388
400
|
if (wsConfig !== undefined) return everything(wsConfig)
|
|
389
401
|
// The workspace file is loaded once, for the extra files below and the
|
|
390
402
|
// changed root names after them.
|
|
403
|
+
// The base commit's `vx lock` record, read once and only when asked.
|
|
404
|
+
let baseDeps: Promise<DependenciesLock | null> | undefined
|
|
405
|
+
const baseDependencies = (): Promise<DependenciesLock | null> =>
|
|
406
|
+
(baseDeps ??= gitBytesAt(args.workspaceRoot, base, DEPENDENCIES_LOCK_NAME).then(
|
|
407
|
+
parseDependenciesLock,
|
|
408
|
+
))
|
|
391
409
|
let claims: FingerprintClaims | undefined
|
|
392
410
|
const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
|
|
393
411
|
(claims ??= await args.fingerprintClaims?.())
|
|
@@ -446,6 +464,32 @@ export async function affectedChanges(
|
|
|
446
464
|
if (patch !== undefined) return everything(patch)
|
|
447
465
|
}
|
|
448
466
|
}
|
|
467
|
+
// The projects whose digest of `file` moved, from the base's recorded
|
|
468
|
+
// digests and the claimant's now: the base side is never parsed. Null
|
|
469
|
+
// when the base recorded none for these bytes (no lock, or another
|
|
470
|
+
// hash) or the claimant gives none now: `affected` answers then.
|
|
471
|
+
const recordedMoves = async (
|
|
472
|
+
asked: FingerprintClaims,
|
|
473
|
+
file: string,
|
|
474
|
+
before: Uint8Array | null,
|
|
475
|
+
after: Uint8Array | null,
|
|
476
|
+
): Promise<ReadonlySet<string> | null> => {
|
|
477
|
+
if (before === null || after === null || asked.digests === undefined) return null
|
|
478
|
+
const base = await baseDependencies()
|
|
479
|
+
const was = base === null ? undefined : recordedDigests(base, file)
|
|
480
|
+
// Base digests that read another file are that file's at the base,
|
|
481
|
+
// which the record does not prove.
|
|
482
|
+
if (was === undefined || was.hash !== xxh3hex(before) || Object.keys(was.extras).length > 0)
|
|
483
|
+
return null
|
|
484
|
+
const now = await asked.digests(file)
|
|
485
|
+
if (now === undefined) return null
|
|
486
|
+
const moved = new Set<string>()
|
|
487
|
+
for (const p of projects) {
|
|
488
|
+
const d = was.of(lockKey(rootDir, p.dir))
|
|
489
|
+
if (d === undefined || d !== now.projects[p.name]) moved.add(p.name)
|
|
490
|
+
}
|
|
491
|
+
return moved
|
|
492
|
+
}
|
|
449
493
|
const claimedOwned = new Map<string, string>()
|
|
450
494
|
const rootNamesChanged = changed.filter((p) => !p.includes('/'))
|
|
451
495
|
for (const file of extrasBy.keys()) {
|
|
@@ -455,12 +499,11 @@ export async function affectedChanges(
|
|
|
455
499
|
for (const file of rootNamesChanged) {
|
|
456
500
|
if (claimed === undefined || !claimed.files.has(file)) continue
|
|
457
501
|
const extras = extrasBy.get(file)
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
...(extras !== undefined ? { extras } : {})
|
|
463
|
-
})
|
|
502
|
+
const before = await gitBytesAt(args.workspaceRoot, base, file)
|
|
503
|
+
const after = await bytesOrNull(path.join(args.workspaceRoot, file))
|
|
504
|
+
const answer =
|
|
505
|
+
(extras === undefined ? await recordedMoves(claimed, file, before, after) : undefined) ??
|
|
506
|
+
(await claimed.affected({ file, before, after, ...(extras !== undefined ? { extras } : {}) }))
|
|
464
507
|
const why =
|
|
465
508
|
extras === undefined || changed.includes(file)
|
|
466
509
|
? file
|
|
@@ -505,20 +548,8 @@ export async function affectedChanges(
|
|
|
505
548
|
if (diffed.paths.includes(LOCKFILE_NAME) || untracked.includes(LOCKFILE_NAME)) {
|
|
506
549
|
// A side with no lock (or one that will not parse) ran nothing from
|
|
507
550
|
// it: the paths of the configs say what changed, as before the lock.
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
try {
|
|
511
|
-
const locked = (JSON.parse(new TextDecoder().decode(bytes)) as { projects?: unknown })
|
|
512
|
-
.projects
|
|
513
|
-
return typeof locked === 'object' && locked !== null
|
|
514
|
-
? (locked as Record<string, unknown>)
|
|
515
|
-
: null
|
|
516
|
-
} catch {
|
|
517
|
-
return null
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
const before = entries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
|
|
521
|
-
const after = entries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
|
|
551
|
+
const before = lockEntries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
|
|
552
|
+
const after = lockEntries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
|
|
522
553
|
const same = (dir: string): boolean =>
|
|
523
554
|
before === null ||
|
|
524
555
|
after === null ||
|
|
@@ -538,9 +569,23 @@ export async function affectedChanges(
|
|
|
538
569
|
const atBase = await gitBlobsAt(args.workspaceRoot, base, markers)
|
|
539
570
|
// A config the base had in a directory that is no project now: the
|
|
540
571
|
// project is gone, and with it the edge naming who depended on it,
|
|
541
|
-
// while their keys lost an upstream. Today's graph cannot say who
|
|
542
|
-
|
|
543
|
-
|
|
572
|
+
// while their keys lost an upstream. Today's graph cannot say who; the
|
|
573
|
+
// base's dependencies lock can. Without one, every project.
|
|
574
|
+
// A config that moved, the same bytes under a new project, is no
|
|
575
|
+
// loss: its files select it where it lives.
|
|
576
|
+
const nameAt = new Map(projects.map((p) => [lockKey(rootDir, p.dir), p.name]))
|
|
577
|
+
for (const gone of await removedProjectConfigs(args.workspaceRoot, atBase, projects)) {
|
|
578
|
+
const recorded = await baseDependencies()
|
|
579
|
+
if (recorded === null) return everything(gone)
|
|
580
|
+
const held = dependantsOf(recorded, path.posix.dirname(gone)).flatMap(
|
|
581
|
+
(d) => nameAt.get(d) ?? [],
|
|
582
|
+
)
|
|
583
|
+
for (const name of held) {
|
|
584
|
+
owned.add(name)
|
|
585
|
+
whole.add(name)
|
|
586
|
+
if (!claimedOwned.has(name)) claimedOwned.set(name, gone)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
544
589
|
for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
|
|
545
590
|
owned.add(name)
|
|
546
591
|
whole.add(name)
|
|
@@ -587,7 +632,12 @@ export async function affectedChanges(
|
|
|
587
632
|
}
|
|
588
633
|
|
|
589
634
|
/** What vx writes under `.vx`, which no key reads (cache/inputs.ts `ALWAYS_IGNORE`). */
|
|
590
|
-
const VX_OWN: ReadonlySet<string> = new Set([
|
|
635
|
+
const VX_OWN: ReadonlySet<string> = new Set([
|
|
636
|
+
LOCKFILE_NAME,
|
|
637
|
+
DEPENDENCIES_LOCK_NAME,
|
|
638
|
+
'.vx/.gitattributes',
|
|
639
|
+
'.vx/.gitignore',
|
|
640
|
+
])
|
|
591
641
|
|
|
592
642
|
const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
593
643
|
/**
|
|
@@ -632,22 +682,31 @@ function parentsOfNewNested(
|
|
|
632
682
|
return out
|
|
633
683
|
}
|
|
634
684
|
|
|
635
|
-
/**
|
|
636
|
-
async function
|
|
685
|
+
/** Each config in `atBase` the base had whose file and project are both gone now. */
|
|
686
|
+
async function removedProjectConfigs(
|
|
637
687
|
workspaceRoot: string,
|
|
638
688
|
atBase: ReadonlyMap<string, Uint8Array | null>,
|
|
639
689
|
projects: readonly ProjectMeta[],
|
|
640
|
-
): Promise<string
|
|
690
|
+
): Promise<string[]> {
|
|
641
691
|
const dirs = new Set(projects.map((p) => p.dir.normalize('NFC')))
|
|
692
|
+
const gone: string[] = []
|
|
693
|
+
let added: Uint8Array[] | undefined
|
|
642
694
|
for (const [rel, bytes] of atBase) {
|
|
643
695
|
// A manifest's directory may never have been a project; a config's was.
|
|
644
696
|
if (bytes === null || !CONFIG_NAMES.has(path.posix.basename(rel))) continue
|
|
645
697
|
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel)).normalize('NFC')
|
|
646
698
|
if (dirs.has(dir)) continue
|
|
647
699
|
if (await Bun.file(path.join(workspaceRoot, rel)).exists()) continue
|
|
648
|
-
|
|
700
|
+
// Each config the base lacked, read once, only when one went.
|
|
701
|
+
added ??= await Promise.all(
|
|
702
|
+
[...atBase]
|
|
703
|
+
.filter(([r, b]) => b === null && CONFIG_NAMES.has(path.posix.basename(r)))
|
|
704
|
+
.map(([r]) => bytesOrNull(path.join(workspaceRoot, r))),
|
|
705
|
+
).then((all) => all.filter((b): b is Uint8Array => b !== null))
|
|
706
|
+
if (added!.some((b) => Buffer.from(b).equals(bytes))) continue
|
|
707
|
+
gone.push(rel)
|
|
649
708
|
}
|
|
650
|
-
return
|
|
709
|
+
return gone
|
|
651
710
|
}
|
|
652
711
|
|
|
653
712
|
/**
|
|
@@ -65,8 +65,8 @@ export function checkBracketLiterals(root: string, projects: Iterable<ProjectEnt
|
|
|
65
65
|
const lists: [string, readonly string[] | undefined, string][] = [
|
|
66
66
|
['inputs.files', t.inputs?.files, p.dir],
|
|
67
67
|
['inputs.workspaceFiles', t.inputs?.workspaceFiles, root],
|
|
68
|
-
['
|
|
69
|
-
['
|
|
68
|
+
['outputs.files', t.outputs?.files, p.dir],
|
|
69
|
+
['outputs.workspaceFiles', t.outputs?.workspaceFiles, root],
|
|
70
70
|
]
|
|
71
71
|
for (const [field, globs, base] of lists) {
|
|
72
72
|
for (const glob of globs ?? []) {
|
|
@@ -115,7 +115,7 @@ const oneOf = (values: readonly string[]): string =>
|
|
|
115
115
|
|
|
116
116
|
function validateInputStability(stability: unknown, at: string): void {
|
|
117
117
|
if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
|
|
118
|
-
throw new UserError(`${at} must be an object — \`
|
|
118
|
+
throw new UserError(`${at} must be an object — \`stability: { strategy: 'mutable' }\``)
|
|
119
119
|
}
|
|
120
120
|
assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
|
|
121
121
|
const { strategy, severity } = stability as Record<string, unknown>
|
|
@@ -128,13 +128,13 @@ function validateInputStability(stability: unknown, at: string): void {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* `
|
|
132
|
-
* task's `
|
|
131
|
+
* `VX_INPUTS_STABILITY_STRATEGY` and `_SEVERITY`, which override every
|
|
132
|
+
* task's `inputs.stability` for the run: each undefined when unset or
|
|
133
133
|
* empty, refused when it names no value.
|
|
134
134
|
*/
|
|
135
135
|
export function inputStabilityEnv(): InputStability {
|
|
136
|
-
const strategy = settingEnv('
|
|
137
|
-
const severity = settingEnv('
|
|
136
|
+
const strategy = settingEnv('inputs.stability.strategy') as InputStability['strategy']
|
|
137
|
+
const severity = settingEnv('inputs.stability.severity') as InputStability['severity']
|
|
138
138
|
return {
|
|
139
139
|
...(strategy !== undefined ? { strategy } : {}),
|
|
140
140
|
...(severity !== undefined ? { severity } : {}),
|
|
@@ -231,6 +231,8 @@ function ruleSelectorProblem(s: string): string | null {
|
|
|
231
231
|
// The root's own name, which the filter reads as the root's directory.
|
|
232
232
|
if (s === ROOT_NAME) return null
|
|
233
233
|
if (s.startsWith('!')) return "starts with '!', which a filter reads as an exclusion"
|
|
234
|
+
// The filter parser refuses one, which the catch below would read as a walk.
|
|
235
|
+
if (hasControl(s)) return 'holds a control character'
|
|
234
236
|
const walk = "starts or ends with '...', which a filter reads as a dependency walk"
|
|
235
237
|
let f: ReturnType<typeof parseFilter>
|
|
236
238
|
try {
|
|
@@ -440,6 +442,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
440
442
|
files?: unknown
|
|
441
443
|
affected?: unknown
|
|
442
444
|
extraFiles?: unknown
|
|
445
|
+
digests?: unknown
|
|
443
446
|
} | null
|
|
444
447
|
if (
|
|
445
448
|
claim === null ||
|
|
@@ -447,10 +450,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
447
450
|
!Array.isArray(claim.files) ||
|
|
448
451
|
claim.files.length === 0 ||
|
|
449
452
|
typeof claim.affected !== 'function' ||
|
|
450
|
-
(claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function')
|
|
453
|
+
(claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function') ||
|
|
454
|
+
(claim.digests !== undefined && typeof claim.digests !== 'function')
|
|
451
455
|
) {
|
|
452
456
|
throw new UserError(
|
|
453
|
-
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function }`,
|
|
457
|
+
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function, digests?: function }`,
|
|
454
458
|
)
|
|
455
459
|
}
|
|
456
460
|
for (const file of claim.files as unknown[]) {
|
|
@@ -588,6 +592,9 @@ export function validateProjectConfig(
|
|
|
588
592
|
const exec = (task as { exec?: unknown }).exec
|
|
589
593
|
const dependsOn = (task as { dependsOn?: unknown }).dependsOn
|
|
590
594
|
const cache = (task as { cache?: unknown }).cache
|
|
595
|
+
if (cache !== undefined && typeof cache !== 'boolean') {
|
|
596
|
+
throw new UserError(`${where}.cache must be true or false`)
|
|
597
|
+
}
|
|
591
598
|
if (exec !== undefined) {
|
|
592
599
|
if (typeof exec !== 'object' || exec === null) {
|
|
593
600
|
throw new UserError(`${where}.exec must be an object with a \`command\` string`)
|
|
@@ -621,9 +628,9 @@ export function validateProjectConfig(
|
|
|
621
628
|
// Documented and never held: an uncached 'only' task loaded, and the
|
|
622
629
|
// REAPI executor refused it at run time for want of described inputs
|
|
623
630
|
// (item 1001).
|
|
624
|
-
if (remote === 'only' && cache
|
|
631
|
+
if (remote === 'only' && cache !== true) {
|
|
625
632
|
throw new UserError(
|
|
626
|
-
`${where}.exec.remote 'only' needs \`cache\`: its inputs are what a worker reproduces ` +
|
|
633
|
+
`${where}.exec.remote 'only' needs \`cache: true\`: its inputs are what a worker reproduces ` +
|
|
627
634
|
`and its key is the address of its remote record`,
|
|
628
635
|
)
|
|
629
636
|
}
|
|
@@ -710,7 +717,7 @@ export function validateProjectConfig(
|
|
|
710
717
|
throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
|
|
711
718
|
}
|
|
712
719
|
if (interactive === true) {
|
|
713
|
-
if (cache
|
|
720
|
+
if (cache === true) {
|
|
714
721
|
throw new UserError(
|
|
715
722
|
`${where}: \`cache\` is not allowed on an interactive task — what it does ` +
|
|
716
723
|
`depends on what is typed, which no key holds`,
|
|
@@ -763,7 +770,7 @@ export function validateProjectConfig(
|
|
|
763
770
|
)
|
|
764
771
|
}
|
|
765
772
|
}
|
|
766
|
-
if (cache
|
|
773
|
+
if (cache === true) {
|
|
767
774
|
throw new UserError(
|
|
768
775
|
`${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
|
|
769
776
|
`don't terminate, so there's no exit to cache`,
|
|
@@ -941,16 +948,8 @@ export function validateProjectConfig(
|
|
|
941
948
|
validateWorkspaceGlobs(wsInputs, `${where}.inputs.workspaceFiles`, true)
|
|
942
949
|
}
|
|
943
950
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
throw new UserError(
|
|
947
|
-
`${where}.cache must be an object when present — \`inputs: { files: [...] }, cache: { outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
|
|
948
|
-
)
|
|
949
|
-
}
|
|
950
|
-
assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
|
|
951
|
-
const stability = (cache as { inputStability?: unknown }).inputStability
|
|
952
|
-
if (stability !== undefined)
|
|
953
|
-
validateInputStability(stability, `${where}.cache.inputStability`)
|
|
951
|
+
const outputs = (task as { outputs?: unknown }).outputs
|
|
952
|
+
if (cache === true) {
|
|
954
953
|
// The key is the task's inputs, and a group runs nothing to cache.
|
|
955
954
|
if (exec === undefined) {
|
|
956
955
|
throw new UserError(
|
|
@@ -962,23 +961,38 @@ export function validateProjectConfig(
|
|
|
962
961
|
`${where}: \`cache\` needs \`inputs\` — the key is the task's inputs, \`inputs: { files: [...] }\``,
|
|
963
962
|
)
|
|
964
963
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
964
|
+
if (outputs === undefined) {
|
|
965
|
+
throw new UserError(
|
|
966
|
+
`${where}: \`cache\` needs \`outputs\` — what the task produces, \`outputs: { files: [...] }\` (\`[]\` for none)`,
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const stability = (inputs as { stability?: unknown } | undefined)?.stability
|
|
971
|
+
if (stability !== undefined) {
|
|
972
|
+
validateInputStability(stability, `${where}.inputs.stability`)
|
|
973
|
+
if (exec === undefined) {
|
|
974
|
+
throw new UserError(
|
|
975
|
+
`${where}.inputs.stability needs \`exec\` — a task with no command runs nothing that could write its inputs`,
|
|
976
|
+
)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (outputs !== undefined) {
|
|
980
|
+
if (outputs === null || typeof outputs !== 'object') {
|
|
981
|
+
throw new UserError(`${where}.outputs must be an object — \`outputs: { files: [...] }\``)
|
|
968
982
|
}
|
|
969
|
-
|
|
983
|
+
assertKnownFields(outputs, OUTPUT_FIELDS, `${where}.outputs`)
|
|
984
|
+
if (exec === undefined) {
|
|
970
985
|
throw new UserError(
|
|
971
|
-
`${where}.
|
|
986
|
+
`${where}.outputs needs \`exec\` — a task with no command writes nothing`,
|
|
972
987
|
)
|
|
973
988
|
}
|
|
974
|
-
assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
|
|
975
989
|
const clean = (outputs as { clean?: unknown }).clean
|
|
976
990
|
if (clean !== undefined && typeof clean !== 'boolean') {
|
|
977
|
-
throw new UserError(`${where}.
|
|
991
|
+
throw new UserError(`${where}.outputs.clean must be true or false`)
|
|
978
992
|
}
|
|
979
993
|
const outFiles = (outputs as { files?: unknown }).files
|
|
980
994
|
if (!Array.isArray(outFiles)) {
|
|
981
|
-
throw new UserError(`${where}.
|
|
995
|
+
throw new UserError(`${where}.outputs.files must be an array of glob strings`)
|
|
982
996
|
}
|
|
983
997
|
// Reject zero-length strings and absolute paths up front.
|
|
984
998
|
// Both reach `resolveOutputs` as undefined behavior; the error
|
|
@@ -986,26 +1000,26 @@ export function validateProjectConfig(
|
|
|
986
1000
|
// at the user's config. Fail loud at load time.
|
|
987
1001
|
for (const g of outFiles as unknown[]) {
|
|
988
1002
|
if (typeof g !== 'string' || g.length === 0) {
|
|
989
|
-
throw new UserError(`${where}.
|
|
1003
|
+
throw new UserError(`${where}.outputs.files must be an array of non-empty strings`)
|
|
990
1004
|
}
|
|
991
1005
|
if (isAbsoluteGlob(g)) {
|
|
992
1006
|
throw new UserError(
|
|
993
|
-
`${where}.
|
|
1007
|
+
`${where}.outputs.files: absolute paths are not allowed (got "${g}") — ` +
|
|
994
1008
|
`outputs must be project-relative globs`,
|
|
995
1009
|
)
|
|
996
1010
|
}
|
|
997
1011
|
if (hasParentSegment(g)) {
|
|
998
1012
|
throw new UserError(
|
|
999
|
-
`${where}.
|
|
1013
|
+
`${where}.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
|
|
1000
1014
|
`outputs must stay within the project (vx clears a task's outputs before it runs, ` +
|
|
1001
1015
|
`so a glob that escapes the project would delete files outside it)`,
|
|
1002
1016
|
)
|
|
1003
1017
|
}
|
|
1004
|
-
assertPosixGlob(g, `${where}.
|
|
1005
|
-
assertNoForeignToken(g, `${where}.
|
|
1018
|
+
assertPosixGlob(g, `${where}.outputs.files`, 'project')
|
|
1019
|
+
assertNoForeignToken(g, `${where}.outputs.files`)
|
|
1006
1020
|
if (namesDirItself(g)) {
|
|
1007
1021
|
throw new UserError(
|
|
1008
|
-
`${where}.
|
|
1022
|
+
`${where}.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
|
|
1009
1023
|
`name the directory the task writes, such as "dist/**"`,
|
|
1010
1024
|
)
|
|
1011
1025
|
}
|
|
@@ -1014,31 +1028,31 @@ export function validateProjectConfig(
|
|
|
1014
1028
|
// a "corrupt artifact", dropped and run again.
|
|
1015
1029
|
if (!g.startsWith('!') && normalizeGlob(g).split('/')[0] === 'workspace-outputs') {
|
|
1016
1030
|
throw new UserError(
|
|
1017
|
-
`${where}.
|
|
1031
|
+
`${where}.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
|
|
1018
1032
|
`artifacts reserve for outputs.workspaceFiles — write the task's files to another directory`,
|
|
1019
1033
|
)
|
|
1020
1034
|
}
|
|
1021
1035
|
// A `!` entry takes a path back from the outputs (A-44): it is not
|
|
1022
1036
|
// cleaned, saved or restored, and it stays an input.
|
|
1023
|
-
assertNotDoubleNegated(g, `${where}.
|
|
1037
|
+
assertNotDoubleNegated(g, `${where}.outputs.files`)
|
|
1024
1038
|
// Taken back by a `!`, the file is never cleaned or restored.
|
|
1025
1039
|
const own = g.startsWith('!')
|
|
1026
1040
|
? undefined
|
|
1027
1041
|
: ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
|
|
1028
1042
|
if (own !== undefined) {
|
|
1029
1043
|
throw new UserError(
|
|
1030
|
-
`${where}.
|
|
1044
|
+
`${where}.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
|
|
1031
1045
|
`task's outputs before it runs and restores them on a hit, so the project would lose ` +
|
|
1032
1046
|
`its config. Name the directory the task writes, such as "dist/**", ` +
|
|
1033
1047
|
`or take the file back with "!${own}".`,
|
|
1034
1048
|
)
|
|
1035
1049
|
}
|
|
1036
1050
|
}
|
|
1037
|
-
assertOutputsNotNegationOnly(outFiles as string[], `${where}.
|
|
1038
|
-
assertNoLiteralTakenBack(outFiles as string[], `${where}.
|
|
1051
|
+
assertOutputsNotNegationOnly(outFiles as string[], `${where}.outputs.files`)
|
|
1052
|
+
assertNoLiteralTakenBack(outFiles as string[], `${where}.outputs.files`, OUTPUT_LOST)
|
|
1039
1053
|
const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
|
|
1040
1054
|
if (wsOutputs !== undefined) {
|
|
1041
|
-
validateWorkspaceGlobs(wsOutputs, `${where}.
|
|
1055
|
+
validateWorkspaceGlobs(wsOutputs, `${where}.outputs.workspaceFiles`, false)
|
|
1042
1056
|
}
|
|
1043
1057
|
}
|
|
1044
1058
|
}
|
|
@@ -1053,7 +1067,7 @@ export function validateProjectConfig(
|
|
|
1053
1067
|
// `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
|
|
1054
1068
|
// from both.
|
|
1055
1069
|
const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
|
|
1056
|
-
const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'cache'])
|
|
1070
|
+
const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'outputs', 'cache'])
|
|
1057
1071
|
const EXEC_FIELDS = new Set([
|
|
1058
1072
|
'command',
|
|
1059
1073
|
'env',
|
|
@@ -1067,7 +1081,6 @@ const EXEC_FIELDS = new Set([
|
|
|
1067
1081
|
])
|
|
1068
1082
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
1069
1083
|
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
1070
|
-
const CACHE_FIELDS = new Set(['outputs', 'inputStability'])
|
|
1071
1084
|
const INPUT_FIELDS = new Set([
|
|
1072
1085
|
'files',
|
|
1073
1086
|
'workspaceFiles',
|
|
@@ -1075,8 +1088,9 @@ const INPUT_FIELDS = new Set([
|
|
|
1075
1088
|
'tasks',
|
|
1076
1089
|
'runtime',
|
|
1077
1090
|
'workspaceRuntime',
|
|
1091
|
+
'stability',
|
|
1078
1092
|
])
|
|
1079
|
-
const
|
|
1093
|
+
const OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
|
|
1080
1094
|
|
|
1081
1095
|
/**
|
|
1082
1096
|
* Another runner's spelling of a field vx has under another name, by the
|
|
@@ -1088,8 +1102,10 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1088
1102
|
[
|
|
1089
1103
|
TASK_FIELDS,
|
|
1090
1104
|
{
|
|
1091
|
-
outputs: '`cache.outputs.files`',
|
|
1092
1105
|
env: '`inputs.env` (to key the task on a variable) or `exec.env` (to pass one)',
|
|
1106
|
+
// wireit's script keys.
|
|
1107
|
+
output: '`outputs.files`',
|
|
1108
|
+
dependencies: "`dependsOn` (an upstream's key goes into this one)",
|
|
1093
1109
|
passThroughEnv: '`exec.env.passThrough`',
|
|
1094
1110
|
command: '`exec.command`',
|
|
1095
1111
|
cmd: '`exec.command`',
|
|
@@ -1126,18 +1142,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1126
1142
|
shell: 'no field: `command` always runs in a shell',
|
|
1127
1143
|
},
|
|
1128
1144
|
],
|
|
1129
|
-
// wireit's `files` / `output` and include-style spellings written into
|
|
1130
|
-
// a cache block, refused with no word on where they go (D-118).
|
|
1131
|
-
[
|
|
1132
|
-
CACHE_FIELDS,
|
|
1133
|
-
{
|
|
1134
|
-
files: "the task's `inputs.files`",
|
|
1135
|
-
output: '`outputs.files`',
|
|
1136
|
-
env: "the task's `inputs.env`",
|
|
1137
|
-
dependencies: "the task's `dependsOn` (an upstream's key goes into this one)",
|
|
1138
|
-
enabled: 'no field: a task with a `cache` block caches, one without runs every time',
|
|
1139
|
-
},
|
|
1140
|
-
],
|
|
1141
1145
|
[
|
|
1142
1146
|
INPUT_FIELDS,
|
|
1143
1147
|
{
|
|
@@ -1149,7 +1153,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1149
1153
|
},
|
|
1150
1154
|
],
|
|
1151
1155
|
[
|
|
1152
|
-
|
|
1156
|
+
OUTPUT_FIELDS,
|
|
1153
1157
|
{
|
|
1154
1158
|
globs: '`files`',
|
|
1155
1159
|
include: '`files`',
|
|
@@ -1174,12 +1178,11 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1174
1178
|
sourceRoot: "a task's `inputs.files`",
|
|
1175
1179
|
projectType: 'no field: vx runs every project alike',
|
|
1176
1180
|
...Object.fromEntries(
|
|
1177
|
-
['dependsOn', 'inputs', 'cache', 'exec', 'description'].map((k) => [
|
|
1181
|
+
['dependsOn', 'inputs', 'outputs', 'cache', 'exec', 'description'].map((k) => [
|
|
1178
1182
|
k,
|
|
1179
1183
|
`\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
|
|
1180
1184
|
]),
|
|
1181
1185
|
),
|
|
1182
|
-
outputs: '`tasks.<name>.cache.outputs.files`',
|
|
1183
1186
|
env: '`tasks.<name>.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
|
|
1184
1187
|
},
|
|
1185
1188
|
],
|
|
@@ -1250,7 +1253,7 @@ function nestedHome(allowed: ReadonlySet<string>, key: string): string | undefin
|
|
|
1250
1253
|
for (const [field, own] of [
|
|
1251
1254
|
['exec', EXEC_FIELDS],
|
|
1252
1255
|
['inputs', INPUT_FIELDS],
|
|
1253
|
-
['
|
|
1256
|
+
['outputs', OUTPUT_FIELDS],
|
|
1254
1257
|
] as const) {
|
|
1255
1258
|
if (own.has(key)) return `\`${field}.${key}\``
|
|
1256
1259
|
}
|
|
@@ -1536,7 +1539,7 @@ function assertInputGlob(glob: string, where: string): void {
|
|
|
1536
1539
|
if (glob.replace(/^!/, '').split('/').includes('.git')) {
|
|
1537
1540
|
throw new UserError(
|
|
1538
1541
|
`${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
|
|
1539
|
-
`inputs are the
|
|
1542
|
+
`inputs are the workspace's files`,
|
|
1540
1543
|
)
|
|
1541
1544
|
}
|
|
1542
1545
|
}
|
|
@@ -1697,6 +1700,9 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1697
1700
|
function tagProblem(tag: string): string | null {
|
|
1698
1701
|
if (tag.trim() !== tag) return 'has surrounding whitespace'
|
|
1699
1702
|
if (tag.includes('*')) return "holds '*', which makes it a pattern"
|
|
1703
|
+
// Refused by the caller in its own words; the filter parser refuses one
|
|
1704
|
+
// too, which the catch below would read as a walk.
|
|
1705
|
+
if (hasControl(tag)) return null
|
|
1700
1706
|
const walk = "ends in '...', which a filter reads as a dependency walk"
|
|
1701
1707
|
let f: ReturnType<typeof parseFilter>
|
|
1702
1708
|
try {
|
|
@@ -1826,6 +1832,7 @@ const SANDBOX_FIELDS = new Set([
|
|
|
1826
1832
|
'ignore',
|
|
1827
1833
|
'weakerWhenNested',
|
|
1828
1834
|
'weakerNetworkIsolation',
|
|
1835
|
+
'hermetic',
|
|
1829
1836
|
])
|
|
1830
1837
|
const GRANT_PATH_FIELDS = ['read', 'write'] as const
|
|
1831
1838
|
const GRANT_NAME_FIELDS = ['systemInfo', 'machLookup'] as const
|
|
@@ -1869,7 +1876,7 @@ function validateSandbox(sandbox: unknown, where: string): void {
|
|
|
1869
1876
|
assertKnownFields(sandbox, SANDBOX_FIELDS, `${where}.sandbox`)
|
|
1870
1877
|
const obj = sandbox as Record<string, unknown>
|
|
1871
1878
|
|
|
1872
|
-
for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation']) {
|
|
1879
|
+
for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation', 'hermetic']) {
|
|
1873
1880
|
if (obj[flag] !== undefined && typeof obj[flag] !== 'boolean') {
|
|
1874
1881
|
throw new UserError(`${where}.sandbox.${flag} must be a boolean`)
|
|
1875
1882
|
}
|