@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,5 +1,5 @@
1
1
  // The run-level cache policy: four axes (local/remote × read/write) and
2
- // the `--cache=<spec>` grammar that sets them. Pure data + parsing; no I/O.
2
+ // the `--cache-policy=<spec>` grammar that sets them. Pure data + parsing; no I/O.
3
3
 
4
4
  import { UserError } from '../util/index.js'
5
5
 
@@ -10,8 +10,7 @@ import { UserError } from '../util/index.js'
10
10
  * cache) is distinct from `--no-cache` (disable everything).
11
11
  *
12
12
  * The task-artifact get/save path is gated, and the local axes also gate
13
- * the local store's config-evaluation reads and writes and its file-hash
14
- * writes (`cache.ts`). `recordRun`, `stats`, `prune`, key derivation, and
13
+ * the local store's file-hash writes (`cache.ts`). `recordRun`, `stats`, `prune`, key derivation, and
15
14
  * prefetch-ingest are never affected — they are bookkeeping/analytics
16
15
  * that a run policy has no business disabling.
17
16
  */
@@ -20,12 +19,6 @@ export interface CachePolicy {
20
19
  localWrite: boolean
21
20
  remoteRead: boolean
22
21
  remoteWrite: boolean
23
- /**
24
- * An untrusted scope (`cacheScope: 'pr-123'`): remote reads try this
25
- * scope's keys, then the trusted ones; remote writes go to this scope's
26
- * keys only. Absent = trusted. See {@link scopeCachePolicy}.
27
- */
28
- remoteScope?: string
29
22
  }
30
23
 
31
24
  /** All four axes on — the default when no cache flag is passed. */
@@ -36,13 +29,13 @@ export const FULL_CACHE_POLICY: CachePolicy = {
36
29
  remoteWrite: true,
37
30
  }
38
31
 
39
- /** No axis on (`--no-cache`, `--cache=local:`): no cache answers for any task. */
32
+ /** No axis on (`--no-cache`, `--cache-policy=local:`): no cache answers for any task. */
40
33
  export function cachesNothing(p: CachePolicy): boolean {
41
34
  return !(p.localRead || p.localWrite || p.remoteRead || p.remoteWrite)
42
35
  }
43
36
 
44
37
  /**
45
- * Parse a `--cache=<spec>` value into a `CachePolicy`, starting from a
38
+ * Parse a `--cache-policy=<spec>` value into a `CachePolicy`, starting from a
46
39
  * base (defaults to {@link FULL_CACHE_POLICY}). The spec is a
47
40
  * comma-separated list of `layer:flags` segments where `layer` is
48
41
  * `local` or `remote` and `flags` is any subset of `r` (read) and `w`
@@ -53,7 +46,8 @@ export function cachesNothing(p: CachePolicy): boolean {
53
46
  * So `local:rw,remote:r` = remote read-only, `remote:` = remote fully
54
47
  * off, `local:r` = local read-only with remote untouched.
55
48
  *
56
- * Throws a {@link UserError} on an unknown layer, an unknown flag, a
49
+ * Throws a {@link UserError}, whose message the caller prefixes with the
50
+ * spec's source (`--cache-policy`, `VX_CACHE_POLICY`, `cache.policy`), on an unknown layer, an unknown flag, a
57
51
  * duplicated flag, a missing colon, or a repeated layer.
58
52
  */
