@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -31,7 +31,9 @@ import {
31
31
  } from '../util/index.js'
32
32
  import * as ui from '../ui/index.js'
33
33
  import type { ProjectMeta } from '../workspace/index.js'
34
- import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
34
+ import type { EventBus, Logger, RunEvent, RunStartInfo } from '../report/index.js'
35
+ import { readOnlyEvents } from './read-only.js'
36
+ import { unlessStopped } from './signals.js'
35
37
  import type { CiContext } from './run-context.js'
36
38
  import type { CacheFacts, TaskArtifacts } from './task-artifacts.js'
37
39
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
@@ -121,7 +123,7 @@ export interface VxPlugin {
121
123
  * feature flag, anything the declared inputs cannot see. Runs once per
122
124
  * task per run, before any key is derived. Return undefined to add
123
125
  * nothing. Values must be deterministic for the same inputs or the key
124
- * never hits.
126
+ * never hits. The task is read-only (a write throws): `graph` edits tasks.
125
127
  */
126
128
  key?(
127
129
  task: TaskNode,
@@ -153,6 +155,7 @@ export interface VxPlugin {
153
155
  * The run waits for this answer before its first task, so it should
154
156
  * come from what is at hand (local history); better weights that need
155
157
  * a fetch go to `ctx.refine` when they arrive, without holding the run.
158
+ * The nodes are read-only (a write throws): their keys are taken.
156
159
  */
157
160
  schedule?(
158
161
  nodes: ReadonlyMap<string, TaskNode>,
@@ -170,7 +173,8 @@ export interface VxPlugin {
170
173
  * hold no local resources: never asked, never in `ctx.running`. When
171
174
  * several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
172
175
  * packs memory or CPU learns or declares the numbers itself
173
- * (`@vzn/vx-history` packs what past executions used).
176
+ * (`@vzn/vx-history` packs what past executions used). The task and
177
+ * `ctx.running` are read-only (a write throws, and admits from then on).
174
178
  */
175
179
  admit?(task: TaskNode, ctx: AdmitContext): boolean
176
180
 
@@ -234,9 +238,10 @@ export interface VxPlugin {
234
238
  * renderer in place. The first plugin, in declaration order, that
235
239
  * returns one renders the run; the rest are not asked. Not consulted
236
240
  * when an embedder passed its own `log`. A logger that throws or rejects
237
- * hands the rest of the run back to core's renderer with one warning; a
238
- * hook that does not answer within the teardown bound (3 s) is passed
239
- * over with one.
241
+ * hands the rest of the run back to core's renderer with one warning, and
242
+ * core draws the event it was handed; a hook that does not answer within
243
+ * the teardown bound (3 s) is passed over with one. The nodes and
244
+ * outcomes it is handed are read-only: a write throws.
240
245
  */
241
246
  output?(ctx: OutputContext): Logger | undefined | Promise<Logger | undefined>
242
247
 
@@ -268,7 +273,8 @@ export interface VxPlugin {
268
273
  * task that saved no entry, and a failed task's log and the files its
269
274
  * outputs left when `cache.upload.failures` covers the run. Read
270
275
  * them with `ctx.cache.getRun(name)`. Core keeps no manifest of its own; a
271
- * plugin that wants one writes it from here (`ctx.cache.put`).
276
+ * plugin that wants one writes it from here (`ctx.cache.put`). Frozen:
277
+ * every teardown reads the same report.
272
278
  */
273
279
  export interface RunReport {
274
280
  /** The `run_id` the run's history rows and telemetry carry. */
@@ -317,6 +323,8 @@ export interface PluginSetupContext extends BaseContext, PluginSecrets {
317
323
  /**
318
324
  * Subscribe a lifecycle handler; it leaves with the run. A throw or a
319
325
  * rejection warns once and switches the plugin's handlers off for the run.
326
+ * What a handler is handed is read-only: a write throws, so a handler
327
+ * can never turn a failed task into a pass.
320
328
  */
321
329
  on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
322
330
  }
@@ -397,7 +405,11 @@ export interface ConfigContext extends WorkspaceHookContext {
397
405
  export interface DiscoverContext extends WorkspaceHookContext {
398
406
  /** Where vx's cache lives: a plugin may keep what it read to answer here. */
399
407
  readonly cacheDir: string
400
- /** The projects found so far: the lock's, then earlier plugins'. */
408
+ /**
409
+ * The projects found so far: the lock's, then earlier plugins'. Read-only,
410
+ * entries too (a write throws): a project is added by returning it. The
411
+ * same object `project` hooks get as `ctx.projects`.
412
+ */
401
413
  readonly projects: readonly ProjectMeta[]
402
414
  /**
403
415
  * What `git status` lists in the workspace, workspace-relative: modified,
@@ -436,10 +448,11 @@ export interface ProjectHookContext extends BaseContext {
436
448
  * not — as the `discover` stage left them. A plugin whose mapping needs
437
449
  * the whole workspace (a `dependsOn` is only valid against every
438
450
  * package's scripts at once) reads it here instead of walking the workspace a
439
- * second time. The same array is handed to every visit of a run and a
451
+ * second time. The same array (`discover`'s) is handed to every visit of a run and a
440
452
  * new one to the next run — a plugin instance outlives a run under `vx
441
453
  * watch`, so a per-run memo keys on this array's identity, never on the
442
- * process.
454
+ * process. Frozen, entries too: a hook edits its own project's config,
455
+ * never which projects there are or where they live.
443
456
  */
444
457
  readonly projects: readonly ProjectMeta[]
445
458
  /**
@@ -656,7 +669,8 @@ export interface ExecutorContext extends BaseContext {
656
669
  export interface PluginContext extends PluginSetupContext {
657
670
  /**
658
671
  * The run event bus. A plugin can subscribe directly if its needs exceed
659
- * the hooks; the subscription ends with the run, as a hook's does.
672
+ * the hooks; the subscription ends with the run, as a hook's does. The
673
+ * events a subscriber gets are read-only, as a hook's are.
660
674
  */
661
675
  readonly bus: EventBus
662
676
  }
@@ -670,16 +684,16 @@ export type PluginHookName =
670
684
  | 'onRunStatus'
671
685
  | 'onRunEnd'
672
686
 
673
- // A Record so the compiler holds it to `PluginHookName` both ways: a name
674
- // missing here, or one the type lacks, does not compile.
675
- const HOOK_NAMES: Record<PluginHookName, true> = {
676
- onRunStart: true,
677
- onTaskStart: true,
678
- onTaskStdout: true,
679
- onTaskStderr: true,
680
- onTaskComplete: true,
681
- onRunStatus: true,
682
- onRunEnd: true,
687
+ // Each hook's event. A Record so the compiler holds it to `PluginHookName`
688
+ // both ways: a name missing here, or one the type lacks, does not compile.
689
+ const HOOK_EVENTS: Record<PluginHookName, RunEvent['kind']> = {
690
+ onRunStart: 'run:start',
691
+ onTaskStart: 'task:start',
692
+ onTaskStdout: 'task:stdout',
693
+ onTaskStderr: 'task:stderr',
694
+ onTaskComplete: 'task:complete',
695
+ onRunStatus: 'run:status',
696
+ onRunEnd: 'run:end',
683
697
  }
684
698
 
685
699
  export interface PluginHookHandlers {
@@ -898,6 +912,8 @@ export interface InstallPluginsArgs {
898
912
  secrets?: SecretBroker
899
913
  /** Each setup as the step `<plugin>:setup`; default: untimed. */
900
914
  step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
915
+ /** The run's held signals: a stop ends a setup's wait, which then throws. */
916
+ signal?: AbortSignal | undefined
901
917
  }
902
918
 
903
919
  /**
@@ -932,6 +948,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
932
948
  const disposers: Array<() => void> = []
933
949
  // By object, not name: one package can export two plugins.
934
950
  const disabled = new Set<Plugin>()
951
+ // Made at the first event a plugin hears: a run no handler hears pays nothing.
952
+ let readOnly: ((event: RunEvent) => RunEvent) | undefined
953
+ const viewOf = (event: RunEvent): RunEvent => (readOnly ??= readOnlyEvents())(event)
935
954
 
936
955
  for (const plugin of plugins) {
937
956
  if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
@@ -976,7 +995,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
976
995
  if (disabled.has(plugin)) return
977
996
  let ret: unknown
978
997
  try {
979
- ret = subscriber(event)
998
+ ret = subscriber(viewOf(event))
980
999
  } catch (err) {
981
1000
  fail('a bus subscriber', err)
982
1001
  return
@@ -990,9 +1009,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
990
1009
  on(hook, handler) {
991
1010
  // A misspelt name ('taskComplete') subscribed a handler that never
992
1011
  // ran, and no word of it; a config file has no type-checker (H-16).
993
- if (!Object.hasOwn(HOOK_NAMES, hook)) {
1012
+ if (!Object.hasOwn(HOOK_EVENTS, hook)) {
994
1013
  throw new Error(
995
- `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
1014
+ `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_EVENTS).join(', ')})`,
996
1015
  )
997
1016
  }
998
1017
  // Taken, it threw at the first event and switched the whole plugin off.
@@ -1002,8 +1021,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
1002
1021
  )
1003
1022
  }
1004
1023
  const onFail = (err: unknown): void => fail(hook, err)
1005
- const dispose = bus.subscribe((event) => {
1024
+ const dispose = bus.subscribe((live) => {
1006
1025
  if (disabled.has(plugin)) return
1026
+ const event = HOOK_EVENTS[hook] === live.kind ? viewOf(live) : live
1007
1027
  // Not awaited: the bus is synchronous and plugin work runs off the
1008
1028
  // critical path. A handler's rejection is caught like its throw.
1009
1029
  let ret: unknown
@@ -1048,12 +1068,18 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
1048
1068
  }
1049
1069
 
1050
1070
  try {
1051
- if (args.step === undefined) await plugin.setup(ctx)
1052
- else await args.step(`${plugin.name}:setup`, async () => plugin.setup?.(ctx))
1071
+ const setup = async (): Promise<void> => plugin.setup?.(ctx)
1072
+ await unlessStopped(
1073
+ args.step === undefined ? setup() : args.step(`${plugin.name}:setup`, setup),
1074
+ args.signal,
1075
+ )
1053
1076
  } catch (err) {
1054
1077
  // Earlier plugins' subscriptions too: the caller never gets the
1055
1078
  // disposer, and a bus that outlives the run would keep them.
1056
1079
  for (const d of disposers) d()
1080
+ // A stop is no refusal of this plugin's: its setup may hold what
1081
+ // its teardown releases, so it is torn down with every other.
1082
+ if (args.signal?.aborted === true) throw err
1057
1083
  throw new PluginSetupError(
1058
1084
  plugin,
1059
1085
  // Masked at the source, as plugin-host.ts's `safe()` does (L-11).
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { existsSync, realpathSync } from 'node:fs'
11
11
  import path from 'node:path'
12
- import type { WorkspaceConfig } from '../config.js'
12
+ import type { InputStability, WorkspaceConfig } from '../config.js'
13
13
  import {
14
14
  beginRun,
15
15
  mark,
@@ -47,6 +47,7 @@ import {
47
47
  import {
48
48
  buildPackageGraph,
49
49
  computeNestedProjectDirs,
50
+ findProjectConfigs,
50
51
  findWorkspaceRoot,
51
52
  configuredBaseBranch,
52
53
  type LoadReads,
@@ -83,24 +84,26 @@ import {
83
84
  discoverProjects,
84
85
  gitOfDiscovery,
85
86
  graphOfDiscovery,
87
+ ignoredConfigRefusal,
86
88
  loadProjects,
87
89
  loadWorkspacePlugins,
88
90
  lockOfDiscovery,
89
91
  lockReader,
90
92
  type LoadedProjects,
93
+ nestedWorkspacesOf,
91
94
  } from './projects.js'
92
95
  import { keptByAffected } from './affected-tasks.js'
93
96
  import { applyGraphStage } from './graph-stage.js'
94
97
  import { keyExcludedDependencies } from './excluded-keys.js'
95
98
  import { FingerprintWatch } from './fingerprint-watch.js'
96
99
  import type { VxPlugin } from './plugin.js'
97
- import { memoGraph, type GraphMemo } from './graph-memo.js'
100
+ import { graphCounts, memoGraph, type GraphMemo } from './graph-memo.js'
98
101
  import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
99
102
  import { cacheRefs, currentBranch, resolveCacheScope } from './run-context.js'
100
103
  import { createHashCache, type HashCache } from './task-hash.js'
101
104
  import { type Logger, warnOf } from '../report/index.js'
102
105
  import type { RunOptions } from './options.js'
103
- import { resolveSetting, runRules } from '../settings.js'
106
+ import { resolveSetting, runRules, settingEnv } from '../settings.js'
104
107
 
105
108
  export interface PreparedRun {
106
109
  workspaceRoot: string
@@ -207,6 +210,12 @@ export interface PreparedRun {
207
210
  * `computeTaskHash` call so the same config object isn't re-hashed.
208
211
  */
209
212
  hashCache: HashCache
213
+ /**
214
+ * Each task's count of the tasks downstream of it (`graphCounts`), when
215
+ * nothing after the graph build can edit `nodes`: counted while git's
216
+ * blob-size check runs, so `run` reads it instead of counting.
217
+ */
218
+ dependents?: ReadonlyMap<string, number>
210
219
  /**
211
220
  * Reason `nodes` is empty. `null` when the prepared run is ready to
212
221
  * execute. Either:
@@ -237,6 +246,26 @@ export interface PreparedRun {
237
246
  * empty plan).
238
247
  */
239
248
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
249
+ return prepareRunLocked(options, log)
250
+ }
251
+
252
+ /**
253
+ * `prepareRun`, with `beforeOpen` run once the workspace root is known and
254
+ * before the cache opens: a run takes its lock there (run.ts), so it never
255
+ * holds the store while it waits for a `vx cache clean` that holds the lock,
256
+ * and holds a cache directory with no store (`sharedCacheDir`) shared.
257
+ * `countDependents`: a run's, which schedules by them (`PreparedRun.dependents`);
258
+ * a plan never reads them. `planning`: a plan's (`--dry`). With no remote
259
+ * to probe it opens the cache to read only: no directory, index,
260
+ * `.gitignore` or row is made.
261
+ */
262
+ export async function prepareRunLocked(
263
+ options: RunOptions,
264
+ log: Logger,
265
+ beforeOpen?: (workspaceRoot: string, sharedCacheDir: string | undefined) => Promise<void>,
266
+ countDependents = false,
267
+ planning = false,
268
+ ): Promise<PreparedRun> {
240
269
  beginRun()
241
270
  mark('startup')
242
271
  // A claimed root file is read once. A watch cycle is a new run and reads
@@ -301,8 +330,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
301
330
  ? path.resolve(options.cwd, options.cacheDir)
302
331
  : resolveCacheDir(workspaceRoot, workspaceConfig)
303
332
  // Before any racy window is judged: a file system behind this process's
304
- // clock dated a mid-command edit before the command (fs-clock.ts).
305
- measureFsClockLag(cacheDir)
333
+ // clock dated a mid-command edit before the command (fs-clock.ts). A plan
334
+ // keys as the run does but may make no cache dir: it measures in `.vx`,
335
+ // on the workspace's file system, which the workspace file makes exist.
336
+ measureFsClockLag(planning ? path.join(workspaceRoot, '.vx') : cacheDir)
306
337
  // Asked now, read when the cache opens: a repository with no remote is
307
338
  // named by its root commit, a git spawn the discovery below overlaps.
308
339
  const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
@@ -363,20 +394,51 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
363
394
  options.remoteReadOnlyByDefault === true &&
364
395
  !(options.cache === undefined && namesRemote(workspaceConfig?.cache?.policy))
365
396
  const wanted: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
366
- const localCache = new Cache(
367
- cacheDir,
368
- { read: wanted.localRead, write: wanted.localWrite },
369
- workspaceRoot,
370
- options.artifactCeiling,
371
- 'open',
372
- await storeRoot,
373
- {
374
- embeddings: (resolveSetting('cache.store.embeddings', workspaceConfig).value ??
375
- DEFAULT_STORE.embeddings) as number,
376
- decompress: (resolveSetting('cache.store.decompress', workspaceConfig).value ??
377
- DEFAULT_STORE.decompress) as boolean,
378
- },
379
- )
397
+ const resolvedStore = await storeRoot
398
+ // A cache directory with no store may be another workspace's too: the
399
+ // run holds it shared (`holdCacheDir`).
400
+ await beforeOpen?.(workspaceRoot, resolvedStore === null ? cacheDir : undefined)
401
+ // A plan with no remote to probe writes nothing: it reads the index as the
402
+ // run would, one of another schema as the empty one the run's reset
403
+ // leaves (`'preview'`), and upserts no file-hash memo. One with a remote
404
+ // keeps the hits its probe finds, for the `vx run` that follows (an
405
+ // index made for them if need be): that run GETs them before its own
406
+ // probe answers (2026-10-23).
407
+ const planReadOnly =
408
+ planning &&
409
+ (!wanted.remoteRead ||
410
+ (options.remoteCache === undefined && !plugins.some((p) => p.cache !== undefined)))
411
+ const openCache = (): Cache =>
412
+ new Cache(
413
+ cacheDir,
414
+ { read: wanted.localRead, write: wanted.localWrite && !planReadOnly },
415
+ workspaceRoot,
416
+ options.artifactCeiling,
417
+ planReadOnly ? 'preview' : 'open',
418
+ resolvedStore,
419
+ {
420
+ embeddings: (resolveSetting('cache.store.embeddings', workspaceConfig).value ??
421
+ DEFAULT_STORE.embeddings) as number,
422
+ decompress: (resolveSetting('cache.store.decompress', workspaceConfig).value ??
423
+ DEFAULT_STORE.decompress) as boolean,
424
+ },
425
+ )
426
+ // A `vx cache clean` in another checkout holds the shared store alone
427
+ // until it has deleted it (`attachStore`), as this run's hold keeps a
428
+ // clean waiting: the run waits its turn, then opens the store there now.
429
+ let localCache: Cache
430
+ for (let said = false; ; await Bun.sleep(50)) {
431
+ try {
432
+ localCache = openCache()
433
+ break
434
+ } catch (err) {
435
+ if (resolvedStore == null || (err as { code?: unknown }).code !== 'SQLITE_BUSY') throw err
436
+ }
437
+ if (!said) {
438
+ said = true
439
+ warnOf(log)(`[vx] waiting for a vx cache clean of ${resolvedStore} to finish…`)
440
+ }
441
+ }
380
442
  const unwritable = localCache.readOnlyNotice()
381
443
  if (unwritable !== null) warnOf(log)(`[vx] ${unwritable}`)
382
444
  const policy: CachePolicy = unwritable === null ? wanted : { ...wanted, localWrite: false }
@@ -456,6 +518,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
456
518
  // a parent's glob that folded (or cleaned) them crossed a boundary the
457
519
  // selection drew (X-57).
458
520
  const nestedDirsByProject = computeNestedProjectDirs(projectMetas)
521
+ const nestedWorkspaces = nestedWorkspacesOf(projectMetas)
522
+ if (nestedWorkspaces.length > 0)
523
+ outputWalls.set(nestedDirsByProject, computeNestedProjectDirs(projectMetas, nestedWorkspaces))
459
524
 
460
525
  const candidateProjects = options.projects
461
526
  ? options.projects.filter((p) => projects.has(p))
@@ -471,7 +536,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
471
536
  // nothing, exit 0.
472
537
  if (options.selectedByDiff === true && candidateProjects.length === 0)
473
538
  unresolvedTasks = [...new Set([...tasks.filter((t) => !t.includes('#')), ...unresolvedTasks])]
474
- refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
539
+ await refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
475
540
  // A scope that loaded no candidate judged no bare name: each is unresolved.
476
541
  if (options.projects !== undefined && options.selectedByDiff !== true)
477
542
  refuseUnlockedScope(
@@ -514,11 +579,15 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
514
579
  // short name for `@scope/pkg`) was measured against nothing and got no
515
580
  // "did you mean". Only a failing run pays for the rest of the workspace.
516
581
  let hintProjects: ReadonlyMap<string, ProjectEntry> = projects
582
+ // So does a bare name where no project in scope declares a task: "no
583
+ // project declares a task yet" must be true of the workspace, not of the
584
+ // root project a run from the root selects.
517
585
  if (
518
586
  unresolvedTasks.some((t) => {
519
587
  const at = t.indexOf('#')
520
588
  return at > 0 && !projects.has(t.slice(0, at))
521
- })
589
+ }) ||
590
+ (unresolvedTasks.some((t) => !t.includes('#')) && !declaresAny(projects))
522
591
  ) {
523
592
  try {
524
593
  hintProjects = (
@@ -587,7 +656,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
587
656
  // cycle under `vx watch` (item 1029). run() owns both only once this
588
657
  // returns.
589
658
  try {
590
- const hashCache = createHashCache(localCache.foldMemo())
659
+ const hashCache = createHashCache(
660
+ localCache.foldMemo(),
661
+ resolveSetting('outputs.clean', workspaceConfig).value as boolean | undefined,
662
+ {
663
+ run: settingEnv('inputs.stability.strategy') as InputStability['strategy'],
664
+ workspace: workspaceConfig?.inputs?.stability?.strategy,
665
+ },
666
+ )
591
667
  const fingerprintWatch = new FingerprintWatch(
592
668
  workspaceRoot,
593
669
  claimed,
@@ -699,11 +775,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
699
775
  options.affected,
700
776
  projects,
701
777
  packageGraph,
778
+ workspaceRoot,
702
779
  {
703
780
  named: new Set(tasks.filter((t) => t.includes('#'))),
704
781
  outright: new Set(options.selectedOutright),
705
782
  },
706
- options.affectedReasons && { workspaceRoot, reasons: options.affectedReasons },
783
+ options.affectedReasons && { reasons: options.affectedReasons },
707
784
  )
708
785
  if (kept.length === 0) return emptyRun('none-affected')
709
786
  if (before !== null) {
@@ -790,7 +867,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
790
867
  const settleIgnored = askIgnoredOutputs(gatedOutputs(nodes, workspaceRoot), workspaceRoot)
791
868
  const enumeration = await enumerating
792
869
  localCache.holdBlobSizes()
793
- await applyGitEnumeration(
870
+ const applied = applyGitEnumeration(
794
871
  enumeration,
795
872
  workspaceRoot,
796
873
  projectDirs,
@@ -798,6 +875,19 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
798
875
  usesWorkspaceInputs,
799
876
  localCache,
800
877
  )
878
+ // The dependents count while the blob-size check's `cat-file` runs: the
879
+ // thread waited ~80 ms on it in a cold 8,002-task run, and counting
880
+ // after it, before the first task, took 40 ms (2026-11-07). Only when
881
+ // nothing below edits the nodes: a graph or key hook, or
882
+ // `--exclude-dependencies`.
883
+ const excluding =
884
+ options.excludeDependencies !== undefined &&
885
+ (options.excludeDependencies === 'all' || options.excludeDependencies.length > 0)
886
+ const dependents =
887
+ !countDependents || (hasGraphHook && !hooksFirst) || hasHook(plugins, 'key') || excluding
888
+ ? undefined
889
+ : graphCounts(nodes, hashCache.folds, hashCache.graph)
890
+ await applied
801
891
  await settleIgnored(gitFilesCache)
802
892
  mark('git enumeration')
803
893
  if (hasGraphHook && !hooksFirst) await graphStage()
@@ -909,6 +999,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
909
999
  nestedDirsByProject,
910
1000
  gitFilesCache,
911
1001
  hashCache,
1002
+ ...(dependents !== undefined ? { dependents } : {}),
912
1003
  workspaceProjectCount: projectMetas.length,
913
1004
  empty: nodes.size === 0 ? 'empty-graph' : null,
914
1005
  }
@@ -938,6 +1029,13 @@ async function declaredNowhere(
938
1029
  return undeclaredIn(unresolved, all.projects)
939
1030
  }
940
1031
 
1032
+ /** Whether any project in `projects` declares a task: its default `build` is none. */
1033
+ function declaresAny(projects: LoadedProjects['projects']): boolean {
1034
+ for (const p of projects.values())
1035
+ for (const task of Object.values(p.config.tasks ?? {})) if (!isDefaultBuild(task)) return true
1036
+ return false
1037
+ }
1038
+
941
1039
  /**
942
1040
  * The bare names in `unresolved` no project in `projects` declares; `a#b`
943
1041
  * forms pass through. Every project has a `build`, its default one if
@@ -1008,6 +1106,23 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
1008
1106
  export const namesRemote = (spec: string | undefined): boolean =>
1009
1107
  spec !== undefined && /remote:[rw]/.test(spec)
1010
1108
 
1109
+ /** Each run's output walls, by its `nestedDirsByProject`, when it has a nested workspace. */
1110
+ const outputWalls = new WeakMap<ReadonlyMap<string, string[]>, ReadonlyMap<string, string[]>>()
1111
+
1112
+ /**
1113
+ * Where each project's outputs stop: its nested projects and every nested
1114
+ * workspace under it. Its inputs stop at the projects alone: a task may
1115
+ * read a nested workspace's files (a git worktree checked out under
1116
+ * `src/` holds one), so folding them costs a miss, and leaving them out
1117
+ * a stale hit; cleaning them deleted another workspace's work. The map
1118
+ * itself when there is no nested workspace.
1119
+ */
1120
+ export function outputWallsOf(
1121
+ nestedDirsByProject: ReadonlyMap<string, string[]>,
1122
+ ): ReadonlyMap<string, string[]> {
1123
+ return outputWalls.get(nestedDirsByProject) ?? nestedDirsByProject
1124
+ }
1125
+
1011
1126
  /**
1012
1127
  * The workspace's `cache.policy`, parsed: the schema checks only its shape,
1013
1128
  * since the grammar lives in the cache module the workspace module may not
@@ -1022,7 +1137,7 @@ export function workspaceCachePolicy(
1022
1137
  return parseCachePolicy(spec)
1023
1138
  } catch (err) {
1024
1139
  throw new UserError(
1025
- `.vx/workspace: \`cache.policy\`: ${err instanceof Error ? err.message : String(err)}`,
1140
+ `.vx/workspace: \`cache.policy\` ${err instanceof Error ? err.message : String(err)}`,
1026
1141
  'VX_E_CONFIG',
1027
1142
  )
1028
1143
  }
@@ -1031,13 +1146,17 @@ export function workspaceCachePolicy(
1031
1146
  /** A config no locked project has, looked for only when the lock holds none. */
1032
1147
  function unlockedConfigOf(
1033
1148
  configured: readonly unknown[],
1034
- metas: ReadonlyArray<{ dir: string }>,
1149
+ metas: ReadonlyArray<{ name: string; dir: string }>,
1035
1150
  workspaceRoot: string,
1036
1151
  ): string | undefined {
1037
1152
  if (configured.length > 0) return undefined
1038
1153
  for (const m of metas) {
1039
1154
  const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(m.dir, n)))
1040
- if (config !== undefined) return relPosix(workspaceRoot, path.join(m.dir, config))
1155
+ if (config === undefined) continue
1156
+ const file = path.join(m.dir, config)
1157
+ // `vx lock` never reads it: the hint would send the user to it for nothing.
1158
+ if (ignoredConfigRefusal(workspaceRoot, m, file) === undefined)
1159
+ return relPosix(workspaceRoot, file)
1041
1160
  }
1042
1161
  return undefined
1043
1162
  }
@@ -1048,12 +1167,12 @@ function unlockedConfigOf(
1048
1167
  * one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
1049
1168
  * renamed task gets "run `vx lock`" (Growth, lock drift).
1050
1169
  */
1051
- function refuseUnlockedConfig(
1170
+ async function refuseUnlockedConfig(
1052
1171
  tasks: readonly string[],
1053
1172
  projects: ReadonlyMap<string, ProjectEntry>,
1054
1173
  metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
1055
1174
  workspaceRoot: string,
1056
- ): void {
1175
+ ): Promise<void> {
1057
1176
  for (const spec of tasks) {
1058
1177
  const at = spec.indexOf('#')
1059
1178
  if (at < 0) continue
@@ -1061,12 +1180,13 @@ function refuseUnlockedConfig(
1061
1180
  const entry = projects.get(name)
1062
1181
  // No entry: a package the lock holds no project for, whose config came after it.
1063
1182
  if (entry !== undefined && !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
1064
- const config = unlockedConfig(name, metas)
1183
+ const config =
1184
+ unlockedConfig(name, metas) ??
1185
+ (metas.some((m) => m.name === name)
1186
+ ? undefined
1187
+ : await unlockedDir(name, metas, workspaceRoot))
1065
1188
  if (config === undefined) continue
1066
- throw new UserError(
1067
- `${spec}: ${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``,
1068
- 'VX_E_LOCK',
1069
- )
1189
+ throw new UserError(`${spec}: ${notLockedYet(workspaceRoot, config, metas, name)}`, 'VX_E_LOCK')
1070
1190
  }
1071
1191
  }
1072
1192
 
@@ -1089,13 +1209,28 @@ function refuseUnlockedScope(
1089
1209
  for (const name of selected) {
1090
1210
  const config = unlockedConfig(name, metas)
1091
1211
  if (config === undefined) continue
1092
- throw new UserError(
1093
- `${spec}: ${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``,
1094
- 'VX_E_LOCK',
1095
- )
1212
+ throw new UserError(`${spec}: ${notLockedYet(workspaceRoot, config, metas, name)}`, 'VX_E_LOCK')
1096
1213
  }
1097
1214
  }
1098
1215
 
1216
+ /**
1217
+ * Why the lock holds no `config`: git ignores it, in a directory a
1218
+ * `discover` plugin made a project, which `vx lock` cannot fix; else it
1219
+ * came after the lock.
1220
+ */
1221
+ function notLockedYet(
1222
+ workspaceRoot: string,
1223
+ config: string,
1224
+ metas: ReadonlyArray<{ name: string; dir: string }>,
1225
+ name: string,
1226
+ ): string {
1227
+ const meta = metas.find((m) => m.name === name)
1228
+ return (
1229
+ (meta === undefined ? undefined : ignoredConfigRefusal(workspaceRoot, meta, config)) ??
1230
+ `${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``
1231
+ )
1232
+ }
1233
+
1099
1234
  /** The config file in a project's directory the lock holds none for. */
1100
1235
  function unlockedConfig(
1101
1236
  name: string,
@@ -1108,6 +1243,27 @@ function unlockedConfig(
1108
1243
  return config === undefined ? undefined : path.join(meta.dir, config)
1109
1244
  }
1110
1245
 
1246
+ /**
1247
+ * The config `vx lock` would find in the directory a name no project has
1248
+ * reads as (`packages/d`, `c`): with no discover plugin a config written
1249
+ * after the lock makes no project, so `unlockedConfig` never sees it.
1250
+ * Asked only for a name that matches nothing, a run or `vx show` about to
1251
+ * be refused; the listing is paid only when the directory holds a config.
1252
+ */
1253
+ export async function unlockedDir(
1254
+ name: string,
1255
+ metas: ReadonlyArray<{ dir: string }>,
1256
+ workspaceRoot: string,
1257
+ ): Promise<string | undefined> {
1258
+ const dir = path.resolve(workspaceRoot, name)
1259
+ // A project's directory typed for its name is that project, hinted as such.
1260
+ if (!dir.startsWith(workspaceRoot + path.sep) || metas.some((m) => m.dir === dir))
1261
+ return undefined
1262
+ if (!PROJECT_CONFIG_FILENAMES.some((n) => existsSync(path.join(dir, n)))) return undefined
1263
+ const { configs } = await findProjectConfigs(workspaceRoot)
1264
+ return configs.find((c) => c.dir === dir)?.configPath
1265
+ }
1266
+
1111
1267
  /**
1112
1268
  * Each remote's `open`, in declared order, for the ones the policy lets read
1113
1269
  * or write. One at a time: two that ask on the terminal must not interleave.