@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
package/src/cli/index.ts CHANGED
@@ -43,12 +43,21 @@ export async function run(argv: readonly string[]): Promise<number> {
43
43
  await printHelp(await (await plugins()).pluginCommandHelp(), command)
44
44
  return 0
45
45
  }
46
+ if (command !== undefined && forwardsNothing(command, rest)) {
47
+ return refuse(
48
+ command,
49
+ rest,
50
+ 'forwards nothing after -- (only vx run and vx watch pass arguments on to a task)',
51
+ 'VX_E_USAGE',
52
+ )
53
+ }
46
54
  // Each setting's variable once, before any verb: a renamed one is named,
47
55
  // and a deep reader trusts what it reads. Not for help, as `vx run
48
56
  // --help` above is not: help is where a setting's values are read. Nor
49
- // for `vx info`, which reports a refused one (doctor.ts).
57
+ // for `vx info`, which reports a refused one (doctor.ts). Nor for one
58
+ // whose flag is given: the flag beats it.
50
59
  if (command !== 'help' && command !== '--help' && command !== '-h' && command !== 'info')
51
- checkSettingsEnv()
60
+ checkSettingsEnv(process.env, rest)
52
61
 
53
62
  try {
54
63
  return await dispatch(command, rest)
@@ -93,6 +102,21 @@ function refuseControlArgs(command: string, rest: readonly string[]): void {
93
102
  }
94
103
  }
95
104
 
105
+ /**
106
+ * A `--` given to a core verb that hands no task its words: each parser
107
+ * refused it as `unknown flag: --`, true of no flag. A `--sql` query is
108
+ * the one value that may be `--`.
109
+ */
110
+ function forwardsNothing(command: string, rest: readonly string[]): boolean {
111
+ if (command === 'run' || command === 'watch') return false
112
+ if (!(CORE_VERBS as readonly string[]).includes(command)) return false
113
+ for (let i = 0; i < rest.length; i++) {
114
+ if (rest[i] === '--sql') i++
115
+ else if (rest[i] === '--') return true
116
+ }
117
+ return false
118
+ }
119
+
96
120
  /**
97
121
  * A crash from a hand-broken lock entry is that entry's fault: runs validate
98
122
  * no entry, so it surfaced as a raw TypeError (2026-10-14). The lock is
@@ -148,7 +172,9 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
148
172
  // A name that is no verb here is the typo `vx rnu` is, not a request
149
173
  // for the whole reference: it printed 145 lines and no hint.
150
174
  const resolved = await (await plugins()).resolvePluginCommand(verb)
151
- const unknown = resolved === null || 'declaredVerbs' in resolved
175
+ // A workspace that failed to load cannot say the verb exists: the
176
+ // whole reference and exit 0 hid the broken file (`vx okk` says so).
177
+ const unknown = resolved === null || !('plugin' in resolved)
152
178
  if (unknown && verb.startsWith('-')) {
153
179
  return refuse(
154
180
  'help',
@@ -158,15 +184,20 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
158
184
  )
159
185
  }
160
186
  if (unknown) {
161
- const declared = resolved === null ? [] : resolved.declaredVerbs
187
+ const declared =
188
+ resolved !== null && 'declaredVerbs' in resolved ? resolved.declaredVerbs : []
189
+ const loadNote =
190
+ resolved !== null && 'loadError' in resolved
191
+ ? ` (plugin verbs could not be looked up: .vx/workspace failed to load: ${resolved.loadError})`
192
+ : ''
162
193
  return refuse(
163
194
  'help',
164
195
  rest,
165
- `unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)`,
196
+ `unknown command: ${verb}${didYouMeanVerb(verb, declared)}${loadNote} (see \`vx help\`)`,
166
197
  'VX_E_UNKNOWN_COMMAND',
167
198
  )
168
199
  }
169
- if (resolved !== null && !('loadError' in resolved)) return dispatch(verb, ['--help'])
200
+ return dispatch(verb, ['--help'])
170
201
  }
171
202
  await printHelp(await (await plugins()).pluginCommandHelp(), core ? verb : undefined)
172
203
  return 0
@@ -239,7 +270,7 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
239
270
  // that returned 256 (a count of failures, say) exited 0.
240
271
  if (!Number.isInteger(code) || code < 0 || code > 255) {
241
272
  throw new UserError(
242
- `plugin '${resolved.plugin.name}': command '${command}' resolved ${JSON.stringify(code)} instead of an exit code (an integer 0–255)`,
273
+ `plugin '${resolved.plugin.name}': command '${command}' resolved ${typeof code === 'bigint' ? `${String(code)}n` : typeof code === 'number' || code === undefined ? String(code) : JSON.stringify(code)} instead of an exit code (an integer 0–255)`,
243
274
  )
244
275
  }
245
276
  return code
package/src/cli/info.ts CHANGED
@@ -101,7 +101,7 @@ function infoReport(f: InfoFacts): ui.Block {
101
101
  ],
102
102
  ['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
103
103
  ['cache entries', describeEntries(f)],
104
- // Only the settings a variable or the workspace changed.
104
+ // Only the settings a variable, the workspace or a plugin changed.
105
105
  ...(Object.values(f.settings).some((s) => s.source !== 'default')
106
106
  ? ([['settings', describeSettings(f.settings)]] as [string, string][])
107
107
  : []),
@@ -141,14 +141,15 @@ function describeEntries(f: InfoFacts): string {
141
141
 
142
142
  /**
143
143
  * The settings not at their default, with where each came from:
144
- * `run.timeout 30m (VX_RUN_TIMEOUT), cache.policy local:rw (.vx/workspace)`.
144
+ * `run.timeout 30m (VX_RUN_TIMEOUT), cache.policy local:rw (.vx/workspace)`,
145
+ * `cache.scope pr-2 (plugin @org/ci)`.
145
146
  */
