@vzn/vx 0.0.605 → 0.0.607
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/cache/key-fold.ts +4 -0
- package/src/cli/watch.ts +171 -124
- package/src/cli/why.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.607",
|
|
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.607",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.607",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.607",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.607"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -153,6 +153,10 @@ export async function foldKey(
|
|
|
153
153
|
h = xxh3(`pkg:${input.projectPackageJsonHash}`, h)
|
|
154
154
|
h = xxh3(`config:${input.taskConfigHash}`, h)
|
|
155
155
|
if (cap) {
|
|
156
|
+
// Recorded so `vx why` can name a key that moved only because vx
|
|
157
|
+
// keys differently now (an upgrade): unrecorded, every task's miss
|
|
158
|
+
// read as "inputs differ" with no input changed (X-142).
|
|
159
|
+
cap.push({ kind: 'format', name: 'cache-version', hash: CACHE_VERSION })
|
|
156
160
|
cap.push({ kind: 'workspace', name: 'fingerprint', hash: input.workspaceFingerprint })
|
|
157
161
|
cap.push({ kind: 'package', name: 'package.json', hash: input.projectPackageJsonHash })
|
|
158
162
|
cap.push({ kind: 'config', name: 'config', hash: input.taskConfigHash })
|
package/src/cli/watch.ts
CHANGED
|
@@ -192,30 +192,49 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
192
192
|
// config loaded — so the initial run's own loads become REPEATs and pay a
|
|
193
193
|
// worker round-trip each (see config-eval.ts). Trading a hot-path regression
|
|
194
194
|
// on every `vx watch` for a window a user rarely edits into is a bad deal;
|
|
195
|
-
// closing it properly needs the workspaceWide decision made without loading
|
|
196
195
|
// configs.
|
|
196
|
+
// The exception: once the run waits on a server's readiness alone, every
|
|
197
|
+
// config is loaded, and the loop arms with the run as its cycle in
|
|
198
|
+
// flight. Unarmed, a server that never got ready held the initial run
|
|
199
|
+
// for good, and the fix to it was never heard (X-139).
|
|
197
200
|
process.stdout.write('vx watch: initial run...\n\n')
|
|
201
|
+
let loop: Promise<void> | undefined
|
|
202
|
+
const initial: InitialCycle = {
|
|
203
|
+
cycle: watchCycle(opts, stop.signal, () => {
|
|
204
|
+
if (initial.onWaiting !== undefined) initial.onWaiting()
|
|
205
|
+
else if (loop === undefined && scope.length > 0 && !stop.signal.aborted)
|
|
206
|
+
loop = armLoop(initial)
|
|
207
|
+
}),
|
|
208
|
+
held: Promise.resolve(undefined),
|
|
209
|
+
}
|
|
198
210
|
// A run that throws (a config that does not parse) is a failed cycle, as
|
|
199
211
|
// it is once the loop runs: watch keeps watching, so the fix re-runs.
|
|
200
212
|
// Uncaught, it ended watch at start while the same break mid-watch did
|
|
201
213
|
// not (item 1017).
|
|
202
|
-
let held: HeldPersistent | undefined
|
|
203
214
|
let refusedToStart = false
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
initial.held = runOrchestrator(initial.cycle.opts).then(
|
|
216
|
+
(run) => {
|
|
217
|
+
if (run.refused !== undefined) process.stderr.write(`vx watch: ${run.refused}\n`)
|
|
218
|
+
// A run that failed having run nothing refused to start: a requested
|
|
219
|
+
// name no project declares (run() says which, with a "did you mean").
|
|
220
|
+
// `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
|
|
221
|
+
// same refusal on every change, since no edit to an input can declare
|
|
222
|
+
// a task. A name only the diff left out (`--affected`) is `ok` and
|
|
223
|
+
// keeps watching.
|
|
224
|
+
refusedToStart = !run.ok && run.outcomes.length === 0
|
|
225
|
+
return run.persistent
|
|
226
|
+
},
|
|
227
|
+
(err: unknown) => {
|
|
228
|
+
process.stderr.write(
|
|
229
|
+
`vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
230
|
+
)
|
|
231
|
+
return undefined
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
const held = await initial.held
|
|
235
|
+
if (loop !== undefined) {
|
|
236
|
+
await loop
|
|
237
|
+
return stopped()
|
|
219
238
|
}
|
|
220
239
|
if (stop.signal.aborted) {
|
|
221
240
|
await held?.stop(forwardedSignal(stop.signal.reason))
|
|
@@ -229,82 +248,98 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
229
248
|
process.stderr.write(`vx watch: no projects in scope\n`)
|
|
230
249
|
return 1
|
|
231
250
|
}
|
|
232
|
-
|
|
233
|
-
// later edit out of the scope it picked. A cycle is an edit, and the
|
|
234
|
-
// cache keys decide what in the scope it re-runs.
|
|
235
|
-
delete opts.affected
|
|
236
|
-
delete opts.selectedOutright
|
|
237
|
-
|
|
238
|
-
const load: CliLoadOptions = {
|
|
239
|
-
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
240
|
-
...(opts.frozen === true ? { frozen: true } : {}),
|
|
241
|
-
}
|
|
242
|
-
const swept = await sweepConfigs(allProjects, workspaceRoot, load, opts.tasks)
|
|
243
|
-
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
244
|
-
const ws = await loadCliWorkspace(workspaceRoot)
|
|
245
|
-
const claimsOf = (plugins: Parameters<typeof fingerprintClaims>[0]): Set<string> =>
|
|
246
|
-
new Set([
|
|
247
|
-
...fingerprintClaims(plugins).keys(),
|
|
248
|
-
...(opts.frozen === true ? [LOCKFILE_NAME] : []),
|
|
249
|
-
])
|
|
250
|
-
await runWatchLoop({
|
|
251
|
-
opts,
|
|
252
|
-
held,
|
|
253
|
-
stop: stop.signal,
|
|
254
|
-
workspaceRoot,
|
|
255
|
-
projects: watched,
|
|
256
|
-
workspaceWide: swept.workspaceWide,
|
|
257
|
-
projectDirs: watched.map((p) => p.dir),
|
|
258
|
-
workspaceInputs: swept.workspaceInputs,
|
|
259
|
-
outputs: swept.outputs,
|
|
260
|
-
inputs: swept.inputs,
|
|
261
|
-
uncached: swept.uncached,
|
|
262
|
-
configImports: swept.configImports,
|
|
263
|
-
workspaceConfigImports: swept.workspaceConfigImports,
|
|
264
|
-
memberBases: memberBaseDirs(workspace),
|
|
265
|
-
packageDirs: new Set(allProjects.map((p) => p.dir)),
|
|
266
|
-
fenceDirs: fenceDirs(allProjects),
|
|
267
|
-
// The workspace as the cycle that just ran saw it: a package added or
|
|
268
|
-
// removed since the loop armed joins or leaves the watched set. The
|
|
269
|
-
// scope is the one resolved at start; a new package joins it only as a
|
|
270
|
-
// dependency of it.
|
|
271
|
-
rediscover: async () => {
|
|
272
|
-
const workspace = await loadWorkspace(workspaceRoot)
|
|
273
|
-
const all = await discoverCliProjects(workspace)
|
|
274
|
-
const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
|
|
275
|
-
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
276
|
-
return {
|
|
277
|
-
projects: now,
|
|
278
|
-
// A plugin the workspace config gained since claims its file from
|
|
279
|
-
// the cycle that loaded it; read once, its edits started nothing
|
|
280
|
-
// until a restart.
|
|
281
|
-
claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot)).plugins),
|
|
282
|
-
memberBases: memberBaseDirs(workspace),
|
|
283
|
-
workspaceWide: sweep.workspaceWide,
|
|
284
|
-
workspaceInputs: sweep.workspaceInputs,
|
|
285
|
-
outputs: sweep.outputs,
|
|
286
|
-
inputs: sweep.inputs,
|
|
287
|
-
uncached: sweep.uncached,
|
|
288
|
-
configImports: sweep.configImports,
|
|
289
|
-
workspaceConfigImports: sweep.workspaceConfigImports,
|
|
290
|
-
packageDirs: new Set(all.map((p) => p.dir)),
|
|
291
|
-
fenceDirs: fenceDirs(all),
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
// Under --frozen every cycle's configs are the lock's, so a re-lock is
|
|
295
|
-
// the one edit that changes what a cycle runs; unheard, the loop ran
|
|
296
|
-
// the old lock until a restart (item 971).
|
|
297
|
-
claimedRootFiles: claimsOf(ws.plugins),
|
|
298
|
-
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
299
|
-
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
300
|
-
})
|
|
251
|
+
await armLoop(undefined, held)
|
|
301
252
|
return stopped()
|
|
253
|
+
|
|
254
|
+
async function armLoop(initial?: InitialCycle, held?: HeldPersistent): Promise<void> {
|
|
255
|
+
// `--affected`'s diff is the tree at start; judged again, it held every
|
|
256
|
+
// later edit out of the scope it picked. A cycle is an edit, and the
|
|
257
|
+
// cache keys decide what in the scope it re-runs.
|
|
258
|
+
delete opts.affected
|
|
259
|
+
delete opts.selectedOutright
|
|
260
|
+
|
|
261
|
+
const load: CliLoadOptions = {
|
|
262
|
+
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
263
|
+
...(opts.frozen === true ? { frozen: true } : {}),
|
|
264
|
+
}
|
|
265
|
+
const swept = await sweepConfigs(allProjects, workspaceRoot, load, opts.tasks)
|
|
266
|
+
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
267
|
+
const ws = await loadCliWorkspace(workspaceRoot)
|
|
268
|
+
const claimsOf = (plugins: Parameters<typeof fingerprintClaims>[0]): Set<string> =>
|
|
269
|
+
new Set([
|
|
270
|
+
...fingerprintClaims(plugins).keys(),
|
|
271
|
+
...(opts.frozen === true ? [LOCKFILE_NAME] : []),
|
|
272
|
+
])
|
|
273
|
+
await runWatchLoop({
|
|
274
|
+
opts,
|
|
275
|
+
held,
|
|
276
|
+
initial,
|
|
277
|
+
stop: stop.signal,
|
|
278
|
+
workspaceRoot,
|
|
279
|
+
projects: watched,
|
|
280
|
+
workspaceWide: swept.workspaceWide,
|
|
281
|
+
projectDirs: watched.map((p) => p.dir),
|
|
282
|
+
workspaceInputs: swept.workspaceInputs,
|
|
283
|
+
outputs: swept.outputs,
|
|
284
|
+
inputs: swept.inputs,
|
|
285
|
+
uncached: swept.uncached,
|
|
286
|
+
configImports: swept.configImports,
|
|
287
|
+
workspaceConfigImports: swept.workspaceConfigImports,
|
|
288
|
+
memberBases: memberBaseDirs(workspace),
|
|
289
|
+
packageDirs: new Set(allProjects.map((p) => p.dir)),
|
|
290
|
+
fenceDirs: fenceDirs(allProjects),
|
|
291
|
+
// The workspace as the cycle that just ran saw it: a package added or
|
|
292
|
+
// removed since the loop armed joins or leaves the watched set. The
|
|
293
|
+
// scope is the one resolved at start; a new package joins it only as a
|
|
294
|
+
// dependency of it.
|
|
295
|
+
rediscover: async () => {
|
|
296
|
+
const workspace = await loadWorkspace(workspaceRoot)
|
|
297
|
+
const all = await discoverCliProjects(workspace)
|
|
298
|
+
const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
|
|
299
|
+
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
300
|
+
return {
|
|
301
|
+
projects: now,
|
|
302
|
+
// A plugin the workspace config gained since claims its file from
|
|
303
|
+
// the cycle that loaded it; read once, its edits started nothing
|
|
304
|
+
// until a restart.
|
|
305
|
+
claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot)).plugins),
|
|
306
|
+
memberBases: memberBaseDirs(workspace),
|
|
307
|
+
workspaceWide: sweep.workspaceWide,
|
|
308
|
+
workspaceInputs: sweep.workspaceInputs,
|
|
309
|
+
outputs: sweep.outputs,
|
|
310
|
+
inputs: sweep.inputs,
|
|
311
|
+
uncached: sweep.uncached,
|
|
312
|
+
configImports: sweep.configImports,
|
|
313
|
+
workspaceConfigImports: sweep.workspaceConfigImports,
|
|
314
|
+
packageDirs: new Set(all.map((p) => p.dir)),
|
|
315
|
+
fenceDirs: fenceDirs(all),
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
// Under --frozen every cycle's configs are the lock's, so a re-lock is
|
|
319
|
+
// the one edit that changes what a cycle runs; unheard, the loop ran
|
|
320
|
+
// the old lock until a restart (item 971).
|
|
321
|
+
claimedRootFiles: claimsOf(ws.plugins),
|
|
322
|
+
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
323
|
+
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The initial run, when the loop arms while it is still in flight. */
|
|
329
|
+
interface InitialCycle {
|
|
330
|
+
readonly cycle: WatchCycle
|
|
331
|
+
/** What the run leaves running once it returns. */
|
|
332
|
+
held: Promise<HeldPersistent | undefined>
|
|
333
|
+
/** The loop's readiness hook, once the loop has armed. */
|
|
334
|
+
onWaiting?: () => void
|
|
302
335
|
}
|
|
303
336
|
|
|
304
337
|
interface WatchLoopArgs {
|
|
305
338
|
opts: RunOptions
|
|
306
339
|
/** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
|
|
307
340
|
held: HeldPersistent | undefined
|
|
341
|
+
/** The initial run, still in flight: the loop's first cycle. */
|
|
342
|
+
initial: InitialCycle | undefined
|
|
308
343
|
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
309
344
|
stop: AbortSignal
|
|
310
345
|
workspaceRoot: string
|
|
@@ -381,7 +416,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
381
416
|
// Reentrancy guard — never two orchestrator runs in flight. Events that
|
|
382
417
|
// land while one is running wait in `changes.pending` and are judged, on
|
|
383
418
|
// settled bytes, one debounce window after it ends.
|
|
384
|
-
let running =
|
|
419
|
+
let running = args.initial !== undefined
|
|
385
420
|
/** The cycle in flight, so the stop path can wait for its teardown before resolving. */
|
|
386
421
|
let inFlight: Promise<void> = Promise.resolve()
|
|
387
422
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
@@ -428,45 +463,51 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
428
463
|
const done = (inFlight = runCycle(label))
|
|
429
464
|
await done
|
|
430
465
|
}
|
|
431
|
-
const runCycle = async (first: string): Promise<void> => {
|
|
466
|
+
const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
|
|
432
467
|
try {
|
|
433
468
|
let label: string | undefined = first
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
469
|
+
// The initial run is collected even after a stop: its server is the
|
|
470
|
+
// loop's to stop.
|
|
471
|
+
while (initial !== undefined || (label !== undefined && !stop.aborted)) {
|
|
472
|
+
if (initial !== undefined) {
|
|
473
|
+
held = await initial.held
|
|
474
|
+
initial = undefined
|
|
475
|
+
} else
|
|
476
|
+
try {
|
|
477
|
+
// A member came or went: armed before the run keys it, an edit in a
|
|
478
|
+
// new package while its first cycle runs queues like any other; armed
|
|
479
|
+
// only after, it fell in the gap and ran nothing (X-138). The re-read
|
|
480
|
+
// after the run stays, for what the run itself changed.
|
|
481
|
+
if (reread) await rearm(false)
|
|
482
|
+
process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
|
|
483
|
+
await held?.stop()
|
|
484
|
+
held = undefined
|
|
485
|
+
// A Ctrl-C while the old server shut down ran a cycle anyway: a
|
|
486
|
+
// `not run` row and a footer printed above `stopped` (WD-22).
|
|
487
|
+
if (stop.aborted) break
|
|
488
|
+
restartTimings()
|
|
489
|
+
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
490
|
+
// run made within a tick of it carried an earlier mtime and read as
|
|
491
|
+
// an edit (WD-20). The end needs no stamp: an mtime never leads it.
|
|
492
|
+
const start = fsClockNow(cacheDir)
|
|
493
|
+
current = watchCycle(opts, stop, interruptIfWaiting)
|
|
494
|
+
const cycle = await runOrchestrator(current.opts)
|
|
495
|
+
current = undefined
|
|
496
|
+
held = cycle.persistent
|
|
497
|
+
if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
|
|
498
|
+
changes.lastCycle = { start, end: Date.now() }
|
|
499
|
+
} catch (err) {
|
|
500
|
+
// A re-run can fail catastrophically when the workspace
|
|
501
|
+
// itself moved out from under us — e.g. the user deleted
|
|
502
|
+
// the project dir, or git lost its repo (the watch loop
|
|
503
|
+
// outlives its own cwd in test teardown). Surface the
|
|
504
|
+
// message but DON'T let it crash the watch loop; the next
|
|
505
|
+
// FS event (if any) will retry. The dispose() on SIGINT
|
|
506
|
+
// is the canonical exit; we don't unilaterally abort here.
|
|
507
|
+
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
508
|
+
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
509
|
+
process.stderr.write(`vx watch: cycle failed: ${message}\n`)
|
|
510
|
+
}
|
|
470
511
|
// After a failed cycle too: a package added with a config that does
|
|
471
512
|
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
472
513
|
// was never an event.
|
|
@@ -903,6 +944,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
903
944
|
// "watching" is a promise that an edit from now on is seen; every
|
|
904
945
|
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
905
946
|
await pool.proved()
|
|
947
|
+
if (args.initial !== undefined) {
|
|
948
|
+
current = args.initial.cycle
|
|
949
|
+
args.initial.onWaiting = interruptIfWaiting
|
|
950
|
+
inFlight = runCycle(undefined, args.initial)
|
|
951
|
+
interruptIfWaiting()
|
|
952
|
+
}
|
|
906
953
|
|
|
907
954
|
process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
|
|
908
955
|
|
package/src/cli/why.ts
CHANGED
|
@@ -49,6 +49,8 @@ export const WHAT_TO_DO: Readonly<Record<string, string>> = {
|
|
|
49
49
|
"the task's evaluated config changed (its vx.config or a file it imports); `vx show <that task>` prints it as it is now",
|
|
50
50
|
upstream: "a dependency's key moved; `vx why <that task>` says why",
|
|
51
51
|
plugin: "a key plugin's material changed; that plugin decides what it folds",
|
|
52
|
+
format:
|
|
53
|
+
'vx keys tasks differently since an upgrade; every cached task misses once, nothing to fix',
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export function parseWhyArgs(args: readonly string[]): WhyArgs {
|