@vzn/vx 0.0.16 → 0.0.18

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.
Files changed (111) hide show
  1. package/README.md +66 -89
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +88 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +12 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
package/src/cli/watch.ts CHANGED
@@ -21,6 +21,9 @@ import {
21
21
  listProjects,
22
22
  loadProjectConfig,
23
23
  loadWorkspace,
24
+ loadWorkspaceConfig,
25
+ resolveCacheDir,
26
+ WORKSPACE_FINGERPRINT_FILES,
24
27
  type ProjectMeta,
25
28
  } from '../workspace/index.js'
26
29
 
@@ -31,6 +34,144 @@ const DEBOUNCE_MS = 150
31
34
  const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
32
35
  const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
33
36
 
37
+ /**
38
+ * True for a path the ignore filter drops. Every project dir is watched
39
+ * RECURSIVELY, so without this a `bun install` would re-run the graph on every
40
+ * file it writes, and vx's own `.vx/cache` writes would trigger a cycle that
41
+ * writes to `.vx/cache` again.
42
+ *
43
+ * Segment-wise, not prefix-wise: `node_modules` is ignored wherever it appears
44
+ * in the path, not only at the root.
45
+ */
46
+ export function isIgnoredWatchPath(rel: string): boolean {
47
+ const segments = rel.split(path.sep)
48
+ if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
49
+ if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
50
+ return false
51
+ }
52
+
53
+ /**
54
+ * The ignore predicate a watcher applies, closed over the run's ACTUAL cache
55
+ * directory.
56
+ *
57
+ * `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
58
+ * shipped `defineWorkspace` field: point it anywhere else and vx's own cache
59
+ * writes land in a watched subtree, so every cycle triggers the next one.
60
+ * Measured on a relocated cache under a recursive root watcher: ONE edit kicked
61
+ * it and the loop then ran **22 more times during 6 seconds of total silence**
62
+ * (~3.7 re-runs/second, forever) where the default `.vx` cache settles at 0.
63
+ * A hard-coded literal cannot see a configured path — the resolved one can.
64
+ */
65
+ export function makeWatchIgnore(
66
+ cacheDir: string,
67
+ outputs: ReadonlyMap<string, readonly string[]> = new Map(),
68
+ ): (base: string, filename: string) => boolean {
69
+ const cacheAbs = path.resolve(cacheDir)
70
+ // A task's own outputs are not edits: without this every cycle that
71
+ // writes `dist/` (or `out.txt`) re-runs once more, reporting
72
+ // "up-to-date" for the trouble. Matched under the directory the globs
73
+ // are relative to, whichever watcher delivered the event.
74
+ const declared = [...outputs].map(
75
+ ([dir, globs]) => [path.resolve(dir), globs.map((g) => new Bun.Glob(g))] as const,
76
+ )
77
+ return (base, filename) => {
78
+ if (isIgnoredWatchPath(filename)) return true
79
+ const abs = path.resolve(base, filename)
80
+ if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
81
+ for (const [dir, globs] of declared) {
82
+ if (!abs.startsWith(dir + path.sep)) continue
83
+ const rel = abs
84
+ .slice(dir.length + 1)
85
+ .split(path.sep)
86
+ .join('/')
87
+ if (globs.some((g) => g.match(rel))) return true
88
+ }
89
+ return false
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The file `armWatcher` writes under a watched directory to prove the
95
+ * watcher delivers. Intercepted by name before any other handling, so it
96
+ * can never trigger a cycle, and removed before "watching" is printed.
97
+ */
98
+ export const WATCH_PROBE = '.vx-watch-probe'
99
+
100
+ /** How long a watcher gets to report its own probe before the loop goes on without proof. */
101
+ const WATCH_PROBE_TIMEOUT_MS = 2_000
102
+
103
+ export interface ArmedWatcher {
104
+ watcher: fs.FSWatcher
105
+ /** Resolves `true` once the watcher reported the probe, `false` on timeout. */
106
+ ready: Promise<boolean>
107
+ }
108
+
109
+ /**
110
+ * `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
111
+ * FSEvents stream that another thread schedules AFTER the call returns, and
112
+ * a change landing in that gap is never delivered — MEASURED 2026-09-03: a
113
+ * write made immediately after `fs.watch` was lost 5 times in 30 under CPU
114
+ * load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
115
+ * width, so no pause is the answer and no timeout on the waiting side ever
116
+ * was (the e2e flake this closes had one of 45 s). A probe file written
117
+ * under the watcher and waited for is: once ITS event arrives, the stream
118
+ * is live for everything after it.
119
+ *
120
+ * `onEvent` never sees the probe (create or unlink), and the probe is
121
+ * removed before `ready` resolves.
122
+ */
123
+ export function armWatcher(
124
+ dir: string,
125
+ recursive: boolean,
126
+ onEvent: (filename: string) => void,
127
+ timeoutMs = WATCH_PROBE_TIMEOUT_MS,
128
+ ): ArmedWatcher {
129
+ let markReady: (ok: boolean) => void = () => {}
130
+ const seen = new Promise<boolean>((resolve) => {
131
+ markReady = resolve
132
+ })
133
+ const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
134
+ if (filename == null || typeof filename !== 'string') return
135
+ if (filename === WATCH_PROBE) {
136
+ markReady(true)
137
+ return
138
+ }
139
+ onEvent(filename)
140
+ })
141
+ const probe = path.join(dir, WATCH_PROBE)
142
+ const ready = (async (): Promise<boolean> => {
143
+ // The probe is subject to the very race it detects: a write that lands
144
+ // in the gap is lost like any other (1 in 20 under a full gate's load,
145
+ // measured 2026-09-03, with every delivered event under 60 ms). So it is
146
+ // re-written on a short backoff until its event arrives — the first write
147
+ // after the stream goes live is the one that proves it.
148
+ const deadline = Date.now() + timeoutMs
149
+ let ok = false
150
+ let step = 50
151
+ while (!ok) {
152
+ try {
153
+ fs.writeFileSync(probe, String(Date.now()))
154
+ } catch {
155
+ break // an unwritable dir gets no proof; the watcher is kept
156
+ }
157
+ const remaining = deadline - Date.now()
158
+ if (remaining <= 0) break
159
+ const pause = new Promise<boolean>((resolve) => {
160
+ setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
161
+ })
162
+ ok = await Promise.race([seen, pause])
163
+ step = Math.min(step * 2, 400)
164
+ }
165
+ try {
166
+ fs.unlinkSync(probe)
167
+ } catch {
168
+ // already gone
169
+ }
170
+ return ok
171
+ })()
172
+ return { watcher, ready }
173
+ }
174
+
34
175
  export async function watchCmd(args: readonly string[]): Promise<number> {
35
176
  const parsed = parseRunArgs(args)
36
177
  if (parsed.error) {
@@ -48,6 +189,15 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
48
189
  )
49
190
  return 1
50
191
  }