146
147
  function describeSettings(settings: InfoFacts['settings']): string {
147
148
  return Object.entries(settings)
148
149
  .filter(([, s]) => s.source !== 'default')
149
150
  .map(
150
151
  ([path, s]) =>
151
- `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : '.vx/workspace'}${s.refused === undefined ? '' : `, refused: ${s.refused}`})`,
152
+ `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : s.source === 'plugin' ? `plugin ${s.plugin}` : '.vx/workspace'}${s.refused === undefined ? '' : `, refused: ${s.refused}`})`,
152
153
  )
153
154
  .join(', ')
154
155
  }
package/src/cli/init.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // `vx init --plugin <hook>` writes a runnable plugin for one hook and its
7
7
  // test instead (plugin-templates.ts, the examples the gate runs).
8
8
 
9
- import { existsSync, readFileSync } from 'node:fs'
9
+ import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs'
10
10
  import { mkdir, unlink } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { gitTop, workspaceFileWritten } from '../handoff.js'
@@ -132,11 +132,17 @@ export async function initCmd(args: readonly string[]): Promise<number> {
132
132
  dirs.push(d)
133
133
  if (d === top || path.dirname(d) === d) break
134
134
  }
135
- if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
135
+ const pkgRoot = packageRoot(dirs, top !== null)
136
+ // Outside git `dirs` reach `/`: a stray turbo.json or nx.json above the
137
+ // project took init over, as a stray package.json did (2026-10-28).
138
+ const own = top !== null ? dirs : dirs.slice(0, dirs.indexOf(pkgRoot ?? cwd) + 1)
139
+ if (own.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
136
140
  return migrate(dirs, parsed)
137
141
  }
138
- const pkgRoot = packageRoot(dirs, top !== null)
139
- return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
142
+ // Asked of git itself: a kept workspace file is `top` too, and the
143
+ // `git init` hint went missing where the next run would refuse.
144
+ const noGit = marked === undefined ? top === null : (await gitTop(cwd)) === null
145
+ return starter(marked ?? pkgRoot ?? cwd, pkgRoot, noGit, parsed)
140
146
  }
141
147
 
