@vzn/vx 0.0.637 → 0.0.638

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 (76) hide show
  1. package/docs/caching.md +41 -14
  2. package/docs/cli.md +115 -73
  3. package/docs/execution.md +1 -1
  4. package/docs/features.md +6 -5
  5. package/docs/schema.md +24 -15
  6. package/package.json +5 -5
  7. package/schemas/plan.json +2 -2
  8. package/skills/vx/SKILL.md +13 -2
  9. package/src/bin.ts +13 -7
  10. package/src/cache/cache.ts +21 -2
  11. package/src/cache/chained-cache.ts +4 -0
  12. package/src/cache/index.ts +9 -1
  13. package/src/cache/inputs.ts +0 -24
  14. package/src/cache/layer.ts +7 -1
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/plugin-cache.ts +248 -0
  17. package/src/cache/policy.ts +9 -0
  18. package/src/cache/run-history.ts +68 -0
  19. package/src/cache/run-store.ts +77 -0
  20. package/src/cache/schema.ts +38 -9
  21. package/src/cache/scoped-cache.ts +15 -2
  22. package/src/cli/cache.ts +33 -16
  23. package/src/cli/completions.ts +9 -5
  24. package/src/cli/help.ts +16 -7
  25. package/src/cli/index.ts +25 -11
  26. package/src/cli/info.ts +6 -5
  27. package/src/cli/init.ts +14 -15
  28. package/src/cli/last.ts +14 -7
  29. package/src/cli/lock.ts +39 -27
  30. package/src/cli/plan-format.ts +5 -3
  31. package/src/cli/plugin-commands.ts +77 -6
  32. package/src/cli/plugin-templates.ts +1 -1
  33. package/src/cli/run.ts +14 -12
  34. package/src/cli/select.ts +18 -9
  35. package/src/cli/show.ts +66 -40
  36. package/src/cli/task-verb.ts +4 -4
  37. package/src/cli/upgrade.ts +20 -11
  38. package/src/cli/watch-filter.ts +7 -2
  39. package/src/cli/watch-judge.ts +6 -1
  40. package/src/cli/watch.ts +44 -20
  41. package/src/cli/why.ts +10 -1
  42. package/src/cli/workspace-config.ts +2 -2
  43. package/src/graph/scheduler.ts +8 -0
  44. package/src/graph/task-graph.ts +32 -4
  45. package/src/index.ts +11 -1
  46. package/src/orchestrator/affected-tasks.ts +8 -3
  47. package/src/orchestrator/doctor.ts +1 -1
  48. package/src/orchestrator/execute-task.ts +26 -43
  49. package/src/orchestrator/failure-mode.ts +19 -10
  50. package/src/orchestrator/framed-output.ts +1 -2
  51. package/src/orchestrator/history.ts +60 -4
  52. package/src/orchestrator/index.ts +2 -1
  53. package/src/orchestrator/logger.ts +9 -2
  54. package/src/orchestrator/metrics.ts +7 -3
  55. package/src/orchestrator/plugin-host.ts +14 -4
  56. package/src/orchestrator/plugin.ts +80 -8
  57. package/src/orchestrator/prepare.ts +51 -7
  58. package/src/orchestrator/projects.ts +3 -0
  59. package/src/orchestrator/remote-prefetch.ts +9 -5
  60. package/src/orchestrator/run-records.ts +1 -1
  61. package/src/orchestrator/run.ts +126 -27
  62. package/src/orchestrator/status-line.ts +1 -1
  63. package/src/orchestrator/summary.ts +69 -44
  64. package/src/orchestrator/telemetry.ts +1 -1
  65. package/src/settings.ts +12 -1
  66. package/src/{orchestrator → util}/colors.ts +5 -0
  67. package/src/util/config-frame.ts +31 -2
  68. package/src/util/errors.ts +32 -0
  69. package/src/util/index.ts +8 -1
  70. package/src/util/refusal-notice.ts +111 -0
  71. package/src/util/report.ts +31 -0
  72. package/src/util/ui.ts +186 -0
  73. package/src/workspace/affected.ts +20 -23
  74. package/src/workspace/dependency-rules.ts +12 -4
  75. package/src/workspace/migration.ts +87 -91
  76. package/src/workspace/workspace.ts +5 -4
package/docs/schema.md CHANGED
@@ -881,7 +881,7 @@ lockfile change when a lockfile plugin (`@vzn/vx-lockfile`) is declared.
881
881
  Always applied to every glob pass (regardless of what you wrote):
