@vzn/vx 0.0.582 → 0.0.585
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/cli/watch-filter.ts +3 -4
- package/src/cli/watch.ts +2 -3
- package/src/graph/scheduler.ts +47 -2
- package/src/graph/task-graph.ts +8 -0
- package/src/orchestrator/prepare.ts +6 -6
- package/src/orchestrator/projects.ts +2 -3
- package/src/orchestrator/sandbox-request.ts +11 -5
- package/src/workspace/migration.ts +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.585",
|
|
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.585",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.585",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.585",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.585"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -270,10 +270,9 @@ export function makeRootEventFilter(
|
|
|
270
270
|
* project nested there: the key's boundary (`computeNestedProjectDirs`)
|
|
271
271
|
* leaves such a file out, so it is no edit of `ownDir`'s. A root project
|
|
272
272
|
* watched its whole tree and ran a cycle for every edit in a nested one
|
|
273
|
-
* (X-42). The fences are
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* moves the boundary, and the cycle it starts re-reads the set.
|
|
273
|
+
* (X-42). The fences are every project, config or not, as the key's are
|
|
274
|
+
* (X-57). The fence's own config is let through: it may give the project
|
|
275
|
+
* tasks, and the cycle it starts re-reads the set.
|
|
277
276
|
*/
|
|
278
277
|
export function makeFence(fenceDirs: readonly string[]): (ownDir: string, abs: string) => boolean {
|
|
279
278
|
const fences = fenceDirs.map((d) => path.resolve(d))
|
package/src/cli/watch.ts
CHANGED
|
@@ -316,14 +316,13 @@ interface WatchLoopArgs {
|
|
|
316
316
|
memberBases: readonly string[]
|
|
317
317
|
/** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
|
|
318
318
|
packageDirs: ReadonlySet<string>
|
|
319
|
-
/** Every project
|
|
319
|
+
/** Every project, in scope or not: a project's key leaves out what lies in one nested under it. */
|
|
320
320
|
fenceDirs: readonly string[]
|
|
321
321
|
/** The watched set again, after a cycle that followed an event which can change it. */
|
|
322
322
|
rediscover: () => Promise<Rediscovered>
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
const fenceDirs = (all: readonly ProjectMeta[]): string[] =>
|
|
326
|
-
all.filter((p) => p.configPath !== null).map((p) => p.dir)
|
|
325
|
+
const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.dir)
|
|
327
326
|
|
|
328
327
|
interface Rediscovered {
|
|
329
328
|
projects: readonly ProjectMeta[]
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -267,6 +267,9 @@ export interface ScheduleOptions {
|
|
|
267
267
|
* output is reported `cache-hit` regardless of a dep failing);
|
|
268
268
|
* - leaves the tier when `execute` throws `RestoreDemoted`, and is
|
|
269
269
|
* dispatched again once its deps are done, with the dep check.
|
|
270
|
+
* A persistent task nobody requested whose every dependant is in the
|
|
271
|
+
* tier starts only when one of them is demoted; once they have all
|
|
272
|
+
* finished it settles without an outcome, never spawned.
|
|
270
273
|
* It still runs through `execute()` (so the logger frame is unchanged);
|
|
271
274
|
* the orchestrator's execute reuses the up-front probe, so there is no
|
|
272
275
|
* second cache.get. When undefined/empty, behavior is byte-identical.
|
|
@@ -463,6 +466,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
463
466
|
// they are exec-tier tasks, gated on their deps like any other.
|
|
464
467
|
const demoted = new Set<string>()
|
|
465
468
|
const inRestoreTier = (id: string): boolean => restoreTier?.has(id) === true && !demoted.has(id)
|
|
469
|
+
// A server pulled in only for restore-tier hits serves nobody: it waits
|
|
470
|
+
// (`dormant`) until a dependant is demoted, which starts it, or until
|
|
471
|
+
// every dependant has finished, which settles it unspawned. Settled
|
|
472
|
+
// without an outcome: it is not part of the run, as a pruned task is not.
|
|
473
|
+
const idle = idleServers(nodes, dependents, restoreTier)
|
|
474
|
+
const dormant = new Set<string>()
|
|
475
|
+
let settledIdle = 0
|
|
466
476
|
const execReady = new ReadyHeap(priority)
|
|
467
477
|
const restoreReady = new ReadyHeap(priority)
|
|
468
478
|
// A restore-tier task is dep-independent (a stable hit's restore needs
|
|
@@ -580,6 +590,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
580
590
|
const finishOne = (id: string, outcome: TaskOutcome): void => {
|
|
581
591
|
if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
|
|
582
592
|
outcomes.set(id, outcome)
|
|
593
|
+
// A dormant server is asked again: the dispatch settles it once its
|
|
594
|
+
// last dependant is in.
|
|
595
|
+
if (dormant.size > 0) {
|
|
596
|
+
for (const p of (nodes.get(id) as TaskNode).deps) if (dormant.delete(p)) execReady.push(p)
|
|
597
|
+
}
|
|
583
598
|
// Observer hook (the logger's taskComplete). Crash-isolated: a
|
|
584
599
|
// throwing observer must NOT break scheduling — it would otherwise
|
|
585
600
|
// skip the dependent-enqueue + tick below and hang the run. Same
|
|
@@ -724,7 +739,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
724
739
|
while (execReady.size > 0) {
|
|
725
740
|
const seq = execReady.peekSeq()
|
|
726
741
|
const id = execReady.pop() as string
|
|
727
|
-
if (willSkip(id)) return id
|
|
742
|
+
if (willSkip(id) || idle.has(id)) return id
|
|
728
743
|
if (exclusive?.has(id) === true) {
|
|
729
744
|
if (!busy() && (!admitActive || admits(id))) return id
|
|
730
745
|
parked.push([id, seq])
|
|
@@ -779,6 +794,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
779
794
|
})
|
|
780
795
|
continue
|
|
781
796
|
}
|
|
797
|
+
if (idle.has(id)) {
|
|
798
|
+
readyAt.delete(id)
|
|
799
|
+
if (dependents.get(id)!.every((d) => outcomes.has(d))) {
|
|
800
|
+
settledIdle++
|
|
801
|
+
release(id)
|
|
802
|
+
} else dormant.add(id)
|
|
803
|
+
continue
|
|
804
|
+
}
|
|
782
805
|
|
|
783
806
|
const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
|
|
784
807
|
readyAt.delete(id)
|
|
@@ -847,6 +870,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
847
870
|
leave()
|
|
848
871
|
untrack()
|
|
849
872
|
demoted.add(id)
|
|
873
|
+
for (const p of node.deps) {
|
|
874
|
+
if (idle.delete(p) && dormant.delete(p)) {
|
|
875
|
+
execReady.push(p)
|
|
876
|
+
readyAt.set(p, Date.now())
|
|
877
|
+
}
|
|
878
|
+
}
|
|
850
879
|
if (pending.get(id) === 0) {
|
|
851
880
|
execReady.push(id)
|
|
852
881
|
readyAt.set(id, Date.now())
|
|
@@ -919,7 +948,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
919
948
|
// takes none), so a task still holding one has no outcome yet, and
|
|
920
949
|
// the lane counters could add nothing — deleting both from this
|
|
921
950
|
// condition survived the whole core suite (item 644).
|
|
922
|
-
if (outcomes.size === nodes.size) {
|
|
951
|
+
if (outcomes.size + settledIdle === nodes.size) {
|
|
923
952
|
resolved = true
|
|
924
953
|
resolve(outcomes)
|
|
925
954
|
}
|
|
@@ -928,3 +957,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
928
957
|
tick()
|
|
929
958
|
})
|
|
930
959
|
}
|
|
960
|
+
|
|
961
|
+
/** Persistent tasks nobody asked for whose every dependant is a restore-tier hit. */
|
|
962
|
+
function idleServers(
|
|
963
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
964
|
+
dependents: ReadonlyMap<string, string[]>,
|
|
965
|
+
restoreTier: ReadonlySet<string> | undefined,
|
|
966
|
+
): Set<string> {
|
|
967
|
+
const out = new Set<string>()
|
|
968
|
+
if (restoreTier === undefined || restoreTier.size === 0) return out
|
|
969
|
+
for (const n of nodes.values()) {
|
|
970
|
+
if (n.config.exec?.persistent === undefined || n.requested || n.surfaced === true) continue
|
|
971
|
+
const ds = dependents.get(n.id)
|
|
972
|
+
if (ds !== undefined && ds.every((d) => restoreTier.has(d))) out.add(n.id)
|
|
973
|
+
}
|
|
974
|
+
return out
|
|
975
|
+
}
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -929,6 +929,14 @@ function collideAcross(
|
|
|
929
929
|
;(files.outputsAddedToBy ??= []).push(...own)
|
|
930
930
|
return
|
|
931
931
|
}
|
|
932
|
+
// Already ordered: "make one depend on the other" would send the
|
|
933
|
+
// reader after an edge the config has. Name what keeps it out.
|
|
934
|
+
const outside = wsGlobs[own.indexOf(null)]!
|
|
935
|
+
throw new UserError(
|
|
936
|
+
`${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
|
|
937
|
+
`cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
|
|
938
|
+
`and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
|
|
939
|
+
)
|
|
932
940
|
}
|
|
933
941
|
throw new UserError(
|
|
934
942
|
`${head} and the run still reports success. Give each task its own output path` +
|
|
@@ -357,12 +357,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
357
357
|
const { projects, configured: projectsWithConfigs } = loaded
|
|
358
358
|
mark('load configs')
|
|
359
359
|
|
|
360
|
-
// Boundary geometry considers every
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
)
|
|
360
|
+
// Boundary geometry considers every workspace project, loaded or not,
|
|
361
|
+
// config or not: `--affected` gives a changed path to the deepest project
|
|
362
|
+
// dir, and a config-less one keys its files under its default `build`, so
|
|
363
|
+
// a parent's glob that folded (or cleaned) them crossed a boundary the
|
|
364
|
+
// selection drew (X-57).
|
|
365
|
+
const nestedDirsByProject = computeNestedProjectDirs(projectMetas)
|
|
366
366
|
|
|
367
367
|
const candidateProjects = options.projects
|
|
368
368
|
? options.projects.filter((p) => projects.has(p))
|
|
@@ -155,8 +155,7 @@ export interface LoadedProjects {
|
|
|
155
155
|
projects: Map<string, ProjectEntry>
|
|
156
156
|
/**
|
|
157
157
|
* Every project that can carry tasks: it has a config file, or a plugin
|
|
158
|
-
* fills the `project` stage and may give it tasks.
|
|
159
|
-
* fences all of them, loaded or not.
|
|
158
|
+
* fills the `project` stage and may give it tasks.
|
|
160
159
|
*/
|
|
161
160
|
configured: readonly ProjectMeta[]
|
|
162
161
|
}
|
|
@@ -179,7 +178,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
179
178
|
// scripts mapped onto packages that never wrote a `vx.config.ts`). It
|
|
180
179
|
// then loads as `{ tasks: {} }` — nothing to evaluate, nothing to freeze
|
|
181
180
|
// — and the stage runs on that like on any loaded config. With no
|
|
182
|
-
// `project` plugin a plain run never seeds
|
|
181
|
+
// `project` plugin a plain run never seeds a config-less
|
|
183
182
|
// package, but loads one a loaded project's closure reaches: it gets the
|
|
184
183
|
// default `build`, so a dependant bundling its source is keyed on it
|
|
185
184
|
// behind `^build`. Left out, its edit replayed the dependant's stale
|
|
@@ -240,12 +240,18 @@ export async function sandboxRequestFor(
|
|
|
240
240
|
? [cacheDir]
|
|
241
241
|
: []),
|
|
242
242
|
].map(toRealPath)
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
)
|
|
243
|
+
const resolved = resolveSandboxConfig(sandbox, node.projectDir, walls)
|
|
244
|
+
// Made before the walls judge the binds: a directory grant naming nothing
|
|
245
|
+
// yet (`dist/`, `dist/**`) was judged as a file, its bind its parent, and
|
|
246
|
+
// a root project's was refused for `.git`.
|
|
248
247
|
const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
|
|
248
|
+
let config: ResolvedSandboxConfig
|
|
249
|
+
try {
|
|
250
|
+
config = wallOff(resolved, workspaceRoot, walls)
|
|
251
|
+
} catch (err) {
|
|
252
|
+
await sweepPlaceholders(placeholders)
|
|
253
|
+
throw err
|
|
254
|
+
}
|
|
249
255
|
const request: NonNullable<ExecuteRequest['sandbox']> = {
|
|
250
256
|
// Only what the task declared, plus node_modules. Write paths are
|
|
251
257
|
// readable too: a task that writes `dist/x` expects to read it back
|
|
@@ -50,9 +50,13 @@ export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
|
|
|
50
50
|
* forwarded `--` args are appended to, and only the body takes them, as
|
|
51
51
|
* npm appends them to the script and never to its hooks. A plain ` && `
|
|
52
52
|
* join handed them to the post hook, and `test -f x && echo A; echo B` ran
|
|
53
|
-
* `echo B` after a failed pre hook and went green (item 905).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* `echo B` after a failed pre hook and went green (item 905). npm appends
|
|
54
|
+
* them as TEXT: no part sees them as `$1`…, so the function quotes them
|
|
55
|
+
* into `vx_a`, clears its positional parameters, and evals the body with
|
|
56
|
+
* `vx_a` after it; `"$@"` on the body made a script's `$1` the first
|
|
57
|
+
* forwarded arg and its `$*` print them twice. Each part ends on its own
|
|
58
|
+
* line, so a trailing `# comment` cannot swallow the paren. A script with
|
|
59
|
+
* no hooks is its body, verbatim.
|
|
56
60
|
*/
|
|
57
61
|
export function foldScriptHooks(
|
|
58
62
|
pre: string | undefined,
|
|
@@ -60,14 +64,22 @@ export function foldScriptHooks(
|
|
|
60
64
|
post: string | undefined,
|
|
61
65
|
): string {
|
|
62
66
|
if (pre === undefined && post === undefined) return body
|
|
67
|
+
const quoted = `'${body.replaceAll("'", `'\\''`)}'`
|
|
63
68
|
const parts = [
|
|
64
69
|
...(pre === undefined ? [] : [`(${pre}\n)`]),
|
|
65
|
-
`(${
|
|
70
|
+
`(eval ${quoted}"$vx_a"\n)`,
|
|
66
71
|
...(post === undefined ? [] : [`(${post}\n)`]),
|
|
67
72
|
]
|
|
68
|
-
return `vx_script() {\n${parts.join(' && ')}\n}\nvx_script`
|
|
73
|
+
return `vx_script() {\n${QUOTE_ARGS}\nset --\n${parts.join(' && ')}\n}\nvx_script`
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
/** Each argument single-quoted into `vx_a`, in sh alone (no spawn per arg). */
|
|
77
|
+
const QUOTE_ARGS =
|
|
78
|
+
`vx_q="'" vx_a=\n` +
|
|
79
|
+
`for vx_s in "$@"; do vx_r=; while :; do case $vx_s in *"$vx_q"*) ` +
|
|
80
|
+
`vx_r="$vx_r\${vx_s%%"$vx_q"*}'\\''"; vx_s=\${vx_s#*"$vx_q"};; *) break;; esac; done; ` +
|
|
81
|
+
`vx_a="$vx_a '$vx_r$vx_s'"; done`
|
|
82
|
+
|
|
71
83
|
/**
|
|
72
84
|
* Where a framework's build writes by default, for the cache TODO: the
|
|
73
85
|
* hint said `dist/**` to every build, `next build` included, which writes
|