@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -13,11 +13,11 @@ const INSERT_RUNS = `
13
13
  started_at, ended_at,
14
14
  run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
15
15
  cache_hit, attempts, cached,
16
- blocked_by, timed_out, sandbox_violations, not_ready, restored
16
+ blocked_by, timed_out, sandbox_violations, not_ready, restored, remote_error
17
17
  )
18
18
  VALUES `
19
- /** One `runs` row's placeholders: the 22 columns `bindRun` fills. */
20
- const RUN_TUPLE = `(${Array(22).fill('?').join(', ')})`
19
+ /** One `runs` row's placeholders: the 23 columns `bindRun` fills. */
20
+ const RUN_TUPLE = `(${Array(23).fill('?').join(', ')})`
21
21
 
22
22
  /** An INSERT of `n` `runs` rows. */
23
23
  function insertRunsSql(n: number): string {
@@ -25,7 +25,7 @@ function insertRunsSql(n: number): string {
25
25
  }
26
26
 
27
27
  /**
28
- * Rows per INSERT: 40 × 22 variables stays under 999, the oldest default
28
+ * Rows per INSERT: 40 × 23 variables stays under 999, the oldest default
29
29
  * ceiling. A statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
30
30
  * (2026-10-03).
31
31
  */
@@ -34,6 +34,31 @@ const RUNS_PER_INSERT = 40
34
34
  /** Rows per `task_durations` INSERT: 6 variables each, under 999. */
35
35
  const DURATIONS_PER_INSERT = 160
36
36
 
37
+ /** Rows per `task_outcomes` INSERT: 6 variables each, under 999. */
38
+ const OUTCOMES_PER_INSERT = 160
39
+
40
+ /**
41
+ * Whether a run says anything of its key, so the flaky record may keep it:
42
+ * keyed, cached, and not run behind a failure (its bytes were not the key's).
43
+ */
44
+ const keyed = (r: RunRecord): boolean =>
45
+ r.hash !== undefined && r.hash !== '' && r.cached !== false && r.blockedBy == null
46
+
47
+ /** A hit, as the orchestrator marks one (`isCacheHit`, run-records.ts). */
48
+ const isHit = (r: RunRecord): boolean => r.cacheHit === true
49
+
50
+ /**
51
+ * Of a JSON array of hashes, those the record holds a pass of: one index
52
+ * probe each (0.6 ms for 1,000; a join reading every row of each key was
53
+ * 5.7 ms at 20 runs of history). A hit's row is kept only when its key has
54
+ * none: a hit replays a pass, and the first is what a later failure
55
+ * relapses from (an entry adopted from its artifact, or older than the
56
+ * record, has no executed row). By hash alone, as the entry it replays is.
57
+ */
58
+ const PASSED_HASHES = `
59
+ SELECT j.value AS hash FROM json_each(?) AS j
60
+ WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
61
+
37
62
  /** A local run's `task_durations.source`: its branch, `''` when it had none. */
38
63
  export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
39
64
 
@@ -55,9 +80,9 @@ export class RunHistory {
55
80
  up_to_date_count, restored_local_count, restored_remote_count,
56
81
  exit_ok,
57
82
  commit_sha, branch, dirty, ci, ci_provider,
58
- host, os, arch, vx_version, tags
83
+ host, os, arch, vx_version
59
84
  )
60
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
61
86
  ON CONFLICT(run_id) DO NOTHING
62
87
  `,
63
88
  )
@@ -75,6 +100,7 @@ export class RunHistory {
75
100
  .transaction(() => {
76
101
  this.insertRuns(runs)
77
102
  this.insertDurations(runs, branchSource(null), null)
103
+ this.insertOutcomes(runs)
78
104
  })
79
105
  .immediate()
80
106
  }
@@ -94,6 +120,45 @@ export class RunHistory {
94
120
  }
95
121
  }
96
122
 
