@vzn/vx 0.0.49 → 0.0.51

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 (55) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +1 -0
  22. package/src/cli/select.ts +29 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +35 -18
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/orchestrator/admission.ts +72 -17
  35. package/src/orchestrator/execute-task.ts +39 -25
  36. package/src/orchestrator/hit-restore.ts +7 -1
  37. package/src/orchestrator/miss-save.ts +29 -1
  38. package/src/orchestrator/placement.ts +23 -3
  39. package/src/orchestrator/plugin-host.ts +1 -1
  40. package/src/orchestrator/plugin.ts +9 -1
  41. package/src/orchestrator/prepare.ts +1 -0
  42. package/src/orchestrator/run.ts +75 -12
  43. package/src/orchestrator/sandbox-request.ts +19 -10
  44. package/src/orchestrator/summary.ts +26 -15
  45. package/src/orchestrator/task-hash.ts +7 -2
  46. package/src/orchestrator/telemetry.ts +14 -4
  47. package/src/util/paths.ts +16 -9
  48. package/src/workspace/affected.ts +201 -38
  49. package/src/workspace/config-eval.ts +4 -1
  50. package/src/workspace/config-schema.ts +70 -10
  51. package/src/workspace/fingerprint.ts +6 -0
  52. package/src/workspace/migrate-scripts.ts +11 -5
  53. package/src/workspace/migration.ts +1 -1
  54. package/src/workspace/project-loader.ts +53 -1
  55. package/src/workspace/workspace.ts +54 -14
@@ -90,13 +90,20 @@ function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
90
90
  return raw as Record<string, unknown>
91
91
  }
92
92
 
93
+ /** The cache block the task that builds should declare, as a TODO on it. */
94
+ const CACHE_TODO =
95
+ "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task"
96
+
93
97
  /**
94
98
  * A `build` that only delegates (`build: pnpm run compile`) is a group over
95
99
  * its target, and the `^build` edge every `build` carries belongs on the
96
100
  * task that does the work: the group ran `compile` with no edge at all, so
97
101
  * a package compiled before the ones it imports had built (item 907). On
98
102
  * the group itself it would not hold — the group waits on both, `compile`
99
- * on neither. Followed through a chain of groups to the first command.
103
+ * on neither. Followed through a chain of groups to the first command,
104
+ * which takes the cache TODO too: a group has no command to cache, and the
105
+ * TODO a delegating `build` never got was the one the header promised
106
+ * (item 1045).
100
107
  */
101
108
  function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
102
109
  const byName = new Map(tasks.map((t) => [t.name, t]))
@@ -111,6 +118,7 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
111
118
  if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
112
119
  const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
113
120
  if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
121
+ if (!at.todos.includes(CACHE_TODO)) at.todos.push(CACHE_TODO)
114
122
  }
115
123
 
116
124
  export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
@@ -175,9 +183,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
175
183
  }
176
184
  if (name === 'build') {
177
185
  task['dependsOn'] = ['^build']
178
- todos.push(
179
- "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task",
180
- )
186
+ todos.push(CACHE_TODO)
181
187
  } else if (AFTER_BUILD.has(name) && hasBuild) {
182
188
  task['dependsOn'] = ['build']
183
189
  }
@@ -197,7 +203,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
197
203
  }
