@vzn/vx 0.0.0 → 0.0.12

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,325 @@
1
+ import type { TaskConfig } from '../config.js'
2
+ import { UserError } from '../util/index.js'
3
+ import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
4
+ import { DependencySpecError, parseDependencySpec, type DependencySpec } from './dependency-spec.js'
5
+
6
+ // Re-exported so existing importers keep working while the type's home
7
+ // moves to workspace (it's the joint product of discovery + loading).
8
+ export type { ProjectEntry } from '../workspace/index.js'
9
+
10
+ export interface TaskNode {
11
+ /** Stable id: `${projectName}#${taskName}`. */
12
+ id: string
13
+ projectName: string
14
+ projectDir: string
15
+ taskName: string
16
+ config: TaskConfig
17
+ /** Ids of tasks that must complete before this one runs. */
18
+ deps: string[]
19
+ /**
20
+ * True for the tasks the user actually asked for (via cwd, `-r`, `-F`,
21
+ * or `pkg#task`). False for dependencies pulled in by `dependsOn`
22
+ * expansion. Used by the orchestrator to scope `forwardArgs` so trailing
23
+ * CLI args don't leak into upstream tasks the user didn't address.
24
+ */
25
+ requested: boolean
26
+ /**
27
+ * Display-only: a same-project, non-group, direct `dependsOn` task of
28
+ * a REQUESTED GROUP. A group produces no output of its own, so in
29
+ * focused flow we surface the work it stands for one level down (no
30
+ * recursion, no `^`/cross-project deps — see `markSurfacedDeps`). The
31
+ * focused logger shows these like requested tasks; it does NOT make
32
+ * them `requested`, so `forwardArgs` scoping is unaffected.
33
+ */
34
+ surfaced?: boolean
35
+ }
36
+
37
+ export function taskId(project: string, task: string): string {
38
+ return `${project}#${task}`
39
+ }
40
+
41
+ /**
42
+ * A task is a "group" if it has no `exec` — it exists only to chain
43
+ * `dependsOn` (an umbrella for `vx run ci`). Group tasks:
44
+ * - never spawn a process,
45
+ * - never read/write the cache (it's a config error to declare one),
46
+ * - never appear in the run summary or `runs` analytics table,
47
+ * - render no framed block in the live output.
48
+ * Six call sites used to repeat `node.config.exec === undefined`;
49
+ * centralising the predicate prevents that check from drifting.
50
+ */
51
+ export function isGroupTask(node: TaskNode): boolean {
52
+ return node.config.exec === undefined
53
+ }
54
+
55
+ /**
56
+ * Mark, for focused-flow display, the real tasks a requested GROUP
57
+ * stands for. A group has no output of its own, so `vx run build`
58
+ * (where `build` is a group) would otherwise print nothing.
59
+ *
60
+ * Walk `dependsOn` from the requested group, DESCENDING THROUGH nested
61
+ * same-project groups (e.g. `build` → `build.bun` → `build.bun.*`), and
62
+ * surface the first non-group task on each path. Two hard limits:
63
+ * - never leave the requested project — `^`/cross-project deps are
64
+ * not entered (the user asked to run THIS project's group),
65
+ * - never descend past a real task — its own `dependsOn` is its
66
+ * implementation detail, not work the group "is".
67
+ *
68
+ * Display-only: it never flips `requested`, so `forwardArgs` scoping
69
+ * stays put. Returns the count of newly surfaced nodes.
70
+ */
71
+ export function markSurfacedDeps(nodes: Map<string, TaskNode>): number {
72
+ let surfaced = 0
73
+ for (const node of nodes.values()) {
74
+ if (!node.requested || !isGroupTask(node)) continue
75
+ const project = node.projectName
76
+ const stack = [...node.deps]
77
+ const visited = new Set<string>()
78
+ while (stack.length > 0) {
79
+ const depId = stack.pop()!
80
+ if (visited.has(depId)) continue
81
+ visited.add(depId)
82
+ const dep = nodes.get(depId)
83
+ // Stay inside the requested project; `^`/cross-project deps are
84
+ // neither surfaced nor traversed.
85
+ if (!dep || dep.projectName !== project) continue
86
+ if (isGroupTask(dep)) {
87
+ // A nested group: keep descending, don't surface the group.
88
+ for (const next of dep.deps) stack.push(next)
89
+ continue
90
+ }
91
+ if (dep.surfaced !== true) {
92
+ dep.surfaced = true
93
+ surfaced++
94
+ }
95
+ }
96
+ }
97
+ return surfaced
98
+ }
99
+
100
+ /**
101
+ * Expand the user-requested task list into concrete `{project, task}`
102
+ * pairs the graph builder consumes.
103
+ *
104
+ * - Bare task names (`'build'`) → one entry per project in
105
+ * `candidates` that declares the task. Missing in a given project
106
+ * is silent (sparse tasks are normal across a workspace).
107
+ * - Anchored entries (`'pkg#task'`) → one entry exactly, ignoring
108
+ * `candidates`. Silently dropped if pkg/task doesn't exist (the
109
+ * CLI's pre-validation catches malformed strings).
110
+ *
111
+ * Duplicates are deduped (a user might pass `vx run build pkg#build`).
112
+ */
113
+ export function expandRequested(
114
+ tasks: readonly string[],
115
+ candidates: readonly string[],
116
+ projects: Map<string, ProjectEntry>,
117
+ ): Array<{ project: string; task: string }> {
118
+ const seen = new Set<string>()
119
+ const out: Array<{ project: string; task: string }> = []
120
+ const push = (project: string, task: string): void => {
121
+ const key = `${project}#${task}`
122
+ if (seen.has(key)) return
123
+ seen.add(key)
124
+ out.push({ project, task })
125
+ }
126
+ for (const spec of tasks) {
127
+ const idx = spec.indexOf('#')
128
+ if (idx >= 0) {
129
+ const project = spec.slice(0, idx)
130
+ const task = spec.slice(idx + 1)
131
+ if (projects.get(project)?.config.tasks?.[task]) push(project, task)
132
+ continue
133
+ }
134
+ for (const name of candidates) {
135
+ if (projects.get(name)?.config.tasks?.[spec]) push(name, spec)
136
+ }
137
+ }
138
+ return out
139
+ }
140
+
141
+ export interface BuildGraphOptions {
142
+ projects: Map<string, ProjectEntry>
143
+ packageGraph: PackageGraph
144
+ /** Initial set: `{ project, task }` pairs the user asked to run. */
145
+ requested: Array<{ project: string; task: string }>
146
+ /**
147
+ * Filter `dependsOn` expansion.
148
+ * - `undefined` → every dependsOn entry is followed (default).
149
+ * - `'all'` → no expansion; only the requested nodes are added.
150
+ * - `string[]` → expand normally, but drop edges whose target
151
+ * task name is in the list. `dependsOn.self` and
152
+ * `dependsOn.dependencies` are both filtered.
153
+ */
154
+ excludeDependencies?: 'all' | readonly string[]
155
+ }
156
+
157
+ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode> {
158
+ const { projects, packageGraph, requested, excludeDependencies } = options
159
+ const skipAll = excludeDependencies === 'all'
160
+ const skipNames =
161
+ Array.isArray(excludeDependencies) && excludeDependencies.length > 0
162
+ ? new Set(excludeDependencies)
163
+ : null
164
+ const nodes = new Map<string, TaskNode>()
165
+
166
+ function addNode(projectName: string, taskName: string, requested: boolean): TaskNode | null {
167
+ const id = taskId(projectName, taskName)
168
+ const existing = nodes.get(id)
169
+ if (existing) {
170
+ // Promote an already-added node to requested if any caller asked
171
+ // for it directly. Once requested, never demoted.
172
+ if (requested) existing.requested = true
173
+ return existing
174
+ }
175
+
176
+ const project = projects.get(projectName)
177
+ if (!project) return null
178
+ const taskConfig = project.config.tasks?.[taskName]
179
+ if (!taskConfig) return null
180
+
181
+ const node: TaskNode = {
182
+ id,
183
+ projectName,
184
+ projectDir: project.dir,
185
+ taskName,
186
+ config: taskConfig,
187
+ deps: [],
188
+ requested,
189
+ }
190
+ nodes.set(id, node)
191
+
192
+ if (skipAll) return node
193
+
194
+ const rawSpecs = taskConfig.dependsOn ?? []
195
+ for (const raw of rawSpecs) {
196
+ let spec: DependencySpec
197
+ try {
198
+ spec = parseDependencySpec(raw)
199
+ } catch (err) {
200
+ if (err instanceof DependencySpecError) {
201
+ throw new UserError(`Task ${id}: ${err.message}`)
202
+ }
203
+ throw err
204
+ }
205
+
206
+ // dependsOn is about which tasks to ADD to the graph, not which
207
+ // to filter. Wildcards and negation aren't meaningful here —
208
+ // they're cache.inputs.tasks operations.
209
+ if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
210
+ throw new UserError(`Task ${id}: dependsOn does not accept wildcards (got "${raw}")`)
211
+ }
212
+ if (spec.negated) {
213
+ throw new UserError(`Task ${id}: dependsOn does not accept negation (got "${raw}")`)
214
+ }
215
+ // CLI `--excludeDependencies=name1,name2` drops edges whose target
216
+ // task name matches, regardless of bucket (self / deps / cross).
217
+ if (skipNames?.has(spec.task)) continue
218
+
219
+ if (spec.kind === 'self') {
220
+ // Missing target is a hard error — the user typed a name that
221
+ // doesn't resolve in this project.
222
+ const child = addNode(projectName, spec.task, false)
223
+ if (!child) {
224
+ throw new UserError(
225
+ `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
226
+ )
227
+ }
228
+ node.deps.push(child.id)
229
+ } else if (spec.kind === 'deps') {
230
+ // Nearest-holder frontier (Turbo/Nx direct-deps parity +
231
+ // sparse bridging): walk the package dep graph from this
232
+ // project's direct deps; each path stops at the FIRST package
233
+ // declaring the task — a holder's own dependsOn is responsible
234
+ // for anything deeper. Packages without the task are passed
235
+ // through so a sparse dep doesn't break ordering to deeper
236
+ // holders. The visited set both dedupes shared subtrees and
237
+ // terminates on package-graph cycles (legal in PMs).
238
+ const visited = new Set<string>()
239
+ const frontier = [...packageGraph.directDeps(projectName)]
240
+ while (frontier.length > 0) {
241
+ const target = frontier.pop()!
242
+ if (visited.has(target)) continue
243
+ visited.add(target)
244
+ const child = addNode(target, spec.task, false)
245
+ if (child) node.deps.push(child.id)
246
+ else frontier.push(...packageGraph.directDeps(target))
247
+ }
248
+ } else {
249
+ // Cross-project edge: pkg#task. Missing target is a hard error
250
+ // because the user named the package + task explicitly.
251
+ const child = addNode(spec.project, spec.task, false)
252
+ if (!child) {
253
+ throw new UserError(
254
+ `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
255
+ )
256
+ }
257
+ node.deps.push(child.id)
258
+ }
259
+ }
260
+
261
+ // Stable ordering for deterministic scheduling and cache keys.
262
+ node.deps.sort()
263
+ return node
264
+ }
265
+
266
+ for (const { project, task } of requested) {
267
+ addNode(project, task, true)
268
+ }
269
+
270
+ detectCycle(nodes)
271
+ return nodes
272
+ }
273
+
274
+ function detectCycle(nodes: Map<string, TaskNode>): void {
275
+ // Iterative DFS over dense-indexed colors. Recursion + `Map<string,
276
+ // number>` worked, but deep workspaces (long chains of `dependsOn`)
277
+ // can blow V8's frame budget, and per-node Map lookups dominate the
278
+ // pass. A Uint8Array indexed by node-position is allocation-free
279
+ // and ~2× faster on cycle detection itself.
280
+ // 0 = WHITE (unvisited) — the typed array's zero-init default.
281
+ const GRAY = 1
282
+ const BLACK = 2
283
+ const idsArr = [...nodes.keys()]
284
+ const idIdx = new Map<string, number>()
285
+ for (let i = 0; i < idsArr.length; i++) idIdx.set(idsArr[i]!, i)
286
+ const color = new Uint8Array(idsArr.length)
287
+
288
+ // Each stack frame is `[idIndex, nextChildIdx]`. We mutate the
289
+ // child index in place as children are visited so we know which one
290
+ // to resume on when the leaf returns control here.
291
+ const stack: number[] = []
292
+ for (let start = 0; start < idsArr.length; start++) {
293
+ if (color[start] === BLACK) continue
294
+ stack.push(start, 0)
295
+ color[start] = GRAY
296
+ while (stack.length > 0) {
297
+ const childIdx = stack[stack.length - 1]!
298
+ const idx = stack[stack.length - 2]!
299
+ const node = nodes.get(idsArr[idx]!)
300
+ const deps = node ? node.deps : []
301
+ if (childIdx < deps.length) {
302
+ stack[stack.length - 1] = childIdx + 1
303
+ const depIdx = idIdx.get(deps[childIdx]!)
304
+ if (depIdx === undefined) continue
305
+ const c = color[depIdx]!
306
+ if (c === BLACK) continue
307
+ if (c === GRAY) {
308
+ // Reconstruct the cycle by walking the open stack until we
309
+ // hit the id that triggered the GRAY-on-GRAY hit.
310
+ const path: string[] = []
311
+ for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
312
+ const startInPath = path.indexOf(idsArr[depIdx]!)
313
+ const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
314
+ throw new UserError(`Cycle detected in task graph: ${cycle}`)
315
+ }
316
+ color[depIdx] = GRAY
317
+ stack.push(depIdx, 0)
318
+ } else {
319
+ color[idx] = BLACK
320
+ stack.pop()
321
+ stack.pop()
322
+ }
323
+ }
324
+ }
325
+ }
package/src/index.ts ADDED
@@ -0,0 +1,197 @@
1
+ // Public API for @vzn/vx.
2
+ //
3
+ // This is the stable cross-package contract — any plugin or integration
4
+ // package imports everything it needs from here via the bare
5
+ // `'@vzn/vx'` specifier (never a deep `src/...` path). The surface is pinned
6
+ // by tests/package-boundaries.test.ts; a widening updates that snapshot
7
+ // deliberately. See docs/design/core-cloud-split-2026-06.md §3.5.
8
+
9
+ export { VERSION } from './version.js'
10
+
11
+ // Clean error type — user-input failures print a message without a stack.
12
+ export { UserError } from './util/index.js'
13
+
14
+ // Schema types and helpers (used by user vx.config files and presets).
15
+ export type {
16
+ WorkspaceConfig,
17
+ ProjectConfig,
18
+ TaskConfig,
19
+ ExecConfig,
20
+ ExecEnv,
21
+ CacheConfig,
22
+ CacheInputs,
23
+ CacheOutputs,
24
+ SandboxConfig,
25
+ SandboxNetworkConfig,
26
+ } from './config.js'
27
+ export { defineProject, defineWorkspace } from './config.js'
28
+
29
+ // Programmatic engine API (run / plan / prepare) + the graph primitives a
30
+ // distribution submitter/agent reasons over + the cache-key hashing seam.
31
+ // `deriveStableKeys` is THE shared stable-key derivation (remote-prefetch,
32
+ // the local short-circuit, and the distributed submitter must never drift
33
+ // on the stability gate); `captureGitContext`/`captureWorkspaceIdentity`
34
+ // give agents + the submitter identity before/without a telemetry run.
35
+ export { run, planRun, prepareRun } from './orchestrator/index.js'
36
+ export type { PreparedRun } from './orchestrator/index.js'
37
+ export { computeTaskHash, createHashCache, deriveStableKeys } from './orchestrator/index.js'
38
+ export type { DeriveStableKeysArgs, HashCache, StableKey } from './orchestrator/index.js'
39
+ export {
40
+ captureGitContext,
41
+ captureWorkspaceIdentity,
42
+ resolveCacheScope,
43
+ } from './orchestrator/index.js'
44
+ export type { GitContext, WorkspaceIdentity } from './orchestrator/index.js'
45
+ export { FULL_CACHE_POLICY, parseCachePolicy } from './orchestrator/index.js'
46
+ export type {
47
+ CachePolicy,
48
+ Logger,
49
+ OutputView,
50
+ RunOptions,
51
+ RunSummary,
52
+ } from './orchestrator/index.js'
53
+ export { defaultLogger, resolveOutputView } from './orchestrator/index.js'
54
+ export { buildTaskGraph, expandRequested, isGroupTask, markSurfacedDeps } from './graph/index.js'
55
+ export type { TaskNode, TaskOutcome, TaskStatus, VerifyVerdict } from './graph/index.js'
56
+
57
+ // Cache classes + the layer interface (the `cache` capability's currency)
58
+ // and input-output resolution. The blob-CAS/digest substrate
59
+ // (cas-backend.ts / digest.ts) stays module-internal until it has a
60
+ // consumer — no speculative public API. `cleanOutputs` is public for the
61
+ // distributed submitter's targeted output materialization (wipe declared
62
+ // outputs, then `restoreOutputs` the artifact — never a naive re-run).
63
+ export {
64
+ Cache,
65
+ LayeredCache,
66
+ RemoteCache,
67
+ GitFilesCache,
68
+ cleanOutputs,
69
+ resolveInputs,
70
+ resolveOutputs,
71
+ } from './cache/index.js'
72
+ export type { CacheLayer, RunRecord, InvocationRecord } from './cache/index.js'
73
+
74
+ // Workspace discovery — an out-of-process service/CLI needs these.
75
+ export { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from './workspace/index.js'
76
+
77
+ // Plugin API — the run-level extension points. Behavior capabilities
78
+ // (backend / cache) change WHAT/HOW work runs; the observe-only `telemetry`
79
+ // capability is the canonical data-export path and cannot change behavior.
80
+ // A plugin is declared in vx.workspace.ts via defineWorkspace({ plugins }).
81
+ // See docs/design/observability-architecture-2026-06.md.
82
+ export type {
83
+ VxPlugin,
84
+ EventSink,
85
+ BackendContext,
86
+ CacheContext,
87
+ EventSinkContext,
88
+ PluginSetupContext,
89
+ } from './orchestrator/index.js'
90
+
91
+ // Telemetry — THE canonical, versioned data-export contract every exporter
92
+ // (an OTel exporter, an HTTP sink, or any third-party consumer) reads. A sink implements TelemetrySink and is
93
+ // returned from VxPlugin.telemetry(); it receives immutable records and holds
94
+ // no run handle (observe-only by construction).
95
+ export { TELEMETRY_SCHEMA_VERSION, deriveCacheSource } from './orchestrator/index.js'
96
+ export type {
97
+ CacheSource,
98
+ RunContextRecord,
99
+ RunSummaryRecord,
100
+ TaskTelemetry,
101
+ TelemetryContext,
102
+ TelemetryRecord,
103
+ TelemetrySink,
104
+ } from './orchestrator/index.js'
105
+
106
+ // The submitter wire contract + backend interface (the `backend`
107
+ // capability's currency) and the serializable event projection.
108
+ export {
109
+ optionsToRequest,
110
+ requestToOptions,
111
+ projectNode,
112
+ projectOutcome,
113
+ createWireRenderer,
114
+ } from './orchestrator/index.js'
115
+ export type {
116
+ RunBackend,
117
+ RunRequest,
118
+ RunResult,
119
+ ClientMessage,
120
+ ServerMessage,
121
+ } from './orchestrator/index.js'
122
+
123
+ // Event bus + wire form — adapters (otel-bridge, custom subscribers) ride this.
124
+ export {
125
+ createEventBus,
126
+ wireForwarder,
127
+ toWireEvent,
128
+ createVxSurface,
129
+ } from './orchestrator/index.js'
130
+ export type {
131
+ EventBus,
132
+ RunEvent,
133
+ RunEventSubscriber,
134
+ WireEvent,
135
+ TaskView,
136
+ OutcomeView,
137
+ } from './orchestrator/index.js'
138
+
139
+ // The JSON-RPC 2.0 wire envelope — an out-of-process serve/agent speaks this framing.
140
+ export {
141
+ clientMessageToEnvelope,
142
+ decodeEnvelope,
143
+ encodeForNDJSON,
144
+ encodeForSSE,
145
+ encodeForWS,
146
+ ENVELOPE_ERRORS,
147
+ envelopeToClientMessage,
148
+ envelopeToServerMessage,
149
+ isEnvelope,
150
+ isNotification,
151
+ isRequest,
152
+ makeError,
153
+ makeNotification,
154
+ makeRequest,
155
+ makeResponse,
156
+ serverMessageToEnvelope,
157
+ WIRE_CHANNELS,
158
+ WIRE_PROTOCOL_VERSION,
159
+ } from './orchestrator/index.js'
160
+ export type {
161
+ Envelope,
162
+ ErrorResponse,
163
+ Notification,
164
+ WireRequest,
165
+ WireResponse,
166
+ WireChannel,
167
+ } from './orchestrator/index.js'
168
+
169
+ // Metrics / analytics query layer over cache.db — a service plugin's HTTP
170
+ // surface reads from these (the queries stay in core; a serve exposes them over HTTP).
171
+ export {
172
+ cacheKeyDiff,
173
+ compareRuns,
174
+ explainCacheKeyQuery,
175
+ getBottlenecks,
176
+ getCacheBreakdown,
177
+ getCacheSavings,
178
+ getCacheStatsSql,
179
+ getFlakiestTasks,
180
+ getHistory,
181
+ getHitRateSplit,
182
+ getInvocation,
183
+ getParallelismHistory,
184
+ getPrunableEntries,
185
+ getRecentFailures,
186
+ getRun,
187
+ getRunHeatmap,
188
+ getRunTrends,
189
+ getStorageGrowth,
190
+ getTaskDetail,
191
+ getTopTimeBurners,
192
+ listCacheEntries,
193
+ listInvocations,
194
+ listProjects,
195
+ listRuns,
196
+ whyDidThisRerunQuery,
197
+ } from './orchestrator/index.js'
@@ -0,0 +1,63 @@
1
+ // Tiny color helper — gates Bun.color through a `ColorSupport` token
2
+ // so the formatters stay pure. The token is computed once at logger
3
+ // construction; tests pass `{ enabled: false }` so assertions match
4
+ // plain strings.
5
+ //
6
+ // Bun.color('red', 'ansi') is broken in current Bun versions (emits
7
+ // the color number as a raw byte instead of decimal text), so we use
8
+ // 'ansi-16m' which emits 24-bit truecolor sequences. Every modern
9
+ // terminal supports them, and the `tput colors`-style downgrade isn't
10
+ // worth the complexity for our handful of UI accents.
11
+
12
+ const RESET = '\x1b[0m'
13
+ const BOLD = '\x1b[1m'
14
+ const DIM = '\x1b[2m'
15
+
16
+ export interface ColorSupport {
17
+ enabled: boolean
18
+ }
19
+
20
+ export interface PaintOptions {
21
+ bold?: boolean
22
+ dim?: boolean
23
+ }
24
+
25
+ /**
26
+ * Standard env precedence:
27
+ * NO_COLOR=anything → off (overrides FORCE_COLOR)
28
+ * FORCE_COLOR=anything → on
29
+ * else: on iff stdout is a TTY.
30
+ */
31
+ export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
32
+ if (process.env['NO_COLOR']) return { enabled: false }
33
+ if (process.env['FORCE_COLOR']) return { enabled: true }
34
+ return { enabled: stream.isTTY === true }
35
+ }
36
+
37
+ // Bun.color() is pure for a given (hex, mode) pair but does FFI work
38
+ // on each call. The framed-output renderer paints the same handful of
39
+ // accent colors thousands of times per run; memoizing here turns those
40
+ // into hash-map hits.
41
+ const ansiCache = new Map<string, string>()
42
+ function ansiFor(color: string): string {
43
+ const cached = ansiCache.get(color)
44
+ if (cached !== undefined) return cached
45
+ const v = Bun.color(color, 'ansi-16m') ?? ''
46
+ ansiCache.set(color, v)
47
+ return v
48
+ }
49
+
50
+ export function paint(
51
+ color: string,
52
+ text: string,
53
+ colors: ColorSupport,
54
+ opts: PaintOptions = {},
55
+ ): string {
56
+ if (!colors.enabled) return text
57
+ let prefix = ''
58
+ if (opts.bold) prefix += BOLD
59
+ if (opts.dim) prefix += DIM
60
+ if (color) prefix += ansiFor(color)
61
+ if (prefix.length === 0) return text
62
+ return `${prefix}${text}${RESET}`
63
+ }
@@ -0,0 +1,74 @@
1
+ // The devframe surface for a vx run: one definition that exposes the run
2
+ // as a live streaming channel (`vx:events`, the raw WireEvent feed) plus a
3
+ // patch-synced shared state (`vx:run`, the reduced RunState aggregate).
4
+ // A host adapter (dev server / MCP — see cli wiring) mounts this same
5
+ // definition; this module owns only the translation from our in-process
6
+ // event bus into devframe's streaming + shared-state primitives.
7
+ //
8
+ // devframe is touched ONLY through type-only imports here, so core `vx`
9
+ // never gains a runtime dependency on it — the host that actually boots a
10
+ // devframe server imports the runtime adapter dynamically and is the
11
+ // optional, opt-in path. See docs/design/event-stream-2026-06.md.
12
+
13
+ import type { DevframeDefinition, DevframeNodeContext, RpcStreamingChannel } from 'devframe'
14
+ import { VERSION } from '../version.js'
15
+ import { toWireEvent, type EventBus, type WireEvent } from './events.js'
16
+ import { initRunState, reduce, type RunState } from './run-state.js'
17
+
18
+ // Type the `vx:run` shared-state key so `ctx.rpc.sharedState.get` is
19
+ // checked against our aggregate shape.
20
+ declare module 'devframe' {
21
+ interface DevframeRpcSharedStates {
22
+ 'vx:run': RunState
23
+ }
24
+ }
25
+
26
+ export interface VxSurfaceOptions {
27
+ /** Stream id for the run's event feed. One run → one stream. */
28
+ streamId?: string
29
+ }
30
+
31
+ /**
32
+ * Build a devframe definition that mirrors a single run's bus onto
33
+ * devframe's wire. `setup` runs at host boot (before the run emits), so
34
+ * it subscribes to the bus, forwards every event onto the `vx:events`
35
+ * stream, and folds it into the `vx:run` shared state. The host syncs
36
+ * both to any connected client (web SPA, MCP resource).
37
+ */
38
+ export function createVxSurface(bus: EventBus, options: VxSurfaceOptions = {}): DevframeDefinition {
39
+ const streamId = options.streamId ?? 'run'
40
+ return {
41
+ id: 'vx',
42
+ name: 'vx',
43
+ version: VERSION,
44
+ async setup(ctx: DevframeNodeContext) {
45
+ const channel: RpcStreamingChannel<WireEvent> = ctx.rpc.streaming.create('vx:events')
46
+ const stream = channel.start({ id: streamId })
47
+ const state = await ctx.rpc.sharedState.get('vx:run', { initialValue: initRunState() })
48
+
49
+ // Local snapshot fed to `reduce`; the shared state is updated by
50
+ // assigning the new snapshot onto its draft (pure reduce → diffable
51
+ // patch). Kept beside the shared state rather than read back from it
52
+ // so reduction never depends on devframe's immutable accessors.
53
+ let snapshot = initRunState()
54
+ // `run()` emits `run:end` more than once (the normal end + a `finally`
55
+ // safety call) plus summary `run:status` lines AFTER the first
56
+ // `run:end`. Once the stream is closed, ignore everything — writing
57
+ // to a closed stream throws (StreamClosedError), and the run is over.
58
+ let closed = false
59
+
60
+ bus.subscribe((event) => {
61
+ if (closed) return
62
+ stream.write(toWireEvent(event))
63
+ snapshot = reduce(snapshot, event)
64
+ state.mutate((draft) => {
65
+ Object.assign(draft, snapshot)
66
+ })
67
+ if (event.kind === 'run:end') {
68
+ stream.close()
69
+ closed = true
70
+ }
71
+ })
72
+ },
73
+ }
74
+ }