@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.
- package/docs/caching.md +41 -14
- package/docs/cli.md +115 -73
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -5
- package/docs/schema.md +24 -15
- package/package.json +5 -5
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +13 -2
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +21 -2
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/index.ts +9 -1
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/run-history.ts +68 -0
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +38 -9
- package/src/cache/scoped-cache.ts +15 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +9 -5
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +6 -5
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +14 -7
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +5 -3
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +14 -12
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +66 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +20 -11
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +44 -20
- package/src/cli/why.ts +10 -1
- package/src/cli/workspace-config.ts +2 -2
- package/src/graph/scheduler.ts +8 -0
- package/src/graph/task-graph.ts +32 -4
- package/src/index.ts +11 -1
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +26 -43
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/framed-output.ts +1 -2
- package/src/orchestrator/history.ts +60 -4
- package/src/orchestrator/index.ts +2 -1
- package/src/orchestrator/logger.ts +9 -2
- package/src/orchestrator/metrics.ts +7 -3
- package/src/orchestrator/plugin-host.ts +14 -4
- package/src/orchestrator/plugin.ts +80 -8
- package/src/orchestrator/prepare.ts +51 -7
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +9 -5
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run.ts +126 -27
- package/src/orchestrator/status-line.ts +1 -1
- package/src/orchestrator/summary.ts +69 -44
- package/src/orchestrator/telemetry.ts +1 -1
- package/src/settings.ts +12 -1
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/index.ts +8 -1
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/ui.ts +186 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- 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/**`,
|
|
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
|
-
|
|
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`).
|
|
1190
|
-
|
|
1191
|
-
|
|
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).
|
|
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` |
|
|
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
|
-
|
|
2269
|
+
✗ vx lock lock not written
|
|
2267
2270
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
|
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.
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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)" },
|
package/skills/vx/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
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)) {
|
package/src/cache/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/cache/index.ts
CHANGED
|
@@ -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'
|
package/src/cache/inputs.ts
CHANGED
|
@@ -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
|
package/src/cache/layer.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|
package/src/cache/policy.ts
CHANGED
|
@@ -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`. */
|