198
204
  return {
199
205
  headerNotes: [
200
- 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block — `build` carries a TODO showing the one to add',
206
+ 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block — `build`, or the script it delegates to, carries a TODO showing the one to add',
201
207
  ],
202
208
  projects,
203
209
  extraFiles: [],
@@ -101,7 +101,7 @@ export interface MigrationPlan {
101
101
  projects: GeneratedProject[]
102
102
  /** extra root-relative files (e.g. the preset) */
103
103
  extraFiles: { relPath: string; contents: string }[]
104
- /** trailing report lines (e.g. implicit Nx deps) */
104
+ /** trailing report lines (e.g. what a turbo.json holds that vx has no place for) */
105
105
  notes: string[]
106
106
  }
107
107
 
@@ -1,10 +1,16 @@
1
+ import { realpathSync, statSync } from 'node:fs'
1
2
  import path from 'node:path'
2
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
3
4
  import { UserError, xxh3hex } from '../util/index.js'
4
5
  import { validateProjectConfig, validateWorkspace } from './config-schema.js'
5
6
  import { beginEvalRound, evaluateConfigFresh } from './config-eval.js'
6
7
  import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
7
- import { configEvalKey, configEvalKeyFromClosure, type ConfigEvalStore } from './config-cache.js'
8
+ import {
9
+ configEvalKey,
10
+ configEvalKeyFromClosure,
11
+ configImports,
12
+ type ConfigEvalStore,
13
+ } from './config-cache.js'
8
14
  import { readOnce } from './load-reads.js'
9
15
 
10
16
  // The validator lives in config-schema.ts; re-exported so a reader that
@@ -420,6 +426,41 @@ export async function loadProjectConfig(
420
426
  return config!
421
427
  }
422
428
 
429
+ /** When this process first began loading each workspace config, in ms. */
430
+ const workspaceLoadedAt = new Map<string, number>()
431
+
432
+ /**
433
+ * A repeat load in one process (`vx mcp`'s every call, `vx watch`'s every
434
+ * cycle) busts the workspace config's own URL, but Bun answers what it
435
+ * imports from the module registry: an edited local plugin kept its first
436
+ * version and `listTasks` served its old tasks with no word (item 1046).
437
+ * Bun cannot evaluate an imported module again, so the load is refused,
438
+ * naming the file, as `vx watch` already says when it sees the edit. Only
439
+ * a repeat load pays for the walk; a CLI verb loads once.
440
+ */
441
+ async function refuseStaleWorkspaceImports(configPath: string, since: number): Promise<void> {
442
+ for (const file of await configImports(configPath)) {
443
+ let mtime: number
444
+ try {
445
+ mtime = statSync(file).mtimeMs
446
+ } catch {
447
+ continue
448
+ }
449
+ // `since` is whole milliseconds and an mtime is not: a file written in
450
+ // the millisecond the first load began (1000.4 against 1000) read as
451
+ // changed, and the control reload was refused one run in ten. An edit
452
+ // inside that millisecond is one the load may have read anyway.
453
+ if (Math.floor(mtime) > since) {
454
+ // The walk names real paths: a root reached through a symlink (macOS's
455
+ // temp dir) named `../target/helper.mjs` from the link.
456
+ const from = path.dirname(realpathSync(configPath))
457
+ throw new UserError(
458
+ `${path.basename(configPath)} imports ${path.relative(from, file)}, which changed after this process loaded it; a running process cannot evaluate an imported module again — restart it to apply the edit`,
459
+ )
460
+ }
461
+ }
462
+ }
463
+
423
464
  /**
424
465
  * Find and load `vx.workspace.{ts,mts,js,mjs}` from the workspace
425
466
  * root. Returns `null` if no such file exists (the common case;
@@ -430,8 +471,19 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
430
471
  for (const configPath of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
431
472
  const bytes = await readOnce(undefined, configPath)
432
473
  if (bytes === null) continue
474
+ const since = workspaceLoadedAt.get(configPath)
475
+ if (since !== undefined) await refuseStaleWorkspaceImports(configPath, since)
476
+ const startedAt = Date.now()
433
477
  const mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
478
+ // Checked again once awaited, as a project config is: a Promise default
479
+ // passed the first check, and `Promise.resolve(null)` crashed the
480
+ // validator with a stack while `Promise.resolve(42)` loaded as no
481
+ // config at all (D-6).
482
+ assertDefaultObject(mod, 'Workspace', configPath)
434
483
  validateWorkspace(mod, configPath)
484
+ // Set by a load that succeeded: a failed one may have left nothing in
485
+ // the registry, and its fix must not be refused.
486
+ if (since === undefined) workspaceLoadedAt.set(configPath, startedAt)
435
487
  return mod
436
488
  }
437
489
  return null
@@ -416,25 +416,65 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
416
416
  }
417
417
  return dirs
418
418
  }
419
- const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
420
- const glob = new Bun.Glob(globPattern)
421
419
  const dirs: string[] = []
422
- // A linked member is a member, as the readdir path above has it:
423
- // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
424
- // `pack*/*` did not (item 987). Followed only where the depth is bounded:
425
- // under `**` the scan would walk every pnpm `node_modules` link.
426
- const followSymlinks = !normalized.includes('**')
427
- for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
428
- // Skip nested node_modules — workspace package globs shouldn't
429
- // ever reach into them, but a pathological pattern like `**`
430
- // would. Avoid splitting the path on the hot loop.
431
- if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
432
- if (rel.startsWith(`node_modules${path.sep}`)) continue
433
- dirs.push(path.dirname(path.resolve(root, rel)))
420
+ // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
421
+ // slash, though its `match` reads one: `packages/{a,nested/b}` listed
422
+ // neither package, and both ran under no verb while the root still
423
+ // claimed them (D-2). Such a pattern is scanned as its expansions.
424
+ for (const expanded of slashBraceExpansions(normalized)) {
425
+ const globPattern = expanded === '' ? 'package.json' : `${expanded}/package.json`
426
+ const glob = new Bun.Glob(globPattern)
427
+ // A linked member is a member, as the readdir path above has it:
428
+ // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
429
+ // `pack*/*` did not (item 987). Followed only where the depth is bounded:
430
+ // under `**` the scan would walk every pnpm `node_modules` link.
431
+ const followSymlinks = !expanded.includes('**')
432
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
433
+ // Skip nested node_modules — workspace package globs shouldn't
434
+ // ever reach into them, but a pathological pattern like `**`
435
+ // would. Avoid splitting the path on the hot loop.
436
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
437
+ if (rel.startsWith(`node_modules${path.sep}`)) continue
438
+ dirs.push(path.dirname(path.resolve(root, rel)))
439
+ }
434
440
  }
435
441
  return dirs
436
442
  }
437
443
 
444
+ /**
445
+ * `pattern` with its first brace group expanded, recursively, while that
446
+ * group holds a `/`; any other pattern as itself, since `Bun.Glob`
447
+ * expands a slash-free brace on its own. An unbalanced or escaped brace
448
+ * is left to the glob.
449
+ */
450
+ function slashBraceExpansions(pattern: string): string[] {
451
+ if (pattern.includes('\\')) return [pattern]
452
+ for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
453
+ let depth = 0
454
+ const cuts: number[] = []
455
+ let close = -1
456
+ for (let i = open; i < pattern.length && close === -1; i++) {
457
+ const c = pattern[i]
458
+ if (c === '{') depth++
459
+ else if (c === '}' && --depth === 0) close = i
460
+ else if (c === ',' && depth === 1) cuts.push(i)
461
+ }
462
+ if (close === -1) return [pattern]
463
+ const body = pattern.slice(open + 1, close)
464
+ if (cuts.length === 0 || !body.includes('/')) continue
465
+ const head = pattern.slice(0, open)
466
+ const tail = pattern.slice(close + 1)
467
+ const bounds = [open, ...cuts, close]
468
+ const out: string[] = []
469
+ for (let k = 0; k + 1 < bounds.length; k++) {
470
+ const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
471
+ out.push(...slashBraceExpansions(head + alt + tail))
472
+ }
473
+ return [...new Set(out)]
474
+ }
475
+ return [pattern]
476
+ }
477
+
438
478
  /**
439
479
  * The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
440
480
  *