@vzn/vx 0.0.640 → 0.0.642

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 (76) hide show
  1. package/launcher.sh +9 -2
  2. package/package.json +5 -12
  3. package/skills/vx/SKILL.md +4 -4
  4. package/src/bin.ts +8 -6
  5. package/src/cache/archive.ts +735 -107
  6. package/src/cache/cache.ts +133 -29
  7. package/src/cache/fold-memo.ts +58 -0
  8. package/src/cache/git-inputs.ts +213 -31
  9. package/src/cache/index.ts +9 -2
  10. package/src/cache/inputs.ts +244 -108
  11. package/src/cache/key-fold.ts +90 -69
  12. package/src/cache/layer.ts +9 -7
  13. package/src/cache/layered-cache.ts +74 -9
  14. package/src/cache/output-index.ts +25 -4
  15. package/src/cache/remote-lane.ts +27 -10
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +11 -3
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +11 -5
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/run.ts +10 -3
  23. package/src/cli/select.ts +2 -0
  24. package/src/cli/upgrade.ts +37 -2
  25. package/src/cli/watch-filter.ts +1 -6
  26. package/src/cli/watch-fs.ts +2 -1
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/handoff.ts +5 -0
  31. package/src/history/history.ts +43 -33
  32. package/src/index.ts +11 -1
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +111 -29
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/local-shortcircuit.ts +8 -0
  38. package/src/orchestrator/lockfile-claim.ts +54 -8
  39. package/src/orchestrator/miss-save.ts +27 -17
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +35 -5
  43. package/src/orchestrator/plugin.ts +29 -3
  44. package/src/orchestrator/prepare.ts +74 -5
  45. package/src/orchestrator/projects.ts +6 -1
  46. package/src/orchestrator/run-report.ts +30 -9
  47. package/src/orchestrator/run.ts +62 -38
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +46 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +58 -15
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +11 -6
  58. package/src/util/errors.ts +1 -1
  59. package/src/util/hidden-prompt.ts +74 -0
  60. package/src/util/index.ts +13 -0
  61. package/src/util/paths.ts +58 -5
  62. package/src/util/secrets.ts +150 -0
  63. package/src/workspace/affected.ts +63 -13
  64. package/src/workspace/config-eval.ts +2 -2
  65. package/src/workspace/config-schema.ts +37 -3
  66. package/src/workspace/project-loader.ts +6 -4
  67. package/src/workspace/workspace.ts +17 -11
  68. package/docs/caching.md +0 -2352
  69. package/docs/cli.md +0 -3280
  70. package/docs/execution.md +0 -675
  71. package/docs/features.md +0 -178
  72. package/docs/patterns.md +0 -219
  73. package/docs/schema.md +0 -2461
  74. package/docs/security.md +0 -252
  75. package/src/cli/docs-corpus.ts +0 -21
  76. package/src/cli/docs-text.d.ts +0 -5
package/src/cli/docs.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  // `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
2
- // reference offline, for an agent that cannot reach the site: each page is
2
+ // reference as this version wrote it: packages/vx/docs at its git tag on
3
+ // GitHub, read online (M, 2026-10-17: nothing is embedded). Each page is
3
4
  // cut at its headings, and the sections holding every query word print
4
5
  // whole, best first.
5
6
 
7
+ import { VERSION } from '../version.js'
6
8
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
7
9
 
8
10
  const SITE = 'https://vznjs.github.io/vx/'
9
11
  const DEFAULT_LIMIT = 3
12
+ const REPO = 'vznjs/vx'
13
+ const DIR = 'packages/vx/docs'
10
14
 