142
148
  /**
@@ -314,6 +320,20 @@ function ownerNamed(dir: string): boolean {
314
320
  }
315
321
  }
316
322
 
323
+ /**
324
+ * Whether anything stands at `file`, a dangling symlink included: writing
325
+ * through one created its target outside the workspace.
326
+ */
327
+ function occupied(file: string): boolean {
328
+ try {
329
+ lstatSync(file)
330
+ return true
331
+ } catch {
332
+ // quiet: nothing there (a parent that is a file is refused at the write)
333
+ return false
334
+ }
335
+ }
336
+
317
337
  /** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
318
338
  async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
319
339
  const template = PLUGIN_TEMPLATES[seam]!
@@ -329,13 +349,14 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
329
349
  ] as const
330
350
  if (!args.force) {
331
351
  for (const [rel] of files) {
332
- if (await Bun.file(path.join(root, rel)).exists()) {
352
+ if (occupied(path.join(root, rel))) {
333
353
  sayNotice('error', 'vx init', `${rel} exists. --force overwrites it`)
334
354
  return 1
335
355
  }
336
356
  }
337
357
  }
338
358
  if (!args.dry) {
359
+ for (const [rel] of files) refuseSymlink(path.join(root, rel), root)
339
360
  await mkdir(path.join(root, 'plugins'), { recursive: true }).catch(
340
361
  (err: NodeJS.ErrnoException) => {
341
362
  if (err.code !== 'EEXIST' && err.code !== 'ENOTDIR') throw err
@@ -344,7 +365,7 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
344
365
  )
345
366
  },
346
367
  )
347
- for (const [rel, text] of files) await Bun.write(path.join(root, rel), text)
368
+ for (const [rel, text] of files) await writeNoFollow(path.join(root, rel), text, root)
348
369
  }
349
370
  const factory = /^export function (\w+)/m.exec(template.plugin)![1]!
350
371
  const workspaceFile =
@@ -423,7 +444,7 @@ async function starter(
423
444
  ): Promise<number> {
424
445
  if (args.mode !== undefined) {
425
446
  throw new UserError(
426
- `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
447
+ `vx init: --${args.mode} answers a Turbo or Nx workspace's question, and there is no turbo.json or nx.json here`,
427
448
  'VX_E_USAGE',
428
449
  )
429
450
  }
@@ -450,11 +471,20 @@ async function starter(
450
471
  ],
451
472
  [PROJECT_CONFIG_FILENAMES, config, starterProject(ext)],
452
473
  ] as const) {
453
- const existing = names.find((n) => existsSync(path.join(root, n)))
454
- if (existing !== undefined && !args.force) kept.push(existing)
474
+ const existing = names.filter((n) => existsSync(path.join(root, n)))
475
+ // Kept, a directory read as the workspace's marker, and every verb after
476
+ // then found none and sent the user back here.
477
+ const dir = existing.find((n) => statSync(path.join(root, n)).isDirectory())
478
+ if (dir !== undefined) {
479
+ throw new UserError(
480
+ `vx init: ${dir} is a directory, where the ${name === config ? 'project config' : 'workspace file'} goes; move it and re-run`,
481
+ )
482
+ }
483
+ if (existing.length > 0 && !args.force) kept.push(existing[0]!)
455
484
  else {
456
485
  files.push({ path: name, contents })
457
- if (existing !== undefined && existing !== name) replaced.push(existing)
486
+ // Every other name goes too: a second config beside the one written.
487
+ replaced.push(...existing.filter((n) => n !== name))
458
488
  }
459
489
  }
460
490
  // vx's own housekeeping, as a run's first cache open and `vx lock` write them.
@@ -465,7 +495,10 @@ async function starter(
465
495
  if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
466
496
  }
467
497
  const declare = `declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then vx lock && vx run <task>`
468
- const next = `${noGit ? 'git init, ' : ''}${hasScripts(pkgRoot) ? `bunx @vzn/vx-migrate${args.mjs ? ' --mjs' : ''} to make package.json scripts tasks, or ${declare}` : declare}`
498
+ // Run in the cwd, a git init below the root it chose hides the workspace from every verb.
499
+ const gitInit =
500
+ root === process.cwd() ? 'git init' : `git init ${path.relative(process.cwd(), root)}`
501
+ const next = `${noGit ? `${gitInit}, ` : ''}${hasScripts(pkgRoot) ? `bunx @vzn/vx-migrate${args.mjs ? ' --mjs' : ''} to make package.json scripts tasks, or ${declare}` : declare}`
469
502
  if (args.json === true) {
470
503
  ui.data(
471
504
  'out',
@@ -473,7 +506,7 @@ async function starter(
473
506
  )
474
507
  return 0
475
508
  }
476
- const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
509
+ const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json']]
477
510
  // The files are root-relative: a root above the cwd is named, or they read as written here.
478
511
  if (root !== process.cwd()) rows.push(['root', root])
479
512
  if (args.dry) rows.push(['dry run', 'nothing written'])
@@ -486,17 +519,19 @@ async function starter(
486
519
  ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
487
520
  return 0
488
521
  }
489
- for (const f of files) refuseSymlink(path.join(root, f.path))
522
+ for (const f of files) refuseSymlink(path.join(root, f.path), root)
490
523
  const ran = await install(pkgRoot, false, plugins)
491
524
  if (ran.length > 0) rows.push(['installed', ...ran])
492
525
  for (const f of files) {
493
- await writeNoFollow(path.join(root, f.path), f.contents).catch((err: NodeJS.ErrnoException) => {
494
- // writeNoFollow's mkdir of a parent that is a file says EEXIST.
495
- if (err.code !== 'ENOTDIR' && err.code !== 'EEXIST') throw err
496
- throw new UserError(
497
- `vx init: cannot write ${f.path}: ${path.dirname(f.path)} is a file, not a directory; move it and re-run`,
498
- )
499
- })
526
+ await writeNoFollow(path.join(root, f.path), f.contents, root).catch(
527
+ (err: NodeJS.ErrnoException) => {
528
+ // writeNoFollow's mkdir of a parent that is a file says EEXIST.
529
+ if (err.code !== 'ENOTDIR' && err.code !== 'EEXIST') throw err
530
+ throw new UserError(
531
+ `vx init: cannot write ${f.path}: ${path.dirname(f.path)} is a file, not a directory; move it and re-run`,
532
+ )
533
+ },
534
+ )
500
535
  }
501
536
  for (const f of replaced) await unlink(path.join(root, f))
502
537
  // The lock that follows loads the workspace file just written.
package/src/cli/last.ts CHANGED
@@ -2,11 +2,12 @@
2
2
  // without re-executing anything. Comparison gap #12: with the dashboard's
3
3
  // run-detail page gone (the 2026-08-23 cloud removal), the local run
4
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.
5
+ // Read-only — no config evaluation, no re-hash, no cache probe; `--stats`
6
+ // reads the lock only for each task's definition.
6
7
 
7
8
  import type { Database } from 'bun:sqlite'
8
9
  import { indexDb, Cache, keptSteps, keptWarnings, type RunStep } from '../cache/index.js'
9
- import { formatBytes } from './format.js'
10
+ import { buildTaskGraph, expandRequested, isDefaultBuild, taskDefinitions } from '../graph/index.js'
10
11
  import { flagHint, formatValue, seeHelp } from './help.js'
11
12
  import {
12
13
  type FailureMode,
@@ -18,7 +19,9 @@ import {
18
19
  type RunSummaryRow,
19
20
  } from '../history/index.js'
20
21
  import {
22
+ applyGraphStage,
21
23
  currentBranch,
24
+ hasHook,
22
25
  type LiveSnapshot,
23
26
  liveSocketPath,
24
27
  exitSignal,
@@ -28,7 +31,13 @@ import {
28
31
  shortRunId,
29
32
  taskLog,
30
33
  } from '../orchestrator/index.js'
31
- import { formatRecordedRow, outcomeWord, taskList, type WireEvent } from '../report/index.js'
34
+ import {
35
+ formatRecordedRow,
36
+ formatSize,
37
+ outcomeWord,
38
+ taskList,
39
+ type WireEvent,
40
+ } from '../report/index.js'
32
41
  import {
33
42
  formatElapsed,
34
43
  nearest,
@@ -39,9 +48,18 @@ import {
39
48
  } from '../util/index.js'
40
49
  import * as ui from '../ui/index.js'
41
50
  import { sayNotice } from '../ui/index.js'
42
- import { findWorkspaceRoot } from '../workspace/index.js'
51
+ import { runRules } from '../settings.js'
52
+ import { buildPackageGraph, findWorkspaceRoot } from '../workspace/index.js'
43
53
  import { findCwdProject } from './select.js'
44
- import { cliCacheDir, parseCacheDirFlag, rootSpelled } from './workspace-config.js'
54
+ import {
55
+ cliCacheDir,
56
+ discoverCliProjects,
57
+ loadCliProjects,
58
+ loadCliWorkspace,
59
+ parseCacheDirFlag,
60
+ rootSpelled,
61
+ stageCacheDir,
62
+ } from './workspace-config.js'
45
63
 
46
64
  interface LastArgs {
47
65
  runId?: string
@@ -68,6 +86,9 @@ interface LastArgs {
68
86
  error?: string
69
87
  }
70
88
 
89
+ /** The run grid's mark for a number history does not hold. */
90
+ const UNKNOWN = '\u2014'
91
+
71
92
  /**
72
93
  * A run id as typed beside `--list`: a UUIDv7's first group (8 hex digits)
73
94
  * at least, whole or cut as `--list` prints it.
@@ -127,10 +148,16 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
127
148
  }
128
149
  if (a === '--sql' || a.startsWith('--sql=')) {
129
150
  const v = a === '--sql' ? args[++i] : a.slice('--sql='.length)
130
- if (v === undefined || v.trim() === '') {
151
+ // The space form took the next flag as the query, then blamed that
152
+ // flag for combining with --sql.
153
+ const flag =
154
+ a === '--sql' && v?.startsWith('-') === true
155
+ ? ` (got flag ${v}; a query that starts with - needs --sql=${v})`
156
+ : ''
157
+ if (v === undefined || v.trim() === '' || flag !== '') {
131
158
  return {
132
159
  ...out,
133
- error: `--sql requires a query, like 'SELECT * FROM runs'${seeHelp('last')}`,
160
+ error: `--sql requires a query, like 'SELECT * FROM runs'${flag}${seeHelp('last')}`,
134
161
  }
135
162
  }
136
163
  out.sql = v
@@ -276,18 +303,71 @@ export interface TaskStats {
276
303
  maxCpuParallelism: number | null
277
304
  }
278
305
 
306
+ /**
307
+ * Each task's definition (`taskDefinitions`) as a run builds the graph
308
+ * now: the lock's configs with the `project` and `graph` stages applied.
309
+ * Undefined when none loads (no lock, a broken config): then every
310
+ * definition's rows are read, as before definitions were kept.
311
+ */
312
+ async function definitionsNow(
313
+ root: string,
314
+ cacheDir: string | undefined,
315
+ ): Promise<Map<string, string> | undefined> {
316
+ try {
317
+ // A plugin's warning is the run's to say, not a history read's.
318
+ const opts = { warn: () => {}, ...(cacheDir === undefined ? {} : { cacheDir }) }
319
+ const metas = await discoverCliProjects(root)
320
+ const projects = await loadCliProjects(root, metas, 'all', opts)
321
+ const ws = await loadCliWorkspace(root, opts.warn)
322
+ const rules = runRules(ws.workspaceConfig)
323
+ const names = new Set<string>()
324
+ for (const p of projects.values())
325
+ for (const t of Object.keys(p.config.tasks ?? {})) names.add(t)
326
+ const nodes = buildTaskGraph({
327
+ projects,
328
+ packageGraph: buildPackageGraph(metas),
329
+ requested: expandRequested([...names], [...projects.keys()], projects, isDefaultBuild),
330
+ workspaceRoot: root,
331
+ rules,
332
+ })
333
+ if (hasHook(ws.plugins, 'graph'))
334
+ await applyGraphStage(ws.plugins, nodes, {
335
+ workspaceRoot: root,
336
+ cacheDir: stageCacheDir(ws, opts),
337
+ projectMetas: metas,
338
+ warn: opts.warn,
339
+ rules,
340
+ })
341
+ return taskDefinitions(nodes.values())
342
+ } catch {
343
+ return undefined
344
+ }
345
+ }
346
+
347
+ /** Each task's definition as run `runId` recorded it: what its live rows read history by. */
348
+ function runDefinitions(db: Database, runId: string): Map<string, string> {
349
+ const rows = db
350
+ .query(
351
+ `SELECT r.project, r.task, r.def FROM main.runs r JOIN main.run_ids k ON k.id = r.run
352
+ WHERE k.run_id = ? AND r.def IS NOT NULL`,
353
+ )
354
+ .all(runId) as { project: string; task: string; def: string }[]
355
+ return new Map(rows.map((r) => [`${r.project}#${r.task}`, r.def]))
356
+ }
357
+
279
358
  /**
280
359
  * `--stats`: what the scheduler and `--dry` learn from history, per task,
281
360
  * over the same window (`LocalHistoryProvider`), with the p50 a run on
282
- * `branch` forecasts.
361
+ * `branch` forecasts from the timings of each task's definition `defs`.
283
362
  */
