@vzn/vx 0.0.16 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
package/src/cache/tar.ts DELETED
@@ -1,343 +0,0 @@
1
- // In-process tar reader (POSIX ustar) for cache-hit restore.
2
- //
3
- // We replace `Bun.spawn(['tar', '-xf', ...])` on the cache-hit path
4
- // to save a per-restore fork+exec (~5-10ms on Linux). At 200 cached
5
- // tasks per run, that's 1-2s of pure overhead reclaimed.
6
- //
7
- // Tar layout we accept (produced by GNU/BSD `tar -cf - -C stage X Y Z`):
8
- // - 512-byte POSIX ustar headers
9
- // - name : bytes 0..99 (null-padded)
10
- // - mode : bytes 100..107 (octal ASCII, null-padded)
11
- // - mtime : bytes 136..147 (octal ASCII seconds since epoch)
12
- // - size : bytes 124..135 (octal ASCII)
13
- // - typeflag : byte 156 ('0' or '\0' = file, '5' = dir,
14
- // '2' = symlink, 'L' = GNU longname,
15
- // 'x' / 'g' / 'X' = PAX extended
16
- // header — metadata for the next
17
- // entry, not a file itself)
18
- // - data : `size` bytes, padded up to next 512-byte block
19
- // - end : two 512-byte zero blocks
20
- //
21
- // We tolerate the GNU longname extension (typeflag 'L') because some
22
- // tar binaries emit it for paths > 100 chars even when ustar's
23
- // prefix+name (256 chars) would suffice. PAX extended-header records
24
- // ('x' / 'g' / 'X') are SKIPPED — BSD tar (macOS default) emits one
25
- // per entry for xattrs / mtime-nanos / SCHILY metadata. We don't
26
- // need any of that; treating the headers as regular files would put
27
- // `PaxHeaders/foo` junk entries into the restored tree.
28
- //
29
- // AppleDouble files (`._<name>`) are also SKIPPED — macOS Finder /
30
- // `cp -p` leave these resource-fork siblings around (e.g. `._main.js`
31
- // next to `main.js`). They're not real outputs; filtering at parse
32
- // time prevents them from showing up in restored trees even when an
33
- // older / contaminated cache entry includes them. The matching
34
- // input-side filter lives in `src/cache/inputs.ts:ALWAYS_IGNORE`.
35
- //
36
- // We DO NOT support sparse files, character/block devices, or
37
- // hardlinks — they don't appear in build outputs.
38
-
39
- import { lstat, mkdir, chmod, realpath, unlink, utimes } from 'node:fs/promises'
40
- import path from 'node:path'
41
-
42
- export interface TarHeader {
43
- /** Full archive-relative name, e.g. "outputs/dist/index.js" or "manifest.json". */
44
- name: string
45
- size: number
46
- /** File permission bits, e.g. 0o644. */
47
- mode: number
48
- /** Modification time in ms since epoch (seconds-resolution from tar). */
49
- mtimeMs: number
50
- isDir: boolean
51
- /** Offset in `tarBytes` where the file's data starts. */
52
- dataOffset: number
53
- }
54
-
55
- /**
56
- * Walk a tar archive's headers and return them in source order.
57
- * Cheap: only reads 512-byte blocks; never decodes file bodies.
58
- */
59
- export function parseTarHeaders(tarBytes: Uint8Array): TarHeader[] {
60
- const headers: TarHeader[] = []
61
- const dec = new TextDecoder('utf-8')
62
- let off = 0
63
- let pendingLongName: string | null = null
64
-
65
- while (off + 512 <= tarBytes.length) {
66
- const header = tarBytes.subarray(off, off + 512)
67
- // End-of-archive marker: 512 zero bytes (tar pads two such blocks).
68
- let zero = true
69
- for (let i = 0; i < 512 && zero; i++) {
70
- if (header[i] !== 0) zero = false
71
- }
72
- if (zero) break
73
-
74
- let nameEnd = 0
75
- while (nameEnd < 100 && header[nameEnd] !== 0) nameEnd++
76
- const rawName = dec.decode(header.subarray(0, nameEnd)).replace(/^\.\//, '')
77
-
78
- const modeStr = dec.decode(header.subarray(100, 108)).trim().replace(/ +$/, '')
79
- const mode = parseInt(modeStr, 8) || 0o644
80
-
81
- const sizeStr = dec.decode(header.subarray(124, 136)).trim().replace(/ +$/, '')
82
- const size = parseInt(sizeStr, 8) || 0
83
-
84
- const mtimeStr = dec.decode(header.subarray(136, 148)).trim().replace(/ +$/, '')
85
- const mtimeMs = (parseInt(mtimeStr, 8) || 0) * 1000
86
-
87
- const typeFlag = header[156]
88
- const isDir = typeFlag === 0x35 /* '5' */ || rawName.endsWith('/')
89
-
90
- const dataStart = off + 512
91
- const padded = Math.ceil(size / 512) * 512
92
-
93
- if (typeFlag === 0x4c /* 'L' — GNU longname */) {
94
- // The data block carries the next entry's full name (null-
95
- // terminated). Stash it; the actual file header follows.
96
- // Strip the null terminator(s) without writing a regex that lint
97
- // flags as a control-char match.
98
- const raw = dec.decode(tarBytes.subarray(dataStart, dataStart + size))
99
- let endIdx = raw.length
100
- while (endIdx > 0 && raw.charCodeAt(endIdx - 1) === 0) endIdx--
101
- pendingLongName = raw.slice(0, endIdx)
102
- off = dataStart + padded
103
- continue
104
- }
105
-
106
- // PAX extended headers: metadata about the next entry (long
107
- // names, large sizes, nanosecond mtime, BSD xattrs, etc). Their
108
- // own entry name is something like `PaxHeaders/<basename>` or
109
- // `./PaxHeader.NNNN/<basename>` — definitely not a file we want
110
- // to write to disk. Skip the whole record (header + padded data)
111
- // and let the next entry through unchanged.
112
- if (
113
- typeFlag === 0x78 /* 'x' — per-entry PAX */ ||
114
- typeFlag === 0x67 /* 'g' — global PAX */ ||
115
- typeFlag === 0x58 /* 'X' — Solaris extended */
116
- ) {
117
- off = dataStart + padded
118
- continue
119
- }
120
-
121
- // Reject typeflags we don't support: hardlink (1), symlink (2),
122
- // char device (3), block device (4), FIFO (6), contiguous (7).
123
- // Build outputs are regular files + directories; anything else
124
- // is either a producer bug or a smuggled non-file artifact (a
125
- // symlink to /etc/passwd, a /dev/null mapping, etc.). Reject
126
- // explicitly so future extractors can't be tricked into honoring
127
- // these by accident.
128
- if (typeFlag === 0x31 /* '1' */) {
129
- throw new TarSecurityError(`tar entry typeflag 'hardlink' is unsupported: ${rawName}`)
130
- }
131
- if (typeFlag === 0x32 /* '2' */) {
132
- throw new TarSecurityError(`tar entry typeflag 'symlink' is unsupported: ${rawName}`)
133
- }
134
- if (typeFlag === 0x33 /* '3' */) {
135
- throw new TarSecurityError(`tar entry typeflag 'chardev' is unsupported: ${rawName}`)
136
- }
137
- if (typeFlag === 0x34 /* '4' */) {
138
- throw new TarSecurityError(`tar entry typeflag 'blockdev' is unsupported: ${rawName}`)
139
- }
140
- if (typeFlag === 0x36 /* '6' */) {
141
- throw new TarSecurityError(`tar entry typeflag 'fifo' is unsupported: ${rawName}`)
142
- }
143
- if (typeFlag === 0x37 /* '7' */) {
144
- throw new TarSecurityError(`tar entry typeflag 'contiguous' is unsupported: ${rawName}`)
145
- }
146
-
147
- const name = pendingLongName ?? rawName
148
- pendingLongName = null
149
-
150
- if (name.length === 0) {
151
- off = dataStart + padded
152
- continue
153
- }
154
-
155
- // Path-traversal defense. We only ever want to extract entries
156
- // under `outputs/<rel>` where `<rel>` is project-relative and
157
- // contains no `..` segments and no absolute prefix. Reject at
158
- // parse time so a malicious cache artifact can't write outside
159
- // the destination (zip-slip class). Mirrors Turbo's
160
- // restore.rs:check_path normalization.
161
- if (name.startsWith('/')) {
162
- throw new TarSecurityError(`tar entry name is absolute (unsafe): ${name}`)
163
- }
164
- if (hasParentSegment(name)) {
165
- throw new TarSecurityError(`tar entry name escapes via '..' (unsafe): ${name}`)
166
- }
167
- if (name.includes('//')) {
168
- throw new TarSecurityError(`tar entry name has empty path component (unsafe): ${name}`)
169
- }
170
- // Windows-shaped paths are categorically unsafe to extract on
171
- // POSIX hosts (and vice versa): backslash separators, drive
172
- // letters, extended-length prefixes. Reject before we touch
173
- // anything.
174
- if (name.includes('\\')) {
175
- throw new TarSecurityError(`tar entry uses backslash separators (windows-unsafe): ${name}`)
176
- }
177
- if (/^[A-Za-z]:[\\/]/.test(name)) {
178
- throw new TarSecurityError(`tar entry has windows drive prefix (unsafe): ${name}`)
179
- }
180
-
181
- // AppleDouble resource-fork siblings — basename starting with
182
- // `._`. macOS Finder / `cp -p` leave these next to real files
183
- // (e.g. `._main.js` shadowing `main.js`). They carry xattrs we
184
- // don't care about; restoring them just pollutes the tree.
185
- const basename = name.slice(name.lastIndexOf('/') + 1)
186
- if (basename.startsWith('._')) {
187
- off = dataStart + padded
188
- continue
189
- }
190
-
191
- headers.push({ name, size, mode, mtimeMs, isDir, dataOffset: dataStart })
192
- off = dataStart + padded
193
- }
194
- return headers
195
- }
196
-
197
- /**
198
- * Thrown by `parseTarHeaders` when an entry's name would let it
199
- * escape the extraction destination (absolute path, `..` traversal,
200
- * doubled separators). Callers should treat this as a corrupt /
201
- * malicious artifact — log it, drop the cache entry, and re-run.
202
- */
203
- export class TarSecurityError extends Error {
204
- constructor(message: string) {
205
- super(message)
206
- this.name = 'TarSecurityError'
207
- }
208
- }
209
-
210
- function hasParentSegment(p: string): boolean {
211
- if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
212
- return p.includes('/../')
213
- }
214
-
215
- /**
216
- * Read a single tar entry's text body. Returns '' for missing entries.
217
- * Used to extract `stdout` / `stderr` inline.
218
- */
219
- export function readTarText(tarBytes: Uint8Array, headers: TarHeader[], name: string): string {
220
- const h = headers.find((e) => e.name === name)
221
- if (!h) return ''
222
- return new TextDecoder('utf-8').decode(tarBytes.subarray(h.dataOffset, h.dataOffset + h.size))
223
- }
224
-
225
- /**
226
- * Extract a tar's `outputs/<rel>` entries into `destDir/<rel>` (strips
227
- * the `outputs/` prefix), and — when `workspaceDest` is given — its
228
- * `workspace-outputs/<rel>` entries into `workspaceDest/<rel>`.
229
- * Entries in neither namespace are ignored; workspace entries are
230
- * also ignored when no `workspaceDest` is supplied.
231
- */
232
- export async function extractOutputs(
233
- tarBytes: Uint8Array,
234
- destDir: string,
235
- workspaceDest?: string,
236
- ): Promise<void> {
237
- const headers = parseTarHeaders(tarBytes)
238
-
239
- await mkdir(destDir, { recursive: true })
240
-
241
- // Each namespace anchors at its own destination dir.
242
- const destFor = (name: string): { base: string; rel: string } | null => {
243
- if (name.startsWith('outputs/')) {
244
- return { base: destDir, rel: name.slice('outputs/'.length) }
245
- }
246
- if (workspaceDest !== undefined && name.startsWith('workspace-outputs/')) {
247
- return { base: workspaceDest, rel: name.slice('workspace-outputs/'.length) }
248
- }
249
- return null
250
- }
251
-
252
- // Two passes: directories first (depth-first via sort), then files.
253
- // Keeps file writes from racing with their parent dir creation.
254
- const dirEntries = headers
255
- .filter((h) => h.isDir)
256
- .map((h) => destFor(h.name))
257
- .filter((d): d is { base: string; rel: string } => d !== null && d.rel.length > 0)
258
- .sort((a, b) => (a.rel < b.rel ? -1 : 1))
259
- for (const d of dirEntries) {
260
- await mkdir(path.join(d.base, d.rel), { recursive: true })
261
- }
262
-
263
- const fileEntries = headers
264
- .map((h) => ({ h, dest: destFor(h.name) }))
265
- .filter(
266
- (e): e is { h: TarHeader; dest: { base: string; rel: string } } =>
267
- e.dest !== null && !e.h.isDir && e.dest.rel.length > 0,
268
- )
269
-
270
- // The REAL path of each base dir (symlinks resolved), memoized. Used to
271
- // catch a symlinked ANCESTOR under the output tree: the lexical check
272
- // below stops a `..` name, but if `<dest>/dist` is already an on-disk
273
- // symlink pointing outside (planted in the repo or by a dependency
274
- // postinstall), a lexically-contained entry `dist/x` would follow it and
275
- // escape. Both sides are realpath'd, so a legitimate symlinked ANCESTOR of
276
- // the base dir itself (e.g. macOS /tmp → /private/tmp) resolves
277
- // consistently and is not flagged.
278
- const realBaseCache = new Map<string, string>()
279
- const realBaseOf = async (base: string): Promise<string> => {
280
- const key = path.resolve(base)
281
- let r = realBaseCache.get(key)
282
- if (r === undefined) {
283
- r = await realpath(key).catch(() => key)
284
- realBaseCache.set(key, r)
285
- }
286
- return r
287
- }
288
-
289
- await Promise.all(
290
- fileEntries.map(async ({ h, dest }) => {
291
- const destResolved = path.resolve(dest.base)
292
- const target = path.join(dest.base, dest.rel)
293
- // Defense in depth — parseTarHeaders already rejects `..` /
294
- // absolute paths, but if a future glitch lets one through we
295
- // catch it here. path.resolve normalizes `..` components in
296
- // the joined path so the comparison is sound.
297
- const targetResolved = path.resolve(target)
298
- if (targetResolved !== destResolved && !targetResolved.startsWith(destResolved + path.sep)) {
299
- throw new TarSecurityError(`tar entry escapes destDir (unsafe): ${h.name}`)
300
- }
301
-
302
- // Ensure the parent dir exists. Cheap because mkdir(recursive)
303
- // is a no-op when the dir is already there.
304
- await mkdir(path.dirname(target), { recursive: true })
305
-
306
- // Symlinked-parent escape check: the parent dir now exists — resolve
307
- // its real path and require it to still sit inside the real base dir.
308
- // A poisoned artifact whose entry lands under a pre-existing symlinked
309
- // directory is refused before any bytes are written outside the tree.
310
- const realBase = await realBaseOf(dest.base)
311
- const realParent = await realpath(path.dirname(target)).catch(() => path.dirname(target))
312
- if (realParent !== realBase && !realParent.startsWith(realBase + path.sep)) {
313
- throw new TarSecurityError(`tar entry escapes destDir via a symlinked parent: ${h.name}`)
314
- }
315
-
316
- // Symlink TOCTOU defense: if the target IS a symlink, unlink
317
- // it first so the upcoming write doesn't follow the link and
318
- // clobber whatever the link points to (e.g. an attacker placed
319
- // `<dest>/link -> /etc/passwd` to redirect our write). lstat
320
- // doesn't follow the symlink, so we can detect the link itself.
321
- try {
322
- const ls = await lstat(target)
323
- if (ls.isSymbolicLink()) await unlink(target)
324
- } catch {
325
- // Target doesn't exist — that's the common case; fall through.
326
- }
327
-
328
- const body = tarBytes.subarray(h.dataOffset, h.dataOffset + h.size)
329
- // Bun.write benchmarks ~2× faster than fs/promises.writeFile
330
- // for these per-entry restore writes (167µs vs 361µs per 1KB
331
- // on the dev box; ~1.6× in batched Promise.all over 50 files).
332
- // Native fast path; same correctness contract.
333
- await Bun.write(target, body)
334
- // Permission bits + mtime restoration — keeps a re-tar of the
335
- // restored tree byte-identical to the original artifact.
336
- if ((h.mode & 0o777) !== 0) await chmod(target, h.mode & 0o777)
337
- if (h.mtimeMs > 0) {
338
- const t = h.mtimeMs / 1000
339
- await utimes(target, t, t)
340
- }
341
- }),
342
- )
343
- }
@@ -1,23 +0,0 @@
1
- // Execution as a pluggable backend — the same idea as the cache's
2
- // local/remote split, applied to running tasks. `vx run` resolves a
3
- // backend and calls `run(request)`; it neither knows nor cares whether the
4
- // work happened in-process or was delegated to a service. The DEFAULT is
5
- // pure in-process; routing to a service (local or hosted) is a `backend`
6
- // plugin capability, never core.
7
-
8
- import {
9
- run as runOrchestrator,
10
- projectOutcome,
11
- requestToOptions,
12
- type RunBackend,
13
- } from '../orchestrator/index.js'
14
-
15
- /** Run in-process via `run()` — core's default backend. Byte-identical to a plain `run()`. */
16
- export function localBackend(): RunBackend {
17
- return {
18
- async run(request) {
19
- const summary = await runOrchestrator(requestToOptions(request))
20
- return { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
21
- },
22
- }
23
- }
@@ -1,292 +0,0 @@
1
- // MCP RPC dispatcher — pure handlers that the MCP server (and a future
2
- // WS-side inspector) both delegate to. Each tool here corresponds to one
3
- // of the inspector RPCs from docs/design/wire-protocol-2026-06.md §3.
4
- // Schemas are JSON Schema for the MCP listing; the handlers stay
5
- // untyped at the boundary and validate per-tool.
6
- //
7
- // Handlers open the local cache.db on demand. The MCP server is a
8
- // short-lived adapter — long-lived embedders should keep the Cache
9
- // open across calls via `setMcpContext`.
10
-
11
- import { Cache } from '../cache/index.js'
12
- import { LocalHistoryProvider } from '../orchestrator/index.js'
13
- import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
14
- import { UserError } from '../util/index.js'
15
-
16
- export interface McpToolDef {
17
- name: string
18
- description: string
19
- inputSchema: Record<string, unknown>
20
- }
21
-
22
- /**
23
- * Where the handlers reach for state. Overridable so an embedder
24
- * (a future inspector WS server) can inject a long-lived Cache + a
25
- * specific workspace root instead of opening one per RPC.
26
- */
27
- export interface McpContext {
28
- /** Workspace root. Defaults to discovery from process.cwd(). */
29
- workspaceRoot?: string
30
- }
31
-
32
- let mcpContext: McpContext = {}
33
-
34
- export function setMcpContext(ctx: McpContext): void {
35
- mcpContext = ctx
36
- }
37
-
38
- const TOOLS: readonly McpToolDef[] = [
39
- {
40
- name: 'getCacheStats',
41
- description: 'Aggregate cache statistics (entries, total size, hits in last 24h).',
42
- inputSchema: {
43
- type: 'object',
44
- properties: {
45
- scope: {
46
- oneOf: [
47
- { type: 'string', enum: ['all'] },
48
- { type: 'object', properties: { project: { type: 'string' } }, required: ['project'] },
49
- ],
50
- },
51
- },
52
- },
53
- },
54
- {
55
- name: 'getRunHistory',
56
- description: 'Recent runs filtered by project / task, with per-task summary stats.',
57
- inputSchema: {
58
- type: 'object',
59
- properties: {
60
- project: { type: 'string' },
61
- task: { type: 'string' },
62
- limit: { type: 'integer', minimum: 1, maximum: 500, default: 50 },
63
- },
64
- },
65
- },
66
- {
67
- name: 'explainCacheKey',
68
- description:
69
- 'Break down the inputs that contribute to a task cache key (files / env / runtime / upstream).',
70
- inputSchema: {
71
- type: 'object',
72
- properties: { taskId: { type: 'string', description: 'project#task' } },
73
- required: ['taskId'],
74
- },
75
- },
76
- {
77
- name: 'whyDidThisRerun',
78
- description:
79
- 'Compare two recent run cache keys for a task and identify the inputs that changed.',
80
- inputSchema: {
81
- type: 'object',
82
- properties: {
83
- runId: { type: 'string' },
84
- taskId: { type: 'string' },
85
- },
86
- required: ['runId', 'taskId'],
87
- },
88
- },
89
- ]
90
-
91
- export function listMcpTools(): readonly McpToolDef[] {
92
- return TOOLS
93
- }
94
-
95
- /**
96
- * Dispatch a tool call by name. Returns a JSON-serializable result.
97
- */
98
- export async function handleMcpRequest(
99
- name: string,
100
- argsRaw: unknown,
101
- ): Promise<Record<string, unknown>> {
102
- const args = (argsRaw ?? {}) as Record<string, unknown>
103
- switch (name) {
104
- case 'getCacheStats':
105
- return getCacheStats(args)
106
- case 'getRunHistory':
107
- return getRunHistory(args)
108
- case 'explainCacheKey':
109
- return explainCacheKey(args)
110
- case 'whyDidThisRerun':
111
- return whyDidThisRerun(args)
112
- default:
113
- throw new UserError(`vx mcp: unknown tool: ${name}`)
114
- }
115
- }
116
-
117
- /** Discover + open a Cache against the current workspace. Caller closes. */
118
- async function openCache(): Promise<{ cache: Cache; workspaceRoot: string }> {
119
- const workspaceRoot = mcpContext.workspaceRoot ?? (await findWorkspaceRoot(process.cwd()))
120
- const config = await loadWorkspaceConfig(workspaceRoot)
121
- const cacheDir = resolveCacheDir(workspaceRoot, config)
122
- return { cache: new Cache(cacheDir), workspaceRoot }
123
- }
124
-
125
- async function getCacheStats(args: Record<string, unknown>): Promise<Record<string, unknown>> {
126
- const { cache } = await openCache()
127
- try {
128
- const stats = cache.stats()
129
- const hitRate24h = stats.runCountLast24h > 0 ? stats.hitCountLast24h / stats.runCountLast24h : 0
130
- return {
131
- scope: args.scope ?? 'all',
132
- entryCount: stats.entryCount,
133
- totalBytes: stats.totalBytes,
134
- runCountLast24h: stats.runCountLast24h,
135
- hitCountLast24h: stats.hitCountLast24h,
136
- hitRate24h,
137
- }
138
- } finally {
139
- cache.close()
140
- }
141
- }
142
-
143
- async function getRunHistory(args: Record<string, unknown>): Promise<Record<string, unknown>> {
144
- const limit = typeof args.limit === 'number' ? Math.min(500, Math.max(1, args.limit)) : 50
145
- const projectFilter = typeof args.project === 'string' ? args.project : undefined
146
- const taskFilter = typeof args.task === 'string' ? args.task : undefined
147
-
148
- const { cache } = await openCache()
149
- try {
150
- const db = cache.dbHandle()
151
- // Distinct (project, task) pairs from the most recent runs.
152
- const where: string[] = []
153
- const params: (string | number)[] = []
154
- if (projectFilter) {
155
- where.push('project = ?')
156
- params.push(projectFilter)
157
- }
158
- if (taskFilter) {
159
- where.push('task = ?')
160
- params.push(taskFilter)
161
- }
162
- const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
163
- const pairs = db
164
- .query(`SELECT DISTINCT project, task FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`)
165
- .all(...params, limit) as { project: string; task: string }[]
166
- if (pairs.length === 0) {
167
- return { runs: [], history: [] }
168
- }
169
- const ids = pairs.map((p) => `${p.project}#${p.task}`)
170
- const provider = new LocalHistoryProvider(db)
171
- const table = await provider.loadFor(ids)
172
- const history = ids
173
- .map((id) => {
174
- const h = table.get(id)
175
- return h ? { id, ...h } : null
176
- })
177
- .filter((x) => x !== null)
178
- // Most-recent N rows for the timeline view.
179
- const recent = db
180
- .query(
181
- `SELECT run_id AS runId, project, task, status, duration_ms AS durationMs,
182
- started_at AS startedAt, ended_at AS endedAt, cache_hit AS cacheHit
183
- FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`,
184
- )
185
- .all(...params, limit) as Array<{
186
- runId: string | null
187
- project: string
188
- task: string
189
- status: string
190
- durationMs: number
191
- startedAt: number
192
- endedAt: number
193
- cacheHit: number | null
194
- }>
195
- return { runs: recent, history }
196
- } finally {
197
- cache.close()
198
- }
199
- }
200
-
201
- async function explainCacheKey(args: Record<string, unknown>): Promise<Record<string, unknown>> {
202
- const taskId = args.taskId
203
- if (typeof taskId !== 'string' || !taskId.includes('#')) {
204
- throw new UserError('explainCacheKey: taskId must be a "project#task" string')
205
- }
206
- const [project, task] = taskId.split('#', 2) as [string, string]
207
- const { cache } = await openCache()
208
- try {
209
- const db = cache.dbHandle()
210
- // The cache stores the resolved task hash on the latest entry; we
211
- // surface what we know from the entries + runs tables. The deeper
212
- // input-component breakdown (env / runtime / upstream) requires
213
- // re-deriving the key from a live config; that's the next layer
214
- // (a "prepareRun-lite" tool). For now we return what's persisted.
215
- const entry = db
216
- .query(
217
- `SELECT hash, command, exit_code AS exitCode, duration_ms AS durationMs,
218
- size_bytes AS sizeBytes, created_at AS createdAt
219
- FROM entries WHERE project = ? AND task = ? ORDER BY created_at DESC LIMIT 1`,
220
- )
221
- .get(project, task) as
222
- | {
223
- hash: string
224
- command: string
225
- exitCode: number
226
- durationMs: number
227
- sizeBytes: number
228
- createdAt: number
229
- }
230
- | undefined
231
- return {
232
- taskId,
233
- project,
234
- task,
235
- latestEntry: entry ?? null,
236
- note: 'cache key components (files / env / runtime / upstream hashes) require live config evaluation; this surface returns the persisted entry metadata',
237
- }
238
- } finally {
239
- cache.close()
240
- }
241
- }
242
-
243
- async function whyDidThisRerun(args: Record<string, unknown>): Promise<Record<string, unknown>> {
244
- const runId = args.runId
245
- const taskId = args.taskId
246
- if (typeof runId !== 'string' || typeof taskId !== 'string') {
247
- throw new UserError('whyDidThisRerun: runId and taskId must be strings')
248
- }
249
- if (!taskId.includes('#')) {
250
- throw new UserError('whyDidThisRerun: taskId must be a "project#task" string')
251
- }
252
- const [project, task] = taskId.split('#', 2) as [string, string]
253
- const { cache } = await openCache()
254
- try {
255
- const db = cache.dbHandle()
256
- const this_ = db
257
- .query(
258
- `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
259
- FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
260
- )
261
- .get(runId, project, task) as
262
- | { hash: string; status: string; cacheHit: number | null; startedAt: number }
263
- | undefined
264
- if (!this_) {
265
- return { runId, taskId, found: false, note: 'no row matching that runId + taskId' }
266
- }
267
- const prev = db
268
- .query(
269
- `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
270
- FROM runs WHERE project = ? AND task = ? AND started_at < ?
271
- ORDER BY started_at DESC LIMIT 1`,
272
- )
273
- .get(project, task, this_.startedAt) as
274
- | { hash: string; status: string; cacheHit: number | null; startedAt: number }
275
- | undefined
276
- return {
277
- runId,
278
- taskId,
279
- thisRun: this_,
280
- previousRun: prev ?? null,
281
- hashChanged: prev ? prev.hash !== this_.hash : null,
282
- note:
283
- prev && prev.hash !== this_.hash
284
- ? 'cache key changed between the previous run and this one (inputs differ)'
285
- : prev
286
- ? 'cache key unchanged — re-run with the same key (likely --no-cache or unrelated)'
287
- : 'no prior run for this (project, task)',
288
- }
289
- } finally {
290
- cache.close()
291
- }
292
- }