11
15
  interface DocsArgs {
12
16
  terms: string[]
@@ -59,9 +63,55 @@ function anchor(heading: string): string {
59
63
  .replace(/ /g, '-')
60
64
  }
61
65
 
62
- /** A page's URL on the site; `features.md` is served as `all-features/`. */
66
+ /** A page's URL on the site, as packages/vx-docs/scripts/import-docs.ts serves it. */
63
67
  function pageUrl(page: string): string {
64
- return `${SITE}${page === 'features' ? 'all-features' : page}/`
68
+ const served: Record<string, string> = {
69
+ README: 'overview',
70
+ features: 'all-features',
71
+ 'turbo-nx-support': 'compare/turbo-nx-support',
72
+ }
73
+ return `${SITE}${served[page] ?? page.toLowerCase()}/`
74
+ }
75
+
76
+ /** The git ref this vx's docs are at: its release tag; a source checkout (0.0.0) reads main. */
77
+ export function docsRef(version: string = VERSION): string {
78
+ return version === '0.0.0' ? 'main' : `v${version}`
79
+ }
80
+
81
+ /**
82
+ * Every page of packages/vx/docs at `ref` (its own `.md` files, the
83
+ * maintainers' STATUS.md aside), page name → markdown. One listing, then
84
+ * the pages at once. Throws with what failed; a token in GITHUB_TOKEN or
85
+ * GH_TOKEN lifts GitHub's anonymous listing limit.
86
+ */
87
+ export async function loadPages(
88
+ ref: string,
89
+ get: (url: string, init?: RequestInit) => Promise<Response> = fetch,
90
+ env: Record<string, string | undefined> = process.env,
91
+ ): Promise<Record<string, string>> {
92
+ const token = env['GITHUB_TOKEN'] ?? env['GH_TOKEN']
93
+ const listing = await get(
94
+ `https://api.github.com/repos/${REPO}/contents/${DIR}?ref=${encodeURIComponent(ref)}`,
95
+ {
96
+ headers: {
97
+ accept: 'application/vnd.github+json',
98
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
99
+ },
100
+ },
101
+ )
102
+ if (!listing.ok) throw new Error(`GitHub answered ${listing.status} listing ${DIR} at ${ref}`)
103
+ const names = ((await listing.json()) as Array<{ name: string; type: string }>)
104
+ .filter((e) => e.type === 'file' && e.name.endsWith('.md') && e.name !== 'STATUS.md')
105
+ .map((e) => e.name)
106
+ const texts = await Promise.all(
107
+ names.map(async (name) => {
108
+ const url = `https://raw.githubusercontent.com/${REPO}/${ref}/${DIR}/${name}`
109
+ const r = await get(url)
110
+ if (!r.ok) throw new Error(`GitHub answered ${r.status} for ${url}`)
111
+ return r.text()
112
+ }),
113
+ )
114
+ return Object.fromEntries(names.map((n, i) => [n.slice(0, -3), texts[i]!]))
65
115
  }
66
116
 
67
117
  interface Section {
@@ -137,7 +187,10 @@ export function searchDocs(
137
187
  }))
138
188
  }
139
189
 
