@vzn/vx 0.0.16 → 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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
package/src/cli/index.ts CHANGED
@@ -4,25 +4,36 @@
4
4
 
5
5
  import { VERSION } from '../version.js'
6
6
  import { runCmd } from './run.js'
7
- import { watchCmd } from './watch.js'
8
- import { cacheCmd } from './cache.js'
9
- import { lockCmd } from './lock.js'
10
- import { migrateCmd } from './migrate.js'
11
- import { upgradeCmd } from './upgrade.js'
12
- import { showCmd } from './show.js'
13
- import { infoCmd } from './info.js'
14
- import { mcpCmd } from './mcp.js'
15
- import { printHelp } from './help.js'
7
+ import { CORE_VERBS, printHelp } from './help.js'
8
+ import { pluginCommandHelp, resolvePluginCommand, pluginVerbs } from './plugin-commands.js'
9
+ import { MOVED_VERBS, nearest, UserError } from '../util/index.js'
10
+
11
+ // Every verb but `run` is imported when invoked. `vx run` is the hot path
12
+ // and nearly every invocation; the other verbs' modules are code that
13
+ // process never calls. Measured 2026-09-03: `--version` is 25 ms either
14
+ // way (module loading is not where start-up goes), so this is hygiene, not
15
+ // a speed-up. The specifiers are string literals, so `bun build --compile`
16
+ // still embeds them.
16
17
 
