@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -6,18 +6,45 @@
6
6
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
7
  import { lazyStatement } from './schema.js'
8
8
  import type { InvocationRecord, RunRecord } from './layer.js'
9
+ import { isMeasure } from '../util/index.js'
9
10
 
10
- const INSERT_RUNS = `
11
- INSERT INTO runs(
12
- hash, project, task, status, exit_code, duration_ms, forward_args,
13
- started_at, ended_at,
14
- run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
15
- cache_hit, attempts, cached,
16
- blocked_by, timed_out, sandbox_violations, not_ready, restored, remote_error
17
- )
18
- VALUES `
19
- /** One `runs` row's placeholders: the 23 columns `bindRun` fills. */
20
- const RUN_TUPLE = `(${Array(23).fill('?').join(', ')})`
11
+ /** The `runs` columns `bindRun` fills, in its order. */
12
+ const RUN_COLUMNS = [
13
+ 'hash',
14
+ 'project',
15
+ 'task',
16
+ 'status',
17
+ 'exit_code',
18
+ 'duration_ms',
19
+ 'forward_args',
20
+ 'started_at',
21
+ 'ended_at',
22
+ 'run_id',
23
+ 'cpu_ms',
24
+ 'peak_rss_bytes',
25
+ 'wallclock_start_ns',
26
+ 'wallclock_end_ns',
27
+ 'cache_hit',
28
+ 'attempts',
29
+ 'cached',
30
+ 'blocked_by',
31
+ 'timed_out',
32
+ 'sandbox_violations',
33
+ 'not_ready',
34
+ 'restored',
35
+ 'remote_error',
36
+ 'peak_cores',
37
+ 'avg_rss_bytes',
38
+ 'placement',
39
+ 'placement_cores',
40
+ 'placement_memory',
41
+ 'keyless_by',
42
+ 'def',
43
+ 'cpu_share',
44
+ ]
45
+
46
+ const INSERT_RUNS = `INSERT INTO runs(${RUN_COLUMNS.join(', ')}) VALUES `
47
+ const RUN_TUPLE = `(${RUN_COLUMNS.map(() => '?').join(', ')})`
21
48
 
22
49
  /** An INSERT of `n` `runs` rows. */