140
- export async function docsCmd(args: readonly string[]): Promise<number> {
190
+ export async function docsCmd(
191
+ args: readonly string[],
192
+ load: (ref: string) => Promise<Record<string, string>> = loadPages,
193
+ ): Promise<number> {
141
194
  const parsed = parseDocsArgs(args)
142
195
  if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
143
196
  if (parsed.terms.length === 0) {
@@ -148,8 +201,19 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
148
201
  'VX_E_USAGE',
149
202
  )
150
203
  }
151
- const { PAGES } = await import('./docs-corpus.js')
152
- const hits = searchDocs(PAGES, parsed.terms, parsed.limit)
204
+ const ref = docsRef()
205
+ let pages: Record<string, string>
206
+ try {
207
+ pages = await load(ref)
208
+ } catch (err) {
209
+ return refuse(
210
+ 'docs',
211
+ args,
212
+ `cannot read the docs at ${ref} from GitHub (${err instanceof Error ? err.message : String(err)}); they are on the site, ${SITE}`,
213
+ 'VX_E_NETWORK',
214
+ )
215
+ }
216
+ const hits = searchDocs(pages, parsed.terms, parsed.limit)
153
217
  const query = parsed.terms.join(' ')
154
218
  if (parsed.json) {
155
219
  process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
@@ -157,7 +221,7 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
157
221
  }
158
222
  if (hits.length === 0) {
159
223
  process.stdout.write(
160
- `vx docs: no section of ${Object.keys(PAGES).join(', ')} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
224
+ `vx docs: no section of the docs at ${ref} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
161
225
  )
162
226
  return 0
163
227
  }
package/src/cli/help.ts CHANGED
@@ -117,7 +117,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
117
117
  ' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
118
118
  ' vx lock [--check] [--format pretty|json]',
119
119
  ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
120
- ' vx upgrade [TAG]',
120
+ ' vx upgrade [TAG] [-g|--global]',
121
121
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
122
122
  ' vx info [--format pretty|json] [--cache-dir <path>]',
123
123
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
@@ -274,6 +274,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
274
274
  ' test (executor, cache, telemetry, schedule, admit, commands,',
275
275
  ' project, graph, key).',
276
276
  '',
277
+ 'Upgrade:',
278
+ ' vx upgrade [TAG] Bring vx to TAG (default: latest) the way it was installed.',
279
+ " In a project: the project's vx, its @vzn/vx-* plugins and",
280
+ ' the global vx that ran it, to one release.',
281
+ ' -g, --global Only the global vx: no project, no plugin.',
282
+ '',
277
283
  'Shell completions:',
278
284
  ' vx completions <shell> Print a completion script for bash, zsh or fish: the',
279
285
  ' verbs (plugin verbs of this workspace included) and',
@@ -281,10 +287,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
281
287
  ' or write it where the shell loads completions from.',
282
288
  '',
283
289
  'Docs:',
284
- ' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
285
- ' patterns, security, features): the sections holding every',
286
- ' word of the query print whole, best first, with their URL;',
287
- ' an error code (vx docs VX_E_CYCLE) prints its fix alone.',
290
+ " vx docs <query> Search the reference at this version's tag on GitHub (online):",
291
+ ' the sections holding every word of the query print whole,',
292
+ ' best first, with their URL; an error code',
293
+ ' (vx docs VX_E_CYCLE) prints its fix alone.',
288
294
  ' --limit N How many sections (default 3).',
289
295
  ' --format json {query, hits: [{page, heading, url, text}]}.',
290
296
  '',
@@ -148,7 +148,10 @@ export interface PlanTaskJson {
148
148
  }
149
149
 
150
150
  /** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
151
- export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
151
+ export function formatPlanJson(
152
+ plan: Pick<RunPlan, 'tasks' | 'predicted' | 'downloadDowngrades'>,
153
+ affectedBase?: string,
154
+ ): string {
152
155
  return (
153
156
  JSON.stringify(
154
157
  {
package/src/cli/run.ts CHANGED
@@ -595,9 +595,16 @@ export async function runCmd(args: readonly string[]): Promise<number> {
595
595
  const scopeOut: { affectedBase?: string } = {}
596
596
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
597
597
  if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
598
+ // `--dry=json` prints a plan even when there is nothing to run: a script
599
+ // reading stdout broke exactly then (Growth, 2026-10-16).
600
+ const nothingToRun = (): 0 => {
601
+ if (parsed.dry === 'json')
602
+ process.stdout.write(formatPlanJson({ tasks: [] }, scopeOut.affectedBase))
603
+ return 0
604
+ }
598
605
  if ('nothingSelected' in resolved) {
599
606
  sayNotice('info', 'vx run', resolved.nothingSelected)
600
- return 0
607
+ return nothingToRun()
601
608
  }
602
609
  const opts = resolved
603
610
  // The raw invocation, recorded on the `invocations` row so dashboards
@@ -628,11 +635,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
628
635
  // said no affected project declared a task one did (X-141).
629
636
  if (plan.noneAffected !== undefined) {
630
637
  sayLine('info', plan.noneAffected, 'vx run')
631
- return 0
638
+ return nothingToRun()
632
639
  }
633
640
  if (opts.selectedByDiff === true) {
634
641
  sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
635
- return 0
642
+ return nothingToRun()
636
643
  }
637
644
  return refuse(
638
645
  'run',
package/src/cli/select.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from '../util/index.js'
37
37
  import {
38
38
  claimedAffected,
39
+ claimedExtraFiles,
39
40
  fingerprintClaims,
40
41
  gitOfDiscovery,
41
42
  hasHook,
@@ -123,6 +124,7 @@ async function workspaceFingerprintClaims(
123
124
  return {
124
125
  files: new Set(claims.keys()),
125
126
  affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
127
+ extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes),
126
128
  }
127
129
  }
128
130
 
@@ -712,6 +712,31 @@ export async function upgradeGlobal(
712
712
  })
713
713
  }
714
714
 
715
+ /**
716
+ * `vx upgrade -g`: bring this vx, the global one, to `version` and nothing
717
+ * else (owner, 2026-10-16) — no project's package.json, no plugin. The
718
+ * project's own vx refuses: no global ran.
719
+ */
720
+ export async function upgradeOnlyGlobal(
721
+ self: string,
722
+ version: string,
723
+ current = VERSION,
724
+ ): Promise<void> {
725
+ if (current === version) {
726
+ ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
727
+ return
728
+ }
729
+ // Read from the binary's own directory, not cwd: a project there is not this install's.
730
+ const install = managedInstall(self, path.dirname(self))
731
+ if (install?.scope === 'project') {
732
+ throw new UserError(
733
+ `vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
734
+ )
735
+ }
736
+ if (install === null) await upgradeBinary(self, version, current, 'global ')
737
+ else await upgradeManaged(install, version, current, 'global ')
738
+ }
739
+
715
740
  /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
716
741
  async function named(what: string, run: () => Promise<void>): Promise<void> {
717
742
  try {
@@ -722,7 +747,9 @@ async function named(what: string, run: () => Promise<void>): Promise<void> {
722
747
  }
723
748
  }
724
749
 
725
- export async function upgradeCmd(args: readonly string[]): Promise<number> {
750
+ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
751
+ const global = argv.some((a) => a === '-g' || a === '--global')
752
+ const args = argv.filter((a) => a !== '-g' && a !== '--global')
726
753
  const unknown = args.find((a) => a.startsWith('-'))
727
754
  if (unknown !== undefined) {
728
755
  sayNotice(
@@ -746,10 +773,18 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
746
773
  )
747
774
  }
748
775
  const dest = process.execPath
776
+ const handedOffFrom = process.env['VX_HANDED_OFF']
777
+ if (global) {
778
+ // A launcher from before -g handed the call over: the global is the one that did.
779
+ const version = await targetVersion(tag)
780
+ if (handedOffFrom !== undefined) await upgradeGlobal(handedOffFrom, version)
781
+ else await upgradeOnlyGlobal(dest, version)
782
+ return 0
783
+ }
749
784
  const install = managedInstall(dest, process.cwd())
750
785
  // A global vx handed this run to the project's own: both move, to one
751
786
  // version, the project first (owner, 2026-10-15).
752
- const from = process.env['VX_HANDED_OFF']
787
+ const from = handedOffFrom
753
788
  if (from !== undefined) {
754
789
  const version = await targetVersion(tag)
755
790
  await named('project vx', () =>
@@ -33,13 +33,8 @@ export function isIgnoredWatchPath(rel: string): boolean {
33
33
  const segments = rel.split(path.sep)
34
34
  for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
35
35
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
36
- const name = segments[segments.length - 1]!
37
- // A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
38
- // renamed away before the judgement, it read as a deletion and ran one
39
- // more cycle where an uncached task makes ignored paths count.
40
- if (name.startsWith('.vx-tmp-')) return true
41
36
  // `vx lock`'s review copy: no input, so a re-lock is no cycle.
42
- return name === 'vx.lock.json'
37
+ return segments[segments.length - 1] === 'vx.lock.json'
43
38
  }
44
39
 
45
40
  /**
@@ -16,7 +16,8 @@ export const IGNORED_SEGMENTS = ['node_modules', '.git']
16
16
  export function isIgnoredDir(segments: readonly string[]): boolean {
17
17
  const last = segments.length - 1
18
18
  if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
19
- return segments[last] === 'cache' && segments[last - 1] === '.vx'
19
+ // vx's cache, and where its restores stage (archive.ts).
20
+ return (segments[last] === 'cache' || segments[last] === 'tmp') && segments[last - 1] === '.vx'
20
21
  }
21
22
 
22
23
  /**
@@ -50,6 +50,10 @@ export interface RunResult {
50
50
  /** `Bun.spawn` threw: no shell ran, so its 127 is vx's and says nothing
51
51
  * about the command (A-41). */
52
52
  spawnFailed?: true
53
+ /** The shell exited while a process it started still held its
54
+ * stdout/stderr past the drain bound (`POST_EXIT_CUT_LINE`): that process
55
+ * may still write the task's outputs, so the result is not saved. */
56
+ leftRunning?: true
53
57
  }
54
58
 
55
59
  /**
@@ -1034,6 +1038,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1034
1038
  stderr,
1035
1039
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
1036
1040
  ...(timeout.timedOut() ? { timedOut: true } : {}),
1041
+ ...(cut ? { leftRunning: true } : {}),
1037
1042
  ...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
1038
1043
  }
1039
1044
  }
@@ -1344,7 +1344,7 @@ async function wrapIn(
1344
1344
  if (process.platform === 'darwin') {
1345
1345
  const rules = [
1346
1346
  ...macProfileRules(args.config),
1347
- ...darwinWallRules(args.config, baselines.allowRead),
1347
+ ...darwinGrantRules(args.config, baselines.allowRead, baselines.denyRead),
1348
1348
  ]
1349
1349
  try {
1350
1350
  if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
@@ -2270,6 +2270,7 @@ async function runSandboxedOnce(
2270
2270
  violations,
2271
2271
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
2272
2272
  ...(timeout.timedOut() ? { timedOut: true } : {}),
2273
+ ...(cut ? { leftRunning: true } : {}),
2273
2274
  // Linux: the task's CPU and peak never reach this wait. bwrap runs it
2274
2275
  // in a pid namespace (`--unshare-pid`), and what the namespace's
2275
2276
  // processes used is not folded into bwrap's usage: a 500 ms busy loop
@@ -2570,6 +2571,85 @@ export function wallsGlobsReach(grants: readonly string[], walls: readonly strin
2570
2571
  return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
2571
2572
  }
2572
2573
 
2574
+ /**
2575
+ * macOS: the grant rules vx appends. The listings come first so a wall's
2576
+ * deny, later, wins: SBPL is last-match-wins.
2577
+ */
2578
+ export function darwinGrantRules(
2579
+ c: Pick<ResolvedSandboxConfig, 'wallsReached' | 'allowRead' | 'allowWrite'>,
2580
+ baseAllowRead: readonly string[],
2581
+ anchors: readonly string[],
2582
+ ): string[] {
2583
+ return [...darwinAncestorRules(c, anchors), ...darwinWallRules(c, baseAllowRead)]
2584
+ }
2585
+
2586
+ /**
2587
+ * macOS: each directory above a grant may be listed, as on Linux. bwrap
2588
+ * makes every directory above a mount, so a task granted
2589
+ * `../vx-*\/package.json` could glob it from `..`; seatbelt matches paths
2590
+ * and refused the listing (EPERM), so `@vzn/vx-docs#import` failed on
2591
+ * every Mac (2026-10-17). Above the glob that is each literal directory
2592
+ * strictly inside a deny anchor (outside one, all is readable already);
2593
+ * a directory the glob itself names (`vx-a/`) is matched as a regex, and
2594
+ * under a `**` any directory is. The rules grant directories only, never
2595
+ * a file, and come before the wall denies, which still win.
2596
+ *
2597
+ * A listing shows every name in the directory, where Linux's scaffold
2598
+ * shows only what it mounted: names, never a file's data. Not the anchor
2599
+ * itself: Bun, shown the workspace root's `package.json` it
2600
+ * may not read, failed with "Cannot read file" (EPERM).
2601
+ */
2602
+ export function darwinAncestorRules(
2603
+ c: Pick<ResolvedSandboxConfig, 'allowRead' | 'allowWrite'>,
2604
+ anchors: readonly string[],
2605
+ ): string[] {
2606
+ const literals = new Set<string>()
2607
+ const patterns = new Set<string>()
2608
+ // Grants are canonical already (`resolveSandboxConfig`), and so is each
2609
+ // directory above one: no realpath here, at every spawn.
2610
+ const inside = (d: string): boolean => anchors.some((a) => d !== a && atOrUnder(d, a))
2611
+ for (const g of [...c.allowRead, ...c.allowWrite]) {
2612
+ const named = namedPath(g)
2613
+ const head = g.slice(0, g.search(MOUNT_WILDCARDS))
2614
+ const cut = head.lastIndexOf('/')
2615
+ const top = named !== undefined ? path.dirname(named) : head.slice(0, cut) || '/'
2616
+ for (let d = top; inside(d); d = path.dirname(d)) literals.add(d)
2617
+ if (named !== undefined || g.includes('\\')) continue
2618
+ let re = top === '/' ? '' : regexLiteral(top)
2619
+ for (const part of g
2620
+ .slice(cut + 1)
2621
+ .split('/')
2622
+ .slice(0, -1)) {
2623
+ if (part === '**') {
2624
+ patterns.add(`^${re}(/.*)?$`)
2625
+ break
2626
+ }
2627
+ re += `/${part
2628
+ .replace(/[.^$+{}()|]/g, '[$&]')
2629
+ .replaceAll('*', '[^/]*')
2630
+ .replaceAll('?', '[^/]')}`
2631
+ patterns.add(`^${re}$`)
2632
+ }
2633
+ }
2634
+ // A name seatbelt's quoted string cannot hold keeps today's refusal
2635
+ // rather than failing the task (`sbplResolvedPath` would throw).
2636
+ const spellable = (p: string): boolean => !UNSPELLABLE.test(p)
2637
+ return [
2638
+ ...[...literals].filter(spellable).map((d) => `(allow file-read-data (literal "${d}"))`),
2639
+ ...[...patterns]
2640
+ .filter(spellable)
2641
+ .map((r) => `(allow file-read-data (require-all (vnode-type DIRECTORY) (regex #"${r}")))`),
2642
+ ]
2643
+ }
2644
+
2645
+ // eslint-disable-next-line no-control-regex -- the control range is the point
2646
+ const UNSPELLABLE = /["'\\\x00-\x1f\x7f]/
2647
+
2648
+ /** A real path as a regex matching only itself: each special in a class of one. */
2649
+ function regexLiteral(p: string): string {
2650
+ return p.replace(/[.^$+{}()|*?[\]]/g, (ch) => `[${ch}]`)
2651
+ }
2652
+
2573
2653
  /**
2574
2654
  * macOS: the walls a glob grant reaches, denied at the profile's tail
2575
2655
  * (SBPL is last-match-wins). SRT re-emits a wall's deny after the allows
@@ -23,7 +23,20 @@ export function tieredReverseDepCount(
23
23
  restoreTier: ReadonlySet<string>,
24
24
  ): Map<string, number> {
25
25
  const exec = new Map<string, TaskNode>()
26
- for (const [id, node] of nodes) if (!restoreTier.has(id)) exec.set(id, node)
26
+ let runsAny = false
27
+ for (const [id, node] of nodes) {
28
+ if (restoreTier.has(id)) continue
29
+ exec.set(id, node)
30
+ if (node.config.exec !== undefined) runsAny = true
31
+ }
32
+ // Nothing outside the restore tier runs a command (a warm run: restores
33
+ // and groups), so no order finishes any work sooner: everything ranks 0.
34
+ // The weights below were 10 ms of a 1,000-project warm run.
35
+ if (!runsAny) {
36
+ const zeros = new Map<string, number>()
37
+ for (const id of nodes.keys()) zeros.set(id, 0)
38
+ return zeros
39
+ }
27
40
  // An exec tier with no edge inside it (a warm run's groups) counts 0
28
41
  // everywhere: no closure to build.
29
42
  let linked = false
package/src/handoff.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  // walk runs before anything else loads, through `Bun.file(…).exists()`:
6
6
  // importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
7
7
 
8
+ /** `vx upgrade -g`: moves only the global vx, so it never hands off (owner, 2026-10-16). */
9
+ export function upgradesOnlyItself(argv: readonly string[]): boolean {
10
+ return argv[0] === 'upgrade' && argv.some((a) => a === '-g' || a === '--global')
11
+ }
12
+
8
13
  /**
9
14
  * The project-local vx to run instead of this one, or null: the workspace
10
15
  * root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
@@ -263,56 +263,66 @@ export class LocalHistoryProvider implements HistoryProvider {
263
263
  * newest imported timing (`importTimings`), else the median on any
264
264
  * branch, else none. The timings are the shared store's `task_durations`, so every
265
265
  * worktree of the repo reads them and a new one forecasts on its first
266
- * run. A point read per task on `(project, task, source, id)`: exact
267
- * however old the rows, where the runs window holds only the newest
268
- * invocations.
266
+ * run. One statement per tier, an index probe per task on `(project,
267
+ * task, source, id)` inside it: exact however old the rows, where the
268
+ * runs window holds only the newest invocations.
269
269
  */
270
270
  private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
271
271
  const out = new Map<string, number>()
272
272
  this.lackedBranchHistory = false
273
273
  if (wanted.size === 0) return out
274
- // Tiers, first answer wins.
275
- type Tier = (project: string, task: string) => Array<{ ms: number }>
274
+ // Tiers, first answer wins: a WHERE over `task_durations`, its params,
275
+ // and how many of the newest rows it reads.
276
+ type Tier = { where: string; params: string[]; limit: number }
276
277
  const tiers: Tier[] = []
277
- const newest = (where: string) =>
278
- this.db.query(
279
- `SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND ${where}
280
- ORDER BY id DESC LIMIT ${FORECAST_RUNS}`,
281
- )
282
278
  if (this.branch !== undefined) {
283
- const source = branchSource(this.branch)
284
- const q = newest('source = ?')
285
- tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
279
+ tiers.push({ where: 'source = ?', params: [branchSource(this.branch)], limit: FORECAST_RUNS })
286
280
  }
287
281
  // One probe on the partial index first: a store with no import costs
288
- // no query per task.
282
+ // no tier.
289
283
  if (
290
284
  this.db.query(`SELECT 1 FROM task_durations WHERE source = 'remote' LIMIT 1`).get() !== null
291
285
  ) {
292
- const remote = this.db.query(
293
- `SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND source = 'remote'
294
- ORDER BY id DESC LIMIT 1`,
295
- )
296
- tiers.push((p, t) => remote.all(p, t) as Array<{ ms: number }>)
286
+ tiers.push({ where: `source = 'remote'`, params: [], limit: 1 })
297
287
  }
298
- const any = newest(`source LIKE 'branch:%'`)
299
- tiers.push((p, t) => any.all(p, t) as Array<{ ms: number }>)
288
+ tiers.push({ where: `source LIKE 'branch:%'`, params: [], limit: FORECAST_RUNS })
300
289
  const own = this.branch !== undefined ? tiers[0] : tiers.at(-1)
301
- let lacked = false
302
- for (const id of wanted) {
303
- const [project, task] = splitTaskId(id)
304
- let answered: Tier | undefined
305
- for (const tier of tiers) {
306
- const rows = tier(project, task)
307
- if (rows.length === 0) continue
308
- const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
290
+ let pending = [...wanted]
291
+ let ownAnswered = 0
292
+ for (const tier of tiers) {
293
+ if (pending.length === 0) break
294
+ // One statement per tier over every task it is asked for: an index
295
+ // probe per task inside SQLite, where a query per task from here was
296
+ // 31-40 ms of 2,000 tasks against 5-8.
297
+ const rows = this.db
298
+ .query(
299
+ `SELECT j.key AS i, (SELECT group_concat(ms) FROM (
300
+ SELECT duration_ms AS ms FROM task_durations
301
+ WHERE project = j.value ->> 0 AND task = j.value ->> 1 AND ${tier.where}
302
+ ORDER BY id DESC LIMIT ${tier.limit})) AS ds
303
+ FROM json_each(?) AS j`,
304
+ )
305
+ .all(...tier.params, JSON.stringify(pending.map(splitTaskId))) as {
306
+ i: number
307
+ ds: string | null
308
+ }[]
309
+ const left: string[] = []
310
+ for (const r of rows) {
311
+ const id = pending[r.i]!
312
+ if (r.ds === null) {
313
+ left.push(id)
314
+ continue
315
+ }
316
+ const durations = r.ds
317
+ .split(',')
318
+ .map(Number)
319
+ .sort((x, y) => x - y)
309
320
  out.set(id, pickPercentile(durations, 0.5))
310
- answered = tier
311
- break
321
+ if (tier === own) ownAnswered++
312
322
  }
313
- if (answered !== own) lacked = true
323
+ pending = left
314
324
  }
315
- this.lackedBranchHistory = lacked
325
+ this.lackedBranchHistory = ownAnswered < wanted.size
316
326
  return out
317
327
  }
318
328
 
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export { machineMemoryBytes, machineParallelism } from './util/index.js'
22
22
  // How core reads a task glob — its spelling, and which characters are
23
23
  // wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
24
24
  // outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
25
- export { isLiteralPattern, normalizeGlob } from './util/index.js'
25
+ export { isLiteralPattern, normalizeGlob, restorableOutput } from './util/index.js'
26
26
 
27
27
  // Schema types and helpers (used by user vx.config files and presets).
28
28
  export type {
@@ -83,9 +83,19 @@ export type {
83
83
  CacheLayer,
84
84
  PluginCache,
85
85
  RemoteCacheLayer,
86
+ RemoteOpenContext,
86
87
  RunRecord,
87
88
  InvocationRecord,
88
89
  } from './cache/index.js'
90
+ // A plugin's keychain (`ctx.secret` on `setup` and a remote's `open`), and
91
+ // the guard that tells a missing secret from a fault.
92
+ export { isSecretUnavailable } from './util/index.js'
93
+ export type {
94
+ PluginSecrets,
95
+ SecretRequest,
96
+ SecretUnavailable,
97
+ SecretUnavailableReason,
98
+ } from './util/index.js'
89
99
 
90
100
  // Workspace discovery + the project/config catalog surface — an
91
101
  // out-of-process service/CLI needs these. `loadProjectConfig` is the RAW
@@ -15,7 +15,7 @@
15
15
  import {
16
16
  cleanOutputs,
17
17
  committedFiles,
18
- resolveOutputs,
18
+ resolveOutputTree,
19
19
  type CacheLayer,
20
20
  type GitFilesCache,
21
21
  } from '../cache/index.js'
@@ -144,7 +144,7 @@ export class DeferredOutputs {
144
144
  `could not fetch deferred outputs of ${taskId}: ${msg} — re-run it with --force, or use --cache-download=all`,
145
145
  )
146
146
  }
147
- const outputFiles = await resolveOutputs(cleanArgs)
147
+ const { files: outputFiles, emptyDirs } = await resolveOutputTree(cleanArgs)
148
148
  this.args.gitFilesCache?.markOutputsChanged(
149
149
  producer.projectDir,
150
150
  outputFiles.map((f) => f.slice(producer.projectDir.length + 1)),
@@ -157,6 +157,7 @@ export class DeferredOutputs {
157
157
  hash: entry.hash,
158
158
  projectDir: producer.projectDir,
159
159
  outputFiles,
160
+ ...(emptyDirs.length > 0 ? { outputDirs: emptyDirs } : {}),
160
161
  entry: entry.entry,
161
162
  })
162
163
  }