@vzn/vx 0.0.16 → 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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,171 @@
1
+ // `--download` policy: which tasks may leave their outputs in the remote
2
+ // store instead of landing them on this machine, and which must not.
3
+ //
4
+ // See docs/design/download-policy-cas-cache-2026-08.md. Two decisions live
5
+ // here, both made ONCE per run at plan time (never per attempt), so
6
+ // `--dry` can show them and the scheduler never re-derives them.
7
+
8
+ import { isGroupTask, type TaskNode } from '../graph/index.js'
9
+ import { staticPrefix } from '../util/index.js'
10
+
11
+ /** Where a task's outputs go. `never` is `exec.remote: 'only'`. */
12
+ export type DownloadMode = 'eager' | 'deferred' | 'never'
13
+
14
+ /**
15
+ * Deferral eligibility: the tasks whose outputs NO key in this run could
16
+ * observe on disk. Returns the INELIGIBLE ids mapped to the reason, so a
17
+ * downgrade can name itself in `--dry`.
18
+ *
19
+ * The channel this closes is narrow and specific. A dependent's key folds
20
+ * an upstream's KEY, never its output content (pure-input transitive
21
+ * hashing), so deferral cannot move a dependent's key that way. The one
22
+ * real channel is a task whose `cache.inputs` globs can MATCH a producer's
23
+ * declared outputs on disk: then its key differs by whether the bytes
24
+ * arrived.
25
+ *
26
+ * DELIBERATELY TIGHTER than "any task sharing the producer's project"
27
+ * (the shape the design doc first sketched): that rule fires whenever a
28
+ * sibling reads the project at all, which is every ordinary workspace —
29
+ * `test` reads `src/**` while `build` writes `dist/**` — and would leave
30
+ * `--download=none` with nothing to defer. Comparing the globs' static
31
+ * prefixes answers the question actually being asked, and stays
32
+ * conservative in every direction that matters: a leading wildcard yields
33
+ * `.` and reaches everything, a cacheable task with no declared `files`
34
+ * is treated as reading its whole project, and `workspaceFiles` on either
35
+ * side ignores project boundaries — all three force ineligible.
36
+ */
37
+ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, string> {
38
+ const ineligible = new Map<string, string>()
39
+
40
+ const readersByProject = new Map<string, { taskId: string; prefixes: string[] }[]>()
41
+ let workspaceReader: string | undefined
42
+ let runtimeReader: string | undefined
43
+ for (const n of nodes.values()) {
44
+ const cache = n.config.cache
45
+ if (cache === undefined) continue
46
+ if ((cache.inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
47
+ // A `runtime` input is a SHELL COMMAND whose reads are unknowable —
48
+ // the same reason vx refuses to infer inputs by tracing. It can `cat` a
49
+ // producer's output (or path-escape its project to do it), and its
50
+ // stdout is folded into the key, so its answer would differ by whether
51
+ // the bytes were fetched. Deferral makes that sharper than it already
52
+ // was: it deliberately skips the output clean, so a stale prior build
53
+ // is exactly what such a command would sample. Nothing defers in a run
54
+ // that declares one — worse than nothing would be a key that moves with
55
+ // a transfer flag.
56
+ if (
57
+ (cache.inputs?.runtime?.length ?? 0) > 0 ||
58
+ (cache.inputs?.workspaceRuntime?.length ?? 0) > 0
59
+ ) {
60
+ runtimeReader ??= n.id
61
+ }
62
+ const files = cache.inputs?.files
63
+ const prefixes = files === undefined || files.length === 0 ? ['.'] : files.map(staticPrefix)
64
+ const list = readersByProject.get(n.projectName)
65
+ if (list) list.push({ taskId: n.id, prefixes })
66
+ else readersByProject.set(n.projectName, [{ taskId: n.id, prefixes }])
67
+ }
68
+
69
+ for (const n of nodes.values()) {
70
+ const outputs = n.config.cache?.outputs
71
+ if ((outputs?.workspaceFiles?.length ?? 0) > 0) {
72
+ ineligible.set(
73
+ n.id,
74
+ 'declares cache.outputs.workspaceFiles — root-anchored outputs can land where any project-relative input reads',
75
+ )
76
+ continue
77
+ }
78
+ const files = outputs?.files ?? []
79
+ if (files.length === 0) continue
80
+ if (runtimeReader !== undefined) {
81
+ ineligible.set(
82
+ n.id,
83
+ `${runtimeReader} declares a cache.inputs.runtime command, whose reads cannot be bounded`,
84
+ )
85
+ continue
86
+ }
87
+ if (workspaceReader !== undefined) {
88
+ ineligible.set(
89
+ n.id,
90
+ `${workspaceReader} declares cache.inputs.workspaceFiles, which can read any project`,
91
+ )
92
+ continue
93
+ }
94
+ const outPrefixes = files.map(staticPrefix)
95
+ // Project boundaries are hard, so only a SAME-project reader can reach a
96
+ // project-relative output. A task never observes its own outputs — they
97
+ // are excluded from its own key by construction.
98
+ for (const reader of readersByProject.get(n.projectName) ?? []) {
99
+ if (reader.taskId === n.id) continue
100
+ const clash = reader.prefixes.find((ip) => outPrefixes.some((op) => prefixesOverlap(ip, op)))
101
+ if (clash !== undefined) {
102
+ ineligible.set(
103
+ n.id,
104
+ `${reader.taskId} reads ${clash === '.' ? 'the whole project' : clash} with cache.inputs.files`,
105
+ )
106
+ break
107
+ }
108
+ }
109
+ }
110
+ return ineligible
111
+ }
112
+
113
+ /** Could two glob prefixes, anchored at the same dir, name overlapping paths? */
114
+ function prefixesOverlap(a: string, b: string): boolean {
115
+ if (a === '.' || b === '.' || a === '' || b === '' || a === '/' || b === '/') return true
116
+ if (a === b) return true
117
+ return a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
118
+ }
119
+
120
+ /**
121
+ * The effective per-task download mode. `all` (the default) reproduces
122
+ * today's behaviour exactly: every task eager.
123
+ */
124
+ export function resolveDownloadModes(args: {
125
+ nodes: Map<string, TaskNode>
126
+ policy: 'all' | 'toplevel' | 'none'
127
+ /** Task ids placed on a LOCAL executor — they write in place. */
128
+ localPlaced: ReadonlySet<string>
129
+ /** Task ids with `exec.remote: 'only'`. */
130
+ remoteOnly: ReadonlySet<string>
131
+ }): { modeOf: Map<string, DownloadMode>; downgrades: Map<string, string> } {
132
+ const modeOf = new Map<string, DownloadMode>()
133
+ const downgrades = new Map<string, string>()
134
+ const ineligible = args.policy === 'all' ? new Map() : deferralEligibility(args.nodes)
135
+
136
+ for (const n of args.nodes.values()) {
137
+ if (isGroupTask(n)) continue
138
+ if (args.remoteOnly.has(n.id)) {
139
+ modeOf.set(n.id, 'never')
140
+ continue
141
+ }
142
+ if (args.policy === 'all' || args.localPlaced.has(n.id)) {
143
+ modeOf.set(n.id, 'eager')
144
+ continue
145
+ }
146
+ // `toplevel` = the outputs you ASKED for come home; intermediates stay
147
+ // remote. Decided here rather than by materialising at run end: eager
148
+ // materialisation rides each task's own completion, overlapped with the
149
+ // rest of the run, where a run-end batch would serialise every download
150
+ // after the last task finishes.
151
+ //
152
+ // SURFACED counts as asked-for. A requested GROUP has no outputs of its
153
+ // own — `markSurfacedDeps` marks the real tasks it chains — so keying on
154
+ // `requested` alone made `vx run ci --download=toplevel` (ci being a
155
+ // group) bring home NOTHING, which is the one thing the mode exists to
156
+ // avoid. run.ts already pairs the two flags for the same reason when it
157
+ // decides which persistent children to keep alive.
158
+ if (args.policy === 'toplevel' && (n.requested === true || n.surfaced === true)) {
159
+ modeOf.set(n.id, 'eager')
160
+ continue
161
+ }
162
+ const reason = ineligible.get(n.id)
163
+ if (reason !== undefined) {
164
+ modeOf.set(n.id, 'eager')
165
+ downgrades.set(n.id, reason)
166
+ continue
167
+ }
168
+ modeOf.set(n.id, 'deferred')
169
+ }
170
+ return { modeOf, downgrades }
171
+ }
@@ -18,6 +18,11 @@ export interface RunStartInfo {
18
18
  requestedCount?: number
19
19
  /** Run banner context so a live footer can match the final summary. */
20
20
  context?: RunContext
21
+ /** The run's canonical start (epoch ms) — the SAME value the end-of-run
22
+ * summary reports as `startedAt`. A telemetry sink needs it during the run
23
+ * (e.g. to derive per-task started_at consistently with the batch summary
24
+ * for idempotent incremental ingest). */
25
+ startedAtMs?: number
21
26
  }
22
27
 
23
28
  /**
@@ -54,7 +59,7 @@ export function createEventBus(): EventBus {
54
59
  // depends on it), so emit must never reorder or defer.
55
60
  //
56
61
  // A throwing subscriber must NEVER break the run — the whole point
57
- // 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
58
63
  // TUI) is isolated from execution. We swallow per-subscriber so one
59
64
  // bad surface can't crash the user's build (mirrors the deleted
60
65
  // Observer's makeSafeObserver contract). The terminal renderer is
@@ -133,9 +138,9 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
133
138
 
134
139
  /**
135
140
  * A bus subscriber that projects each event to its `WireEvent` and hands
136
- * it to `send` — the shared run→consumer forwarding path (the `vx dev`
137
- * hub frames each as NDJSON; the `vx serve` protocol wraps each in an
138
- * 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,
139
144
  * pure scheduling noise). `run()` emits run:end twice (normal + finally)
140
145
  * plus trailing summary status lines; we forward the run once and then go
141
146
  * quiet, so a consumer sees exactly one terminal frame per run. `send`
@@ -146,9 +151,17 @@ export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscri
146
151
  // run() emits run:end TWICE (normal + finally), with the summary footer
147
152
  // (run:status lines) emitted in BETWEEN. Drop the duplicate run:end, but
148
153
  // keep forwarding everything else — including those post-run:end status
149
- // lines, so a consumer that renders them (the serve client) gets the
150
- // 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.
151
156
  let endForwarded = false
157
+ // Which tasks the consumer has been told about. A skipped task never
158
+ // reaches the scheduler's onStart, so its completion arrives with no
159
+ // preceding task:start — and `createWireRenderer` resolves a completion's
160
+ // node from the start it recorded, so it would drop the task entirely
161
+ // while the forwarded footer still counted it. Synthesize the start here,
162
+ // where the live TaskNode is in hand, so the projection stays full
163
+ // fidelity (real requested/surfaced/command) instead of a stand-in.
164
+ const started = new Set<string>()
152
165
  return (event) => {
153
166
  if (event.kind === 'run:end') {
154
167
  if (endForwarded) return
@@ -158,12 +171,17 @@ export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscri
158
171
  isGroupTask(event.node)
159
172
  ) {
160
173
  return
174
+ } else if (event.kind === 'task:start') {
175
+ started.add(event.node.id)
176
+ } else if (event.kind === 'task:complete' && !started.has(event.node.id)) {
177
+ started.add(event.node.id)
178
+ send({ kind: 'task:start', task: projectNode(event.node) })
161
179
  }
162
180
  send(toWireEvent(event))
163
181
  }
164
182
  }
165
183
 
166
- // --- Serializable projections (the off-thread / devframe wire form) ----
184
+ // --- Serializable projections (the off-thread wire form) ---------------
167
185
  //
168
186
  // The in-process bus carries live refs; crossing a worker postMessage or
169
187
  // an RPC needs a JSON/structured-clone-safe form. Two concrete blockers
@@ -195,9 +213,26 @@ export interface OutcomeView {
195
213
  status: TaskOutcome['status']
196
214
  exitCode: number
197
215
  durationMs: number
216
+ /**
217
+ * Group node (no `exec`) — organizational, not work. `TaskView` carries
218
+ * this too, but a consumer that only holds outcomes (the `--report`
219
+ * writer) has no node to ask, and counting groups as executed tasks is
220
+ * exactly how it drifted from the terminal and `--summarize`.
221
+ */
222
+ isGroup?: boolean
223
+ /** Executed with no `cache` block: never consulted the cache, not a miss. */
224
+ noCache?: boolean
225
+ /** Cache hits only: the exec time the entry was stored with — the work
226
+ * this hit skipped, as opposed to `durationMs`, the restore it cost. */
227
+ storedDurationMs?: number
228
+ /** Cache hits only: what the producing execution used (see `TaskOutcome`). */
229
+ storedCpuMs?: number
230
+ storedPeakRssBytes?: number
198
231
  hash?: string
199
232
  cpuMs?: number
200
233
  peakRssBytes?: number
234
+ /** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
235
+ admissionHeldMs?: number
201
236
  restored?: boolean
202
237
  sandboxViolations?: number
203
238
  sandboxViolationLines?: string[]
@@ -206,6 +241,29 @@ export interface OutcomeView {
206
241
  wallclockEndNs?: string
207
242
  }
208
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
+
209
267
  export function projectNode(node: TaskNode): TaskView {
210
268
  const view: TaskView = {
211
269
  id: node.id,
@@ -227,9 +285,15 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
227
285
  exitCode: outcome.exitCode,
228
286
  durationMs: outcome.durationMs,
229
287
  }
288
+ if (isGroupTask(outcome.node)) view.isGroup = true
289
+ else if (outcome.node.config.cache === undefined) view.noCache = true
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
230
293
  if (outcome.hash !== undefined) view.hash = outcome.hash
231
294
  if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
232
295
  if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes
296
+ if (outcome.admissionHeldMs !== undefined) view.admissionHeldMs = outcome.admissionHeldMs
233
297
  if (outcome.restored !== undefined) view.restored = outcome.restored
234
298
  if (outcome.sandboxViolations !== undefined) view.sandboxViolations = outcome.sandboxViolations
235
299
  if (outcome.sandboxViolationLines !== undefined)
@@ -242,7 +306,7 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
242
306
 
243
307
  /**
244
308
  * The serializable form of a RunEvent — JSON / structured-clone safe, the
245
- * wire contract a non-local consumer (devframe, `vx serve` client) reads.
309
+ * wire contract a non-local consumer (an embedder's client) reads.
246
310
  * `task:start` carries the full `TaskView` so a consumer can rebuild a
247
311
  * node-shaped object incrementally (no upfront table needed); later
248
312
  * task events reference the id, which the consumer already holds.