@vzn/vx 0.0.631 → 0.0.633
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 +21 -18
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +14 -5
- package/schemas/cache.json +24 -0
- package/schemas/docs.json +28 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/init.json +57 -0
- package/schemas/last.json +188 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +89 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +542 -137
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +10 -5
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +44 -17
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +42 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +131 -24
- package/src/cli/last.ts +54 -3
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +19 -7
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +4 -4
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +76 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
- package/src/workspace/workspace.ts +1 -0
package/src/cli/run.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
4
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
5
5
|
import { translateForeign } from './foreign-flags.js'
|
|
6
|
-
import { flagHint, seeHelp } from './help.js'
|
|
6
|
+
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
7
7
|
import {
|
|
8
8
|
planRun,
|
|
9
9
|
formatRunReportMarkdown,
|
|
@@ -69,6 +69,8 @@ export interface RunArgs {
|
|
|
69
69
|
forwardArgs: string[]
|
|
70
70
|
verbosity: number
|
|
71
71
|
dry: 'text' | 'json' | undefined
|
|
72
|
+
/** `--format json`: the run's result document on stdout, every other line on stderr. */
|
|
73
|
+
format: 'pretty' | 'json'
|
|
72
74
|
graph: string | undefined
|
|
73
75
|
summarize: string | undefined
|
|
74
76
|
profile: string | undefined
|
|
@@ -132,6 +134,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
132
134
|
forwardArgs: [],
|
|
133
135
|
verbosity: 0,
|
|
134
136
|
dry: undefined,
|
|
137
|
+
format: 'pretty',
|
|
135
138
|
graph: undefined,
|
|
136
139
|
summarize: undefined,
|
|
137
140
|
profile: undefined,
|
|
@@ -342,6 +345,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
342
345
|
return { ...out, error: `--dry must be text or json (got ${fmt})` }
|
|
343
346
|
}
|
|
344
347
|
out.dry = fmt
|
|
348
|
+
} else if (a === '--format' || a?.startsWith('--format=')) {
|
|
349
|
+
const f = formatValue(a === '--format' ? before[++i] : a.slice('--format='.length), verb)
|
|
350
|
+
if (typeof f !== 'string') return { ...out, error: f.error }
|
|
351
|
+
out.format = f
|
|
345
352
|
} else if (a === '--graph') {
|
|
346
353
|
out.graph = ''
|
|
347
354
|
} else if (a?.startsWith('--graph=')) {
|
|
@@ -429,6 +436,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
429
436
|
if (out.graph !== undefined && needsRun) {
|
|
430
437
|
return { ...out, error: `--graph skips execution; ${runOnly}` }
|
|
431
438
|
}
|
|
439
|
+
if (out.format === 'json') {
|
|
440
|
+
// Each of these prints something else on stdout, which the document owns.
|
|
441
|
+
if (out.dry !== undefined)
|
|
442
|
+
return { ...out, error: '--format json runs the tasks; --dry=json prints the plan' }
|
|
443
|
+
if (out.graph === '')
|
|
444
|
+
return {
|
|
445
|
+
...out,
|
|
446
|
+
error: '--format json and --graph both print on stdout; give --graph a file',
|
|
447
|
+
}
|
|
448
|
+
if (out.report === 'markdown')
|
|
449
|
+
return {
|
|
450
|
+
...out,
|
|
451
|
+
error: '--format json and --report=markdown both print on stdout; use --report-file',
|
|
452
|
+
}
|
|
453
|
+
}
|
|
432
454
|
return out
|
|
433
455
|
}
|
|
434
456
|
|
|
@@ -614,6 +636,7 @@ export async function resolveRunOptions(
|
|
|
614
636
|
if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
|
|
615
637
|
if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
|
|
616
638
|
if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
|
|
639
|
+
if (parsed.format === 'json') opts.json = true
|
|
617
640
|
if (parsed.profile !== undefined) opts.profile = parsed.profile
|
|
618
641
|
if (Object.keys(parsed.tags).length > 0) opts.tags = parsed.tags
|
|
619
642
|
|
|
@@ -626,8 +649,7 @@ const TASKS_SHOWN = 12
|
|
|
626
649
|
export async function runCmd(args: readonly string[]): Promise<number> {
|
|
627
650
|
const parsed = parseRunArgs(args)
|
|
628
651
|
if (parsed.error) {
|
|
629
|
-
|
|
630
|
-
return 1
|
|
652
|
+
return refuse('run', args, `${parsed.error}${seeHelp('run')}`, 'VX_E_USAGE')
|
|
631
653
|
}
|
|
632
654
|
|
|
633
655
|
const cwd = process.cwd()
|
|
@@ -638,7 +660,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
638
660
|
// anchored pkg#task; the rest of the pipeline treats it like any
|
|
639
661
|
// explicit anchored positional.
|
|
640
662
|
if (tasks.length === 0) {
|
|
641
|
-
|
|
663
|
+
// A JSON answer is for a program, which cannot pick from a menu: an
|
|
664
|
+
// agent on a pty waited at the picker forever.
|
|
665
|
+
const forProgram = parsed.format === 'json' || parsed.dry === 'json'
|
|
666
|
+
if (!process.stdin.isTTY || forProgram) {
|
|
642
667
|
const names = await taskNamesHere(cwd)
|
|
643
668
|
const shown = (names ?? []).slice(0, TASKS_SHOWN)
|
|
644
669
|
const more = (names?.length ?? 0) - shown.length
|
|
@@ -646,10 +671,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
646
671
|
shown.length === 0
|
|
647
672
|
? 'vx run <task>, e.g. vx run build'
|
|
648
673
|
: `tasks here: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
|
|
649
|
-
|
|
650
|
-
|
|
674
|
+
return refuse(
|
|
675
|
+
'run',
|
|
676
|
+
args,
|
|
677
|
+
`missing task name (${forProgram ? 'a JSON answer opens' : 'stdin is not a TTY, so'} no picker; ${tasksHere})${seeHelp('run')}`,
|
|
678
|
+
'VX_E_USAGE',
|
|
651
679
|
)
|
|
652
|
-
return 1
|
|
653
680
|
}
|
|
654
681
|
// Every run needs git: refused here, not after the user has chosen.
|
|
655
682
|
const refusal = gitRefusal(await findWorkspaceRoot(cwd))
|
|
@@ -664,17 +691,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
664
691
|
// `--filter` / `--affected` scope the menu, as they scope a bare task:
|
|
665
692
|
// the menu listed every project and the anchored pick ran outside it.
|
|
666
693
|
const scope = await scopeFilters(parsed, cwd)
|
|
667
|
-
if ('error' in scope)
|
|
668
|
-
process.stderr.write(`vx run: ${scope.error}\n`)
|
|
669
|
-
return 1
|
|
670
|
-
}
|
|
694
|
+
if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
|
|
671
695
|
let only: ReadonlySet<string> | undefined
|
|
672
696
|
if (scope.filterStrings.length > 0) {
|
|
673
697
|
const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
|
|
674
|
-
if ('error' in selected)
|
|
675
|
-
process.stderr.write(`vx run: ${selected.error}\n`)
|
|
676
|
-
return 1
|
|
677
|
-
}
|
|
698
|
+
if ('error' in selected) return refuse('run', args, selected.error, 'VX_E_USAGE')
|
|
678
699
|
if ('empty' in selected) {
|
|
679
700
|
process.stderr.write(`vx run: ${selected.empty}\n`)
|
|
680
701
|
return 0
|
|
@@ -689,10 +710,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
689
710
|
}
|
|
690
711
|
|
|
691
712
|
const resolved = await resolveRunOptions(parsed, cwd, tasks)
|
|
692
|
-
if ('error' in resolved)
|
|
693
|
-
process.stderr.write(`vx run: ${resolved.error}\n`)
|
|
694
|
-
return 1
|
|
695
|
-
}
|
|
713
|
+
if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
|
|
696
714
|
if ('nothingSelected' in resolved) {
|
|
697
715
|
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
698
716
|
return 0
|
|
@@ -783,8 +801,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
783
801
|
// is no whole-run delegation seam to consult.
|
|
784
802
|
const summary = await runOrchestrator(opts)
|
|
785
803
|
if (summary.refused !== undefined) {
|
|
786
|
-
|
|
787
|
-
return
|
|
804
|
+
const unknown = summary.refused.startsWith('no projects declare')
|
|
805
|
+
return refuse('run', args, summary.refused, unknown ? 'VX_E_UNKNOWN_TASK' : 'VX_E_REFUSED')
|
|
788
806
|
}
|
|
789
807
|
if (wantsReport) {
|
|
790
808
|
const md =
|
|
@@ -816,5 +834,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
816
834
|
}
|
|
817
835
|
}
|
|
818
836
|
}
|
|
837
|
+
if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json, null, 2)}\n`)
|
|
819
838
|
return summary.ok ? 0 : 1
|
|
820
839
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -10,7 +10,7 @@ import path from 'node:path'
|
|
|
10
10
|
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
|
-
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
13
|
+
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
14
|
import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
|
|
15
15
|
import { affectedFilterFor, resolveFilters } from './select.js'
|
|
16
16
|
import {
|
|
@@ -82,10 +82,7 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
82
82
|
|
|
83
83
|
export async function showCmd(args: readonly string[]): Promise<number> {
|
|
84
84
|
const parsed = parseShowArgs(args)
|
|
85
|
-
if (parsed.error)
|
|
86
|
-
process.stderr.write(`vx show: ${parsed.error}\n`)
|
|
87
|
-
return 1
|
|
88
|
-
}
|
|
85
|
+
if (parsed.error) return refuse('show', args, parsed.error, 'VX_E_USAGE')
|
|
89
86
|
const reads: LoadReads = new Map()
|
|
90
87
|
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
91
88
|
const metas = await discoverCliProjects(await loadWorkspace(root, reads))
|
|
@@ -98,6 +95,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
98
95
|
if (rootMeta === undefined) {
|
|
99
96
|
throw new UserError(
|
|
100
97
|
`vx show: "${parsed.target}" names the workspace root's project, and the root is no project here`,
|
|
98
|
+
'VX_E_USAGE',
|
|
101
99
|
)
|
|
102
100
|
}
|
|
103
101
|
parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
|
|
@@ -114,12 +112,14 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
114
112
|
const list = projectName === '' ? 'vx show' : `vx show ${projectName}`
|
|
115
113
|
throw new UserError(
|
|
116
114
|
`vx show: missing task name after '#' in "${parsed.target}" (${list} lists the tasks)`,
|
|
115
|
+
'VX_E_USAGE',
|
|
117
116
|
)
|
|
118
117
|
}
|
|
119
118
|
if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
|
|
120
119
|
if (!byName.has(projectName)) {
|
|
121
120
|
throw new UserError(
|
|
122
121
|
`vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()], '', 'projects')}`,
|
|
122
|
+
'VX_E_UNKNOWN_PROJECT',
|
|
123
123
|
)
|
|
124
124
|
}
|
|
125
125
|
}
|
|
@@ -138,18 +138,19 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
138
138
|
if (scope !== 'all') {
|
|
139
139
|
throw new UserError(
|
|
140
140
|
'vx show: --filter and --affected narrow a list: `vx show` or `vx show <task>`',
|
|
141
|
+
'VX_E_USAGE',
|
|
141
142
|
)
|
|
142
143
|
}
|
|
143
144
|
const raw = [...parsed.filters]
|
|
144
145
|
let affected: string | undefined
|
|
145
146
|
if (parsed.affected !== undefined) {
|
|
146
147
|
const f = await affectedFilterFor(process.cwd(), parsed.affected)
|
|
147
|
-
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}
|
|
148
|
+
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
|
|
148
149
|
affected = f
|
|
149
150
|
raw.unshift(f)
|
|
150
151
|
}
|
|
151
152
|
const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
|
|
152
|
-
if ('error' in r) throw new UserError(`vx show: ${r.error}
|
|
153
|
+
if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
|
|
153
154
|
if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
|
|
154
155
|
selected = new Set('empty' in r ? [] : r.names)
|
|
155
156
|
if ('names' in r && r.affected !== undefined)
|
|
@@ -181,6 +182,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
181
182
|
const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
|
|
182
183
|
throw new UserError(
|
|
183
184
|
`vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
|
|
185
|
+
'VX_E_UNKNOWN_TASK',
|
|
184
186
|
)
|
|
185
187
|
}
|
|
186
188
|
let shown = declaring.filter((p) => inSelection(p.name))
|
|
@@ -224,6 +226,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
224
226
|
if (task === undefined) {
|
|
225
227
|
throw new UserError(
|
|
226
228
|
`vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`, 'its tasks')}`,
|
|
229
|
+
'VX_E_UNKNOWN_TASK',
|
|
227
230
|
)
|
|
228
231
|
}
|
|
229
232
|
process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
14
14
|
|
|
15
15
|
import { constants } from 'node:fs'
|
|
16
|
-
import { access, chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
|
|
16
|
+
import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { flagHint, seeHelp } from './help.js'
|
|
19
19
|
import { UserError } from '../util/index.js'
|
|
@@ -38,7 +38,7 @@ export function isBunfsPath(p: string): boolean {
|
|
|
38
38
|
* upgrade` refused with "running from source". `Bun.main` stays the
|
|
39
39
|
* bunfs path under every compile-flag combination.
|
|
40
40
|
*/
|
|
41
|
-
function isCompiledBinary(): boolean {
|
|
41
|
+
export function isCompiledBinary(): boolean {
|
|
42
42
|
return (
|
|
43
43
|
isBunfsPath(Bun.main) || isBunfsPath(process.argv[1] ?? '') || isBunfsPath(import.meta.path)
|
|
44
44
|
)
|
|
@@ -247,8 +247,20 @@ export async function replaceBinary(
|
|
|
247
247
|
`vx upgrade: the download did not match the release's SHA-256 (expected ${sha256}, got ${got}) — nothing replaced; try again, and if it repeats the asset is not the one the release published`,
|
|
248
248
|
)
|
|
249
249
|
}
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
// A fresh directory beside the binary, not a name built from the pid:
|
|
251
|
+
// anyone who can write the install directory could plant a link at a
|
|
252
|
+
// predictable name, and a `sudo vx upgrade` wrote through it as root.
|
|
253
|
+
let stage: string
|
|
254
|
+
try {
|
|
255
|
+
stage = await mkdtemp(path.join(dir, '.vx-upgrade-'))
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
258
|
+
throw new UserError(
|
|
259
|
+
`vx upgrade: could not stage the new binary in ${dir} (${msg}) — nothing replaced; check the permissions of ${dir}`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
const tmp = path.join(stage, 'new')
|
|
263
|
+
const old = path.join(stage, 'previous')
|
|
252
264
|
let kept = false
|
|
253
265
|
try {
|
|
254
266
|
const was = await stat(dest).catch(() => null)
|
|
@@ -265,18 +277,18 @@ export async function replaceBinary(
|
|
|
265
277
|
}
|
|
266
278
|
await rename(tmp, dest)
|
|
267
279
|
} catch (err) {
|
|
268
|
-
await rm(
|
|
269
|
-
if (kept) await rm(old, { force: true })
|
|
280
|
+
await rm(stage, { recursive: true, force: true })
|
|
270
281
|
const msg = err instanceof Error ? err.message : String(err)
|
|
271
282
|
throw new UserError(
|
|
272
283
|
`vx upgrade: could not replace ${dest} (${msg}) — nothing replaced; check the permissions of ${dest} and its directory`,
|
|
273
284
|
)
|
|
274
285
|
}
|
|
275
286
|
if (starts === undefined || starts(dest)) {
|
|
276
|
-
|
|
287
|
+
await rm(stage, { recursive: true, force: true })
|
|
277
288
|
return
|
|
278
289
|
}
|
|
279
290
|
if (kept) await rename(old, dest)
|
|
291
|
+
await rm(stage, { recursive: true, force: true })
|
|
280
292
|
throw new UserError(
|
|
281
293
|
`vx upgrade: the new binary did not start on this machine (\`${dest} --version\` failed)${kept ? ' — the previous vx is back in place' : ''}. Nothing else changed; report it with this os/arch`,
|
|
282
294
|
)
|
package/src/cli/watch.ts
CHANGED
|
@@ -78,8 +78,13 @@ export function watchRefusal(parsed: RunArgs): string | null {
|
|
|
78
78
|
// All three format ONE run's result and are consumed by `runCmd` alone, so
|
|
79
79
|
// a watch loop silently ignored them. `--verbosity 0` is not rejected: it
|
|
80
80
|
// asks for the output watch already gives.
|
|
81
|
-
if (
|
|
82
|
-
|
|
81
|
+
if (
|
|
82
|
+
parsed.report !== undefined ||
|
|
83
|
+
parsed.reportFile !== undefined ||
|
|
84
|
+
parsed.verbosity > 0 ||
|
|
85
|
+
parsed.format !== 'pretty'
|
|
86
|
+
) {
|
|
87
|
+
return 'vx watch: --report / --report-file / --verbosity / --format are not supported in watch mode (they report a single run)'
|
|
83
88
|
}
|
|
84
89
|
return null
|
|
85
90
|
}
|
package/src/cli/why.ts
CHANGED
|
@@ -9,12 +9,11 @@ import path from 'node:path'
|
|
|
9
9
|
import { Cache } from '../cache/index.js'
|
|
10
10
|
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
11
11
|
import { splitTaskId } from '../graph/index.js'
|
|
12
|
-
import type { Database } from 'bun:sqlite'
|
|
13
12
|
import {
|
|
14
|
-
type CacheKeyDiff,
|
|
15
13
|
cacheKeyDiff,
|
|
16
14
|
explainCacheKeyQuery as explainCacheKey,
|
|
17
15
|
latestRunId,
|
|
16
|
+
rootCauses,
|
|
18
17
|
whyDidThisRerunQuery as whyDidThisRerun,
|
|
19
18
|
resolveRunId,
|
|
20
19
|
} from '../orchestrator/index.js'
|
|
@@ -151,7 +150,10 @@ async function resolveTarget(
|
|
|
151
150
|
const ids = pairs.map((p) => `${p.project}#${p.task}`)
|
|
152
151
|
const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
|
|
153
152
|
if (target.includes('#')) {
|
|
154
|
-
throw new UserError(
|
|
153
|
+
throw new UserError(
|
|
154
|
+
`vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
|
|
155
|
+
'VX_E_NO_HISTORY',
|
|
156
|
+
)
|
|
155
157
|
}
|
|
156
158
|
const matches = ids.filter((id) => id.endsWith(`#${target}`))
|
|
157
159
|
if (matches.length === 1) return matches[0]!
|
|
@@ -161,10 +163,12 @@ async function resolveTarget(
|
|
|
161
163
|
throw new UserError(
|
|
162
164
|
`vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
|
|
163
165
|
matches.map((m) => ` ${m}`).join('\n'),
|
|
166
|
+
'VX_E_AMBIGUOUS',
|
|
164
167
|
)
|
|
165
168
|
}
|
|
166
169
|
throw new UserError(
|
|
167
170
|
`vx why: no recorded runs for task "${target}"${suggest(target, ids, invoked)}`,
|
|
171
|
+
'VX_E_NO_HISTORY',
|
|
168
172
|
)
|
|
169
173
|
}
|
|
170
174
|
|
|
@@ -187,9 +191,12 @@ function restoredWord(r: { restored: boolean | null }): string {
|
|
|
187
191
|
|
|
188
192
|
export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
189
193
|
const parsed = parseWhyArgs(args)
|
|
190
|
-
if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}
|
|
194
|
+
if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`, 'VX_E_USAGE')
|
|
191
195
|
if (parsed.target === undefined) {
|
|
192
|
-
throw new UserError(
|
|
196
|
+
throw new UserError(
|
|
197
|
+
'vx why: <task> required (e.g. vx why app#build, or vx why build)',
|
|
198
|
+
'VX_E_USAGE',
|
|
199
|
+
)
|
|
193
200
|
}
|
|
194
201
|
|
|
195
202
|
const root = await findWorkspaceRoot(process.cwd())
|
|
@@ -206,6 +213,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
206
213
|
if (runId === null) {
|
|
207
214
|
throw new UserError(
|
|
208
215
|
`vx why: no recorded run ${parsed.runId} (vx last --list shows recent runs)`,
|
|
216
|
+
'VX_E_NO_HISTORY',
|
|
209
217
|
)
|
|
210
218
|
}
|
|
211
219
|
} else runId = latestRunId(db, taskId)
|
|
@@ -231,6 +239,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
231
239
|
if (!why.found) {
|
|
232
240
|
throw new UserError(
|
|
233
241
|
`vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last RUNID\` what one ran`,
|
|
242
|
+
'VX_E_NO_HISTORY',
|
|
234
243
|
)
|
|
235
244
|
}
|
|
236
245
|
const diff = cacheKeyDiff(db, runId, taskId)
|
|
@@ -344,38 +353,3 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
344
353
|
}
|
|
345
354
|
|
|
346
355
|
const ROOTS_SHOWN = 5
|
|
347
|
-
|
|
348
|
-
interface RootCause {
|
|
349
|
-
/** From the asked task down to the one whose own inputs moved. */
|
|
350
|
-
chain: string[]
|
|
351
|
-
entries: CacheKeyDiff['entries']
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* The tasks under `taskId`'s moved upstreams whose OWN key components
|
|
356
|
-
* moved in the same run, each with the chain that carried it up. A task
|
|
357
|
-
* visited once is not walked again (a diamond names its root once).
|
|
358
|
-
*/
|
|
359
|
-
function rootCauses(
|
|
360
|
-
db: Database,
|
|
361
|
-
runId: string,
|
|
362
|
-
taskId: string,
|
|
363
|
-
entries: CacheKeyDiff['entries'],
|
|
364
|
-
): RootCause[] {
|
|
365
|
-
const roots: RootCause[] = []
|
|
366
|
-
const seen = new Set([taskId])
|
|
367
|
-
const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
|
|
368
|
-
for (const e of moved) {
|
|
369
|
-
if (e.kind !== 'upstream' || seen.has(e.name)) continue
|
|
370
|
-
seen.add(e.name)
|
|
371
|
-
const next = [...chain, e.name]
|
|
372
|
-
const d = cacheKeyDiff(db, runId, e.name)
|
|
373
|
-
if (!d.found) continue
|
|
374
|
-
const own = d.entries.filter((x) => x.kind !== 'upstream')
|
|
375
|
-
if (own.length > 0) roots.push({ chain: next, entries: own })
|
|
376
|
-
walk(next, d.entries)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
walk([taskId], entries)
|
|
380
|
-
return roots
|
|
381
|
-
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -849,7 +849,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
849
849
|
}
|
|
850
850
|
}
|
|
851
851
|
const reader = stream.getReader()
|
|
852
|
-
const decoder = new TextDecoder()
|
|
852
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
853
853
|
try {
|
|
854
854
|
while (true) {
|
|
855
855
|
const { value, done } = await reader.read()
|
|
@@ -1097,12 +1097,17 @@ export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
|
|
|
1097
1097
|
export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
|
|
1098
1098
|
|
|
1099
1099
|
const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
|
|
1100
|
+
const size = (n: number): string => (n < 1024 * 1024 ? `${n / 1024} KiB` : mib(n))
|
|
1100
1101
|
|
|
1101
1102
|
/** The line that stands where the dropped middle was. */
|
|
1102
|
-
export function droppedOutputLine(
|
|
1103
|
+
export function droppedOutputLine(
|
|
1104
|
+
dropped: number,
|
|
1105
|
+
headChars = CAPTURE_HEAD_CHARS,
|
|
1106
|
+
tailChars = CAPTURE_TAIL_CHARS,
|
|
1107
|
+
): string {
|
|
1103
1108
|
return (
|
|
1104
|
-
`\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${
|
|
1105
|
-
`and the last ${
|
|
1109
|
+
`\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${size(headChars)} ` +
|
|
1110
|
+
`and the last ${size(tailChars)} of a task's output\n`
|
|
1106
1111
|
)
|
|
1107
1112
|
}
|
|
1108
1113
|
|
|
@@ -1145,12 +1150,17 @@ export class BoundedCapture {
|
|
|
1145
1150
|
private tailLen = 0
|
|
1146
1151
|
private dropped = 0
|
|
1147
1152
|
|
|
1153
|
+
constructor(
|
|
1154
|
+
private readonly headChars = CAPTURE_HEAD_CHARS,
|
|
1155
|
+
private readonly tailChars = CAPTURE_TAIL_CHARS,
|
|
1156
|
+
) {}
|
|
1157
|
+
|
|
1148
1158
|
push(text: string, err = false): void {
|
|
1149
1159
|
// Once the tail has begun the head is closed, even a unit short of its
|
|
1150
1160
|
// bound (the surrogate case below): a later short chunk fit there and
|
|
1151
1161
|
// was retained ahead of the chunk before it.
|
|
1152
|
-
if (this.tailLen === 0 && this.headLen <
|
|
1153
|
-
let room =
|
|
1162
|
+
if (this.tailLen === 0 && this.headLen < this.headChars) {
|
|
1163
|
+
let room = this.headChars - this.headLen
|
|
1154
1164
|
if (text.length <= room) {
|
|
1155
1165
|
this.head.push({ text, err })
|
|
1156
1166
|
this.headLen += text.length
|
|
@@ -1165,9 +1175,9 @@ export class BoundedCapture {
|
|
|
1165
1175
|
}
|
|
1166
1176
|
this.tail.push({ text, err })
|
|
1167
1177
|
this.tailLen += text.length
|
|
1168
|
-
while (this.tailLen >
|
|
1178
|
+
while (this.tailLen > this.tailChars) {
|
|
1169
1179
|
const first = this.tail[0]!
|
|
1170
|
-
const excess = this.tailLen -
|
|
1180
|
+
const excess = this.tailLen - this.tailChars
|
|
1171
1181
|
if (first.text.length <= excess) {
|
|
1172
1182
|
this.tail.shift()
|
|
1173
1183
|
this.tailLen -= first.text.length
|
|
@@ -1185,7 +1195,14 @@ export class BoundedCapture {
|
|
|
1185
1195
|
chunks(): CapturedChunk[] {
|
|
1186
1196
|
return this.dropped === 0
|
|
1187
1197
|
? [...this.head, ...this.tail]
|
|
1188
|
-
: [
|
|
1198
|
+
: [
|
|
1199
|
+
...this.head,
|
|
1200
|
+
{
|
|
1201
|
+
text: droppedOutputLine(this.dropped, this.headChars, this.tailChars),
|
|
1202
|
+
err: false,
|
|
1203
|
+
},
|
|
1204
|
+
...this.tail,
|
|
1205
|
+
]
|
|
1189
1206
|
}
|
|
1190
1207
|
|
|
1191
1208
|
text(): string {
|
|
@@ -1213,7 +1230,7 @@ export async function streamToString(
|
|
|
1213
1230
|
if (!stream || typeof stream === 'number') return ''
|
|
1214
1231
|
const full = new BoundedCapture()
|
|
1215
1232
|
const reader = stream.getReader()
|
|
1216
|
-
const decoder = new TextDecoder()
|
|
1233
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
1217
1234
|
// On abort, cancel the read so a pending `reader.read()` resolves
|
|
1218
1235
|
// `done` and we return whatever we captured. Needed for the timeout
|
|
1219
1236
|
// path: SIGTERMing `sh` doesn't close the pipe if an orphaned
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -140,6 +140,12 @@ export interface TaskOutcome {
|
|
|
140
140
|
* of reading it as a signal (item 268).
|
|
141
141
|
*/
|
|
142
142
|
timedOut?: true
|
|
143
|
+
/**
|
|
144
|
+
* On a `failed` outcome of a command that ran: the final attempt's
|
|
145
|
+
* output, both streams, as `encodeOutputLog` stores it, secrets masked,
|
|
146
|
+
* bounded to its first 8 KiB and last 56 KiB. The run's history keeps it.
|
|
147
|
+
*/
|
|
148
|
+
failedOutput?: string
|
|
143
149
|
/**
|
|
144
150
|
* On a failed persistent task: why it never became ready — the readiness
|
|
145
151
|
* deadline fired, the child exited first (`exitCode` is then its own), or
|
|
@@ -255,6 +261,15 @@ export interface ScheduleOptions {
|
|
|
255
261
|
* slot admits other work at once. Undefined: nothing owed.
|
|
256
262
|
*/
|
|
257
263
|
settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
|
|
264
|
+
/**
|
|
265
|
+
* The outcome of a task the caller settles in place, or undefined to
|
|
266
|
+
* dispatch it through `execute`. Asked of an exec-tier task the lane has
|
|
267
|
+
* room for, after the skip check; its outcome lands in the same tick, it
|
|
268
|
+
* holds no slot, and `onStart` / `onFinish` hear it as any other. An
|
|
269
|
+
* unkeyed group runs nothing, and the dispatch, the slot and the promise
|
|
270
|
+
* around it were ~10 µs of each of a warm run's 1,090 groups (X-192).
|
|
271
|
+
*/
|
|
272
|
+
settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => TaskOutcome | undefined
|
|
258
273
|
/**
|
|
259
274
|
* Optional priority override: callers pass their own per-node weight
|
|
260
275
|
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
@@ -749,6 +764,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
749
764
|
})
|
|
750
765
|
}
|
|
751
766
|
|
|
767
|
+
// A throw falls back to the dispatch, whose `execute` reports it.
|
|
768
|
+
const settleInPlace = (node: TaskNode, upstream: TaskOutcome[]): TaskOutcome | undefined => {
|
|
769
|
+
if (options.settleNow === undefined) return undefined
|
|
770
|
+
try {
|
|
771
|
+
return options.settleNow(node, upstream)
|
|
772
|
+
} catch {
|
|
773
|
+
return undefined
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
752
777
|
const tick = (): void => {
|
|
753
778
|
if (resolved) return
|
|
754
779
|
// Exec-tier tasks parked THIS tick on a refused admission. Within
|
|
@@ -844,6 +869,17 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
844
869
|
|
|
845
870
|
const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
|
|
846
871
|
readyAt.delete(id)
|
|
872
|
+
const now = inRestoreTier(id) ? undefined : settleInPlace(node, upstream)
|
|
873
|
+
if (now !== undefined) {
|
|
874
|
+
try {
|
|
875
|
+
onStart?.(node)
|
|
876
|
+
} catch (err) {
|
|
877
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
878
|
+
process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
|
|
879
|
+
}
|
|
880
|
+
finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
|
|
881
|
+
continue
|
|
882
|
+
}
|
|
847
883
|
const leave = admit(id)
|
|
848
884
|
// Listed as running on dispatch, so the policy's next ask in this
|
|
849
885
|
// tick sees it; the completion callbacks unlist it.
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function isGroupTask(node: TaskNode): boolean {
|
|
|
109
109
|
return node.config.exec === undefined
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
112
|
+
/** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
|
|
113
113
|
function isKeyedGroup(task: TaskConfig): boolean {
|
|
114
114
|
return task.exec === undefined && task.cache !== undefined
|
|
115
115
|
}
|
|
@@ -1505,8 +1505,8 @@ interface Side {
|
|
|
1505
1505
|
* `inputs.files` rebased to the root against another task's
|
|
1506
1506
|
* `outputs.workspaceFiles`, which may land in any project (X-135). A task's own outputs are
|
|
1507
1507
|
* already subtracted from its inputs, and an output the reader's `!` entries
|
|
1508
|
-
* take back whole is no overlap. A keyed group is exempt:
|
|
1509
|
-
* `build` reads `**` of a config-less dependency
|
|
1508
|
+
* take back whole is no overlap. A keyed group is exempt: it runs nothing
|
|
1509
|
+
* (the default `build` reads `**` of a config-less dependency), and its
|
|
1510
1510
|
* key waits as before.
|
|
1511
1511
|
*/
|
|
1512
1512
|
function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
|
|
@@ -1670,7 +1670,7 @@ function detectCycle(nodes: Map<string, TaskNode>): void {
|
|
|
1670
1670
|
for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
|
|
1671
1671
|
const startInPath = path.indexOf(idsArr[depIdx]!)
|
|
1672
1672
|
const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
|
|
1673
|
-
throw new UserError(`Cycle detected in task graph: ${cycle}
|
|
1673
|
+
throw new UserError(`Cycle detected in task graph: ${cycle}`, 'VX_E_CYCLE')
|
|
1674
1674
|
}
|
|
1675
1675
|
color[depIdx] = GRAY
|
|
1676
1676
|
stack.push(depIdx, 0)
|
package/src/index.ts
CHANGED
|
@@ -239,8 +239,25 @@ export type {
|
|
|
239
239
|
// The serializable run result and its per-task views (what `run()` returns).
|
|
240
240
|
export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
|
|
241
241
|
|
|
242
|
-
// The
|
|
243
|
-
// answer, which `@vzn/vx-mcp` serves. The event bus /
|
|
244
|
-
// other history readers (`listRuns`, `getRun`, …) left the
|
|
245
|
-
// with no consumer; the telemetry seam above is the
|
|
246
|
-
|
|
242
|
+
// The run-history queries a reader outside the CLI takes today: `vx why`'s
|
|
243
|
+
// answer, a run's failures and a task's log, which `@vzn/vx-mcp` serves. The event bus /
|
|
244
|
+
// wire form and the other history readers (`listRuns`, `getRun`, …) left the
|
|
245
|
+
// façade 2026-09-10 with no consumer; the telemetry seam above is the
|
|
246
|
+
// canonical export path.
|
|
247
|
+
export {
|
|
248
|
+
cacheKeyDiff,
|
|
249
|
+
latestRunId,
|
|
250
|
+
resolveRunId,
|
|
251
|
+
rootCauses,
|
|
252
|
+
runFailures,
|
|
253
|
+
taskLog,
|
|
254
|
+
whyDidThisRerunQuery,
|
|
255
|
+
} from './orchestrator/index.js'
|
|
256
|
+
export type {
|
|
257
|
+
CacheKeyDiff,
|
|
258
|
+
OutputLocation,
|
|
259
|
+
RootCause,
|
|
260
|
+
RunFailures,
|
|
261
|
+
TaskFailure,
|
|
262
|
+
TaskLog,
|
|
263
|
+
} from './orchestrator/index.js'
|