192
+ // All three format ONE run's result and are consumed by `runCmd` alone, so
193
+ // a watch loop silently ignored them. `--verbosity 0` is not rejected: it
194
+ // asks for the output watch already gives.
195
+ if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
196
+ process.stderr.write(
197
+ `vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)\n`,
198
+ )
199
+ return 1
200
+ }
51
201
  if (parsed.tasks.length === 0) {
52
202
  process.stderr.write(`vx watch: missing task name\n`)
53
203
  return 1
@@ -59,6 +209,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
59
209
  process.stderr.write(`vx watch: ${resolved.error}\n`)
60
210
  return 1
61
211
  }
212
+ if ('nothingSelected' in resolved) {
213
+ process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
214
+ return 0
215
+ }
62
216
  // The watch loop owns SIGINT/SIGTERM for its whole lifetime (the
63
217
  // process.once handlers below close watchers and resolve 0). A
64
218
  // cycle's run() must not install its exit-the-process handlers —
@@ -83,39 +237,75 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
83
237
  }
84
238
 
85
239
  // Initial run — same code path as `vx run`.
240
+ //
241
+ // Watchers do not exist yet, so an edit made WHILE this run is executing is
242
+ // dropped by the OS and never triggers a re-run. Known and deliberate: the
243
+ // obvious fix (install watchers first) forces `anyTaskUsesWorkspaceFiles`
244
+ // ahead of the run to decide which watchers to install, and that marks every
245
+ // config loaded — so the initial run's own loads become REPEATs and pay a
246
+ // worker round-trip each (see config-eval.ts). Trading a hot-path regression
247
+ // on every `vx watch` for a window a user rarely edits into is a bad deal;
248
+ // closing it properly needs the workspaceWide decision made without loading
249
+ // configs.
86
250
  process.stdout.write('vx watch: initial run...\n\n')
