@vzn/vx 0.0.641 → 0.0.642

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 (67) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +92 -12
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +6 -2
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +2 -6
  11. package/src/cache/layered-cache.ts +68 -4
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/remote-lane.ts +27 -10
  14. package/src/cache/schema.ts +13 -3
  15. package/src/cache/scoped-cache.ts +3 -1
  16. package/src/cache/umask.ts +29 -0
  17. package/src/cli/docs.ts +71 -7
  18. package/src/cli/help.ts +4 -4
  19. package/src/cli/plan-format.ts +4 -1
  20. package/src/cli/run.ts +10 -3
  21. package/src/cli/select.ts +2 -0
  22. package/src/exec/runner.ts +5 -0
  23. package/src/exec/sandbox-runtime.ts +81 -1
  24. package/src/graph/priorities.ts +14 -1
  25. package/src/history/history.ts +43 -33
  26. package/src/index.ts +10 -0
  27. package/src/orchestrator/deferred-outputs.ts +3 -2
  28. package/src/orchestrator/execute-task.ts +20 -14
  29. package/src/orchestrator/hit-restore.ts +10 -2
  30. package/src/orchestrator/index.ts +1 -0
  31. package/src/orchestrator/lockfile-claim.ts +54 -8
  32. package/src/orchestrator/miss-save.ts +27 -17
  33. package/src/orchestrator/output-host.ts +9 -1
  34. package/src/orchestrator/plan.ts +4 -0
  35. package/src/orchestrator/plugin-host.ts +31 -0
  36. package/src/orchestrator/plugin.ts +29 -3
  37. package/src/orchestrator/prepare.ts +74 -5
  38. package/src/orchestrator/projects.ts +6 -1
  39. package/src/orchestrator/run.ts +37 -15
  40. package/src/orchestrator/sandbox-request.ts +1 -48
  41. package/src/orchestrator/save-lane.ts +17 -10
  42. package/src/orchestrator/signals.ts +7 -10
  43. package/src/orchestrator/stable-keys.ts +83 -25
  44. package/src/orchestrator/task-hash.ts +46 -12
  45. package/src/report/events.ts +12 -0
  46. package/src/report/framed-output.ts +2 -1
  47. package/src/report/logger.ts +53 -13
  48. package/src/report/status-line.ts +45 -4
  49. package/src/report/summary.ts +5 -2
  50. package/src/util/errors.ts +1 -1
  51. package/src/util/hidden-prompt.ts +74 -0
  52. package/src/util/index.ts +12 -2
  53. package/src/util/paths.ts +33 -14
  54. package/src/util/secrets.ts +150 -0
  55. package/src/workspace/affected.ts +63 -13
  56. package/src/workspace/config-schema.ts +37 -3
  57. package/src/workspace/project-loader.ts +1 -0
  58. package/src/workspace/workspace.ts +17 -11
  59. package/docs/caching.md +0 -2376
  60. package/docs/cli.md +0 -3285
  61. package/docs/execution.md +0 -676
  62. package/docs/features.md +0 -178
  63. package/docs/patterns.md +0 -219
  64. package/docs/schema.md +0 -2468
  65. package/docs/security.md +0 -253
  66. package/src/cli/docs-corpus.ts +0 -21
  67. package/src/cli/docs-text.d.ts +0 -5
@@ -109,9 +109,10 @@ import {
109
109
  zstdEncoder,
110
110
  } from './zstd.js'
111
111
  import { FileHashStore } from './file-hashes.js'
112
- import { OutputIndex } from './output-index.js'
112
+ import { LINK_MODE, OutputIndex } from './output-index.js'
113
113
  import { RunHistory } from './run-history.js'
114
114
  import { CACHE_VERSION, foldKey } from './key-fold.js'
115
+ import { FoldMemo } from './fold-memo.js'
115
116
  import { behindLink } from './linked-inputs.js'
116
117
 
