@vzn/vx 0.0.423 → 0.0.425

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.423",
3
+ "version": "0.0.425",
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.423",
25
- "@vzn/vx-linux-arm64": "0.0.423",
26
- "@vzn/vx-darwin-x64": "0.0.423",
27
- "@vzn/vx-darwin-arm64": "0.0.423"
24
+ "@vzn/vx-linux-x64": "0.0.425",
25
+ "@vzn/vx-linux-arm64": "0.0.425",
26
+ "@vzn/vx-darwin-x64": "0.0.425",
27
+ "@vzn/vx-darwin-arm64": "0.0.425"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/watch.ts CHANGED
@@ -205,6 +205,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
205
205
  const swept = await sweepConfigs(allProjects, workspaceRoot, load, opts.tasks)
206
206
  const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
207
207
  const ws = await loadCliWorkspace(workspaceRoot)
208
+ const claimsOf = (plugins: Parameters<typeof fingerprintClaims>[0]): Set<string> =>
209
+ new Set([
210
+ ...fingerprintClaims(plugins).keys(),
211
+ ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
212
+ ])
208
213
  return await runWatchLoop({
209
214
  opts,
210
215
  held,
@@ -232,6 +237,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
232
237
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
233
238
  return {
234
239
  projects: now,
240
+ // A plugin the workspace config gained since claims its file from
241
+ // the cycle that loaded it; read once, its edits started nothing
242
+ // until a restart.
243
+ claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot)).plugins),
235
244
  memberBases: memberBaseDirs(workspace),
236
245
  workspaceWide: sweep.workspaceWide,
237
246
  workspaceInputs: sweep.workspaceInputs,
@@ -246,10 +255,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
246
255
  // Under --frozen every cycle's configs are the lock's, so a re-lock is
247
256
  // the one edit that changes what a cycle runs; unheard, the loop ran
248
257
  // the old lock until a restart (item 971).
249
- claimedRootFiles: new Set([
250
- ...fingerprintClaims(ws.plugins).keys(),
251
- ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
252
- ]),
258
+ claimedRootFiles: claimsOf(ws.plugins),
253
259
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
254
260
  cacheDir: opts.cacheDir ?? ws.cacheDir,
255
261
  })
