@vzn/vx 0.0.649 → 0.0.651
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 +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +225 -51
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/fold-memo.ts +8 -1
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +12 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +120 -0
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-hits.ts +9 -1
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/run-notes.ts +123 -0
- package/src/cache/schema.ts +54 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +224 -223
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +10 -3
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- 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 +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +126 -92
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- 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-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +12 -2
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +39 -2
- package/src/history/index.ts +3 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +12 -3
- package/src/orchestrator/doctor.ts +3 -3
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +3 -0
- package/src/orchestrator/index.ts +3 -1
- package/src/orchestrator/live-usage.ts +98 -0
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +176 -102
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +49 -4
- package/src/orchestrator/prepare.ts +18 -2
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +35 -114
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +89 -26
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/forecast.ts +12 -33
- package/src/report/framed-output.ts +388 -161
- package/src/report/index.ts +8 -2
- package/src/report/logger.ts +100 -36
- package/src/report/status-line.ts +81 -365
- package/src/report/summary.ts +104 -131
- 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 +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/hidden-prompt.ts +8 -2
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/secrets.ts +3 -0
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/orchestrator/run-steps.ts +0 -93
- package/src/util/colors.ts +0 -130
- 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
package/src/cli/lock.ts
CHANGED
|
@@ -8,10 +8,16 @@ import path from 'node:path'
|
|
|
8
8
|
import type { ProjectConfig } from '../config.js'
|
|
9
9
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
10
10
|
import { buildTaskGraph, expandRequested, isDefaultBuild } from '../graph/index.js'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
applyGraphStage,
|
|
13
|
+
claimedDigests,
|
|
14
|
+
discoverProjects,
|
|
15
|
+
fingerprintClaims,
|
|
16
|
+
hasHook,
|
|
17
|
+
loadProjects,
|
|
18
|
+
} from '../orchestrator/index.js'
|
|
12
19
|
import { resolveSetting, runRules } from '../settings.js'
|
|
13
20
|
import {
|
|
14
|
-
refusalNotice,
|
|
15
21
|
DOT_VX_GITATTRIBUTES,
|
|
16
22
|
fsRefusalHint,
|
|
17
23
|
isFsRefusal,
|
|
@@ -23,11 +29,11 @@ import {
|
|
|
23
29
|
type UserError,
|
|
24
30
|
secretMask,
|
|
25
31
|
type SecretMask,
|
|
26
|
-
ui,
|
|
27
|
-
sayNotice,
|
|
28
32
|
refuseSymlink,
|
|
29
33
|
writeNoFollow,
|
|
30
34
|
} from '../util/index.js'
|
|
35
|
+
import * as ui from '../ui/index.js'
|
|
36
|
+
import { refusalNotice, sayNotice } from '../ui/index.js'
|
|
31
37
|
import {
|
|
32
38
|
ConfigsRefused,
|
|
33
39
|
findProjectConfigs,
|
|
@@ -43,6 +49,11 @@ import {
|
|
|
43
49
|
checkDependencyRules,
|
|
44
50
|
computeNestedProjectDirs,
|
|
45
51
|
writeLockfile,
|
|
52
|
+
DEPENDENCIES_LOCK_NAME,
|
|
53
|
+
dependenciesLock,
|
|
54
|
+
parseDependenciesLock,
|
|
55
|
+
writeDependenciesLock,
|
|
56
|
+
type DependenciesLock,
|
|
46
57
|
type Lockfile,
|
|
47
58
|
type ProjectMeta,
|
|
48
59
|
} from '../workspace/index.js'
|
|
@@ -140,6 +151,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
140
151
|
reviewFiles: true,
|
|
141
152
|
strayReviews: [],
|
|
142
153
|
shadowed: [],
|
|
154
|
+
dependencies: null,
|
|
143
155
|
}
|
|
144
156
|
try {
|
|
145
157
|
const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
|
|
@@ -178,6 +190,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
178
190
|
})
|
|
179
191
|
for (const entry of projects.values()) resolved.set(entry.name, entry.config)
|
|
180
192
|
out.loaded = true
|
|
193
|
+
out.dependencies = await dependenciesOf(root, out.all, plugins, cacheDir, warn)
|
|
181
194
|
checkBracketLiterals(root, projects.values())
|
|
182
195
|
const names = new Set<string>()
|
|
183
196
|
for (const entry of projects.values()) {
|
|
@@ -252,6 +265,30 @@ interface GraphCheck {
|
|
|
252
265
|
strayReviews: string[]
|
|
253
266
|
/** Config files a higher-precedence one beside them hides (`findProjectConfigs`). */
|
|
254
267
|
shadowed: { file: string; read: string }[]
|
|
268
|
+
/** What the dependencies resolve to here; null when the configs did not load. */
|
|
269
|
+
dependencies: DependenciesLock | null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Each project's edges and each claimed file's digests, as `--affected` reads them back. */
|
|
273
|
+
async function dependenciesOf(
|
|
274
|
+
root: string,
|
|
275
|
+
all: readonly ProjectMeta[],
|
|
276
|
+
plugins: Parameters<typeof fingerprintClaims>[0],
|
|
277
|
+
cacheDir: string,
|
|
278
|
+
warn: (message: string) => void,
|
|
279
|
+
): Promise<DependenciesLock> {
|
|
280
|
+
const ctx = {
|
|
281
|
+
workspaceRoot: root,
|
|
282
|
+
cacheDir,
|
|
283
|
+
warn,
|
|
284
|
+
projects: all.map((p) => ({ name: p.name, dir: p.dir })),
|
|
285
|
+
}
|
|
286
|
+
const files = new Map<string, NonNullable<Awaited<ReturnType<typeof claimedDigests>>>>()
|
|
287
|
+
for (const [file, plugin] of fingerprintClaims(plugins)) {
|
|
288
|
+
const answer = await claimedDigests(plugin, file, ctx)
|
|
289
|
+
if (answer !== undefined) files.set(file, answer)
|
|
290
|
+
}
|
|
291
|
+
return dependenciesLock(root, all, files)
|
|
255
292
|
}
|
|
256
293
|
|
|
257
294
|
function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
|
|
@@ -415,6 +452,7 @@ async function writeLock(
|
|
|
415
452
|
return 1
|
|
416
453
|
}
|
|
417
454
|
await writeLockfile(root, check.fresh)
|
|
455
|
+
if (check.dependencies !== null) await writeDependenciesLock(root, check.dependencies)
|
|
418
456
|
await writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES)
|
|
419
457
|
const n = metas.length
|
|
420
458
|
say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
|
|
@@ -517,7 +555,7 @@ async function checkLock(root: string, json: boolean): Promise<number> {
|
|
|
517
555
|
notAudited: bare.length,
|
|
518
556
|
problems,
|
|
519
557
|
}
|
|
520
|
-
|
|
558
|
+
ui.data('out', `${JSON.stringify(out)}\n`)
|
|
521
559
|
}
|
|
522
560
|
return problems.length === 0 ? 0 : 1
|
|
523
561
|
}
|
|
@@ -600,6 +638,18 @@ async function checkLock(root: string, json: boolean): Promise<number> {
|
|
|
600
638
|
failures.push(`the lock holds ${what}, which has no vx.config now — run 'vx lock'`)
|
|
601
639
|
}
|
|
602
640
|
}
|
|
641
|
+
// Named only over a lock that holds: a config drift moves the record too.
|
|
642
|
+
if (check.dependencies !== null && failures.length === 0) {
|
|
643
|
+
const text = await Bun.file(path.join(root, DEPENDENCIES_LOCK_NAME))
|
|
644
|
+
.bytes()
|
|
645
|
+
.catch(() => null)
|
|
646
|
+
// Compared as JSON: a formatter may lay the file out its own way.
|
|
647
|
+
const read = parseDependenciesLock(text)
|
|
648
|
+
if (read === null || !Bun.deepEquals(read, check.dependencies, true))
|
|
649
|
+
failures.push(
|
|
650
|
+
`${DEPENDENCIES_LOCK_NAME} ${text === null ? 'is missing' : 'is not what the dependencies resolve to here'} — run 'vx lock'`,
|
|
651
|
+
)
|
|
652
|
+
}
|
|
603
653
|
if (json)
|
|
604
654
|
// Root-relative, as every other problem: the loader names the absolute path.
|
|
605
655
|
return answer([
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,45 +1,37 @@
|
|
|
1
1
|
// Formatters for `--dry` (human / JSON).
|
|
2
2
|
|
|
3
3
|
import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
paint,
|
|
10
|
-
SUCCESS,
|
|
11
|
-
ui,
|
|
12
|
-
WARN,
|
|
13
|
-
type ColorSupport,
|
|
14
|
-
} from '../util/index.js'
|
|
4
|
+
import { computeReverseDepCount } from '../graph/index.js'
|
|
5
|
+
import { planList, type PlannedRow, skippedReason } from '../report/index.js'
|
|
6
|
+
import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
|
|
7
|
+
import * as ui from '../ui/index.js'
|
|
8
|
+
import type { ColorSupport } from '../ui/index.js'
|
|
15
9
|
|
|
16
10
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* `vx run --dry`'s screen: the `Plan` panel, each task on the running
|
|
12
|
+
* list's grid (what the run would do with it, its usual time, where it
|
|
13
|
+
* would run), then the counts and the predicted time.
|
|
20
14
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
15
|
+
* ─ Plan 3 ─────────────────────────────────────────────────────
|
|
16
|
+
* time usual status cache cpu mem outputs blocks worker task
|
|
17
|
+
* ⇢ cached local local @vzn/vx#lint
|
|
18
|
+
* ⇣ cached remote 12MB local @vzn/vx#test
|
|
19
|
+
* ⏺︎ ~1.2s run miss local @vzn/vx#build
|
|
25
20
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
21
|
+
* tasks 3 planned · 2 cache hits (1 local, 1 remote, 12MB) · 1 would run
|
|
22
|
+
* predicted ~1.20s wall · ~1.20s total execution
|
|
28
23
|
*/
|
|
29
|
-
export function
|
|
24
|
+
export function planReport(plan: RunPlan): ui.Block {
|
|
30
25
|
// A group the platform skip reaches is still a group: it runs nothing.
|
|
31
26
|
const real = plan.tasks.filter(
|
|
32
27
|
(t) =>
|
|
33
28
|
t.cacheStatus !== 'group' &&
|
|
34
29
|
(t.cacheStatus !== 'skipped' || t.node.config.exec !== undefined),
|
|
35
30
|
)
|
|
36
|
-
|
|
37
|
-
if (real.length === 0) return `${dim('No tasks planned.')}\n`
|
|
38
|
-
|
|
39
|
-
const idWidth = Math.max(...real.map((t) => t.node.id.length))
|
|
40
|
-
const tagWidth = 24
|
|
31
|
+
if (real.length === 0) return ui.section('Plan', ui.text('No tasks planned.'))
|
|
41
32
|
|
|
42
|
-
const
|
|
33
|
+
const blocks = downstream(plan)
|
|
34
|
+
const rows: PlannedRow[] = []
|
|
43
35
|
let local = 0
|
|
44
36
|
let remote = 0
|
|
45
37
|
let miss = 0
|
|
@@ -50,33 +42,28 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
|
|
|
50
42
|
for (const t of real) {
|
|
51
43
|
// `@noop`: the run succeeds it without running, whatever its key says.
|
|
52
44
|
const skipped = t.executor === 'noop' && t.cacheStatus !== 'skipped'
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
: describe(t.cacheStatus)
|
|
59
|
-
const shortHash = t.hash ? t.hash.slice(0, 8) : ''
|
|
45
|
+
const notes: string[] = []
|
|
46
|
+
if (t.cacheStatus === 'skipped') notes.push(skippedReason(t))
|
|
47
|
+
const desc = shownDescription(t)
|
|
48
|
+
if (desc !== undefined && desc !== '') notes.push(desc)
|
|
49
|
+
if (t.affected !== undefined) notes.push(`affected: ${describeReason(t.affected)}`)
|
|
60
50
|
const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
if (t.affected !== undefined) {
|
|
78
|
-
lines.push(` ${' '.repeat(idWidth)} ${dim(`affected: ${describeReason(t.affected)}`)}`)
|
|
79
|
-
}
|
|
51
|
+
rows.push({
|
|
52
|
+
node: t.node,
|
|
53
|
+
...(skipped ? NOOP : LOOK[t.cacheStatus]),
|
|
54
|
+
...(executes && t.p50Ms !== undefined ? { usualMs: t.p50Ms } : {}),
|
|
55
|
+
...(t.cacheStatus === 'hit-remote' && t.remoteBytes !== undefined
|
|
56
|
+
? { outputBytes: t.remoteBytes }
|
|
57
|
+
: {}),
|
|
58
|
+
blocks: blocks.get(t.node.id) ?? 0,
|
|
59
|
+
// Placement by executor name; a task that will not run reaches no
|
|
60
|
+
// machine, as a skip's row in the run has none.
|
|
61
|
+
worker:
|
|
62
|
+
skipped || t.cacheStatus === 'skipped' || t.cacheStatus === 'not-started'
|
|
63
|
+
? ''
|
|
64
|
+
: (t.executor ?? 'local'),
|
|
65
|
+
note: notes.join(' · '),
|
|
66
|
+
})
|
|
80
67
|
if (skipped) noop++
|
|
81
68
|
else if (t.cacheStatus === 'hit-local') local++
|
|
82
69
|
else if (t.cacheStatus === 'hit-remote') remote++
|
|
@@ -94,15 +81,14 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
|
|
|
94
81
|
hitParts.push(bytes > 0 ? `${remote} remote, ${formatBytes(bytes)}` : `${remote} remote`)
|
|
95
82
|
}
|
|
96
83
|
const hits = local + remote
|
|
97
|
-
const summary: string[] = [`${real.length}
|
|
84
|
+
const summary: string[] = [`${real.length} planned`]
|
|
98
85
|
if (hits > 0) summary.push(`${hits} cache hit${hits === 1 ? '' : 's'} (${hitParts.join(', ')})`)
|
|
99
86
|
if (miss > 0) summary.push(`${miss} would run`)
|
|
100
87
|
if (nocache > 0) summary.push(`${nocache} no-cache`)
|
|
101
88
|
if (noop > 0) summary.push(`${noop} noop`)
|
|
102
89
|
if (idle > 0) summary.push(`${idle} not started`)
|
|
103
90
|
if (platform > 0) summary.push(`${platform} skipped`)
|
|
104
|
-
|
|
105
|
-
lines.push(dim(summary.join(', ') + '.'))
|
|
91
|
+
const facts: ui.FieldRow[] = [['tasks', summary.join(' · ')]]
|
|
106
92
|
|
|
107
93
|
// `--cache-download` is invisible without this: the eligibility gate silently
|
|
108
94
|
// keeps producers eager, so a user who asked for `none` and got no
|
|
@@ -111,16 +97,13 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
|
|
|
111
97
|
const deferred = real.filter((t) => t.download === 'deferred')
|
|
112
98
|
const downgrades = plan.downloadDowngrades ?? []
|
|
113
99
|
if (deferred.length > 0 || downgrades.length > 0) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
),
|
|
120
|
-
)
|
|
121
|
-
for (const d of downgrades.slice(0, 3))
|
|
122
|
-
lines.push(` ${ui.inline(ui.id(d.taskId), c)} ${dim(`— ${d.reason}`)}`)
|
|
123
|
-
if (downgrades.length > 3) lines.push(dim(` …and ${downgrades.length - 3} more`))
|
|
100
|
+
facts.push([
|
|
101
|
+
'download',
|
|
102
|
+
`${deferred.length} task(s) would keep outputs remote` +
|
|
103
|
+
(downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
|
|
104
|
+
...downgrades.slice(0, 3).map((d) => [ui.id(d.taskId), ui.dim(` — ${d.reason}`)]),
|
|
105
|
+
...(downgrades.length > 3 ? [`…and ${downgrades.length - 3} more`] : []),
|
|
106
|
+
])
|
|
124
107
|
}
|
|
125
108
|
|
|
126
109
|
// Time prediction — only when history gave us something to say: at least
|
|
@@ -128,15 +111,63 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
|
|
|
128
111
|
const p = plan.predicted
|
|
129
112
|
const executes = miss + nocache
|
|
130
113
|
if (p !== undefined && executes > 0 && executes > p.unknownCount) {
|
|
131
|
-
const parts = [
|
|
114
|
+
const parts = [`~${formatElapsed(p.wallMs)} wall`]
|
|
132
115
|
parts.push(`~${formatElapsed(p.workMs)} total execution`)
|
|
133
116
|
if (p.unknownCount > 0) {
|
|
134
117
|
parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
|
|
135
118
|
}
|
|
136
|
-
|
|
119
|
+
facts.push(['predicted', parts.join(' · ')])
|
|
137
120
|
}
|
|
138
|
-
return
|
|
121
|
+
return ui.section('Plan', String(real.length), planList(rows), ui.fields(facts))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How many tasks are downstream of each, all of them (groups too), by the
|
|
126
|
+
* run's own count: a closure walked per task was quadratic, and a 10,000
|
|
127
|
+
* task chain's `--dry` spent 7 s here.
|
|
128
|
+
*/
|
|
129
|
+
function downstream(plan: RunPlan): Map<string, number> {
|
|
130
|
+
return computeReverseDepCount(
|
|
131
|
+
new Map(plan.tasks.map((t) => [t.node.id, { ...t.node, deps: [...t.deps] }])),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The plan as plain text: what a pipe, a test or `grep` reads. */
|
|
136
|
+
export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false }): string {
|
|
137
|
+
return ui.render([planReport(plan)], c)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type Look = Pick<PlannedRow, 'glyph' | 'status' | 'tone' | 'cache' | 'cacheTone'>
|
|
141
|
+
|
|
142
|
+
/** Each outcome on the run's grid: its glyph and words, as the run would print them. */
|
|
143
|
+
const LOOK: Record<CacheStatus, Look> = {
|
|
144
|
+
'hit-local': {
|
|
145
|
+
glyph: '\u21e2',
|
|
146
|
+
status: 'cached',
|
|
147
|
+
tone: 'ok',
|
|
148
|
+
cache: 'local',
|
|
149
|
+
cacheTone: 'local',
|
|
150
|
+
},
|
|
151
|
+
'hit-remote': {
|
|
152
|
+
glyph: '\u21e3',
|
|
153
|
+
status: 'cached',
|
|
154
|
+
tone: 'ok',
|
|
155
|
+
cache: 'remote',
|
|
156
|
+
cacheTone: 'remote',
|
|
157
|
+
},
|
|
158
|
+
miss: { glyph: '\u23fa\ufe0e', status: 'run', tone: 'info', cache: 'miss', cacheTone: 'muted' },
|
|
159
|
+
'no-cache': {
|
|
160
|
+
glyph: '\u23fa\ufe0e',
|
|
161
|
+
status: 'run',
|
|
162
|
+
tone: 'info',
|
|
163
|
+
cache: 'no-cache',
|
|
164
|
+
cacheTone: 'muted',
|
|
165
|
+
},
|
|
166
|
+
group: { glyph: '\u25cb', status: 'group', tone: 'muted', cache: '', cacheTone: '' },
|
|
167
|
+
'not-started': { glyph: '\u25cc', status: 'not run', tone: 'muted', cache: '', cacheTone: '' },
|
|
168
|
+
skipped: { glyph: '\u2298', status: 'skipped', tone: 'warn', cache: '', cacheTone: '' },
|
|
139
169
|
}
|
|
170
|
+
const NOOP: Look = { glyph: '\u00b7', status: 'noop', tone: 'muted', cache: '', cacheTone: '' }
|
|
140
171
|
|
|
141
172
|
/** One `--affected --dry` reason line: the cause, then the edge chain that carried it. */
|
|
142
173
|
function describeReason(r: AffectedReason): string {
|
|
@@ -219,52 +250,3 @@ export function formatPlanJson(
|
|
|
219
250
|
}) + '\n'
|
|
220
251
|
)
|
|
221
252
|
}
|
|
222
|
-
|
|
223
|
-
/** A hit is green, a task that would run is yellow, the rest dim. */
|
|
224
|
-
const HUE: Record<CacheStatus, string> = {
|
|
225
|
-
'hit-local': SUCCESS,
|
|
226
|
-
'hit-remote': SUCCESS,
|
|
227
|
-
miss: WARN,
|
|
228
|
-
'no-cache': WARN,
|
|
229
|
-
group: '',
|
|
230
|
-
'not-started': '',
|
|
231
|
-
skipped: '',
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function symbolFor(s: CacheStatus): string {
|
|
235
|
-
switch (s) {
|
|
236
|
-
case 'hit-local':
|
|
237
|
-
return '✓'
|
|
238
|
-
case 'hit-remote':
|
|
239
|
-
return '↓'
|
|
240
|
-
case 'miss':
|
|
241
|
-
return '▶'
|
|
242
|
-
case 'no-cache':
|
|
243
|
-
return '∅'
|
|
244
|
-
case 'group':
|
|
245
|
-
return '○'
|
|
246
|
-
case 'not-started':
|
|
247
|
-
return '−'
|
|
248
|
-
case 'skipped':
|
|
249
|
-
return '·'
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function describe(s: CacheStatus): string {
|
|
254
|
-
switch (s) {
|
|
255
|
-
case 'hit-local':
|
|
256
|
-
return 'cache hit (local)'
|
|
257
|
-
case 'hit-remote':
|
|
258
|
-
return 'cache hit (remote)'
|
|
259
|
-
case 'miss':
|
|
260
|
-
return 'cache miss — would exec'
|
|
261
|
-
case 'no-cache':
|
|
262
|
-
return 'no-cache (would exec)'
|
|
263
|
-
case 'group':
|
|
264
|
-
return 'group task'
|
|
265
|
-
case 'not-started':
|
|
266
|
-
return 'not started (unneeded)'
|
|
267
|
-
case 'skipped':
|
|
268
|
-
return 'skipped'
|
|
269
|
-
}
|
|
270
|
-
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// reports an unknown command as it always did. Core's verbs are matched
|
|
8
8
|
// before this is asked, so a plugin cannot shadow `run`.
|
|
9
9
|
|
|
10
|
+
import { indexDb } from '../cache/index.js'
|
|
10
11
|
import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
|
|
11
12
|
import type { WorkspaceConfig } from '../config.js'
|
|
12
13
|
import type {
|
|
@@ -23,9 +24,9 @@ import {
|
|
|
23
24
|
maskedLine,
|
|
24
25
|
SecretBroker,
|
|
25
26
|
thrownMessage,
|
|
26
|
-
ui,
|
|
27
27
|
UserError,
|
|
28
28
|
} from '../util/index.js'
|
|
29
|
+
import * as ui from '../ui/index.js'
|
|
29
30
|
import { resolveSetting } from '../settings.js'
|
|
30
31
|
|
|
31
32
|
export interface ResolvedPluginCommand {
|
|
@@ -184,7 +185,7 @@ async function verbCache(
|
|
|
184
185
|
remotes,
|
|
185
186
|
})
|
|
186
187
|
await openVerbRemotes(remotes, openRemotes)
|
|
187
|
-
return pluginCache(plugin, { db: localCache
|
|
188
|
+
return pluginCache(plugin, { db: indexDb(localCache), policy: VERB_POLICY, remotes, scope })
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
/**
|
|
@@ -283,7 +284,12 @@ async function openVerbRemotes(
|
|
|
283
284
|
await open(remotes, ci, new SecretBroker(ci, warn))
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
|
|
287
|
+
/**
|
|
288
|
+
* `Required`, so a member `TaskArtifacts` gains cannot be left out here:
|
|
289
|
+
* this wrapper dropped `uploaded`, and `vx dte worker` took every upload a
|
|
290
|
+
* remote reset as landed (CI, 2026-10-29).
|
|
291
|
+
*/
|
|
292
|
+
function lazyArtifacts(open: () => Promise<TaskArtifacts>): Required<TaskArtifacts> {
|
|
287
293
|
let opened: Promise<TaskArtifacts> | undefined
|
|
288
294
|
const artifacts = (): Promise<TaskArtifacts> => (opened ??= open())
|
|
289
295
|
return {
|
|
@@ -292,6 +298,7 @@ function lazyArtifacts(open: () => Promise<TaskArtifacts>): TaskArtifacts {
|
|
|
292
298
|
output: async (key) => (await artifacts()).output(key),
|
|
293
299
|
share: async (key) => (await artifacts()).share(key),
|
|
294
300
|
resend: async (key) => (await artifacts()).resend(key),
|
|
301
|
+
uploaded: async (key) => (await artifacts()).uploaded?.(key),
|
|
295
302
|
}
|
|
296
303
|
}
|
|
297
304
|
|
|
@@ -15,8 +15,8 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
15
15
|
},
|
|
16
16
|
commands: {
|
|
17
17
|
plugin:
|
|
18
|
-
"// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n
|
|
19
|
-
test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n artifacts: {\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n },\n artifactsFor: () => ({\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n }),\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(
|
|
18
|
+
"// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, ui, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n ui.say('error', `vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n ui.data('out', `${ctx.cacheDir}\\n`)\n return 0\n },\n },\n },\n })\n}\n",
|
|
19
|
+
test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n artifacts: {\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n },\n artifactsFor: () => ({\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n }),\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(process.stdout, 'write').mockImplementation(() => true)\n const err = spyOn(process.stderr, 'write').mockImplementation(() => true)\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache\\n']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n // On CI the line is coloured: the words are what this asserts.\n expect(err.mock.calls.map(([l]) => Bun.stripANSI(String(l)))).toEqual([\n 'vx cache-dir: takes no arguments, got x\\n',\n ])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
|
|
20
20
|
},
|
|
21
21
|
executor: {
|
|
22
22
|
plugin:
|
package/src/cli/run.ts
CHANGED
|
@@ -19,17 +19,12 @@ import {
|
|
|
19
19
|
parseCachePolicy,
|
|
20
20
|
} from '../cache/index.js'
|
|
21
21
|
import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
22
|
-
import { nxTargetHint, taskNamesHere } from './task-verb.js'
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
hasControl,
|
|
27
|
-
parseDecimalInt,
|
|
28
|
-
sayNotice,
|
|
29
|
-
sayLine,
|
|
30
|
-
} from '../util/index.js'
|
|
22
|
+
import { nxTargetHint, taskNamesHere, undeclaredTasks } from './task-verb.js'
|
|
23
|
+
import { envFlagOn, hasControl, parseDecimalInt } from '../util/index.js'
|
|
24
|
+
import * as ui from '../ui/index.js'
|
|
25
|
+
import { sayNotice, sayLine } from '../ui/index.js'
|
|
31
26
|
import { kindHint, parseSettingValue, type Setting, SETTINGS, settingRefusal } from '../settings.js'
|
|
32
|
-
import { formatPlanJson,
|
|
27
|
+
import { formatPlanJson, planReport } from './plan-format.js'
|
|
33
28
|
|
|
34
29
|
export interface RunArgs {
|
|
35
30
|
/** Failure propagation (`--continue[=never|deps-ok|always]`). */
|
|
@@ -80,6 +75,12 @@ export interface RunArgs {
|
|
|
80
75
|
* `defaultAffectedBase`). Any other string is an explicit git ref.
|
|
81
76
|
*/
|
|
82
77
|
affected: string | undefined
|
|
78
|
+
/**
|
|
79
|
+
* A bare `--affected` or `--profile` followed by a word: the word is read
|
|
80
|
+
* as a task, and when no project declares it the refusal names the `=`
|
|
81
|
+
* form the user likely meant.
|
|
82
|
+
*/
|
|
83
|
+
spaced?: { flag: '--affected' | '--profile'; value: string }
|
|
83
84
|
error?: string
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -154,6 +155,12 @@ export function parseRunArgs(args: readonly string[], verb: 'run' | 'watch' = 'r
|
|
|
154
155
|
let noCacheFlag = false
|
|
155
156
|
let forceFlag = false
|
|
156
157
|
|
|
158
|
+
// Its value optional, the flag cannot take the next word: `--affected
|
|
159
|
+
// main build` reads `main` as a task, so only a refusal of it says why.
|
|
160
|
+
const spacedValue = (flag: '--affected' | '--profile', next: string | undefined): void => {
|
|
161
|
+
if (next !== undefined && !next.startsWith('-')) out.spaced ??= { flag, value: next }
|
|
162
|
+
}
|
|
163
|
+
|
|
157
164
|
for (let i = 0; i < before.length; i++) {
|
|
158
165
|
const a = before[i]
|
|
159
166
|
if (a === '--filter' || a?.startsWith('--filter=')) {
|
|
@@ -294,6 +301,7 @@ export function parseRunArgs(args: readonly string[], verb: 'run' | 'watch' = 'r
|
|
|
294
301
|
out.dry = fmt
|
|
295
302
|
} else if (a === '--profile') {
|
|
296
303
|
out.profile = 'profile.json'
|
|
304
|
+
spacedValue('--profile', before[i + 1])
|
|
297
305
|
} else if (a?.startsWith('--profile=')) {
|
|
298
306
|
// An optional-value flag: an empty `=` means "no value given", so it
|
|
299
307
|
// takes the same documented default as the bare form. Resolving ''
|
|
@@ -302,6 +310,7 @@ export function parseRunArgs(args: readonly string[], verb: 'run' | 'watch' = 'r
|
|
|
302
310
|
out.profile = a.slice('--profile='.length) || 'profile.json'
|
|
303
311
|
} else if (a === '--affected') {
|
|
304
312
|
out.affected = ''
|
|
313
|
+
spacedValue('--affected', before[i + 1])
|
|
305
314
|
} else if (a?.startsWith('--affected=')) {
|
|
306
315
|
out.affected = a.slice('--affected='.length)
|
|
307
316
|
} else if (a === '--poll') {
|
|
@@ -505,6 +514,8 @@ export async function resolveRunOptions(
|
|
|
505
514
|
// `pkg#task`, which resolves to its own project regardless of
|
|
506
515
|
// scope. An empty project list keeps the bare tasks unselected
|
|
507
516
|
// while the anchored ones still run.
|
|
517
|
+
const typo = await undeclaredTasks(bareTasks, cwd)
|
|
518
|
+
if (typo !== undefined) return { error: typo, code: 'VX_E_UNKNOWN_TASK' }
|
|
508
519
|
if (anchoredTasks.length === 0) return { nothingSelected: resolved.empty }
|
|
509
520
|
sayNotice('info', 'vx run', `${resolved.empty} — running ${anchoredTasks.join(', ')} only`)
|
|
510
521
|
projects = []
|
|
@@ -587,6 +598,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
587
598
|
}
|
|
588
599
|
|
|
589
600
|
const cwd = process.cwd()
|
|
601
|
+
const unknownTask = async (message: string): Promise<number> =>
|
|
602
|
+
refuse('run', args, `${message}${await spacedHint(parsed, cwd)}`, 'VX_E_UNKNOWN_TASK')
|
|
590
603
|
if (parsed.profile !== undefined) {
|
|
591
604
|
const problem = profileProblem(cwd, parsed.profile)
|
|
592
605
|
if (problem !== undefined) return refuse('run', args, problem, 'VX_E_USAGE')
|
|
@@ -647,12 +660,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
647
660
|
|
|
648
661
|
const scopeOut: { affectedBase?: string } = {}
|
|
649
662
|
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
650
|
-
if ('error' in resolved)
|
|
663
|
+
if ('error' in resolved)
|
|
664
|
+
return resolved.code === 'VX_E_UNKNOWN_TASK'
|
|
665
|
+
? unknownTask(resolved.error)
|
|
666
|
+
: refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
|
|
651
667
|
// `--dry=json` prints a plan even when there is nothing to run: a script
|
|
652
668
|
// reading stdout broke exactly then (Growth, 2026-10-16).
|
|
653
669
|
const nothingToRun = (): 0 => {
|
|
654
|
-
if (parsed.dry === 'json')
|
|
655
|
-
process.stdout.write(formatPlanJson({ tasks: [] }, scopeOut.affectedBase))
|
|
670
|
+
if (parsed.dry === 'json') ui.data('out', formatPlanJson({ tasks: [] }, scopeOut.affectedBase))
|
|
656
671
|
return 0
|
|
657
672
|
}
|
|
658
673
|
if ('nothingSelected' in resolved) {
|
|
@@ -676,11 +691,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
676
691
|
const plan = await planRun(opts)
|
|
677
692
|
// Refused as a run refuses: `--dry=json` gets the error document.
|
|
678
693
|
if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
|
|
679
|
-
return
|
|
680
|
-
'run',
|
|
681
|
-
args,
|
|
694
|
+
return unknownTask(
|
|
682
695
|
`no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}`,
|
|
683
|
-
'VX_E_UNKNOWN_TASK',
|
|
684
696
|
)
|
|
685
697
|
}
|
|
686
698
|
if (plan.tasks.length === 0) {
|
|
@@ -694,17 +706,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
694
706
|
sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
|
|
695
707
|
return nothingToRun()
|
|
696
708
|
}
|
|
697
|
-
return
|
|
698
|
-
'run',
|
|
699
|
-
args,
|
|
700
|
-
`no projects declare task(s): ${tasks.join(', ')}`,
|
|
701
|
-
'VX_E_UNKNOWN_TASK',
|
|
702
|
-
)
|
|
709
|
+
return unknownTask(`no projects declare task(s): ${tasks.join(', ')}`)
|
|
703
710
|
}
|
|
704
711
|
if (parsed.dry === 'json') {
|
|
705
|
-
|
|
712
|
+
ui.data('out', formatPlanJson(plan, scopeOut.affectedBase))
|
|
706
713
|
} else {
|
|
707
|
-
|
|
714
|
+
ui.print('out', planReport(plan))
|
|
708
715
|
}
|
|
709
716
|
return 0
|
|
710
717
|
}
|
|
@@ -715,12 +722,24 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
715
722
|
// is no whole-run delegation seam to consult.
|
|
716
723
|
const summary = await runOrchestrator(opts)
|
|
717
724
|
if (summary.refused !== undefined) {
|
|
718
|
-
|
|
719
|
-
|
|
725
|
+
return summary.refused.startsWith('no projects declare')
|
|
726
|
+
? unknownTask(summary.refused)
|
|
727
|
+
: refuse('run', args, summary.refused, 'VX_E_REFUSED')
|
|
720
728
|
}
|
|
721
729
|
return summary.ok ? 0 : 1
|
|
722
730
|
}
|
|
723
731
|
|
|
732
|
+
/**
|
|
733
|
+
* The `=` form a refused task name was likely meant as: the word after a
|
|
734
|
+
* bare `--affected` or `--profile`, when no project declares it.
|
|
735
|
+
*/
|
|
736
|
+
export async function spacedHint(parsed: RunArgs, cwd: string): Promise<string> {
|
|
737
|
+
const s = parsed.spaced
|
|
738
|
+
if (s === undefined || (await undeclaredTasks([s.value], cwd)) === undefined) return ''
|
|
739
|
+
const what = s.flag === '--affected' ? 'base' : 'file'
|
|
740
|
+
return ` \`${s.flag}\` takes its ${what} with '=': ${s.flag}=${s.value}`
|
|
741
|
+
}
|
|
742
|
+
|
|
724
743
|
/**
|
|
725
744
|
* Why `--profile` cannot write `target`, or undefined. Asked before the
|
|
726
745
|
* run: a directory named as the file died with EISDIR only after it. A
|