117
118
  /**
@@ -315,7 +316,11 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
315
316
  // unchanged.
316
317
  // v40: runs.remote_error — each remote read or write of a task's key that
317
318
  // failed, for `vx last`. The cache KEY is unchanged.
318
- export const SCHEMA_VERSION = 'v40'
319
+ // v41: the artifacts table is a rowid table (layout a2). The cache KEY
320
+ // is unchanged.
321
+ // v42: fold_memo — each task's last key by its fold up to the files and
322
+ // a token of their git records. The cache KEY is unchanged.
323
+ export const SCHEMA_VERSION = 'v42'
319
324
 
320
325
  /**
321
326
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -363,7 +368,7 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
363
368
  * reset, a new index or store, a store moved), so a change to it bumps
364
369
  * `SCHEMA_VERSION` too: a warm open reads no statement for it.
365
370
  */
366
- const ARTIFACT_LAYOUT = 'a1'
371
+ const ARTIFACT_LAYOUT = 'a2'
367
372
 
368
373
  /**
369
374
  * The largest compressed artifact kept in the index rather than in a file
@@ -807,6 +812,12 @@ export interface RemoteTraffic {
807
812
  /** Uploads queued or running across every remote. */
808
813
  inFlight: number
809
814
  readonly keys: Map<string, RemoteKeyTraffic>
815
+ /**
816
+ * A key a remote is asked under that is not the task's own (a scope's)
817
+ * → the task key it answers for: its traffic counts under the task key,
818
+ * which the footer and the run's rows look up.
819
+ */
820
+ readonly aliases: Map<string, string>
810
821
  }
811
822
 
