@vzn/vx 0.0.645 → 0.0.648
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 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
package/src/cli/watch-judge.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { filesBehind } from '../cache/index.js'
|
|
8
|
-
import { xxh3 } from '../util/index.js'
|
|
8
|
+
import { printable, ui, xxh3 } from '../util/index.js'
|
|
9
9
|
import { gitIgnored, gitSpeller } from './watch-filter.js'
|
|
10
10
|
import { modifiedBefore } from './watch-fs.js'
|
|
11
11
|
|
|
12
12
|
const ABSENT = -1n
|
|
13
|
+
/** `settledState` of a directory with no entries. */
|
|
14
|
+
const EMPTY_DIR = xxh3(Buffer.from(''))
|
|
13
15
|
|
|
14
16
|
function settledState(abs: string): bigint {
|
|
15
17
|
let st: fs.Stats
|
|
@@ -52,6 +54,14 @@ function settledState(abs: string): bigint {
|
|
|
52
54
|
return xxh3(Buffer.from(entries.join('\n')))
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
function isDirectory(abs: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
return fs.statSync(abs).isDirectory()
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
/**
|
|
56
66
|
* What a link points to, as change detection reads it: a file's bytes and
|
|
57
67
|
* mode, a directory's files by size and times (cache/linked-inputs.ts keys
|
|
@@ -93,6 +103,8 @@ export interface JudgeContext {
|
|
|
93
103
|
namedIgnored?(): readonly string[]
|
|
94
104
|
/** Whether `abs`, under the project at `ownDir`, lies in a project nested there (`makeFence`). */
|
|
95
105
|
fenced?(ownDir: string, abs: string): boolean
|
|
106
|
+
/** Whether a watch waits on `abs` itself: a project's directory, or one above it. */
|
|
107
|
+
awaited?(abs: string): boolean
|
|
96
108
|
/** The files git listed at the arm; absent when git could not answer. */
|
|
97
109
|
existedAtArm?: ReadonlySet<string>
|
|
98
110
|
/** The files git tracked at the arm; absent when git could not answer. */
|
|
@@ -111,6 +123,8 @@ export class ChangeJudge {
|
|
|
111
123
|
// are judged one window after the run ends, all together — an edit made
|
|
112
124
|
// meanwhile still differs from what the loop last saw and re-runs.
|
|
113
125
|
readonly pending = new Map<string, string>()
|
|
126
|
+
/** The paths the last judgement found changed. */
|
|
127
|
+
changed: readonly string[] = []
|
|
114
128
|
/** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
|
|
115
129
|
lastCycle: { start: number; end: number } | undefined
|
|
116
130
|
// Declared outputs are ignored by PATH above. A task with no `cache`
|
|
@@ -172,6 +186,11 @@ export class ChangeJudge {
|
|
|
172
186
|
const prev = this.lastState.get(abs)
|
|
173
187
|
this.lastState.set(abs, state)
|
|
174
188
|
if (prev !== undefined) return prev === state
|
|
189
|
+
// A new empty directory holds nothing a key reads, and the poller,
|
|
190
|
+
// sampling files, never reported one: a file made in it is the edit.
|
|
191
|
+
// One a watch waits on (a project directory made again) is a change:
|
|
192
|
+
// its cycle re-arms the project.
|
|
193
|
+
if (state === EMPTY_DIR && isDirectory(abs) && this.ctx.awaited?.(abs) !== true) return true
|
|
175
194
|
if (state === ABSENT) {
|
|
176
195
|
const listed = this.ctx.existedAtArm
|
|
177
196
|
return !ignored && listed !== undefined && !listed.has(spell(abs)) && !this.inNestedRepo(abs)
|
|
@@ -189,6 +208,11 @@ export class ChangeJudge {
|
|
|
189
208
|
return false
|
|
190
209
|
}
|
|
191
210
|
|
|
211
|
+
/** Whether git tracked `abs` when the loop armed; false when git could not answer. */
|
|
212
|
+
trackedAtArm(abs: string): boolean {
|
|
213
|
+
return this.ctx.trackedAtArm?.has(gitSpeller(this.ctx.workspaceRoot)(abs)) === true
|
|
214
|
+
}
|
|
215
|
+
|
|
192
216
|
/** `spell` (git's spelling, see `gitSpeller`) given: a held server's write counts outside a cycle too. */
|
|
193
217
|
private writtenDuringLastCycle(abs: string, spell?: (p: string) => string): boolean {
|
|
194
218
|
// A server the last cycle left running is still that cycle's: its
|
|
@@ -204,7 +228,7 @@ export class ChangeJudge {
|
|
|
204
228
|
try {
|
|
205
229
|
// The later of the two clocks: a cache hit restores an output with
|
|
206
230
|
// the mtime it was saved with, and read by mtime alone the restore
|
|
207
|
-
// was the user's edit and ran one more cycle (solid,
|
|
231
|
+
// was the user's edit and ran one more cycle (solid, 2026-10-15).
|
|
208
232
|
// The restore's write moves the ctime.
|
|
209
233
|
const st = fs.statSync(abs)
|
|
210
234
|
const m = Math.max(st.mtimeMs, st.ctimeMs)
|
|
@@ -220,6 +244,7 @@ export class ChangeJudge {
|
|
|
220
244
|
const spell = gitSpeller(this.ctx.workspaceRoot)
|
|
221
245
|
let first: string | undefined
|
|
222
246
|
let firstAbs: string | undefined
|
|
247
|
+
let firstIsFile = false
|
|
223
248
|
// `sameState` before the `first` test, never after it: it is what
|
|
224
249
|
// RECORDS a path's settled state, and every path this judgement saw
|
|
225
250
|
// must be recorded even though only the first change names the cycle.
|
|
@@ -248,25 +273,40 @@ export class ChangeJudge {
|
|
|
248
273
|
// leaves out the editor's temporary file renamed away.
|
|
249
274
|
let live = 0
|
|
250
275
|
let goneCount = 0
|
|
276
|
+
const changed: string[] = []
|
|
251
277
|
for (const [p, l] of this.pending) {
|
|
252
278
|
if (ignored.has(p) && !editForUncached(p)) continue
|
|
253
279
|
if (this.sameState(p, ignored.has(p), spell)) continue
|
|
280
|
+
changed.push(p)
|
|
254
281
|
if (!fs.existsSync(p)) {
|
|
255
282
|
goneCount++
|
|
256
283
|
gone ??= [l, p]
|
|
257
284
|
continue
|
|
258
285
|
}
|
|
259
286
|
live++
|
|
260
|
-
|
|
287
|
+
// A file names the cycle before a directory: `mkdir -p` and a write
|
|
288
|
+
// in it fire the directory first, and the cycle was named by it.
|
|
289
|
+
if (first !== undefined && (firstIsFile || isDirectory(p))) continue
|
|
261
290
|
first = l
|
|
262
291
|
firstAbs = p
|
|
292
|
+
firstIsFile = !isDirectory(p)
|
|
263
293
|
}
|
|
264
294
|
if (first === undefined && gone !== undefined) [first, firstAbs] = gone
|
|
295
|
+
// A path is anyone's bytes, and a file named `ESC ]0;…` printed raw
|
|
296
|
+
// in `changed …` retitled the terminal.
|
|
297
|
+
if (first !== undefined) first = printable(first)
|
|
265
298
|
this.pending.clear()
|
|
299
|
+
this.changed = changed
|
|
266
300
|
const more = (live > 0 ? live : goneCount) - 1
|
|
267
301
|
const named = more > 0 ? `${first} (+${more} more)` : first
|
|
268
|
-
|
|
269
|
-
|
|
302
|
+
// A judgement that found nothing starts no cycle, so it breaks no
|
|
303
|
+
// streak. A server's log stamped to the second rewrites the same bytes
|
|
304
|
+
// between its changes, and an event can land after the judgement that
|
|
305
|
+
// read its write; resetting on either, the storm ran on unnamed (macOS
|
|
306
|
+
// CI, 2026-10-29).
|
|
307
|
+
if (firstAbs === undefined) return named
|
|
308
|
+
const byServer = this.ctx.held()
|
|
309
|
+
if (!this.writtenDuringLastCycle(firstAbs, spell)) {
|
|
270
310
|
this.streak.n = 0
|
|
271
311
|
return named
|
|
272
312
|
}
|
|
@@ -274,10 +314,12 @@ export class ChangeJudge {
|
|
|
274
314
|
this.streak.abs = firstAbs
|
|
275
315
|
if (this.streak.n >= 3 && !this.noticed.has(firstAbs)) {
|
|
276
316
|
this.noticed.add(firstAbs)
|
|
277
|
-
|
|
317
|
+
ui.say(
|
|
318
|
+
'warn',
|
|
278
319
|
byServer
|
|
279
320
|
? `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`
|
|
280
321
|
: `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
|
|
322
|
+
'out',
|
|
281
323
|
)
|
|
282
324
|
}
|
|
283
325
|
return named
|
package/src/cli/watch.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from 'node:fs'
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
|
|
18
|
-
import { seeHelp } from './help.js'
|
|
18
|
+
import { refuse, seeHelp } from './help.js'
|
|
19
19
|
import {
|
|
20
20
|
fingerprintClaims,
|
|
21
21
|
forwardedSignal,
|
|
@@ -24,10 +24,17 @@ import {
|
|
|
24
24
|
type RunOptions,
|
|
25
25
|
shellQuote,
|
|
26
26
|
} from '../orchestrator/index.js'
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
findWorkspaceRoot,
|
|
29
|
+
LOCKFILE_NAME,
|
|
30
|
+
NO_LOCK,
|
|
31
|
+
type ProjectMeta,
|
|
32
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
33
|
+
} from '../workspace/index.js'
|
|
28
34
|
import { type CliLoadOptions, discoverCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
29
35
|
import { resolveSetting } from '../settings.js'
|
|
30
36
|
import {
|
|
37
|
+
isProjectConfigFile,
|
|
31
38
|
isWorkspaceConfigFile,
|
|
32
39
|
gitFiles,
|
|
33
40
|
makeFence,
|
|
@@ -38,14 +45,36 @@ import {
|
|
|
38
45
|
import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
39
46
|
import { ChangeJudge } from './watch-judge.js'
|
|
40
47
|
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
41
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
hangupIgnored,
|
|
50
|
+
maskedLine,
|
|
51
|
+
printable,
|
|
52
|
+
restartTimings,
|
|
53
|
+
sayNotice,
|
|
54
|
+
ui,
|
|
55
|
+
} from '../util/index.js'
|
|
42
56
|
import { sweepConfigs, watchedProjects } from './watch-set.js'
|
|
57
|
+
import { isCacheHit } from '../graph/index.js'
|
|
58
|
+
import { resolveOutputView } from '../report/index.js'
|
|
43
59
|
|
|
44
60
|
/** One line for a watcher or re-read the OS refused; the loop goes on without it. */
|
|
45
61
|
function sayCannot(what: string, err: unknown): void {
|
|
46
62
|
sayNotice('warn', 'vx watch', `${what}: ${err instanceof Error ? err.message : String(err)}`)
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
/**
|
|
66
|
+
* One line for a cycle every task of which was a cache hit, where hits
|
|
67
|
+
* print no row: watch prints no footer, so such a cycle said nothing, and
|
|
68
|
+
* a failure the cycle before stayed the last outcome on screen.
|
|
69
|
+
*/
|
|
70
|
+
function sayAllCached(opts: RunOptions, outcomes: readonly { status: string }[]): void {
|
|
71
|
+
if (outcomes.length === 0 || !outcomes.every((o) => isCacheHit(o.status))) return
|
|
72
|
+
const mode = resolveOutputView(opts).mode
|
|
73
|
+
if (mode !== 'broad' && mode !== 'errors-only' && mode !== 'none') return
|
|
74
|
+
const n = outcomes.length
|
|
75
|
+
process.stdout.write(` ${n === 1 ? '1 task' : `${n} tasks`} cached, nothing ran\n`)
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
/** Wait this long after the last filesystem event before re-running. */
|
|
50
79
|
const DEBOUNCE_MS = 150
|
|
51
80
|
/**
|
|
@@ -78,16 +107,16 @@ export function watchRefusal(parsed: RunArgs): string | null {
|
|
|
78
107
|
|
|
79
108
|
export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
80
109
|
const parsed = parseRunArgs(args, 'watch')
|
|
81
|
-
if (parsed.error) {
|
|
82
|
-
sayNotice('error', 'vx watch', `${parsed.error}${seeHelp('watch')}`)
|
|
83
|
-
return 1
|
|
84
|
-
}
|
|
110
|
+
if (parsed.error) return refuse('watch', args, `${parsed.error}${seeHelp('watch')}`, 'VX_E_USAGE')
|
|
85
111
|
|
|
86
112
|
const refused = watchRefusal(parsed)
|
|
87
|
-
if (refused !== null)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
if (refused !== null)
|
|
114
|
+
return refuse(
|
|
115
|
+
'watch',
|
|
116
|
+
args,
|
|
117
|
+
`${refused.replace(/^vx watch: /, '')}${seeHelp('watch')}`,
|
|
118
|
+
'VX_E_USAGE',
|
|
119
|
+
)
|
|
91
120
|
if (parsed.tasks.length === 0) {
|
|
92
121
|
sayNotice(
|
|
93
122
|
'error',
|
|
@@ -136,7 +165,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
136
165
|
// Said once the loop is down, the last line: written as the signal
|
|
137
166
|
// landed, the aborted cycle's summary printed below it.
|
|
138
167
|
const stopped = (): number => {
|
|
139
|
-
if (stop.signal.reason === 'SIGINT')
|
|
168
|
+
if (stop.signal.reason === 'SIGINT') {
|
|
169
|
+
process.stdout.write('\n')
|
|
170
|
+
ui.say('info', 'vx watch: stopped', 'out')
|
|
171
|
+
}
|
|
140
172
|
return 0
|
|
141
173
|
}
|
|
142
174
|
process.once('SIGTERM', () => stop.abort('SIGTERM'))
|
|
@@ -158,28 +190,33 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
158
190
|
// No lock yet is no project yet: the first cycle fails saying so, and
|
|
159
191
|
// the lock `vx lock` writes is the event that arms the set.
|
|
160
192
|
let unlocked = false
|
|
161
|
-
|
|
193
|
+
// The watch's own reads say no plugin warning: each cycle's run says it.
|
|
194
|
+
const quiet = (): void => {}
|
|
195
|
+
const stageLoad: CliLoadOptions = {
|
|
196
|
+
warn: quiet,
|
|
197
|
+
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
198
|
+
}
|
|
199
|
+
const allProjects = await discoverCliProjects(workspaceRoot, stageLoad).catch((err: unknown) => {
|
|
162
200
|
if (!(err instanceof Error) || err.message !== NO_LOCK) throw err
|
|
163
201
|
unlocked = true
|
|
164
202
|
return []
|
|
165
203
|
})
|
|
166
|
-
|
|
167
|
-
// it (prepare.ts, D-39): taken literally, no project was in scope and
|
|
168
|
-
// the watch exited 1 after a green run.
|
|
169
|
-
const rootDir = path.resolve(workspaceRoot)
|
|
170
|
-
const rootName = allProjects.find((p) => path.resolve(p.dir) === rootDir)?.name
|
|
171
|
-
const anchored = opts.tasks
|
|
172
|
-
.filter((t) => t.includes('#'))
|
|
173
|
-
.map((t) => t.slice(0, t.indexOf('#')))
|
|
174
|
-
.map((p) => (p === '//' && rootName !== undefined ? rootName : p))
|
|
204
|
+
const anchored = opts.tasks.filter((t) => t.includes('#')).map((t) => t.slice(0, t.indexOf('#')))
|
|
175
205
|
const named =
|
|
176
206
|
anchored.length === opts.tasks.length
|
|
177
207
|
? new Set(anchored)
|
|
178
208
|
: opts.projects === undefined
|
|
179
209
|
? undefined
|
|
180
210
|
: new Set([...opts.projects, ...anchored])
|
|
211
|
+
// `//#check` is the root project's task, under the name the run gives
|
|
212
|
+
// it (prepare.ts, D-39): taken literally, no project was in scope and
|
|
213
|
+
// the watch exited 1 after a green run. Resolved per discovery: started
|
|
214
|
+
// with no lock, the root has no name until `vx lock` writes one.
|
|
215
|
+
const rootDir = path.resolve(workspaceRoot)
|
|
181
216
|
const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
|
|
182
|
-
named === undefined
|
|
217
|
+
named === undefined
|
|
218
|
+
? [...all]
|
|
219
|
+
: all.filter((p) => named.has(p.name) || (named.has('//') && path.resolve(p.dir) === rootDir))
|
|
183
220
|
const scope = inScope(allProjects)
|
|
184
221
|
|
|
185
222
|
// Initial run — same code path as `vx run`.
|
|
@@ -214,6 +251,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
214
251
|
initial.held = runOrchestrator(initial.cycle.opts).then(
|
|
215
252
|
(run) => {
|
|
216
253
|
if (run.refused !== undefined) sayNotice('error', 'vx watch', run.refused)
|
|
254
|
+
sayAllCached(opts, run.outcomes)
|
|
217
255
|
// A run that failed having run nothing refused to start: a requested
|
|
218
256
|
// name no project declares (run() says which, with a "did you mean").
|
|
219
257
|
// `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
|
|
@@ -259,10 +297,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
259
297
|
delete opts.affected
|
|
260
298
|
delete opts.selectedOutright
|
|
261
299
|
|
|
262
|
-
const load
|
|
300
|
+
const load = stageLoad
|
|
263
301
|
const swept = await sweepConfigs(allProjects, workspaceRoot, load, opts.tasks)
|
|
264
302
|
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
265
|
-
const ws = await loadCliWorkspace(workspaceRoot)
|
|
303
|
+
const ws = await loadCliWorkspace(workspaceRoot, quiet)
|
|
266
304
|
const claimsOf = (plugins: Parameters<typeof fingerprintClaims>[0]): Set<string> =>
|
|
267
305
|
new Set([...fingerprintClaims(plugins).keys(), LOCKFILE_NAME])
|
|
268
306
|
await runWatchLoop({
|
|
@@ -288,7 +326,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
288
326
|
// set. The scope is the one resolved at start; a new project joins
|
|
289
327
|
// it only as a dependency of it.
|
|
290
328
|
rediscover: async () => {
|
|
291
|
-
const all = await discoverCliProjects(workspaceRoot)
|
|
329
|
+
const all = await discoverCliProjects(workspaceRoot, load)
|
|
292
330
|
const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
|
|
293
331
|
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
294
332
|
return {
|
|
@@ -296,7 +334,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
296
334
|
// A plugin the workspace config gained since claims its file from
|
|
297
335
|
// the cycle that loaded it; read once, its edits started nothing
|
|
298
336
|
// until a restart.
|
|
299
|
-
claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot)).plugins),
|
|
337
|
+
claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot, quiet)).plugins),
|
|
300
338
|
workspaceWide: sweep.workspaceWide,
|
|
301
339
|
workspaceInputs: sweep.workspaceInputs,
|
|
302
340
|
outputs: sweep.outputs,
|
|
@@ -420,6 +458,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
420
458
|
/** What stopped a cycle that waited on a server's readiness; the loop runs it next. */
|
|
421
459
|
let interruptedBy: string | undefined
|
|
422
460
|
let interruptTimer: ReturnType<typeof setTimeout> | null = null
|
|
461
|
+
/** The server's own writes among the paths that stopped the last cycle waiting on readiness. */
|
|
462
|
+
let stoppedLast = new Set<string>()
|
|
423
463
|
// Judged on settled state one window later, as an idle edit is, and only
|
|
424
464
|
// if the cycle still waits on readiness alone by then.
|
|
425
465
|
const interruptIfWaiting = (): void => {
|
|
@@ -431,12 +471,49 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
431
471
|
if (c === undefined || c.interrupted || !c.waitsOnReadiness() || stop.aborted) return
|
|
432
472
|
const label = changes.judge()
|
|
433
473
|
if (label === undefined) return
|
|
474
|
+
// What a server writes in its project as it boots (a timestamped
|
|
475
|
+
// config bundle, a route tree) is an edit: it stopped the cycle, the
|
|
476
|
+
// next server wrote it again, and the loop restarted it for good,
|
|
477
|
+
// never ready. A path that stopped the last such cycle, untracked
|
|
478
|
+
// and in a waiting server's project, is that server's own write.
|
|
479
|
+
const dirs = c.serverDirs()
|
|
480
|
+
const own = changes.changed.filter(
|
|
481
|
+
(p) => !changes.trackedAtArm(p) && dirs.some((d) => p.startsWith(d + path.sep)),
|
|
482
|
+
)
|
|
483
|
+
const again = own.length === changes.changed.length && own.every((p) => stoppedLast.has(p))
|
|
484
|
+
stoppedLast = new Set(own)
|
|
485
|
+
if (again) return
|
|
434
486
|
interruptedBy = label
|
|
435
487
|
c.interrupt()
|
|
436
488
|
}, DEBOUNCE_MS)
|
|
437
489
|
}
|
|
438
490
|
let windowOpened = 0
|
|
439
491
|
|
|
492
|
+
const hasWorkspaceFile = (): boolean =>
|
|
493
|
+
WORKSPACE_CONFIG_FILENAMES.some((n) => fs.existsSync(path.join(workspaceRoot, n)))
|
|
494
|
+
const lockAbs = path.join(workspaceRoot, LOCKFILE_NAME)
|
|
495
|
+
const lockStamp = (): string => {
|
|
496
|
+
try {
|
|
497
|
+
const st = fs.statSync(lockAbs)
|
|
498
|
+
return `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`
|
|
499
|
+
} catch {
|
|
500
|
+
return ''
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** The lock as the last cycle found it: a config edit with no re-lock since changes no command. */
|
|
504
|
+
let lockSeen = lockStamp()
|
|
505
|
+
// Every cycle's configs are the lock's, so a config edit alone ran a cycle
|
|
506
|
+
// that printed `changed` and nothing else, read as the edit applied. A
|
|
507
|
+
// config gone is the run's own refusal to name.
|
|
508
|
+
const sayUnlocked = (paths: readonly string[]): void => {
|
|
509
|
+
for (const abs of paths)
|
|
510
|
+
if ((isProjectConfigFile(abs) || configImportFiles.includes(abs)) && fs.existsSync(abs))
|
|
511
|
+
sayNotice(
|
|
512
|
+
'warn',
|
|
513
|
+
'vx watch',
|
|
514
|
+
`${printable(path.relative(workspaceRoot, abs))} changed; runs read the lock, so run \`vx lock\` to apply it`,
|
|
515
|
+
)
|
|
516
|
+
}
|
|
440
517
|
const trigger = (label: string, abs: string): void => {
|
|
441
518
|
if (!changes.pending.has(abs)) changes.pending.set(abs, label)
|
|
442
519
|
if (running) return interruptIfWaiting()
|
|
@@ -461,6 +538,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
461
538
|
const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
|
|
462
539
|
try {
|
|
463
540
|
let label: string | undefined = first
|
|
541
|
+
let lockAtRun = ''
|
|
464
542
|
// The initial run is collected even after a stop: its server is the
|
|
465
543
|
// loop's to stop.
|
|
466
544
|
while (initial !== undefined || (label !== undefined && !stop.aborted)) {
|
|
@@ -473,9 +551,31 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
473
551
|
// new package while its first cycle runs queues like any other; armed
|
|
474
552
|
// only after, it fell in the gap and ran nothing (X-138). The re-read
|
|
475
553
|
// after the run stays, for what the run itself changed.
|
|
554
|
+
lockAtRun = lockStamp()
|
|
555
|
+
const relocked = lockAtRun !== lockSeen
|
|
556
|
+
lockSeen = lockAtRun
|
|
557
|
+
// The file marks the root, and `vx run` refuses without it; a
|
|
558
|
+
// cycle ran on with no workspace config, its plugins gone and
|
|
559
|
+
// its tasks named by path.
|
|
560
|
+
if (!hasWorkspaceFile()) {
|
|
561
|
+
process.stdout.write(`\nchanged ${label}\n`)
|
|
562
|
+
throw new Error(
|
|
563
|
+
`no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
|
|
564
|
+
)
|
|
565
|
+
}
|
|
476
566
|
if (reread) await rearm(false)
|
|
477
|
-
process.stdout.write(
|
|
567
|
+
process.stdout.write('\n')
|
|
568
|
+
ui.say('info', `changed ${label}`, 'out')
|
|
569
|
+
if (!relocked) sayUnlocked(changes.changed)
|
|
478
570
|
if (stop.aborted) break
|
|
571
|
+
// A file a `.gitignore` edit stops ignoring is an input from this
|
|
572
|
+
// cycle on, and was not on the arm's inventory: its deletion read
|
|
573
|
+
// as a file born and gone since, and ran nothing.
|
|
574
|
+
if (
|
|
575
|
+
atArm !== undefined &&
|
|
576
|
+
changes.changed.some((p) => path.basename(p) === '.gitignore')
|
|
577
|
+
)
|
|
578
|
+
for (const p of gitFiles(workspaceRoot)?.listed ?? []) atArm.listed.add(p)
|
|
479
579
|
restartTimings()
|
|
480
580
|
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
481
581
|
// run made within a tick of it carried an earlier mtime and read as
|
|
@@ -491,6 +591,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
491
591
|
interruptIfWaiting,
|
|
492
592
|
)
|
|
493
593
|
const cycle = await runOrchestrator(current.opts)
|
|
594
|
+
sayAllCached(opts, cycle.outcomes)
|
|
494
595
|
lastTasks = current.tasks ?? lastTasks
|
|
495
596
|
current = undefined
|
|
496
597
|
held = cycle.persistent
|
|
@@ -509,11 +610,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
509
610
|
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
510
611
|
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
511
612
|
sayNotice('error', 'vx watch', `cycle failed: ${message}`)
|
|
613
|
+
} finally {
|
|
614
|
+
// A re-lock since this cycle began: the re-arm below can
|
|
615
|
+
// close the watcher its event was still on its way to (FSEvents'
|
|
616
|
+
// latency, a poller's tick), and the new lock ran nothing until
|
|
617
|
+
// the next edit (macOS CI, 2026-10-23). A cycle that read the
|
|
618
|
+
// lock mid-write failed on it, and is the same case.
|
|
619
|
+
if (lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
|
|
512
620
|
}
|
|
513
621
|
// After a failed cycle too: a package added with a config that does
|
|
514
622
|
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
515
623
|
// was never an event.
|
|
516
|
-
if (reread && !stop.aborted) {
|
|
624
|
+
if (reread && !stop.aborted && hasWorkspaceFile()) {
|
|
517
625
|
reread = false
|
|
518
626
|
await rearm()
|
|
519
627
|
}
|
|
@@ -521,6 +629,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
521
629
|
// after the run, under the label of what actually arrived.
|
|
522
630
|
current = undefined
|
|
523
631
|
if (stop.aborted) break
|
|
632
|
+
if (interruptedBy === undefined) stoppedLast = new Set()
|
|
524
633
|
if (interruptedBy !== undefined) {
|
|
525
634
|
label = interruptedBy
|
|
526
635
|
interruptedBy = undefined
|
|
@@ -561,7 +670,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
561
670
|
// unconditional segments AND this run's declared output containers. Read
|
|
562
671
|
// through `isIgnoredPath` rather than captured, because `rearm` replaces
|
|
563
672
|
// the filter when the selection changes.
|
|
564
|
-
const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel), args.poll)
|
|
673
|
+
const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel, true), args.poll)
|
|
565
674
|
const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
|
|
566
675
|
pool.arm(dir, recursive, onEvent)
|
|
567
676
|
|
|
@@ -575,6 +684,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
575
684
|
uncached: () => uncached,
|
|
576
685
|
namedIgnored: () => namedIgnored,
|
|
577
686
|
fenced: (ownDir, abs) => fenced(ownDir, abs),
|
|
687
|
+
awaited: (abs) => ancestorNames.get(path.dirname(abs))?.has(path.basename(abs)) === true,
|
|
578
688
|
...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
|
|
579
689
|
})
|
|
580
690
|
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
@@ -651,6 +761,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
651
761
|
// too: its edit can add an output or a `workspaceFiles` input.
|
|
652
762
|
if (shapesWatchedSet(filename) || configImportFiles.includes(abs) || linkChanged(abs))
|
|
653
763
|
reread = true
|
|
764
|
+
// Inside a project (any repo whose root is one) no import arm hears it.
|
|
765
|
+
if (wsConfigImportFiles.includes(abs)) sayStaleImport(abs)
|
|
654
766
|
trigger(`${proj.name} ${filename}`, abs)
|
|
655
767
|
}
|
|
656
768
|
const armProject = (proj: ProjectMeta): void => {
|
|
@@ -716,6 +828,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
716
828
|
configImportFiles.includes(abs)
|
|
717
829
|
)
|
|
718
830
|
reread = true
|
|
831
|
+
if (wsConfigImportFiles.includes(abs)) sayStaleImport(abs)
|
|
719
832
|
const owner = ownerOf(abs)
|
|
720
833
|
trigger(
|
|
721
834
|
owner === undefined
|
|
@@ -776,6 +889,17 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
776
889
|
const importArms = new Map<string, WatchHandle>()
|
|
777
890
|
let importNames = new Map<string, Set<string>>()
|
|
778
891
|
const staleNamed = new Map<string, number>()
|
|
892
|
+
const sayStaleImport = (abs: string): void => {
|
|
893
|
+
// One line per save, not per event: a save is often two.
|
|
894
|
+
const now = Date.now()
|
|
895
|
+
if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
|
|
896
|
+
staleNamed.set(abs, now)
|
|
897
|
+
ui.say(
|
|
898
|
+
'warn',
|
|
899
|
+
`vx watch: ${printable(path.relative(workspaceRoot, abs))} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
|
|
900
|
+
'out',
|
|
901
|
+
)
|
|
902
|
+
}
|
|
779
903
|
const armImports = (): void => {
|
|
780
904
|
const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
|
|
781
905
|
const want = new Map<string, Set<string>>()
|
|
@@ -807,13 +931,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
807
931
|
trigger(`config import ${rel}`, abs)
|
|
808
932
|
return
|
|
809
933
|
}
|
|
810
|
-
|
|
811
|
-
const now = Date.now()
|
|
812
|
-
if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
|
|
813
|
-
staleNamed.set(abs, now)
|
|
814
|
-
process.stdout.write(
|
|
815
|
-
`vx watch: ${rel} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
|
|
816
|
-
)
|
|
934
|
+
sayStaleImport(abs)
|
|
817
935
|
}),
|
|
818
936
|
)
|
|
819
937
|
armedAs.set(dir, inodeOf(dir) ?? '')
|
|
@@ -890,11 +1008,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
890
1008
|
const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
|
|
891
1009
|
const sayWatching = (count: number): void => {
|
|
892
1010
|
const tasks = lastTasks === undefined ? '' : ` · ${plural(lastTasks, 'task')}`
|
|
893
|
-
|
|
1011
|
+
ui.say('info', `watching ${plural(count, 'project')}${tasks}`, 'out')
|
|
894
1012
|
}
|
|
895
1013
|
const debugArms = resolveSetting('debug.watch', undefined).value === true
|
|
896
1014
|
const sayArmed = (count: number): void => {
|
|
897
|
-
if (debugArms)
|
|
1015
|
+
if (debugArms) ui.say('info', `vx watch: armed ${plural(count, 'project')}`)
|
|
898
1016
|
}
|
|
899
1017
|
|
|
900
1018
|
const rearm = async (say = true): Promise<void> => {
|
|
@@ -902,7 +1020,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
902
1020
|
try {
|
|
903
1021
|
next = await args.rediscover()
|
|
904
1022
|
} catch (err) {
|
|
905
|
-
|
|
1023
|
+
// No lock is the cycle's own failure to say; said here too, it was two lines.
|
|
1024
|
+
if (!(err instanceof Error && err.message === NO_LOCK))
|
|
1025
|
+
sayCannot(`cannot re-read the workspace`, err)
|
|
906
1026
|
return
|
|
907
1027
|
}
|
|
908
1028
|
projectDirs = next.projects.map((p) => p.dir)
|
|
@@ -931,7 +1051,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
931
1051
|
dropMode()
|
|
932
1052
|
workspaceWide = next.workspaceWide
|
|
933
1053
|
armMode(next.projects)
|
|
934
|
-
} else if (
|
|
1054
|
+
} else if (workspaceWide) wide = next.projects
|
|
1055
|
+
else {
|
|
935
1056
|
streamed = oneStream ? toStream(next.projects) : []
|
|
936
1057
|
const inStream = new Set(streamed.map((s) => s.proj))
|
|
937
1058
|
const own = next.projects.filter((p) => !inStream.has(p))
|
|
@@ -1015,7 +1136,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1015
1136
|
// A project directory still the one its arm holds is no change:
|
|
1016
1137
|
// its arm judges what is in it. macOS reports the directory's
|
|
1017
1138
|
// mtime here on every write at its top level, and a cycle's own
|
|
1018
|
-
// write started the next (solid's `types`,
|
|
1139
|
+
// write started the next (solid's `types`, 2026-10-15).
|
|
1019
1140
|
if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
|
|
1020
1141
|
reread = true
|
|
1021
1142
|
trigger(path.relative(workspaceRoot, abs), abs)
|