@vzn/vx 0.0.648 → 0.0.650

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.648",
3
+ "version": "0.0.650",
4
4
  "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.648",
25
- "@vzn/vx-linux-arm64": "0.0.648",
26
- "@vzn/vx-darwin-x64": "0.0.648",
27
- "@vzn/vx-darwin-arm64": "0.0.648"
24
+ "@vzn/vx-linux-x64": "0.0.650",
25
+ "@vzn/vx-linux-arm64": "0.0.650",
26
+ "@vzn/vx-darwin-x64": "0.0.650",
27
+ "@vzn/vx-darwin-arm64": "0.0.650"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -117,8 +117,8 @@ import { FileHashStore } from './file-hashes.js'
117
117
  import { DIR_MODE, LINK_MODE, OutputIndex } from './output-index.js'
118
118
  import { RunHistory } from './run-history.js'
119
119
  import { CACHE_VERSION, foldKey } from './key-fold.js'
120
- import { FoldMemo } from './fold-memo.js'
121
- import { RemoteHits } from './remote-hits.js'
120
+ import { type FoldMemo, IndexFoldMemo } from './fold-memo.js'
121
+ import { IndexRemoteHits, type RemoteHits } from './remote-hits.js'
122
122
  import { behindLink } from './linked-inputs.js'
123
123
 
124
124
  /**
@@ -164,13 +164,8 @@ const VX_CACHE_NAMES = new Set([
164
164
  'cache.db-wal',
165
165
  'cache.db-shm',
166
166
  'cache.db-journal',
167
- // run-failures.ts, run-steps.ts, orchestrator/lockfile-claim.ts. Run
168
- // history lives in cache.db and the config bundle always in
169
- // `.vx/cache/compiled`, so a `runs` or `compiled` here is the user's.
170
- 'failures',
171
- 'steps',
172
- 'warnings',
173
- 'lockfile-claims',
167
+ // Every record vx keeps lives in cache.db, and the config bundle always
168
+ // in `.vx/cache/compiled`, so any directory here is the user's.
174
169
  ])
175
170
 
176
171
  /**
@@ -357,7 +352,10 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
357
352
  // v49: runs.cpu_share — the share of a core each runnable thread got
358
353
  // where the task ran, so a crowded run's CPU reads as need. The
359
354
  // cache KEY is unchanged.
360
- export const SCHEMA_VERSION = 'v49'
355
+ // v50: steps, warnings, failures and lockfile_memos — what a run said
356
+ // and a lockfile claimant parsed, files under the cache dir until
357
+ // now (if it can be in the DB, it is). The cache KEY is unchanged.
358
+ export const SCHEMA_VERSION = 'v50'
361
359
 
362
360
  /**
363
361
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -948,7 +946,7 @@ export class Cache implements CacheLayer {
948
946
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
949
947
  private readonly files: FileHashStore
950
948
  private readonly outputs: OutputIndex
951
- private readonly folds: FoldMemo
949
+ private readonly folds: IndexFoldMemo
952
950
  private readonly history: RunHistory
953
951
  /** What the remotes said they hold, across runs (remote-hits.ts). */
954
952
  readonly remoteHits: RemoteHits