17
18
  export async function run(argv: readonly string[]): Promise<number> {
18
19
  const [command, ...rest] = argv
19
20
 
21
+ // `vx <verb> --help` is the universal reflex, and every verb answered
22
+ // `unknown flag: --help` and exited 1 (walkthrough, 2026-09-04). Only args
23
+ // BEFORE a `--` count: `vx run build -- --help` forwards it to the task,
24
+ // which is the one place `--help` is not being asked of vx. Core verbs
25
+ // only — a plugin verb owns its own arguments, `--help` included.
26
+ if (command !== undefined && wantsHelp(command, rest)) {
27
+ printHelp(await pluginCommandHelp(), command)
28
+ return 0
29
+ }
30
+
20
31
  switch (command) {
21
32
  case undefined:
22
33
  case '--help':
23
34
  case '-h':
24
35
  case 'help':
25
- printHelp()
36
+ printHelp(await pluginCommandHelp())
26
37
  return 0
27
38
  case '--version':
28
39
  case 'version':
@@ -31,55 +42,114 @@ export async function run(argv: readonly string[]): Promise<number> {
31
42
  case 'run':
32
43
  return await runCmd(rest)
33
44
  case 'watch':
34
- return await watchCmd(rest)
45
+ return await (await import('./watch.js')).watchCmd(rest)
35
46
  case 'cache':
36
- return await cacheCmd(rest)
47
+ return await (await import('./cache.js')).cacheCmd(rest)
37
48
  case 'lock':
38
- return await lockCmd(rest)
39
- case 'migrate':
40
- return await migrateCmd(rest)
49
+ return await (await import('./lock.js')).lockCmd(rest)
50
+ case 'init':
51
+ return await (await import('./init.js')).initCmd(rest)
41
52
  case 'upgrade':
42
- return await upgradeCmd(rest)
53
+ return await (await import('./upgrade.js')).upgradeCmd(rest)
43
54
  case 'show':
44
- return await showCmd(rest)
55
+ return await (await import('./show.js')).showCmd(rest)
45
56
  case 'info':
46
57
  case 'stats': // deprecated alias — `vx info` absorbed `vx stats`
47
- return await infoCmd(rest)
48
- case 'mcp':
49
- return await mcpCmd(rest)
50
- case 'serve':
51
- case 'dev':
52
- // vx core is only a task runner — it has no service layer of its own.
53
- // A dashboard, remote cache, distributed execution, etc. are provided
54
- // by PLUGINS (declared in vx.workspace.ts), never by core. We keep this
55
- // neutral hint for the common muscle-memory verbs, but core names no
56
- // specific plugin package: any package can provide these.
57
- process.stderr.write(
58
- `vx: '${command}' is not a vx core command.\n` +
59
- ` vx core runs tasks in-process. A dashboard, remote cache, and\n` +
60
- ` distributed execution come from plugins — not core. See the plugin\n` +
61
- ` guide: https://vznjs.github.io/vx/guides/plugins/\n`,
62
- )
63
- return 1
64
- default:
65
- process.stderr.write(`vx: unknown command: ${command}\n`)
58
+ return await (await import('./info.js')).infoCmd(rest)
59
+ case 'why':
60
+ return await (await import('./why.js')).whyCmd(rest)
61
+ case 'last':
62
+ return await (await import('./last.js')).lastCmd(rest)
63
+ case 'completions':
64
+ return await (await import('./completions.js')).completionsCmd(rest, await pluginVerbs())
65
+ default: {
66
+ // Not a core verb: a plugin declared in the workspace around the cwd
67
+ // may own it (`VxPlugin.commands`). Core verbs were matched above, so
68
+ // nothing here can shadow them.
69
+ const resolved = await resolvePluginCommand(command)
70
+ if (resolved !== null && !('loadError' in resolved)) {
71
+ const code = await resolved.command.run(rest, resolved.ctx)
72
+ // A plugin is a boundary: a JS-authored verb that resolves nothing
73
+ // would reach `process.exit(undefined)` and read as SUCCESS. A verb
74
+ // that cannot say whether it succeeded fails, naming its owner.
75
+ if (!Number.isInteger(code)) {
76
+ throw new UserError(
77
+ `plugin '${resolved.plugin.name}': command '${command}' resolved ${JSON.stringify(code)} instead of an exit code`,
78
+ )
79
+ }
80
+ return code
81
+ }
82
+ // A broken workspace file cannot say whether the verb exists. Say
83
+ // both things: the verb is unknown HERE, and why the lookup could not
84
+ // finish — a typo still reads as a typo, and a real plugin verb still
85
+ // points at the file that broke it.
86
+ const loadNote =
87
+ resolved !== null && 'loadError' in resolved
88
+ ? `\n (plugin verbs could not be looked up: vx.workspace failed to load: ${resolved.loadError})`
89
+ : ''
90
+ // A verb core owned once (`migrate`, `prune`) and a package owns now:
91
+ // the pointer, but only after the plugins had their chance — a
92
+ // workspace that declares a plugin verb of that name keeps it.
93
+ const moved = MOVED_VERBS[command]
94
+ if (moved !== undefined) {
95
+ process.stderr.write(`${moved}${loadNote}\n`)
96
+ return 1
97
+ }
98
+ if (command === 'serve' || command === 'dev') {
99
+ // vx core is only a task runner — it has no service layer of its
100
+ // own. A dashboard, remote cache, distributed execution, etc. are
101
+ // provided by PLUGINS (declared in vx.workspace.ts), never by core.
102
+ // We keep this neutral hint for the common muscle-memory verbs, but
103
+ // core names no specific plugin package: any package can provide
104
+ // these.
105
+ process.stderr.write(
106
+ `vx: '${command}' is not a vx core command.\n` +
107
+ ` vx core runs tasks in-process. A dashboard, remote cache, and\n` +
108
+ ` distributed execution come from plugins — not core. See the plugin\n` +
109
+ ` guide: https://vznjs.github.io/vx/guides/plugins/\n`,
110
+ )
111
+ return 1
112
+ }
113
+ process.stderr.write(`vx: unknown command: ${command}${didYouMeanVerb(command)}${loadNote}\n`)
66
114
  printHelp()
67
115
  return 1
116
+ }
68
117
  }
69
118
  }
70
119
 
71
120
  // Re-exports for tests + programmatic embedders.
72
- export { detectFlow, parseRunArgs, type RunArgs } from './run.js'
121
+ export {
122
+ detectFlow,
123
+ parseConcurrency,
124
+ parseRunArgs,
125
+ resolveRunOptions,
126
+ type RunArgs,
127
+ } from './run.js'
73
128
  export { parsePruneArgs, parseDuration, parseSize } from './cache.js'
74
129
  export { parseLockArgs, type LockArgs } from './lock.js'
75
- export { parseMigrateArgs, type MigrateArgs } from './migrate.js'
130
+ export { parseInitArgs, type InitArgs } from './init.js'
76
131
  export { parseShowArgs, type ShowArgs } from './show.js'
77
- export { parseMcpArgs, type McpArgs } from './mcp.js'
78
- export {
79
- handleMcpRequest,
80
- listMcpTools,
81
- setMcpContext,
82
- type McpContext,
83
- type McpToolDef,
84
- } from './mcp-rpc.js'
132
+ export { parseWhyArgs } from './why.js'
133
+ export { parseLastArgs } from './last.js'
85
134
  export { formatBytes } from './format.js'
135
+ export { registerCoreAlias } from './core-alias.js'
136
+
137
+ /**
138
+ * Is this invocation asking for help rather than work? See the call site for
139
+ * why the scan stops at `--`, and why plugin verbs are excluded.
140
+ */
141
+ function wantsHelp(command: string, rest: readonly string[]): boolean {
142
+ if (!(CORE_VERBS as readonly string[]).includes(command)) return false
143
+ const sep = rest.indexOf('--')
144
+ const own = sep === -1 ? rest : rest.slice(0, sep)
145
+ return own.includes('--help') || own.includes('-h')
146
+ }
147
+
148
+ /** ` Did you mean run?` for a verb within two edits of a core one — the same
149
+ * hint a task or flag typo gets. Plugin verbs are not listed: resolving
150
+ * them loads the workspace, and this path is reached only when that
151
+ * lookup found nothing. */
152
+ function didYouMeanVerb(verb: string): string {
153
+ const best = nearest(verb, CORE_VERBS)
154
+ return best === undefined ? '' : `. Did you mean ${best}?`
155
+ }
package/src/cli/info.ts CHANGED
@@ -1,80 +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
- ['workspace root', root],
57
- ['projects', `${metas.length} (${taskCount} task${taskCount === 1 ? '' : 's'})`],
58
- ['cache dir', cacheDir],
59
- ['cache entries', `${stats.entryCount} (${formatBytes(stats.totalBytes)})`],
60
- ['runs (24h)', `${stats.runCountLast24h} (${stats.hitCountLast24h} cache hits)`],
61
- ['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'],
62
85
  ]