882
882
 
883
883
  - **gitignore filter** — workspace-root + project `.gitignore`.
884
- - **Always-ignored** — `.git/**`, `.vx/cache/**`, `*.tsbuildinfo`,
884
+ - **Always-ignored** — `.git/**`, `.vx/cache/**`,
885
885
  `.vx/projects.lock.json`, `vx.lock.json`, `*.bun-build`, and Bun's cross-compile extraction
886
886
  directory `.<16 hex>-<8 hex>.tmp/**`, and vx's own restore temp
887
887
  `.vx-tmp-*`, at any depth.
@@ -937,7 +937,7 @@ backslash separator (`src\**`, `src\*.ts`) or a drive (`C:\src\**`,
937
937
  backslash stays an escape.
938
938
 
939
939
  Still applied: the always-ignored set (`.git/**`, `.vx/cache/**`,
940
- `*.tsbuildinfo`, `.vx/projects.lock.json`, `vx.lock.json`, `*.bun-build`, `.<16 hex>-<8 hex>.tmp/**`, `.vx-tmp-*`),
940
+ `.vx/projects.lock.json`, `vx.lock.json`, `*.bun-build`, `.<16 hex>-<8 hex>.tmp/**`, `.vx-tmp-*`),
941
941
  untracked files under `node_modules/`, and the task's own declared
942
942
  outputs, `outputs.workspaceFiles` and the `outputs.files` its globs reach
943
943
  in its own project (a task never invalidates itself).
@@ -1186,10 +1186,10 @@ run is a no-op too.
1186
1186
 
1187
1187
  **`clean: false`** keeps the outputs on a miss instead of wiping them
1188
1188
  first, for a tool that reads its own last output (tsc's
1189
- `.tsbuildinfo`). An entry still holds only what a clean run would
1190
- leave: if the run leaves any old file unchanged, nothing is cached, and
1191
- the next run executes again. A retry still starts clean, and a hit
1192
- still restores bit-identically. Omitted → the workspace's
1189
+ `.tsbuildinfo`). It opts out of the clean-tree guarantee: the save
1190
+ stores every file the outputs match after the run, so a file an older
1191
+ build left behind is cached and restored on every machine. A retry
1192
+ still starts clean, and a hit still restores bit-identically. Omitted → the workspace's
1193
1193
  `cache.outputs.clean`, else `true`.
1194
1194
 
1195
1195
  A **`!` entry takes a path back** from what the positive globs select
@@ -1259,7 +1259,9 @@ and so are `dist//**` and `dist/./app.js` (item 441) — and a literal
1259
1259
  entry is read as the file OR its whole tree, the same rule the resolver
1260
1260
  uses, so `dist` collides with `dist/app.js` (item 442). A whole subtree
1261
1261
  collides with any glob whose literal head lies inside it: `dist/**` with
1262
- `dist/extra/**` or `dist/*.js` (item 941). Globs that only _might_
1262
+ `dist/extra/**` or `dist/*.js` (item 941). Two globs a path built from
1263
+ either one matches are one too: `**/*` and `**/dist/**` both match
1264
+ `x/dist/x`. Globs that only _might_
1263
1265
  overlap (`dist/*` and `dist/sub/**`) are let through; there, last
1264
1266
  restore wins. A workspace output is also compared with every other
1265
1267
  project's `files` outputs, read from the root: `packages/b/dist/a.txt`
@@ -1676,7 +1678,7 @@ task's value over all of them; the input-stability variables are the one
1676
1678
  exception, overriding every task for the run. A duration carries its
1677
1679
  unit (`'500ms'`, `'30s'`, `'10m'`, `'2h'`, `'30d'`), and a bare number is
1678
1680
  refused naming the forms with units; a size carries one too (`'10GB'`).
1679
- `debug.timing`, `debug.dumpable`, `config.workerTimeout` and `run.outputLogs` are read
1681
+ `debug.timing`, `debug.watch`, `debug.dumpable`, `config.workerTimeout` and `run.outputLogs` are read
1680
1682
  before the workspace loads, so they have a variable (and a flag) only.
1681
1683
  None is folded into a cache key. `vx info` lists every setting not at its
1682
1684
  default and where it came from.
@@ -1708,11 +1710,12 @@ default and where it came from.
1708
1710
  | `cache.download` | `VX_CACHE_DOWNLOAD` | `--cache-download` | one of 'all', 'toplevel', 'none' | all | Which remote hits land on disk: every one, only the requested tasks, or none. |
