@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/cli/watch.ts
CHANGED
|
@@ -16,6 +16,7 @@ import fs from 'node:fs'
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import { parseRunArgs, resolveRunOptions, type RunArgs, spacedHint } from './run.js'
|
|
18
18
|
import { refuse, seeHelp } from './help.js'
|
|
19
|
+
import { fsClockNow, namedIgnoredReach, repoFacts } from '../cache/index.js'
|
|
19
20
|
import {
|
|
20
21
|
fingerprintClaims,
|
|
21
22
|
forwardedSignal,
|
|
@@ -37,12 +38,13 @@ import {
|
|
|
37
38
|
isProjectConfigFile,
|
|
38
39
|
isWorkspaceConfigFile,
|
|
39
40
|
gitFiles,
|
|
41
|
+
lockedExtras,
|
|
40
42
|
makeFence,
|
|
41
43
|
makeRootEventFilter,
|
|
42
44
|
makeWatchIgnore,
|
|
43
45
|
shapesWatchedSet,
|
|
44
46
|
} from './watch-filter.js'
|
|
45
|
-
import { CLOSED,
|
|
47
|
+
import { CLOSED, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
46
48
|
import { ChangeJudge } from './watch-judge.js'
|
|
47
49
|
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
48
50
|
import { hangupIgnored, maskedLine, printable, restartTimings } from '../util/index.js'
|
|
@@ -70,6 +72,14 @@ function sayAllCached(opts: RunOptions, outcomes: readonly { status: string }[])
|
|
|
70
72
|
ui.print('out', ui.line(' ', n === 1 ? '1 task' : `${n} tasks`, ' cached, nothing ran'))
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
function isDirectory(abs: string): boolean {
|
|
76
|
+
try {
|
|
77
|
+
return fs.statSync(abs).isDirectory()
|
|
78
|
+
} catch {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
/** Wait this long after the last filesystem event before re-running. */
|
|
74
84
|
const DEBOUNCE_MS = 150
|
|
75
85
|
/**
|
|
@@ -148,6 +158,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
148
158
|
handleSignals: false,
|
|
149
159
|
signal: stop.signal,
|
|
150
160
|
holdPersistent: true,
|
|
161
|
+
watch: true,
|
|
151
162
|
}
|
|
152
163
|
// Every cycle's `invocations` row names the verb, as `vx run`'s does;
|
|
153
164
|
// run()'s process.argv fallback put the bin's absolute path there, so
|
|
@@ -157,7 +168,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
157
168
|
// worth; every cycle after an edit must load and list live.
|
|
158
169
|
delete opts.staged
|
|
159
170
|
delete opts.discovered
|
|
160
|
-
|
|
171
|
+
// `on`, not `once`: a second signal during the teardown is absorbed (an
|
|
172
|
+
// abort is once), where `once` left it to Bun's default, exit 130 with
|
|
173
|
+
// the cycle's children mid-grace.
|
|
174
|
+
process.on('SIGINT', () => stop.abort('SIGINT'))
|
|
161
175
|
// Said once the loop is down, the last line: written as the signal
|
|
162
176
|
// landed, the aborted cycle's summary printed below it.
|
|
163
177
|
const stopped = (): number => {
|
|
@@ -167,11 +181,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
167
181
|
}
|
|
168
182
|
return 0
|
|
169
183
|
}
|
|
170
|
-
process.
|
|
184
|
+
process.on('SIGTERM', () => stop.abort('SIGTERM'))
|
|
171
185
|
// A task runs in its own session (exec/kill-tree.ts): the terminal
|
|
172
186
|
// closing reaches the loop alone, and the loop passes it on — unless vx
|
|
173
187
|
// was started with it ignored (nohup).
|
|
174
|
-
if (!hangupIgnored()) process.
|
|
188
|
+
if (!hangupIgnored()) process.on('SIGHUP', () => stop.abort('SIGHUP'))
|
|
175
189
|
|
|
176
190
|
// Enumerate projects-in-scope so we know what dirs to watch: the bare
|
|
177
191
|
// tasks' scope (`opts.projects`; undefined means "every project", or
|
|
@@ -223,8 +237,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
223
237
|
// ahead of the run to decide which watchers to install, and that marks every
|
|
224
238
|
// config loaded — so the initial run's own loads become REPEATs and pay a
|
|
225
239
|
// worker round-trip each (see config-eval.ts). Trading a hot-path regression
|
|
226
|
-
// on every `vx watch` for a window a user rarely edits into is a bad deal
|
|
227
|
-
//
|
|
240
|
+
// on every `vx watch` for a window a user rarely edits into is a bad deal.
|
|
241
|
+
// An edit to the inputs of a task the run was running is caught anyway:
|
|
242
|
+
// the run leaves that task unsaved, and the loop starts on it (`drifted`).
|
|
228
243
|
// The exception: once the run waits on a server's readiness alone, every
|
|
229
244
|
// config is loaded, and the loop arms with the run as its cycle in
|
|
230
245
|
// flight. Unarmed, a server that never got ready held the initial run
|
|
@@ -244,8 +259,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
244
259
|
// Uncaught, it ended watch at start while the same break mid-watch did
|
|
245
260
|
// not (item 1017).
|
|
246
261
|
let refusedToStart = false
|
|
262
|
+
/** A task whose inputs the user edited while the initial run ran it: no watcher heard the edit. */
|
|
263
|
+
let drifted: string | undefined
|
|
247
264
|
initial.held = runOrchestrator(initial.cycle.opts).then(
|
|
248
265
|
async (run) => {
|
|
266
|
+
drifted = run.outcomes.find((o) => o.unkeyed === true)?.node.id
|
|
249
267
|
if (run.refused !== undefined) {
|
|
250
268
|
const hint = run.refused.startsWith('no projects declare')
|
|
251
269
|
? await spacedHint(parsed, cwd)
|
|
@@ -288,10 +306,14 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
288
306
|
sayNotice('error', 'vx watch', 'no projects in scope')
|
|
289
307
|
return 1
|
|
290
308
|
}
|
|
291
|
-
await armLoop(undefined, held)
|
|
309
|
+
await armLoop(undefined, held, drifted)
|
|
292
310
|
return stopped()
|
|
293
311
|
|
|
294
|
-
async function armLoop(
|
|
312
|
+
async function armLoop(
|
|
313
|
+
initial?: InitialCycle,
|
|
314
|
+
held?: HeldPersistent,
|
|
315
|
+
drifted?: string,
|
|
316
|
+
): Promise<void> {
|
|
295
317
|
// `--affected`'s diff is the tree at start; judged again, it held every
|
|
296
318
|
// later edit out of the scope it picked. A cycle is an edit, and the
|
|
297
319
|
// cache keys decide what in the scope it re-runs.
|
|
@@ -309,6 +331,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
309
331
|
held,
|
|
310
332
|
initial,
|
|
311
333
|
tasks: openingTasks,
|
|
334
|
+
drifted,
|
|
312
335
|
stop: stop.signal,
|
|
313
336
|
workspaceRoot,
|
|
314
337
|
projects: watched,
|
|
@@ -330,12 +353,18 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
330
353
|
const all = await discoverCliProjects(workspaceRoot, load)
|
|
331
354
|
const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
|
|
332
355
|
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
356
|
+
const wsNow = await loadCliWorkspace(workspaceRoot, quiet)
|
|
333
357
|
return {
|
|
334
358
|
projects: now,
|
|
335
359
|
// A plugin the workspace config gained since claims its file from
|
|
336
360
|
// the cycle that loaded it; read once, its edits started nothing
|
|
337
361
|
// until a restart.
|
|
338
|
-
claimedRootFiles: claimsOf(
|
|
362
|
+
claimedRootFiles: claimsOf(wsNow.plugins),
|
|
363
|
+
// A re-lock that records a new `file:` directory or patch.
|
|
364
|
+
extraFiles: lockedExtras(workspaceRoot),
|
|
365
|
+
// The dir the cycle just wrote its store to: kept from the start,
|
|
366
|
+
// a `cache.dir` edit let those writes start the next cycle.
|
|
367
|
+
cacheDir: opts.cacheDir ?? wsNow.cacheDir,
|
|
339
368
|
workspaceWide: sweep.workspaceWide,
|
|
340
369
|
workspaceInputs: sweep.workspaceInputs,
|
|
341
370
|
outputs: sweep.outputs,
|
|
@@ -351,6 +380,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
351
380
|
// the one edit that changes what a cycle runs; unheard, the loop ran
|
|
352
381
|
// the old lock until a restart (item 971).
|
|
353
382
|
claimedRootFiles: claimsOf(ws.plugins),
|
|
383
|
+
extraFiles: lockedExtras(workspaceRoot),
|
|
354
384
|
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
355
385
|
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
356
386
|
poll: resolveSetting('watch.poll', ws.workspaceConfig, parsed.poll).value === true,
|
|
@@ -375,6 +405,8 @@ interface WatchLoopArgs {
|
|
|
375
405
|
initial: InitialCycle | undefined
|
|
376
406
|
/** The initial run's task count, once it has started. */
|
|
377
407
|
tasks: () => number | undefined
|
|
408
|
+
/** A task the initial run left unsaved because its inputs moved under it (`TaskOutcome.unkeyed`). */
|
|
409
|
+
drifted: string | undefined
|
|
378
410
|
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
379
411
|
stop: AbortSignal
|
|
380
412
|
workspaceRoot: string
|
|
@@ -404,6 +436,8 @@ interface WatchLoopArgs {
|
|
|
404
436
|
* loaded once per process, so the set is fixed for the loop.
|
|
405
437
|
*/
|
|
406
438
|
claimedRootFiles: ReadonlySet<string>
|
|
439
|
+
/** The files a claimed file's recorded digests read besides it (`lockedExtras`), absolute. */
|
|
440
|
+
extraFiles: ReadonlySet<string>
|
|
407
441
|
/** Every project, in scope or not: a project's key leaves out what lies in one nested under it. */
|
|
408
442
|
fenceDirs: readonly string[]
|
|
409
443
|
/** The watched set again, after a cycle that followed an event which can change it. */
|
|
@@ -414,9 +448,20 @@ interface WatchLoopArgs {
|
|
|
414
448
|
|
|
415
449
|
const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.dir)
|
|
416
450
|
|
|
451
|
+
/** The input globs that name a `node_modules` segment, per directory: what the tree watcher enters. */
|
|
452
|
+
const modulesNamed = (inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>>): string =>
|
|
453
|
+
JSON.stringify(
|
|
454
|
+
[...inputs].map(([dir, tasks]) => [
|
|
455
|
+
dir,
|
|
456
|
+
tasks.flat().filter((g) => g.split('/').includes('node_modules')),
|
|
457
|
+
]),
|
|
458
|
+
)
|
|
459
|
+
|
|
417
460
|
interface Rediscovered {
|
|
418
461
|
projects: readonly ProjectMeta[]
|
|
462
|
+
cacheDir: string
|
|
419
463
|
claimedRootFiles: ReadonlySet<string>
|
|
464
|
+
extraFiles: ReadonlySet<string>
|
|
420
465
|
workspaceWide: boolean
|
|
421
466
|
workspaceInputs: readonly string[]
|
|
422
467
|
outputs: ReadonlyMap<string, readonly string[]>
|
|
@@ -429,9 +474,10 @@ interface Rediscovered {
|
|
|
429
474
|
}
|
|
430
475
|
|
|
431
476
|
async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
432
|
-
const { opts, stop, workspaceRoot, projects
|
|
477
|
+
const { opts, stop, workspaceRoot, projects } = args
|
|
433
478
|
// The watched set as of the last cycle: `rearm` replaces these after an
|
|
434
479
|
// event that can change it, and every filter below reads the current one.
|
|
480
|
+
let cacheDir = args.cacheDir
|
|
435
481
|
let workspaceWide = args.workspaceWide
|
|
436
482
|
let projectDirs = args.projectDirs
|
|
437
483
|
let workspaceInputs = args.workspaceInputs
|
|
@@ -440,6 +486,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
440
486
|
let uncached = args.uncached
|
|
441
487
|
let namedIgnored = args.namedIgnored
|
|
442
488
|
let claimedRootFiles = args.claimedRootFiles
|
|
489
|
+
let extraFiles = args.extraFiles
|
|
443
490
|
let configImportFiles = args.configImports
|
|
444
491
|
let wsConfigImportFiles = args.workspaceConfigImports
|
|
445
492
|
// A dev server stays up while the loop idles and across cycles; a cycle
|
|
@@ -517,11 +564,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
517
564
|
}
|
|
518
565
|
const trigger = (label: string, abs: string): void => {
|
|
519
566
|
if (!changes.pending.has(abs)) changes.pending.set(abs, label)
|
|
567
|
+
changes.sawEvent(abs)
|
|
520
568
|
if (running) return interruptIfWaiting()
|
|
521
569
|
if (debounceTimer) {
|
|
522
|
-
if (
|
|
570
|
+
if (performance.now() - windowOpened >= DEBOUNCE_MAX_MS) return
|
|
523
571
|
clearTimeout(debounceTimer)
|
|
524
|
-
} else windowOpened =
|
|
572
|
+
} else windowOpened = performance.now()
|
|
525
573
|
debounceTimer = setTimeout(() => {
|
|
526
574
|
debounceTimer = null
|
|
527
575
|
if (running) return
|
|
@@ -539,10 +587,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
539
587
|
const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
|
|
540
588
|
try {
|
|
541
589
|
let label: string | undefined = first
|
|
542
|
-
|
|
590
|
+
/** The lock this cycle's run read; none for the initial run. */
|
|
591
|
+
let lockAtRun: string | undefined
|
|
543
592
|
// The initial run is collected even after a stop: its server is the
|
|
544
593
|
// loop's to stop.
|
|
545
594
|
while (initial !== undefined || (label !== undefined && !stop.aborted)) {
|
|
595
|
+
/** This cycle's failure, as said: a re-read failing on the same cause says nothing more. */
|
|
596
|
+
let failed: string | undefined
|
|
597
|
+
/** The judged cycle this run is (`ChangeJudge.cycle`): its window closes when the run ends. */
|
|
598
|
+
const judged = changes.cycle
|
|
546
599
|
if (initial !== undefined) {
|
|
547
600
|
held = await initial.held
|
|
548
601
|
initial = undefined
|
|
@@ -565,6 +618,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
565
618
|
`no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
|
|
566
619
|
)
|
|
567
620
|
}
|
|
621
|
+
// A failed re-read is said once, by the re-read after the
|
|
622
|
+
// cycle: said here too, it printed above the cycle's header.
|
|
568
623
|
if (reread) await rearm(false)
|
|
569
624
|
ui.print('out', ui.gap())
|
|
570
625
|
ui.say('info', `changed ${label}`, 'out')
|
|
@@ -575,9 +630,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
575
630
|
// as a file born and gone since, and ran nothing.
|
|
576
631
|
if (
|
|
577
632
|
atArm !== undefined &&
|
|
578
|
-
changes.changed.some((p) => path.basename(p) === '.gitignore')
|
|
579
|
-
)
|
|
633
|
+
changes.changed.some((p) => path.basename(p) === '.gitignore' || p === excludeFile)
|
|
634
|
+
) {
|
|
580
635
|
for (const p of gitFiles(workspaceRoot)?.listed ?? []) atArm.listed.add(p)
|
|
636
|
+
reachOf = undefined
|
|
637
|
+
}
|
|
581
638
|
restartTimings()
|
|
582
639
|
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
583
640
|
// run made within a tick of it carried an earlier mtime and read as
|
|
@@ -588,11 +645,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
588
645
|
// takes it from there. The run stops the others before its graph.
|
|
589
646
|
const before = held
|
|
590
647
|
current = watchCycle(
|
|
591
|
-
{
|
|
648
|
+
{
|
|
649
|
+
...opts,
|
|
650
|
+
...(before ? { keep: before } : {}),
|
|
651
|
+
...(judged ? { commandWindows: judged.windows } : {}),
|
|
652
|
+
},
|
|
592
653
|
stop,
|
|
593
654
|
interruptIfWaiting,
|
|
594
655
|
)
|
|
595
656
|
const cycle = await runOrchestrator(current.opts)
|
|
657
|
+
changes.unseen(
|
|
658
|
+
cycle.outcomes.flatMap((o) => (o.driftedInput === undefined ? [] : [o.driftedInput])),
|
|
659
|
+
)
|
|
596
660
|
sayAllCached(opts, cycle.outcomes)
|
|
597
661
|
lastTasks = current.tasks ?? lastTasks
|
|
598
662
|
current = undefined
|
|
@@ -610,23 +674,30 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
610
674
|
// FS event (if any) will retry. The dispose() on SIGINT
|
|
611
675
|
// is the canonical exit; we don't unilaterally abort here.
|
|
612
676
|
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
613
|
-
|
|
614
|
-
sayNotice('error', 'vx watch', `cycle failed: ${
|
|
615
|
-
} finally {
|
|
616
|
-
// A re-lock since this cycle began: the re-arm below can
|
|
617
|
-
// close the watcher its event was still on its way to (FSEvents'
|
|
618
|
-
// latency, a poller's tick), and the new lock ran nothing until
|
|
619
|
-
// the next edit (macOS CI, 2026-10-23). A cycle that read the
|
|
620
|
-
// lock mid-write failed on it, and is the same case.
|
|
621
|
-
if (lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
|
|
677
|
+
failed = err instanceof Error ? err.message : String(err)
|
|
678
|
+
sayNotice('error', 'vx watch', `cycle failed: ${maskedLine(failed)}`)
|
|
622
679
|
}
|
|
680
|
+
if (judged !== undefined) judged.to = Math.min(judged.to, Date.now())
|
|
623
681
|
// After a failed cycle too: a package added with a config that does
|
|
624
682
|
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
625
683
|
// was never an event.
|
|
626
684
|
if (reread && !stop.aborted && hasWorkspaceFile()) {
|
|
627
685
|
reread = false
|
|
628
|
-
await rearm()
|
|
686
|
+
const err = await rearm()
|
|
687
|
+
// No lock is the cycle's own failure to say; said here too, it was two lines.
|
|
688
|
+
const why = err instanceof Error ? err.message : String(err)
|
|
689
|
+
if (err !== undefined && why !== NO_LOCK && why !== failed)
|
|
690
|
+
sayCannot(`cannot re-read the workspace`, err)
|
|
629
691
|
}
|
|
692
|
+
// A re-lock since this cycle began: the re-arm can close the watcher
|
|
693
|
+
// its event was still on its way to (FSEvents' latency, a poller's
|
|
694
|
+
// tick), and the new lock ran nothing until the next edit (macOS CI,
|
|
695
|
+
// 2026-10-23). A cycle that read the lock mid-write failed on it, and
|
|
696
|
+
// is the same case. Asked after the re-arm, not when the run ends: a
|
|
697
|
+
// lock written in between was the re-arm's to read, its new pollers
|
|
698
|
+
// took it as their baseline, and it was never an event (macOS CI,
|
|
699
|
+
// 2026-11-06).
|
|
700
|
+
if (lockAtRun !== undefined && lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
|
|
630
701
|
// What landed mid-run is judged on settled state, one window
|
|
631
702
|
// after the run, under the label of what actually arrived.
|
|
632
703
|
current = undefined
|
|
@@ -676,15 +747,30 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
676
747
|
const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
|
|
677
748
|
pool.arm(dir, recursive, onEvent)
|
|
678
749
|
|
|
750
|
+
// Only the named entries a key reads ignored files under: `src` of
|
|
751
|
+
// `src/**` over a listed `src` reaches none, and a dev server's ignored
|
|
752
|
+
// `src/dev.log` started ~3 cycles a second. Reckoned per sweep.
|
|
753
|
+
let reachOf: readonly string[] | undefined
|
|
754
|
+
let reach: readonly string[] = []
|
|
679
755
|
/** 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. */
|
|
680
756
|
const armedAt = fsClockNow(cacheDir)
|
|
681
757
|
/** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
|
|
682
758
|
const changes = new ChangeJudge({
|
|
683
759
|
workspaceRoot,
|
|
684
760
|
armedAt,
|
|
761
|
+
clock: () => fsClockNow(cacheDir),
|
|
685
762
|
held: () => held !== undefined,
|
|
686
763
|
uncached: () => uncached,
|
|
687
|
-
namedIgnored: () =>
|
|
764
|
+
namedIgnored: () => {
|
|
765
|
+
if (reachOf !== namedIgnored) {
|
|
766
|
+
reachOf = namedIgnored
|
|
767
|
+
reach =
|
|
768
|
+
atArm === undefined
|
|
769
|
+
? namedIgnored
|
|
770
|
+
: namedIgnoredReach(workspaceRoot, namedIgnored, atArm.listed)
|
|
771
|
+
}
|
|
772
|
+
return reach
|
|
773
|
+
},
|
|
688
774
|
fenced: (ownDir, abs) => fenced(ownDir, abs),
|
|
689
775
|
awaited: (abs) => ancestorNames.get(path.dirname(abs))?.has(path.basename(abs)) === true,
|
|
690
776
|
...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
|
|
@@ -697,6 +783,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
697
783
|
// went live in 8 ms (tests/watch-one-stream.test.ts). Polling keeps its
|
|
698
784
|
// per-project walks, which skip what lies between projects.
|
|
699
785
|
const oneStream = process.platform === 'darwin' && !args.poll
|
|
786
|
+
const treeArms = process.platform === 'linux' && !args.poll
|
|
700
787
|
/** The projects the one stream hands events to, by their real directory. */
|
|
701
788
|
let streamed: { proj: ProjectMeta; real: string }[] = []
|
|
702
789
|
const realRoot = ((): string => {
|
|
@@ -1034,6 +1121,64 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1034
1121
|
}
|
|
1035
1122
|
}
|
|
1036
1123
|
|
|
1124
|
+
/**
|
|
1125
|
+
* The files a claimed file's recorded digests read besides it (a `file:`
|
|
1126
|
+
* dependency's directory, a patch; `lockedExtras`): a run keys them, and
|
|
1127
|
+
* no project's arm hears one outside its tree. A directory recursively,
|
|
1128
|
+
* a file (or a path gone for now) from its directory; armed again when a
|
|
1129
|
+
* re-lock changes the set.
|
|
1130
|
+
*/
|
|
1131
|
+
const extraArms = new Map<string, { at: string; handle: WatchHandle }>()
|
|
1132
|
+
const armExtras = (): void => {
|
|
1133
|
+
const want = new Map<string, { at: string; dir: boolean }>()
|
|
1134
|
+
for (const abs of extraFiles) {
|
|
1135
|
+
const dir = isDirectory(abs)
|
|
1136
|
+
want.set(abs, { at: dir ? abs : path.dirname(abs), dir })
|
|
1137
|
+
}
|
|
1138
|
+
for (const [abs, armed] of extraArms) {
|
|
1139
|
+
if (want.get(abs)?.at === armed.at && !stale(armed.at)) continue
|
|
1140
|
+
armed.handle.close()
|
|
1141
|
+
extraArms.delete(abs)
|
|
1142
|
+
}
|
|
1143
|
+
for (const [abs, { at, dir }] of want) {
|
|
1144
|
+
if (extraArms.has(abs) || inodeOf(at) === undefined) continue
|
|
1145
|
+
const name = path.basename(abs)
|
|
1146
|
+
try {
|
|
1147
|
+
const handle = arm(at, dir, (filename) => {
|
|
1148
|
+
if (!dir && filename !== name) return
|
|
1149
|
+
const changed = dir ? path.join(abs, filename) : abs
|
|
1150
|
+
// A directory made where a file or nothing was armed: armed again, recursively.
|
|
1151
|
+
if (!dir && isDirectory(abs)) reread = true
|
|
1152
|
+
trigger(`root ${path.relative(workspaceRoot, changed)}`, changed)
|
|
1153
|
+
})
|
|
1154
|
+
extraArms.set(abs, { at, handle })
|
|
1155
|
+
armedAs.set(at, inodeOf(at) ?? '')
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
sayCannot(`cannot watch ${at}`, err)
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* `info/exclude` ignores as a `.gitignore` does, and keys are
|
|
1163
|
+
* gitignore-aware (X-136), so its edit is a cycle; the ignore filter drops
|
|
1164
|
+
* every path under `.git/`. Armed once, from the common dir the run's own
|
|
1165
|
+
* enumeration asked git for (`repoFacts`, memoised).
|
|
1166
|
+
*/
|
|
1167
|
+
const commonDir = repoFacts(workspaceRoot)?.commonDir
|
|
1168
|
+
const excludeFile =
|
|
1169
|
+
commonDir === undefined ? undefined : path.resolve(workspaceRoot, commonDir, 'info', 'exclude')
|
|
1170
|
+
const armExclude = (): void => {
|
|
1171
|
+
if (excludeFile === undefined || inodeOf(path.dirname(excludeFile)) === undefined) return
|
|
1172
|
+
try {
|
|
1173
|
+
arm(path.dirname(excludeFile), false, (filename) => {
|
|
1174
|
+
if (filename === 'exclude')
|
|
1175
|
+
trigger(`root ${path.relative(workspaceRoot, excludeFile)}`, excludeFile)
|
|
1176
|
+
})
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
sayCannot(`cannot watch ${path.dirname(excludeFile)}`, err)
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1037
1182
|
// Said once, when the loop first arms: a re-arm repeated it after every
|
|
1038
1183
|
// cycle that re-read the watched set, between task rows. `debug.watch`
|
|
1039
1184
|
// says each arm on stderr, for whoever needs to see the set change.
|
|
@@ -1052,23 +1197,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1052
1197
|
)
|
|
1053
1198
|
}
|
|
1054
1199
|
|
|
1055
|
-
|
|
1200
|
+
/** Re-read the workspace and re-arm on it; a failed re-read is returned, for the caller to say. */
|
|
1201
|
+
const rearm = async (say = true): Promise<unknown> => {
|
|
1056
1202
|
let next: Rediscovered
|
|
1057
1203
|
try {
|
|
1058
1204
|
next = await args.rediscover()
|
|
1059
1205
|
} catch (err) {
|
|
1060
|
-
|
|
1061
|
-
if (!(err instanceof Error && err.message === NO_LOCK))
|
|
1062
|
-
sayCannot(`cannot re-read the workspace`, err)
|
|
1063
|
-
return
|
|
1206
|
+
return err
|
|
1064
1207
|
}
|
|
1208
|
+
cacheDir = next.cacheDir
|
|
1065
1209
|
projectDirs = next.projects.map((p) => p.dir)
|
|
1066
1210
|
workspaceInputs = next.workspaceInputs
|
|
1067
1211
|
outputs = next.outputs
|
|
1212
|
+
// Linux's tree watcher picks the `node_modules` it enters when it arms
|
|
1213
|
+
// (watch-fs.ts): an input newly named there needs the trees armed again.
|
|
1214
|
+
const modulesRenamed = treeArms && modulesNamed(next.inputs) !== modulesNamed(inputs)
|
|
1068
1215
|
inputs = next.inputs
|
|
1069
1216
|
uncached = next.uncached
|
|
1070
1217
|
namedIgnored = next.namedIgnored
|
|
1071
1218
|
claimedRootFiles = next.claimedRootFiles
|
|
1219
|
+
extraFiles = next.extraFiles
|
|
1072
1220
|
configImportFiles = next.configImports
|
|
1073
1221
|
wsConfigImportFiles = next.workspaceConfigImports
|
|
1074
1222
|
armAncestors()
|
|
@@ -1081,7 +1229,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1081
1229
|
claimedRootFiles,
|
|
1082
1230
|
fenced,
|
|
1083
1231
|
)
|
|
1084
|
-
if (next.workspaceWide !== workspaceWide) {
|
|
1232
|
+
if (next.workspaceWide !== workspaceWide || modulesRenamed) {
|
|
1085
1233
|
// A task started or stopped declaring `workspaceFiles`: the other
|
|
1086
1234
|
// arm's shape. Until item 891 the choice was made once, at start, and
|
|
1087
1235
|
// a root file a config newly named was never an event.
|
|
@@ -1105,10 +1253,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1105
1253
|
watched = next.projects
|
|
1106
1254
|
links = gitFiles(workspaceRoot)?.links ?? links
|
|
1107
1255
|
armLinks()
|
|
1256
|
+
armExtras()
|
|
1108
1257
|
// A new arm proves delivery like the first ones: an edit in the new
|
|
1109
1258
|
// project right after this cycle is seen, not lost in the gap.
|
|
1110
1259
|
await pool.proved()
|
|
1111
1260
|
if (say) sayArmed(next.projects.length)
|
|
1261
|
+
return undefined
|
|
1112
1262
|
}
|
|
1113
1263
|
|
|
1114
1264
|
armMode(projects)
|
|
@@ -1190,6 +1340,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1190
1340
|
armAncestors()
|
|
1191
1341
|
armImports()
|
|
1192
1342
|
armLinks()
|
|
1343
|
+
armExtras()
|
|
1344
|
+
armExclude()
|
|
1193
1345
|
|
|
1194
1346
|
// The orchestrator's own writes never kick the loop: `makeWatchIgnore`
|
|
1195
1347
|
// closes over the RESOLVED cache dir (relocated or not) and the tasks'
|
|
@@ -1208,6 +1360,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1208
1360
|
lastTasks = args.tasks()
|
|
1209
1361
|
sayWatching(projects.length)
|
|
1210
1362
|
sayArmed(projects.length)
|
|
1363
|
+
// An edit made while the initial run ran landed before any watcher, and
|
|
1364
|
+
// its mtime predates the arm, so no event or judgement can find it: the
|
|
1365
|
+
// run's own re-check did (it left the task unsaved), and that starts the
|
|
1366
|
+
// first cycle. Unheard, watch idled over output built from the old bytes.
|
|
1367
|
+
if (args.drifted !== undefined) void cycle(`${args.drifted} inputs, edited during the first run`)
|
|
1211
1368
|
|
|
1212
1369
|
return await new Promise<void>((resolve) => {
|
|
1213
1370
|
const cleanup = async (): Promise<void> => {
|