@vzn/vx 0.0.636 → 0.0.638
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 +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- 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 +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- 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 +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- 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 +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- 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 +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- 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-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- 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 +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -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/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- 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 +65 -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 +307 -215
- 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 +363 -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,14 +14,10 @@ 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
|
-
const IGNORED_SUFFIXES = ['
|
|
20
|
+
const IGNORED_SUFFIXES = ['~']
|
|
24
21
|
|
|
25
22
|
/**
|
|
26
23
|
* True for a path the ignore filter drops. Every project dir is watched
|
|
@@ -33,16 +30,22 @@ 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
|
+
const name = segments[segments.length - 1]!
|
|
36
|
+
// A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
|
|
37
|
+
// renamed away before the judgement, it read as a deletion and ran one
|
|
38
|
+
// more cycle where an uncached task makes ignored paths count.
|
|
39
|
+
if (name.startsWith('.vx-tmp-')) return true
|
|
40
|
+
// `vx lock`'s review copy: no input, so a re-lock is no cycle.
|
|
41
|
+
return name === 'vx.lock.json'
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* The ignore predicate a watcher applies, closed over the run's ACTUAL cache
|
|
43
46
|
* directory.
|
|
44
47
|
*
|
|
45
|
-
* `
|
|
48
|
+
* `isIgnoredDir` covers the `.vx/cache` default, but `cacheDir` is a
|
|
46
49
|
* shipped `defineWorkspace` field: point it anywhere else and vx's own cache
|
|
47
50
|
* writes land in a watched subtree, so every cycle triggers the next one.
|
|
48
51
|
* Measured on a relocated cache under a recursive root watcher: ONE edit kicked
|
|
@@ -54,6 +57,8 @@ export function makeWatchIgnore(
|
|
|
54
57
|
cacheDir: string,
|
|
55
58
|
outputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
56
59
|
inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>> = new Map(),
|
|
60
|
+
/** What git tracked at the arm (`gitFiles`): never an output, so an edit there is one. */
|
|
61
|
+
tracked?: ReadonlySet<string>,
|
|
57
62
|
): (base: string, filename: string) => boolean {
|
|
58
63
|
// Watchers report under the real root; a cache dir named through a link
|
|
59
64
|
// (`VX_CACHE_DIR`, macOS `/var` -> `/private/var`) is matched as its target.
|
|
@@ -122,6 +127,14 @@ export function makeWatchIgnore(
|
|
|
122
127
|
const abs = path.resolve(base, filename)
|
|
123
128
|
if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
|
|
124
129
|
if (isInput(abs)) return false
|
|
130
|
+
// Core never cleans or saves a file git tracks and keys it under the
|
|
131
|
+
// task's own output glob (2026-10-14); a submodule is listed as its
|
|
132
|
+
// gitlink alone, so a path under a tracked one counts.
|
|
133
|
+
if (tracked !== undefined)
|
|
134
|
+
for (let p = abs; p.length > 1; p = path.dirname(p)) {
|
|
135
|
+
if (tracked.has(p)) return false
|
|
136
|
+
if (p === path.dirname(p)) break
|
|
137
|
+
}
|
|
125
138
|
for (const [dir, isOutput, containers] of declared) {
|
|
126
139
|
if (!abs.startsWith(dir + path.sep)) continue
|
|
127
140
|
const rel = abs
|
|
@@ -191,6 +204,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
191
204
|
try {
|
|
192
205
|
proc = Bun.spawnSync({
|
|
193
206
|
cmd: [executablePath('git'), 'check-ignore', '-v', '-n', '-z', '--stdin'],
|
|
207
|
+
env: gitEnv(),
|
|
194
208
|
cwd: workspaceRoot,
|
|
195
209
|
stdin: Buffer.from(rest.map((p) => `${p}\0`).join('')),
|
|
196
210
|
stdout: 'pipe',
|
|
@@ -215,6 +229,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
215
229
|
inWorkTree ??=
|
|
216
230
|
Bun.spawnSync({
|
|
217
231
|
cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
|
|
232
|
+
env: gitEnv(),
|
|
218
233
|
cwd: workspaceRoot,
|
|
219
234
|
stdout: 'ignore',
|
|
220
235
|
stderr: 'ignore',
|
|
@@ -231,7 +246,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
231
246
|
* watcher hears every write in the workspace; a key can see three kinds
|
|
232
247
|
* of path and no other: a file inside a project's directory (its own
|
|
233
248
|
* `inputs.files`, or its outputs, which the ignore filter drops next), a
|
|
234
|
-
*
|
|
249
|
+
* root file a plugin claims, and a match of a declared
|
|
235
250
|
* `inputs.workspaceFiles` glob. Everything else — a log written at the
|
|
236
251
|
* root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
|
|
237
252
|
* dropped before the trigger, so it costs no cycle. Before this rule the
|
|
@@ -257,24 +272,10 @@ export function makeRootEventFilter(
|
|
|
257
272
|
)
|
|
258
273
|
return (filename: string): boolean => {
|
|
259
274
|
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
|
-
) {
|
|
275
|
+
// Exact membership, not a basename match: `nested/pnpm-lock.yaml` and
|
|
276
|
+
// `nested/.vx/workspace.ts` are no root file. The workspace config and
|
|
277
|
+
// a claimed file carry their `.vx/` prefix.
|
|
278
|
+
if (isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel) || rel === '.gitignore') {
|
|
278
279
|
return true
|
|
279
280
|
}
|
|
280
281
|
const abs = path.resolve(workspaceRoot, filename)
|
|
@@ -305,30 +306,19 @@ export function makeFence(fenceDirs: readonly string[]): (ownDir: string, abs: s
|
|
|
305
306
|
}
|
|
306
307
|
|
|
307
308
|
/**
|
|
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).
|
|
309
|
+
* A file whose edit can change what the loop watches: a project config (a
|
|
310
|
+
* task that starts declaring `workspaceFiles` needs the root watcher, a
|
|
311
|
+
* new output must stop being an event) or the workspace config (a
|
|
312
|
+
* plugin's `project` stage). Until item 891 each of these waited for a
|
|
313
|
+
* restart while the loop looked alive. The lock and a file a plugin
|
|
314
|
+
* claims re-read the set as well (the caller asks: the projects come from
|
|
315
|
+
* the lock, a lockfile plugin's `discover` names and edges from its file).
|
|
317
316
|
*/
|
|
318
317
|
const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
319
318
|
|
|
320
319
|
export function shapesWatchedSet(filename: string): boolean {
|
|
321
320
|
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)
|
|
321
|
+
return PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(filename.split(path.sep).join('/'))
|
|
332
322
|
}
|
|
333
323
|
|
|
334
324
|
/**
|
|
@@ -344,19 +334,6 @@ export function isWorkspaceConfigFile(name: string): boolean {
|
|
|
344
334
|
return WORKSPACE_CONFIGS.has(name)
|
|
345
335
|
}
|
|
346
336
|
|
|
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
337
|
/**
|
|
361
338
|
* Paths as git spells them under `root`: a project reached through a
|
|
362
339
|
* symlink below the root (`packages/x -> ../shared/x`) is watched at the
|
|
@@ -410,6 +387,7 @@ export function gitFiles(
|
|
|
410
387
|
'--others',
|
|
411
388
|
'--exclude-standard',
|
|
412
389
|
],
|
|
390
|
+
env: gitEnv(),
|
|
413
391
|
cwd: workspaceRoot,
|
|
414
392
|
stdout: 'pipe',
|
|
415
393
|
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
|
@@ -161,7 +161,12 @@ export class ChangeJudge {
|
|
|
161
161
|
spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
|
|
162
162
|
if (this.lastCycle === undefined && !open) return false
|
|
163
163
|
try {
|
|
164
|
-
|
|
164
|
+
// The later of the two clocks: a cache hit restores an output with
|
|
165
|
+
// the mtime it was saved with, and read by mtime alone the restore
|
|
166
|
+
// was the user's edit and ran one more cycle (solid, M 2026-10-15).
|
|
167
|
+
// The restore's write moves the ctime.
|
|
168
|
+
const st = fs.statSync(abs)
|
|
169
|
+
const m = Math.max(st.mtimeMs, st.ctimeMs)
|
|
165
170
|
const start = this.lastCycle?.start ?? this.ctx.armedAt
|
|
166
171
|
return m >= start && (open || m <= this.lastCycle!.end)
|
|
167
172
|
} catch {
|
|
@@ -195,22 +200,31 @@ export class ChangeJudge {
|
|
|
195
200
|
// `sed -i` saving through a temporary file fires that file first, and
|
|
196
201
|
// the cycle was announced by a name already renamed away.
|
|
197
202
|
let gone: [label: string, abs: string] | undefined
|
|
203
|
+
// "+N more" counts the changes of the winner's kind: an edit's count
|
|
204
|
+
// leaves out the editor's temporary file renamed away.
|
|
205
|
+
let live = 0
|
|
206
|
+
let goneCount = 0
|
|
198
207
|
for (const [p, l] of this.pending) {
|
|
199
208
|
if (ignored.has(p) && !editForUncached(p)) continue
|
|
200
|
-
if (this.sameState(p, ignored.has(p), spell)
|
|
209
|
+
if (this.sameState(p, ignored.has(p), spell)) continue
|
|
201
210
|
if (!fs.existsSync(p)) {
|
|
211
|
+
goneCount++
|
|
202
212
|
gone ??= [l, p]
|
|
203
213
|
continue
|
|
204
214
|
}
|
|
215
|
+
live++
|
|
216
|
+
if (first !== undefined) continue
|
|
205
217
|
first = l
|
|
206
218
|
firstAbs = p
|
|
207
219
|
}
|
|
208
220
|
if (first === undefined && gone !== undefined) [first, firstAbs] = gone
|
|
209
221
|
this.pending.clear()
|
|
222
|
+
const more = (live > 0 ? live : goneCount) - 1
|
|
223
|
+
const named = more > 0 ? `${first} (+${more} more)` : first
|
|
210
224
|
const byServer = firstAbs !== undefined && this.ctx.held()
|
|
211
225
|
if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
|
|
212
226
|
this.streak.n = 0
|
|
213
|
-
return
|
|
227
|
+
return named
|
|
214
228
|
}
|
|
215
229
|
this.streak.n = this.streak.abs === firstAbs ? this.streak.n + 1 : 1
|
|
216
230
|
this.streak.abs = firstAbs
|
|
@@ -222,6 +236,6 @@ export class ChangeJudge {
|
|
|
222
236
|
: `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
237
|
)
|
|
224
238
|
}
|
|
225
|
-
return
|
|
239
|
+
return named
|
|
226
240
|
}
|
|
227
241
|
}
|
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
|
-
}
|