@vzn/vx 0.0.18 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/info.ts CHANGED
@@ -1,110 +1,139 @@
1
1
  // `vx info` — workspace doctor printout. One screen of environment +
2
2
  // workspace + cache facts for bug reports and quick sanity checks.
3
- // `vx stats` is a deprecated alias (info absorbed it).
3
+ // `vx stats` is a deprecated alias (info absorbed it). The facts come
4
+ // from the orchestrator's `collectInfo` (doctor.ts), which `vx mcp` reads
5
+ // too; this file parses the flags and renders the rows.
4
6
 
5
- import { Cache } from '../cache/index.js'
6
- import { VERSION } from '../version.js'
7
- import {
8
- findWorkspaceRoot,
9
- listProjects,
10
- loadProjectConfig,
11
- loadWorkspace,
12
- loadWorkspaceConfig,
13
- lockfilePath,
14
- resolveCacheDir,
15
- } from '../workspace/index.js'
7
+ import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
+ import { seeHelp } from './help.js'
9
+ import { parseCacheDirFlag, warnToStderr } from './workspace-config.js'
16
10
  import { formatBytes } from './format.js'
17
11
 
18
- export async function infoCmd(args: readonly string[]): Promise<number> {
19
- if (args.length > 0) {
20
- process.stderr.write(`vx info: unknown argument: ${args[0]}\n`)
21
- return 1
22
- }
23
- const root = await findWorkspaceRoot(process.cwd())
24
- const metas = await listProjects(await loadWorkspace(root))
12
+ export interface InfoArgs {
13
+ format: 'pretty' | 'json'
14
+ /** `--cache-dir`: report on the cache a run with the same flag uses. */
15
+ cacheDir?: string
16
+ error?: string
17
+ }
25
18
 
26
- let taskCount = 0
27
- await Promise.all(
28
- metas.map(async (meta) => {
29
- if (meta.configPath === null) return
30
- // A broken config must not take the doctor down with it — it
31
- // just contributes zero tasks to the count.
32
- try {
33
- const config = await loadProjectConfig(meta.configPath)
34
- taskCount += Object.keys(config.tasks ?? {}).length
35
- } catch {
36
- // counted as zero
19
+ export function parseInfoArgs(args: readonly string[]): InfoArgs {
20
+ const out: InfoArgs = { format: 'pretty' }
21
+ for (let i = 0; i < args.length; i++) {
22
+ const a = args[i]
23
+ if (a === '--format' || a?.startsWith('--format=')) {
24
+ const v = a === '--format' ? args[++i] : a.slice('--format='.length)
25
+ if (v !== 'pretty' && v !== 'json') {
26
+ return { ...out, error: `--format must be pretty or json${seeHelp('info')}` }
37
27
  }
38
- }),
39
- )
40
-
41
- const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
42
- const cache = new Cache(cacheDir)
43
- let stats
44
- try {
45
- stats = cache.stats()
46
- } finally {
47
- cache.close()
28
+ out.format = v
29
+ continue
30
+ }
31
+ const cd = parseCacheDirFlag(args, i)
32
+ if (cd !== null) {
33
+ if ('error' in cd) return { ...out, error: cd.error }
34
+ out.cacheDir = cd.cacheDir
35
+ i = cd.next
36
+ continue
37
+ }
38
+ return { ...out, error: `unknown argument: ${a}${seeHelp('info')}` }
48
39
  }
40
+ return out
41
+ }
49
42
 
50
- const lockPresent = await Bun.file(lockfilePath(root)).exists()
43
+ export async function infoCmd(args: readonly string[]): Promise<number> {
44
+ const parsed = parseInfoArgs(args)
45
+ if (parsed.error) {
46
+ process.stderr.write(`vx info: ${parsed.error}\n`)
47
+ return 1
48
+ }
49
+ const facts = await collectInfo(process.cwd(), {
50
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
51
+ warn: warnToStderr,
52
+ })
53
+ process.stdout.write(
54
+ parsed.format === 'json' ? `${JSON.stringify(facts, null, 2)}\n` : `${renderInfo(facts)}\n`,
55
+ )
56
+ return 0
57
+ }
51
58
 
59
+ export function renderInfo(f: InfoFacts): string {
52
60
  const rows: [string, string][] = [
53
- ['vx', VERSION],
54
- ['bun', Bun.version],
55
- ['git', gitVersion()],
56
- // The one `git status` walk per run is the warm path's critical path on
57
- // a large tree (~55 ms at 1000 projects, measured 2026-09-02). git's
58
- // own caches make it near-free after the first run, and they are OFF by
59
- // default — say so, since nothing else in a run would.
60
- ['git status cache', gitStatusCache(root)],
61
- ['workspace root', root],
62
- ['projects', `${metas.length} (${taskCount} task${taskCount === 1 ? '' : 's'})`],
63
- ['cache dir', cacheDir],
64
- ['cache entries', `${stats.entryCount} (${formatBytes(stats.totalBytes)})`],
65
- ['runs (24h)', `${stats.runCountLast24h} (${stats.hitCountLast24h} cache hits)`],
66
- ['vx-lock.json', lockPresent ? 'yes' : 'no'],
61
+ ['vx', f.vx],
62
+ ['bun', f.bun],
63
+ ['git', f.git ?? '(not found)'],
64
+ ['git status cache', renderGitStatusCache(f.gitStatusCache)],
65
+ ['workspace root', f.workspaceRoot],
66
+ ['projects', `${f.projects} (${f.tasks} task${f.tasks === 1 ? '' : 's'})`],
67
+ ['plugins', describePlugins(f.plugins)],
68
+ ['workers', describeWorkers(f.workers)],
69
+ ['memory', describeMemory(f.memory)],
70
+ ['cache dir', f.cacheDir],
71
+ ['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
72
+ ['cache entries', `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`],
73
+ // Only when there is something to say.
74
+ ...(f.orphans.artifacts > 0
75
+ ? ([
76
+ [
77
+ 'orphans',
78
+ `${f.orphans.artifacts} artifact${f.orphans.artifacts === 1 ? '' : 's'} (${formatBytes(f.orphans.bytes)}) the index does not know — \`vx cache prune\` reaps them`,
79
+ ],
80
+ ] as [string, string][])
81
+ : []),
82
+ ['runs (24h)', `${f.runs24h} (${f.hits24h} cache hits)`],
83
+ ['flaky tasks', describeFlakyTasks(f.flakyTasks)],
84
+ ['vx-lock.json', f.lockfile ? 'yes' : 'no'],
67
85
  ]
68
86
  const labelW = Math.max(...rows.map(([label]) => label.length))
69
- const lines = rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`)
70
- process.stdout.write(`${lines.join('\n')}\n`)
71
- return 0
87
+ return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
72
88
  }
73
89
 
74
- /** Whether git's fsmonitor / untracked cache are on, with the remedy when not. */
75
- function gitStatusCache(root: string): string {
76
- try {
77
- const p = Bun.spawnSync({
78
- cmd: ['git', 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
79
- cwd: root,
80
- stdout: 'pipe',
81
- stderr: 'pipe',
82
- })
83
- const out = p.exitCode === 0 ? new TextDecoder().decode(p.stdout) : ''
84
- const on = (key: string): boolean =>
85
- new RegExp(`^core\\.${key} (true|1|yes|on)$`, 'im').test(out)
86
- const fsmonitor = on('fsmonitor')
87
- const untracked = on('untrackedcache')
88
- if (fsmonitor && untracked) return 'fsmonitor + untrackedCache on'
89
- const missing = [
90
- ...(fsmonitor ? [] : ['core.fsmonitor']),
91
- ...(untracked ? [] : ['core.untrackedCache']),
92
- ]
93
- return `${missing.join(', ')} off — \`git config ${missing[0]} true\` makes every run's status walk near-free on a large tree`
94
- } catch {
95
- return '(unknown)'
96
- }
90
+ /** `4 — the CPU count`, `2 — cgroup CPU quota 2 of 8 cores`, `8 — vx.workspace.ts (4 cores)`. */
91
+ export function describeWorkers(w: InfoFacts['workers']): string {
92
+ const quota = w.cpuQuota === null ? '' : `, cgroup CPU quota ${trimCores(w.cpuQuota)}`
93
+ if (w.source === 'workspace') return `${w.count} — vx.workspace.ts (${w.cores} cores${quota})`
94
+ if (w.source === 'cgroup')
95
+ return `${w.count} — cgroup CPU quota ${trimCores(w.cpuQuota ?? w.count)} of ${w.cores} cores`
96
+ return `${w.count} — the CPU count${quota}`
97
97
  }
98
98
 
99
- function gitVersion(): string {
100
- try {
101
- const p = Bun.spawnSync({ cmd: ['git', '--version'], stdout: 'pipe', stderr: 'pipe' })
102
- if (p.exitCode !== 0) return '(not found)'
103
- return new TextDecoder()
104
- .decode(p.stdout)
105
- .trim()
106
- .replace(/^git version /, '')
107
- } catch {
108
- return '(not found)'
99
+ /** `13 GB usable — cgroup limit; the machine has 16 GB`, or `16 GB`. */
100
+ export function describeMemory(m: InfoFacts['memory']): string {
101
+ if (m.cgroupLimitBytes !== null && m.usableBytes < m.totalBytes) {
102
+ return `${formatBytes(m.usableBytes)} usable — cgroup limit; the machine has ${formatBytes(m.totalBytes)}`
109
103
  }
104
+ return formatBytes(m.totalBytes)
105
+ }
106
+
107
+ function trimCores(n: number): string {
108
+ return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, '')
109
+ }
110
+
111
+ /** `2 — app#test (3 of 7 runs failed on unchanged inputs); api#e2e (1 of 4)`, or `none`. */
112
+ export function describeFlakyTasks(tasks: readonly FlakyTask[]): string {
113
+ if (tasks.length === 0) return 'none'
114
+ const parts = tasks.map(
115
+ (t, i) =>
116
+ `${t.taskId} (${t.failures} of ${t.passes + t.failures} runs failed${i === 0 ? ' on unchanged inputs' : ''})`,
117
+ )
118
+ return `${tasks.length} — ${parts.join('; ')}`
119
+ }
120
+
121
+ export function describePlugins(
122
+ plugins: ReadonlyArray<{ name: string; seams: readonly string[] }>,
123
+ ): string {
124
+ if (plugins.length === 0) return 'none'
125
+ const parts = plugins.map(
126
+ (p) => `${p.name} (${p.seams.length === 0 ? 'no seams' : p.seams.join(', ')})`,
127
+ )
128
+ return `${plugins.length} — ${parts.join('; ')}`
129
+ }
130
+
131
+ function renderGitStatusCache(c: InfoFacts['gitStatusCache']): string {
132
+ if (c === null) return '(unknown)'
133
+ if (c.fsmonitor && c.untrackedCache) return 'fsmonitor + untrackedCache on'
134
+ const missing = [
135
+ ...(c.fsmonitor ? [] : ['core.fsmonitor']),
136
+ ...(c.untrackedCache ? [] : ['core.untrackedCache']),
137
+ ]
138
+ return `${missing.join(', ')} off`
110
139
  }
@@ -0,0 +1,75 @@
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
+ listProjects,
12
+ loadWorkspace,
13
+ migrateScripts,
14
+ } from '../workspace/index.js'
15
+
16
+ export interface InitArgs {
17
+ dry: boolean
18
+ force: boolean
19
+ /** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
20
+ mjs: boolean
21
+ error?: string
22
+ }
23
+
24
+ export function parseInitArgs(args: readonly string[]): InitArgs {
25
+ const out: InitArgs = { dry: false, force: false, mjs: false }
26
+ for (const a of args) {
27
+ if (a === '--dry') out.dry = true
28
+ else if (a === '--force') out.force = true
29
+ else if (a === '--mjs') out.mjs = true
30
+ else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('init')}` }
31
+ else return { ...out, error: `unexpected argument: ${a}` }
32
+ }
33
+ return out
34
+ }
35
+
36
+ export async function initCmd(args: readonly string[]): Promise<number> {
37
+ const parsed = parseInitArgs(args)
38
+ if (parsed.error) {
39
+ process.stderr.write(`vx init: ${parsed.error}\n`)
40
+ return 1
41
+ }
42
+ const root = await findWorkspaceRoot(process.cwd())
43
+ const metas = await listProjects(await loadWorkspace(root))
44
+ // `init` reads scripts only; a runner's own config beside them is the
45
+ // richer source (dependsOn, inputs, outputs) and was ignored without a
46
+ // word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
47
+ // TODOs and none of the edges turbo.json already declared.
48
+ const notes: string[] = []
49
+ if (await Bun.file(path.join(root, 'turbo.json')).exists()) {
50
+ notes.push(
51
+ 'turbo.json found and not read — `bunx @vzn/vx-migrate` maps it (dependsOn, inputs, ' +
52
+ 'outputs), or `plugins: [turbo()]` from @vzn/vx-migrate runs it with nothing written',
53
+ )
54
+ } else if (
55
+ (await Bun.file(path.join(root, 'nx.json')).exists()) ||
56
+ (await Bun.file(path.join(root, '.nx', 'workspace-data', 'project-graph.json')).exists())
57
+ ) {
58
+ notes.push(
59
+ 'an Nx workspace found and not read — `bunx @vzn/vx-migrate --from nx` maps its ' +
60
+ 'exported project graph',
61
+ )
62
+ }
63
+ return applyMigration({
64
+ root,
65
+ metas,
66
+ plan: migrateScripts(metas),
67
+ source: 'package.json scripts',
68
+ verb: 'vx init',
69
+ dry: parsed.dry,
70
+ force: parsed.force,
71
+ init: true,
72
+ notes,
73
+ format: parsed.mjs ? 'mjs' : 'ts',
74
+ })
75
+ }
package/src/cli/last.ts CHANGED
@@ -4,15 +4,20 @@
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'
7
+ import { Cache, noteSchemaReset } from '../cache/index.js'
8
+ import { formatBytes } from './format.js'
9
+ import { seeHelp } from './help.js'
8
10
  import { getInvocation, getRun, listInvocations } from '../orchestrator/index.js'
9
11
  import { UserError } from '../util/index.js'
10
- import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
12
+ import { findWorkspaceRoot } from '../workspace/index.js'
13
+ import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
11
14
 
12
15
  interface LastArgs {
13
16
  runId?: string
14
17
  list?: number
15
18
  format: 'pretty' | 'json'
19
+ /** `--cache-dir`: read the history a run with the same flag wrote. */
20
+ cacheDir?: string
16
21
  error?: string
17
22
  }
18
23
 
@@ -37,7 +42,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
37
42
  out.format = fv
38
43
  continue
39
44
  }
40
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
45
+ const cd = parseCacheDirFlag(args, i)
46
+ if (cd !== null) {
47
+ if ('error' in cd) return { ...out, error: cd.error }
48
+ out.cacheDir = cd.cacheDir
49
+ i = cd.next
50
+ continue
51
+ }
52
+ if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('last')}` }
41
53
  if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
42
54
  out.runId = a
43
55
  }
@@ -53,13 +65,31 @@ function fmtMs(ms: number): string {
53
65
  return `${m}m ${Math.round((ms - m * 60_000) / 1000)}s`
54
66
  }
55
67
 
68
+ /**
69
+ * What an executed task used — its peak RSS and its CPU parallelism (CPU
70
+ * time over wall time) — as the runner recorded them. This is the number
71
+ * `@vzn/vx-schedule-history` reserves from, so the replay shows it where
72
+ * a developer can read it; a hit spent nothing and shows nothing.
73
+ */
74
+ function fmtUsage(t: {
75
+ cpuMs: number | null
76
+ peakRssBytes: number | null
77
+ durationMs: number
78
+ }): string {
79
+ const parts: string[] = []
80
+ if (t.peakRssBytes !== null) parts.push(formatBytes(t.peakRssBytes))
81
+ if (t.cpuMs !== null && t.durationMs > 0)
82
+ parts.push(`${(t.cpuMs / t.durationMs).toFixed(1)}× cpu`)
83
+ return parts.length > 0 ? ` ${parts.join(' · ')}` : ''
84
+ }
85
+
56
86
  export async function lastCmd(args: readonly string[]): Promise<number> {
57
87
  const parsed = parseLastArgs(args)
58
88
  if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
59
89
 
60
90
  const root = await findWorkspaceRoot(process.cwd())
61
- const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
62
- const cache = new Cache(cacheDir)
91
+ const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
92
+ noteSchemaReset(cache, warnToStderr)
63
93
  try {
64
94
  const db = cache.dbHandle()
65
95
 
@@ -126,9 +156,11 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
126
156
  const idW = Math.max(...tasks.map((t) => `${t.project}#${t.task}`.length), 4)
127
157
  for (const t of failedFirst) {
128
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.
129
161
  lines.push(
130
162
  ` ${t.status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
131
- `${t.hash !== '' ? ` ${t.hash}` : ''}`,
163
+ `${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}`,
132
164
  )
133
165
  }
134
166
  }
package/src/cli/lock.ts CHANGED
@@ -3,6 +3,7 @@
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,
@@ -27,7 +28,7 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
27
28
  const out: LockArgs = { check: false }
28
29
  for (const a of args) {
29
30
  if (a === '--check') out.check = true
30
- else return { check: false, error: `unknown argument: ${a}` }
31
+ else return { check: false, error: `unknown argument: ${a}${seeHelp('lock')}` }
31
32
  }
32
33
  return out
33
34
  }
@@ -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(` "${t.node.id}" [label="${label}", style="filled", fillcolor="${color}"];`)
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(` "${dep}" -> "${t.node.id}";`)
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, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
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
@@ -39,17 +40,13 @@ async function workspacePlugins(
39
40
  } catch {
40
41
  return null
41
42
  }
42
- let config
43
+ let ws
43
44
  try {
44
- config = await loadWorkspaceConfig(workspaceRoot)
45
+ ws = await loadCliWorkspace(workspaceRoot)
45
46
  } catch (err) {
46
47
  return { loadError: err instanceof Error ? err.message : String(err) }
47
48
  }
48
- return {
49
- workspaceRoot,
50
- cacheDir: resolveCacheDir(workspaceRoot, config),
51
- plugins: (config?.plugins ?? []) as readonly VxPlugin[],
52
- }
49
+ return { workspaceRoot, cacheDir: ws.cacheDir, plugins: ws.plugins }
53
50
  }
54
51
 
55
52
  /**
@@ -80,6 +77,16 @@ export async function resolvePluginCommand(
80
77
  }
81
78
 
82
79
  /** `vx help` lines for every plugin verb in the workspace around `cwd`. */
80
+ /** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
81
+ export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
82
+ const ws = await workspacePlugins(cwd).catch(() => null)
83
+ if (ws === null || 'loadError' in ws) return []
84
+ const verbs = new Set<string>()
85
+ for (const plugin of ws.plugins)
86
+ for (const verb of Object.keys(plugin.commands ?? {})) verbs.add(verb)
87
+ return [...verbs]
88
+ }
89
+
83
90
  export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]> {
84
91
  const ws = await workspacePlugins(cwd).catch(() => null)
85
92
  if (ws === null || 'loadError' in ws) return []