@@ -296,6 +302,7 @@ interface WatchLoopArgs {
296
302
 
297
303
  interface Rediscovered {
298
304
  projects: readonly ProjectMeta[]
305
+ claimedRootFiles: ReadonlySet<string>
299
306
  memberBases: readonly string[]
300
307
  workspaceWide: boolean
301
308
  workspaceInputs: readonly string[]
@@ -319,6 +326,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
319
326
  let outputs = args.outputs
320
327
  let inputs = args.inputs
321
328
  let uncached = args.uncached
329
+ let claimedRootFiles = args.claimedRootFiles
322
330
  let configImportFiles = args.configImports
323
331
  let wsConfigImportFiles = args.workspaceConfigImports
324
332
  // A dev server stays up while the loop idles; the cycle that replaces it
@@ -398,12 +406,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
398
406
  // project would otherwise trigger every save during `bun install` —
399
407
  // and vx's own cache writes would trigger a cycle that writes again.
400
408
  let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
401
- let matters = makeRootEventFilter(
402
- workspaceRoot,
403
- projectDirs,
404
- workspaceInputs,
405
- args.claimedRootFiles,
406
- )
409
+ let matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs, claimedRootFiles)
407
410
  /** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
408
411
  let reread = false
409
412
 
@@ -466,7 +469,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
466
469
  isWorkspaceFingerprintFile(filename) ||
467
470
  isWorkspaceConfigFile(filename) ||
468
471
  filename === 'package.json' ||
469
- args.claimedRootFiles.has(filename)
472
+ claimedRootFiles.has(filename)
470
473
  ) {
471
474
  if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
472
475
  trigger(`root ${filename}`, path.join(workspaceRoot, filename))
@@ -608,18 +611,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
608
611
  outputs = next.outputs
609
612
  inputs = next.inputs
610
613
  uncached = next.uncached
614
+ claimedRootFiles = next.claimedRootFiles
611
615
  configImportFiles = next.configImports
612
616
  wsConfigImportFiles = next.workspaceConfigImports
613
617
  packageDirs = next.packageDirs
614
618
  memberBases = next.memberBases
615
619
  armBases()
616
620
  isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
617
- matters = makeRootEventFilter(
618
- workspaceRoot,
619
- projectDirs,
620
- workspaceInputs,
621
- args.claimedRootFiles,
622
- )
621
+ matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs, claimedRootFiles)
623
622
  if (next.workspaceWide !== workspaceWide) {
624
623
  // A task started or stopped declaring `workspaceFiles`: the other
625
624
  // arm's shape. Until item 891 the choice was made once, at start, and
@@ -160,6 +160,12 @@ export interface ExecuteArgs {
160
160
  * Absent → probe lazily, exactly as today (unstable / unclassified).
161
161
  */
162
162
  preProbed?: { hash: string; hit: CacheEntry | null }
163
+ /**
164
+ * An uncached task's key as the up-front pass derived it, present only
165
+ * when no upstream can change it (`deriveStableKeys`' `uncachedKeys`):
166
+ * used verbatim instead of deriving the same key again.
167
+ */
168
+ upfrontKey?: string
163
169
  /**
164
170
  * Start the sandbox runtime, on the first task that executes inside one
165
171
  * (run.ts, `prepareSandbox`). Absent when no task in the run declares a
@@ -274,7 +280,8 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
274
280
  const hash =
275
281
  args.noDependants === true
276
282
  ? undefined
277
- : await computeTaskHash({
283
+ : (args.upfrontKey ??
284
+ (await computeTaskHash({
278
285
  node,
279
286
  upstream: args.upstream,
280
287
  workspaceRoot: args.workspaceRoot,
@@ -284,7 +291,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
284
291
  nestedProjectDirs: args.nestedProjectDirs,
285
292
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
286
293
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
287
- })
294
+ })))
288
295
 
289
296
  // The args after `--` reach a server as they reach any task. A readyWhen
290
297
  // server once got none — "so the matcher sees the unmodified output",
@@ -658,19 +665,19 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
658
665
  // authoritative stable key) and skip the probe below.
659
666
  const preProbed = args.preProbed
660
667
  const hash =
661
- preProbed !== undefined
662
- ? preProbed.hash
663
- : await computeTaskHash({
664
- node,
665
- upstream,
666
- workspaceRoot: args.workspaceRoot,
667
- workspaceFingerprint: args.workspaceFingerprint,
668
- cache,
669
- forwardArgs: args.forwardArgs,
670
- nestedProjectDirs: args.nestedProjectDirs,
671
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
672
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
673
- })
668
+ preProbed?.hash ??
669
+ args.upfrontKey ??
670
+ (await computeTaskHash({
671
+ node,
672
+ upstream,
673
+ workspaceRoot: args.workspaceRoot,
674
+ workspaceFingerprint: args.workspaceFingerprint,
675
+ cache,
676
+ forwardArgs: args.forwardArgs,
677
+ nestedProjectDirs: args.nestedProjectDirs,
678
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
679
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
680
+ }))
674
681
 
675
682
  // The local no-op half of `exec.remote: 'only'`: no remote executor took
676
683
  // the task, so it succeeds without running. The hash was still computed —
@@ -84,10 +84,14 @@ export interface ShortCircuit {
84
84
  * with a non-null hit, minus the workspace-outputs exclusion).
85
85
  */
86
86
  restoreTier: Set<string>
87
+ /**
88
+ * Uncached task id → its up-front key, for the tasks whose key no
89
+ * upstream can change: execute-task reuses it instead of deriving it
90
+ * again (`deriveStableKeys`' `uncachedKeys`).
91
+ */
92
+ uncachedKeys: Map<string, string>
87
93
  }
88
94
 
89
- const EMPTY: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
90
-
91
95
  /**
92
96
  * Classify the graph's stable tasks: derive keys + probe local ONCE,
93
97
  * returning the `preProbed` map (probe reuse) and the `restoreTier` set
@@ -99,11 +103,12 @@ const EMPTY: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
99
103
  */
