@vzn/vx 0.0.637 → 0.0.638
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/docs/caching.md +41 -14
- package/docs/cli.md +115 -73
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -5
- package/docs/schema.md +24 -15
- package/package.json +5 -5
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +13 -2
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +21 -2
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/index.ts +9 -1
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/run-history.ts +68 -0
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +38 -9
- package/src/cache/scoped-cache.ts +15 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +9 -5
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +6 -5
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +14 -7
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +5 -3
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +14 -12
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +66 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +20 -11
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +44 -20
- package/src/cli/why.ts +10 -1
- package/src/cli/workspace-config.ts +2 -2
- package/src/graph/scheduler.ts +8 -0
- package/src/graph/task-graph.ts +32 -4
- package/src/index.ts +11 -1
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +26 -43
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/framed-output.ts +1 -2
- package/src/orchestrator/history.ts +60 -4
- package/src/orchestrator/index.ts +2 -1
- package/src/orchestrator/logger.ts +9 -2
- package/src/orchestrator/metrics.ts +7 -3
- package/src/orchestrator/plugin-host.ts +14 -4
- package/src/orchestrator/plugin.ts +80 -8
- package/src/orchestrator/prepare.ts +51 -7
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +9 -5
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run.ts +126 -27
- package/src/orchestrator/status-line.ts +1 -1
- package/src/orchestrator/summary.ts +69 -44
- package/src/orchestrator/telemetry.ts +1 -1
- package/src/settings.ts +12 -1
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/index.ts +8 -1
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/ui.ts +186 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
package/src/cli/show.ts
CHANGED
|
@@ -11,7 +11,17 @@ import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
|
11
11
|
import { declaredTask } from '../graph/index.js'
|
|
12
12
|
import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
|
|
13
13
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type ColorSupport,
|
|
16
|
+
detectColors,
|
|
17
|
+
listed,
|
|
18
|
+
nearMatches,
|
|
19
|
+
relPosix,
|
|
20
|
+
secretMask,
|
|
21
|
+
UserError,
|
|
22
|
+
sayNotice,
|
|
23
|
+
ui,
|
|
24
|
+
} from '../util/index.js'
|
|
15
25
|
import { affectedFilterFor, resolveFilters } from './select.js'
|
|
16
26
|
import {
|
|
17
27
|
discoverCliProjects,
|
|
@@ -149,7 +159,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
149
159
|
}
|
|
150
160
|
const r = await resolveFilters(process.cwd(), raw, {}, affected)
|
|
151
161
|
if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
|
|
152
|
-
if ('empty' in r)
|
|
162
|
+
if ('empty' in r) sayNotice('info', 'vx show', r.empty)
|
|
153
163
|
selected = new Set('empty' in r ? [] : r.names)
|
|
154
164
|
if ('names' in r && r.affected !== undefined)
|
|
155
165
|
byDiff = { changes: r.affected, outright: r.outright }
|
|
@@ -160,7 +170,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
160
170
|
|
|
161
171
|
if (parsed.target === undefined) {
|
|
162
172
|
const shown = metas.filter((m) => inSelection(m.name))
|
|
163
|
-
process.stdout.write(renderList(root, shown, projects, parsed.format))
|
|
173
|
+
process.stdout.write(renderList(root, shown, projects, parsed.format, colorsOut()))
|
|
164
174
|
return 0
|
|
165
175
|
}
|
|
166
176
|
|
|
@@ -204,7 +214,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
204
214
|
)
|
|
205
215
|
shown = shown.filter((p) => kept.has(p.name))
|
|
206
216
|
}
|
|
207
|
-
process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
|
|
217
|
+
process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format, colorsOut()))
|
|
208
218
|
return 0
|
|
209
219
|
}
|
|
210
220
|
|
|
@@ -215,7 +225,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
215
225
|
|
|
216
226
|
if (taskName === undefined) {
|
|
217
227
|
process.stdout.write(
|
|
218
|
-
renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format),
|
|
228
|
+
renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format, colorsOut()),
|
|
219
229
|
)
|
|
220
230
|
return 0
|
|
221
231
|
}
|
|
@@ -227,7 +237,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
227
237
|
'VX_E_UNKNOWN_TASK',
|
|
228
238
|
)
|
|
229
239
|
}
|
|
230
|
-
process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
|
|
240
|
+
process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format, colorsOut()))
|
|
231
241
|
return 0
|
|
232
242
|
}
|
|
233
243
|
|
|
@@ -284,11 +294,20 @@ function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
|
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
296
|
|
|
297
|
+
const colorsOut = (): ColorSupport => detectColors(process.stdout)
|
|
298
|
+
const PLAIN: ColorSupport = { enabled: false }
|
|
299
|
+
|
|
300
|
+
/** One block of several, with no blank line between them. */
|
|
301
|
+
const joined = (blocks: readonly ui.Block[]): ui.Block => ({
|
|
302
|
+
lines: (c) => blocks.flatMap((b) => b.lines(c)),
|
|
303
|
+
})
|
|
304
|
+
|
|
287
305
|
function renderList(
|
|
288
306
|
root: string,
|
|
289
307
|
metas: readonly ProjectMeta[],
|
|
290
308
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
291
309
|
format: 'pretty' | 'json',
|
|
310
|
+
colors: ColorSupport = PLAIN,
|
|
292
311
|
): string {
|
|
293
312
|
const rows = metas.map((meta) => {
|
|
294
313
|
const tasks = projects.get(meta.name)?.config.tasks ?? {}
|
|
@@ -309,9 +328,8 @@ function renderList(
|
|
|
309
328
|
)}\n`
|
|
310
329
|
}
|
|
311
330
|
if (rows.length === 0) return ''
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
const lines = rows.map((r) => {
|
|
331
|
+
const anyTags = rows.some((r) => r.tags.length > 0)
|
|
332
|
+
const cells = rows.map((r) => {
|
|
315
333
|
const n = r.tasks.length
|
|
316
334
|
const count = `${n} task${n === 1 ? '' : 's'}`
|
|
317
335
|
// A package with no config file has only tasks a plugin gave it, or the
|
|
@@ -319,14 +337,14 @@ function renderList(
|
|
|
319
337
|
const tasks = r.configured
|
|
320
338
|
? count
|
|
321
339
|
: n === 0
|
|
322
|
-
? '
|
|
340
|
+
? 'no vx config'
|
|
323
341
|
: r.onlyDefault
|
|
324
|
-
? `${count} (no vx config
|
|
325
|
-
: `${count} (no vx config
|
|
326
|
-
|
|
327
|
-
return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}${tags}`
|
|
342
|
+
? `${count}, default build (no vx config)`
|
|
343
|
+
: `${count} from plugins (no vx config)`
|
|
344
|
+
return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
|
|
328
345
|
})
|
|
329
|
-
|
|
346
|
+
const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
|
|
347
|
+
return ui.render([ui.table(columns, cells)], colors)
|
|
330
348
|
}
|
|
331
349
|
|
|
332
350
|
function renderProject(
|
|
@@ -335,6 +353,7 @@ function renderProject(
|
|
|
335
353
|
config: ProjectConfig | null,
|
|
336
354
|
hasConfigFile: boolean,
|
|
337
355
|
format: 'pretty' | 'json',
|
|
356
|
+
colors: ColorSupport = PLAIN,
|
|
338
357
|
): string {
|
|
339
358
|
if (format === 'json') {
|
|
340
359
|
// Round-trip so the printed object is exactly the JSON form of the
|
|
@@ -344,14 +363,17 @@ function renderProject(
|
|
|
344
363
|
}
|
|
345
364
|
const tags = config?.tags ?? []
|
|
346
365
|
const tasks = Object.entries(config?.tasks ?? {})
|
|
347
|
-
const head
|
|
366
|
+
const head: ui.FieldRow[] = [
|
|
367
|
+
['dir', dir],
|
|
368
|
+
...(tags.length > 0 ? [['tags', tags.join(', ')] as const] : []),
|
|
348
369
|
// A config-less package a dependant reaches has the default `build` (X-9).
|
|
349
|
-
hasConfigFile
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
370
|
+
...(hasConfigFile ? [] : [['config', 'none'] as const]),
|
|
371
|
+
...(tasks.length === 0 && hasConfigFile ? [['tasks', 'none declared'] as const] : []),
|
|
372
|
+
]
|
|
373
|
+
const blocks = tasks.map(([taskName, task]) =>
|
|
374
|
+
joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
|
|
375
|
+
)
|
|
376
|
+
return ui.render([ui.report(name, head), ...blocks], colors)
|
|
355
377
|
}
|
|
356
378
|
|
|
357
379
|
function renderTask(
|
|
@@ -360,12 +382,13 @@ function renderTask(
|
|
|
360
382
|
taskName: string,
|
|
361
383
|
task: TaskConfig,
|
|
362
384
|
format: 'pretty' | 'json',
|
|
385
|
+
colors: ColorSupport = PLAIN,
|
|
363
386
|
): string {
|
|
364
387
|
if (format === 'json') {
|
|
365
388
|
const obj = { name, dir, task: taskName, config: shownTask(task) }
|
|
366
389
|
return `${JSON.stringify(obj)}\n`
|
|
367
390
|
}
|
|
368
|
-
return
|
|
391
|
+
return ui.render([taskReport(name, dir, taskName, task)], colors)
|
|
369
392
|
}
|
|
370
393
|
|
|
371
394
|
function renderTaskAcross(
|
|
@@ -373,6 +396,7 @@ function renderTaskAcross(
|
|
|
373
396
|
declaring: readonly ProjectEntry[],
|
|
374
397
|
taskName: string,
|
|
375
398
|
format: 'pretty' | 'json',
|
|
399
|
+
colors: ColorSupport,
|
|
376
400
|
): string {
|
|
377
401
|
if (format === 'json') {
|
|
378
402
|
const list = declaring.map((p) => ({
|
|
@@ -383,18 +407,28 @@ function renderTaskAcross(
|
|
|
383
407
|
}))
|
|
384
408
|
return `${JSON.stringify(list)}\n`
|
|
385
409
|
}
|
|
386
|
-
return
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
410
|
+
if (declaring.length === 0) return ''
|
|
411
|
+
return ui.render(
|
|
412
|
+
[
|
|
413
|
+
joined(
|
|
414
|
+
declaring.map((p) =>
|
|
415
|
+
taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
],
|
|
419
|
+
colors,
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** `─ app#build ───`, the project's dir, then every field the run reads. */
|
|
424
|
+
function taskReport(name: string, dir: string, taskName: string, task: TaskConfig): ui.Block {
|
|
425
|
+
return ui.report(`${name}#${taskName}`, [['dir', dir], ...taskRows(task)])
|
|
392
426
|
}
|
|
393
427
|
|
|
394
428
|
/** Every field the run reads, in the order the schema declares them. */
|
|
395
|
-
function
|
|
429
|
+
function taskRows(raw: TaskConfig): ui.FieldRow[] {
|
|
396
430
|
const task = shownTask(raw)
|
|
397
|
-
const rows: [
|
|
431
|
+
const rows: ui.FieldRow[] = []
|
|
398
432
|
const list = (xs: readonly string[] | undefined): string | undefined =>
|
|
399
433
|
xs === undefined ? undefined : xs.join(', ')
|
|
400
434
|
const add = (label: string, value: string | number | boolean | undefined): void => {
|
|
@@ -437,13 +471,5 @@ function taskBlock(taskName: string, raw: TaskConfig): string {
|
|
|
437
471
|
add('outputs.files', list(cache.outputs.files))
|
|
438
472
|
add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
|
|
439
473
|
}
|
|
440
|
-
|
|
441
|
-
// A multi-line value (a generated `vx_script` wrapper, a heredoc)
|
|
442
|
-
// continues under its first line, not at column 0 where it reads as
|
|
443
|
-
// the next task's header.
|
|
444
|
-
const indent = `\n${' '.repeat(labelW + 4)}`
|
|
445
|
-
const body = rows.map(
|
|
446
|
-
([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value.replaceAll('\n', indent)}`,
|
|
447
|
-
)
|
|
448
|
-
return `${taskName}\n${body.join('\n')}\n`
|
|
474
|
+
return rows
|
|
449
475
|
}
|
package/src/cli/task-verb.ts
CHANGED
|
@@ -21,7 +21,7 @@ export async function taskVerbHint(
|
|
|
21
21
|
cwd: string,
|
|
22
22
|
typo = false,
|
|
23
23
|
): Promise<string | null> {
|
|
24
|
-
if (command.includes('#')) return `\`${command}\` is a task
|
|
24
|
+
if (command.includes('#')) return `\`${command}\` is a task (did you mean vx run ${command}?)`
|
|
25
25
|
const projects = await workspaceProjects(cwd)
|
|
26
26
|
if (projects === null) return null
|
|
27
27
|
const tasks = new Set<string>()
|
|
@@ -40,8 +40,8 @@ export async function taskVerbHint(
|
|
|
40
40
|
? `vx run ${task} --all`
|
|
41
41
|
: `vx run ${task}`
|
|
42
42
|
return task === command
|
|
43
|
-
? `\`${command}\` is a task here, not a command
|
|
44
|
-
: `unknown command: ${command}
|
|
43
|
+
? `\`${command}\` is a task here, not a command (did you mean ${run}?)`
|
|
44
|
+
: `unknown command: ${command}, a typo of the task \`${task}\` (did you mean ${run}?)`
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -54,7 +54,7 @@ export async function nxTargetHint(tasks: readonly string[], cwd: string): Promi
|
|
|
54
54
|
if (projects === null) return null
|
|
55
55
|
const specs = tasks.map((t) => nxProjectTarget(t, projects))
|
|
56
56
|
if (specs.some((s) => s === undefined)) return null
|
|
57
|
-
return `\`${tasks.join(' ')}\` is Nx's project:target
|
|
57
|
+
return `\`${tasks.join(' ')}\` is Nx's project:target (did you mean vx run ${specs.join(' ')}?)`
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { createPublicKey, verify } from 'node:crypto'
|
|
|
18
18
|
import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
|
|
19
19
|
import path from 'node:path'
|
|
20
20
|
import { flagHint, seeHelp } from './help.js'
|
|
21
|
-
import { UserError } from '../util/index.js'
|
|
21
|
+
import { UserError, sayNotice, ui } from '../util/index.js'
|
|
22
22
|
import { VERSION } from '../version.js'
|
|
23
23
|
|
|
24
24
|
const REGISTRY = 'https://registry.npmjs.org'
|
|
@@ -522,7 +522,7 @@ async function targetVersion(tag: string | undefined): Promise<string> {
|
|
|
522
522
|
async function upgradeManaged(install: ManagedInstall, tag: string | undefined): Promise<number> {
|
|
523
523
|
const version = await targetVersion(tag)
|
|
524
524
|
if (version === VERSION) {
|
|
525
|
-
|
|
525
|
+
ui.print('out', ui.status('info', 'vx upgrade', `already at ${VERSION}`))
|
|
526
526
|
return 0
|
|
527
527
|
}
|
|
528
528
|
const argv = upgradeArgv(install, version)
|
|
@@ -532,8 +532,10 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
|
|
|
532
532
|
`vx upgrade: this vx was installed by ${argv[0]}, which is not on PATH — vx does not install package managers; run it yourself: ${argv.join(' ')} (in ${install.cwd})`,
|
|
533
533
|
)
|
|
534
534
|
}
|
|
535
|
-
|
|
536
|
-
|
|
535
|
+
ui.print(
|
|
536
|
+
'out',
|
|
537
|
+
ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
|
|
538
|
+
ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
|
|
537
539
|
)
|
|
538
540
|
const proc = Bun.spawnSync({
|
|
539
541
|
cmd: [bin, ...argv.slice(1)],
|
|
@@ -551,8 +553,10 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
|
|
|
551
553
|
export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
552
554
|
const unknown = args.find((a) => a.startsWith('-'))
|
|
553
555
|
if (unknown !== undefined) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
+
sayNotice(
|
|
557
|
+
'error',
|
|
558
|
+
'vx upgrade',
|
|
559
|
+
`unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}`,
|
|
556
560
|
)
|
|
557
561
|
return 1
|
|
558
562
|
}
|
|
@@ -560,7 +564,7 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
560
564
|
// the first one went in (`vx upgrade v0.0.22 v0.0.23`).
|
|
561
565
|
const [tag, extra] = args
|
|
562
566
|
if (extra !== undefined) {
|
|
563
|
-
|
|
567
|
+
sayNotice('error', 'vx upgrade', `unexpected argument: ${extra}${seeHelp('upgrade')}`)
|
|
564
568
|
return 1
|
|
565
569
|
}
|
|
566
570
|
if (!isCompiledBinary()) {
|
|
@@ -579,7 +583,11 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
579
583
|
dest,
|
|
580
584
|
() =>
|
|
581
585
|
releaseBinary(pkg, spec, (version) =>
|
|
582
|
-
|
|
586
|
+
ui.print(
|
|
587
|
+
'out',
|
|
588
|
+
ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
|
|
589
|
+
ui.text(ui.dim(dest)),
|
|
590
|
+
),
|
|
583
591
|
),
|
|
584
592
|
// The replaced binary's own version: the new build speaks for itself
|
|
585
593
|
// rather than this process guessing, and one that cannot is rolled back.
|
|
@@ -588,10 +596,11 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
588
596
|
return installed !== ''
|
|
589
597
|
},
|
|
590
598
|
)
|
|
591
|
-
|
|
599
|
+
ui.print(
|
|
600
|
+
'out',
|
|
592
601
|
replaced
|
|
593
|
-
?
|
|
594
|
-
:
|
|
602
|
+
? ui.status('ok', 'vx upgrade', `installed ${installed.replace(/^vx /, '')}`)
|
|
603
|
+
: ui.status('info', 'vx upgrade', `already at ${VERSION}`),
|
|
595
604
|
)
|
|
596
605
|
return 0
|
|
597
606
|
}
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { asTrees } from '../cache/index.js'
|
|
|
17
17
|
import { PROJECT_CONFIG_FILENAMES, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
|
|
18
18
|
import { isIgnoredDir } from './watch-fs.js'
|
|
19
19
|
|
|
20
|
-
const IGNORED_SUFFIXES = ['
|
|
20
|
+
const IGNORED_SUFFIXES = ['~']
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* True for a path the ignore filter drops. Every project dir is watched
|
|
@@ -32,8 +32,13 @@ export function isIgnoredWatchPath(rel: string): boolean {
|
|
|
32
32
|
const segments = rel.split(path.sep)
|
|
33
33
|
for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
|
|
34
34
|
if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
|
|
35
|
+
const name = segments[segments.length - 1]!
|
|
36
|
+
// A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
|
|
37
|
+
// renamed away before the judgement, it read as a deletion and ran one
|
|
38
|
+
// more cycle where an uncached task makes ignored paths count.
|
|
39
|
+
if (name.startsWith('.vx-tmp-')) return true
|
|
35
40
|
// `vx lock`'s review copy: no input, so a re-lock is no cycle.
|
|
36
|
-
return
|
|
41
|
+
return name === 'vx.lock.json'
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/**
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -161,7 +161,12 @@ export class ChangeJudge {
|
|
|
161
161
|
spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
|
|
162
162
|
if (this.lastCycle === undefined && !open) return false
|
|
163
163
|
try {
|
|
164
|
-
|
|
164
|
+
// The later of the two clocks: a cache hit restores an output with
|
|
165
|
+
// the mtime it was saved with, and read by mtime alone the restore
|
|
166
|
+
// was the user's edit and ran one more cycle (solid, M 2026-10-15).
|
|
167
|
+
// The restore's write moves the ctime.
|
|
168
|
+
const st = fs.statSync(abs)
|
|
169
|
+
const m = Math.max(st.mtimeMs, st.ctimeMs)
|
|
165
170
|
const start = this.lastCycle?.start ?? this.ctx.armedAt
|
|
166
171
|
return m >= start && (open || m <= this.lastCycle!.end)
|
|
167
172
|
} catch {
|
package/src/cli/watch.ts
CHANGED
|
@@ -38,12 +38,12 @@ import {
|
|
|
38
38
|
import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
39
39
|
import { ChangeJudge } from './watch-judge.js'
|
|
40
40
|
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
41
|
-
import { hangupIgnored, maskedLine, restartTimings } from '../util/index.js'
|
|
41
|
+
import { hangupIgnored, maskedLine, restartTimings, sayNotice, sayLine } from '../util/index.js'
|
|
42
42
|
import { sweepConfigs, watchedProjects } from './watch-set.js'
|
|
43
43
|
|
|
44
44
|
/** One line for a watcher or re-read the OS refused; the loop goes on without it. */
|
|
45
45
|
function sayCannot(what: string, err: unknown): void {
|
|
46
|
-
|
|
46
|
+
sayNotice('warn', 'vx watch', `${what}: ${err instanceof Error ? err.message : String(err)}`)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** Wait this long after the last filesystem event before re-running. */
|
|
@@ -84,18 +84,20 @@ export function watchRefusal(parsed: RunArgs): string | null {
|
|
|
84
84
|
export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
85
85
|
const parsed = parseRunArgs(args, 'watch')
|
|
86
86
|
if (parsed.error) {
|
|
87
|
-
|
|
87
|
+
sayNotice('error', 'vx watch', `${parsed.error}${seeHelp('watch')}`)
|
|
88
88
|
return 1
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const refused = watchRefusal(parsed)
|
|
92
92
|
if (refused !== null) {
|
|
93
|
-
|
|
93
|
+
sayLine('error', refused, 'vx watch')
|
|
94
94
|
return 1
|
|
95
95
|
}
|
|
96
96
|
if (parsed.tasks.length === 0) {
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
sayNotice(
|
|
98
|
+
'error',
|
|
99
|
+
'vx watch',
|
|
100
|
+
`missing task name (vx watch <task>, e.g. vx watch build)${seeHelp('watch')}`,
|
|
99
101
|
)
|
|
100
102
|
return 1
|
|
101
103
|
}
|
|
@@ -103,11 +105,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
103
105
|
const cwd = process.cwd()
|
|
104
106
|
const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
|
|
105
107
|
if ('error' in resolved) {
|
|
106
|
-
|
|
108
|
+
sayNotice('error', 'vx watch', resolved.error)
|
|
107
109
|
return 1
|
|
108
110
|
}
|
|
109
111
|
if ('nothingSelected' in resolved) {
|
|
110
|
-
|
|
112
|
+
sayNotice('info', 'vx watch', resolved.nothingSelected)
|
|
111
113
|
return 0
|
|
112
114
|
}
|
|
113
115
|
// The watch loop owns SIGINT/SIGTERM for its whole lifetime. A cycle's
|
|
@@ -216,7 +218,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
216
218
|
let refusedToStart = false
|
|
217
219
|
initial.held = runOrchestrator(initial.cycle.opts).then(
|
|
218
220
|
(run) => {
|
|
219
|
-
if (run.refused !== undefined)
|
|
221
|
+
if (run.refused !== undefined) sayNotice('error', 'vx watch', run.refused)
|
|
220
222
|
// A run that failed having run nothing refused to start: a requested
|
|
221
223
|
// name no project declares (run() says which, with a "did you mean").
|
|
222
224
|
// `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
|
|
@@ -227,8 +229,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
227
229
|
return run.persistent
|
|
228
230
|
},
|
|
229
231
|
(err: unknown) => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
sayNotice(
|
|
233
|
+
'error',
|
|
234
|
+
'vx watch',
|
|
235
|
+
`cycle failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
232
236
|
)
|
|
233
237
|
return undefined
|
|
234
238
|
},
|
|
@@ -247,7 +251,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
247
251
|
// run's own refusal and its "did you mean".
|
|
248
252
|
if (scope.length === 0 && !unlocked) {
|
|
249
253
|
await held?.stop()
|
|
250
|
-
|
|
254
|
+
sayNotice('error', 'vx watch', 'no projects in scope')
|
|
251
255
|
return 1
|
|
252
256
|
}
|
|
253
257
|
await armLoop(undefined, held)
|
|
@@ -491,7 +495,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
491
495
|
held = cycle.persistent
|
|
492
496
|
// What this cycle did not take (a server whose task did not run).
|
|
493
497
|
await before?.stop()
|
|
494
|
-
if (cycle.refused !== undefined)
|
|
498
|
+
if (cycle.refused !== undefined) sayNotice('error', 'vx watch', cycle.refused)
|
|
495
499
|
changes.lastCycle = { start, end: Date.now() }
|
|
496
500
|
} catch (err) {
|
|
497
501
|
// A re-run can fail catastrophically when the workspace
|
|
@@ -503,7 +507,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
503
507
|
// is the canonical exit; we don't unilaterally abort here.
|
|
504
508
|
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
505
509
|
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
506
|
-
|
|
510
|
+
sayNotice('error', 'vx watch', `cycle failed: ${message}`)
|
|
507
511
|
}
|
|
508
512
|
// After a failed cycle too: a package added with a config that does
|
|
509
513
|
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
@@ -797,12 +801,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
797
801
|
armAncestors()
|
|
798
802
|
}
|
|
799
803
|
|
|
800
|
-
// Said once the loop
|
|
801
|
-
//
|
|
804
|
+
// Said once, when the loop first arms: a re-arm repeated it after every
|
|
805
|
+
// cycle that re-read the watched set, between task rows. `debug.watch`
|
|
806
|
+
// says each arm on stderr, for whoever needs to see the set change.
|
|
802
807
|
let lastTasks: number | undefined
|
|
808
|
+
const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
|
|
803
809
|
const sayWatching = (count: number): void => {
|
|
804
|
-
const tasks = lastTasks === undefined ? '' : ` · ${lastTasks
|
|
805
|
-
process.stdout.write(`watching ${count
|
|
810
|
+
const tasks = lastTasks === undefined ? '' : ` · ${plural(lastTasks, 'task')}`
|
|
811
|
+
process.stdout.write(`watching ${plural(count, 'project')}${tasks}\n`)
|
|
812
|
+
}
|
|
813
|
+
const debugArms = resolveSetting('debug.watch', undefined).value === true
|
|
814
|
+
const sayArmed = (count: number): void => {
|
|
815
|
+
if (debugArms) process.stderr.write(`vx watch: armed ${plural(count, 'project')}\n`)
|
|
806
816
|
}
|
|
807
817
|
|
|
808
818
|
const rearm = async (say = true): Promise<void> => {
|
|
@@ -854,7 +864,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
854
864
|
// A new arm proves delivery like the first ones: an edit in the new
|
|
855
865
|
// project right after this cycle is seen, not lost in the gap.
|
|
856
866
|
await pool.proved()
|
|
857
|
-
if (say)
|
|
867
|
+
if (say) sayArmed(next.projects.length)
|
|
858
868
|
}
|
|
859
869
|
|
|
860
870
|
armMode(projects)
|
|
@@ -872,10 +882,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
872
882
|
// never heard `packages/` removed under `packages/a` (inotify did).
|
|
873
883
|
const ancestorArms = new Map<string, WatchHandle>()
|
|
874
884
|
let ancestorNames = new Map<string, Set<string>>()
|
|
885
|
+
/** Each project directory below the root as these arms first saw it (`inodeOf`). */
|
|
886
|
+
let heldDirs = new Map<string, string>()
|
|
875
887
|
const armAncestors = (): void => {
|
|
876
888
|
ancestorNames = new Map()
|
|
877
889
|
const rootDir = path.resolve(workspaceRoot)
|
|
878
890
|
const below = projectDirs.filter((d) => path.resolve(d) !== rootDir)
|
|
891
|
+
heldDirs = new Map(
|
|
892
|
+
below.flatMap((d) => {
|
|
893
|
+
const id = inodeOf(d)
|
|
894
|
+
return id === undefined ? [] : [[d, id]]
|
|
895
|
+
}),
|
|
896
|
+
)
|
|
879
897
|
for (const base of [...below, ...importNames.keys()]) {
|
|
880
898
|
let dir = path.dirname(base)
|
|
881
899
|
let name = path.basename(base)
|
|
@@ -907,8 +925,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
907
925
|
arm(dir, false, (filename) => {
|
|
908
926
|
// A project's parent's `.gitignore` shapes what its keys read, as the root's does.
|
|
909
927
|
if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
|
|
910
|
-
reread = true
|
|
911
928
|
const abs = path.join(dir, filename)
|
|
929
|
+
// A project directory still the one its arm holds is no change:
|
|
930
|
+
// its arm judges what is in it. macOS reports the directory's
|
|
931
|
+
// mtime here on every write at its top level, and a cycle's own
|
|
932
|
+
// write started the next (solid's `types`, M 2026-10-15).
|
|
933
|
+
if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
|
|
934
|
+
reread = true
|
|
912
935
|
trigger(path.relative(workspaceRoot, abs), abs)
|
|
913
936
|
}),
|
|
914
937
|
)
|
|
@@ -937,6 +960,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
937
960
|
|
|
938
961
|
lastTasks = args.tasks()
|
|
939
962
|
sayWatching(projects.length)
|
|
963
|
+
sayArmed(projects.length)
|
|
940
964
|
|
|
941
965
|
return await new Promise<void>((resolve) => {
|
|
942
966
|
const cleanup = async (): Promise<void> => {
|
package/src/cli/why.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
whyDidThisRerunQuery as whyDidThisRerun,
|
|
17
17
|
resolveRunId,
|
|
18
18
|
} from '../orchestrator/index.js'
|
|
19
|
-
import { MASKED, nearMatches, printable, UserError } from '../util/index.js'
|
|
19
|
+
import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
|
|
20
20
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
21
21
|
import { findCwdProject } from './select.js'
|
|
22
22
|
import { cliCacheDir, discoverCliProjects, parseCacheDirFlag } from './workspace-config.js'
|
|
@@ -145,6 +145,15 @@ async function resolveTarget(
|
|
|
145
145
|
.all() as Array<{ project: string; task: string }>
|
|
146
146
|
const ids = pairs.map((p) => `${p.project}#${p.task}`)
|
|
147
147
|
const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
|
|
148
|
+
if (ids.length === 0 && !invoked) {
|
|
149
|
+
throw refusal(
|
|
150
|
+
{ tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` },
|
|
151
|
+
{
|
|
152
|
+
code: 'VX_E_NO_HISTORY',
|
|
153
|
+
message: `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
}
|
|
148
157
|
if (target.includes('#')) {
|
|
149
158
|
throw new UserError(
|
|
150
159
|
`vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import type { WorkspaceConfig } from '../config.js'
|
|
10
|
-
import { UserError } from '../util/index.js'
|
|
10
|
+
import { UserError, sayLine } from '../util/index.js'
|
|
11
11
|
import {
|
|
12
12
|
discoverProjects,
|
|
13
13
|
loadProjects,
|
|
@@ -33,7 +33,7 @@ interface CliWorkspace {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export const warnToStderr = (message: string): void => {
|
|
36
|
-
|
|
36
|
+
sayLine('warn', message)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -152,6 +152,12 @@ export interface TaskOutcome {
|
|
|
152
152
|
* bounded to its first 8 KiB and last 56 KiB. The run's history keeps it.
|
|
153
153
|
*/
|
|
154
154
|
failedOutput?: string
|
|
155
|
+
/**
|
|
156
|
+
* When the run keeps its bytes in a remote, a task that ran and saved no
|
|
157
|
+
* entry (failed, or uncached): its whole output as `encodeOutputLog`
|
|
158
|
+
* stores it, secrets masked, at the entry's bounds.
|
|
159
|
+
*/
|
|
160
|
+
runLog?: string
|
|
155
161
|
/**
|
|
156
162
|
* On a failed persistent task: why it never became ready — the readiness
|
|
157
163
|
* deadline fired, the child exited first (`exitCode` is then its own), or
|
|
@@ -171,6 +177,8 @@ export interface TaskOutcome {
|
|
|
171
177
|
* (`--cache-download=none`) instead of landing on this machine. Absent for
|
|
172
178
|
* every ordinary outcome. Telemetry-only. */
|
|
173
179
|
outputs?: 'deferred'
|
|
180
|
+
/** The task ran here and its result went to the cache. The footer's `↑`. */
|
|
181
|
+
saved?: true
|
|
174
182
|
/**
|
|
175
183
|
* v11 analytics: hrtime span relative to the parent run's t=0.
|
|
176
184
|
* Lets downstream analytics reconstruct the actual parallel timeline
|