@vzn/vx 0.0.18 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `vx init [--dry] [--force] [--mjs]` — a workspace from nowhere: one vx.config.ts
|
|
2
|
+
// per package from its package.json scripts, and the workspace file every
|
|
3
|
+
// run needs. Turbo and Nx are not read here; a runner's own config beside
|
|
4
|
+
// the scripts is the richer source, and `@vzn/vx-migrate` maps it.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { seeHelp } from './help.js'
|
|
8
|
+
import {
|
|
9
|
+
applyMigration,
|
|
10
|
+
findWorkspaceRoot,
|
|
11
|
+
type LoadReads,
|
|
12
|
+
listProjects,
|
|
13
|
+
loadWorkspace,
|
|
14
|
+
migrateScripts,
|
|
15
|
+
} from '../workspace/index.js'
|
|
16
|
+
|
|
17
|
+
export interface InitArgs {
|
|
18
|
+
dry: boolean
|
|
19
|
+
force: boolean
|
|
20
|
+
/** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
|
|
21
|
+
mjs: boolean
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
26
|
+
const out: InitArgs = { dry: false, force: false, mjs: false }
|
|
27
|
+
for (const a of args) {
|
|
28
|
+
if (a === '--dry') out.dry = true
|
|
29
|
+
else if (a === '--force') out.force = true
|
|
30
|
+
else if (a === '--mjs') out.mjs = true
|
|
31
|
+
else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('init')}` }
|
|
32
|
+
else return { ...out, error: `unexpected argument: ${a}` }
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function initCmd(args: readonly string[]): Promise<number> {
|
|
38
|
+
const parsed = parseInitArgs(args)
|
|
39
|
+
if (parsed.error) {
|
|
40
|
+
process.stderr.write(`vx init: ${parsed.error}\n`)
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
43
|
+
const reads: LoadReads = new Map()
|
|
44
|
+
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
45
|
+
const metas = await listProjects(await loadWorkspace(root, reads))
|
|
46
|
+
// `init` reads scripts only; a runner's own config beside them is the
|
|
47
|
+
// richer source (dependsOn, inputs, outputs) and was ignored without a
|
|
48
|
+
// word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
|
|
49
|
+
// TODOs and none of the edges turbo.json already declared.
|
|
50
|
+
const notes: string[] = []
|
|
51
|
+
// Turbo 2.5+ reads `turbo.jsonc` as well (item 938).
|
|
52
|
+
let turbo: string | undefined
|
|
53
|
+
for (const name of ['turbo.json', 'turbo.jsonc']) {
|
|
54
|
+
if (await Bun.file(path.join(root, name)).exists()) {
|
|
55
|
+
turbo = name
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (turbo !== undefined) {
|
|
60
|
+
notes.push(
|
|
61
|
+
`${turbo} found and not read — ` +
|
|
62
|
+
'`bunx @vzn/vx-migrate` maps it (dependsOn, inputs, ' +
|
|
63
|
+
'outputs), or `plugins: [turbo()]` from @vzn/vx-migrate runs it with nothing written',
|
|
64
|
+
)
|
|
65
|
+
} else if (
|
|
66
|
+
(await Bun.file(path.join(root, 'nx.json')).exists()) ||
|
|
67
|
+
(await Bun.file(path.join(root, '.nx', 'workspace-data', 'project-graph.json')).exists())
|
|
68
|
+
) {
|
|
69
|
+
notes.push(
|
|
70
|
+
'an Nx workspace found and not read — `bunx @vzn/vx-migrate --from nx` maps its ' +
|
|
71
|
+
'exported project graph, or `plugins: [nx()]` from @vzn/vx-migrate runs it with ' +
|
|
72
|
+
'nothing written',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return applyMigration({
|
|
76
|
+
root,
|
|
77
|
+
metas,
|
|
78
|
+
plan: migrateScripts(metas),
|
|
79
|
+
source: 'package.json scripts',
|
|
80
|
+
verb: 'vx init',
|
|
81
|
+
dry: parsed.dry,
|
|
82
|
+
force: parsed.force,
|
|
83
|
+
init: true,
|
|
84
|
+
notes,
|
|
85
|
+
format: parsed.mjs ? 'mjs' : 'ts',
|
|
86
|
+
})
|
|
87
|
+
}
|
package/src/cli/last.ts
CHANGED
|
@@ -4,15 +4,26 @@
|
|
|
4
4
|
// history in cache.db is the only replay surface, and this verb reads it.
|
|
5
5
|
// Read-only — no config evaluation, no re-hash, no cache probe.
|
|
6
6
|
|
|
7
|
-
import { Cache } from '../cache/index.js'
|
|
8
|
-
import {
|
|
7
|
+
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
8
|
+
import { formatBytes } from './format.js'
|
|
9
|
+
import { seeHelp } from './help.js'
|
|
10
|
+
import {
|
|
11
|
+
exitSignal,
|
|
12
|
+
getInvocation,
|
|
13
|
+
getRun,
|
|
14
|
+
listInvocations,
|
|
15
|
+
type RunSummaryRow,
|
|
16
|
+
} from '../orchestrator/index.js'
|
|
9
17
|
import { UserError } from '../util/index.js'
|
|
10
|
-
import { findWorkspaceRoot
|
|
18
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
19
|
+
import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
11
20
|
|
|
12
21
|
interface LastArgs {
|
|
13
22
|
runId?: string
|
|
14
23
|
list?: number
|
|
15
24
|
format: 'pretty' | 'json'
|
|
25
|
+
/** `--cache-dir`: read the history a run with the same flag wrote. */
|
|
26
|
+
cacheDir?: string
|
|
16
27
|
error?: string
|
|
17
28
|
}
|
|
18
29
|
|
|
@@ -21,7 +32,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
21
32
|
for (let i = 0; i < args.length; i++) {
|
|
22
33
|
const a = args[i]!
|
|
23
34
|
if (a === '--list' || a.startsWith('--list=')) {
|
|
24
|
-
|
|
35
|
+
// `--list 5` is the count, like every other value flag's space form:
|
|
36
|
+
// it read as a run id, which `--list` then ignored, and ten runs came
|
|
37
|
+
// back (item 899). A run id is never a bare integer, so the next
|
|
38
|
+
// argument is taken only when it is one.
|
|
39
|
+
const next = args[i + 1]
|
|
40
|
+
const spaced = a === '--list' && next !== undefined && /^\d+$/.test(next)
|
|
41
|
+
const lv = a === '--list' ? (spaced ? next : '10') : a.slice(7)
|
|
42
|
+
if (spaced) i++
|
|
25
43
|
const n = Number(lv)
|
|
26
44
|
if (!Number.isInteger(n) || n < 1 || n > 500) {
|
|
27
45
|
return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
|
|
@@ -37,10 +55,24 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
37
55
|
out.format = fv
|
|
38
56
|
continue
|
|
39
57
|
}
|
|
40
|
-
|
|
58
|
+
const cd = parseCacheDirFlag(args, i)
|
|
59
|
+
if (cd !== null) {
|
|
60
|
+
if ('error' in cd) return { ...out, error: cd.error }
|
|
61
|
+
out.cacheDir = cd.cacheDir
|
|
62
|
+
i = cd.next
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('last')}` }
|
|
41
66
|
if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
42
67
|
out.runId = a
|
|
43
68
|
}
|
|
69
|
+
// One run's replay or a list of runs, not both: the id was dropped.
|
|
70
|
+
if (out.runId !== undefined && out.list !== undefined) {
|
|
71
|
+
return {
|
|
72
|
+
...out,
|
|
73
|
+
error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
44
76
|
return out
|
|
45
77
|
}
|
|
46
78
|
|
|
@@ -53,13 +85,103 @@ function fmtMs(ms: number): string {
|
|
|
53
85
|
return `${m}m ${Math.round((ms - m * 60_000) / 1000)}s`
|
|
54
86
|
}
|
|
55
87
|
|
|
88
|
+
/**
|
|
89
|
+
* What an executed task used — its peak RSS and its CPU parallelism (CPU
|
|
90
|
+
* time over wall time) — as the runner recorded them. This is the number
|
|
91
|
+
* `@vzn/vx-schedule-history` reserves from, so the replay shows it where
|
|
92
|
+
* a developer can read it; a hit spent nothing and shows nothing.
|
|
93
|
+
*/
|
|
94
|
+
function fmtUsage(t: {
|
|
95
|
+
cpuMs: number | null
|
|
96
|
+
peakRssBytes: number | null
|
|
97
|
+
durationMs: number
|
|
98
|
+
}): string {
|
|
99
|
+
const parts: string[] = []
|
|
100
|
+
if (t.peakRssBytes !== null) parts.push(formatBytes(t.peakRssBytes))
|
|
101
|
+
if (t.cpuMs !== null && t.durationMs > 0)
|
|
102
|
+
parts.push(`${(t.cpuMs / t.durationMs).toFixed(1)}× cpu`)
|
|
103
|
+
return parts.length > 0 ? ` ${parts.join(' · ')}` : ''
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Why a row failed or was skipped, as the run's own footer said it (the
|
|
108
|
+
* v27 columns): a timeout or a never-ready server instead of the signal
|
|
109
|
+
* their exit stands for, the sandbox's violation count, a skip's blocker.
|
|
110
|
+
* Older rows carry none and read as before.
|
|
111
|
+
*/
|
|
112
|
+
function reasonParts(t: RunSummaryRow): string {
|
|
113
|
+
const parts: string[] = []
|
|
114
|
+
if (t.status === 'failed') {
|
|
115
|
+
if (t.notReady !== null) {
|
|
116
|
+
parts.push(
|
|
117
|
+
`never ready: ${t.notReady === 'timeout' ? 'timed out' : t.notReady === 'exited' ? 'exited' : 'spawn failed'}`,
|
|
118
|
+
)
|
|
119
|
+
} else if (t.timedOut === true) {
|
|
120
|
+
parts.push('timed out')
|
|
121
|
+
} else {
|
|
122
|
+
const signal = exitSignal(t.exitCode)
|
|
123
|
+
if (signal !== undefined) parts.push(`128 + ${signal}`)
|
|
124
|
+
}
|
|
125
|
+
if (t.sandboxViolations !== null && t.sandboxViolations > 0) {
|
|
126
|
+
parts.push(`${t.sandboxViolations} sandbox violation${t.sandboxViolations === 1 ? '' : 's'}`)
|
|
127
|
+
}
|
|
128
|
+
} else if (t.status === 'skipped' && t.blockedBy !== null) {
|
|
129
|
+
parts.push(`after ${t.blockedBy} failed`)
|
|
130
|
+
}
|
|
131
|
+
return parts.map((p) => ` ${p}`).join('')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Hit rows shown before the rest fold — the slowest restores. */
|
|
135
|
+
const HITS_SHOWN = 16
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The per-task rows: failures first, then what executed or was skipped,
|
|
139
|
+
* then cache hits. Hits are the noise of a warm run — 996 of a thousand
|
|
140
|
+
* rows on a red run put the failure a screen's height above the prompt
|
|
141
|
+
* (item 280) — so past `HITS_SHOWN` they fold into one line with their
|
|
142
|
+
* count; the ones shown are the slowest restores, the one thing a hit's
|
|
143
|
+
* row tells. `--format json` lists every row.
|
|
144
|
+
*/
|
|
145
|
+
export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
146
|
+
if (tasks.length === 0) return []
|
|
147
|
+
const failed = tasks.filter((t) => t.status === 'failed')
|
|
148
|
+
const hits = tasks.filter((t) => t.status !== 'failed' && t.cacheHit === true)
|
|
149
|
+
const rest = tasks.filter((t) => t.status !== 'failed' && t.cacheHit !== true)
|
|
150
|
+
const folded = hits.length > HITS_SHOWN
|
|
151
|
+
const shownHits = folded
|
|
152
|
+
? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
|
|
153
|
+
: hits
|
|
154
|
+
const ordered = [...failed, ...rest, ...shownHits]
|
|
155
|
+
const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}`.length), 4)
|
|
156
|
+
const lines = ['']
|
|
157
|
+
for (const t of ordered) {
|
|
158
|
+
const id = `${t.project}#${t.task}`
|
|
159
|
+
// The terminal summary's own word for a task that runs every time
|
|
160
|
+
// by design; a reader must not take its row for a miss.
|
|
161
|
+
// A failure's row reads as the frame did — `failed (exit 137)` —
|
|
162
|
+
// and above 128 names the signal the number stands for (260).
|
|
163
|
+
const status = t.status === 'failed' ? `failed (exit ${t.exitCode})` : t.status
|
|
164
|
+
lines.push(
|
|
165
|
+
` ${status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
|
|
166
|
+
`${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}` +
|
|
167
|
+
reasonParts(t),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (folded) {
|
|
171
|
+
lines.push(
|
|
172
|
+
` … +${hits.length - HITS_SHOWN} more cache hits (the ${HITS_SHOWN} slowest restores shown) — vx last --format json lists every row`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return lines
|
|
176
|
+
}
|
|
177
|
+
|
|
56
178
|
export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
57
179
|
const parsed = parseLastArgs(args)
|
|
58
180
|
if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
|
|
59
181
|
|
|
60
182
|
const root = await findWorkspaceRoot(process.cwd())
|
|
61
|
-
const
|
|
62
|
-
|
|
183
|
+
const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
|
|
184
|
+
noteSchemaReset(cache, warnToStderr)
|
|
63
185
|
try {
|
|
64
186
|
const db = cache.dbHandle()
|
|
65
187
|
|
|
@@ -116,22 +238,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
116
238
|
` (${inv.hitLocalCount} local, ${inv.hitRemoteCount} remote)` +
|
|
117
239
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
118
240
|
)
|
|
119
|
-
|
|
120
|
-
if (tasks.length > 0) {
|
|
121
|
-
lines.push('')
|
|
122
|
-
const failedFirst = [
|
|
123
|
-
...tasks.filter((t) => t.status === 'failed'),
|
|
124
|
-
...tasks.filter((t) => t.status !== 'failed'),
|
|
125
|
-
]
|
|
126
|
-
const idW = Math.max(...tasks.map((t) => `${t.project}#${t.task}`.length), 4)
|
|
127
|
-
for (const t of failedFirst) {
|
|
128
|
-
const id = `${t.project}#${t.task}`
|
|
129
|
-
lines.push(
|
|
130
|
-
` ${t.status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
|
|
131
|
-
`${t.hash !== '' ? ` ${t.hash}` : ''}`,
|
|
132
|
-
)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
241
|
+
lines.push(...formatTaskRows(detail?.tasks ?? []))
|
|
135
242
|
process.stdout.write(`${lines.join('\n')}\n`)
|
|
136
243
|
return 0
|
|
137
244
|
} finally {
|
package/src/cli/lock.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// current environment. Design: docs/design/config-lock-2026-06.md.
|
|
4
4
|
|
|
5
5
|
import type { ProjectConfig } from '../config.js'
|
|
6
|
+
import { seeHelp } from './help.js'
|
|
6
7
|
import { relPosix, xxh3hex } from '../util/index.js'
|
|
7
8
|
import {
|
|
8
9
|
findWorkspaceRoot,
|
|
10
|
+
type LoadReads,
|
|
9
11
|
listProjects,
|
|
10
12
|
loadProjectConfig,
|
|
11
13
|
loadWorkspace,
|
|
@@ -27,7 +29,7 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
|
|
|
27
29
|
const out: LockArgs = { check: false }
|
|
28
30
|
for (const a of args) {
|
|
29
31
|
if (a === '--check') out.check = true
|
|
30
|
-
else return { check: false, error: `unknown argument: ${a}` }
|
|
32
|
+
else return { check: false, error: `unknown argument: ${a}${seeHelp('lock')}` }
|
|
31
33
|
}
|
|
32
34
|
return out
|
|
33
35
|
}
|
|
@@ -40,32 +42,42 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
|
40
42
|
process.stderr.write(`vx lock: ${parsed.error}\n`)
|
|
41
43
|
return 1
|
|
42
44
|
}
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
const reads: LoadReads = new Map()
|
|
46
|
+
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
47
|
+
const workspace = await loadWorkspace(root, reads)
|
|
48
|
+
const all = await listProjects(workspace)
|
|
49
|
+
const metas = all.filter((m): m is ConfiguredMeta => m.configPath !== null)
|
|
50
|
+
// A project with no vx.config has nothing to freeze: its tasks (a plugin's,
|
|
51
|
+
// or none) load live under --frozen too, so both verbs say how many the
|
|
52
|
+
// count above leaves out — `locked 0 project configs` on a turbo() repo
|
|
53
|
+
// read like an audit of something (2026-09-16).
|
|
54
|
+
const bare = all.length - metas.length
|
|
55
|
+
if (parsed.check) return await checkLock(root, metas, bare)
|
|
56
|
+
return await writeLock(root, metas, bare)
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
/** Evaluate one config in the current env, returning its lock entry. */
|
|
53
60
|
async function evaluateEntry(root: string, meta: ConfiguredMeta): Promise<LockfileEntry> {
|
|
54
61
|
const bytes = await Bun.file(meta.configPath).bytes()
|
|
55
|
-
// `fresh: true` —
|
|
56
|
-
// a
|
|
62
|
+
// `fresh: true` — the lock freezes this environment's evaluation, never
|
|
63
|
+
// a stored one, whatever options a later edit passes beside it.
|
|
57
64
|
const config = await loadProjectConfig(meta.configPath, { fresh: true })
|
|
58
65
|
return {
|
|
59
66
|
configPath: relPosix(root, meta.configPath),
|
|
60
67
|
configHash: xxh3hex(bytes),
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
config: JSON.parse(JSON.stringify(config)) as ProjectConfig,
|
|
68
|
+
// Stored through `writeLockfile`'s JSON.stringify, which drops a field
|
|
69
|
+
// left `undefined` — the form `--check` compares a fresh evaluation in.
|
|
70
|
+
config,
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
function bareNote(bare: number): string {
|
|
75
|
+
return bare === 0
|
|
76
|
+
? ''
|
|
77
|
+
: ` (${bare} project${bare === 1 ? ' has' : 's have'} no vx.config; their tasks are never frozen)`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
|
|
69
81
|
const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
|
|
70
82
|
const projects: Record<string, LockfileEntry> = {}
|
|
71
83
|
// `listProjects` sorts by name — stable lockfile diffs for free.
|
|
@@ -73,7 +85,9 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
73
85
|
const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
|
|
74
86
|
await writeLockfile(root, lock)
|
|
75
87
|
const n = metas.length
|
|
76
|
-
process.stdout.write(
|
|
88
|
+
process.stdout.write(
|
|
89
|
+
`vx: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
|
|
90
|
+
)
|
|
77
91
|
return 0
|
|
78
92
|
}
|
|
79
93
|
|
|
@@ -85,7 +99,7 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
85
99
|
* catches eval-time env-var drift that file hashes cannot see —
|
|
86
100
|
* file bytes unchanged, resolved value changed.
|
|
87
101
|
*/
|
|
88
|
-
async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number> {
|
|
102
|
+
async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
|
|
89
103
|
const lock = await readLockfile(root)
|
|
90
104
|
if (!lock) {
|
|
91
105
|
process.stderr.write(
|
|
@@ -104,6 +118,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
104
118
|
if (hash !== entry.configHash) {
|
|
105
119
|
return `config file changed since lock (${m.name}: ${rel}) — run 'vx lock'`
|
|
106
120
|
}
|
|
121
|
+
// The lock's form: a first load is the module's live export, where a
|
|
122
|
+
// field left `undefined` is a key the stored JSON never has.
|
|
107
123
|
const fresh = JSON.parse(
|
|
108
124
|
JSON.stringify(await loadProjectConfig(m.configPath, { fresh: true })),
|
|
109
125
|
) as ProjectConfig
|
|
@@ -130,6 +146,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
130
146
|
return 1
|
|
131
147
|
}
|
|
132
148
|
const n = metas.length
|
|
133
|
-
|
|
149
|
+
const audited = `${n} project${n === 1 ? '' : 's'}`
|
|
150
|
+
const skipped = bare === 0 ? '' : `; ${bare} without a vx.config not audited`
|
|
151
|
+
process.stdout.write(`vx: lock is up to date (${audited}${skipped})\n`)
|
|
134
152
|
return 0
|
|
135
153
|
}
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -9,12 +9,12 @@ import { formatDuration } from '../orchestrator/index.js'
|
|
|
9
9
|
* task with recorded history also shows its typical duration (~p50).
|
|
10
10
|
*
|
|
11
11
|
* would run:
|
|
12
|
-
*
|
|
12
|
+
* ◉ @vzn/vx#lint cache hit (local) abc12345
|
|
13
13
|
* ↓ @vzn/vx#test cache hit (remote) def67890
|
|
14
|
-
* ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.
|
|
14
|
+
* ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.20s
|
|
15
15
|
*
|
|
16
|
-
* 3 task(s) planned
|
|
17
|
-
* predicted: ~1.
|
|
16
|
+
* 3 task(s) planned, 2 cache hits (1 local, 1 remote), 1 would run.
|
|
17
|
+
* predicted: ~1.20s wall · ~1.20s total execution
|
|
18
18
|
*/
|
|
19
19
|
export function formatPlanText(plan: RunPlan): string {
|
|
20
20
|
const real = plan.tasks.filter((t) => t.cacheStatus !== 'group')
|
|
@@ -144,19 +144,30 @@ export function formatPlanJson(plan: RunPlan): string {
|
|
|
144
144
|
export function formatGraphDot(plan: RunPlan): string {
|
|
145
145
|
const lines: string[] = ['digraph TaskGraph {', ' rankdir=LR;', ' node [shape=box];']
|
|
146
146
|
for (const t of plan.tasks) {
|
|
147
|
-
const label = `${t.node.id}\\n${t.hash.slice(0, 8)}`
|
|
148
147
|
const color = dotColor(t.cacheStatus)
|
|
149
|
-
lines.push(
|
|
148
|
+
lines.push(
|
|
149
|
+
` ${dotString(t.node.id)} [label=${dotString(`${t.node.id}\n${t.hash.slice(0, 8)}`)}, style="filled", fillcolor="${color}"];`,
|
|
150
|
+
)
|
|
150
151
|
}
|
|
151
152
|
for (const t of plan.tasks) {
|
|
152
153
|
for (const dep of t.deps) {
|
|
153
|
-
lines.push(`
|
|
154
|
+
lines.push(` ${dotString(dep)} -> ${dotString(t.node.id)};`)
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
lines.push('}')
|
|
157
158
|
return lines.join('\n') + '\n'
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
/**
|
|
162
|
+
* A DOT double-quoted ID. A task name is any object key in a config, so
|
|
163
|
+
* `"`, `\` and a newline are all reachable; unescaped they end the
|
|
164
|
+
* string early and the document stops parsing. `\n` is DOT's own line
|
|
165
|
+
* break inside a label.
|
|
166
|
+
*/
|
|
167
|
+
function dotString(s: string): string {
|
|
168
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
function symbolFor(s: CacheStatus): string {
|
|
161
172
|
switch (s) {
|
|
162
173
|
case 'hit-local':
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// before this is asked, so a plugin cannot shadow `run`.
|
|
9
9
|
|
|
10
10
|
import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
|
|
11
|
-
import { findWorkspaceRoot
|
|
11
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
12
|
+
import { loadCliWorkspace } from './workspace-config.js'
|
|
12
13
|
|
|
13
14
|
export interface ResolvedPluginCommand {
|
|
14
15
|
plugin: VxPlugin
|
|
@@ -16,6 +17,17 @@ export interface ResolvedPluginCommand {
|
|
|
16
17
|
ctx: CommandContext
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* No plugin here declares the verb, and these are the verbs they DO declare
|
|
22
|
+
* — the same load that failed to find the verb already knows them, so the
|
|
23
|
+
* "did you mean" and the "where verbs come from" line cost nothing extra.
|
|
24
|
+
* An empty list is a workspace whose plugins declare no verb at all; `null`
|
|
25
|
+
* from the lookup is no workspace.
|
|
26
|
+
*/
|
|
27
|
+
interface NoPluginCommand {
|
|
28
|
+
declaredVerbs: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/**
|
|
20
32
|
* Why a verb could not be looked up: the workspace file failed to load.
|
|
21
33
|
* Surfaced next to "unknown command" rather than as the whole answer — a
|
|
@@ -39,31 +51,31 @@ async function workspacePlugins(
|
|
|
39
51
|
} catch {
|
|
40
52
|
return null
|
|
41
53
|
}
|
|
42
|
-
let
|
|
54
|
+
let ws
|
|
43
55
|
try {
|
|
44
|
-
|
|
56
|
+
ws = await loadCliWorkspace(workspaceRoot)
|
|
45
57
|
} catch (err) {
|
|
46
58
|
return { loadError: err instanceof Error ? err.message : String(err) }
|
|
47
59
|
}
|
|
48
|
-
return {
|
|
49
|
-
workspaceRoot,
|
|
50
|
-
cacheDir: resolveCacheDir(workspaceRoot, config),
|
|
51
|
-
plugins: (config?.plugins ?? []) as readonly VxPlugin[],
|
|
52
|
-
}
|
|
60
|
+
return { workspaceRoot, cacheDir: ws.cacheDir, plugins: ws.plugins }
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/**
|
|
56
|
-
* The plugin verb named `verb` for the workspace around `cwd`;
|
|
57
|
-
*
|
|
64
|
+
* The plugin verb named `verb` for the workspace around `cwd`; the verbs
|
|
65
|
+
* this workspace's plugins do declare when none of them is it; null when
|
|
66
|
+
* there is no workspace; the load error when the workspace file could not
|
|
67
|
+
* say.
|
|
58
68
|
*/
|
|
59
69
|
export async function resolvePluginCommand(
|
|
60
70
|
verb: string,
|
|
61
71
|
cwd = process.cwd(),
|
|
62
|
-
): Promise<ResolvedPluginCommand | UnresolvedPluginCommand | null> {
|
|
72
|
+
): Promise<ResolvedPluginCommand | NoPluginCommand | UnresolvedPluginCommand | null> {
|
|
63
73
|
const ws = await workspacePlugins(cwd)
|
|
64
74
|
if (ws === null) return null
|
|
65
75
|
if ('loadError' in ws) return ws
|
|
76
|
+
const declaredVerbs = new Set<string>()
|
|
66
77
|
for (const plugin of ws.plugins) {
|
|
78
|
+
for (const declared of Object.keys(plugin.commands ?? {})) declaredVerbs.add(declared)
|
|
67
79
|
const command = plugin.commands?.[verb]
|
|
68
80
|
if (command === undefined) continue
|
|
69
81
|
return {
|
|
@@ -76,7 +88,17 @@ export async function resolvePluginCommand(
|
|
|
76
88
|
},
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
|
-
return
|
|
91
|
+
return { declaredVerbs: [...declaredVerbs] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
|
|
95
|
+
export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
|
|
96
|
+
const ws = await workspacePlugins(cwd).catch(() => null)
|
|
97
|
+
if (ws === null || 'loadError' in ws) return []
|
|
98
|
+
const verbs = new Set<string>()
|
|
99
|
+
for (const plugin of ws.plugins)
|
|
100
|
+
for (const verb of Object.keys(plugin.commands ?? {})) verbs.add(verb)
|
|
101
|
+
return [...verbs]
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
/** `vx help` lines for every plugin verb in the workspace around `cwd`. */
|