@vzn/vx 0.0.18 → 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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -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/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// The workspace doctor's facts — what `vx info` prints and `vx mcp`'s
|
|
2
|
+
// `getWorkspaceInfo` returns: versions, the git status cache, the
|
|
3
|
+
// workspace's shape, the plugins and the seams each fills, what a run
|
|
4
|
+
// will use (workers, memory) and where those come from, the cache's
|
|
5
|
+
// versions and state, the flaky tasks, the lock. One collector, so the
|
|
6
|
+
// verb and the agent tool cannot disagree; the CLI renders, this gathers.
|
|
7
|
+
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { Cache, CACHE_VERSION, noteSchemaReset, SCHEMA_VERSION } from '../cache/index.js'
|
|
11
|
+
import { PLUGIN_HOOKS } from '../config.js'
|
|
12
|
+
import {
|
|
13
|
+
cgroupCpuQuota,
|
|
14
|
+
cgroupMemoryLimitBytes,
|
|
15
|
+
executablePath,
|
|
16
|
+
isUnsupportedBun,
|
|
17
|
+
machineMemoryBytes,
|
|
18
|
+
machineParallelism,
|
|
19
|
+
} from '../util/index.js'
|
|
20
|
+
import { VERSION } from '../version.js'
|
|
21
|
+
import { probeSandbox, resetSandbox } from '../exec/index.js'
|
|
22
|
+
import {
|
|
23
|
+
buildPackageGraph,
|
|
24
|
+
computeWorkspaceFingerprint,
|
|
25
|
+
findWorkspaceRoot,
|
|
26
|
+
type LoadReads,
|
|
27
|
+
listProjects,
|
|
28
|
+
loadProjectConfig,
|
|
29
|
+
loadWorkspace,
|
|
30
|
+
lockfilePath,
|
|
31
|
+
type ProjectMeta,
|
|
32
|
+
resolveCacheDir,
|
|
33
|
+
} from '../workspace/index.js'
|
|
34
|
+
import { flakyTasks, type FlakyTask } from './failure-mode.js'
|
|
35
|
+
import type { VxPlugin } from './plugin.js'
|
|
36
|
+
import { loadProjects, loadWorkspacePlugins } from './projects.js'
|
|
37
|
+
|
|
38
|
+
const warnToStderr = (message: string): void => {
|
|
39
|
+
process.stderr.write(`${message}\n`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The doctor's facts, typed: what `--format json` prints and the pretty rows render. */
|
|
43
|
+
export interface InfoFacts {
|
|
44
|
+
vx: string
|
|
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
|
|
54
|
+
git: string | null
|
|
55
|
+
/** null when git could not answer. */
|
|
56
|
+
gitStatusCache: { fsmonitor: boolean; untrackedCache: boolean } | null
|
|
57
|
+
workspaceRoot: string
|
|
58
|
+
projects: number
|
|
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 }>
|
|
67
|
+
plugins: Array<{ name: string; seams: string[] }>
|
|
68
|
+
/**
|
|
69
|
+
* The worker count a run defaults to and where it comes from: the
|
|
70
|
+
* workspace's `concurrency`, else the cores this process may use — the
|
|
71
|
+
* CPU count capped by a cgroup quota (`cpuQuota`, in cores, null when
|
|
72
|
+
* none binds).
|
|
73
|
+
*/
|
|
74
|
+
workers: {
|
|
75
|
+
count: number
|
|
76
|
+
source: 'workspace' | 'cgroup' | 'cores'
|
|
77
|
+
cores: number
|
|
78
|
+
cpuQuota: number | null
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* What a memory-packing policy budgets: the machine's total capped by
|
|
82
|
+
* the cgroup limit (`cgroupLimitBytes`, null when none binds).
|
|
83
|
+
*/
|
|
84
|
+
memory: { usableBytes: number; totalBytes: number; cgroupLimitBytes: number | null }
|
|
85
|
+
cacheDir: string
|
|
86
|
+
cacheVersion: string
|
|
87
|
+
schemaVersion: string
|
|
88
|
+
cacheEntries: number
|
|
89
|
+
cacheBytes: number
|
|
90
|
+
orphans: { artifacts: number; bytes: number }
|
|
91
|
+
runs24h: number
|
|
92
|
+
hits24h: number
|
|
93
|
+
/** Tasks the retained history shows both passing and failing on unchanged inputs. */
|
|
94
|
+
flakyTasks: FlakyTask[]
|
|
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 }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CollectInfoOptions {
|
|
107
|
+
/** `--cache-dir`: report on the cache a run with the same flag uses. */
|
|
108
|
+
readonly cacheDir?: string
|
|
109
|
+
/** Where a warning (a schema reset, a plugin's note) goes. Default: stderr. */
|
|
110
|
+
readonly warn?: (message: string) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): Promise<InfoFacts> {
|
|
114
|
+
const warn = opts.warn ?? warnToStderr
|
|
115
|
+
const reads: LoadReads = new Map()
|
|
116
|
+
const root = await findWorkspaceRoot(cwd, reads)
|
|
117
|
+
const metas = await listProjects(await loadWorkspace(root, reads))
|
|
118
|
+
const { workspaceConfig, plugins } = await loadWorkspacePlugins(root, warn)
|
|
119
|
+
const cacheDir =
|
|
120
|
+
opts.cacheDir === undefined
|
|
121
|
+
? resolveCacheDir(root, workspaceConfig)
|
|
122
|
+
: path.resolve(cwd, opts.cacheDir)
|
|
123
|
+
const cache = Cache.inspect(cacheDir)
|
|
124
|
+
noteSchemaReset(cache, warn)
|
|
125
|
+
let stats
|
|
126
|
+
let orphans
|
|
127
|
+
let flaky: FlakyTask[]
|
|
128
|
+
let taskCount = 0
|
|
129
|
+
let sandboxed = 0
|
|
130
|
+
let configErrors: InfoFacts['configErrors'] = []
|
|
131
|
+
try {
|
|
132
|
+
stats = cache.stats()
|
|
133
|
+
orphans = await cache.orphanStats()
|
|
134
|
+
flaky = flakyTasks(cache.dbHandle())
|
|
135
|
+
// The run path's load — a plugin's `project` stage counts — so the
|
|
136
|
+
// doctor's task count is the number a run would see. A broken config
|
|
137
|
+
// must not take the doctor down with it: the count then falls back to
|
|
138
|
+
// the configs that do load, one by one, the broken ones as zero.
|
|
139
|
+
try {
|
|
140
|
+
const loaded = await loadProjects({
|
|
141
|
+
workspaceRoot: root,
|
|
142
|
+
cacheDir,
|
|
143
|
+
plugins,
|
|
144
|
+
projectMetas: metas,
|
|
145
|
+
packageGraph: buildPackageGraph([...metas]),
|
|
146
|
+
seeds: 'all',
|
|
147
|
+
closure: false,
|
|
148
|
+
lock: null,
|
|
149
|
+
evalCache: {
|
|
150
|
+
store: cache,
|
|
151
|
+
workspaceFingerprint: await computeWorkspaceFingerprint(root, reads),
|
|
152
|
+
},
|
|
153
|
+
warn,
|
|
154
|
+
})
|
|
155
|
+
for (const p of loaded.projects.values()) {
|
|
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++
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
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))
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
cache.close()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lockPresent = await Bun.file(lockfilePath(root)).exists()
|
|
175
|
+
const sandbox = await sandboxFact(sandboxed)
|
|
176
|
+
return {
|
|
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.
|
|
183
|
+
bun: Bun.version,
|
|
184
|
+
bunSupported: !isUnsupportedBun(Bun.version),
|
|
185
|
+
git: gitVersion(),
|
|
186
|
+
// The one `git status` walk per run is the warm path's critical path on
|
|
187
|
+
// a large tree (~55 ms at 1000 projects, measured 2026-09-02). git's
|
|
188
|
+
// own caches make it near-free after the first run, and they are OFF by
|
|
189
|
+
// default — say so, since nothing else in a run would.
|
|
190
|
+
gitStatusCache: gitStatusCache(root),
|
|
191
|
+
workspaceRoot: root,
|
|
192
|
+
projects: metas.length,
|
|
193
|
+
tasks: taskCount,
|
|
194
|
+
configErrors,
|
|
195
|
+
// Which plugins loaded and which seams each fills, in pipeline order —
|
|
196
|
+
// the answer to "why did this task run there / cache there / not at
|
|
197
|
+
// all" before reading any config. A declined seam still costs nothing;
|
|
198
|
+
// this names the declarations, not what a run consulted.
|
|
199
|
+
plugins: plugins.map((p) => ({ name: p.name, seams: filledSeams(p) })),
|
|
200
|
+
workers: workersFact(workspaceConfig?.concurrency),
|
|
201
|
+
memory: memoryFact(),
|
|
202
|
+
cacheDir,
|
|
203
|
+
// The two versions a bug report needs and the reset notice names: the
|
|
204
|
+
// key prefix (a bump orphans every entry) and the index schema (a
|
|
205
|
+
// mismatch drops every table).
|
|
206
|
+
cacheVersion: CACHE_VERSION,
|
|
207
|
+
schemaVersion: SCHEMA_VERSION,
|
|
208
|
+
cacheEntries: stats.entryCount,
|
|
209
|
+
cacheBytes: stats.totalBytes,
|
|
210
|
+
// The index is authoritative, so a row-less artifact is bytes nothing
|
|
211
|
+
// will ever hit — and only `vx cache prune` reclaims them (after an
|
|
212
|
+
// upgrade's schema reset, most often).
|
|
213
|
+
orphans: { artifacts: orphans.orphans, bytes: orphans.orphanBytes },
|
|
214
|
+
runs24h: stats.runCountLast24h,
|
|
215
|
+
hits24h: stats.hitCountLast24h,
|
|
216
|
+
// Same inputs, both outcomes — the history's definition of flaky, over
|
|
217
|
+
// the 30 days it keeps. A run names its own findings in its footer;
|
|
218
|
+
// this is the workspace's standing list.
|
|
219
|
+
flakyTasks: flaky,
|
|
220
|
+
lockfile: lockPresent,
|
|
221
|
+
sandbox,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
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
|
+
|
|
254
|
+
/** The seams a plugin can fill, in pipeline order: the one hook list, less the lifecycle end. */
|
|
255
|
+
const SEAMS = PLUGIN_HOOKS.filter((h) => h !== 'teardown')
|
|
256
|
+
|
|
257
|
+
function filledSeams(p: VxPlugin): string[] {
|
|
258
|
+
return SEAMS.filter((s) => p[s as keyof VxPlugin] !== undefined)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Inside a container the CPU count and `os.totalmem()` are the HOST's; the
|
|
263
|
+
* cgroup is what the kernel enforces, and a run that used the host's
|
|
264
|
+
* numbers would be the OOM killer's. The doctor says which one a run
|
|
265
|
+
* reads, because nothing else would.
|
|
266
|
+
*/
|
|
267
|
+
function workersFact(workspaceConcurrency: number | undefined): InfoFacts['workers'] {
|
|
268
|
+
const cores = Math.max(1, navigator.hardwareConcurrency)
|
|
269
|
+
const quota = process.platform === 'linux' ? (cgroupCpuQuota() ?? null) : null
|
|
270
|
+
if (workspaceConcurrency !== undefined) {
|
|
271
|
+
return { count: workspaceConcurrency, source: 'workspace', cores, cpuQuota: quota }
|
|
272
|
+
}
|
|
273
|
+
const machine = machineParallelism()
|
|
274
|
+
return { count: machine, source: machine < cores ? 'cgroup' : 'cores', cores, cpuQuota: quota }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function memoryFact(): InfoFacts['memory'] {
|
|
278
|
+
const totalBytes = os.totalmem()
|
|
279
|
+
const limit = process.platform === 'linux' ? (cgroupMemoryLimitBytes() ?? null) : null
|
|
280
|
+
return { usableBytes: machineMemoryBytes(), totalBytes, cgroupLimitBytes: limit }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether git's fsmonitor / untracked cache are on. Reported as a fact,
|
|
285
|
+
* not a remedy: interleaved A/B at 1000 projects measured neither moving
|
|
286
|
+
* the warm run (STATUS, waves 5 and the 2026-09-03 refutations) — the
|
|
287
|
+
* status walk's cost is git's own, and vx already overlaps it.
|
|
288
|
+
*/
|
|
289
|
+
function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
|
|
290
|
+
try {
|
|
291
|
+
const p = Bun.spawnSync({
|
|
292
|
+
cmd: [executablePath('git'), 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
|
|
293
|
+
cwd: root,
|
|
294
|
+
stdout: 'pipe',
|
|
295
|
+
stderr: 'pipe',
|
|
296
|
+
})
|
|
297
|
+
// exit 1 is git's "no key matched": both off, still an answer.
|
|
298
|
+
if (p.exitCode !== 0 && p.exitCode !== 1) return null
|
|
299
|
+
const out = p.exitCode === 0 ? new TextDecoder().decode(p.stdout) : ''
|
|
300
|
+
const on = (key: string): boolean =>
|
|
301
|
+
new RegExp(`^core\\.${key} (true|1|yes|on)$`, 'im').test(out)
|
|
302
|
+
return { fsmonitor: on('fsmonitor'), untrackedCache: on('untrackedcache') }
|
|
303
|
+
} catch {
|
|
304
|
+
return null
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function gitVersion(): string | null {
|
|
309
|
+
try {
|
|
310
|
+
const p = Bun.spawnSync({
|
|
311
|
+
cmd: [executablePath('git'), '--version'],
|
|
312
|
+
stdout: 'pipe',
|
|
313
|
+
stderr: 'pipe',
|
|
314
|
+
})
|
|
315
|
+
if (p.exitCode !== 0) return null
|
|
316
|
+
return new TextDecoder()
|
|
317
|
+
.decode(p.stdout)
|
|
318
|
+
.trim()
|
|
319
|
+
.replace(/^git version /, '')
|
|
320
|
+
} catch {
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
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'] = []
|
|
332
|
+
await Promise.all(
|
|
333
|
+
metas.map(async (meta) => {
|
|
334
|
+
if (meta.configPath === null) return
|
|
335
|
+
try {
|
|
336
|
+
const config = await loadProjectConfig(meta.configPath)
|
|
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
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}),
|
|
360
|
+
)
|
|
361
|
+
errors.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
362
|
+
return { tasks, sandboxed, errors }
|
|
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'
|
|
@@ -59,7 +60,7 @@ export function createEventBus(): EventBus {
|
|
|
59
60
|
// depends on it), so emit must never reorder or defer.
|
|
60
61
|
//
|
|
61
62
|
// A throwing subscriber must NEVER break the run — the whole point
|
|
62
|
-
// of the bus is that a surface (
|
|
63
|
+
// of the bus is that a surface (an embedder's renderer, a future
|
|
63
64
|
// TUI) is isolated from execution. We swallow per-subscriber so one
|
|
64
65
|
// bad surface can't crash the user's build (mirrors the deleted
|
|
65
66
|
// Observer's makeSafeObserver contract). The terminal renderer is
|
|
@@ -138,9 +139,9 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
|
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
141
|
* A bus subscriber that projects each event to its `WireEvent` and hands
|
|
141
|
-
* it to `send` — the shared run→consumer forwarding path
|
|
142
|
-
*
|
|
143
|
-
*
|
|
142
|
+
* it to `send` — the shared run→consumer forwarding path for whatever an
|
|
143
|
+
* embedder builds on it (an NDJSON stream, an enveloped protocol; core
|
|
144
|
+
* ships neither). Group-task start/complete events are dropped (no command,
|
|
144
145
|
* pure scheduling noise). `run()` emits run:end twice (normal + finally)
|
|
145
146
|
* plus trailing summary status lines; we forward the run once and then go
|
|
146
147
|
* quiet, so a consumer sees exactly one terminal frame per run. `send`
|
|
@@ -151,8 +152,8 @@ export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscri
|
|
|
151
152
|
// run() emits run:end TWICE (normal + finally), with the summary footer
|
|
152
153
|
// (run:status lines) emitted in BETWEEN. Drop the duplicate run:end, but
|
|
153
154
|
// keep forwarding everything else — including those post-run:end status
|
|
154
|
-
// lines, so a consumer that renders them
|
|
155
|
-
//
|
|
155
|
+
// lines, so a consumer that renders them gets the footer, and one that
|
|
156
|
+
// ignores run:status is unaffected.
|
|
156
157
|
let endForwarded = false
|
|
157
158
|
// Which tasks the consumer has been told about. A skipped task never
|
|
158
159
|
// reaches the scheduler's onStart, so its completion arrives with no
|
|
@@ -181,7 +182,7 @@ export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscri
|
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
// --- Serializable projections (the off-thread
|
|
185
|
+
// --- Serializable projections (the off-thread wire form) ---------------
|
|
185
186
|
//
|
|
186
187
|
// The in-process bus carries live refs; crossing a worker postMessage or
|
|
187
188
|
// an RPC needs a JSON/structured-clone-safe form. Two concrete blockers
|
|
@@ -220,12 +221,26 @@ export interface OutcomeView {
|
|
|
220
221
|
* exactly how it drifted from the terminal and `--summarize`.
|
|
221
222
|
*/
|
|
222
223
|
isGroup?: boolean
|
|
224
|
+
/** Executed with no `cache` block: never consulted the cache, not a miss. */
|
|
225
|
+
noCache?: boolean
|
|
223
226
|
/** Cache hits only: the exec time the entry was stored with — the work
|
|
224
227
|
* this hit skipped, as opposed to `durationMs`, the restore it cost. */
|
|
225
228
|
storedDurationMs?: number
|
|
229
|
+
/** Cache hits only: what the producing execution used (see `TaskOutcome`). */
|
|
230
|
+
storedCpuMs?: number
|
|
231
|
+
storedPeakRssBytes?: number
|
|
226
232
|
hash?: string
|
|
227
233
|
cpuMs?: number
|
|
228
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
|
|
242
|
+
/** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
|
|
243
|
+
admissionHeldMs?: number
|
|
229
244
|
restored?: boolean
|
|
230
245
|
sandboxViolations?: number
|
|
231
246
|
sandboxViolationLines?: string[]
|
|
@@ -234,6 +249,87 @@ export interface OutcomeView {
|
|
|
234
249
|
wallclockEndNs?: string
|
|
235
250
|
}
|
|
236
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The one outcome vocabulary every renderer prints — the framed block
|
|
254
|
+
* header and footer, the focused one-liner, the hash-only audit line,
|
|
255
|
+
* the verbose summary table: `success` / `restored-local` /
|
|
256
|
+
* `restored-remote` / `up-to-date` / `failed` / `skipped`. A hit that
|
|
257
|
+
* materialized nothing is up-to-date whichever layer answered.
|
|
258
|
+
*/
|
|
259
|
+
export function outcomeWord(o: Pick<OutcomeView, 'status' | 'restored'>): string {
|
|
260
|
+
switch (o.status) {
|
|
261
|
+
case 'cache-hit':
|
|
262
|
+
return o.restored === false ? 'up-to-date' : 'restored-local'
|
|
263
|
+
case 'cache-hit-remote':
|
|
264
|
+
return o.restored === false ? 'up-to-date' : 'restored-remote'
|
|
265
|
+
default:
|
|
266
|
+
return o.status
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** `outcomeWord` with the exit code a failure carries: `failed (exit 9)`. */
|
|
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})`
|
|
331
|
+
}
|
|
332
|
+
|
|
237
333
|
export function projectNode(node: TaskNode): TaskView {
|
|
238
334
|
const view: TaskView = {
|
|
239
335
|
id: node.id,
|
|
@@ -256,10 +352,17 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
256
352
|
durationMs: outcome.durationMs,
|
|
257
353
|
}
|
|
258
354
|
if (isGroupTask(outcome.node)) view.isGroup = true
|
|
355
|
+
else if (outcome.node.config.cache === undefined) view.noCache = true
|
|
259
356
|
if (outcome.storedDurationMs !== undefined) view.storedDurationMs = outcome.storedDurationMs
|
|
357
|
+
if (outcome.storedCpuMs !== undefined) view.storedCpuMs = outcome.storedCpuMs
|
|
358
|
+
if (outcome.storedPeakRssBytes !== undefined) view.storedPeakRssBytes = outcome.storedPeakRssBytes
|
|
260
359
|
if (outcome.hash !== undefined) view.hash = outcome.hash
|
|
261
360
|
if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
|
|
262
361
|
if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes
|
|
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
|
|
263
366
|
if (outcome.restored !== undefined) view.restored = outcome.restored
|
|
264
367
|
if (outcome.sandboxViolations !== undefined) view.sandboxViolations = outcome.sandboxViolations
|
|
265
368
|
if (outcome.sandboxViolationLines !== undefined)
|
|
@@ -272,7 +375,7 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
272
375
|
|
|
273
376
|
/**
|
|
274
377
|
* The serializable form of a RunEvent — JSON / structured-clone safe, the
|
|
275
|
-
* wire contract a non-local consumer (
|
|
378
|
+
* wire contract a non-local consumer (an embedder's client) reads.
|
|
276
379
|
* `task:start` carries the full `TaskView` so a consumer can rebuild a
|
|
277
380
|
* node-shaped object incrementally (no upfront table needed); later
|
|
278
381
|
* task events reference the id, which the consumer already holds.
|