@vzn/vx 0.0.578 → 0.0.580
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/package.json +5 -5
- package/src/config.ts +23 -0
- package/src/graph/index.ts +1 -0
- package/src/graph/task-graph.ts +181 -21
- package/src/orchestrator/plugin-host.ts +3 -2
- package/src/orchestrator/prepare.ts +2 -0
- package/src/orchestrator/stable-keys.ts +8 -2
- package/src/workspace/config-schema.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.580",
|
|
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.580",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.580",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.580",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.580"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/config.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface WorkspaceConfig {
|
|
|
46
46
|
* folded into a cache key.
|
|
47
47
|
*/
|
|
48
48
|
cacheScope?: string
|
|
49
|
+
/**
|
|
50
|
+
* Graph rules a run checks before it schedules anything. Each is on unless
|
|
51
|
+
* set to `false`; turning one off allows a shape vx handles correctly but
|
|
52
|
+
* more slowly. Never folded into a cache key.
|
|
53
|
+
*/
|
|
54
|
+
rules?: WorkspaceRules
|
|
49
55
|
/**
|
|
50
56
|
* Plugins registered for this workspace, consulted in this order once
|
|
51
57
|
* per `vx run`: each fills any of the pipeline stages and capabilities
|
|
@@ -55,6 +61,23 @@ export interface WorkspaceConfig {
|
|
|
55
61
|
plugins?: readonly Plugin[]
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/** `WorkspaceConfig.rules`. */
|
|
65
|
+
export interface WorkspaceRules {
|
|
66
|
+
/**
|
|
67
|
+
* Refuse two tasks whose declared outputs overlap even when a `dependsOn`
|
|
68
|
+
* edge orders them. Off, the dependant adds to its upstream's tree and
|
|
69
|
+
* owns only what its run added (`caching.md` § Additive outputs). Two
|
|
70
|
+
* overlapping tasks with no edge are refused either way.
|
|
71
|
+
*/
|
|
72
|
+
exclusiveOutputs?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* Refuse a task whose input globs can match another task's declared
|
|
75
|
+
* outputs, so every key is derived before any task runs. Off, such a key
|
|
76
|
+
* waits for its producer (`caching.md` § Local restore tier).
|
|
77
|
+
*/
|
|
78
|
+
upfrontKeys?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
58
81
|
/**
|
|
59
82
|
* The runtime brand `definePlugin` stamps on a plugin: the name of the
|
|
60
83
|
* package it was defined in. The workspace loader accepts a plugin only
|
package/src/graph/index.ts
CHANGED
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
2
|
+
import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
|
|
3
3
|
import {
|
|
4
4
|
asTrees,
|
|
5
5
|
isLiteralPattern,
|
|
@@ -267,6 +267,8 @@ export interface BuildGraphOptions {
|
|
|
267
267
|
* with other root-anchored ones.
|
|
268
268
|
*/
|
|
269
269
|
workspaceRoot?: string
|
|
270
|
+
/** The workspace's `rules`; each is on unless set to `false`. */
|
|
271
|
+
rules?: WorkspaceRules | undefined
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
/**
|
|
@@ -519,7 +521,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
519
521
|
}
|
|
520
522
|
}
|
|
521
523
|
|
|
522
|
-
checkGraph(nodes, options.workspaceRoot)
|
|
524
|
+
checkGraph(nodes, options.workspaceRoot, options.rules)
|
|
523
525
|
return nodes
|
|
524
526
|
}
|
|
525
527
|
|
|
@@ -534,7 +536,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
534
536
|
* marks are derived here, so they are cleared first and follow the edges
|
|
535
537
|
* the graph has now.
|
|
536
538
|
*/
|
|
537
|
-
export function checkGraph(
|
|
539
|
+
export function checkGraph(
|
|
540
|
+
nodes: Map<string, TaskNode>,
|
|
541
|
+
workspaceRoot?: string,
|
|
542
|
+
rules?: WorkspaceRules,
|
|
543
|
+
): void {
|
|
538
544
|
for (const [key, node] of nodes) {
|
|
539
545
|
if (node.id !== key) {
|
|
540
546
|
throw new Error(`the task ${node.id} is stored under '${key}', not its own id`)
|
|
@@ -548,7 +554,8 @@ export function checkGraph(nodes: Map<string, TaskNode>, workspaceRoot?: string)
|
|
|
548
554
|
delete node.outputsAddedToBy
|
|
549
555
|
}
|
|
550
556
|
detectCycle(nodes)
|
|
551
|
-
detectOutputCollisions(nodes, workspaceRoot)
|
|
557
|
+
detectOutputCollisions(nodes, workspaceRoot, rules?.exclusiveOutputs !== false)
|
|
558
|
+
if (rules?.upfrontKeys !== false) detectInputOverlaps(nodes, workspaceRoot)
|
|
552
559
|
}
|
|
553
560
|
|
|
554
561
|
/**
|
|
@@ -770,7 +777,11 @@ function covers(tree: string, glob: string): boolean {
|
|
|
770
777
|
* project boundaries by design, so ANY two tasks can. No cache key changes —
|
|
771
778
|
* this only refuses a graph that was already destroying files.
|
|
772
779
|
*/
|
|
773
|
-
function detectOutputCollisions(
|
|
780
|
+
function detectOutputCollisions(
|
|
781
|
+
nodes: Map<string, TaskNode>,
|
|
782
|
+
workspaceRoot: string | undefined,
|
|
783
|
+
exclusive: boolean,
|
|
784
|
+
): void {
|
|
774
785
|
// Does `from` reach `to` through deps? Asked only for a colliding pair,
|
|
775
786
|
// so the walk is rare; memoised per source across the detector's calls.
|
|
776
787
|
const reachMemo = new Map<string, Set<string>>()
|
|
@@ -834,14 +845,14 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
|
|
|
834
845
|
for (const [i, j] of overlapCandidates(bucket, filesOf)) {
|
|
835
846
|
const a = bucket[i]!
|
|
836
847
|
const b = bucket[j]!
|
|
837
|
-
collide(a, b, filesOf(a), filesOf(b), 'files', reaches)
|
|
848
|
+
collide(a, b, filesOf(a), filesOf(b), 'files', reaches, exclusive)
|
|
838
849
|
}
|
|
839
850
|
}
|
|
840
851
|
if (wsDeclarers.length >= 2) {
|
|
841
852
|
for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
|
|
842
853
|
const a = wsDeclarers[i]!
|
|
843
854
|
const b = wsDeclarers[j]!
|
|
844
|
-
collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
|
|
855
|
+
collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches, exclusive)
|
|
845
856
|
}
|
|
846
857
|
}
|
|
847
858
|
// A root-anchored output that reaches into ANOTHER project's `files`
|
|
@@ -870,7 +881,7 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
|
|
|
870
881
|
const y = entries[j]!
|
|
871
882
|
if ((x.rel === undefined) === (y.rel === undefined) || x.node === y.node) continue
|
|
872
883
|
const [ws, files] = x.rel === undefined ? [x, y] : [y, x]
|
|
873
|
-
collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches)
|
|
884
|
+
collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches, exclusive)
|
|
874
885
|
}
|
|
875
886
|
}
|
|
876
887
|
|
|
@@ -889,11 +900,20 @@ function collideAcross(
|
|
|
889
900
|
rebased: readonly string[],
|
|
890
901
|
rel: string,
|
|
891
902
|
reaches: (from: string, to: string) => boolean,
|
|
903
|
+
exclusive: boolean,
|
|
892
904
|
): void {
|
|
893
905
|
if (neverWritesLocally(ws) || neverWritesLocally(files)) return
|
|
894
906
|
for (const ga of wsGlobs) {
|
|
895
907
|
for (const gb of rebased) {
|
|
896
908
|
if (!outputsOverlap(ga, gb)) continue
|
|
909
|
+
const head =
|
|
910
|
+
`${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
|
|
911
|
+
`inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
|
|
912
|
+
`cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
|
|
913
|
+
`cache-hit restore, so whichever of these runs second DELETES the other's output`
|
|
914
|
+
if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
|
|
915
|
+
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
|
|
916
|
+
}
|
|
897
917
|
if (reaches(files.id, ws.id)) {
|
|
898
918
|
;(files.addsToOutputsOf ??= []).push(ws.id)
|
|
899
919
|
;(ws.outputsAddedToBy ??= []).push(...rebased)
|
|
@@ -910,12 +930,8 @@ function collideAcross(
|
|
|
910
930
|
}
|
|
911
931
|
}
|
|
912
932
|
throw new UserError(
|
|
913
|
-
`${
|
|
914
|
-
|
|
915
|
-
`cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
|
|
916
|
-
`cache-hit restore, so whichever of these runs second DELETES the other's output and ` +
|
|
917
|
-
`the run still reports success. Give each task its own output path, or make one ` +
|
|
918
|
-
`depend on the other.`,
|
|
933
|
+
`${head} and the run still reports success. Give each task its own output path` +
|
|
934
|
+
(exclusive ? '.' : ', or make one depend on the other.'),
|
|
919
935
|
)
|
|
920
936
|
}
|
|
921
937
|
}
|
|
@@ -1039,6 +1055,15 @@ function neverWritesLocally(n: TaskNode): boolean {
|
|
|
1039
1055
|
return n.config.exec?.remote === 'only'
|
|
1040
1056
|
}
|
|
1041
1057
|
|
|
1058
|
+
/**
|
|
1059
|
+
* The way out of an edge-ordered overlap under `rules.exclusiveOutputs`: an
|
|
1060
|
+
* ordered pair is correct (the addition shape, item 588), only slower to
|
|
1061
|
+
* clean and restore, so the rule may be turned off.
|
|
1062
|
+
*/
|
|
1063
|
+
const EXCLUSIVE_OUTPUTS_FIX =
|
|
1064
|
+
'Give each task its own output path, or set rules: { exclusiveOutputs: false } in ' +
|
|
1065
|
+
"vx.workspace.ts to let a dependant add to its upstream's outputs."
|
|
1066
|
+
|
|
1042
1067
|
function collide(
|
|
1043
1068
|
a: TaskNode,
|
|
1044
1069
|
b: TaskNode,
|
|
@@ -1046,17 +1071,26 @@ function collide(
|
|
|
1046
1071
|
bGlobs: readonly string[] | undefined,
|
|
1047
1072
|
field: 'files' | 'workspaceFiles',
|
|
1048
1073
|
reaches: (from: string, to: string) => boolean,
|
|
1074
|
+
exclusive: boolean,
|
|
1049
1075
|
): void {
|
|
1050
1076
|
if (neverWritesLocally(a) || neverWritesLocally(b)) return
|
|
1051
1077
|
for (const ga of aGlobs ?? []) {
|
|
1052
1078
|
for (const gb of bGlobs ?? []) {
|
|
1053
1079
|
if (!outputsOverlap(ga, gb)) continue
|
|
1080
|
+
const head =
|
|
1081
|
+
`${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
|
|
1082
|
+
(ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
|
|
1083
|
+
` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
|
|
1084
|
+
`before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
|
|
1085
|
+
`output`
|
|
1054
1086
|
// An overlap WITH an edge is the addition shape (item 588): the
|
|
1055
1087
|
// dependant runs after its upstream and adds to that tree, so the
|
|
1056
1088
|
// order is fixed and the dependant's own set can be told apart from
|
|
1057
|
-
// what it found. Marked on both, and the pair is allowed
|
|
1058
|
-
//
|
|
1089
|
+
// what it found. Marked on both, and the pair is allowed when
|
|
1090
|
+
// `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1091
|
+
// in either order, and the refusal below stands.
|
|
1059
1092
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1093
|
+
if (up !== undefined && exclusive) throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
|
|
1060
1094
|
if (up !== undefined && down !== undefined) {
|
|
1061
1095
|
const downGlobs = splitNegations(
|
|
1062
1096
|
(field === 'files'
|
|
@@ -1068,11 +1102,137 @@ function collide(
|
|
|
1068
1102
|
return
|
|
1069
1103
|
}
|
|
1070
1104
|
throw new UserError(
|
|
1071
|
-
`${
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1105
|
+
`${head} and the run still reports success. Give each task its own output path.`,
|
|
1106
|
+
)
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Does the `!` set `negatives` take back every path the output glob
|
|
1113
|
+
* `output` can select? Each of the output's `asTrees` forms must be one of
|
|
1114
|
+
* the negatives' forms, sit inside a negative whole subtree (`covers`), or,
|
|
1115
|
+
* as a literal, be matched by one. Sound and no more: an output only partly
|
|
1116
|
+
* taken back answers false.
|
|
1117
|
+
*/
|
|
1118
|
+
export function outputTakenBack(output: string, negatives: readonly string[]): boolean {
|
|
1119
|
+
if (negatives.length === 0) return false
|
|
1120
|
+
const neg = asTrees(negatives)
|
|
1121
|
+
return asTrees([output]).every((t) =>
|
|
1122
|
+
neg.some((n) => n === t || covers(n, t) || (isLiteralPattern(t) && taskGlob(n).match(t))),
|
|
1123
|
+
)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** One task's inputs (`reads`) or outputs in one namespace, for `detectInputOverlaps`. */
|
|
1127
|
+
interface Side {
|
|
1128
|
+
node: TaskNode
|
|
1129
|
+
globs: readonly string[]
|
|
1130
|
+
reads: boolean
|
|
1131
|
+
/** The project's root-relative dir for a rebased `files` output. */
|
|
1132
|
+
rel?: string
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* `rules.upfrontKeys` (X-54): refuse a task whose input globs can match
|
|
1137
|
+
* another task's declared outputs. Such a key reads bytes a producer writes
|
|
1138
|
+
* this run, so it is not known until the producer ran (`stable-keys.ts`
|
|
1139
|
+
* keeps it waiting); refused, every key reads only what no task writes and
|
|
1140
|
+
* can be derived before anything runs (M, 2026-10-07: "hash ahead of time,
|
|
1141
|
+
* not waterfall"). A dependency's own key already cascades through
|
|
1142
|
+
* `dependsOn`, so the bytes it wrote add nothing a key needs.
|
|
1143
|
+
*
|
|
1144
|
+
* Proven overlaps only (`outputsOverlap`), as for outputs: a pair the rule
|
|
1145
|
+
* cannot decide passes, and the stability gate still keeps it waiting. The
|
|
1146
|
+
* scope mirrors `detectOutputCollisions`: `inputs.files` against another
|
|
1147
|
+
* same-project task's `outputs.files`; root-anchored `inputs.workspaceFiles`
|
|
1148
|
+
* against every other task's `outputs.workspaceFiles` and, with the root
|
|
1149
|
+
* known, its `outputs.files` rebased to the root. A task's own outputs are
|
|
1150
|
+
* already subtracted from its inputs, and an output the reader's `!` entries
|
|
1151
|
+
* take back whole is no overlap. A keyed group is exempt: the default
|
|
1152
|
+
* `build` reads `**` of a config-less dependency and runs nothing, and its
|
|
1153
|
+
* key waits as before.
|
|
1154
|
+
*/
|
|
1155
|
+
function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
|
|
1156
|
+
const byProject = new Map<string, Side[]>()
|
|
1157
|
+
const rooted: Side[] = []
|
|
1158
|
+
const filesWriters: TaskNode[] = []
|
|
1159
|
+
let projectReaders = false
|
|
1160
|
+
let rootReaders = false
|
|
1161
|
+
const bucket = (name: string): Side[] => {
|
|
1162
|
+
let list = byProject.get(name)
|
|
1163
|
+
if (list === undefined) byProject.set(name, (list = []))
|
|
1164
|
+
return list
|
|
1165
|
+
}
|
|
1166
|
+
for (const n of nodes.values()) {
|
|
1167
|
+
const cache = n.config.cache
|
|
1168
|
+
if (cache === undefined) continue
|
|
1169
|
+
const outFiles = splitNegations(cache.outputs.files).positive
|
|
1170
|
+
const outWs = splitNegations(cache.outputs.workspaceFiles ?? []).positive
|
|
1171
|
+
if (outFiles.length > 0) {
|
|
1172
|
+
bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
|
|
1173
|
+
filesWriters.push(n)
|
|
1174
|
+
}
|
|
1175
|
+
if (outWs.length > 0) rooted.push({ node: n, globs: outWs, reads: false })
|
|
1176
|
+
if (isGroupTask(n)) continue
|
|
1177
|
+
const inFiles = splitNegations(cache.inputs.files).positive
|
|
1178
|
+
const inWs = splitNegations(cache.inputs.workspaceFiles ?? []).positive
|
|
1179
|
+
if (inFiles.length > 0) {
|
|
1180
|
+
bucket(n.projectName).push({ node: n, globs: inFiles, reads: true })
|
|
1181
|
+
projectReaders = true
|
|
1182
|
+
}
|
|
1183
|
+
if (inWs.length > 0) {
|
|
1184
|
+
rooted.push({ node: n, globs: inWs, reads: true })
|
|
1185
|
+
rootReaders = true
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (projectReaders) {
|
|
1189
|
+
for (const sides of byProject.values()) {
|
|
1190
|
+
if (sides.length < 2) continue
|
|
1191
|
+
for (const [i, j] of overlapCandidates(sides, (s) => s.globs)) {
|
|
1192
|
+
readsOutputs(sides[i]!, sides[j]!, 'files')
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (!rootReaders) return
|
|
1197
|
+
if (workspaceRoot !== undefined) {
|
|
1198
|
+
for (const node of filesWriters) {
|
|
1199
|
+
const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
|
|
1200
|
+
const globs = splitNegations(node.config.cache!.outputs.files).positive.map((g) =>
|
|
1201
|
+
rel === '' ? g : `${rel}/${g}`,
|
|
1202
|
+
)
|
|
1203
|
+
rooted.push({ node, globs, reads: false, rel })
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
for (const [i, j] of overlapCandidates(rooted, (s) => s.globs)) {
|
|
1207
|
+
readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function readsOutputs(x: Side, y: Side, field: 'files' | 'workspaceFiles'): void {
|
|
1212
|
+
if (x.reads === y.reads || x.node === y.node) return
|
|
1213
|
+
const [reader, writer] = x.reads ? [x, y] : [y, x]
|
|
1214
|
+
const cache = reader.node.config.cache!
|
|
1215
|
+
// What the reader's key never reads: its `!` entries and its own outputs.
|
|
1216
|
+
const takeBack = [
|
|
1217
|
+
...splitNegations(field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []))
|
|
1218
|
+
.negative,
|
|
1219
|
+
...splitNegations(
|
|
1220
|
+
field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
|
|
1221
|
+
).positive,
|
|
1222
|
+
]
|
|
1223
|
+
for (const go of writer.globs) {
|
|
1224
|
+
if (outputTakenBack(go, takeBack)) continue
|
|
1225
|
+
for (const gi of reader.globs) {
|
|
1226
|
+
if (!outputsOverlap(gi, go)) continue
|
|
1227
|
+
const shown =
|
|
1228
|
+
writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
|
|
1229
|
+
throw new UserError(
|
|
1230
|
+
`${reader.node.id} reads ${JSON.stringify(gi)} in cache.inputs.${field}, which matches ` +
|
|
1231
|
+
`${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
|
|
1232
|
+
`another task's outputs (the dependency's key already cascades through dependsOn). ` +
|
|
1233
|
+
`Exclude it: add ${JSON.stringify(`!${go}`)} to ${reader.node.id}'s ` +
|
|
1234
|
+
`cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
|
|
1235
|
+
`let it wait for its producer.`,
|
|
1076
1236
|
)
|
|
1077
1237
|
}
|
|
1078
1238
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { ChainedCache, type CacheLayer } from '../cache/index.js'
|
|
11
11
|
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
12
12
|
import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
13
|
-
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
13
|
+
import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
|
|
14
14
|
import { checkGraph, type TaskNode } from '../graph/index.js'
|
|
15
15
|
import type {
|
|
16
16
|
CacheContext,
|
|
@@ -178,6 +178,7 @@ export async function applyGraphHooks(
|
|
|
178
178
|
ctx: GraphHookContext,
|
|
179
179
|
/** Runs after EACH plugin's edit, so a refused task config names the plugin that wrote it. */
|
|
180
180
|
afterEach?: (plugin: VxPlugin) => void,
|
|
181
|
+
rules?: WorkspaceRules,
|
|
181
182
|
): Promise<void> {
|
|
182
183
|
let last: VxPlugin | undefined
|
|
183
184
|
for (const plugin of plugins) {
|
|
@@ -188,7 +189,7 @@ export async function applyGraphHooks(
|
|
|
188
189
|
last = plugin
|
|
189
190
|
}
|
|
190
191
|
if (last === undefined) return
|
|
191
|
-
await safe(last, 'graph', () => checkGraph(nodes, ctx.workspaceRoot))
|
|
192
|
+
await safe(last, 'graph', () => checkGraph(nodes, ctx.workspaceRoot, rules))
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
/**
|
|
@@ -515,6 +515,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
515
515
|
packageGraph,
|
|
516
516
|
requested: req,
|
|
517
517
|
workspaceRoot,
|
|
518
|
+
rules: workspaceConfig?.rules,
|
|
518
519
|
...(projects.size < projectsWithConfigs.length
|
|
519
520
|
? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
|
|
520
521
|
: {}),
|
|
@@ -628,6 +629,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
628
629
|
)
|
|
629
630
|
}
|
|
630
631
|
},
|
|
632
|
+
workspaceConfig?.rules,
|
|
631
633
|
)
|
|
632
634
|
}
|
|
633
635
|
// Args after `--` go to requested commands only; with none among them
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// fall back to lazy read-through in execute-task, which is always
|
|
12
12
|
// correct. The check is conservative: when unsure, treat as unstable.
|
|
13
13
|
|
|
14
|
-
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
14
|
+
import { isGroupTask, outputTakenBack, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
15
15
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
16
|
import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
|
|
17
17
|
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
@@ -458,7 +458,13 @@ export function dependsOnSiblingOutputs(
|
|
|
458
458
|
const inputs = cache.inputs?.files
|
|
459
459
|
const outputs = sameProject.outputsOf.get(node.projectName) ?? []
|
|
460
460
|
if (inputs === undefined || outputs.length === 0) return true
|
|
461
|
-
|
|
461
|
+
// An output the reader's own `!` inputs take back whole is not in its
|
|
462
|
+
// key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
|
|
463
|
+
// before and after it, which is what `rules.upfrontKeys` asks a config
|
|
464
|
+
// to write (X-54).
|
|
465
|
+
const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
466
|
+
const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
|
|
467
|
+
if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
|
|
462
468
|
// Nor where it WRITES inside another task's tree: restored ahead of
|
|
463
469
|
// that producer, its restore raced the producer's clean and extract
|
|
464
470
|
// (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
|
|
@@ -54,9 +54,25 @@ const WORKSPACE_FIELDS = new Set([
|
|
|
54
54
|
'cacheRetention',
|
|
55
55
|
'affectedBase',
|
|
56
56
|
'cacheScope',
|
|
57
|
+
'rules',
|
|
57
58
|
'plugins',
|
|
58
59
|
])
|
|
59
60
|
|
|
61
|
+
const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
|
|
62
|
+
|
|
63
|
+
function validateRules(rules: unknown, configPath: string): void {
|
|
64
|
+
const where = `${configPath}: \`rules\``
|
|
65
|
+
if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
|
|
66
|
+
throw new UserError(`${where} must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }`)
|
|
67
|
+
}
|
|
68
|
+
assertKnownFields(rules as Record<string, unknown>, RULE_FIELDS, where)
|
|
69
|
+
for (const [name, value] of Object.entries(rules)) {
|
|
70
|
+
if (value !== undefined && typeof value !== 'boolean') {
|
|
71
|
+
throw new UserError(`${where}.${name} must be true or false`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
|
|
61
77
|
|
|
62
78
|
function validateRetention(retention: unknown, configPath: string): void {
|
|
@@ -142,6 +158,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
142
158
|
if (config.cacheScope !== undefined && !isCacheScope(config.cacheScope)) {
|
|
143
159
|
throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
|
|
144
160
|
}
|
|
161
|
+
if (config.rules !== undefined) validateRules(config.rules, configPath)
|
|
145
162
|
if (config.plugins !== undefined) {
|
|
146
163
|
if (!Array.isArray(config.plugins)) {
|
|
147
164
|
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|