@vzn/vx 0.0.651 → 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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  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 +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. 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',
@@ -48,21 +48,22 @@ const RUN_COLUMNS = [
48
48
  'flaky_failures',
49
49
  ]
50
50
 
51
- const INSERT_RUNS = `INSERT INTO runs(${RUN_COLUMNS.join(', ')}) VALUES `
52
- const RUN_TUPLE = `(${RUN_COLUMNS.map(() => '?').join(', ')})`
53
-
54
- /** An INSERT of `n` `runs` rows. */
55
- function insertRunsSql(n: number): string {
56
- return INSERT_RUNS + Array(n).fill(RUN_TUPLE).join(', ')
51
+ /**
52
+ * An INSERT of `n` `runs` rows over the columns `kept` names (indexes into
53
+ * `RUN_COLUMNS`); a column it leaves out is NULL, as no `runs` column has
54
+ * a default.
55
+ */
56
+ function insertRunsSql(kept: readonly number[], n: number): string {
57
+ const tuple = `(${kept.map(() => '?').join(', ')})`
58
+ return `INSERT INTO main.runs(${kept.map((i) => RUN_COLUMNS[i]).join(', ')}) VALUES ${Array(n).fill(tuple).join(', ')}`
57
59
  }
58
60
 
59
61
  /**
60
- * Rows per INSERT: as many as stay within 999 bound variables, the oldest
61
- * default ceiling, read off the column count (33 rows were sized for 30
62
- * columns and bound 1,023 at 31). A statement per row was 8.7 ms of 1,000
63
- * rows, 40 at a time 6.4 (2026-10-03).
62
+ * Bound variables per INSERT: within 999, the oldest default ceiling. A
63
+ * statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
64
+ * (2026-10-03).
64
65
  */
65
- const RUNS_PER_INSERT = Math.floor(999 / RUN_COLUMNS.length)
66
+ const VARIABLES_PER_INSERT = 999
66
67
 
67
68
  /** Rows per `task_durations` INSERT: 7 variables each, under 999. */
68
69
  const DURATIONS_PER_INSERT = 140
@@ -92,6 +93,10 @@ const PASSED_HASHES = `
92
93
  SELECT j.value AS hash FROM json_each(?) AS j
93
94
  WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
94
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
+
95
100
  /** A local run's `task_durations.source`: its branch, `''` when it had none. */
96
101
  export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
97
102
 
@@ -139,14 +144,34 @@ export class RunHistory {
139
144
  this.recordRuns([run])
140
145
  }
141
146
 
142
- 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 {
143
153
  if (runs.length === 0) return
144
154
  // One transaction, one fsync: for a 200-task run, one instead of 200.
145
155
  // IMMEDIATE, as `recordRunBundle` explains.
146
156
  this.db
147
157
  .transaction(() => {
148
158
  this.insertRuns(runs)
149
- this.insertDurations(runs, branchSource(null), null)
159
+ this.insertDurations(runs, branchSource(head.branch), head.commitSha)
160
+ this.insertOutcomes(runs)
161
+ })
162
+ .immediate()
163
+ }
164
+
165
+ /**
166
+ * Hits written while the run goes on (history-stream.ts): their rows and
167
+ * the flaky record's first hit of a key. A hit times nothing, so it
168
+ * leaves no `task_durations` row; IMMEDIATE, as `recordRunBundle` says.
169
+ */
170
+ recordHits(runs: readonly RunRecord[]): void {
171
+ if (runs.length === 0) return
172
+ this.db
173
+ .transaction(() => {
174
+ this.insertRuns(runs)
150
175
  this.insertOutcomes(runs)
151
176
  })
152
177
  .immediate()
@@ -209,7 +234,13 @@ export class RunHistory {
209
234
  }
210
235
  }
211
236
 
212
- /** `runs` as few INSERTs; each distinct forward-args list digested once. */
237
+ /**
238
+ * `runs` as few INSERTs; each distinct forward-args list digested once.
239
+ * Only the columns some row fills are bound: a hit leaves half of them
240
+ * NULL, and binding 18 of 36 took the 8,002 rows of a warm run from
241
+ * 31.7 to 24.0 ms alone, the whole record stage 86.8 to 83.3 ms (median
242
+ * of 15 interleaved runs, 2026-11-06).
243
+ */
213
244
  private insertRuns(runs: readonly RunRecord[]): void {
214
245
  const digests = new Map<string, string>()
215
246
  const digest = (value: string): string => {
@@ -217,15 +248,41 @@ export class RunHistory {
217
248
  if (d === undefined) digests.set(value, (d = this.digestValue(value)))
218
249
  return d
219
250
  }
220
- for (let i = 0; i < runs.length; i += RUNS_PER_INSERT) {
221
- const block = runs.slice(i, i + RUNS_PER_INSERT)
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))
258
+ const filled = RUN_COLUMNS.map(() => false)
259
+ for (const row of bound)
260
+ for (let c = 0; c < row.length; c++) if (row[c] !== null) filled[c] = true
261
+ const kept: number[] = []
262
+ for (let c = 0; c < filled.length; c++) if (filled[c]) kept.push(c)
263
+ const perInsert = Math.floor(VARIABLES_PER_INSERT / kept.length)
264
+ for (let i = 0; i < bound.length; i += perInsert) {
265
+ const block = bound.slice(i, i + perInsert)
222
266
  const params: SQLQueryBindings[] = []
223
- for (const r of block) for (const v of bindRun(r, digest)) params.push(v)
267
+ for (const row of block) for (const c of kept) params.push(row[c] as SQLQueryBindings)
224
268
  // `query` caches by text: one statement for every full block.
225
- this.db.query(insertRunsSql(block.length)).run(...params)
269
+ this.db.query(insertRunsSql(kept, block.length)).run(...params)
226
270
  }
227
271
  }
228
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
+
229
286
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
230
287
  // Whole run records atomically — one transaction, one fsync: the
231
288
  // per-task `runs` rows and the one `invocations` header. The
@@ -262,7 +319,9 @@ export class RunHistory {
262
319
  runId: string,
263
320
  errors: readonly (readonly [hash: string, text: string])[],
264
321
  ): void {
265
- 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
+ )
266
325
  this.db
267
326
  .transaction(() => {
268
327
  for (const [hash, text] of errors) update.run(text, runId, hash)
@@ -278,7 +337,13 @@ export class RunHistory {
278
337
  * outlive its rows and `vx last` would list a run whose detail is gone.
279
338
  */
280
339
  pruneOlderThan(cutoff: number): void {
281
- 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()
282
347
  this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
283
348
  this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
284
349
  this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
@@ -287,8 +352,12 @@ export class RunHistory {
287
352
  }
288
353
  }
289
354
 
290
- /** Bind a RunRecord to one `RUN_TUPLE`'s positional parameters, in `RUN_COLUMNS` order. */
291
- function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
355
+ /** A RunRecord as one value per `RUN_COLUMNS` entry, in its order. */
356
+ function bindRun(
357
+ run: RunRecord,
358
+ digestValue: (v: string) => string,
359
+ runKey: (runId: string) => number,
360
+ ): SQLQueryBindings[] {
292
361
  return [
293
362
  // The ONE place the no-key sentinel is applied, so the column's
294
363
  // NOT NULL invariant can't be violated from a call site.
@@ -304,7 +373,7 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
304
373
  run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
305
374
  run.startedAt,
306
375
  run.endedAt,
307
- run.runId ?? null,
376
+ run.runId === undefined ? null : runKey(run.runId),
308
377
  run.cpuMs ?? null,
309
378
  run.peakRssBytes ?? null,
310
379
  run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
@@ -6,6 +6,9 @@
6
6
 
7
7
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
8
8
 
9
+ /** SQLite's file-control opcode (sqlite3.h). */
10
+ export const SQLITE_FCNTL_PERSIST_WAL = 10
11
+
9
12
  /**
10
13
  * A statement prepared on its first `run` or `get`. A warm run executes few
11
14
  * of the index's statements, and preparing every one at the open cost each
@@ -20,6 +23,24 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
20
23
  } as unknown as ReturnType<Database['prepare']>
21
24
  }
22
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
+
23
44
  /**
24
45
  * Create every table. `store` names the schema holding the ENTRY tables
25
46
  * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
@@ -48,15 +69,21 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
48
69
  accessed_at INTEGER NOT NULL,
49
70
  -- v26: the producing execution's usage, from the artifact's sidecar.
50
71
  cpu_ms INTEGER,
51
- 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
52
77
  );
53
78
  -- v29: an entry's stdout, which the <hash>.tar.zst artifact also
54
79
  -- carries so it survives a remote round trip; a local hit replays it
55
80
  -- from here. Its own table: an UPDATE rewrites a whole record, and the
56
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.
57
84
  CREATE TABLE IF NOT EXISTS ${store}.entry_stdout (
58
85
  hash TEXT PRIMARY KEY,
59
- stdout TEXT NOT NULL,
86
+ stdout BLOB NOT NULL,
60
87
  FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
61
88
  );
62
89
  CREATE TABLE IF NOT EXISTS main.runs (
@@ -72,10 +99,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
72
99
  forward_args TEXT,
73
100
  started_at INTEGER NOT NULL,
74
101
  ended_at INTEGER NOT NULL,
75
- -- v11 analytics columns. Nullable until the runner / orchestrator
76
- -- PRs populate them. Storing them now means we can swap on the
77
- -- producer side without touching the schema again.
78
- 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,
79
106
  cpu_ms INTEGER,
80
107
  peak_rss_bytes INTEGER,
81
108
  wallclock_start_ns INTEGER,
@@ -84,7 +111,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
84
111
  -- v23: attempts a retried task took (>1); NULL for a once-run task.
85
112
  -- The direct within-run flaky signal.
86
113
  attempts INTEGER,
87
- -- v25: 1 when the task declared a cache block, 0 when it runs every
114
+ -- v25: 1 when the task declared cache: true, 0 when it runs every
88
115
  -- time by design; NULL on rows older than the column.
89
116
  cached INTEGER,
90
117
  -- v27: why a task failed or was skipped, as the run's own footer
@@ -132,7 +159,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
132
159
  flaky_failures INTEGER
133
160
  );
134
161
  -- Two whole-table indexes only, and both APPEND: every row of a run carries the
135
- -- 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
136
163
  -- 1,000 inserts touch a handful of leaf pages. The dropped ones did
137
164
  -- not — the DROPs shed them from existing databases (a schema-meta
138
165
  -- bump is for stored shapes, and an index is not one):
@@ -153,7 +180,13 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
153
180
  DROP INDEX IF EXISTS main.runs_ended;
154
181
  DROP INDEX IF EXISTS main.runs_failed;
155
182
  CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
156
- 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}
157
190
  -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
158
191
  -- skip the content-hash on inputs whose stat hasn't changed
159
192
  -- since the last run. Pure performance optimization; the stored
@@ -212,7 +245,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
212
245
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
213
246
  );
214
247
  -- v28, its own table since v32: the inode and ctime THIS workspace saw
215
- -- 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
216
251
  -- row: never current. Apart from output_files because the entry is
217
252
  -- shared and the disk is not: two worktrees on one store overwrote each
218
253
  -- other's stamps, and every switch between them restored. No foreign
@@ -223,6 +258,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
223
258
  path TEXT NOT NULL,
224
259
  ino INTEGER NOT NULL,
225
260
  ctime_ms INTEGER NOT NULL,
261
+ mtime_ms INTEGER NOT NULL,
226
262
  PRIMARY KEY (entry_hash, path)
227
263
  );
228
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,
@@ -105,7 +114,7 @@ interface PruneArgs {
105
114
  format?: 'pretty' | 'json'
106
115
  /** `--cache-dir`: prune the cache a run with the same flag uses. */
107
116
  cacheDir?: string
108
- /** `--global`: clear every repository's store under `~/.vx`. */
117
+ /** `--global`: clear every workspace's store under `~/.vx`. */
109
118
  global?: boolean
110
119
  error?: string
111
120
  }
@@ -181,7 +190,7 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
181
190
  }
182
191
  if (out.global === true && (out.olderThanMs !== undefined || out.maxBytes !== undefined)) {
183
192
  return {
184
- error: `--global clears every repository's store and takes no --older-than or --max-size${seeHelp('cache')}`,
193
+ error: `--global clears every workspace's store and takes no --older-than or --max-size${seeHelp('cache')}`,
185
194
  }
186
195
  }
187
196
  if (out.global === true && out.cacheDir !== undefined) {
@@ -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