@vzn/vx 0.0.633 → 0.0.635
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/README.md +51 -52
- package/docs/caching.md +11 -2
- package/docs/cli.md +192 -56
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -3
- package/docs/patterns.md +8 -5
- package/docs/schema.md +48 -2
- package/docs/security.md +6 -0
- package/launcher.cjs +0 -0
- package/launcher.sh +50 -0
- package/package.json +17 -8
- package/schemas/error.json +5 -1
- package/schemas/last.json +5 -1
- package/schemas/plan.json +4 -0
- package/schemas/show.json +2 -1
- package/schemas/why.json +4 -1
- package/skills/vx/SKILL.md +7 -1
- package/src/cache/cache.ts +60 -5
- package/src/cache/inputs.ts +66 -30
- package/src/cache/layered-cache.ts +3 -1
- package/src/cli/docs.ts +9 -3
- package/src/cli/help.ts +2 -1
- package/src/cli/init.ts +9 -2
- package/src/cli/last.ts +9 -1
- package/src/cli/plan-format.ts +3 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +17 -8
- package/src/cli/select.ts +17 -11
- package/src/cli/show.ts +2 -2
- package/src/config.ts +23 -2
- package/src/graph/priorities.ts +72 -6
- package/src/graph/scheduler.ts +31 -7
- package/src/graph/task-graph.ts +75 -20
- package/src/index.ts +6 -1
- package/src/orchestrator/execute-task.ts +30 -1
- package/src/orchestrator/framed-output.ts +27 -1
- package/src/orchestrator/history.ts +75 -1
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/path-links.ts +5 -3
- package/src/orchestrator/plugin.ts +3 -1
- package/src/orchestrator/prepare.ts +15 -12
- package/src/orchestrator/remote-prefetch.ts +8 -2
- package/src/orchestrator/run-failures.ts +20 -3
- package/src/orchestrator/run.ts +24 -2
- package/src/orchestrator/sandbox-fix.ts +123 -0
- package/src/orchestrator/task-hash.ts +7 -1
- package/src/util/errors.ts +9 -2
- package/src/util/paths.ts +32 -0
- package/src/workspace/affected.ts +16 -4
- package/src/workspace/config-schema.ts +30 -2
- package/src/workspace/lockfile.ts +29 -2
- package/src/workspace/migration.ts +51 -4
- package/src/workspace/project-loader.ts +3 -1
package/docs/patterns.md
CHANGED
|
@@ -183,11 +183,14 @@ Sharing the patterns doesn't mean sharing the overhead. On the
|
|
|
183
183
|
476-package / 1,428-node synthetic workspace
|
|
184
184
|
(`packages/vx-bench/compare.ts 20 25 1`, 2026-09-02):
|
|
185
185
|
|
|
186
|
-
| Runner |
|
|
187
|
-
| ------ |
|
|
188
|
-
| **vx** |
|
|
189
|
-
| Turbo |
|
|
190
|
-
| Nx |
|
|
186
|
+
| Runner | Warm (no restore) | Warm (restore) |
|
|
187
|
+
| ------ | ----------------------- | ----------------------- |
|
|
188
|
+
| **vx** | **297 ms** | **416 ms** |
|
|
189
|
+
| Turbo | 342 ms (vx 15% faster) | 612 ms (vx 47% faster) |
|
|
190
|
+
| Nx | 1.38 s (vx 4.6× faster) | 1.33 s (vx 3.2× faster) |
|
|
191
|
+
|
|
192
|
+
A cached run is all runner overhead. Cold builds are compared by the
|
|
193
|
+
time the runner adds, on [`benchmarks.md`](./benchmarks.md).
|
|
191
194
|
|
|
192
195
|
vx N% faster: that tool takes N% longer than vx; N× faster: N times as long.
|
|
193
196
|
|
package/docs/schema.md
CHANGED
|
@@ -704,9 +704,38 @@ Semantics:
|
|
|
704
704
|
interface CacheConfig {
|
|
705
705
|
inputs: CacheInputs // required when `cache` is provided
|
|
706
706
|
outputs: CacheOutputs // required when `cache` is provided
|
|
707
|
+
rewrittenInputs?: 'withhold' | 'compare' // optional, default 'withhold'
|
|
707
708
|
}
|
|
708
709
|
```
|
|
709
710
|
|
|
711
|
+
`rewrittenInputs` says what a write to an input while the command runs
|
|
712
|
+
means. `'withhold'` (the default, on CI too): the result stands but the
|
|
713
|
+
entry is not saved, since an edit undone mid-run, or another task
|
|
714
|
+
changing the file and putting it back, cannot be told from no edit.
|
|
715
|
+
`'compare'`: a file rewritten to the bytes the key folded counts as
|
|
716
|
+
unchanged and the entry is saved, for a task that rewrites its own input
|
|
717
|
+
identically (tsdown's `package.json`); different bytes still withhold
|
|
718
|
+
it. `VX_REWRITTEN_INPUTS=compare` sets the default for every task that
|
|
719
|
+
does not say (owner, 2026-10-12).
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
import { defineProject } from '@vzn/vx/config'
|
|
723
|
+
|
|
724
|
+
export default defineProject({
|
|
725
|
+
tasks: {
|
|
726
|
+
build: {
|
|
727
|
+
// tsdown rewrites package.json with the same bytes on every build.
|
|
728
|
+
exec: { command: 'tsdown' },
|
|
729
|
+
cache: {
|
|
730
|
+
inputs: { files: ['src/**', 'package.json'] },
|
|
731
|
+
outputs: { files: ['dist/**'] },
|
|
732
|
+
rewrittenInputs: 'compare',
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
})
|
|
737
|
+
```
|
|
738
|
+
|
|
710
739
|
**Caching is opt-in.** Omit `cache` and the task always runs (no read,
|
|
711
740
|
no write). Provide it and caching is active for that task. The
|
|
712
741
|
project loader requires _both_ `inputs` and `outputs` when `cache` is
|
|
@@ -1534,6 +1563,7 @@ export default defineWorkspace({
|
|
|
1534
1563
|
affectedBase: 'origin/main',
|
|
1535
1564
|
cacheScope: 'trusted',
|
|
1536
1565
|
rules: { exclusiveOutputs: true, upfrontKeys: true },
|
|
1566
|
+
schedule: 'most-work',
|
|
1537
1567
|
plugins: [otel()],
|
|
1538
1568
|
})
|
|
1539
1569
|
```
|
|
@@ -1554,8 +1584,10 @@ interface WorkspaceConfig {
|
|
|
1554
1584
|
cacheScope?: string
|
|
1555
1585
|
/** Graph rules checked before anything runs; each on unless set to false. */
|
|
1556
1586
|
rules?: WorkspaceRules
|
|
1587
|
+
/** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
|
|
1588
|
+
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
1557
1589
|
/** Run-level plugins (cache / executor / telemetry capabilities). */
|
|
1558
|
-
plugins?: readonly Plugin[]
|
|
1590
|
+
plugins?: readonly (Plugin | undefined | null | false)[]
|
|
1559
1591
|
}
|
|
1560
1592
|
|
|
1561
1593
|
interface WorkspaceRules {
|
|
@@ -1631,6 +1663,17 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
|
|
|
1631
1663
|
boundary: a run holding a write credential can write any key, so only
|
|
1632
1664
|
a cache server that scopes writes by token can refuse one
|
|
1633
1665
|
(`docs/security.md` § Cache poisoning). Not folded into any cache key.
|
|
1666
|
+
- **`schedule`** — which ready task starts first when more are ready
|
|
1667
|
+
than workers, with no timings. `most-work` (the default): the one the
|
|
1668
|
+
most tasks wait on, at any depth. `critical-path`: the head of the
|
|
1669
|
+
longest chain of tasks below it. `direct-dependents`: the one the most
|
|
1670
|
+
tasks wait on directly (Nx's rule). `ready-order`: the one that became
|
|
1671
|
+
ready first (Turbo's rule). Each loses some graph shape the others win
|
|
1672
|
+
(`blog/scheduling-strategies`). A `schedule` plugin's weights
|
|
1673
|
+
(`@vzn/vx-schedule-history`) still sort first, and the strategy breaks
|
|
1674
|
+
their ties. The default costs what it did; another strategy is
|
|
1675
|
+
computed only when named. Any other value is refused. Never folded
|
|
1676
|
+
into a cache key.
|
|
1634
1677
|
- **`rules`** — checks on the task graph, each on unless set to
|
|
1635
1678
|
`false`. Turning one off allows a shape vx runs correctly but more
|
|
1636
1679
|
slowly; never folded into a cache key. A value that is not a boolean,
|
|
@@ -1668,7 +1711,9 @@ false } in vx.workspace.ts to let it wait for its producer.` Such a key
|
|
|
1668
1711
|
Declaration order is precedence: every `executor` is consulted in
|
|
1669
1712
|
order per task (first to accept runs it; what all decline runs
|
|
1670
1713
|
locally); every `cache` layer is chained (lookup walks, save reaches
|
|
1671
|
-
all, the local store last).
|
|
1714
|
+
all, the local store last). An `undefined`, `null` or `false` entry
|
|
1715
|
+
is skipped, as Vite skips one: a factory that declines (`github()` off
|
|
1716
|
+
Actions) or `cond && plugin()` needs no filter. Each entry is a `VxPlugin` contributing
|
|
1672
1717
|
any subset of the fourteen hooks, in pipeline order: `config` (edit
|
|
1673
1718
|
the workspace config before anything derives from it), `discover`
|
|
1674
1719
|
(name directories to make projects beyond the members), `project`
|
|
@@ -2043,6 +2088,7 @@ a user meets most:
|
|
|
2043
2088
|
| `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
|
|
2044
2089
|
| `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
|
|
2045
2090
|
| `cache.outputs is required when cache is set` | Forgot `outputs`. |
|
|
2091
|
+
| `cache.rewrittenInputs must be 'withhold' or 'compare'` | Any other value (`'bytes'`, `true`). |
|
|
2046
2092
|
| `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
|
|
2047
2093
|
| `cache.outputs.files must be an array` | Wrong shape. |
|
|
2048
2094
|
| `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
|
package/docs/security.md
CHANGED
|
@@ -89,6 +89,12 @@ writes whatever its config says. Never give the write secret to a
|
|
|
89
89
|
out a pull request's code: it runs with main's ref and secrets, and
|
|
90
90
|
`github()` scopes it `pr-<n>` only on the client side.
|
|
91
91
|
|
|
92
|
+
A boundary that holds whoever can write the store is a signing cache
|
|
93
|
+
plugin: main's CI signs each upload, and a reader refuses, as it streams,
|
|
94
|
+
a download no trusted key signed, so the hit is a miss. Core needs no
|
|
95
|
+
change for it; the plugins guide (§ Your own cache › Signed artifacts) has a
|
|
96
|
+
complete one, run by `tests/signed-cache-guide.unsafe.test.ts`.
|
|
97
|
+
|
|
92
98
|
## What vx checks on bytes it did not write
|
|
93
99
|
|
|
94
100
|
Every artifact that arrives from a remote, and every one read back from
|
package/launcher.cjs
CHANGED
|
File without changes
|
package/launcher.sh
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# npm launcher for a vx command: the published package's `bin`. It execs the
|
|
3
|
+
# platform package's prebuilt binary straight from the shell, since Node's
|
|
4
|
+
# own start was ~40 ms of every `vx` call made through node_modules/.bin
|
|
5
|
+
# (45-53 ms against 12-14 ms for `vx --version`, 2026-10-12). Anything it
|
|
6
|
+
# cannot settle (no sibling platform package, musl, Yarn PnP, an unknown
|
|
7
|
+
# platform) goes to launcher.cjs beside it, which resolves through Node and
|
|
8
|
+
# owns the fallbacks and the error messages.
|
|
9
|
+
|
|
10
|
+
# The real file: npm's .bin entry is a symlink to it. Parameter expansion,
|
|
11
|
+
# not dirname/basename, and no `cd`: each fork is a millisecond, and the
|
|
12
|
+
# kernel resolves `<dir>/..` through a symlinked directory (pnpm's
|
|
13
|
+
# node_modules/@vzn/vx) to its real parent on its own.
|
|
14
|
+
self=$0
|
|
15
|
+
hops=0
|
|
16
|
+
while [ -L "$self" ] && [ "$hops" -lt 40 ]; do
|
|
17
|
+
hops=$((hops + 1))
|
|
18
|
+
target=$(readlink "$self")
|
|
19
|
+
case $target in
|
|
20
|
+
/*) self=$target ;;
|
|
21
|
+
*) self=${self%/*}/$target ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
case $self in
|
|
25
|
+
*/*) here=${self%/*} ;;
|
|
26
|
+
*) here=. ;;
|
|
27
|
+
esac
|
|
28
|
+
base=${here##*/}
|
|
29
|
+
scope=${here%/*}
|
|
30
|
+
scope=${scope##*/}
|
|
31
|
+
|
|
32
|
+
key=
|
|
33
|
+
loader=
|
|
34
|
+
case $(uname -sm) in
|
|
35
|
+
'Linux x86_64') key=linux-x64 loader=/lib64/ld-linux-x86-64.so.2 ;;
|
|
36
|
+
'Linux aarch64' | 'Linux arm64') key=linux-arm64 loader=/lib/ld-linux-aarch64.so.1 ;;
|
|
37
|
+
'Darwin x86_64') key=darwin-x64 ;;
|
|
38
|
+
'Darwin arm64') key=darwin-arm64 ;;
|
|
39
|
+
esac
|
|
40
|
+
|
|
41
|
+
# The platform package sits beside this one under npm, pnpm and Bun
|
|
42
|
+
# (node_modules/@vzn/vx-<key>), or under this package's own node_modules
|
|
43
|
+
# when npm could not hoist it. The glibc binary needs its loader (musl
|
|
44
|
+
# has none); launcher.cjs names that cause.
|
|
45
|
+
if [ -n "$key" ] && { [ -z "$loader" ] || [ -e "$loader" ]; }; then
|
|
46
|
+
for bin in "$here/../$base-$key/$base" "$here/node_modules/$scope/$base-$key/$base"; do
|
|
47
|
+
if [ -x "$bin" ]; then exec "$bin" "$@"; fi
|
|
48
|
+
done
|
|
49
|
+
fi
|
|
50
|
+
exec node "$here/launcher.cjs" "$@"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.635",
|
|
4
|
+
"description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
},
|
|
16
16
|
"types": "./src/index.ts",
|
|
17
17
|
"bin": {
|
|
18
|
-
"vx": "./launcher.
|
|
18
|
+
"vx": "./launcher.sh"
|
|
19
19
|
},
|
|
20
20
|
"engines": {
|
|
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.635",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.635",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.635",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.635"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"docs/patterns.md",
|
|
43
43
|
"docs/security.md",
|
|
44
44
|
"docs/features.md",
|
|
45
|
+
"launcher.sh",
|
|
45
46
|
"launcher.cjs",
|
|
46
47
|
"README.md",
|
|
47
48
|
"LICENSE"
|
|
@@ -64,6 +65,14 @@
|
|
|
64
65
|
"nx",
|
|
65
66
|
"bun",
|
|
66
67
|
"workspaces",
|
|
67
|
-
"ci"
|
|
68
|
+
"ci",
|
|
69
|
+
"task-orchestration",
|
|
70
|
+
"turborepo-alternative",
|
|
71
|
+
"nx-alternative",
|
|
72
|
+
"remote-cache",
|
|
73
|
+
"sandbox",
|
|
74
|
+
"incremental-build",
|
|
75
|
+
"mcp",
|
|
76
|
+
"pnpm"
|
|
68
77
|
]
|
|
69
78
|
}
|
package/schemas/error.json
CHANGED
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
"type": "string",
|
|
17
17
|
"description": "Stable; branch on it. VX_E_USAGE, VX_E_UNKNOWN_COMMAND, VX_E_UNKNOWN_TASK, VX_E_UNKNOWN_PROJECT, VX_E_NO_HISTORY, VX_E_AMBIGUOUS, VX_E_CONFIG, VX_E_CYCLE, VX_E_NO_WORKSPACE, VX_E_CACHE_VERSION, VX_E_REFUSED, VX_E_FS, VX_E_FDS, VX_E_INTERNAL."
|
|
18
18
|
},
|
|
19
|
-
"message": { "type": "string", "description": "The refusal in prose; may change." }
|
|
19
|
+
"message": { "type": "string", "description": "The refusal in prose; may change." },
|
|
20
|
+
"docs": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "For a core code (VX_E_…): its section of the CLI reference, which says what to do. A plugin's code has none."
|
|
23
|
+
}
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
26
|
}
|
package/schemas/last.json
CHANGED
|
@@ -170,7 +170,7 @@
|
|
|
170
170
|
},
|
|
171
171
|
"locations": {
|
|
172
172
|
"type": "array",
|
|
173
|
-
"description": "on a failed row: the existing files its output names, in order of first appearance",
|
|
173
|
+
"description": "on a failed row: the existing files its output names, in order of first appearance; a file it also names with a line drops its bare mention",
|
|
174
174
|
"items": {
|
|
175
175
|
"type": "object",
|
|
176
176
|
"additionalProperties": false,
|
|
@@ -181,6 +181,10 @@
|
|
|
181
181
|
"col": { "type": "integer" }
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
},
|
|
185
|
+
"fixedIn": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"description": "on a failed row: the first later run where this task passed (ran or hit); absent while it has not"
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
}
|
package/schemas/plan.json
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
"additionalProperties": false,
|
|
7
7
|
"required": ["tasks"],
|
|
8
8
|
"properties": {
|
|
9
|
+
"affectedBase": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "under --affected: the ref the diff was taken against (a bare --affected guesses it: origin/HEAD, a trunk branch, else HEAD~1)"
|
|
12
|
+
},
|
|
9
13
|
"tasks": {
|
|
10
14
|
"type": "array",
|
|
11
15
|
"items": {
|
package/schemas/show.json
CHANGED
package/schemas/why.json
CHANGED
|
@@ -77,7 +77,10 @@
|
|
|
77
77
|
"type": ["boolean", "null"],
|
|
78
78
|
"description": "on a hit: true when outputs were restored, false when up-to-date"
|
|
79
79
|
},
|
|
80
|
-
"cached": {
|
|
80
|
+
"cached": {
|
|
81
|
+
"type": ["boolean", "null"],
|
|
82
|
+
"description": "whether the task declares a cache; not whether this run hit it"
|
|
83
|
+
},
|
|
81
84
|
"startedAt": { "type": "integer", "description": "epoch ms" }
|
|
82
85
|
}
|
|
83
86
|
},
|
package/skills/vx/SKILL.md
CHANGED
|
@@ -21,6 +21,9 @@ vx run build --all --format json # the result as JSON on stdout; logs go to s
|
|
|
21
21
|
The exit code is the verdict: `0` every task passed or hit the cache, `1` a
|
|
22
22
|
task failed or was skipped, or the command was refused. `--format json`
|
|
23
23
|
prints `schemas/summary.json` (`runId`, `ok`, `exitCode`, a row per task).
|
|
24
|
+
A refusal under `--format json` is one line on stdout,
|
|
25
|
+
`{"ok":false,"error":{"code","message","docs"}}`: branch on `code`;
|
|
26
|
+
`docs` (or `vx docs <code>` offline) says what to do.
|
|
24
27
|
Without a TTY a missing task name never prompts: vx exits 1 and lists the
|
|
25
28
|
tasks it found.
|
|
26
29
|
|
|
@@ -31,7 +34,10 @@ vx show --format json # projects, their tasks and depen
|
|
|
31
34
|
vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
|
|
32
35
|
```
|
|
33
36
|
|
|
34
|
-
Under `--affected`,
|
|
37
|
+
Under `--affected`, `--dry=json` states `affectedBase` (the ref it diffed
|
|
38
|
+
against; name one, `--affected=origin/main`, rather than trust the guess)
|
|
39
|
+
and each task you asked for carries `affected` (a dependency it pulls in
|
|
40
|
+
does not):
|
|
35
41
|
`{ kind, file?, project?, via? }`, where `kind` is `input` (a changed file
|
|
36
42
|
is one of its inputs), `project`, `package`, `named` or `selected`, and
|
|
37
43
|
`via` is the `dependsOn` chain that carried the change.
|
package/src/cache/cache.ts
CHANGED
|
@@ -122,6 +122,13 @@ import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
|
122
122
|
*/
|
|
123
123
|
const ORPHAN_GRACE_MS = 60 * 60 * 1000
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Row-less lookups answered by a stat each before the artifact directory
|
|
127
|
+
* is listed once instead: a warm run's few misses keep their stat, a
|
|
128
|
+
* cold run's thousands share one listing.
|
|
129
|
+
*/
|
|
130
|
+
const ADOPT_STATS = 64
|
|
131
|
+
|
|
125
132
|
/**
|
|
126
133
|
* A hit renews an entry's `accessed_at` only once it is older than this:
|
|
127
134
|
* renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
|
|
@@ -730,6 +737,10 @@ export class Cache implements CacheLayer {
|
|
|
730
737
|
private readonly heldInlineAt = new Map<string, number>()
|
|
731
738
|
/** Keys the last lookups found an inline artifact for and no row: `adopt`'s to read. */
|
|
732
739
|
private readonly rowless = new Set<string>()
|
|
740
|
+
/** Row-less lookups that stat'ed their artifact path (`artifactFileMayExist`). */
|
|
741
|
+
private adoptStats = 0
|
|
742
|
+
/** The artifact directory's names, listed once past `ADOPT_STATS`; this process's writes kept in it. */
|
|
743
|
+
private artifactNames: Set<string> | undefined
|
|
733
744
|
private readonly insertEntryInput: ReturnType<Database['prepare']>
|
|
734
745
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
735
746
|
private readonly files: FileHashStore
|
|
@@ -1472,20 +1483,21 @@ export class Cache implements CacheLayer {
|
|
|
1472
1483
|
* reader reads it, and indexed in one transaction with its bytes.
|
|
1473
1484
|
*/
|
|
1474
1485
|
private async adopt(hash: string, ctx: CacheGetContext): Promise<boolean> {
|
|
1475
|
-
|
|
1486
|
+
// Built only for an artifact found: its command is masked on read.
|
|
1487
|
+
const meta = (): IngestMeta => ({
|
|
1476
1488
|
taskId: ctx.taskId,
|
|
1477
1489
|
command: ctx.command,
|
|
1478
1490
|
durationMs: 0,
|
|
1479
1491
|
...(ctx.outputs !== undefined ? { outputs: ctx.outputs } : {}),
|
|
1480
|
-
}
|
|
1492
|
+
})
|
|
1481
1493
|
// Only a key whose lookup saw a row-less inline artifact asks for it: a
|
|
1482
1494
|
// statement per miss otherwise, on every cold save.
|
|
1483
1495
|
const blob = this.rowless.delete(hash)
|
|
1484
1496
|
? (this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
|
|
1485
1497
|
: null
|
|
1486
|
-
if (blob !== null) return this.adopted(this.writeArtifactAndIndex(hash, blob.bytes, meta))
|
|
1498
|
+
if (blob !== null) return this.adopted(this.writeArtifactAndIndex(hash, blob.bytes, meta()))
|
|
1487
1499
|
const finalPath = this.tarPath(hash)
|
|
1488
|
-
if (!
|
|
1500
|
+
if (!this.artifactFileMayExist(hash)) return false
|
|
1489
1501
|
const tmpPath = this.tempPath(hash)
|
|
1490
1502
|
try {
|
|
1491
1503
|
// A second name for the same bytes: the index step renames it over
|
|
@@ -1500,7 +1512,28 @@ export class Cache implements CacheLayer {
|
|
|
1500
1512
|
} catch {
|
|
1501
1513
|
return false
|
|
1502
1514
|
}
|
|
1503
|
-
return this.adopted(this.writeArtifactAndIndex(hash, { tmpPath }, meta))
|
|
1515
|
+
return this.adopted(this.writeArtifactAndIndex(hash, { tmpPath }, meta()))
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Whether `hash` may have a file artifact: a stat for the first
|
|
1520
|
+
* `ADOPT_STATS` asks, then one listing of the directory. A cold
|
|
1521
|
+
* 1,090-package run asked 3,270 times before its first task ran. A
|
|
1522
|
+
* name listed and since removed fails the adopt's link, a miss; a file
|
|
1523
|
+
* another process writes after the listing is missed too, and its own
|
|
1524
|
+
* row serves it.
|
|
1525
|
+
*/
|
|
1526
|
+
private artifactFileMayExist(hash: string): boolean {
|
|
1527
|
+
const name = `${hash}.tar.zst`
|
|
1528
|
+
if (this.artifactNames === undefined) {
|
|
1529
|
+
if (++this.adoptStats <= ADOPT_STATS) return existsSync(path.join(this.artifactDir, name))
|
|
1530
|
+
try {
|
|
1531
|
+
this.artifactNames = new Set(readdirSync(this.artifactDir))
|
|
1532
|
+
} catch {
|
|
1533
|
+
this.artifactNames = new Set()
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
return this.artifactNames.has(name)
|
|
1504
1537
|
}
|
|
1505
1538
|
|
|
1506
1539
|
/** An adoption's verdict: an artifact that fails the checks is a miss. */
|
|
@@ -1562,6 +1595,27 @@ export class Cache implements CacheLayer {
|
|
|
1562
1595
|
return entry
|
|
1563
1596
|
}
|
|
1564
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* Whether no lookup here can hit: no artifact, inline or file. Every
|
|
1600
|
+
* entry has one, and a row-less one is still a hit (`adopt`). The listing
|
|
1601
|
+
* is kept for `adopt`.
|
|
1602
|
+
*/
|
|
1603
|
+
holdsNothing(): boolean {
|
|
1604
|
+
if (!this.read) return true
|
|
1605
|
+
if (this.guard(() => this.db.query('SELECT 1 FROM artifacts LIMIT 1').get()) != null) {
|
|
1606
|
+
return false
|
|
1607
|
+
}
|
|
1608
|
+
let names: string[]
|
|
1609
|
+
try {
|
|
1610
|
+
names = readdirSync(this.artifactDir)
|
|
1611
|
+
} catch (err) {
|
|
1612
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false
|
|
1613
|
+
names = []
|
|
1614
|
+
}
|
|
1615
|
+
this.artifactNames ??= new Set(names)
|
|
1616
|
+
return !names.some((n) => n.endsWith('.tar.zst'))
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1565
1619
|
/**
|
|
1566
1620
|
* `get` for many hashes at once: one `entries` query and one
|
|
1567
1621
|
* `output_files` query per chunk instead of two per hash, with the
|
|
@@ -2391,6 +2445,7 @@ export class Cache implements CacheLayer {
|
|
|
2391
2445
|
} else {
|
|
2392
2446
|
aside = moveAside()
|
|
2393
2447
|
renameSync(tmpPath!, finalPath)
|
|
2448
|
+
this.artifactNames?.add(path.basename(finalPath))
|
|
2394
2449
|
renamed = true
|
|
2395
2450
|
deleteBlob.run(hash)
|
|
2396
2451
|
}
|
package/src/cache/inputs.ts
CHANGED
|
@@ -219,7 +219,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
219
219
|
// Dedupe: when the project dir IS the workspace root (or a glob
|
|
220
220
|
// overlaps), the same absolute path can arrive via both lists —
|
|
221
221
|
// it must contribute to the key exactly once.
|
|
222
|
-
if (wsFiles.length > 0) files =
|
|
222
|
+
if (wsFiles.length > 0) files = mergeSorted(projectFiles, wsFiles)
|
|
223
223
|
}
|
|
224
224
|
// Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
|
|
225
225
|
// run (profiled 2026-09-09); a task declaring neither skips the fan-out.
|
|
@@ -285,7 +285,6 @@ function resolveWorkspaceFiles(args: {
|
|
|
285
285
|
}
|
|
286
286
|
if (positive.length === 0) return undefined
|
|
287
287
|
|
|
288
|
-
const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
|
|
289
288
|
// A path the task's own outputs take back with `!` is no output, so it
|
|
290
289
|
// stays an input (A-44).
|
|
291
290
|
const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
|
|
@@ -310,40 +309,44 @@ function resolveWorkspaceFiles(args: {
|
|
|
310
309
|
// The memo is valid for the snapshot it was computed over: a task that
|
|
311
310
|
// wrote workspace outputs mid-run replaces the partition, and the next
|
|
312
311
|
// caller sees a different array and scans again.
|
|
313
|
-
|
|
314
|
-
args.memo === undefined
|
|
315
|
-
? undefined
|
|
316
|
-
: JSON.stringify([
|
|
317
|
-
positive,
|
|
318
|
-
negative,
|
|
319
|
-
args.ownWorkspaceOutputs,
|
|
320
|
-
args.ownOutputs,
|
|
321
|
-
args.projectRel,
|
|
322
|
-
])
|
|
312
|
+
let isExcluded: ((rel: string) => boolean) | undefined
|
|
323
313
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
324
|
-
const
|
|
314
|
+
const excludedByDeclaration = (rel: string): boolean =>
|
|
315
|
+
(isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
316
|
+
const own = (rel: string): boolean => ownOutput(rel) || ownProject(rel)
|
|
325
317
|
const listing: InputListing = {
|
|
326
318
|
root: args.workspaceRoot,
|
|
327
319
|
listed: gitFiles,
|
|
328
|
-
isInput: (rel) =>
|
|
320
|
+
isInput: (rel) =>
|
|
321
|
+
(isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
|
|
322
|
+
!excludedByDeclaration(rel) &&
|
|
323
|
+
!own(rel),
|
|
329
324
|
nested: () => false,
|
|
330
325
|
...reachOf(positive),
|
|
331
326
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
327
|
+
// Two levels: the declaration's files over the snapshot, shared by every
|
|
328
|
+
// task that declares it, then the task's own outputs taken out. A
|
|
329
|
+
// Turbo-mapped repo gives two thousand tasks one 400-entry list, and a
|
|
330
|
+
// memo keyed by the project too scanned, resolved and sorted the 4,000
|
|
331
|
+
// files it matched once per task (astro, 2026-10-09).
|
|
332
|
+
const declared = JSON.stringify([positive, negative])
|
|
333
|
+
let base: Promise<string[]> | undefined
|
|
334
|
+
const hit = args.memo?.get(declared)
|
|
335
|
+
if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
|
|
336
|
+
if (base === undefined) {
|
|
337
|
+
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
338
|
+
base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
|
|
339
|
+
args.memo?.set(declared, { snapshot: gitFiles, result: base })
|
|
335
340
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
args
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
|
|
346
|
-
return { files: result, listing }
|
|
341
|
+
const ownsAny =
|
|
342
|
+
splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
|
|
343
|
+
splitNegations(args.ownOutputs).positive.length > 0
|
|
344
|
+
const files = base.then((all) => {
|
|
345
|
+
const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
|
|
346
|
+
refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
347
|
+
return kept
|
|
348
|
+
})
|
|
349
|
+
return { files, listing }
|
|
347
350
|
}
|
|
348
351
|
|
|
349
352
|
async function resolveWorkspaceFilesOver(
|
|
@@ -352,7 +355,6 @@ async function resolveWorkspaceFilesOver(
|
|
|
352
355
|
positive: readonly string[],
|
|
353
356
|
isPositive: (rel: string) => boolean,
|
|
354
357
|
excluded: (rel: string) => boolean,
|
|
355
|
-
undecodable: ReadonlySet<string> | undefined,
|
|
356
358
|
): Promise<string[]> {
|
|
357
359
|
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
358
360
|
// carries its own copy of the filter-over-git-set design, so the same
|
|
@@ -371,13 +373,47 @@ async function resolveWorkspaceFilesOver(
|
|
|
371
373
|
if (unmatchedLiterals.size > 0) {
|
|
372
374
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
373
375
|
}
|
|
374
|
-
refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
375
376
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
376
377
|
// tracked file necessarily exists on disk.
|
|
377
378
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
378
379
|
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
379
380
|
}
|
|
380
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The sorted union of two path lists, each once. Both sides arrive sorted
|
|
384
|
+
* and distinct, so one merge does what a Set and a sort of the
|
|
385
|
+
* concatenation did: 3.7 s of an astro plan, where 2,000 tasks each joined
|
|
386
|
+
* a few project files to the same 4,000 workspace files (2026-10-09). A
|
|
387
|
+
* side out of order takes the general path.
|
|
388
|
+
*/
|
|
389
|
+
function mergeSorted(a: readonly string[], b: readonly string[]): string[] {
|
|
390
|
+
if (a.length === 0 && isStrictlySorted(b)) return b as string[]
|
|
391
|
+
if (!isStrictlySorted(a) || !isStrictlySorted(b)) return [...new Set([...a, ...b])].sort()
|
|
392
|
+
const out: string[] = []
|
|
393
|
+
let i = 0
|
|
394
|
+
let j = 0
|
|
395
|
+
while (i < a.length && j < b.length) {
|
|
396
|
+
const x = a[i]!
|
|
397
|
+
const y = b[j]!
|
|
398
|
+
if (x <= y) {
|
|
399
|
+
out.push(x)
|
|
400
|
+
i++
|
|
401
|
+
if (x === y) j++
|
|
402
|
+
} else {
|
|
403
|
+
out.push(y)
|
|
404
|
+
j++
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
while (i < a.length) out.push(a[i++]!)
|
|
408
|
+
while (j < b.length) out.push(b[j++]!)
|
|
409
|
+
return out
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isStrictlySorted(list: readonly string[]): boolean {
|
|
413
|
+
for (let i = 1; i < list.length; i++) if (!(list[i - 1]! < list[i]!)) return false
|
|
414
|
+
return true
|
|
415
|
+
}
|
|
416
|
+
|
|
381
417
|
/** A project-relative matcher asked of root-relative paths: false outside the project. */
|
|
382
418
|
function underProject(
|
|
383
419
|
projectRel: string,
|
|
@@ -76,7 +76,9 @@ export interface RemoteCacheLayer {
|
|
|
76
76
|
* straight into the local cache — an HTTP wire returns its `fetch`
|
|
77
77
|
* `Response`, a chunked wire `new Response(stream)`, bytes in hand
|
|
78
78
|
* `new Blob([bytes])`. `durationMs` is the producing task's duration
|
|
79
|
-
* when the wire carries it.
|
|
79
|
+
* when the wire carries it. A body that errors before its end stores
|
|
80
|
+
* nothing and the read is a miss, so a layer can check bytes as they pass
|
|
81
|
+
* (a digest, a signature) and refuse at the end.
|
|
80
82
|
*/
|
|
81
83
|
get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
|
|
82
84
|
/**
|
package/src/cli/docs.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface DocsHit {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function words(s: string): string[] {
|
|
51
|
-
return s.toLowerCase().match(/[a-z0-9][a-z0-
|
|
51
|
+
return s.toLowerCase().match(/[a-z0-9][a-z0-9_.-]*/g) ?? []
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
|
|
@@ -99,7 +99,11 @@ function count(hay: string, term: string): number {
|
|
|
99
99
|
return n
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* The sections holding every term, best first: a term in the heading
|
|
104
|
+
* outweighs any count in the text. A section titled by the one word asked
|
|
105
|
+
* (an error code) is the answer alone.
|
|
106
|
+
*/
|
|
103
107
|
export function searchDocs(
|
|
104
108
|
pages: Readonly<Record<string, string>>,
|
|
105
109
|
terms: readonly string[],
|
|
@@ -123,7 +127,9 @@ export function searchDocs(
|
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
129
|
scored.sort((a, b) => b.score - a.score || a.order - b.order)
|
|
126
|
-
|
|
130
|
+
const exact =
|
|
131
|
+
terms.length === 1 ? scored.filter(({ s }) => words(s.heading).join(' ') === terms[0]) : []
|
|
132
|
+
return (exact.length > 0 ? exact : scored).slice(0, limit).map(({ s }) => ({
|
|
127
133
|
page: s.page,
|
|
128
134
|
heading: s.heading,
|
|
129
135
|
url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
|
package/src/cli/help.ts
CHANGED
|
@@ -261,7 +261,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
261
261
|
'Docs:',
|
|
262
262
|
' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
|
|
263
263
|
' patterns, security, features): the sections holding every',
|
|
264
|
-
' word of the query print whole, best first, with their URL
|
|
264
|
+
' word of the query print whole, best first, with their URL;',
|
|
265
|
+
' an error code (vx docs VX_E_CYCLE) prints its fix alone.',
|
|
265
266
|
' --limit N How many sections (default 3).',
|
|
266
267
|
' --format json {query, hits: [{page, heading, url, text}]}.',
|
|
267
268
|
'',
|