@vzn/vx 0.0.634 → 0.0.636
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 +14 -2
- package/docs/cli.md +94 -36
- package/docs/execution.md +1 -1
- package/docs/features.md +4 -3
- package/docs/patterns.md +8 -7
- package/docs/schema.md +73 -3
- package/docs/security.md +7 -1
- package/launcher.cjs +0 -0
- package/launcher.sh +50 -0
- package/package.json +17 -8
- package/schemas/last.json +5 -1
- package/schemas/show.json +8 -0
- package/schemas/why.json +4 -1
- package/src/cache/layered-cache.ts +3 -1
- package/src/cli/help.ts +3 -1
- package/src/cli/last.ts +9 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +35 -7
- package/src/cli/select.ts +17 -11
- package/src/cli/show.ts +2 -2
- package/src/config.ts +36 -3
- package/src/graph/scheduler.ts +14 -3
- package/src/graph/task-graph.ts +22 -5
- package/src/index.ts +7 -1
- package/src/orchestrator/execute-task.ts +62 -6
- 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/options.ts +2 -0
- package/src/orchestrator/path-links.ts +5 -3
- package/src/orchestrator/plugin.ts +3 -1
- package/src/orchestrator/prepare.ts +17 -14
- package/src/orchestrator/run-failures.ts +20 -3
- package/src/orchestrator/run.ts +25 -2
- package/src/orchestrator/sandbox-fix.ts +123 -0
- package/src/orchestrator/task-hash.ts +7 -1
- package/src/workspace/affected.ts +16 -4
- package/src/workspace/config-schema.ts +85 -4
- package/src/workspace/index.ts +2 -0
- package/src/workspace/migration.ts +51 -4
- package/src/workspace/project-loader.ts +3 -1
package/docs/schema.md
CHANGED
|
@@ -704,7 +704,65 @@ Semantics:
|
|
|
704
704
|
interface CacheConfig {
|
|
705
705
|
inputs: CacheInputs // required when `cache` is provided
|
|
706
706
|
outputs: CacheOutputs // required when `cache` is provided
|
|
707
|
+
inputStability?: InputStability // optional
|
|
707
708
|
}
|
|
709
|
+
|
|
710
|
+
interface InputStability {
|
|
711
|
+
strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
|
|
712
|
+
severity?: 'warn' | 'error' // default 'warn'
|
|
713
|
+
}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
`inputStability` says what a write to an input while the command runs
|
|
717
|
+
means. `strategy` says which writes count:
|
|
718
|
+
|
|
719
|
+
- `'immutable'` (the default, on CI too): any write. An edit undone
|
|
720
|
+
mid-run, or another task changing the file and putting it back, cannot
|
|
721
|
+
be told from no edit.
|
|
722
|
+
- `'mutable'`: a file rewritten to the bytes the key folded counts as
|
|
723
|
+
unchanged, for a task that rewrites its own input identically
|
|
724
|
+
(tsdown's `package.json`); different bytes still count.
|
|
725
|
+
- `'unchecked'`: none. The entry is saved under the key taken before the
|
|
726
|
+
command, whatever the command did to its inputs; `severity` is ignored.
|
|
727
|
+
A key that no longer held before the command started still withholds
|
|
728
|
+
it.
|
|
729
|
+
|
|
730
|
+
A write that counts withholds the entry, and `severity` says what else
|
|
731
|
+
happens: `'warn'` (the default) keeps the result, says why on the status
|
|
732
|
+
line, and the run goes on; `'error'` fails the task, so the run fails.
|
|
733
|
+
Either way, every task that ran over the task's outputs, down the graph,
|
|
734
|
+
withholds its save too, and a task of any project that declares the
|
|
735
|
+
moved file as an input withholds its own.
|
|
736
|
+
|
|
737
|
+
Each field is resolved on its own, highest first:
|
|
738
|
+
|
|
739
|
+
1. `VX_INPUT_STABILITY` (`immutable`, `mutable`, `unchecked`) and
|
|
740
|
+
`VX_INPUT_STABILITY_SEVERITY` (`warn`, `error`), for the whole run
|
|
741
|
+
(a CI job that wants `error` sets it without editing configs); any
|
|
742
|
+
other value is refused before a task runs;
|
|
743
|
+
2. the task's own `cache.inputStability`;
|
|
744
|
+
3. the workspace's `inputStability` (`vx.workspace.ts`);
|
|
745
|
+
4. the default, `'immutable'` and `'warn'`.
|
|
746
|
+
|
|
747
|
+
Never folded into a cache key.
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
import { defineProject } from '@vzn/vx/config'
|
|
751
|
+
|
|
752
|
+
export default defineProject({
|
|
753
|
+
tasks: {
|
|
754
|
+
build: {
|
|
755
|
+
// tsdown rewrites package.json with the same bytes on every build;
|
|
756
|
+
// different bytes fail the run rather than rebuild next time.
|
|
757
|
+
exec: { command: 'tsdown' },
|
|
758
|
+
cache: {
|
|
759
|
+
inputs: { files: ['src/**', 'package.json'] },
|
|
760
|
+
outputs: { files: ['dist/**'] },
|
|
761
|
+
inputStability: { strategy: 'mutable', severity: 'error' },
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
})
|
|
708
766
|
```
|
|
709
767
|
|
|
710
768
|
**Caching is opt-in.** Omit `cache` and the task always runs (no read,
|
|
@@ -1535,6 +1593,7 @@ export default defineWorkspace({
|
|
|
1535
1593
|
cacheScope: 'trusted',
|
|
1536
1594
|
rules: { exclusiveOutputs: true, upfrontKeys: true },
|
|
1537
1595
|
schedule: 'most-work',
|
|
1596
|
+
inputStability: { strategy: 'immutable', severity: 'warn' },
|
|
1538
1597
|
plugins: [otel()],
|
|
1539
1598
|
})
|
|
1540
1599
|
```
|
|
@@ -1557,8 +1616,10 @@ interface WorkspaceConfig {
|
|
|
1557
1616
|
rules?: WorkspaceRules
|
|
1558
1617
|
/** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
|
|
1559
1618
|
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
1619
|
+
/** Every task's cache.inputStability default: { strategy, severity }. */
|
|
1620
|
+
inputStability?: InputStability
|
|
1560
1621
|
/** Run-level plugins (cache / executor / telemetry capabilities). */
|
|
1561
|
-
plugins?: readonly Plugin[]
|
|
1622
|
+
plugins?: readonly (Plugin | undefined | null | false)[]
|
|
1562
1623
|
}
|
|
1563
1624
|
|
|
1564
1625
|
interface WorkspaceRules {
|
|
@@ -1630,7 +1691,7 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
|
|
|
1630
1691
|
each other. A scope's key is derived from the task key, so every
|
|
1631
1692
|
remote wire stores it unchanged. A clamp on `--cache`, never a
|
|
1632
1693
|
widening. `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
|
|
1633
|
-
the default branch. `VX_CACHE_SCOPE` beats both. A client-side convention, not a security
|
|
1694
|
+
the default branch. `VX_CACHE_SCOPE` beats both, and `vx run --cache-scope` beats all three. A client-side convention, not a security
|
|
1634
1695
|
boundary: a run holding a write credential can write any key, so only
|
|
1635
1696
|
a cache server that scopes writes by token can refuse one
|
|
1636
1697
|
(`docs/security.md` § Cache poisoning). Not folded into any cache key.
|
|
@@ -1645,6 +1706,10 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
|
|
|
1645
1706
|
their ties. The default costs what it did; another strategy is
|
|
1646
1707
|
computed only when named. Any other value is refused. Never folded
|
|
1647
1708
|
into a cache key.
|
|
1709
|
+
- **`inputStability`** — the `cache.inputStability` of every task that
|
|
1710
|
+
does not set its own field, same shape (`{ strategy, severity }`, §
|
|
1711
|
+
`cache`). The env vars still override it for the run. Never folded
|
|
1712
|
+
into a cache key.
|
|
1648
1713
|
- **`rules`** — checks on the task graph, each on unless set to
|
|
1649
1714
|
`false`. Turning one off allows a shape vx runs correctly but more
|
|
1650
1715
|
slowly; never folded into a cache key. A value that is not a boolean,
|
|
@@ -1682,7 +1747,9 @@ false } in vx.workspace.ts to let it wait for its producer.` Such a key
|
|
|
1682
1747
|
Declaration order is precedence: every `executor` is consulted in
|
|
1683
1748
|
order per task (first to accept runs it; what all decline runs
|
|
1684
1749
|
locally); every `cache` layer is chained (lookup walks, save reaches
|
|
1685
|
-
all, the local store last).
|
|
1750
|
+
all, the local store last). An `undefined`, `null` or `false` entry
|
|
1751
|
+
is skipped, as Vite skips one: a factory that declines (`github()` off
|
|
1752
|
+
Actions) or `cond && plugin()` needs no filter. Each entry is a `VxPlugin` contributing
|
|
1686
1753
|
any subset of the fourteen hooks, in pipeline order: `config` (edit
|
|
1687
1754
|
the workspace config before anything derives from it), `discover`
|
|
1688
1755
|
(name directories to make projects beyond the members), `project`
|
|
@@ -2057,6 +2124,9 @@ a user meets most:
|
|
|
2057
2124
|
| `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
|
|
2058
2125
|
| `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
|
|
2059
2126
|
| `cache.outputs is required when cache is set` | Forgot `outputs`. |
|
|
2127
|
+
| `cache.inputStability must be an object` | A bare string (`inputStability: 'mutable'`). |
|
|
2128
|
+
| `cache.inputStability.strategy must be 'immutable', 'mutable' or 'unchecked'` | Any other value (`'content'`, `false`). |
|
|
2129
|
+
| `cache.inputStability.severity must be 'warn' or 'error'` | Any other value (`'fail'`, `true`). |
|
|
2060
2130
|
| `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
|
|
2061
2131
|
| `cache.outputs.files must be an array` | Wrong shape. |
|
|
2062
2132
|
| `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
|
package/docs/security.md
CHANGED
|
@@ -42,7 +42,7 @@ namespace), or none.
|
|
|
42
42
|
`cacheScope` is the client half. `'pr-123'` reads its own keys,
|
|
43
43
|
then the trusted ones, and writes only its own, which `github()` sets on
|
|
44
44
|
Actions (`pr-<n>`, `ref-<name>`; the default branch stays trusted)
|
|
45
|
-
and `VX_CACHE_SCOPE` sets on any CI.
|
|
45
|
+
and `VX_CACHE_SCOPE` or `--cache-scope` sets on any CI.
|
|
46
46
|
It keeps honest runs apart and stops a non-hermetic PR build from
|
|
47
47
|
landing where main reads; it is not a boundary, since the PR's own
|
|
48
48
|
config decides it and any run holding a write credential can write any
|
|
@@ -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.636",
|
|
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.636",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.636",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.636",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.636"
|
|
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/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/show.json
CHANGED
|
@@ -115,6 +115,14 @@
|
|
|
115
115
|
"files": { "$ref": "#/$defs/stringList" },
|
|
116
116
|
"workspaceFiles": { "$ref": "#/$defs/stringList" }
|
|
117
117
|
}
|
|
118
|
+
},
|
|
119
|
+
"inputStability": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"additionalProperties": false,
|
|
122
|
+
"properties": {
|
|
123
|
+
"strategy": { "enum": ["immutable", "mutable", "unchecked"] },
|
|
124
|
+
"severity": { "enum": ["warn", "error"] }
|
|
125
|
+
}
|
|
118
126
|
}
|
|
119
127
|
}
|
|
120
128
|
}
|
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
|
},
|
|
@@ -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/help.ts
CHANGED
|
@@ -140,7 +140,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
140
140
|
' (layer = local|remote, flags ⊆ rw). A named layer is set EXACTLY',
|
|
141
141
|
' to its flags; unnamed layers keep their value. Examples:',
|
|
142
142
|
' local:rw,remote:r (remote read-only), remote: (remote off),',
|
|
143
|
-
' local:r (local read-only).',
|
|
143
|
+
' local:r (local read-only). Without it, VX_CACHE holds the spec.',
|
|
144
|
+
" --cache-scope <name> This run's cacheScope: trusted | read-only | a scope (pr-123).",
|
|
145
|
+
' Beats VX_CACHE_SCOPE and the workspace cacheScope.',
|
|
144
146
|
' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
|
|
145
147
|
' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
|
|
146
148
|
' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
|
package/src/cli/last.ts
CHANGED
|
@@ -384,7 +384,15 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
384
384
|
)
|
|
385
385
|
const tasks = (detail?.tasks ?? []).map((t) => {
|
|
386
386
|
const f = failures.get(`${t.project}#${t.task}`)
|
|
387
|
-
return f === undefined
|
|
387
|
+
return f === undefined
|
|
388
|
+
? t
|
|
389
|
+
: {
|
|
390
|
+
...t,
|
|
391
|
+
output: f.output,
|
|
392
|
+
locations: f.locations,
|
|
393
|
+
...(f.sandboxFix !== undefined ? { sandboxFix: f.sandboxFix } : {}),
|
|
394
|
+
...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
|
|
395
|
+
}
|
|
388
396
|
})
|
|
389
397
|
process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
|
|
390
398
|
return 0
|
|
@@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
10
10
|
},
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
13
|
-
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { mkdir,
|
|
13
|
+
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
|
|
14
14
|
test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
|
|
15
15
|
},
|
|
16
16
|
commands: {
|
package/src/cli/run.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
|
-
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
4
|
+
import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
|
|
5
5
|
import { translateForeign } from './foreign-flags.js'
|
|
6
6
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
7
7
|
import {
|
|
@@ -52,6 +52,8 @@ export interface RunArgs {
|
|
|
52
52
|
remoteRequested?: boolean
|
|
53
53
|
/** `--cache-dir <path>`: override the cache directory (cwd-relative). */
|
|
54
54
|
cacheDir: string | undefined
|
|
55
|
+
/** `--cache-scope <name>`: this run's `cacheScope`, above `VX_CACHE_SCOPE` and the workspace's. */
|
|
56
|
+
cacheScope?: string
|
|
55
57
|
frozen: boolean
|
|
56
58
|
/**
|
|
57
59
|
* `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
|
|
@@ -158,6 +160,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
158
160
|
// beats `--force`, and `--cache` is the base both may override.
|
|
159
161
|
let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
|
|
160
162
|
let cacheSpecError: string | undefined
|
|
163
|
+
let cacheFlag = false
|
|
161
164
|
let noCacheFlag = false
|
|
162
165
|
let forceFlag = false
|
|
163
166
|
|
|
@@ -282,7 +285,13 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
282
285
|
}
|
|
283
286
|
}
|
|
284
287
|
out.cacheDir = v
|
|
288
|
+
} else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
|
|
289
|
+
const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
|
|
290
|
+
if (v === undefined || !isCacheScope(v))
|
|
291
|
+
return { ...out, error: cacheScopeError('--cache-scope') }
|
|
292
|
+
out.cacheScope = v
|
|
285
293
|
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
294
|
+
cacheFlag = true
|
|
286
295
|
const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
|
|
287
296
|
if (v === undefined)
|
|
288
297
|
return {
|
|
@@ -407,6 +416,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
407
416
|
if (cacheSpecError !== undefined) {
|
|
408
417
|
return { ...out, error: cacheSpecError }
|
|
409
418
|
}
|
|
419
|
+
// `VX_CACHE` is the spec a run takes when no `--cache` names one.
|
|
420
|
+
const envSpec = process.env['VX_CACHE'] || undefined
|
|
421
|
+
if (!cacheFlag && envSpec !== undefined) {
|
|
422
|
+
if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
|
|
423
|
+
return {
|
|
424
|
+
...out,
|
|
425
|
+
error: `VX_CACHE needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
cachePolicy = parseCachePolicy(envSpec, cachePolicy)
|
|
429
|
+
} catch (err) {
|
|
430
|
+
return { ...out, error: `VX_CACHE: ${err instanceof Error ? err.message : String(err)}` }
|
|
431
|
+
}
|
|
432
|
+
if (/remote:[rw]/.test(envSpec)) out.remoteRequested = true
|
|
433
|
+
}
|
|
410
434
|
// Precedence: --no-cache (all off) beats --force (reads off, writes
|
|
411
435
|
// kept), both layered on top of any --cache spec.
|
|
412
436
|
if (noCacheFlag) {
|
|
@@ -479,7 +503,9 @@ function onCI(v: string | undefined): boolean {
|
|
|
479
503
|
async function scopeFilters(
|
|
480
504
|
parsed: RunArgs,
|
|
481
505
|
cwd: string,
|
|
482
|
-
): Promise<
|
|
506
|
+
): Promise<
|
|
507
|
+
{ filterStrings: string[]; affectedFilter?: string } | { error: string; code?: string }
|
|
508
|
+
> {
|
|
483
509
|
// `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
|
|
484
510
|
// changed projects AND their dependents — what a CI gate must run, what
|
|
485
511
|
// the flag's name says, what the guides promised while the sugar was the
|
|
@@ -523,7 +549,7 @@ export async function resolveRunOptions(
|
|
|
523
549
|
verb: 'run' | 'watch' = 'run',
|
|
524
550
|
/** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
|
|
525
551
|
scopeOut?: { affectedBase?: string },
|
|
526
|
-
): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
|
|
552
|
+
): Promise<RunOptions | { error: string; code?: string } | { nothingSelected: string }> {
|
|
527
553
|
for (const t of tasks) {
|
|
528
554
|
const idx = t.indexOf('#')
|
|
529
555
|
if (idx >= 0) {
|
|
@@ -566,7 +592,7 @@ export async function resolveRunOptions(
|
|
|
566
592
|
affectedFilter,
|
|
567
593
|
)
|
|
568
594
|
: undefined
|
|
569
|
-
if (resolved !== undefined && 'error' in resolved) return
|
|
595
|
+
if (resolved !== undefined && 'error' in resolved) return resolved
|
|
570
596
|
if (bareTasks.length === 0) {
|
|
571
597
|
projects = undefined
|
|
572
598
|
} else if (resolved !== undefined) {
|
|
@@ -609,6 +635,7 @@ export async function resolveRunOptions(
|
|
|
609
635
|
tasks: [...tasks],
|
|
610
636
|
cache: parsed.cache,
|
|
611
637
|
...(parsed.remoteRequested ? { remoteRequested: true } : {}),
|
|
638
|
+
...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
|
|
612
639
|
// A laptop reads the shared cache and never writes it (owner,
|
|
613
640
|
// 2026-10-03); naming the remote in `--cache` is asking to.
|
|
614
641
|
...(!parsed.remoteRequested && !onCI(process.env['CI'])
|
|
@@ -696,11 +723,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
696
723
|
// `--filter` / `--affected` scope the menu, as they scope a bare task:
|
|
697
724
|
// the menu listed every project and the anchored pick ran outside it.
|
|
698
725
|
const scope = await scopeFilters(parsed, cwd)
|
|
699
|
-
if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
|
|
726
|
+
if ('error' in scope) return refuse('run', args, scope.error, scope.code ?? 'VX_E_USAGE')
|
|
700
727
|
let only: ReadonlySet<string> | undefined
|
|
701
728
|
if (scope.filterStrings.length > 0) {
|
|
702
729
|
const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
|
|
703
|
-
if ('error' in selected)
|
|
730
|
+
if ('error' in selected)
|
|
731
|
+
return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
|
|
704
732
|
if ('empty' in selected) {
|
|
705
733
|
process.stderr.write(`vx run: ${selected.empty}\n`)
|
|
706
734
|
return 0
|
|
@@ -716,7 +744,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
716
744
|
|
|
717
745
|
const scopeOut: { affectedBase?: string } = {}
|
|
718
746
|
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
719
|
-
if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
|
|
747
|
+
if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
|
|
720
748
|
if ('nothingSelected' in resolved) {
|
|
721
749
|
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
722
750
|
return 0
|
package/src/cli/select.ts
CHANGED
|
@@ -197,7 +197,7 @@ export type FilterResolution =
|
|
|
197
197
|
/** What the other includes selected (`RunOptions.selectedOutright`). */
|
|
198
198
|
outright?: string[]
|
|
199
199
|
}
|
|
200
|
-
|
|
|
200
|
+
| Refusal
|
|
201
201
|
| { empty: string }
|
|
202
202
|
|
|
203
203
|
/**
|
|
@@ -239,16 +239,23 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
|
|
|
239
239
|
return out
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/** A refusal a resolver returns; `code` is the thrown one's own, when it has one. */
|
|
243
|
+
type Refusal = { error: string; code?: string }
|
|
244
|
+
|
|
245
|
+
function refusalOf(err: unknown): Refusal {
|
|
246
|
+
if (!(err instanceof Error)) return { error: String(err) }
|
|
247
|
+
return isUserError(err) && err.code !== 'VX_E_REFUSED'
|
|
248
|
+
? { error: err.message, code: err.code }
|
|
249
|
+
: { error: err.message }
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
/**
|
|
243
253
|
* The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
|
|
244
254
|
* projects and their dependents. An empty base is the workspace's
|
|
245
255
|
* `affectedBase` (or a plugin's `config` stage, from nx.json's
|
|
246
256
|
* `defaultBase` or `TURBO_SCM_BASE`), then the guess.
|
|
247
257
|
*/
|
|
248
|
-
export async function affectedFilterFor(
|
|
249
|
-
cwd: string,
|
|
250
|
-
affected: string,
|
|
251
|
-
): Promise<string | { error: string }> {
|
|
258
|
+
export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
|
|
252
259
|
const root = await findWorkspaceRoot(cwd)
|
|
253
260
|
let base = affected
|
|
254
261
|
if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
|
|
@@ -257,7 +264,7 @@ export async function affectedFilterFor(
|
|
|
257
264
|
base = await defaultAffectedBase(root)
|
|
258
265
|
} catch (err) {
|
|
259
266
|
if (!isUserError(err)) throw err
|
|
260
|
-
return
|
|
267
|
+
return refusalOf(err)
|
|
261
268
|
}
|
|
262
269
|
}
|
|
263
270
|
return `...[${base}]`
|
|
@@ -276,7 +283,7 @@ export async function resolveFilters(
|
|
|
276
283
|
try {
|
|
277
284
|
parsed = raw.map((r) => parseFilter(r, root))
|
|
278
285
|
} catch (err) {
|
|
279
|
-
return
|
|
286
|
+
return refusalOf(err)
|
|
280
287
|
}
|
|
281
288
|
// `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
|
|
282
289
|
const walksGraph = parsed.some(
|
|
@@ -299,7 +306,7 @@ export async function resolveFilters(
|
|
|
299
306
|
try {
|
|
300
307
|
edges = taskEdgesFrom(await stagedOnce())
|
|
301
308
|
} catch (err) {
|
|
302
|
-
return
|
|
309
|
+
return refusalOf(err)
|
|
303
310
|
}
|
|
304
311
|
}
|
|
305
312
|
// A tag lives in the config, so a `tag:` selector needs the staged load
|
|
@@ -309,7 +316,7 @@ export async function resolveFilters(
|
|
|
309
316
|
try {
|
|
310
317
|
tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
|
|
311
318
|
} catch (err) {
|
|
312
|
-
return
|
|
319
|
+
return refusalOf(err)
|
|
313
320
|
}
|
|
314
321
|
}
|
|
315
322
|
const graph = buildPackageGraph(projects, edges)
|
|
@@ -353,8 +360,7 @@ export async function resolveFilters(
|
|
|
353
360
|
}
|
|
354
361
|
affectedByFilter.set(f, names)
|
|
355
362
|
} catch (err) {
|
|
356
|
-
|
|
357
|
-
return { error: msg }
|
|
363
|
+
return refusalOf(err)
|
|
358
364
|
}
|
|
359
365
|
}
|
|
360
366
|
|
package/src/cli/show.ts
CHANGED
|
@@ -145,12 +145,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
145
145
|
let affected: string | undefined
|
|
146
146
|
if (parsed.affected !== undefined) {
|
|
147
147
|
const f = await affectedFilterFor(process.cwd(), parsed.affected)
|
|
148
|
-
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
|
|
148
|
+
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
|
|
149
149
|
affected = f
|
|
150
150
|
raw.unshift(f)
|
|
151
151
|
}
|
|
152
152
|
const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
|
|
153
|
-
if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
|
|
153
|
+
if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
|
|
154
154
|
if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
|
|
155
155
|
selected = new Set('empty' in r ? [] : r.names)
|
|
156
156
|
if ('names' in r && r.affected !== undefined)
|