812
823
  export class Cache implements CacheLayer {
@@ -819,7 +830,7 @@ export class Cache implements CacheLayer {
819
830
  */
820
831
  readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
821
832
  /** The same remotes' traffic per key, filled by the same `LayeredCache`. */
822
- readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
833
+ readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map(), aliases: new Map() }
823
834
 
824
835
  private readonly db: Database
825
836
  private readonly insertEntry: ReturnType<Database['prepare']>
@@ -851,6 +862,7 @@ export class Cache implements CacheLayer {
851
862
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
852
863
  private readonly files: FileHashStore
853
864
  private readonly outputs: OutputIndex
865
+ private readonly folds: FoldMemo
854
866
  private readonly history: RunHistory
855
867
 
856
868
  /**
@@ -866,6 +878,8 @@ export class Cache implements CacheLayer {
866
878
  private readonly inspecting: boolean
867
879
  /** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
868
880
  private readonly writeBlocked: string | null
881
+ /** The index is in memory: nothing on disk to read. */
882
+ private readonly empty: boolean
869
883
  private readonly dbFile: string
870
884
  /** Where the artifacts live: the shared store, or `cacheDir` itself. */
871
885
  private readonly artifactDir: string
@@ -952,8 +966,15 @@ export class Cache implements CacheLayer {
952
966
  (!existsSync(dbFile) || (mode === 'preview' && indexOfAnotherSchema(dbFile)))
953
967
  this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
954
968
  this.write = localPolicy.write && this.writeBlocked === null
969
+ // A directory this user cannot write with no index in it yet: SQLite
970
+ // cannot make one, and its `unable to open database file` stack reached
971
+ // the user before `assertWritable` could name the directory (a
972
+ // read-only mount, Growth's parity run 2026-10-16). Nothing there to
973
+ // read, so an empty index in memory, as a reader's typo gets.
974
+ const memory = absent || (this.writeBlocked !== null && !existsSync(dbFile))
975
+ this.empty = memory
955
976
  try {
956
- this.db = new Database(absent ? ':memory:' : dbFile, { create: true })
977
+ this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
957
978
  } catch (err) {
958
979
  throw outOfFdsAtOpen(dbFile, err) ?? err
959
980
  }
@@ -1062,7 +1083,7 @@ export class Cache implements CacheLayer {
1062
1083
  // keeps it so).
1063
1084
  if (found !== undefined) {
1064
1085
  this.db.exec(
1065
- '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;',
1086
+ '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;',
1066
1087
  )
1067
1088
  }
1068
1089
  // The tables land under the same write lock as the stamp: made
@@ -1224,6 +1245,7 @@ export class Cache implements CacheLayer {
1224
1245
  // the schema above is the one place every table is declared.
1225
1246
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1226
1247
  this.outputs = new OutputIndex(this.db)
1248
+ this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
1227
1249
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1228
1250
 
1229
1251
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
@@ -1493,6 +1515,11 @@ export class Cache implements CacheLayer {
1493
1515
  return rel
1494
1516
  }
1495
1517
 
1518
+ /** Each task's last key by its probe (`computeTaskHash`). */
1519
+ foldMemo(): FoldMemo {
1520
+ return this.folds
1521
+ }
1522
+
1496
1523
  key(input: CacheKeyInput): Promise<string> {
1497
1524
  const hashFile = (f: string): Promise<string> => this.hashFile(f)
1498
1525
  return foldKey(
@@ -2091,17 +2118,31 @@ export class Cache implements CacheLayer {
2091
2118
  )
2092
2119
  }
2093
2120
 
2121
+ /**
2122
+ * A run over a cache directory it cannot write: one holding entries
2123
+ * serves its hits and saves nothing (M, 2026-10-16), and the line this
2124
+ * returns says so; an empty one has nothing to serve and refuses.
2125
+ */
2126
+ readOnlyNotice(): string | null {
2127
+ if (this.writeBlocked === null) return null
2128
+ if (this.empty) this.assertWritable()
2129
+ return (
2130
+ `cache directory ${this.cacheDir} is not writable (${this.writeBlocked}) — serving hits ` +
2131
+ `from it; this run saves nothing and records no history`
2132
+ )
2133
+ }
2134
+
2094
2135
  async save(args: {
2095
2136
  hash: string
2096
2137
  /** See `CacheLayer.save` — `exitCode` is not a caller's to supply. */
2097
2138
  entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
2098
2139
  projectDir: string
2099
2140
  outputFiles: string[]
2141
+ outputDirs?: string[]
2100
2142
  workspaceOutputFiles?: string[]
2101
2143
  workspaceRoot?: string
2102
2144
  inputComponents?: readonly TaskInputRow[]
2103
2145
  /** See `CacheLayer.save`. */
2104
- readable?: ((real: string) => boolean) | undefined
2105
2146
  }): Promise<void> {
2106
2147
  // Layout (v17, extended additively for workspaceFiles): one
2107
2148
  // `<hash>.tar.zst` per entry. Tar carries ONLY the things you'd
@@ -2267,6 +2308,7 @@ export class Cache implements CacheLayer {
2267
2308
  private outputsOf(args: {
2268
2309
  projectDir: string
2269
2310
  outputFiles: string[]
2311
+ outputDirs?: string[]
2270
2312
  workspaceOutputFiles?: string[]
2271
2313
  workspaceRoot?: string
2272
2314
  }): Map<string, string> {
@@ -2299,9 +2341,9 @@ export class Cache implements CacheLayer {
2299
2341
  entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
2300
2342
  projectDir: string
2301
2343
  outputFiles: string[]
2344
+ outputDirs?: string[]
2302
2345
  workspaceOutputFiles?: string[]
2303
2346
  workspaceRoot?: string
2304
- readable?: ((real: string) => boolean) | undefined
2305
2347
  }): Promise<Uint8Array> {
2306
2348
  // stdout is ALWAYS present in the artifact, even if empty, so the
2307
2349
  // layout is predictable: a successful read finds `stdout` and
@@ -2323,9 +2365,9 @@ export class Cache implements CacheLayer {
2323
2365
  key: args.hash,
2324
2366
  stdout: args.entry.stdout ?? '',
2325
2367
  outputs: this.outputsOf(args),
2368
+ dirs: args.outputDirs?.map((d) => `outputs/${relPosix(args.projectDir, d)}`),
2326
2369
  exec: usageOfEntry(args.entry),
2327
- within: args.projectDir,
2328
- readable: args.readable,
2370
+ root: args.workspaceRoot ?? args.projectDir,
2329
2371
  })
2330
2372
  if (plan.size > this.artifactCeiling) {
2331
2373
  throw new Error(
@@ -2463,7 +2505,10 @@ export class Cache implements CacheLayer {
2463
2505
  : `artifact was stored under another key (${scanned.key})`,
2464
2506
  )
2465
2507
  }
2466
- assertArtifactNames(hash, scanned.entries, meta.outputs)
2508
+ assertArtifactNames(hash, scanned.entries, meta.outputs, [
2509
+ ...scanned.dirs,
2510
+ ...scanned.links.map(([name]) => name),
2511
+ ])
2467
2512
  } catch (err) {
2468
2513
  if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2469
2514
  if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
@@ -2496,6 +2541,28 @@ export class Cache implements CacheLayer {
2496
2541
  if (rowPath === null) continue
2497
2542
  outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
2498
2543
  }
2544
+ // An empty directory's row is its rel with a trailing slash: the hit's
2545
+ // check asks only that a directory stands there.
2546
+ for (const d of scanned.dirs) {
2547
+ outputFileRows.push([
2548
+ `${d.startsWith('outputs/') ? d.slice('outputs/'.length) : d}/`,
2549
+ 0,
2550
+ 0,
2551
+ 0,
2552
+ ])
2553
+ }
2554
+ // A link's row: its rel, the target's length and CRC-32 where a file
2555
+ // has its size and mtime, and the link type in the mode, so the hit's
2556
+ // check compares the target and never follows it.
2557
+ for (const [name, to] of scanned.links) {
2558
+ const bytes = new TextEncoder().encode(to)
2559
+ outputFileRows.push([
2560
+ name.startsWith('outputs/') ? name.slice('outputs/'.length) : name,
2561
+ bytes.byteLength,
2562
+ LINK_MODE,
2563
+ Bun.hash.crc32(bytes),
2564
+ ])
2565
+ }
2499
2566
  const stdoutText = scanned.stdout
2500
2567
 
2501
2568
  const [project, task] = splitTaskId(meta.taskId)
@@ -2665,13 +2732,18 @@ export class Cache implements CacheLayer {
2665
2732
  }
2666
2733
 
2667
2734
  // --- run history: delegated to `RunHistory` (see run-history.ts) ---
2735
+ // A run over a directory it cannot write records no history:
2736
+ // `readOnlyNotice` said so once.
2668
2737
  recordRun(run: RunRecord): void {
2738
+ if (this.writeBlocked !== null) return
2669
2739
  return this.guard(() => this.history.recordRun(run))
2670
2740
  }
2671
2741
  recordRuns(runs: readonly RunRecord[]): void {
2742
+ if (this.writeBlocked !== null) return
2672
2743
  return this.guard(() => this.history.recordRuns(runs))
2673
2744
  }
2674
2745
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
2746
+ if (this.writeBlocked !== null) return
2675
2747
  return this.guard(() => this.history.recordRunBundle(bundle))
2676
2748
  }
2677
2749
  recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
@@ -3026,6 +3098,7 @@ export class Cache implements CacheLayer {
3026
3098
  try {
3027
3099
  this.flushAccessed()
3028
3100
  this.outputs.flushOutputDirs()
3101
+ this.folds.flush(Date.now() - 30 * 24 * 60 * 60 * 1000)
3029
3102
  } catch {
3030
3103
  // Same contract as the retention prune above, which was already
3031
3104
  // guarded while this sibling was not: `accessed_at` is LRU
@@ -3083,8 +3156,15 @@ function assertArtifactNames(
3083
3156
  hash: string,
3084
3157
  entries: ReadonlyArray<{ name: string }>,
3085
3158
  declared: DeclaredOutputs | undefined,
3159
+ dirs: readonly string[] = [],
3086
3160
  ): void {
3087
3161
  const names = new Set(entries.map((e) => e.name))
3162
+ for (const d of dirs) {
3163
+ if (names.has(d)) {
3164
+ throw new CorruptArtifactError(hash, `artifact holds ${d} as a file and as a directory`)
3165
+ }
3166
+ names.add(d)
3167
+ }
3088
3168
  for (const name of names) {
3089
3169
  for (let sep = name.indexOf('/'); sep !== -1; sep = name.indexOf('/', sep + 1)) {
3090
3170
  if (names.has(name.slice(0, sep))) {
@@ -3116,7 +3196,7 @@ function assertArtifactNames(
3116
3196
  */
3117
3197
  function declaredName(declared: DeclaredOutputs): (name: string) => boolean {
3118
3198
  const isFile = restorableOutput(declared.files)
3119
- const isWsFile = restorableOutput(declared.workspaceFiles)
3199
+ const isWsFile = restorableOutput(declared.workspaceFiles, 'workspace')
3120
3200
  return (name) =>
3121
3201
  name.startsWith('outputs/')
3122
3202
  ? isFile(name.slice('outputs/'.length))
@@ -0,0 +1,58 @@
1
+ // Each task's last key, by what its input files were: a `probe` that is the
2
+ // fold up to the files (`foldHead`) seeded into a token of the files' git
3
+ // records and the declaration that picks among them (`filesToken`). Equal
4
+ // probes are equal folds, so a warm task's key is read back instead of
5
+ // resolving and folding its files again: ~300 ms of a 5,000-project warm
6
+ // run (2026-10-17). One row per task, rewritten when its probe moves; a
7
+ // pure memo, so a lost or swept row is one fold more.
8
+
9
+ import type { Database } from 'bun:sqlite'
10
+
11
+ export interface FoldMemoRow {
12
+ probe: string
13
+ key: string
14
+ }
15
+
16
+ export class FoldMemo {
17
+ private rows: Map<string, FoldMemoRow> | undefined
18
+ private readonly dirty = new Map<string, FoldMemoRow>()
19
+
20
+ constructor(
21
+ private readonly db: Database,
22
+ /** The local WRITE axis: off, and a fold is answered but not remembered. */
23
+ private readonly write: boolean,
24
+ ) {}
25
+
26
+ /** The key remembered for `taskId` under `probe`, if that is the probe it was folded under. */
27
+ get(taskId: string, probe: string): string | undefined {
28
+ // One read of every row: a warm run asks for nearly all of them.
29
+ this.rows ??= new Map(
30
+ (
31
+ this.db.query('SELECT task_id, probe, key FROM fold_memo').all() as Array<{
32
+ task_id: string
33
+ probe: string
34
+ key: string
35
+ }>
36
+ ).map((r) => [r.task_id, { probe: r.probe, key: r.key }]),
37
+ )
38
+ const row = this.dirty.get(taskId) ?? this.rows.get(taskId)
39
+ return row?.probe === probe ? row.key : undefined
40
+ }
41
+
42
+ set(taskId: string, probe: string, key: string): void {
43
+ if (this.write) this.dirty.set(taskId, { probe, key })
44
+ }
45
+
46
+ /** Write the folds this run made; rows a run has not rewritten for `cutoff` go. */
47
+ flush(cutoff: number, now: number = Date.now()): void {
48
+ if (this.dirty.size === 0) return
49
+ const upsert = this.db.prepare(
50
+ 'INSERT OR REPLACE INTO fold_memo(task_id, probe, key, seen_at) VALUES (?, ?, ?, ?)',
51
+ )
52
+ this.db.transaction(() => {
53
+ for (const [taskId, r] of this.dirty) upsert.run(taskId, r.probe, r.key, now)
54
+ this.db.prepare('DELETE FROM fold_memo WHERE seen_at < ?').run(cutoff)
55
+ })()
56
+ this.dirty.clear()
57
+ }
58
+ }