@vzn/vx 0.0.18 → 0.0.21

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