59
53
  export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_POLICY): CachePolicy {
@@ -64,24 +58,24 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
64
58
  if (seg.length === 0) continue
65
59
  const colon = seg.indexOf(':')
66
60
  if (colon < 0) {
67
- throw new UserError(`invalid --cache segment '${seg}': expected '<layer>:<flags>'`)
61
+ throw new UserError(`invalid segment '${seg}': expected '<layer>:<flags>'`)
68
62
  }
69
63
  const layer = seg.slice(0, colon)
70
64
  const flags = seg.slice(colon + 1)
71
65
  if (layer !== 'local' && layer !== 'remote') {
72
- throw new UserError(`invalid --cache layer '${layer}': expected 'local' or 'remote'`)
66
+ throw new UserError(`invalid layer '${layer}': expected 'local' or 'remote'`)
73
67
  }
74
68
  if (seen.has(layer)) {
75
- throw new UserError(`--cache layer '${layer}' specified twice`)
69
+ throw new UserError(`layer '${layer}' specified twice`)
76
70
  }
77
71
  seen.add(layer)
78
72
  const flagSet = new Set<string>()
79
73
  for (const ch of flags) {
80
74
  if (ch !== 'r' && ch !== 'w') {
81
- throw new UserError(`invalid --cache flag '${ch}' for '${layer}': expected 'r' and/or 'w'`)
75
+ throw new UserError(`invalid flag '${ch}' for '${layer}': expected 'r' and/or 'w'`)
82
76
  }
83
77
  if (flagSet.has(ch)) {
84
- throw new UserError(`--cache flag '${ch}' repeated for '${layer}'`)
78
+ throw new UserError(`flag '${ch}' repeated for '${layer}'`)
85
79
  }
86
80
  flagSet.add(ch)
87
81
  }
@@ -96,16 +90,18 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
96
90
  return out
97
91
  }
98
92
 
