@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.
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- 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
|
+
}
|
package/src/util/hash.ts
ADDED
|
@@ -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
|
+
}
|
package/src/util/size.ts
ADDED
|
@@ -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
|
+
}
|
package/src/util/ulid.ts
ADDED
|
@@ -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
|