@vzn/vx 0.0.15 → 0.0.18
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 +91 -130
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +129 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +27 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/cli/watch.ts
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
listProjects,
|
|
22
22
|
loadProjectConfig,
|
|
23
23
|
loadWorkspace,
|
|
24
|
+
loadWorkspaceConfig,
|
|
25
|
+
resolveCacheDir,
|
|
26
|
+
WORKSPACE_FINGERPRINT_FILES,
|
|
24
27
|
type ProjectMeta,
|
|
25
28
|
} from '../workspace/index.js'
|
|
26
29
|
|
|
@@ -31,6 +34,144 @@ const DEBOUNCE_MS = 150
|
|
|
31
34
|
const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
|
|
32
35
|
const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* True for a path the ignore filter drops. Every project dir is watched
|
|
39
|
+
* RECURSIVELY, so without this a `bun install` would re-run the graph on every
|
|
40
|
+
* file it writes, and vx's own `.vx/cache` writes would trigger a cycle that
|
|
41
|
+
* writes to `.vx/cache` again.
|
|
42
|
+
*
|
|
43
|
+
* Segment-wise, not prefix-wise: `node_modules` is ignored wherever it appears
|
|
44
|
+
* in the path, not only at the root.
|
|
45
|
+
*/
|
|
46
|
+
export function isIgnoredWatchPath(rel: string): boolean {
|
|
47
|
+
const segments = rel.split(path.sep)
|
|
48
|
+
if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
|
|
49
|
+
if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The ignore predicate a watcher applies, closed over the run's ACTUAL cache
|
|
55
|
+
* directory.
|
|
56
|
+
*
|
|
57
|
+
* `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
|
|
58
|
+
* shipped `defineWorkspace` field: point it anywhere else and vx's own cache
|
|
59
|
+
* writes land in a watched subtree, so every cycle triggers the next one.
|
|
60
|
+
* Measured on a relocated cache under a recursive root watcher: ONE edit kicked
|
|
61
|
+
* it and the loop then ran **22 more times during 6 seconds of total silence**
|
|
62
|
+
* (~3.7 re-runs/second, forever) where the default `.vx` cache settles at 0.
|
|
63
|
+
* A hard-coded literal cannot see a configured path — the resolved one can.
|
|
64
|
+
*/
|
|
65
|
+
export function makeWatchIgnore(
|
|
66
|
+
cacheDir: string,
|
|
67
|
+
outputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
68
|
+
): (base: string, filename: string) => boolean {
|
|
69
|
+
const cacheAbs = path.resolve(cacheDir)
|
|
70
|
+
// A task's own outputs are not edits: without this every cycle that
|
|
71
|
+
// writes `dist/` (or `out.txt`) re-runs once more, reporting
|
|
72
|
+
// "up-to-date" for the trouble. Matched under the directory the globs
|
|
73
|
+
// are relative to, whichever watcher delivered the event.
|
|
74
|
+
const declared = [...outputs].map(
|
|
75
|
+
([dir, globs]) => [path.resolve(dir), globs.map((g) => new Bun.Glob(g))] as const,
|
|
76
|
+
)
|
|
77
|
+
return (base, filename) => {
|
|
78
|
+
if (isIgnoredWatchPath(filename)) return true
|
|
79
|
+
const abs = path.resolve(base, filename)
|
|
80
|
+
if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
|
|
81
|
+
for (const [dir, globs] of declared) {
|
|
82
|
+
if (!abs.startsWith(dir + path.sep)) continue
|
|
83
|
+
const rel = abs
|
|
84
|
+
.slice(dir.length + 1)
|
|
85
|
+
.split(path.sep)
|
|
86
|
+
.join('/')
|
|
87
|
+
if (globs.some((g) => g.match(rel))) return true
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The file `armWatcher` writes under a watched directory to prove the
|
|
95
|
+
* watcher delivers. Intercepted by name before any other handling, so it
|
|
96
|
+
* can never trigger a cycle, and removed before "watching" is printed.
|
|
97
|
+
*/
|
|
98
|
+
export const WATCH_PROBE = '.vx-watch-probe'
|
|
99
|
+
|
|
100
|
+
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
101
|
+
const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
102
|
+
|
|
103
|
+
export interface ArmedWatcher {
|
|
104
|
+
watcher: fs.FSWatcher
|
|
105
|
+
/** Resolves `true` once the watcher reported the probe, `false` on timeout. */
|
|
106
|
+
ready: Promise<boolean>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
111
|
+
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
112
|
+
* a change landing in that gap is never delivered — MEASURED 2026-09-03: a
|
|
113
|
+
* write made immediately after `fs.watch` was lost 5 times in 30 under CPU
|
|
114
|
+
* load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
|
|
115
|
+
* width, so no pause is the answer and no timeout on the waiting side ever
|
|
116
|
+
* was (the e2e flake this closes had one of 45 s). A probe file written
|
|
117
|
+
* under the watcher and waited for is: once ITS event arrives, the stream
|
|
118
|
+
* is live for everything after it.
|
|
119
|
+
*
|
|
120
|
+
* `onEvent` never sees the probe (create or unlink), and the probe is
|
|
121
|
+
* removed before `ready` resolves.
|
|
122
|
+
*/
|
|
123
|
+
export function armWatcher(
|
|
124
|
+
dir: string,
|
|
125
|
+
recursive: boolean,
|
|
126
|
+
onEvent: (filename: string) => void,
|
|
127
|
+
timeoutMs = WATCH_PROBE_TIMEOUT_MS,
|
|
128
|
+
): ArmedWatcher {
|
|
129
|
+
let markReady: (ok: boolean) => void = () => {}
|
|
130
|
+
const seen = new Promise<boolean>((resolve) => {
|
|
131
|
+
markReady = resolve
|
|
132
|
+
})
|
|
133
|
+
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
134
|
+
if (filename == null || typeof filename !== 'string') return
|
|
135
|
+
if (filename === WATCH_PROBE) {
|
|
136
|
+
markReady(true)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
onEvent(filename)
|
|
140
|
+
})
|
|
141
|
+
const probe = path.join(dir, WATCH_PROBE)
|
|
142
|
+
const ready = (async (): Promise<boolean> => {
|
|
143
|
+
// The probe is subject to the very race it detects: a write that lands
|
|
144
|
+
// in the gap is lost like any other (1 in 20 under a full gate's load,
|
|
145
|
+
// measured 2026-09-03, with every delivered event under 60 ms). So it is
|
|
146
|
+
// re-written on a short backoff until its event arrives — the first write
|
|
147
|
+
// after the stream goes live is the one that proves it.
|
|
148
|
+
const deadline = Date.now() + timeoutMs
|
|
149
|
+
let ok = false
|
|
150
|
+
let step = 50
|
|
151
|
+
while (!ok) {
|
|
152
|
+
try {
|
|
153
|
+
fs.writeFileSync(probe, String(Date.now()))
|
|
154
|
+
} catch {
|
|
155
|
+
break // an unwritable dir gets no proof; the watcher is kept
|
|
156
|
+
}
|
|
157
|
+
const remaining = deadline - Date.now()
|
|
158
|
+
if (remaining <= 0) break
|
|
159
|
+
const pause = new Promise<boolean>((resolve) => {
|
|
160
|
+
setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
|
|
161
|
+
})
|
|
162
|
+
ok = await Promise.race([seen, pause])
|
|
163
|
+
step = Math.min(step * 2, 400)
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
fs.unlinkSync(probe)
|
|
167
|
+
} catch {
|
|
168
|
+
// already gone
|
|
169
|
+
}
|
|
170
|
+
return ok
|
|
171
|
+
})()
|
|
172
|
+
return { watcher, ready }
|
|
173
|
+
}
|
|
174
|
+
|
|
34
175
|
export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
35
176
|
const parsed = parseRunArgs(args)
|
|
36
177
|
if (parsed.error) {
|
|
@@ -48,6 +189,15 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
48
189
|
)
|
|
49
190
|
return 1
|
|
50
191
|
}
|
|
192
|
+
// All three format ONE run's result and are consumed by `runCmd` alone, so
|
|
193
|
+
// a watch loop silently ignored them. `--verbosity 0` is not rejected: it
|
|
194
|
+
// asks for the output watch already gives.
|
|
195
|
+
if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
|
|
196
|
+
process.stderr.write(
|
|
197
|
+
`vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)\n`,
|
|
198
|
+
)
|
|
199
|
+
return 1
|
|
200
|
+
}
|
|
51
201
|
if (parsed.tasks.length === 0) {
|
|
52
202
|
process.stderr.write(`vx watch: missing task name\n`)
|
|
53
203
|
return 1
|
|
@@ -59,6 +209,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
59
209
|
process.stderr.write(`vx watch: ${resolved.error}\n`)
|
|
60
210
|
return 1
|
|
61
211
|
}
|
|
212
|
+
if ('nothingSelected' in resolved) {
|
|
213
|
+
process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
62
216
|
// The watch loop owns SIGINT/SIGTERM for its whole lifetime (the
|
|
63
217
|
// process.once handlers below close watchers and resolve 0). A
|
|
64
218
|
// cycle's run() must not install its exit-the-process handlers —
|
|
@@ -83,39 +237,75 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
83
237
|
}
|
|
84
238
|
|
|
85
239
|
// Initial run — same code path as `vx run`.
|
|
240
|
+
//
|
|
241
|
+
// Watchers do not exist yet, so an edit made WHILE this run is executing is
|
|
242
|
+
// dropped by the OS and never triggers a re-run. Known and deliberate: the
|
|
243
|
+
// obvious fix (install watchers first) forces `anyTaskUsesWorkspaceFiles`
|
|
244
|
+
// ahead of the run to decide which watchers to install, and that marks every
|
|
245
|
+
// config loaded — so the initial run's own loads become REPEATs and pay a
|
|
246
|
+
// worker round-trip each (see config-eval.ts). Trading a hot-path regression
|
|
247
|
+
// on every `vx watch` for a window a user rarely edits into is a bad deal;
|
|
248
|
+
// closing it properly needs the workspaceWide decision made without loading
|
|
249
|
+
// configs.
|
|
86
250
|
process.stdout.write('vx watch: initial run...\n\n')
|
|
87
251
|
await runOrchestrator(opts)
|
|
88
252
|
|
|
253
|
+
const swept = await sweepConfigs(allProjects, workspaceRoot)
|
|
89
254
|
return await runWatchLoop({
|
|
90
255
|
opts,
|
|
91
256
|
workspaceRoot,
|
|
92
257
|
projects: scope,
|
|
93
|
-
workspaceWide:
|
|
258
|
+
workspaceWide: swept.workspaceWide,
|
|
259
|
+
outputs: swept.outputs,
|
|
260
|
+
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
261
|
+
cacheDir:
|
|
262
|
+
opts.cacheDir ?? resolveCacheDir(workspaceRoot, await loadWorkspaceConfig(workspaceRoot)),
|
|
94
263
|
})
|
|
95
264
|
}
|
|
96
265
|
|
|
97
266
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* per-project watchers can't see all triggering paths
|
|
101
|
-
* switches to one recursive root watcher
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
267
|
+
* One sweep over every config for the two things the loop needs: whether
|
|
268
|
+
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
269
|
+
* boundary, so the per-project watchers can't see all triggering paths
|
|
270
|
+
* and the loop switches to one recursive root watcher — and each
|
|
271
|
+
* project's declared outputs, so their writes are not taken for edits.
|
|
272
|
+
* Checked across ALL projects (not just the scope) because dependsOn can
|
|
273
|
+
* pull tasks from anywhere; a broken out-of-scope config is skipped,
|
|
274
|
+
* matching scoped-run semantics (it surfaces when that project enters
|
|
275
|
+
* scope).
|
|
105
276
|
*/
|
|
106
|
-
async function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
} catch {
|
|
115
|
-
// broken config — out of this concern's scope
|
|
116
|
-
}
|
|
277
|
+
async function sweepConfigs(
|
|
278
|
+
projects: readonly ProjectMeta[],
|
|
279
|
+
workspaceRoot: string,
|
|
280
|
+
): Promise<{ workspaceWide: boolean; outputs: Map<string, string[]> }> {
|
|
281
|
+
const outputs = new Map<string, string[]>()
|
|
282
|
+
const add = (dir: string, globs: readonly string[] | undefined): void => {
|
|
283
|
+
if (globs === undefined || globs.length === 0) return
|
|
284
|
+
outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
|
|
117
285
|
}
|
|
118
|
-
|
|
286
|
+
// Concurrent, not sequential: the run that just happened already
|
|
287
|
+
// loaded the in-scope configs, so these are REPEAT loads that
|
|
288
|
+
// re-evaluate in a worker. Issuing them together lets one worker
|
|
289
|
+
// serve the whole sweep instead of one per project.
|
|
290
|
+
const uses = await Promise.all(
|
|
291
|
+
projects.map(async (p): Promise<boolean> => {
|
|
292
|
+
if (p.configPath === null) return false
|
|
293
|
+
try {
|
|
294
|
+
const config = await loadProjectConfig(p.configPath)
|
|
295
|
+
let wide = false
|
|
296
|
+
for (const task of Object.values(config.tasks ?? {})) {
|
|
297
|
+
if ((task.cache?.inputs?.workspaceFiles?.length ?? 0) > 0) wide = true
|
|
298
|
+
add(p.dir, task.cache?.outputs?.files)
|
|
299
|
+
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
300
|
+
}
|
|
301
|
+
return wide
|
|
302
|
+
} catch {
|
|
303
|
+
// broken config — out of this concern's scope
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
}),
|
|
307
|
+
)
|
|
308
|
+
return { workspaceWide: uses.includes(true), outputs }
|
|
119
309
|
}
|
|
120
310
|
|
|
121
311
|
interface WatchLoopArgs {
|
|
@@ -123,10 +313,14 @@ interface WatchLoopArgs {
|
|
|
123
313
|
workspaceRoot: string
|
|
124
314
|
projects: readonly ProjectMeta[]
|
|
125
315
|
workspaceWide: boolean
|
|
316
|
+
/** Absolute, already-resolved — the loop must never re-derive it. */
|
|
317
|
+
cacheDir: string
|
|
318
|
+
/** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
|
|
319
|
+
outputs: ReadonlyMap<string, readonly string[]>
|
|
126
320
|
}
|
|
127
321
|
|
|
128
322
|
async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
129
|
-
const { opts, workspaceRoot, projects, workspaceWide } = args
|
|
323
|
+
const { opts, workspaceRoot, projects, workspaceWide, cacheDir, outputs } = args
|
|
130
324
|
|
|
131
325
|
// Reentrancy guard — never two orchestrator runs in flight. While
|
|
132
326
|
// one is running, any further events set `pending = true` and the
|
|
@@ -175,15 +369,25 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
175
369
|
|
|
176
370
|
// Filter out events for paths we don't care about. We watch each
|
|
177
371
|
// project's dir recursively, so a `node_modules` write under a
|
|
178
|
-
// project would otherwise trigger every save during `bun install
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
|
|
182
|
-
if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
|
|
183
|
-
return false
|
|
184
|
-
}
|
|
372
|
+
// project would otherwise trigger every save during `bun install` —
|
|
373
|
+
// and vx's own cache writes would trigger a cycle that writes again.
|
|
374
|
+
const isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
|
|
185
375
|
|
|
186
376
|
const watchers: fs.FSWatcher[] = []
|
|
377
|
+
const proofs: Promise<void>[] = []
|
|
378
|
+
const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): void => {
|
|
379
|
+
const armed = armWatcher(dir, recursive, onEvent)
|
|
380
|
+
watchers.push(armed.watcher)
|
|
381
|
+
proofs.push(
|
|
382
|
+
armed.ready.then((ok) => {
|
|
383
|
+
if (!ok) {
|
|
384
|
+
process.stderr.write(
|
|
385
|
+
`vx watch: ${dir}: the watcher gave no sign of life within ${WATCH_PROBE_TIMEOUT_MS} ms; early edits there may be missed\n`,
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
}),
|
|
389
|
+
)
|
|
390
|
+
}
|
|
187
391
|
|
|
188
392
|
if (workspaceWide) {
|
|
189
393
|
// workspaceFiles inputs in play: any file in the workspace can be
|
|
@@ -192,16 +396,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
192
396
|
// ignore filter keeps node_modules / .git / .vx churn out; edits
|
|
193
397
|
// outside any task's inputs still cost only a cache-hit cycle.
|
|
194
398
|
try {
|
|
195
|
-
|
|
196
|
-
workspaceRoot,
|
|
197
|
-
{
|
|
198
|
-
|
|
199
|
-
if (filename == null || typeof filename !== 'string') return
|
|
200
|
-
if (isIgnoredPath(filename)) return
|
|
201
|
-
trigger(`root ${filename}`)
|
|
202
|
-
},
|
|
203
|
-
)
|
|
204
|
-
watchers.push(w)
|
|
399
|
+
arm(workspaceRoot, true, (filename) => {
|
|
400
|
+
if (isIgnoredPath(workspaceRoot, filename)) return
|
|
401
|
+
trigger(`root ${filename}`)
|
|
402
|
+
})
|
|
205
403
|
} catch (err) {
|
|
206
404
|
const msg = err instanceof Error ? err.message : String(err)
|
|
207
405
|
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
@@ -212,13 +410,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
212
410
|
// cover every project + node_modules + caches).
|
|
213
411
|
for (const proj of projects) {
|
|
214
412
|
try {
|
|
215
|
-
|
|
216
|
-
if (filename
|
|
217
|
-
if (typeof filename !== 'string') return
|
|
218
|
-
if (isIgnoredPath(filename)) return
|
|
413
|
+
arm(proj.dir, true, (filename) => {
|
|
414
|
+
if (isIgnoredPath(proj.dir, filename)) return
|
|
219
415
|
trigger(`${proj.name} ${filename}`)
|
|
220
416
|
})
|
|
221
|
-
watchers.push(w)
|
|
222
417
|
} catch (err) {
|
|
223
418
|
const msg = err instanceof Error ? err.message : String(err)
|
|
224
419
|
process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
|
|
@@ -229,15 +424,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
229
424
|
// pnpm-workspace.yaml edits trigger re-runs even when no project
|
|
230
425
|
// dir saw the change.
|
|
231
426
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
(_event, filename) => {
|
|
236
|
-
if (filename == null || typeof filename !== 'string') return
|
|
237
|
-
if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
|
|
238
|
-
},
|
|
239
|
-
)
|
|
240
|
-
watchers.push(w)
|
|
427
|
+
arm(workspaceRoot, false, (filename) => {
|
|
428
|
+
if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
|
|
429
|
+
})
|
|
241
430
|
} catch (err) {
|
|
242
431
|
const msg = err instanceof Error ? err.message : String(err)
|
|
243
432
|
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
@@ -248,6 +437,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
248
437
|
// re-runs because IGNORED_SEGMENTS includes `.vx`. Users who
|
|
249
438
|
// relocate the cache dir outside `.vx/` need their own filtering.
|
|
250
439
|
|
|
440
|
+
// "watching" is a promise that an edit from now on is seen; every
|
|
441
|
+
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
442
|
+
await Promise.all(proofs)
|
|
443
|
+
|
|
251
444
|
process.stdout.write(
|
|
252
445
|
workspaceWide
|
|
253
446
|
? `\nvx watch: watching the workspace root (workspaceFiles inputs in use); press Ctrl+C to stop\n`
|
|
@@ -274,14 +467,19 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
274
467
|
})
|
|
275
468
|
}
|
|
276
469
|
|
|
470
|
+
/**
|
|
471
|
+
* True for a root file that re-keys EVERY task, so a change to one must trigger
|
|
472
|
+
* a cycle even though it lives in no project dir.
|
|
473
|
+
*
|
|
474
|
+
* Reads the SHARED constant the fingerprint itself walks — this used to be a
|
|
475
|
+
* third hand-rolled copy of that list, which is precisely the drift the
|
|
476
|
+
* `--affected` wave exported the constant to prevent. A name added there but
|
|
477
|
+
* not here would re-key every task while `vx watch` silently never re-ran on
|
|
478
|
+
* it: the loop looks alive, and the one edit that invalidates the whole
|
|
479
|
+
* workspace is the one it ignores.
|
|
480
|
+
*/
|
|
481
|
+
const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
|
|
482
|
+
|
|
277
483
|
function isWorkspaceFingerprintFile(name: string): boolean {
|
|
278
|
-
return (
|
|
279
|
-
name === 'pnpm-lock.yaml' ||
|
|
280
|
-
name === 'package-lock.json' ||
|
|
281
|
-
name === 'npm-shrinkwrap.json' ||
|
|
282
|
-
name === 'yarn.lock' ||
|
|
283
|
-
name === 'bun.lock' ||
|
|
284
|
-
name === 'bun.lockb' ||
|
|
285
|
-
name === 'pnpm-workspace.yaml'
|
|
286
|
-
)
|
|
484
|
+
return FINGERPRINT_FILES.has(name)
|
|
287
485
|
}
|
package/src/cli/why.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `vx why [pkg#task | task]` — answer "why did this task re-run?" from the
|
|
2
|
+
// terminal. The same persisted data the MCP `whyDidThisRerun` tool reads:
|
|
3
|
+
// the runs table names the
|
|
4
|
+
// run-over-run hash change, and `entry_inputs` (the input-fingerprint moat)
|
|
5
|
+
// names the exact cache-key components that differ. Read-only over cache.db —
|
|
6
|
+
// no config evaluation, no re-hash.
|
|
7
|
+
|
|
8
|
+
import { Cache } from '../cache/index.js'
|
|
9
|
+
import { splitTaskId } from '../graph/index.js'
|
|
10
|
+
import {
|
|
11
|
+
cacheKeyDiff,
|
|
12
|
+
explainCacheKeyQuery as explainCacheKey,
|
|
13
|
+
whyDidThisRerunQuery as whyDidThisRerun,
|
|
14
|
+
} from '../orchestrator/index.js'
|
|
15
|
+
import { UserError } from '../util/index.js'
|
|
16
|
+
import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
|
|
17
|
+
|
|
18
|
+
interface WhyArgs {
|
|
19
|
+
target?: string
|
|
20
|
+
runId?: string
|
|
21
|
+
format: 'pretty' | 'json'
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
26
|
+
const out: WhyArgs = { format: 'pretty' }
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
const a = args[i]!
|
|
29
|
+
// Match on the flag NAME first. Reading the value into the same
|
|
30
|
+
// `undefined` that means "not this flag" conflated the two: a trailing
|
|
31
|
+
// `--run` (value omitted) consumed a non-existent argv slot, fell through,
|
|
32
|
+
// and was reported as `unknown flag: --run` — false, since `--run` is very
|
|
33
|
+
// much known, and silent about the real mistake. The `=` spelling of the
|
|
34
|
+
// same mistake already said `invalid --run: empty`, so one omitted value
|
|
35
|
+
// got two different diagnoses depending on how it was typed.
|
|
36
|
+
if (a === '--run' || a.startsWith('--run=')) {
|
|
37
|
+
const rv = a === '--run' ? args[++i] : a.slice(6)
|
|
38
|
+
if (rv === undefined || rv === '') return { ...out, error: 'invalid --run: empty' }
|
|
39
|
+
out.runId = rv
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (a === '--format' || a.startsWith('--format=')) {
|
|
43
|
+
const fv = a === '--format' ? args[++i] : a.slice(9)
|
|
44
|
+
if (fv !== 'pretty' && fv !== 'json') {
|
|
45
|
+
// An omitted value reads as empty, not as the literal word "undefined".
|
|
46
|
+
return {
|
|
47
|
+
...out,
|
|
48
|
+
error: `invalid --format: ${fv ?? ''} (expected pretty | json)`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
out.format = fv
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
|
|
55
|
+
if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
56
|
+
out.target = a
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Simple includes-match in both directions, case-insensitive. */
|
|
62
|
+
function suggest(query: string, candidates: readonly string[]): string {
|
|
63
|
+
const q = query.toLowerCase()
|
|
64
|
+
const hits = candidates.filter((c) => {
|
|
65
|
+
const n = c.toLowerCase()
|
|
66
|
+
return n.includes(q) || q.includes(n)
|
|
67
|
+
})
|
|
68
|
+
return hits.length > 0 ? ` — did you mean ${hits.slice(0, 3).join(', ')}?` : ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a positional target to a full `project#task` id against the runs
|
|
73
|
+
* table. A `pkg#task` form is used as-is; a bare task name matches every
|
|
74
|
+
* project that ran it — unique → resolved, several → error listing them.
|
|
75
|
+
*/
|
|
76
|
+
function resolveTarget(cache: Cache, target: string): string {
|
|
77
|
+
const db = cache.dbHandle()
|
|
78
|
+
const pairs = db
|
|
79
|
+
.query('SELECT DISTINCT project, task FROM runs ORDER BY project, task')
|
|
80
|
+
.all() as Array<{ project: string; task: string }>
|
|
81
|
+
const ids = pairs.map((p) => `${p.project}#${p.task}`)
|
|
82
|
+
if (target.includes('#')) {
|
|
83
|
+
if (ids.includes(target)) return target
|
|
84
|
+
throw new UserError(`vx why: no recorded runs for "${target}"${suggest(target, ids)}`)
|
|
85
|
+
}
|
|
86
|
+
const matches = ids.filter((id) => id.endsWith(`#${target}`))
|
|
87
|
+
if (matches.length === 1) return matches[0]!
|
|
88
|
+
if (matches.length > 1) {
|
|
89
|
+
throw new UserError(
|
|
90
|
+
`vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
|
|
91
|
+
matches.map((m) => ` ${m}`).join('\n'),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
throw new UserError(`vx why: no recorded runs for task "${target}"${suggest(target, ids)}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The latest recorded run of a task (run_id may be NULL on very old rows). */
|
|
98
|
+
function latestRunId(cache: Cache, taskId: string): string | null {
|
|
99
|
+
const [project, task] = splitTaskId(taskId)
|
|
100
|
+
const row = cache
|
|
101
|
+
.dbHandle()
|
|
102
|
+
.query(
|
|
103
|
+
`SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
|
|
104
|
+
ORDER BY started_at DESC LIMIT 1`,
|
|
105
|
+
)
|
|
106
|
+
.get(project, task) as { runId: string | null } | undefined
|
|
107
|
+
return row?.runId ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fmtWhen = (ms: number): string => new Date(ms).toISOString()
|
|
111
|
+
|
|
112
|
+
export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
113
|
+
const parsed = parseWhyArgs(args)
|
|
114
|
+
if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`)
|
|
115
|
+
if (parsed.target === undefined) {
|
|
116
|
+
throw new UserError('vx why: <task> required (e.g. vx why app#build, or vx why build)')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const root = await findWorkspaceRoot(process.cwd())
|
|
120
|
+
const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
|
|
121
|
+
const cache = new Cache(cacheDir)
|
|
122
|
+
try {
|
|
123
|
+
const db = cache.dbHandle()
|
|
124
|
+
const taskId = resolveTarget(cache, parsed.target)
|
|
125
|
+
const runId = parsed.runId ?? latestRunId(cache, taskId)
|
|
126
|
+
|
|
127
|
+
if (runId === null) {
|
|
128
|
+
// Runs exist (resolveTarget passed) but predate run ids — fall back to
|
|
129
|
+
// the persisted entry metadata so the verb still says something useful.
|
|
130
|
+
const explanation = explainCacheKey(db, taskId)
|
|
131
|
+
if (parsed.format === 'json') {
|
|
132
|
+
process.stdout.write(`${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
|
|
133
|
+
} else {
|
|
134
|
+
process.stdout.write(
|
|
135
|
+
`${taskId}: recorded runs carry no run id — showing the latest cache entry instead\n` +
|
|
136
|
+
(explanation.latestEntry !== null
|
|
137
|
+
? ` hash ${explanation.latestEntry.hash} · $ ${explanation.latestEntry.command}\n`
|
|
138
|
+
: ' (no cache entry either)\n'),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const why = whyDidThisRerun(db, runId, taskId)
|
|
145
|
+
if (!why.found) {
|
|
146
|
+
throw new UserError(`vx why: run ${runId} has no row for ${taskId}`)
|
|
147
|
+
}
|
|
148
|
+
const diff = cacheKeyDiff(db, runId, taskId)
|
|
149
|
+
|
|
150
|
+
if (parsed.format === 'json') {
|
|
151
|
+
process.stdout.write(`${JSON.stringify({ taskId, runId, why, diff })}\n`)
|
|
152
|
+
return 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const lines: string[] = []
|
|
156
|
+
const t = why.thisRun!
|
|
157
|
+
lines.push(`${taskId} — run ${runId}`)
|
|
158
|
+
lines.push(
|
|
159
|
+
` this run ${fmtWhen(t.startedAt)} · ${t.status}` +
|
|
160
|
+
`${t.cacheHit === true ? ' · cache hit' : t.cacheHit === false ? ' · executed' : ''} · key ${t.hash}`,
|
|
161
|
+
)
|
|
162
|
+
if (why.previousRun == null) {
|
|
163
|
+
lines.push(' previous (none — first recorded run of this task)')
|
|
164
|
+
} else {
|
|
165
|
+
const p = why.previousRun
|
|
166
|
+
lines.push(` previous ${fmtWhen(p.startedAt)} · ${p.status} · key ${p.hash}`)
|
|
167
|
+
lines.push(` verdict ${why.note}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (diff.entries.length > 0) {
|
|
171
|
+
lines.push('')
|
|
172
|
+
lines.push(
|
|
173
|
+
` what changed (${diff.entries.length} component${diff.entries.length === 1 ? '' : 's'}, ${diff.unchangedCount} unchanged):`,
|
|
174
|
+
)
|
|
175
|
+
const kindW = Math.max(...diff.entries.map((e) => e.kind.length), 4)
|
|
176
|
+
for (const e of diff.entries) {
|
|
177
|
+
const beforeAfter =
|
|
178
|
+
e.change === 'added'
|
|
179
|
+
? `+ ${e.after}`
|
|
180
|
+
: e.change === 'removed'
|
|
181
|
+
? `- ${e.before}`
|
|
182
|
+
: `${e.before} → ${e.after}`
|
|
183
|
+
lines.push(` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${e.name} ${beforeAfter}`)
|
|
184
|
+
}
|
|
185
|
+
} else if (why.hashChanged === true) {
|
|
186
|
+
lines.push(` detail ${diff.note}`)
|
|
187
|
+
}
|
|
188
|
+
process.stdout.write(`${lines.join('\n')}\n`)
|
|
189
|
+
return 0
|
|
190
|
+
} finally {
|
|
191
|
+
cache.close()
|
|
192
|
+
}
|
|
193
|
+
}
|