100
104
  export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<ShortCircuit> {
101
105
  let stableKeys
106
+ const uncachedKeys = new Map<string, string>()
102
107
  const endKeys = span('stable keys')
103
108
  try {
104
- stableKeys = await deriveStableKeys(args)
109
+ stableKeys = await deriveStableKeys({ ...args, uncachedKeys })
105
110
  } catch {
106
- return EMPTY
111
+ return { preProbed: new Map(), restoreTier: new Set(), uncachedKeys: new Map() }
107
112
  } finally {
108
113
  endKeys()
109
114
  }
@@ -111,12 +116,17 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
111
116
  // `cacheEnabled && !unstable` (item 640 deleted a second filter here and
112
117
  // nothing reddened).
113
118
  const candidates = stableKeys
114
- if (candidates.length === 0) return EMPTY
115
-
116
- const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
117
-
118
119
  const preProbed = new Map<string, ProbedEntry>()
119
120
  const restoreTier = new Set<string>()
121
+ if (candidates.length === 0 && uncachedKeys.size === 0) {
122
+ return { preProbed, restoreTier, uncachedKeys }
123
+ }
124
+
125
+ const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
126
+ // A root-anchored output may land in a project no edge leads from, so an
127
+ // uncached key there is derived again once the writer may have run.
128
+ for (const id of keptOut) uncachedKeys.delete(id)
129
+ if (candidates.length === 0) return { preProbed, restoreTier, uncachedKeys }
120
130
 
121
131
  // Bounded pool over the stable candidates: probe local ONCE each. A
122
132
  // confirmed hit becomes restore-tier (unless workspace outputs disable
@@ -135,7 +145,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
135
145
  preProbed.set(node.id, { hash, hit })
136
146
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
137
147
  }
138
- return { preProbed, restoreTier }
148
+ return { preProbed, restoreTier, uncachedKeys }
139
149
  } catch {
140
150
  // Fall through to the per-hash pool, which isolates a failing probe
141
151
  // to its own task.
@@ -159,7 +169,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
159
169
  }
160
170
  await Promise.all(Array.from({ length: workers }, () => pump()))
161
171
 
162
- return { preProbed, restoreTier }
172
+ return { preProbed, restoreTier, uncachedKeys }
163
173
  }
164
174
 
165
175
  /**
@@ -103,7 +103,11 @@ import type { RunOptions, RunSummary } from './options.js'
103
103
 
104
104
  // Per run, never shared: a `vx watch` process runs many, and a shared map
105
105
  // is one `preProbed.set` away from leaking a hit across cycles.
106
- const emptyShortCircuit = (): ShortCircuit => ({ preProbed: new Map(), restoreTier: new Set() })
106
+ const emptyShortCircuit = (): ShortCircuit => ({
107
+ preProbed: new Map(),
108
+ restoreTier: new Set(),
109
+ uncachedKeys: new Map(),
110
+ })
107
111
 
108
112
  /**
109
113
  * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
@@ -891,6 +895,7 @@ async function runOnBus(
891
895
 
892
896
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
893
897
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
898
+ const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
894
899
  if (
895
900
  options.continueMode === 'always' &&
896
901
  node.deps.some((d) => deadServerBehind(nodes, serverDied, d) !== undefined)
@@ -921,6 +926,7 @@ async function runOnBus(
921
926
  gitFilesCache,
922
927
  hashCache,
923
928
  ...(probe !== undefined ? { preProbed: probe } : {}),
929
+ ...(upfrontKey !== undefined ? { upfrontKey } : {}),
924
930
  ...(tainted ? { taintedUpstream: true } : {}),
925
931
  ...(dependedOn.has(node.id) ? {} : { noDependants: true as const }),
926
932
  fingerprintWatch,
@@ -27,6 +27,11 @@ export interface DeriveStableKeysArgs {
27
27
  nestedDirsByProject: Map<string, string[]>
28
28
  gitFilesCache: GitFilesCache
29
29
  hashCache: HashCache
30
+ /**
31
+ * Filled with each uncached task's key when no upstream can write where
32
+ * that key reads, so execute-task need not derive it a second time.
33
+ */
34
+ uncachedKeys?: Map<string, string>
30
35
  }
31
36
 