23
50
  function insertRunsSql(n: number): string {
@@ -25,14 +52,15 @@ function insertRunsSql(n: number): string {
25
52
  }
26
53
 
27
54
  /**
28
- * Rows per INSERT: 40 × 23 variables stays under 999, the oldest default
29
- * ceiling. A statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
30
- * (2026-10-03).
55
+ * Rows per INSERT: as many as stay within 999 bound variables, the oldest
56
+ * default ceiling, read off the column count (33 rows were sized for 30
57
+ * columns and bound 1,023 at 31). A statement per row was 8.7 ms of 1,000
58
+ * rows, 40 at a time 6.4 (2026-10-03).
31
59
  */
32
- const RUNS_PER_INSERT = 40
60
+ const RUNS_PER_INSERT = Math.floor(999 / RUN_COLUMNS.length)
33
61
 
34
- /** Rows per `task_durations` INSERT: 6 variables each, under 999. */
35
- const DURATIONS_PER_INSERT = 160
62
+ /** Rows per `task_durations` INSERT: 7 variables each, under 999. */
63
+ const DURATIONS_PER_INSERT = 140
36
64
 
37
65
  /** Rows per `task_outcomes` INSERT: 6 variables each, under 999. */
38
66
  const OUTCOMES_PER_INSERT = 160
@@ -62,6 +90,20 @@ const PASSED_HASHES = `
62
90
  /** A local run's `task_durations.source`: its branch, `''` when it had none. */
63
91
  export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
64
92
 
93
+ /**
94
+ * How long a run would have taken alone: on a crowded worker
95
+ * (`cpuShare` < 1) each runnable thread waited for a core. A task that
96
+ * kept `n` cores busy lost its CPU's wait spread over them, under one
97
+ * core all of it. vx-dte#test (20 s CPU in 75 s) took 81-102 s beside
98
+ * test shards and the plan, reading those, said 118 s for a 92 s run
99
+ * (main fd5c72e, 2026-10-23).
100
+ */
101
+ function aloneMs(r: Pick<RunRecord, 'durationMs' | 'cpuMs' | 'cpuShare'>): number {
102
+ const { durationMs: d, cpuMs: c, cpuShare: s } = r
103
+ if (c === undefined || s === undefined || !(s > 0 && s < 1) || !(d > 0)) return d
104
+ return Math.round(d - (c / s - c) / Math.max(1, c / (d * s)))
105
+ }
106
+
65
107
  export class RunHistory {
66
108
  private readonly insertInvocation: ReturnType<Database['prepare']>
67
109
 
@@ -105,16 +147,19 @@ export class RunHistory {
105
147
  .immediate()
106
148
  }
107
149
 
108
- /** One `task_durations` row per executed success: what a forecast reads. */
150
+ /** One `task_durations` row per executed success, as if run alone: what a forecast reads. */
109
151
  private insertDurations(runs: readonly RunRecord[], source: string, sha: string | null): void {
110
- const done = runs.filter((r) => r.status === 'success' && r.cacheHit !== true)
152
+ const done = runs.filter(
153
+ (r) => r.status === 'success' && r.cacheHit !== true && isMeasure(aloneMs(r)),
154
+ )
111
155
  for (let i = 0; i < done.length; i += DURATIONS_PER_INSERT) {
112
156
  const block = done.slice(i, i + DURATIONS_PER_INSERT)
113
157
  const params: SQLQueryBindings[] = []
114
- for (const r of block) params.push(r.project, r.task, source, sha, r.durationMs, r.endedAt)
158
+ for (const r of block)
159
+ params.push(r.project, r.task, source, sha, aloneMs(r), r.endedAt, r.def ?? null)
115
160
  this.db
116
161
  .query(
117
- `INSERT INTO task_durations(project, task, source, sha, duration_ms, recorded_at) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?)').join(', ')}`,
162
+ `INSERT INTO task_durations(project, task, source, sha, duration_ms, recorded_at, def) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?, ?)').join(', ')}`,
118
163
  )
119
164
  .run(...params)
120
165
  }
@@ -170,7 +215,7 @@ export class RunHistory {
170
215
  for (let i = 0; i < runs.length; i += RUNS_PER_INSERT) {
171
216
  const block = runs.slice(i, i + RUNS_PER_INSERT)
172
217
  const params: SQLQueryBindings[] = []
173
- for (const r of block) params.push(...bindRun(r, digest))
218
+ for (const r of block) for (const v of bindRun(r, digest)) params.push(v)
174
219
  // `query` caches by text: one statement for every full block.
175
220
  this.db.query(insertRunsSql(block.length)).run(...params)
176
221
  }
@@ -233,10 +278,11 @@ export class RunHistory {
233
278
  this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
234
279
  this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
235
280
  this.db.prepare('DELETE FROM plugin_blobs WHERE at < ?').run(cutoff)
281
+ this.db.prepare('DELETE FROM remote_hits WHERE seen_at < ?').run(cutoff)
236
282
  }
237
283
  }
238
284
 
239
- /** Bind a RunRecord to one `RUN_TUPLE`'s 23 positional parameters. */
285
+ /** Bind a RunRecord to one `RUN_TUPLE`'s 29 positional parameters. */
240
286
  function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
241
287
  return [
242
288
  // The ONE place the no-key sentinel is applied, so the column's
@@ -267,6 +313,14 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
267
313
  run.notReady ?? null,
268
314
  run.restored === undefined ? null : run.restored ? 1 : 0,
269
315
  run.remoteError ?? null,
316
+ run.peakCores ?? null,
317
+ run.avgRssBytes ?? null,
318
+ run.placement ?? null,
319
+ run.placementCores ?? null,
320
+ run.placementMemoryBytes ?? null,
321
+ run.keylessBy ?? null,
322
+ run.def ?? null,
323
+ run.cpuShare ?? null,
270
324
  ]
