@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -15,6 +15,8 @@ export interface WatchCycle {
15
15
  /** Stop the run as a SIGTERM would; watch keeps going. */
16
16
  interrupt(): void
17
17
  readonly interrupted: boolean
18
+ /** The run's task count, once it has started. */
19
+ readonly tasks: number | undefined
18
20
  }
19
21
 
20
22
  /** `onWaiting` hears each task event after which the cycle waits on readiness alone. */
@@ -23,21 +25,26 @@ export function watchCycle(opts: RunOptions, stop: AbortSignal, onWaiting: () =>
23
25
  const bus = createEventBus()
24
26
  // id → persistent; a group task has no process and holds nothing up.
25
27
  const active = new Map<string, boolean>()
28
+ let tasks: number | undefined
26
29
  const waitsOnReadiness = (): boolean =>
27
30
  active.size > 0 && [...active.values()].every((persistent) => persistent)
28
31
  bus.subscribe((e) => {
29
- if (e.kind === 'task:start' && e.node.config.exec !== undefined)
32
+ if (e.kind === 'run:start') tasks = e.info.total
33
+ else if (e.kind === 'task:start' && e.node.config.exec !== undefined)
30
34
  active.set(e.node.id, e.node.config.exec.persistent !== undefined)
31
35
  else if (e.kind === 'task:complete') active.delete(e.node.id)
32
36
  else return
33
37
  if (waitsOnReadiness()) onWaiting()
34
38
  })
35
39
  return {
36
- opts: { ...opts, bus, signal: AbortSignal.any([stop, own.signal]) },
40
+ opts: { ...opts, bus, footer: false, signal: AbortSignal.any([stop, own.signal]) },
37
41
  waitsOnReadiness,
38
42
  interrupt: () => own.abort('SIGTERM'),
39
43
  get interrupted() {
40
44
  return own.signal.aborted
41
45
  },
46
+ get tasks() {
47
+ return tasks
48
+ },
42
49
  }
43
50
  }
@@ -5,6 +5,7 @@
5
5
  import path from 'node:path'