32
37
  export interface StableKey {
@@ -237,6 +242,18 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
237
242
 
238
243
  const cacheEnabled = node.config.cache !== undefined
239
244
  if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
245
+ // An uncached key reads its whole project, so `dependsOnSiblingOutputs`
246
+ // (which answers false for it) is not the gate: any upstream that may
247
+ // write into this project, or anywhere, leaves the key preliminary.
248
+ else if (
249
+ !cacheEnabled &&
250
+ !unstable &&
251
+ !outputProjects.has(node.projectName) &&
252
+ unfolded?.has(node.projectName) !== true &&
253
+ !wsOutputUpstream &&
254
+ !wsUnfoldedHere
255
+ )
256
+ args.uncachedKeys?.set(id, hash)
240
257
  }
241
258
  return stableKeys
242
259
  }
@@ -256,6 +256,18 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
256
256
  const configName = `vx.config.${format}`
257
257
  const workspaceName = `vx.workspace.${format}`
258
258
  const empty = plan.projects.length === 0
259
+ // `vx init` never overwrites a member's config: a half-adopted workspace
260
+ // keeps each one it has, by name, and gets the rest (M-52). It refused
261
+ // the whole workspace, and `--force` would have replaced the hand-written
262
+ // file; `--force` still replaces, as asked.
263
+ const kept: string[] = []
264
+ const projects = plan.projects.filter((p) => {
265
+ if (!init || force || p.tasks.length === 0) return true
266
+ const existing = metas.find((m) => m.dir === p.dir)?.configPath
267
+ if (!existing) return true
268
+ kept.push(relPosix(root, existing))
269
+ return false
270
+ })
259
271
  if (empty && !init) {
260
272
  throw new UserError(
261
273
  `nothing to migrate: no ${source === 'package.json scripts' ? 'package.json scripts in any workspace member' : 'tasks in ' + source}`,
@@ -263,7 +275,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
263
275
  }
264
276
 
265
277
  const files: { relPath: string; abs: string; contents: string }[] = []
266
- for (const p of plan.projects) {
278
+ for (const p of projects) {
267
279
  if (p.tasks.length === 0) continue
268
280
  const abs = path.join(p.dir, configName)
269
281
  files.push({
@@ -291,7 +303,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
291
303
  const conflicts = new Set<string>()
292
304
  // A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
293
305
  // so we never shadow a hand-written config with a fresh .ts.
294
- for (const p of plan.projects) {
306
+ for (const p of projects) {
295
307
  if (p.tasks.length === 0) continue
296
308
  const meta = metas.find((m) => m.dir === p.dir)
297
309
  if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
@@ -316,7 +328,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
316
328
  // order, and the report said "written" for a file the run never loaded
317
329
  // (item 1033).
318
330
  const replaced: string[] = []
319
- for (const p of plan.projects) {
331
+ for (const p of projects) {
320
332
  if (p.tasks.length === 0) continue
321
333
  const existing = metas.find((m) => m.dir === p.dir)?.configPath
322
334
  if (existing && existing !== path.join(p.dir, configName)) replaced.push(existing)
@@ -337,7 +349,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
337
349
  let todoCount = 0
338
350
  let clean = 0
339
351
  let cached = false
340
- for (const p of plan.projects) {
352
+ for (const p of projects) {
341
353
  for (const t of p.tasks) {
342
354
  if (t.todos.length === 0 && t.task !== null) clean++
343
355
  if (t.task !== null && 'cache' in t.task) cached = true
@@ -408,8 +420,13 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
408
420
  }
409
421
  }
410
422
  report.push(...plan.notes)
411
- report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
423
+ if (files.length === 0) report.push(dry ? 'no files (dry run)' : 'no files written')
424
+ else report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
412
425
  for (const f of files) report.push(` ${f.relPath}`)
426
+ if (kept.length > 0) {
427
+ report.push('kept (each already has a vx config):')
428
+ for (const f of kept) report.push(` ${f}`)
429
+ }
413
430
  if (replaced.length > 0) {
414
431
  report.push(dry ? 'would replace (dry run):' : 'replaced:')
415
432
  for (const f of replaced) report.push(` ${relPosix(root, f)}`)