87
251
  await runOrchestrator(opts)
88
252
 
253
+ const swept = await sweepConfigs(allProjects, workspaceRoot)
89
254
  return await runWatchLoop({
90
255
  opts,
91
256
  workspaceRoot,
92
257
  projects: scope,
93
- workspaceWide: await anyTaskUsesWorkspaceFiles(allProjects),
258
+ workspaceWide: swept.workspaceWide,
259
+ outputs: swept.outputs,
260
+ // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
261
+ cacheDir:
262
+ opts.cacheDir ?? resolveCacheDir(workspaceRoot, await loadWorkspaceConfig(workspaceRoot)),
94
263
  })
95
264
  }
96
265
 
97
266
  /**
98
- * `inputs.workspaceFiles` globs have no project boundary — any file in
99
- * the workspace can be an input. When any config declares them, the
100
- * per-project watchers can't see all triggering paths, so the loop
101
- * switches to one recursive root watcher. Checked across ALL projects
102
- * (not just the scope) because dependsOn can pull tasks from anywhere;
103
- * a broken out-of-scope config is skipped, matching scoped-run
104
- * semantics (it surfaces when that project enters scope).
267
+ * One sweep over every config for the two things the loop needs: whether
268
+ * any task declares `inputs.workspaceFiles` — globs with no project
269
+ * boundary, so the per-project watchers can't see all triggering paths
270
+ * and the loop switches to one recursive root watcher — and each
271
+ * project's declared outputs, so their writes are not taken for edits.
272
+ * Checked across ALL projects (not just the scope) because dependsOn can
273
+ * pull tasks from anywhere; a broken out-of-scope config is skipped,
274
+ * matching scoped-run semantics (it surfaces when that project enters
275
+ * scope).
105
276
  */