123
+ /**
124
+ * The flaky record's rows: every executed outcome, and a key's first hit,
125
+ * in the order given (a batch may hold one key's history).
126
+ */
127
+ private insertOutcomes(runs: readonly RunRecord[]): void {
128
+ const rows = runs.filter(
129
+ (r) => keyed(r) && (isHit(r) || r.status === 'success' || r.status === 'failed'),
130
+ )
131
+ const hits = rows.filter(isHit)
132
+ let passed: Set<string> | undefined
133
+ if (hits.length > 0) {
134
+ const found = this.db.query(PASSED_HASHES).all(JSON.stringify(hits.map((r) => r.hash))) as {
135
+ hash: string
136
+ }[]
137
+ passed = new Set(found.map((f) => f.hash))
138
+ }
139
+ const kept: RunRecord[] = []
140
+ for (const r of rows) {
141
+ if (isHit(r)) {
142
+ if (passed!.has(r.hash!)) continue
143
+ passed!.add(r.hash!)
144
+ } else if (r.status === 'success') {
145
+ passed?.add(r.hash!)
146
+ }
147
+ kept.push(r)
148
+ }
149
+ for (let i = 0; i < kept.length; i += OUTCOMES_PER_INSERT) {
150
+ const block = kept.slice(i, i + OUTCOMES_PER_INSERT)
151
+ const params: SQLQueryBindings[] = []
152
+ for (const r of block)
153
+ params.push(r.project, r.task, r.hash!, r.status, r.attempts ?? null, r.startedAt)
154
+ this.db
155
+ .query(
156
+ `INSERT INTO task_outcomes(project, task, hash, status, attempts, recorded_at) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?)').join(', ')}`,
157
+ )
158
+ .run(...params)
159
+ }
160
+ }
161
+
97
162
  /** `runs` as few INSERTs; each distinct forward-args list digested once. */
98
163
  private insertRuns(runs: readonly RunRecord[]): void {
99
164
  const digests = new Map<string, string>()
@@ -134,6 +199,23 @@ export class RunHistory {
134
199
  branchSource(bundle.invocation.branch),
135
200
  bundle.invocation.commitSha,
136
201
  )
202
+ this.insertOutcomes(bundle.runs)
203
+ })
204
+ .immediate()
205
+ }
206
+
207
+ /**
208
+ * The remote failures a run's uploads met after its rows were written:
209
+ * the uploads drain once the history is in (run.ts).
210
+ */
211
+ recordRemoteErrors(
212
+ runId: string,
213
+ errors: readonly (readonly [hash: string, text: string])[],
214
+ ): void {
215
+ const update = this.db.prepare('UPDATE runs SET remote_error = ? WHERE run_id = ? AND hash = ?')
216
+ this.db
217
+ .transaction(() => {
218
+ for (const [hash, text] of errors) update.run(text, runId, hash)
137
219
  })
138
220
  .immediate()
139
221
  }
@@ -149,10 +231,12 @@ export class RunHistory {
149
231
  this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
150
232
  this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
151
233
  this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
234
+ this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
235
+ this.db.prepare('DELETE FROM plugin_blobs WHERE at < ?').run(cutoff)
152
236
  }
153
237
  }
154
238
 
155
- /** Bind a RunRecord to one `RUN_TUPLE`'s 22 positional parameters. */
239
+ /** Bind a RunRecord to one `RUN_TUPLE`'s 23 positional parameters. */
156
240
  function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
157
241
  return [
158
242
  // The ONE place the no-key sentinel is applied, so the column's
@@ -182,6 +266,7 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
182
266
  run.sandboxViolations ?? null,
183
267
  run.notReady ?? null,
184
268
  run.restored === undefined ? null : run.restored ? 1 : 0,
269
+ run.remoteError ?? null,
185
270
  ]
186
271
  }
187
272
 
@@ -219,6 +304,5 @@ function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
219
304
  inv.os,
220
305
  inv.arch,
221
306
  inv.vxVersion,
222
- inv.tags,
223
307
  ]
224
308
  }
