@vzn/vx 0.0.638 → 0.0.640
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 +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/graph/index.ts
CHANGED
|
@@ -8,6 +8,15 @@ export {
|
|
|
8
8
|
parseDependencySpec,
|
|
9
9
|
type DependencySpec,
|
|
10
10
|
} from './dependency-spec.js'
|
|
11
|
+
export {
|
|
12
|
+
type FoldCandidate,
|
|
13
|
+
foldedDeps,
|
|
14
|
+
isGroupTask,
|
|
15
|
+
keyedDeps,
|
|
16
|
+
refuseFiltersThroughGroups,
|
|
17
|
+
selectFoldedDeps,
|
|
18
|
+
} from './folds.js'
|
|
19
|
+
export { computeReverseDepCount } from './priorities.js'
|
|
11
20
|
export {
|
|
12
21
|
type ContinueMode,
|
|
13
22
|
deadServerBehind,
|
|
@@ -16,8 +25,15 @@ export {
|
|
|
16
25
|
RestoreDemoted,
|
|
17
26
|
runGraph,
|
|
18
27
|
type TaskOutcome,
|
|
19
|
-
type TaskStatus,
|
|
20
28
|
} from './scheduler.js'
|
|
29
|
+
export {
|
|
30
|
+
type CacheSource,
|
|
31
|
+
deriveCacheSource,
|
|
32
|
+
isCacheHit,
|
|
33
|
+
isPassStatus,
|
|
34
|
+
TASK_STATUSES,
|
|
35
|
+
type TaskStatus,
|
|
36
|
+
} from './task-status.js'
|
|
21
37
|
export {
|
|
22
38
|
buildTaskGraph,
|
|
23
39
|
checkGraph,
|
|
@@ -25,7 +41,6 @@ export {
|
|
|
25
41
|
excludeDependencies,
|
|
26
42
|
expandRequested,
|
|
27
43
|
type GraphRules,
|
|
28
|
-
isGroupTask,
|
|
29
44
|
markSurfacedDeps,
|
|
30
45
|
outputsOverlap,
|
|
31
46
|
outputTakenBack,
|
package/src/graph/priorities.ts
CHANGED
|
@@ -85,7 +85,7 @@ export function tieredReverseDepCount(
|
|
|
85
85
|
* dependents only (`calculateReverseDeps` in
|
|
86
86
|
* `packages/nx/src/tasks-runner/utils.ts`, read 2026-09-24).
|
|
87
87
|
*/
|
|
88
|
-
export function computeReverseDepCount(nodes:
|
|
88
|
+
export function computeReverseDepCount(nodes: ReadonlyMap<string, TaskNode>): Map<string, number> {
|
|
89
89
|
// Exact transitive-dependent COUNTS need closure SETS (diamonds
|
|
90
90
|
// double-count under naive summing). Set-of-strings closures are
|
|
91
91
|
// O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
tieredReverseDepCount,
|
|
13
13
|
type ScheduleStrategy,
|
|
14
14
|
} from './priorities.js'
|
|
15
|
-
import { isGroupTask
|
|
15
|
+
import { isGroupTask } from './folds.js'
|
|
16
|
+
import type { TaskNode } from './task-graph.js'
|
|
17
|
+
import type { TaskStatus } from './task-status.js'
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Thrown by `execute` for a restore-tier task that turned out to have
|
|
@@ -29,19 +31,6 @@ export class RestoreDemoted extends Error {
|
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
export type TaskStatus =
|
|
33
|
-
| 'success'
|
|
34
|
-
| 'cache-hit'
|
|
35
|
-
| 'cache-hit-remote'
|
|
36
|
-
| 'failed'
|
|
37
|
-
| 'skipped'
|
|
38
|
-
// A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
|
|
39
|
-
// the task never finished on its own terms, so it reverts to aborted —
|
|
40
|
-
// excluded from the tally and the run history. It still PROPAGATES like
|
|
41
|
-
// a failure (see `willSkip`): the child died mid-write, so its outputs
|
|
42
|
-
// are partial.
|
|
43
|
-
| 'aborted'
|
|
44
|
-
|
|
45
34
|
/** What moved in a missed task's key since the last entry saved for it. */
|
|
46
35
|
export interface InputChanges {
|
|
47
36
|
/** How many components changed, appeared or went. 0: the same key, its entry gone. */
|
|
@@ -79,8 +68,8 @@ export interface TaskOutcome {
|
|
|
79
68
|
* How long an `admit` policy held this task after it was ready with a
|
|
80
69
|
* free worker — the wait the plugin, not the count gate, imposed. Set
|
|
81
70
|
* only when a policy refused it at least once (so a run with no policy
|
|
82
|
-
* carries the field on no outcome);
|
|
83
|
-
*
|
|
71
|
+
* carries the field on no outcome); the event stream and the summary
|
|
72
|
+
* footer show it.
|
|
84
73
|
*/
|
|
85
74
|
admissionHeldMs?: number
|
|
86
75
|
/**
|
|
@@ -126,6 +115,12 @@ export interface TaskOutcome {
|
|
|
126
115
|
* so a dependant that folds `hash` saves nothing either (A-12).
|
|
127
116
|
*/
|
|
128
117
|
unkeyed?: true
|
|
118
|
+
/**
|
|
119
|
+
* This task's key folds a task with no `inputs` (or it is one): no run
|
|
120
|
+
* can derive that key again, so it neither read nor saved an entry, and a
|
|
121
|
+
* dependant that folds `hash` does neither.
|
|
122
|
+
*/
|
|
123
|
+
keyless?: true
|
|
129
124
|
/**
|
|
130
125
|
* The run's cache policy read and wrote nothing (`--no-cache`,
|
|
131
126
|
* `--cache-policy=local:`): no cache answered for this task, so a run of it is
|
|
@@ -136,7 +131,7 @@ export interface TaskOutcome {
|
|
|
136
131
|
* On a `skipped` outcome: the task at the ROOT of what blocked it — the
|
|
137
132
|
* failed (or aborted) upstream, followed through any chain of skips
|
|
138
133
|
* between. Absent when the skip was fail-fast's (no upstream failed).
|
|
139
|
-
* The footer,
|
|
134
|
+
* The footer, the telemetry record and the run row all
|
|
140
135
|
* read this one field (item 267).
|
|
141
136
|
*/
|
|
142
137
|
blockedBy?: string
|
|
@@ -231,7 +226,7 @@ export interface TaskOutcome {
|
|
|
231
226
|
/** Credential paths denied and left out of the grants: an agent applies the fix as given. */
|
|
232
227
|
withheld?: string[]
|
|
233
228
|
/**
|
|
234
|
-
* Reads of the project the task's `
|
|
229
|
+
* Reads of the project the task's `inputs.files` does not match:
|
|
235
230
|
* add them there too when the output depends on them, or a hit replays
|
|
236
231
|
* output built from an older copy. Cached tasks only.
|
|
237
232
|
*/
|
|
@@ -313,6 +308,12 @@ export interface ScheduleOptions {
|
|
|
313
308
|
* heuristic (the default and historically the only behavior).
|
|
314
309
|
*/
|
|
315
310
|
priorities?: ReadonlyMap<string, number>
|
|
311
|
+
/**
|
|
312
|
+
* Called once as the run starts with a function that replaces
|
|
313
|
+
* `priorities`: the ready queues re-rank at once, over the same baseline.
|
|
314
|
+
* A `schedule` plugin's later, better weights land through it.
|
|
315
|
+
*/
|
|
316
|
+
reprioritize?: (apply: (priorities: ReadonlyMap<string, number>) => void) => void
|
|
316
317
|
/**
|
|
317
318
|
* The baseline ranking under `priorities` (`schedule` in .vx/workspace.ts).
|
|
318
319
|
* Undefined or `most-work` is the reverse-deps count above.
|
|
@@ -383,10 +384,28 @@ class ReadyHeap {
|
|
|
383
384
|
// was two per heap level on every push and pop.
|
|
384
385
|
private readonly prio: number[] = []
|
|
385
386
|
private next = 0
|
|
386
|
-
constructor(private
|
|
387
|
+
constructor(private priority: ReadonlyMap<string, number>) {}
|
|
387
388
|
get size(): number {
|
|
388
389
|
return this.ids.length
|
|
389
390
|
}
|
|
391
|
+
/** Re-rank every queued id under `priority`, and every later push. */
|
|
392
|
+
reprioritize(priority: ReadonlyMap<string, number>): void {
|
|
393
|
+
this.priority = priority
|
|
394
|
+
for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority.get(this.ids[i]!) ?? 0
|
|
395
|
+
for (let i = (this.ids.length >> 1) - 1; i >= 0; i--) this.down(i)
|
|
396
|
+
}
|
|
397
|
+
private down(i: number): void {
|
|
398
|
+
for (;;) {
|
|
399
|
+
const l = 2 * i + 1
|
|
400
|
+
const r = 2 * i + 2
|
|
401
|
+
let best = i
|
|
402
|
+
if (l < this.ids.length && this.higher(l, best)) best = l
|
|
403
|
+
if (r < this.ids.length && this.higher(r, best)) best = r
|
|
404
|
+
if (best === i) return
|
|
405
|
+
this.swap(i, best)
|
|
406
|
+
i = best
|
|
407
|
+
}
|
|
408
|
+
}
|
|
390
409
|
/** True if slot i outranks slot j (higher priority, or equal priority + earlier seq). */
|
|
391
410
|
private higher(i: number, j: number): boolean {
|
|
392
411
|
const pi = this.prio[i]!
|
|
@@ -446,17 +465,7 @@ class ReadyHeap {
|
|
|
446
465
|
this.ids[0] = lastId
|
|
447
466
|
this.seq[0] = lastSeq
|
|
448
467
|
this.prio[0] = lastPrio
|
|
449
|
-
|
|
450
|
-
for (;;) {
|
|
451
|
-
const l = 2 * i + 1
|
|
452
|
-
const r = 2 * i + 2
|
|
453
|
-
let best = i
|
|
454
|
-
if (l < this.ids.length && this.higher(l, best)) best = l
|
|
455
|
-
if (r < this.ids.length && this.higher(r, best)) best = r
|
|
456
|
-
if (best === i) break
|
|
457
|
-
this.swap(i, best)
|
|
458
|
-
i = best
|
|
459
|
-
}
|
|
468
|
+
this.down(0)
|
|
460
469
|
}
|
|
461
470
|
return top
|
|
462
471
|
}
|
|
@@ -565,6 +574,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
565
574
|
let settledIdle = 0
|
|
566
575
|
const execReady = new ReadyHeap(priority)
|
|
567
576
|
const restoreReady = new ReadyHeap(priority)
|
|
577
|
+
options.reprioritize?.((weights) => {
|
|
578
|
+
const merged = mergePriorities(baseline, weights)
|
|
579
|
+
execReady.reprioritize(merged)
|
|
580
|
+
restoreReady.reprioritize(merged)
|
|
581
|
+
})
|
|
568
582
|
// A restore-tier task is dep-independent (a stable hit's restore needs
|
|
569
583
|
// none of its deps' output), so it's ready immediately; its `pending`
|
|
570
584
|
// decrements still happen (in finishOne) but never re-enqueue it.
|
|
@@ -1020,7 +1034,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1020
1034
|
return
|
|
1021
1035
|
}
|
|
1022
1036
|
// A UserError is a config/input failure (e.g. a failed
|
|
1023
|
-
// `
|
|
1037
|
+
// `inputs.runtime` command), not a vx bug — report it
|
|
1024
1038
|
// plainly, never as an "internal error".
|
|
1025
1039
|
let line: string
|
|
1026
1040
|
// Out of descriptors is the environment's limit too: a stack
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
|
|
2
2
|
import {
|
|
3
3
|
asTrees,
|
|
4
|
+
checkIgnoreSync,
|
|
5
|
+
ignoreProbes,
|
|
4
6
|
isLiteralPattern,
|
|
7
|
+
listedSync,
|
|
5
8
|
normalizeGlob,
|
|
6
9
|
relPosix,
|
|
7
10
|
splitNegations,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
taskGlob,
|
|
10
13
|
refusal,
|
|
11
14
|
UserError,
|
|
15
|
+
wholeIgnored,
|
|
12
16
|
wholeSubtreePrefixes,
|
|
13
17
|
} from '../util/index.js'
|
|
14
18
|
import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
|
|
@@ -19,6 +23,7 @@ import {
|
|
|
19
23
|
parseDependencySpec,
|
|
20
24
|
type DependencySpec,
|
|
21
25
|
} from './dependency-spec.js'
|
|
26
|
+
import { foldedDeps, isGroupTask } from './folds.js'
|
|
22
27
|
import type { TaskOutcome } from './scheduler.js'
|
|
23
28
|
|
|
24
29
|
// Re-exported so existing importers keep working while the type's home
|
|
@@ -96,23 +101,9 @@ export function taskId(project: string, task: string): string {
|
|
|
96
101
|
// graph is where every reader of a task id looks for it.
|
|
97
102
|
export { splitTaskId } from '../util/index.js'
|
|
98
103
|
|
|
99
|
-
/**
|
|
100
|
-
* A task is a "group" if it has no `exec` — it exists only to chain
|
|
101
|
-
* `dependsOn` (an umbrella for `vx run ci`). Group tasks:
|
|
102
|
-
* - never spawn a process,
|
|
103
|
-
* - never read/write the cache (it's a config error to declare one),
|
|
104
|
-
* - never appear in the run summary or `runs` analytics table,
|
|
105
|
-
* - render no framed block in the live output.
|
|
106
|
-
* Six call sites used to repeat `node.config.exec === undefined`;
|
|
107
|
-
* centralising the predicate prevents that check from drifting.
|
|
108
|
-
*/
|
|
109
|
-
export function isGroupTask(node: TaskNode): boolean {
|
|
110
|
-
return node.config.exec === undefined
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
|
|
104
|
+
/** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `inputs`. */
|
|
114
105
|
function isKeyedGroup(task: TaskConfig): boolean {
|
|
115
|
-
return task.exec === undefined && task.
|
|
106
|
+
return task.exec === undefined && task.inputs !== undefined
|
|
116
107
|
}
|
|
117
108
|
|
|
118
109
|
/**
|
|
@@ -507,7 +498,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
507
498
|
|
|
508
499
|
// dependsOn is about which tasks to ADD to the graph, not which
|
|
509
500
|
// to filter. BARE wildcards ("*"/"^*" = "everything upstream") and
|
|
510
|
-
// negation aren't meaningful here — they're
|
|
501
|
+
// negation aren't meaningful here — they're inputs.tasks
|
|
511
502
|
// operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
|
|
512
503
|
// they name a namespace of tasks to add (Nx 19.5 parity).
|
|
513
504
|
if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
|
|
@@ -584,7 +575,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
584
575
|
// task behind it would fold no upstream either.
|
|
585
576
|
if (!packageGraph.edgeSource) {
|
|
586
577
|
throw new UserError(
|
|
587
|
-
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
|
|
578
|
+
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
|
|
588
579
|
'VX_E_DEPENDS_ON',
|
|
589
580
|
)
|
|
590
581
|
}
|
|
@@ -785,7 +776,7 @@ export function checkGraph(
|
|
|
785
776
|
// Under a lock an exclusive graph has no overlap to mark; a non-exclusive
|
|
786
777
|
// one still needs the addition marks the walk derives.
|
|
787
778
|
if (!locked || !exclusive) detectOutputCollisions(nodes, workspaceRoot, exclusive)
|
|
788
|
-
if (
|
|
779
|
+
if (!locked) detectInputOverlaps(nodes, workspaceRoot)
|
|
789
780
|
}
|
|
790
781
|
|
|
791
782
|
/**
|
|
@@ -1128,6 +1119,15 @@ function detectOutputCollisions(
|
|
|
1128
1119
|
}
|
|
1129
1120
|
}
|
|
1130
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* What a task's `!` output entries take back (A-44). A glob of the other
|
|
1124
|
+
* task's that one takes back whole is a path this task never cleans or
|
|
1125
|
+
* captures, so the pair cannot collide there.
|
|
1126
|
+
*/
|
|
1127
|
+
function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
|
|
1128
|
+
return splitNegations(n.config.cache?.outputs[field] ?? []).negative
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
1131
|
/**
|
|
1132
1132
|
* `collide` for a root-anchored output against another project's `files`
|
|
1133
1133
|
* output rebased to the root. With an edge the dependant adds to its
|
|
@@ -1146,9 +1146,12 @@ function collideAcross(
|
|
|
1146
1146
|
exclusive: boolean,
|
|
1147
1147
|
): void {
|
|
1148
1148
|
if (neverWritesLocally(ws) || neverWritesLocally(files)) return
|
|
1149
|
+
const wsBack = takenBackOf(ws, 'workspaceFiles')
|
|
1150
|
+
const filesBack = takenBackOf(files, 'files').map((g) => (rel === '' ? g : `${rel}/${g}`))
|
|
1149
1151
|
for (const ga of wsGlobs) {
|
|
1150
1152
|
for (const gb of rebased) {
|
|
1151
|
-
if (!outputsOverlap(ga, gb))
|
|
1153
|
+
if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
|
|
1154
|
+
continue
|
|
1152
1155
|
const head =
|
|
1153
1156
|
`${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
|
|
1154
1157
|
`inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
|
|
@@ -1405,9 +1408,12 @@ function collide(
|
|
|
1405
1408
|
exclusive: boolean,
|
|
1406
1409
|
): void {
|
|
1407
1410
|
if (neverWritesLocally(a) || neverWritesLocally(b)) return
|
|
1411
|
+
const aBack = takenBackOf(a, field)
|
|
1412
|
+
const bBack = takenBackOf(b, field)
|
|
1408
1413
|
for (const ga of aGlobs ?? []) {
|
|
1409
1414
|
for (const gb of bGlobs ?? []) {
|
|
1410
|
-
if (!outputsOverlap(ga, gb))
|
|
1415
|
+
if (!outputsOverlap(ga, gb) || outputTakenBack(gb, aBack) || outputTakenBack(ga, bBack))
|
|
1416
|
+
continue
|
|
1411
1417
|
const head =
|
|
1412
1418
|
`${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
|
|
1413
1419
|
(ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
|
|
@@ -1478,10 +1484,11 @@ export function outputTakenBack(output: string, negatives: readonly string[]): b
|
|
|
1478
1484
|
*/
|
|
1479
1485
|
function refuseSelfClean(node: TaskNode): void {
|
|
1480
1486
|
const cache = node.config.cache
|
|
1481
|
-
|
|
1487
|
+
const declared = node.config.inputs
|
|
1488
|
+
if (cache === undefined || declared === undefined) return
|
|
1482
1489
|
for (const field of ['files', 'workspaceFiles'] as const) {
|
|
1483
|
-
if (!coversAny(
|
|
1484
|
-
const inputs = splitNegations(
|
|
1490
|
+
if (!coversAny(declared[field], cache.outputs[field])) continue
|
|
1491
|
+
const inputs = splitNegations(declared[field] ?? [])
|
|
1485
1492
|
const out = splitNegations(cache.outputs[field] ?? [])
|
|
1486
1493
|
const outputs = out.positive
|
|
1487
1494
|
if (outputs.length === 0) continue
|
|
@@ -1500,7 +1507,7 @@ function refuseSelfClean(node: TaskNode): void {
|
|
|
1500
1507
|
if (outputTakenBack(gi, out.negative)) continue
|
|
1501
1508
|
if (!outputTakenBack(gi, near)) continue
|
|
1502
1509
|
throw new UserError(
|
|
1503
|
-
`${node.id}: every file ${JSON.stringify(gi)} in
|
|
1510
|
+
`${node.id}: every file ${JSON.stringify(gi)} in inputs.${field} selects is also ` +
|
|
1504
1511
|
`its own output — vx removes a task's outputs before it runs, so the task would ` +
|
|
1505
1512
|
`delete its own sources. A task that rewrites files in place (a formatter) declares ` +
|
|
1506
1513
|
`no outputs.`,
|
|
@@ -1550,16 +1557,19 @@ interface Side {
|
|
|
1550
1557
|
reads: boolean
|
|
1551
1558
|
/** The project's root-relative dir for rebased `files` globs. */
|
|
1552
1559
|
rel?: string
|
|
1560
|
+
/** The root-relative dir project-relative `globs` sit in, for git. */
|
|
1561
|
+
anchor?: string | undefined
|
|
1553
1562
|
}
|
|
1554
1563
|
|
|
1555
1564
|
/**
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
* this run, so it is not known until the producer ran
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
1561
|
-
*
|
|
1562
|
-
* `dependsOn`, so the bytes it wrote add nothing a key needs
|
|
1565
|
+
* Refuse a task whose input globs can match another task's declared
|
|
1566
|
+
* outputs (X-54; no opt-out, owner 2026-10-16). Such a key reads bytes a
|
|
1567
|
+
* producer writes this run, so it is not known until the producer ran;
|
|
1568
|
+
* refused, every key reads only what no task writes and can be derived
|
|
1569
|
+
* before anything runs (M, 2026-10-07: "hash ahead of time, not
|
|
1570
|
+
* waterfall"). A dependency's own key already cascades through
|
|
1571
|
+
* `dependsOn`, so the bytes it wrote add nothing a key needs; the one
|
|
1572
|
+
* exception is `refuseOverlaps`'s unfolded dependency.
|
|
1563
1573
|
*
|
|
1564
1574
|
* Proven overlaps only (`outputsOverlap`), as for outputs: a pair the rule
|
|
1565
1575
|
* cannot decide passes, and the stability gate still keeps it waiting. The
|
|
@@ -1583,6 +1593,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1583
1593
|
let projectReaders = false
|
|
1584
1594
|
let rootReaders = false
|
|
1585
1595
|
const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
|
|
1596
|
+
const found: Found = new Map()
|
|
1586
1597
|
const bucket = (name: string): Side[] => {
|
|
1587
1598
|
let list = byProject.get(name)
|
|
1588
1599
|
if (list === undefined) byProject.set(name, (list = []))
|
|
@@ -1590,11 +1601,13 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1590
1601
|
}
|
|
1591
1602
|
for (const n of nodes.values()) {
|
|
1592
1603
|
const cache = n.config.cache
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
const
|
|
1604
|
+
const inputs = n.config.inputs
|
|
1605
|
+
if (inputs === undefined) continue
|
|
1606
|
+
const outFiles = positives(cache?.outputs.files ?? [])
|
|
1607
|
+
const outWs = positives(cache?.outputs.workspaceFiles ?? [])
|
|
1596
1608
|
if (outFiles.length > 0) {
|
|
1597
|
-
|
|
1609
|
+
const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
|
|
1610
|
+
bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
|
|
1598
1611
|
filesWriters.push(n)
|
|
1599
1612
|
}
|
|
1600
1613
|
if (outWs.length > 0) {
|
|
@@ -1603,10 +1616,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1603
1616
|
wsWriters.push(side)
|
|
1604
1617
|
}
|
|
1605
1618
|
if (isGroupTask(n)) continue
|
|
1606
|
-
const inFiles = positives(
|
|
1607
|
-
const inWs = positives(
|
|
1619
|
+
const inFiles = positives(inputs.files)
|
|
1620
|
+
const inWs = positives(inputs.workspaceFiles ?? [])
|
|
1608
1621
|
if (inFiles.length > 0) {
|
|
1609
|
-
|
|
1622
|
+
const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
|
|
1623
|
+
bucket(n.projectName).push({ node: n, globs: inFiles, reads: true, anchor })
|
|
1610
1624
|
filesReaders.push(n)
|
|
1611
1625
|
projectReaders = true
|
|
1612
1626
|
}
|
|
@@ -1623,7 +1637,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1623
1637
|
(s) => s.globs,
|
|
1624
1638
|
(s) => s.reads,
|
|
1625
1639
|
)) {
|
|
1626
|
-
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1640
|
+
readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
|
|
1627
1641
|
}
|
|
1628
1642
|
}
|
|
1629
1643
|
}
|
|
@@ -1631,7 +1645,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1631
1645
|
const sides = [...wsWriters]
|
|
1632
1646
|
for (const node of filesReaders) {
|
|
1633
1647
|
const rel = relPosix(workspaceRoot, node.projectDir)
|
|
1634
|
-
const globs = positives(node.config.
|
|
1648
|
+
const globs = positives(node.config.inputs!.files).map((g) =>
|
|
1635
1649
|
rel === '' ? g : `${rel}/${g}`,
|
|
1636
1650
|
)
|
|
1637
1651
|
sides.push({ node, globs, reads: true, rel })
|
|
@@ -1641,10 +1655,20 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1641
1655
|
(s) => s.globs,
|
|
1642
1656
|
(s) => s.reads,
|
|
1643
1657
|
)) {
|
|
1644
|
-
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1658
|
+
readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
|
|
1645
1659
|
}
|
|
1646
1660
|
}
|
|
1647
|
-
if (
|
|
1661
|
+
if (rootReaders) readsRootedOutputs(memo, found, rooted, filesWriters, workspaceRoot)
|
|
1662
|
+
refuseOverlaps(found, nodes, workspaceRoot)
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function readsRootedOutputs(
|
|
1666
|
+
memo: TakeBackMemo,
|
|
1667
|
+
found: Found,
|
|
1668
|
+
rooted: Side[],
|
|
1669
|
+
filesWriters: readonly TaskNode[],
|
|
1670
|
+
workspaceRoot: string | undefined,
|
|
1671
|
+
): void {
|
|
1648
1672
|
if (workspaceRoot !== undefined) {
|
|
1649
1673
|
for (const node of filesWriters) {
|
|
1650
1674
|
const rel = relPosix(workspaceRoot, node.projectDir)
|
|
@@ -1659,10 +1683,104 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1659
1683
|
(s) => s.globs,
|
|
1660
1684
|
(s) => s.reads,
|
|
1661
1685
|
)) {
|
|
1662
|
-
readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1686
|
+
readsOutputs(memo, found, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1663
1687
|
}
|
|
1664
1688
|
}
|
|
1665
1689
|
|
|
1690
|
+
/** One reader of another task's output: the reader's glob's fixed prefix, root-relative. */
|
|
1691
|
+
interface Overlap {
|
|
1692
|
+
reader: TaskNode
|
|
1693
|
+
writer: TaskNode
|
|
1694
|
+
prefix: string
|
|
1695
|
+
refuse: () => UserError
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** Each overlapped output, root-relative, and every reader of it. */
|
|
1699
|
+
type Found = Map<string, Overlap[]>
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Which overlaps stand. A reader may read the outputs of a task in its
|
|
1703
|
+
* `dependsOn` closure whose key its own does not fold (it sets
|
|
1704
|
+
* `inputs.tasks` and leaves the producer out, also through the tasks it
|
|
1705
|
+
* folds): its key waits for the producer and hashes the bytes (owner,
|
|
1706
|
+
* 2026-10-16). Otherwise an output git ignores whole is no overlap (M,
|
|
1707
|
+
* 2026-10-15), a key reading only what git lists, unless the reader's
|
|
1708
|
+
* glob starts in an ignored path (`dist/**`): naming it is asking for it.
|
|
1709
|
+
* Git is asked once, for every output and prefix together; one it cannot
|
|
1710
|
+
* speak for is refused. The answer holds for the lock; a run's stability
|
|
1711
|
+
* gate asks git again for itself.
|
|
1712
|
+
*/
|
|
1713
|
+
function refuseOverlaps(
|
|
1714
|
+
found: Found,
|
|
1715
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
1716
|
+
workspaceRoot: string | undefined,
|
|
1717
|
+
): void {
|
|
1718
|
+
if (found.size === 0) return
|
|
1719
|
+
const reads = dependencyReads(nodes)
|
|
1720
|
+
const left = new Map<string, Overlap[]>()
|
|
1721
|
+
for (const [g, list] of found) {
|
|
1722
|
+
const refused = list.filter((o) => !reads(o.reader, o.writer))
|
|
1723
|
+
if (refused.length > 0) left.set(g, refused)
|
|
1724
|
+
}
|
|
1725
|
+
if (left.size === 0) return
|
|
1726
|
+
if (workspaceRoot === undefined) throw left.values().next().value![0]!.refuse()
|
|
1727
|
+
const asks = [...left.keys()].map(ignoreProbes)
|
|
1728
|
+
const prefixes = new Set<string>()
|
|
1729
|
+
for (const list of left.values()) {
|
|
1730
|
+
for (const o of list) if (o.prefix !== '') prefixes.add(o.prefix).add(`${o.prefix}/`)
|
|
1731
|
+
}
|
|
1732
|
+
const ignored = checkIgnoreSync(workspaceRoot, [
|
|
1733
|
+
...asks.flatMap((a) => a?.paths ?? []),
|
|
1734
|
+
...prefixes,
|
|
1735
|
+
])
|
|
1736
|
+
let listed: string[] | null | undefined
|
|
1737
|
+
const pass = wholeIgnored(left.keys(), ignored, (g) => {
|
|
1738
|
+
listed ??= listedSync(workspaceRoot, [
|
|
1739
|
+
...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
|
|
1740
|
+
])
|
|
1741
|
+
return listed === null || listed.some((f) => taskGlob(g).match(f))
|
|
1742
|
+
})
|
|
1743
|
+
const named = (o: Overlap): boolean =>
|
|
1744
|
+
o.prefix !== '' && ignored !== null && (ignored.has(o.prefix) || ignored.has(`${o.prefix}/`))
|
|
1745
|
+
for (const [g, list] of left) {
|
|
1746
|
+
for (const o of list) if (!pass.has(g) || named(o)) throw o.refuse()
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Whether a reader may read a writer's outputs: the writer is in the
|
|
1752
|
+
* reader's `dependsOn` closure and not in its folded closure. Each closure
|
|
1753
|
+
* walked once per reader.
|
|
1754
|
+
*/
|
|
1755
|
+
function dependencyReads(
|
|
1756
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
1757
|
+
): (reader: TaskNode, writer: TaskNode) => boolean {
|
|
1758
|
+
const nodeOf = (id: string): TaskNode => nodes.get(id)!
|
|
1759
|
+
const deps = new Map<string, Set<string>>()
|
|
1760
|
+
const folded = new Map<string, Set<string>>()
|
|
1761
|
+
const closure = (
|
|
1762
|
+
memo: Map<string, Set<string>>,
|
|
1763
|
+
root: TaskNode,
|
|
1764
|
+
next: (n: TaskNode) => Iterable<TaskNode>,
|
|
1765
|
+
): Set<string> => {
|
|
1766
|
+
let out = memo.get(root.id)
|
|
1767
|
+
if (out !== undefined) return out
|
|
1768
|
+
out = new Set()
|
|
1769
|
+
const stack = [...next(root)]
|
|
1770
|
+
while (stack.length > 0) {
|
|
1771
|
+
const n = stack.pop()!
|
|
1772
|
+
if (out.has(n.id)) continue
|
|
1773
|
+
out.add(n.id)
|
|
1774
|
+
stack.push(...next(n))
|
|
1775
|
+
}
|
|
1776
|
+
memo.set(root.id, out)
|
|
1777
|
+
return out
|
|
1778
|
+
}
|
|
1779
|
+
return (reader, writer) =>
|
|
1780
|
+
closure(deps, reader, (n) => n.deps.flatMap((d) => nodes.get(d) ?? [])).has(writer.id) &&
|
|
1781
|
+
!closure(folded, reader, (n) => foldedDeps(n, nodeOf).map((c) => c.node)).has(writer.id)
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1666
1784
|
/**
|
|
1667
1785
|
* Whether a reader's take-back list covers an output glob, asked once per
|
|
1668
1786
|
* list and glob: a Turbo-mapped repo gives two thousand readers one
|
|
@@ -1683,16 +1801,15 @@ function takenBack(
|
|
|
1683
1801
|
): boolean {
|
|
1684
1802
|
let tb = memo.bySide.get(reader)
|
|
1685
1803
|
if (tb === undefined) {
|
|
1686
|
-
const
|
|
1804
|
+
const inputs = reader.node.config.inputs!
|
|
1805
|
+
const cache = reader.node.config.cache
|
|
1687
1806
|
// A reader rebased to the root (X-135) speaks in its project's terms.
|
|
1688
1807
|
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1689
1808
|
// What the reader's key never reads: its `!` entries and its own outputs.
|
|
1690
1809
|
const list = [
|
|
1810
|
+
...splitNegations(field === 'files' ? inputs.files : (inputs.workspaceFiles ?? [])).negative,
|
|
1691
1811
|
...splitNegations(
|
|
1692
|
-
field === 'files' ? cache.
|
|
1693
|
-
).negative,
|
|
1694
|
-
...splitNegations(
|
|
1695
|
-
field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
|
|
1812
|
+
field === 'files' ? (cache?.outputs.files ?? []) : (cache?.outputs.workspaceFiles ?? []),
|
|
1696
1813
|
).positive,
|
|
1697
1814
|
].map((g) => at + g)
|
|
1698
1815
|
const key = list.join('\0')
|
|
@@ -1707,6 +1824,7 @@ function takenBack(
|
|
|
1707
1824
|
|
|
1708
1825
|
function readsOutputs(
|
|
1709
1826
|
memo: TakeBackMemo,
|
|
1827
|
+
found: Found,
|
|
1710
1828
|
x: Side,
|
|
1711
1829
|
y: Side,
|
|
1712
1830
|
field: 'files' | 'workspaceFiles',
|
|
@@ -1714,22 +1832,36 @@ function readsOutputs(
|
|
|
1714
1832
|
if (x.reads === y.reads || x.node === y.node) return
|
|
1715
1833
|
const [reader, writer] = x.reads ? [x, y] : [y, x]
|
|
1716
1834
|
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1835
|
+
const readerAt = reader.anchor === undefined || reader.anchor === '' ? '' : `${reader.anchor}/`
|
|
1717
1836
|
for (const go of writer.globs) {
|
|
1837
|
+
const rooted =
|
|
1838
|
+
writer.anchor === undefined || writer.anchor === '' ? go : `${writer.anchor}/${go}`
|
|
1839
|
+
let list = found.get(rooted)
|
|
1840
|
+
if (list?.some((o) => o.reader === reader.node) === true) continue
|
|
1718
1841
|
if (takenBack(memo, reader, field, go)) continue
|
|
1719
1842
|
for (const gi of reader.globs) {
|
|
1720
1843
|
if (!outputsOverlap(gi, go)) continue
|
|
1721
1844
|
const shown =
|
|
1722
1845
|
writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
|
|
1723
1846
|
const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1847
|
+
const prefix = staticPrefix(readerAt + gi)
|
|
1848
|
+
if (list === undefined) found.set(rooted, (list = []))
|
|
1849
|
+
list.push({
|
|
1850
|
+
reader: reader.node,
|
|
1851
|
+
writer: writer.node,
|
|
1852
|
+
prefix: prefix === '.' ? '' : prefix,
|
|
1853
|
+
refuse: () =>
|
|
1854
|
+
new UserError(
|
|
1855
|
+
`${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in inputs.${field}, which matches ` +
|
|
1856
|
+
`${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
|
|
1857
|
+
`the outputs of a task it does not depend on, or whose key it already folds. ` +
|
|
1858
|
+
`Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
|
|
1859
|
+
`inputs.${field}, or depend on ${writer.node.id} and leave it out of ` +
|
|
1860
|
+
`${reader.node.id}'s inputs.tasks, so its key hashes those bytes instead.`,
|
|
1861
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1862
|
+
),
|
|
1863
|
+
})
|
|
1864
|
+
break
|
|
1733
1865
|
}
|
|
1734
1866
|
}
|
|
1735
1867
|
}
|