@vzn/vx 0.0.0 → 0.0.14

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 +349 -1
  3. package/launcher.mjs +92 -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 +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.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 +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,377 @@
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 {
5
+ DependencySpecError,
6
+ compileTaskPattern,
7
+ isTaskPattern,
8
+ parseDependencySpec,
9
+ type DependencySpec,
10
+ } from './dependency-spec.js'
11
+
12
+ // Re-exported so existing importers keep working while the type's home
13
+ // moves to workspace (it's the joint product of discovery + loading).
14
+ export type { ProjectEntry } from '../workspace/index.js'
15
+
16
+ export interface TaskNode {
17
+ /** Stable id: `${projectName}#${taskName}`. */
18
+ id: string
19
+ projectName: string
20
+ projectDir: string
21
+ taskName: string
22
+ config: TaskConfig
23
+ /** Ids of tasks that must complete before this one runs. */
24
+ deps: string[]
25
+ /**
26
+ * True for the tasks the user actually asked for (via cwd, `-r`, `-F`,
27
+ * or `pkg#task`). False for dependencies pulled in by `dependsOn`
28
+ * expansion. Used by the orchestrator to scope `forwardArgs` so trailing
29
+ * CLI args don't leak into upstream tasks the user didn't address.
30
+ */
31
+ requested: boolean
32
+ /**
33
+ * Display-only: a same-project, non-group, direct `dependsOn` task of
34
+ * a REQUESTED GROUP. A group produces no output of its own, so in
35
+ * focused flow we surface the work it stands for one level down (no
36
+ * recursion, no `^`/cross-project deps — see `markSurfacedDeps`). The
37
+ * focused logger shows these like requested tasks; it does NOT make
38
+ * them `requested`, so `forwardArgs` scoping is unaffected.
39
+ */
40
+ surfaced?: boolean
41
+ }
42
+
43
+ export function taskId(project: string, task: string): string {
44
+ return `${project}#${task}`
45
+ }
46
+
47
+ /**
48
+ * A task is a "group" if it has no `exec` — it exists only to chain
49
+ * `dependsOn` (an umbrella for `vx run ci`). Group tasks:
50
+ * - never spawn a process,
51
+ * - never read/write the cache (it's a config error to declare one),
52
+ * - never appear in the run summary or `runs` analytics table,
53
+ * - render no framed block in the live output.
54
+ * Six call sites used to repeat `node.config.exec === undefined`;
55
+ * centralising the predicate prevents that check from drifting.
56
+ */
57
+ export function isGroupTask(node: TaskNode): boolean {
58
+ return node.config.exec === undefined
59
+ }
60
+
61
+ /**
62
+ * Mark, for focused-flow display, the real tasks a requested GROUP
63
+ * stands for. A group has no output of its own, so `vx run build`
64
+ * (where `build` is a group) would otherwise print nothing.
65
+ *
66
+ * Walk `dependsOn` from the requested group, DESCENDING THROUGH nested
67
+ * same-project groups (e.g. `build` → `build.bun` → `build.bun.*`), and
68
+ * surface the first non-group task on each path. Two hard limits:
69
+ * - never leave the requested project — `^`/cross-project deps are
70
+ * not entered (the user asked to run THIS project's group),
71
+ * - never descend past a real task — its own `dependsOn` is its
72
+ * implementation detail, not work the group "is".
73
+ *
74
+ * Display-only: it never flips `requested`, so `forwardArgs` scoping
75
+ * stays put. Returns the count of newly surfaced nodes.
76
+ */
77
+ export function markSurfacedDeps(nodes: Map<string, TaskNode>): number {
78
+ let surfaced = 0
79
+ for (const node of nodes.values()) {
80
+ if (!node.requested || !isGroupTask(node)) continue
81
+ const project = node.projectName
82
+ const stack = [...node.deps]
83
+ const visited = new Set<string>()
84
+ while (stack.length > 0) {
85
+ const depId = stack.pop()!
86
+ if (visited.has(depId)) continue
87
+ visited.add(depId)
88
+ const dep = nodes.get(depId)
89
+ // Stay inside the requested project; `^`/cross-project deps are
90
+ // neither surfaced nor traversed.
91
+ if (!dep || dep.projectName !== project) continue
92
+ if (isGroupTask(dep)) {
93
+ // A nested group: keep descending, don't surface the group.
94
+ for (const next of dep.deps) stack.push(next)
95
+ continue
96
+ }
97
+ if (dep.surfaced !== true) {
98
+ dep.surfaced = true
99
+ surfaced++
100
+ }
101
+ }
102
+ }
103
+ return surfaced
104
+ }
105
+
106
+ /**
107
+ * Expand the user-requested task list into concrete `{project, task}`
108
+ * pairs the graph builder consumes.
109
+ *
110
+ * - Bare task names (`'build'`) → one entry per project in
111
+ * `candidates` that declares the task. Missing in a given project
112
+ * is silent (sparse tasks are normal across a workspace).
113
+ * - Anchored entries (`'pkg#task'`) → one entry exactly, ignoring
114
+ * `candidates`. Silently dropped if pkg/task doesn't exist (the
115
+ * CLI's pre-validation catches malformed strings).
116
+ *
117
+ * Duplicates are deduped (a user might pass `vx run build pkg#build`).
118
+ */
119
+ export function expandRequested(
120
+ tasks: readonly string[],
121
+ candidates: readonly string[],
122
+ projects: Map<string, ProjectEntry>,
123
+ ): Array<{ project: string; task: string }> {
124
+ const seen = new Set<string>()
125
+ const out: Array<{ project: string; task: string }> = []
126
+ const push = (project: string, task: string): void => {
127
+ const key = `${project}#${task}`
128
+ if (seen.has(key)) return
129
+ seen.add(key)
130
+ out.push({ project, task })
131
+ }
132
+ for (const spec of tasks) {
133
+ const idx = spec.indexOf('#')
134
+ if (idx >= 0) {
135
+ const project = spec.slice(0, idx)
136
+ const task = spec.slice(idx + 1)
137
+ if (projects.get(project)?.config.tasks?.[task]) push(project, task)
138
+ continue
139
+ }
140
+ for (const name of candidates) {
141
+ if (projects.get(name)?.config.tasks?.[spec]) push(name, spec)
142
+ }
143
+ }
144
+ return out
145
+ }
146
+
147
+ export interface BuildGraphOptions {
148
+ projects: Map<string, ProjectEntry>
149
+ packageGraph: PackageGraph
150
+ /** Initial set: `{ project, task }` pairs the user asked to run. */
151
+ requested: Array<{ project: string; task: string }>
152
+ /**
153
+ * Filter `dependsOn` expansion.
154
+ * - `undefined` → every dependsOn entry is followed (default).
155
+ * - `'all'` → no expansion; only the requested nodes are added.
156
+ * - `string[]` → expand normally, but drop edges whose target
157
+ * task name is in the list. `dependsOn.self` and
158
+ * `dependsOn.dependencies` are both filtered.
159
+ */
160
+ excludeDependencies?: 'all' | readonly string[]
161
+ }
162
+
163
+ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode> {
164
+ const { projects, packageGraph, requested, excludeDependencies } = options
165
+ const skipAll = excludeDependencies === 'all'
166
+ const skipNames =
167
+ Array.isArray(excludeDependencies) && excludeDependencies.length > 0
168
+ ? new Set(excludeDependencies)
169
+ : null
170
+ const nodes = new Map<string, TaskNode>()
171
+
172
+ function addNode(projectName: string, taskName: string, requested: boolean): TaskNode | null {
173
+ const id = taskId(projectName, taskName)
174
+ const existing = nodes.get(id)
175
+ if (existing) {
176
+ // Promote an already-added node to requested if any caller asked
177
+ // for it directly. Once requested, never demoted.
178
+ if (requested) existing.requested = true
179
+ return existing
180
+ }
181
+
182
+ const project = projects.get(projectName)
183
+ if (!project) return null
184
+ const taskConfig = project.config.tasks?.[taskName]
185
+ if (!taskConfig) return null
186
+
187
+ const node: TaskNode = {
188
+ id,
189
+ projectName,
190
+ projectDir: project.dir,
191
+ taskName,
192
+ config: taskConfig,
193
+ deps: [],
194
+ requested,
195
+ }
196
+ nodes.set(id, node)
197
+
198
+ if (skipAll) return node
199
+
200
+ const rawSpecs = taskConfig.dependsOn ?? []
201
+ for (const raw of rawSpecs) {
202
+ let spec: DependencySpec
203
+ try {
204
+ spec = parseDependencySpec(raw)
205
+ } catch (err) {
206
+ if (err instanceof DependencySpecError) {
207
+ throw new UserError(`Task ${id}: ${err.message}`)
208
+ }
209
+ throw err
210
+ }
211
+
212
+ // dependsOn is about which tasks to ADD to the graph, not which
213
+ // to filter. BARE wildcards ("*"/"^*" = "everything upstream") and
214
+ // negation aren't meaningful here — they're cache.inputs.tasks
215
+ // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
216
+ // they name a namespace of tasks to add (Nx 19.5 parity).
217
+ if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
218
+ throw new UserError(`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`)
219
+ }
220
+ if (spec.negated) {
221
+ throw new UserError(`Task ${id}: dependsOn does not accept negation (got "${raw}")`)
222
+ }
223
+ if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
224
+ throw new UserError(
225
+ `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
226
+ )
227
+ }
228
+ // CLI `--excludeDependencies=name1,name2` drops edges whose target
229
+ // task name matches, regardless of bucket (self / deps / cross).
230
+ // Pattern specs re-apply the filter per EXPANDED name below.
231
+ if (skipNames?.has(spec.task)) continue
232
+
233
+ if (spec.kind === 'self') {
234
+ if (isTaskPattern(spec.task)) {
235
+ // `build.*` — every OTHER same-project task matching the
236
+ // pattern (the declaring task never matches itself — that
237
+ // would be an instant self-cycle). Zero matches is legal: a
238
+ // preset-spread pattern needn't match in every project.
239
+ const re = compileTaskPattern(spec.task)
240
+ for (const name of Object.keys(project.config.tasks ?? {})) {
241
+ if (name === taskName || !re.test(name)) continue
242
+ if (skipNames?.has(name)) continue
243
+ const child = addNode(projectName, name, false)
244
+ if (child) node.deps.push(child.id)
245
+ }
246
+ } else {
247
+ // Missing target is a hard error — the user typed a name that
248
+ // doesn't resolve in this project.
249
+ const child = addNode(projectName, spec.task, false)
250
+ if (!child) {
251
+ throw new UserError(
252
+ `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
253
+ )
254
+ }
255
+ node.deps.push(child.id)
256
+ }
257
+ } else if (spec.kind === 'deps') {
258
+ // Nearest-holder frontier (Turbo/Nx direct-deps parity +
259
+ // sparse bridging): walk the package dep graph from this
260
+ // project's direct deps; each path stops at the FIRST package
261
+ // declaring the task — a holder's own dependsOn is responsible
262
+ // for anything deeper. Packages without the task are passed
263
+ // through so a sparse dep doesn't break ordering to deeper
264
+ // holders. The visited set both dedupes shared subtrees and
265
+ // terminates on package-graph cycles (legal in PMs).
266
+ //
267
+ // With a pattern (`^build.*`), a holder is a package declaring
268
+ // AT LEAST ONE matching task and it receives edges to ALL its
269
+ // matches — holder-ness is about declaration, so a holder still
270
+ // stops the walk even when every match is --excludeDependencies'd.
271
+ const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
272
+ const visited = new Set<string>()
273
+ const frontier = [...packageGraph.directDeps(projectName)]
274
+ while (frontier.length > 0) {
275
+ const target = frontier.pop()!
276
+ if (visited.has(target)) continue
277
+ visited.add(target)
278
+ if (re === null) {
279
+ const child = addNode(target, spec.task, false)
280
+ if (child) node.deps.push(child.id)
281
+ else frontier.push(...packageGraph.directDeps(target))
282
+ } else {
283
+ const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
284
+ re.test(n),
285
+ )
286
+ if (names.length > 0) {
287
+ for (const name of names) {
288
+ if (skipNames?.has(name)) continue
289
+ const child = addNode(target, name, false)
290
+ if (child) node.deps.push(child.id)
291
+ }
292
+ } else {
293
+ frontier.push(...packageGraph.directDeps(target))
294
+ }
295
+ }
296
+ }
297
+ } else {
298
+ // Cross-project edge: pkg#task. Missing target is a hard error
299
+ // because the user named the package + task explicitly.
300
+ const child = addNode(spec.project, spec.task, false)
301
+ if (!child) {
302
+ throw new UserError(
303
+ `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
304
+ )
305
+ }
306
+ node.deps.push(child.id)
307
+ }
308
+ }
309
+
310
+ // Stable ordering for deterministic scheduling and cache keys — deduped:
311
+ // a target named twice (an exact entry + a pattern matching it, or a
312
+ // literal duplicate) must contribute ONE edge, not a double-folded
313
+ // upstream hash and a doubled DOT edge.
314
+ node.deps = [...new Set(node.deps)].sort()
315
+ return node
316
+ }
317
+
318
+ for (const { project, task } of requested) {
319
+ addNode(project, task, true)
320
+ }
321
+
322
+ detectCycle(nodes)
323
+ return nodes
324
+ }
325
+
326
+ function detectCycle(nodes: Map<string, TaskNode>): void {
327
+ // Iterative DFS over dense-indexed colors. Recursion + `Map<string,
328
+ // number>` worked, but deep workspaces (long chains of `dependsOn`)
329
+ // can blow V8's frame budget, and per-node Map lookups dominate the
330
+ // pass. A Uint8Array indexed by node-position is allocation-free
331
+ // and ~2× faster on cycle detection itself.
332
+ // 0 = WHITE (unvisited) — the typed array's zero-init default.
333
+ const GRAY = 1
334
+ const BLACK = 2
335
+ const idsArr = [...nodes.keys()]
336
+ const idIdx = new Map<string, number>()
337
+ for (let i = 0; i < idsArr.length; i++) idIdx.set(idsArr[i]!, i)
338
+ const color = new Uint8Array(idsArr.length)
339
+
340
+ // Each stack frame is `[idIndex, nextChildIdx]`. We mutate the
341
+ // child index in place as children are visited so we know which one
342
+ // to resume on when the leaf returns control here.
343
+ const stack: number[] = []
344
+ for (let start = 0; start < idsArr.length; start++) {
345
+ if (color[start] === BLACK) continue
346
+ stack.push(start, 0)
347
+ color[start] = GRAY
348
+ while (stack.length > 0) {
349
+ const childIdx = stack[stack.length - 1]!
350
+ const idx = stack[stack.length - 2]!
351
+ const node = nodes.get(idsArr[idx]!)
352
+ const deps = node ? node.deps : []
353
+ if (childIdx < deps.length) {
354
+ stack[stack.length - 1] = childIdx + 1
355
+ const depIdx = idIdx.get(deps[childIdx]!)
356
+ if (depIdx === undefined) continue
357
+ const c = color[depIdx]!
358
+ if (c === BLACK) continue
359
+ if (c === GRAY) {
360
+ // Reconstruct the cycle by walking the open stack until we
361
+ // hit the id that triggered the GRAY-on-GRAY hit.
362
+ const path: string[] = []
363
+ for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
364
+ const startInPath = path.indexOf(idsArr[depIdx]!)
365
+ const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
366
+ throw new UserError(`Cycle detected in task graph: ${cycle}`)
367
+ }
368
+ color[depIdx] = GRAY
369
+ stack.push(depIdx, 0)
370
+ } else {
371
+ color[idx] = BLACK
372
+ stack.pop()
373
+ stack.pop()
374
+ }
375
+ }
376
+ }
377
+ }
package/src/index.ts ADDED
@@ -0,0 +1,222 @@
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
+ ResourcesConfig,
25
+ SandboxConfig,
26
+ SandboxNetworkConfig,
27
+ } from './config.js'
28
+ export { defineProject, defineWorkspace } from './config.js'
29
+
30
+ // Programmatic engine API (run / plan / prepare) + the graph primitives a
31
+ // distribution submitter/agent reasons over + the cache-key hashing seam.
32
+ // `deriveStableKeys` is THE shared stable-key derivation (remote-prefetch,
33
+ // the local short-circuit, and the distributed submitter must never drift
34
+ // on the stability gate); `captureGitContext`/`captureWorkspaceIdentity`
35
+ // give agents + the submitter identity before/without a telemetry run.
36
+ export { run, planRun, prepareRun } from './orchestrator/index.js'
37
+ export type { PreparedRun } from './orchestrator/index.js'
38
+ export { computeTaskHash, createHashCache, deriveStableKeys } from './orchestrator/index.js'
39
+ export type { DeriveStableKeysArgs, HashCache, StableKey } from './orchestrator/index.js'
40
+ export {
41
+ captureGitContext,
42
+ captureWorkspaceIdentity,
43
+ resolveCacheScope,
44
+ } from './orchestrator/index.js'
45
+ export type { GitContext, WorkspaceIdentity } from './orchestrator/index.js'
46
+ export { FULL_CACHE_POLICY, parseCachePolicy } from './orchestrator/index.js'
47
+ export type {
48
+ CachePolicy,
49
+ Logger,
50
+ OutputView,
51
+ RunOptions,
52
+ RunSummary,
53
+ } from './orchestrator/index.js'
54
+ export { defaultLogger, resolveOutputView } from './orchestrator/index.js'
55
+ export { buildTaskGraph, expandRequested, isGroupTask, markSurfacedDeps } from './graph/index.js'
56
+ export type {
57
+ OutputFingerprint,
58
+ TaskNode,
59
+ TaskOutcome,
60
+ TaskStatus,
61
+ VerifyVerdict,
62
+ } from './graph/index.js'
63
+ // The one output-tree diff implementation (verify verdicts + a serve's
64
+ // cross-machine fingerprint diff both name rels through it).
65
+ export { diffOutputTrees } from './orchestrator/index.js'
66
+
67
+ // Cache classes + the layer interface (the `cache` capability's currency)
68
+ // and input-output resolution. The blob-CAS/digest substrate
69
+ // (cas-backend.ts / digest.ts) stays module-internal until it has a
70
+ // consumer — no speculative public API. `cleanOutputs` is public for the
71
+ // distributed submitter's targeted output materialization (wipe declared
72
+ // outputs, then `restoreOutputs` the artifact — never a naive re-run).
73
+ export {
74
+ Cache,
75
+ LayeredCache,
76
+ GitFilesCache,
77
+ cleanOutputs,
78
+ resolveInputs,
79
+ resolveOutputs,
80
+ } from './cache/index.js'
81
+ export type { CacheLayer, RemoteCacheLayer, RunRecord, InvocationRecord } from './cache/index.js'
82
+
83
+ // Workspace discovery + the project/config catalog surface — an
84
+ // out-of-process service/CLI needs these. `readLockfile` is THE one reader
85
+ // of vx-lock.json (the format carries its own version sentinel; a second
86
+ // parser in a sibling package would drift), and the loader chain
87
+ // (`loadWorkspace` → `listProjectMetas` → `loadProjectConfig`) is the same
88
+ // one `vx show` uses. Workspace's `listProjects` re-exports as
89
+ // `listProjectMetas` — the bare name belongs to the metrics query below.
90
+ export { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from './workspace/index.js'
91
+ export { readLockfile, LOCKFILE_NAME } from './workspace/index.js'
92
+ export type { Lockfile, LockfileEntry } from './workspace/index.js'
93
+ export {
94
+ loadWorkspace,
95
+ loadProjectConfig,
96
+ listProjects as listProjectMetas,
97
+ } from './workspace/index.js'
98
+ export type { ProjectMeta } from './workspace/index.js'
99
+
100
+ // Plugin API — the run-level extension points. Behavior capabilities
101
+ // (backend / cache) change WHAT/HOW work runs; the observe-only `telemetry`
102
+ // capability is the canonical data-export path and cannot change behavior.
103
+ // A plugin is declared in vx.workspace.ts via defineWorkspace({ plugins }).
104
+ // See docs/design/observability-architecture-2026-06.md.
105
+ export type {
106
+ VxPlugin,
107
+ EventSink,
108
+ BackendContext,
109
+ CacheContext,
110
+ EventSinkContext,
111
+ PluginSetupContext,
112
+ } from './orchestrator/index.js'
113
+
114
+ // Telemetry — THE canonical, versioned data-export contract every exporter
115
+ // (an OTel exporter, an HTTP sink, or any third-party consumer) reads. A sink implements TelemetrySink and is
116
+ // returned from VxPlugin.telemetry(); it receives immutable records and holds
117
+ // no run handle (observe-only by construction).
118
+ export { TELEMETRY_SCHEMA_VERSION, deriveCacheSource } from './orchestrator/index.js'
119
+ export type {
120
+ CacheSource,
121
+ RunContextRecord,
122
+ RunSummaryRecord,
123
+ TaskTelemetry,
124
+ TelemetryContext,
125
+ TelemetryRecord,
126
+ TelemetrySink,
127
+ } from './orchestrator/index.js'
128
+
129
+ // The submitter wire contract + backend interface (the `backend`
130
+ // capability's currency) and the serializable event projection.
131
+ export {
132
+ optionsToRequest,
133
+ requestToOptions,
134
+ projectNode,
135
+ projectOutcome,
136
+ createWireRenderer,
137
+ } from './orchestrator/index.js'
138
+ export type {
139
+ RunBackend,
140
+ RunRequest,
141
+ RunResult,
142
+ ClientMessage,
143
+ ServerMessage,
144
+ } from './orchestrator/index.js'
145
+
146
+ // Event bus + wire form — adapters (otel-bridge, custom subscribers) ride this.
147
+ export {
148
+ createEventBus,
149
+ wireForwarder,
150
+ toWireEvent,
151
+ createVxSurface,
152
+ } from './orchestrator/index.js'
153
+ export type {
154
+ EventBus,
155
+ RunEvent,
156
+ RunEventSubscriber,
157
+ WireEvent,
158
+ TaskView,
159
+ OutcomeView,
160
+ } from './orchestrator/index.js'
161
+
162
+ // The JSON-RPC 2.0 wire envelope — an out-of-process serve/agent speaks this framing.
163
+ export {
164
+ clientMessageToEnvelope,
165
+ decodeEnvelope,
166
+ encodeForNDJSON,
167
+ encodeForSSE,
168
+ encodeForWS,
169
+ ENVELOPE_ERRORS,
170
+ envelopeToClientMessage,
171
+ envelopeToServerMessage,
172
+ isEnvelope,
173
+ isNotification,
174
+ isRequest,
175
+ makeError,
176
+ makeNotification,
177
+ makeRequest,
178
+ makeResponse,
179
+ serverMessageToEnvelope,
180
+ WIRE_CHANNELS,
181
+ WIRE_PROTOCOL_VERSION,
182
+ } from './orchestrator/index.js'
183
+ export type {
184
+ Envelope,
185
+ ErrorResponse,
186
+ Notification,
187
+ WireRequest,
188
+ WireResponse,
189
+ WireChannel,
190
+ } from './orchestrator/index.js'
191
+
192
+ // Metrics / analytics query layer over cache.db — a service plugin's HTTP
193
+ // surface reads from these (the queries stay in core; a serve exposes them over HTTP).
194
+ export {
195
+ cacheKeyDiff,
196
+ compareRuns,
197
+ explainCacheKeyQuery,
198
+ getBottlenecks,
199
+ getCacheBreakdown,
200
+ getCacheSavings,
201
+ getCacheStatsSql,
202
+ getFlakiestTasks,
203
+ getHistory,
204
+ getHitRateSplit,
205
+ getInvocation,
206
+ getParallelismHistory,
207
+ getPeriodComparison,
208
+ getPrunableEntries,
209
+ getRecentFailures,
210
+ getRegressions,
211
+ getRun,
212
+ getRunHeatmap,
213
+ getRunTrends,
214
+ getStorageGrowth,
215
+ getTaskDetail,
216
+ getTopTimeBurners,
217
+ listCacheEntries,
218
+ listInvocations,
219
+ listProjects,
220
+ listRuns,
221
+ whyDidThisRerunQuery,
222
+ } 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
+ }