63
86
  const labelW = Math.max(...rows.map(([label]) => label.length))
64
- const lines = rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`)
65
- process.stdout.write(`${lines.join('\n')}\n`)
66
- return 0
87
+ return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
67
88
  }
68
89
 
69
- function gitVersion(): string {
70
- try {
71
- const p = Bun.spawnSync({ cmd: ['git', '--version'], stdout: 'pipe', stderr: 'pipe' })
72
- if (p.exitCode !== 0) return '(not found)'
73
- return new TextDecoder()
74
- .decode(p.stdout)
75
- .trim()
76
- .replace(/^git version /, '')
77
- } catch {
78
- return '(not found)'
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
+ }
98
+
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)}`
79
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`
80
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
+ }
@@ -0,0 +1,172 @@
1
+ // `vx last [runId]` — replay a recorded run's summary from the terminal,
2
+ // without re-executing anything. Comparison gap #12: with the dashboard's
3
+ // run-detail page gone (the 2026-08-23 cloud removal), the local run
4
+ // history in cache.db is the only replay surface, and this verb reads it.
5
+ // Read-only — no config evaluation, no re-hash, no cache probe.
6
+
7
+ import { Cache, noteSchemaReset } from '../cache/index.js'
8
+ import { formatBytes } from './format.js'
9
+ import { seeHelp } from './help.js'
10
+ import { getInvocation, getRun, listInvocations } from '../orchestrator/index.js'
11
+ import { UserError } from '../util/index.js'
12
+ import { findWorkspaceRoot } from '../workspace/index.js'
13
+ import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
14
+
15
+ interface LastArgs {
16
+ runId?: string
17
+ list?: number
18
+ format: 'pretty' | 'json'
19
+ /** `--cache-dir`: read the history a run with the same flag wrote. */
20
+ cacheDir?: string
21
+ error?: string
22
+ }
23
+
24
+ export function parseLastArgs(args: readonly string[]): LastArgs {
25
+ const out: LastArgs = { format: 'pretty' }
26
+ for (let i = 0; i < args.length; i++) {
27
+ const a = args[i]!
28
+ if (a === '--list' || a.startsWith('--list=')) {
29
+ const lv = a === '--list' ? '10' : a.slice(7)
30
+ const n = Number(lv)
31
+ if (!Number.isInteger(n) || n < 1 || n > 500) {
32
+ return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
33
+ }
34
+ out.list = n
35
+ continue
36
+ }
37
+ if (a === '--format' || a.startsWith('--format=')) {
38
+ const fv = a === '--format' ? args[++i] : a.slice(9)
39
+ if (fv !== 'pretty' && fv !== 'json') {
40
+ return { ...out, error: `invalid --format: ${fv ?? ''} (expected pretty | json)` }
41
+ }
42
+ out.format = fv
43
+ continue
44
+ }
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')}` }
53
+ if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
54
+ out.runId = a
55
+ }
56
+ return out
57
+ }
58
+
59
+ const fmtWhen = (ms: number): string => new Date(ms).toISOString()
60
+
61
+ function fmtMs(ms: number): string {
62
+ if (ms < 1000) return `${Math.round(ms)}ms`
63
+ if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
64
+ const m = Math.floor(ms / 60_000)
65
+ return `${m}m ${Math.round((ms - m * 60_000) / 1000)}s`
66
+ }
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
+
86
+ export async function lastCmd(args: readonly string[]): Promise<number> {
87
+ const parsed = parseLastArgs(args)
88
+ if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
89
+
90
+ const root = await findWorkspaceRoot(process.cwd())
91
+ const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
92
+ noteSchemaReset(cache, warnToStderr)
93
+ try {
94
+ const db = cache.dbHandle()
95
+
96
+ if (parsed.list !== undefined) {
97
+ const invocations = listInvocations(db, { limit: parsed.list })
98
+ if (parsed.format === 'json') {
99
+ process.stdout.write(`${JSON.stringify(invocations)}\n`)
100
+ return 0
101
+ }
102
+ if (invocations.length === 0) {
103
+ process.stdout.write('no recorded runs\n')
104
+ return 0
105
+ }
106
+ for (const inv of invocations) {
107
+ const verdict = inv.exitOk ? 'ok ' : 'FAILED'
108
+ process.stdout.write(
109
+ `${verdict} ${fmtWhen(inv.startedAt)} ${inv.runId} ` +
110
+ `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
111
+ `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''} · ${fmtMs(inv.totalDurationMs)} $ ${inv.command}\n`,
112
+ )
113
+ }
114
+ return 0
115
+ }
116
+
117
+ const inv =
118
+ parsed.runId !== undefined
119
+ ? getInvocation(db, parsed.runId)
120
+ : (listInvocations(db, { limit: 1 })[0] ?? null)
121
+ if (inv === null || inv === undefined) {
122
+ throw new UserError(
123
+ parsed.runId !== undefined
124
+ ? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
125
+ : 'vx last: no recorded runs yet — run something first',
126
+ )
127
+ }
128
+ const detail = getRun(db, inv.runId)
129
+
130
+ if (parsed.format === 'json') {
131
+ process.stdout.write(`${JSON.stringify({ invocation: inv, tasks: detail?.tasks ?? [] })}\n`)
132
+ return 0
133
+ }
134
+
135
+ const lines: string[] = []
136
+ lines.push(`run ${inv.runId} — ${inv.exitOk ? 'ok' : 'FAILED'}`)
137
+ lines.push(` $ ${inv.command}`)
138
+ lines.push(
139
+ ` ${fmtWhen(inv.startedAt)} · ${fmtMs(inv.totalDurationMs)}` +
140
+ `${inv.branch !== null ? ` · ${inv.branch}` : ''}` +
141
+ `${inv.commitSha !== null ? ` @ ${inv.commitSha.slice(0, 8)}${inv.dirty === true ? '+dirty' : ''}` : ''}` +
142
+ `${inv.ci ? ` · CI${inv.ciProvider !== null ? ` (${inv.ciProvider})` : ''}` : ''}`,
143
+ )
144
+ lines.push(
145
+ ` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
146
+ ` (${inv.hitLocalCount} local, ${inv.hitRemoteCount} remote)` +
147
+ `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
148
+ )
149
+ const tasks = detail?.tasks ?? []
150
+ if (tasks.length > 0) {
151
+ lines.push('')
152
+ const failedFirst = [
153
+ ...tasks.filter((t) => t.status === 'failed'),
154
+ ...tasks.filter((t) => t.status !== 'failed'),
155
+ ]
156
+ const idW = Math.max(...tasks.map((t) => `${t.project}#${t.task}`.length), 4)
157
+ for (const t of failedFirst) {
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
+ lines.push(
162
+ ` ${t.status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
163
+ `${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}`,
164
+ )
165
+ }
166
+ }
167
+ process.stdout.write(`${lines.join('\n')}\n`)
168
+ return 0
169
+ } finally {
170
+ cache.close()
171
+ }
172
+ }
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
  }