@vzn/vx 0.0.636 → 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.
- package/README.md +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cli/watch-cycle.ts
CHANGED
|
@@ -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 === '
|
|
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
|
}
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* `
|
|
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
|
-
*
|
|
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
|
-
//
|
|
261
|
-
// are
|
|
262
|
-
//
|
|
263
|
-
|
|
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
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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',
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -5,8 +5,19 @@
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
export const IGNORED_SEGMENTS = ['node_modules', '.git'
|
|
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 `
|
|
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) =>
|
|
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 (
|
|
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 (!
|
|
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; `
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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 {
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
234
|
+
return named
|
|
226
235
|
}
|
|
227
236
|
}
|
package/src/cli/watch-set.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
}
|