@vzn/vx 0.0.0 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,85 @@
1
+ // Module contract for `cli`: the top-level dispatcher plus re-exports
2
+ // for the test suite, which asserts on the pure parsers and formatters
3
+ // directly. Each subcommand handler lives in a sibling `<name>.ts`.
4
+
5
+ import { VERSION } from '../version.js'
6
+ import { runCmd } from './run.js'
7
+ import { watchCmd } from './watch.js'
8
+ import { cacheCmd } from './cache.js'
9
+ import { lockCmd } from './lock.js'
10
+ import { migrateCmd } from './migrate.js'
11
+ import { upgradeCmd } from './upgrade.js'
12
+ import { showCmd } from './show.js'
13
+ import { infoCmd } from './info.js'
14
+ import { mcpCmd } from './mcp.js'
15
+ import { printHelp } from './help.js'
16
+
17
+ export async function run(argv: readonly string[]): Promise<number> {
18
+ const [command, ...rest] = argv
19
+
20
+ switch (command) {
21
+ case undefined:
22
+ case '--help':
23
+ case '-h':
24
+ case 'help':
25
+ printHelp()
26
+ return 0
27
+ case '--version':
28
+ case 'version':
29
+ process.stdout.write(`vx ${VERSION}\n`)
30
+ return 0
31
+ case 'run':
32
+ return await runCmd(rest)
33
+ case 'watch':
34
+ return await watchCmd(rest)
35
+ case 'cache':
36
+ return await cacheCmd(rest)
37
+ case 'lock':
38
+ return await lockCmd(rest)
39
+ case 'migrate':
40
+ return await migrateCmd(rest)
41
+ case 'upgrade':
42
+ return await upgradeCmd(rest)
43
+ case 'show':
44
+ return await showCmd(rest)
45
+ case 'info':
46
+ case 'stats': // deprecated alias — `vx info` absorbed `vx stats`
47
+ return await infoCmd(rest)
48
+ case 'mcp':
49
+ return await mcpCmd(rest)
50
+ case 'serve':
51
+ case 'dev':
52
+ // vx core is only a task runner — it has no service layer of its own.
53
+ // A dashboard, remote cache, distributed execution, etc. are provided
54
+ // by PLUGINS (declared in vx.workspace.ts), never by core. We keep this
55
+ // neutral hint for the common muscle-memory verbs, but core names no
56
+ // specific plugin package: any package can provide these.
57
+ process.stderr.write(
58
+ `vx: '${command}' is not a vx core command.\n` +
59
+ ` vx core runs tasks in-process. A dashboard, remote cache, and\n` +
60
+ ` distributed execution come from plugins — not core. See the plugin\n` +
61
+ ` guide: https://vznjs.github.io/vx/guides/plugins/\n`,
62
+ )
63
+ return 1
64
+ default:
65
+ process.stderr.write(`vx: unknown command: ${command}\n`)
66
+ printHelp()
67
+ return 1
68
+ }
69
+ }
70
+
71
+ // Re-exports for tests + programmatic embedders.
72
+ export { detectFlow, parseRunArgs, type RunArgs } from './run.js'
73
+ export { parsePruneArgs, parseDuration, parseSize } from './cache.js'
74
+ export { parseLockArgs, type LockArgs } from './lock.js'
75
+ export { parseMigrateArgs, type MigrateArgs } from './migrate.js'
76
+ export { parseShowArgs, type ShowArgs } from './show.js'
77
+ export { parseMcpArgs, type McpArgs } from './mcp.js'
78
+ export {
79
+ handleMcpRequest,
80
+ listMcpTools,
81
+ setMcpContext,
82
+ type McpContext,
83
+ type McpToolDef,
84
+ } from './mcp-rpc.js'
85
+ export { formatBytes } from './format.js'
@@ -0,0 +1,83 @@
1
+ // `vx info` — workspace doctor printout. One screen of environment +
2
+ // workspace + cache facts for bug reports and quick sanity checks.
3
+ // `vx stats` is a deprecated alias (info absorbed it).
4
+
5
+ import { Cache } from '../cache/index.js'
6
+ import { VERSION } from '../version.js'
7
+ import {
8
+ findWorkspaceRoot,
9
+ listProjects,
10
+ loadProjectConfig,
11
+ loadWorkspace,
12
+ loadWorkspaceConfig,
13
+ lockfilePath,
14
+ resolveCacheDir,
15
+ } from '../workspace/index.js'
16
+ import { formatBytes } from './format.js'
17
+
18
+ export async function infoCmd(args: readonly string[]): Promise<number> {
19
+ if (args.length > 0) {
20
+ process.stderr.write(`vx info: unknown argument: ${args[0]}\n`)
21
+ return 1
22
+ }
23
+ const root = await findWorkspaceRoot(process.cwd())
24
+ const metas = await listProjects(await loadWorkspace(root))
25
+
26
+ let taskCount = 0
27
+ await Promise.all(
28
+ metas.map(async (meta) => {
29
+ if (meta.configPath === null) return
30
+ // A broken config must not take the doctor down with it — it
31
+ // just contributes zero tasks to the count.
32
+ try {
33
+ const config = await loadProjectConfig(meta.configPath)
34
+ taskCount += Object.keys(config.tasks ?? {}).length
35
+ } catch {
36
+ // counted as zero
37
+ }
38
+ }),
39
+ )
40
+
41
+ const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
42
+ const cache = new Cache(cacheDir)
43
+ let stats
44
+ try {
45
+ stats = cache.stats()
46
+ } finally {
47
+ cache.close()
48
+ }
49
+
50
+ const lockPresent = await Bun.file(lockfilePath(root)).exists()
51
+ const remoteConfigured =
52
+ Boolean(process.env.VX_REMOTE_CACHE_URL) && Boolean(process.env.VX_REMOTE_CACHE_TOKEN)
53
+
54
+ const rows: [string, string][] = [
55
+ ['vx', VERSION],
56
+ ['bun', Bun.version],
57
+ ['git', gitVersion()],
58
+ ['workspace root', root],
59
+ ['projects', `${metas.length} (${taskCount} task${taskCount === 1 ? '' : 's'})`],
60
+ ['cache dir', cacheDir],
61
+ ['cache entries', `${stats.entryCount} (${formatBytes(stats.totalBytes)})`],
62
+ ['runs (24h)', `${stats.runCountLast24h} (${stats.hitCountLast24h} cache hits)`],
63
+ ['vx-lock.json', lockPresent ? 'yes' : 'no'],
64
+ ['remote cache', remoteConfigured ? 'yes' : 'no'],
65
+ ]
66
+ const labelW = Math.max(...rows.map(([label]) => label.length))
67
+ const lines = rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`)
68
+ process.stdout.write(`${lines.join('\n')}\n`)
69
+ return 0
70
+ }
71
+
72
+ function gitVersion(): string {
73
+ try {
74
+ const p = Bun.spawnSync({ cmd: ['git', '--version'], stdout: 'pipe', stderr: 'pipe' })
75
+ if (p.exitCode !== 0) return '(not found)'
76
+ return new TextDecoder()
77
+ .decode(p.stdout)
78
+ .trim()
79
+ .replace(/^git version /, '')
80
+ } catch {
81
+ return '(not found)'
82
+ }
83
+ }
@@ -0,0 +1,135 @@
1
+ // `vx lock` — freeze every resolved vx.config.* into vx-lock.json.
2
+ // `vx lock --check` — audit the lock against a fresh evaluation in the
3
+ // current environment. Design: docs/design/config-lock-2026-06.md.
4
+
5
+ import type { ProjectConfig } from '../config.js'
6
+ import { relPosix, xxh3hex } from '../util/index.js'
7
+ import {
8
+ findWorkspaceRoot,
9
+ listProjects,
10
+ loadProjectConfig,
11
+ loadWorkspace,
12
+ LOCKFILE_NAME,
13
+ LOCKFILE_VERSION,
14
+ readLockfile,
15
+ writeLockfile,
16
+ type Lockfile,
17
+ type LockfileEntry,
18
+ type ProjectMeta,
19
+ } from '../workspace/index.js'
20
+
21
+ export interface LockArgs {
22
+ check: boolean
23
+ error?: string
24
+ }
25
+
26
+ export function parseLockArgs(args: readonly string[]): LockArgs {
27
+ const out: LockArgs = { check: false }
28
+ for (const a of args) {
29
+ if (a === '--check') out.check = true
30
+ else return { check: false, error: `unknown argument: ${a}` }
31
+ }
32
+ return out
33
+ }
34
+
35
+ type ConfiguredMeta = ProjectMeta & { configPath: string }
36
+
37
+ export async function lockCmd(args: readonly string[]): Promise<number> {
38
+ const parsed = parseLockArgs(args)
39
+ if (parsed.error) {
40
+ process.stderr.write(`vx lock: ${parsed.error}\n`)
41
+ return 1
42
+ }
43
+ const root = await findWorkspaceRoot(process.cwd())
44
+ const workspace = await loadWorkspace(root)
45
+ const metas = (await listProjects(workspace)).filter(
46
+ (m): m is ConfiguredMeta => m.configPath !== null,
47
+ )
48
+ if (parsed.check) return await checkLock(root, metas)
49
+ return await writeLock(root, metas)
50
+ }
51
+
52
+ /** Evaluate one config in the current env, returning its lock entry. */
53
+ async function evaluateEntry(root: string, meta: ConfiguredMeta): Promise<LockfileEntry> {
54
+ const bytes = await Bun.file(meta.configPath).bytes()
55
+ // `fresh: true` — locking must observe the current environment, not
56
+ // a module-cache replay from earlier in this process.
57
+ const config = await loadProjectConfig(meta.configPath, { fresh: true })
58
+ return {
59
+ configPath: relPosix(root, meta.configPath),
60
+ configHash: xxh3hex(bytes),
61
+ // JSON round-trip drops `undefined` fields so the stored object is
62
+ // byte-identical to what a later read of the lock will produce —
63
+ // the exact form `--check` compares against.
64
+ config: JSON.parse(JSON.stringify(config)) as ProjectConfig,
65
+ }
66
+ }
67
+
68
+ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number> {
69
+ const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
70
+ const projects: Record<string, LockfileEntry> = {}
71
+ // `listProjects` sorts by name — stable lockfile diffs for free.
72
+ for (let i = 0; i < metas.length; i++) projects[metas[i]!.name] = entries[i]!
73
+ const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
74
+ await writeLockfile(root, lock)
75
+ const n = metas.length
76
+ process.stdout.write(`vx: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}\n`)
77
+ return 0
78
+ }
79
+
80
+ /**
81
+ * Audit the lock. Two layers, strictly stronger than what runs do:
82
+ * 1. The same hash check runs perform (file bytes vs lock hash).
83
+ * 2. A full re-evaluation of every config in the CURRENT environment,
84
+ * deep-compared against the lock's stored resolved object. This
85
+ * catches eval-time env-var drift that file hashes cannot see —
86
+ * file bytes unchanged, resolved value changed.
87
+ */
88
+ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number> {
89
+ const lock = await readLockfile(root)
90
+ if (!lock) {
91
+ process.stderr.write(
92
+ `vx lock --check: no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first\n`,
93
+ )
94
+ return 1
95
+ }
96
+ const results = await Promise.all(
97
+ metas.map(async (m): Promise<string | null> => {
98
+ const rel = relPosix(root, m.configPath)
99
+ const entry = lock.projects[m.name]
100
+ if (!entry || entry.configPath !== rel) {
101
+ return `"${m.name}" (${rel}) is not in the lock — run 'vx lock'`
102
+ }
103
+ const hash = xxh3hex(await Bun.file(m.configPath).bytes())
104
+ if (hash !== entry.configHash) {
105
+ return `config file changed since lock (${m.name}: ${rel}) — run 'vx lock'`
106
+ }
107
+ const fresh = JSON.parse(
108
+ JSON.stringify(await loadProjectConfig(m.configPath, { fresh: true })),
109
+ ) as ProjectConfig
110
+ if (!Bun.deepEquals(fresh, entry.config, true)) {
111
+ return (
112
+ `lock differs from fresh evaluation in this environment (${m.name}) — ` +
113
+ `env-dependent config? run 'vx lock' here or remove env reads from config`
114
+ )
115
+ }
116
+ return null
117
+ }),
118
+ )
119
+ const failures = results.filter((r): r is string => r !== null)
120
+ const inWorkspace = new Set(metas.map((m) => m.name))
121
+ for (const name of Object.keys(lock.projects)) {
122
+ if (!inWorkspace.has(name)) {
123
+ failures.push(
124
+ `locked project "${name}" no longer has a config in the workspace — run 'vx lock'`,
125
+ )
126
+ }
127
+ }
128
+ if (failures.length > 0) {
129
+ for (const f of failures) process.stderr.write(`vx lock --check: ${f}\n`)
130
+ return 1
131
+ }
132
+ const n = metas.length
133
+ process.stdout.write(`vx: lock is up to date (${n} project${n === 1 ? '' : 's'})\n`)
134
+ return 0
135
+ }
@@ -0,0 +1,292 @@
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
+ }
package/src/cli/mcp.ts ADDED
@@ -0,0 +1,92 @@
1
+ // `vx mcp` — Model Context Protocol server adapter.
2
+ //
3
+ // Exposes vx as a typed tool surface to AI agents (Claude Code, Cursor,
4
+ // Continue.dev, etc.). The MCP envelope is JSON-RPC 2.0 — the same
5
+ // envelope wire-protocol-2026-06.md commits to — so this adapter is a
6
+ // thin mapping layer: MCP tool calls dispatch to internal RPC methods.
7
+ //
8
+ // stdio transport in v1: every relevant agent client runs servers over
9
+ // stdio. Streamable HTTP is a follow-up; the SDK supports both, dispatch
10
+ // is identical, only the transport object changes.
11
+ //
12
+ // Tools exposed:
13
+ // runTasks(tasks: string[], cwd?: string)
14
+ // getCacheStats(scope?: 'all' | { project: string })
15
+ // getRunHistory({ project?, task?, limit? })
16
+ // explainCacheKey(taskId)
17
+ // whyDidThisRerun({ runId, taskId })
18
+ //
19
+ // The implementations live in `src/cli/mcp-rpc.ts` so a future WS-side
20
+ // inspector can reuse them without duplicating logic.
21
+
22
+ import { UserError } from '../util/index.js'
23
+ import { VERSION } from '../version.js'
24
+ import { handleMcpRequest, listMcpTools, type McpToolDef } from './mcp-rpc.js'
25
+
26
+ export interface McpArgs {
27
+ /** stdio (default) | http (deferred to a follow-up) */
28
+ transport: 'stdio'
29
+ }
30
+
31
+ export function parseMcpArgs(args: readonly string[]): McpArgs {
32
+ for (const a of args) {
33
+ if (a === '--http') {
34
+ throw new UserError('vx mcp --http is not yet implemented; use stdio (default)')
35
+ }
36
+ if (a !== '--stdio') {
37
+ throw new UserError(`vx mcp: unknown flag ${a}`)
38
+ }
39
+ }
40
+ return { transport: 'stdio' }
41
+ }
42
+
43
+ export async function mcpCmd(args: readonly string[]): Promise<number> {
44
+ parseMcpArgs(args) // validate flags; defaults to stdio
45
+ let StdioServerTransport: any
46
+ let McpServer: any
47
+ try {
48
+ const sdkServer = await import('@modelcontextprotocol/sdk/server/index.js')
49
+ const sdkStdio = await import('@modelcontextprotocol/sdk/server/stdio.js')
50
+ McpServer = (sdkServer as { Server: any }).Server
51
+ StdioServerTransport = (sdkStdio as { StdioServerTransport: any }).StdioServerTransport
52
+ } catch {
53
+ throw new UserError(
54
+ `vx mcp requires @modelcontextprotocol/sdk to be installed. ` +
55
+ `Add it to your package.json or install with: bun add @modelcontextprotocol/sdk`,
56
+ )
57
+ }
58
+
59
+ const server = new McpServer(
60
+ { name: 'vx', version: VERSION },
61
+ { capabilities: { tools: {}, resources: {} } },
62
+ )
63
+
64
+ // Tool listing
65
+ const sdkTypes = await import('@modelcontextprotocol/sdk/types.js')
66
+ server.setRequestHandler(
67
+ (sdkTypes as { ListToolsRequestSchema: any }).ListToolsRequestSchema,
68
+ async () => ({
69
+ tools: listMcpTools().map((t: McpToolDef) => ({
70
+ name: t.name,
71
+ description: t.description,
72
+ inputSchema: t.inputSchema,
73
+ })),
74
+ }),
75
+ )
76
+
77
+ // Tool call dispatch
78
+ server.setRequestHandler(
79
+ (sdkTypes as { CallToolRequestSchema: any }).CallToolRequestSchema,
80
+ async (req: { params: { name: string; arguments?: unknown } }) => {
81
+ const result = await handleMcpRequest(req.params.name, req.params.arguments)
82
+ return {
83
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
84
+ }
85
+ },
86
+ )
87
+
88
+ const transport = new StdioServerTransport()
89
+ await server.connect(transport)
90
+ await new Promise(() => undefined) // run until stdin closes
91
+ return 0
92
+ }