99
- /**
100
- * Apply a workspace's `cacheScope` to the run's policy. `'trusted'` (or
101
- * none) changes nothing; `'read-only'` turns remote writes off; any other
102
- * name is an untrusted scope. A clamp, never a widening: `--cache` still
103
- * decides which axes are on. A client-side convention, not a boundary —
104
- * any run holding a write credential can write a trusted key; only the
105
- * cache server, by token, can refuse it (docs/security.md).
106
- */
107
- export function scopeCachePolicy(policy: CachePolicy, scope: string | undefined): CachePolicy {
108
- if (scope === undefined || scope === 'trusted') return policy
109
- if (scope === 'read-only') return { ...policy, remoteWrite: false }
110
- return { ...policy, remoteScope: scope }
93
+ /** `--no-cache` and `--force`, which still apply over whichever spec set the base. */
94
+ export interface CacheFlags {
95
+ /** `--no-cache`: every axis off. Beats `force`. */
96
+ noCache?: boolean
97
+ /** `--force`: both reads off, writes kept. */
98
+ force?: boolean
99
+ }
100
+
101
+ export function applyCacheFlags(policy: CachePolicy, flags: CacheFlags | undefined): CachePolicy {
102
+ if (flags?.noCache === true) {
103
+ return { localRead: false, localWrite: false, remoteRead: false, remoteWrite: false }
104
+ }
105
+ if (flags?.force === true) return { ...policy, localRead: false, remoteRead: false }
106
+ return policy
111
107
  }
@@ -31,8 +31,13 @@ function insertRunsSql(n: number): string {
31
31
  */
32
32
  const RUNS_PER_INSERT = 40
33
33
 
34
+ /** Rows per `task_durations` INSERT: 6 variables each, under 999. */
35
+ const DURATIONS_PER_INSERT = 160
36
+
37
+ /** A local run's `task_durations.source`: its branch, `''` when it had none. */
38
+ export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
39
+
34
40
  export class RunHistory {
35
- private readonly insertRun: ReturnType<Database['prepare']>
36
41
  private readonly insertInvocation: ReturnType<Database['prepare']>
37
42
 
38
43
  constructor(
@@ -40,7 +45,6 @@ export class RunHistory {
40
45
  /** The store's salted value digest (L-4). */
41
46
  private readonly digestValue: (value: string) => string,
42
47
  ) {
43
- this.insertRun = lazyStatement(this.db, insertRunsSql(1))
44
48
  this.insertInvocation = lazyStatement(
45
49
  this.db,
46
50
  `
@@ -60,18 +64,34 @@ export class RunHistory {
60
64
  }
61
65
 
62
66
  recordRun(run: RunRecord): void {
63
- this.insertRun.run(...bindRun(run, this.digestValue))
67
+ this.recordRuns([run])
64
68
  }
65
69
 
66
70
  recordRuns(runs: readonly RunRecord[]): void {
67
71
  if (runs.length === 0) return
68
- if (runs.length === 1) {
69
- this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
70
- return
71
- }
72
72
  // One transaction, one fsync: for a 200-task run, one instead of 200.
73
73
  // IMMEDIATE, as `recordRunBundle` explains.
74
- this.db.transaction(() => this.insertRuns(runs)).immediate()
74
+ this.db
75
+ .transaction(() => {
76
+ this.insertRuns(runs)
77
+ this.insertDurations(runs, branchSource(null), null)
78
+ })
79
+ .immediate()
80
+ }
81
+
82
+ /** One `task_durations` row per executed success: what a forecast reads. */
83
+ private insertDurations(runs: readonly RunRecord[], source: string, sha: string | null): void {
84
+ const done = runs.filter((r) => r.status === 'success' && r.cacheHit !== true)
85
+ for (let i = 0; i < done.length; i += DURATIONS_PER_INSERT) {
86
+ const block = done.slice(i, i + DURATIONS_PER_INSERT)
87
+ const params: SQLQueryBindings[] = []
88
+ for (const r of block) params.push(r.project, r.task, source, sha, r.durationMs, r.endedAt)
89
+ this.db
90
+ .query(
91
+ `INSERT INTO task_durations(project, task, source, sha, duration_ms, recorded_at) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?)').join(', ')}`,
92
+ )
93
+ .run(...params)
94
+ }
75
95
  }
76
96
 
77
97
  /** `runs` as few INSERTs; each distinct forward-args list digested once. */
@@ -109,6 +129,11 @@ export class RunHistory {
109
129
  .transaction(() => {
110
130
  this.insertRuns(bundle.runs)
111
131
  this.insertInvocation.run(...bindInvocation(bundle.invocation))
132
+ this.insertDurations(
133
+ bundle.runs,
134
+ branchSource(bundle.invocation.branch),
135
+ bundle.invocation.commitSha,
136
+ )
112
137
  })
113
138
  .immediate()
114
139
  }
@@ -116,21 +141,18 @@ export class RunHistory {
116
141
  /**
117
142
  * Retention: the runs table grows by one row per executed task per
118
143
  * invocation — a 2000-task repo accretes ~20k rows in days. 30 days
119
- * covers `vx stats` (24 h windows) and CI-side consumers. The
144
+ * covers `vx info` (24 h windows) and CI-side consumers. The
120
145
  * `invocations` header is pruned on the SAME window, or a header would
121
146
  * outlive its rows and `vx last` would list a run whose detail is gone.
122
147
  */
123
148
  pruneOlderThan(cutoff: number): void {
124
149
  this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
125
150
  this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
151
+ this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
126
152
  }
127
153
  }
128
154
 
129
- /**
130
- * Bind a RunRecord to the positional parameters expected by the
131
- * `insertRun` prepared statement (22 columns). Shared between the
132
- * single and batched record paths.
133
- */
155
+ /** Bind a RunRecord to one `RUN_TUPLE`'s 22 positional parameters. */
134
156
  function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
135
157
  return [
136
158
  // The ONE place the no-key sentinel is applied, so the column's
@@ -1,7 +1,7 @@
1
1
  // The index's tables, one concern: what a cache.db holds. `Cache` opens the
2
2
  // file, checks `SCHEMA_VERSION` and creates these; every statement against
3
- // them lives in the store that owns the table (file-hashes, config-evals,
4
- // output-index, history) or in cache.ts for `entries`, `entry_inputs` and
3
+ // them lives in the store that owns the table (file-hashes, output-index,
4
+ // history) or in cache.ts for `entries`, `entry_inputs` and
5
5
  // `artifacts`.
6
6
 
7
7
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
@@ -22,7 +22,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
22
22
 
23
23
  /**
24
24
  * Create every table. `store` names the schema holding the ENTRY tables
25
- * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`):
25
+ * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
26
+ * and the task timings (`task_durations`):
26
27
  * `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
27
28
  * they live in the shared store attached beside it (v32). The rest is the
28
29
  * workspace's own: its history, its memos, and what its disk looked like
@@ -30,17 +31,6 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
30
31
  * a statement; SQLite finds it in `main` or in `store`, whichever has it.
31
32
  */
32
33
  export function createTables(db: Database, store: 'main' | 'store' = 'main'): void {
33
- // Cached config evaluations (workspace/config-cache.ts): the validated
34
- // config as JSON, keyed by everything the evaluation could observe. A
35
- // separate exec so the artifact schema stays byte-identical.
36
- db.exec(`
37
- CREATE TABLE IF NOT EXISTS main.config_evals (
38
- key TEXT PRIMARY KEY,
39
- json TEXT NOT NULL,
40
- created_at INTEGER NOT NULL
41
- );
42
- `)
43
-
44
34
  db.exec(`
45
35
  -- The queryable index: command, exit_code, duration, size and
46
36
  -- timestamps; stdout in entry_stdout below.
@@ -197,15 +187,6 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
197
187
  ctime_ms INTEGER NOT NULL,
198
188
  PRIMARY KEY (entry_hash, path)
199
189
  );
200
- -- Each config's ORDERED import closure (the config first), so a warm
201
- -- load keys it by stat-hashing the list (the file_hashes memo) instead
202
- -- of reading and scanning every file. Machine-local; pruned with
203
- -- config_evals (2026-09-03).
204
- CREATE TABLE IF NOT EXISTS main.config_closures (
205
- config_path TEXT PRIMARY KEY,
206
- files_json TEXT NOT NULL,
207
- created_at INTEGER NOT NULL
208
- );
209
190
  -- Every directory under a whole-subtree output glob, with its mtime
210
191
  -- as of the last save/restore on THIS machine (2026-09-03). On a warm
211
192
  -- hit, unchanged mtimes prove the output SET is unchanged, replacing
@@ -277,6 +258,23 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
277
258
  key TEXT PRIMARY KEY,
278
259
  value TEXT NOT NULL
279
260
  );
261
+ -- What a duration forecast reads (orchestrator/history.ts), one row per
262
+ -- executed success, shared by every worktree of the repo so a new one
263
+ -- forecasts on its first run (owner, 2026-10-14). Append-only: never
264
+ -- updated, pruned by age with the run history. \`source\` tags a local
265
+ -- run 'branch:<name>' ('branch:' detached or outside git); \`sha\` is its
266
+ -- commit. A forecast reads a task's newest rows of one source.
267
+ CREATE TABLE IF NOT EXISTS ${store}.task_durations (
268
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
269
+ project TEXT NOT NULL,
270
+ task TEXT NOT NULL,
271
+ source TEXT NOT NULL,
272
+ sha TEXT,
273
+ duration_ms INTEGER NOT NULL,
274
+ recorded_at INTEGER NOT NULL
275
+ );
276
+ CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
277
+ ON task_durations(project, task, source, id);
280
278
  ${artifactTables(store)}
281
279
  `)
282
280
  }
@@ -0,0 +1,224 @@
1
+ // The run's `cache.scope` over every layer at once (owner, 2026-10-14): a
2
+ // scoped run reads the default key, then the scope's, and writes only the
3
+ // scope's, in the local store and every remote alike. Below this wrapper no
4
+ // layer knows a scope exists: the scope's key is a key like any other.
5
+
6
+ import { xxh3hex } from '../util/index.js'
7
+ import type {
8
+ CacheEntry,
9
+ CacheGetContext,
10
+ CacheLayer,
11
+ IngestMeta,
12
+ InvocationRecord,
13
+ OutputDirRow,
14
+ RunRecord,
15
+ SaveArgs,
16
+ } from './layer.js'
17
+
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 {
20
+ return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
21
+ }
22
+
23
+ /**
24
+ * A layer as a scoped run sees it. Callers speak task keys; each answer is
25
+ * remembered under the key that served it (the default key on a default hit,
26
+ * the scope's on a scope hit or a save), so the restore, the output index and
27
+ * the run's history all reach the entry that answered. An artifact records
28
+ * the key it was packed under and ingest refuses another, so a derived-key
29
+ * collision degrades to a miss. A default artifact a remote ingest refuses is
30
+ * a miss there, and the scope's key is asked next (Security F6).
31
+ */
32
+ export class ScopedCache implements CacheLayer {
33
+ readonly local?: CacheLayer['local']
34
+ readonly hasRemote?: boolean
35
+ readonly drainUploads?: () => Promise<void>
36
+ readonly remoteHasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
37
+ readonly getMany?: (
38
+ hashes: readonly string[],
39
+ ctx?: (hash: string) => CacheGetContext,
40
+ ) => Promise<Map<string, CacheEntry>>
41
+ /** Task key → the key that answers for it this run. */
42
+ private readonly served = new Map<string, string>()
43
+
44
+ constructor(
45
+ private readonly inner: CacheLayer,
46
+ private readonly scope: string,
47
+ ) {
48
+ if (inner.local !== undefined) this.local = inner.local
49
+ if (inner.hasRemote !== undefined) this.hasRemote = inner.hasRemote
50
+ if (inner.drainUploads !== undefined) this.drainUploads = () => inner.drainUploads!()
51
+ if (inner.remoteHasMany !== undefined) this.remoteHasMany = (h) => this.scopedHasMany(h)
52
+ if (inner.getMany !== undefined) this.getMany = (h, ctx) => this.scopedGetMany(h, ctx)
53
+ }
54
+
55
+ private own(hash: string): string {
56
+ return scopedKey(this.scope, hash)
57
+ }
58
+
59
+ private keyOf(hash: string): string {
60
+ return this.served.get(hash) ?? hash
61
+ }
62
+
63
+ /** A batch keyed by served keys, keyed back by task key. */
64
+ private batch<T>(
65
+ hashes: readonly string[],
66
+ read: (keys: string[]) => Map<string, T>,
67
+ ): Map<string, T> {
68
+ const keys = hashes.map((h) => this.keyOf(h))
69
+ const found = read(keys)
70
+ const out = new Map<string, T>()
71
+ for (let i = 0; i < hashes.length; i++) {
72
+ const v = found.get(keys[i]!)
73
+ if (v !== undefined) out.set(hashes[i]!, v)
74
+ }
75
+ return out
76
+ }
77
+
78
+ private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
79
+ const scoped = hashes.map((h) => this.own(h))
80
+ const [plain, own] = await Promise.all([
81
+ this.inner.remoteHasMany!(hashes),
82
+ this.inner.remoteHasMany!(scoped),
83
+ ])
84
+ if (plain == null || own == null) return null
85
+ const found = new Set(plain)
86
+ const scopeOnly: string[] = []
87
+ for (let i = 0; i < hashes.length; i++) {
88
+ if (plain.has(hashes[i]!) || !own.has(scoped[i]!)) continue
89
+ found.add(hashes[i]!)
90
+ scopeOnly.push(hashes[i]!)
91
+ }
92
+ // Their default key is known absent: its GET is skipped.
93
+ this.inner.markRemoteAbsent?.(scopeOnly)
94
+ return found
95
+ }
96
+
97
+ markRemoteAbsent(hashes: Iterable<string>): void {
98
+ const all: string[] = []
99
+ for (const h of hashes) all.push(h, this.own(h))
100
+ this.inner.markRemoteAbsent?.(all)
101
+ }
102
+
103
+ key(input: Parameters<CacheLayer['key']>[0]): Promise<string> {
104
+ return this.inner.key(input)
105
+ }
106
+
107
+ async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
108
+ for (const key of [hash, this.own(hash)]) {
109
+ const entry = await this.inner.get(key, ctx)
110
+ if (entry === null) continue
111
+ this.served.set(hash, key)
112
+ return key === hash ? entry : { ...entry, hash }
113
+ }
114
+ return null
115
+ }
116
+
117
+ private async scopedGetMany(
118
+ hashes: readonly string[],
119
+ ctx?: (hash: string) => CacheGetContext,
120
+ ): Promise<Map<string, CacheEntry>> {
121
+ const out = await this.inner.getMany!(hashes, ctx)
122
+ for (const h of out.keys()) this.served.set(h, h)
123
+ const rest = hashes.filter((h) => !out.has(h))
124
+ if (rest.length === 0) return out
125
+ const byOwn = new Map(rest.map((h) => [this.own(h), h]))
126
+ const scoped = await this.inner.getMany!(
127
+ [...byOwn.keys()],
128
+ ctx === undefined ? undefined : (key) => ctx(byOwn.get(key)!),
129
+ )
130
+ for (const [key, entry] of scoped) {
131
+ const hash = byOwn.get(key)!
132
+ this.served.set(hash, key)
133
+ out.set(hash, { ...entry, hash })
134
+ }
135
+ return out
136
+ }
137
+
138
+ async has(hash: string): Promise<'local' | 'remote' | null> {
139
+ return (await this.inner.has(hash)) ?? (await this.inner.has(this.own(hash)))
140
+ }
141
+
142
+ async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
143
+ return (await this.inner.prefetch(hash, ctx)) || this.inner.prefetch(this.own(hash), ctx)
144
+ }
145
+
146
+ loadOutputFilesBatch(hashes: readonly string[]): ReturnType<CacheLayer['loadOutputFilesBatch']> {
147
+ return this.batch(hashes, (keys) => this.inner.loadOutputFilesBatch(keys))
148
+ }
149
+
150
+ isOutputsCurrent(
151
+ ...args: Parameters<CacheLayer['isOutputsCurrent']>
152
+ ): ReturnType<CacheLayer['isOutputsCurrent']> {
153
+ return this.inner.isOutputsCurrent(...args)
154
+ }
155
+
156
+ async recordOutputDirs(
157
+ hash: string,
158
+ projectDir: string,
159
+ prefixes: readonly string[],
160
+ holds?: (files: readonly string[]) => boolean,
161
+ ): Promise<void> {
162
+ await this.inner.recordOutputDirs?.(this.keyOf(hash), projectDir, prefixes, holds)
163
+ }
164
+
165
+ recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
166
+ this.inner.recordOutputStamps?.(this.keyOf(hash), projectDir, workspaceRoot)
167
+ }
168
+
169
+ loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
170
+ return this.batch(hashes, (keys) => this.inner.loadOutputDirsBatch?.(keys) ?? new Map())
171
+ }
172
+
173
+ async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
174
+ return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
175
+ }
176
+
177
+ restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
178
+ return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot)
179
+ }
180
+
181
+ save(args: SaveArgs): Promise<void> {
182
+ const key = this.own(args.hash)
183
+ this.served.set(args.hash, key)
184
+ return this.inner.save({ ...args, hash: key })
185
+ }
186
+
187
+ ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
188
+ const key = this.own(hash)
189
+ this.served.set(hash, key)
190
+ return this.inner.ingest(key, body, meta)
191
+ }
192
+
193
+ /** History names the key that answered, so `vx why` reads that entry's components. */
194
+ recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
195
+ this.inner.recordRunBundle({
196
+ ...bundle,
197
+ runs: bundle.runs.map((r) => (r.hash === undefined ? r : { ...r, hash: this.keyOf(r.hash) })),
198
+ })
199
+ }
200
+
201
+ stats(...args: Parameters<CacheLayer['stats']>): ReturnType<CacheLayer['stats']> {
202
+ return this.inner.stats(...args)
203
+ }
204
+
205
+ hashFile(filePath: string): Promise<string> {
206
+ return this.inner.hashFile(filePath)
207
+ }
208
+
209
+ outputsPath(hash: string): string {
210
+ return this.inner.outputsPath(this.keyOf(hash))
211
+ }
212
+
213
+ artifactSize(hash: string): number | undefined {
214
+ return this.inner.artifactSize?.(this.keyOf(hash))
215
+ }
216
+
217
+ prune(...args: Parameters<CacheLayer['prune']>): ReturnType<CacheLayer['prune']> {
218
+ return this.inner.prune(...args)
219
+ }
220
+
221
+ close(): void {
222
+ this.inner.close()
223
+ }
224
+ }