1709
1711
  | `cache.inputStability.strategy` | `VX_CACHE_INPUT_STABILITY_STRATEGY` | — | one of 'immutable', 'mutable', 'unchecked' | immutable | What a write to an input during the command means for the save, for tasks without their own. A task's own `cache.inputStability.strategy` beats it. |
1710
1712
  | `cache.inputStability.severity` | `VX_CACHE_INPUT_STABILITY_SEVERITY` | — | one of 'warn', 'error' | warn | Whether a skipped save warns or fails the task, for tasks without their own. A task's own `cache.inputStability.severity` beats it. |
1711
- | `cache.outputs.clean` | `VX_CACHE_OUTPUTS_CLEAN` | — | true or false | true | Remove a task's declared outputs before it runs on a miss, for tasks without their own. A task's own `cache.outputs.clean` beats it. |
1713
+ | `cache.outputs.clean` | — | — | true or false | true | Remove a task's declared outputs before it runs on a miss, for tasks without their own. A task's own `cache.outputs.clean` beats it. |
1712
1714
  | `lock.reviewFiles` | `VX_LOCK_REVIEW_FILES` | — | true or false | true | `vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it. |
1713
1715
  | `watch.poll` | `VX_WATCH_POLL` | `vx watch --poll` | true or false | false | Poll for changes from the start instead of the OS watcher. |
1714
1716
  | `config.workerTimeout` (variable or flag only) | `VX_CONFIG_WORKER_TIMEOUT` | — | a duration like '500ms', '30s' or '5m', at most 24 days | 30s | How long one config evaluation may take. |
1715
1717
  | `debug.timing` (variable or flag only) | `VX_DEBUG_TIMING` | — | true or false | false | Print the run's stage timings to stderr. |
1718
+ | `debug.watch` (variable or flag only) | `VX_DEBUG_WATCH` | — | true or false | false | `vx watch`: say on stderr each time the loop arms, with the projects it watches. |
1716
1719
  | `debug.dumpable` (variable or flag only) | `VX_DEBUG_DUMPABLE` | — | true or false | false | Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env. |
1717
1720
 
1718
1721
  <!-- /settings table -->
@@ -2263,16 +2266,22 @@ opens it on click), and the lines up to it with a caret under the field
2263
2266
  (`util/config-frame.ts`):
2264
2267
 
2265
2268
  ```
2266
- vx: packages/b/vx.config.ts:4:15: tasks.build.exec has unknown field "comand" (allowed: …) — did you mean command?
2269
+ ✗ vx lock lock not written
2267
2270
 
