@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -12,6 +12,8 @@ export interface LiveForecast {
12
12
  critical?: string
13
13
  /** Each running task's unfinished dependents, direct and transitive. */
14
14
  blocks: ReadonlyMap<string, number>
15
+ /** Each running task's p50, where history knows it: the row's expected duration. */
16
+ expected?: ReadonlyMap<string, number>
15
17
  }
16
18
 
17
19
  export type Forecast = (
@@ -90,7 +92,12 @@ export function createForecast(
90
92
 
91
93
  return (finished, running, nowMs) => {
92
94
  const blocks = new Map<string, number>()
93
- for (const id of running.keys()) blocks.set(id, blocked(id, finished))
95
+ const expected = new Map<string, number>()
96
+ for (const id of running.keys()) {
97
+ blocks.set(id, blocked(id, finished))
98
+ const p = cost.get(id)
99
+ if (p !== undefined) expected.set(id, p)
100
+ }
94
101
  const end = new Map<string, number>()
95
102
  // The running task each node's longest chain waits on.
96
103
  const head = new Map<string, string>()
@@ -128,12 +135,13 @@ export function createForecast(
128
135
  }
129
136
  work += own
130
137
  }
131
- if (!known || overrun) return { blocks }
138
+ if (!known || overrun) return { blocks, expected }
132
139
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
133
140
  return {
134
141
  etaMs: Math.max(chain, work / workers),
135
142
  ...(critical !== undefined ? { critical } : {}),
136
143
  blocks,
144
+ expected,
137
145
  }
138
146
  }
139
147
  }
@@ -532,7 +532,8 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
532
532
  // Red, not amber: a violation fails the task, so it reads as a failure.
533
533
  // vx's note, after the evidence: the grants that would have allowed it.
534
534
  // A read the output depends on is a key input too; the sandbox never
535
- // adds one (owner, 2026-10-09).
535
+ // adds one (owner, 2026-10-09), so the note names the reads no input
536
+ // glob matches and the user decides.
536
537
  const fix = outcome.sandboxFix
537
538
  const line = fix === undefined ? undefined : sandboxFixLine(fix)
538
539
  const dim = (text: string): string => paint('', text, colors, { dim: true })
@@ -541,8 +542,12 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
541
542
  ? []
