@vzn/vx 0.0.604 → 0.0.606

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.604",
3
+ "version": "0.0.606",
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.604",
25
- "@vzn/vx-linux-arm64": "0.0.604",
26
- "@vzn/vx-darwin-x64": "0.0.604",
27
- "@vzn/vx-darwin-arm64": "0.0.604"
24
+ "@vzn/vx-linux-x64": "0.0.606",
25
+ "@vzn/vx-linux-arm64": "0.0.606",
26
+ "@vzn/vx-darwin-x64": "0.0.606",
27
+ "@vzn/vx-darwin-arm64": "0.0.606"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/last.ts CHANGED
@@ -354,13 +354,21 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
354
354
  )
355
355
  lines.push(...formatTaskRows(detail?.tasks ?? []))
356
356
  // The next command after reading a failed run, with the arguments the
357
- // run forwarded (a failure under `-- --shard 2` is that shard's).
357
+ // run forwarded to the tasks that got them (a failure under `-- --shard
358
+ // 2` is that shard's); a dependency never got them, so its line has none.
358
359
  const failed = (detail?.tasks ?? []).filter((t) => t.status === 'failed')
359
360
  if (failed.length > 0) {
360
361
  const at = inv.command.indexOf(' -- ')
361
- const forwarded = at === -1 ? '' : inv.command.slice(at)
362
- const ids = failed.map((t) => `${t.project}#${t.task}`).join(' ')
363
- lines.push('', ` re-run what failed: vx run ${ids}${forwarded}`)
362
+ const args = at === -1 ? '' : inv.command.slice(at)
363
+ const rerun: string[] = []
364
+ for (const withArgs of [false, true]) {
365
+ const ids = failed
366
+ .filter((t) => t.forwarded === withArgs)
367
+ .map((t) => `${t.project}#${t.task}`)
368
+ if (ids.length > 0)
369
+ rerun.push(` re-run what failed: vx run ${ids.join(' ')}${withArgs ? args : ''}`)
370
+ }
371
+ lines.push('', ...rerun)
364
372
  }
365
373
  process.stdout.write(`${lines.join('\n')}\n`)
366
374
  return 0
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
 
@@ -48,6 +48,8 @@ export interface RunSummaryRow {
48
48
  timedOut: boolean | null
49
49
  sandboxViolations: number | null
50
50
  notReady: 'timeout' | 'exited' | 'spawn' | null
51
+ /** The task ran with the run's forwarded args (`-- …`): a requested one under a run that had some. */
52
+ forwarded: boolean
51
53
  }
52
54
 
53
55
  export interface ListRunsArgs {
@@ -78,8 +80,15 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
78
80
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
79
81
  type RawRow = Omit<
80
82
  RunSummaryRow,
81
- 'cacheHit' | 'restored' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs' | 'timedOut'
83
+ | 'cacheHit'
84
+ | 'restored'
85
+ | 'cached'
86
+ | 'wallclockStartNs'
87
+ | 'wallclockEndNs'
88
+ | 'timedOut'
89
+ | 'forwarded'
82
90
  > & {
91
+ forwarded: number
83
92
  cacheHit: number | null
84
93
  restored: number | null
85
94
  cached: number | null
@@ -100,7 +109,8 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
100
109
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
101
110
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
102
111
  blocked_by AS blockedBy, timed_out AS timedOut,
103
- sandbox_violations AS sandboxViolations, not_ready AS notReady
112
+ sandbox_violations AS sandboxViolations, not_ready AS notReady,
113
+ forward_args IS NOT NULL AS forwarded
104
114
  FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
105
115
  )
106
116
  .all(...params, limit) as RawRow[]
@@ -110,6 +120,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
110
120
  restored: r.restored === null ? null : Boolean(r.restored),
111
121
  cached: r.cached === null ? null : Boolean(r.cached),
112
122
  timedOut: r.timedOut === null ? null : Boolean(r.timedOut),
123
+ forwarded: Boolean(r.forwarded),
113
124
  wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
114
125
  wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
115
126
  }))
@@ -78,7 +78,11 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
78
78
  status: o.status,
79
79
  exitCode: o.exitCode,
80
80
  durationMs: o.durationMs,
81
- ...(input.forwardArgs !== undefined ? { forwardArgs: input.forwardArgs } : {}),
81
+ // A run forwards its args to the tasks it was asked for, never to a
82
+ // dependency: the row says which ran with them.
83
+ ...(input.forwardArgs !== undefined && input.forwardArgs.length > 0 && o.node.requested
84
+ ? { forwardArgs: input.forwardArgs }
85
+ : {}),
82
86
  // Anchor to the REAL per-task wall-clock window: run-start wall time +
83
87
  // the task's ns offset (captured for hits and executed tasks alike).
84
88
  // The `end - duration` fallback applies to outcomes without an offset —