271
325
  }
272
326
 
@@ -1,4 +1,4 @@
1
- // What core keeps of a run that writes a remote (owner 2026-10-15): a failed
1
+ // What core keeps of a run that writes a remote (2026-10-15): a failed
2
2
  // task's log and the files its outputs left, an uncached task's log. Names
3
3
  // come from the run id and task (`run/<id>/<task>/log`); each key is derived
4
4
  // in a namespace no task key or plugin entry shares. Remote only: locally
@@ -6,7 +6,7 @@
6
6
  // the cache is free). Plugins read them by name (`ctx.cache.getRun`).
7
7
 
8
8
  import { xxh3hex } from '../util/index.js'
9
- import type { ResolvedRemote } from './remote-lane.js'
9
+ import { sayRemoteFault, type ResolvedRemote } from './remote-lane.js'
10
10
  import { scopedKey } from './scoped-cache.js'
11
11
  import { tarPackInto, tarSize } from './tar-stream.js'
12
12
 
@@ -30,8 +30,8 @@ export function writesRunStore(remotes: readonly ResolvedRemote[]): boolean {
30
30
 
31
31
  /**
32
32
  * Store `bytes` under `name` in every remote the policy writes. An entry is
33
- * written once; a remote's failure leaves the others theirs, silently: what
34
- * a run keeps here is a record, never its verdict.
33
+ * written once; a remote's failure is said and leaves the others theirs:
34
+ * what a run keeps here is a record, never its verdict.
35
35
  */
36
36
  export async function putRunEntry(
37
37
  remotes: readonly ResolvedRemote[],
@@ -41,16 +41,17 @@ export async function putRunEntry(
41
41
  ): Promise<boolean> {
42
42
  const key = scope === undefined ? runKey(name) : scopedKey(scope, runKey(name))
43
43
  let kept = false
44
- for (const { remote, write } of remotes) {
45
- if (!write) continue
44
+ for (const r of remotes) {
45
+ if (!r.write) continue
46
46
  try {
47
- await remote.put(key, new Blob([bytes]), {
47
+ const put = await r.remote.put(key, new Blob([bytes]), {
48
48
  durationMs: 0,
49
49
  ...(scope === undefined ? {} : { scope }),
50
50
  })
51
- kept = true
52
- } catch {
53
- // the other remotes still keep it
51
+ // `false`: the remote sent nothing, so nothing is kept there.
52
+ if (put !== false) kept = true
53
+ } catch (err) {
54
+ sayRemoteFault(r, err instanceof Error ? err : new Error(String(err)))
54
55
  }
55
56
  }
56
57
  return kept
@@ -24,7 +24,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
24
24
  * Create every table. `store` names the schema holding the ENTRY tables
25
25
  * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
26
26
  * the task timings (`task_durations`), the flaky record (`task_outcomes`),
27
- * the blob sizes (`blob_sizes`) and the plugins' blobs (`plugin_blobs`):
27
+ * the blob sizes (`blob_sizes`), the plugins' blobs (`plugin_blobs`) and
28
+ * the remotes' hits (`remote_hits`):
28
29
  * `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
29
30
  * they live in the shared store attached beside it (v32). The rest is the
30
31
  * workspace's own: its history, its memos, and what its disk looked like
@@ -101,7 +102,24 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
101
102
  restored INTEGER,
102
103
  -- v40: each remote read or write of the key that failed, as
103
104
  -- 'upload: <message>', '; '-joined; NULL when none did.
104
- remote_error TEXT
105
+ remote_error TEXT,
106
+ -- v43: the most cores one sampling interval burned and the mean
107
+ -- resident memory over the looks; NULL when nothing sampled.
108
+ peak_cores REAL,
109
+ avg_rss_bytes INTEGER,
110
+ -- v44: where an executor ran it (ExecuteResult.where) and that
111
+ -- machine's cores and memory; NULL = here.
112
+ placement TEXT,
113
+ placement_cores INTEGER,
114
+ placement_memory INTEGER,
115
+ -- v46: the task with no inputs its key folded (TaskOutcome.keylessBy).
116
+ keyless_by TEXT,
117
+ -- v48: the task's definition (taskDefinition): a history reader
118
+ -- counts only the rows of the task as it is now.
119
+ def TEXT,
120
+ -- v49: the share of a core each runnable thread got where it ran
121
+ -- (ExecuteResult.machine.cpuShare); NULL = all it wanted.
122
+ cpu_share REAL
105
123
  );
106
124
  -- Two whole-table indexes only, and both APPEND: every row of a run carries the
107
125
  -- same run_id and a started_at newer than everything before it, so
@@ -152,7 +170,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
152
170
  -- worktree size git recorded (A-60). Fixed for its OID, so a warm
153
171
  -- run asks git for none; swept with file_hashes by seen_at, the
154
172
  -- time the row was written. v38: in the store, so a worktree reads
155
- -- what another learned (owner, 2026-10-14).
173
+ -- what another learned (2026-10-14).
156
174
  CREATE TABLE IF NOT EXISTS ${store}.blob_sizes (
157
175
  oid TEXT PRIMARY KEY,
158
176
  size INTEGER NOT NULL,
@@ -269,7 +287,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
269
287
  );
270
288
  -- What a duration forecast reads (orchestrator/history.ts), one row per
271
289
  -- executed success, shared by every worktree of the repo so a new one
272
- -- forecasts on its first run (owner, 2026-10-14). Append-only: never
290
+ -- forecasts on its first run (2026-10-14). Append-only: never
273
291
  -- updated, pruned by age with the run history. \`source\` tags a local
274
292
  -- run 'branch:<name>' ('branch:' detached or outside git); \`sha\` is its
275
293
  -- commit. A forecast reads a task's newest rows of one source.
@@ -280,7 +298,15 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
280
298
  source TEXT NOT NULL,
281
299
  sha TEXT,
282
300
  duration_ms INTEGER NOT NULL,
283
- recorded_at INTEGER NOT NULL
301
+ recorded_at INTEGER NOT NULL,
302
+ -- v45: a 'remote' row's measured need (TaskResources); NULL on the
303
+ -- rest, whose need the run history holds.
304
+ cpu_parallelism REAL,
305
+ peak_rss_bytes INTEGER,
306
+ saturated_cores INTEGER,
307
+ saturated_memory_bytes INTEGER,
308
+ -- v48: the task's definition (taskDefinition), as on \`runs\`.
309
+ def TEXT
284
310
  );
285
311
  CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
286
312
  ON task_durations(project, task, source, id);
@@ -292,7 +318,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
292
318
  -- v38: the flaky record (orchestrator/failure-mode.ts): each keyed,
293
319
  -- cached, unblocked outcome that observed its key, shared by every
294
320
  -- worktree so a key that passed in one and failed in another is flaky
295
- -- in both (owner, 2026-10-14). Every executed success or failure, and
321
+ -- in both (2026-10-14). Every executed success or failure, and
296
322
  -- a key's first hit (run-history.ts): a later hit replays a pass the
297
323
  -- record holds, so a warm run on keys that passed writes no row.
298
324
  -- Append-only, ordered by id, pruned by age with the run history.
@@ -314,6 +340,16 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
314
340
  bytes BLOB NOT NULL,
315
341
  at INTEGER NOT NULL
316
342
  );
343
+ -- What a remote's has-many said it holds (remote-hits.ts), shared by
344
+ -- every worktree: a key here is a hit until a has-many or a GET says it
345
+ -- is gone, and a key not here is a miss. Only hits are kept. Pruned by
346
+ -- \`seen_at\` with the run history.
347
+ CREATE TABLE IF NOT EXISTS ${store}.remote_hits (
348
+ hash TEXT PRIMARY KEY,
349
+ size INTEGER,
350
+ duration_ms INTEGER,
351
+ seen_at INTEGER NOT NULL
352
+ );
317
353
  ${artifactTables(store)}
318
354
  `)
319
355
  }
@@ -38,6 +38,8 @@ const BLOCK = 512
38
38
  * numbers; a MiB is far past any writer's.
39
39
  */
40
40
  const MAX_EXTENDED_HEADER = 1024 * 1024
41
+ /** The largest millisecond count a Date holds (ECMA-262 §21.4.1.1). */
42
+ const DATE_MAX_MS = 8.64e15
41
43
  // `ignoreBOM`: a default decoder drops a leading U+FEFF, and a name or
42
44
  // prefix field that began with one read back without it.
43
45
  const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
@@ -232,6 +234,9 @@ class TarDecoder {
232
234
  const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]!)
233
235
  const size = this.pendingSize ?? octal(h, 124, 12)
234
236
  const mtimeMs = octal(h, 136, 12) * 1000
237
+ // Only GNU base-256 reaches past it, and a restore's utimes threw on
238
+ // such a Date after the renames before it had landed.
239
+ if (mtimeMs > DATE_MAX_MS) throw new TarFormatError(`header mtime past what a Date holds`)
235
240
  const padded = Math.ceil(size / BLOCK) * BLOCK
236
241
  // POSIX `ustar\0` only: old GNU's `ustar ` keeps atime and ctime at
237
242
  // 345, and `-G` fills them, so a prefix read there named every entry
@@ -1,8 +1,7 @@
1
1
  // The process umask, read when a restore starts: Bun reads it by setting 0
2
2
  // and putting it back in one synchronous call (project-loader.ts
3
3
  // `currentUmask`), so no spawn on this thread sees the 0, and a run
4
- // evaluates no config in a worker. Through globalThis: the playground's
5
- // shim has no umask.
4
+ // evaluates no config in a worker.
6
5
  const proc = globalThis.process as { umask?: () => number } | undefined
7
6
  let umask = 0
8
7
 
package/src/cli/cache.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { existsSync, readdirSync, rmSync } from 'node:fs'
1
+ import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { Cache, vxCacheEntries } from '../cache/index.js'
5
5
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
6
6
  import {
7
7
  errorCode,
8
+ isUserError,
8
9
  nearest,
9
10
  parseDuration,
10
11
  parseSize,
@@ -57,11 +58,11 @@ const CACHE_ELSEWHERE: Record<string, string> = {
57
58
  }
58
59
 
59
60
  function cacheSubHint(sub: string): string {
60
- const elsewhere = CACHE_ELSEWHERE[sub]
61
- if (elsewhere !== undefined) return ` — ${elsewhere}`
61
+ // Own keys only: `vx cache constructor` printed `Object`'s source.
62
+ if (Object.hasOwn(CACHE_ELSEWHERE, sub)) return ` — ${CACHE_ELSEWHERE[sub]}`
62
63
  const best = nearest(sub, ['clean'])
63
64
  // `clean` is the one subcommand: name it when nothing is near (M-58).
64
- return best === undefined ? '. The subcommand is clean' : `. Did you mean ${best}?`
65
+ return best === undefined ? '. Its one subcommand is `vx cache clean`' : `. Did you mean ${best}?`
65
66
  }
66
67
 
67
68
  export async function cacheCmd(args: readonly string[]): Promise<number> {
@@ -78,6 +79,16 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
78
79
  'VX_E_USAGE',
79
80
  )
80
81
  default:
82
+ // `vx cache --older-than 30d` dropped the verb, not misspelled it:
83
+ // name the clean command those flags make, when they parse as one.
84
+ if (sub.startsWith('-') && parsePruneArgs(args).error === undefined) {
85
+ return refuse(
86
+ 'cache',
87
+ args,
88
+ `missing subcommand before ${sub}. Did you mean \`vx cache clean ${args.join(' ')}\`?${seeHelp('cache')}`,
89
+ 'VX_E_USAGE',
90
+ )
91
+ }
81
92
  return refuse(
82
93
  'cache',
83
94
  args,
@@ -90,7 +101,7 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
90
101
  interface PruneArgs {
91
102
  olderThanMs?: number
92
103
  maxBytes?: number
93
- dryRun?: boolean
104
+ dry?: boolean
94
105
  format?: 'pretty' | 'json'
95
106
  /** `--cache-dir`: prune the cache a run with the same flag uses. */
96
107
  cacheDir?: string
@@ -149,8 +160,8 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
149
160
  }
150
161
  }
151
162
  out.maxBytes = bytes
152
- } else if (a === '--dry-run') {
153
- out.dryRun = true
163
+ } else if (a === '--dry') {
164
+ out.dry = true
154
165
  } else if (a === '--global') {
155
166
  out.global = true
156
167
  } else if (a === '--format' || a?.startsWith('--format=')) {
@@ -205,7 +216,7 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
205
216
  else {
206
217
  const ws = await loadCliWorkspace(root)
207
218
  dir = ws.cacheDir
208
- storeRoot = await ws.storeRoot
219
+ storeRoot = await ws.storeRoot()
209
220
  }
210
221
  if (parsed.olderThanMs === undefined && parsed.maxBytes === undefined) {
211
222
  return clear(args, root, dir, storeRoot, parsed)
@@ -213,15 +224,17 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
213
224
  // A workspace that never ran has nothing to prune, and opening the cache
214
225
  // to find that out made it: `.vx/cache` with a database and a
215
226
  // `.gitignore`, where item 900 had held the dry run to making nothing
216
- // (and a read-only checkout was refused as unwritable instead).
217
- const dry = parsed.dryRun === true
227
+ // (and a read-only checkout was refused as unwritable instead). So has a
228
+ // directory holding nothing vx wrote (`--cache-dir build`): opened, it got
229
+ // an index and a `*` .gitignore that hid the user's files from git.
230
+ const dry = parsed.dry === true
218
231
  const json = parsed.format === 'json'
219
- if (!existsSync(dir) && (storeRoot === null || !existsSync(storeRoot))) {
232
+ if (vxCacheEntries(dir).length === 0 && (storeRoot === null || !existsSync(storeRoot))) {
220
233
  printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
221
234
  return 0
222
235
  }
223
236
  const cache =
224
- parsed.dryRun === true
237
+ parsed.dry === true
225
238
  ? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
226
239
  : new Cache(dir, undefined, root, undefined, 'open', storeRoot)
227
240
  // A prune deletes rows and artifacts; a cache this user cannot write is
@@ -229,19 +242,19 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
229
242
  // than dying in the first DELETE with SQLite's "readonly database" and a
230
243
  // stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
231
244
  // run only reads, and reads a read-only cache fine.
232
- if (!parsed.dryRun) cache.assertWritable()
245
+ if (!parsed.dry) cache.assertWritable()
233
246
  // A prune beside a run on this workspace evicts what the run has just
234
247
  // probed as hits, whose `accessed_at` bumps it has not flushed yet; the
235
248
  // run then re-runs those tasks (upstream survey, nx#36688). So a prune
236
249
  // takes the run's lock and waits its turn. A dry run deletes nothing.
237
- const releaseRunLock = parsed.dryRun
250
+ const releaseRunLock = parsed.dry
238
251
  ? async (): Promise<void> => {}
239
252
  : await acquireRunLock(root, { log: warnToStderr })
240
253
  try {
241
- const opts: { olderThanMs?: number; maxBytes?: number; dryRun?: boolean } = {}
254
+ const opts: { olderThanMs?: number; maxBytes?: number; dry?: boolean } = {}
242
255
  if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
243
256
  if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
244
- if (parsed.dryRun) opts.dryRun = true
257
+ if (parsed.dry) opts.dry = true
245
258
  printPruned(await cache.prune(opts), dry, json)
246
259
  } finally {
247
260
  cache.close()
@@ -258,7 +271,7 @@ function printPruned(
258
271
  if (json) {
259
272
  const { evicted, bytesFreed, orphans, orphanBytes } = result
260
273
  process.stdout.write(
261
- `${JSON.stringify({ dryRun: dry, evicted, bytesFreed, orphans, orphanBytes })}\n`,
274
+ `${JSON.stringify({ dry: dry, evicted, bytesFreed, orphans, orphanBytes })}\n`,
262
275
  )
263
276
  return
264
277
  }
@@ -286,7 +299,7 @@ function freedStatus(dry: boolean, bytes: number, any: boolean): ui.Block {
286
299
 
287
300
  /** `vx cache clean --format json` with no bound (schemas/cache.json `clear`). */
288
301
  export interface ClearResult {
289
- dryRun: boolean
302
+ dry: boolean
290
303
  /** Workspace-relative inside the workspace, else absolute. */
291
304
  cleared: string[]
292
305
  bytesFreed: number
@@ -308,17 +321,27 @@ async function clear(
308
321
  storeRoot: string | null,
309
322
  parsed: PruneArgs,
310
323
  ): Promise<number> {
311
- // The store the index recorded wins over today's (a changed HOME), as a
312
- // run reads it; a directory with no index is not a vx cache to delete.
324
+ // The store the index recorded and today's (a changed HOME, an origin
325
+ // added) both go; a directory with no index is not a vx cache to delete.
313
326
  const targets: string[] = []
314
327
  if (existsSync(path.join(dir, 'cache.db'))) {
315
- const cache = new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
316
- const store = cache.storeDir
317
- cache.close()
328
+ // An index SQLite cannot read is what its own refusal sends here to
329
+ // clear: today's store stands in for the one it recorded.
330
+ let store: string | undefined = storeRoot ?? undefined
331
+ try {
332
+ const cache = new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
333
+ store = cache.storeDir
334
+ cache.close()
335
+ } catch (err) {
336
+ if (!isUserError(err)) throw err
337
+ }
318
338
  targets.push(dir)
319
339
  // The index is a file in the checkout (committed, or restored from a CI
320
340
  // cache): the store it names is deleted only when it is one.
321
341
  if (store !== undefined && isStore(store)) targets.push(store)
342
+ // Today's store too: the next run reads it, whichever store the index
343
+ // last recorded (a moved HOME, an origin added).
344
+ if (storeRoot !== null) targets.push(storeRoot)
322
345
  } else if (storeRoot !== null) targets.push(storeRoot)
323
346
  if (parsed.global === true) {
324
347
  const vxHome = path.join(process.env['HOME'] || homedir(), '.vx')
@@ -338,12 +361,20 @@ async function clear(
338
361
  'VX_E_CONFIG',
339
362
  )
340
363
  }
341
- const dry = parsed.dryRun === true
364
+ const dry = parsed.dry === true
342
365
  // `.vx/cache` and the stores are vx's own; any other directory is the
343
366
  // user's, cleared entry by entry and removed only if that empties it.
344
- const named = path.resolve(dir) === path.join(dotVx, 'cache') ? undefined : path.resolve(dir)
367
+ // A `.vx/cache` linked to a volume (a CI cache mount) is emptied through
368
+ // the link: removing the link alone left the cache where it points.
369
+ const own = path.join(dotVx, 'cache')
370
+ const named = path.resolve(dir) === own ? undefined : path.resolve(dir)
371
+ const linked = named === undefined && existsSync(own) && lstatSync(own).isSymbolicLink()
345
372
  const entries = (t: string): string[] =>
346
- t === named ? vxCacheEntries(t).map((e) => path.join(t, e)) : [t]
373
+ t === named
374
+ ? vxCacheEntries(t).map((e) => path.join(t, e))
375
+ : linked && t === own
376
+ ? readdirSync(t).map((e) => path.join(t, e))
377
+ : [t]
347
378
  const bytes = doomed.reduce((n, t) => n + entries(t).reduce((m, e) => m + treeBytes(e), 0), 0)
348
379
  if (!dry && doomed.length > 0) {
349
380
  // A clear beside a run deletes what the run is restoring or saving; it
@@ -360,7 +391,7 @@ async function clear(
360
391
  }
361
392
  const shown = doomed.map((t) => (path.relative(root, t).startsWith('..') ? t : relPosix(root, t)))
362
393
  if (parsed.format === 'json') {
363
- const result: ClearResult = { dryRun: dry, cleared: shown, bytesFreed: bytes }
394
+ const result: ClearResult = { dry: dry, cleared: shown, bytesFreed: bytes }
364
395
  process.stdout.write(`${JSON.stringify(result)}\n`)
365
396
  } else
366
397
  ui.print(
@@ -5,14 +5,21 @@
5
5
  // project names are not (a completion that evaluates configs on every
6
6
  // Tab is the wrong price).
7
7
 
8
- import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
8
+ import { acceptedFlags, CORE_VERBS, flagHint, refuse, seeHelp } from './help.js'
9
9
  import { nearest, sayNotice } from '../util/index.js'
10
+ import { setting } from '../settings.js'
11
+
12
+ function enumValues(path: string): readonly string[] {
13
+ const kind = setting(path).kind
14
+ return kind.type === 'enum' ? kind.values : []
15
+ }
10
16
 
11
17
  /** A flag whose value is one of a fixed set; the tests hold each to its parser. */
12
18
  export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
13
19
  '--format': ['pretty', 'json'],
14
20
  '--output-logs': ['full', 'errors-only', 'hash-only', 'none'],
15
21
  '--cache-download': ['all', 'toplevel', 'none'],
22
+ '--schedule': enumValues('run.schedule'),
16
23
  }
17
24
 
18
25
  export type CompletionShell = 'bash' | 'zsh' | 'fish'
@@ -139,12 +146,12 @@ export async function completionsCmd(
139
146
  if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
140
147
  const flag = args.find((a) => a.startsWith('-'))
141
148
  if (flag !== undefined) {
142
- sayNotice(
143
- 'error',
144
- 'vx completions',
149
+ return refuse(
150
+ 'completions',
151
+ args,
145
152
  `unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}`,
153
+ 'VX_E_USAGE',
146
154
  )
147
- return 1
148
155
  }
149
156
  const one = shell !== undefined && args.length === 1
150
157
  // One edit: shell names are three or four letters, so two edits took
@@ -14,12 +14,15 @@
14
14
  * so a verb that never loads a plugin never loads the façade. The
15
15
  * source form (`bun src/bin.ts`) resolves the specifier to the same file
16
16
  * anyway; the alias makes that a guarantee rather than a layout fact.
17
+ * The loader is synchronous: the workspace config's bytecode bundle
18
+ * (workspace/config-bytecode.ts) is CommonJS, and `require` refuses a
19
+ * module whose loader is async.
17
20
  */
18
- export function registerCoreAlias(load: () => Promise<Record<string, unknown>>): void {
21
+ export function registerCoreAlias(load: () => Record<string, unknown>): void {
19
22
  Bun.plugin({
20
23
  name: 'vx-core-alias',
21
24
  setup(build) {
22
- build.module('@vzn/vx', async () => ({ exports: await load(), loader: 'object' }))
25
+ build.module('@vzn/vx', () => ({ exports: load(), loader: 'object' }))
23
26
  },
24
27
  })
25
28
  }