@vzn/vx 0.0.578 → 0.0.579
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.579",
|
|
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.579",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.579",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.579",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.579"
|
|
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,17 @@ 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
|
+
|
|
58
75
|
/**
|
|
59
76
|
* The runtime brand `definePlugin` stamps on a plugin: the name of the
|
|
60
77
|
* package it was defined in. The workspace loader accepts a plugin only
|
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,7 @@ 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)
|
|
552
558
|
}
|
|
553
559
|
|
|
554
560
|
/**
|
|
@@ -770,7 +776,11 @@ function covers(tree: string, glob: string): boolean {
|
|
|
770
776
|
* project boundaries by design, so ANY two tasks can. No cache key changes —
|
|
771
777
|
* this only refuses a graph that was already destroying files.
|
|
772
778
|
*/
|
|
773
|
-
function detectOutputCollisions(
|
|
779
|
+
function detectOutputCollisions(
|
|
780
|
+
nodes: Map<string, TaskNode>,
|
|
781
|
+
workspaceRoot: string | undefined,
|
|
782
|
+
exclusive: boolean,
|
|
783
|
+
): void {
|
|
774
784
|
// Does `from` reach `to` through deps? Asked only for a colliding pair,
|
|
775
785
|
// so the walk is rare; memoised per source across the detector's calls.
|
|
776
786
|
const reachMemo = new Map<string, Set<string>>()
|
|
@@ -834,14 +844,14 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
|
|
|
834
844
|
for (const [i, j] of overlapCandidates(bucket, filesOf)) {
|
|
835
845
|
const a = bucket[i]!
|
|
836
846
|
const b = bucket[j]!
|
|
837
|
-
collide(a, b, filesOf(a), filesOf(b), 'files', reaches)
|
|
847
|
+
collide(a, b, filesOf(a), filesOf(b), 'files', reaches, exclusive)
|
|
838
848
|
}
|
|
839
849
|
}
|
|
840
850
|
if (wsDeclarers.length >= 2) {
|
|
841
851
|
for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
|
|
842
852
|
const a = wsDeclarers[i]!
|
|
843
853
|
const b = wsDeclarers[j]!
|
|
844
|
-
collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
|
|
854
|
+
collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches, exclusive)
|
|
845
855
|
}
|
|
846
856
|
}
|
|
847
857
|
// A root-anchored output that reaches into ANOTHER project's `files`
|
|
@@ -870,7 +880,7 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
|
|
|
870
880
|
const y = entries[j]!
|
|
871
881
|
if ((x.rel === undefined) === (y.rel === undefined) || x.node === y.node) continue
|
|
872
882
|
const [ws, files] = x.rel === undefined ? [x, y] : [y, x]
|
|
873
|
-
collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches)
|
|
883
|
+
collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches, exclusive)
|
|
874
884
|
}
|
|
875
885
|
}
|
|
876
886
|
|
|
@@ -889,11 +899,20 @@ function collideAcross(
|
|
|
889
899
|
rebased: readonly string[],
|
|
890
900
|
rel: string,
|
|
891
901
|
reaches: (from: string, to: string) => boolean,
|
|
902
|
+
exclusive: boolean,
|
|
892
903
|
): void {
|
|
893
904
|
if (neverWritesLocally(ws) || neverWritesLocally(files)) return
|
|
894
905
|
for (const ga of wsGlobs) {
|
|
895
906
|
for (const gb of rebased) {
|
|
896
907
|
if (!outputsOverlap(ga, gb)) continue
|
|
908
|
+
const head =
|
|
909
|
+
`${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
|
|
910
|
+
`inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
|
|
911
|
+
`cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
|
|
912
|
+
`cache-hit restore, so whichever of these runs second DELETES the other's output`
|
|
913
|
+
if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
|
|
914
|
+
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
|
|
915
|
+
}
|
|
897
916
|
if (reaches(files.id, ws.id)) {
|
|
898
917
|
;(files.addsToOutputsOf ??= []).push(ws.id)
|
|
899
918
|
;(ws.outputsAddedToBy ??= []).push(...rebased)
|
|
@@ -910,12 +929,8 @@ function collideAcross(
|
|
|
910
929
|
}
|
|
911
930
|
}
|
|
912
931
|
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.`,
|
|
932
|
+
`${head} and the run still reports success. Give each task its own output path` +
|
|
933
|
+
(exclusive ? '.' : ', or make one depend on the other.'),
|
|
919
934
|
)
|
|
920
935
|
}
|
|
921
936
|
}
|
|
@@ -1039,6 +1054,15 @@ function neverWritesLocally(n: TaskNode): boolean {
|
|
|
1039
1054
|
return n.config.exec?.remote === 'only'
|
|
1040
1055
|
}
|
|
1041
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* The way out of an edge-ordered overlap under `rules.exclusiveOutputs`: an
|
|
1059
|
+
* ordered pair is correct (the addition shape, item 588), only slower to
|
|
1060
|
+
* clean and restore, so the rule may be turned off.
|
|
1061
|
+
*/
|
|
1062
|
+
const EXCLUSIVE_OUTPUTS_FIX =
|
|
1063
|
+
'Give each task its own output path, or set rules: { exclusiveOutputs: false } in ' +
|
|
1064
|
+
"vx.workspace.ts to let a dependant add to its upstream's outputs."
|
|
1065
|
+
|
|
1042
1066
|
function collide(
|
|
1043
1067
|
a: TaskNode,
|
|
1044
1068
|
b: TaskNode,
|
|
@@ -1046,17 +1070,26 @@ function collide(
|
|
|
1046
1070
|
bGlobs: readonly string[] | undefined,
|
|
1047
1071
|
field: 'files' | 'workspaceFiles',
|
|
1048
1072
|
reaches: (from: string, to: string) => boolean,
|
|
1073
|
+
exclusive: boolean,
|
|
1049
1074
|
): void {
|
|
1050
1075
|
if (neverWritesLocally(a) || neverWritesLocally(b)) return
|
|
1051
1076
|
for (const ga of aGlobs ?? []) {
|
|
1052
1077
|
for (const gb of bGlobs ?? []) {
|
|
1053
1078
|
if (!outputsOverlap(ga, gb)) continue
|
|
1079
|
+
const head =
|
|
1080
|
+
`${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
|
|
1081
|
+
(ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
|
|
1082
|
+
` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
|
|
1083
|
+
`before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
|
|
1084
|
+
`output`
|
|
1054
1085
|
// An overlap WITH an edge is the addition shape (item 588): the
|
|
1055
1086
|
// dependant runs after its upstream and adds to that tree, so the
|
|
1056
1087
|
// 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
|
-
//
|
|
1088
|
+
// what it found. Marked on both, and the pair is allowed when
|
|
1089
|
+
// `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1090
|
+
// in either order, and the refusal below stands.
|
|
1059
1091
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1092
|
+
if (up !== undefined && exclusive) throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
|
|
1060
1093
|
if (up !== undefined && down !== undefined) {
|
|
1061
1094
|
const downGlobs = splitNegations(
|
|
1062
1095
|
(field === 'files'
|
|
@@ -1068,11 +1101,7 @@ function collide(
|
|
|
1068
1101
|
return
|
|
1069
1102
|
}
|
|
1070
1103
|
throw new UserError(
|
|
1071
|
-
`${
|
|
1072
|
-
(ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
|
|
1073
|
-
` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
|
|
1074
|
-
`before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
|
|
1075
|
-
`output and the run still reports success. Give each task its own output path.`,
|
|
1104
|
+
`${head} and the run still reports success. Give each task its own output path.`,
|
|
1076
1105
|
)
|
|
1077
1106
|
}
|
|
1078
1107
|
}
|
|
@@ -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
|
|
@@ -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'])
|
|
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 }`)
|
|
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`)
|