6
6
  import {
7
7
  executablePath,
8
+ gitEnv,
8
9
  isLiteralPattern,
9
10
  normalizeGlob,
10
11
  outputMatcher,
@@ -13,12 +14,8 @@ import {
13
14
  taskGlob,
14
15
  } from '../util/index.js'
15
16
  import { asTrees } from '../cache/index.js'
16
- import {
17
- PROJECT_CONFIG_FILENAMES,
18
- WORKSPACE_CONFIG_FILENAMES,
19
- WORKSPACE_FINGERPRINT_FILES,
20
- } from '../workspace/index.js'
21
- import { IGNORED_SEGMENTS } from './watch-fs.js'
17
+ import { PROJECT_CONFIG_FILENAMES, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
18
+ import { isIgnoredDir } from './watch-fs.js'
22
19
 
23
20
  const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
24
21
 
@@ -33,16 +30,17 @@ const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
33
30
  */
34
31
  export function isIgnoredWatchPath(rel: string): boolean {
35
32
  const segments = rel.split(path.sep)
36
- if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
33
+ for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
37
34
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
38
- return false
35
+ // `vx lock`'s review copy: no input, so a re-lock is no cycle.
36
+ return segments[segments.length - 1] === 'vx.lock.json'
39
37
  }
40
38
 
41
39
  /**
42
40
  * The ignore predicate a watcher applies, closed over the run's ACTUAL cache
43
41
  * directory.
44
42
  *
45
- * `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
43
+ * `isIgnoredDir` covers the `.vx/cache` default, but `cacheDir` is a
46
44
  * shipped `defineWorkspace` field: point it anywhere else and vx's own cache
47
45
  * writes land in a watched subtree, so every cycle triggers the next one.
48
46
  * Measured on a relocated cache under a recursive root watcher: ONE edit kicked
@@ -54,6 +52,8 @@ export function makeWatchIgnore(
54
52
  cacheDir: string,
55
53
  outputs: ReadonlyMap<string, readonly string[]> = new Map(),
56
54
  inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>> = new Map(),
55
+ /** What git tracked at the arm (`gitFiles`): never an output, so an edit there is one. */
56
+ tracked?: ReadonlySet<string>,
57
57
  ): (base: string, filename: string) => boolean {
58
58
  // Watchers report under the real root; a cache dir named through a link
59
59
  // (`VX_CACHE_DIR`, macOS `/var` -> `/private/var`) is matched as its target.
@@ -122,6 +122,14 @@ export function makeWatchIgnore(
122
122
  const abs = path.resolve(base, filename)
123
123
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
124
124
  if (isInput(abs)) return false
125
+ // Core never cleans or saves a file git tracks and keys it under the
126
+ // task's own output glob (2026-10-14); a submodule is listed as its
127
+ // gitlink alone, so a path under a tracked one counts.
128
+ if (tracked !== undefined)
129
+ for (let p = abs; p.length > 1; p = path.dirname(p)) {
130
+ if (tracked.has(p)) return false
131
+ if (p === path.dirname(p)) break
132
+ }
125
133
  for (const [dir, isOutput, containers] of declared) {
126
134
  if (!abs.startsWith(dir + path.sep)) continue
127
135
  const rel = abs
@@ -191,6 +199,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
191
199
  try {
192
200
  proc = Bun.spawnSync({
193
201
  cmd: [executablePath('git'), 'check-ignore', '-v', '-n', '-z', '--stdin'],
202
+ env: gitEnv(),
194
203
  cwd: workspaceRoot,
195
204
  stdin: Buffer.from(rest.map((p) => `${p}\0`).join('')),
196
205
  stdout: 'pipe',
@@ -215,6 +224,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
215
224
  inWorkTree ??=
216
225
  Bun.spawnSync({
217
226
  cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
227
+ env: gitEnv(),
218
228
  cwd: workspaceRoot,
219
229
  stdout: 'ignore',
220
230
  stderr: 'ignore',
@@ -231,7 +241,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
231
241
  * watcher hears every write in the workspace; a key can see three kinds
232
242
  * of path and no other: a file inside a project's directory (its own
233
243
  * `inputs.files`, or its outputs, which the ignore filter drops next), a
234
- * workspace fingerprint file at the root, and a match of a declared
244
+ * root file a plugin claims, and a match of a declared
235
245
  * `inputs.workspaceFiles` glob. Everything else — a log written at the
236
246
  * root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
237
247
  * dropped before the trigger, so it costs no cycle. Before this rule the
@@ -257,24 +267,10 @@ export function makeRootEventFilter(
257
267
  )
258
268
  return (filename: string): boolean => {
259
269
  const rel = filename.split(path.sep).join('/')
260
- // The depth test is a READING AID, not a guard: both predicates below
261
- // are exact membership in a set of BARE names, so a `rel` carrying a
262
- // slash can never be in one. Measured with it removed (item 502) over
263
- // `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
264
- // eight more: every answer identical. The two table rows that look
265
- // like they pin it — `nested/pnpm-lock.yaml → false` and
266
- // `nested/vx.workspace.ts → false` — pass either way; what they
267
- // actually pin is that the predicates stay exact rather than becoming
268
- // a basename or suffix match, which is the change that WOULD make
269
- // this line load-bearing.
270
- if (
271
- !rel.includes('/') &&
272
- (isWorkspaceFingerprintFile(rel) ||
273
- isWorkspaceConfigFile(rel) ||
274
- rel === 'package.json' ||
275
- rel === '.gitignore' ||
276
- claimedRootFiles.has(rel))
277
- ) {
270
+ // Exact membership, not a basename match: `nested/pnpm-lock.yaml` and
271
+ // `nested/.vx/workspace.ts` are no root file. The workspace config and
272
+ // a claimed file carry their `.vx/` prefix.
273
+ if (isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel) || rel === '.gitignore') {
278
274
  return true
279
275
  }
280
276
  const abs = path.resolve(workspaceRoot, filename)
@@ -305,30 +301,19 @@ export function makeFence(fenceDirs: readonly string[]): (ownDir: string, abs: s
305
301
  }
306
302
 
307
303
  /**
308
- * A file whose edit can change what the loop watches: a `package.json`
309
- * (a dependency added under `--filter` widens the closure), a project
310
- * config (a task that starts declaring `workspaceFiles` needs the root
311
- * watcher, a new output must stop being an event) or the workspace config
312
- * (a plugin's `project` stage). Until item 891 only a member directory
313
- * coming or going re-read the set, so each of these waited for a restart
314
- * while the loop looked alive. A root fingerprint file too, for
315
- * `pnpm-workspace.yaml`: a glob added there was a cycle that ran the new
316
- * packages and watched none of them (item 1018).
304
+ * A file whose edit can change what the loop watches: a project config (a
305
+ * task that starts declaring `workspaceFiles` needs the root watcher, a
306
+ * new output must stop being an event) or the workspace config (a
307
+ * plugin's `project` stage). Until item 891 each of these waited for a
308
+ * restart while the loop looked alive. The lock and a file a plugin
309
+ * claims re-read the set as well (the caller asks: the projects come from
310
+ * the lock, a lockfile plugin's `discover` names and edges from its file).
317
311
  */
318
312
  const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
319
313
 
320
314
  export function shapesWatchedSet(filename: string): boolean {
321
315
  const base = path.basename(filename)
322
- return (
323
- base === 'package.json' ||
324
- isWorkspaceFingerprintFile(base) ||
325
- PROJECT_CONFIGS.has(base) ||
326
- isWorkspaceConfigFile(base)
327
- )
328
- }
329
-
330
- export function isWorkspaceFingerprintFile(name: string): boolean {
331
- return FINGERPRINT_FILES.has(name)
316
+ return PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(filename.split(path.sep).join('/'))
332
317
  }
333
318
 
334
319
  /**
@@ -344,19 +329,6 @@ export function isWorkspaceConfigFile(name: string): boolean {
344
329
  return WORKSPACE_CONFIGS.has(name)
345
330
  }
346
331
 
347
- /**
348
- * True for a root file that re-keys EVERY task, so a change to one must trigger
349
- * a cycle even though it lives in no project dir.
350
- *
351
- * Reads the SHARED constant the fingerprint itself walks — this used to be a
352
- * third hand-rolled copy of that list, which is precisely the drift the
353
- * `--affected` wave exported the constant to prevent. A name added there but
354
- * not here would re-key every task while `vx watch` silently never re-ran on
355
- * it: the loop looks alive, and the one edit that invalidates the whole
356
- * workspace is the one it ignores.
357
- */
358
- const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
359
-
360
332
  /**
361
333
  * Paths as git spells them under `root`: a project reached through a
362
334
  * symlink below the root (`packages/x -> ../shared/x`) is watched at the
@@ -410,6 +382,7 @@ export function gitFiles(
410
382
  '--others',
411
383
  '--exclude-standard',
412
384
  ],
385
+ env: gitEnv(),
413
386
  cwd: workspaceRoot,
414
387
  stdout: 'pipe',
415
388
  stderr: 'ignore',
@@ -5,8 +5,19 @@
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
 
8
- /** Paths whose changes never trigger a re-run. */
9
- export const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
8
+ /** Directories whose changes never trigger a re-run, wherever they appear. */
9
+ export const IGNORED_SEGMENTS = ['node_modules', '.git']
10
+
11
+ /**
12
+ * True for a directory, as `/`-separated segments, the watchers skip: an
13
+ * `IGNORED_SEGMENTS` name, or vx's machine-local `.vx/cache`. The
14
+ * workspace config and the lock beside it in `.vx` are watched.
15
+ */
16
+ export function isIgnoredDir(segments: readonly string[]): boolean {
17
+ const last = segments.length - 1
18
+ if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
19
+ return segments[last] === 'cache' && segments[last - 1] === '.vx'
20
+ }
10
21
 
11
22
  /**
12
23
  * The file `armWatcher` writes under a watched directory to prove the
@@ -41,7 +52,7 @@ const POLL_INTERVAL_MS = 250
41
52
  * `node_modules` is the difference between a cheap fallback and one that
42
53
  * re-stats 40 000 files four times a second.
43
54
  *
44
- * That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
55
+ * That justification is exactly `isIgnoredDir`, so this IS that test.
45
56
  * It used to carry a fourth name, `dist`, which the justification does NOT
46
57
  * cover: `dist` is dropped only when a project DECLARES it as an output, and
47
58
  * a project that does not declare it had its `dist/` sources silently
@@ -50,7 +61,6 @@ const POLL_INTERVAL_MS = 250
50
61
  * delivered them. Two watchers disagreeing about what an edit is (item 482).
51
62
  * The real per-project answer is the caller's `skipDir`, below.
52
63
  */
53
- const POLL_SKIP = new Set(IGNORED_SEGMENTS)
54
64
 
55
65
  /**
56
66
  * A watcher built from `stat`, for when the OS one cannot deliver.
@@ -75,7 +85,7 @@ export function pollWatcher(
75
85
  * what the filter would drop anyway — every declared output container, not
76
86
  * a hard-coded name. The default covers the unconditional segments alone.
77
87
  */
78
- skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
88
+ skipDir: (rel: string) => boolean = (rel) => isIgnoredDir(rel.split('/')),
79
89
  ): WatchHandle {
80
90
  let previous = new Map<string, string>()
81
91
  let first = true
@@ -275,7 +285,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
275
285
  if (name == null || typeof name !== 'string' || name === '' || name === '.') return
276
286
  const child = rel === '' ? name : `${rel}/${name}`
277
287
  listener(child)
278
- if (IGNORED_SEGMENTS.includes(name)) return
288
+ if (isIgnoredDir(child.split('/'))) return
279
289
  let st: fs.Stats
280
290
  try {
281
291
  st = fs.statSync(path.join(abs, name))
@@ -321,7 +331,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
321
331
  for (const e of entries) {
322
332
  const child = rel === '' ? e.name : `${rel}/${e.name}`
323
333
  if (e.isDirectory()) {
324
- if (!IGNORED_SEGMENTS.includes(e.name)) watchDir(child, report)
334
+ if (!isIgnoredDir(child.split('/'))) watchDir(child, report)
325
335
  } else if (report) listener(child)
326
336
  }
327
337
  }
@@ -389,25 +399,28 @@ export const CLOSED: WatchHandle = { close() {} }
389
399
  /**
390
400
  * Every watcher `vx watch` arms. Each OS watcher proves delivery or is
391
401
  * swapped for the poller in its slot; an OS watch limit (ENOSPC, EMFILE)
392
- * goes straight to the poller; `VX_WATCH_POLL=1` polls from the start.
402
+ * goes straight to the poller; `watch.poll` (`--poll`, `VX_WATCH_POLL`)
403
+ * polls from the start.
393
404
  * `skip` is what the poller leaves unsampled, asked on each walk.
394
405
  */
395
406
  export class WatcherPool {
396
407
  private readonly watchers: WatchHandle[] = []
397
408
  private readonly proofs: Promise<void>[] = []
398
- // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
399
- // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
400
- // network mount, a container bind — the attempt costs a denied syscall
401
- // and a two-second wait before the fallback takes over anyway.
402
- private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
403
409
  // The stop closes the pool while a cycle may still be re-arming: an arm
404
410
  // after it, or a proof that fails because it closed the watcher, would
405
411
  // leave a live watcher or poller nothing ever closes.
406
412
  private closed = false
407
413
 
408
- constructor(private readonly skip: (dir: string, rel: string) => boolean) {
409
- if (this.forcePoll)
410
- process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
414
+ // `forcePoll` skips the OS watcher entirely. Where it is known not to
415
+ // work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
416
+ // network mount, a container bind — the attempt costs a denied syscall
417
+ // and a two-second wait before the fallback takes over anyway.
418
+ constructor(
419
+ private readonly skip: (dir: string, rel: string) => boolean,
420
+ private readonly forcePoll = false,
421
+ ) {
422
+ if (forcePoll)
423
+ process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (watch.poll)\n`)
411
424
  }
412
425
 
413
426
  private poll(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
@@ -195,22 +195,31 @@ export class ChangeJudge {
195
195
  // `sed -i` saving through a temporary file fires that file first, and
196
196
  // the cycle was announced by a name already renamed away.
197
197
  let gone: [label: string, abs: string] | undefined
198
+ // "+N more" counts the changes of the winner's kind: an edit's count
199
+ // leaves out the editor's temporary file renamed away.
200
+ let live = 0
201
+ let goneCount = 0
198
202
  for (const [p, l] of this.pending) {
199
203
  if (ignored.has(p) && !editForUncached(p)) continue
200
- if (this.sameState(p, ignored.has(p), spell) || first !== undefined) continue
204
+ if (this.sameState(p, ignored.has(p), spell)) continue
201
205
  if (!fs.existsSync(p)) {
206
+ goneCount++
202
207
  gone ??= [l, p]
203
208
  continue
204
209
  }
210
+ live++
211
+ if (first !== undefined) continue
205
212
  first = l
206
213
  firstAbs = p
207
214
  }
208
215
  if (first === undefined && gone !== undefined) [first, firstAbs] = gone
209
216
  this.pending.clear()
217
+ const more = (live > 0 ? live : goneCount) - 1
218
+ const named = more > 0 ? `${first} (+${more} more)` : first
210
219
  const byServer = firstAbs !== undefined && this.ctx.held()
211
220
  if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
212
221
  this.streak.n = 0
213
- return first
222
+ return named
214
223
  }
215
224
  this.streak.n = this.streak.abs === firstAbs ? this.streak.n + 1 : 1
216
225
  this.streak.abs = firstAbs
@@ -222,6 +231,6 @@ export class ChangeJudge {
222
231
  : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
223
232
  )
224
233
  }
225
- return first
234
+ return named
226
235
  }
227
236
  }
@@ -3,13 +3,13 @@
3
3
  // directories a package glob can grow. Read at start and again whenever an
4
4
  // edit reshapes the set; the loop in watch.ts decides when.
5
5
 
6
- import fs from 'node:fs'
7
6
  import path from 'node:path'
8
7
  import type { ProjectConfig } from '../config.js'
9
8
  import {
10
9
  buildPackageGraph,
10
+ lockKey,
11
11
  configImports,
12
- loadProjectConfig,
12
+ readLockfile,
13
13
  WORKSPACE_CONFIG_FILENAMES,
14
14
  type ProjectEntry,
15
15
  type ProjectMeta,
@@ -79,10 +79,9 @@ export interface ConfigSweep {
79
79
  * declared outputs, so their writes are not taken for edits. The run
80
80
  * path's load (`loadProjects`): the plugin `project` stage applies, so an
81
81
  * output a plugin gave a config-less package is ignored like a declared
82
- * one, and a pure config is served from its cached evaluation rather than
83
- * re-evaluated in a worker. A config that fails to load is out of this
82
+ * one. Configs come from the lock. A load that fails is out of this
84
83
  * concern's scope — the run that just happened already said so, or will —
85
- * and the sweep falls back to the files that do load, one by one.
84
+ * and the sweep falls back to the lock's entries, one by one.
86
85
  */
87
86
  export async function sweepConfigs(
88
87
  projects: readonly ProjectMeta[],
@@ -175,16 +174,18 @@ export async function sweepConfigs(
175
174
  foldAll()
176
175
  return result(staged)
177
176
  }
178
- await Promise.all(
179
- projects.map(async (p) => {
180
- if (p.configPath === null) return
181
- try {
182
- fold(p.dir, await loadProjectConfig(p.configPath))
183
- } catch {
184
- // broken config — out of this concern's scope
185
- }
186
- }),
187
- )
177
+ // The lock's entries one by one: a project it does not hold, or a
178
+ // hand-edited entry, is out of this concern's scope.
179
+ const lock = await readLockfile(workspaceRoot).catch(() => null)
180
+ for (const p of projects) {
181
+ const dir = lockKey(workspaceRoot, p.dir)
182
+ if (p.configPath === null || lock === null || !Object.hasOwn(lock.projects, dir)) continue
183
+ try {
184
+ fold(p.dir, lock.projects[dir]!.config)
185
+ } catch {
186
+ // malformed entry
187
+ }
188
+ }
188
189
  foldAll()
189
190
  return result(null)
190
191
  }
@@ -218,30 +219,3 @@ function reachableNames(
218
219
  }
219
220
  return [...names].some((n) => n.includes('*')) ? null : names
220
221
  }
221
-
222
- /**
223
- * The directory entries under a package glob's directory that can be
224
- * members: directories (or links) whose name is not dotted and not
225
- * `node_modules`, the same rule discovery applies. A watcher on that
226
- * directory reacts only when this set changes.
227
- */
228
- export function memberEntries(base: string): ReadonlySet<string> {
229
- const out = new Set<string>()
230
- let entries: fs.Dirent[]
231
- try {
232
- entries = fs.readdirSync(base, { withFileTypes: true })
233
- } catch {
234
- return out
235
- }
236
- for (const e of entries) {
237
- if (e.name.startsWith('.') || e.name === 'node_modules') continue
238
- if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
239
- }
240
- return out
241
- }
242
-
243
- export function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
244
- if (a.size !== b.size) return false
245
- for (const name of a) if (!b.has(name)) return false
246
- return true
247
- }