@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- 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
|
-
}
|
package/src/cli/backend.ts
DELETED
|
@@ -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
|
-
}
|
package/src/cli/mcp-rpc.ts
DELETED
|
@@ -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
|
-
}
|