@vzn/vx 0.0.594 → 0.0.596

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 (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
package/src/cli/select.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  refIsHead,
13
13
  applyFilters,
14
14
  buildPackageGraph,
15
+ defaultAffectedBase,
15
16
  findWorkspaceRoot,
16
17
  type LoadReads,
17
18
  type FingerprintClaims,
@@ -26,7 +27,7 @@ import type { ProjectConfig } from '../config.js'
26
27
  import type { ProjectEntry } from '../workspace/index.js'
27
28
  import { parseDependencySpec } from '../graph/index.js'
28
29
  import { declaresInput } from '../cache/index.js'
29
- import { listed, maskedLine, nearest, UserError } from '../util/index.js'
30
+ import { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
30
31
  import {
31
32
  claimedAffected,
32
33
  fingerprintClaims,
@@ -228,6 +229,30 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
228
229
  return out
229
230
  }
230
231
 
232
+ /**
233
+ * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
234
+ * projects and their dependents. An empty base is the workspace's
235
+ * `affectedBase` (or a plugin's `config` stage, from nx.json's
236
+ * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
237
+ */
238
+ export async function affectedFilterFor(
239
+ cwd: string,
240
+ affected: string,
241
+ ): Promise<string | { error: string }> {
242
+ const root = await findWorkspaceRoot(cwd)
243
+ let base = affected
244
+ if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
245
+ if (base === '') {
246
+ try {
247
+ base = await defaultAffectedBase(root)
248
+ } catch (err) {
249
+ if (!isUserError(err)) throw err
250
+ return { error: err.message }
251
+ }
252
+ }
253
+ return `...[${base}]`
254
+ }
255
+
231
256
  export async function resolveFilters(
232
257
  cwd: string,
233
258
  raw: string[],
@@ -471,7 +496,9 @@ export async function pickTask(
471
496
  )
472
497
  return null
473
498
  }
474
- const out = io.output ?? process.stdout
499
+ // The menu is a conversation with the terminal, not the run's output: on
500
+ // stdout, `vx run > out.txt` put it in the file and asked a blank screen.
501
+ const out = io.output ?? process.stderr
475
502
  const numW = String(entries.length).length
476
503
  const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
477
504
  out.write('Tasks:\n')
@@ -486,7 +513,7 @@ export async function pickTask(
486
513
  const readline = await import('node:readline/promises')
487
514
  const rl = readline.createInterface({
488
515
  input: io.input ?? process.stdin,
489
- output: io.output ?? process.stdout,
516
+ output: out,
490
517
  })
491
518
  // On a terminal readline takes Ctrl-C and Ctrl-D itself, raw, and
492
519
  // rejects the pending question with an AbortError — which reached the
package/src/cli/show.ts CHANGED
@@ -12,6 +12,7 @@ import { declaredTask } from '../graph/index.js'
12
12
  import { isDefaultBuild } from '../orchestrator/index.js'
13
13
  import { flagHint, formatValue, seeHelp } from './help.js'
14
14
  import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
15
+ import { affectedFilterFor, resolveFilters } from './select.js'
15
16
  import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
16
17
  import {
17
18
  findWorkspaceRoot,
@@ -24,16 +25,27 @@ import {
24
25
  interface ShowArgs {
25
26
  target?: string
26
27
  format: 'pretty' | 'json'
28
+ /** `--filter`, as `vx run` reads it: which projects the list shows. */
29
+ filters: string[]
30
+ /** `--affected[=<base>]`: `''` is the default base. */
31
+ affected?: string
27
32
  error?: string
28
33
  }
29
34
 
30
35
  export function parseShowArgs(args: readonly string[]): ShowArgs {
31
- const out: ShowArgs = { format: 'pretty' }
36
+ const out: ShowArgs = { format: 'pretty', filters: [] }
32
37
  for (let i = 0; i < args.length; i++) {
33
38
  const a = args[i]!
34
39
  let format: string | undefined
35
40
  if (a === '--format') format = args[++i] ?? ''
36
41
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
42
+ else if (a === '--filter') {
43
+ const v = args[++i]
44
+ if (v === undefined) return { ...out, error: '--filter requires a value (a pattern)' }
45
+ out.filters.push(v)
46
+ } else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
47
+ else if (a === '--affected') out.affected = ''
48
+ else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
37
49
  else if (a.startsWith('-'))
38
50
  return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
39
51
  else if (out.target === 'project')
@@ -103,10 +115,35 @@ export async function showCmd(args: readonly string[]): Promise<number> {
103
115
  const bareTask = projectName !== undefined && taskName === undefined && !byName.has(projectName)
104
116
  const scope = projectName === undefined || bareTask ? 'all' : [projectName]
105
117
 
118
+ // `turbo ls --filter` / `nx show projects --affected`: the list, and a
119
+ // task's projects, narrowed by the selection `vx run` would make.
120
+ let selected: Set<string> | undefined
121
+ if (parsed.filters.length > 0 || parsed.affected !== undefined) {
122
+ if (scope !== 'all') {
123
+ throw new UserError(
124
+ 'vx show: --filter and --affected narrow a list: `vx show` or `vx show <task>`',
125
+ )
126
+ }
127
+ const raw = [...parsed.filters]
128
+ let affected: string | undefined
129
+ if (parsed.affected !== undefined) {
130
+ const f = await affectedFilterFor(process.cwd(), parsed.affected)
131
+ if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`)
132
+ affected = f
133
+ raw.unshift(f)
134
+ }
135
+ const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
136
+ if ('error' in r) throw new UserError(`vx show: ${r.error}`)
137
+ if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
138
+ selected = new Set('empty' in r ? [] : r.names)
139
+ }
140
+ const inSelection = (name: string): boolean => selected === undefined || selected.has(name)
141
+
106
142
  const projects = await loadCliProjects(root, metas, scope, { noCreate: true, closure: true })
107
143
 
108
144
  if (parsed.target === undefined) {
109
- process.stdout.write(renderList(root, metas, projects, parsed.format))
145
+ const shown = metas.filter((m) => inSelection(m.name))
146
+ process.stdout.write(renderList(root, shown, projects, parsed.format))
110
147
  return 0
111
148
  }
112
149
 
@@ -124,7 +161,8 @@ export async function showCmd(args: readonly string[]): Promise<number> {
124
161
  `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
125
162
  )
126
163
  }
127
- process.stdout.write(renderTaskAcross(root, declaring, projectName!, parsed.format))
164
+ const shown = declaring.filter((p) => inSelection(p.name))
165
+ process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
128
166
  return 0
129
167
  }
130
168
 
@@ -239,6 +277,7 @@ function renderList(
239
277
  2,
240
278
  )}\n`
241
279
  }
280
+ if (rows.length === 0) return ''
242
281
  const nameW = Math.max(...rows.map((r) => r.name.length))
243
282
  const dirW = Math.max(...rows.map((r) => r.dir.length))
244
283
  const lines = rows.map((r) => {
@@ -8,6 +8,7 @@ import {
8
8
  isLiteralPattern,
9
9
  normalizeGlob,
10
10
  outputMatcher,
11
+ realPath,
11
12
  staticPrefix,
12
13
  taskGlob,
13
14
  } from '../util/index.js'
@@ -54,7 +55,9 @@ export function makeWatchIgnore(
54
55
  outputs: ReadonlyMap<string, readonly string[]> = new Map(),
55
56
  inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>> = new Map(),
56
57
  ): (base: string, filename: string) => boolean {
57
- const cacheAbs = path.resolve(cacheDir)
58
+ // Watchers report under the real root; a cache dir named through a link
59
+ // (`VX_CACHE_DIR`, macOS `/var` -> `/private/var`) is matched as its target.
60
+ const cacheAbs = realThrough(path.resolve(cacheDir))
58
61
  // A task's own outputs are not edits: without this every cycle that
59
62
  // writes `dist/` (or `out.txt`) re-runs once more, reporting
60
63
  // "up-to-date" for the trouble. Matched under the directory the globs
@@ -132,6 +135,16 @@ export function makeWatchIgnore(
132
135
  }
133
136
  }
134
137
 
138
+ /** `p` with its longest existing prefix realpath'd: the cache dir may not exist yet. */
139
+ function realThrough(p: string): string {
140
+ try {
141
+ return realPath(p)
142
+ } catch {
143
+ const up = path.dirname(p)
144
+ return up === p ? p : path.join(realThrough(up), path.basename(p))
145
+ }
146
+ }
147
+
135
148
  /**
136
149
  * The literal directory a glob's matches live under (`''` when the glob
137
150
  * starts with a pattern, or negates). A literal entry is a file or its
@@ -166,9 +179,14 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
166
179
  // ignored let a pid file in the same window start cycles again. The
167
180
  // refused path is skipped and the rest asked again; a refusal before
168
181
  // any record outside a work tree is git refusing them all.
169
- let rest = paths
182
+ // Git refuses a path beyond a symbolic link: its ignored pid file read
183
+ // as an edit and an uncached task re-ran forever. Asked at its real
184
+ // place, answered as given.
185
+ const real = paths.map(gitSpeller(workspaceRoot))
186
+ let from = 0
170
187
  let inWorkTree: boolean | undefined
171
- while (rest.length > 0) {
188
+ while (from < paths.length) {
189
+ const rest = real.slice(from)
172
190
  let proc: ReturnType<typeof Bun.spawnSync>
173
191
  try {
174
192
  proc = Bun.spawnSync({
@@ -188,8 +206,8 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
188
206
  const fields = new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')
189
207
  const records = Math.floor(fields.length / 4)
190
208
  for (let i = 0; i < records; i++) {
191
- const [source, , pattern, p] = fields.slice(i * 4, i * 4 + 4)
192
- if (source !== '' && !pattern!.startsWith('!')) ignored.add(p!)
209
+ const [source, , pattern] = fields.slice(i * 4, i * 4 + 4)
210
+ if (source !== '' && !pattern!.startsWith('!')) ignored.add(paths[from + i]!)
193
211
  }
194
212
  // 0: some ignored; 1: none. Anything else is git refusing.
195
213
  if (proc.exitCode === 0 || proc.exitCode === 1) return ignored
@@ -203,7 +221,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
203
221
  }).exitCode === 0
204
222
  if (!inWorkTree) return ignored
205
223
  }
206
- rest = rest.slice(records + 1)
224
+ from += records + 1
207
225
  }
208
226
  return ignored
209
227
  }
@@ -338,19 +356,59 @@ export function isWorkspaceConfigFile(name: string): boolean {
338
356
  */
339
357
  const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
340
358
 
359
+ /**
360
+ * Paths as git spells them under `root`: a project reached through a
361
+ * symlink below the root (`packages/x -> ../shared/x`) is watched at the
362
+ * link, and git names and answers for its files only at the real place.
363
+ * The root keeps its own spelling (macOS's `/var` is `/private/var`), the
364
+ * name its own (the file may be gone); a directory outside the root is
365
+ * kept as given. Directories are resolved once per speller.
366
+ */
367
+ export function gitSpeller(root: string): (p: string) => string {
368
+ const dirs = new Map<string, string>()
369
+ let realRoot: string | undefined
370
+ return (p) => {
371
+ const dir = path.dirname(p)
372
+ let at = dirs.get(dir)
373
+ if (at === undefined) {
374
+ at = dir
375
+ try {
376
+ realRoot ??= realPath(root)
377
+ const rel = path.relative(realRoot, realPath(dir))
378
+ if (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)) {
379
+ at = path.join(root, rel)
380
+ }
381
+ } catch {}
382
+ dirs.set(dir, at)
383
+ }
384
+ return path.join(at, path.basename(p))
385
+ }
386
+ }
387
+
341
388
  /**
342
389
  * The files under the workspace git lists, tracked and untracked, ignored
343
390
  * ones aside, and every directory above one (git lists no directory, and
344
391
  * a project moved away whole is one event on its directory): what existed
345
392
  * when watch armed, so a path born and gone since is told from a deletion
346
- * (watch-judge.ts). Undefined when git cannot answer: no inventory, every
347
- * gone path is a deletion as before.
393
+ * (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
394
+ * so a server cannot be blamed for one. Undefined when git cannot answer:
395
+ * no inventory, every gone path is a deletion as before.
348
396
  */
349
- export function gitFiles(workspaceRoot: string): Set<string> | undefined {
397
+ export function gitFiles(
398
+ workspaceRoot: string,
399
+ ): { listed: Set<string>; tracked: Set<string> } | undefined {
350
400
  let proc: ReturnType<typeof Bun.spawnSync>
351
401
  try {
352
402
  proc = Bun.spawnSync({
353
- cmd: [executablePath('git'), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
403
+ cmd: [
404
+ executablePath('git'),
405
+ 'ls-files',
406
+ '-z',
407
+ '-t',
408
+ '--cached',
409
+ '--others',
410
+ '--exclude-standard',
411
+ ],
354
412
  cwd: workspaceRoot,
355
413
  stdout: 'pipe',
356
414
  stderr: 'ignore',
@@ -360,13 +418,19 @@ export function gitFiles(workspaceRoot: string): Set<string> | undefined {
360
418
  }
361
419
  if (proc.exitCode !== 0) return undefined
362
420
  const files = new Set<string>()
363
- for (const p of new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')) {
364
- if (p.length === 0) continue
421
+ const tracked = new Set<string>()
422
+ for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
423
+ .decode(proc.stdout)
424
+ .split('\0')) {
425
+ if (entry.length === 0) continue
426
+ // `-t` prefixes a tag and a space; `?` is untracked.
427
+ const p = entry.slice(2)
365
428
  // An untracked nested repository is listed as `dir/`.
366
429
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
367
430
  files.add(abs)
431
+ if (entry[0] !== '?') tracked.add(abs)
368
432
  for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
369
433
  files.add(abs)
370
434
  }
371
- return files
435
+ return { listed: files, tracked }
372
436
  }
@@ -77,10 +77,10 @@ export function pollWatcher(
77
77
  */
78
78
  skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
79
79
  ): WatchHandle {
80
- let previous = new Map<string, number>()
80
+ let previous = new Map<string, string>()
81
81
  let first = true
82
82
  const scan = (): void => {
83
- const current = new Map<string, number>()
83
+ const current = new Map<string, string>()
84
84
  const walk = (abs: string, rel: string): void => {
85
85
  let entries: fs.Dirent[]
86
86
  try {
@@ -95,15 +95,23 @@ export function pollWatcher(
95
95
  if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
96
96
  continue
97
97
  }
98
- if (!e.isFile()) continue
98
+ // A link is an input as its target string (watch-judge.ts), so that
99
+ // string is sampled, never the target's times: a retarget inside
100
+ // one clock tick carries the same times.
101
+ const link = e.isSymbolicLink()
102
+ if (!e.isFile() && !link) continue
99
103
  try {
100
104
  // The later of the two clocks, as `modifiedBefore` reads them: a
101
105
  // replacement that carries the old file's mtime (`cp -p`, `rsync
102
106
  // -a`, `mv` of a file stamped the same) moved nothing under mtime
103
107
  // alone, and the poller never ran it where the native watcher did.
104
108
  // A rename or a write moves ctime, and no process can set it.
105
- const st = fs.statSync(path.join(abs, e.name))
106
- current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
109
+ const p = path.join(abs, e.name)
110
+ if (link) current.set(childRel, `link:${fs.readlinkSync(p)}`)
111
+ else {
112
+ const st = fs.statSync(p)
113
+ current.set(childRel, String(Math.max(st.mtimeMs, st.ctimeMs)))
114
+ }
107
115
  } catch {
108
116
  // raced with a delete; the next scan settles it
109
117
  }
@@ -111,8 +119,8 @@ export function pollWatcher(
111
119
  }
112
120
  walk(dir, '')
113
121
  if (!first) {
114
- for (const [rel, mtime] of current) {
115
- if (previous.get(rel) !== mtime) onEvent(rel)
122
+ for (const [rel, stamp] of current) {
123
+ if (previous.get(rel) !== stamp) onEvent(rel)
116
124
  }
117
125
  for (const rel of previous.keys()) {
118
126
  if (!current.has(rel)) onEvent(rel)
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import { xxh3 } from '../util/index.js'
8
- import { gitIgnored } from './watch-filter.js'
8
+ import { gitIgnored, gitSpeller } from './watch-filter.js'
9
9
  import { modifiedBefore } from './watch-fs.js'
10
10
 
11
11
  const ABSENT = -1n
@@ -63,6 +63,8 @@ export interface JudgeContext {
63
63
  fenced?(ownDir: string, abs: string): boolean
64
64
  /** The files git listed at the arm; absent when git could not answer. */
65
65
  existedAtArm?: ReadonlySet<string>
66
+ /** The files git tracked at the arm; absent when git could not answer. */
67
+ trackedAtArm?: ReadonlySet<string>
66
68
  }
67
69
 
68
70
  export class ChangeJudge {
@@ -77,7 +79,7 @@ export class ChangeJudge {
77
79
  // are judged one window after the run ends, all together — an edit made
78
80
  // meanwhile still differs from what the loop last saw and re-runs.
79
81
  readonly pending = new Map<string, string>()
80
- /** The last cycle's wall window; a write inside it is the run's own. */
82
+ /** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
81
83
  lastCycle: { start: number; end: number } | undefined
82
84
  // Declared outputs are ignored by PATH above. A task with no `cache`
83
85
  // block declares none and still writes into its project, and the
@@ -124,14 +126,14 @@ export class ChangeJudge {
124
126
  // the moment between a judgement and that cycle's keys, and gone by the
125
127
  // next judgement, was read and its deletion re-runs nothing. Git lists
126
128
  // no ignored path, so one of those is a deletion as before.
127
- private sameState(abs: string, ignored = false): boolean {
129
+ private sameState(abs: string, ignored: boolean, spell: (p: string) => string): boolean {
128
130
  const state = settledState(abs)
129
131
  const prev = this.lastState.get(abs)
130
132
  this.lastState.set(abs, state)
131
133
  if (prev !== undefined) return prev === state
132
134
  if (state === ABSENT) {
133
135
  const listed = this.ctx.existedAtArm
134
- return !ignored && listed !== undefined && !listed.has(abs) && !this.inNestedRepo(abs)
136
+ return !ignored && listed !== undefined && !listed.has(spell(abs)) && !this.inNestedRepo(abs)
135
137
  }
136
138
  return modifiedBefore(abs, this.ctx.armedAt)
137
139
  }
@@ -146,13 +148,17 @@ export class ChangeJudge {
146
148
  return false
147
149
  }
148
150
 
149
- private writtenDuringLastCycle(abs: string, openWhileHeld = false): boolean {
151
+ /** `spell` (git's spelling, see `gitSpeller`) given: a held server's write counts outside a cycle too. */
152
+ private writtenDuringLastCycle(abs: string, spell?: (p: string) => string): boolean {
150
153
  // A server the last cycle left running is still that cycle's: its
151
154
  // writes land after the cycle ended, and with a closed window a dev
152
155
  // server that rewrites a log in its project restarted itself forever
153
156
  // with no word of it, 12 restarts in 8 s (item 948). The initial run's
154
- // server is one too, from the arm on.
155
- const open = openWhileHeld && this.ctx.held()
157
+ // server is one too, from the arm on. Not a tracked file: that is the
158
+ // user's edit, and three saves of one source file were told to
159
+ // .gitignore it as a server's write (WD-7).
160
+ const open =
161
+ spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
156
162
  if (this.lastCycle === undefined && !open) return false
157
163
  try {
158
164
  const m = fs.statSync(abs).mtimeMs
@@ -165,6 +171,7 @@ export class ChangeJudge {
165
171
 
166
172
  judge(): string | undefined {
167
173
  const ignored = gitIgnored(this.ctx.workspaceRoot, [...this.pending.keys()])
174
+ const spell = gitSpeller(this.ctx.workspaceRoot)
168
175
  let first: string | undefined
169
176
  let firstAbs: string | undefined
170
177
  // `sameState` before the `first` test, never after it: it is what
@@ -190,7 +197,7 @@ export class ChangeJudge {
190
197
  let gone: [label: string, abs: string] | undefined
191
198
  for (const [p, l] of this.pending) {
192
199
  if (ignored.has(p) && !editForUncached(p)) continue
193
- if (this.sameState(p, ignored.has(p)) || first !== undefined) continue
200
+ if (this.sameState(p, ignored.has(p), spell) || first !== undefined) continue
194
201
  if (!fs.existsSync(p)) {
195
202
  gone ??= [l, p]
196
203
  continue
@@ -201,7 +208,7 @@ export class ChangeJudge {
201
208
  if (first === undefined && gone !== undefined) [first, firstAbs] = gone
202
209
  this.pending.clear()
203
210
  const byServer = firstAbs !== undefined && this.ctx.held()
204
- if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, true)) {
211
+ if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
205
212
  this.streak.n = 0
206
213
  return first
207
214
  }
package/src/cli/watch.ts CHANGED
@@ -86,7 +86,7 @@ export function watchRefusal(parsed: RunArgs): string | null {
86
86
  export async function watchCmd(args: readonly string[]): Promise<number> {
87
87
  const parsed = parseRunArgs(args, 'watch')
88
88
  if (parsed.error) {
89
- process.stderr.write(`vx watch: ${parsed.error}\n`)
89
+ process.stderr.write(`vx watch: ${parsed.error}${seeHelp('watch')}\n`)
90
90
  return 1
91
91
  }
92
92
 
@@ -103,7 +103,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
103
103
  }
104
104
 
105
105
  const cwd = process.cwd()
106
- const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks)
106
+ const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
107
107
  if ('error' in resolved) {
108
108
  process.stderr.write(`vx watch: ${resolved.error}\n`)
109
109
  return 1
@@ -137,10 +137,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
137
137
  // worth; every cycle after an edit must load and list live.
138
138
  delete opts.staged
139
139
  delete opts.discovered
140
- process.once('SIGINT', () => {
141
- process.stdout.write('\nvx watch: stopped\n')
142
- stop.abort('SIGINT')
143
- })
140
+ process.once('SIGINT', () => stop.abort('SIGINT'))
141
+ // Said once the loop is down, the last line: written as the signal
142
+ // landed, the aborted cycle's summary printed below it.
143
+ const stopped = (): number => {
144
+ if (stop.signal.reason === 'SIGINT') process.stdout.write('\nvx watch: stopped\n')
145
+ return 0
146
+ }
144
147
  process.once('SIGTERM', () => stop.abort('SIGTERM'))
145
148
  // A task runs in its own session (exec/kill-tree.ts): the terminal
146
149
  // closing reaches the loop alone, and the loop passes it on.
@@ -206,7 +209,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
206
209
  }
207
210
  if (stop.signal.aborted) {
208
211
  await held?.stop(forwardedSignal(stop.signal.reason))
209
- return 0
212
+ return stopped()
210
213
  }
211
214
  if (refusedToStart) return 1
212
215
  // After the initial run, so a `pkg#task` naming no project gets the
@@ -234,7 +237,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
234
237
  ...fingerprintClaims(plugins).keys(),
235
238
  ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
236
239
  ])
237
- return await runWatchLoop({
240
+ await runWatchLoop({
238
241
  opts,
239
242
  held,
240
243
  stop: stop.signal,
@@ -285,6 +288,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
285
288
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
286
289
  cacheDir: opts.cacheDir ?? ws.cacheDir,
287
290
  })
291
+ return stopped()
288
292
  }
289
293
 
290
294
  interface WatchLoopArgs {
@@ -345,7 +349,7 @@ interface Rediscovered {
345
349
  fenceDirs: readonly string[]
346
350
  }
347
351
 
348
- async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
352
+ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
349
353
  const { opts, stop, workspaceRoot, projects, cacheDir } = args
350
354
  // The watched set as of the last cycle: `rearm` replaces these after an
351
355
  // event that can change it, and every filter below reads the current one.
@@ -403,7 +407,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
403
407
  await held?.stop()
404
408
  held = undefined
405
409
  restartTimings()
406
- const start = Date.now()
410
+ // On the mtime clock, as the arm is: from `Date.now()` a write the
411
+ // run made within a tick of it carried an earlier mtime and read as
412
+ // an edit (WD-20). The end needs no stamp: an mtime never leads it.
413
+ const start = fsClockNow(cacheDir)
407
414
  const cycle = await runOrchestrator(opts)
408
415
  held = cycle.persistent
409
416
  if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
@@ -464,8 +471,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
464
471
 
465
472
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
466
473
  const armedAt = fsClockNow(cacheDir)
467
- /** What existed at the arm, so a file born and gone since is no deletion (watch-judge.ts). */
468
- const existedAtArm = gitFiles(workspaceRoot)
474
+ /** What existed and was tracked at the arm, so a file born and gone since is no deletion and a tracked one is the user's edit (watch-judge.ts). */
475
+ const atArm = gitFiles(workspaceRoot)
469
476
  /** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
470
477
  const changes = new ChangeJudge({
471
478
  workspaceRoot,
@@ -473,7 +480,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
473
480
  held: () => held !== undefined,
474
481
  uncached: () => uncached,
475
482
  fenced: (ownDir, abs) => fenced(ownDir, abs),
476
- ...(existedAtArm !== undefined ? { existedAtArm } : {}),
483
+ ...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
477
484
  })
478
485
  /** Per-project arms by directory, so `rearm` can add and drop them. */
479
486
  const perProject = new Map<string, WatchHandle>()
@@ -838,7 +845,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
838
845
 
839
846
  process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
840
847
 
841
- return await new Promise<number>((resolve) => {
848
+ return await new Promise<void>((resolve) => {
842
849
  const cleanup = async (): Promise<void> => {
843
850
  pool.closeAll()
844
851
  if (debounceTimer) clearTimeout(debounceTimer)
@@ -846,7 +853,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
846
853
  // it has returned, so the process never exits over a live child.
847
854
  await inFlight
848
855
  await held?.stop(forwardedSignal(stop.reason))
849
- resolve(0)
856
+ resolve()
850
857
  }
851
858
  if (stop.aborted) {
852
859
  void cleanup()