@vzn/vx 0.0.15 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -1,7 +1,7 @@
1
- import { readdir } from 'node:fs/promises'
1
+ import { readdir, stat } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
4
- import { UserError } from '../util/index.js'
4
+ import { relPosix, UserError } from '../util/index.js'
5
5
 
6
6
  export interface PackageJson {
7
7
  name: string
@@ -40,29 +40,117 @@ export interface ProjectEntry {
40
40
  const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
41
41
 
42
42
  /**
43
- * Walk up from `start` to find the workspace root. A directory is a
44
- * workspace root if it contains either:
45
- * - `pnpm-workspace.yaml`, OR
46
- * - a `package.json` (with or without a `workspaces` field).
43
+ * Walk up from `start` to find the workspace root. A directory is a root
44
+ * CANDIDATE when it contains `pnpm-workspace.yaml` or a `package.json`.
47
45
  *
48
- * The first match wins. A bare `package.json` without `workspaces`
49
- * means a single-project workspace (the root itself IS the project).
50
- * Throws a `UserError` if neither signal is found before `/`.
46
+ * The nearest candidate that CLAIMS `start` wins — one of the directories
47
+ * between it and `start` matches one of its package globs. Every workspace
48
+ * member has its own `package.json`, so stopping at the first candidate would
49
+ * make a run from inside a package treat that package as the whole workspace:
50
+ * `^task` edges vanish, upstream hashes drop out of the cache key (stale
51
+ * hits), and a second cache dir appears under the member. Claiming is decided
52
+ * with the same globs `loadWorkspace` applies, so "the root that claims me"
53
+ * and "the root that lists me as a project" cannot diverge.
54
+ *
55
+ * When no candidate claims `start` — a standalone package, or a subdirectory
56
+ * of a single-project repo — the nearest candidate wins (the root itself IS
57
+ * the project). Throws a `UserError` when there is no candidate before `/`.
51
58
  */
52
59
  export async function findWorkspaceRoot(start: string): Promise<string> {
53
60
  let dir = path.resolve(start)
61
+ const below: string[] = []
62
+ let nearest: string | null = null
54
63
  while (true) {
55
- if (await Bun.file(path.join(dir, 'pnpm-workspace.yaml')).exists()) return dir
56
- if (await Bun.file(path.join(dir, 'package.json')).exists()) return dir
57
- const parent = path.dirname(dir)
58
- if (parent === dir) {
59
- throw new UserError(
60
- `Could not find a workspace root in any parent of ${start} ` +
61
- `(looked for pnpm-workspace.yaml or package.json)`,
62
- )
64
+ let globs: string[] | null
65
+ try {
66
+ globs = await readPackageGlobs(dir)
67
+ } catch {
68
+ // An unparseable manifest is still a root SIGNAL (the pre-existing
69
+ // behaviour probed only for existence); it just can't claim members.
70
+ // `loadWorkspace` surfaces the parse error if this dir is chosen.
71
+ globs = []
72
+ }
73
+ if (globs !== null) {
74
+ nearest ??= dir
75
+ if (claimsMember(dir, below, globs)) return dir
63
76
  }
77
+ const parent = path.dirname(dir)
78
+ if (parent === dir) break
79
+ below.push(dir)
64
80
  dir = parent
65
81
  }
82
+ if (nearest !== null) return nearest
83
+ throw new UserError(
84
+ `Could not find a workspace root in any parent of ${start} ` +
85
+ `(looked for pnpm-workspace.yaml or package.json)`,
86
+ )
87
+ }
88
+
89
+ /** True when one of `below` (dirs under `root`, toward `start`) is a member. */
90
+ function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
91
+ if (below.length === 0 || globs.length === 0) return false
92
+ const rels = below.map((d) => relPosix(root, d))
93
+ for (const pattern of globs) {
94
+ const normalized = pattern.replace(/\/+$/, '')
95
+ // `.` means the root itself is the project — never a directory below it.
96
+ if (normalized === '' || normalized === '.') continue
97
+ const glob = new Bun.Glob(normalized)
98
+ if (rels.some((rel) => glob.match(rel))) return true
99
+ }
100
+ return false
101
+ }
102
+
103
+ /**
104
+ * Package globs declared by `dir`, or `null` when `dir` is not a root
105
+ * candidate. A bare `package.json` (no `workspaces`) is single-project mode:
106
+ * the root itself is the only project, hence `['.']`.
107
+ */
108
+ async function readPackageGlobs(dir: string): Promise<string[] | null> {
109
+ const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
110
+ if (await Bun.file(yamlPath).exists()) {
111
+ const parsed = (parseManifest(await Bun.file(yamlPath).text(), yamlPath, Bun.YAML.parse) ??
112
+ {}) as { packages?: unknown }
113
+ return assertGlobList(parsed.packages ?? [], yamlPath, 'packages')
114
+ }
115
+ const pkgPath = path.join(dir, 'package.json')
116
+ if (!(await Bun.file(pkgPath).exists())) return null
117
+ const pkg = parseManifest(await Bun.file(pkgPath).text(), pkgPath, JSON.parse) as PackageJson
118
+ const ws = pkg.workspaces as unknown
119
+ if (ws === undefined || ws === null) return ['.']
120
+ if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
121
+ return assertGlobList(
122
+ (ws as { packages?: unknown }).packages ?? [],
123
+ pkgPath,
124
+ 'workspaces.packages',
125
+ )
126
+ }
127
+ return assertGlobList(ws, pkgPath, 'workspaces')
128
+ }
129
+
130
+ /**
131
+ * Parse a workspace manifest, naming the FILE on failure. The raw parser
132
+ * errors (`Failed to parse JSON`) carry no path, so in a 1000-package
133
+ * monorepo they say nothing about which manifest is broken.
134
+ */
135
+ function parseManifest(text: string, file: string, parse: (t: string) => unknown): unknown {
136
+ try {
137
+ return parse(text)
138
+ } catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err)
140
+ throw new UserError(`failed to parse ${file}: ${msg}`)
141
+ }
142
+ }
143
+
144
+ /**
145
+ * A glob list is user input from a manifest: `packages: "packages/*"`
146
+ * (a bare string instead of a list) is an easy YAML slip, and without
147
+ * this it surfaces as `workspace.packageGlobs.map is not a function`.
148
+ */
149
+ function assertGlobList(value: unknown, file: string, field: string): string[] {
150
+ if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
151
+ throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
152
+ }
153
+ return value as string[]
66
154
  }
