@vzn/vx 0.0.649 → 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.
@@ -12,7 +12,7 @@
12
12
  // (zero-config); the interface is the seam a plugin fills for any other.
13
13
 
14
14
  import type { Database } from 'bun:sqlite'
15
- import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
15
+ import { branchSource, type Cache, EXECUTED_RUNS_SQL, indexDb } from '../cache/index.js'
16
16
  import { isMeasure, splitTaskId } from '../util/index.js'
17
17
  import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
18
18
  import type { FailureMode } from './failure-mode.js'
@@ -117,8 +117,40 @@ export class EmptyHistoryProvider implements HistoryProvider {
117
117
  }
118
118
  }
119
119
 
120
+ /** One local cache's run history, read and written by core (`localHistory`): plain rows out, no handle. */
121
+ export interface LocalHistory extends HistoryProvider {
122
+ /** Each task's measured need (`p50DurationMs`, peak cores and memory). */
123
+ resourcesFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, TaskResources>>
124
+ /** Whether timings of commit `sha` were imported. */
125
+ hasImported(sha: string): boolean
126
+ /** Keep commit `sha`'s timings (task id → ms) and needs as forecast rows. */
127
+ importTimings(
128
+ sha: string,
129
+ timings: ReadonlyMap<string, number>,
130
+ needs?: ReadonlyMap<string, TaskResources>,
131
+ defs?: ReadonlyMap<string, string>,
132
+ ): void
133
+ }
134
+
135
+ export interface LocalHistoryOptions {
136
+ /** The newest invocations a read sees (default 50). */
137
+ readonly recent?: number | undefined
138
+ /** The run's branch (`currentBranch`); undefined: a read sees every branch's timings. */
139
+ readonly branch?: string | null | undefined
140
+ /** Each task's definition (`taskDefinitions`): given, a task reads only rows of it as it is now. */
141
+ readonly defs?: ReadonlyMap<string, string> | undefined
142
+ }
143
+
144
+ /**
145
+ * The run history in `cache`'s index. A plugin reads history only through
146
+ * this: core owns every read and write of cache.db (2026-10-29).
147
+ */
148
+ export function localHistory(cache: Cache, options: LocalHistoryOptions = {}): LocalHistory {
149
+ return new LocalHistoryProvider(indexDb(cache), options.recent, options.branch, options.defs)
150
+ }
151
+
120
152
  /** Reads from the orchestrator's local SQLite cache.db. */
