@vzn/vx 0.0.18 → 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.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,7 +1,8 @@
1
+ import type { Dirent } from 'node:fs'
1
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 { relPosix, 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
@@ -89,8 +90,9 @@ export async function findWorkspaceRoot(start: string): Promise<string> {
89
90
  /** True when one of `below` (dirs under `root`, toward `start`) is a member. */
90
91
  function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
91
92
  if (below.length === 0 || globs.length === 0) return false
92
- const rels = below.map((d) => relPosix(root, d))
93
- for (const pattern of globs) {
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) {
94
96
  const normalized = pattern.replace(/\/+$/, '')
95
97
  // `.` means the root itself is the project — never a directory below it.
96
98
  if (normalized === '' || normalized === '.') continue
@@ -100,6 +102,37 @@ function claimsMember(root: string, below: readonly string[], globs: readonly st
100
102
  return false
101
103
  }
102
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
+
103
136
  /**
104
137
  * Package globs declared by `dir`, or `null` when `dir` is not a root
105
138
  * candidate. A bare `package.json` (no `workspaces`) is single-project mode:
@@ -188,6 +221,23 @@ export async function loadWorkspace(root: string): Promise<Workspace> {
188
221
  // path.
189
222
  const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
190
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
+
191
241
  /**
192
242
  * The directories a workspace glob names. For the `<dir>/*` shape this is
193
243
  * one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
@@ -237,31 +287,66 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
237
287
 
238
288
  /**
239
289
  * 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.
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.
244
302
  */
245
303
  async function findConfigFile(dir: string): Promise<string | null> {
246
- for (const name of CONFIG_FILENAMES) {
247
- const candidate = path.join(dir, name)
304
+ if (process.platform === 'linux') {
305
+ let entries: Dirent[]
248
306
  try {
249
- if ((await stat(candidate)).isFile()) return candidate
307
+ entries = await readdir(dir, { withFileTypes: true })
250
308
  } catch {
251
- // not this name
309
+ return null
252
310
  }
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
253
324
  }
254
325
  return null
255
326
  }
256
327
 
328
+ async function isFile(p: string): Promise<boolean> {
329
+ try {
330
+ return (await stat(p)).isFile()
331
+ } catch {
332
+ return false
333
+ }
334
+ }
335
+
257
336
  export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
258
337
  // Run all package globs concurrently. Disk-bound walks parallelize
259
338
  // well; serializing them just stretches the discovery phase by N×.
339
+ const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
260
340
  const perPattern = await Promise.all(
261
- workspace.packageGlobs.map((pattern) => memberDirs(workspace.root, pattern)),
341
+ positive.map((pattern) => memberDirs(workspace.root, pattern)),
262
342
  )
263
343
  const matches = new Set<string>()
264
- for (const arr of perPattern) for (const m of arr) matches.add(m)
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
+ }
265
350
 
266
351
  // Per-project discovery: the manifest read answers "is there a
267
352
  // package.json" (an ENOENT is the "not a member" answer, at the cost of
@@ -1,2 +0,0 @@
1
- // Subpath shim for Bun's compiled binaries — see ../../index.ts.
2
- export * from '../../src/plugins/local-cache/index.js'
@@ -1,2 +0,0 @@
1
- // Subpath shim for Bun's compiled binaries — see ../../index.ts.
2
- export * from '../../src/plugins/local-executor/index.js'
@@ -1,2 +0,0 @@
1
- // Subpath shim for Bun's compiled binaries — see ../../index.ts.
2
- export * from '../../src/plugins/schedule-history/index.js'
@@ -1,115 +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 { mkdir, rename, rm } from 'node:fs/promises'
22
- import path from 'node:path'
23
- import type { Digest } from './digest.js'
24
-
25
- /** Per-process counter for put() temp names; uniqueness only. */
26
- let casTmpSeq = 0
27
-
28
- export interface CASBackend {
29
- /** Write bytes under `digest`. Idempotent — putting the same digest twice is a no-op. */
30
- put(digest: Digest, bytes: Uint8Array): Promise<void>
31
- /** Read bytes under `digest`, or null if absent. */
32
- get(digest: Digest): Promise<Uint8Array | null>
33
- /** Cheap existence probe — no bytes round-tripped if possible. */
34
- has(digest: Digest): Promise<boolean>
35
- /** Drop one entry (eviction). No-op if absent. */
36
- remove(digest: Digest): Promise<void>
37
- }
38
-
39
- /** In-memory backend, useful for tests and ephemeral runs. */
40
- export class MemoryCASBackend implements CASBackend {
41
- private readonly store = new Map<string, Uint8Array>()
42
-
43
- async put(digest: Digest, bytes: Uint8Array): Promise<void> {
44
- if (bytes.byteLength !== digest.sizeBytes) {
45
- throw new Error(
46
- `MemoryCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
47
- )
48
- }
49
- this.store.set(digest.hash, bytes)
50
- }
51
-
52
- async get(digest: Digest): Promise<Uint8Array | null> {
53
- return this.store.get(digest.hash) ?? null
54
- }
55
-
56
- async has(digest: Digest): Promise<boolean> {
57
- return this.store.has(digest.hash)
58
- }
59
-
60
- async remove(digest: Digest): Promise<void> {
61
- this.store.delete(digest.hash)
62
- }
63
-
64
- /** Test-only: how many entries are held. */
65
- size(): number {
66
- return this.store.size
67
- }
68
- }
69
-
70
- /** Filesystem-backed backend writing `<rootDir>/<hash>.tar.zst` files. */
71
- export class FsCASBackend implements CASBackend {
72
- constructor(private readonly rootDir: string) {}
73
-
74
- private pathFor(digest: Digest): string {
75
- return path.join(this.rootDir, `${digest.hash}.tar.zst`)
76
- }
77
-
78
- async put(digest: Digest, bytes: Uint8Array): Promise<void> {
79
- if (bytes.byteLength !== digest.sizeBytes) {
80
- throw new Error(
81
- `FsCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
82
- )
83
- }
84
- // Temp + rename, the same shape `Cache.writeArtifactAndIndex` and the
85
- // archive extractor use: two writers of the SAME content-addressed
86
- // blob are expected (that is what content-addressing invites), and a
87
- // plain write lets a reader observe a half-written file under a name
88
- // that promises complete bytes. rename(2) is atomic, so a concurrent
89
- // reader sees either the old file or the whole new one.
90
- const final = this.pathFor(digest)
91
- const tmp = `${final}.tmp-${process.pid.toString(36)}-${(casTmpSeq++).toString(36)}`
92
- await mkdir(this.rootDir, { recursive: true })
93
- try {
94
- await Bun.write(tmp, bytes)
95
- await rename(tmp, final)
96
- } catch (err) {
97
- await rm(tmp, { force: true })
98
- throw err
99
- }
100
- }
101
-
102
- async get(digest: Digest): Promise<Uint8Array | null> {
103
- const file = Bun.file(this.pathFor(digest))
104
- if (!(await file.exists())) return null
105
- return new Uint8Array(await file.arrayBuffer())
106
- }
107
-
108
- async has(digest: Digest): Promise<boolean> {
109
- return Bun.file(this.pathFor(digest)).exists()
110
- }
111
-
112
- async remove(digest: Digest): Promise<void> {
113
- await rm(this.pathFor(digest), { force: true })
114
- }
115
- }
@@ -1,32 +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
- }
@@ -1,21 +0,0 @@
1
- // Shared TS-literal emission helper for the migrate mappers. Lives in its own
2
- // leaf module so both migrate.ts (the config emitter) and migrate-turbo.ts (the
3
- // preset renderer) can escape strings identically without a dependency cycle
4
- // (migrate.ts already imports the turbo/nx mappers, so a back-import of a value
5
- // from migrate.ts would close a runtime cycle).
6
-
7
- /**
8
- * Escape an arbitrary string into a single-quoted TS literal. Escapes
9
- * backslash + quote AND raw newlines/CR — a value with an embedded newline
10
- * (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would
11
- * otherwise splice into a single-quoted literal as an unterminated / malformed
12
- * string that fails to load (generated files must round-trip through the
13
- * loader).
14
- */
15
- export function quote(s: string): string {
16
- return `'${s
17
- .replaceAll('\\', '\\\\')
18
- .replaceAll("'", "\\'")
19
- .replaceAll('\n', '\\n')
20
- .replaceAll('\r', '\\r')}'`
21
- }