@vzn/vx 0.0.21 → 0.0.22
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/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/index.ts
CHANGED
|
@@ -30,6 +30,10 @@ export { collectInfo, type CollectInfoOptions, type InfoFacts } from './orchestr
|
|
|
30
30
|
// first; a policy that budgets memory (`@vzn/vx-schedule-history`) reads
|
|
31
31
|
// the second, so the cgroup walk has one implementation.
|
|
32
32
|
export { machineMemoryBytes, machineParallelism } from './util/index.js'
|
|
33
|
+
// How core reads a task glob — its spelling, and which characters are
|
|
34
|
+
// wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
|
|
35
|
+
// outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
|
|
36
|
+
export { isLiteralPattern, normalizeGlob } from './util/index.js'
|
|
33
37
|
|
|
34
38
|
// Schema types and helpers (used by user vx.config files and presets).
|
|
35
39
|
export type {
|
|
@@ -60,6 +64,11 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
|
|
|
60
64
|
export { LocalHistoryProvider } from './orchestrator/index.js'
|
|
61
65
|
export type { HistoryProvider, HistoryTable, TaskHistory } from './orchestrator/index.js'
|
|
62
66
|
export type { PreparedRun } from './orchestrator/index.js'
|
|
67
|
+
// `planRun` returns these, and an embedder that cannot NAME a return type
|
|
68
|
+
// cannot hold it: `run` and `prepareRun` both had theirs here from the
|
|
69
|
+
// start, so the omission read as "planRun is not really public" while
|
|
70
|
+
// docs/cli.md § Programmatic API listed all three (item 387, 2026-09-19).
|
|
71
|
+
export type { RunPlan, PlannedTask } from './orchestrator/index.js'
|
|
63
72
|
export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './orchestrator/index.js'
|
|
64
73
|
export type {
|
|
65
74
|
CachePolicy,
|
|
@@ -72,7 +81,7 @@ export type {
|
|
|
72
81
|
// with only `taskId()` to JOIN an id and nothing exported to SPLIT one,
|
|
73
82
|
// consumers roll their own `split('#', 2)` and drift from the graph, which
|
|
74
83
|
// splits on the FIRST '#'.
|
|
75
|
-
export { splitTaskId } from './graph/index.js'
|
|
84
|
+
export { outputsOverlap, splitTaskId } from './graph/index.js'
|
|
76
85
|
export type { TaskNode, TaskOutcome, TaskStatus } from './graph/index.js'
|
|
77
86
|
|
|
78
87
|
// Cache classes + the layer interface (the `cache` capability's currency).
|
|
@@ -106,6 +115,7 @@ export type { ProjectEntry } from './workspace/index.js'
|
|
|
106
115
|
// `@vzn/vx-migrate` (Turbo, Nx) and any other adoption tool use it from here.
|
|
107
116
|
export {
|
|
108
117
|
applyMigration,
|
|
118
|
+
foldScriptHooks,
|
|
109
119
|
PERSISTENT_TASK_NAMES,
|
|
110
120
|
PERSISTENT_TODO,
|
|
111
121
|
quoteTsLiteral,
|
|
@@ -193,6 +203,10 @@ export {
|
|
|
193
203
|
// a hit's bytes belong to the run that executed) are a decision, not an
|
|
194
204
|
// implementation detail. Two sinks rolling their own is how they fork.
|
|
195
205
|
export { LOG_WIRE_VERSION, TaskLogBuffer } from './orchestrator/index.js'
|
|
206
|
+
// `exitSignal` is on the façade on the same demonstrated need: a sink that
|
|
207
|
+
// renders a failed task's exit code faces 137 where the frame and `vx last`
|
|
208
|
+
// say `128 + SIGKILL`; the decode is the runner's convention, one copy.
|
|
209
|
+
export { exitSignal } from './orchestrator/index.js'
|
|
196
210
|
export type {
|
|
197
211
|
CacheSource,
|
|
198
212
|
RunContextRecord,
|
|
@@ -212,4 +226,4 @@ export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
|
|
|
212
226
|
// answer, which `@vzn/vx-mcp` serves. The event bus / wire form and the
|
|
213
227
|
// other history readers (`listRuns`, `getRun`, …) left the façade 2026-09-10
|
|
214
228
|
// with no consumer; the telemetry seam above is the canonical export path.
|
|
215
|
-
export { whyDidThisRerunQuery } from './orchestrator/index.js'
|
|
229
|
+
export { latestRunId, whyDidThisRerunQuery } from './orchestrator/index.js'
|
|
@@ -13,36 +13,42 @@
|
|
|
13
13
|
// every success built on it — or a grand-dependent would cache the same
|
|
14
14
|
// partial tree one hop later. Only that mode ever executes a task behind
|
|
15
15
|
// a failure; the other modes skip it, so a default run carries no check.
|
|
16
|
+
// `--exclude-dependencies` seeds the same taint: a task whose key folds a
|
|
17
|
+
// dependency that did not run has bytes nothing vouches for
|
|
18
|
+
// (excluded-keys.ts).
|
|
16
19
|
//
|
|
17
20
|
// Split from `run.ts` on 2026-09-10 (pure motion).
|
|
18
21
|
|
|
19
22
|
import type { CachePolicy } from '../cache/index.js'
|
|
20
|
-
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
23
|
+
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
21
24
|
import { executeTask, type ExecuteArgs } from './execute-task.js'
|
|
22
25
|
import type { ShortCircuit } from './local-shortcircuit.js'
|
|
23
26
|
import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
|
-
* Records which tasks ran behind a failure
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
+
* Records which tasks ran behind a failure, or on the key of a dependency
|
|
30
|
+
* that did not run (`seeds`), and answers, per task, whether this one does.
|
|
31
|
+
* Disabled (always `false`, nothing recorded) unless the run's
|
|
32
|
+
* `continueMode` is `'always'` or there is a seed.
|
|
29
33
|
*/
|
|
30
34
|
export function taintTracker(
|
|
31
|
-
|
|
35
|
+
continueAlways: boolean,
|
|
36
|
+
seeds: ReadonlySet<string>,
|
|
32
37
|
): (node: TaskNode, upstream: TaskOutcome[]) => boolean {
|
|
33
|
-
if (!
|
|
38
|
+
if (!continueAlways && seeds.size === 0) return () => false
|
|
34
39
|
const tainted = new Set<string>()
|
|
35
40
|
return (node, upstream) => {
|
|
36
|
-
const taint =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
(u
|
|
42
|
-
u
|
|
43
|
-
u.
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
const taint =
|
|
42
|
+
seeds.has(node.id) ||
|
|
43
|
+
upstream.some(
|
|
44
|
+
// A restore-tier task may run before its deps and see holes here;
|
|
45
|
+
// it never saves anyway (a hit restores), so a hole is not taint.
|
|
46
|
+
(u) =>
|
|
47
|
+
u !== undefined &&
|
|
48
|
+
(tainted.has(u.node.id) ||
|
|
49
|
+
(continueAlways &&
|
|
50
|
+
(u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped'))),
|
|
51
|
+
)
|
|
46
52
|
if (taint) tainted.add(node.id)
|
|
47
53
|
return taint
|
|
48
54
|
}
|
|
@@ -70,6 +76,15 @@ export function admitTasks(
|
|
|
70
76
|
args: AdmissionArgs,
|
|
71
77
|
): (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome> {
|
|
72
78
|
const { inflight, policy, shortCircuit, hashArgs, buildExecuteArgs } = args
|
|
79
|
+
// A probed hit whose artifact vanished before its restore is thrown back
|
|
80
|
+
// to the scheduler (`RestoreDemoted`, execute-task.ts), which dispatches
|
|
81
|
+
// the task again once its deps are done. That dispatch probes for itself:
|
|
82
|
+
// the up-front probe answered for an artifact that is gone. The task
|
|
83
|
+
// stays in the restore-tier SET, so it still skips dedup below.
|
|
84
|
+
const dropProbe = (err: unknown): never => {
|
|
85
|
+
if (err instanceof RestoreDemoted) shortCircuit.preProbed.delete(err.taskId)
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
73
88
|
return async (node, upstream) => {
|
|
74
89
|
// Dedup only helps when the sibling will WRITE the artifact and this
|
|
75
90
|
// task can READ it back — i.e. both axes effectively on.
|
|
@@ -87,8 +102,15 @@ export function admitTasks(
|
|
|
87
102
|
// wrong. Route it straight to executeTask, which reuses the up-front
|
|
88
103
|
// probe.
|
|
89
104
|
const restorable = shortCircuit.restoreTier.has(node.id)
|
|
105
|
+
// `!cacheable` is an early-out for bookkeeping, not a guard: measured
|
|
106
|
+
// (item 506), a non-cacheable task never finds a sibling barrier,
|
|
107
|
+
// because the hash folds the taskId and a taskId executes once per
|
|
108
|
+
// run. Removing it reddens nothing and a throw on "a non-cacheable
|
|
109
|
+
// task joined a barrier" never fires across the whole suite. It saves
|
|
110
|
+
// the set/delete, and the wait a joiner would spend before running the
|
|
111
|
+
// task anyway — there is no artifact for it to hit.
|
|
90
112
|
if (inflight === undefined || !cacheable || restorable) {
|
|
91
|
-
return executeTask(buildExecuteArgs(node, upstream))
|
|
113
|
+
return executeTask(buildExecuteArgs(node, upstream)).catch(dropProbe)
|
|
92
114
|
}
|
|
93
115
|
const { nestedDirsByProject, ...rest } = hashArgs
|
|
94
116
|
const hash = await computeTaskHash({
|
|
@@ -119,6 +141,8 @@ export function admitTasks(
|
|
|
119
141
|
// `return await`, deliberately: the finally must run after the task
|
|
120
142
|
// settles, not when its promise is handed back.
|
|
121
143
|
return await executeTask(execArgs)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return dropProbe(err)
|
|
122
146
|
} finally {
|
|
123
147
|
// The barrier lifts when the ENTRY is there, not when the task is:
|
|
124
148
|
// the save runs off the slot (save-lane.ts), and a sibling released
|
|
@@ -24,13 +24,18 @@ export interface PaintOptions {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Standard env precedence:
|
|
27
|
-
* NO_COLOR
|
|
28
|
-
* FORCE_COLOR=
|
|
27
|
+
* NO_COLOR non-empty → off (overrides FORCE_COLOR)
|
|
28
|
+
* FORCE_COLOR=0 or =false → off, even on a TTY
|
|
29
|
+
* FORCE_COLOR any other value → on (the empty string too)
|
|
29
30
|
* else: on iff stdout is a TTY.
|
|
31
|
+
* `0` and `false` are the convention's "off" (supports-color, chalk,
|
|
32
|
+
* Node); reading every non-empty value as on painted a piped CI log that
|
|
33
|
+
* set `FORCE_COLOR=0` (nx#35292's bug).
|
|
30
34
|
*/
|
|
31
35
|
export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
|
|
32
36
|
if (process.env['NO_COLOR']) return { enabled: false }
|
|
33
|
-
|
|
37
|
+
const force = process.env['FORCE_COLOR']
|
|
38
|
+
if (force !== undefined) return { enabled: force !== '0' && force !== 'false' }
|
|
34
39
|
return { enabled: stream.isTTY === true }
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -77,15 +77,24 @@ export class DeferredOutputs {
|
|
|
77
77
|
async materializeFor(node: TaskNode): Promise<void> {
|
|
78
78
|
const needed: string[] = []
|
|
79
79
|
const seen = new Set<string>()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
// Pre-order on an explicit stack of dependency lists: a closure is as
|
|
81
|
+
// deep as the graph, and a recursion per edge threw `RangeError` at the
|
|
82
|
+
// 50,000 the builder takes (item 737).
|
|
83
|
+
const stack: Array<[deps: readonly string[], next: number]> = [
|
|
84
|
+
[this.args.nodes.get(node.id)?.deps ?? [], 0],
|
|
85
|
+
]
|
|
86
|
+
while (stack.length > 0) {
|
|
87
|
+
const frame = stack[stack.length - 1]!
|
|
88
|
+
if (frame[1] === frame[0].length) {
|
|
89
|
+
stack.pop()
|
|
90
|
+
continue
|
|
86
91
|
}
|
|
92
|
+
const dep = frame[0][frame[1]++]!
|
|
93
|
+
if (seen.has(dep)) continue
|
|
94
|
+
seen.add(dep)
|
|
95
|
+
if (this.entries.has(dep)) needed.push(dep)
|
|
96
|
+
stack.push([this.args.nodes.get(dep)?.deps ?? [], 0])
|
|
87
97
|
}
|
|
88
|
-
walk(node.id)
|
|
89
98
|
if (needed.length === 0) return
|
|
90
99
|
await Promise.all(needed.map((id) => this.materializeOne(id)))
|
|
91
100
|
}
|
|
@@ -110,6 +119,12 @@ export class DeferredOutputs {
|
|
|
110
119
|
|
|
111
120
|
// Mirror `restoreHit`'s sequencing so the two paths cannot drift: wipe
|
|
112
121
|
// the declared outputs, write, then tell the git snapshot what moved.
|
|
122
|
+
// The wipe is a rule (a straggler in the declared tree is gone after
|
|
123
|
+
// the fetch); the two marks are the mirror's cost, not a rule: no
|
|
124
|
+
// reader of these outputs can be in the run — `deferralEligibility`
|
|
125
|
+
// keeps every same-project reader and every workspaceFiles reader
|
|
126
|
+
// eager — so deleting the post-fetch mark survives the whole core
|
|
127
|
+
// suite (item 643).
|
|
113
128
|
if (outputs.length > 0) {
|
|
114
129
|
const cleaned = await cleanOutputs(cleanArgs)
|
|
115
130
|
this.args.gitFilesCache?.markOutputsChanged(producer.projectDir, cleaned)
|
|
@@ -12,14 +12,18 @@ import { PLUGIN_HOOKS } from '../config.js'
|
|
|
12
12
|
import {
|
|
13
13
|
cgroupCpuQuota,
|
|
14
14
|
cgroupMemoryLimitBytes,
|
|
15
|
+
executablePath,
|
|
16
|
+
isUnsupportedBun,
|
|
15
17
|
machineMemoryBytes,
|
|
16
18
|
machineParallelism,
|
|
17
19
|
} from '../util/index.js'
|
|
18
20
|
import { VERSION } from '../version.js'
|
|
21
|
+
import { probeSandbox, resetSandbox } from '../exec/index.js'
|
|
19
22
|
import {
|
|
20
23
|
buildPackageGraph,
|
|
21
24
|
computeWorkspaceFingerprint,
|
|
22
25
|
findWorkspaceRoot,
|
|
26
|
+
type LoadReads,
|
|
23
27
|
listProjects,
|
|
24
28
|
loadProjectConfig,
|
|
25
29
|
loadWorkspace,
|
|
@@ -39,12 +43,27 @@ const warnToStderr = (message: string): void => {
|
|
|
39
43
|
export interface InfoFacts {
|
|
40
44
|
vx: string
|
|
41
45
|
bun: string
|
|
46
|
+
/**
|
|
47
|
+
* False when `bun` is below `MIN_BUN`. A separate field on purpose: `bun`
|
|
48
|
+
* is a machine surface (`vx info --format json`, and a test holds it to
|
|
49
|
+
* `Bun.version` exactly), so the verdict is a boolean here and prose only
|
|
50
|
+
* in the rendered row. What an unsupported runtime breaks is measured in
|
|
51
|
+
* `util/bun-version.ts`.
|
|
52
|
+
*/
|
|
53
|
+
bunSupported: boolean
|
|
42
54
|
git: string | null
|
|
43
55
|
/** null when git could not answer. */
|
|
44
56
|
gitStatusCache: { fsmonitor: boolean; untrackedCache: boolean } | null
|
|
45
57
|
workspaceRoot: string
|
|
46
58
|
projects: number
|
|
47
59
|
tasks: number
|
|
60
|
+
/**
|
|
61
|
+
* The project configs that did not load, each with the loader's own
|
|
62
|
+
* message (the path stripped). A broken config counts as zero tasks
|
|
63
|
+
* rather than failing the doctor — and the doctor says which, since a
|
|
64
|
+
* `0 tasks` that hides a typo is the one fact a bug report needs.
|
|
65
|
+
*/
|
|
66
|
+
configErrors: Array<{ path: string; message: string }>
|
|
48
67
|
plugins: Array<{ name: string; seams: string[] }>
|
|
49
68
|
/**
|
|
50
69
|
* The worker count a run defaults to and where it comes from: the
|
|
@@ -74,6 +93,14 @@ export interface InfoFacts {
|
|
|
74
93
|
/** Tasks the retained history shows both passing and failing on unchanged inputs. */
|
|
75
94
|
flakyTasks: FlakyTask[]
|
|
76
95
|
lockfile: boolean
|
|
96
|
+
/**
|
|
97
|
+
* Whether this host can run a task's `exec.sandbox`, and how many loaded
|
|
98
|
+
* tasks declare one. A declared sandbox whose runtime cannot start is a
|
|
99
|
+
* hard failure at run time, not a downgrade — so the doctor says so
|
|
100
|
+
* first (root inside a container, a missing bubblewrap, a nested
|
|
101
|
+
* seatbelt), with the probe's own reason.
|
|
102
|
+
*/
|
|
103
|
+
sandbox: { available: boolean; reason: string; declared: number }
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
export interface CollectInfoOptions {
|
|
@@ -85,19 +112,22 @@ export interface CollectInfoOptions {
|
|
|
85
112
|
|
|
86
113
|
export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): Promise<InfoFacts> {
|
|
87
114
|
const warn = opts.warn ?? warnToStderr
|
|
88
|
-
const
|
|
89
|
-
const
|
|
115
|
+
const reads: LoadReads = new Map()
|
|
116
|
+
const root = await findWorkspaceRoot(cwd, reads)
|
|
117
|
+
const metas = await listProjects(await loadWorkspace(root, reads))
|
|
90
118
|
const { workspaceConfig, plugins } = await loadWorkspacePlugins(root, warn)
|
|
91
119
|
const cacheDir =
|
|
92
120
|
opts.cacheDir === undefined
|
|
93
121
|
? resolveCacheDir(root, workspaceConfig)
|
|
94
122
|
: path.resolve(cwd, opts.cacheDir)
|
|
95
|
-
const cache =
|
|
123
|
+
const cache = Cache.inspect(cacheDir)
|
|
96
124
|
noteSchemaReset(cache, warn)
|
|
97
125
|
let stats
|
|
98
126
|
let orphans
|
|
99
127
|
let flaky: FlakyTask[]
|
|
100
128
|
let taskCount = 0
|
|
129
|
+
let sandboxed = 0
|
|
130
|
+
let configErrors: InfoFacts['configErrors'] = []
|
|
101
131
|
try {
|
|
102
132
|
stats = cache.stats()
|
|
103
133
|
orphans = await cache.orphanStats()
|
|
@@ -116,23 +146,42 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
|
|
|
116
146
|
seeds: 'all',
|
|
117
147
|
closure: false,
|
|
118
148
|
lock: null,
|
|
119
|
-
evalCache: {
|
|
149
|
+
evalCache: {
|
|
150
|
+
store: cache,
|
|
151
|
+
workspaceFingerprint: await computeWorkspaceFingerprint(root, reads),
|
|
152
|
+
},
|
|
120
153
|
warn,
|
|
121
154
|
})
|
|
122
155
|
for (const p of loaded.projects.values()) {
|
|
123
|
-
|
|
156
|
+
const tasks = p.config.tasks ?? {}
|
|
157
|
+
taskCount += Object.keys(tasks).length
|
|
158
|
+
for (const t of Object.values(tasks)) if (t?.exec?.sandbox !== undefined) sandboxed++
|
|
124
159
|
}
|
|
125
160
|
} catch {
|
|
126
|
-
|
|
161
|
+
// A config that will not load counts as zero, for both numbers: the
|
|
162
|
+
// sandbox row's "N tasks declare" must not read 0 because one other
|
|
163
|
+
// project's config is broken while this one's declares a sandbox.
|
|
164
|
+
;({
|
|
165
|
+
tasks: taskCount,
|
|
166
|
+
sandboxed,
|
|
167
|
+
errors: configErrors,
|
|
168
|
+
} = await countLoadableTasks(metas, root))
|
|
127
169
|
}
|
|
128
170
|
} finally {
|
|
129
171
|
cache.close()
|
|
130
172
|
}
|
|
131
173
|
|
|
132
174
|
const lockPresent = await Bun.file(lockfilePath(root)).exists()
|
|
175
|
+
const sandbox = await sandboxFact(sandboxed)
|
|
133
176
|
return {
|
|
134
177
|
vx: VERSION,
|
|
178
|
+
// An unsupported Bun does not stop a run, it makes the run's ANSWERS
|
|
179
|
+
// wrong (util/bun-version.ts measures which). The row a reader already
|
|
180
|
+
// consults for "is my setup sane" is where that belongs: a warning on
|
|
181
|
+
// every invocation would put a line on stderr that a clean run must not
|
|
182
|
+
// have, which is a property 19 tests hold on purpose.
|
|
135
183
|
bun: Bun.version,
|
|
184
|
+
bunSupported: !isUnsupportedBun(Bun.version),
|
|
136
185
|
git: gitVersion(),
|
|
137
186
|
// The one `git status` walk per run is the warm path's critical path on
|
|
138
187
|
// a large tree (~55 ms at 1000 projects, measured 2026-09-02). git's
|
|
@@ -142,6 +191,7 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
|
|
|
142
191
|
workspaceRoot: root,
|
|
143
192
|
projects: metas.length,
|
|
144
193
|
tasks: taskCount,
|
|
194
|
+
configErrors,
|
|
145
195
|
// Which plugins loaded and which seams each fills, in pipeline order —
|
|
146
196
|
// the answer to "why did this task run there / cache there / not at
|
|
147
197
|
// all" before reading any config. A declined seam still costs nothing;
|
|
@@ -168,9 +218,39 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
|
|
|
168
218
|
// this is the workspace's standing list.
|
|
169
219
|
flakyTasks: flaky,
|
|
170
220
|
lockfile: lockPresent,
|
|
221
|
+
sandbox,
|
|
171
222
|
}
|
|
172
223
|
}
|
|
173
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The runtime probe's verdict — one sandboxed `true` (memoized) — and the
|
|
227
|
+
* count of loaded tasks that would meet it. The probe initializes the
|
|
228
|
+
* Linux runtime, whose proxy sockets would keep a standalone process
|
|
229
|
+
* alive; reset after asking, since the doctor runs nothing.
|
|
230
|
+
*/
|
|
231
|
+
async function sandboxFact(declared: number): Promise<InfoFacts['sandbox']> {
|
|
232
|
+
try {
|
|
233
|
+
const verdict = await probeSandbox()
|
|
234
|
+
return { available: verdict.available, reason: stableSandboxReason(verdict.reason), declared }
|
|
235
|
+
} catch (err) {
|
|
236
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
237
|
+
return { available: false, reason: stableSandboxReason(message), declared }
|
|
238
|
+
} finally {
|
|
239
|
+
await resetSandbox()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The doctor's output is pasted into bug reports and compared between
|
|
245
|
+
* invocations (`vx stats` is pinned byte-identical to `vx info`), so a
|
|
246
|
+
* reason must not carry this process's id. The Linux runtime names its
|
|
247
|
+
* mux socket after the pid (`srt-mux-<pid>-<n>.sock`), and a listen that
|
|
248
|
+
* fails — a nested sandbox, a read-only tmpdir — quotes that path.
|
|
249
|
+
*/
|
|
250
|
+
export function stableSandboxReason(reason: string): string {
|
|
251
|
+
return reason.replace(/srt-mux-\d+-\d+\.sock/g, 'srt-mux-<pid>.sock')
|
|
252
|
+
}
|
|
253
|
+
|
|
174
254
|
/** The seams a plugin can fill, in pipeline order: the one hook list, less the lifecycle end. */
|
|
175
255
|
const SEAMS = PLUGIN_HOOKS.filter((h) => h !== 'teardown')
|
|
176
256
|
|
|
@@ -209,7 +289,7 @@ function memoryFact(): InfoFacts['memory'] {
|
|
|
209
289
|
function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
|
|
210
290
|
try {
|
|
211
291
|
const p = Bun.spawnSync({
|
|
212
|
-
cmd: ['git', 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
|
|
292
|
+
cmd: [executablePath('git'), 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
|
|
213
293
|
cwd: root,
|
|
214
294
|
stdout: 'pipe',
|
|
215
295
|
stderr: 'pipe',
|
|
@@ -227,7 +307,11 @@ function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
|
|
|
227
307
|
|
|
228
308
|
function gitVersion(): string | null {
|
|
229
309
|
try {
|
|
230
|
-
const p = Bun.spawnSync({
|
|
310
|
+
const p = Bun.spawnSync({
|
|
311
|
+
cmd: [executablePath('git'), '--version'],
|
|
312
|
+
stdout: 'pipe',
|
|
313
|
+
stderr: 'pipe',
|
|
314
|
+
})
|
|
231
315
|
if (p.exitCode !== 0) return null
|
|
232
316
|
return new TextDecoder()
|
|
233
317
|
.decode(p.stdout)
|
|
@@ -238,18 +322,42 @@ function gitVersion(): string | null {
|
|
|
238
322
|
}
|
|
239
323
|
}
|
|
240
324
|
|
|
241
|
-
async function countLoadableTasks(
|
|
242
|
-
|
|
325
|
+
async function countLoadableTasks(
|
|
326
|
+
metas: readonly ProjectMeta[],
|
|
327
|
+
root: string,
|
|
328
|
+
): Promise<{ tasks: number; sandboxed: number; errors: InfoFacts['configErrors'] }> {
|
|
329
|
+
let tasks = 0
|
|
330
|
+
let sandboxed = 0
|
|
331
|
+
const errors: InfoFacts['configErrors'] = []
|
|
243
332
|
await Promise.all(
|
|
244
333
|
metas.map(async (meta) => {
|
|
245
334
|
if (meta.configPath === null) return
|
|
246
335
|
try {
|
|
247
336
|
const config = await loadProjectConfig(meta.configPath)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
337
|
+
const declared = Object.values(config.tasks ?? {})
|
|
338
|
+
tasks += declared.length
|
|
339
|
+
for (const t of declared) if (t?.exec?.sandbox !== undefined) sandboxed++
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// Counted as zero, and named: the loader's message opens with the
|
|
342
|
+
// absolute path — bare for a schema refusal, behind `Project config`
|
|
343
|
+
// for an import that failed — which the row carries
|
|
344
|
+
// workspace-relative instead, once (the second form named the file
|
|
345
|
+
// twice, 2026-09-16).
|
|
346
|
+
const raw = err instanceof Error ? err.message : String(err)
|
|
347
|
+
let message = raw
|
|
348
|
+
for (const prefix of [`${meta.configPath}: `, `Project config ${meta.configPath}: `]) {
|
|
349
|
+
if (raw.startsWith(prefix)) {
|
|
350
|
+
message = raw.slice(prefix.length)
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
errors.push({
|
|
355
|
+
path: path.relative(root, meta.configPath).split(path.sep).join('/'),
|
|
356
|
+
message,
|
|
357
|
+
})
|
|
251
358
|
}
|
|
252
359
|
}),
|
|
253
360
|
)
|
|
254
|
-
|
|
361
|
+
errors.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
362
|
+
return { tasks, sandboxed, errors }
|
|
255
363
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// additional subscribers later, so a slow/wedged renderer can never
|
|
7
7
|
// stall task exec. See docs/design/event-stream-2026-06.md.
|
|
8
8
|
|
|
9
|
+
import { exitSignal } from '../exec/index.js'
|
|
9
10
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
10
11
|
import { isGroupTask } from '../graph/index.js'
|
|
11
12
|
import type { Logger } from './logger.js'
|
|
@@ -231,6 +232,13 @@ export interface OutcomeView {
|
|
|
231
232
|
hash?: string
|
|
232
233
|
cpuMs?: number
|
|
233
234
|
peakRssBytes?: number
|
|
235
|
+
/** A failure vx's own `timeout` killed (see `TaskOutcome`). */
|
|
236
|
+
timedOut?: true
|
|
237
|
+
/** A persistent task that never became ready, and why (see `TaskOutcome`). */
|
|
238
|
+
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
239
|
+
/** A skipped task: the failed (or aborted) task at the root of the block
|
|
240
|
+
* (see `TaskOutcome`); absent for fail-fast's skip, which nothing blocked. */
|
|
241
|
+
blockedBy?: string
|
|
234
242
|
/** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
|
|
235
243
|
admissionHeldMs?: number
|
|
236
244
|
restored?: boolean
|
|
@@ -260,8 +268,66 @@ export function outcomeWord(o: Pick<OutcomeView, 'status' | 'restored'>): string
|
|
|
260
268
|
}
|
|
261
269
|
|
|
262
270
|
/** `outcomeWord` with the exit code a failure carries: `failed (exit 9)`. */
|
|
263
|
-
export function outcomeLabel(
|
|
264
|
-
|
|
271
|
+
export function outcomeLabel(
|
|
272
|
+
o: Pick<
|
|
273
|
+
OutcomeView,
|
|
274
|
+
'status' | 'restored' | 'exitCode' | 'timedOut' | 'sandboxViolations' | 'notReady' | 'blockedBy'
|
|
275
|
+
>,
|
|
276
|
+
): string {
|
|
277
|
+
if (o.status === 'failed')
|
|
278
|
+
return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady)
|
|
279
|
+
if (o.status === 'skipped') return skippedLabel(o.blockedBy)
|
|
280
|
+
return outcomeWord(o)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Why a task was skipped, one copy for every surface: `blocked by lib#build`
|
|
285
|
+
* (the failed or aborted task at the root of the block, what the footer's
|
|
286
|
+
* Skipped section groups by), or '' for fail-fast's skip — the run stopped,
|
|
287
|
+
* nothing blocked it, and a label claiming an upstream failure would lie.
|
|
288
|
+
*/
|
|
289
|
+
export function skippedReason(blockedBy: string | undefined): string {
|
|
290
|
+
return blockedBy === undefined ? '' : `blocked by ${blockedBy}`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** `skipped (blocked by lib#build)`, or a bare `skipped` for fail-fast's. */
|
|
294
|
+
export function skippedLabel(blockedBy: string | undefined): string {
|
|
295
|
+
const why = skippedReason(blockedBy)
|
|
296
|
+
return why === '' ? 'skipped' : `skipped (${why})`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A failure's label, one copy for every surface (the frame's footer, the
|
|
301
|
+
* status line, the run report, the Actions annotation): the exit code, and
|
|
302
|
+
* above 128 the signal it stands for — `failed (exit 137, 128 + SIGKILL)` —
|
|
303
|
+
* or, for vx's own timeout, `failed (timed out, exit 143)`, or a persistent
|
|
304
|
+
* task that never became ready, `failed (never ready: timed out, exit 1)`; a
|
|
305
|
+
* sandboxed task's violations are counted after: `failed (exit 1, 2 sandbox
|
|
306
|
+
* violations)`.
|
|
307
|
+
*/
|
|
308
|
+
export function failedLabel(
|
|
309
|
+
exitCode: number,
|
|
310
|
+
timedOut?: true,
|
|
311
|
+
sandboxViolations?: number,
|
|
312
|
+
notReady?: 'timeout' | 'exited' | 'spawn',
|
|
313
|
+
): string {
|
|
314
|
+
// A sandboxed task that touched what it never declared fails on that
|
|
315
|
+
// alone (its exit is forced to 1 when it was 0): the count is the reason.
|
|
316
|
+
const violations =
|
|
317
|
+
sandboxViolations === undefined || sandboxViolations === 0
|
|
318
|
+
? ''
|
|
319
|
+
: `, ${sandboxViolations} sandbox violation${sandboxViolations === 1 ? '' : 's'}`
|
|
320
|
+
// A persistent task that never became ready: the reason reads first; its
|
|
321
|
+
// exit is the child's own when it exited, else vx's 1.
|
|
322
|
+
if (notReady !== undefined) {
|
|
323
|
+
const why =
|
|
324
|
+
notReady === 'timeout' ? 'timed out' : notReady === 'exited' ? 'exited' : 'spawn failed'
|
|
325
|
+
return `failed (never ready: ${why}, exit ${exitCode}${violations})`
|
|
326
|
+
}
|
|
327
|
+
// A timeout's 143 is vx's own SIGTERM: the reason, not the signal, reads.
|
|
328
|
+
if (timedOut === true) return `failed (timed out, exit ${exitCode}${violations})`
|
|
329
|
+
const signal = exitSignal(exitCode)
|
|
330
|
+
return `failed (exit ${exitCode}${signal === undefined ? '' : `, 128 + ${signal}`}${violations})`
|
|
265
331
|
}
|
|
266
332
|
|
|
267
333
|
export function projectNode(node: TaskNode): TaskView {
|
|
@@ -294,6 +360,9 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
294
360
|
if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
|
|
295
361
|
if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes
|
|
296
362
|
if (outcome.admissionHeldMs !== undefined) view.admissionHeldMs = outcome.admissionHeldMs
|
|
363
|
+
if (outcome.timedOut === true) view.timedOut = true
|
|
364
|
+
if (outcome.notReady !== undefined) view.notReady = outcome.notReady
|
|
365
|
+
if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
|
|
297
366
|
if (outcome.restored !== undefined) view.restored = outcome.restored
|
|
298
367
|
if (outcome.sandboxViolations !== undefined) view.sandboxViolations = outcome.sandboxViolations
|
|
299
368
|
if (outcome.sandboxViolationLines !== undefined)
|