67
155
 
68
156
  /**
@@ -86,83 +174,133 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
86
174
  * itself is treated as a single-project workspace.
87
175
  */
88
176
  export async function loadWorkspace(root: string): Promise<Workspace> {
89
- const yamlPath = path.join(root, 'pnpm-workspace.yaml')
90
- if (await Bun.file(yamlPath).exists()) {
91
- const parsed = (Bun.YAML.parse(await Bun.file(yamlPath).text()) ?? {}) as {
92
- packages?: string[]
93
- }
94
- return { root, packageGlobs: parsed.packages ?? [] }
177
+ const packageGlobs = await readPackageGlobs(root)
178
+ if (packageGlobs === null) {
179
+ // Should be unreachable: findWorkspaceRoot only returns dirs that
180
+ // pass at least one of the two existence checks.
181
+ throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
95
182
  }
183
+ return { root, packageGlobs }
184
+ }
185
+
186
+ // `<dir>/*` — the shape of nearly every workspace glob. Anything with another
187
+ // glob character (`**`, `{a,b}`, `?`, `[`) or a negation takes the general
188
+ // path.
189
+ const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
96
190
 
97
- const pkgPath = path.join(root, 'package.json')
98
- if (await Bun.file(pkgPath).exists()) {
99
- const pkg = (await Bun.file(pkgPath).json()) as PackageJson
100
- const ws = pkg.workspaces
101
- if (Array.isArray(ws)) return { root, packageGlobs: ws }
102
- if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) {
103
- return { root, packageGlobs: ws.packages }
191
+ /**
192
+ * The directories a workspace glob names. For the `<dir>/*` shape this is
193
+ * one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
194
+ * the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
195
+ * symlinked member is followed when it points at a directory, as the glob
196
+ * did. Dot-directories are skipped (`dot: false`), so are `node_modules`.
197
+ */
198
+ async function memberDirs(root: string, pattern: string): Promise<string[]> {
199
+ // Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
200
+ const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
201
+ if (SIMPLE_STAR_RE.test(normalized)) {
202
+ const base = path.resolve(root, normalized.slice(0, -2))
203
+ let entries: import('node:fs').Dirent[]
204
+ try {
205
+ entries = await readdir(base, { withFileTypes: true })
206
+ } catch {
207
+ return []
208
+ }
209
+ const dirs: string[] = []
210
+ for (const e of entries) {
211
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
212
+ if (e.isDirectory()) dirs.push(path.join(base, e.name))
213
+ else if (e.isSymbolicLink()) {
214
+ try {
215
+ if ((await stat(path.join(base, e.name))).isDirectory())
216
+ dirs.push(path.join(base, e.name))
217
+ } catch {
218
+ // dangling link: not a member
219
+ }
220
+ }
104
221
  }
105
- // Bare package.json with no `workspaces` — single-project mode.
106
- // Treat the root itself as the only project.
107
- return { root, packageGlobs: ['.'] }
222
+ return dirs
223
+ }
224
+ const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
225
+ const glob = new Bun.Glob(globPattern)
226
+ const dirs: string[] = []
227
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false })) {
228
+ // Skip nested node_modules — workspace package globs shouldn't
229
+ // ever reach into them, but a pathological pattern like `**`
230
+ // would. Avoid splitting the path on the hot loop.
231
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
232
+ if (rel.startsWith(`node_modules${path.sep}`)) continue
233
+ dirs.push(path.dirname(path.resolve(root, rel)))
108
234
  }
