@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.
Files changed (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. 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 (the devframe dev server, a future
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 (the `vx dev`
142
- * hub frames each as NDJSON; the `vx serve` protocol wraps each in an
143
- * envelope). Group-task start/complete events are dropped (no command,
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 (the serve client) gets the
155
- // footer. A consumer that ignores run:status (the dev hub) is unaffected.
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 / devframe wire form) ----
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 (devframe, `vx serve` client) reads.
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.