@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.605",
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.605",
25
- "@vzn/vx-linux-arm64": "0.0.605",
26
- "@vzn/vx-darwin-x64": "0.0.605",
27
- "@vzn/vx-darwin-arm64": "0.0.605"
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"
@@ -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
- try {
205
- const initial = await runOrchestrator(opts)
206
- held = initial.persistent
207
- if (initial.refused !== undefined) process.stderr.write(`vx watch: ${initial.refused}\n`)
208
- // A run that failed having run nothing refused to start: a requested
209
- // name no project declares (run() says which, with a "did you mean").
210
- // `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
211
- // same refusal on every change, since no edit to an input can declare
212
- // a task. A name only the diff left out (`--affected`) is `ok` and
213
- // keeps watching.
214
- refusedToStart = !initial.ok && initial.outcomes.length === 0
215
- } catch (err) {
216
- process.stderr.write(
217
- `vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
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
- // `--affected`'s diff is the tree at start; judged again, it held every
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 = false
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
- while (label !== undefined && !stop.aborted) {
435
- // A member came or went: armed before the run keys it, an edit in a
436
- // new package while its first cycle runs queues like any other; armed
437
- // only after, it fell in the gap and ran nothing (X-138). The re-read
438
- // after the run stays, for what the run itself changed.
439
- if (reread) await rearm(false)
440
- process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
441
- try {
442
- await held?.stop()
443
- held = undefined
444
- // A Ctrl-C while the old server shut down ran a cycle anyway: a
445
- // `not run` row and a footer printed above `stopped` (WD-22).
446
- if (stop.aborted) break
447
- restartTimings()
448
- // On the mtime clock, as the arm is: from `Date.now()` a write the
449
- // run made within a tick of it carried an earlier mtime and read as
450
- // an edit (WD-20). The end needs no stamp: an mtime never leads it.
451
- const start = fsClockNow(cacheDir)
452
- current = watchCycle(opts, stop, interruptIfWaiting)
453
- const cycle = await runOrchestrator(current.opts)
454
- current = undefined
455
- held = cycle.persistent
456
- if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
457
- changes.lastCycle = { start, end: Date.now() }
458
- } catch (err) {
459
- // A re-run can fail catastrophically when the workspace
460
- // itself moved out from under us — e.g. the user deleted
461
- // the project dir, or git lost its repo (the watch loop
462
- // outlives its own cwd in test teardown). Surface the
463
- // message but DON'T let it crash the watch loop; the next
464
- // FS event (if any) will retry. The dispose() on SIGINT
465
- // is the canonical exit; we don't unilaterally abort here.
466
- // A config's own throw or a plugin's failure may quote a secret (L-11).
467
- const message = maskedLine(err instanceof Error ? err.message : String(err))
468
- process.stderr.write(`vx watch: cycle failed: ${message}\n`)
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 {