@vzn/vx 0.0.650 → 0.0.652
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 +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/show.ts
CHANGED
|
@@ -13,17 +13,15 @@ import { isDefaultBuild } from '../graph/index.js'
|
|
|
13
13
|
import { affectedTaskProjects } from '../orchestrator/index.js'
|
|
14
14
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
15
15
|
import {
|
|
16
|
-
type ColorSupport,
|
|
17
|
-
detectColors,
|
|
18
16
|
listed,
|
|
19
17
|
nearMatches,
|
|
20
18
|
relPosix,
|
|
21
19
|
secretMask,
|
|
22
20
|
type SecretMask,
|
|
23
21
|
UserError,
|
|
24
|
-
sayNotice,
|
|
25
|
-
ui,
|
|
26
22
|
} from '../util/index.js'
|
|
23
|
+
import * as ui from '../ui/index.js'
|
|
24
|
+
import { sayNotice } from '../ui/index.js'
|
|
27
25
|
import { affectedFilterFor, resolveFilters } from './select.js'
|
|
28
26
|
import {
|
|
29
27
|
discoverCliProjects,
|
|
@@ -48,6 +46,8 @@ interface ShowArgs {
|
|
|
48
46
|
filters: string[]
|
|
49
47
|
/** `--affected[=<base>]`: `''` is the default base. */
|
|
50
48
|
affected?: string
|
|
49
|
+
/** The word after a bare `--affected`, read as the target: the base meant, when nothing is named so. */
|
|
50
|
+
spacedBase?: string
|
|
51
51
|
error?: string
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -67,8 +67,11 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
67
67
|
error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
|
|
68
68
|
}
|
|
69
69
|
out.filters.push(v)
|
|
70
|
-
} else if (a === '--affected')
|
|
71
|
-
|
|
70
|
+
} else if (a === '--affected') {
|
|
71
|
+
out.affected = ''
|
|
72
|
+
const next = args[i + 1]
|
|
73
|
+
if (next !== undefined && !next.startsWith('-')) out.spacedBase ??= next
|
|
74
|
+
} else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
|
|
72
75
|
else if (a.startsWith('-'))
|
|
73
76
|
return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
|
|
74
77
|
else if (out.target === 'project')
|
|
@@ -188,7 +191,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
188
191
|
`no projects: ${EMPTY_LOCK.charAt(0).toLowerCase()}${EMPTY_LOCK.slice(1)}`,
|
|
189
192
|
)
|
|
190
193
|
const shown = metas.filter((m) => inSelection(m.name))
|
|
191
|
-
|
|
194
|
+
emit(renderList(root, shown, projects, parsed.format))
|
|
192
195
|
return 0
|
|
193
196
|
}
|
|
194
197
|
|
|
@@ -206,10 +209,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
206
209
|
if (declares(task)) names.add(t)
|
|
207
210
|
// `nx show projects` lists them; here a bare `vx show` does.
|
|
208
211
|
const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
const unknown = `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`
|
|
213
|
+
const base =
|
|
214
|
+
parsed.spacedBase === projectName
|
|
215
|
+
? `${unknown.endsWith('.') ? '' : '.'} \`--affected\` takes its base with '=': --affected=${projectName}`
|
|
216
|
+
: ''
|
|
217
|
+
throw new UserError(`${unknown}${base}`, 'VX_E_UNKNOWN_TASK')
|
|
213
218
|
}
|
|
214
219
|
let shown = declaring.filter((p) => inSelection(p.name))
|
|
215
220
|
if (byDiff !== undefined) {
|
|
@@ -243,7 +248,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
243
248
|
: `no selected project declares ${projectName} (${n} outside the selection ${n === 1 ? 'does' : 'do'})`,
|
|
244
249
|
)
|
|
245
250
|
}
|
|
246
|
-
|
|
251
|
+
emit(renderTaskAcross(root, shown, projectName!, parsed.format))
|
|
247
252
|
return 0
|
|
248
253
|
}
|
|
249
254
|
|
|
@@ -253,9 +258,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
253
258
|
const dir = projectDir(root, meta)
|
|
254
259
|
|
|
255
260
|
if (taskName === undefined) {
|
|
256
|
-
|
|
257
|
-
renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format, colorsOut()),
|
|
258
|
-
)
|
|
261
|
+
emit(renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format))
|
|
259
262
|
return 0
|
|
260
263
|
}
|
|
261
264
|
|
|
@@ -266,7 +269,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
266
269
|
'VX_E_UNKNOWN_TASK',
|
|
267
270
|
)
|
|
268
271
|
}
|
|
269
|
-
|
|
272
|
+
emit(renderTask(meta.name, dir, taskName, task, parsed.format))
|
|
270
273
|
return 0
|
|
271
274
|
}
|
|
272
275
|
|
|
@@ -339,8 +342,13 @@ function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
|
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
344
|
|
|
342
|
-
|
|
343
|
-
|
|
345
|
+
/** What `vx show` prints: JSON a program reads, or blocks a person does. */
|
|
346
|
+
type Shown = string | readonly ui.Block[]
|
|
347
|
+
|
|
348
|
+
function emit(shown: Shown): void {
|
|
349
|
+
if (typeof shown === 'string') ui.data('out', shown)
|
|
350
|
+
else if (shown.length > 0) ui.print('out', ...shown)
|
|
351
|
+
}
|
|
344
352
|
|
|
345
353
|
/** One block of several, with no blank line between them. */
|
|
346
354
|
const joined = (blocks: readonly ui.Block[]): ui.Block => ({
|
|
@@ -352,8 +360,7 @@ function renderList(
|
|
|
352
360
|
metas: readonly ProjectMeta[],
|
|
353
361
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
354
362
|
format: 'pretty' | 'json',
|
|
355
|
-
|
|
356
|
-
): string {
|
|
363
|
+
): Shown {
|
|
357
364
|
const rows = metas.map((meta) => {
|
|
358
365
|
const tasks = projects.get(meta.name)?.config.tasks ?? {}
|
|
359
366
|
return {
|
|
@@ -368,7 +375,7 @@ function renderList(
|
|
|
368
375
|
if (format === 'json') {
|
|
369
376
|
return `${JSON.stringify(rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })))}\n`
|
|
370
377
|
}
|
|
371
|
-
if (rows.length === 0) return
|
|
378
|
+
if (rows.length === 0) return []
|
|
372
379
|
const anyTags = rows.some((r) => r.tags.length > 0)
|
|
373
380
|
const cells = rows.map((r) => {
|
|
374
381
|
const n = r.tasks.length
|
|
@@ -385,7 +392,7 @@ function renderList(
|
|
|
385
392
|
return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
|
|
386
393
|
})
|
|
387
394
|
const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
|
|
388
|
-
return ui.
|
|
395
|
+
return [ui.section('Projects', String(rows.length), ui.table(columns, cells))]
|
|
389
396
|
}
|
|
390
397
|
|
|
391
398
|
function renderProject(
|
|
@@ -394,8 +401,7 @@ function renderProject(
|
|
|
394
401
|
config: ProjectConfig | null,
|
|
395
402
|
hasConfigFile: boolean,
|
|
396
403
|
format: 'pretty' | 'json',
|
|
397
|
-
|
|
398
|
-
): string {
|
|
404
|
+
): Shown {
|
|
399
405
|
if (format === 'json') {
|
|
400
406
|
// Round-trip so the printed object is exactly the JSON form of the
|
|
401
407
|
// resolved config (drops `undefined` fields).
|
|
@@ -414,7 +420,7 @@ function renderProject(
|
|
|
414
420
|
const blocks = tasks.map(([taskName, task]) =>
|
|
415
421
|
joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
|
|
416
422
|
)
|
|
417
|
-
return
|
|
423
|
+
return [ui.section(name, ui.fields(head), ...blocks)]
|
|
418
424
|
}
|
|
419
425
|
|
|
420
426
|
function renderTask(
|
|
@@ -423,13 +429,12 @@ function renderTask(
|
|
|
423
429
|
taskName: string,
|
|
424
430
|
task: TaskConfig,
|
|
425
431
|
format: 'pretty' | 'json',
|
|
426
|
-
|
|
427
|
-
): string {
|
|
432
|
+
): Shown {
|
|
428
433
|
if (format === 'json') {
|
|
429
434
|
const obj = { name, dir, task: taskName, config: shownTask(task) }
|
|
430
435
|
return `${JSON.stringify(obj)}\n`
|
|
431
436
|
}
|
|
432
|
-
return
|
|
437
|
+
return [taskReport(name, dir, taskName, task)]
|
|
433
438
|
}
|
|
434
439
|
|
|
435
440
|
function renderTaskAcross(
|
|
@@ -437,8 +442,7 @@ function renderTaskAcross(
|
|
|
437
442
|
declaring: readonly ProjectEntry[],
|
|
438
443
|
taskName: string,
|
|
439
444
|
format: 'pretty' | 'json',
|
|
440
|
-
|
|
441
|
-
): string {
|
|
445
|
+
): Shown {
|
|
442
446
|
if (format === 'json') {
|
|
443
447
|
const list = declaring.map((p) => ({
|
|
444
448
|
name: p.name,
|
|
@@ -448,22 +452,14 @@ function renderTaskAcross(
|
|
|
448
452
|
}))
|
|
449
453
|
return `${JSON.stringify(list)}\n`
|
|
450
454
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
[
|
|
454
|
-
joined(
|
|
455
|
-
declaring.map((p) =>
|
|
456
|
-
taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
|
|
457
|
-
),
|
|
458
|
-
),
|
|
459
|
-
],
|
|
460
|
-
colors,
|
|
455
|
+
return declaring.map((p) =>
|
|
456
|
+
taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
|
|
461
457
|
)
|
|
462
458
|
}
|
|
463
459
|
|
|
464
|
-
/**
|
|
460
|
+
/** The `app#build` panel: the project's dir, then every field the run reads. */
|
|
465
461
|
function taskReport(name: string, dir: string, taskName: string, task: TaskConfig): ui.Block {
|
|
466
|
-
return ui.
|
|
462
|
+
return ui.section(`${name}#${taskName}`, ui.fields([['dir', dir], ...taskRows(task)]))
|
|
467
463
|
}
|
|
468
464
|
|
|
469
465
|
/** Every field the run reads, in the order the schema declares them. */
|
|
@@ -511,17 +507,14 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
|
|
|
511
507
|
add('inputs.tasks', list(inputs.tasks))
|
|
512
508
|
add('inputs.runtime', list(inputs.runtime))
|
|
513
509
|
add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
|
|
510
|
+
add('inputs.stability.strategy', inputs.stability?.strategy)
|
|
511
|
+
add('inputs.stability.severity', inputs.stability?.severity)
|
|
514
512
|
}
|
|
515
|
-
const
|
|
516
|
-
if (
|
|
517
|
-
add('outputs.files', list(
|
|
518
|
-
add('outputs.workspaceFiles', list(
|
|
519
|
-
add(
|
|
520
|
-
'outputs.clean',
|
|
521
|
-
cache.outputs.clean === undefined ? undefined : cache.outputs.clean ? 'yes' : 'no',
|
|
522
|
-
)
|
|
523
|
-
add('inputStability.strategy', cache.inputStability?.strategy)
|
|
524
|
-
add('inputStability.severity', cache.inputStability?.severity)
|
|
513
|
+
const outputs = task.outputs
|
|
514
|
+
if (outputs !== undefined) {
|
|
515
|
+
add('outputs.files', list(outputs.files))
|
|
516
|
+
add('outputs.workspaceFiles', list(outputs.workspaceFiles))
|
|
517
|
+
add('outputs.clean', outputs.clean === undefined ? undefined : outputs.clean ? 'yes' : 'no')
|
|
525
518
|
}
|
|
526
519
|
return rows
|
|
527
520
|
}
|
package/src/cli/task-verb.ts
CHANGED
|
@@ -84,6 +84,28 @@ export async function taskNamesHere(cwd: string): Promise<string[] | null> {
|
|
|
84
84
|
return [...names].sort()
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* "no projects declare task(s): …" for the bare names no project in the
|
|
89
|
+
* workspace declares, or undefined. A diff that matched nothing judged no
|
|
90
|
+
* name, so `vx run tset --affected` exited 0 as "nothing affected"
|
|
91
|
+
* (vercel/turborepo#12268). A workspace that does not load judges none.
|
|
92
|
+
*/
|
|
93
|
+
export async function undeclaredTasks(
|
|
94
|
+
tasks: readonly string[],
|
|
95
|
+
cwd: string,
|
|
96
|
+
): Promise<string | undefined> {
|
|
97
|
+
const projects = await workspaceProjects(cwd)
|
|
98
|
+
if (projects === null) return undefined
|
|
99
|
+
const declared = new Set<string>()
|
|
100
|
+
for (const p of projects.values())
|
|
101
|
+
for (const [t, task] of Object.entries(p.config.tasks ?? {}))
|
|
102
|
+
if (!isDefaultBuild(task)) declared.add(t)
|
|
103
|
+
const missing = tasks.filter((t) => !t.includes('#') && !declared.has(t))
|
|
104
|
+
if (missing.length === 0) return undefined
|
|
105
|
+
const near = [...new Set(missing.map((t) => nearest(t, declared)).filter((t) => t !== undefined))]
|
|
106
|
+
return `no projects declare task(s): ${missing.join(', ')}.${near.length > 0 ? ` Did you mean ${near.join(', ')}?` : ''}`
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
async function workspaceProjects(
|
|
88
110
|
cwd: string,
|
|
89
111
|
): Promise<Awaited<ReturnType<typeof loadCliProjects>> | null> {
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -22,7 +22,9 @@ import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/p
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import { flagHint, refuse, seeHelp } from './help.js'
|
|
24
24
|
import { resolveRegistry, type Registry } from './registry.js'
|
|
25
|
-
import { UserError
|
|
25
|
+
import { UserError } from '../util/index.js'
|
|
26
|
+
import * as ui from '../ui/index.js'
|
|
27
|
+
import { sayNotice } from '../ui/index.js'
|
|
26
28
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
27
29
|
import { VERSION } from '../version.js'
|
|
28
30
|
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import
|
|
7
|
+
import * as ui from '../ui/index.js'
|
|
8
8
|
|
|
9
9
|
/** Directories whose changes never trigger a re-run, wherever they appear. */
|
|
10
10
|
export const IGNORED_SEGMENTS = ['node_modules', '.git']
|
|
@@ -493,6 +493,11 @@ export class WatcherPool {
|
|
|
493
493
|
return handle
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
+
/** The watchers open now, each an OS watch or the poller in its slot. */
|
|
497
|
+
get open(): number {
|
|
498
|
+
return this.watchers.filter((w) => w !== CLOSED).length
|
|
499
|
+
}
|
|
500
|
+
|
|
496
501
|
/** Settles once every arm so far has proved delivery or fallen back. */
|
|
497
502
|
async proved(): Promise<void> {
|
|
498
503
|
await Promise.all(this.proofs)
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { filesBehind } from '../cache/index.js'
|
|
8
|
-
import { printable,
|
|
8
|
+
import { printable, xxh3 } from '../util/index.js'
|
|
9
|
+
import * as ui from '../ui/index.js'
|
|
9
10
|
import { gitIgnored, gitSpeller } from './watch-filter.js'
|
|
10
11
|
import { modifiedBefore } from './watch-fs.js'
|
|
11
12
|
|
|
@@ -318,7 +319,7 @@ export class ChangeJudge {
|
|
|
318
319
|
'warn',
|
|
319
320
|
byServer
|
|
320
321
|
? `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`
|
|
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
|
|
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 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
323
|
'out',
|
|
323
324
|
)
|
|
324
325
|
}
|
package/src/cli/watch-set.ts
CHANGED
|
@@ -132,8 +132,8 @@ export async function sweepConfigs(
|
|
|
132
132
|
}
|
|
133
133
|
const foldTasks = (dir: string, config: ProjectConfig, names: Set<string> | null): void => {
|
|
134
134
|
for (const [name, task] of Object.entries(config.tasks ?? {})) {
|
|
135
|
-
add(dir, task.
|
|
136
|
-
add(workspaceRoot, task.
|
|
135
|
+
add(dir, task.outputs?.files)
|
|
136
|
+
add(workspaceRoot, task.outputs?.workspaceFiles)
|
|
137
137
|
if (names !== null && !names.has(name)) continue
|
|
138
138
|
if (
|
|
139
139
|
task.inputs === undefined &&
|
|
@@ -147,8 +147,8 @@ export async function sweepConfigs(
|
|
|
147
147
|
for (const rel of named.workspaceFiles) namedIgnored.add(path.join(workspaceRoot, rel))
|
|
148
148
|
}
|
|
149
149
|
for (const g of task.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
150
|
-
addInputs(dir, task.inputs?.files, task.
|
|
151
|
-
addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.
|
|
150
|
+
addInputs(dir, task.inputs?.files, task.outputs?.files)
|
|
151
|
+
addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.outputs?.workspaceFiles)
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
|
package/src/cli/watch.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs'
|
|
16
16
|
import path from 'node:path'
|
|
17
|
-
import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
|
|
17
|
+
import { parseRunArgs, resolveRunOptions, type RunArgs, spacedHint } from './run.js'
|
|
18
18
|
import { refuse, seeHelp } from './help.js'
|
|
19
19
|
import {
|
|
20
20
|
fingerprintClaims,
|
|
@@ -45,14 +45,9 @@ import {
|
|
|
45
45
|
import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
46
46
|
import { ChangeJudge } from './watch-judge.js'
|
|
47
47
|
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
printable,
|
|
52
|
-
restartTimings,
|
|
53
|
-
sayNotice,
|
|
54
|
-
ui,
|
|
55
|
-
} from '../util/index.js'
|
|
48
|
+
import { hangupIgnored, maskedLine, printable, restartTimings } from '../util/index.js'
|
|
49
|
+
import * as ui from '../ui/index.js'
|
|
50
|
+
import { sayNotice } from '../ui/index.js'
|
|
56
51
|
import { sweepConfigs, watchedProjects } from './watch-set.js'
|
|
57
52
|
import { isCacheHit } from '../graph/index.js'
|
|
58
53
|
import { resolveOutputView } from '../report/index.js'
|
|
@@ -72,7 +67,7 @@ function sayAllCached(opts: RunOptions, outcomes: readonly { status: string }[])
|
|
|
72
67
|
const mode = resolveOutputView(opts).mode
|
|
73
68
|
if (mode !== 'broad' && mode !== 'errors-only' && mode !== 'none') return
|
|
74
69
|
const n = outcomes.length
|
|
75
|
-
|
|
70
|
+
ui.print('out', ui.line(' ', n === 1 ? '1 task' : `${n} tasks`, ' cached, nothing ran'))
|
|
76
71
|
}
|
|
77
72
|
|
|
78
73
|
/** Wait this long after the last filesystem event before re-running. */
|
|
@@ -129,7 +124,8 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
129
124
|
const cwd = process.cwd()
|
|
130
125
|
const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
|
|
131
126
|
if ('error' in resolved) {
|
|
132
|
-
|
|
127
|
+
const hint = resolved.code === 'VX_E_UNKNOWN_TASK' ? await spacedHint(parsed, cwd) : ''
|
|
128
|
+
sayNotice('error', 'vx watch', `${resolved.error}${hint}`)
|
|
133
129
|
return 1
|
|
134
130
|
}
|
|
135
131
|
if ('nothingSelected' in resolved) {
|
|
@@ -166,7 +162,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
166
162
|
// landed, the aborted cycle's summary printed below it.
|
|
167
163
|
const stopped = (): number => {
|
|
168
164
|
if (stop.signal.reason === 'SIGINT') {
|
|
169
|
-
|
|
165
|
+
ui.print('out', ui.gap())
|
|
170
166
|
ui.say('info', 'vx watch: stopped', 'out')
|
|
171
167
|
}
|
|
172
168
|
return 0
|
|
@@ -249,8 +245,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
249
245
|
// not (item 1017).
|
|
250
246
|
let refusedToStart = false
|
|
251
247
|
initial.held = runOrchestrator(initial.cycle.opts).then(
|
|
252
|
-
(run) => {
|
|
253
|
-
if (run.refused !== undefined)
|
|
248
|
+
async (run) => {
|
|
249
|
+
if (run.refused !== undefined) {
|
|
250
|
+
const hint = run.refused.startsWith('no projects declare')
|
|
251
|
+
? await spacedHint(parsed, cwd)
|
|
252
|
+
: ''
|
|
253
|
+
sayNotice('error', 'vx watch', `${run.refused}${hint}`)
|
|
254
|
+
}
|
|
254
255
|
sayAllCached(opts, run.outcomes)
|
|
255
256
|
// A run that failed having run nothing refused to start: a requested
|
|
256
257
|
// name no project declares (run() says which, with a "did you mean").
|
|
@@ -538,7 +539,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
538
539
|
const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
|
|
539
540
|
try {
|
|
540
541
|
let label: string | undefined = first
|
|
541
|
-
|
|
542
|
+
/** The lock this cycle's run read; none for the initial run. */
|
|
543
|
+
let lockAtRun: string | undefined
|
|
542
544
|
// The initial run is collected even after a stop: its server is the
|
|
543
545
|
// loop's to stop.
|
|
544
546
|
while (initial !== undefined || (label !== undefined && !stop.aborted)) {
|
|
@@ -558,13 +560,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
558
560
|
// cycle ran on with no workspace config, its plugins gone and
|
|
559
561
|
// its tasks named by path.
|
|
560
562
|
if (!hasWorkspaceFile()) {
|
|
561
|
-
|
|
563
|
+
ui.print('out', ui.gap())
|
|
564
|
+
ui.say('info', `changed ${label}`, 'out')
|
|
562
565
|
throw new Error(
|
|
563
566
|
`no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
|
|
564
567
|
)
|
|
565
568
|
}
|
|
566
569
|
if (reread) await rearm(false)
|
|
567
|
-
|
|
570
|
+
ui.print('out', ui.gap())
|
|
568
571
|
ui.say('info', `changed ${label}`, 'out')
|
|
569
572
|
if (!relocked) sayUnlocked(changes.changed)
|
|
570
573
|
if (stop.aborted) break
|
|
@@ -610,13 +613,6 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
610
613
|
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
611
614
|
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
612
615
|
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)
|
|
620
616
|
}
|
|
621
617
|
// After a failed cycle too: a package added with a config that does
|
|
622
618
|
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
@@ -625,6 +621,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
625
621
|
reread = false
|
|
626
622
|
await rearm()
|
|
627
623
|
}
|
|
624
|
+
// A re-lock since this cycle began: the re-arm can close the watcher
|
|
625
|
+
// its event was still on its way to (FSEvents' latency, a poller's
|
|
626
|
+
// tick), and the new lock ran nothing until the next edit (macOS CI,
|
|
627
|
+
// 2026-10-23). A cycle that read the lock mid-write failed on it, and
|
|
628
|
+
// is the same case. Asked after the re-arm, not when the run ends: a
|
|
629
|
+
// lock written in between was the re-arm's to read, its new pollers
|
|
630
|
+
// took it as their baseline, and it was never an event (macOS CI,
|
|
631
|
+
// 2026-11-06).
|
|
632
|
+
if (lockAtRun !== undefined && lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
|
|
628
633
|
// What landed mid-run is judged on settled state, one window
|
|
629
634
|
// after the run, under the label of what actually arrived.
|
|
630
635
|
current = undefined
|
|
@@ -738,6 +743,33 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
738
743
|
}
|
|
739
744
|
}
|
|
740
745
|
const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
|
|
746
|
+
// Bun keeps ONE FSEvents stream for every `fs.watch` of a process and
|
|
747
|
+
// rebuilds it, from "now", on each watch opened or closed: what lands
|
|
748
|
+
// while it is down is lost to every watch, the proved root stream
|
|
749
|
+
// included, and a close proves nothing after it. A re-arm that swapped
|
|
750
|
+
// `apps/`'s arms lost `apps/` made again, and the edit after it, for good
|
|
751
|
+
// (macOS CI, 2026-11-04). So inside the root the one stream serves the
|
|
752
|
+
// ancestor and import arms too, and a re-arm there opens or closes none.
|
|
753
|
+
/** Whether the one stream hears `dir`'s children: under the root by its real path. */
|
|
754
|
+
const inStream = (dir: string): boolean => {
|
|
755
|
+
if (!oneStream) return false
|
|
756
|
+
const rel = path.relative(workspaceRoot, dir)
|
|
757
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
758
|
+
try {
|
|
759
|
+
return fs.realpathSync(dir) === path.join(realRoot, rel)
|
|
760
|
+
} catch {
|
|
761
|
+
return false
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/** The ancestor and import directories the one stream serves (`inStream`). */
|
|
765
|
+
let streamedAncestors = new Set<string>()
|
|
766
|
+
let streamedImports = new Set<string>()
|
|
767
|
+
const heardInStream = (filename: string): void => {
|
|
768
|
+
const abs = path.join(workspaceRoot, filename)
|
|
769
|
+
const dir = path.dirname(abs)
|
|
770
|
+
if (streamedAncestors.has(dir)) ancestorEvent(dir, path.basename(abs))
|
|
771
|
+
if (streamedImports.has(dir)) importEvent(dir, path.basename(abs))
|
|
772
|
+
}
|
|
741
773
|
/** The projects the workspace-wide arm names an event by. */
|
|
742
774
|
let wide: readonly ProjectMeta[] = []
|
|
743
775
|
// The deepest, as a nested project's file is its own.
|
|
@@ -800,6 +832,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
800
832
|
* it from the outer), so each listener decides exactly as its own arm did.
|
|
801
833
|
*/
|
|
802
834
|
const streamEvent = (filename: string): void => {
|
|
835
|
+
heardInStream(filename)
|
|
803
836
|
const rel = filename.split(path.sep).join('/')
|
|
804
837
|
if (!rel.includes('/')) rootFileEvent(rel)
|
|
805
838
|
else if (rel.startsWith('.vx/') && rel.indexOf('/', 4) < 0) dotVxEvent(rel.slice(4))
|
|
@@ -819,6 +852,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
819
852
|
// the rest of the tree; the ignore filter then keeps node_modules
|
|
820
853
|
// / .git / .vx and declared outputs out of what remains.
|
|
821
854
|
arm(workspaceRoot, true, (filename) => {
|
|
855
|
+
if (oneStream) heardInStream(filename)
|
|
822
856
|
if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
|
|
823
857
|
const abs = path.join(workspaceRoot, filename)
|
|
824
858
|
if (
|
|
@@ -900,6 +934,17 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
900
934
|
'out',
|
|
901
935
|
)
|
|
902
936
|
}
|
|
937
|
+
const importEvent = (dir: string, filename: string): void => {
|
|
938
|
+
if (!(importNames.get(dir)?.has(filename) ?? false)) return
|
|
939
|
+
const abs = path.join(dir, filename)
|
|
940
|
+
const rel = path.relative(workspaceRoot, abs)
|
|
941
|
+
if (configImportFiles.includes(abs)) {
|
|
942
|
+
reread = true
|
|
943
|
+
trigger(`config import ${rel}`, abs)
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
sayStaleImport(abs)
|
|
947
|
+
}
|
|
903
948
|
const armImports = (): void => {
|
|
904
949
|
const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
|
|
905
950
|
const want = new Map<string, Set<string>>()
|
|
@@ -910,29 +955,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
910
955
|
want.set(path.dirname(f), names)
|
|
911
956
|
}
|
|
912
957
|
importNames = want
|
|
958
|
+
streamedImports = new Set([...want.keys()].filter(inStream))
|
|
913
959
|
for (const [dir, handle] of importArms) {
|
|
914
|
-
if (want.has(dir) && !stale(dir)) continue
|
|
960
|
+
if (want.has(dir) && !streamedImports.has(dir) && !stale(dir)) continue
|
|
915
961
|
handle.close()
|
|
916
962
|
importArms.delete(dir)
|
|
917
963
|
}
|
|
918
964
|
for (const dir of want.keys()) {
|
|
919
|
-
if (importArms.has(dir)) continue
|
|
965
|
+
if (importArms.has(dir) || streamedImports.has(dir)) continue
|
|
920
966
|
// Gone for now: its ancestor's arm (`armAncestors`) hears it return.
|
|
921
967
|
if (inodeOf(dir) === undefined) continue
|
|
922
968
|
try {
|
|
923
969
|
importArms.set(
|
|
924
970
|
dir,
|
|
925
|
-
arm(dir, false, (filename) =>
|
|
926
|
-
if (!(importNames.get(dir)?.has(filename) ?? false)) return
|
|
927
|
-
const abs = path.join(dir, filename)
|
|
928
|
-
const rel = path.relative(workspaceRoot, abs)
|
|
929
|
-
if (configImportFiles.includes(abs)) {
|
|
930
|
-
reread = true
|
|
931
|
-
trigger(`config import ${rel}`, abs)
|
|
932
|
-
return
|
|
933
|
-
}
|
|
934
|
-
sayStaleImport(abs)
|
|
935
|
-
}),
|
|
971
|
+
arm(dir, false, (filename) => importEvent(dir, filename)),
|
|
936
972
|
)
|
|
937
973
|
armedAs.set(dir, inodeOf(dir) ?? '')
|
|
938
974
|
} catch (err) {
|
|
@@ -1012,7 +1048,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1012
1048
|
}
|
|
1013
1049
|
const debugArms = resolveSetting('debug.watch', undefined).value === true
|
|
1014
1050
|
const sayArmed = (count: number): void => {
|
|
1015
|
-
if (debugArms)
|
|
1051
|
+
if (debugArms)
|
|
1052
|
+
ui.say(
|
|
1053
|
+
'info',
|
|
1054
|
+
`vx watch: armed ${plural(count, 'project')} (${pool.open} watch${pool.open === 1 ? '' : 'es'})`,
|
|
1055
|
+
)
|
|
1016
1056
|
}
|
|
1017
1057
|
|
|
1018
1058
|
const rearm = async (say = true): Promise<void> => {
|
|
@@ -1089,6 +1129,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1089
1129
|
// never heard `packages/` removed under `packages/a` (inotify did).
|
|
1090
1130
|
const ancestorArms = new Map<string, WatchHandle>()
|
|
1091
1131
|
let ancestorNames = new Map<string, Set<string>>()
|
|
1132
|
+
const ancestorEvent = (dir: string, filename: string): void => {
|
|
1133
|
+
// A project's parent's `.gitignore` shapes what its keys read, as the root's does.
|
|
1134
|
+
if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
|
|
1135
|
+
const abs = path.join(dir, filename)
|
|
1136
|
+
// A project directory still the one its arm holds is no change:
|
|
1137
|
+
// its arm judges what is in it. macOS reports the directory's
|
|
1138
|
+
// mtime here on every write at its top level, and a cycle's own
|
|
1139
|
+
// write started the next (solid's `types`, 2026-10-15).
|
|
1140
|
+
if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
|
|
1141
|
+
reread = true
|
|
1142
|
+
trigger(path.relative(workspaceRoot, abs), abs)
|
|
1143
|
+
}
|
|
1092
1144
|
/** Each project directory below the root as these arms first saw it (`inodeOf`). */
|
|
1093
1145
|
let heldDirs = new Map<string, string>()
|
|
1094
1146
|
const armAncestors = (): void => {
|
|
@@ -1119,28 +1171,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1119
1171
|
dir = path.dirname(dir)
|
|
1120
1172
|
}
|
|
1121
1173
|
}
|
|
1174
|
+
streamedAncestors = new Set([...ancestorNames.keys()].filter(inStream))
|
|
1122
1175
|
for (const [dir, handle] of ancestorArms) {
|
|
1123
|
-
if (ancestorNames.has(dir) && !stale(dir)) continue
|
|
1176
|
+
if (ancestorNames.has(dir) && !streamedAncestors.has(dir) && !stale(dir)) continue
|
|
1124
1177
|
handle.close()
|
|
1125
1178
|
ancestorArms.delete(dir)
|
|
1126
1179
|
}
|
|
1127
1180
|
for (const dir of ancestorNames.keys()) {
|
|
1128
|
-
if (ancestorArms.has(dir)) continue
|
|
1181
|
+
if (ancestorArms.has(dir) || streamedAncestors.has(dir)) continue
|
|
1129
1182
|
try {
|
|
1130
1183
|
ancestorArms.set(
|
|
1131
1184
|
dir,
|
|
1132
|
-
arm(dir, false, (filename) =>
|
|
1133
|
-
// A project's parent's `.gitignore` shapes what its keys read, as the root's does.
|
|
1134
|
-
if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
|
|
1135
|
-
const abs = path.join(dir, filename)
|
|
1136
|
-
// A project directory still the one its arm holds is no change:
|
|
1137
|
-
// its arm judges what is in it. macOS reports the directory's
|
|
1138
|
-
// mtime here on every write at its top level, and a cycle's own
|
|
1139
|
-
// write started the next (solid's `types`, 2026-10-15).
|
|
1140
|
-
if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
|
|
1141
|
-
reread = true
|
|
1142
|
-
trigger(path.relative(workspaceRoot, abs), abs)
|
|
1143
|
-
}),
|
|
1185
|
+
arm(dir, false, (filename) => ancestorEvent(dir, filename)),
|
|
1144
1186
|
)
|
|
1145
1187
|
armedAs.set(dir, inodeOf(dir) ?? '')
|
|
1146
1188
|
} catch (err) {
|