@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-fs.ts
CHANGED
|
@@ -30,8 +30,6 @@ export const WATCH_PROBE = '.vx-watch-probe'
|
|
|
30
30
|
|
|
31
31
|
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
32
32
|
export const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
33
|
-
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
34
|
-
const WATCH_CLOCK_STAMP = '.vx-watch-clock'
|
|
35
33
|
|
|
36
34
|
/** Anything the loop needs to shut down at exit. */
|
|
37
35
|
export interface WatchHandle {
|
|
@@ -104,20 +102,19 @@ export function pollWatcher(
|
|
|
104
102
|
if (e.name === WATCH_PROBE) continue
|
|
105
103
|
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
106
104
|
if (e.isDirectory()) {
|
|
107
|
-
if (recursive)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// raced with a delete; the next scan settles it
|
|
119
|
-
}
|
|
105
|
+
if (recursive && skipDir(childRel)) continue
|
|
106
|
+
// A directory is sampled for a name coming or going (a project's
|
|
107
|
+
// parent hears the project made again, one moved in names its
|
|
108
|
+
// files, whose times predate the arm), as the OS watcher reports
|
|
109
|
+
// it: by its identity, not its times, which every write inside
|
|
110
|
+
// moves.
|
|
111
|
+
try {
|
|
112
|
+
const st = fs.statSync(path.join(abs, e.name))
|
|
113
|
+
current.set(childRel, `dir:${st.dev}:${st.ino}:${st.birthtimeMs}`)
|
|
114
|
+
} catch {
|
|
115
|
+
continue // raced with a delete; the next scan settles it
|
|
120
116
|
}
|
|
117
|
+
if (recursive) walk(path.join(abs, e.name), childRel)
|
|
121
118
|
continue
|
|
122
119
|
}
|
|
123
120
|
// A link is an input as its target string (watch-judge.ts), so that
|
|
@@ -184,6 +181,8 @@ export function armWatcher(
|
|
|
184
181
|
recursive: boolean,
|
|
185
182
|
onEvent: (filename: string) => void,
|
|
186
183
|
timeoutMs = WATCH_PROBE_TIMEOUT_MS,
|
|
184
|
+
/** The poller's `skipDir`, asked on Linux of a directory under `node_modules` only. */
|
|
185
|
+
skipDir: (rel: string) => boolean = (rel) => isIgnoredDir(rel.split('/')),
|
|
187
186
|
): ArmedWatcher {
|
|
188
187
|
let markReady: (ok: boolean) => void = () => {}
|
|
189
188
|
let arrived = false
|
|
@@ -211,7 +210,7 @@ export function armWatcher(
|
|
|
211
210
|
}
|
|
212
211
|
const watcher: WatchHandle =
|
|
213
212
|
recursive && process.platform === 'linux'
|
|
214
|
-
? treeWatcher(dir, listener)
|
|
213
|
+
? treeWatcher(dir, listener, skipDir)
|
|
215
214
|
: fs.watch(dir, { recursive, persistent: true }, (_event, filename) => listener(filename))
|
|
216
215
|
const probe = path.join(dir, WATCH_PROBE)
|
|
217
216
|
const ready = (async (): Promise<boolean> => {
|
|
@@ -220,7 +219,7 @@ export function armWatcher(
|
|
|
220
219
|
// measured 2026-09-03, with every delivered event under 60 ms). So it is
|
|
221
220
|
// re-written on a short backoff until its event arrives — the first write
|
|
222
221
|
// after the stream goes live is the one that proves it.
|
|
223
|
-
const deadline =
|
|
222
|
+
const deadline = performance.now() + timeoutMs
|
|
224
223
|
let ok = false
|
|
225
224
|
let step = 50
|
|
226
225
|
while (!ok) {
|
|
@@ -229,7 +228,7 @@ export function armWatcher(
|
|
|
229
228
|
} catch {
|
|
230
229
|
break // an unwritable dir gets no proof; the watcher is kept
|
|
231
230
|
}
|
|
232
|
-
const remaining = deadline -
|
|
231
|
+
const remaining = deadline - performance.now()
|
|
233
232
|
if (remaining <= 0) {
|
|
234
233
|
// The write itself can outlast the budget (a loaded macOS runner:
|
|
235
234
|
// 261 ms against 100, E-87), and the loop has not yielded yet: let
|
|
@@ -267,8 +266,19 @@ export function armWatcher(
|
|
|
267
266
|
* inotify watch follows the inode and would report the old name. The
|
|
268
267
|
* arming walk throws at the watch limit, so the pool's fallback still
|
|
269
268
|
* applies; a subdirectory that vanished mid-walk is simply not watched.
|
|
269
|
+
* A `node_modules` is entered where `skipDir` keeps it: an input names a
|
|
270
|
+
* path there, or git tracks a file there (watch-filter.ts).
|
|
270
271
|
*/
|
|
271
|
-
function treeWatcher(
|
|
272
|
+
function treeWatcher(
|
|
273
|
+
root: string,
|
|
274
|
+
listener: (filename: string) => void,
|
|
275
|
+
skipDir: (rel: string) => boolean,
|
|
276
|
+
): WatchHandle {
|
|
277
|
+
const skips = (rel: string): boolean => {
|
|
278
|
+
const segments = rel.split('/')
|
|
279
|
+
if (isIgnoredDir(segments) && segments.at(-1) !== 'node_modules') return true
|
|
280
|
+
return segments.includes('node_modules') && skipDir(rel)
|
|
281
|
+
}
|
|
272
282
|
const watchers = new Map<string, fs.FSWatcher>()
|
|
273
283
|
// Which directory each watch holds: inotify follows the inode, so a name
|
|
274
284
|
// removed and made again before its event is handled stats as a
|
|
@@ -300,7 +310,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
|
|
|
300
310
|
if (name == null || typeof name !== 'string' || name === '' || name === '.') return
|
|
301
311
|
const child = rel === '' ? name : `${rel}/${name}`
|
|
302
312
|
listener(child)
|
|
303
|
-
if (
|
|
313
|
+
if (skips(child)) return
|
|
304
314
|
let st: fs.Stats
|
|
305
315
|
try {
|
|
306
316
|
st = fs.statSync(path.join(abs, name))
|
|
@@ -347,7 +357,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
|
|
|
347
357
|
for (const e of entries) {
|
|
348
358
|
const child = rel === '' ? e.name : `${rel}/${e.name}`
|
|
349
359
|
if (e.isDirectory()) {
|
|
350
|
-
if (!
|
|
360
|
+
if (!skips(child)) watchDir(child, report)
|
|
351
361
|
} else if (report) listener(child)
|
|
352
362
|
}
|
|
353
363
|
}
|
|
@@ -384,31 +394,6 @@ export function modifiedBefore(abs: string, t: number): boolean {
|
|
|
384
394
|
}
|
|
385
395
|
}
|
|
386
396
|
|
|
387
|
-
/**
|
|
388
|
-
* "Now" as the filesystem will stamp the next write, not as `Date.now()`
|
|
389
|
-
* reads it. A file's mtime comes from the kernel's coarse clock, which
|
|
390
|
-
* runs up to a tick behind the fine clock `Date.now()` reads — measured
|
|
391
|
-
* on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
|
|
392
|
-
* mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
|
|
393
|
-
* more under CPU load, where the tick is skipped. An `armedAt` from the
|
|
394
|
-
* fine clock then judged an edit made right after the ready line as
|
|
395
|
-
* "modified before the arm" and dropped it (the watch e2e flake, three
|
|
396
|
-
* shard runs that day; traced: raw event, trigger, `same=true`). A stamp
|
|
397
|
-
* read off a file written here is on the mtime clock itself, and every
|
|
398
|
-
* later write's mtime is at or after it.
|
|
399
|
-
*/
|
|
400
|
-
export function fsClockNow(dir: string): number {
|
|
401
|
-
const stamp = path.join(dir, WATCH_CLOCK_STAMP)
|
|
402
|
-
try {
|
|
403
|
-
fs.writeFileSync(stamp, '')
|
|
404
|
-
const at = fs.statSync(stamp).mtimeMs
|
|
405
|
-
fs.unlinkSync(stamp)
|
|
406
|
-
return at
|
|
407
|
-
} catch {
|
|
408
|
-
return Date.now()
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
397
|
/** A handle that watches nothing: a slot a drop emptied, an arm not made. */
|
|
413
398
|
export const CLOSED: WatchHandle = { close() {} }
|
|
414
399
|
|
|
@@ -460,7 +445,9 @@ export class WatcherPool {
|
|
|
460
445
|
}
|
|
461
446
|
let armed: ArmedWatcher
|
|
462
447
|
try {
|
|
463
|
-
armed = armWatcher(dir, recursive, onEvent)
|
|
448
|
+
armed = armWatcher(dir, recursive, onEvent, WATCH_PROBE_TIMEOUT_MS, (rel) =>
|
|
449
|
+
this.skip(dir, rel),
|
|
450
|
+
)
|
|
464
451
|
} catch (err) {
|
|
465
452
|
// The OS's watch limit, not the directory: the loop said "watching"
|
|
466
453
|
// and never fired. The poller needs no watch slot.
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import { filesBehind } from '../cache/index.js'
|
|
7
|
+
import { FILE_HASH_RACY_MS, filesBehind, racyWindowMs } from '../cache/index.js'
|
|
8
|
+
import { commandWindows, type CommandWindows } from '../orchestrator/index.js'
|
|
8
9
|
import { printable, xxh3 } from '../util/index.js'
|
|
9
10
|
import * as ui from '../ui/index.js'
|
|
10
11
|
import { gitIgnored, gitSpeller } from './watch-filter.js'
|
|
11
12
|
import { modifiedBefore } from './watch-fs.js'
|
|
12
13
|
|
|
13
14
|
const ABSENT = -1n
|
|
15
|
+
/** A state no judgement saw (`unseen`): equal to none, since a digest is never negative. */
|
|
16
|
+
const UNSEEN = -2n
|
|
14
17
|
/** `settledState` of a directory with no entries. */
|
|
15
18
|
const EMPTY_DIR = xxh3(Buffer.from(''))
|
|
16
19
|
|
|
@@ -108,6 +111,8 @@ export interface JudgeContext {
|
|
|
108
111
|
awaited?(abs: string): boolean
|
|
109
112
|
/** The files git listed at the arm; absent when git could not answer. */
|
|
110
113
|
existedAtArm?: ReadonlySet<string>
|
|
114
|
+
/** "Now" on the mtime clock (`fsClockNow`); `Date.now()` when absent. */
|
|
115
|
+
clock?(): number
|
|
111
116
|
/** The files git tracked at the arm; absent when git could not answer. */
|
|
112
117
|
trackedAtArm?: ReadonlySet<string>
|
|
113
118
|
}
|
|
@@ -128,6 +133,12 @@ export class ChangeJudge {
|
|
|
128
133
|
changed: readonly string[] = []
|
|
129
134
|
/** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
|
|
130
135
|
lastCycle: { start: number; end: number } | undefined
|
|
136
|
+
/**
|
|
137
|
+
* The cycle the last change-finding judgement started: from that
|
|
138
|
+
* judgement on the mtime clock, until the loop sets `to` when its run
|
|
139
|
+
* ends, with the run's command windows (`RunOptions.commandWindows`).
|
|
140
|
+
*/
|
|
141
|
+
cycle: { from: number; to: number; windows: CommandWindows } | undefined
|
|
131
142
|
// Declared outputs are ignored by PATH above. A task with no `cache`
|
|
132
143
|
// block declares none and still writes into its project, and the
|
|
133
144
|
// watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
|
|
@@ -169,6 +180,52 @@ export class ChangeJudge {
|
|
|
169
180
|
if (!this.lastState.has(abs)) this.lastState.set(abs, settledState(abs))
|
|
170
181
|
}
|
|
171
182
|
|
|
183
|
+
/**
|
|
184
|
+
* An event on `abs`. A write that lands after the judgement that started
|
|
185
|
+
* a cycle and before its run ends, where no command of the run may write
|
|
186
|
+
* (`CommandWindows.writerDuring`) and no server the loop holds may have,
|
|
187
|
+
* is the user's: the run may have keyed its bytes, so the path is a
|
|
188
|
+
* change at its next judgement whatever it settles to. Judged against
|
|
189
|
+
* the state that judgement recorded, an edit landing before the keys and
|
|
190
|
+
* put back while the cycle ran settled to "the same", and the outputs
|
|
191
|
+
* stood built from bytes the tree no longer held. A write inside a
|
|
192
|
+
* command's window is judged by state as before: a task rewriting a file
|
|
193
|
+
* with the same bytes every run is no change, and so is a user's write
|
|
194
|
+
* there, which the run cannot tell from its own. Read when the event
|
|
195
|
+
* comes, not at the judgement: the edit's own time, before a revert
|
|
196
|
+
* stamps over it. The stamp runs up to a tick behind `Date.now()`, which
|
|
197
|
+
* dates the windows: a task's write was dated before its window opened,
|
|
198
|
+
* and every such write re-ran the loop, so the window widens by the racy
|
|
199
|
+
* window, as the hash memo judges a stamp.
|
|
200
|
+
*/
|
|
201
|
+
sawEvent(abs: string): void {
|
|
202
|
+
const c = this.cycle
|
|
203
|
+
if (c === undefined) return
|
|
204
|
+
let t: number
|
|
205
|
+
try {
|
|
206
|
+
t = fs.lstatSync(abs).ctimeMs
|
|
207
|
+
} catch {
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (t < c.from || t > c.to) return
|
|
211
|
+
if (c.windows.writerDuring('', abs, t, t + racyWindowMs(t, FILE_HASH_RACY_MS)) !== undefined)
|
|
212
|
+
return
|
|
213
|
+
if (this.ctx.held() && !this.trackedAtArm(abs)) return
|
|
214
|
+
this.lastState.set(abs, UNSEEN)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The inputs a run read in a state no judgement saw (`TaskOutcome.driftedInput`):
|
|
219
|
+
* edited and put back while the task ran, a file settles to the state this
|
|
220
|
+
* loop last saw, and judged against it, the output built from the other
|
|
221
|
+
* state stood with no cycle after it. Each is a change at its next
|
|
222
|
+
* judgement; null (the input unnamed) makes every pending path one.
|
|
223
|
+
*/
|
|
224
|
+
unseen(moved: readonly (string | null)[]): void {
|
|
225
|
+
for (const abs of moved)
|
|
226
|
+
for (const p of abs === null ? [...this.pending.keys()] : [abs]) this.lastState.set(p, UNSEEN)
|
|
227
|
+
}
|
|
228
|
+
|
|
172
229
|
// A path this loop has never judged is a change only if it moved since
|
|
173
230
|
// the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
|
|
174
231
|
// AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
|
|
@@ -178,17 +235,18 @@ export class ChangeJudge {
|
|
|
178
235
|
// change: a deletion has no date to read.
|
|
179
236
|
// One exception: a path git did not list at the arm, and gone now, was
|
|
180
237
|
// born and removed since (vim's `4913` write probe, a tool's lock file),
|
|
181
|
-
// unless a nested repository holds it (`inNestedRepo`); it started a cycle with nothing changed.
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
238
|
+
// unless a nested repository holds it (`inNestedRepo`); it started a cycle with nothing changed. One born
|
|
239
|
+
// during a cycle where no command of it may write is a change all the
|
|
240
|
+
// same (`sawEvent`): the run may have read it. The blind spot left: one
|
|
241
|
+
// gone again before its event is read, which has no time to judge by.
|
|
242
|
+
// Git lists no ignored path, so one of those is a deletion as before.
|
|
185
243
|
private sameState(abs: string, ignored: boolean, spell: (p: string) => string): boolean {
|
|
186
244
|
const state = settledState(abs)
|
|
187
245
|
const prev = this.lastState.get(abs)
|
|
188
246
|
this.lastState.set(abs, state)
|
|
189
247
|
if (prev !== undefined) return prev === state
|
|
190
|
-
// A new empty directory holds nothing a key reads
|
|
191
|
-
//
|
|
248
|
+
// A new empty directory holds nothing a key reads: a file made in it
|
|
249
|
+
// is the edit.
|
|
192
250
|
// One a watch waits on (a project directory made again) is a change:
|
|
193
251
|
// its cycle re-arms the project.
|
|
194
252
|
if (state === EMPTY_DIR && isDirectory(abs) && this.ctx.awaited?.(abs) !== true) return true
|
|
@@ -241,6 +299,8 @@ export class ChangeJudge {
|
|
|
241
299
|
}
|
|
242
300
|
|
|
243
301
|
judge(): string | undefined {
|
|
302
|
+
// Before any state is read: a write after this is one the judgement may not have seen.
|
|
303
|
+
const at = this.ctx.clock?.() ?? Date.now()
|
|
244
304
|
const ignored = gitIgnored(this.ctx.workspaceRoot, [...this.pending.keys()])
|
|
245
305
|
const spell = gitSpeller(this.ctx.workspaceRoot)
|
|
246
306
|
let first: string | undefined
|
|
@@ -260,12 +320,15 @@ export class ChangeJudge {
|
|
|
260
320
|
// task rewrites every run, the loop the filter exists for) stays out,
|
|
261
321
|
// as does one in a project nested under it, which it does not own.
|
|
262
322
|
// So is one a task's inputs name outright (`.env.local`, `dist/**`).
|
|
323
|
+
// A write `sawEvent` found no command of the cycle could have made is
|
|
324
|
+
// the user's though it lies in the cycle: taken for the run's own, an
|
|
325
|
+
// edit to `node_modules/.gen/**` after its task ran never re-ran.
|
|
263
326
|
const editForUncached = (p: string): boolean =>
|
|
264
327
|
([...this.ctx.uncached()].some(
|
|
265
328
|
(dir) => p.startsWith(dir + path.sep) && this.ctx.fenced?.(dir, p) !== true,
|
|
266
329
|
) ||
|
|
267
330
|
(this.ctx.namedIgnored?.() ?? []).some((n) => p === n || p.startsWith(n + path.sep))) &&
|
|
268
|
-
!this.writtenDuringLastCycle(p)
|
|
331
|
+
(this.lastState.get(p) === UNSEEN || !this.writtenDuringLastCycle(p))
|
|
269
332
|
// A path still there names the cycle before a gone one: an editor or
|
|
270
333
|
// `sed -i` saving through a temporary file fires that file first, and
|
|
271
334
|
// the cycle was announced by a name already renamed away.
|
|
@@ -306,6 +369,7 @@ export class ChangeJudge {
|
|
|
306
369
|
// read its write; resetting on either, the storm ran on unnamed (macOS
|
|
307
370
|
// CI, 2026-10-29).
|
|
308
371
|
if (firstAbs === undefined) return named
|
|
372
|
+
this.cycle = { from: at, to: Infinity, windows: commandWindows() }
|
|
309
373
|
const byServer = this.ctx.held()
|
|
310
374
|
if (!this.writtenDuringLastCycle(firstAbs, spell)) {
|
|
311
375
|
this.streak.n = 0
|
|
@@ -319,7 +383,7 @@ export class ChangeJudge {
|
|
|
319
383
|
'warn',
|
|
320
384
|
byServer
|
|
321
385
|
? `vx watch: ${first} has started 3 cycles in a row, written while a server was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write starts a cycle.\n`
|
|
322
|
-
: `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
|
|
386
|
+
: `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 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`,
|
|
323
387
|
'out',
|
|
324
388
|
)
|
|
325
389
|
}
|
package/src/cli/watch-set.ts
CHANGED
|
@@ -26,8 +26,10 @@ import { type CliLoadOptions, loadCliProjects } from './workspace-config.js'
|
|
|
26
26
|
* per-project arm watched the filter's answer only, so
|
|
27
27
|
* `vx watch build --filter app` never re-ran on a `lib` edit that
|
|
28
28
|
* `vx run build --filter app` would have rebuilt. A whole-workspace
|
|
29
|
-
* scope is every project already and walks nothing.
|
|
30
|
-
*
|
|
29
|
+
* scope is every project already and walks nothing. When the edges cannot
|
|
30
|
+
* be read (a config the loader rejects, a dependency's directory removed)
|
|
31
|
+
* every project is watched: the scope alone dropped the dependencies a
|
|
32
|
+
* cycle runs, and nothing heard them again until a `vx lock`.
|
|
31
33
|
*/
|
|
32
34
|
export async function watchedProjects(
|
|
33
35
|
workspaceRoot: string,
|
|
@@ -37,14 +39,14 @@ export async function watchedProjects(
|
|
|
37
39
|
staged: ReadonlyMap<string, ProjectEntry> | null = null,
|
|
38
40
|
): Promise<ProjectMeta[]> {
|
|
39
41
|
if (scope.length === allProjects.length) return [...allProjects]
|
|
40
|
-
let edges: Map<string, string[]>
|
|
42
|
+
let edges: Map<string, string[]>
|
|
41
43
|
try {
|
|
42
44
|
edges =
|
|
43
45
|
staged !== null
|
|
44
46
|
? taskEdgesFrom(staged)
|
|
45
47
|
: (await taskEdges(workspaceRoot, allProjects, load)).edges
|
|
46
48
|
} catch {
|
|
47
|
-
|
|
49
|
+
return [...allProjects]
|
|
48
50
|
}
|
|
49
51
|
const graph = buildPackageGraph([...allProjects], edges)
|
|
50
52
|
const names = new Set(scope.map((p) => p.name))
|
|
@@ -132,8 +134,8 @@ export async function sweepConfigs(
|
|
|
132
134
|
}
|
|
133
135
|
const foldTasks = (dir: string, config: ProjectConfig, names: Set<string> | null): void => {
|
|
134
136
|
for (const [name, task] of Object.entries(config.tasks ?? {})) {
|
|
135
|
-
add(dir, task.
|
|
136
|
-
add(workspaceRoot, task.
|
|
137
|
+
add(dir, task.outputs?.files)
|
|
138
|
+
add(workspaceRoot, task.outputs?.workspaceFiles)
|
|
137
139
|
if (names !== null && !names.has(name)) continue
|
|
138
140
|
if (
|
|
139
141
|
task.inputs === undefined &&
|
|
@@ -147,8 +149,8 @@ export async function sweepConfigs(
|
|
|
147
149
|
for (const rel of named.workspaceFiles) namedIgnored.add(path.join(workspaceRoot, rel))
|
|
148
150
|
}
|
|
149
151
|
for (const g of task.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
150
|
-
addInputs(dir, task.inputs?.files, task.
|
|
151
|
-
addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.
|
|
152
|
+
addInputs(dir, task.inputs?.files, task.outputs?.files)
|
|
153
|
+
addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.outputs?.workspaceFiles)
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
|