@@ -0,0 +1,77 @@
1
+ // What core keeps of a run that writes a remote (owner 2026-10-15): a failed
2
+ // task's log and the files its outputs left, an uncached task's log. Names
3
+ // come from the run id and task (`run/<id>/<task>/log`); each key is derived
4
+ // in a namespace no task key or plugin entry shares. Remote only: locally
5
+ // `vx last --log` and the working tree already hold them. No size cap (M:
6
+ // the cache is free). Plugins read them by name (`ctx.cache.getRun`).
7
+
8
+ import { xxh3hex } from '../util/index.js'
9
+ import type { ResolvedRemote } from './remote-lane.js'
10
+ import { scopedKey } from './scoped-cache.js'
11
+ import { tarPackInto, tarSize } from './tar-stream.js'
12
+
13
+ /** The name core stores a task's `part` of run `runId` under. */
14
+ export function runEntryName(runId: string, taskId: string, part: 'log' | 'outputs'): string {
15
+ return `run/${runId}/${taskId}/${part}`
16
+ }
17
+
18
+ const runKey = (name: string): string => xxh3hex(`vx-run\0${name}`)
19
+
20
+ /** The keys `name` is read under: the default, then the scope's, as tasks read. */
21
+ export function runReadKeys(name: string, scope: string | undefined): string[] {
22
+ const key = runKey(name)
23
+ return scope === undefined ? [key] : [key, scopedKey(scope, key)]
24
+ }
25
+
26
+ /** Whether some remote takes this run's writes: only then does core keep run bytes. */
27
+ export function writesRunStore(remotes: readonly ResolvedRemote[]): boolean {
28
+ return remotes.some((r) => r.write)
29
+ }
30
+
31
+ /**
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.
35
+ */
36
+ export async function putRunEntry(
37
+ remotes: readonly ResolvedRemote[],
38
+ scope: string | undefined,
39
+ name: string,
40
+ bytes: Uint8Array,
41
+ ): Promise<boolean> {
42
+ const key = scope === undefined ? runKey(name) : scopedKey(scope, runKey(name))
43
+ let kept = false
44
+ for (const { remote, write } of remotes) {
45
+ if (!write) continue
46
+ try {
47
+ await remote.put(key, new Blob([bytes]), { durationMs: 0 })
48
+ kept = true
49
+ } catch {
50
+ // the other remotes still keep it
51
+ }
52
+ }
53
+ return kept
54
+ }
55
+
56
+ /**
57
+ * The files at `paths` (workspace-relative) as a plain ustar archive, names
58
+ * as given, so any `tar` reads it; null when none is left to read.
59
+ */
60
+ export async function packRunOutputs(
61
+ workspaceRoot: string,
62
+ paths: readonly string[],
63
+ ): Promise<Uint8Array | null> {
64
+ const inputs: { name: string; size: number; body: Uint8Array }[] = []
65
+ for (const name of paths) {
66
+ try {
67
+ const body = await Bun.file(`${workspaceRoot}/${name}`).bytes()
68
+ inputs.push({ name, size: body.byteLength, body })
69
+ } catch {
70
+ // gone since the glob: nothing to keep
71
+ }
72
+ }
73
+ if (inputs.length === 0) return null
74
+ const out = new Uint8Array(tarSize(inputs))
75
+ tarPackInto(inputs, out)
76
+ return out
77
+ }
@@ -23,7 +23,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
23
23
  /**
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
- * and the task timings (`task_durations`):
26
+ * the task timings (`task_durations`), the flaky record (`task_outcomes`),
27
+ * the blob sizes (`blob_sizes`) and the plugins' blobs (`plugin_blobs`):
27
28
  * `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
28
29
  * they live in the shared store attached beside it (v32). The rest is the
29
30
  * workspace's own: its history, its memos, and what its disk looked like
@@ -97,7 +98,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
97
98
  not_ready TEXT,
98
99
  -- v31: on a hit, 1 when outputs were restored, 0 when the disk
99
100
  -- already matched (up-to-date); NULL on every other status.
100
- restored INTEGER
101
+ restored INTEGER,
102
+ -- v40: each remote read or write of the key that failed, as
103
+ -- 'upload: <message>', '; '-joined; NULL when none did.
104
+ remote_error TEXT
101
105
  );
102
106
  -- Two whole-table indexes only, and both APPEND: every row of a run carries the
103
107
  -- same run_id and a started_at newer than everything before it, so
@@ -114,17 +118,14 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
114
118
  -- runs_ended (2026-09-09) served only the retention DELETE, which
115
119
  -- prunes on started_at now (a row ends after it starts, so the
116
120
  -- 30-day window moves by at most one task's duration).
121
+ -- runs_failed (v38) served the flakiness probe, which reads the
122
+ -- store's task_outcomes now.
117
123
  DROP INDEX IF EXISTS main.runs_hash;
118
124
  DROP INDEX IF EXISTS main.runs_project;
119
125
  DROP INDEX IF EXISTS main.runs_ended;
126
+ DROP INDEX IF EXISTS main.runs_failed;
120
127
  CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
121
128
  CREATE INDEX IF NOT EXISTS main.runs_run_id ON runs(run_id);
122
- -- The one keyed index, PARTIAL over failed rows: a green run's
123
- -- inserts only evaluate its predicate, so the append-only cost above
124
- -- holds, and the flakiness probe after a miss (failure-mode.ts,
125
- -- "did this key ever fail?") reads a handful of leaves instead of
126
- -- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
127
- CREATE INDEX IF NOT EXISTS main.runs_failed ON runs(hash) WHERE status = 'failed';
128
129
  -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
129
130
  -- skip the content-hash on inputs whose stat hasn't changed
130
131
  -- since the last run. Pure performance optimization; the stored
@@ -142,8 +143,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
142
143
  -- The size of each index blob the enumeration checked against the
143
144
  -- worktree size git recorded (A-60). Fixed for its OID, so a warm
144
145
  -- run asks git for none; swept with file_hashes by seen_at, the
145
- -- time the row was written.
146
- CREATE TABLE IF NOT EXISTS main.blob_sizes (
146
+ -- time the row was written. v38: in the store, so a worktree reads
147
+ -- what another learned (owner, 2026-10-14).
148
+ CREATE TABLE IF NOT EXISTS ${store}.blob_sizes (
147
149
  oid TEXT PRIMARY KEY,
148
150
  size INTEGER NOT NULL,
149
151
  seen_at INTEGER NOT NULL
@@ -200,7 +202,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
200
202
  );
201
203
  -- v22 (Tier 3): one header row per vx-run invocation. The runs
202
204
  -- table is per-task; this is the per-invocation record that
203
- -- carries git/CI/host context, the command, tags, and run-level
205
+ -- carries git/CI/host context, the command, and run-level
204
206
  -- counts so the dashboard never reconstructs a header with a
205
207
  -- lossy GROUP BY over runs.
206
208
  CREATE TABLE IF NOT EXISTS main.invocations (
@@ -231,8 +233,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
231
233
  host TEXT,
232
234
  os TEXT,
233
235
  arch TEXT,
234
- vx_version TEXT NOT NULL,
235
- tags TEXT NOT NULL DEFAULT '{}'
236
+ vx_version TEXT NOT NULL
236
237
  );
237
238
  CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
238
239
  CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
@@ -275,6 +276,36 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
275
276
  );
276
277
  CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
277
278
  ON task_durations(project, task, source, id);
279
+ -- v37: a 'remote' row is a default-branch commit's timing a plugin
280
+ -- imported (\`sha\` that commit): one per task, so a repeat import is a
281
+ -- no-op under INSERT OR IGNORE.
282
+ CREATE UNIQUE INDEX IF NOT EXISTS ${store}.task_durations_remote
283
+ ON task_durations(sha, project, task) WHERE source = 'remote';
284
+ -- v38: the flaky record (orchestrator/failure-mode.ts): each keyed,
285
+ -- cached, unblocked outcome that observed its key, shared by every
286
+ -- worktree so a key that passed in one and failed in another is flaky
287
+ -- in both (owner, 2026-10-14). Every executed success or failure, and
288
+ -- a key's first hit (run-history.ts): a later hit replays a pass the
289
+ -- record holds, so a warm run on keys that passed writes no row.
290
+ -- Append-only, ordered by id, pruned by age with the run history.
291
+ CREATE TABLE IF NOT EXISTS ${store}.task_outcomes (
292
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
293
+ project TEXT NOT NULL,
294
+ task TEXT NOT NULL,
295
+ hash TEXT NOT NULL,
296
+ status TEXT NOT NULL,
297
+ attempts INTEGER,
298
+ recorded_at INTEGER NOT NULL
299
+ );
300
+ CREATE INDEX IF NOT EXISTS ${store}.task_outcomes_hash ON task_outcomes(hash, status);
301
+ -- v37: what a plugin keeps through \`ctx.cache\` (plugin-cache.ts), by
302
+ -- its namespaced key. Written once, never updated; pruned by age with
303
+ -- the run history.
304
+ CREATE TABLE IF NOT EXISTS ${store}.plugin_blobs (
305
+ key TEXT PRIMARY KEY,
306
+ bytes BLOB NOT NULL,
307
+ at INTEGER NOT NULL
308
+ );
278
309
  ${artifactTables(store)}
279
310
  `)
280
311
  }
@@ -16,7 +16,7 @@ import type {
16
16
  } from './layer.js'
17
17
 
18
18
  /** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
19
- function scopedKey(scope: string, hash: string): string {
19
+ export function scopedKey(scope: string, hash: string): string {
20
20
  return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
21
21
  }
22
22
 
@@ -33,7 +33,10 @@ export class ScopedCache implements CacheLayer {
33
33
  readonly local?: CacheLayer['local']
34
34
  readonly hasRemote?: boolean
35
35
  readonly drainUploads?: () => Promise<void>
36
+ readonly stopUploads?: () => void
36
37
  readonly remoteHasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
38
+ /** A scoped run saves under the scope's key, so that is the key asked. */
39
+ readonly probeBeforeUpload?: (hashes: Promise<readonly string[]>) => void
37
40
  readonly getMany?: (
38
41
  hashes: readonly string[],
39
42
  ctx?: (hash: string) => CacheGetContext,
@@ -48,7 +51,11 @@ export class ScopedCache implements CacheLayer {
48
51
  if (inner.local !== undefined) this.local = inner.local
49
52
  if (inner.hasRemote !== undefined) this.hasRemote = inner.hasRemote
50
53
  if (inner.drainUploads !== undefined) this.drainUploads = () => inner.drainUploads!()
54
+ if (inner.stopUploads !== undefined) this.stopUploads = () => inner.stopUploads!()
51
55
  if (inner.remoteHasMany !== undefined) this.remoteHasMany = (h) => this.scopedHasMany(h)
56
+ if (inner.probeBeforeUpload !== undefined)
57
+ this.probeBeforeUpload = (h) =>
58
+ inner.probeBeforeUpload!(h.then((keys) => keys.map((k) => this.own(k))))
52
59
  if (inner.getMany !== undefined) this.getMany = (h, ctx) => this.scopedGetMany(h, ctx)
53
60
  }
54
61
 
@@ -75,6 +82,13 @@ export class ScopedCache implements CacheLayer {
75
82
  return out
76
83
  }
77
84
 
85
+ /** A task key the store holds under the default key or the scope's. */
86
+ async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
87
+ const held = await this.inner.localHasMany?.([...hashes, ...hashes.map((h) => this.own(h))])
88
+ if (held === undefined) return new Set()
89
+ return new Set(hashes.filter((h) => held.has(h) || held.has(this.own(h))))
90
+ }
91
+
78
92
  private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
