@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
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Dirent } from 'node:fs'
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
|
-
import { UserError } from '../util/index.js'
|
|
5
|
+
import { relPosix, UserError, normalizeGlob } from '../util/index.js'
|
|
5
6
|
|
|
6
7
|
export interface PackageJson {
|
|
7
8
|
name: string
|
|
@@ -40,29 +41,149 @@ export interface ProjectEntry {
|
|
|
40
41
|
const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Walk up from `start` to find the workspace root. A directory is a
|
|
44
|
-
*
|
|
45
|
-
* - `pnpm-workspace.yaml`, OR
|
|
46
|
-
* - a `package.json` (with or without a `workspaces` field).
|
|
44
|
+
* Walk up from `start` to find the workspace root. A directory is a root
|
|
45
|
+
* CANDIDATE when it contains `pnpm-workspace.yaml` or a `package.json`.
|
|
47
46
|
*
|
|
48
|
-
* The
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* The nearest candidate that CLAIMS `start` wins — one of the directories
|
|
48
|
+
* between it and `start` matches one of its package globs. Every workspace
|
|
49
|
+
* member has its own `package.json`, so stopping at the first candidate would
|
|
50
|
+
* make a run from inside a package treat that package as the whole workspace:
|
|
51
|
+
* `^task` edges vanish, upstream hashes drop out of the cache key (stale
|
|
52
|
+
* hits), and a second cache dir appears under the member. Claiming is decided
|
|
53
|
+
* with the same globs `loadWorkspace` applies, so "the root that claims me"
|
|
54
|
+
* and "the root that lists me as a project" cannot diverge.
|
|
55
|
+
*
|
|
56
|
+
* When no candidate claims `start` — a standalone package, or a subdirectory
|
|
57
|
+
* of a single-project repo — the nearest candidate wins (the root itself IS
|
|
58
|
+
* the project). Throws a `UserError` when there is no candidate before `/`.
|
|
51
59
|
*/
|
|
52
60
|
export async function findWorkspaceRoot(start: string): Promise<string> {
|
|
53
61
|
let dir = path.resolve(start)
|
|
62
|
+
const below: string[] = []
|
|
63
|
+
let nearest: string | null = null
|
|
54
64
|
while (true) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
let globs: string[] | null
|
|
66
|
+
try {
|
|
67
|
+
globs = await readPackageGlobs(dir)
|
|
68
|
+
} catch {
|
|
69
|
+
// An unparseable manifest is still a root SIGNAL (the pre-existing
|
|
70
|
+
// behaviour probed only for existence); it just can't claim members.
|
|
71
|
+
// `loadWorkspace` surfaces the parse error if this dir is chosen.
|
|
72
|
+
globs = []
|
|
73
|
+
}
|
|
74
|
+
if (globs !== null) {
|
|
75
|
+
nearest ??= dir
|
|
76
|
+
if (claimsMember(dir, below, globs)) return dir
|
|
63
77
|
}
|
|
78
|
+
const parent = path.dirname(dir)
|
|
79
|
+
if (parent === dir) break
|
|
80
|
+
below.push(dir)
|
|
64
81
|
dir = parent
|
|
65
82
|
}
|
|
83
|
+
if (nearest !== null) return nearest
|
|
84
|
+
throw new UserError(
|
|
85
|
+
`Could not find a workspace root in any parent of ${start} ` +
|
|
86
|
+
`(looked for pnpm-workspace.yaml or package.json)`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when one of `below` (dirs under `root`, toward `start`) is a member. */
|
|
91
|
+
function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
|
|
92
|
+
if (below.length === 0 || globs.length === 0) return false
|
|
93
|
+
const { positive, negative } = splitPackageGlobs(globs)
|
|
94
|
+
const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
|
|
95
|
+
for (const pattern of positive) {
|
|
96
|
+
const normalized = pattern.replace(/\/+$/, '')
|
|
97
|
+
// `.` means the root itself is the project — never a directory below it.
|
|
98
|
+
if (normalized === '' || normalized === '.') continue
|
|
99
|
+
const glob = new Bun.Glob(normalized)
|
|
100
|
+
if (rels.some((rel) => glob.match(rel))) return true
|
|
101
|
+
}
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
|
|
107
|
+
* list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
|
|
108
|
+
* so `!packages/fixtures/package.json` matched every manifest in the tree
|
|
109
|
+
* and every directory holding one became a project — the excluded package
|
|
110
|
+
* ran under `--all`, and a fixture repeating a name killed the run with
|
|
111
|
+
* "Duplicate package name". Negations subtract from what the positive
|
|
112
|
+
* globs found; a literal one excludes its tree.
|
|
113
|
+
*/
|
|
114
|
+
function splitPackageGlobs(globs: readonly string[]): { positive: string[]; negative: string[] } {
|
|
115
|
+
const positive: string[] = []
|
|
116
|
+
const negative: string[] = []
|
|
117
|
+
// `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
|
|
118
|
+
// (2026-09-10): the same spellings the input globs normalize.
|
|
119
|
+
for (const raw of globs) {
|
|
120
|
+
const g = normalizeGlob(raw)
|
|
121
|
+
if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
|
|
122
|
+
else positive.push(g)
|
|
123
|
+
}
|
|
124
|
+
return { positive, negative }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function excludedBy(rel: string, negative: readonly string[]): boolean {
|
|
128
|
+
for (const neg of negative) {
|
|
129
|
+
if (neg.length === 0) continue
|
|
130
|
+
if (rel === neg || rel.startsWith(`${neg}/`)) return true
|
|
131
|
+
if (/[*?[\]{}]/.test(neg) && new Bun.Glob(neg).match(rel)) return true
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Package globs declared by `dir`, or `null` when `dir` is not a root
|
|
138
|
+
* candidate. A bare `package.json` (no `workspaces`) is single-project mode:
|
|
139
|
+
* the root itself is the only project, hence `['.']`.
|
|
140
|
+
*/
|
|
141
|
+
async function readPackageGlobs(dir: string): Promise<string[] | null> {
|
|
142
|
+
const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
|
|
143
|
+
if (await Bun.file(yamlPath).exists()) {
|
|
144
|
+
const parsed = (parseManifest(await Bun.file(yamlPath).text(), yamlPath, Bun.YAML.parse) ??
|
|
145
|
+
{}) as { packages?: unknown }
|
|
146
|
+
return assertGlobList(parsed.packages ?? [], yamlPath, 'packages')
|
|
147
|
+
}
|
|
148
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
149
|
+
if (!(await Bun.file(pkgPath).exists())) return null
|
|
150
|
+
const pkg = parseManifest(await Bun.file(pkgPath).text(), pkgPath, JSON.parse) as PackageJson
|
|
151
|
+
const ws = pkg.workspaces as unknown
|
|
152
|
+
if (ws === undefined || ws === null) return ['.']
|
|
153
|
+
if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
|
|
154
|
+
return assertGlobList(
|
|
155
|
+
(ws as { packages?: unknown }).packages ?? [],
|
|
156
|
+
pkgPath,
|
|
157
|
+
'workspaces.packages',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
return assertGlobList(ws, pkgPath, 'workspaces')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse a workspace manifest, naming the FILE on failure. The raw parser
|
|
165
|
+
* errors (`Failed to parse JSON`) carry no path, so in a 1000-package
|
|
166
|
+
* monorepo they say nothing about which manifest is broken.
|
|
167
|
+
*/
|
|
168
|
+
function parseManifest(text: string, file: string, parse: (t: string) => unknown): unknown {
|
|
169
|
+
try {
|
|
170
|
+
return parse(text)
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
173
|
+
throw new UserError(`failed to parse ${file}: ${msg}`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A glob list is user input from a manifest: `packages: "packages/*"`
|
|
179
|
+
* (a bare string instead of a list) is an easy YAML slip, and without
|
|
180
|
+
* this it surfaces as `workspace.packageGlobs.map is not a function`.
|
|
181
|
+
*/
|
|
182
|
+
function assertGlobList(value: unknown, file: string, field: string): string[] {
|
|
183
|
+
if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
|
|
184
|
+
throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
|
|
185
|
+
}
|
|
186
|
+
return value as string[]
|
|
66
187
|
}
|
|
67
188
|
|
|
68
189
|
/**
|
|
@@ -86,83 +207,185 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
|
|
|
86
207
|
* itself is treated as a single-project workspace.
|
|
87
208
|
*/
|
|
88
209
|
export async function loadWorkspace(root: string): Promise<Workspace> {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
210
|
+
const packageGlobs = await readPackageGlobs(root)
|
|
211
|
+
if (packageGlobs === null) {
|
|
212
|
+
// Should be unreachable: findWorkspaceRoot only returns dirs that
|
|
213
|
+
// pass at least one of the two existence checks.
|
|
214
|
+
throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
|
|
215
|
+
}
|
|
216
|
+
return { root, packageGlobs }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// `<dir>/*` — the shape of nearly every workspace glob. Anything with another
|
|
220
|
+
// glob character (`**`, `{a,b}`, `?`, `[`) or a negation takes the general
|
|
221
|
+
// path.
|
|
222
|
+
const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The directory each `<dir>/*` package glob names — where a member comes
|
|
226
|
+
* and goes as one directory entry, so `vx watch` can hear a package added
|
|
227
|
+
* or removed without walking anything. A glob of another shape (`apps/**`)
|
|
228
|
+
* has no such directory and contributes none.
|
|
229
|
+
*/
|
|
230
|
+
export function memberBaseDirs(workspace: Workspace): string[] {
|
|
231
|
+
const bases = new Set<string>()
|
|
232
|
+
for (const pattern of splitPackageGlobs(workspace.packageGlobs).positive) {
|
|
233
|
+
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
234
|
+
if (SIMPLE_STAR_RE.test(normalized)) {
|
|
235
|
+
bases.add(path.resolve(workspace.root, normalized.slice(0, -2)))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return [...bases]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The directories a workspace glob names. For the `<dir>/*` shape this is
|
|
243
|
+
* one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
|
|
244
|
+
* the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
|
|
245
|
+
* symlinked member is followed when it points at a directory, as the glob
|
|
246
|
+
* did. Dot-directories are skipped (`dot: false`), so are `node_modules`.
|
|
247
|
+
*/
|
|
248
|
+
async function memberDirs(root: string, pattern: string): Promise<string[]> {
|
|
249
|
+
// Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
|
|
250
|
+
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
251
|
+
if (SIMPLE_STAR_RE.test(normalized)) {
|
|
252
|
+
const base = path.resolve(root, normalized.slice(0, -2))
|
|
253
|
+
let entries: import('node:fs').Dirent[]
|
|
254
|
+
try {
|
|
255
|
+
entries = await readdir(base, { withFileTypes: true })
|
|
256
|
+
} catch {
|
|
257
|
+
return []
|
|
258
|
+
}
|
|
259
|
+
const dirs: string[] = []
|
|
260
|
+
for (const e of entries) {
|
|
261
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
262
|
+
if (e.isDirectory()) dirs.push(path.join(base, e.name))
|
|
263
|
+
else if (e.isSymbolicLink()) {
|
|
264
|
+
try {
|
|
265
|
+
if ((await stat(path.join(base, e.name))).isDirectory())
|
|
266
|
+
dirs.push(path.join(base, e.name))
|
|
267
|
+
} catch {
|
|
268
|
+
// dangling link: not a member
|
|
269
|
+
}
|
|
270
|
+
}
|
|
93
271
|
}
|
|
94
|
-
return
|
|
272
|
+
return dirs
|
|
273
|
+
}
|
|
274
|
+
const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
|
|
275
|
+
const glob = new Bun.Glob(globPattern)
|
|
276
|
+
const dirs: string[] = []
|
|
277
|
+
for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false })) {
|
|
278
|
+
// Skip nested node_modules — workspace package globs shouldn't
|
|
279
|
+
// ever reach into them, but a pathological pattern like `**`
|
|
280
|
+
// would. Avoid splitting the path on the hot loop.
|
|
281
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
282
|
+
if (rel.startsWith(`node_modules${path.sep}`)) continue
|
|
283
|
+
dirs.push(path.dirname(path.resolve(root, rel)))
|
|
95
284
|
}
|
|
285
|
+
return dirs
|
|
286
|
+
}
|
|
96
287
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
288
|
+
/**
|
|
289
|
+
* The project's config file, by `CONFIG_FILENAMES` precedence, or null.
|
|
290
|
+
*
|
|
291
|
+
* Two shapes, one per platform, each the measured winner there across
|
|
292
|
+
* 1,000 projects with the manifest read in flight alongside:
|
|
293
|
+
* - macOS stats the candidates in order: a readdir each cost 9.6 ms
|
|
294
|
+
* against 6.3 ms of stats even with the config at the LAST name
|
|
295
|
+
* (2026-09-03), and a `.ts` config — the first name — pays one stat.
|
|
296
|
+
* - Linux lists the directory once: a readdir each is 12 ms against
|
|
297
|
+
* 43 ms of stats at the last name and ties the one-stat case
|
|
298
|
+
* (2026-09-09, a `vx.config.mjs` workspace; stats there cost a
|
|
299
|
+
* thread-pool round trip apiece).
|
|
300
|
+
* Both answer identically: a directory or dangling link under a config
|
|
301
|
+
* name is skipped for the next name, a symlink to a file counts.
|
|
302
|
+
*/
|
|
303
|
+
async function findConfigFile(dir: string): Promise<string | null> {
|
|
304
|
+
if (process.platform === 'linux') {
|
|
305
|
+
let entries: Dirent[]
|
|
306
|
+
try {
|
|
307
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
308
|
+
} catch {
|
|
309
|
+
return null
|
|
104
310
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
311
|
+
const byName = new Map(entries.map((e) => [e.name, e]))
|
|
312
|
+
for (const name of CONFIG_FILENAMES) {
|
|
313
|
+
const entry = byName.get(name)
|
|
314
|
+
if (entry === undefined) continue
|
|
315
|
+
const candidate = path.join(dir, name)
|
|
316
|
+
if (entry.isFile()) return candidate
|
|
317
|
+
if (entry.isSymbolicLink() && (await isFile(candidate))) return candidate
|
|
318
|
+
}
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
for (const name of CONFIG_FILENAMES) {
|
|
322
|
+
const candidate = path.join(dir, name)
|
|
323
|
+
if (await isFile(candidate)) return candidate
|
|
108
324
|
}
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
109
327
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
328
|
+
async function isFile(p: string): Promise<boolean> {
|
|
329
|
+
try {
|
|
330
|
+
return (await stat(p)).isFile()
|
|
331
|
+
} catch {
|
|
332
|
+
return false
|
|
333
|
+
}
|
|
113
334
|
}
|
|
114
335
|
|
|
115
336
|
export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
|
|
116
337
|
// Run all package globs concurrently. Disk-bound walks parallelize
|
|
117
338
|
// well; serializing them just stretches the discovery phase by N×.
|
|
339
|
+
const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
|
|
118
340
|
const perPattern = await Promise.all(
|
|
119
|
-
|
|
120
|
-
// Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
|
|
121
|
-
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
122
|
-
const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
|
|
123
|
-
const glob = new Bun.Glob(globPattern)
|
|
124
|
-
const hits: string[] = []
|
|
125
|
-
for await (const rel of glob.scan({
|
|
126
|
-
cwd: workspace.root,
|
|
127
|
-
onlyFiles: true,
|
|
128
|
-
dot: false,
|
|
129
|
-
})) {
|
|
130
|
-
// Skip nested node_modules — workspace package globs shouldn't
|
|
131
|
-
// ever reach into them, but a pathological pattern like `**`
|
|
132
|
-
// would. Avoid splitting the path on the hot loop.
|
|
133
|
-
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
134
|
-
if (rel.startsWith(`node_modules${path.sep}`)) continue
|
|
135
|
-
hits.push(path.resolve(workspace.root, rel))
|
|
136
|
-
}
|
|
137
|
-
return hits
|
|
138
|
-
}),
|
|
341
|
+
positive.map((pattern) => memberDirs(workspace.root, pattern)),
|
|
139
342
|
)
|
|
140
343
|
const matches = new Set<string>()
|
|
141
|
-
for (const arr of perPattern)
|
|
344
|
+
for (const arr of perPattern) {
|
|
345
|
+
for (const m of arr) {
|
|
346
|
+
if (negative.length > 0 && excludedBy(relPosix(workspace.root, m), negative)) continue
|
|
347
|
+
matches.add(m)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
142
350
|
|
|
143
|
-
// Per-project
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
351
|
+
// Per-project discovery: the manifest read answers "is there a
|
|
352
|
+
// package.json" (an ENOENT is the "not a member" answer, at the cost of
|
|
353
|
+
// one failed open) and `findConfigFile` answers "which config file", both
|
|
354
|
+
// in flight together. All async and all in flight at once — the thread
|
|
355
|
+
// pool runs them in parallel, and a sync read here measured 2× slower on
|
|
356
|
+
// a 1000-member workspace.
|
|
148
357
|
const loaded = await Promise.all(
|
|
149
|
-
[...matches].map(async (
|
|
150
|
-
const
|
|
151
|
-
const [
|
|
152
|
-
|
|
153
|
-
|
|
358
|
+
[...matches].map(async (dir) => {
|
|
359
|
+
const pkgJsonPath = path.join(dir, 'package.json')
|
|
360
|
+
const [configPath, text] = await Promise.all([
|
|
361
|
+
findConfigFile(dir),
|
|
362
|
+
Bun.file(pkgJsonPath)
|
|
363
|
+
.text()
|
|
364
|
+
.catch(() => null),
|
|
154
365
|
])
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
const configPath = configName !== undefined ? path.join(dir, configName) : null
|
|
366
|
+
if (text === null) return null
|
|
367
|
+
const pkg = parseManifest(text, pkgJsonPath, JSON.parse) as PackageJson
|
|
158
368
|
return { dir, pkg, configPath }
|
|
159
369
|
}),
|
|
160
370
|
)
|
|
161
371
|
|
|
162
372
|
const projects: ProjectMeta[] = []
|
|
163
373
|
const seenName = new Map<string, string>()
|
|
164
|
-
for (const
|
|
165
|
-
if (
|
|
374
|
+
for (const entry of loaded) {
|
|
375
|
+
if (entry === null) continue
|
|
376
|
+
const { dir, pkg, configPath } = entry
|
|
377
|
+
if (!pkg.name) {
|
|
378
|
+
// A nameless manifest can't be addressed, filtered, or made affected —
|
|
379
|
+
// and vx identifies projects by name, so it simply vanishes. Silent is
|
|
380
|
+
// fine for a dir that declares no tasks; a dir with a vx config was
|
|
381
|
+
// meant to run.
|
|
382
|
+
if (configPath !== null) {
|
|
383
|
+
process.stderr.write(
|
|
384
|
+
`vx: ${relPosix(workspace.root, dir)} has a vx config but its package.json has no "name" — skipped\n`,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
166
389
|
const previous = seenName.get(pkg.name)
|
|
167
390
|
if (previous) {
|
|
168
391
|
throw new UserError(
|
|
@@ -172,5 +395,8 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
|
|
|
172
395
|
seenName.set(pkg.name, dir)
|
|
173
396
|
projects.push({ name: pkg.name, dir, packageJson: pkg, configPath })
|
|
174
397
|
}
|
|
175
|
-
|
|
398
|
+
// Code-unit order, not `localeCompare`: ICU collation cost 28 ms of a
|
|
399
|
+
// 300 ms warm run at 1000 projects, and a stable deterministic order is
|
|
400
|
+
// all any consumer needs.
|
|
401
|
+
return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
176
402
|
}
|
package/src/cache/cas-backend.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
// CASBackend — pluggable content-addressed storage beneath the cache.
|
|
2
|
-
//
|
|
3
|
-
// Today's Cache + LayeredCache both bundle "where do the bytes live"
|
|
4
|
-
// with "how do we look up entries metadata + key derivation."
|
|
5
|
-
// CASBackend separates the former so a future R2/S3 backend
|
|
6
|
-
// or REAPI CAS bridge can drop in without touching the
|
|
7
|
-
// orchestrator or the SQL entries index.
|
|
8
|
-
//
|
|
9
|
-
// Two reference implementations in this file:
|
|
10
|
-
// - MemoryCASBackend — testing + in-memory builds. Holds raw bytes.
|
|
11
|
-
// - FsCASBackend — writes bytes to a directory; mirrors what
|
|
12
|
-
// Cache.save does today. Bun-native using Bun.file
|
|
13
|
-
// for the read path so big artifacts stream
|
|
14
|
-
// without slurping into memory.
|
|
15
|
-
//
|
|
16
|
-
// Cache.ts has NOT yet been rewired to use a CASBackend — that's a
|
|
17
|
-
// follow-up (Phase 1b). This file ships the abstraction + the two
|
|
18
|
-
// reference impls so downstream work (R2, otel-bridge, distributed-ci)
|
|
19
|
-
// can rely on the type. Byte-identical behaviour today.
|
|
20
|
-
|
|
21
|
-
import { rm } from 'node:fs/promises'
|
|
22
|
-
import path from 'node:path'
|
|
23
|
-
import type { Digest } from './digest.js'
|
|
24
|
-
|
|
25
|
-
export interface CASBackend {
|
|
26
|
-
/** Write bytes under `digest`. Idempotent — putting the same digest twice is a no-op. */
|
|
27
|
-
put(digest: Digest, bytes: Uint8Array): Promise<void>
|
|
28
|
-
/** Read bytes under `digest`, or null if absent. */
|
|
29
|
-
get(digest: Digest): Promise<Uint8Array | null>
|
|
30
|
-
/** Cheap existence probe — no bytes round-tripped if possible. */
|
|
31
|
-
has(digest: Digest): Promise<boolean>
|
|
32
|
-
/** Drop one entry (eviction). No-op if absent. */
|
|
33
|
-
remove(digest: Digest): Promise<void>
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** In-memory backend, useful for tests and ephemeral runs. */
|
|
37
|
-
export class MemoryCASBackend implements CASBackend {
|
|
38
|
-
private readonly store = new Map<string, Uint8Array>()
|
|
39
|
-
|
|
40
|
-
async put(digest: Digest, bytes: Uint8Array): Promise<void> {
|
|
41
|
-
if (bytes.byteLength !== digest.sizeBytes) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
`MemoryCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
this.store.set(digest.hash, bytes)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async get(digest: Digest): Promise<Uint8Array | null> {
|
|
50
|
-
return this.store.get(digest.hash) ?? null
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async has(digest: Digest): Promise<boolean> {
|
|
54
|
-
return this.store.has(digest.hash)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async remove(digest: Digest): Promise<void> {
|
|
58
|
-
this.store.delete(digest.hash)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Test-only: how many entries are held. */
|
|
62
|
-
size(): number {
|
|
63
|
-
return this.store.size
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Filesystem-backed backend writing `<rootDir>/<hash>.tar.zst` files. */
|
|
68
|
-
export class FsCASBackend implements CASBackend {
|
|
69
|
-
constructor(private readonly rootDir: string) {}
|
|
70
|
-
|
|
71
|
-
private pathFor(digest: Digest): string {
|
|
72
|
-
return path.join(this.rootDir, `${digest.hash}.tar.zst`)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async put(digest: Digest, bytes: Uint8Array): Promise<void> {
|
|
76
|
-
if (bytes.byteLength !== digest.sizeBytes) {
|
|
77
|
-
throw new Error(
|
|
78
|
-
`FsCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
await Bun.write(this.pathFor(digest), bytes)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async get(digest: Digest): Promise<Uint8Array | null> {
|
|
85
|
-
const file = Bun.file(this.pathFor(digest))
|
|
86
|
-
if (!(await file.exists())) return null
|
|
87
|
-
return new Uint8Array(await file.arrayBuffer())
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async has(digest: Digest): Promise<boolean> {
|
|
91
|
-
return Bun.file(this.pathFor(digest)).exists()
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async remove(digest: Digest): Promise<void> {
|
|
95
|
-
await rm(this.pathFor(digest), { force: true })
|
|
96
|
-
}
|
|
97
|
-
}
|
package/src/cache/digest.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
// Digest — the explicit content-address used by every cache layer.
|
|
2
|
-
//
|
|
3
|
-
// Lifted as a first-class type (architecture-review-2026-06.md §4.3)
|
|
4
|
-
// so storage backends become pluggable beneath CacheLayer: local FS,
|
|
5
|
-
// R2, S3, or a future REAPI CAS bridge all speak (hash, sizeBytes).
|
|
6
|
-
//
|
|
7
|
-
// Two practical wins from making this explicit:
|
|
8
|
-
// 1. sizeBytes is the truncation check at transport boundaries (HTTP
|
|
9
|
-
// Content-Length, R2 ETag-by-size). Free correctness, free corrupt-
|
|
10
|
-
// artifact-early-fail.
|
|
11
|
-
// 2. CASBackend.has(digest) can answer with just the metadata, no
|
|
12
|
-
// bytes round-trip — what existence probes want.
|
|
13
|
-
//
|
|
14
|
-
// Why sizeBytes is `number` and not `bigint`: Bun.file().size returns
|
|
15
|
-
// number; JS Number safely represents integers to 2^53-1 (~9 PB); no
|
|
16
|
-
// real cache artifact gets there. Bigint would create the same
|
|
17
|
-
// JSON.stringify-throws nuisance we already navigated for ns timestamps.
|
|
18
|
-
|
|
19
|
-
export interface Digest {
|
|
20
|
-
readonly hash: string
|
|
21
|
-
readonly sizeBytes: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function makeDigest(hash: string, sizeBytes: number): Digest {
|
|
25
|
-
if (!/^[0-9a-f]+$/i.test(hash)) {
|
|
26
|
-
throw new Error(`invalid digest hash: ${JSON.stringify(hash)}`)
|
|
27
|
-
}
|
|
28
|
-
if (!Number.isInteger(sizeBytes) || sizeBytes < 0) {
|
|
29
|
-
throw new Error(`invalid digest sizeBytes: ${sizeBytes}`)
|
|
30
|
-
}
|
|
31
|
-
return { hash, sizeBytes }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function digestEqual(a: Digest, b: Digest): boolean {
|
|
35
|
-
return a.hash === b.hash && a.sizeBytes === b.sizeBytes
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Stable wire format for logging / RPC: `hash/sizeBytes`. */
|
|
39
|
-
export function digestString(d: Digest): string {
|
|
40
|
-
return `${d.hash}/${d.sizeBytes}`
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function parseDigest(s: string): Digest {
|
|
44
|
-
const slash = s.indexOf('/')
|
|
45
|
-
if (slash === -1) throw new Error(`invalid digest string: ${JSON.stringify(s)}`)
|
|
46
|
-
return makeDigest(s.slice(0, slash), Number(s.slice(slash + 1)))
|
|
47
|
-
}
|