@vzn/vx 0.0.630 → 0.0.632

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 (62) hide show
  1. package/README.md +7 -8
  2. package/package.json +7 -5
  3. package/schemas/cache.json +24 -0
  4. package/schemas/error.json +23 -0
  5. package/schemas/info.json +147 -0
  6. package/schemas/last.json +188 -0
  7. package/schemas/plan.json +88 -0
  8. package/schemas/show.json +124 -0
  9. package/schemas/summary.json +133 -0
  10. package/schemas/why.json +168 -0
  11. package/skills/vx/SKILL.md +85 -0
  12. package/src/bin.ts +9 -0
  13. package/src/cache/archive.ts +153 -51
  14. package/src/cache/cache.ts +45 -19
  15. package/src/cache/layer.ts +2 -1
  16. package/src/cache/schema.ts +2 -1
  17. package/src/cache/tar-stream.ts +204 -66
  18. package/src/cache/zstd.ts +1 -1
  19. package/src/cli/cache.ts +14 -14
  20. package/src/cli/foreign-flags.ts +1 -1
  21. package/src/cli/help.ts +30 -5
  22. package/src/cli/index.ts +23 -11
  23. package/src/cli/info.ts +38 -9
  24. package/src/cli/init.ts +97 -19
  25. package/src/cli/last.ts +54 -3
  26. package/src/cli/plan-format.ts +23 -1
  27. package/src/cli/plugin-commands.ts +3 -0
  28. package/src/cli/plugin-templates.ts +1 -1
  29. package/src/cli/run.ts +40 -21
  30. package/src/cli/show.ts +10 -7
  31. package/src/cli/upgrade.ts +1 -1
  32. package/src/cli/watch.ts +7 -2
  33. package/src/cli/why.ts +14 -40
  34. package/src/exec/runner.ts +27 -10
  35. package/src/graph/scheduler.ts +36 -0
  36. package/src/graph/task-graph.ts +1 -1
  37. package/src/index.ts +22 -5
  38. package/src/orchestrator/admission.ts +29 -1
  39. package/src/orchestrator/affected-tasks.ts +87 -18
  40. package/src/orchestrator/doctor.ts +14 -1
  41. package/src/orchestrator/execute-task.ts +149 -64
  42. package/src/orchestrator/failure-mode.ts +5 -3
  43. package/src/orchestrator/framed-output.ts +17 -3
  44. package/src/orchestrator/index.ts +11 -0
  45. package/src/orchestrator/logger.ts +57 -6
  46. package/src/orchestrator/metrics.ts +65 -0
  47. package/src/orchestrator/options.ts +12 -0
  48. package/src/orchestrator/output-log.ts +14 -1
  49. package/src/orchestrator/path-links.ts +79 -0
  50. package/src/orchestrator/plan.ts +20 -3
  51. package/src/orchestrator/plugin.ts +7 -0
  52. package/src/orchestrator/prepare.ts +14 -4
  53. package/src/orchestrator/run-artifacts.ts +18 -2
  54. package/src/orchestrator/run-failures.ts +192 -0
  55. package/src/orchestrator/run-records.ts +10 -8
  56. package/src/orchestrator/run.ts +87 -22
  57. package/src/orchestrator/terminal-signals.ts +16 -0
  58. package/src/orchestrator/upstream.ts +65 -10
  59. package/src/util/errors.ts +55 -1
  60. package/src/util/index.ts +3 -0
  61. package/src/util/secret-mask.ts +13 -1
  62. package/src/workspace/workspace.ts +1 -0
package/src/cli/why.ts CHANGED
@@ -9,12 +9,11 @@ import path from 'node:path'
9
9
  import { Cache } from '../cache/index.js'
10
10
  import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import { splitTaskId } from '../graph/index.js'
12
- import type { Database } from 'bun:sqlite'
13
12
  import {
14
- type CacheKeyDiff,
15
13
  cacheKeyDiff,
16
14
  explainCacheKeyQuery as explainCacheKey,
17
15
  latestRunId,
16
+ rootCauses,
18
17
  whyDidThisRerunQuery as whyDidThisRerun,
19
18
  resolveRunId,
20
19
  } from '../orchestrator/index.js'