121
- export class LocalHistoryProvider implements HistoryProvider {
153
+ export class LocalHistoryProvider implements LocalHistory {
122
154
  constructor(
123
155
  private readonly db: Database,
124
156
  readonly recent: number = DEFAULT_RECENT,
@@ -6,6 +6,9 @@ export {
6
6
  EmptyHistoryProvider,
7
7
  type HistoryProvider,
8
8
  type HistoryTable,
9
+ type LocalHistory,
10
+ type LocalHistoryOptions,
11
+ localHistory,
9
12
  LocalHistoryProvider,
10
13
  PASS_STATUSES,
11
14
  type TaskHistory,
package/src/index.ts CHANGED
@@ -66,8 +66,15 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
66
66
  // (`@vzn/vx-history` does; a plugin package reaches core only
67
67
  // through this façade, which is what put these here).
68
68
  export { currentBranch } from './orchestrator/index.js'
69
- export { LocalHistoryProvider } from './history/index.js'
70
- export type { HistoryProvider, HistoryTable, TaskHistory, TaskResources } from './history/index.js'
69
+ export { localHistory } from './history/index.js'
70
+ export type {
71
+ HistoryProvider,
72
+ HistoryTable,
73
+ LocalHistory,
74
+ LocalHistoryOptions,
75
+ TaskHistory,
76
+ TaskResources,
77
+ } from './history/index.js'
71
78
  export type { PreparedRun } from './orchestrator/index.js'
72
79
  // `RunOptions.affected`: what `--affected`'s diff changed, per project.
73
80
  export type { AffectedChanges } from './workspace/index.js'
@@ -7,7 +7,7 @@
7
7
 
8
8
  import os from 'node:os'
9
9
  import path from 'node:path'
10
- import { Cache, CACHE_VERSION, SCHEMA_VERSION } from '../cache/index.js'
10
+ import { indexDb, Cache, CACHE_VERSION, SCHEMA_VERSION } from '../cache/index.js'
11
11
  import { PLUGIN_HOOKS, type WorkspaceConfig } from '../config.js'
12
12
  import {
13
13
  cgroupCpuQuota,
@@ -245,7 +245,7 @@ async function collectWorkspaceInfo(
245
245
  indexStore = cache.storeDir
246
246
  stats = cache.stats()
247
247
  orphans = await cache.orphanStats()
248
- flaky = flakyTasks(cache.dbHandle())
248
+ flaky = flakyTasks(indexDb(cache))
249
249
  } finally {
250
250
  cache.close()
251
251
  }
@@ -458,6 +458,9 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
458
458
  ...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
459
459
  ...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
460
460
  ...(hit.sizeBytes !== undefined ? { artifactBytes: hit.sizeBytes } : {}),
461
+ ...(hit.outputRows !== undefined
462
+ ? { outputBytes: hit.outputRows.reduce((n, r) => n + r.size, 0) }
463
+ : {}),
461
464
  ...(hit.fetchMs !== undefined ? { fetchMs: hit.fetchMs } : {}),
462
465
  hash,
463
466
  restored: restored || hit.source === 'remote',
@@ -146,7 +146,6 @@ export {
146
146
  type TaskLog,
147
147
  taskLog,
148
148
  } from './run-failures.js'
149
- export { type RunStep, runSteps, runWarnings } from './run-steps.js'
150
149
  export { resolveRunId, shortRunId } from './run-id.js'
151
150
  export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
152
151
  export { localFloor, type LocalFloor } from './local-floor.js'
@@ -0,0 +1,93 @@
1
+ // The live list's `cpu` and `mem`: what each running task's process tree
2
+ // uses now. Looked at on a timer only once the list asks for a row, so a
3
+ // run with no live list (a pipe, CI) registers pids and reads nothing.
4
+ // The look is async and off the dispatch path: a slow one skips a tick.
5
+
6
+ import { sampleTrees, type TreeUsage } from '../exec/index.js'
7
+ import type { LiveUsage } from '../report/index.js'
8
+
9
+ /** How often a tracked tree is looked at while the live list shows. */
10
+ const LOOK_MS = 500
11
+
12
+ type Every = (ms: number, fn: () => Promise<void>) => () => void
13
+
14
+ const everyInterval: Every = (ms, fn) => {
15
+ const timer = setInterval(() => void fn(), ms)
16
+ timer.unref()
17
+ return () => clearInterval(timer)
18
+ }
19
+
20
+ interface Tracked {
21
+ pid: number
22
+ last?: { ts: number; cpuMs: number }
23
+ now?: LiveUsage
24
+ }
25
+
26
+ export function createLiveUsage(
27
+ opts: {
28
+ every?: Every
29
+ now?: () => number
30
+ sample?: (pids: number[]) => Promise<Map<number, TreeUsage>>
31
+ } = {},
32
+ ): {
33
+ track(taskId: string, pid: number): () => void
34
+ usage(taskId: string): LiveUsage | undefined
35
+ } {
36
+ const every = opts.every ?? everyInterval
37
+ const now = opts.now ?? Date.now
38
+ const sample = opts.sample ?? sampleTrees
39
+ const tracked = new Map<string, Tracked>()
40
+ let asked = false
41
+ let looking = false
42
+ let stop: (() => void) | undefined
43
+
44
+ const look = async (): Promise<void> => {
45
+ if (looking) return
46
+ looking = true
47
+ try {
48
+ const entries = [...tracked]
49
+ const usage = await sample(entries.map(([, t]) => t.pid))
50
+ const ts = now()
51
+ for (const [id, t] of entries) {
52
+ const u = usage.get(t.pid)
53
+ if (u === undefined || tracked.get(id) !== t) continue
54
+ // A descendant that exited takes its CPU out of the live sum, so
55
+ // an interval can read negative: none is shown below zero.
56
+ if (t.last !== undefined && ts > t.last.ts) {
57
+ const cores = Math.max(0, (u.cpuMs - t.last.cpuMs) / (ts - t.last.ts))
58
+ t.now = { cores: Math.round(cores * 100) / 100, rssBytes: u.rssBytes }
59
+ }
60
+ t.last = { ts, cpuMs: u.cpuMs }
61
+ }
62
+ } catch {
63
+ // A failed look is a missing point, never a broken run.
64
+ } finally {
65
+ looking = false
66
+ }
67
+ }
68
+ const start = (): void => {
69
+ if (asked && stop === undefined && tracked.size > 0) stop = every(LOOK_MS, look)
70
+ }
71
+
72
+ return {
73
+ track(taskId, pid) {
74
+ const t: Tracked = { pid }
75
+ tracked.set(taskId, t)
76
+ start()
77
+ return () => {
78
+ if (tracked.get(taskId) === t) tracked.delete(taskId)
79
+ if (tracked.size === 0 && stop !== undefined) {
80
+ stop()
81
+ stop = undefined
82
+ }
83
+ }
84
+ },
85
+ usage(taskId) {
86
+ if (!asked) {
87
+ asked = true
88
+ start()
89
+ }
90
+ return tracked.get(taskId)?.now
91
+ },
92
+ }
93
+ }
@@ -7,14 +7,15 @@
7
7
  // fallback for a project the file does not list and the `--affected`
8
8
  // diff are one implementation here, so a third lockfile is a parser.
9
9
  //
10
- // The digests are memoised on disk under the cache dir by the file's
11
- // xxh3, so a warm run pays one read + hash + a small JSON read, never a
12
- // parse; within one process (a run, a watch cycle) the file's size,
10
+ // The digests are memoised in cache.db (`lockfile_memos`, one row per
11
+ // lockfile and claimant) by the file's xxh3, so a warm run pays one read +
12
+ // hash + one row read, never a parse; within one process (a run, a watch cycle) the file's size,
13
13
  // mtime, ctime and inode gate even the read; and per run the read happens once (a WeakMap
14
14
  // on the context core hands every `key` call of a run).
15
15
 
16
16
  import path from 'node:path'
17
- import { rename, stat } from 'node:fs/promises'
17
+ import { stat } from 'node:fs/promises'
18
+ import { type LockfileMemo, readLockfileMemo, writeLockfileMemo } from '../cache/index.js'
18
19
  import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
19
20
  import type { TaskNode } from '../graph/index.js'
20
21
  import type {
@@ -62,6 +63,12 @@ export interface LockfileClaimOptions {
62
63
  * parse. Called once per distinct content, under either scope.
63
64
  */
64
65
  readonly importers?: (text: string) => ReadonlyMap<string, LockfileImporter>
66
+ /**
67
+ * Each importer's recorded dependency names, from the same text, kept in
68
+ * the memo so a staleness check reads them without a parse. Called once
69
+ * per distinct content.
70
+ */
71
+ readonly recorded?: (text: string) => ReadonlyMap<string, readonly string[]>
65
72
  }
66
73
 
67
74
  /**
@@ -89,6 +96,11 @@ export interface LockfileClaimHooks {
89
96
  readonly workspaceRoot: string
90
97
  readonly cacheDir: string
91
98
  }): Promise<ReadonlyMap<string, LockfileImporter> | undefined>
99
+ /** `recorded`'s answer for the file as it is now, as `importers` reads it; undefined when the file is absent or `recorded` is unset. */
100
+ recorded(ctx: {
101
+ readonly workspaceRoot: string
102
+ readonly cacheDir: string
103
+ }): Promise<ReadonlyMap<string, ReadonlySet<string>> | undefined>
92
104
  }
93
105
 
94
106
  interface Digests {
@@ -96,16 +108,7 @@ interface Digests {
96
108
  readonly lock: string
97
109
  readonly importers: ReadonlyMap<string, string>
98
110
  readonly edges: ReadonlyMap<string, LockfileImporter>
99
- }
100
-
101
- interface Memo {
102
- version: number
103
- claimant: string
104
- lock: string
105
- /** `extraFiles` → content hash when the digests were computed. */
106
- extras?: Record<string, string>
107
- importers: Record<string, string>
108
- edges?: Record<string, LockfileImporter>
111
+ readonly recorded: ReadonlyMap<string, ReadonlySet<string>> | undefined
109
112
  }
110
113
 
111
114
  /**
@@ -202,6 +205,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
202
205
  String(digest),
203
206
  String(options.extraFiles ?? ''),
204
207
  String(options.importers ?? ''),
208
+ String(options.recorded ?? ''),
205
209
  ].join('\0'),
206
210
  )
207
211
  if (scope !== 'project' && scope !== 'workspace') {
@@ -216,8 +220,18 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
216
220
  extras: readonly string[]
217
221
  extraStats: Readonly<Record<string, string>>
218
222
  digests: Digests
223
+ /** The memo a run found no index to keep in (a discover before the first run's open). */
224
+ unsaved?: LockfileMemo
219
225
  }
220
226
  >()
227
+ // Workspace scope with nothing else to keep has nothing a memo saves.
228
+ const memoises =
229
+ scope === 'project' || options.importers !== undefined || options.recorded !== undefined
230
+ const readMemo = (cacheDir: string, lock: string) => {
231
+ const memo = readLockfileMemo(cacheDir, file, claimant)
232
+ if (memo === undefined || memo.version !== version || memo.lock !== lock) return undefined
233
+ return memo
234
+ }
221
235
  // Per run: every `key` call of one run gets the same context object.
222
236
  const perRun = new WeakMap<object, Promise<Digests>>()
223
237
 
@@ -227,7 +241,12 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
227
241
  try {
228
242
  identity = identityOf(await stat(full))
229
243
  } catch {
230
- const digests: Digests = { lock: '', importers: new Map(), edges: new Map() }
244
+ const digests: Digests = {
245
+ lock: '',
246
+ importers: new Map(),
247
+ edges: new Map(),
248
+ recorded: undefined,
249
+ }
231
250
  seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
232
251
  return digests
233
252
  }
@@ -235,26 +254,30 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
235
254
  if (last !== undefined && last.identity === identity) {
236
255
  const now = await statsOf(workspaceRoot, last.extras)
237
256
  if (now.every((s, i) => s === last.extraStats[last.extras[i]!])) {
257
+ if (last.unsaved !== undefined && writeLockfileMemo(cacheDir, file, claimant, last.unsaved))
258
+ delete last.unsaved
238
259
  return last.digests
239
260
  }
240
261
  }
241
262
  const bytes = await Bun.file(full).bytes()
242
263
  const lockHash = xxh3hex(bytes)
243
- const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
244
264
  // The extra files the last digest read, and their hashes now: a warm run
245
265
  // re-hashes the files the memo names and never parses to learn them.
246
- const memo = await readMemo(memoFile, version, claimant, lockHash)
266
+ const memo = memoises ? readMemo(cacheDir, lockHash) : undefined
247
267
  let extras: Record<string, string> = {}
248
268
  let extraStats: Record<string, string> = {}
249
269
  let importers: ReadonlyMap<string, string> | undefined
250
270
  let edges: ReadonlyMap<string, LockfileImporter> = new Map()
271
+ let recorded: ReadonlyMap<string, readonly string[]> | undefined
272
+ let unsaved: LockfileMemo | undefined
251
273
  if (memo !== undefined) {
252
274
  const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
253
275
  if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
254
276
  extras = memo.extras
255
277
  extraStats = now.stats
256
- importers = memo.importers
257
- edges = memo.edges
278
+ importers = new Map(Object.entries(memo.importers))
279
+ edges = new Map(Object.entries(memo.edges as Record<string, LockfileImporter>))
280
+ recorded = memo.recorded === undefined ? undefined : new Map(Object.entries(memo.recorded))
258
281
  }
259
282
  }
260
283
  if (importers === undefined) {
@@ -268,9 +291,17 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
268
291
  ? new Map<string, string>()
269
292
  : digest(text, new Map(Object.entries(extras)))
270
293
  edges = options.importers?.(text) ?? new Map()
271
- // Workspace scope with no edges to keep has nothing a memo saves.
272
- if (scope === 'project' || options.importers !== undefined) {
273
- await writeMemo(memoFile, { version, claimant, lock: lockHash }, extras, importers, edges)
294
+ recorded = options.recorded?.(text)
295
+ if (memoises) {
296
+ const kept: LockfileMemo = {
297
+ version,
298
+ lock: lockHash,
299
+ extras,
300
+ importers: Object.fromEntries(importers),
301
+ edges: Object.fromEntries(edges),
302
+ ...(recorded !== undefined ? { recorded: Object.fromEntries(recorded) } : {}),
303
+ }
304
+ if (!writeLockfileMemo(cacheDir, file, claimant, kept)) unsaved = kept
274
305
  }
275
306
  }
276
307
  const rels = Object.keys(extras)
@@ -278,8 +309,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
278
309
  rels.length === 0
279
310
  ? lockHash
280
311
  : xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
281
- const digests: Digests = { lock, importers, edges }
282
- seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
312
+ const digests: Digests = {
313
+ lock,
314
+ importers,
315
+ edges,
316
+ recorded:
317
+ recorded === undefined
318
+ ? undefined
319
+ : new Map([...recorded].map(([i, names]) => [i, new Set(names)])),
320
+ }
321
+ seen.set(workspaceRoot, {
322
+ identity,
323
+ extras: rels,
324
+ extraStats,
325
+ digests,
326
+ ...(unsaved !== undefined ? { unsaved } : {}),
327
+ })
283
328
  return digests
284
329
  }
285
330
  const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
@@ -339,8 +384,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
339
384
  // parser: `--affected` asks on every diff, and a cold parse of
340
385
  // a large lockfile was most of its selection's cost.
341
386
  async extraFiles(_file: string, bytes: Uint8Array, ctx: FingerprintContext) {
342
- const memoFile = path.join(ctx.cacheDir, 'lockfile-claims', `${file}.json`)
343
- const memo = await readMemo(memoFile, version, claimant, xxh3hex(bytes))
387
+ const memo = memoises ? readMemo(ctx.cacheDir, xxh3hex(bytes)) : undefined
344
388
  return memo !== undefined
345
389
  ? Object.keys(memo.extras)
346
390
  : options.extraFiles!(decode(bytes))
@@ -360,6 +404,10 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
360
404
  const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
361
405
  return digests.lock === '' ? undefined : digests.edges
362
406
  },
407
+ async recorded(ctx) {
408
+ const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
409
+ return digests.lock === '' ? undefined : digests.recorded
410
+ },
363
411
  }
364
412
  }
365
413
 
@@ -394,59 +442,6 @@ function importerOf(workspaceRoot: string, projectDir: string): string {
394
442
  return relPosix(workspaceRoot, projectDir) || '.'
395
443
  }
396
444
 
397
- async function readMemo(
398
- memoFile: string,
399
- version: number,
400
- claimant: string,
401
- lock: string,
402
- ): Promise<
403
- | {
404
- extras: Record<string, string>
405
- importers: Map<string, string>
406
- edges: Map<string, LockfileImporter>
407
- }
408
- | undefined
409
- > {
410
- try {
411
- const memo = (await Bun.file(memoFile).json()) as Memo
412
- if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
413
- return undefined
414
- }
415
- return {
416
- extras: memo.extras ?? {},
417
- importers: new Map(Object.entries(memo.importers)),
418
- edges: new Map(Object.entries(memo.edges ?? {})),
419
- }
420
- } catch {
421
- return undefined
422
- }
423
- }
424
-
425
- async function writeMemo(
426
- memoFile: string,
427
- identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
428
- extras: Record<string, string>,
429
- importers: ReadonlyMap<string, string>,
430
- edges: ReadonlyMap<string, LockfileImporter>,
431
- ): Promise<void> {
432
- const memo: Memo = {
433
- ...identity,
434
- extras,
435
- importers: Object.fromEntries(importers),
436
- ...(edges.size > 0 ? { edges: Object.fromEntries(edges) } : {}),
437
- }
438
- // Write-then-rename: a reader never sees a half-written memo, and two
439
- // concurrent runs each land a whole one.
440
- const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
441
- try {
442
- await Bun.write(tmp, JSON.stringify(memo))
443
- await rename(tmp, memoFile)
444
- } catch {
445
- // The memo is a speed-up, never a requirement: the digests are
446
- // computed either way, and the next run computes them again.
447
- }
448
- }
449
-
450
445
  function decode(bytes: Uint8Array): string {
451
446
  return new TextDecoder().decode(bytes)
452
447
  }
@@ -527,9 +527,9 @@ export interface AdmitContext {
527
527
  }
528
528
 
529
529
  export interface ScheduleHookContext extends BaseContext {
530
- /** The run's local cache handle — its `dbHandle()` holds the run history a policy can learn from. */
530
+ /** The run's local cache: `localHistory(localCache)` reads the run history a policy learns from. */
531
531
  readonly localCache: Cache
532
- /** The run's branch (`currentBranch`), the timings a forecast reads first: pass it to `LocalHistoryProvider`. */
532
+ /** The run's branch (`currentBranch`), the timings a forecast reads first: pass it to `localHistory`. */
533
533
  readonly branch: string | null
534
534
  /** The refs the run reads, in order (`CacheContext.refs`): a remote's timings are read by them too. */
535
535
  readonly refs: readonly string[]
@@ -20,6 +20,7 @@ import {
20
20
  UserError,
21
21
  } from '../util/index.js'
22
22
  import {
23
+ indexDb,
23
24
  Cache,
24
25
  type CacheLayer,
25
26
  applyCacheFlags,
@@ -834,7 +835,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
834
835
  let own = pluginCaches.get(plugin)
835
836
  if (own === undefined) {
836
837
  own = pluginCache(plugin, {
837
- db: localCache.dbHandle(),
838
+ db: indexDb(localCache),
838
839
  policy,
839
840
  remotes,
840
841
  scope: cacheScope,