@vzn/vx 0.0.633 → 0.0.634
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 +4 -1
- package/docs/cli.md +90 -21
- package/docs/execution.md +1 -1
- package/docs/features.md +4 -2
- package/docs/schema.md +14 -0
- package/package.json +5 -5
- package/schemas/error.json +5 -1
- package/schemas/plan.json +4 -0
- package/skills/vx/SKILL.md +7 -1
- package/src/cache/cache.ts +60 -5
- package/src/cache/inputs.ts +66 -30
- package/src/cli/docs.ts +9 -3
- package/src/cli/help.ts +2 -1
- package/src/cli/init.ts +9 -2
- package/src/cli/plan-format.ts +3 -1
- package/src/cli/run.ts +8 -2
- package/src/config.ts +9 -0
- package/src/graph/priorities.ts +72 -6
- package/src/graph/scheduler.ts +17 -4
- package/src/graph/task-graph.ts +53 -15
- package/src/orchestrator/remote-prefetch.ts +8 -2
- package/src/orchestrator/run.ts +13 -1
- package/src/util/errors.ts +9 -2
- package/src/util/paths.ts +32 -0
- package/src/workspace/config-schema.ts +16 -0
- package/src/workspace/lockfile.ts +29 -2
package/docs/caching.md
CHANGED
|
@@ -465,7 +465,10 @@ fold, and runtime probes after a writer still leave a key preliminary.
|
|
|
465
465
|
The
|
|
466
466
|
short-circuit never runs under a `LayeredCache` (remote prefetch owns
|
|
467
467
|
those runs — an up-front `get` there would put remote GETs on the
|
|
468
|
-
critical path), never fires with local reads off
|
|
468
|
+
critical path), never fires with local reads off or on a local cache
|
|
469
|
+
that holds no artifact (`Cache.holdsNothing`: every probe would miss,
|
|
470
|
+
and a cold 1,090-package run started its first task ~90 ms sooner
|
|
471
|
+
without it), and never throws —
|
|
469
472
|
any error degrades to the normal lazy schedule. Measured: −6.6% on a
|
|
470
473
|
mixed slow-upstream/warm-downstream workload; parity on all-hit warm
|
|
471
474
|
runs.
|
package/docs/cli.md
CHANGED
|
@@ -976,6 +976,10 @@ single `file` for a lockfile claim, a manifest edge or a config import),
|
|
|
976
976
|
selected its project). `via` is the `dependsOn` chain, nearest first, to
|
|
977
977
|
the task the change reached. A dependency the kept tasks pull in carries
|
|
978
978
|
none. The reasons are gathered only for a plan; a run pays nothing.
|
|
979
|
+
`--dry=json` also states `affectedBase`, the ref the diff was taken
|
|
980
|
+
against: a bare `--affected` guesses it (`origin/HEAD`, a trunk branch,
|
|
981
|
+
else `HEAD~1`), so a reason that names no file may come from a commit
|
|
982
|
+
the base reaches, a root `package.json` edit among them.
|
|
979
983
|
|
|
980
984
|
`--graph` prints Graphviz DOT (stdout by default; `--graph=path`
|
|
981
985
|
writes a file):
|
|
@@ -2287,26 +2291,89 @@ refuses `--dry` (use `--dry=json`), a bare `--graph` and
|
|
|
2287
2291
|
|
|
2288
2292
|
A verb asked for JSON (`--format json`, `--format=json`, `--dry=json`,
|
|
2289
2293
|
before any `--`) answers a refusal on stdout too, as one line that
|
|
2290
|
-
`schemas/error.json` describes: `{"ok":false,"error":{"code","message"}}`.
|
|
2291
|
-
|
|
2292
|
-
code
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2294
|
+
`schemas/error.json` describes: `{"ok":false,"error":{"code","message","docs"}}`.
|
|
2295
|
+
`docs` links the code's section below, which says what to do;
|
|
2296
|
+
`vx docs <code>` prints that section offline. The exit code (1) and the
|
|
2297
|
+
prose on stderr do not change. Branch on the code; the message may change.
|
|
2298
|
+
|
|
2299
|
+
#### `VX_E_USAGE`
|
|
2300
|
+
|
|
2301
|
+
A flag, argument or selection the verb does not take. The message names
|
|
2302
|
+
it, and a close spelling the verb does take when there is one;
|
|
2303
|
+
`vx <verb> --help` lists every flag.
|
|
2304
|
+
|
|
2305
|
+
#### `VX_E_UNKNOWN_COMMAND`
|
|
2306
|
+
|
|
2307
|
+
No verb of that name here, core's or a declared plugin's. `vx --help`
|
|
2308
|
+
lists core's verbs. A plugin's verb exists once its plugin is in
|
|
2309
|
+
`vx.workspace.ts` `plugins`; a task runs as `vx run <task>`.
|
|
2310
|
+
|
|
2311
|
+
#### `VX_E_UNKNOWN_TASK`
|
|
2312
|
+
|
|
2313
|
+
No project in scope declares a requested task. The message lists the
|
|
2314
|
+
tasks it found; `vx show --format json` lists each project's. Check the
|
|
2315
|
+
spelling, and the scope: `--filter`, `--all`, or the project the working
|
|
2316
|
+
directory is in.
|
|
2317
|
+
|
|
2318
|
+
#### `VX_E_UNKNOWN_PROJECT`
|
|
2319
|
+
|
|
2320
|
+
`vx show` names a project the workspace does not have. A project is named
|
|
2321
|
+
by its `package.json` `name`; `vx show --format json` lists them.
|
|
2322
|
+
|
|
2323
|
+
#### `VX_E_NO_HISTORY`
|
|
2324
|
+
|
|
2325
|
+
`vx why` / `vx last` find no recorded run for what they were asked. Run
|
|
2326
|
+
the task once (`vx run <task>`); history is this checkout's own.
|
|
2327
|
+
|
|
2328
|
+
#### `VX_E_AMBIGUOUS`
|
|
2329
|
+
|
|
2330
|
+
`vx why <task>` matches the task in several projects. Name one:
|
|
2331
|
+
`vx why <project>#<task>`.
|
|
2332
|
+
|
|
2333
|
+
#### `VX_E_CONFIG`
|
|
2334
|
+
|
|
2335
|
+
A `vx.config.*` or `vx.workspace.*` that does not load or is refused. The
|
|
2336
|
+
message names the file and the field; fix that field
|
|
2337
|
+
(`vx docs <field>` prints its reference). `vx info --format json` lists
|
|
2338
|
+
every config error at once.
|
|
2339
|
+
|
|
2340
|
+
#### `VX_E_CYCLE`
|
|
2341
|
+
|
|
2342
|
+
The task graph has a cycle. The message names the loop; drop one of its
|
|
2343
|
+
`dependsOn` edges.
|
|
2344
|
+
|
|
2345
|
+
#### `VX_E_NO_WORKSPACE`
|
|
2346
|
+
|
|
2347
|
+
No workspace root above the working directory. Run vx inside the repo,
|
|
2348
|
+
or create a `package.json` and run `vx init`.
|
|
2349
|
+
|
|
2350
|
+
#### `VX_E_CACHE_VERSION`
|
|
2351
|
+
|
|
2352
|
+
A reading verb met a cache index from another vx version. It reads
|
|
2353
|
+
nothing from it and leaves it be; the next `vx run` resets it. Or run the
|
|
2354
|
+
vx version that wrote it.
|
|
2355
|
+
|
|
2356
|
+
#### `VX_E_REFUSED`
|
|
2357
|
+
|
|
2358
|
+
Any other refusal (a plugin's, a check's) with no code of its own. The
|
|
2359
|
+
message is that plugin's or check's: do what it says.
|
|
2360
|
+
`vx info --format json` lists the plugins.
|
|
2361
|
+
|
|
2362
|
+
#### `VX_E_FS`
|
|
2363
|
+
|
|
2364
|
+
The file system refused a write: permissions, a full disk. The message
|
|
2365
|
+
names the path. Free space (`vx cache prune --max-size <size>` trims the
|
|
2366
|
+
cache) or grant the write, then re-run.
|
|
2367
|
+
|
|
2368
|
+
#### `VX_E_FDS`
|
|
2369
|
+
|
|
2370
|
+
Out of file descriptors. Raise the limit (`ulimit -n 4096`), or run fewer
|
|
2371
|
+
tasks at once (`--concurrency`), then re-run.
|
|
2372
|
+
|
|
2373
|
+
#### `VX_E_INTERNAL`
|
|
2374
|
+
|
|
2375
|
+
A defect in vx; stderr carries the stack. Report it with that stack at
|
|
2376
|
+
https://github.com/vznjs/vx/issues.
|
|
2310
2377
|
|
|
2311
2378
|
A plugin can give its refusal a code: `new UserError(message, 'MY_CODE')`.
|
|
2312
2379
|
|
|
@@ -2786,7 +2853,9 @@ parse error.
|
|
|
2786
2853
|
`schema`, `caching`, `execution`, `patterns`, `security` and `features`,
|
|
2787
2854
|
which ship inside the package and the compiled binary. Each page is cut
|
|
2788
2855
|
at its headings; a section that holds every word of the query matches,
|
|
2789
|
-
and a word in its heading ranks it above any count in its text
|
|
2856
|
+
and a word in its heading ranks it above any count in its text; a
|
|
2857
|
+
one-word query that titles a section, an error code
|
|
2858
|
+
(`vx docs VX_E_CYCLE`), prints that section alone. The best
|
|
2790
2859
|
`--limit` sections (default 3) print whole, each under its page,
|
|
2791
2860
|
heading and URL on the site. `--format json` prints
|
|
2792
2861
|
`{query, hits: [{page, heading, url, text}]}` (`schemas/docs.json`).
|
package/docs/execution.md
CHANGED
|
@@ -45,7 +45,7 @@ terminal and a task succeeding or failing. Read it alongside
|
|
|
45
45
|
│ 3. loadWorkspacePlugins — loadWorkspaceConfig reads the optional
|
|
46
46
|
│ vx.workspace.{ts,mts,js,mjs,cts,cjs} at the root (concurrency /
|
|
47
47
|
│ cacheDir / timeout / cacheRetention / affectedBase / cacheScope /
|
|
48
|
-
│ rules / plugins),
|
|
48
|
+
│ rules / schedule / plugins),
|
|
49
49
|
│ then the plugin `config` stage runs on it.
|
|
50
50
|
│ 4. listProjects — globs every workspace member's package.json,
|
|
51
51
|
│ finds sibling vx.config.* files, detects duplicate package
|
package/docs/features.md
CHANGED
|
@@ -28,6 +28,7 @@ config key or an environment variable is missing here.
|
|
|
28
28
|
- **Turbo and Nx spellings** (`-t`, `-p`, `--exclude`, `--parallel`, `--base`, `--dry-run`, `--skip-nx-cache`) — accepted, or refused naming the vx spelling. [page](https://vznjs.github.io/vx/features/turbo-nx-flags/) · post: [Flags you already know](https://vznjs.github.io/vx/blog/flags-you-already-know/)
|
|
29
29
|
- **Ctrl-C leaves nothing running** (`VX_KILL_GRACE_MS`, `VX_TEARDOWN_TIMEOUT_MS`) — the whole process tree stops, then teardown runs. [page](https://vznjs.github.io/vx/features/ctrl-c/) · post: [Ctrl-C leaves nothing running](https://vznjs.github.io/vx/blog/ctrl-c/)
|
|
30
30
|
- **Longest chain first** — the scheduler starts the critical path first; `@vzn/vx-schedule-history` learns it from past runs. [page](https://vznjs.github.io/vx/features/critical-path/) · post: [Bitsets, popcount, and a scheduler tick](https://vznjs.github.io/vx/blog/bitsets-and-the-scheduler/)
|
|
31
|
+
- **Schedule strategy** (`schedule`) — pick the order with no timings: most work waiting (default), longest chain, most direct dependents, or ready order; plugins still lead. [page](https://vznjs.github.io/vx/features/critical-path/) · post: [Pick the order your graph wants](https://vznjs.github.io/vx/blog/schedule-strategy/)
|
|
31
32
|
- **No daemon** — every run starts cold and still answers in milliseconds. [page](https://vznjs.github.io/vx/features/no-daemon/) · post: [No daemon, on purpose](https://vznjs.github.io/vx/blog/no-daemon/)
|
|
32
33
|
- **Group tasks** (`dependsOn` with no `exec`) — a task that only runs its dependencies; `dependsOn: []` is a named no-op. [page](https://vznjs.github.io/vx/features/group-tasks/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/)
|
|
33
34
|
- **Keyed groups** (`cache` with no `exec`) — a key a dependant folds: it re-runs on those inputs, and nothing spawns or counts for the group. [page](https://vznjs.github.io/vx/features/group-tasks/) · post: [A key with nothing to run](https://vznjs.github.io/vx/blog/keyed-groups/)
|
|
@@ -68,10 +69,10 @@ config key or an environment variable is missing here.
|
|
|
68
69
|
- **vx info** (`vx info`) — workspace doctor: versions, projects, cache size against `cacheRetention.maxSize`. [page](https://vznjs.github.io/vx/features/vx-info/) · post: [One command to know your workspace](https://vznjs.github.io/vx/blog/know-your-workspace/)
|
|
69
70
|
- **JSON everywhere** (`--format`) — `show`, `info`, `why`, `last` and `cache` print JSON for agents. [page](https://vznjs.github.io/vx/features/json-everywhere/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
|
|
70
71
|
- **Run analytics in SQLite** — every task's time, CPU, peak memory and cache result, queryable with `sqlite3`. [page](https://vznjs.github.io/vx/features/run-analytics/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
|
|
71
|
-
- **Stable error codes** (`VX_E_…`) — under `--format json` a refusal is a JSON line on stdout with a code an agent branches on. [page](https://vznjs.github.io/vx/features/error-codes/) · post: [A refusal an agent can read](https://vznjs.github.io/vx/blog/error-codes/)
|
|
72
|
+
- **Stable error codes** (`VX_E_…`) — under `--format json` a refusal is a JSON line on stdout with a code an agent branches on and a `docs` link to its fix (`vx docs <code>` offline). [page](https://vznjs.github.io/vx/features/error-codes/) · post: [A refusal an agent can read](https://vznjs.github.io/vx/blog/error-codes/)
|
|
72
73
|
- **Shipped JSON Schemas** (`@vzn/vx/schemas/`) — every `--format json` shape has a schema, and stdout always parses. [page](https://vznjs.github.io/vx/features/json-schemas/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
|
|
73
74
|
- **Docs for agents** (`llms.txt`, `llms-full.txt`) — the whole site as markdown for coding agents. [page](https://vznjs.github.io/vx/features/llms-txt/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
|
|
74
|
-
- **vx docs** (`vx docs`, `--limit`) — search the CLI, config and cache reference offline; it ships inside vx. [page](https://vznjs.github.io/vx/
|
|
75
|
+
- **vx docs** (`vx docs`, `--limit`) — search the CLI, config and cache reference offline; it ships inside vx. [page](https://vznjs.github.io/vx/features/vx-docs/) · post: [The reference, offline](https://vznjs.github.io/vx/blog/vx-docs-offline/)
|
|
75
76
|
- **Affected reasons** (`--affected --dry`) — each kept task says the changed file or the dependency chain that reached it, text or JSON. [page](https://vznjs.github.io/vx/features/affected-reasons/) · post: [See inside a run](https://vznjs.github.io/vx/blog/see-inside-a-run/)
|
|
76
77
|
- **Agent skill** (`skills/vx/SKILL.md`) — an installable skill that teaches a coding agent to run, debug and query vx. [page](https://vznjs.github.io/vx/features/agent-skill/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/)
|
|
77
78
|
|
|
@@ -170,3 +171,4 @@ config key or an environment variable is missing here.
|
|
|
170
171
|
- **Install as a remote action** (`exec.remote: 'only'`) — `node_modules` is built by an action, so stateless workers have it. [page](https://vznjs.github.io/vx/features/remote-install/) · post: [Remote execution without moving the scheduler](https://vznjs.github.io/vx/blog/remote-execution/)
|
|
171
172
|
- **OTel live export** (`otel({ live })`) — spans and metrics stream as tasks end, so a dashboard follows a CI run live. [page](https://vznjs.github.io/vx/features/otel-live/) · post: [Watch a CI run while it runs](https://vznjs.github.io/vx/blog/otel-live/)
|
|
172
173
|
- **Memory-aware admission** (`@vzn/vx-schedule-history`) — tasks are packed by the peak memory learned from past runs. [page](https://vznjs.github.io/vx/features/memory-admission/) · post: [A scheduler that learns from your runs](https://vznjs.github.io/vx/blog/a-scheduler-that-learns/)
|
|
174
|
+
- **CI timings file** (`@vzn/vx-schedule-history`, `file`) — one JSON file of learned task times a CI cache carries between runners, so a fresh runner orders by real times. [page](https://vznjs.github.io/vx/features/critical-path/) · post: [Carry the scheduler's memory between CI runners](https://vznjs.github.io/vx/blog/ci-timings-file/)
|
package/docs/schema.md
CHANGED
|
@@ -1534,6 +1534,7 @@ export default defineWorkspace({
|
|
|
1534
1534
|
affectedBase: 'origin/main',
|
|
1535
1535
|
cacheScope: 'trusted',
|
|
1536
1536
|
rules: { exclusiveOutputs: true, upfrontKeys: true },
|
|
1537
|
+
schedule: 'most-work',
|
|
1537
1538
|
plugins: [otel()],
|
|
1538
1539
|
})
|
|
1539
1540
|
```
|
|
@@ -1554,6 +1555,8 @@ interface WorkspaceConfig {
|
|
|
1554
1555
|
cacheScope?: string
|
|
1555
1556
|
/** Graph rules checked before anything runs; each on unless set to false. */
|
|
1556
1557
|
rules?: WorkspaceRules
|
|
1558
|
+
/** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
|
|
1559
|
+
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
1557
1560
|
/** Run-level plugins (cache / executor / telemetry capabilities). */
|
|
1558
1561
|
plugins?: readonly Plugin[]
|
|
1559
1562
|
}
|
|
@@ -1631,6 +1634,17 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
|
|
|
1631
1634
|
boundary: a run holding a write credential can write any key, so only
|
|
1632
1635
|
a cache server that scopes writes by token can refuse one
|
|
1633
1636
|
(`docs/security.md` § Cache poisoning). Not folded into any cache key.
|
|
1637
|
+
- **`schedule`** — which ready task starts first when more are ready
|
|
1638
|
+
than workers, with no timings. `most-work` (the default): the one the
|
|
1639
|
+
most tasks wait on, at any depth. `critical-path`: the head of the
|
|
1640
|
+
longest chain of tasks below it. `direct-dependents`: the one the most
|
|
1641
|
+
tasks wait on directly (Nx's rule). `ready-order`: the one that became
|
|
1642
|
+
ready first (Turbo's rule). Each loses some graph shape the others win
|
|
1643
|
+
(`blog/scheduling-strategies`). A `schedule` plugin's weights
|
|
1644
|
+
(`@vzn/vx-schedule-history`) still sort first, and the strategy breaks
|
|
1645
|
+
their ties. The default costs what it did; another strategy is
|
|
1646
|
+
computed only when named. Any other value is refused. Never folded
|
|
1647
|
+
into a cache key.
|
|
1634
1648
|
- **`rules`** — checks on the task graph, each on unless set to
|
|
1635
1649
|
`false`. Turning one off allows a shape vx runs correctly but more
|
|
1636
1650
|
slowly; never folded into a cache key. A value that is not a boolean,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.634",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
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.634",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.634",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.634",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.634"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
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/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/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,
|
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
|
'',
|
package/src/cli/init.ts
CHANGED
|
@@ -421,12 +421,17 @@ const INSTALL: Record<PackageManager, string> = {
|
|
|
421
421
|
|
|
422
422
|
const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
|
|
423
423
|
|
|
424
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Install what the workspace file imports, if missing, then run the repo's
|
|
426
|
+
* build. Pinned to this vx's version: every @vzn package releases in
|
|
427
|
+
* lockstep, and `latest` beside an older vx mismatched them (agent trial 2).
|
|
428
|
+
*/
|
|
425
429
|
export function adoptionNext(
|
|
426
430
|
root: string,
|
|
427
431
|
runner: 'turbo' | 'nx',
|
|
428
432
|
source: string,
|
|
429
433
|
needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
|
|
434
|
+
version: string = VERSION,
|
|
430
435
|
): string {
|
|
431
436
|
const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
|
|
432
437
|
const missing = needs.filter(
|
|
@@ -443,7 +448,9 @@ export function adoptionNext(
|
|
|
443
448
|
pm === 'yarn' && !readText(path.join(root, 'yarn.lock')).includes('__metadata:')
|
|
444
449
|
? `${INSTALL.yarn} -W`
|
|
445
450
|
: INSTALL[pm]
|
|
446
|
-
|
|
451
|
+
// A source checkout is 0.0.0, which npm never had.
|
|
452
|
+
const pinned = version === '0.0.0' ? missing : missing.map((p) => `${p}@${version}`)
|
|
453
|
+
return `${install} ${pinned.join(' ')} && ${run}`
|
|
447
454
|
}
|
|
448
455
|
|
|
449
456
|
function readText(file: string): string {
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -145,10 +145,12 @@ export interface PlanTaskJson {
|
|
|
145
145
|
affected?: AffectedReason
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
/** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
|
|
149
|
+
export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
|
|
149
150
|
return (
|
|
150
151
|
JSON.stringify(
|
|
151
152
|
{
|
|
153
|
+
...(affectedBase !== undefined ? { affectedBase } : {}),
|
|
152
154
|
tasks: plan.tasks.map((t): PlanTaskJson => {
|
|
153
155
|
const description = shownDescription(t)
|
|
154
156
|
return {
|
package/src/cli/run.ts
CHANGED
|
@@ -521,6 +521,8 @@ export async function resolveRunOptions(
|
|
|
521
521
|
cwd: string,
|
|
522
522
|
tasks: readonly string[],
|
|
523
523
|
verb: 'run' | 'watch' = 'run',
|
|
524
|
+
/** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
|
|
525
|
+
scopeOut?: { affectedBase?: string },
|
|
524
526
|
): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
|
|
525
527
|
for (const t of tasks) {
|
|
526
528
|
const idx = t.indexOf('#')
|
|
@@ -536,6 +538,9 @@ export async function resolveRunOptions(
|
|
|
536
538
|
const scope = await scopeFilters(parsed, cwd)
|
|
537
539
|
if ('error' in scope) return scope
|
|
538
540
|
const { filterStrings, affectedFilter } = scope
|
|
541
|
+
// `...[<base>]`: the base alone.
|
|
542
|
+
if (scopeOut !== undefined && affectedFilter !== undefined)
|
|
543
|
+
scopeOut.affectedBase = affectedFilter.slice(4, -1)
|
|
539
544
|
|
|
540
545
|
// Project scope applies to bare task names only. Anchored entries
|
|
541
546
|
// (pkg#task) resolve directly to their own project regardless.
|
|
@@ -709,7 +714,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
709
714
|
tasks = [picked]
|
|
710
715
|
}
|
|
711
716
|
|
|
712
|
-
const
|
|
717
|
+
const scopeOut: { affectedBase?: string } = {}
|
|
718
|
+
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
713
719
|
if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
|
|
714
720
|
if ('nothingSelected' in resolved) {
|
|
715
721
|
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
@@ -769,7 +775,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
769
775
|
}
|
|
770
776
|
}
|
|
771
777
|
} else if (parsed.dry === 'json') {
|
|
772
|
-
process.stdout.write(formatPlanJson(plan))
|
|
778
|
+
process.stdout.write(formatPlanJson(plan, scopeOut.affectedBase))
|
|
773
779
|
} else {
|
|
774
780
|
process.stdout.write(formatPlanText(plan))
|
|
775
781
|
}
|
package/src/config.ts
CHANGED
|
@@ -46,6 +46,15 @@ export interface WorkspaceConfig {
|
|
|
46
46
|
* folded into a cache key.
|
|
47
47
|
*/
|
|
48
48
|
cacheScope?: string
|
|
49
|
+
/**
|
|
50
|
+
* Which ready task starts first when there are more than workers, with no
|
|
51
|
+
* timings: `most-work` (the default) the one the most work waits on at
|
|
52
|
+
* any depth, `critical-path` the head of the longest chain of tasks,
|
|
53
|
+
* `direct-dependents` the one the most tasks wait on directly (Nx's
|
|
54
|
+
* rule), `ready-order` the one that became ready first (Turbo's). A
|
|
55
|
+
* `schedule` plugin's weights still come first; this breaks their ties.
|
|
56
|
+
*/
|
|
57
|
+
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
49
58
|
/**
|
|
50
59
|
* Graph rules a run checks before it schedules anything. Each is on unless
|
|
51
60
|
* set to `false`; turning one off allows a shape vx handles correctly but
|
package/src/graph/priorities.ts
CHANGED
|
@@ -125,20 +125,26 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// Reverse-topo sweep: every direct dependent's closure is final
|
|
128
|
-
// before its dependency folds it in. closure[
|
|
129
|
-
//
|
|
128
|
+
// before its dependency folds it in. closure[t] = bitset over topo
|
|
129
|
+
// POSITIONS of the node at position t's transitive dependents. A
|
|
130
|
+
// dependent sits after its dependency, so a closure holds only bits
|
|
131
|
+
// past its own position, and each OR starts at the dependent's word:
|
|
132
|
+
// half the words of indexing by insertion order on a layered graph.
|
|
133
|
+
const pos = new Int32Array(n)
|
|
134
|
+
for (let t = 0; t < tail; t++) pos[topo[t]!] = t
|
|
130
135
|
const closure = new Uint32Array(n * words)
|
|
131
136
|
const counts = new Map<string, number>()
|
|
132
137
|
for (let t = tail - 1; t >= 0; t--) {
|
|
133
138
|
const i = topo[t]!
|
|
134
|
-
const base =
|
|
135
|
-
for (const
|
|
139
|
+
const base = t * words
|
|
140
|
+
for (const ri of directReverse[i]!) {
|
|
141
|
+
const r = pos[ri]!
|
|
136
142
|
closure[base + (r >>> 5)]! |= 1 << (r & 31)
|
|
137
143
|
const rbase = r * words
|
|
138
|
-
for (let w =
|
|
144
|
+
for (let w = r >>> 5; w < words; w++) closure[base + w]! |= closure[rbase + w]!
|
|
139
145
|
}
|
|
140
146
|
let count = 0
|
|
141
|
-
for (let w =
|
|
147
|
+
for (let w = t >>> 5; w < words; w++) {
|
|
142
148
|
let v = closure[base + w]!
|
|
143
149
|
v = v - ((v >>> 1) & 0x55555555)
|
|
144
150
|
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
|
|
@@ -153,6 +159,66 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
|
|
|
153
159
|
return counts
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The ready-queue baseline a workspace may pick in place of the default
|
|
164
|
+
* (`schedule` in vx.workspace.ts). Each ranks with no timings; a `schedule`
|
|
165
|
+
* plugin's weights still sort above it, and it breaks their ties.
|
|
166
|
+
* - `most-work`: transitive dependents (`computeReverseDepCount`), the default.
|
|
167
|
+
* - `critical-path`: steps on the longest chain of dependents below a task.
|
|
168
|
+
* - `direct-dependents`: direct dependents only, Nx's rule.
|
|
169
|
+
* - `ready-order`: none; the task that became ready first starts first, Turbo's rule.
|
|
170
|
+
*/
|
|
171
|
+
export type ScheduleStrategy = 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
172
|
+
|
|
173
|
+
/** The baseline for a strategy other than `most-work`, over the whole graph. */
|
|
174
|
+
export function strategyPriorities(
|
|
175
|
+
nodes: Map<string, TaskNode>,
|
|
176
|
+
strategy: Exclude<ScheduleStrategy, 'most-work'>,
|
|
177
|
+
): Map<string, number> {
|
|
178
|
+
const out = new Map<string, number>()
|
|
179
|
+
if (strategy === 'ready-order') {
|
|
180
|
+
for (const id of nodes.keys()) out.set(id, 0)
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
const dependents = new Map<string, string[]>()
|
|
184
|
+
for (const node of nodes.values()) {
|
|
185
|
+
for (const dep of node.deps) {
|
|
186
|
+
if (!nodes.has(dep)) continue
|
|
187
|
+
const list = dependents.get(dep)
|
|
188
|
+
if (list === undefined) dependents.set(dep, [node.id])
|
|
189
|
+
else list.push(node.id)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (strategy === 'direct-dependents') {
|
|
193
|
+
for (const id of nodes.keys()) out.set(id, dependents.get(id)?.length ?? 0)
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
// Longest chain below each task, dependents first: a task's depth is one
|
|
197
|
+
// more than its deepest dependent's. Iterative, as a deep chain would
|
|
198
|
+
// overflow a recursive walk.
|
|
199
|
+
const left = new Map<string, number>()
|
|
200
|
+
const ready: string[] = []
|
|
201
|
+
for (const id of nodes.keys()) {
|
|
202
|
+
const n = dependents.get(id)?.length ?? 0
|
|
203
|
+
left.set(id, n)
|
|
204
|
+
if (n === 0) ready.push(id)
|
|
205
|
+
}
|
|
206
|
+
while (ready.length > 0) {
|
|
207
|
+
const id = ready.pop()!
|
|
208
|
+
let depth = 0
|
|
209
|
+
for (const d of dependents.get(id) ?? []) depth = Math.max(depth, out.get(d)! + 1)
|
|
210
|
+
out.set(id, depth)
|
|
211
|
+
for (const dep of nodes.get(id)!.deps) {
|
|
212
|
+
if (!nodes.has(dep)) continue
|
|
213
|
+
const n = left.get(dep)! - 1
|
|
214
|
+
left.set(dep, n)
|
|
215
|
+
if (n === 0) ready.push(dep)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const id of nodes.keys()) if (!out.has(id)) out.set(id, 0)
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
|
|
156
222
|
export function mergePriorities(
|
|
157
223
|
baseline: ReadonlyMap<string, number>,
|
|
158
224
|
overrides: ReadonlyMap<string, number>,
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
isUserError,
|
|
6
6
|
OUT_OF_FDS_HINT,
|
|
7
7
|
} from '../util/index.js'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
computeReverseDepCount,
|
|
10
|
+
mergePriorities,
|
|
11
|
+
strategyPriorities,
|
|
12
|
+
tieredReverseDepCount,
|
|
13
|
+
type ScheduleStrategy,
|
|
14
|
+
} from './priorities.js'
|
|
9
15
|
import { isGroupTask, type TaskNode } from './task-graph.js'
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -278,6 +284,11 @@ export interface ScheduleOptions {
|
|
|
278
284
|
* heuristic (the default and historically the only behavior).
|
|
279
285
|
*/
|
|
280
286
|
priorities?: ReadonlyMap<string, number>
|
|
287
|
+
/**
|
|
288
|
+
* The baseline ranking under `priorities` (`schedule` in vx.workspace.ts).
|
|
289
|
+
* Undefined or `most-work` is the reverse-deps count above.
|
|
290
|
+
*/
|
|
291
|
+
strategy?: ScheduleStrategy
|
|
281
292
|
/**
|
|
282
293
|
* Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
|
|
283
294
|
* local short-circuit). A restore-tier task:
|
|
@@ -494,9 +505,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
494
505
|
// use those weights. Falls back to the reverse-deps-count heuristic
|
|
495
506
|
// for nodes the caller didn't score, so partial coverage works.
|
|
496
507
|
const baseline =
|
|
497
|
-
options.
|
|
498
|
-
?
|
|
499
|
-
:
|
|
508
|
+
options.strategy !== undefined && options.strategy !== 'most-work'
|
|
509
|
+
? strategyPriorities(nodes, options.strategy)
|
|
510
|
+
: options.restoreTier !== undefined && options.restoreTier.size > 0
|
|
511
|
+
? tieredReverseDepCount(nodes, options.restoreTier)
|
|
512
|
+
: computeReverseDepCount(nodes)
|
|
500
513
|
const priority: ReadonlyMap<string, number> = options.priorities
|
|
501
514
|
? mergePriorities(baseline, options.priorities)
|
|
502
515
|
: baseline
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -1517,6 +1517,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1517
1517
|
const filesWriters: TaskNode[] = []
|
|
1518
1518
|
let projectReaders = false
|
|
1519
1519
|
let rootReaders = false
|
|
1520
|
+
const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
|
|
1520
1521
|
const bucket = (name: string): Side[] => {
|
|
1521
1522
|
let list = byProject.get(name)
|
|
1522
1523
|
if (list === undefined) byProject.set(name, (list = []))
|
|
@@ -1557,7 +1558,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1557
1558
|
(s) => s.globs,
|
|
1558
1559
|
(s) => s.reads,
|
|
1559
1560
|
)) {
|
|
1560
|
-
readsOutputs(sides[i]!, sides[j]!, 'files')
|
|
1561
|
+
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1561
1562
|
}
|
|
1562
1563
|
}
|
|
1563
1564
|
}
|
|
@@ -1575,7 +1576,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1575
1576
|
(s) => s.globs,
|
|
1576
1577
|
(s) => s.reads,
|
|
1577
1578
|
)) {
|
|
1578
|
-
readsOutputs(sides[i]!, sides[j]!, 'files')
|
|
1579
|
+
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1579
1580
|
}
|
|
1580
1581
|
}
|
|
1581
1582
|
if (!rootReaders) return
|
|
@@ -1593,26 +1594,63 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1593
1594
|
(s) => s.globs,
|
|
1594
1595
|
(s) => s.reads,
|
|
1595
1596
|
)) {
|
|
1596
|
-
readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1597
|
+
readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1597
1598
|
}
|
|
1598
1599
|
}
|
|
1599
1600
|
|
|
1600
|
-
|
|
1601
|
+
/**
|
|
1602
|
+
* Whether a reader's take-back list covers an output glob, asked once per
|
|
1603
|
+
* list and glob: a Turbo-mapped repo gives two thousand readers one
|
|
1604
|
+
* 400-entry list, and asking it per reader and writer pair was 2.7 s of an
|
|
1605
|
+
* astro plan (2026-10-09). One per `detectInputOverlaps` call.
|
|
1606
|
+
*/
|
|
1607
|
+
interface TakeBack {
|
|
1608
|
+
list: string[]
|
|
1609
|
+
verdicts: Map<string, boolean>
|
|
1610
|
+
}
|
|
1611
|
+
type TakeBackMemo = { bySide: Map<Side, TakeBack>; byList: Map<string, TakeBack> }
|
|
1612
|
+
|
|
1613
|
+
function takenBack(
|
|
1614
|
+
memo: TakeBackMemo,
|
|
1615
|
+
reader: Side,
|
|
1616
|
+
field: 'files' | 'workspaceFiles',
|
|
1617
|
+
go: string,
|
|
1618
|
+
): boolean {
|
|
1619
|
+
let tb = memo.bySide.get(reader)
|
|
1620
|
+
if (tb === undefined) {
|
|
1621
|
+
const cache = reader.node.config.cache!
|
|
1622
|
+
// A reader rebased to the root (X-135) speaks in its project's terms.
|
|
1623
|
+
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1624
|
+
// What the reader's key never reads: its `!` entries and its own outputs.
|
|
1625
|
+
const list = [
|
|
1626
|
+
...splitNegations(
|
|
1627
|
+
field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []),
|
|
1628
|
+
).negative,
|
|
1629
|
+
...splitNegations(
|
|
1630
|
+
field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
|
|
1631
|
+
).positive,
|
|
1632
|
+
].map((g) => at + g)
|
|
1633
|
+
const key = list.join('\0')
|
|
1634
|
+
tb = memo.byList.get(key)
|
|
1635
|
+
if (tb === undefined) memo.byList.set(key, (tb = { list, verdicts: new Map() }))
|
|
1636
|
+
memo.bySide.set(reader, tb)
|
|
1637
|
+
}
|
|
1638
|
+
let verdict = tb.verdicts.get(go)
|
|
1639
|
+
if (verdict === undefined) tb.verdicts.set(go, (verdict = outputTakenBack(go, tb.list)))
|
|
1640
|
+
return verdict
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function readsOutputs(
|
|
1644
|
+
memo: TakeBackMemo,
|
|
1645
|
+
x: Side,
|
|
1646
|
+
y: Side,
|
|
1647
|
+
field: 'files' | 'workspaceFiles',
|
|
1648
|
+
): void {
|
|
1601
1649
|
if (x.reads === y.reads || x.node === y.node) return
|
|
1602
1650
|
const [reader, writer] = x.reads ? [x, y] : [y, x]
|
|
1603
|
-
const cache = reader.node.config.cache!
|
|
1604
|
-
// A reader rebased to the root (X-135) speaks in its project's terms.
|
|
1605
1651
|
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1606
|
-
// What the reader's key never reads: its `!` entries and its own outputs.
|
|
1607
|
-
const takeBack = [
|
|
1608
|
-
...splitNegations(field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []))
|
|
1609
|
-
.negative,
|
|
1610
|
-
...splitNegations(
|
|
1611
|
-
field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
|
|
1612
|
-
).positive,
|
|
1613
|
-
].map((g) => at + g)
|
|
1614
1652
|
for (const go of writer.globs) {
|
|
1615
|
-
if (
|
|
1653
|
+
if (takenBack(memo, reader, field, go)) continue
|
|
1616
1654
|
for (const gi of reader.globs) {
|
|
1617
1655
|
if (!outputsOverlap(gi, go)) continue
|
|
1618
1656
|
const shown =
|
|
@@ -113,11 +113,17 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
|
|
|
113
113
|
*/
|
|
114
114
|
export function getContext(node: TaskNode, command: string) {
|
|
115
115
|
const outputs = node.config.cache?.outputs
|
|
116
|
+
let masked: string | undefined
|
|
116
117
|
return {
|
|
117
118
|
taskId: node.id,
|
|
118
119
|
// Stored in the entry row a remote hit inserts: masked as a save's is,
|
|
119
|
-
// or the token a config inlined sat in cache.db (L-27).
|
|
120
|
-
|
|
120
|
+
// or the token a config inlined sat in cache.db (L-27). Masked on the
|
|
121
|
+
// first read: only an adopt or a remote hit reads it, and masking every
|
|
122
|
+
// lookup's command cost a cold 1,090-package run ~20 ms before its
|
|
123
|
+
// first task.
|
|
124
|
+
get command() {
|
|
125
|
+
return (masked ??= maskedCommand(command, node.config.exec?.env))
|
|
126
|
+
},
|
|
121
127
|
outputs: { files: outputs?.files ?? [], workspaceFiles: outputs?.workspaceFiles ?? [] },
|
|
122
128
|
}
|
|
123
129
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -945,7 +945,15 @@ async function runOnBus(
|
|
|
945
945
|
// shouldShortCircuit (local reads on, no remote layer); when off, both
|
|
946
946
|
// maps are empty and the run is byte-identical.
|
|
947
947
|
let shortCircuit: ShortCircuit = emptyShortCircuit()
|
|
948
|
-
|
|
948
|
+
// An empty local cache answers every probe with a miss: each task
|
|
949
|
+
// derives its key as it starts instead (~100 ms before a cold 1,090-
|
|
950
|
+
// package run's first spawn), and every sandboxed task runs.
|
|
951
|
+
const coldLocal =
|
|
952
|
+
cache === prepared.localCache &&
|
|
953
|
+
shouldShortCircuit(nodes, policy, cache) &&
|
|
954
|
+
prepared.localCache.holdsNothing()
|
|
955
|
+
if (coldLocal) prewarm()
|
|
956
|
+
else if (shouldShortCircuit(nodes, policy, cache)) {
|
|
949
957
|
shortCircuit = await startLocalShortCircuit({
|
|
950
958
|
nodes,
|
|
951
959
|
cache,
|
|
@@ -1165,6 +1173,10 @@ async function runOnBus(
|
|
|
1165
1173
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1166
1174
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1167
1175
|
...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
|
|
1176
|
+
// The baseline under them; `most-work` when the workspace names none.
|
|
1177
|
+
...(prepared.workspaceConfig?.schedule !== undefined
|
|
1178
|
+
? { strategy: prepared.workspaceConfig.schedule }
|
|
1179
|
+
: {}),
|
|
1168
1180
|
// Local short-circuit: confirmed stable local hits the scheduler
|
|
1169
1181
|
// runs ahead of their deps as low-priority worker-slot backfill.
|
|
1170
1182
|
// Empty when the short-circuit didn't fire → byte-identical.
|
package/src/util/errors.ts
CHANGED
|
@@ -62,9 +62,16 @@ export function wantsJson(argv: readonly string[]): boolean {
|
|
|
62
62
|
return false
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* The JSON a verb that answers JSON prints for a refusal: one line. A
|
|
67
|
+
* core code (`VX_E_…`) links its section of cli.md, which says the fix;
|
|
68
|
+
* `vx docs <code>` prints the same section offline. A plugin's code has none.
|
|
69
|
+
*/
|
|
66
70
|
export function errorDocument(code: string, message: string): string {
|
|
67
|
-
|
|
71
|
+
const docs = code.startsWith('VX_E_')
|
|
72
|
+
? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
|
|
73
|
+
: {}
|
|
74
|
+
return `${JSON.stringify({ ok: false, error: { code, message, ...docs } })}\n`
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
/**
|
package/src/util/paths.ts
CHANGED
|
@@ -195,6 +195,30 @@ export const EXTGLOB = /[!@+*?]\(/
|
|
|
195
195
|
* else: one site that forgot is a route directory that keys nothing.
|
|
196
196
|
*/
|
|
197
197
|
export function taskGlob(pattern: string): Bun.Glob {
|
|
198
|
+
const memo = memoFor(taskGlobs)
|
|
199
|
+
let glob = memo.get(pattern)
|
|
200
|
+
if (glob === undefined) memo.set(pattern, (glob = compileTaskGlob(pattern)))
|
|
201
|
+
return glob
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Compiled once per pattern: a Turbo-mapped config repeats one 400-entry
|
|
206
|
+
* list across two thousand tasks, and the graph's overlap checks compiled
|
|
207
|
+
* each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
|
|
208
|
+
* comes from a config, so the set is bounded by what the configs say. Kept
|
|
209
|
+
* per `Bun.Glob` in force: the playground and a test swap it, and a glob
|
|
210
|
+
* built by one is not the other's.
|
|
211
|
+
*/
|
|
212
|
+
const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
|
|
213
|
+
const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
|
|
214
|
+
|
|
215
|
+
function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
|
|
216
|
+
let memo = memos.get(Bun.Glob)
|
|
217
|
+
if (memo === undefined) memos.set(Bun.Glob, (memo = new Map()))
|
|
218
|
+
return memo
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function compileTaskGlob(pattern: string): Bun.Glob {
|
|
198
222
|
// A `]` with no `[` before it is already literal to `Bun.Glob`.
|
|
199
223
|
const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
|
|
200
224
|
const glob = new Bun.Glob(source)
|
|
@@ -228,6 +252,14 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
228
252
|
* `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
|
|
229
253
|
*/
|
|
230
254
|
export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
|
|
255
|
+
const memo = memoFor(anyTaskGlobs)
|
|
256
|
+
const key = rawPatterns.join('\0')
|
|
257
|
+
let any = memo.get(key)
|
|
258
|
+
if (any === undefined) memo.set(key, (any = compileAnyTaskGlob(rawPatterns)))
|
|
259
|
+
return any
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function compileAnyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
|
|
231
263
|
const patterns = rawPatterns.flatMap(braceAlternatives)
|
|
232
264
|
if (patterns.length === 0) return () => false
|
|
233
265
|
const sources = patterns.map(regExpSource)
|
|
@@ -56,9 +56,18 @@ const WORKSPACE_FIELDS = new Set([
|
|
|
56
56
|
'affectedBase',
|
|
57
57
|
'cacheScope',
|
|
58
58
|
'rules',
|
|
59
|
+
'schedule',
|
|
59
60
|
'plugins',
|
|
60
61
|
])
|
|
61
62
|
|
|
63
|
+
type Schedule = NonNullable<WorkspaceConfig['schedule']>
|
|
64
|
+
const SCHEDULES = {
|
|
65
|
+
'most-work': true,
|
|
66
|
+
'critical-path': true,
|
|
67
|
+
'direct-dependents': true,
|
|
68
|
+
'ready-order': true,
|
|
69
|
+
} satisfies Record<Schedule, true>
|
|
70
|
+
|
|
62
71
|
const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
|
|
63
72
|
|
|
64
73
|
function validateRules(rules: unknown, configPath: string): void {
|
|
@@ -184,6 +193,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
184
193
|
throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
|
|
185
194
|
}
|
|
186
195
|
if (config.rules !== undefined) validateRules(config.rules, configPath)
|
|
196
|
+
if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
|
|
197
|
+
throw new UserError(
|
|
198
|
+
`${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
|
|
199
|
+
.map((k) => `'${k}'`)
|
|
200
|
+
.join(', ')}`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
187
203
|
if (config.plugins !== undefined) {
|
|
188
204
|
if (!Array.isArray(config.plugins)) {
|
|
189
205
|
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|
|
@@ -36,6 +36,8 @@ export interface LockfileEntry {
|
|
|
36
36
|
export interface Lockfile {
|
|
37
37
|
version: number
|
|
38
38
|
projects: Record<string, LockfileEntry>
|
|
39
|
+
/** Written by `writeLockfile` from the bytes; never read off the object. */
|
|
40
|
+
validated?: string
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function lockfilePath(root: string): string {
|
|
@@ -100,12 +102,33 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
lockDigests.set(parsed, xxh3(text).toString(16))
|
|
105
|
+
const stamped = VALIDATED_LINE.exec(text)
|
|
106
|
+
if (stamped !== null && stamped[1] === validatedDigest(text.slice(0, stamped.index) + '\n}')) {
|
|
107
|
+
selfValidated.add(parsed)
|
|
108
|
+
}
|
|
103
109
|
return parsed as Lockfile
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
/** The digest of the bytes each lock `readLockfile` returned was parsed from. */
|
|
107
113
|
const lockDigests = new WeakMap<object, string>()
|
|
108
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The lock's own record that this vx validated its entries: `vx lock`
|
|
117
|
+
* evaluates and validates every config, then writes a digest of the bytes
|
|
118
|
+
* it wrote, under vx's version (validation's rules are vx's alone, so Bun's
|
|
119
|
+
* is left out and does not churn a committed lock), as the last field. A lock
|
|
120
|
+
* whose digest matches is validated already, and a cold `--frozen` run
|
|
121
|
+
* skips the ~30 ms of validating 1,090 entries before its first task. An
|
|
122
|
+
* edit, a merge, or another vx version leaves it unmatched, and the
|
|
123
|
+
* entries are validated as before.
|
|
124
|
+
*/
|
|
125
|
+
const VALIDATED_LINE = /,\n {2}"validated": "([0-9a-f]+)"\n\}\n?$/
|
|
126
|
+
const selfValidated = new WeakSet<object>()
|
|
127
|
+
|
|
128
|
+
function validatedDigest(body: string): string {
|
|
129
|
+
return xxh3(`vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${body}`).toString(16)
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
|
|
110
133
|
// Written beside its name and renamed over it: `Bun.write` truncates in
|
|
111
134
|
// place, so a `--frozen` run, or `vx watch --frozen` woken by the write
|
|
@@ -114,7 +137,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
|
|
|
114
137
|
const file = lockfilePath(root)
|
|
115
138
|
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
116
139
|
try {
|
|
117
|
-
|
|
140
|
+
const body = JSON.stringify({ version: lock.version, projects: lock.projects }, null, 2)
|
|
141
|
+
await Bun.write(tmp, `${body.slice(0, -2)},\n "validated": "${validatedDigest(body)}"\n}\n`)
|
|
118
142
|
await rename(tmp, file)
|
|
119
143
|
} catch (err) {
|
|
120
144
|
// Best effort: where the write was refused the temp cannot be named
|
|
@@ -187,7 +211,10 @@ export async function frozenProjectConfigs(
|
|
|
187
211
|
// asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
|
|
188
212
|
// config load, where the eval-cache path it replaces is cheaper (X-181).
|
|
189
213
|
// A scoped run still validates, and remembers, only what it loads.
|
|
190
|
-
if (
|
|
214
|
+
if (
|
|
215
|
+
selfValidated.has(lock) ||
|
|
216
|
+
(prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null)
|
|
217
|
+
) {
|
|
191
218
|
return entries.map((e) => e.config)
|
|
192
219
|
}
|
|
193
220
|
const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
|