@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
@@ -8,7 +8,7 @@ import { lazyStatement } from './schema.js'
8
8
  import type { InvocationRecord, RunRecord } from './layer.js'
9
9
  import { isMeasure } from '../util/index.js'
10
10
 
11
- /** The `runs` columns `bindRun` fills, in its order. */
11
+ /** The `main.runs` columns `bindRun` fills, in its order. */
12
12
  const RUN_COLUMNS = [
13
13
  'hash',
14
14
  'project',
@@ -19,7 +19,7 @@ const RUN_COLUMNS = [
19
19
  'forward_args',
20
20
  'started_at',
21
21
  'ended_at',
22
- 'run_id',
22
+ 'run',
23
23
  'cpu_ms',
24
24
  'peak_rss_bytes',
25
25
  'wallclock_start_ns',
@@ -55,7 +55,7 @@ const RUN_COLUMNS = [
55
55
  */
56
56
  function insertRunsSql(kept: readonly number[], n: number): string {
57
57
  const tuple = `(${kept.map(() => '?').join(', ')})`
58
- return `INSERT INTO runs(${kept.map((i) => RUN_COLUMNS[i]).join(', ')}) VALUES ${Array(n).fill(tuple).join(', ')}`
58
+ return `INSERT INTO main.runs(${kept.map((i) => RUN_COLUMNS[i]).join(', ')}) VALUES ${Array(n).fill(tuple).join(', ')}`
59
59
  }
60
60
 
61
61
  /**
@@ -93,6 +93,10 @@ const PASSED_HASHES = `
93
93
  SELECT j.value AS hash FROM json_each(?) AS j
94
94
  WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
95
95
 
96
+ /** The commit a run ran on, as `task_durations` files its timings. */
97
+ type RunHead = Pick<InvocationRecord, 'branch' | 'commitSha'>
98
+ const NO_HEAD: RunHead = { branch: null, commitSha: null }
99
+
96
100
  /** A local run's `task_durations.source`: its branch, `''` when it had none. */
97
101
  export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
98
102
 
@@ -140,14 +144,19 @@ export class RunHistory {
140
144
  this.recordRuns([run])
141
145
  }
142
146
 
143
- recordRuns(runs: readonly RunRecord[]): void {
147
+ /**
148
+ * Rows with no `invocations` header (a stopped run, item 854). `head` is
149
+ * the commit the run ran on: its timings file under that branch, as a
150
+ * finished run's do, or the branch's forecast never reads them.
151
+ */
152
+ recordRuns(runs: readonly RunRecord[], head: RunHead = NO_HEAD): void {
144
153
  if (runs.length === 0) return
145
154
  // One transaction, one fsync: for a 200-task run, one instead of 200.
146
155
  // IMMEDIATE, as `recordRunBundle` explains.
147
156
  this.db
148
157
  .transaction(() => {
149
158
  this.insertRuns(runs)
150
- this.insertDurations(runs, branchSource(null), null)
159
+ this.insertDurations(runs, branchSource(head.branch), head.commitSha)
151
160
  this.insertOutcomes(runs)
152
161
  })
153
162
  .immediate()
@@ -239,7 +248,13 @@ export class RunHistory {
239
248
  if (d === undefined) digests.set(value, (d = this.digestValue(value)))
240
249
  return d
241
250
  }
242
- const bound = runs.map((r) => bindRun(r, digest))
251
+ const keys = new Map<string, number>()
252
+ const key = (runId: string): number => {
253
+ let k = keys.get(runId)
254
+ if (k === undefined) keys.set(runId, (k = this.runKey(runId)))
255
+ return k
256
+ }
257
+ const bound = runs.map((r) => bindRun(r, digest, key))
243
258
  const filled = RUN_COLUMNS.map(() => false)
244
259
  for (const row of bound)
245
260
  for (let c = 0; c < row.length; c++) if (row[c] !== null) filled[c] = true
@@ -255,6 +270,19 @@ export class RunHistory {
255
270
  }
256
271
  }
257
272
 
273
+ /** A run's row in `run_ids`, made on its first rows; inside their transaction. */
274
+ private runKey(runId: string): number {
275
+ const found = this.db.query('SELECT id FROM run_ids WHERE run_id = ?').get(runId) as {
276
+ id: number
277
+ } | null
278
+ return (
279
+ found ??
280
+ (this.db.query('INSERT INTO run_ids(run_id) VALUES (?) RETURNING id').get(runId) as {
281
+ id: number
282
+ })
283
+ ).id
284
+ }
285
+
258
286
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
259
287
  // Whole run records atomically — one transaction, one fsync: the
260
288
  // per-task `runs` rows and the one `invocations` header. The
@@ -291,7 +319,9 @@ export class RunHistory {
291
319
  runId: string,
292
320
  errors: readonly (readonly [hash: string, text: string])[],
293
321
  ): void {
294
- const update = this.db.prepare('UPDATE runs SET remote_error = ? WHERE run_id = ? AND hash = ?')
322
+ const update = this.db.prepare(
323
+ 'UPDATE main.runs SET remote_error = ? WHERE run = (SELECT id FROM run_ids WHERE run_id = ?) AND hash = ?',
324
+ )
295
325
  this.db
296
326
  .transaction(() => {
297
327
  for (const [hash, text] of errors) update.run(text, runId, hash)
@@ -307,7 +337,13 @@ export class RunHistory {
307
337
  * outlive its rows and `vx last` would list a run whose detail is gone.
308
338
  */
309
339
  pruneOlderThan(cutoff: number): void {
310
- this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
340
+ this.db.prepare('DELETE FROM main.runs WHERE started_at < ?').run(cutoff)
341
+ // A run's id goes with its last row; ids only grow, so the oldest kept row bounds them.
342
+ this.db
343
+ .prepare(
344
+ 'DELETE FROM run_ids WHERE id < COALESCE((SELECT MIN(run) FROM main.runs), (SELECT MAX(id) + 1 FROM run_ids))',
345
+ )
346
+ .run()
311
347
  this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
312
348
  this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
313
349
  this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
@@ -317,7 +353,11 @@ export class RunHistory {
317
353
  }
318
354
 
319
355
  /** A RunRecord as one value per `RUN_COLUMNS` entry, in its order. */
320
- function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
356
+ function bindRun(
357
+ run: RunRecord,
358
+ digestValue: (v: string) => string,
359
+ runKey: (runId: string) => number,
360
+ ): SQLQueryBindings[] {
321
361
  return [
322
362
  // The ONE place the no-key sentinel is applied, so the column's
323
363
  // NOT NULL invariant can't be violated from a call site.
@@ -333,7 +373,7 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
333
373
  run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
334
374
  run.startedAt,
335
375
  run.endedAt,
336
- run.runId ?? null,
376
+ run.runId === undefined ? null : runKey(run.runId),
337
377
  run.cpuMs ?? null,
338
378
  run.peakRssBytes ?? null,
339
379
  run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
@@ -23,6 +23,24 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
23
23
  } as unknown as ReturnType<Database['prepare']>
24
24
  }
25
25
 
26
+ /**
27
+ * What every reader and `vx last --sql` means by `runs`: the rows with
28
+ * their run_id, in the columns' old order. TEMP, so no other vx meets it: a
29
+ * persistent view would fail an older vx's `DROP TABLE runs` when it resets
30
+ * the index. It shadows main.runs, which only the writers name, so a
31
+ * VACUUM drops it first: rebuilding `runs_run ON runs(run)` resolves `runs`
32
+ * to the view and fails ("views may not be indexed").
33
+ */
34
+ export const RUNS_VIEW = `CREATE TEMP VIEW IF NOT EXISTS runs AS
35
+ SELECT r.id, r.hash, r.project, r.task, r.status, r.exit_code, r.duration_ms,
36
+ r.forward_args, r.started_at, r.ended_at, k.run_id, r.cpu_ms, r.peak_rss_bytes,
37
+ r.wallclock_start_ns, r.wallclock_end_ns, r.cache_hit, r.attempts, r.cached,
38
+ r.blocked_by, r.timed_out, r.sandbox_violations, r.not_ready, r.restored,
39
+ r.remote_error, r.peak_cores, r.avg_rss_bytes, r.placement, r.placement_cores,
40
+ r.placement_memory, r.keyless_by, r.def, r.cpu_share, r.queued_ms,
41
+ r.output_bytes, r.blocks, r.flaky_passes, r.flaky_failures
42
+ FROM main.runs r LEFT JOIN main.run_ids k ON k.id = r.run;`
43
+
26
44
  /**
27
45
  * Create every table. `store` names the schema holding the ENTRY tables
28
46
  * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
@@ -51,15 +69,21 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
51
69
  accessed_at INTEGER NOT NULL,
52
70
  -- v26: the producing execution's usage, from the artifact's sidecar.
53
71
  cpu_ms INTEGER,
54
- peak_rss_bytes INTEGER
72
+ peak_rss_bytes INTEGER,
73
+ -- v54: the run whose save or download wrote the entry as it stands
74
+ -- (created_at is when): \`vx last --log\` shows a run's log from the
75
+ -- entry only when it holds that run's output.
76
+ run_id TEXT
55
77
  );
56
78
  -- v29: an entry's stdout, which the <hash>.tar.zst artifact also
57
79
  -- carries so it survives a remote round trip; a local hit replays it
58
80
  -- from here. Its own table: an UPDATE rewrites a whole record, and the
59
81
  -- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
82
+ -- v52: a zstd frame of the UTF-8 (storedLog); as text, logs were most
83
+ -- of a store's bytes.
60
84
  CREATE TABLE IF NOT EXISTS ${store}.entry_stdout (
61
85
  hash TEXT PRIMARY KEY,
62
- stdout TEXT NOT NULL,
86
+ stdout BLOB NOT NULL,
63
87
  FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
64
88
  );
65
89
  CREATE TABLE IF NOT EXISTS main.runs (
@@ -75,10 +99,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
75
99
  forward_args TEXT,
76
100
  started_at INTEGER NOT NULL,
77
101
  ended_at INTEGER NOT NULL,
78
- -- v11 analytics columns. Nullable until the runner / orchestrator
79
- -- PRs populate them. Storing them now means we can swap on the
80
- -- producer side without touching the schema again.
81
- run_id TEXT,
102
+ -- v53: the run's row in run_ids, not its 36-char id: the text
103
+ -- and its index were a third of what an all-hit run added. The
104
+ -- \`runs\` view below shows it as run_id again.
105
+ run INTEGER,
82
106
  cpu_ms INTEGER,
83
107
  peak_rss_bytes INTEGER,
84
108
  wallclock_start_ns INTEGER,
@@ -135,7 +159,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
135
159
  flaky_failures INTEGER
136
160
  );
137
161
  -- Two whole-table indexes only, and both APPEND: every row of a run carries the
138
- -- same run_id and a started_at newer than everything before it, so
162
+ -- same run and a started_at newer than everything before it, so
139
163
  -- 1,000 inserts touch a handful of leaf pages. The dropped ones did
140
164
  -- not — the DROPs shed them from existing databases (a schema-meta
141
165
  -- bump is for stored shapes, and an index is not one):
@@ -156,7 +180,13 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
156
180
  DROP INDEX IF EXISTS main.runs_ended;
157
181
  DROP INDEX IF EXISTS main.runs_failed;
158
182
  CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
159
- CREATE INDEX IF NOT EXISTS main.runs_run_id ON runs(run_id);
183
+ CREATE INDEX IF NOT EXISTS main.runs_run ON runs(run);
184
+ -- v53: each run's id once; a runs row names it by this rowid.
185
+ CREATE TABLE IF NOT EXISTS main.run_ids (
186
+ id INTEGER PRIMARY KEY,
187
+ run_id TEXT NOT NULL UNIQUE
188
+ );
189
+ ${RUNS_VIEW}
160
190
  -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
161
191
  -- skip the content-hash on inputs whose stat hasn't changed
162
192
  -- since the last run. Pure performance optimization; the stored
@@ -215,7 +245,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
215
245
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
216
246
  );
217
247
  -- v28, its own table since v32: the inode and ctime THIS workspace saw
218
- -- after the save or restore that left the file equal to the entry. No
248
+ -- after the save or restore that left the file equal to the entry, and
249
+ -- since v55 its mtime: a restore dates a file it writes now, not with
250
+ -- the entry's saved mtime, so the row's is not the file's. No
219
251
  -- row: never current. Apart from output_files because the entry is
220
252
  -- shared and the disk is not: two worktrees on one store overwrote each
221
253
  -- other's stamps, and every switch between them restored. No foreign
@@ -226,6 +258,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
226
258
  path TEXT NOT NULL,
227
259
  ino INTEGER NOT NULL,
228
260
  ctime_ms INTEGER NOT NULL,
261
+ mtime_ms INTEGER NOT NULL,
229
262
  PRIMARY KEY (entry_hash, path)
230
263
  );
231
264
  -- Every directory under a whole-subtree output glob, with its mtime
package/src/cli/cache.ts CHANGED
@@ -1,7 +1,15 @@
1
- import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs'
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ existsSync,
5
+ lstatSync,
6
+ readdirSync,
7
+ renameSync,
8
+ rmSync,
9
+ } from 'node:fs'
2
10
  import { homedir } from 'node:os'
3
11
  import path from 'node:path'
4
- import { Cache, vxCacheEntries } from '../cache/index.js'
12
+ import { Cache, holdCacheDir, holdStore, vxCacheEntries } from '../cache/index.js'
5
13
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
6
14
  import {
7
15
  errorCode,
@@ -9,12 +17,13 @@ import {
9
17
  nearest,
10
18
  parseDuration,
11
19
  parseSize,
20
+ refuseForeignCacheDir,
12
21
  relPosix,
13
22
  treeBytes,
14
23
  } from '../util/index.js'
15
24
  import * as ui from '../ui/index.js'
16
25
  import { acquireRunLock } from '../orchestrator/index.js'
17
- import { findWorkspaceRoot } from '../workspace/index.js'
26
+ import { findWorkspaceRoot, repoIdsOf } from '../workspace/index.js'
18
27
  import {
19
28
  cliCacheDir,
20
29
  loadCliWorkspace,
@@ -233,16 +242,6 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
233
242
  printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
234
243
  return 0
235
244
  }
236
- const cache =
237
- parsed.dry === true
238
- ? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
239
- : new Cache(dir, undefined, root, undefined, 'open', storeRoot)
240
- // A prune deletes rows and artifacts; a cache this user cannot write is
241
- // refused up front with the directory named, as a run refuses it, rather
242
- // than dying in the first DELETE with SQLite's "readonly database" and a
243
- // stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
244
- // run only reads, and reads a read-only cache fine.
245
- if (!parsed.dry) cache.assertWritable()
246
245
  // A prune beside a run on this workspace evicts what the run has just
247
246
  // probed as hits, whose `accessed_at` bumps it has not flushed yet; the
248
247
  // run then re-runs those tasks (upstream survey, nx#36688). So a prune
@@ -251,13 +250,37 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
251
250
  ? async (): Promise<void> => {}
252
251
  : await acquireRunLock(root, { log: warnToStderr })
253
252
  try {
254
- const opts: { olderThanMs?: number; maxBytes?: number; dry?: boolean } = {}
255
- if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
256
- if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
257
- if (parsed.dry) opts.dry = true
258
- printPruned(await cache.prune(opts), dry, json)
253
+ // A run in another checkout of the repository holds no lock of this
254
+ // one's, and shares the store: the prune waits for every process that
255
+ // has the store open, then opens its own handle on it. A cache
256
+ // directory with no store may be another workspace's: the prune holds
257
+ // it until done, as its runs do.
258
+ if (!parsed.dry && storeRoot !== null) (await holdStore(storeRoot, warnToStderr))()
259
+ const releaseDir =
260
+ !parsed.dry && storeRoot === null
261
+ ? await holdCacheDir(dir, 'exclusive', warnToStderr)
262
+ : () => {}
263
+ const cache =
264
+ parsed.dry === true
265
+ ? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
266
+ : new Cache(dir, undefined, root, undefined, 'open', storeRoot)
267
+ try {
268
+ // A prune deletes rows and artifacts; a cache this user cannot write is
269
+ // refused up front with the directory named, as a run refuses it, rather
270
+ // than dying in the first DELETE with SQLite's "readonly database" and a
271
+ // stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
272
+ // run only reads, and reads a read-only cache fine.
273
+ if (!parsed.dry) cache.assertWritable()
274
+ const opts: { olderThanMs?: number; maxBytes?: number; dry?: boolean } = {}
275
+ if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
276
+ if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
277
+ if (parsed.dry) opts.dry = true
278
+ printPruned(await cache.prune(opts), dry, json)
279
+ } finally {
280
+ cache.close()
281
+ releaseDir()
282
+ }
259
283
  } finally {
260
- cache.close()
261
284
  await releaseRunLock()
262
285
  }
263
286
  return 0
@@ -325,28 +348,36 @@ async function clear(
325
348
  const release =
326
349
  parsed.dry === true ? async () => {} : await acquireRunLock(root, { log: warnToStderr })
327
350
  try {
328
- return clearHeld(args, root, dir, storeRoot, parsed)
351
+ return await clearHeld(args, root, dir, storeRoot, parsed)
329
352
  } finally {
330
353
  await release()
331
354
  }
332
355
  }
333
356
 
334
- function clearHeld(
357
+ async function clearHeld(
335
358
  args: readonly string[],
336
359
  root: string,
337
360
  dir: string,
338
361
  storeRoot: string | null,
339
362
  parsed: PruneArgs,
340
- ): number {
363
+ ): Promise<number> {
341
364
  // The store the index recorded and today's (a changed HOME, an origin
342
365
  // added) both go; a directory with no index is not a vx cache to delete.
343
366
  const targets: string[] = []
367
+ try {
368
+ refuseForeignCacheDir(dir, root)
369
+ } catch (err) {
370
+ if (!isUserError(err)) throw err
371
+ return refuse('cache clean', args, err.message, errorCode(err))
372
+ }
344
373
  if (existsSync(path.join(dir, 'cache.db'))) {
345
374
  // An index SQLite cannot read is what its own refusal sends here to
346
375
  // clear: today's store stands in for the one it recorded.
347
376
  let store: string | undefined = storeRoot ?? undefined
348
377
  try {
349
- const cache = new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
378
+ // No store root: given today's, the index answers with it, never
379
+ // the store it recorded.
380
+ const cache = new Cache(dir, undefined, undefined, undefined, 'preview')
350
381
  store = cache.storeDir
351
382
  cache.close()
352
383
  } catch (err) {
@@ -354,8 +385,12 @@ function clearHeld(
354
385
  }
355
386
  targets.push(dir)
356
387
  // The index is a file in the checkout (committed, or restored from a CI
357
- // cache): the store it names is deleted only when it is one.
358
- if (store !== undefined && isStore(store)) targets.push(store)
388
+ // cache): the store it names is deleted only when it is one, and this
389
+ // workspace's: an index copied from another named that one's store.
390
+ if (store !== undefined && store !== storeRoot && isStore(store)) {
391
+ const id = path.basename(path.dirname(path.resolve(store)))
392
+ if ((await repoIdsOf(root)).includes(id)) targets.push(store)
393
+ }
359
394
  // Today's store too: the next run reads it, whichever store the index
360
395
  // last recorded (a moved HOME, an origin added).
361
396
  if (storeRoot !== null) targets.push(storeRoot)
@@ -363,13 +398,18 @@ function clearHeld(
363
398
  if (parsed.global === true) {
364
399
  const vxHome = path.join(process.env['HOME'] || homedir(), '.vx')
365
400
  const ids = existsSync(vxHome) ? readdirSync(vxHome) : []
366
- for (const id of ids) targets.push(path.join(vxHome, id, 'cache'))
401
+ // `~/.vx` is not vx's alone to fill: a `cache` directory with no
402
+ // store.db is someone else's and stays.
403
+ for (const id of ids) {
404
+ const store = path.join(vxHome, id, 'cache')
405
+ if (isStore(store)) targets.push(store)
406
+ }
367
407
  }
368
408
  // `cache.dir: '.vx'` (or the root) would take the workspace config and
369
409
  // the lock with it.
370
410
  const dotVx = path.join(root, '.vx')
371
- const doomed = [...new Set(targets.map((t) => path.resolve(t)))].filter((t) => existsSync(t))
372
- const unsafe = doomed.find((t) => t === dotVx || !path.relative(t, dotVx).startsWith('..'))
411
+ const found = [...new Set(targets.map((t) => path.resolve(t)))].filter((t) => existsSync(t))
412
+ const unsafe = found.find((t) => t === dotVx || !path.relative(t, dotVx).startsWith('..'))
373
413
  if (unsafe !== undefined) {
374
414
  return refuse(
375
415
  'cache clean',
@@ -381,23 +421,69 @@ function clearHeld(
381
421
  const dry = parsed.dry === true
382
422
  // `.vx/cache` and the stores are vx's own; any other directory is the
383
423
  // user's, cleared entry by entry and removed only if that empties it.
384
- // A `.vx/cache` linked to a volume (a CI cache mount) is emptied through
385
- // the link: removing the link alone left the cache where it points.
424
+ // A `.vx/cache` linked to a volume (a CI cache mount) is cleared through
425
+ // the link: removing the link alone left the cache where it points. Only
426
+ // vx's entries there: emptying it took whatever else the target held.
386
427
  const own = path.join(dotVx, 'cache')
387
428
  const named = path.resolve(dir) === own ? undefined : path.resolve(dir)
388
429
  const linked = named === undefined && existsSync(own) && lstatSync(own).isSymbolicLink()
430
+ // Every target is asked before the first delete: a store another user
431
+ // owns (one `sudo vx run`) failed the clear after `.vx/cache` was gone.
432
+ // Such a store is left, as runs leave it for `.vx/cache`; a cache
433
+ // directory this user cannot remove refuses the clear whole.
434
+ const doomed: string[] = []
435
+ for (const t of found) {
436
+ const why = removeBlocked(t, t !== named && !(linked && t === own))
437
+ if (why === null) doomed.push(t)
438
+ else if (t !== path.resolve(dir))
439
+ warnToStderr(`cache store ${t} left as is: this user cannot remove it (${why})`)
440
+ else
441
+ return refuse(
442
+ 'cache clean',
443
+ args,
444
+ `cache directory ${t} cannot be removed by this user (${why}); nothing was deleted`,
445
+ 'VX_E_FS',
446
+ )
447
+ }
389
448
  const entries = (t: string): string[] =>
390
449
  t === named
391
450
  ? vxCacheEntries(t).map((e) => path.join(t, e))
392
451
  : linked && t === own
393
- ? readdirSync(t).map((e) => path.join(t, e))
452
+ ? [...vxCacheEntries(t), 'compiled']
453
+ .map((e) => path.join(t, e))
454
+ .filter((e) => existsSync(e))
394
455
  : [t]
395
456
  const bytes = doomed.reduce((n, t) => n + entries(t).reduce((m, e) => m + treeBytes(e), 0), 0)
396
457
  if (!dry) {
397
- for (const t of doomed) {
398
- for (const e of entries(t)) rmSync(e, { recursive: true, force: true })
399
- if (t === named && readdirSync(t).length === 0) rmSync(t, { recursive: true })
458
+ // A run in another checkout of the repository holds no lock of this
459
+ // one's: each store is deleted once no process has it open, and held
460
+ // until then; so is a cache directory with no store, which another
461
+ // workspace's runs may share. In path order, so two clears never wait
462
+ // on each other.
463
+ const shared = storeRoot === null ? path.resolve(dir) : undefined
464
+ const holds: (() => void)[] = []
465
+ const asides: string[] = []
466
+ try {
467
+ for (const t of [...doomed].sort()) {
468
+ holds.push(await holdStore(t, warnToStderr))
469
+ if (t === shared) holds.push(await holdCacheDir(t, 'exclusive', warnToStderr))
470
+ }
471
+ for (const t of doomed) {
472
+ for (const e of entries(t)) {
473
+ // A store leaves its name at once, under the hold, and is deleted
474
+ // after it. Deleted where it stood, a run in another checkout
475
+ // opening it mid-delete made a store.db there that the delete then
476
+ // took, with the entry the run saved.
477
+ const aside = e === t && t !== path.resolve(dir) ? moveAside(t) : undefined
478
+ if (aside !== undefined) asides.push(aside)
479
+ else rmSync(e, { recursive: true, force: true })
480
+ }
481
+ if (t === named && readdirSync(t).length === 0) rmSync(t, { recursive: true })
482
+ }
483
+ } finally {
484
+ for (const release of holds) release()
400
485
  }
486
+ for (const aside of asides) rmSync(aside, { recursive: true, force: true })
401
487
  }
402
488
  const shown = doomed.map((t) => (path.relative(root, t).startsWith('..') ? t : relPosix(root, t)))
403
489
  if (parsed.format === 'json') {
@@ -408,10 +494,37 @@ function clearHeld(
408
494
  'out',
409
495
  freedStatus(dry, bytes, shown.length > 0),
410
496
  ...(shown.length > 0 ? [ui.text(...shown)] : []),
497
+ // The index holds the run history: said where the user decides.
498
+ ...(doomed.includes(path.resolve(dir))
499
+ ? [ui.text(`run history ${dry ? 'goes' : 'cleared'} too: vx last and vx why start over`)]
500
+ : []),
411
501
  )
412
502
  return 0
413
503
  }
414
504
 
505
+ /** `dir` renamed beside itself, over a crashed clean's leftover, or undefined where it cannot be. */
506
+ function moveAside(dir: string): string | undefined {
507
+ const aside = `${dir}.removing`
508
+ try {
509
+ rmSync(aside, { recursive: true, force: true })
510
+ renameSync(dir, aside)
511
+ return aside
512
+ } catch {
513
+ return undefined
514
+ }
515
+ }
516
+
517
+ /** Why this user cannot remove `t` (`whole`) or empty it, or null. */
518
+ function removeBlocked(t: string, whole: boolean): string | null {
519
+ try {
520
+ accessSync(t, constants.R_OK | constants.W_OK | constants.X_OK)
521
+ if (whole) accessSync(path.dirname(t), constants.W_OK | constants.X_OK)
522
+ return null
523
+ } catch (err) {
524
+ return (err as NodeJS.ErrnoException).code ?? String(err)
525
+ }
526
+ }
527
+
415
528
  /** A store's shape: `<home>/.vx/<id>/cache` holding its `store.db`. */
416
529
  function isStore(stored: string): boolean {
417
530
  const dir = path.resolve(stored)
@@ -48,7 +48,7 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
48
48
  verb: v,
49
49
  // A plugin verb owns its own arguments; only `--help` is known here.
50
50
  words: [
51
- ...(VERB_WORDS[v] ?? []),
51
+ ...(v === 'help' ? verbs.filter((w) => w !== 'help') : (VERB_WORDS[v] ?? [])),
52
52
  ...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
53
53
  ],
54
54
  }))
@@ -155,7 +155,7 @@ export async function completionsCmd(
155
155
  'VX_E_USAGE',
156
156
  )
157
157
  }
158
- const one = shell !== undefined && args.length === 1
158
+ const one = shell !== undefined && shell !== '' && args.length === 1
159
159
  // One edit: shell names are three or four letters, so two edits took
160
160
  // `tcsh` to `bash`, a different shell.
161
161
  const best = one ? nearest(shell, SHELLS, 1) : undefined
@@ -9,7 +9,10 @@
9
9
  * and two module states — two `definePlugin` memos, two of everything
10
10
  * keyed by identity rather than by registry symbol.
11
11
  *
12
- * The alias is a Bun virtual module for the exact specifier `@vzn/vx`,
12
+ * The alias is a Bun virtual module for the exact specifiers `@vzn/vx`
13
+ * and `@vzn/vx/config` (the second for `import { defineProject }` in a
14
+ * config: with a node_modules and no `@vzn/vx` in it, the import failed
15
+ * though the page says vx runs a config without it),
13
16
  * served from the host's own façade, loaded lazily on the first import
14
17
  * so a verb that never loads a plugin never loads the façade. The
15
18
  * source form (`bun src/bin.ts`) resolves the specifier to the same file
@@ -18,11 +21,15 @@
18
21
  * (workspace/config-bytecode.ts) is CommonJS, and `require` refuses a
19
22
  * module whose loader is async.
20
23
  */
21
- export function registerCoreAlias(load: () => Record<string, unknown>): void {
24
+ export function registerCoreAlias(
25
+ load: () => Record<string, unknown>,
26
+ loadConfig: () => Record<string, unknown>,
27
+ ): void {
22
28
  Bun.plugin({
23
29
  name: 'vx-core-alias',
24
30
  setup(build) {
25
31
  build.module('@vzn/vx', () => ({ exports: load(), loader: 'object' }))
32
+ build.module('@vzn/vx/config', () => ({ exports: loadConfig(), loader: 'object' }))
26
33
  },
27
34
  })
28
35
  }
package/src/cli/docs.ts CHANGED
@@ -31,10 +31,12 @@ export function parseDocsArgs(args: readonly string[]): DocsArgs {
31
31
  out.json = fv === 'json'
32
32
  } else if (a === '--limit' || a.startsWith('--limit=')) {
33
33
  const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
34
- if (v === undefined || v === '') {
34
+ // The space form took the next flag as the count and refused it as one.
35
+ const flag = a === '--limit' && v !== undefined && /^-\D/.test(v) ? ` (got flag ${v})` : ''
36
+ if (v === undefined || v === '' || flag !== '') {
35
37
  return {
36
38
  ...out,
37
- error: `--limit requires a value: a whole number above 0${seeHelp('docs')}`,
39
+ error: `--limit requires a value: a whole number above 0${flag}${seeHelp('docs')}`,
38
40
  }
39
41
  }
40
42
  const n = parseDecimalInt(v)
package/src/cli/help.ts CHANGED
@@ -90,12 +90,33 @@ export function verbHelpText(verb: string): string {
90
90
  if (own.length > 0) kept.push(['Usage:', ...own].join('\n'))
91
91
  continue
92
92
  }
93
- if (lines.some(names)) kept.push(block)
93
+ if (lines.some(names)) kept.push(own(lines, names, quoted))
94
94
  }
95
95
  if (kept.length === 0) return helpText()
96
96
  return [title, ...kept, 'Full reference: vx help', ''].join('\n\n')
97
97
  }
98
98
 
99
+ /**
100
+ * A section's entries for one verb, when the section holds several verbs'
101
+ * entries (Introspection): an entry is a two-space line and the deeper
102
+ * lines under it; a shared one (`info, why and last:`) stays if it names
103
+ * the verb. A one-verb or `(for <verb>)` section stays whole.
104
+ */
105
+ function own(lines: string[], names: (line: string) => boolean, quoted: string): string {
106
+ const entries: string[][] = []
107
+ for (const line of lines.slice(1)) {
108
+ if (/^ {2}\S/.test(line) || entries.length === 0) entries.push([line])
109
+ else entries.at(-1)!.push(line)
110
+ }
111
+ const verbs = new Set(entries.map((e) => /^ {2}vx ([\w-]+)/.exec(e[0]!)?.[1]).filter(Boolean))
112
+ if (verbs.size < 2) return lines.join('\n')
113
+ const word = new RegExp(`\\b${quoted}\\b`)
114
+ const mine = entries.filter(
115
+ (e) => names(e[0]!) || (!e[0]!.startsWith(' vx ') && word.test(e[0]!)),
116
+ )
117
+ return [lines[0]!, ...mine.flat()].join('\n')
118
+ }
119
+
99
120
  export function helpText(pluginCommands: readonly string[] = []): string {
100
121
  return [
101
122
  'vx — task orchestration you own',
@@ -141,7 +162,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
141
162
  ' --no-cache No cache reads or writes; outputs are left',
142
163
  ' alone.',
143
164
  ' --force Skip cache reads but still write: every task',
144
- ' runs and refreshes its entry.',
165
+ ' runs; a key already stored is kept.',
145
166
  ' --cache-policy <spec> Per-layer read/write: a comma list of',
146
167
  ' <layer>:<flags> (layer local or remote, flags',
147
168
  ' from rw). A named layer is set to exactly its',
@@ -366,6 +387,8 @@ export function flagHint(verb: string, arg: string): string {
366
387
  // `--json` is gh's and Nx's spelling; a verb with `--format` reads it
367
388
  // there, and no edit budget reaches `--format json` from `--json`.
368
389
  if (name === '--json' && flags.includes('--format')) return ' (did you mean --format json?)'
390
+ // pnpm's and Turbo's short `--filter`.
391
+ if (name === '-F') return flags.includes('--filter') ? ' (did you mean --filter?)' : ''
369
392
  // `vx run` prints no JSON of its own, and edit distance reached `--force`
370
393
  // from `--format`: a run with its cache reads turned off.
371
394
  if ((name === '--format' || name === '--json') && flags.includes('--dry'))