@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,164 @@
1
+ // Cache-correctness verification (Phase 1: determinism). Pure helpers for the
2
+ // `vx run --verify` hook in execute-task.ts: content-fingerprint a task's
3
+ // output tree (mtime-independent) and diff two runs' trees, so a task whose
4
+ // outputs are NOT a pure function of its declared inputs — a non-hermetic task
5
+ // whose cache entry would replay arbitrary past bytes — is caught and named.
6
+ // See docs/design/cache-correctness-2026-07.md.
7
+
8
+ import path from 'node:path'
9
+ import type { OutputFingerprint, TaskOutcome, VerifyVerdict } from '../graph/index.js'
10
+ import { xxh3hex } from '../util/index.js'
11
+
12
+ /** An output file to fingerprint: absolute path + a stable, run-independent
13
+ * key (project-relative, or `workspace-outputs/<rel-to-root>` for workspace
14
+ * outputs — matching the cache artifact's namespace so the two never collide). */
15
+ export interface OutputRef {
16
+ abs: string
17
+ key: string
18
+ }
19
+
20
+ /** Build the fingerprint key set from a task's resolved output paths. */
21
+ export function outputRefs(
22
+ projectDir: string,
23
+ outputFiles: readonly string[],
24
+ workspaceRoot: string,
25
+ workspaceOutputFiles: readonly string[],
26
+ ): OutputRef[] {
27
+ const rel = (from: string, abs: string): string =>
28
+ path.relative(from, abs).split(path.sep).join('/')
29
+ return [
30
+ ...outputFiles.map((abs) => ({ abs, key: rel(projectDir, abs) })),
31
+ ...workspaceOutputFiles.map((abs) => ({
32
+ abs,
33
+ key: `workspace-outputs/${rel(workspaceRoot, abs)}`,
34
+ })),
35
+ ]
36
+ }
37
+
38
+ /** Content-fingerprint an output tree: key → xxh3 of the file's BYTES, read
39
+ * unconditionally from disk. Deliberately NOT `Cache.hashFile`: its
40
+ * mtime+size memo would return attempt 1's digest for a re-run output with
41
+ * equal size and mtime (e.g. a build that normalizes mtimes — standard
42
+ * reproducible-build practice), silently proving a divergent task
43
+ * deterministic. fp1/fp2 are only ever compared to each other, so any
44
+ * content hash works — and the proof must not trust a cache. */
45
+ export async function hashOutputTree(refs: readonly OutputRef[]): Promise<Map<string, string>> {
46
+ const out = new Map<string, string>()
47
+ for (const r of refs) {
48
+ out.set(r.key, xxh3hex(new Uint8Array(await Bun.file(r.abs).arrayBuffer())))
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** Per-file map cap on a shipped fingerprint (~40 KB at the cap). The tree
54
+ * digest always folds ALL entries, so detection never depends on the map. */
55
+ export const FP_MAX_FILES = 500
56
+
57
+ /** Roll an output-tree fingerprint map into the shippable `OutputFingerprint`
58
+ * payload (Phase 4, cross-machine diff): a tree digest over ALL sorted
59
+ * entries folded `key\0hash\n` (\0 boundaries so parts can't alias), plus
60
+ * the first-`cap` pairs — deterministic truncation, so two machines'
61
+ * truncated maps cover the same key subset and stay diffable. */
62
+ export function foldFingerprint(fp: Map<string, string>, cap = FP_MAX_FILES): OutputFingerprint {
63
+ const keys = [...fp.keys()].sort()
64
+ let folded = ''
65
+ for (const k of keys) folded += `${k}\0${fp.get(k)!}\n`
66
+ return {
67
+ tree: xxh3hex(folded),
68
+ fileCount: keys.length,
69
+ files: keys.slice(0, cap).map((k) => [k, fp.get(k)!] as const),
70
+ ...(keys.length > cap ? { truncated: true } : {}),
71
+ }
72
+ }
73
+
74
+ /** The output keys whose content OID differs between two fingerprints
75
+ * (added, removed, or changed), sorted. */
76
+ export function diffOutputTrees(a: Map<string, string>, b: Map<string, string>): string[] {
77
+ const changed: string[] = []
78
+ for (const k of new Set([...a.keys(), ...b.keys()])) {
79
+ if (a.get(k) !== b.get(k)) changed.push(k)
80
+ }
81
+ return changed.sort()
82
+ }
83
+
84
+ /** Turn a determinism diff into a verdict, honoring the `--verify-allow`
85
+ * escape hatch (an allowed divergence is reported but never fails the run). */
86
+ export function classifyDeterminism(changed: readonly string[], allowed: boolean): VerifyVerdict {
87
+ if (changed.length === 0) return { kind: 'proven-deterministic' }
88
+ return allowed
89
+ ? { kind: 'allowed-nondeterministic', changed }
90
+ : { kind: 'nondeterministic', changed }
91
+ }
92
+
93
+ /** Extract the workspace-relative paths a task read outside its declared inputs
94
+ * (Phase 2, `--verify=inputs`). A strace-derived violation line carries the
95
+ * offending absolute path in trailing brackets (`… [/abs/path]`); pull it out,
96
+ * make it workspace-relative, dedup, and sort. Lines without a bracketed path
97
+ * (e.g. macOS syscall lines) fall back to the raw line so nothing is lost. */
98
+ export function undeclaredInputPaths(
99
+ violations: readonly { line: string }[],
100
+ workspaceRoot: string,
101
+ ): string[] {
102
+ const rels = new Set<string>()
103
+ for (const v of violations) {
104
+ const m = /\[([^\]]+)\]\s*$/.exec(v.line)
105
+ const raw = m ? m[1]! : v.line
106
+ rels.add(
107
+ path.isAbsolute(raw) ? path.relative(workspaceRoot, raw).split(path.sep).join('/') : raw,
108
+ )
109
+ }
110
+ return [...rels].sort()
111
+ }
112
+
113
+ /** The end-of-run `--verify` summary section: a counts line plus a
114
+ * failures block naming each non-hermetic task and its diverging outputs.
115
+ * Empty when no task carried a verdict. */
116
+ export function formatVerifySection(outcomes: readonly TaskOutcome[]): string[] {
117
+ const verdicts = outcomes.filter((o) => o.verify !== undefined)
118
+ if (verdicts.length === 0) return []
119
+ let proven = 0
120
+ let bad = 0
121
+ let na = 0
122
+ let notVerified = 0
123
+ for (const o of verdicts) {
124
+ switch (o.verify!.kind) {
125
+ case 'proven-deterministic':
126
+ case 'proven-complete':
127
+ proven++
128
+ break
129
+ case 'nondeterministic':
130
+ case 'rerun-failed':
131
+ case 'undeclared-inputs':
132
+ bad++
133
+ break
134
+ case 'allowed-nondeterministic':
135
+ case 'no-outputs':
136
+ na++
137
+ break
138
+ case 'not-verified':
139
+ notVerified++
140
+ break
141
+ }
142
+ }
143
+ const lines = [
144
+ '',
145
+ ` Verify: ${proven} proven · ${bad} unsafe · ${na} n/a · ${notVerified} not-verified`,
146
+ ]
147
+ for (const o of outcomes) {
148
+ const v = o.verify
149
+ if (v === undefined) continue
150
+ if (v.kind === 'nondeterministic') {
151
+ lines.push(` ✗ ${o.node.id} — nondeterministic`)
152
+ lines.push(` changed: ${v.changed.join(', ')}`)
153
+ } else if (v.kind === 'undeclared-inputs') {
154
+ lines.push(` ✗ ${o.node.id} — read undeclared inputs`)
155
+ if (v.paths.length > 0) lines.push(` ${v.paths.join(', ')}`)
156
+ lines.push(' add them to cache.inputs.files / workspaceFiles')
157
+ } else if (v.kind === 'rerun-failed') {
158
+ lines.push(` ✗ ${o.node.id} — verify re-run failed (exit ${v.exitCode})`)
159
+ } else if (v.kind === 'allowed-nondeterministic') {
160
+ lines.push(` ⚠ ${o.node.id} — nondeterministic (allowed)`)
161
+ }
162
+ }
163
+ return lines
164
+ }
@@ -0,0 +1,92 @@
1
+ // The inverse of `wireForwarder`: turn a stream of `WireEvent`s back into
2
+ // `Logger` calls on a concrete renderer. A `vx serve` client feeds the
3
+ // service's event stream through this into a normal `defaultLogger`, so a
4
+ // DELEGATED run renders identically to a local one — the terminal
5
+ // renderer stays completely untouched (it just receives reconstructed
6
+ // node-shaped objects instead of live graph nodes). This file is the
7
+ // whole adapter; it can be rewritten or removed without touching the
8
+ // renderer or the producer side.
9
+
10
+ import type { TaskNode, TaskOutcome } from '../graph/index.js'
11
+ import type { Logger } from './logger.js'
12
+ import type { OutcomeView, TaskView, WireEvent } from './events.js'
13
+
14
+ /**
15
+ * Rebuild a node-shaped object from a `TaskView`. The formatters only ever
16
+ * read `id` / `projectName` / `taskName` / `requested` / `surfaced` and
17
+ * `config.exec` (presence ⇒ not a group; `.command`; `.persistent`), so a
18
+ * structural stand-in is indistinguishable from a real `TaskNode` to them.
19
+ */
20
+ function reconstructNode(view: TaskView): TaskNode {
21
+ const exec = view.isGroup
22
+ ? undefined
23
+ : {
24
+ command: view.command ?? '',
25
+ ...(view.persistent ? { persistent: { readyWhen: '' } } : {}),
26
+ }
27
+ return {
28
+ id: view.id,
29
+ projectName: view.project,
30
+ taskName: view.task,
31
+ requested: view.requested,
32
+ surfaced: view.surfaced,
33
+ config: exec === undefined ? {} : { exec },
34
+ } as unknown as TaskNode
35
+ }
36
+
37
+ function reconstructOutcome(view: OutcomeView, node: TaskNode): TaskOutcome {
38
+ return {
39
+ node,
40
+ status: view.status,
41
+ exitCode: view.exitCode,
42
+ durationMs: view.durationMs,
43
+ ...(view.restored !== undefined ? { restored: view.restored } : {}),
44
+ ...(view.sandboxViolations !== undefined ? { sandboxViolations: view.sandboxViolations } : {}),
45
+ ...(view.sandboxViolationLines !== undefined
46
+ ? { sandboxViolationLines: view.sandboxViolationLines }
47
+ : {}),
48
+ } as TaskOutcome
49
+ }
50
+
51
+ /**
52
+ * Build a `WireEvent` consumer that drives `sink` (typically a
53
+ * `defaultLogger`). Stateful: it accumulates the reconstructed nodes from
54
+ * `task:start` so later `task:*` events can resolve their node by id.
55
+ */
56
+ export function createWireRenderer(sink: Logger): (event: WireEvent) => void {
57
+ const nodes = new Map<string, TaskNode>()
58
+ return (event) => {
59
+ switch (event.kind) {
60
+ case 'run:start':
61
+ sink.runStart?.(event.info)
62
+ return
63
+ case 'task:start': {
64
+ const node = reconstructNode(event.task)
65
+ nodes.set(node.id, node)
66
+ sink.taskStart?.(node)
67
+ return
68
+ }
69
+ case 'task:stdout': {
70
+ const node = nodes.get(event.taskId)
71
+ if (node) sink.taskStdout(node, event.chunk)
72
+ return
73
+ }
74
+ case 'task:stderr': {
75
+ const node = nodes.get(event.taskId)
76
+ if (node) sink.taskStderr(node, event.chunk)
77
+ return
78
+ }
79
+ case 'task:complete': {
80
+ const node = nodes.get(event.outcome.taskId)
81
+ if (node) sink.taskComplete(node, reconstructOutcome(event.outcome, node))
82
+ return
83
+ }
84
+ case 'run:status':
85
+ sink.status(event.line)
86
+ return
87
+ case 'run:end':
88
+ sink.runEnd?.()
89
+ return
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,212 @@
1
+ // JSON-RPC 2.0 envelope + OTel-LogRecord payload — the wire spec
2
+ // committed by docs/design/wire-protocol-2026-06.md. Lives alongside
3
+ // the existing protocol.ts (t-discriminated ServerMessage/ClientMessage)
4
+ // so vx serve can speak both formats during the transition.
5
+ //
6
+ // One envelope, three transports (WS / SSE / NDJSON), four channels
7
+ // (vx:events / vx:state / vx:rpc / vx:submit). Every external consumer
8
+ // that already speaks JSON-RPC works against vx out of the box.
9
+ //
10
+ // Pure types + a small adapter pair. No transport here; the Hono
11
+ // router in src/cli/serve.ts wires the byte frames.
12
+
13
+ import type { RunRequest, RunResult, ServerMessage, ClientMessage } from './protocol.js'
14
+ import type { WireEvent as InternalWireEvent } from './events.js'
15
+
16
+ /** Protocol version returned by GET /version. */
17
+ export const WIRE_PROTOCOL_VERSION = '1.0'
18
+
19
+ /** The four channels we expose; clients pick the subset they care about. */
20
+ export const WIRE_CHANNELS = ['vx:events', 'vx:state', 'vx:rpc', 'vx:submit'] as const
21
+ export type WireChannel = (typeof WIRE_CHANNELS)[number]
22
+
23
+ /** JSON-RPC 2.0 method names emitted by the server (notifications). */
24
+ export type ServerNotificationMethod = 'events.append' | 'state.patch'
25
+
26
+ /** JSON-RPC 2.0 method names dispatched from the client (request/response). */
27
+ export type ClientRequestMethod = 'state.snapshot' | 'submit.run' | `rpc.${string}`
28
+
29
+ /**
30
+ * The JSON-RPC 2.0 envelope. One of four shapes per the spec.
31
+ *
32
+ * - request: { jsonrpc, id, method, params? }
33
+ * - response: { jsonrpc, id, result }
34
+ * - error: { jsonrpc, id, error: { code, message, data? } }
35
+ * - notification: { jsonrpc, method, params? }
36
+ */
37
+ export type Envelope = Request | Response | ErrorResponse | Notification
38
+
39
+ export interface Request {
40
+ jsonrpc: '2.0'
41
+ id: number | string
42
+ method: string
43
+ params?: unknown
44
+ }
45
+
46
+ export interface Response {
47
+ jsonrpc: '2.0'
48
+ id: number | string
49
+ result: unknown
50
+ }
51
+
52
+ export interface ErrorResponse {
53
+ jsonrpc: '2.0'
54
+ id: number | string
55
+ error: { code: number; message: string; data?: unknown }
56
+ }
57
+
58
+ export interface Notification {
59
+ jsonrpc: '2.0'
60
+ method: string
61
+ params?: unknown
62
+ }
63
+
64
+ /** JSON-RPC 2.0 error codes used by vx. The spec reserves -32000..-32099 for impl-defined. */
65
+ export const ENVELOPE_ERRORS = {
66
+ PARSE_ERROR: -32700,
67
+ INVALID_REQUEST: -32600,
68
+ METHOD_NOT_FOUND: -32601,
69
+ INVALID_PARAMS: -32602,
70
+ INTERNAL_ERROR: -32603,
71
+ USER_ERROR: -32000, // a vx UserError — clean message, no stack
72
+ TASK_HASH_UNKNOWN: -32001,
73
+ RUN_NOT_FOUND: -32002,
74
+ UNAUTHORIZED: -32003,
75
+ RATE_LIMITED: -32004,
76
+ } as const
77
+
78
+ /** Build a request envelope. */
79
+ export function makeRequest(id: number | string, method: string, params?: unknown): Request {
80
+ const req: Request = { jsonrpc: '2.0', id, method }
81
+ if (params !== undefined) req.params = params
82
+ return req
83
+ }
84
+
85
+ /** Build a notification envelope. */
86
+ export function makeNotification(method: string, params?: unknown): Notification {
87
+ const note: Notification = { jsonrpc: '2.0', method }
88
+ if (params !== undefined) note.params = params
89
+ return note
90
+ }
91
+
92
+ /** Build a success response envelope. */
93
+ export function makeResponse(id: number | string, result: unknown): Response {
94
+ return { jsonrpc: '2.0', id, result }
95
+ }
96
+
97
+ /** Build an error response envelope. */
98
+ export function makeError(
99
+ id: number | string,
100
+ code: number,
101
+ message: string,
102
+ data?: unknown,
103
+ ): ErrorResponse {
104
+ const err: ErrorResponse = { jsonrpc: '2.0', id, error: { code, message } }
105
+ if (data !== undefined) err.error.data = data
106
+ return err
107
+ }
108
+
109
+ /** Type-guard: does a parsed object look like an Envelope? */
110
+ export function isEnvelope(value: unknown): value is Envelope {
111
+ if (value === null || typeof value !== 'object') return false
112
+ const v = value as { jsonrpc?: unknown }
113
+ return v.jsonrpc === '2.0'
114
+ }
115
+
116
+ /** Type-guard for a request envelope. */
117
+ export function isRequest(env: Envelope): env is Request {
118
+ return 'method' in env && 'id' in env
119
+ }
120
+
121
+ /** Type-guard for a notification envelope (request without id). */
122
+ export function isNotification(env: Envelope): env is Notification {
123
+ return 'method' in env && !('id' in env)
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Bidirectional adapters between the legacy t-discriminated wire and the
128
+ // JSON-RPC envelope. Lets vx serve accept both formats on the same WS
129
+ // endpoint during the transition: parse one, fall back to the other.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Project a ServerMessage to an envelope. Events become `events.append`
134
+ * notifications; results become `submit.run` responses; errors become
135
+ * error responses. The distributed coordinator messages map to the
136
+ * `coord.*` namespace in a distribution plugin's own adapters.
137
+ */
138
+ export function serverMessageToEnvelope(msg: ServerMessage, id?: number | string): Envelope {
139
+ switch (msg.t) {
140
+ case 'event':
141
+ return makeNotification('events.append', msg.event)
142
+ case 'result':
143
+ // submit.run's response. id required from the caller.
144
+ return makeResponse(id ?? 0, msg.result)
145
+ case 'error':
146
+ return makeError(id ?? 0, ENVELOPE_ERRORS.USER_ERROR, msg.message)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Project an envelope back to a ServerMessage where one exists. Returns
152
+ * null for envelopes that don't have a mapping (e.g. state.snapshot
153
+ * responses — pure JSON-RPC, no submitter form).
154
+ */
155
+ export function envelopeToServerMessage(env: Envelope): ServerMessage | null {
156
+ if (isNotification(env) && env.method === 'events.append') {
157
+ return { t: 'event', event: env.params as InternalWireEvent }
158
+ }
159
+ if ('result' in env) {
160
+ return { t: 'result', result: env.result as RunResult }
161
+ }
162
+ if ('error' in env) {
163
+ return { t: 'error', message: env.error.message }
164
+ }
165
+ return null
166
+ }
167
+
168
+ /** Project a ClientMessage to an envelope. `run` becomes a `submit.run` request. */
169
+ export function clientMessageToEnvelope(msg: ClientMessage, id?: number | string): Envelope {
170
+ return makeRequest(id ?? 1, 'submit.run', msg.request)
171
+ }
172
+
173
+ /** Project an envelope back to a ClientMessage. */
174
+ export function envelopeToClientMessage(env: Envelope): ClientMessage | null {
175
+ if (isRequest(env) && env.method === 'submit.run') {
176
+ return { t: 'run', request: env.params as RunRequest }
177
+ }
178
+ return null
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Transport encoders — single bus, one envelope, three byte framings.
183
+ // Helpers exposed for the Hono mounts in src/cli/serve.ts.
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /** Encode an envelope for a WebSocket frame (one JSON object per frame). */
187
+ export function encodeForWS(env: Envelope): string {
188
+ return JSON.stringify(env)
189
+ }
190
+
191
+ /**
192
+ * Encode an envelope as an SSE event block. Each block is one
193
+ * `data: <json>\n\n` chunk so a `curl` or browser EventSource gets it
194
+ * cleanly.
195
+ */
196
+ export function encodeForSSE(env: Envelope): string {
197
+ return `data: ${JSON.stringify(env)}\n\n`
198
+ }
199
+
200
+ /** Encode an envelope as a NDJSON line. One envelope per line. */
201
+ export function encodeForNDJSON(env: Envelope): string {
202
+ return JSON.stringify(env) + '\n'
203
+ }
204
+
205
+ /** Parse a UTF-8 string back to an envelope (and validate the shape). */
206
+ export function decodeEnvelope(raw: string): Envelope {
207
+ const value = JSON.parse(raw) as unknown
208
+ if (!isEnvelope(value)) {
209
+ throw new Error(`not a JSON-RPC 2.0 envelope: ${raw.slice(0, 100)}`)
210
+ }
211
+ return value
212
+ }
@@ -0,0 +1,11 @@
1
+ // Shared error types used across modules to distinguish user-input
2
+ // failures (clean message only) from internal bugs (full stack trace).
3
+ //
4
+ // `bin.ts` consults `instanceof UserError` to decide what to print.
5
+
6
+ export class UserError extends Error {
7
+ constructor(message: string) {
8
+ super(message)
9
+ this.name = 'UserError'
10
+ }
11
+ }
@@ -0,0 +1,25 @@
1
+ // Shared non-cryptographic hash helper. xxHash3 is ~5× faster than
2
+ // SHA-256 on modern x86 and produces enough entropy (64 bits) for our
3
+ // uses: cache-key derivation, file content fingerprinting, config-
4
+ // load module cache-busting. None of those need collision resistance
5
+ // against an adversary; they just need uniqueness across honest input.
6
+ //
7
+ // Hex output is a fixed 16-char string. Turbo uses the same shape
8
+ // (xxh64 -> hex(to_be_bytes(u64))) — short enough to live in
9
+ // filenames, recognizable when grepping logs.
10
+ //
11
+ // `Bun.hash.xxHash3` has no streaming Hasher API but takes a seed —
12
+ // we use that seed to chain multiple updates: each `xxh3(part, prev)`
13
+ // folds `part` into the running digest. Equivalent to the old
14
+ // `CryptoHasher.update(...).update(...).digest()` pattern, without
15
+ // allocating an intermediate buffer.
16
+
17
+ /** xxHash3 bigint output. Use as the seed for the next chain step. */
18
+ export function xxh3(input: string | Uint8Array, seed: bigint = 0n): bigint {
19
+ return Bun.hash.xxHash3(input, seed)
20
+ }
21
+
22
+ /** xxHash3 hex-encoded, fixed 16 chars (zero-padded). */
23
+ export function xxh3hex(input: string | Uint8Array, seed: bigint = 0n): string {
24
+ return xxh3(input, seed).toString(16).padStart(16, '0')
25
+ }
@@ -0,0 +1,8 @@
1
+ // Module contract. Cross-module imports must come through here; see
2
+ // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
+
4
+ export { UserError } from './errors.js'
5
+ export { xxh3, xxh3hex } from './hash.js'
6
+ export { relPosix } from './paths.js'
7
+ export { parseSize } from './size.js'
8
+ export { ulid } from './ulid.js'
@@ -0,0 +1,16 @@
1
+ import path from 'node:path'
2
+
3
+ const IS_POSIX = path.sep === '/'
4
+
5
+ /** Convert a path to POSIX-style for cross-platform stable cache keys. */
6
+ export function toPosix(p: string): string {
7
+ // Fast path: on Linux/macOS the separator is already '/'. This
8
+ // function is called for every input file on every cache-key
9
+ // derivation; skipping the split+join is free perf on the dominant
10
+ // dev platform.
11
+ return IS_POSIX ? p : p.split(path.sep).join('/')
12
+ }
13
+
14
+ export function relPosix(from: string, to: string): string {
15
+ return toPosix(path.relative(from, to))
16
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Parse a byte-size string: a bare integer (`"1048576"`) or an integer
3
+ * with a K/M/G/T suffix, powers of 1024, optional trailing `B`
4
+ * (`"512MB"`, `"8G"`). Returns `null` for anything else — including
5
+ * fractional sizes (`"1.5GB"`); callers wanting fractions must express
6
+ * them in a smaller unit.
7
+ */
8
+ export function parseSize(input: string): number | null {
9
+ const m = input.match(/^(\d+)([KMGT])?B?$/i)
10
+ if (!m) return null
11
+ const n = Number(m[1])
12
+ const u = (m[2] ?? '').toUpperCase()
13
+ const mult =
14
+ u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
15
+ return n * mult
16
+ }
@@ -0,0 +1,15 @@
1
+ // Run-id generator. Thin wrapper over `Bun.randomUUIDv7()` so the
2
+ // `runId` column on `runs` carries a timestamp-ordered identifier —
3
+ // `vx stats --since` queries can range-scan without an index on the
4
+ // time column, and the IDs lexicographically sort by creation time
5
+ // for grouping.
6
+ //
7
+ // UUIDv7 layout: 48-bit ms-epoch timestamp + 74 bits of randomness
8
+ // across the remaining bytes (RFC 9562). Standard format, hex with
9
+ // hyphens, 36 chars. Previously a hand-rolled 26-char Crockford-
10
+ // base32 ULID — Bun's built-in covers the same guarantees with zero
11
+ // custom code.
12
+
13
+ export function ulid(): string {
14
+ return Bun.randomUUIDv7()
15
+ }
package/src/version.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Leaf module so orchestrator/cli can read the version without
2
+ // importing the public façade (index.ts imports the orchestrator —
3
+ // that edge must stay one-directional).
4
+ //
5
+ // Single source of truth is package.json — Bun resolves the JSON
6
+ // import natively (and inlines it under `bun build --compile`), so
7
+ // a release bump can never drift from what the banner prints.
8
+ import pkg from '../package.json'
9
+
10
+ export const VERSION: string = pkg.version