@@ -151,7 +150,10 @@ async function resolveTarget(
151
150
  const ids = pairs.map((p) => `${p.project}#${p.task}`)
152
151
  const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
153
152
  if (target.includes('#')) {
154
- throw new UserError(`vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`)
153
+ throw new UserError(
154
+ `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
155
+ 'VX_E_NO_HISTORY',
156
+ )
155
157
  }
156
158
  const matches = ids.filter((id) => id.endsWith(`#${target}`))
157
159
  if (matches.length === 1) return matches[0]!
@@ -161,10 +163,12 @@ async function resolveTarget(
161
163
  throw new UserError(
162
164
  `vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
163
165
  matches.map((m) => ` ${m}`).join('\n'),
166
+ 'VX_E_AMBIGUOUS',
164
167
  )
165
168
  }
166
169
  throw new UserError(
167
170
  `vx why: no recorded runs for task "${target}"${suggest(target, ids, invoked)}`,
171
+ 'VX_E_NO_HISTORY',
168
172
  )
169
173
  }
170
174
 
@@ -187,9 +191,12 @@ function restoredWord(r: { restored: boolean | null }): string {
187
191
 
188
192
  export async function whyCmd(args: readonly string[]): Promise<number> {
189
193
  const parsed = parseWhyArgs(args)
190
- if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`)
194
+ if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`, 'VX_E_USAGE')
191
195
  if (parsed.target === undefined) {
192
- throw new UserError('vx why: <task> required (e.g. vx why app#build, or vx why build)')
196
+ throw new UserError(
197
+ 'vx why: <task> required (e.g. vx why app#build, or vx why build)',
198
+ 'VX_E_USAGE',
199
+ )
193
200
  }
194
201
 
195
202
  const root = await findWorkspaceRoot(process.cwd())
@@ -206,6 +213,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
206
213
  if (runId === null) {
207
214
  throw new UserError(
208
215
  `vx why: no recorded run ${parsed.runId} (vx last --list shows recent runs)`,
216
+ 'VX_E_NO_HISTORY',
209
217
  )
210
218
  }
211
219
  } else runId = latestRunId(db, taskId)
@@ -231,6 +239,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
231
239
  if (!why.found) {
232
240
  throw new UserError(
233
241
  `vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last RUNID\` what one ran`,
242
+ 'VX_E_NO_HISTORY',
234
243
  )
235
244
  }
236
245
  const diff = cacheKeyDiff(db, runId, taskId)
@@ -344,38 +353,3 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
344
353
  }
345
354
 
346
355
  const ROOTS_SHOWN = 5
347
-
348
- interface RootCause {
349
- /** From the asked task down to the one whose own inputs moved. */
350
- chain: string[]
351
- entries: CacheKeyDiff['entries']
352
- }
353
-
354
- /**
355
- * The tasks under `taskId`'s moved upstreams whose OWN key components
356
- * moved in the same run, each with the chain that carried it up. A task
357
- * visited once is not walked again (a diamond names its root once).
358
- */
359
- function rootCauses(
360
- db: Database,
361
- runId: string,
362
- taskId: string,
363
- entries: CacheKeyDiff['entries'],
364
- ): RootCause[] {
365
- const roots: RootCause[] = []
366
- const seen = new Set([taskId])
367
- const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
368
- for (const e of moved) {
369
- if (e.kind !== 'upstream' || seen.has(e.name)) continue
370
- seen.add(e.name)
371
- const next = [...chain, e.name]
372
- const d = cacheKeyDiff(db, runId, e.name)
373
- if (!d.found) continue
374
- const own = d.entries.filter((x) => x.kind !== 'upstream')
375
- if (own.length > 0) roots.push({ chain: next, entries: own })
376
- walk(next, d.entries)
377
- }
378
- }
379
- walk([taskId], entries)
380
- return roots
381
- }
@@ -849,7 +849,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
849
849
  }
850
850
  }
851
851
  const reader = stream.getReader()
