@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.
- package/README.md +1 -1
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +235 -174
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +122 -49
- package/src/cache/layer.ts +7 -5
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/policy.ts +5 -0
- package/src/cache/tar-stream.ts +1 -1
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +11 -15
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +26 -5
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +60 -42
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +77 -13
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +16 -9
- package/src/cli/watch.ts +22 -15
- package/src/config.ts +26 -26
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +6 -2
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +12 -6
- package/src/exec/sandbox-runtime.ts +136 -44
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +29 -13
- package/src/graph/task-graph.ts +134 -25
- package/src/index.ts +6 -0
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +65 -20
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +28 -4
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +39 -17
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +72 -12
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +16 -5
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/index.ts +7 -1
- package/src/util/paths.ts +23 -3
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +8 -6
- package/src/workspace/config-schema.ts +74 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +76 -2
- package/src/workspace/repo-id.ts +42 -4
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
192
|
-
if (source !== '' && !pattern!.startsWith('!')) ignored.add(
|
|
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
|
-
|
|
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).
|
|
347
|
-
*
|
|
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(
|
|
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: [
|
|
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
|
-
|
|
364
|
-
|
|
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
|
}
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -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,
|
|
80
|
+
let previous = new Map<string, string>()
|
|
81
81
|
let first = true
|
|
82
82
|
const scan = (): void => {
|
|
83
|
-
const current = new Map<string,
|
|
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
|
-
|
|
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
|
|
106
|
-
current.set(childRel,
|
|
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,
|
|
115
|
-
if (previous.get(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)
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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<
|
|
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(
|
|
856
|
+
resolve()
|
|
850
857
|
}
|
|
851
858
|
if (stop.aborted) {
|
|
852
859
|
void cleanup()
|