@vzn/vx 0.0.50 → 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 (54) 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/migration.ts +1 -1
  53. package/src/workspace/project-loader.ts +53 -1
  54. package/src/workspace/workspace.ts +54 -14
@@ -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
  *