@vzn/vx 0.0.636 → 0.0.637
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 +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/exec/executor.ts
CHANGED
|
@@ -73,12 +73,8 @@ export interface TaskInputs {
|
|
|
73
73
|
readonly hash: string
|
|
74
74
|
readonly outputs: readonly string[]
|
|
75
75
|
}>
|
|
76
|
-
/** Digest of the project's own `package.json` (folded even when no glob lists it). */
|
|
77
|
-
readonly packageJsonDigest: string
|
|
78
76
|
/** Digest of the resolved task config. */
|
|
79
77
|
readonly configDigest: string
|
|
80
|
-
/** The workspace fingerprint (root manifests + lockfile). */
|
|
81
|
-
readonly workspaceFingerprint: string
|
|
82
78
|
}
|
|
83
79
|
|
|
84
80
|
export interface ExecuteRequest {
|
|
@@ -146,7 +142,7 @@ export interface ExecuteRequest {
|
|
|
146
142
|
* work and returns: a child an executor spawned is not one core can
|
|
147
143
|
* reach (H-10, H-12). One already aborted is work not to start (B-55).
|
|
148
144
|
* Any exit after the timeout's abort, a 0 included, is a timeout. Core
|
|
149
|
-
* waits the kill grace (`
|
|
145
|
+
* waits the kill grace (`VX_RUN_KILL_GRACE`, 2 s) after the abort, then
|
|
150
146
|
* settles the attempt without `execute` and abandons it (H-14).
|
|
151
147
|
*/
|
|
152
148
|
readonly signal?: AbortSignal
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// takes even that.
|
|
12
12
|
|
|
13
13
|
import { closeSync, readdirSync, readFileSync, writeSync } from 'node:fs'
|
|
14
|
-
import { procfsIsOwn, taskShell } from '../util/index.js'
|
|
14
|
+
import { helperEnv, procfsIsOwn, taskShell } from '../util/index.js'
|
|
15
15
|
|
|
16
16
|
export type Child = ReturnType<typeof Bun.spawn>
|
|
17
17
|
|
|
@@ -104,6 +104,7 @@ function startGuard(): void {
|
|
|
104
104
|
try {
|
|
105
105
|
const guard = Bun.spawn([taskShell(), '-c', GUARD_SCRIPT], {
|
|
106
106
|
argv0: 'vx-group-guard',
|
|
107
|
+
env: helperEnv(),
|
|
107
108
|
stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
|
|
108
109
|
// Out of vx's group, so a Ctrl-C at the terminal leaves it to the
|
|
109
110
|
// pipe; vx's own teardown owns that path.
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
import {
|
|
63
63
|
BUN_GLOB_WILDCARDS,
|
|
64
64
|
executablePath,
|
|
65
|
+
helperEnv,
|
|
65
66
|
taskShell,
|
|
66
67
|
grantPrefix,
|
|
67
68
|
isTmpdirRefusal,
|
|
@@ -1806,10 +1807,11 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1806
1807
|
? Bun.spawn(argv, {
|
|
1807
1808
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1808
1809
|
detached: true,
|
|
1810
|
+
env: helperEnv(),
|
|
1809
1811
|
})
|
|
1810
1812
|
: Bun.spawn(
|
|
1811
1813
|
[taskShell(), '-c', `${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`],
|
|
1812
|
-
{ stdio: ['ignore', 'ignore', 'ignore', guard], detached: true },
|
|
1814
|
+
{ stdio: ['ignore', 'ignore', 'ignore', guard], detached: true, env: helperEnv() },
|
|
1813
1815
|
),
|
|
1814
1816
|
),
|
|
1815
1817
|
)
|
|
@@ -2691,6 +2693,15 @@ function expandGrants(
|
|
|
2691
2693
|
// names a wall literally never reaches this loop and stays, and one
|
|
2692
2694
|
// spelled with escaped brackets names it as surely.
|
|
2693
2695
|
if (namedPath(p) === undefined && walls.some((w) => atOrUnder(real, w))) continue
|
|
2696
|
+
// A write hit that HOLDS a wall would bind it writable: `.*` in a root
|
|
2697
|
+
// project matched `.vx`, which holds the cache. A read hit is punched
|
|
2698
|
+
// around it instead (`punchWalls`).
|
|
2699
|
+
if (
|
|
2700
|
+
unmatched !== undefined &&
|
|
2701
|
+
namedPath(p) === undefined &&
|
|
2702
|
+
walls.some((w) => atOrUnder(w, real))
|
|
2703
|
+
)
|
|
2704
|
+
continue
|
|
2694
2705
|
out.push(abs)
|
|
2695
2706
|
hits++
|
|
2696
2707
|
}
|
|
@@ -744,6 +744,9 @@ export function refusedConnections(records: readonly string[]): SandboxViolation
|
|
|
744
744
|
}))
|
|
745
745
|
}
|
|
746
746
|
|
|
747
|
+
/** Write syscalls that act on a path's last component, not what a link there names. */
|
|
748
|
+
const NAME_SYSCALL = /^(?:symlink|unlink|rename|rmdir|mknod|link)/
|
|
749
|
+
|
|
747
750
|
/**
|
|
748
751
|
* The writes SRT's Linux observer saw that this task's binds do not cover.
|
|
749
752
|
* The observer reports every write-intent syscall as `deny <syscall>
|
|
@@ -774,7 +777,12 @@ export function refusedWrites(
|
|
|
774
777
|
// `5>out.log` in the project reported a write to `out.log` and failed
|
|
775
778
|
// a clean task (2026-10-02).
|
|
776
779
|
if (/^\/(?:dev|proc)\//.test(raw)) continue
|
|
777
|
-
|
|
780
|
+
// These act on the name itself, never through it: resolving the link
|
|
781
|
+
// `ln -s ../src/a.ts dist/a` just made judged the write by `src/a.ts`,
|
|
782
|
+
// and a clean task failed with a fix that granted `write: ['src/']`.
|
|
783
|
+
const abs = NAME_SYSCALL.test(syscall)
|
|
784
|
+
? path.join(toRealPath(path.dirname(raw)), path.basename(raw))
|
|
785
|
+
: toRealPath(raw)
|
|
778
786
|
if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
|
|
779
787
|
// A directory a write bind lies in exists, since bwrap made it to mount
|
|
780
788
|
// the bind: `mkdir -p node_modules/.cache/tool` under a grant of
|
package/src/graph/index.ts
CHANGED
package/src/graph/priorities.ts
CHANGED
|
@@ -161,7 +161,7 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
|
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
163
|
* The ready-queue baseline a workspace may pick in place of the default
|
|
164
|
-
* (`schedule` in vx
|
|
164
|
+
* (`schedule` in .vx/workspace.ts). Each ranks with no timings; a `schedule`
|
|
165
165
|
* plugin's weights still sort above it, and it breaks their ties.
|
|
166
166
|
* - `most-work`: transitive dependents (`computeReverseDepCount`), the default.
|
|
167
167
|
* - `critical-path`: steps on the longest chain of dependents below a task.
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -121,14 +121,14 @@ export interface TaskOutcome {
|
|
|
121
121
|
groupUpstream?: readonly TaskOutcome[]
|
|
122
122
|
/**
|
|
123
123
|
* This task RAN over inputs its key no longer describes: one moved while
|
|
124
|
-
* it ran,
|
|
124
|
+
* it ran, a claimed root file moved, or an upstream carried this
|
|
125
125
|
* mark. Its save was withheld, and its outputs are not what `hash` names,
|
|
126
126
|
* so a dependant that folds `hash` saves nothing either (A-12).
|
|
127
127
|
*/
|
|
128
128
|
unkeyed?: true
|
|
129
129
|
/**
|
|
130
130
|
* The run's cache policy read and wrote nothing (`--no-cache`,
|
|
131
|
-
* `--cache=local:`): no cache answered for this task, so a run of it is
|
|
131
|
+
* `--cache-policy=local:`): no cache answered for this task, so a run of it is
|
|
132
132
|
* `no-cache`, not a miss. Set by `run()`; `ranNoCache` reads it.
|
|
133
133
|
*/
|
|
134
134
|
cacheOff?: true
|
|
@@ -158,11 +158,17 @@ export interface TaskOutcome {
|
|
|
158
158
|
* the spawn itself failed. Every label reads this instead of the exit.
|
|
159
159
|
*/
|
|
160
160
|
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
161
|
+
/**
|
|
162
|
+
* The input that changed while the command ran, failing the task under
|
|
163
|
+
* `inputStability.severity: 'error'` (workspace-relative; '' when vx
|
|
164
|
+
* could not name it). The exit is vx's 1; the label reads this instead.
|
|
165
|
+
*/
|
|
166
|
+
inputMoved?: string
|
|
161
167
|
/** Executor-reported placement label (`ExecuteResult.where`) — set only
|
|
162
168
|
* when the task ran somewhere other than this host. Telemetry-only. */
|
|
163
169
|
where?: string
|
|
164
170
|
/** `'deferred'` when the task's outputs were left in the remote store
|
|
165
|
-
* (`--download=none`) instead of landing on this machine. Absent for
|
|
171
|
+
* (`--cache-download=none`) instead of landing on this machine. Absent for
|
|
166
172
|
* every ordinary outcome. Telemetry-only. */
|
|
167
173
|
outputs?: 'deferred'
|
|
168
174
|
/**
|
|
@@ -183,7 +189,7 @@ export interface TaskOutcome {
|
|
|
183
189
|
restored?: boolean
|
|
184
190
|
/**
|
|
185
191
|
* How many times the task executed this run, when `exec.retries` /
|
|
186
|
-
* `--
|
|
192
|
+
* `--retries` re-ran failed attempts. Set only when > 1 — a plain
|
|
187
193
|
* single-attempt run carries nothing. Not persisted; telemetry-side
|
|
188
194
|
* flaky detection reads it off the outcome stream.
|
|
189
195
|
*/
|
|
@@ -216,6 +222,12 @@ export interface TaskOutcome {
|
|
|
216
222
|
systemInfo?: string[]
|
|
217
223
|
/** Credential paths denied and left out of the grants: an agent applies the fix as given. */
|
|
218
224
|
withheld?: string[]
|
|
225
|
+
/**
|
|
226
|
+
* Reads of the project the task's `cache.inputs.files` does not match:
|
|
227
|
+
* add them there too when the output depends on them, or a hit replays
|
|
228
|
+
* output built from an older copy. Cached tasks only.
|
|
229
|
+
*/
|
|
230
|
+
inputs?: string[]
|
|
219
231
|
}
|
|
220
232
|
}
|
|
221
233
|
|
|
@@ -267,7 +279,7 @@ export interface ScheduleOptions {
|
|
|
267
279
|
* outcome lands. Absent = stderr. The run gives it the task's own
|
|
268
280
|
* stderr, so its frame carries the reason.
|
|
269
281
|
*/
|
|
270
|
-
onError?: (node: TaskNode, line: string) => void
|
|
282
|
+
onError?: (node: TaskNode, line: string, err: unknown) => void
|
|
271
283
|
/**
|
|
272
284
|
* What an outcome still owes before its dependents may start — a cache
|
|
273
285
|
* save that runs off the execution slot (the orchestrator's save lane):
|
|
@@ -294,7 +306,7 @@ export interface ScheduleOptions {
|
|
|
294
306
|
*/
|
|
295
307
|
priorities?: ReadonlyMap<string, number>
|
|
296
308
|
/**
|
|
297
|
-
* The baseline ranking under `priorities` (`schedule` in vx
|
|
309
|
+
* The baseline ranking under `priorities` (`schedule` in .vx/workspace.ts).
|
|
298
310
|
* Undefined or `most-work` is the reverse-deps count above.
|
|
299
311
|
*/
|
|
300
312
|
strategy?: ScheduleStrategy
|
|
@@ -1027,7 +1039,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1027
1039
|
}
|
|
1028
1040
|
try {
|
|
1029
1041
|
if (options.onError === undefined) process.stderr.write(line)
|
|
1030
|
-
else options.onError(node, line)
|
|
1042
|
+
else options.onError(node, line, err)
|
|
1031
1043
|
} catch {
|
|
1032
1044
|
process.stderr.write(line)
|
|
1033
1045
|
}
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -288,10 +288,16 @@ export interface BuildGraphOptions {
|
|
|
288
288
|
* with other root-anchored ones.
|
|
289
289
|
*/
|
|
290
290
|
workspaceRoot?: string
|
|
291
|
-
/** The workspace's `rules`; each is on unless set to `false`. */
|
|
292
|
-
rules?:
|
|
291
|
+
/** The workspace's `run.rules`; each is on unless set to `false`. */
|
|
292
|
+
rules?: GraphRules | undefined
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* `run.rules` as the graph applies them. `locked`: a run, which never
|
|
297
|
+
* checks them; `vx lock` and `vx lock --check` do (owner, 2026-10-14).
|
|
298
|
+
*/
|
|
299
|
+
export type GraphRules = WorkspaceRules & { readonly locked?: boolean }
|
|
300
|
+
|
|
295
301
|
/**
|
|
296
302
|
* The refusal of a literal `^name` that no project in the workspace
|
|
297
303
|
* declares: it has no holder anywhere, so it can only be a typo (nx#32779
|
|
@@ -301,7 +307,7 @@ export interface BuildGraphOptions {
|
|
|
301
307
|
*/
|
|
302
308
|
export function undeclaredDepsError(taskId: string, name: string): UserError {
|
|
303
309
|
return new UserError(
|
|
304
|
-
|
|
310
|
+
`${taskId}: dependsOn ^${name} but no project in the workspace declares ${name}`,
|
|
305
311
|
'VX_E_DEPENDS_ON',
|
|
306
312
|
)
|
|
307
313
|
}
|
|
@@ -493,7 +499,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
493
499
|
spec = parseDependencySpec(raw)
|
|
494
500
|
} catch (err) {
|
|
495
501
|
if (err instanceof DependencySpecError) {
|
|
496
|
-
throw new UserError(
|
|
502
|
+
throw new UserError(`${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
|
|
497
503
|
}
|
|
498
504
|
throw err
|
|
499
505
|
}
|
|
@@ -505,19 +511,19 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
505
511
|
// they name a namespace of tasks to add (Nx 19.5 parity).
|
|
506
512
|
if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
|
|
507
513
|
throw new UserError(
|
|
508
|
-
|
|
514
|
+
`${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
|
|
509
515
|
'VX_E_DEPENDS_ON',
|
|
510
516
|
)
|
|
511
517
|
}
|
|
512
518
|
if (spec.negated) {
|
|
513
519
|
throw new UserError(
|
|
514
|
-
|
|
520
|
+
`${id}: dependsOn does not accept negation (got "${raw}")`,
|
|
515
521
|
'VX_E_DEPENDS_ON',
|
|
516
522
|
)
|
|
517
523
|
}
|
|
518
524
|
if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
|
|
519
525
|
throw new UserError(
|
|
520
|
-
|
|
526
|
+
`${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
|
|
521
527
|
'VX_E_DEPENDS_ON',
|
|
522
528
|
)
|
|
523
529
|
}
|
|
@@ -537,7 +543,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
537
543
|
// doesn't resolve in this project.
|
|
538
544
|
if (!visit(frame, projectName, spec.task, false)) {
|
|
539
545
|
throw new UserError(
|
|
540
|
-
|
|
546
|
+
`${id}: dependsOn ${taskId(projectName, spec.task)} but no such task is declared`,
|
|
541
547
|
'VX_E_DEPENDS_ON',
|
|
542
548
|
)
|
|
543
549
|
}
|
|
@@ -570,6 +576,17 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
570
576
|
// last (`resolveDeferred`), and a walk that meets it on a cycle takes
|
|
571
577
|
// the edge and goes on past it, so the builds it could not depend on
|
|
572
578
|
// still come first.
|
|
579
|
+
//
|
|
580
|
+
// With no edge source every `^` resolves to nothing and its upstream
|
|
581
|
+
// folds into no key: a stale hit, green. With one, a project with no
|
|
582
|
+
// deps is a leaf. The default `build`'s `^build` is no exception: a
|
|
583
|
+
// task behind it would fold no upstream either.
|
|
584
|
+
if (!packageGraph.edgeSource) {
|
|
585
|
+
throw new UserError(
|
|
586
|
+
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
|
|
587
|
+
'VX_E_DEPENDS_ON',
|
|
588
|
+
)
|
|
589
|
+
}
|
|
573
590
|
const visited = new Set<string>([projectName])
|
|
574
591
|
if (!isTaskPattern(spec.task)) {
|
|
575
592
|
const held = walkHolders(frame, spec.task, visited, projectName)
|
|
@@ -602,11 +619,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
602
619
|
// The loader spells `//` as the root project's name when there is one.
|
|
603
620
|
if (spec.project === '//')
|
|
604
621
|
throw new UserError(
|
|
605
|
-
|
|
622
|
+
`${id}: dependsOn //#${spec.task}, the workspace root's task, but the root declares no such task: add a vx.config at the root declaring it and run \`vx lock\``,
|
|
606
623
|
'VX_E_DEPENDS_ON',
|
|
607
624
|
)
|
|
608
625
|
throw new UserError(
|
|
609
|
-
|
|
626
|
+
`${id}: dependsOn ${taskId(spec.project, spec.task)} but no such project or task is declared`,
|
|
610
627
|
'VX_E_DEPENDS_ON',
|
|
611
628
|
)
|
|
612
629
|
}
|
|
@@ -746,7 +763,7 @@ function reachingTo(nodes: Map<string, TaskNode>, target: string): Set<string> {
|
|
|
746
763
|
export function checkGraph(
|
|
747
764
|
nodes: Map<string, TaskNode>,
|
|
748
765
|
workspaceRoot?: string,
|
|
749
|
-
rules?:
|
|
766
|
+
rules?: GraphRules,
|
|
750
767
|
): void {
|
|
751
768
|
for (const [key, node] of nodes) {
|
|
752
769
|
if (node.id !== key) {
|
|
@@ -762,8 +779,12 @@ export function checkGraph(
|
|
|
762
779
|
}
|
|
763
780
|
for (const node of nodes.values()) refuseSelfClean(node)
|
|
764
781
|
detectCycle(nodes)
|
|
765
|
-
|
|
766
|
-
|
|
782
|
+
const exclusive = rules?.exclusiveOutputs !== false
|
|
783
|
+
const locked = rules?.locked === true
|
|
784
|
+
// Under a lock an exclusive graph has no overlap to mark; a non-exclusive
|
|
785
|
+
// one still needs the addition marks the walk derives.
|
|
786
|
+
if (!locked || !exclusive) detectOutputCollisions(nodes, workspaceRoot, exclusive)
|
|
787
|
+
if (rules?.upfrontKeys !== false && !locked) detectInputOverlaps(nodes, workspaceRoot)
|
|
767
788
|
}
|
|
768
789
|
|
|
769
790
|
/**
|
|
@@ -1352,13 +1373,13 @@ function neverWritesLocally(n: TaskNode): boolean {
|
|
|
1352
1373
|
}
|
|
1353
1374
|
|
|
1354
1375
|
/**
|
|
1355
|
-
* The way out of an edge-ordered overlap under `rules.exclusiveOutputs`: an
|
|
1376
|
+
* The way out of an edge-ordered overlap under `run.rules.exclusiveOutputs`: an
|
|
1356
1377
|
* ordered pair is correct (the addition shape, item 588), only slower to
|
|
1357
1378
|
* clean and restore, so the rule may be turned off.
|
|
1358
1379
|
*/
|
|
1359
1380
|
const EXCLUSIVE_OUTPUTS_FIX =
|
|
1360
|
-
'Give each task its own output path, or set rules
|
|
1361
|
-
"vx
|
|
1381
|
+
'Give each task its own output path, or set run.rules.exclusiveOutputs: false in ' +
|
|
1382
|
+
".vx/workspace.ts to let a dependant add to its upstream's outputs."
|
|
1362
1383
|
|
|
1363
1384
|
function collide(
|
|
1364
1385
|
a: TaskNode,
|
|
@@ -1383,7 +1404,7 @@ function collide(
|
|
|
1383
1404
|
// dependant runs after its upstream and adds to that tree, so the
|
|
1384
1405
|
// order is fixed and the dependant's own set can be told apart from
|
|
1385
1406
|
// what it found. Marked on both, and the pair is allowed when
|
|
1386
|
-
// `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1407
|
+
// `run.rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1387
1408
|
// in either order, and the refusal below stands.
|
|
1388
1409
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1389
1410
|
if (up !== undefined && exclusive)
|
|
@@ -1504,7 +1525,7 @@ interface Side {
|
|
|
1504
1525
|
}
|
|
1505
1526
|
|
|
1506
1527
|
/**
|
|
1507
|
-
* `rules.upfrontKeys` (X-54): refuse a task whose input globs can match
|
|
1528
|
+
* `run.rules.upfrontKeys` (X-54): refuse a task whose input globs can match
|
|
1508
1529
|
* another task's declared outputs. Such a key reads bytes a producer writes
|
|
1509
1530
|
* this run, so it is not known until the producer ran (`stable-keys.ts`
|
|
1510
1531
|
* keeps it waiting); refused, every key reads only what no task writes and
|
|
@@ -1677,7 +1698,7 @@ function readsOutputs(
|
|
|
1677
1698
|
`${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
|
|
1678
1699
|
`another task's outputs (the dependency's key already cascades through dependsOn). ` +
|
|
1679
1700
|
`Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
|
|
1680
|
-
`cache.inputs.${field}, or set rules
|
|
1701
|
+
`cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
|
|
1681
1702
|
`let it wait for its producer.`,
|
|
1682
1703
|
'VX_E_INPUT_IS_OUTPUT',
|
|
1683
1704
|
)
|
|
@@ -1725,7 +1746,10 @@ function detectCycle(nodes: Map<string, TaskNode>): void {
|
|
|
1725
1746
|
for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
|
|
1726
1747
|
const startInPath = path.indexOf(idsArr[depIdx]!)
|
|
1727
1748
|
const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
|
|
1728
|
-
throw new UserError(
|
|
1749
|
+
throw new UserError(
|
|
1750
|
+
`dependsOn cycle: ${cycle} — remove one edge of the loop`,
|
|
1751
|
+
'VX_E_CYCLE',
|
|
1752
|
+
)
|
|
1729
1753
|
}
|
|
1730
1754
|
color[depIdx] = GRAY
|
|
1731
1755
|
stack.push(depIdx, 0)
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// A global vx hands off to the project's own (owner, 2026-10-13), found
|
|
2
|
+
// from the workspace root, the one lookup every verb shares. Here for
|
|
3
|
+
// the compiled binary installed by hand; an npm install's launcher
|
|
4
|
+
// (npm-launcher.sh) walks the same way and `exec`s, which Bun cannot. The
|
|
5
|
+
// walk runs before anything else loads, through `Bun.file(…).exists()`:
|
|
6
|
+
// importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The project-local vx to run instead of this one, or null: the workspace
|
|
10
|
+
* root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
|
|
11
|
+
* `.bin/vx`. Null once handed
|
|
12
|
+
* off (one hop), for a vx that is not a standalone compiled binary (source;
|
|
13
|
+
* an npm install, whose launcher already walked), outside a workspace,
|
|
14
|
+
* when the local one is this binary, and when another user owns it.
|
|
15
|
+
*/
|
|
16
|
+
export async function localVx(
|
|
17
|
+
cwd: string,
|
|
18
|
+
execPath: string,
|
|
19
|
+
compiled: boolean,
|
|
20
|
+
uid = process.getuid?.(),
|
|
21
|
+
): Promise<string | null> {
|
|
22
|
+
if (process.env['VX_HANDED_OFF'] !== undefined || !compiled) return null
|
|
23
|
+
if (execPath.includes('/node_modules/')) return null
|
|
24
|
+
const file = await workspaceFile(cwd)
|
|
25
|
+
if (file === null) return null
|
|
26
|
+
const root = workspaceRootOf(file)
|
|
27
|
+
if (!(await Bun.file(`${root}/node_modules/@vzn/vx/package.json`).exists())) return null
|
|
28
|
+
const bin = `${root}/node_modules/.bin/vx`
|
|
29
|
+
const { realpathSync, statSync } = await import('node:fs')
|
|
30
|
+
try {
|
|
31
|
+
const real = realpathSync(bin)
|
|
32
|
+
// One another user planted would run as you.
|
|
33
|
+
if (statSync(real).uid !== uid) return null
|
|
34
|
+
return real === realpathSync(execPath) ? null : bin
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The workspace config's paths under the root, in lookup order: one marks
|
|
42
|
+
* the workspace root, the directory holding its `.vx` (owner, 2026-10-14).
|
|
43
|
+
*/
|
|
44
|
+
export const WORKSPACE_CONFIG_FILENAMES = [
|
|
45
|
+
'.vx/workspace.ts',
|
|
46
|
+
'.vx/workspace.mts',
|
|
47
|
+
'.vx/workspace.js',
|
|
48
|
+
'.vx/workspace.mjs',
|
|
49
|
+
// A CommonJS config (`module.exports = …`), as Vite and TS name theirs: it
|
|
50
|
+
// was ignored without a word (D-86).
|
|
51
|
+
'.vx/workspace.cts',
|
|
52
|
+
'.vx/workspace.cjs',
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** The workspace root a workspace config marks: the directory above its `.vx`. */
|
|
56
|
+
export function workspaceRootOf(file: string): string {
|
|
57
|
+
return file.slice(0, file.lastIndexOf('/.vx/')) || '/'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const files = new Map<string, Promise<string | null>>()
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The workspace config that marks the root for `dir`: the first
|
|
64
|
+
* `.vx/workspace.*` from `dir` up to the git top-level ({@link gitTop}),
|
|
65
|
+
* inclusive, or to `/` outside git (owner, 2026-10-13: "Workspace file is
|
|
66
|
+
* the ultimate" and "never go out of git root"). Null when there is none.
|
|
67
|
+
* Learned once per directory per run: the hand-off and the workspace root
|
|
68
|
+
* share it (principle 9).
|
|
69
|
+
*/
|
|
70
|
+
export function workspaceFile(dir: string): Promise<string | null> {
|
|
71
|
+
let file = files.get(dir)
|
|
72
|
+
if (file === undefined) {
|
|
73
|
+
file = findWorkspaceFile(dir)
|
|
74
|
+
files.set(dir, file)
|
|
75
|
+
}
|
|
76
|
+
return file
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** vx wrote a workspace config (`vx init`): what was learned before it is stale. */
|
|
80
|
+
export function workspaceFileWritten(): void {
|
|
81
|
+
files.clear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function findWorkspaceFile(start: string): Promise<string | null> {
|
|
85
|
+
const top = await gitTop(start)
|
|
86
|
+
const passed: string[] = []
|
|
87
|
+
let found: string | null = null
|
|
88
|
+
walk: for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
|
|
89
|
+
passed.push(d || '/')
|
|
90
|
+
for (const name of WORKSPACE_CONFIG_FILENAMES) {
|
|
91
|
+
const file = `${d}/${name}`
|
|
92
|
+
if (await Bun.file(file).exists()) {
|
|
93
|
+
found = file
|
|
94
|
+
break walk
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (d === '' || d === top) break
|
|
98
|
+
}
|
|
99
|
+
// Every directory walked has the same answer: the loader asks for the root's.
|
|
100
|
+
for (const d of passed) if (!files.has(d)) files.set(d, Promise.resolve(found))
|
|
101
|
+
return found
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tops = new Map<string, Promise<string | null>>()
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The git top-level holding `dir`, or null outside git: the nearest `.git`
|
|
108
|
+
* up from it, a directory in a clone, a file in a worktree. A submodule's
|
|
109
|
+
* `.git` file points into its superproject's `.git/modules`, so the walk
|
|
110
|
+
* goes on to the superproject's. Learned once per directory per run: the
|
|
111
|
+
* hand-off and the workspace root walk share it (principle 9).
|
|
112
|
+
*/
|
|
113
|
+
export function gitTop(dir: string): Promise<string | null> {
|
|
114
|
+
let top = tops.get(dir)
|
|
115
|
+
if (top === undefined) {
|
|
116
|
+
top = findGitTop(dir)
|
|
117
|
+
tops.set(dir, top)
|
|
118
|
+
}
|
|
119
|
+
return top
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function findGitTop(start: string): Promise<string | null> {
|
|
123
|
+
for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
|
|
124
|
+
// A file each: `exists()` is false for a directory.
|
|
125
|
+
if (await Bun.file(`${d}/.git/HEAD`).exists()) return d || '/'
|
|
126
|
+
const file = Bun.file(`${d}/.git`)
|
|
127
|
+
if (await file.exists()) {
|
|
128
|
+
const gitdir = await file.text().catch(() => '')
|
|
129
|
+
if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
|
|
130
|
+
}
|
|
131
|
+
if (d === '') return null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** This vx's own file: the compiled binary, else the source entry Bun runs. */
|
|
136
|
+
export function vxPath(): string {
|
|
137
|
+
return Bun.main.startsWith('/$bunfs') ? process.execPath : Bun.main
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run `bin` with this process's arguments and stdio, and exit as it does.
|
|
142
|
+
* A signal sent to this vx alone (`kill`, a process manager) is passed on;
|
|
143
|
+
* one the terminal sent reached the whole foreground group already, and a
|
|
144
|
+
* second would read as "stop now" (npm-launcher.cjs, the same rule).
|
|
145
|
+
*/
|
|
146
|
+
export async function handOff(bin: string, args: readonly string[]): Promise<never> {
|
|
147
|
+
const child = Bun.spawn([bin, ...args], {
|
|
148
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
149
|
+
env: { ...process.env, VX_HANDED_OFF: process.execPath },
|
|
150
|
+
})
|
|
151
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
|
152
|
+
process.on(sig, () => {
|
|
153
|
+
if (!inForeground()) child.kill(sig)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
const code = await child.exited
|
|
157
|
+
const { constants } = await import('node:os')
|
|
158
|
+
process.exit(child.signalCode === null ? code : 128 + (constants.signals[child.signalCode] ?? 1))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function inForeground(): boolean {
|
|
162
|
+
try {
|
|
163
|
+
const stat = Bun.spawnSync(['ps', '-o', 'pgid=,tpgid=', '-p', String(process.pid)])
|
|
164
|
+
const [pgid, tpgid] = stat.stdout.toString().trim().split(/\s+/)
|
|
165
|
+
return pgid !== undefined && pgid === tpgid
|
|
166
|
+
} catch {
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
}
|