@vzn/vx 0.0.0 → 0.0.14

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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
package/src/cli/run.ts ADDED
@@ -0,0 +1,683 @@
1
+ import readline from 'node:readline/promises'
2
+ import path from 'node:path'
3
+ import {
4
+ affectedProjects,
5
+ applyFilters,
6
+ buildPackageGraph,
7
+ defaultAffectedBase,
8
+ findWorkspaceRoot,
9
+ listProjects,
10
+ loadProjectConfig,
11
+ loadWorkspace,
12
+ loadWorkspaceConfig,
13
+ parseFilter,
14
+ resolveCacheDir,
15
+ type ProjectMeta,
16
+ } from '../workspace/index.js'
17
+ import {
18
+ planRun,
19
+ formatRunReportMarkdown,
20
+ optionsToRequest,
21
+ resolveBackend as resolvePluginBackend,
22
+ type OutcomeView,
23
+ type RunOptions,
24
+ type RunResult,
25
+ type VxPlugin,
26
+ } from '../orchestrator/index.js'
27
+ import type { ContinueMode } from '../graph/index.js'
28
+ import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
29
+ import { parseSize } from '../util/index.js'
30
+ import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
31
+ import { localBackend } from './backend.js'
32
+
33
+ export interface RunArgs {
34
+ /** Failure propagation (`--continue[=never|deps-ok|always]`). */
35
+ continueMode?: ContinueMode
36
+ /**
37
+ * Positional task names. Each entry is either a bare task (`'build'`)
38
+ * — applied across the resolved project scope — or anchored as
39
+ * `'pkg#task'` to target a specific project directly. Multiple
40
+ * entries run in one orchestrator invocation with a shared graph.
41
+ */
42
+ tasks: string[]
43
+ filters: string[]
44
+ all: boolean
45
+ /**
46
+ * `'all'` → skip every `dependsOn` edge (run just the requested task).
47
+ * `[]` → no exclusion (default).
48
+ * `[...names]` → drop only these specific dep names.
49
+ */
50
+ excludeDependencies: 'all' | string[]
51
+ concurrency: number | undefined
52
+ /**
53
+ * Resolved 4-axis cache policy after applying `--cache` / `--no-cache`
54
+ * / `--force` in precedence order. Defaults to all-on.
55
+ */
56
+ cache: CachePolicy
57
+ /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
58
+ cacheDir: string | undefined
59
+ frozen: boolean
60
+ /**
61
+ * `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
62
+ * without their own `exec.retries`. Undefined when not passed.
63
+ */
64
+ retries: number | undefined
65
+ /**
66
+ * `--timeout <ms>` / `--timeout=<ms>`: run-level default task timeout for
67
+ * tasks without their own `exec.timeout`. Sits above the `VX_TASK_TIMEOUT`
68
+ * env and workspace `timeout` defaults. Undefined when not passed.
69
+ */
70
+ timeout: number | undefined
71
+ /**
72
+ * `--memory <size>` / `--memory=<size>`: memory budget (resolved to
73
+ * bytes) for `exec.resources.memory` reservations. Defaults to
74
+ * os.totalmem() when not passed — pass it in cgroup-limited containers.
75
+ */
76
+ memory: number | undefined
77
+ /**
78
+ * `--verify[=determinism|inputs|fingerprint|all]`: cache-correctness
79
+ * verification. Undefined when not passed. `determinism` re-runs and
80
+ * content-compares outputs; `inputs` sandboxes with the declared-input
81
+ * baseline and flags undeclared reads; `fingerprint` ships output-tree
82
+ * fingerprints for the cross-machine diff (no re-run; the determinism
83
+ * modes set it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`)
84
+ * exempts tasks from failing the run on a divergence.
85
+ */
86
+ verify: { determinism: boolean; inputs: boolean; fingerprint: boolean } | undefined
87
+ verifyAllow: string[]
88
+ outputLogs?: 'full' | 'errors-only' | 'none'
89
+ forwardArgs: string[]
90
+ verbosity: number
91
+ dry: 'text' | 'json' | undefined
92
+ graph: string | undefined
93
+ summarize: string | undefined
94
+ profile: string | undefined
95
+ /**
96
+ * `--affected[=<base>]`. When undefined the flag wasn't passed.
97
+ * Empty string means "use the default base" (resolved later via
98
+ * `defaultAffectedBase`). Any other string is an explicit git ref.
99
+ */
100
+ affected: string | undefined
101
+ /** `--tag k=v` pairs (repeatable). Empty key is a parse error. */
102
+ tags: Record<string, string>
103
+ /** `--report[=markdown]`. Undefined when the flag wasn't passed. */
104
+ report: 'markdown' | undefined
105
+ error?: string
106
+ }
107
+
108
+ export function parseRunArgs(args: readonly string[]): RunArgs {
109
+ const out: RunArgs = {
110
+ tasks: [],
111
+ filters: [],
112
+ all: false,
113
+ excludeDependencies: [],
114
+ concurrency: undefined,
115
+ cache: { ...FULL_CACHE_POLICY },
116
+ cacheDir: undefined,
117
+ frozen: false,
118
+ retries: undefined,
119
+ timeout: undefined,
120
+ memory: undefined,
121
+ verify: undefined,
122
+ verifyAllow: [],
123
+ forwardArgs: [],
124
+ verbosity: 0,
125
+ dry: undefined,
126
+ graph: undefined,
127
+ summarize: undefined,
128
+ profile: undefined,
129
+ affected: undefined,
130
+ tags: {},
131
+ report: undefined,
132
+ }
133
+
134
+ const sepIdx = args.indexOf('--')
135
+ const before = sepIdx === -1 ? args : args.slice(0, sepIdx)
136
+ out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
137
+
138
+ // Cache policy is resolved AFTER the loop in precedence order:
139
+ // start all-true → apply each `--cache` spec → `--no-cache` forces
140
+ // all false → `--force` forces both reads false. So `--no-cache`
141
+ // beats `--force`, and `--cache` is the base both may override.
142
+ let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
143
+ let cacheSpecError: string | undefined
144
+ let noCacheFlag = false
145
+ let forceFlag = false
146
+
147
+ for (let i = 0; i < before.length; i++) {
148
+ const a = before[i]
149
+ if (a === '--filter') {
150
+ const v = before[++i]
151
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
152
+ out.filters.push(v)
153
+ } else if (a === '--concurrency') {
154
+ const v = before[++i]
155
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
156
+ const n = Number(v)
157
+ if (!Number.isFinite(n) || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
158
+ out.concurrency = Math.floor(n)
159
+ } else if (a === '--all') {
160
+ out.all = true
161
+ } else if (a === '--excludeDependencies') {
162
+ out.excludeDependencies = 'all'
163
+ } else if (a?.startsWith('--excludeDependencies=')) {
164
+ const raw = a.slice('--excludeDependencies='.length)
165
+ out.excludeDependencies = raw
166
+ .split(',')
167
+ .map((s) => s.trim())
168
+ .filter((s) => s.length > 0)
169
+ } else if (a === '--frozen') {
170
+ out.frozen = true
171
+ } else if (a === '--retry' || a?.startsWith('--retry=')) {
172
+ const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
173
+ if (v === undefined) return { ...out, error: `--retry requires a value` }
174
+ const n = Number(v)
175
+ if (v === '' || !Number.isInteger(n) || n < 0) {
176
+ return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
177
+ }
178
+ out.retries = n
179
+ } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
180
+ const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
181
+ if (v === undefined) return { ...out, error: `--timeout requires a value` }
182
+ const n = Number(v)
183
+ if (v === '' || !Number.isInteger(n) || n <= 0) {
184
+ return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
185
+ }
186
+ out.timeout = n
187
+ } else if (a === '--memory' || a?.startsWith('--memory=')) {
188
+ const v = a === '--memory' ? before[++i] : a.slice('--memory='.length)
189
+ if (v === undefined || v === '') return { ...out, error: `--memory requires a value` }
190
+ const bytes = parseSize(v)
191
+ if (bytes === null || bytes <= 0) {
192
+ return { ...out, error: `--memory must be a size like 8GB or 512MB, got: ${v}` }
193
+ }
194
+ out.memory = bytes
195
+ } else if (a === '--verify' || a?.startsWith('--verify=')) {
196
+ const what = a === '--verify' ? 'determinism' : a.slice('--verify='.length)
197
+ if (what === 'determinism') {
198
+ out.verify = { determinism: true, inputs: false, fingerprint: true }
199
+ } else if (what === 'inputs') {
200
+ out.verify = { determinism: false, inputs: true, fingerprint: false }
201
+ } else if (what === 'fingerprint') {
202
+ out.verify = { determinism: false, inputs: false, fingerprint: true }
203
+ } else if (what === 'all') {
204
+ out.verify = { determinism: true, inputs: true, fingerprint: true }
205
+ } else {
206
+ return {
207
+ ...out,
208
+ error: `--verify must be determinism | inputs | fingerprint | all (or bare --verify), got: ${what}`,
209
+ }
210
+ }
211
+ } else if (a?.startsWith('--verify-allow=')) {
212
+ out.verifyAllow = a
213
+ .slice('--verify-allow='.length)
214
+ .split(',')
215
+ .map((s) => s.trim())
216
+ .filter((s) => s.length > 0)
217
+ } else if (a === '--output-logs') {
218
+ const v = before[++i]
219
+ if (v !== 'full' && v !== 'errors-only' && v !== 'none') {
220
+ return { ...out, error: `--output-logs must be full, errors-only, or none` }
221
+ }
222
+ out.outputLogs = v
223
+ } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
224
+ const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
225
+ if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a value` }
226
+ out.cacheDir = v
227
+ } else if (a === '--cache') {
228
+ const v = before[++i]
229
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
230
+ try {
231
+ cachePolicy = parseCachePolicy(v, cachePolicy)
232
+ } catch (err) {
233
+ cacheSpecError = err instanceof Error ? err.message : String(err)
234
+ }
235
+ } else if (a?.startsWith('--cache=')) {
236
+ const v = a.slice('--cache='.length)
237
+ try {
238
+ cachePolicy = parseCachePolicy(v, cachePolicy)
239
+ } catch (err) {
240
+ cacheSpecError = err instanceof Error ? err.message : String(err)
241
+ }
242
+ } else if (a === '--no-cache') {
243
+ noCacheFlag = true
244
+ } else if (a === '--force') {
245
+ forceFlag = true
246
+ } else if (a === '--continue') {
247
+ // Bare --continue = 'always' (the Turbo convention).
248
+ out.continueMode = 'always'
249
+ } else if (a?.startsWith('--continue=')) {
250
+ const v = a.slice('--continue='.length)
251
+ if (v !== 'never' && v !== 'deps-ok' && v !== 'always') {
252
+ return { ...out, error: `--continue must be never, deps-ok, or always` }
253
+ }
254
+ out.continueMode = v
255
+ } else if (a === '--verbosity') {
256
+ const v = before[++i]
257
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
258
+ const n = Number(v)
259
+ if (!Number.isInteger(n) || n < 0) return { ...out, error: `invalid verbosity: ${v}` }
260
+ out.verbosity = n
261
+ } else if (a === '--dry') {
262
+ out.dry = 'text'
263
+ } else if (a?.startsWith('--dry=')) {
264
+ const fmt = a.slice('--dry='.length)
265
+ if (fmt !== 'text' && fmt !== 'json') {
266
+ return { ...out, error: `invalid --dry value: ${fmt}` }
267
+ }
268
+ out.dry = fmt
269
+ } else if (a === '--graph') {
270
+ out.graph = ''
271
+ } else if (a?.startsWith('--graph=')) {
272
+ out.graph = a.slice('--graph='.length)
273
+ } else if (a === '--summarize') {
274
+ out.summarize = ''
275
+ } else if (a?.startsWith('--summarize=')) {
276
+ out.summarize = a.slice('--summarize='.length)
277
+ } else if (a === '--profile') {
278
+ out.profile = 'profile.json'
279
+ } else if (a?.startsWith('--profile=')) {
280
+ out.profile = a.slice('--profile='.length)
281
+ } else if (a === '--affected') {
282
+ out.affected = ''
283
+ } else if (a?.startsWith('--affected=')) {
284
+ out.affected = a.slice('--affected='.length)
285
+ } else if (a === '--tag' || a?.startsWith('--tag=')) {
286
+ const raw = a === '--tag' ? before[++i] : a.slice('--tag='.length)
287
+ if (raw === undefined) return { ...out, error: `${a} requires a value` }
288
+ // Split on the FIRST `=` so values may contain `=` (e.g. a URL).
289
+ const eq = raw.indexOf('=')
290
+ if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
291
+ out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
292
+ } else if (a === '--report') {
293
+ out.report = 'markdown'
294
+ } else if (a?.startsWith('--report=')) {
295
+ const fmt = a.slice('--report='.length)
296
+ if (fmt !== 'markdown') {
297
+ return { ...out, error: `invalid --report value: ${fmt} (only markdown)` }
298
+ }
299
+ out.report = fmt
300
+ } else if (a !== undefined && a.startsWith('-')) {
301
+ return { ...out, error: `unknown flag: ${a}` }
302
+ } else if (a !== undefined) {
303
+ out.tasks.push(a)
304
+ }
305
+ }
306
+
307
+ if (cacheSpecError !== undefined) {
308
+ return { ...out, error: cacheSpecError }
309
+ }
310
+ // Precedence: --no-cache (all off) beats --force (reads off, writes
311
+ // kept), both layered on top of any --cache spec.
312
+ if (noCacheFlag) {
313
+ cachePolicy = { localRead: false, localWrite: false, remoteRead: false, remoteWrite: false }
314
+ }
315
+ if (forceFlag) {
316
+ cachePolicy = { ...cachePolicy, localRead: false, remoteRead: false }
317
+ }
318
+ out.cache = cachePolicy
319
+
320
+ if (out.dry !== undefined && out.graph !== undefined) {
321
+ return { ...out, error: '--dry and --graph are mutually exclusive' }
322
+ }
323
+ if (out.dry !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
324
+ return { ...out, error: '--dry skips execution; --summarize / --profile need a real run' }
325
+ }
326
+ if (out.graph !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
327
+ return { ...out, error: '--graph skips execution; --summarize / --profile need a real run' }
328
+ }
329
+ return out
330
+ }
331
+
332
+ /**
333
+ * Classify the run's intent from its selection flags. BROAD iff the
334
+ * invocation used `--all` / `--filter` / `--affected` — the user asked
335
+ * about a swath of the workspace and wants news, not output. Everything
336
+ * else is FOCUSED — the user is running "their" task and wants to see
337
+ * it. Owner decision: cwd and task count are irrelevant ("when just run
338
+ * no --all etc then single. cwd does not matter").
339
+ */
340
+ export function detectFlow(
341
+ parsed: Pick<RunArgs, 'all' | 'filters' | 'affected'>,
342
+ ): 'focused' | 'broad' {
343
+ return parsed.all || parsed.filters.length > 0 || parsed.affected !== undefined
344
+ ? 'broad'
345
+ : 'focused'
346
+ }
347
+
348
+ /**
349
+ * Resolve parsed `vx run` argv into the `RunOptions` the orchestrator
350
+ * consumes. Shared between `runCmd` and `watchCmd` so both subcommands
351
+ * honor the same selection semantics (cwd / `--all` / `--filter` /
352
+ * `--affected` / anchored positionals).
353
+ *
354
+ * Returns either the resolved options or an error message string. The
355
+ * caller is responsible for prefixing the message with its subcommand
356
+ * name (`vx run: <msg>` / `vx watch: <msg>`) and exiting non-zero.
357
+ *
358
+ * Assumes the caller has already populated `parsed.tasks` (e.g. via
359
+ * the interactive picker for `vx run`).
360
+ */
361
+ export async function resolveRunOptions(
362
+ parsed: RunArgs,
363
+ cwd: string,
364
+ tasks: readonly string[],
365
+ ): Promise<RunOptions | { error: string }> {
366
+ for (const t of tasks) {
367
+ const idx = t.indexOf('#')
368
+ if (idx >= 0) {
369
+ const project = t.slice(0, idx)
370
+ const task = t.slice(idx + 1)
371
+ if (!project || !task) {
372
+ return { error: `invalid pkg#task: ${t}` }
373
+ }
374
+ }
375
+ }
376
+
377
+ // `--affected[=<base>]` is sugar for `--filter '[<base>]'`. Merging
378
+ // it into the filter list means the same code path handles plain
379
+ // filter use, --affected alone, and the combo.
380
+ const filterStrings = [...parsed.filters]
381
+ if (parsed.affected !== undefined) {
382
+ const root = await findWorkspaceRoot(cwd)
383
+ const base = parsed.affected === '' ? await defaultAffectedBase(root) : parsed.affected
384
+ filterStrings.push(`[${base}]`)
385
+ }
386
+
387
+ // Project scope applies to bare task names only. Anchored entries
388
+ // (pkg#task) resolve directly to their own project regardless.
389
+ const bareTasks = tasks.filter((t) => !t.includes('#'))
390
+ let projects: string[] | undefined
391
+ if (bareTasks.length === 0) {
392
+ projects = undefined
393
+ } else if (filterStrings.length > 0) {
394
+ const resolved = await resolveFilters(cwd, filterStrings)
395
+ if (resolved.error) return { error: resolved.error }
396
+ projects = resolved.names
397
+ } else if (parsed.all) {
398
+ projects = undefined
399
+ } else {
400
+ const cwdProject = await findCwdProject(cwd)
401
+ if (!cwdProject) {
402
+ return {
403
+ error:
404
+ 'not inside a project. Pass --all for every project, --filter <pattern> to filter, or run from within a project directory.',
405
+ }
406
+ }
407
+ projects = [cwdProject]
408
+ }
409
+
410
+ const opts: RunOptions = {
411
+ cwd,
412
+ tasks: [...tasks],
413
+ cache: parsed.cache,
414
+ flow: detectFlow(parsed),
415
+ ...(parsed.frozen ? { frozen: true } : {}),
416
+ ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
417
+ ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
418
+ forwardArgs: parsed.forwardArgs,
419
+ }
420
+ if (parsed.excludeDependencies === 'all') {
421
+ opts.excludeDependencies = 'all'
422
+ } else if (parsed.excludeDependencies.length > 0) {
423
+ opts.excludeDependencies = parsed.excludeDependencies
424
+ }
425
+ if (projects !== undefined) opts.projects = projects
426
+ if (parsed.retries !== undefined) opts.retries = parsed.retries
427
+ if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
428
+ if (parsed.memory !== undefined) opts.memory = parsed.memory
429
+ if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
430
+ if (parsed.verify !== undefined) {
431
+ opts.verify = {
432
+ determinism: parsed.verify.determinism,
433
+ inputs: parsed.verify.inputs,
434
+ fingerprint: parsed.verify.fingerprint,
435
+ allow: new Set(parsed.verifyAllow),
436
+ }
437
+ }
438
+ if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
439
+ if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
440
+ if (parsed.profile !== undefined) opts.profile = parsed.profile
441
+ if (Object.keys(parsed.tags).length > 0) opts.tags = parsed.tags
442
+
443
+ return opts
444
+ }
445
+
446
+ export async function runCmd(args: readonly string[]): Promise<number> {
447
+ const parsed = parseRunArgs(args)
448
+ if (parsed.error) {
449
+ process.stderr.write(`vx run: ${parsed.error}\n`)
450
+ return 1
451
+ }
452
+
453
+ const cwd = process.cwd()
454
+ let tasks = [...parsed.tasks]
455
+
456
+ // No positionals → interactive picker (TTY only). Yields a single
457
+ // anchored pkg#task; the rest of the pipeline treats it like any
458
+ // explicit anchored positional.
459
+ if (tasks.length === 0) {
460
+ if (!process.stdin.isTTY) {
461
+ process.stderr.write(`vx run: missing task name (stdin is not a TTY)\n`)
462
+ return 1
463
+ }
464
+ const picked = await pickTask(cwd)
465
+ if (!picked) return 1
466
+ tasks = [`${picked.project}#${picked.task}`]
467
+ }
468
+
469
+ const resolved = await resolveRunOptions(parsed, cwd, tasks)
470
+ if ('error' in resolved) {
471
+ process.stderr.write(`vx run: ${resolved.error}\n`)
472
+ return 1
473
+ }
474
+ const opts = resolved
475
+ // The raw invocation, recorded on the `invocations` row so dashboards
476
+ // show what was actually run. `args` is everything after `run`.
477
+ opts.command = ['vx', 'run', ...args].join(' ')
478
+
479
+ // Planning paths short-circuit execution. Both build the full task
480
+ // graph + probe the cache; the difference is just the formatter.
481
+ if (parsed.dry !== undefined || parsed.graph !== undefined) {
482
+ const plan = await planRun(opts)
483
+ if (plan.tasks.length === 0) {
484
+ process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
485
+ return 1
486
+ }
487
+ if (parsed.graph !== undefined) {
488
+ const out = formatGraphDot(plan)
489
+ if (parsed.graph === '') {
490
+ process.stdout.write(out)
491
+ } else {
492
+ await Bun.write(parsed.graph, out)
493
+ }
494
+ } else if (parsed.dry === 'json') {
495
+ process.stdout.write(formatPlanJson(plan))
496
+ } else {
497
+ process.stdout.write(formatPlanText(plan))
498
+ }
499
+ return 0
500
+ }
501
+
502
+ // Resolve where this run executes. A plugin's `backend` capability wins
503
+ // first (e.g. a backend plugin routes to a local-or-hosted service);
504
+ // otherwise core's default is pure in-process. Core names no service —
505
+ // delegation is entirely a plugin concern.
506
+ const request = optionsToRequest(opts)
507
+ const root = await findWorkspaceRoot(cwd)
508
+ const workspaceConfig = await loadWorkspaceConfig(root)
509
+ const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
510
+ const backend = await resolvePluginBackend(
511
+ plugins,
512
+ {
513
+ workspaceRoot: root,
514
+ cacheDir: resolveCacheDir(root, workspaceConfig),
515
+ warn: (m) => process.stderr.write(`${m}\n`),
516
+ request,
517
+ },
518
+ () => Promise.resolve(localBackend()),
519
+ )
520
+ const result = await backend.run(request)
521
+ if (parsed.verbosity > 0) printSummary(result)
522
+ // Report generation is post-run, gated on the flag — zero cost when
523
+ // absent. Goes to stdout (NOT the status logger) so it stays
524
+ // machine-clean for `vx run ci --report=markdown >> $GITHUB_STEP_SUMMARY`.
525
+ if (parsed.report === 'markdown') {
526
+ process.stdout.write(formatRunReportMarkdown(result))
527
+ }
528
+ return result.ok ? 0 : 1
529
+ }
530
+
531
+ async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
532
+ const root = await findWorkspaceRoot(cwd)
533
+ const ws = await loadWorkspace(root)
534
+ return await listProjects(ws)
535
+ }
536
+
537
+ async function findCwdProject(cwd: string): Promise<string | null> {
538
+ const projects = await loadWorkspaceProjects(cwd)
539
+ const abs = path.resolve(cwd)
540
+ let best: ProjectMeta | null = null
541
+ for (const p of projects) {
542
+ if (abs === p.dir || abs.startsWith(p.dir + path.sep)) {
543
+ if (!best || p.dir.length > best.dir.length) best = p
544
+ }
545
+ }
546
+ return best?.name ?? null
547
+ }
548
+
549
+ async function resolveFilters(
550
+ cwd: string,
551
+ raw: string[],
552
+ ): Promise<{ names?: string[]; error?: string }> {
553
+ const root = await findWorkspaceRoot(cwd)
554
+ const projects = await loadWorkspaceProjects(cwd)
555
+ const graph = buildPackageGraph(projects)
556
+ const parsed = raw.map((r) => parseFilter(r, root))
557
+
558
+ // Resolve every `[<since>]` filter against git before the pure
559
+ // applyFilters pass runs. One spawn per distinct ref — usually
560
+ // there's only one anyway.
561
+ const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
562
+ for (const f of parsed) {
563
+ if (f.gitSince === undefined) continue
564
+ try {
565
+ const names = await affectedProjects({ workspaceRoot: root, since: f.gitSince, projects })
566
+ affectedByFilter.set(f, names)
567
+ } catch (err) {
568
+ const msg = err instanceof Error ? err.message : String(err)
569
+ return { error: msg }
570
+ }
571
+ }
572
+
573
+ const selected = applyFilters({ filters: parsed, projects, graph, affectedByFilter })
574
+ if (selected.size === 0) {
575
+ return { error: `no projects matched filter(s): ${raw.join(', ')}` }
576
+ }
577
+ return { names: [...selected].sort() }
578
+ }
579
+
580
+ interface PickedTask {
581
+ project: string
582
+ task: string
583
+ description?: string
584
+ }
585
+
586
+ export async function pickTask(
587
+ cwd: string,
588
+ io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
589
+ ): Promise<PickedTask | null> {
590
+ const projects = await loadWorkspaceProjects(cwd)
591
+ const entries: PickedTask[] = []
592
+ for (const meta of projects) {
593
+ if (!meta.configPath) continue
594
+ const config = await loadProjectConfig(meta.configPath)
595
+ const taskNames = Object.keys(config.tasks ?? {}).sort()
596
+ for (const t of taskNames) {
597
+ const desc = config.tasks?.[t]?.description
598
+ entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
599
+ }
600
+ }
601
+ if (entries.length === 0) {
602
+ process.stderr.write(`vx run: no tasks declared in any project\n`)
603
+ return null
604
+ }
605
+ const out = io.output ?? process.stdout
606
+ const numW = String(entries.length).length
607
+ const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
608
+ out.write('Tasks:\n')
609
+ entries.forEach((e, i) => {
610
+ const n = String(i + 1).padStart(numW, ' ')
611
+ const id = `${e.project}#${e.task}`.padEnd(idW)
612
+ const desc = e.description ? ` ${e.description}` : ''
613
+ out.write(` ${n}. ${id}${desc}\n`)
614
+ })
615
+ const rl = readline.createInterface({
616
+ input: io.input ?? process.stdin,
617
+ output: io.output ?? process.stdout,
618
+ })
619
+ try {
620
+ const answer = (await rl.question(`Pick a task [1-${entries.length}]: `)).trim()
621
+ const n = Number(answer)
622
+ if (!Number.isInteger(n) || n < 1 || n > entries.length) {
623
+ process.stderr.write(`vx run: invalid selection: ${answer}\n`)
624
+ return null
625
+ }
626
+ return entries[n - 1] ?? null
627
+ } finally {
628
+ rl.close()
629
+ }
630
+ }
631
+
632
+ function printSummary(summary: RunResult): void {
633
+ const rows = summary.outcomes.map((o) => formatRow(o))
634
+ if (rows.length === 0) return
635
+ const widths = {
636
+ task: Math.max(4, ...rows.map((r) => r.task.length)),
637
+ status: Math.max(6, ...rows.map((r) => r.status.length)),
638
+ duration: Math.max(8, ...rows.map((r) => r.duration.length)),
639
+ }
640
+ const header =
641
+ 'TASK'.padEnd(widths.task) +
642
+ ' ' +
643
+ 'STATUS'.padEnd(widths.status) +
644
+ ' ' +
645
+ 'DURATION'.padStart(widths.duration)
646
+ process.stdout.write(`\n${header}\n`)
647
+ process.stdout.write('-'.repeat(header.length) + '\n')
648
+ for (const r of rows) {
649
+ process.stdout.write(
650
+ r.task.padEnd(widths.task) +
651
+ ' ' +
652
+ r.status.padEnd(widths.status) +
653
+ ' ' +
654
+ r.duration.padStart(widths.duration) +
655
+ '\n',
656
+ )
657
+ }
658
+ }
659
+
660
+ function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
661
+ // Same outcome vocabulary as the framed blocks + summary:
662
+ // executed / restored-local / restored-remote / up-to-date /
663
+ // failed / skipped.
664
+ const status =
665
+ o.status === 'cache-hit'
666
+ ? o.restored === false
667
+ ? 'up-to-date'
668
+ : 'restored-local'
669
+ : o.status === 'cache-hit-remote'
670
+ ? o.restored === false
671
+ ? 'up-to-date'
672
+ : 'restored-remote'
673
+ : o.status === 'success'
674
+ ? 'executed'
675
+ : o.status === 'failed'
676
+ ? `failed (exit ${o.exitCode})`
677
+ : o.status
678
+ return {
679
+ task: o.taskId,
680
+ status,
681
+ duration: `${o.durationMs}ms`,
682
+ }
683
+ }