@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.ts
CHANGED
|
@@ -24,20 +24,12 @@ import {
|
|
|
24
24
|
type RunOptions,
|
|
25
25
|
shellQuote,
|
|
26
26
|
} from '../orchestrator/index.js'
|
|
27
|
-
import {
|
|
28
|
-
findWorkspaceRoot,
|
|
29
|
-
type LoadReads,
|
|
30
|
-
loadWorkspace,
|
|
31
|
-
LOCKFILE_NAME,
|
|
32
|
-
memberBaseDirs,
|
|
33
|
-
type ProjectMeta,
|
|
34
|
-
} from '../workspace/index.js'
|
|
27
|
+
import { findWorkspaceRoot, LOCKFILE_NAME, NO_LOCK, type ProjectMeta } from '../workspace/index.js'
|
|
35
28
|
import { type CliLoadOptions, discoverCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
29
|
+
import { resolveSetting } from '../settings.js'
|
|
36
30
|
import {
|
|
37
|
-
isIgnoredWatchPath,
|
|
38
31
|
isWorkspaceConfigFile,
|
|
39
32
|
gitFiles,
|
|
40
|
-
isWorkspaceFingerprintFile,
|
|
41
33
|
makeFence,
|
|
42
34
|
makeRootEventFilter,
|
|
43
35
|
makeWatchIgnore,
|
|
@@ -46,12 +38,12 @@ import {
|
|
|
46
38
|
import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
47
39
|
import { ChangeJudge } from './watch-judge.js'
|
|
48
40
|
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
49
|
-
import { hangupIgnored, maskedLine, restartTimings } from '../util/index.js'
|
|
50
|
-
import {
|
|
41
|
+
import { hangupIgnored, maskedLine, restartTimings, sayNotice, sayLine } from '../util/index.js'
|
|
42
|
+
import { sweepConfigs, watchedProjects } from './watch-set.js'
|
|
51
43
|
|
|
52
44
|
/** One line for a watcher or re-read the OS refused; the loop goes on without it. */
|
|
53
45
|
function sayCannot(what: string, err: unknown): void {
|
|
54
|
-
|
|
46
|
+
sayNotice('warn', 'vx watch', `${what}: ${err instanceof Error ? err.message : String(err)}`)
|
|
55
47
|
}
|
|
56
48
|
|
|
57
49
|
/** Wait this long after the last filesystem event before re-running. */
|
|
@@ -92,18 +84,20 @@ export function watchRefusal(parsed: RunArgs): string | null {
|
|
|
92
84
|
export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
93
85
|
const parsed = parseRunArgs(args, 'watch')
|
|
94
86
|
if (parsed.error) {
|
|
95
|
-
|
|
87
|
+
sayNotice('error', 'vx watch', `${parsed.error}${seeHelp('watch')}`)
|
|
96
88
|
return 1
|
|
97
89
|
}
|
|
98
90
|
|
|
99
91
|
const refused = watchRefusal(parsed)
|
|
100
92
|
if (refused !== null) {
|
|
101
|
-
|
|
93
|
+
sayLine('error', refused, 'vx watch')
|
|
102
94
|
return 1
|
|
103
95
|
}
|
|
104
96
|
if (parsed.tasks.length === 0) {
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
sayNotice(
|
|
98
|
+
'error',
|
|
99
|
+
'vx watch',
|
|
100
|
+
`missing task name (vx watch <task>, e.g. vx watch build)${seeHelp('watch')}`,
|
|
107
101
|
)
|
|
108
102
|
return 1
|
|
109
103
|
}
|
|
@@ -111,11 +105,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
111
105
|
const cwd = process.cwd()
|
|
112
106
|
const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
|
|
113
107
|
if ('error' in resolved) {
|
|
114
|
-
|
|
108
|
+
sayNotice('error', 'vx watch', resolved.error)
|
|
115
109
|
return 1
|
|
116
110
|
}
|
|
117
111
|
if ('nothingSelected' in resolved) {
|
|
118
|
-
|
|
112
|
+
sayNotice('info', 'vx watch', resolved.nothingSelected)
|
|
119
113
|
return 0
|
|
120
114
|
}
|
|
121
115
|
// The watch loop owns SIGINT/SIGTERM for its whole lifetime. A cycle's
|
|
@@ -165,13 +159,18 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
165
159
|
// closure `--filter 'app...'` walks, computed below once the initial
|
|
166
160
|
// run has staged the configs. Plus the workspace root, for lockfile
|
|
167
161
|
// changes.
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
162
|
+
const workspaceRoot = await findWorkspaceRoot(cwd)
|
|
163
|
+
// No lock yet is no project yet: the first cycle fails saying so, and
|
|
164
|
+
// the lock `vx lock` writes is the event that arms the set.
|
|
165
|
+
let unlocked = false
|
|
166
|
+
const allProjects = await discoverCliProjects(workspaceRoot).catch((err: unknown) => {
|
|
167
|
+
if (!(err instanceof Error) || err.message !== NO_LOCK) throw err
|
|
168
|
+
unlocked = true
|
|
169
|
+
return []
|
|
170
|
+
})
|
|
171
|
+
// `//#check` is the root project's task, under the name the run gives
|
|
172
|
+
// it (prepare.ts, D-39): taken literally, no project was in scope and
|
|
173
|
+
// the watch exited 1 after a green run.
|
|
175
174
|
const rootDir = path.resolve(workspaceRoot)
|
|
176
175
|
const rootName = allProjects.find((p) => path.resolve(p.dir) === rootDir)?.name
|
|
177
176
|
const anchored = opts.tasks
|
|
@@ -202,16 +201,16 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
202
201
|
// config is loaded, and the loop arms with the run as its cycle in
|
|
203
202
|
// flight. Unarmed, a server that never got ready held the initial run
|
|
204
203
|
// for good, and the fix to it was never heard (X-139).
|
|
205
|
-
process.stdout.write('vx watch: initial run...\n\n')
|
|
206
204
|
let loop: Promise<void> | undefined
|
|
207
205
|
const initial: InitialCycle = {
|
|
208
206
|
cycle: watchCycle(opts, stop.signal, () => {
|
|
209
207
|
if (initial.onWaiting !== undefined) initial.onWaiting()
|
|
210
|
-
else if (loop === undefined && scope.length > 0 && !stop.signal.aborted)
|
|
208
|
+
else if (loop === undefined && (scope.length > 0 || unlocked) && !stop.signal.aborted)
|
|
211
209
|
loop = armLoop(initial)
|
|
212
210
|
}),
|
|
213
211
|
held: Promise.resolve(undefined),
|
|
214
212
|
}
|
|
213
|
+
const openingTasks = (): number | undefined => initial.cycle.tasks
|
|
215
214
|
// A run that throws (a config that does not parse) is a failed cycle, as
|
|
216
215
|
// it is once the loop runs: watch keeps watching, so the fix re-runs.
|
|
217
216
|
// Uncaught, it ended watch at start while the same break mid-watch did
|
|
@@ -219,7 +218,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
219
218
|
let refusedToStart = false
|
|
220
219
|
initial.held = runOrchestrator(initial.cycle.opts).then(
|
|
221
220
|
(run) => {
|
|
222
|
-
if (run.refused !== undefined)
|
|
221
|
+
if (run.refused !== undefined) sayNotice('error', 'vx watch', run.refused)
|
|
223
222
|
// A run that failed having run nothing refused to start: a requested
|
|
224
223
|
// name no project declares (run() says which, with a "did you mean").
|
|
225
224
|
// `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
|
|
@@ -230,8 +229,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
230
229
|
return run.persistent
|
|
231
230
|
},
|
|
232
231
|
(err: unknown) => {
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
sayNotice(
|
|
233
|
+
'error',
|
|
234
|
+
'vx watch',
|
|
235
|
+
`cycle failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
235
236
|
)
|
|
236
237
|
return undefined
|
|
237
238
|
},
|
|
@@ -248,9 +249,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
248
249
|
if (refusedToStart) return 1
|
|
249
250
|
// After the initial run, so a `pkg#task` naming no project gets the
|
|
250
251
|
// run's own refusal and its "did you mean".
|
|
251
|
-
if (scope.length === 0) {
|
|
252
|
+
if (scope.length === 0 && !unlocked) {
|
|
252
253
|
await held?.stop()
|
|
253
|
-
|
|
254
|
+
sayNotice('error', 'vx watch', 'no projects in scope')
|
|
254
255
|
return 1
|
|
255
256
|
}
|
|
256
257
|
await armLoop(undefined, held)
|
|
@@ -263,22 +264,17 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
263
264
|
delete opts.affected
|
|
264
265
|
delete opts.selectedOutright
|
|
265
266
|
|
|
266
|
-
const load: CliLoadOptions = {
|
|
267
|
-
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
268
|
-
...(opts.frozen === true ? { frozen: true } : {}),
|
|
269
|
-
}
|
|
267
|
+
const load: CliLoadOptions = opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}
|
|
270
268
|
const swept = await sweepConfigs(allProjects, workspaceRoot, load, opts.tasks)
|
|
271
269
|
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
272
270
|
const ws = await loadCliWorkspace(workspaceRoot)
|
|
273
271
|
const claimsOf = (plugins: Parameters<typeof fingerprintClaims>[0]): Set<string> =>
|
|
274
|
-
new Set([
|
|
275
|
-
...fingerprintClaims(plugins).keys(),
|
|
276
|
-
...(opts.frozen === true ? [LOCKFILE_NAME] : []),
|
|
277
|
-
])
|
|
272
|
+
new Set([...fingerprintClaims(plugins).keys(), LOCKFILE_NAME])
|
|
278
273
|
await runWatchLoop({
|
|
279
274
|
opts,
|
|
280
275
|
held,
|
|
281
276
|
initial,
|
|
277
|
+
tasks: openingTasks,
|
|
282
278
|
stop: stop.signal,
|
|
283
279
|
workspaceRoot,
|
|
284
280
|
projects: watched,
|
|
@@ -290,16 +286,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
290
286
|
uncached: swept.uncached,
|
|
291
287
|
configImports: swept.configImports,
|
|
292
288
|
workspaceConfigImports: swept.workspaceConfigImports,
|
|
293
|
-
memberBases: memberBaseDirs(workspace),
|
|
294
|
-
packageDirs: new Set(allProjects.map((p) => p.dir)),
|
|
295
289
|
fenceDirs: fenceDirs(allProjects),
|
|
296
|
-
// The workspace as the cycle that just ran saw it: a
|
|
297
|
-
//
|
|
298
|
-
// scope is the one resolved at start; a new
|
|
299
|
-
// dependency of it.
|
|
290
|
+
// The workspace as the cycle that just ran saw it: a project the lock
|
|
291
|
+
// gained or lost since the loop armed joins or leaves the watched
|
|
292
|
+
// set. The scope is the one resolved at start; a new project joins
|
|
293
|
+
// it only as a dependency of it.
|
|
300
294
|
rediscover: async () => {
|
|
301
|
-
const
|
|
302
|
-
const all = await discoverCliProjects(workspace)
|
|
295
|
+
const all = await discoverCliProjects(workspaceRoot)
|
|
303
296
|
const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
|
|
304
297
|
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
305
298
|
return {
|
|
@@ -308,7 +301,6 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
308
301
|
// the cycle that loaded it; read once, its edits started nothing
|
|
309
302
|
// until a restart.
|
|
310
303
|
claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot)).plugins),
|
|
311
|
-
memberBases: memberBaseDirs(workspace),
|
|
312
304
|
workspaceWide: sweep.workspaceWide,
|
|
313
305
|
workspaceInputs: sweep.workspaceInputs,
|
|
314
306
|
outputs: sweep.outputs,
|
|
@@ -316,16 +308,16 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
316
308
|
uncached: sweep.uncached,
|
|
317
309
|
configImports: sweep.configImports,
|
|
318
310
|
workspaceConfigImports: sweep.workspaceConfigImports,
|
|
319
|
-
packageDirs: new Set(all.map((p) => p.dir)),
|
|
320
311
|
fenceDirs: fenceDirs(all),
|
|
321
312
|
}
|
|
322
313
|
},
|
|
323
|
-
//
|
|
314
|
+
// Every cycle's configs are the lock's, so a re-lock is
|
|
324
315
|
// the one edit that changes what a cycle runs; unheard, the loop ran
|
|
325
316
|
// the old lock until a restart (item 971).
|
|
326
317
|
claimedRootFiles: claimsOf(ws.plugins),
|
|
327
318
|
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
328
319
|
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
320
|
+
poll: resolveSetting('watch.poll', ws.workspaceConfig, parsed.poll).value === true,
|
|
329
321
|
})
|
|
330
322
|
}
|
|
331
323
|
}
|
|
@@ -345,6 +337,8 @@ interface WatchLoopArgs {
|
|
|
345
337
|
held: HeldPersistent | undefined
|
|
346
338
|
/** The initial run, still in flight: the loop's first cycle. */
|
|
347
339
|
initial: InitialCycle | undefined
|
|
340
|
+
/** The initial run's task count, once it has started. */
|
|
341
|
+
tasks: () => number | undefined
|
|
348
342
|
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
349
343
|
stop: AbortSignal
|
|
350
344
|
workspaceRoot: string
|
|
@@ -372,14 +366,12 @@ interface WatchLoopArgs {
|
|
|
372
366
|
* loaded once per process, so the set is fixed for the loop.
|
|
373
367
|
*/
|
|
374
368
|
claimedRootFiles: ReadonlySet<string>
|
|
375
|
-
/** The directory each `<dir>/*` package glob names at start; a member coming or going there is a cycle. */
|
|
376
|
-
memberBases: readonly string[]
|
|
377
|
-
/** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
|
|
378
|
-
packageDirs: ReadonlySet<string>
|
|
379
369
|
/** Every project, in scope or not: a project's key leaves out what lies in one nested under it. */
|
|
380
370
|
fenceDirs: readonly string[]
|
|
381
371
|
/** The watched set again, after a cycle that followed an event which can change it. */
|
|
382
372
|
rediscover: () => Promise<Rediscovered>
|
|
373
|
+
/** `watch.poll`: poll from the start instead of the OS watcher. */
|
|
374
|
+
poll: boolean
|
|
383
375
|
}
|
|
384
376
|
|
|
385
377
|
const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.dir)
|
|
@@ -387,7 +379,6 @@ const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.di
|
|
|
387
379
|
interface Rediscovered {
|
|
388
380
|
projects: readonly ProjectMeta[]
|
|
389
381
|
claimedRootFiles: ReadonlySet<string>
|
|
390
|
-
memberBases: readonly string[]
|
|
391
382
|
workspaceWide: boolean
|
|
392
383
|
workspaceInputs: readonly string[]
|
|
393
384
|
outputs: ReadonlyMap<string, readonly string[]>
|
|
@@ -395,7 +386,6 @@ interface Rediscovered {
|
|
|
395
386
|
uncached: ReadonlySet<string>
|
|
396
387
|
configImports: readonly string[]
|
|
397
388
|
workspaceConfigImports: readonly string[]
|
|
398
|
-
packageDirs: ReadonlySet<string>
|
|
399
389
|
fenceDirs: readonly string[]
|
|
400
390
|
}
|
|
401
391
|
|
|
@@ -404,8 +394,6 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
404
394
|
// The watched set as of the last cycle: `rearm` replaces these after an
|
|
405
395
|
// event that can change it, and every filter below reads the current one.
|
|
406
396
|
let workspaceWide = args.workspaceWide
|
|
407
|
-
let packageDirs = args.packageDirs
|
|
408
|
-
let memberBases = args.memberBases
|
|
409
397
|
let projectDirs = args.projectDirs
|
|
410
398
|
let workspaceInputs = args.workspaceInputs
|
|
411
399
|
let outputs = args.outputs
|
|
@@ -485,7 +473,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
485
473
|
// only after, it fell in the gap and ran nothing (X-138). The re-read
|
|
486
474
|
// after the run stays, for what the run itself changed.
|
|
487
475
|
if (reread) await rearm(false)
|
|
488
|
-
process.stdout.write(`\
|
|
476
|
+
process.stdout.write(`\nchanged ${label}\n`)
|
|
489
477
|
if (stop.aborted) break
|
|
490
478
|
restartTimings()
|
|
491
479
|
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
@@ -502,11 +490,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
502
490
|
interruptIfWaiting,
|
|
503
491
|
)
|
|
504
492
|
const cycle = await runOrchestrator(current.opts)
|
|
493
|
+
lastTasks = current.tasks ?? lastTasks
|
|
505
494
|
current = undefined
|
|
506
495
|
held = cycle.persistent
|
|
507
496
|
// What this cycle did not take (a server whose task did not run).
|
|
508
497
|
await before?.stop()
|
|
509
|
-
if (cycle.refused !== undefined)
|
|
498
|
+
if (cycle.refused !== undefined) sayNotice('error', 'vx watch', cycle.refused)
|
|
510
499
|
changes.lastCycle = { start, end: Date.now() }
|
|
511
500
|
} catch (err) {
|
|
512
501
|
// A re-run can fail catastrophically when the workspace
|
|
@@ -518,7 +507,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
518
507
|
// is the canonical exit; we don't unilaterally abort here.
|
|
519
508
|
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
520
509
|
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
521
|
-
|
|
510
|
+
sayNotice('error', 'vx watch', `cycle failed: ${message}`)
|
|
522
511
|
}
|
|
523
512
|
// After a failed cycle too: a package added with a config that does
|
|
524
513
|
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
@@ -553,7 +542,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
553
542
|
// project's dir recursively, so a `node_modules` write under a
|
|
554
543
|
// project would otherwise trigger every save during `bun install` —
|
|
555
544
|
// and vx's own cache writes would trigger a cycle that writes again.
|
|
556
|
-
|
|
545
|
+
/** 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). */
|
|
546
|
+
const atArm = gitFiles(workspaceRoot)
|
|
547
|
+
let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs, atArm?.tracked)
|
|
557
548
|
let fenced = makeFence(args.fenceDirs)
|
|
558
549
|
let matters = makeRootEventFilter(
|
|
559
550
|
workspaceRoot,
|
|
@@ -569,14 +560,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
569
560
|
// unconditional segments AND this run's declared output containers. Read
|
|
570
561
|
// through `isIgnoredPath` rather than captured, because `rearm` replaces
|
|
571
562
|
// the filter when the selection changes.
|
|
572
|
-
const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel))
|
|
563
|
+
const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel), args.poll)
|
|
573
564
|
const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
|
|
574
565
|
pool.arm(dir, recursive, onEvent)
|
|
575
566
|
|
|
576
567
|
/** 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. */
|
|
577
568
|
const armedAt = fsClockNow(cacheDir)
|
|
578
|
-
/** 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). */
|
|
579
|
-
const atArm = gitFiles(workspaceRoot)
|
|
580
569
|
/** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
|
|
581
570
|
const changes = new ChangeJudge({
|
|
582
571
|
workspaceRoot,
|
|
@@ -588,6 +577,38 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
588
577
|
})
|
|
589
578
|
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
590
579
|
const perProject = new Map<string, WatchHandle>()
|
|
580
|
+
// Each FSEvents stream goes live slower than the one before it: per
|
|
581
|
+
// project, 751 of 3000 missed their 2 s proof on a macos-latest runner
|
|
582
|
+
// and fell back to polling, a full core at idle; one stream at the root
|
|
583
|
+
// went live in 8 ms (tests/watch-one-stream.test.ts). Polling keeps its
|
|
584
|
+
// per-project walks, which skip what lies between projects.
|
|
585
|
+
const oneStream = process.platform === 'darwin' && !args.poll
|
|
586
|
+
/** The projects the one stream hands events to, by their real directory. */
|
|
587
|
+
let streamed: { proj: ProjectMeta; real: string }[] = []
|
|
588
|
+
const realRoot = ((): string => {
|
|
589
|
+
try {
|
|
590
|
+
return fs.realpathSync(workspaceRoot)
|
|
591
|
+
} catch {
|
|
592
|
+
return path.resolve(workspaceRoot)
|
|
593
|
+
}
|
|
594
|
+
})()
|
|
595
|
+
// The stream names a file by its real place under the root, so a project
|
|
596
|
+
// whose real directory lies outside the root keeps its own arm.
|
|
597
|
+
const toStream = (all: readonly ProjectMeta[]): { proj: ProjectMeta; real: string }[] => {
|
|
598
|
+
const out: { proj: ProjectMeta; real: string }[] = []
|
|
599
|
+
for (const proj of all) {
|
|
600
|
+
let real: string
|
|
601
|
+
try {
|
|
602
|
+
real = fs.realpathSync(proj.dir)
|
|
603
|
+
} catch {
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
const rel = path.relative(realRoot, real)
|
|
607
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
608
|
+
out.push({ proj, real })
|
|
609
|
+
}
|
|
610
|
+
return out
|
|
611
|
+
}
|
|
591
612
|
// An OS watch holds the directory's inode, not its path: one removed and
|
|
592
613
|
// made again (`rm -rf packages && git checkout packages`) keeps reporting
|
|
593
614
|
// for the deleted one. Each arm notes the directory it holds, and a
|
|
@@ -605,32 +626,68 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
605
626
|
}
|
|
606
627
|
}
|
|
607
628
|
const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
|
|
608
|
-
/** The root arm: recursive when workspace-wide, else the root's own files only. */
|
|
629
|
+
/** The root arm: recursive when workspace-wide or the one stream, else the root's own files only. */
|
|
609
630
|
let rootArm: WatchHandle = CLOSED
|
|
631
|
+
let dotVxArm: WatchHandle = CLOSED
|
|
632
|
+
const projectEvent = (proj: ProjectMeta, filename: string): void => {
|
|
633
|
+
const abs = path.join(proj.dir, filename)
|
|
634
|
+
if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
|
|
635
|
+
// A module the config imports from inside the project is the config
|
|
636
|
+
// too: its edit can add an output or a `workspaceFiles` input.
|
|
637
|
+
if (shapesWatchedSet(filename) || configImportFiles.includes(abs)) reread = true
|
|
638
|
+
trigger(`${proj.name} ${filename}`, abs)
|
|
639
|
+
}
|
|
610
640
|
const armProject = (proj: ProjectMeta): void => {
|
|
641
|
+
// Gone for now: its ancestor's arm (`armAncestors`) hears it return.
|
|
642
|
+
if (inodeOf(proj.dir) === undefined) return
|
|
611
643
|
try {
|
|
612
|
-
const handle = arm(proj.dir, true, (filename) =>
|
|
613
|
-
const abs = path.join(proj.dir, filename)
|
|
614
|
-
if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
|
|
615
|
-
// A module the config imports from inside the project is the config
|
|
616
|
-
// too: its edit can add an output or a `workspaceFiles` input.
|
|
617
|
-
if (shapesWatchedSet(filename) || configImportFiles.includes(abs)) reread = true
|
|
618
|
-
trigger(`${proj.name} ${filename}`, abs)
|
|
619
|
-
})
|
|
644
|
+
const handle = arm(proj.dir, true, (filename) => projectEvent(proj, filename))
|
|
620
645
|
perProject.set(proj.dir, handle)
|
|
621
646
|
armedAs.set(proj.dir, inodeOf(proj.dir) ?? '')
|
|
622
647
|
} catch (err) {
|
|
623
648
|
sayCannot(`cannot watch ${proj.dir}`, err)
|
|
624
649
|
}
|
|
625
650
|
}
|
|
651
|
+
const rootFileEvent = (filename: string): void => {
|
|
652
|
+
if (
|
|
653
|
+
!isWorkspaceConfigFile(filename) &&
|
|
654
|
+
filename !== '.gitignore' &&
|
|
655
|
+
!claimedRootFiles.has(filename)
|
|
656
|
+
)
|
|
657
|
+
return
|
|
658
|
+
if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME || claimedRootFiles.has(filename))
|
|
659
|
+
reread = true
|
|
660
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
661
|
+
}
|
|
662
|
+
const dotVxEvent = (filename: string): void => {
|
|
663
|
+
const rel = `.vx/${filename}`
|
|
664
|
+
if (!isWorkspaceConfigFile(rel) && !claimedRootFiles.has(rel)) return
|
|
665
|
+
reread = true
|
|
666
|
+
trigger(`root ${rel}`, path.join(workspaceRoot, rel))
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The one stream's events, handed out as the per-project arms, the
|
|
670
|
+
* root's own files and `.vx/` would have heard them: an event lies in
|
|
671
|
+
* every watched project whose tree holds it (a nested one's fence drops
|
|
672
|
+
* it from the outer), so each listener decides exactly as its own arm did.
|
|
673
|
+
*/
|
|
674
|
+
const streamEvent = (filename: string): void => {
|
|
675
|
+
const rel = filename.split(path.sep).join('/')
|
|
676
|
+
if (!rel.includes('/')) rootFileEvent(rel)
|
|
677
|
+
else if (rel.startsWith('.vx/') && rel.indexOf('/', 4) < 0) dotVxEvent(rel.slice(4))
|
|
678
|
+
const abs = path.join(realRoot, filename)
|
|
679
|
+
for (const { proj, real } of streamed) {
|
|
680
|
+
if (abs.startsWith(real + path.sep)) projectEvent(proj, path.relative(real, abs))
|
|
681
|
+
}
|
|
682
|
+
}
|
|
626
683
|
const armRoot = (): void => {
|
|
627
684
|
try {
|
|
628
685
|
rootArm = workspaceWide
|
|
629
686
|
? // workspaceFiles inputs in play: a root-relative glob can name a
|
|
630
687
|
// file anywhere, so one recursive root watcher replaces the
|
|
631
|
-
// per-project ones (it also covers
|
|
632
|
-
//
|
|
633
|
-
// trees, root
|
|
688
|
+
// per-project ones (it also covers claimed root files).
|
|
689
|
+
// `matters` keeps the events a key can see —
|
|
690
|
+
// project trees, claimed root files, the declared globs — and drops
|
|
634
691
|
// the rest of the tree; the ignore filter then keeps node_modules
|
|
635
692
|
// / .git / .vx and declared outputs out of what remains.
|
|
636
693
|
arm(workspaceRoot, true, (filename) => {
|
|
@@ -639,88 +696,50 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
639
696
|
if (
|
|
640
697
|
shapesWatchedSet(filename) ||
|
|
641
698
|
filename === LOCKFILE_NAME ||
|
|
699
|
+
claimedRootFiles.has(filename) ||
|
|
642
700
|
configImportFiles.includes(abs)
|
|
643
701
|
)
|
|
644
702
|
reread = true
|
|
645
703
|
trigger(`root ${filename}`, abs)
|
|
646
704
|
})
|
|
647
|
-
:
|
|
648
|
-
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
isWorkspaceConfigFile(filename) ||
|
|
654
|
-
filename === 'package.json' ||
|
|
655
|
-
filename === '.gitignore' ||
|
|
656
|
-
claimedRootFiles.has(filename)
|
|
657
|
-
) {
|
|
658
|
-
if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
|
|
659
|
-
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
660
|
-
}
|
|
661
|
-
})
|
|
705
|
+
: oneStream
|
|
706
|
+
? arm(workspaceRoot, true, streamEvent)
|
|
707
|
+
: // The root itself, non-recursive, beside the per-project arms, so
|
|
708
|
+
// claimed root files (a lockfile) trigger re-runs even when no
|
|
709
|
+
// project dir saw the change.
|
|
710
|
+
arm(workspaceRoot, false, rootFileEvent)
|
|
662
711
|
} catch (err) {
|
|
663
712
|
sayCannot(`cannot watch workspace root`, err)
|
|
664
713
|
}
|
|
714
|
+
// The workspace config and the lock sit in `.vx/`, which the root's
|
|
715
|
+
// non-recursive arm does not see into.
|
|
716
|
+
if (workspaceWide || oneStream) return
|
|
717
|
+
try {
|
|
718
|
+
dotVxArm = arm(path.join(workspaceRoot, '.vx'), false, dotVxEvent)
|
|
719
|
+
} catch (err) {
|
|
720
|
+
sayCannot(`cannot watch ${path.join(workspaceRoot, '.vx')}`, err)
|
|
721
|
+
}
|
|
665
722
|
}
|
|
666
723
|
// One recursive watcher per project, unless workspace-wide: each project
|
|
667
724
|
// owns its own subtree; the root is never watched recursively for them
|
|
668
|
-
// (it would cover every project + node_modules + caches).
|
|
725
|
+
// (it would cover every project + node_modules + caches). On macOS one
|
|
726
|
+
// recursive stream at the root stands for all of them (`oneStream`).
|
|
669
727
|
const armMode = (watched: readonly ProjectMeta[]): void => {
|
|
670
|
-
if (!workspaceWide)
|
|
728
|
+
if (!workspaceWide) {
|
|
729
|
+
streamed = oneStream ? toStream(watched) : []
|
|
730
|
+
const inStream = new Set(streamed.map((s) => s.proj))
|
|
731
|
+
for (const proj of watched) if (!inStream.has(proj)) armProject(proj)
|
|
732
|
+
}
|
|
671
733
|
armRoot()
|
|
672
734
|
}
|
|
673
735
|
const dropMode = (): void => {
|
|
736
|
+
streamed = []
|
|
674
737
|
for (const handle of perProject.values()) handle.close()
|
|
675
738
|
perProject.clear()
|
|
676
739
|
rootArm.close()
|
|
677
740
|
rootArm = CLOSED
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
/**
|
|
681
|
-
* A directory under a member base with no package in it yet. The base's
|
|
682
|
-
* own watcher is not recursive, so the `package.json` that makes it a
|
|
683
|
-
* package is an event only here; an editor or `git checkout` that makes
|
|
684
|
-
* the directory first and the manifest after left the package unwatched
|
|
685
|
-
* for good (item 891). On a rearm a new entry is armed before its
|
|
686
|
-
* manifest is checked, so one that landed after the re-read is an event
|
|
687
|
-
* or is seen by the check. At start there is no re-read to race, and a
|
|
688
|
-
* manifest already there is a directory the globs exclude.
|
|
689
|
-
*/
|
|
690
|
-
const pending = new Map<string, WatchHandle>()
|
|
691
|
-
const armPending = (afterReread: boolean): void => {
|
|
692
|
-
const want = new Set<string>()
|
|
693
|
-
for (const base of memberBases)
|
|
694
|
-
for (const name of memberEntries(base)) {
|
|
695
|
-
const dir = path.join(base, name)
|
|
696
|
-
if (!packageDirs.has(dir)) want.add(dir)
|
|
697
|
-
}
|
|
698
|
-
for (const [dir, handle] of pending) {
|
|
699
|
-
if (want.has(dir) && !stale(dir)) continue
|
|
700
|
-
handle.close()
|
|
701
|
-
pending.delete(dir)
|
|
702
|
-
}
|
|
703
|
-
for (const dir of want) {
|
|
704
|
-
if (pending.has(dir)) continue
|
|
705
|
-
const manifest = path.join(dir, 'package.json')
|
|
706
|
-
const arrived = (): void => {
|
|
707
|
-
reread = true
|
|
708
|
-
trigger(path.relative(workspaceRoot, manifest), manifest)
|
|
709
|
-
}
|
|
710
|
-
try {
|
|
711
|
-
pending.set(
|
|
712
|
-
dir,
|
|
713
|
-
arm(dir, false, (filename) => {
|
|
714
|
-
if (filename === 'package.json') arrived()
|
|
715
|
-
}),
|
|
716
|
-
)
|
|
717
|
-
armedAs.set(dir, inodeOf(dir) ?? '')
|
|
718
|
-
} catch (err) {
|
|
719
|
-
sayCannot(`cannot watch ${dir}`, err)
|
|
720
|
-
continue
|
|
721
|
-
}
|
|
722
|
-
if (afterReread && fs.existsSync(manifest)) arrived()
|
|
723
|
-
}
|
|
741
|
+
dotVxArm.close()
|
|
742
|
+
dotVxArm = CLOSED
|
|
724
743
|
}
|
|
725
744
|
|
|
726
745
|
/**
|
|
@@ -782,10 +801,19 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
782
801
|
armAncestors()
|
|
783
802
|
}
|
|
784
803
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
804
|
+
// Said once, when the loop first arms: a re-arm repeated it after every
|
|
805
|
+
// cycle that re-read the watched set, between task rows. `debug.watch`
|
|
806
|
+
// says each arm on stderr, for whoever needs to see the set change.
|
|
807
|
+
let lastTasks: number | undefined
|
|
808
|
+
const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
|
|
809
|
+
const sayWatching = (count: number): void => {
|
|
810
|
+
const tasks = lastTasks === undefined ? '' : ` · ${plural(lastTasks, 'task')}`
|
|
811
|
+
process.stdout.write(`watching ${plural(count, 'project')}${tasks}\n`)
|
|
812
|
+
}
|
|
813
|
+
const debugArms = resolveSetting('debug.watch', undefined).value === true
|
|
814
|
+
const sayArmed = (count: number): void => {
|
|
815
|
+
if (debugArms) process.stderr.write(`vx watch: armed ${plural(count, 'project')}\n`)
|
|
816
|
+
}
|
|
789
817
|
|
|
790
818
|
const rearm = async (say = true): Promise<void> => {
|
|
791
819
|
let next: Rediscovered
|
|
@@ -803,10 +831,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
803
831
|
claimedRootFiles = next.claimedRootFiles
|
|
804
832
|
configImportFiles = next.configImports
|
|
805
833
|
wsConfigImportFiles = next.workspaceConfigImports
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
armBases()
|
|
809
|
-
isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
|
|
834
|
+
armAncestors()
|
|
835
|
+
isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs, atArm?.tracked)
|
|
810
836
|
fenced = makeFence(next.fenceDirs)
|
|
811
837
|
matters = makeRootEventFilter(
|
|
812
838
|
workspaceRoot,
|
|
@@ -823,53 +849,68 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
823
849
|
workspaceWide = next.workspaceWide
|
|
824
850
|
armMode(next.projects)
|
|
825
851
|
} else if (!workspaceWide) {
|
|
826
|
-
|
|
852
|
+
streamed = oneStream ? toStream(next.projects) : []
|
|
853
|
+
const inStream = new Set(streamed.map((s) => s.proj))
|
|
854
|
+
const own = next.projects.filter((p) => !inStream.has(p))
|
|
855
|
+
const keep = new Set(own.map((p) => p.dir))
|
|
827
856
|
for (const [dir, handle] of perProject) {
|
|
828
857
|
if (keep.has(dir) && !stale(dir)) continue
|
|
829
858
|
handle.close()
|
|
830
859
|
perProject.delete(dir)
|
|
831
860
|
}
|
|
832
|
-
for (const proj of
|
|
861
|
+
for (const proj of own) if (!perProject.has(proj.dir)) armProject(proj)
|
|
833
862
|
}
|
|
834
|
-
armPending(true)
|
|
835
863
|
armImports()
|
|
836
864
|
// A new arm proves delivery like the first ones: an edit in the new
|
|
837
|
-
//
|
|
865
|
+
// project right after this cycle is seen, not lost in the gap.
|
|
838
866
|
await pool.proved()
|
|
839
|
-
if (say)
|
|
867
|
+
if (say) sayArmed(next.projects.length)
|
|
840
868
|
}
|
|
841
869
|
|
|
842
870
|
armMode(projects)
|
|
843
871
|
|
|
844
|
-
// A
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
//
|
|
855
|
-
// the deleted directory) nor to the root arm, which drops every name but
|
|
856
|
-
// its own files: `watching 0 project(s)` and silence until a restart. So
|
|
857
|
-
// each base, and each directory a config imports from, is watched from
|
|
858
|
-
// its nearest directory that exists, for the next name on the way down:
|
|
859
|
-
// its parent while it is there, an ancestor when the parent went too
|
|
860
|
-
// (`apps/` removed under `apps/web/*`).
|
|
872
|
+
// A project directory, or one a config imports from, removed and made
|
|
873
|
+
// again (`rm -rf packages && git checkout packages`) is no event to its
|
|
874
|
+
// own watch (it holds the deleted directory) nor to the root arm, which
|
|
875
|
+
// drops every name but its own files: silence until a restart. So each
|
|
876
|
+
// is watched from its nearest directory that exists, for the next name
|
|
877
|
+
// on the way down: its parent while it is there, an ancestor when the
|
|
878
|
+
// parent went too (`apps/` removed under `apps/web/site`). Every
|
|
879
|
+
// directory above it up to the root is watched for the next name too:
|
|
880
|
+
// FSEvents reports nothing for a watched directory's children when the
|
|
881
|
+
// directory itself goes with them, so on macOS the parent's arm alone
|
|
882
|
+
// never heard `packages/` removed under `packages/a` (inotify did).
|
|
861
883
|
const ancestorArms = new Map<string, WatchHandle>()
|
|
862
884
|
let ancestorNames = new Map<string, Set<string>>()
|
|
885
|
+
/** Each project directory below the root as these arms first saw it (`inodeOf`). */
|
|
886
|
+
let heldDirs = new Map<string, string>()
|
|
863
887
|
const armAncestors = (): void => {
|
|
864
888
|
ancestorNames = new Map()
|
|
865
|
-
|
|
889
|
+
const rootDir = path.resolve(workspaceRoot)
|
|
890
|
+
const below = projectDirs.filter((d) => path.resolve(d) !== rootDir)
|
|
891
|
+
heldDirs = new Map(
|
|
892
|
+
below.flatMap((d) => {
|
|
893
|
+
const id = inodeOf(d)
|
|
894
|
+
return id === undefined ? [] : [[d, id]]
|
|
895
|
+
}),
|
|
896
|
+
)
|
|
897
|
+
for (const base of [...below, ...importNames.keys()]) {
|
|
866
898
|
let dir = path.dirname(base)
|
|
867
899
|
let name = path.basename(base)
|
|
868
900
|
while (inodeOf(dir) === undefined && dir !== workspaceRoot && dir !== path.dirname(dir)) {
|
|
869
901
|
name = path.basename(dir)
|
|
870
902
|
dir = path.dirname(dir)
|
|
871
903
|
}
|
|
872
|
-
|
|
904
|
+
for (;;) {
|
|
905
|
+
const names = ancestorNames.get(dir) ?? new Set<string>()
|
|
906
|
+
// A directory already holding this name was climbed from here before.
|
|
907
|
+
const climbed = names.has(name)
|
|
908
|
+
ancestorNames.set(dir, names.add(name))
|
|
909
|
+
// Outside the root (a preset a config imports from `../`) only the nearest arm.
|
|
910
|
+
if (climbed || !dir.startsWith(workspaceRoot + path.sep)) break
|
|
911
|
+
name = path.basename(dir)
|
|
912
|
+
dir = path.dirname(dir)
|
|
913
|
+
}
|
|
873
914
|
}
|
|
874
915
|
for (const [dir, handle] of ancestorArms) {
|
|
875
916
|
if (ancestorNames.has(dir) && !stale(dir)) continue
|
|
@@ -882,9 +923,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
882
923
|
ancestorArms.set(
|
|
883
924
|
dir,
|
|
884
925
|
arm(dir, false, (filename) => {
|
|
885
|
-
|
|
886
|
-
|
|
926
|
+
// A project's parent's `.gitignore` shapes what its keys read, as the root's does.
|
|
927
|
+
if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
|
|
887
928
|
const abs = path.join(dir, filename)
|
|
929
|
+
// A project directory still the one its arm holds is no change:
|
|
930
|
+
// its arm judges what is in it. macOS reports the directory's
|
|
931
|
+
// mtime here on every write at its top level, and a cycle's own
|
|
932
|
+
// write started the next (solid's `types`, M 2026-10-15).
|
|
933
|
+
if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
|
|
934
|
+
reread = true
|
|
888
935
|
trigger(path.relative(workspaceRoot, abs), abs)
|
|
889
936
|
}),
|
|
890
937
|
)
|
|
@@ -894,59 +941,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
894
941
|
}
|
|
895
942
|
}
|
|
896
943
|
}
|
|
897
|
-
|
|
898
|
-
armAncestors()
|
|
899
|
-
const want = new Set(memberBases)
|
|
900
|
-
for (const [base, handle] of baseArms) {
|
|
901
|
-
if (want.has(base) && !stale(base)) continue
|
|
902
|
-
handle.close()
|
|
903
|
-
baseArms.delete(base)
|
|
904
|
-
}
|
|
905
|
-
for (const base of want) {
|
|
906
|
-
if (baseArms.has(base)) continue
|
|
907
|
-
// Gone for now: its parent's arm hears it come back.
|
|
908
|
-
if (inodeOf(base) === undefined) continue
|
|
909
|
-
let members = memberEntries(base)
|
|
910
|
-
try {
|
|
911
|
-
baseArms.set(
|
|
912
|
-
base,
|
|
913
|
-
arm(base, false, (filename) => {
|
|
914
|
-
if (isIgnoredWatchPath(filename)) return
|
|
915
|
-
// Inputs are gitignore-aware: an edit here changes what the
|
|
916
|
-
// members' keys read, and `vx run` would miss on it.
|
|
917
|
-
if (filename === '.gitignore') {
|
|
918
|
-
trigger(
|
|
919
|
-
path.relative(workspaceRoot, path.join(base, filename)),
|
|
920
|
-
path.join(base, filename),
|
|
921
|
-
)
|
|
922
|
-
return
|
|
923
|
-
}
|
|
924
|
-
// Only a member coming or going. On macOS a non-recursive watcher
|
|
925
|
-
// also reports a member whose CONTENTS changed (FSEvents names the
|
|
926
|
-
// directory a write landed in), so a task writing into its own
|
|
927
|
-
// project — or the arm's own probe file — read as a member event
|
|
928
|
-
// and cost an uncached task one execution per cycle (CI,
|
|
929
|
-
// 2026-09-10).
|
|
930
|
-
const now = memberEntries(base)
|
|
931
|
-
// The same names, but a directory still without a package made
|
|
932
|
-
// again (or replaced by a rename): its pending watch holds the
|
|
933
|
-
// deleted one, and the `package.json` that lands next is heard
|
|
934
|
-
// only by a new arm.
|
|
935
|
-
const entry = path.join(base, filename)
|
|
936
|
-
if (sameMembers(members, now) && !(pending.has(entry) && stale(entry))) return
|
|
937
|
-
members = now
|
|
938
|
-
reread = true
|
|
939
|
-
trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
|
|
940
|
-
}),
|
|
941
|
-
)
|
|
942
|
-
armedAs.set(base, inodeOf(base) ?? '')
|
|
943
|
-
} catch (err) {
|
|
944
|
-
sayCannot(`cannot watch ${base}`, err)
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
armBases()
|
|
949
|
-
armPending(false)
|
|
944
|
+
armAncestors()
|
|
950
945
|
armImports()
|
|
951
946
|
|
|
952
947
|
// The orchestrator's own writes never kick the loop: `makeWatchIgnore`
|
|
@@ -963,7 +958,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
963
958
|
interruptIfWaiting()
|
|
964
959
|
}
|
|
965
960
|
|
|
966
|
-
|
|
961
|
+
lastTasks = args.tasks()
|
|
962
|
+
sayWatching(projects.length)
|
|
963
|
+
sayArmed(projects.length)
|
|
967
964
|
|
|
968
965
|
return await new Promise<void>((resolve) => {
|
|
969
966
|
const cleanup = async (): Promise<void> => {
|