106
- async function anyTaskUsesWorkspaceFiles(projects: readonly ProjectMeta[]): Promise<boolean> {
107
- for (const p of projects) {
108
- if (p.configPath === null) continue
109
- try {
110
- const config = await loadProjectConfig(p.configPath)
111
- for (const task of Object.values(config.tasks ?? {})) {
112
- if ((task.cache?.inputs?.workspaceFiles?.length ?? 0) > 0) return true
113
- }
114
- } catch {
115
- // broken config — out of this concern's scope
116
- }
277
+ async function sweepConfigs(
278
+ projects: readonly ProjectMeta[],
279
+ workspaceRoot: string,
280
+ ): Promise<{ workspaceWide: boolean; outputs: Map<string, string[]> }> {
281
+ const outputs = new Map<string, string[]>()
282
+ const add = (dir: string, globs: readonly string[] | undefined): void => {
283
+ if (globs === undefined || globs.length === 0) return
284
+ outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
117
285
  }
118
- return false
286
+ // Concurrent, not sequential: the run that just happened already
287
+ // loaded the in-scope configs, so these are REPEAT loads that
288
+ // re-evaluate in a worker. Issuing them together lets one worker
289
+ // serve the whole sweep instead of one per project.
290
+ const uses = await Promise.all(
291
+ projects.map(async (p): Promise<boolean> => {
292
+ if (p.configPath === null) return false
293
+ try {
294
+ const config = await loadProjectConfig(p.configPath)
295
+ let wide = false
296
+ for (const task of Object.values(config.tasks ?? {})) {
297
+ if ((task.cache?.inputs?.workspaceFiles?.length ?? 0) > 0) wide = true
298
+ add(p.dir, task.cache?.outputs?.files)
299
+ add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
300
+ }
301
+ return wide
302
+ } catch {
303
+ // broken config — out of this concern's scope
304
+ return false
305
+ }
306
+ }),
307
+ )
308
+ return { workspaceWide: uses.includes(true), outputs }
119
309
  }
120
310
 
121
311
  interface WatchLoopArgs {
@@ -123,10 +313,14 @@ interface WatchLoopArgs {
123
313
  workspaceRoot: string
124
314
  projects: readonly ProjectMeta[]
125
315
  workspaceWide: boolean
316
+ /** Absolute, already-resolved — the loop must never re-derive it. */
317
+ cacheDir: string
318
+ /** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
319
+ outputs: ReadonlyMap<string, readonly string[]>
126
320
  }
127
321
 
128
322
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
129
- const { opts, workspaceRoot, projects, workspaceWide } = args
323
+ const { opts, workspaceRoot, projects, workspaceWide, cacheDir, outputs } = args
130
324
 
131
325
  // Reentrancy guard — never two orchestrator runs in flight. While
132
326
  // one is running, any further events set `pending = true` and the
@@ -175,15 +369,25 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
175
369
 
176
370
  // Filter out events for paths we don't care about. We watch each
177
371
  // project's dir recursively, so a `node_modules` write under a
178
- // project would otherwise trigger every save during `bun install`.
179
- const isIgnoredPath = (rel: string): boolean => {
180
- const segments = rel.split(path.sep)
181
- if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
182
- if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
183
- return false
184
- }
372
+ // project would otherwise trigger every save during `bun install` —
373
+ // and vx's own cache writes would trigger a cycle that writes again.
374
+ const isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
185
375
 
186
376
  const watchers: fs.FSWatcher[] = []
377
+ const proofs: Promise<void>[] = []
378
+ const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): void => {
379
+ const armed = armWatcher(dir, recursive, onEvent)
380
+ watchers.push(armed.watcher)
381
+ proofs.push(
382
+ armed.ready.then((ok) => {
383
+ if (!ok) {
384
+ process.stderr.write(
385
+ `vx watch: ${dir}: the watcher gave no sign of life within ${WATCH_PROBE_TIMEOUT_MS} ms; early edits there may be missed\n`,
386
+ )
387
+ }
388
+ }),
389
+ )
390
+ }
187
391
 
188
392
  if (workspaceWide) {
189
393
  // workspaceFiles inputs in play: any file in the workspace can be
@@ -192,16 +396,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
192
396
  // ignore filter keeps node_modules / .git / .vx churn out; edits
193
397
  // outside any task's inputs still cost only a cache-hit cycle.
194
398
  try {
195
- const w = fs.watch(
196
- workspaceRoot,
197
- { recursive: true, persistent: true },
198
- (_event, filename) => {
199
- if (filename == null || typeof filename !== 'string') return
200
- if (isIgnoredPath(filename)) return
201
- trigger(`root ${filename}`)
202
- },
203
- )
204
- watchers.push(w)
399
+ arm(workspaceRoot, true, (filename) => {
400
+ if (isIgnoredPath(workspaceRoot, filename)) return
401
+ trigger(`root ${filename}`)
402
+ })
205
403
  } catch (err) {
206
404
  const msg = err instanceof Error ? err.message : String(err)
207
405
  process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
@@ -212,13 +410,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
212
410
  // cover every project + node_modules + caches).
213
411
  for (const proj of projects) {
214
412
  try {
215
- const w = fs.watch(proj.dir, { recursive: true, persistent: true }, (_event, filename) => {
216
- if (filename == null) return
217
- if (typeof filename !== 'string') return
218
- if (isIgnoredPath(filename)) return
413
+ arm(proj.dir, true, (filename) => {
414
+ if (isIgnoredPath(proj.dir, filename)) return
219
415
  trigger(`${proj.name} ${filename}`)
220
416
  })
221
- watchers.push(w)
222
417
  } catch (err) {
223
418
  const msg = err instanceof Error ? err.message : String(err)
224
419
  process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
@@ -229,15 +424,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
229
424
  // pnpm-workspace.yaml edits trigger re-runs even when no project
230
425
  // dir saw the change.
231
426
  try {
232
- const w = fs.watch(
233
- workspaceRoot,
234
- { recursive: false, persistent: true },
235
- (_event, filename) => {
236
- if (filename == null || typeof filename !== 'string') return
237
- if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
238
- },
239
- )
240
- watchers.push(w)
427
+ arm(workspaceRoot, false, (filename) => {
428
+ if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
429
+ })
241
430
  } catch (err) {
242
431
  const msg = err instanceof Error ? err.message : String(err)
243
432
  process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
@@ -248,6 +437,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
248
437
  // re-runs because IGNORED_SEGMENTS includes `.vx`. Users who
249
438
  // relocate the cache dir outside `.vx/` need their own filtering.
250
439
 
440
+ // "watching" is a promise that an edit from now on is seen; every
441
+ // watcher has proved (or been given 2 s to prove) delivery first.
442
+ await Promise.all(proofs)
443
+
251
444
  process.stdout.write(
252
445
  workspaceWide
253
446
  ? `\nvx watch: watching the workspace root (workspaceFiles inputs in use); press Ctrl+C to stop\n`
@@ -274,14 +467,19 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
274
467
  })
275
468
  }
276
469
 
470
+ /**
471
+ * True for a root file that re-keys EVERY task, so a change to one must trigger
472
+ * a cycle even though it lives in no project dir.
473
+ *
474
+ * Reads the SHARED constant the fingerprint itself walks — this used to be a
475
+ * third hand-rolled copy of that list, which is precisely the drift the
476
+ * `--affected` wave exported the constant to prevent. A name added there but
477
+ * not here would re-key every task while `vx watch` silently never re-ran on
478
+ * it: the loop looks alive, and the one edit that invalidates the whole
479
+ * workspace is the one it ignores.
480
+ */
481
+ const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
482
+
277
483
  function isWorkspaceFingerprintFile(name: string): boolean {
278
- return (
279
- name === 'pnpm-lock.yaml' ||
280
- name === 'package-lock.json' ||
281
- name === 'npm-shrinkwrap.json' ||
282
- name === 'yarn.lock' ||
283
- name === 'bun.lock' ||
284
- name === 'bun.lockb' ||
285
- name === 'pnpm-workspace.yaml'
286
- )
484
+ return FINGERPRINT_FILES.has(name)
287
485
  }
package/src/cli/why.ts ADDED
@@ -0,0 +1,193 @@
1
+ // `vx why [pkg#task | task]` — answer "why did this task re-run?" from the
2
+ // terminal. The same persisted data the MCP `whyDidThisRerun` tool reads:
3
+ // the runs table names the
4
+ // run-over-run hash change, and `entry_inputs` (the input-fingerprint moat)
5
+ // names the exact cache-key components that differ. Read-only over cache.db —
6
+ // no config evaluation, no re-hash.
7
+
8
+ import { Cache } from '../cache/index.js'
9
+ import { splitTaskId } from '../graph/index.js'
10
+ import {
11
+ cacheKeyDiff,
12
+ explainCacheKeyQuery as explainCacheKey,
13
+ whyDidThisRerunQuery as whyDidThisRerun,
14
+ } from '../orchestrator/index.js'
15
+ import { UserError } from '../util/index.js'
16
+ import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
17
+
18
+ interface WhyArgs {
19
+ target?: string
20
+ runId?: string
21
+ format: 'pretty' | 'json'
22
+ error?: string
23
+ }
24
+
25
+ export function parseWhyArgs(args: readonly string[]): WhyArgs {
26
+ const out: WhyArgs = { format: 'pretty' }
27
+ for (let i = 0; i < args.length; i++) {
28
+ const a = args[i]!
29
+ // Match on the flag NAME first. Reading the value into the same
30
+ // `undefined` that means "not this flag" conflated the two: a trailing
31
+ // `--run` (value omitted) consumed a non-existent argv slot, fell through,
32
+ // and was reported as `unknown flag: --run` — false, since `--run` is very
33
+ // much known, and silent about the real mistake. The `=` spelling of the
34
+ // same mistake already said `invalid --run: empty`, so one omitted value
35
+ // got two different diagnoses depending on how it was typed.
36
+ if (a === '--run' || a.startsWith('--run=')) {
37
+ const rv = a === '--run' ? args[++i] : a.slice(6)
38
+ if (rv === undefined || rv === '') return { ...out, error: 'invalid --run: empty' }
39
+ out.runId = rv
40
+ continue
41
+ }
42
+ if (a === '--format' || a.startsWith('--format=')) {
43
+ const fv = a === '--format' ? args[++i] : a.slice(9)
44
+ if (fv !== 'pretty' && fv !== 'json') {
45
+ // An omitted value reads as empty, not as the literal word "undefined".
46
+ return {
47
+ ...out,
48
+ error: `invalid --format: ${fv ?? ''} (expected pretty | json)`,
49
+ }
50
+ }
51
+ out.format = fv
52
+ continue
53
+ }
54
+ if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
55
+ if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
56
+ out.target = a
57
+ }
58
+ return out
59
+ }
60
+
61
+ /** Simple includes-match in both directions, case-insensitive. */
62
+ function suggest(query: string, candidates: readonly string[]): string {
63
+ const q = query.toLowerCase()
64
+ const hits = candidates.filter((c) => {
65
+ const n = c.toLowerCase()
66
+ return n.includes(q) || q.includes(n)
67
+ })
68
+ return hits.length > 0 ? ` — did you mean ${hits.slice(0, 3).join(', ')}?` : ''
69
+ }
70
+
71
+ /**
72
+ * Resolve a positional target to a full `project#task` id against the runs
73
+ * table. A `pkg#task` form is used as-is; a bare task name matches every
74
+ * project that ran it — unique → resolved, several → error listing them.
75
+ */
76
+ function resolveTarget(cache: Cache, target: string): string {
77
+ const db = cache.dbHandle()
78
+ const pairs = db
79
+ .query('SELECT DISTINCT project, task FROM runs ORDER BY project, task')
80
+ .all() as Array<{ project: string; task: string }>
81
+ const ids = pairs.map((p) => `${p.project}#${p.task}`)
82
+ if (target.includes('#')) {
83
+ if (ids.includes(target)) return target
84
+ throw new UserError(`vx why: no recorded runs for "${target}"${suggest(target, ids)}`)
85
+ }
86
+ const matches = ids.filter((id) => id.endsWith(`#${target}`))
87
+ if (matches.length === 1) return matches[0]!
88
+ if (matches.length > 1) {
89
+ throw new UserError(
90
+ `vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
91
+ matches.map((m) => ` ${m}`).join('\n'),
92
+ )
93
+ }
94
+ throw new UserError(`vx why: no recorded runs for task "${target}"${suggest(target, ids)}`)
95
+ }
96
+
97
+ /** The latest recorded run of a task (run_id may be NULL on very old rows). */
98
+ function latestRunId(cache: Cache, taskId: string): string | null {
99
+ const [project, task] = splitTaskId(taskId)
100
+ const row = cache
101
+ .dbHandle()
102
+ .query(
103
+ `SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
104
+ ORDER BY started_at DESC LIMIT 1`,
105
+ )
106
+ .get(project, task) as { runId: string | null } | undefined
107
+ return row?.runId ?? null
108
+ }
109
+
110
+ const fmtWhen = (ms: number): string => new Date(ms).toISOString()
111
+
112
+ export async function whyCmd(args: readonly string[]): Promise<number> {
113
+ const parsed = parseWhyArgs(args)
114
+ if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`)
115
+ if (parsed.target === undefined) {
116
+ throw new UserError('vx why: <task> required (e.g. vx why app#build, or vx why build)')
117
+ }
118
+
119
+ const root = await findWorkspaceRoot(process.cwd())
120
+ const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
121
+ const cache = new Cache(cacheDir)
122
+ try {
123
+ const db = cache.dbHandle()
124
+ const taskId = resolveTarget(cache, parsed.target)
125
+ const runId = parsed.runId ?? latestRunId(cache, taskId)
126
+
127
+ if (runId === null) {
128
+ // Runs exist (resolveTarget passed) but predate run ids — fall back to
129
+ // the persisted entry metadata so the verb still says something useful.
130
+ const explanation = explainCacheKey(db, taskId)
131
+ if (parsed.format === 'json') {
132
+ process.stdout.write(`${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
133
+ } else {
134
+ process.stdout.write(
135
+ `${taskId}: recorded runs carry no run id — showing the latest cache entry instead\n` +
136
+ (explanation.latestEntry !== null
137
+ ? ` hash ${explanation.latestEntry.hash} · $ ${explanation.latestEntry.command}\n`
138
+ : ' (no cache entry either)\n'),
139
+ )
140
+ }
141
+ return 0
142
+ }
143
+
144
+ const why = whyDidThisRerun(db, runId, taskId)
145
+ if (!why.found) {
146
+ throw new UserError(`vx why: run ${runId} has no row for ${taskId}`)
147
+ }
148
+ const diff = cacheKeyDiff(db, runId, taskId)
149
+
150
+ if (parsed.format === 'json') {
151
+ process.stdout.write(`${JSON.stringify({ taskId, runId, why, diff })}\n`)
152
+ return 0
153
+ }
154
+
155
+ const lines: string[] = []
156
+ const t = why.thisRun!
157
+ lines.push(`${taskId} — run ${runId}`)
158
+ lines.push(
159
+ ` this run ${fmtWhen(t.startedAt)} · ${t.status}` +
160
+ `${t.cacheHit === true ? ' · cache hit' : t.cacheHit === false ? ' · executed' : ''} · key ${t.hash}`,
161
+ )
162
+ if (why.previousRun == null) {
163
+ lines.push(' previous (none — first recorded run of this task)')
164
+ } else {
165
+ const p = why.previousRun
166
+ lines.push(` previous ${fmtWhen(p.startedAt)} · ${p.status} · key ${p.hash}`)
167
+ lines.push(` verdict ${why.note}`)
168
+ }
169
+
170
+ if (diff.entries.length > 0) {
171
+ lines.push('')
172
+ lines.push(
173
+ ` what changed (${diff.entries.length} component${diff.entries.length === 1 ? '' : 's'}, ${diff.unchangedCount} unchanged):`,
174
+ )
175
+ const kindW = Math.max(...diff.entries.map((e) => e.kind.length), 4)
176
+ for (const e of diff.entries) {
177
+ const beforeAfter =
178
+ e.change === 'added'
179
+ ? `+ ${e.after}`
180
+ : e.change === 'removed'
181
+ ? `- ${e.before}`
182
+ : `${e.before} → ${e.after}`
183
+ lines.push(` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${e.name} ${beforeAfter}`)
184
+ }
185
+ } else if (why.hashChanged === true) {
186
+ lines.push(` detail ${diff.note}`)
187
+ }
188
+ process.stdout.write(`${lines.join('\n')}\n`)
189
+ return 0
190
+ } finally {
191
+ cache.close()
192
+ }
193
+ }