235
+ return dirs
236
+ }
109
237
 
110
- // Should be unreachable: findWorkspaceRoot only returns dirs that
111
- // pass at least one of the two existence checks.
112
- throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
238
+ /**
239
+ * The project's config file, by `CONFIG_FILENAMES` precedence, or null.
240
+ * Stats the candidates in order rather than listing the directory: across
241
+ * 1000 projects a readdir each cost 9.6 ms against 6.3 ms of stats even
242
+ * with the config at the LAST name (measured 2026-09-03), and a `.ts`
243
+ * config — the first name — pays one stat.
244
+ */
245
+ async function findConfigFile(dir: string): Promise<string | null> {
246
+ for (const name of CONFIG_FILENAMES) {
247
+ const candidate = path.join(dir, name)
248
+ try {
249
+ if ((await stat(candidate)).isFile()) return candidate
250
+ } catch {
251
+ // not this name
252
+ }
253
+ }
254
+ return null
113
255
  }
114
256
 
115
257
  export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
116
258
  // Run all package globs concurrently. Disk-bound walks parallelize
117
259
  // well; serializing them just stretches the discovery phase by N×.
118
260
  const perPattern = await Promise.all(
119
- workspace.packageGlobs.map(async (pattern) => {
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
- }),
261
+ workspace.packageGlobs.map((pattern) => memberDirs(workspace.root, pattern)),
139
262
  )
140
263
  const matches = new Set<string>()
141
264
  for (const arr of perPattern) for (const m of arr) matches.add(m)
142
265
 
143
- // Per-project I/O (manifest read + config discovery) is independent
144
- // — run it all concurrently, then a deterministic sequential pass
145
- // for the duplicate-name check. One readdir per project replaces
146
- // up to four exists() probes (CONFIG_FILENAMES has 4 candidates):
147
- // ~5440 awaited syscalls → ~2180 concurrent ones at 1090 projects.
266
+ // Per-project discovery: the manifest read answers "is there a
267
+ // package.json" (an ENOENT is the "not a member" answer, at the cost of
268
+ // one failed open) and `findConfigFile` answers "which config file", both
269
+ // in flight together. All async and all in flight at once — the thread
270
+ // pool runs them in parallel, and a sync read here measured 2× slower on
271
+ // a 1000-member workspace.
148
272
  const loaded = await Promise.all(
149
- [...matches].map(async (pkgJsonPath) => {
150
- const dir = path.dirname(pkgJsonPath)
151
- const [pkg, entries] = await Promise.all([
152
- Bun.file(pkgJsonPath).json() as Promise<PackageJson>,
153
- readdir(dir).catch(() => [] as string[]),
273
+ [...matches].map(async (dir) => {
274
+ const pkgJsonPath = path.join(dir, 'package.json')
275
+ const [configPath, text] = await Promise.all([
276
+ findConfigFile(dir),
277
+ Bun.file(pkgJsonPath)
278
+ .text()
279
+ .catch(() => null),
154
280
  ])
155
- const names = new Set(entries)
156
- const configName = CONFIG_FILENAMES.find((f) => names.has(f))
157
- const configPath = configName !== undefined ? path.join(dir, configName) : null
281
+ if (text === null) return null
282
+ const pkg = parseManifest(text, pkgJsonPath, JSON.parse) as PackageJson
158
283
  return { dir, pkg, configPath }
159
284
  }),
160
285
  )
161
286
 
162
287
  const projects: ProjectMeta[] = []
163
288
  const seenName = new Map<string, string>()
164
- for (const { dir, pkg, configPath } of loaded) {
165
- if (!pkg.name) continue
289
+ for (const entry of loaded) {
290
+ if (entry === null) continue
291
+ const { dir, pkg, configPath } = entry
292
+ if (!pkg.name) {
293
+ // A nameless manifest can't be addressed, filtered, or made affected —
294
+ // and vx identifies projects by name, so it simply vanishes. Silent is
295
+ // fine for a dir that declares no tasks; a dir with a vx config was
296
+ // meant to run.
297
+ if (configPath !== null) {
298
+ process.stderr.write(
299
+ `vx: ${relPosix(workspace.root, dir)} has a vx config but its package.json has no "name" — skipped\n`,
300
+ )
301
+ }
302
+ continue
303
+ }
166
304
  const previous = seenName.get(pkg.name)
167
305
  if (previous) {
168
306
  throw new UserError(
@@ -172,5 +310,8 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
172
310
  seenName.set(pkg.name, dir)
173
311
  projects.push({ name: pkg.name, dir, packageJson: pkg, configPath })
174
312
  }
175
- return projects.sort((a, b) => a.name.localeCompare(b.name))
313
+ // Code-unit order, not `localeCompare`: ICU collation cost 28 ms of a
314
+ // 300 ms warm run at 1000 projects, and a stable deterministic order is
315
+ // all any consumer needs.
316
+ return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
176
317
  }