@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.
- package/LICENSE +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -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 +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -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 +661 -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 +421 -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 +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -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 +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -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 +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -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 +7 -0
- package/src/util/paths.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 +470 -0
- package/src/workspace/workspace.ts +176 -0
package/src/cli/index.ts
ADDED
|
@@ -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'
|
package/src/cli/info.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/lock.ts
ADDED
|
@@ -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
|
+
}
|