852
- const decoder = new TextDecoder()
852
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
853
853
  try {
854
854
  while (true) {
855
855
  const { value, done } = await reader.read()
@@ -1097,12 +1097,17 @@ export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
1097
1097
  export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
1098
1098
 
1099
1099
  const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
1100
+ const size = (n: number): string => (n < 1024 * 1024 ? `${n / 1024} KiB` : mib(n))
1100
1101
 
1101
1102
  /** The line that stands where the dropped middle was. */
1102
- export function droppedOutputLine(dropped: number): string {
1103
+ export function droppedOutputLine(
1104
+ dropped: number,
1105
+ headChars = CAPTURE_HEAD_CHARS,
1106
+ tailChars = CAPTURE_TAIL_CHARS,
1107
+ ): string {
1103
1108
  return (
1104
- `\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${mib(CAPTURE_HEAD_CHARS)} ` +
1105
- `and the last ${mib(CAPTURE_TAIL_CHARS)} of a task's output for its cache entry and replay\n`
1109
+ `\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${size(headChars)} ` +
1110
+ `and the last ${size(tailChars)} of a task's output\n`
1106
1111
  )
1107
1112
  }
1108
1113
 
@@ -1145,12 +1150,17 @@ export class BoundedCapture {
1145
1150
  private tailLen = 0
1146
1151
  private dropped = 0
1147
1152
 
1153
+ constructor(
1154
+ private readonly headChars = CAPTURE_HEAD_CHARS,
1155
+ private readonly tailChars = CAPTURE_TAIL_CHARS,
1156
+ ) {}
1157
+
1148
1158
  push(text: string, err = false): void {
1149
1159
  // Once the tail has begun the head is closed, even a unit short of its
1150
1160
  // bound (the surrogate case below): a later short chunk fit there and
1151
1161
  // was retained ahead of the chunk before it.
1152
- if (this.tailLen === 0 && this.headLen < CAPTURE_HEAD_CHARS) {
1153
- let room = CAPTURE_HEAD_CHARS - this.headLen
1162
+ if (this.tailLen === 0 && this.headLen < this.headChars) {
1163
+ let room = this.headChars - this.headLen
1154
1164
  if (text.length <= room) {
1155
1165
  this.head.push({ text, err })
1156
1166
  this.headLen += text.length
@@ -1165,9 +1175,9 @@ export class BoundedCapture {
1165
1175
  }
1166
1176
  this.tail.push({ text, err })
1167
1177
  this.tailLen += text.length
1168
- while (this.tailLen > CAPTURE_TAIL_CHARS) {
1178
+ while (this.tailLen > this.tailChars) {
1169
1179
  const first = this.tail[0]!
1170
- const excess = this.tailLen - CAPTURE_TAIL_CHARS
1180
+ const excess = this.tailLen - this.tailChars
1171
1181
  if (first.text.length <= excess) {
1172
1182
  this.tail.shift()
1173
1183
  this.tailLen -= first.text.length
@@ -1185,7 +1195,14 @@ export class BoundedCapture {
1185
1195
  chunks(): CapturedChunk[] {
1186
1196
  return this.dropped === 0
1187
1197
  ? [...this.head, ...this.tail]
1188
- : [...this.head, { text: droppedOutputLine(this.dropped), err: false }, ...this.tail]
1198
+ : [
1199
+ ...this.head,
1200
+ {
1201
+ text: droppedOutputLine(this.dropped, this.headChars, this.tailChars),
1202
+ err: false,
1203
+ },
1204
+ ...this.tail,
1205
+ ]
1189
1206
  }
1190
1207
 
1191
1208
  text(): string {
@@ -1213,7 +1230,7 @@ export async function streamToString(
1213
1230
  if (!stream || typeof stream === 'number') return ''
1214
1231
  const full = new BoundedCapture()
1215
1232
  const reader = stream.getReader()
1216
- const decoder = new TextDecoder()
1233
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
1217
1234
  // On abort, cancel the read so a pending `reader.read()` resolves
1218
1235
  // `done` and we return whatever we captured. Needed for the timeout
1219
1236
  // path: SIGTERMing `sh` doesn't close the pipe if an orphaned
@@ -140,6 +140,12 @@ export interface TaskOutcome {
140
140
  * of reading it as a signal (item 268).
141
141
  */
142
142
  timedOut?: true
143
+ /**
144
+ * On a `failed` outcome of a command that ran: the final attempt's
145
+ * output, both streams, as `encodeOutputLog` stores it, secrets masked,
146
+ * bounded to its first 8 KiB and last 56 KiB. The run's history keeps it.
147
+ */
148
+ failedOutput?: string
143
149
  /**
144
150
  * On a failed persistent task: why it never became ready — the readiness
145
151
  * deadline fired, the child exited first (`exitCode` is then its own), or
@@ -255,6 +261,15 @@ export interface ScheduleOptions {
255
261
  * slot admits other work at once. Undefined: nothing owed.
256
262
  */
257
263
  settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
264
+ /**
265
+ * The outcome of a task the caller settles in place, or undefined to
266
+ * dispatch it through `execute`. Asked of an exec-tier task the lane has
267
+ * room for, after the skip check; its outcome lands in the same tick, it
268
+ * holds no slot, and `onStart` / `onFinish` hear it as any other. An
269
+ * unkeyed group runs nothing, and the dispatch, the slot and the promise
270
+ * around it were ~10 µs of each of a warm run's 1,090 groups (X-192).
271
+ */
272
+ settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => TaskOutcome | undefined
258
273
  /**
259
274
  * Optional priority override: callers pass their own per-node weight
260
275
  * (e.g. `computePredictedPriorities` from the orchestrator's history
@@ -749,6 +764,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
749
764
  })
750
765
  }
751
766
 
767
+ // A throw falls back to the dispatch, whose `execute` reports it.
768
+ const settleInPlace = (node: TaskNode, upstream: TaskOutcome[]): TaskOutcome | undefined => {
769
+ if (options.settleNow === undefined) return undefined
770
+ try {
771
+ return options.settleNow(node, upstream)
772
+ } catch {
773
+ return undefined
774
+ }
775
+ }
776
+
752
777
  const tick = (): void => {
753
778
  if (resolved) return
754
779
  // Exec-tier tasks parked THIS tick on a refused admission. Within
@@ -844,6 +869,17 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
844
869
 
845
870
  const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
846
871
  readyAt.delete(id)
872
+ const now = inRestoreTier(id) ? undefined : settleInPlace(node, upstream)
873
+ if (now !== undefined) {
874
+ try {
875
+ onStart?.(node)
876
+ } catch (err) {
877
+ const m = err instanceof Error ? err.message : String(err)
878
+ process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
879
+ }
880
+ finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
881
+ continue
882
+ }
847
883
  const leave = admit(id)
848
884
  // Listed as running on dispatch, so the policy's next ask in this
849
885
  // tick sees it; the completion callbacks unlist it.
@@ -1670,7 +1670,7 @@ function detectCycle(nodes: Map<string, TaskNode>): void {
1670
1670
  for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
1671
1671
  const startInPath = path.indexOf(idsArr[depIdx]!)
1672
1672
  const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
1673
- throw new UserError(`Cycle detected in task graph: ${cycle}`)
1673
+ throw new UserError(`Cycle detected in task graph: ${cycle}`, 'VX_E_CYCLE')
1674
1674
  }
1675
1675
  color[depIdx] = GRAY
1676
1676
  stack.push(depIdx, 0)
package/src/index.ts CHANGED
@@ -239,8 +239,25 @@ export type {
239
239
  // The serializable run result and its per-task views (what `run()` returns).
240
240
  export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
241
241
 
242
- // The one run-history query a reader outside the CLI takes today: `vx why`'s
243
- // answer, which `@vzn/vx-mcp` serves. The event bus / wire form and the
244
- // other history readers (`listRuns`, `getRun`, …) left the façade 2026-09-10
245
- // with no consumer; the telemetry seam above is the canonical export path.
246
- export { latestRunId, resolveRunId, whyDidThisRerunQuery } from './orchestrator/index.js'
242
+ // The run-history queries a reader outside the CLI takes today: `vx why`'s
243
+ // answer, a run's failures and a task's log, which `@vzn/vx-mcp` serves. The event bus /
244
+ // wire form and the other history readers (`listRuns`, `getRun`, …) left the
245
+ // façade 2026-09-10 with no consumer; the telemetry seam above is the
246
+ // canonical export path.
247
+ export {
248
+ cacheKeyDiff,
249
+ latestRunId,
250
+ resolveRunId,
251
+ rootCauses,
252
+ runFailures,
253
+ taskLog,
254
+ whyDidThisRerunQuery,
255
+ } from './orchestrator/index.js'
256
+ export type {
257
+ CacheKeyDiff,
258
+ OutputLocation,
259
+ RootCause,
260
+ RunFailures,
261
+ TaskFailure,
262
+ TaskLog,
263
+ } from './orchestrator/index.js'
@@ -32,11 +32,21 @@ export interface TaintTracker {
32
32
  * bytes nothing vouches for. Its order-only deps count too.
33
33
  */
34
34
  judge: (node: TaskNode, upstream: TaskOutcome[]) => boolean
35
+ /**
36
+ * For a task `judge` called tainted: the task at the root of it, a
37
+ * failed one or a dependency `--exclude-dependencies` dropped. Its run
38
+ * row names it (`blocked_by`), so no reader counts the row on its key.
39
+ */
40
+ cause: (node: TaskNode, upstream: TaskOutcome[]) => string
35
41
  /** Every settled outcome, the scheduler's `onFinish`. */
36
42
  settled: (outcome: TaskOutcome) => void
37
43
  }
38
44
 
39
- const UNTAINTED: TaintTracker = { judge: () => false, settled: () => {} }
45
+ const UNTAINTED: TaintTracker = {
46
+ judge: () => false,
47
+ cause: (node) => node.id,
48
+ settled: () => {},
49
+ }
40
50
 
41
51
  /**
42
52
  * Records which tasks ran behind a failure, or on the key of a dependency
@@ -61,6 +71,7 @@ export function taintTracker(
61
71
  if (!continueAlways && seeds.size === 0) return UNTAINTED
62
72
  const outcomes = new Map<string, TaskOutcome>()
63
73
  const memo = new Map<string, boolean>()
74
+ const causes = new Map<string, string>()
64
75
  const failed = (u: TaskOutcome): boolean =>
65
76
  continueAlways && (u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped')
66
77
  const through = (u: TaskOutcome | undefined): boolean =>
@@ -113,6 +124,23 @@ export function taintTracker(
113
124
  seeds.has(node.id) ||
114
125
  upstream.some(through) ||
115
126
  node.orderOnly?.some((d) => through(outcomes.get(d))) === true,
127
+ cause: (node, upstream) => {
128
+ // A restore-tier task's upstream holds holes (see `judge`).
129
+ const deps = upstream.filter((u) => u !== undefined)
130
+ for (const d of node.orderOnly ?? []) {
131
+ const u = outcomes.get(d)
132
+ if (u !== undefined) deps.push(u)
133
+ }
134
+ const root =
135
+ deps.find(failed) ?? deps.find((u) => taintOf(u.node.id)) ?? node.excludedUpstream?.[0]
136
+ let c: string
137
+ if (root === undefined) c = node.id
138
+ else if (failed(root))
139
+ c = root.status === 'skipped' ? (root.blockedBy ?? root.node.id) : root.node.id
140
+ else c = causes.get(root.node.id) ?? root.node.id
141
+ causes.set(node.id, c)
142
+ return c
143
+ },
116
144
  settled: (outcome) => {
117
145
  outcomes.set(outcome.node.id, outcome)
118
146
  },
@@ -4,6 +4,7 @@
4
4
  // behind `^build` runs when `ui#build` (or what it depends on) is reached,
5
5
  // and a spec edit `ui#build`'s inputs leave out stops at `ui`'s own tasks.
6
6
 
7
+ import path from 'node:path'
7
8
  import type { WorkspaceRules } from '../config.js'
8
9
  import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
9
10
  import {
@@ -54,6 +55,30 @@ function wholeProjects(
54
55
  return whole
55
56
  }
56
57
 
58
+ /**
59
+ * Why `--affected` kept a requested task (`vx run --affected --dry`):
60
+ * `input`, a changed `file` is one of its declared inputs; `project`, its
61
+ * project changed and the task reads it whole (an uncached task, a path no
62
+ * cached task declares, or no single `file`: a lockfile claim, a manifest
63
+ * edge, a config import); `package`, a `^` edge passes through `project`, a
64
+ * changed package with no such task; `named`, asked as `pkg#task`;
65
+ * `selected`, another filter selected its project outright. `via` is the
66
+ * `dependsOn` chain, nearest first, to the task the change seeded.
67
+ */
68
+ export interface AffectedReason {
69
+ kind: 'input' | 'project' | 'package' | 'named' | 'selected'
70
+ /** Workspace-relative. */
71
+ file?: string
72
+ project?: string
73
+ via?: string[]
74
+ }
75
+
76
+ /** Collects each kept task's `AffectedReason` when given; a run without one pays nothing. */
77
+ export interface AffectedExplain {
78
+ workspaceRoot: string
79
+ reasons: Map<string, AffectedReason>
80
+ }
81
+
57
82
  /**
58
83
  * The task ids of `ids` whose closure the change reaches, in their order.
59
84
  * A group seeds nothing (it runs nothing) unless it is keyed, as the default
@@ -69,8 +94,16 @@ export function affectedRoots(
69
94
  changes: AffectedChanges,
70
95
  projects: ReadonlyMap<string, ProjectEntry>,
71
96
  packageGraph: PackageGraph,
97
+ explain?: AffectedExplain,
72
98
  ): string[] {
73
99
  const whole = wholeProjects(changes, projects)
100
+ const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
101
+ const through = explain === undefined ? null : new Map<string, string>()
102
+ const inProject = (name: string, rel: string | undefined): AffectedReason => {
103
+ if (rel === undefined) return { kind: 'project' }
104
+ const dir = path.relative(explain!.workspaceRoot, projects.get(name)!.dir)
105
+ return { kind: 'project', file: dir === '' ? rel : path.posix.join(dir, rel) }
106
+ }
74
107
  // The builder's `^name` walk (task-graph.ts), keeping only the packages
75
108
  // it passes through: a holder's own node is already in `deps`.
76
109
  const passesChanged = (n: TaskNode): boolean => {
@@ -89,7 +122,10 @@ export function affectedRoots(
89
122
  if (visited.has(target)) continue
90
123
  visited.add(target)
91
124
  if (holds(target)) continue
92
- if (changes.projects.has(target)) return true
125
+ if (changes.projects.has(target)) {
126
+ seeds?.set(n.id, { kind: 'package', project: target })
127
+ return true
128
+ }
93
129
  frontier.push(...packageGraph.directDeps(target))
94
130
  }
95
131
  }
@@ -99,22 +135,35 @@ export function affectedRoots(
99
135
  const cache = n.config.cache
100
136
  // Asked of every node, not only the changed projects' (the
101
137
  // `workspaceFiles` owners): a `graph` hook may have given the glob.
102
- if (
103
- cache !== undefined &&
104
- (changes.changed.some((rel) => declaresInput(cache, null, rel)) ||
105
- (changes.nested ?? []).some((dir) => workspaceFilesReachInto(cache, dir)))
106
- ) {
107
- return true
138
+ if (cache !== undefined) {
139
+ const file = changes.changed.find((rel) => declaresInput(cache, null, rel))
140
+ const dir =
141
+ file === undefined
142
+ ? (changes.nested ?? []).find((d) => workspaceFilesReachInto(cache, d))
143
+ : undefined
144
+ if (file !== undefined || dir !== undefined) {
145
+ seeds?.set(n.id, { kind: 'input', file: (file ?? dir)! })
146
+ return true
147
+ }
108
148
  }
109
149
  if (!changes.projects.has(n.projectName)) return false
150
+ const rels = changes.paths.get(n.projectName) ?? []
110
151
  // A group runs nothing; only the default `build` (projects.ts) is keyed.
111
152
  // An uncached task reads its project: a root file another task of it
112
153
  // declares (`workspaceFiles`) is no change there.
113
154
  if (cache === undefined) {
114
- return !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
155
+ const hit = !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
156
+ if (hit) seeds?.set(n.id, inProject(n.projectName, rels[0]))
157
+ return hit
158
+ }
159
+ if (whole.has(n.projectName)) {
160
+ seeds?.set(n.id, inProject(n.projectName, undefined))
161
+ return true
115
162
  }
116
- if (whole.has(n.projectName)) return true
117
- return (changes.paths.get(n.projectName) ?? []).some((rel) => declaresInput(cache, rel, null))
163
+ const rel = rels.find((r) => declaresInput(cache, r, null))
164
+ if (rel === undefined) return false
165
+ if (seeds !== null) seeds.set(n.id, { ...inProject(n.projectName, rel), kind: 'input' })
166
+ return true
118
167
  }
119
168
  const reached = new Map<string, boolean>()
120
169
  // Post-order on an explicit stack: a closure is as deep as the graph, and
@@ -145,6 +194,7 @@ export function affectedRoots(
145
194
  frame[1] = i
146
195
  if (i === deps.length || reached.get(deps[i]!) === true) {
147
196
  reached.set(id, i < deps.length)
197
+ if (i < deps.length) through?.set(id, deps[i]!)
148
198
  stack.pop()
149
199
  continue
150
200
  }
@@ -152,7 +202,20 @@ export function affectedRoots(
152
202
  }
153
203
  return reached.get(root)!
154
204
  }
155
- return ids.filter(reaches)
205
+ const kept = ids.filter(reaches)
206
+ if (explain !== undefined) {
207
+ for (const root of kept) {
208
+ const via: string[] = []
209
+ let id = root
210
+ for (let next = through!.get(id); next !== undefined; next = through!.get(id)) {
211
+ via.push(next)
212
+ id = next
213
+ }
214
+ const seed = seeds!.get(id)!
215
+ explain.reasons.set(root, via.length > 0 ? { ...seed, via } : seed)
216
+ }
217
+ }
218
+ return kept
156
219
  }
157
220
 
158
221
  /**
@@ -167,15 +230,21 @@ export function keptByAffected<R extends { project: string; task: string }>(
167
230
  projects: ReadonlyMap<string, ProjectEntry>,
168
231
  packageGraph: PackageGraph,
169
232
  keep: { named?: ReadonlySet<string>; outright?: ReadonlySet<string> } = {},
233
+ explain?: AffectedExplain,
170
234
  ): R[] {
171
235
  const ids = requested.map((r) => `${r.project}#${r.task}`)
172
- const reached = new Set(affectedRoots(nodes, ids, changes, projects, packageGraph))
173
- return requested.filter(
174
- (r, i) =>
175
- keep.named?.has(ids[i]!) === true ||
176
- keep.outright?.has(r.project) === true ||
177
- reached.has(ids[i]!),
178
- )
236
+ const reached = new Set(affectedRoots(nodes, ids, changes, projects, packageGraph, explain))
237
+ return requested.filter((r, i) => {
238
+ const id = ids[i]!
239
+ const why: AffectedReason['kind'] | null =
240
+ keep.named?.has(id) === true
241
+ ? 'named'
242
+ : keep.outright?.has(r.project) === true
243
+ ? 'selected'
244
+ : null
245
+ if (why !== null) explain?.reasons.set(id, { kind: why })
246
+ return why !== null || reached.has(id)
247
+ })
179
248
  }
180
249
 
181
250
  /**
@@ -8,7 +8,7 @@
8
8
  import os from 'node:os'
9
9
  import path from 'node:path'
10
10
  import { Cache, CACHE_VERSION, SCHEMA_VERSION } from '../cache/index.js'
11
- import { PLUGIN_HOOKS } from '../config.js'
11
+ import { PLUGIN_HOOKS, type WorkspaceConfig } from '../config.js'
12
12
  import {
13
13
  cgroupCpuQuota,
14
14
  cgroupMemoryLimitBytes,
@@ -17,6 +17,7 @@ import {
17
17
  machineMemoryBytes,
18
18
  machineParallelism,
19
19
  maskedLine,
20
+ parseSize,
20
21
  relPosix,
21
22
  } from '../util/index.js'
22
23
  import { VERSION } from '../version.js'
@@ -93,6 +94,8 @@ export interface InfoFacts {
93
94
  schemaVersion: string
94
95
  cacheEntries: number
95
96
  cacheBytes: number
97
+ /** The workspace's `cacheRetention`, with `maxSize` in bytes; null when none is declared. */
98
+ cacheRetention: { olderThan?: string; maxSize?: string; maxBytes?: number } | null
96
99
  orphans: { artifacts: number; bytes: number }
97
100
  runs24h: number
98
101
  hits24h: number
@@ -250,6 +253,7 @@ async function collectWorkspaceInfo(
250
253
  schemaVersion: SCHEMA_VERSION,
251
254
  cacheEntries: stats.entryCount,
252
255
  cacheBytes: stats.totalBytes,
256
+ cacheRetention: retentionFact(workspaceConfig?.cacheRetention),
253
257
  // A row-less artifact is indexed again only when its task asks for its
254
258
  // key; the rest only `vx cache prune` reclaims (after an upgrade's
255
259
  // schema reset, most often).
@@ -407,3 +411,12 @@ async function countLoadableTasks(
407
411
  errors.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
408
412
  return { tasks, sandboxed, errors }
409
413
  }
414
+
415
+ function retentionFact(r: WorkspaceConfig['cacheRetention']): InfoFacts['cacheRetention'] {
416
+ if (r === undefined) return null
417
+ return {
418
+ ...(r.olderThan !== undefined ? { olderThan: r.olderThan } : {}),
419
+ // Validated at load, so it parses.
420
+ ...(r.maxSize !== undefined ? { maxSize: r.maxSize, maxBytes: parseSize(r.maxSize)! } : {}),
421
+ }
422
+ }