79
93
  const scoped = hashes.map((h) => this.own(h))
80
94
  const [plain, own] = await Promise.all([
@@ -105,7 +119,13 @@ export class ScopedCache implements CacheLayer {
105
119
  }
106
120
 
107
121
  async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
108
- for (const key of [hash, this.own(hash)]) {
122
+ // The scope's key first when only it is local: the default key's
123
+ // remote GET would cost a round-trip for the same inputs.
124
+ const own = this.own(hash)
125
+ const keys = (await this.inner.localHasMany?.([hash, own]))?.has(own)
126
+ ? [own, hash]
127
+ : [hash, own]
128
+ for (const key of keys) {
109
129
  const entry = await this.inner.get(key, ctx)
110
130
  if (entry === null) continue
111
131
  this.served.set(hash, key)
package/src/cli/cache.ts CHANGED
@@ -3,7 +3,15 @@ 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
- import { errorCode, nearest, parseDuration, parseSize, relPosix, treeBytes } from '../util/index.js'
6
+ import {
7
+ errorCode,
8
+ nearest,
9
+ parseDuration,
10
+ parseSize,
11
+ relPosix,
12
+ treeBytes,
13
+ ui,
14
+ } from '../util/index.js'
7
15
  import { acquireRunLock } from '../orchestrator/index.js'
8
16
  import { findWorkspaceRoot } from '../workspace/index.js'
9
17
  import {
@@ -254,15 +262,28 @@ function printPruned(
254
262
  )
255
263
  return
256
264
  }
257
- const orphans =
258
- result.orphans > 0
259
- ? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
260
- : ''
261
- process.stdout.write(
262
- `${dry ? 'Would prune' : 'Pruned'} ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)}${dry ? '' : ' freed'})${orphans}\n`,
265
+ const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
266
+ const facts = [
267
+ ...(result.evicted > 0 ? [plural(result.evicted, 'entry', 'entries')] : []),
268
+ ...(result.orphans > 0
269
+ ? [plural(result.orphans, 'orphaned artifact', 'orphaned artifacts')]
270
+ : []),
271
+ ]
272
+ ui.print(
273
+ 'out',
274
+ freedStatus(dry, result.bytesFreed + result.orphanBytes, facts.length > 0),
275
+ ...(facts.length > 0 ? [ui.text(facts.join(' · '))] : []),
263
276
  )
264
277
  }
265
278
 
279
+ /** `✓ vx cache clean freed 1.3 GB`, `› … would free 1.3 GB (dry run)`, or nothing to free. */
280
+ function freedStatus(dry: boolean, bytes: number, any: boolean): ui.Block {
281
+ if (!any) return ui.status('info', 'vx cache clean', `nothing to free${dry ? ' (dry run)' : ''}`)
282
+ return dry
283
+ ? ui.status('info', 'vx cache clean', `would free ${formatBytes(bytes)} (dry run)`)
284
+ : ui.status('ok', 'vx cache clean', `freed ${formatBytes(bytes)}`)
285
+ }
286
+
266
287
  /** `vx cache clean --format json` with no bound (schemas/cache.json `clear`). */
267
288
  export interface ClearResult {
268
289
  dryRun: boolean
@@ -341,16 +362,12 @@ async function clear(
341
362
  if (parsed.format === 'json') {
342
363
  const result: ClearResult = { dryRun: dry, cleared: shown, bytesFreed: bytes }
343
364
  process.stdout.write(`${JSON.stringify(result)}\n`)
344
- } else if (shown.length === 0) process.stdout.write('Nothing to clear: no cache here\n')
345
- else {
346
- const scope =
347
- parsed.global === true
348
- ? "every repository's store and this workspace's run history"
349
- : "this repository's store, shared by its checkouts, and this workspace's run history"
350
- process.stdout.write(
351
- `${dry ? 'Would clear' : 'Cleared'} ${scope} (${formatBytes(bytes)}${dry ? '' : ' freed'}):\n${shown.map((t) => ` ${t}\n`).join('')}`,
365
+ } else
366
+ ui.print(
367
+ 'out',
368
+ freedStatus(dry, bytes, shown.length > 0),
369
+ ...(shown.length > 0 ? [ui.text(...shown)] : []),
352
370
  )
353
- }
354
371
  return 0
355
372
  }
356
373
 
@@ -6,7 +6,7 @@
6
6
  // Tab is the wrong price).
7
7
 
8
8
  import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
9
- import { nearest } from '../util/index.js'
9
+ import { nearest, sayNotice } from '../util/index.js'
10
10
 
11
11
  /** A flag whose value is one of a fixed set; the tests hold each to its parser. */
12
12
  export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
@@ -21,8 +21,8 @@ const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
21
21
  /**
22
22
  * `--help` and every flag the verb accepts. Not every `--flag` its help cut
23
23
  * mentions: the cut's prose names other verbs' flags, and `vx watch`
24
- * completed only `--dry --graph --summarize --profile`, the four its line
25
- * says it refuses, while `vx show` offered `--run` and `--list`.
24
+ * completed only the flags its help line says it refuses, while `vx show`
25
+ * offered `--run` and `--list`.
26
26
  */
27
27
  export function verbFlags(verb: string): string[] {
28
28
  return [...new Set(['--help', ...acceptedFlags(verb)])]
@@ -139,8 +139,10 @@ export async function completionsCmd(
139
139
  if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
140
140
  const flag = args.find((a) => a.startsWith('-'))
141
141
  if (flag !== undefined) {
142
- process.stderr.write(
143
- `vx completions: unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}\n`,
142
+ sayNotice(
143
+ 'error',
144
+ 'vx completions',
145
+ `unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}`,
144
146
  )
145
147
  return 1
146
148
  }
@@ -148,8 +150,10 @@ export async function completionsCmd(
148
150
  // One edit: shell names are three or four letters, so two edits took
149
151
  // `tcsh` to `bash`, a different shell.
150
152
  const best = one ? nearest(shell, SHELLS, 1) : undefined
151
- process.stderr.write(
152
- `vx completions: expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}\n`,
153
+ sayNotice(
154
+ 'error',
155
+ 'vx completions',
156
+ `expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}`,
153
157
  )
154
158
  return 1
155
159
  }
@@ -22,8 +22,7 @@ interface ForeignFlag {
22
22
  /**
23
23
  * The runner reads `=true` / `=false` on it (Turbo's `--force [FORCE]`,
24
24
  * Nx's yargs booleans): `=false` is the flag left out, `=true` the flag
25
- * bare. `--skip-nx-cache=false` forced every task, `--summarize=true`
26
- * wrote the summary to a file named `true`.
25
+ * bare: `--skip-nx-cache=false` forced every task.
27
26
  */
28
27
  bool?: true
29
28
  /** For an alias: the vx argv. For a refusal: none. */
@@ -47,8 +46,6 @@ const nxPattern = (p: string): string => {
47
46
  return p
48
47
  }
49
48
 
50
- const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
51
-
52
49
  export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
53
50
  {
54
51
  runner: 'turbo',
@@ -98,21 +95,12 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
98
95
  vx: '`--dry[=text|json]`',
99
96
  to: (v) => [v === undefined ? '--dry' : `--dry=${v}`],
100
97
  },
101
- {
102
- runner: 'turbo',
103
- names: ['--graph'],
104
- value: true,
105
- when: (v) => v !== undefined && GRAPH_IMAGE.test(v),
106
- shown: '`--graph=<file>.svg|png|json|html|…`',
107
- outcome: 'refuse',
108
- vx: 'vx writes Graphviz DOT only: `--graph=<file>.dot`, then `dot -Tsvg`',
109
- },
110
98
  {
111
99
  runner: 'turbo',
112
100
  names: ['--graph'],
113
101
  value: false,
114
- outcome: 'same',
115
- vx: '`--graph[=<file>.dot]`',
102
+ outcome: 'refuse',
103
+ vx: '`--dry=json` prints the planned task graph, each task with its deps',
116
104
  },
117
105
  {
118
106
  runner: 'turbo',
@@ -133,9 +121,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
133
121
  runner: 'turbo',
134
122
  names: ['--summarize'],
135
123
  value: false,
136
- bool: true,
137
- outcome: 'same',
138
- vx: '`--summarize[=<path>]`',
124
+ outcome: 'refuse',
125
+ vx: "use `vx last --format json` for the run's JSON record",
139
126
  },
140
127
  {
141
128
  runner: 'turbo',
@@ -263,7 +250,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
263
250
  runner: 'nx',
264
251
  names: ['--parallel'],
265
252
  value: true,
266
- when: (v) => v !== undefined && v !== 'true',
253
+ // Nx's value is a count or `false`; a word after it is a task name.
254
+ when: (v) => v === 'false' || (v !== undefined && /^\d+$/.test(v)),
267
255
  shown: '`--parallel <n>`',
268
256
  outcome: 'alias',
269
257
  vx: '`--concurrency <n>` (`--parallel=false` is 1)',
@@ -362,14 +350,14 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
362
350
  names: ['--json'],
363
351
  value: false,
364
352
  outcome: 'refuse',
365
- vx: "use `--format json` for the run's result on stdout, `--dry=json` for the plan",
353
+ vx: "use `vx last --format json` for the run's result, `--dry=json` for the plan",
366
354
  },
367
355
  {
368
356
  runner: 'turbo',
369
357
  names: ['--log-file'],
370
358
  value: false,
371
359
  outcome: 'refuse',
372
- vx: "use `--summarize[=<path>]` for the run's JSON record",
360
+ vx: "use `vx last --format json` for the run's JSON record",
373
361
  },
374
362
  {
375
363
  runner: 'turbo',
@@ -664,7 +652,7 @@ export const FOREIGN_VERBS: Readonly<Record<string, string>> = {
664
652
  '`nx run-many` is `vx run <task> --all` here; -t, -p, --exclude and --parallel work as they are',
665
653
  affected:
666
654
  '`nx affected` is `vx run <task> --affected` here; -t, --base and --exclude work as they are',
667
- graph: '`nx graph` is `vx run <task> --graph[=<file>.dot]` here: the task graph as Graphviz DOT',
655
+ graph: '`nx graph` is `vx run <task> --dry=json` here: the planned task graph as JSON',
668
656
  ls: '`turbo ls` is `vx show` here: every project, its directory and its task count',
669
657
  query:
670
658
  '`turbo query` has no vx form: `vx run <task> --dry=json` prints the planned graph as JSON',