@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.
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- 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
|
+
}
|