2268
- 2 | tasks: {
2269
- 3 | build: {
2270
- > 4 | exec: { comand: 'mkdir -p dist' },
2271
- | ^
2271
+ packages/b/vx.config.ts:4:15
2272
+ tasks.build.exec has unknown field "comand"
2273
+ allowed: …
2274
+
2275
+ 2 | tasks: {
2276
+ 3 | build: {
2277
+ > 4 | exec: { comand: 'mkdir -p dist' },
2278
+ | ^
2279
+
2280
+ fix use command
2272
2281
  ```
2273
2282
 
2274
2283
  A field the file does not hold (a required one missing, or one a plugin
2275
- set) prints the one line as before. The table below lists the messages
2284
+ set) prints without the frame. The table below lists the messages
2276
2285
  a user meets most:
2277
2286
 
2278
2287
  | Symptom | Cause |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.637",
3
+ "version": "0.0.638",
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.637",
25
- "@vzn/vx-linux-arm64": "0.0.637",
26
- "@vzn/vx-darwin-x64": "0.0.637",
27
- "@vzn/vx-darwin-arm64": "0.0.637"
24
+ "@vzn/vx-linux-x64": "0.0.638",
25
+ "@vzn/vx-linux-arm64": "0.0.638",
26
+ "@vzn/vx-darwin-x64": "0.0.638",
27
+ "@vzn/vx-darwin-arm64": "0.0.638"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/plan.json CHANGED
@@ -45,8 +45,8 @@
45
45
  "required": ["kind"],
46
46
  "properties": {
47
47
  "kind": {
48
- "enum": ["input", "project", "package", "named", "selected"],
49
- "description": "input: a changed file is a declared input; project: its project changed and the task reads it whole; package: a ^ edge passes a changed package; named: asked as pkg#task; selected: another filter selected its project"
48
+ "enum": ["input", "project", "workspace", "package", "named", "selected"],
49
+ "description": "input: a changed file is a declared input; project: its project changed and the task reads it whole; workspace: a workspace-wide file (the workspace config, a root file a plugin claims) changed; package: a ^ edge passes a changed package; named: asked as pkg#task; selected: another filter selected its project"
50
50
  },
51
51
  "file": { "type": "string", "description": "the changed file, workspace-relative" },
52
52
  "project": { "type": "string", "description": "the changed package (kind package)" },
@@ -27,10 +27,21 @@ A refusal under `--format json` is one line on stdout,
27
27
  Without a TTY a missing task name never prompts: vx exits 1 and lists the
28
28
  tasks it found.
29
29
 
30
+ ## After editing a config
31
+
32
+ ```sh
33
+ vx lock # after any edit to a vx.config.ts or .vx/workspace.ts
34
+ ```
35
+
36
+ A run reads every config from `.vx/projects.lock.json`, never the files:
37
+ a task you add, or an edit to one, does nothing until `vx lock`. Commit
38
+ the lock with the config.
39
+
30
40
  ## Look before running
31
41
 
32
42
  ```sh
33
- vx show --format json # projects, their tasks and dependencies
43
+ vx show --format json # projects and their tasks
44
+ vx show app --format json # one project's tasks as a run reads them
34
45
  vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
35
46
  ```
36
47
 
@@ -94,6 +105,6 @@ vx info --format json # vx and Bun versions, projects, config errors, plugins,
94
105
 
95
106
  ## Install
96
107
 
97
- `npx skills add vznjs/vx/packages/vx/skills/vx` installs this skill. To match the vx a workspace
108
+ `npx skills add vznjs/vx` installs this skill. To match the vx a workspace
98
109
  has installed, copy it from there instead:
99
110
  `cp node_modules/@vzn/vx/skills/vx/SKILL.md .claude/skills/vx/`.
package/src/bin.ts CHANGED
@@ -46,7 +46,7 @@ async function main(): Promise<void> {
46
46
  process.exitCode = code
47
47
  } catch (err) {
48
48
  const {
49
- configErrorFrame,
49
+ detectColors,
50
50
  errorCode,
51
51
  errorDocument,
52
52
  fsRefusalHint,
@@ -55,6 +55,8 @@ async function main(): Promise<void> {
55
55
  isUserError,
56
56
  maskedLine,
57
57
  OUT_OF_FDS_HINT,
58
+ refusalNotice,
59
+ ui,
58
60
  wantsJson,
59
61
  } = await import('./util/index.js')
60
62
  // A plugin's failure quotes what it was told (a remote's reply, a
@@ -66,12 +68,16 @@ async function main(): Promise<void> {
66
68
  // on. Everything else gets the full stack so internal bugs are
67
69
  // debuggable.
68
70
  if (isUserError(err)) {
69
- // A message that already names the tool (`vx why: …`, thrown by a verb
70
- // that wants its own name in the line) is printed as it is; prefixing
71
- // it produced `vx: vx why: …` (walkthrough, 2026-09-04).
72
- // A config refusal names its line and shows it.
73
- const m = configErrorFrame(err.message, process.cwd()) ?? err.message
74
- say(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
71
+ // A message that names its verb (`vx why: …`) is that verb's; any
72
+ // other is the verb typed. A config refusal shows its line.
73
+ const named = /^(vx [a-z][\w-]*): /.exec(err.message)
74
+ const typed = /^[a-z][\w-]*$/.test(argv[0] ?? '') ? `vx ${argv[0]}` : 'vx'
75
+ const block = refusalNotice(
76
+ named?.[1] ?? typed,
77
+ named === null ? err.message : err.message.slice(named[0].length),
78
+ err.parts,
79
+ )
80
+ process.stderr.write(maskedLine(ui.render([block], detectColors(process.stderr))))
75
81
  } else if (isOutOfFds(err)) {
76
82
  say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
77
83
  } else if (isFsRefusal(err)) {
@@ -303,7 +303,13 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
303
303
  // unchanged.
304
304
  // v36: the store's task_durations: each executed success's time, tagged
305
305
  // by branch, read by the forecast. The cache KEY is unchanged.
306
- export const SCHEMA_VERSION = 'v36'
306
+ // v37: the store's plugin_blobs (a plugin's `ctx.cache`) and one
307
+ // 'remote' task_durations row per (sha, task). The cache KEY is
308
+ // unchanged.
309
+ // v38: blob_sizes moved to the store, and the store's task_outcomes
310
+ // holds the flaky record, so worktrees share both. The cache KEY is
311
+ // unchanged.
312
+ export const SCHEMA_VERSION = 'v38'
307
313
 
308
314
  /**
309
315
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -333,6 +339,9 @@ const STORE_TABLES = [
333
339
  'output_files',
334
340
  'entry_stdout',
335
341
  'task_durations',
342
+ 'task_outcomes',
343
+ 'blob_sizes',
344
+ 'plugin_blobs',
336
345
  'store_meta',
337
346
  'entries',
338
347
  ]
@@ -1030,7 +1039,7 @@ export class Cache implements CacheLayer {
1030
1039
  // keeps it so).
1031
1040
  if (found !== undefined) {
1032
1041
  this.db.exec(
1033
- '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.store_meta;',
1042
+ '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;',
1034
1043
  )
1035
1044
  }
1036
1045
  // The tables land under the same write lock as the stamp: made
@@ -1737,6 +1746,16 @@ export class Cache implements CacheLayer {
1737
1746
  return this.guard(() => this.hasEntry(hash))
1738
1747
  }
1739
1748
 
1749
+ /**
1750
+ * The subset of `hashes` this store holds, `has` for each: the prefetch
1751
+ * pass asks the remote only about the rest (M, 2026-10-15).
1752
+ */
1753
+ async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
1754
+ const out = new Set<string>()
1755
+ for (const hash of hashes) if ((await this.has(hash)) === 'local') out.add(hash)
1756
+ return out
1757
+ }
1758
+
1740
1759
  private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
1741
1760
  if (!this.read) return null
1742
1761
  const row = this.entryExists.get(hash) as { inline_at: number | null } | null
@@ -108,6 +108,10 @@ export class ChainedCache implements CacheLayer {
108
108
  return false
109
109
  }
110
110
 
111
+ localHasMany(hashes: readonly string[]): Promise<Set<string>> {
112
+ return this.layers[0]!.localHasMany?.(hashes) ?? Promise.resolve(new Set())
113
+ }
114
+
111
115
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
112
116
  // The caller (remote-prefetch) treats a non-null answer as authoritative
113
117
  // for the WHOLE chain: complement = absent, broadcast. That is sound only
@@ -15,6 +15,7 @@ export {
15
15
  type CachePolicy,
16
16
  cachesNothing,
17
17
  EXECUTED_RUNS_SQL,
18
+ formatCachePolicy,
18
19
  FULL_CACHE_POLICY,
19
20
  type InvocationRecord,
20
21
  KEYED_RUNS_SQL,
@@ -47,7 +48,6 @@ export {
47
48
  type ProjectFilesCache,
48
49
  type WorkspaceFilesCache,
49
50
  cleanOutputPaths,
50
- anyUntouched,
51
51
  ownOutputsSince,
52
52
  ownWorkspaceOutputsSince,
53
53
  stampOutputs,
@@ -70,5 +70,13 @@ export {
70
70
  } from './git-inputs.js'
71
71
  export { LayeredCache, type RemoteCacheLayer } from './layered-cache.js'
72
72
  export { ScopedCache } from './scoped-cache.js'
73
+ export {
74
+ absentPluginCache,
75
+ pluginCache,
76
+ PLUGIN_ENTRY_MAX_BYTES,
77
+ type PluginCache,
78
+ type PluginCacheRemote,
79
+ } from './plugin-cache.js'
80
+ export { packRunOutputs, putRunEntry, runEntryName, writesRunStore } from './run-store.js'
73
81
  export { ChainedCache } from './chained-cache.js'
74
82
  export { branchSource } from './run-history.js'
@@ -81,7 +81,6 @@ const ALWAYS_IGNORE = [
81
81
  '**/.git/**',
82
82
  // vx's machine-local state; the workspace config beside it is a source.
83
83
  '**/.vx/cache/**',
84
- '**/*.tsbuildinfo',
85
84
  '**/.vx/projects.lock.json',
86
85
  // `vx lock`'s review copy of a project's resolved config: the config is
87
86
  // keyed already, and a re-lock must not move every `**` reader's key.
@@ -1018,29 +1017,6 @@ function changedSince(
1018
1017
  return kept < before.size ? undefined : own
1019
1018
  }
1020
1019
 
1021
- /**
1022
- * Is any file `before` stamped still there, unchanged? Under
1023
- * `cache.outputs.clean: false` such a file is one the run found and never
1024
- * wrote, so no entry may claim it as the run's output.
1025
- */
1026
- export function anyUntouched(before: ReadonlyMap<string, OutputStamp>): boolean {
1027
- for (const [f, was] of before) {
1028
- try {
1029
- const st = lstatSync(f)
1030
- if (
1031
- st.size === was.size &&
1032
- st.mtimeMs === was.mtimeMs &&
1033
- st.ino === was.ino &&
1034
- st.ctimeMs === was.ctimeMs
1035
- )
1036
- return true
1037
- } catch {
1038
- // Removed by the run: nothing of it is left to claim.
1039
- }
1040
- }
1041
- return false
1042
- }
1043
-
1044
1020
  /**
1045
1021
  * Up to this many paths, a clean removes synchronously: the threadpool round
1046
1022
  * trip per `rm`/`rmdir` cost more than the unlink itself, 0.30 ms against
@@ -221,7 +221,7 @@ export interface InvocationRecord {
221
221
  command: string
222
222
  /** JSON-serialized `string[]` of the requested task names. */
223
223
  requestedTasks: string
224
- /** Compact policy flags, e.g. `'lR,lW,rR,rW'` (an axis omitted = off). */
224
+ /** The `--cache-policy` spec, e.g. `'local:rw,remote:r'` (a flag omitted = off). */
225
225
  cachePolicy: string
226
226
  concurrency: number
227
227
  flow: 'focused' | 'broad' | null
@@ -544,6 +544,12 @@ export interface CacheLayer {
544
544
  * batch omits it and the prefetch pass falls back to per-hash GETs.
545
545
  */
546
546
  remoteHasMany?(hashes: readonly string[]): Promise<Set<string> | null>
547
+ /**
548
+ * The subset of `hashes` the local store already holds. The prefetch
549
+ * pass probes the remote only for the rest, so a run the local cache
550
+ * answers makes no remote call. Absent reads as "none held".
551
+ */
552
+ localHasMany?(hashes: readonly string[]): Promise<Set<string>>
547
553
  /**
548
554
  * Record that the remote has NO artifact for each of `hashes` (from a
549
555
  * batch probe), so a later `get`/`prefetch` short-circuits to a miss with
@@ -254,6 +254,10 @@ export class LayeredCache implements CacheLayer {
254
254
  return this.pullFromRemote(hash, ctx)
255
255
  }
256
256
 
257
+ localHasMany(hashes: readonly string[]): Promise<Set<string>> {
258
+ return this.local.localHasMany(hashes)
259
+ }
260
+
257
261
  /**
258
262
  * Batch existence probe over the remote layer — the subset of `hashes`
259
263
  * present remotely, in one round-trip, or `null` when the remote can't
@@ -0,0 +1,248 @@
1
+ // A plugin's own entries in the cache the workspace declares (`ctx.cache`,
2
+ // owner 2026-10-14): the local store first, then each remote in declared
3
+ // order, under the run's policy and scope. The plugin speaks names; core
4
+ // derives the key, so no plugin reaches another's entries or a task's. An
5
+ // entry is written once and never replaced (the cache is immutable). Every
6
+ // failure is an absent answer, never a warning (M: no extra runtime
7
+ // warnings): what a plugin keeps here is a hint.
8
+
9
+ import type { Database } from 'bun:sqlite'
10
+ import { xxh3hex } from '../util/index.js'
11
+ import type { CachePolicy } from './policy.js'
12
+ import type { RemoteCacheLayer } from './layered-cache.js'
13
+ import { scopedKey } from './scoped-cache.js'
14
+ import { runReadKeys } from './run-store.js'
15
+
16
+ /** The largest entry kept or read, in bytes: a remote's larger body is a miss, read no further. */
17
+ export const PLUGIN_ENTRY_MAX_BYTES = 1024 * 1024
18
+
19
+ /** What a plugin's `ctx.cache` offers. Keys are the plugin's own names. */
20
+ export interface PluginCache {
21
+ /** The bytes stored under `key`, from the first layer holding them; null when none does. */
22
+ get(key: string): Promise<Uint8Array | null>
23
+ /** Store `bytes` under `key` in every layer the policy writes, where it is not already; empty bytes store nothing. */
24
+ put(key: string, bytes: Uint8Array): Promise<void>
25
+ /** Whether some layer holds `key`; null when no layer could answer. */
26
+ has(key: string): Promise<boolean | null>
27
+ /**
28
+ * The subset of `keys` some layer holds, in one probe per layer; null
29
+ * when a readable remote has no batch probe: ask `get` one at a time.
30
+ */
31
+ hasMany(keys: readonly string[]): Promise<Set<string> | null>
32
+ /**
33
+ * What core kept of a run under `name` (a `log` or `outputs` name its
34
+ * `RunReport` hands `teardown`), from the first readable remote holding
35
+ * it; null when none does. Uncapped: core wrote it.
36
+ */
37
+ getRun(name: string): Promise<Uint8Array | null>
38
+ }
39
+
40
+ /** One remote as the plugin cache reaches it: its wire and what the policy lets it do. */
41
+ export interface PluginCacheRemote {
42
+ readonly remote: RemoteCacheLayer
43
+ readonly read: boolean
44
+ readonly write: boolean
45
+ }
46
+
47
+ export interface PluginCacheHost {
48
+ /** The run's index; its store holds `plugin_blobs`. */
49
+ readonly db: Database
50
+ readonly policy: CachePolicy
51
+ readonly remotes: readonly PluginCacheRemote[]
52
+ readonly scope: string | undefined
53
+ }
54
+
55
+ /** The key `name` stores under for `plugin`: task-key shaped, so every wire stores it unchanged. */
56
+ function entryKey(plugin: string, name: string): string {
57
+ return xxh3hex(`vx-plugin\0${plugin}\0${name}`)
58
+ }
59
+
60
+ /**
61
+ * A remote body's bytes; null past the cap (read no further than it) and for
62
+ * an empty or odd body, which kept would hide every later good one: an entry
63
+ * is written once (Security, #3580).
64
+ */
65
+ async function capped(body: unknown, max = PLUGIN_ENTRY_MAX_BYTES): Promise<Uint8Array | null> {
66
+ if (body instanceof Blob) {
67
+ if (body.size === 0 || body.size > max) return null
68
+ return new Uint8Array(await body.arrayBuffer())
69
+ }
70
+ if (!(body instanceof Response) || body.body === null) return null
71
+ const reader = body.body.getReader()
72
+ const chunks: Uint8Array[] = []
73
+ let size = 0
74
+ for (;;) {
75
+ const { done, value } = await reader.read()
76
+ if (done) break
77
+ size += value.byteLength
78
+ if (size > max) {
79
+ await reader.cancel().catch(() => {})
80
+ return null
81
+ }
82
+ chunks.push(value)
83
+ }
84
+ if (size === 0) return null
85
+ const out = new Uint8Array(size)
86
+ let at = 0
87
+ for (const c of chunks) {
88
+ out.set(c, at)
89
+ at += c.byteLength
90
+ }
91
+ return out
92
+ }
93
+
94
+ /**
95
+ * `plugin`'s view of the cache. A scoped run reads the default key, then
96
+ * the scope's, and writes only the scope's, as tasks do (`ScopedCache`).
97
+ */
98
+ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache {
99
+ const { db, policy, scope } = host
100
+ const readers = host.remotes.filter((r) => r.read)
101
+ const writers = host.remotes.filter((r) => r.write)
102
+ const reads = (name: string): string[] => {
103
+ const key = entryKey(plugin, name)
104
+ return scope === undefined ? [key] : [key, scopedKey(scope, key)]
105
+ }
106
+ const writeKey = (name: string): string => {
107
+ const key = entryKey(plugin, name)
108
+ return scope === undefined ? key : scopedKey(scope, key)
109
+ }
110
+
111
+ const localGet = (key: string): Uint8Array | null => {
112
+ if (!policy.localRead) return null
113
+ try {
114
+ const row = db.query('SELECT bytes FROM plugin_blobs WHERE key = ?').get(key) as {
115
+ bytes: Uint8Array
116
+ } | null
117
+ return row === null ? null : new Uint8Array(row.bytes)
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+ const localHas = (keys: readonly string[]): Set<string> => {
123
+ const found = new Set<string>()
124
+ if (!policy.localRead || keys.length === 0) return found
125
+ try {
126
+ const rows = db
127
+ .query(`SELECT key FROM plugin_blobs WHERE key IN (${keys.map(() => '?').join(', ')})`)
128
+ .all(...keys) as Array<{ key: string }>
129
+ for (const r of rows) found.add(r.key)
130
+ } catch {
131
+ // an unreadable index holds nothing
132
+ }
133
+ return found
134
+ }
135
+ const localPut = (key: string, bytes: Uint8Array): void => {
136
+ try {
137
+ db.query('INSERT OR IGNORE INTO plugin_blobs(key, bytes, at) VALUES (?, ?, ?)').run(
138
+ key,
139
+ bytes,
140
+ Date.now(),
141
+ )
142
+ } catch {
143
+ // a read-only or busy index keeps nothing
144
+ }
145
+ }
146
+
147
+ return {
148
+ async get(name) {
149
+ const keys = reads(name)
150
+ for (const key of keys) {
151
+ const hit = localGet(key)
152
+ if (hit !== null) return hit
153
+ }
154
+ for (const { remote } of readers) {
155
+ for (const key of keys) {
156
+ try {
157
+ const got = await remote.get(key)
158
+ if (got === null) continue
159
+ const bytes = await capped(got.body)
160
+ if (bytes === null) continue
161
+ if (policy.localWrite) localPut(key, bytes)
162
+ return bytes
163
+ } catch {
164
+ // a failing remote is a miss there
165
+ }
166
+ }
167
+ }
168
+ return null
169
+ },
170
+
171
+ async put(name, bytes) {
172
+ if (bytes.byteLength === 0 || bytes.byteLength > PLUGIN_ENTRY_MAX_BYTES) return
173
+ const key = writeKey(name)
174
+ if (policy.localWrite) localPut(key, bytes)
175
+ for (const { remote } of writers) {
176
+ try {
177
+ if (await remote.has(key)) continue
178
+ await remote.put(key, new Blob([bytes]), { durationMs: 0 })
179
+ } catch {
180
+ // the other layers still keep it
181
+ }
182
+ }
183
+ },
184
+
185
+ async has(name) {
186
+ const keys = reads(name)
187
+ if (localHas(keys).size > 0) return true
188
+ let answered = policy.localRead
189
+ for (const { remote } of readers) {
190
+ for (const key of keys) {
191
+ try {
192
+ if (await remote.has(key)) return true
193
+ answered = true
194
+ } catch {
195
+ // no answer from this remote
196
+ }
197
+ }
198
+ }
199
+ return answered ? false : null
200
+ },
201
+
202
+ async hasMany(names) {
203
+ const byKey = new Map<string, string>()
204
+ for (const name of names) for (const key of reads(name)) byKey.set(key, name)
205
+ const keys = [...byKey.keys()]
206
+ const found = new Set<string>()
207
+ for (const key of localHas(keys)) found.add(byKey.get(key)!)
208
+ for (const { remote } of readers) {
209
+ const ask = keys.filter((k) => !found.has(byKey.get(k)!))
210
+ if (ask.length === 0) break
211
+ if (remote.hasMany === undefined) return null
212
+ try {
213
+ const got: unknown = await remote.hasMany(ask)
214
+ if (!(got instanceof Set)) return null
215
+ for (const key of got) if (byKey.has(key as string)) found.add(byKey.get(key as string)!)
216
+ } catch {
217
+ return null
218
+ }
219
+ }
220
+ return found
221
+ },
222
+
223
+ async getRun(name) {
224
+ for (const { remote } of readers) {
225
+ for (const key of runReadKeys(name, scope)) {
226
+ try {
227
+ const got = await remote.get(key)
228
+ if (got === null) continue
229
+ const bytes = await capped(got.body, Infinity)
230
+ if (bytes !== null) return bytes
231
+ } catch {
232
+ // a failing remote is a miss there
233
+ }
234
+ }
235
+ }
236
+ return null
237
+ },
238
+ }
239
+ }
240
+
241
+ /** A cache with no layer: every answer absent. What a context built outside a run holds. */
242
+ export const absentPluginCache: PluginCache = {
243
+ get: async () => null,
244
+ put: async () => {},
245
+ has: async () => null,
246
+ hasMany: async () => null,
247
+ getRun: async () => null,
248
+ }
@@ -90,6 +90,15 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
90
90
  return out
91
91
  }
92
92
 
93
+ /**
94
+ * The `--cache-policy` spec for `p`, both layers named, so the line
95
+ * `vx last` shows pastes back as the flag: `local:rw,remote:r`.
96
+ */
97
+ export function formatCachePolicy(p: CachePolicy): string {
98
+ const flags = (r: boolean, w: boolean): string => `${r ? 'r' : ''}${w ? 'w' : ''}`
99
+ return `local:${flags(p.localRead, p.localWrite)},remote:${flags(p.remoteRead, p.remoteWrite)}`
100
+ }
101
+
93
102
  /** `--no-cache` and `--force`, which still apply over whichever spec set the base. */
94
103
  export interface CacheFlags {
95
104
  /** `--no-cache`: every axis off. Beats `force`. */