542
543
  : [
543
544
  ...(line !== undefined ? [dim(`fix: ${line}`)] : []),
544
- ...(fix.read !== undefined
545
- ? [dim('a read the output depends on belongs in cache.inputs.files too')]
545
+ ...(fix.inputs !== undefined
546
+ ? [
547
+ dim(
548
+ `and, if the output depends on them, cache.inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
549
+ ),
550
+ ]
546
551
  : []),
547
552
  ...(fix.withheld !== undefined
548
553
  ? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
@@ -2,8 +2,7 @@
2
2
  // CLI's `--affected` task selection (`vx show <task> --affected`), so both
3
3
  // select over the graph the hooks leave.
4
4
 
5
- import type { WorkspaceRules } from '../config.js'
6
- import type { TaskNode } from '../graph/index.js'
5
+ import type { GraphRules, TaskNode } from '../graph/index.js'
7
6
  import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
8
7
  import { applyGraphHooks } from './plugin-host.js'
9
8
  import type { VxPlugin } from './plugin.js'
@@ -17,7 +16,7 @@ export async function applyGraphStage(
17
16
  cacheDir: string
18
17
  projectMetas: readonly ProjectMeta[]
19
18
  warn: (message: string) => void
20
- rules?: WorkspaceRules | undefined
19
+ rules?: GraphRules | undefined
21
20
  },
22
21
  ): Promise<void> {
23
22
  // A hook edits each node's config in place, as `project` does, so the
@@ -36,10 +35,11 @@ export async function applyGraphStage(
36
35
  (plugin) => {
37
36
  for (const n of nodes.values()) {
38
37
  if (isDefaultBuild(n.config)) continue
39
- const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
38
+ const own = configPaths.get(n.projectName) ?? null
40
39
  validateProjectConfig(
41
40
  { tasks: { [n.taskName]: n.config } },
42
- `${where} (after plugin '${plugin.name}')`,
41
+ `${own ?? `${n.projectName} (no config file)`} (after plugin '${plugin.name}')`,
42
+ own,
43
43
  )
44
44
  }
45
45
  },
@@ -12,13 +12,17 @@
12
12
  // (zero-config); the interface is the seam a plugin fills for any other.
13
13
 
14
14
  import type { Database } from 'bun:sqlite'
15
- import { EXECUTED_RUNS_SQL } from '../cache/index.js'
15
+ import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
16
+ import { splitTaskId } from '../util/index.js'
16
17
  import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
17
18
  import type { FailureMode } from './failure-mode.js'
18
19
  import { isPassStatus, TASK_STATUSES } from './telemetry.js'
19
20
 
20
21
  const DEFAULT_RECENT = 50
21
22
 
23
+ /** A task's forecast is the median of this many of its newest timings. */
24
+ const FORECAST_RUNS = 5
25
+
22
26
  // A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
23
27
  // success; the list is derived so a new status cannot miss it.
24
28
  /** The pass set as SQL, `(…)`: one definition, `isPassStatus`. */
@@ -86,7 +90,9 @@ export class EmptyHistoryProvider implements HistoryProvider {
86
90
  export class LocalHistoryProvider implements HistoryProvider {
87
91
  constructor(
88
92
  private readonly db: Database,
89
- private readonly recent: number = DEFAULT_RECENT,
93
+ readonly recent: number = DEFAULT_RECENT,
94
+ /** The run's branch (`currentBranch`); undefined: a forecast reads every branch's timings. */
95
+ private readonly branch?: string | null,
90
96
  ) {}
91
97
 
92
98
  /**
@@ -107,8 +113,17 @@ export class LocalHistoryProvider implements HistoryProvider {
107
113
  * read by `run_id` and stay exact.
108
114
  */
109
115
  async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
116
+ if (taskIds.length === 0) return new Map()
117
+ return this.load(new Set(taskIds))
118
+ }
119
+
120
+ /** Every task the window holds, as `loadFor` answers each (`vx last --stats`). */
121
+ async loadAll(): Promise<HistoryTable> {
122
+ return this.load(null)
123
+ }
124
+
125
+ private load(wanted: ReadonlySet<string> | null): HistoryTable {
110
126
  const out = new Map<string, TaskHistory>()
111
- if (taskIds.length === 0) return out
112
127
  const floor = this.floor()
113
128
 
114
129
  // `skipped` rows are excluded: a skip is a task the run never executed,
@@ -160,15 +175,15 @@ export class LocalHistoryProvider implements HistoryProvider {
160
175
  // Every pair in the slice is grouped (the slice is bounded, so this is
161
176
  // cheap) and the asked-for ones are picked out here — a per-row IN-list
162
177
  // probe in SQL cost more than the grouping it would have saved.
163
- const wanted = new Set(taskIds)
164
- const rows = (this.db.query(sql).all(floor) as Row[]).filter((r) =>
165
- wanted.has(`${r.project}#${r.task}`),
178
+ const rows = (this.db.query(sql).all(floor) as Row[]).filter(
179
+ (r) => wanted === null || wanted.has(`${r.project}#${r.task}`),
166
180
  )
167
181
 
168
182
  // The flakiness signal needs per-key outcomes, which the aggregate above
169
183
  // folded away — one more pass over the slice, only for the pairs whose
170
184
  // verdict actually depends on it (failed, and no retry already proving
171
185
  // nondeterminism); a green history never pays for it.
186
+ const p50s = this.forecasts(new Set(rows.map((r) => `${r.project}#${r.task}`)))
172
187
  const mixed = this.mixedOutcomeKeys(
173
188
  floor,
174
189
  rows.filter((r) => r.failures > 0 && r.retried === 0),
@@ -182,7 +197,7 @@ export class LocalHistoryProvider implements HistoryProvider {
182
197
  if (durations !== undefined) durations.sort((a, b) => a - b)
183
198
  out.set(key, {
184
199
  runs: total,
185
- p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
200
+ p50DurationMs: p50s.get(key),
186
201
  p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
187
202
  successRate: row.successes / total,
188
203
  hitRate: row.hits / total,
@@ -195,27 +210,52 @@ export class LocalHistoryProvider implements HistoryProvider {
195
210
  }
196
211
 
197
212
  /**
198
- * The same window's p50s alone. `loadFor`'s rates and resource maxima
199
- * read every row of the window and join each hit to its entry: 38 ms at
200
- * 27,000 rows against 4 for the executed successes' durations alone.
213
+ * The forecasts alone. `loadFor`'s rates and resource maxima read every
214
+ * row of the window and join each hit to its entry: 38 ms at 27,000 rows
215
+ * against 4 for the executed successes' durations alone.
201
216
  */
202
217
  async p50sFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
218
+ if (taskIds.length === 0) return new Map()
219
+ return this.forecasts(new Set(taskIds))
220
+ }
221
+
222
+ /**
223
+ * Each task's duration forecast (owner, 2026-10-14): the median of its
224
+ * last `FORECAST_RUNS` timings on this branch, else on any branch, else
225
+ * none. The timings are the shared store's `task_durations`, so every
226
+ * worktree of the repo reads them and a new one forecasts on its first
227
+ * run. A point read per task on `(project, task, source, id)`: exact
228
+ * however old the rows, where the runs window holds only the newest
229
+ * invocations.
230
+ */
231
+ private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
203
232
  const out = new Map<string, number>()
204
- if (taskIds.length === 0) return out
205
- const rows = this.db
206
- .query(
207
- `SELECT project, task, GROUP_CONCAT(duration_ms) AS ds FROM runs
208
- WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
209
- GROUP BY project, task`,
233
+ if (wanted.size === 0) return out
234
+ // Tiers, first answer wins. The remote cache's default-branch timings
235
+ // (#3563) slot in between the two.
236
+ type Tier = (project: string, task: string) => Array<{ ms: number }>
237
+ const tiers: Tier[] = []
238
+ const newest = (where: string) =>
239
+ this.db.query(
240
+ `SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND ${where}
241
+ ORDER BY id DESC LIMIT ${FORECAST_RUNS}`,
210
242
  )
211
- .all(this.floor()) as Array<{ project: string; task: string; ds: string }>
212
- const wanted = new Set(taskIds)
213
- for (const row of rows) {
214
- const key = `${row.project}#${row.task}`
215
- if (!wanted.has(key)) continue
216
- const durations = row.ds.split(',').map(Number)
217
- durations.sort((a, b) => a - b)
218
- out.set(key, pickPercentile(durations, 0.5))
243
+ if (this.branch !== undefined) {
244
+ const source = branchSource(this.branch)
245
+ const q = newest('source = ?')
246
+ tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
247
+ }
248
+ const any = newest(`source LIKE 'branch:%'`)
249
+ tiers.push((p, t) => any.all(p, t) as Array<{ ms: number }>)
250
+ for (const id of wanted) {
251
+ const [project, task] = splitTaskId(id)
252
+ for (const tier of tiers) {
253
+ const rows = tier(project, task)
254
+ if (rows.length === 0) continue
255
+ const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
256
+ out.set(id, pickPercentile(durations, 0.5))
257
+ break
258
+ }
219
259
  }
220
260
  return out
221
261
  }
@@ -236,13 +276,12 @@ export class LocalHistoryProvider implements HistoryProvider {
236
276
  type Executed = {
237
277
  project: string
238
278
  task: string
239
- ds: string
240
279
  rss: number | null
241
280
  cpu: number | null
242
281
  }
243
282
  const executed = this.db
244
283
  .query(
245
- `SELECT project, task, GROUP_CONCAT(duration_ms) AS ds, MAX(peak_rss_bytes) AS rss,
284
+ `SELECT project, task, MAX(peak_rss_bytes) AS rss,
246
285
  MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
247
286
  THEN cpu_ms * 1.0 / duration_ms END) AS cpu
248
287
  FROM runs
@@ -250,13 +289,12 @@ export class LocalHistoryProvider implements HistoryProvider {
250
289
  GROUP BY project, task`,
251
290
  )
252
291
  .all(floor) as Executed[]
292
+ const p50s = this.forecasts(wanted)
253
293
  for (const row of executed) {
254
294
  const key = `${row.project}#${row.task}`
255
295
  if (!wanted.has(key)) continue
256
- const durations = row.ds.split(',').map(Number)
257
- durations.sort((a, b) => a - b)
258
296
  out.set(key, {
259
- p50DurationMs: pickPercentile(durations, 0.5),
297
+ p50DurationMs: p50s.get(key),
260
298
  ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
261
299
  ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
262
300
  })
@@ -279,11 +317,13 @@ export class LocalHistoryProvider implements HistoryProvider {
279
317
  const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
280
318
  const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
281
319
  out.set(key, {
282
- p50DurationMs: had?.p50DurationMs,
320
+ p50DurationMs: p50s.get(key),
283
321
  ...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
284
322
  ...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
285
323
  })
286
324
  }
325
+ // A forecast reads past the window: a task with none in it still has one.
326
+ for (const [key, p50] of p50s) if (!out.has(key)) out.set(key, { p50DurationMs: p50 })
287
327
  return out
288
328
  }
289
329
 
@@ -14,6 +14,7 @@ import {
14
14
  cleanOutputPaths,
15
15
  cleanOutputs,
16
16
  cleanWorkspaceOutputs,
17
+ committedFiles,
17
18
  resolveOutputs,
18
19
  resolveWorkspaceOutputs,
19
20
  WORKSPACE_OUTPUT_PREFIX,
@@ -67,8 +68,13 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
67
68
  projectDir: node.projectDir,
68
69
  outputs,
69
70
  nestedProjectDirs: args.nestedProjectDirs,
71
+ committed: committedFiles(args.gitFilesCache, node.projectDir),
72
+ }
73
+ const wsCleanArgs = {
74
+ workspaceRoot: args.workspaceRoot,
75
+ outputs: wsOutputs,
76
+ committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
70
77
  }
71
- const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
72
78
  // The two sides of an overlapping pair (item 588). An ADDITIVE task owns
73
79
  // only its recorded rows: its glob also selects the upstream's files it
74
80
  // adds beside, so "no strays" is not a claim it can make, and its clean
@@ -101,7 +107,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
101
107
  // output replays from the row (`hit.stdout`), and its artifact holds
102
108
  // only logs. Extracting it anyway cost an `exists` + a tar read per hit
103
109
  // — 1.7 ms each on a four-task real repo whose `link` and `element`
104
- // tasks declare `outputs: []` (2026-09-10, VX_TIMING).
110
+ // tasks declare `outputs: []` (2026-09-10, `VX_DEBUG_TIMING`).
105
111
  let skipRestore = !anyOutputs
106
112
  // The walk that found the tree stale, handed to the clean when nothing
107
113
  // was awaited between them: the clean's own walk would only repeat it
@@ -230,7 +236,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
230
236
  let cleanedWsRels: string[] = []
231
237
  if (additive) {
232
238
  cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
233
- await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels, outputs })
239
+ await cleanOutputPaths({
240
+ projectDir: node.projectDir,
241
+ rels: cleanedRels,
242
+ outputs,
243
+ committed: cleanArgs.committed,
244
+ })
234
245
  cleanedWsRels = hit.outputFiles
235
246
  .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
236
247
  .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
@@ -238,6 +249,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
238
249
  projectDir: args.workspaceRoot,
239
250
  rels: cleanedWsRels,
240
251
  outputs: wsOutputs,
252
+ committed: wsCleanArgs.committed,
241
253
  })
242
254
  } else {
243
255
  if (outputs.length > 0) {
@@ -4,6 +4,7 @@
4
4
  export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
5
5
  export { prepareRun, type PreparedRun } from './prepare.js'
6
6
  export { affectedTaskProjects } from './affected-tasks.js'
7
+ export { applyGraphStage } from './graph-stage.js'
7
8
  export type { AffectedReason } from './affected-tasks.js'
8
9
  export {
9
10
  discoverProjects,
@@ -13,6 +14,8 @@ export {
13
14
  loadProjects,
14
15
  loadResolvedProjects,
15
16
  loadWorkspacePlugins,
17
+ lockOfDiscovery,
18
+ lockReader,
16
19
  type LoadedProjects,
17
20
  type LoadProjectsArgs,
18
21
  } from './projects.js'
@@ -21,12 +24,17 @@ export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
21
24
  // Re-surface the cache policy contract (defined in the cache module) so
22
25
  // embedders constructing RunOptions.cache and the package façade can
23
26
  // reach it without importing the cache module directly.
24
- export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
27
+ export {
28
+ type CacheFlags,
29
+ type CachePolicy,
30
+ FULL_CACHE_POLICY,
31
+ parseCachePolicy,
32
+ } from '../cache/index.js'
25
33
  // The CLI reads a recorded exit code; the runner's convention decodes it.
26
34
  // It records its own argv quoted as the runner quotes forwarded args.
27
35
  export { exitSignal, shellQuote } from '../exec/index.js'
28
36
  export { forwardedSignal } from './signals.js'
29
- // `vx cache prune` waits for a run on the workspace before it evicts.
37
+ // `vx cache clean` waits for a run on the workspace before it evicts.
30
38
  export { acquireRunLock } from './run-lock.js'
31
39
  export { defaultLogger, resolveOutputView } from './logger.js'
32
40
  export { detectColors, paint, type ColorSupport } from './colors.js'
@@ -70,6 +78,7 @@ export {
70
78
  installPlugins,
71
79
  type CacheContext,
72
80
  type ExecutorContext,
81
+ type OutputContext,
73
82
  type CommandContext,
74
83
  type GraphHookContext,
75
84
  type KeyHookContext,
@@ -79,7 +88,7 @@ export {
79
88
  type AdmitContext,
80
89
  type WorkspaceHookContext,
81
90
  type DiscoverContext,
82
- type NamedProject,
91
+ type DiscoveredProject,
83
92
  type InstallPluginsArgs,
84
93
  type Plugin,
85
94
  type PluginContext,
@@ -105,8 +114,9 @@ export {
105
114
  claimedAffected,
106
115
  fingerprintClaims,
107
116
  hasHook,
108
- CACHE_LAYER_METHODS,
117
+ REMOTE_CACHE_METHODS,
109
118
  resolveCache,
119
+ type CacheHost,
110
120
  resolveExecutors,
111
121
  } from './plugin-host.js'
112
122
  export {
@@ -114,6 +124,7 @@ export {
114
124
  reachDigests,
115
125
  type LockfileClaimHooks,
116
126
  type LockfileClaimOptions,
127
+ type LockfileImporter,
117
128
  type ReachGraph,
118
129
  } from './lockfile-claim.js'
119
130
  export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
@@ -147,6 +158,7 @@ export {
147
158
  captureGitContext,
148
159
  captureHostContext,
149
160
  captureWorkspaceIdentity,
161
+ currentBranch,
150
162
  detectCi,
151
163
  } from './run-context.js'
152
164
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
@@ -158,6 +170,7 @@ export {
158
170
  latestRunId,
159
171
  listInvocations,
160
172
  listRuns,
173
+ inputWriters,
161
174
  rootCauses,
162
175
  whyDidThisRerun as whyDidThisRerunQuery,
163
176
  } from './metrics.js'
@@ -173,6 +186,7 @@ export { resolveRunId, shortRunId } from './run-id.js'
173
186
  export type {
174
187
  CacheEntryRow,
175
188
  CacheKeyDiff,
189
+ InputWriter,
176
190
  RootCause,
177
191
  CacheKeyExplanation,
178
192
  InputDiffEntry,
@@ -52,7 +52,8 @@ export interface ShortCircuitArgs {
52
52
  nodes: Map<string, TaskNode>
53
53
  cache: CacheLayer
54
54
  workspaceRoot: string
55
- workspaceFingerprint: string
55
+ /** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
56
+ claimedFiles: readonly string[]
56
57
  forwardArgs?: readonly string[] | undefined
57
58
  nestedDirsByProject: Map<string, string[]>
58
59
  gitFilesCache: GitFilesCache
@@ -25,7 +25,7 @@ import type {
25
25
  } from './plugin.js'
26
26
 
27
27
  export interface LockfileClaimOptions {
28
- /** The root-relative lockfile name, one of `WORKSPACE_FINGERPRINT_FILES`. */
28
+ /** The lockfile's name at the workspace root. */
29
29
  readonly file: string
30
30
  /**
31
31
  * Every project's digest from the file's text: importer directory
@@ -46,8 +46,7 @@ export interface LockfileClaimOptions {
46
46
  readonly version: number
47
47
  /**
48
48
  * `'project'` (default): each task folds its own project's digest.
49
- * `'workspace'`: the whole file's hash into every task — the coarse key
50
- * core would fold, through the plugin.
49
+ * `'workspace'`: the whole file's hash into every task — the coarse key.
51
50
  */
52
51
  readonly scope?: 'project' | 'workspace'
53
52
  /**
@@ -55,18 +54,46 @@ export interface LockfileClaimOptions {
55
54
  * (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
56
55
  */
57
56
  readonly part?: string
57
+ /**
58
+ * Each importer's links to other importers, from the same text, kept in
59
+ * the memo beside the digests so a `discover` hook reads them without a
60
+ * parse. Called once per distinct content, under either scope.
61
+ */
62
+ readonly importers?: (text: string) => ReadonlyMap<string, LockfileImporter>
58
63
  }
59
64
 
60
- /** The two hooks a lockfile plugin spreads into `definePlugin`. */
65
+ /**
66
+ * What a lockfile records of one importer's links to the workspace's other
67
+ * importers: dependency name → importer directory (root-relative POSIX,
68
+ * `.` for the root).
69
+ */
70
+ export interface LockfileImporter {
71
+ /** The entries it installs that resolve to an importer. */
72
+ readonly deps: Readonly<Record<string, string>>
73
+ /** Its peers that resolve to an importer, each with no installed entry of the same name. */
74
+ readonly peers: Readonly<Record<string, string>>
75
+ }
76
+
77
+ /** What a lockfile plugin spreads into `definePlugin` (`fingerprint`, `key`), and `importers` for its own stages. */
61
78
  export interface LockfileClaimHooks {
62
79
  readonly fingerprint: FingerprintClaim
63
80
  key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
81
+ /**
82
+ * `importers`' answer for the file as it is now, through the same memo
83
+ * and gates as `key`; undefined when the file is absent. Not a hook: a
84
+ * plugin calls it from its own `discover` or `project`.
85
+ */
86
+ importers(ctx: {
87
+ readonly workspaceRoot: string
88
+ readonly cacheDir: string
89
+ }): Promise<ReadonlyMap<string, LockfileImporter> | undefined>
64
90
  }
65
91
 
66
92
  interface Digests {
67
93
  /** xxh3 of the lockfile bytes; '' when the file is absent. */
68
94
  readonly lock: string
69
95
  readonly importers: ReadonlyMap<string, string>
96
+ readonly edges: ReadonlyMap<string, LockfileImporter>
70
97
  }
71
98
 
72
99
  interface Memo {
@@ -76,6 +103,7 @@ interface Memo {
76
103
  /** `extraFiles` → content hash when the digests were computed. */
77
104
  extras?: Record<string, string>
78
105
  importers: Record<string, string>
106
+ edges?: Record<string, LockfileImporter>
79
107
  }
80
108
 
81
109
  /** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
@@ -125,7 +153,16 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
125
153
  // at the same number read this one's memo: one that named no extra file
126
154
  // left the other's patch edits out of its key (X-130). The functions'
127
155
  // source stands for the claimant; a rebuild that changes it is a miss.
128
- const claimant = xxh3hex([part, String(digest), String(options.extraFiles ?? '')].join('\0'))
156
+ // The scope too: a workspace-scope memo holds no digests.
157
+ const claimant = xxh3hex(
158
+ [
159
+ part,
160
+ scope,
161
+ String(digest),
162
+ String(options.extraFiles ?? ''),
163
+ String(options.importers ?? ''),
164
+ ].join('\0'),
165
+ )
129
166
  if (scope !== 'project' && scope !== 'workspace') {
130
167
  throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
131
168
  }
@@ -149,7 +186,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
149
186
  try {
150
187
  identity = identityOf(await stat(full))
151
188
  } catch {
152
- const digests: Digests = { lock: '', importers: new Map() }
189
+ const digests: Digests = { lock: '', importers: new Map(), edges: new Map() }
153
190
  seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
154
191
  return digests
155
192
  }
@@ -169,12 +206,14 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
169
206
  let extras: Record<string, string> = {}
170
207
  let extraStats: Record<string, string> = {}
171
208
  let importers: ReadonlyMap<string, string> | undefined
209
+ let edges: ReadonlyMap<string, LockfileImporter> = new Map()
172
210
  if (memo !== undefined) {
173
211
  const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
174
212
  if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
175
213
  extras = memo.extras
176
214
  extraStats = now.stats
177
215
  importers = memo.importers
216
+ edges = memo.edges
178
217
  }
179
218
  }
180
219
  if (importers === undefined) {
@@ -186,19 +225,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
186
225
  importers =
187
226
  scope === 'workspace'
188
227
  ? new Map<string, string>()
189
- : await computeAndMemo(
190
- memoFile,
191
- { version, claimant, lock: lockHash },
192
- extras,
193
- digest(text, new Map(Object.entries(extras))),
194
- )
228
+ : digest(text, new Map(Object.entries(extras)))
229
+ edges = options.importers?.(text) ?? new Map()
230
+ // Workspace scope with no edges to keep has nothing a memo saves.
231
+ if (scope === 'project' || options.importers !== undefined) {
232
+ await writeMemo(memoFile, { version, claimant, lock: lockHash }, extras, importers, edges)
233
+ }
195
234
  }
196
235
  const rels = Object.keys(extras)
197
236
  const lock =
198
237
  rels.length === 0
199
238
  ? lockHash
200
239
  : xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
201
- const digests: Digests = { lock, importers }
240
+ const digests: Digests = { lock, importers, edges }
202
241
  seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
203
242
  return digests
204
243
  }
@@ -216,7 +255,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
216
255
  files: [file],
217
256
  affected(change: FingerprintChange, ctx: FingerprintContext) {
218
257
  // One side absent: the file appeared or went — every project's
219
- // installed tree is in question, and core's answer (all) is right.
258
+ // installed tree is in question.
220
259
  if (change.before === null || change.after === null) return undefined
221
260
  if (scope === 'workspace') return undefined
222
261
  // WHICH side failed is the whole difference between "your
@@ -248,6 +287,10 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
248
287
  [part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
249
288
  }
250
289
  },
290
+ async importers(ctx) {
291
+ const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
292
+ return digests.lock === '' ? undefined : digests.edges
293
+ },
251
294
  }
252
295
  }
253
296
 
@@ -280,25 +323,42 @@ async function readMemo(
280
323
  version: number,
281
324
  claimant: string,
282
325
  lock: string,
283
- ): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
326
+ ): Promise<
327
+ | {
328
+ extras: Record<string, string>
329
+ importers: Map<string, string>
330
+ edges: Map<string, LockfileImporter>
331
+ }
332
+ | undefined
333
+ > {
284
334
  try {
285
335
  const memo = (await Bun.file(memoFile).json()) as Memo
286
336
  if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
287
337
  return undefined
288
338
  }
289
- return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
339
+ return {
340
+ extras: memo.extras ?? {},
341
+ importers: new Map(Object.entries(memo.importers)),
342
+ edges: new Map(Object.entries(memo.edges ?? {})),
343
+ }
290
344
  } catch {
291
345
  return undefined
292
346
  }
293
347
  }
294
348
 
295
- async function computeAndMemo(
349
+ async function writeMemo(
296
350
  memoFile: string,
297
351
  identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
298
352
  extras: Record<string, string>,
299
353
  importers: ReadonlyMap<string, string>,
300
- ): Promise<ReadonlyMap<string, string>> {
301
- const memo: Memo = { ...identity, extras, importers: Object.fromEntries(importers) }
354
+ edges: ReadonlyMap<string, LockfileImporter>,
355
+ ): Promise<void> {
356
+ const memo: Memo = {
357
+ ...identity,
358
+ extras,
359
+ importers: Object.fromEntries(importers),
360
+ ...(edges.size > 0 ? { edges: Object.fromEntries(edges) } : {}),
361
+ }
302
362
  // Write-then-rename: a reader never sees a half-written memo, and two
303
363
  // concurrent runs each land a whole one.
304
364
  const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
@@ -309,7 +369,6 @@ async function computeAndMemo(
309
369
  // The memo is a speed-up, never a requirement: the digests are
310
370
  // computed either way, and the next run computes them again.
311
371
  }
312
- return importers
313
372
  }
314
373
 
315
374
  function decode(bytes: Uint8Array): string {