@@ -1172,7 +1170,7 @@ export class Cache implements CacheLayer {
1172
1170
  // keeps it so).
1173
1171
  if (found !== undefined) {
1174
1172
  this.db.exec(
1175
- 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits;',
1173
+ 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits; DROP TABLE IF EXISTS main.steps; DROP TABLE IF EXISTS main.warnings; DROP TABLE IF EXISTS main.failures; DROP TABLE IF EXISTS main.lockfile_memos;',
1176
1174
  )
1177
1175
  }
1178
1176
  // The tables land under the same write lock as the stamp: made
@@ -1336,9 +1334,9 @@ export class Cache implements CacheLayer {
1336
1334
  // the schema above is the one place every table is declared.
1337
1335
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1338
1336
  this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
1339
- this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
1337
+ this.folds = new IndexFoldMemo(this.db, this.write && !this.inspecting)
1340
1338
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1341
- this.remoteHits = new RemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
1339
+ this.remoteHits = new IndexRemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
1342
1340
 
1343
1341
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
1344
1342
  // after an upgrade misses everything. Roadmap 3.3: that is announced,
@@ -3210,17 +3208,6 @@ export class Cache implements CacheLayer {
3210
3208
  this.db.prepare(`UPDATE entries SET accessed_at = ? WHERE hash ${test}`).run(now, ...params)
3211
3209
  }
3212
3210
 
3213
- /**
3214
- * Raw `bun:sqlite` Database handle. Exposed for subsystems that
3215
- * need to issue their own queries (LocalHistoryProvider's CTE,
3216
- * future analytics consumers) without us proxying every method.
3217
- * Callers must NOT close the handle directly — `Cache.close()` owns
3218
- * the lifetime.
3219
- */
3220
- dbHandle(): Database {
3221
- return this.db
3222
- }
3223
-
3224
3211
  // --- run history: delegated to `RunHistory` (see run-history.ts) ---
3225
3212
  // A run over a directory it cannot write records no history:
3226
3213
  // `readOnlyNotice` said so once.
@@ -3564,7 +3551,17 @@ export class Cache implements CacheLayer {
3564
3551
  }
3565
3552
 
3566
3553
  /** SQLite's file-control opcode (sqlite3.h). */
3567
- const SQLITE_FCNTL_PERSIST_WAL = 10
3554
+ export const SQLITE_FCNTL_PERSIST_WAL = 10
3555
+
3556
+ /**
3557
+ * The cache's `bun:sqlite` handle, for core's own readers (`vx last`, `vx
3558
+ * why`, the history provider). A function, not a member: the façade hands
3559
+ * `Cache` to plugins and no plugin touches cache.db (2026-10-29). Callers
3560
+ * never close it — `Cache.close()` owns the lifetime.
3561
+ */
3562
+ export function indexDb(cache: Cache): Database {
3563
+ return cache['db']
3564
+ }
3568
3565
 
3569
3566
  /**
3570
3567
  * Close for real. A plain `close()` leaves the connection open while any
@@ -3577,7 +3574,7 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
3577
3574
  * `vx run` exited 1 with that stack after its tasks had passed (M-44). There
3578
3575
  * the deferred close is the one that closes.
3579
3576
  */
3580
- function closeDb(db: Database): void {
3577
+ export function closeDb(db: Database): void {
3581
3578
  try {
3582
3579
  db.close(true)
3583
3580
  } catch {
@@ -13,7 +13,14 @@ export interface FoldMemoRow {
13
13
  key: string
14
14
  }
15
15
 
16
- export class FoldMemo {
16
+ /** What a key fold asks of the memo; the rows stay core's (`IndexFoldMemo`). */
17
+ export interface FoldMemo {
18
+ /** The key remembered for `taskId` under `probe`, if that is the probe it was folded under. */
19
+ get(taskId: string, probe: string): string | undefined
20
+ set(taskId: string, probe: string, key: string): void
21
+ }
22
+
23
+ export class IndexFoldMemo implements FoldMemo {
17
24
  private rows: Map<string, FoldMemoRow> | undefined
18
25
  private readonly dirty = new Map<string, FoldMemoRow>()
19
26
 
@@ -18,6 +18,7 @@ export {
18
18
  type CachePolicy,
19
19
  cachesNothing,
20
20
  EXECUTED_RUNS_SQL,
21
+ indexDb,
21
22
  formatCachePolicy,
22
23
  FULL_CACHE_POLICY,
23
24
  type InvocationRecord,
@@ -102,3 +103,13 @@ export {
102
103
  } from './plugin-cache.js'
103
104
  export { packRunOutputs, putRunEntry, runEntryName, writesRunStore } from './run-store.js'
104
105
  export { branchSource } from './run-history.js'
106
+ export {
107
+ type KeptFailure,
108
+ keepRunNotes,
109
+ keptFailures,
110
+ keptSteps,
111
+ keptWarnings,
112
+ type RunNotes,
113
+ type RunStep,
114
+ } from './run-notes.js'
115
+ export { readLockfileMemo, writeLockfileMemo, type LockfileMemo } from './lockfile-memo.js'
@@ -0,0 +1,119 @@
1
+ // A lockfile claimant's parse, kept in cache.db's `lockfile_memos` (v50; a
2
+ // `lockfile-claims/` file before): what orchestrator/lockfile-claim.ts reads
3
+ // so a warm run never parses the lockfile. Its readers run where no `Cache`
4
+ // is open yet — a `discover` hook, `--affected`'s selection, a verb that
5
+ // opens none — so each call opens the index on its own and closes it. Only
6
+ // an index that exists and holds this vx's schema is read or written: one
7
+ // another vx wrote is reset by the next run, and a directory with none is
8
+ // left for the run that makes it. A failure of any kind is a miss: the memo
9
+ // is a speed-up, never a requirement.
10
+
11
+ import { Database } from 'bun:sqlite'
12
+ import { existsSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import { closeDb, SCHEMA_VERSION, SQLITE_FCNTL_PERSIST_WAL } from './cache.js'
15
+
16
+ /** One claimant's parse of one lockfile's content. */
17
+ export interface LockfileMemo {
18
+ /** The claimant's own counter: bumped when it digests differently. */
19
+ version: number
20
+ /** xxh3 of the lockfile's bytes. */
21
+ lock: string
22
+ /** Each extra file the digests read → its content hash then. */
23
+ extras: Record<string, string>
24
+ importers: Record<string, string>
25
+ edges: Record<string, unknown>
26
+ /** Each importer's recorded dependency names; absent when the claimant reads none. */
27
+ recorded?: Record<string, readonly string[]>
28
+ }
29
+
30
+ function withIndex<T>(cacheDir: string, use: (db: Database) => T): T | undefined {
31
+ const file = path.join(cacheDir, 'cache.db')
32
+ if (!existsSync(file)) return undefined
33
+ let db: Database
34
+ try {
35
+ db = new Database(file, { readwrite: true })
36
+ } catch {
37
+ return undefined
38
+ }
39
+ try {
40
+ // As `Cache` opens it: wait for another run's write, and keep `-wal`
41
+ // and `-shm` when this is the last connection to close (O-10).
42
+ db.exec('PRAGMA busy_timeout = 5000')
43
+ db.fileControl(SQLITE_FCNTL_PERSIST_WAL, 1)
44
+ db.exec('PRAGMA journal_size_limit = 67108864')
45
+ return use(db)
46
+ } catch {
47
+ return undefined
48
+ } finally {
49
+ closeDb(db)
50
+ }
51
+ }
52
+
53
+ const CURRENT = "(SELECT value FROM schema_meta WHERE key = 'version') = ?"
54
+
55
+ /** The memo `claimant` kept of `file`; undefined when none or another content's. */
56
+ export function readLockfileMemo(
57
+ cacheDir: string,
58
+ file: string,
59
+ claimant: string,
60
+ ): LockfileMemo | undefined {
61
+ return withIndex(cacheDir, (db) => {
62
+ const row = db
63
+ .query(
64
+ `SELECT version, lock, extras, importers, edges, recorded FROM lockfile_memos
65
+ WHERE file = ? AND claimant = ? AND ${CURRENT}`,
66
+ )
67
+ .get(file, claimant, SCHEMA_VERSION) as {
68
+ version: number
69
+ lock: string
70
+ extras: string
71
+ importers: string
72
+ edges: string
73
+ recorded: string | null
74
+ } | null
75
+ if (row === null) return undefined
76
+ return {
77
+ version: row.version,
78
+ lock: row.lock,
79
+ extras: JSON.parse(row.extras) as Record<string, string>,
80
+ importers: JSON.parse(row.importers) as Record<string, string>,
81
+ edges: JSON.parse(row.edges) as Record<string, unknown>,
82
+ ...(row.recorded !== null
83
+ ? { recorded: JSON.parse(row.recorded) as Record<string, readonly string[]> }
84
+ : {}),
85
+ }
86
+ })
87
+ }
88
+
89
+ /** Keep `memo` as `claimant`'s of `file`, replacing the last; false when it was not kept. */
90
+ export function writeLockfileMemo(
91
+ cacheDir: string,
92
+ file: string,
93
+ claimant: string,
94
+ memo: LockfileMemo,
95
+ ): boolean {
96
+ return (
97
+ withIndex(
98
+ cacheDir,
99
+ (db) =>
100
+ db
101
+ .query(
102
+ `INSERT OR REPLACE INTO lockfile_memos
103
+ (file, claimant, version, lock, extras, importers, edges, recorded)
104
+ SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE ${CURRENT}`,
105
+ )
106
+ .run(
107
+ file,
108
+ claimant,
109
+ memo.version,
110
+ memo.lock,
111
+ JSON.stringify(memo.extras),
112
+ JSON.stringify(memo.importers),
113
+ JSON.stringify(memo.edges),
114
+ memo.recorded === undefined ? null : JSON.stringify(memo.recorded),
115
+ SCHEMA_VERSION,
116
+ ).changes > 0,
117
+ ) ?? false
118
+ )
119
+ }
@@ -12,7 +12,15 @@ export interface RemoteHit {
12
12
  readonly durationMs?: number
13
13
  }
14
14
 
15
- export class RemoteHits {
15
+ /** What a layered cache asks of the remote-hit rows; the rows stay core's (`IndexRemoteHits`). */
16
+ export interface RemoteHits {
17
+ /** The keys of `hashes` a remote last said it holds. */
18
+ get(hashes: readonly string[]): Map<string, RemoteHit>
19
+ record(asked: readonly string[], found: ReadonlyMap<string, RemoteHit>, now?: number): void
20
+ forget(hash: string): void
21
+ }
22
+
23
+ export class IndexRemoteHits implements RemoteHits {
16
24
  constructor(
17
25
  private readonly db: Database,
18
26
  /** Off for a reading verb or an index this user cannot write. */
@@ -0,0 +1,123 @@
1
+ // What a run said that its `runs` rows do not hold, for `vx last`: the steps
2
+ // it did not wait on (`vx:prefetch`, 2026-10-16), which never show in a
3
+ // run's output; its warnings, which scroll past on a terminal (an agent
4
+ // reading the run afterwards saw only the footer's count, 2026-10-28); and
5
+ // each failed task's output and the files it names. Rows of cache.db's
6
+ // `steps`, `warnings` and `failures` (v50; files under the cache dir
7
+ // before), so `vx last --sql` reads them too. The newest `KEPT` runs that
8
+ // had any are kept, per table. A run with none writes nothing.
9
+
10
+ import type { Database } from 'bun:sqlite'
11
+
12
+ /** Runs whose steps, warnings or failures are kept; run ids are UUIDv7, so they sort by age. */
13
+ const KEPT = 50
14
+
15
+ /** One of vx's own steps that ran beside the tasks. */
16
+ export interface RunStep {
17
+ id: string
18
+ durationMs: number
19
+ ok: boolean
20
+ }
21
+
22
+ /** A failed task's kept output: plain, bounded, secrets masked. */
23
+ export interface KeptFailure {
24
+ taskId: string
25
+ inputMoved?: string
26
+ output: string
27
+ locations: readonly unknown[]
28
+ sandboxFix?: unknown
29
+ }
30
+
31
+ export interface RunNotes {
32
+ readonly steps?: readonly RunStep[]
33
+ readonly warnings?: readonly string[]
34
+ readonly failures?: readonly KeptFailure[]
35
+ }
36
+
37
+ /** Drop every row of `table` older than its newest `KEPT` runs. */
38
+ function trim(db: Database, table: 'steps' | 'warnings' | 'failures'): void {
39
+ db.query(
40
+ `DELETE FROM ${table} WHERE run_id < (SELECT run_id FROM (SELECT DISTINCT run_id FROM ${table} ORDER BY run_id DESC LIMIT 1 OFFSET ${KEPT - 1}))`,
41
+ ).run()
42
+ }
43
+
44
+ /** Write `notes` for `runId` in one transaction, then keep the newest `KEPT` runs of each table written. */
45
+ export function keepRunNotes(db: Database, runId: string, notes: RunNotes): void {
46
+ const steps = notes.steps ?? []
47
+ const warnings = notes.warnings ?? []
48
+ const failures = notes.failures ?? []
49
+ if (steps.length + warnings.length + failures.length === 0) return
50
+ db.transaction(() => {
51
+ if (steps.length > 0) {
52
+ const insert = db.query(
53
+ 'INSERT OR REPLACE INTO steps(run_id, seq, step, duration_ms, ok) VALUES (?, ?, ?, ?, ?)',
54
+ )
55
+ steps.forEach((s, i) => insert.run(runId, i, s.id, Math.round(s.durationMs), s.ok ? 1 : 0))
56
+ trim(db, 'steps')
57
+ }
58
+ if (warnings.length > 0) {
59
+ const insert = db.query('INSERT OR REPLACE INTO warnings(run_id, seq, text) VALUES (?, ?, ?)')
60
+ warnings.forEach((w, i) => insert.run(runId, i, w))
61
+ trim(db, 'warnings')
62
+ }
63
+ if (failures.length > 0) {
64
+ const insert = db.query(
65
+ `INSERT OR REPLACE INTO failures(run_id, task_id, input_moved, output, locations, sandbox_fix)
66
+ VALUES (?, ?, ?, ?, ?, ?)`,
67
+ )
68
+ for (const f of failures) {
69
+ insert.run(
70
+ runId,
71
+ f.taskId,
72
+ f.inputMoved ?? null,
73
+ f.output,
74
+ JSON.stringify(f.locations),
75
+ f.sandboxFix === undefined ? null : JSON.stringify(f.sandboxFix),
76
+ )
77
+ }
78
+ trim(db, 'failures')
79
+ }
80
+ }).immediate()
81
+ }
82
+
83
+ /** The background steps `runId` kept, in the order they ended; none when it had none. */
84
+ export function keptSteps(db: Database, runId: string): RunStep[] {
85
+ const rows = db
86
+ .query('SELECT step, duration_ms, ok FROM steps WHERE run_id = ? ORDER BY seq')
87
+ .all(runId) as { step: string; duration_ms: number; ok: number }[]
88
+ return rows.map((r) => ({ id: r.step, durationMs: r.duration_ms, ok: r.ok === 1 }))
89
+ }
90
+
91
+ /** The warnings `runId` said, colour stripped; none when it said none. */
92
+ export function keptWarnings(db: Database, runId: string): string[] {
93
+ const rows = db.query('SELECT text FROM warnings WHERE run_id = ? ORDER BY seq').all(runId) as {
94
+ text: string
95
+ }[]
96
+ return rows.map((r) => r.text)
97
+ }
98
+
99
+ /** What `runId` kept of its failed tasks, by task id; empty when it kept none. */
100
+ export function keptFailures(db: Database, runId: string): Map<string, KeptFailure> {
101
+ const rows = db
102
+ .query(
103
+ 'SELECT task_id, input_moved, output, locations, sandbox_fix FROM failures WHERE run_id = ?',
104
+ )
105
+ .all(runId) as {
106
+ task_id: string
107
+ input_moved: string | null
108
+ output: string
109
+ locations: string
110
+ sandbox_fix: string | null
111
+ }[]
112
+ const kept = new Map<string, KeptFailure>()
113
+ for (const r of rows) {
114
+ kept.set(r.task_id, {
115
+ taskId: r.task_id,
116
+ ...(r.input_moved !== null ? { inputMoved: r.input_moved } : {}),
117
+ output: r.output,
118
+ locations: JSON.parse(r.locations) as unknown[],
119
+ ...(r.sandbox_fix !== null ? { sandboxFix: JSON.parse(r.sandbox_fix) as unknown } : {}),
120
+ })
121
+ }
122
+ return kept
123
+ }
@@ -261,6 +261,49 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
261
261
  arch TEXT,
262
262
  vx_version TEXT NOT NULL
263
263
  );
264
+ -- v50: what a run said that its runs rows do not hold (run-notes.ts),
265
+ -- for \`vx last\`: the steps it did not wait on (\`vx:prefetch\`), its
266
+ -- warnings, colour stripped, and each failed task's output, plain,
267
+ -- bounded and masked, with the files it names (JSON). Rows of the
268
+ -- newest 50 runs that had any; run ids are UUIDv7, so they sort by age.
269
+ CREATE TABLE IF NOT EXISTS main.steps (
270
+ run_id TEXT NOT NULL,
271
+ seq INTEGER NOT NULL,
272
+ step TEXT NOT NULL,
273
+ duration_ms INTEGER NOT NULL,
274
+ ok INTEGER NOT NULL,
275
+ PRIMARY KEY (run_id, seq)
276
+ );
277
+ CREATE TABLE IF NOT EXISTS main.warnings (
278
+ run_id TEXT NOT NULL,
279
+ seq INTEGER NOT NULL,
280
+ text TEXT NOT NULL,
281
+ PRIMARY KEY (run_id, seq)
282
+ );
283
+ CREATE TABLE IF NOT EXISTS main.failures (
284
+ run_id TEXT NOT NULL,
285
+ task_id TEXT NOT NULL,
286
+ input_moved TEXT,
287
+ output TEXT NOT NULL,
288
+ locations TEXT NOT NULL,
289
+ sandbox_fix TEXT,
290
+ PRIMARY KEY (run_id, task_id)
291
+ );
292
+ -- v50: a lockfile claimant's parse (orchestrator/lockfile-claim.ts): one
293
+ -- row per lockfile and claimant, read while \`version\` and \`lock\` (the
294
+ -- file's xxh3) match, replaced when they do not. JSON: the extra files'
295
+ -- hashes, the importers' digests and edges, the names each records.
296
+ CREATE TABLE IF NOT EXISTS main.lockfile_memos (
297
+ file TEXT NOT NULL,
298
+ claimant TEXT NOT NULL,
299
+ version INTEGER NOT NULL,
300
+ lock TEXT NOT NULL,
301
+ extras TEXT NOT NULL,
302
+ importers TEXT NOT NULL,
303
+ edges TEXT NOT NULL,
304
+ recorded TEXT,
305
+ PRIMARY KEY (file, claimant)
306
+ );
264
307
  CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
265
308
  CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
266
309
  CREATE INDEX IF NOT EXISTS main.invocations_ci ON invocations(ci);
package/src/cli/last.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // Read-only — no config evaluation, no re-hash, no cache probe.
6
6
 
7
7
  import type { Database } from 'bun:sqlite'
8
- import { Cache } from '../cache/index.js'
8
+ import { indexDb, Cache, keptSteps, keptWarnings, type RunStep } from '../cache/index.js'
9
9
  import { formatBytes } from './format.js'
10
10
  import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import {
@@ -24,9 +24,6 @@ import {
24
24
  exitSignal,
25
25
  resolveRunId,
26
26
  runFailures,
27
- type RunStep,
28
- runSteps,
29
- runWarnings,
30
27
  shellQuote,
31
28
  shortRunId,
32
29
  taskLog,
@@ -358,8 +355,9 @@ async function printStats(
358
355
  }
359
356
 
360
357
  /**
361
- * `--sql`: one SELECT over the history (`runs`, `invocations`, and the
362
- * store's `entries`, `entry_inputs`, `task_durations`, `task_outcomes`),
358
+ * `--sql`: one SELECT over the history (`runs`, `invocations`, `steps`,
359
+ * `warnings`, `failures`, and the store's `entries`, `entry_inputs`,
360
+ * `task_durations`, `task_outcomes`),
363
361
  * so an agent asks what no flag answers: a task's memory trend, the
364
362
  * flakiest project, which input moved a key. `query_only` stops a write
365
363
  * the statement hides (`WITH … DELETE`); it does not stop ATTACH, which
@@ -806,7 +804,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
806
804
  const cacheDir = await cliCacheDir(root, parsed.cacheDir)
807
805
  const cache = Cache.inspect(cacheDir)
808
806
  try {
809
- const db = cache.dbHandle()
807
+ const db = indexDb(cache)
810
808
  // Nothing recorded at all is its own answer: `--failed` said "no
811
809
  // recorded run failed" and a run id pointed at a `--list` that lists
812
810
  // nothing, while `--list --failed` past green runs said there were none.
@@ -825,12 +823,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
825
823
  parsed.runId === undefined
826
824
  ? undefined
827
825
  : (resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
828
- const log = taskLog(
829
- cacheDir,
830
- db,
831
- await bareTaskId(db, await rootSpelled(root, parsed.log)),
832
- runId,
833
- )
826
+ const log = taskLog(db, await bareTaskId(db, await rootSpelled(root, parsed.log)), runId)
834
827
  if (log === null) {
835
828
  // The id's form is hinted only when it lacks one: a task that
836
829
  // never ran was told how to spell a task id.
@@ -921,9 +914,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
921
914
  const detail = getRun(db, inv.runId)
922
915
 
923
916
  if (parsed.format === 'json') {
924
- const failures = new Map(
925
- (runFailures(cacheDir, db, inv.runId)?.tasks ?? []).map((f) => [f.taskId, f]),
926
- )
917
+ const failures = new Map((runFailures(db, inv.runId)?.tasks ?? []).map((f) => [f.taskId, f]))
927
918
  const tasks = (detail?.tasks ?? []).map((t) => {
928
919
  const f = failures.get(`${t.project}#${t.task}`)
929
920
  return f === undefined
@@ -937,8 +928,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
937
928
  ...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
938
929
  }
939
930
  })
940
- const steps = runSteps(cacheDir, inv.runId)
941
- const warnings = runWarnings(cacheDir, inv.runId)
931
+ const steps = keptSteps(db, inv.runId)
932
+ const warnings = keptWarnings(db, inv.runId)
942
933
  process.stdout.write(
943
934
  `${JSON.stringify({
944
935
  invocation: inv,
@@ -971,12 +962,12 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
971
962
  )
972
963
  const moved = new Map<string, string>()
973
964
  if (inv.failedCount > 0) {
974
- for (const f of runFailures(cacheDir, db, inv.runId)?.tasks ?? [])
965
+ for (const f of runFailures(db, inv.runId)?.tasks ?? [])
975
966
  if (f.inputMoved !== undefined) moved.set(f.taskId, f.inputMoved)
976
967
  }
977
968
  lines.push(...formatTaskRows(detail?.tasks ?? [], c, moved))
978
- lines.push(...formatStepRows(runSteps(cacheDir, inv.runId), c))
979
- const warnings = runWarnings(cacheDir, inv.runId)
969
+ lines.push(...formatStepRows(keptSteps(db, inv.runId), c))
970
+ const warnings = keptWarnings(db, inv.runId)
980
971
  if (warnings.length > 0) {
981
972
  lines.push('', ' warnings', ...warnings.flatMap((w) => w.split('\n').map((l) => ` ${l}`)))
982
973
  }
@@ -7,6 +7,7 @@
7
7
  // reports an unknown command as it always did. Core's verbs are matched
8
8
  // before this is asked, so a plugin cannot shadow `run`.
9
9
 
10
+ import { indexDb } from '../cache/index.js'
10
11
  import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
12
  import type { WorkspaceConfig } from '../config.js'
12
13
  import type {
@@ -184,7 +185,7 @@ async function verbCache(
184
185
  remotes,
185
186
  })
186
187
  await openVerbRemotes(remotes, openRemotes)
187
- return pluginCache(plugin, { db: localCache.dbHandle(), policy: VERB_POLICY, remotes, scope })
188
+ return pluginCache(plugin, { db: indexDb(localCache), policy: VERB_POLICY, remotes, scope })
188
189
  }
189
190
 
190
191
  /**
package/src/cli/why.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // moved input file, no re-hash. Configs are evaluated only when a moved
6
6
  // upstream is a group: it records no run, so the graph names what it gathers.
7
7
 
8
- import { Cache } from '../cache/index.js'
8
+ import { indexDb, Cache } from '../cache/index.js'
9
9
  import { anchoredSpecError, flagHint, formatValue, seeHelp } from './help.js'
10
10
  import { buildTaskGraph, isGroupTask, splitTaskId } from '../graph/index.js'
11
11
  import {
@@ -166,7 +166,7 @@ async function resolveTarget(
166
166
  target: string,
167
167
  cwdProject: () => Promise<string | null>,
168
168
  ): Promise<string> {
169
- const db = cache.dbHandle()
169
+ const db = indexDb(cache)
170
170
  if (target.includes('#')) {
171
171
  // An exact id needs one row, and its newest is where a scan from the
172
172
  // top of the table finds it first — the full DISTINCT walk below is for
@@ -263,7 +263,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
263
263
  const root = await findWorkspaceRoot(process.cwd())
264
264
  const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
265
265
  try {
266
- const db = cache.dbHandle()
266
+ const db = indexDb(cache)
267
267
  const taskId = await resolveTarget(cache, await rootSpelled(root, parsed.target), () =>
268
268
  findCwdProject(process.cwd()),
269
269
  )
@@ -88,6 +88,10 @@ export interface TaskOutcome {
88
88
  * its save wrote (measured only when a telemetry sink listens).
89
89
  */
90
90
  artifactBytes?: number
91
+ /**
92
+ * The raw bytes of a hit's outputs: the entry's files as restored.
93
+ */
94
+ outputBytes?: number
91
95
  /** On a remote hit this run pulled: the download and its ingest. */
92
96
  fetchMs?: number
93
97
  /** On a miss that saved, with a sink listening: the save's own time. */
@@ -304,7 +308,8 @@ export interface ScheduleOptions {
304
308
  */
305
309
  onFailFast?: () => void
306
310
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
307
- onStart?: (node: TaskNode) => void
311
+ /** `queuedMs`: how long the task waited ready for a slot, when it did. */
312
+ onStart?: (node: TaskNode, queuedMs?: number) => void
308
313
  onFinish?: (outcome: TaskOutcome) => void
309
314
  /**
310
315
  * Where the line naming a rejected `execute` goes, before its failed
@@ -959,9 +964,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
959
964
  return undefined
960
965
  }
961
966
  }
962
- const started = (node: TaskNode): void => {
967
+ const started = (node: TaskNode, queuedMs = 0): void => {
963
968
  try {
964
- onStart?.(node)
969
+ if (queuedMs > 0) onStart?.(node, queuedMs)
970
+ else onStart?.(node)
965
971
  } catch (err) {
966
972
  const m = thrownMessage(err)
967
973
  ui.say('warn', `[vx] onStart observer threw for ${node.id}: ${m}\n`)
@@ -1100,7 +1106,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1100
1106
  const settle = settleInPlace(node, upstream)
1101
1107
  let now: TaskOutcome | undefined
1102
1108
  if (settle !== undefined) {
1103
- if (!demoted.has(id)) started(node)
1109
+ if (!demoted.has(id)) started(node, queuedMs)
1104
1110
  try {
1105
1111
  now = settle()
1106
1112
  } catch {
@@ -1130,7 +1136,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1130
1136
  // the dispatch loop (it would strand the tick with the slot held).
1131
1137
  // A demoted task's second dispatch is the same task, already started,
1132
1138
  // as is one whose settle threw.
1133
- if (!demoted.has(id) && settle === undefined) started(node)
1139
+ if (!demoted.has(id) && settle === undefined) started(node, queuedMs)
1134
1140
 
1135
1141
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
1136
1142
  // rejection arm handles ONLY `execute()` rejecting; a throw from