284
363
  async function printStats(
285
364
  db: Database,
286
365
  target: string | undefined,
287
366
  format: 'pretty' | 'json',
288
367
  branch: string | null,
368
+ defs: ReadonlyMap<string, string> | undefined,
289
369
  ): Promise<number> {
290
- const provider = new LocalHistoryProvider(db, undefined, branch)
370
+ const provider = new LocalHistoryProvider(db, undefined, branch, defs)
291
371
  const matches = (id: string): boolean =>
292
372
  target === undefined || (target.includes('#') ? id === target : splitTaskId(id)[1] === target)
293
373
  const stats: TaskStats[] = [...(await provider.loadAll())]
@@ -319,7 +399,9 @@ async function printStats(
319
399
  return 0
320
400
  }
321
401
  const pct = (r: number): string => `${Math.round(r * 100)}%`
322
- const ms = (v: number | null): string => (v === null ? '-' : fmtMs(v))
402
+ // The run grid's units and its unknown mark, so one number reads alike
403
+ // in both: cpu `127%` (was `1.3×`), mem `3.6MB` (was `3.6 MB`), `—`.
404
+ const ms = (v: number | null): string => (v === null ? UNKNOWN : fmtMs(v))
323
405
  const right = (title: string): ui.Column => ({ title, align: 'right' })
324
406
  ui.print(
325
407
  'out',
@@ -345,8 +427,8 @@ async function printStats(
345
427
  pct(s.successRate),
346
428
  pct(s.hitRate),
347
429
  s.failureMode,
348
- s.maxPeakRssBytes === null ? '-' : formatBytes(s.maxPeakRssBytes),
349
- s.maxCpuParallelism === null ? '-' : `${s.maxCpuParallelism.toFixed(1)}×`,
430
+ s.maxPeakRssBytes === null ? UNKNOWN : formatSize(s.maxPeakRssBytes),
431
+ s.maxCpuParallelism === null ? UNKNOWN : pct(s.maxCpuParallelism),
350
432
  ]),
351
433
  ),
352
434
  ),
@@ -373,7 +455,8 @@ function printQuery(db: Database, sql: string, format: 'pretty' | 'json'): numbe
373
455
  db.exec('PRAGMA query_only = ON')
374
456
  const refused = (err: unknown): never => {
375
457
  const message = err instanceof Error ? err.message : String(err)
376
- if (/readonly|query_only/i.test(message)) {
458
+ // `runs` is a view: a write to it fails before query_only is asked.
459
+ if (/readonly|query_only|cannot modify/i.test(message)) {
377
460
  throw new UserError(`vx last: --sql reads only: ${message}`, 'VX_E_USAGE')
378
461
  }
379
462
  throw new UserError(`vx last: --sql: ${message}`, 'VX_E_USAGE')
@@ -714,9 +797,19 @@ export function formatTaskRows(
714
797
  // The run's own grid (`taskList`): a replay reads as the run did. What
715
798
  // the run said below the row (an exit, why it skipped) rides it dim.
716
799
  const rows = ordered.map((t) => {
800
+ // A cached failure with no cache outcome is a hit whose restore failed: no
801
+ // exit. One whose command exited 0 failed in its save (a server that
802
+ // exited 0 before ready says so in its reason).
803
+ const failure =
804
+ t.status !== 'failed'
805
+ ? ''
806
+ : t.cached === true && t.cacheHit === null
807
+ ? 'cache restore failed'
808
+ : t.exitCode === 0 && t.notReady === null
809
+ ? 'cache save failed'
810
+ : `exit ${t.exitCode}`
717
811
  const note = (
718
- (t.status === 'failed' ? `exit ${t.exitCode}` : '') +
719
- reasonParts(t, skippedHere, inputMoved.get(`${t.project}#${t.task}`))
812
+ failure + reasonParts(t, skippedHere, inputMoved.get(`${t.project}#${t.task}`))
720
813
  ).trim()
721
814
  const id = `${t.project}#${t.task}`
722
815
  const usualMs = usual === undefined ? undefined : (usual.get(id) ?? null)
@@ -798,7 +891,9 @@ export function formatRunList(
798
891
  async function bareTaskId(db: Database, task: string): Promise<string> {
799
892
  if (task.includes('#')) return task
800
893
  const ids = (
801
- db.query('SELECT DISTINCT project FROM runs WHERE task = ? ORDER BY project').all(task) as {
894
+ db
895
+ .query('SELECT DISTINCT project FROM main.runs WHERE task = ? ORDER BY project')
896
+ .all(task) as {
802
897
  project: string
803
898
  }[]
804
899
  ).map((r) => `${r.project}#${task}`)
@@ -831,7 +926,13 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
831
926
  if (parsed.stats === true) {
832
927
  const branch = currentBranch(root, undefined, parsed.branch)
833
928
  const task = parsed.task === undefined ? undefined : await rootSpelled(root, parsed.task)
834
- return await printStats(db, task, parsed.format, branch)
929
+ return await printStats(
930
+ db,
931
+ task,
932
+ parsed.format,
933
+ branch,
934
+ await definitionsNow(root, parsed.cacheDir),
935
+ )
835
936
  }
836
937
 
837
938
  if (parsed.log !== undefined) {
@@ -844,19 +945,22 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
844
945
  // The id's form is hinted only when it lacks one: a task that
845
946
  // never ran was told how to spell a task id. A typo of one that
846
947
  // ran is named, as `vx why` names it.
847
- const ran = parsed.log.includes('#')
848
- ? nearest(
849
- parsed.log,
850
- (
851
- db.query('SELECT DISTINCT project, task FROM runs').all() as {
852
- project: string
853
- task: string
854
- }[]
855
- ).map((r) => `${r.project}#${r.task}`),
856
- )
857
- : undefined
948
+ const recorded = parsed.log.includes('#')
949
+ ? new LocalHistoryProvider(db).recentTaskIds()
950
+ : []
951
+ const ran = nearest(parsed.log, recorded)
952
+ // No near name: what of that project has run, so the next try is a pick.
953
+ const project = parsed.log.slice(0, parsed.log.lastIndexOf('#') + 1)
954
+ const theirs = recorded
955
+ .filter((id) => project !== '' && id.startsWith(project))
956
+ .map((id) => id.slice(project.length))
957
+ .sort()
958
+ const listed =
959
+ ran === undefined && theirs.length > 0
960
+ ? `; ${project.slice(0, -1)}'s recorded tasks: ${theirs.join(', ')}`
961
+ : ''
858
962
  throw new UserError(
859
- `vx last: no recorded run of ${parsed.log}${parsed.runId === undefined ? '' : ` in run ${parsed.runId}`}${parsed.log.includes('#') ? '' : ' (a task id is project#task)'}${ran === undefined ? '' : ` — did you mean ${ran}?`}`,
963
+ `vx last: no recorded run of ${parsed.log}${parsed.runId === undefined ? '' : ` in run ${parsed.runId}`}${parsed.log.includes('#') ? '' : ' (a task id is project#task)'}${ran === undefined ? '' : ` — did you mean ${ran}?`}${listed}`,
860
964
  'VX_E_NO_HISTORY',
861
965
  )
862
966
  }
@@ -865,8 +969,9 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
865
969
  return 0
866
970
  }
867
971
  if (log.source === null) {
868
- // A cached task with no entry was not saved (a read-only policy) or
869
- // was pruned; calling it uncached sends the user to the config.
972
+ // A cached task with no entry of its own was not saved (a read-only
973
+ // policy, a `--force` run that kept the entry it found), or its entry
974
+ // was pruned or saved over; calling it uncached sends the user to the config.
870
975
  const [project, task] = splitTaskId(log.taskId)
871
976
  const cached =
872
977
  log.status !== 'failed' &&
@@ -875,14 +980,27 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
875
980
  .query('SELECT cached FROM runs WHERE run_id = ? AND project = ? AND task = ?')
876
981
  .get(log.runId, project, task) as { cached: number | null } | null
877
982
  )?.cached === 1
983
+ // A skip ran nothing: the output to read is its blocker's.
984
+ const blocker =
985
+ log.status === 'skipped'
986
+ ? (
987
+ db
988
+ .query(
989
+ 'SELECT blocked_by AS b FROM runs WHERE run_id = ? AND project = ? AND task = ?',
990
+ )
991
+ .get(log.runId, project, task) as { b: string | null } | null
992
+ )?.b
993
+ : null
878
994
  sayNotice(
879
995
  'info',
880
996
  'vx last',
881
- log.status === 'failed'
882
- ? `${log.taskId} ended failed, and vx kept no output for it: it keeps the newest 50 failed runs`
883
- : cached
884
- ? `${log.taskId} ended ${log.status}, and its entry is not in the cache: the run did not save it, or it was pruned since`
885
- : `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
997
+ typeof blocker === 'string'
998
+ ? `${log.taskId} did not run: ${blocker} blocked it (vx last --log ${blocker})`
999
+ : log.status === 'failed'
1000
+ ? `${log.taskId} ended failed, and vx kept no output for it: it keeps the newest 50 failed runs`
1001
+ : cached
1002
+ ? `${log.taskId} ended ${log.status}, and the cache does not hold its output: the run did not save it, or it was pruned or saved over since`
1003
+ : `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
886
1004
  )
887
1005
  } else if (log.output === '') {
888
1006
  sayNotice('info', 'vx last', `${log.taskId} ended ${log.status} and printed nothing`)
@@ -917,18 +1035,34 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
917
1035
  return 0
918
1036
  }
919
1037
 
1038
+ const named = parsed.runId === undefined ? null : resolveRunId(db, parsed.runId, 'vx last')
920
1039
  const inv =
921
1040
  parsed.runId !== undefined
922
- ? getInvocation(db, resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
1041
+ ? getInvocation(db, named ?? parsed.runId)
923
1042
  : parsed.failed === true
924
1043
  ? (failedInvocations(db, 1)[0] ?? null)
925
1044
  : (listInvocations(db, { limit: 1 })[0] ?? null)
1045
+ // A run with rows and no header is one a stop ended (item 854): `vx why`
1046
+ // printed its id, so it is named, not called unrecorded.
1047
+ if (named !== null && (inv === null || inv === undefined)) {
1048
+ throw new UserError(
1049
+ `vx last: run ${named} was stopped before it finished, so vx kept its tasks but no summary: \`vx last ${named} --log <task>\` prints one's output`,
1050
+ 'VX_E_NO_HISTORY',
1051
+ )
1052
+ }
926
1053
  if ((inv === null || inv === undefined) && noRuns()) {
927
1054
  throw refusal(
928
1055
  { tone: 'info', headline: 'no runs yet', fix: 'vx run' },
929
1056
  { code: 'VX_E_NO_HISTORY', message: 'vx last: no recorded runs yet — run something first' },
930
1057
  )
931
1058
  }
1059
+ // Nothing failed is no fault: said as `--list --failed` says it.
1060
+ if ((inv === null || inv === undefined) && parsed.failed === true) {
1061
+ throw refusal(
1062
+ { tone: 'info', headline: 'no failed runs', fix: 'vx last' },
1063
+ { code: 'VX_E_NO_HISTORY', message: 'vx last: no failed runs' },
1064
+ )
1065
+ }
932
1066
  if (inv === null || inv === undefined) {
933
1067
  throw new UserError(
934
1068
  noRuns()
@@ -937,9 +1071,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
937
1071
  ? `vx last: ${parsed.runId} is a task, not a run id — \`vx last --log ${parsed.runId}\` prints its output, \`vx last --stats ${parsed.runId}\` its durations`
938
1072
  : parsed.runId !== undefined
939
1073
  ? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
940
- : parsed.failed === true
941
- ? 'vx last: no recorded run failed'
942
- : 'vx last: no recorded runs yet — run something first',
1074
+ : 'vx last: no recorded runs yet — run something first',
943
1075
  'VX_E_NO_HISTORY',
944
1076
  )
945
1077
  }
@@ -1007,7 +1139,18 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
1007
1139
  const ranIds = (detail?.tasks ?? [])
1008
1140
  .filter((t) => t.status === 'success' || t.status === 'failed')
1009
1141
  .map((t) => `${t.project}#${t.task}`)
1010
- const usual = new LocalHistoryProvider(db, undefined, currentBranch(root)).p50sNow(ranIds)
1142
+ // Each task's timings of the definition it ran under, as its live row
1143
+ // read them: an older definition's timings are another task's.
1144
+ const history = new LocalHistoryProvider(
1145
+ db,
1146
+ undefined,
1147
+ currentBranch(root),
1148
+ runDefinitions(db, inv.runId),
1149
+ )
1150
+ const usual = new Map(history.p50sNow(ranIds))
1151
+ // A task whose one timing is this run's own has no usual to compare
1152
+ // with: `+0ms` claimed a baseline the live row rightly showed as `—`.
1153
+ for (const id of history.timedOnlySince(ranIds, inv.startedAt)) usual.delete(id)
1011
1154
  const taskRows = formatTaskRows(detail?.tasks ?? [], moved, usual)
1012
1155
  if (taskRows.length > 0)
1013
1156
  blocks.push(ui.section('Tasks', String(detail?.tasks.length ?? 0), ...taskRows))