@vzn/vx 0.0.631 → 0.0.633

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 (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. package/src/workspace/workspace.ts +1 -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 type { AffectedReason } from './affected-tasks.js'
7
8
  export {
8
9
  discoverProjects,
9
10
  gitOfDiscovery,
@@ -156,12 +157,22 @@ export {
156
157
  latestRunId,
157
158
  listInvocations,
158
159
  listRuns,
160
+ rootCauses,
159
161
  whyDidThisRerun as whyDidThisRerunQuery,
160
162
  } from './metrics.js'
163
+ export {
164
+ runFailures,
165
+ type RunFailures,
166
+ type TaskFailure,
167
+ type TaskLog,
168
+ taskLog,
169
+ } from './run-failures.js'
170
+ export type { OutputLocation } from './path-links.js'
161
171
  export { resolveRunId, shortRunId } from './run-id.js'
162
172
  export type {
163
173
  CacheEntryRow,
164
174
  CacheKeyDiff,
175
+ RootCause,
165
176
  CacheKeyExplanation,
166
177
  InputDiffEntry,
167
178
  InvocationDetail,
@@ -37,6 +37,7 @@ import {
37
37
  notifyEscape,
38
38
  PROGRESS_CLEAR,
39
39
  progressEscape,
40
+ terminalLinks,
40
41
  terminalSignals,
41
42
  } from './terminal-signals.js'
42
43
 
@@ -298,6 +299,7 @@ export function defaultLogger(
298
299
  const signals = writer.enabled
299
300
  ? terminalSignals(opts.env ?? process.env)
300
301
  : { progress: false, notify: false }
302
+ const links = writer.enabled && terminalLinks(opts.env ?? process.env)
301
303
  let progressShown = -1
302
304
  let progressRed = false
303
305
 
@@ -753,7 +755,15 @@ export function defaultLogger(
753
755
  }
754
756
  if (framed && output.some((c) => c.text.length > 0)) {
755
757
  emitBlock(
756
- formatTaskBlock(node, outcome, { output, ...dropped }, colors, true, forwardArgs),
758
+ formatTaskBlock(
759
+ node,
760
+ outcome,
761
+ { output, ...dropped },
762
+ colors,
763
+ true,
764
+ forwardArgs,
765
+ links,
766
+ ),
757
767
  )
758
768
  return
759
769
  }
@@ -846,7 +856,15 @@ export function defaultLogger(
846
856
  if (outcome.status !== 'failed') return
847
857
  emitLine(failureRow(node, outcome))
848
858
  deferredFailures.push(
849
- formatTaskBlock(node, outcome, { output, ...dropped }, colors, false, forwardArgs),
859
+ formatTaskBlock(
860
+ node,
861
+ outcome,
862
+ { output, ...dropped },
863
+ colors,
864
+ false,
865
+ forwardArgs,
866
+ links,
867
+ ),
850
868
  )
851
869
  return
852
870
  case 'broad':
@@ -858,7 +876,15 @@ export function defaultLogger(
858
876
  // ◼ row now; the full frame replays at runEnd.
859
877
  emitLine(failureRow(node, outcome))
860
878
  deferredFailures.push(
861
- formatTaskBlock(node, outcome, { output, ...dropped }, colors, false, forwardArgs),
879
+ formatTaskBlock(
880
+ node,
881
+ outcome,
882
+ { output, ...dropped },
883
+ colors,
884
+ false,
885
+ forwardArgs,
886
+ links,
887
+ ),
862
888
  )
863
889
  } else if (outcome.status === 'success') {
864
890
  emitLine(formatTaskExecutedLine(node, outcome, colors))
@@ -895,14 +921,30 @@ export function defaultLogger(
895
921
  if (outcome.status === 'failed') {
896
922
  emitLine(failureRow(node, outcome))
897
923
  deferredFailures.push(
898
- formatTaskBlock(node, outcome, { output, ...dropped }, colors, false, forwardArgs),
924
+ formatTaskBlock(
925
+ node,
926
+ outcome,
927
+ { output, ...dropped },
928
+ colors,
929
+ false,
930
+ forwardArgs,
931
+ links,
932
+ ),
899
933
  )
900
934
  return
901
935
  }
902
936
  // forceCommand: a requested task's frame shows `$ cmd`
903
937
  // whether it ran or was cached — same frame every run.
904
938
  emitBlock(
905
- formatTaskBlock(node, outcome, { output, ...dropped }, colors, true, forwardArgs),
939
+ formatTaskBlock(
940
+ node,
941
+ outcome,
942
+ { output, ...dropped },
943
+ colors,
944
+ true,
945
+ forwardArgs,
946
+ links,
947
+ ),
906
948
  )
907
949
  return
908
950
  }
@@ -913,7 +955,15 @@ export function defaultLogger(
913
955
  } else if (outcome.status === 'failed') {
914
956
  emitLine(failureRow(node, outcome))
915
957
  deferredFailures.push(
916
- formatTaskBlock(node, outcome, { output, ...dropped }, colors, false, forwardArgs),
958
+ formatTaskBlock(
959
+ node,
960
+ outcome,
961
+ { output, ...dropped },
962
+ colors,
963
+ false,
964
+ forwardArgs,
965
+ links,
966
+ ),
917
967
  )
918
968
  }
919
969
  return
@@ -939,6 +989,7 @@ export function defaultLogger(
939
989
  colors,
940
990
  false,
941
991
  forwardArgs,
992
+ links,
942
993
  )
943
994
  if (block.length === 0) return
944
995
  if (view.gha) {
@@ -610,10 +610,17 @@ export interface CacheKeyDiff {
610
610
  note: string
611
611
  }
612
612
 
613
- function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
614
- return db
615
- .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
616
- .all(entryHash) as TaskInputComponent[]
613
+ /** An entry's key components, from its one `entry_inputs` row (v34); none when it has none. */
614
+ export function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
615
+ const row = db
616
+ .query('SELECT components FROM entry_inputs WHERE entry_hash = ?')
617
+ .get(entryHash) as { components: string } | null
618
+ if (row === null) return []
619
+ return (JSON.parse(row.components) as [string, string, string][]).map(([kind, name, hash]) => ({
620
+ kind,
621
+ name,
622
+ hash,
623
+ }))
617
624
  }
618
625
 
619
626
  /**
@@ -757,6 +764,36 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
757
764
  const cur = loadEntryInputs(db, this_.hash)
758
765
  const old = loadEntryInputs(db, prev.hash)
759
766
 
767
+ // A failed previous run kept no fingerprints; the last run that saved an
768
+ // entry still names what moved since then (the edit an agent made to fix
769
+ // the failure, or the one that broke it).
770
+ if (cur.length > 0 && old.length === 0 && !isPassStatus(prev.status)) {
771
+ const saved = db
772
+ .query(
773
+ `SELECT run_id AS runId, hash FROM runs
774
+ WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
775
+ AND hash IN (SELECT entry_hash FROM entry_inputs)
776
+ ORDER BY id DESC LIMIT 1`,
777
+ )
778
+ .get(project, task, this_.id) as { runId: string | null; hash: string } | undefined
779
+ if (saved) {
780
+ const { entries, unchangedCount } = diffKeyComponents(loadEntryInputs(db, saved.hash), cur)
781
+ const since = `run ${saved.runId}, the last that saved an entry (the previous run ended ${prev.status})`
782
+ return {
783
+ runId,
784
+ taskId,
785
+ found: true,
786
+ previousRunId: saved.runId,
787
+ entries,
788
+ unchangedCount,
789
+ note:
790
+ entries.length > 0
791
+ ? `${entries.length} cache-key component(s) changed since ${since}`
792
+ : `cache key matches ${since}`,
793
+ }
794
+ }
795
+ }
796
+
760
797
  // Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
761
798
  // either side's rows are gone, we can name the hash change but not the
762
799
  // component-level diff.
@@ -803,6 +840,41 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
803
840
  }
804
841
  }
805
842
 
843
+ export interface RootCause {
844
+ /** From the asked task down to the one whose own inputs moved. */
845
+ chain: string[]
846
+ entries: CacheKeyDiff['entries']
847
+ }
848
+
849
+ /**
850
+ * The tasks under `taskId`'s moved upstreams whose OWN key components
851
+ * moved in the same run, each with the chain that carried it up. A task
852
+ * visited once is not walked again (a diamond names its root once).
853
+ */
854
+ export function rootCauses(
855
+ db: Database,
856
+ runId: string,
857
+ taskId: string,
858
+ entries: CacheKeyDiff['entries'],
859
+ ): RootCause[] {
860
+ const roots: RootCause[] = []
861
+ const seen = new Set([taskId])
862
+ const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
863
+ for (const e of moved) {
864
+ if (e.kind !== 'upstream' || seen.has(e.name)) continue
865
+ seen.add(e.name)
866
+ const next = [...chain, e.name]
867
+ const d = cacheKeyDiff(db, runId, e.name)
868
+ if (!d.found) continue
869
+ const own = d.entries.filter((x) => x.kind !== 'upstream')
870
+ if (own.length > 0) roots.push({ chain: next, entries: own })
871
+ walk(next, d.entries)
872
+ }
873
+ }
874
+ walk([taskId], entries)
875
+ return roots
876
+ }
877
+
806
878
  // ---------------------------------------------------------------------------
807
879
  // Run comparison — diff a run against the immediately-previous invocation
808
880
  // ---------------------------------------------------------------------------
@@ -4,11 +4,11 @@
4
4
  //
5
5
  // Paid only when a telemetry sink exists, and only on a miss: one scan of
6
6
  // `entries` for the newest entry per task, the first time a miss asks, then
7
- // one primary-key read of that entry's `entry_inputs` per miss.
7
+ // one primary-key read of that entry's `entry_inputs` row per miss.
8
8
 
9
9
  import type { Database } from 'bun:sqlite'
10
10
  import type { InputChanges } from '../graph/index.js'
11
- import { diffKeyComponents } from './metrics.js'
11
+ import { diffKeyComponents, loadEntryInputs } from './metrics.js'
12
12
  import type { TaskInputComponent } from './task-hash.js'
13
13
 
14
14
  /** How many changed components a miss names; `count` says how many there were. */
@@ -37,9 +37,7 @@ export function createMissExplainer(db: Database): MissExplainer {
37
37
  )
38
38
  const prev = newest.get(taskId)
39
39
  if (prev === undefined) return undefined
40
- const before = db
41
- .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
42
- .all(prev) as TaskInputComponent[]
40
+ const before = loadEntryInputs(db, prev)
43
41
  // Pruned, or saved before fingerprints were kept: nothing to compare.
44
42
  if (before.length === 0) return undefined
45
43
  const { entries } = diffKeyComponents(before, components)
@@ -191,11 +191,12 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
191
191
  endSave()
192
192
  if (a.measure === true) {
193
193
  facts.saveMs = Math.round(performance.now() - saveStart)
194
- try {
195
- facts.artifactBytes = statSync(cache.outputsPath(a.hash)).size
196
- } catch {
197
- // No local artifact (local writes off): no size to say.
198
- }
194
+ // No local artifact (local writes off): no size to say.
195
+ const size =
196
+ cache.artifactSize !== undefined
197
+ ? cache.artifactSize(a.hash)
198
+ : statSync(cache.outputsPath(a.hash), { throwIfNoEntry: false })?.size
199
+ if (size !== undefined) facts.artifactBytes = size
199
200
  }
200
201
  // The files the task just wrote are the entry's bytes: stamp them,
201
202
  // so the next hit can tell them from another entry's with the same
@@ -5,9 +5,11 @@
5
5
  import type { CachePolicy, RemoteCacheLayer } from '../cache/index.js'
6
6
  import type { ContinueMode } from '../graph/index.js'
7
7
  import type { TaskOutcome } from '../graph/index.js'
8
+ import type { AffectedReason } from './affected-tasks.js'
8
9
  import type { EventBus } from './events.js'
9
10
  import type { TelemetrySink } from './telemetry.js'
10
11
  import type { Logger } from './logger.js'
12
+ import type { RunSummaryJson } from './run-artifacts.js'
11
13
  import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
12
14
 
13
15
  export interface RunOptions {
@@ -41,6 +43,8 @@ export interface RunOptions {
41
43
  * whether or not `affected` reaches them (X-10).
42
44
  */
43
45
  selectedOutright?: readonly string[]
46
+ /** Filled with why `--affected` kept each requested task; `planRun` (`--dry`) passes it. */
47
+ affectedReasons?: Map<string, AffectedReason>
44
48
  /**
45
49
  * Configs a selection pass in this same process already loaded and
46
50
  * staged with the same `cacheDir` and `frozen` (the CLI walks every
@@ -171,6 +175,12 @@ export interface RunOptions {
171
175
  * else is treated as the literal file path (cwd-relative).
172
176
  */
173
177
  summarize?: string
178
+ /**
179
+ * `vx run --format json`: the terminal renderer writes stderr, and the
180
+ * run hands back `RunSummary.json`, the `--summarize` document, for the
181
+ * caller to print on stdout.
182
+ */
183
+ json?: boolean
174
184
  /**
175
185
  * Text printed just above the run's footer, given the outcomes the
176
186
  * footer counts, after the `summaryTable`: nothing prints below the
@@ -312,6 +322,8 @@ export interface RunSummary {
312
322
  * caller prints it; `vx run` writes it to stderr.
313
323
  */
314
324
  refused?: string
325
+ /** The `--summarize` document, when `RunOptions.json` asked for it. */
326
+ json?: RunSummaryJson
315
327
  }
316
328
 
317
329
  export interface HeldPersistent {
@@ -4,7 +4,7 @@
4
4
  // RS (U+001E) + `e` / `o` where the stream switches to stderr / stdout, and
5
5
  // a literal RS doubled. Output that never touched stderr is its own text.
6
6
 
7
- import type { CapturedChunk } from '../exec/index.js'
7
+ import { BoundedCapture, type CapturedChunk } from '../exec/index.js'
8
8
 
9
9
  const RS = '\x1e'
10
10
 
@@ -51,3 +51,16 @@ export function decodeOutputLog(log: string): CapturedChunk[] {
51
51
  flush()
52
52
  return chunks
53
53
  }
54
+
55
+ // What a failed task reports of its output (`vx last`, an agent): the
56
+ // error is usually at the end, and a run's history keeps one per failure.
57
+ export const FAILED_OUTPUT_HEAD_CHARS = 8 * 1024
58
+ export const FAILED_OUTPUT_TAIL_CHARS = 56 * 1024
59
+
60
+ /** A failed attempt's capture, re-bounded to the failure's limits when it is the entry's. */
61
+ export function failedOutputLog(capture: BoundedCapture, entrySized: boolean): string {
62
+ if (!entrySized) return encodeOutputLog(capture.chunks())
63
+ const small = new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
64
+ for (const c of capture.chunks()) small.push(c.text, c.err)
65
+ return encodeOutputLog(small.chunks())
66
+ }
@@ -0,0 +1,79 @@
1
+ // A failed task's output with each file it names made a terminal link
2
+ // (OSC 8). A tool prints paths relative to where it ran, the task's
3
+ // project, so the terminal's own path detection, which resolves against
4
+ // the shell's directory, opens the wrong file or none in a monorepo. The
5
+ // visible text stays the task's bytes; only the link target is vx's.
6
+
7
+ import { statSync } from 'node:fs'
8
+ import path from 'node:path'
9
+
10
+ // `src/a.ts`, `./a.ts:12`, `/abs/a.ts:12:5`, tsc's `src/a.ts(12,5)`. Not
11
+ // after a word, dot, slash or `@`, so a URL's path or a scoped name's tail
12
+ // never starts a match. Groups: file, then line and column of either form.
13
+ const PATH =
14
+ /(?<![\w./@+-])((?:\/|\.{1,2}\/)?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,9})(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))?/g
15
+
16
+ /** A file a task's output names; `file` absolute. */
17
+ export interface OutputLocation {
18
+ file: string
19
+ line?: number
20
+ col?: number
21
+ }
22
+
23
+ /** Most locations one output reports: past it, the first ones are the cause. */
24
+ const LOCATIONS_CAP = 50
25
+
26
+ /** `text` with every path that names a file under `dir` (or an absolute one) linked. */
27
+ export function linkPaths(text: string, dir: string, isFile: (abs: string) => boolean): string {
28
+ if (text.includes('\x1b]8;')) return text
29
+ return text.replace(PATH, (match: string, file: string) => {
30
+ const abs = path.resolve(dir, file)
31
+ if (!isFile(abs)) return match
32
+ return `\x1b]8;;file://${encodeURI(abs)}\x1b\\${match}\x1b]8;;\x1b\\`
33
+ })
34
+ }
35
+
36
+ /**
37
+ * The files `text` names that exist (resolved against `dir`), each with the
38
+ * line and column it gave, unique, in order of first appearance.
39
+ */
40
+ export function fileLocations(
41
+ text: string,
42
+ dir: string,
43
+ isFile: (abs: string) => boolean,
44
+ ): OutputLocation[] {
45
+ const out: OutputLocation[] = []
46
+ const seen = new Set<string>()
47
+ for (const m of text.matchAll(PATH)) {
48
+ const file = path.resolve(dir, m[1]!)
49
+ const line = m[2] ?? m[4]
50
+ const col = m[3] ?? m[5]
51
+ const id = `${file}:${line}:${col}`
52
+ if (seen.has(id) || !isFile(file)) continue
53
+ seen.add(id)
54
+ out.push({
55
+ file,
56
+ ...(line !== undefined ? { line: Number(line) } : {}),
57
+ ...(col !== undefined ? { col: Number(col) } : {}),
58
+ })
59
+ if (out.length === LOCATIONS_CAP) break
60
+ }
61
+ return out
62
+ }
63
+
64
+ /** A file test that asks the file system once per path. */
65
+ export function fileMemo(): (abs: string) => boolean {
66
+ const seen = new Map<string, boolean>()
67
+ return (abs) => {
68
+ let known = seen.get(abs)
69
+ if (known === undefined) {
70
+ try {
71
+ known = statSync(abs).isFile()
72
+ } catch {
73
+ known = false
74
+ }
75
+ seen.set(abs, known)
76
+ }
77
+ return known
78
+ }
79
+ }
@@ -15,6 +15,7 @@ import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
15
  import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
16
16
  import { ranNoCache } from './events.js'
17
17
  import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
18
+ import type { AffectedReason } from './affected-tasks.js'
18
19
  import type { HistoryProvider } from './history.js'
19
20
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
20
21
  import { keyedOutcome, keyUpstream } from './upstream.js'
@@ -53,6 +54,8 @@ export interface PlannedTask {
53
54
  * eager task is the default and saying so on every line says nothing.
54
55
  */
55
56
  download?: 'deferred'
57
+ /** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
58
+ affected?: AffectedReason
56
59
  }
57
60
 
58
61
  /**
@@ -68,6 +71,9 @@ export interface PlanPrediction {
68
71
  /** Would-run tasks with NO history — their cost is unknown and counted as
69
72
  * 0, so wallMs/workMs are lower bounds when this is > 0. */
70
73
  unknownCount: number
74
+ /** The would-run tasks with history along the chain `wallMs` measures,
75
+ * dependencies first; empty when nothing with history would run. */
76
+ criticalPath: string[]
71
77
  }
72
78
 
73
79
  export interface RunPlan {
@@ -271,19 +277,27 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
271
277
  const dist = new Map<string, number>()
272
278
  const queue: string[] = []
273
279
  for (const [id, n] of indeg) if (n === 0) queue.push(id)
280
+ const via = new Map<string, string>()
274
281
  let head = 0
275
282
  let wallMs = 0
283
+ let end: string | undefined
276
284
  while (head < queue.length) {
277
285
  const id = queue[head++]!
278
286
  const t = byId.get(id)!
279
287
  let best = 0
280
288
  for (const d of t.deps) {
281
289
  const v = dist.get(d)
282
- if (v !== undefined && v > best) best = v
290
+ if (v !== undefined && v > best) {
291
+ best = v
292
+ via.set(id, d)
293
+ }
283
294
  }
284
295
  const v = best + cost(t)
285
296
  dist.set(id, v)
286
- if (v > wallMs) wallMs = v
297
+ if (v > wallMs) {
298
+ wallMs = v
299
+ end = id
300
+ }
287
301
  for (const dep of dependents.get(id) ?? []) {
288
302
  const n = indeg.get(dep)! - 1
289
303
  indeg.set(dep, n)
@@ -297,5 +311,8 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
297
311
  if (t.p50Ms === undefined) unknownCount++
298
312
  else workMs += t.p50Ms
299
313
  }
300
- return { wallMs, workMs, unknownCount }
314
+ const criticalPath: string[] = []
315
+ for (let id = end; id !== undefined; id = via.get(id))
316
+ if (cost(byId.get(id)!) > 0) criticalPath.push(id)
317
+ return { wallMs, workMs, unknownCount, criticalPath: criticalPath.reverse() }
301
318
  }
@@ -251,6 +251,13 @@ export interface CommandContext extends BaseContext {
251
251
  * `machineParallelism()` said 4 cores beside `concurrency: 1` (item 1068).
252
252
  */
253
253
  readonly concurrency: number
254
+ /**
255
+ * The argv that runs this vx again: the binary alone when compiled, the
256
+ * runtime and its entry script from source. A verb that runs tasks
257
+ * (`vx mcp`'s `runTasks`) spawns `[...vx, 'run', …]` rather than guess
258
+ * which `vx` a PATH holds.
259
+ */
260
+ readonly vx: readonly string[]
254
261
  }
255
262
 
256
263
  /** `config` runs before the cache dir is known — it may be what the hook changes. */
@@ -74,6 +74,7 @@ import type { VxPlugin } from './plugin.js'
74
74
  import { createHashCache, type HashCache } from './task-hash.js'
75
75
  import type { Logger } from './logger.js'
76
76
  import type { RunOptions } from './options.js'
77
+ import { refuseFiltersThroughGroups } from './upstream.js'
77
78
 
78
79
  export interface PreparedRun {
79
80
  workspaceRoot: string
@@ -549,10 +550,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
549
550
  if (options.affected !== undefined) {
550
551
  const before = hooksFirst ? new Set(nodes.keys()) : null
551
552
  if (hooksFirst) await graphStage()
552
- const kept = keptByAffected(nodes, requested, options.affected, projects, packageGraph, {
553
- named: new Set(tasks.filter((t) => t.includes('#'))),
554
- outright: new Set(options.selectedOutright),
555
- })
553
+ const kept = keptByAffected(
554
+ nodes,
555
+ requested,
556
+ options.affected,
557
+ projects,
558
+ packageGraph,
559
+ {
560
+ named: new Set(tasks.filter((t) => t.includes('#'))),
561
+ outright: new Set(options.selectedOutright),
562
+ },
563
+ options.affectedReasons && { workspaceRoot, reasons: options.affectedReasons },
564
+ )
556
565
  if (kept.length === 0) return emptyRun('none-affected')
557
566
  if (before !== null) {
558
567
  const asked = new Set(requested.map((r) => `${r.project}#${r.task}`))
@@ -656,6 +665,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
656
665
  if (hasHook(plugins, 'key')) {
657
666
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
658
667
  }
668
+ refuseFiltersThroughGroups(nodes)
659
669
  const exclude = options.excludeDependencies
660
670
  let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
661
671
  if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
@@ -328,8 +328,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
328
328
  * one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
329
329
  * the project. A package consumed as source then carries its files to every
330
330
  * dependent behind `^build`, in the key and in `--affected`, and the chain
331
- * through it to deeper builds holds. The one keyed group: a config cannot
332
- * declare `cache` on a group (config-schema.ts), so nothing else is.
331
+ * through it to deeper builds holds. A keyed group, as a config may
332
+ * declare one (config-schema.ts).
333
333
  */
334
334
  const DEFAULT_BUILD: TaskConfig = Object.freeze({
335
335
  dependsOn: Object.freeze(['^build']),
@@ -52,6 +52,7 @@ export interface SummaryTaskJson {
52
52
  flaky?: { passes: number; failures: number; attempts: number }
53
53
  cpuMs?: number
54
54
  peakRssBytes?: number
55
+ storedDurationMs?: number
55
56
  storedCpuMs?: number
56
57
  storedPeakRssBytes?: number
57
58
  admissionHeldMs?: number
@@ -71,6 +72,8 @@ export interface RunSummaryJson {
71
72
  startedAt: string
72
73
  endedAt: string
73
74
  totalMs: number
75
+ /** What the hits skipped: the sum of their `storedDurationMs`. */
76
+ savedMs: number
74
77
  tasks: SummaryTaskJson[]
75
78
  aborted: SummaryTaskJson[]
76
79
  summary: Tally
@@ -105,6 +108,7 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
105
108
  ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
106
109
  // A hit's row names what the PRODUCING execution used under its own
107
110
  // keys, so a consumer never reads a remote worker's peak as this run's.
111
+ ...(o.storedDurationMs !== undefined ? { storedDurationMs: o.storedDurationMs } : {}),
108
112
  ...(o.storedCpuMs !== undefined ? { storedCpuMs: o.storedCpuMs } : {}),
109
113
  ...(o.storedPeakRssBytes !== undefined ? { storedPeakRssBytes: o.storedPeakRssBytes } : {}),
110
114
  // Present only when an `admit` policy held the task with a worker free.
@@ -130,6 +134,14 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
130
134
  args.target === ''
131
135
  ? path.join(args.cacheDir, 'runs', `${args.runId}.json`)
132
136
  : path.resolve(args.cwd, args.target)
137
+ await Bun.write(outPath, JSON.stringify(runSummaryJson(args), null, 2))
138
+ return outPath
139
+ }
140
+
141
+ /** The `--summarize` document, which `vx run --format json` prints. */
142
+ export function runSummaryJson(
143
+ args: Omit<SummarizeArgs, 'target' | 'cacheDir' | 'cwd'>,
144
+ ): RunSummaryJson {
133
145
  // `tasks` and `summary` must describe the same population, or one artifact
134
146
  // contradicts itself (a group task listed in `tasks` but absent from
135
147
  // `summary.total`). `tallyOutcomes` owns the rule; mirror its filter here.
@@ -151,6 +163,11 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
151
163
  startedAt: new Date(args.startedAtMs).toISOString(),
152
164
  endedAt: new Date(args.endedAtMs).toISOString(),
153
165
  totalMs: args.totalMs,
166
+ // The summary row's `saved`, by the same rule: hits only, groups aside.
167
+ savedMs: counted.reduce(
168
+ (ms, o) => (isCacheHit(o.status) ? ms + (o.storedDurationMs ?? 0) : ms),
169
+ 0,
170
+ ),
154
171
  tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
155
172
  aborted: aborted.map((o) => taskEntry(o)),
156
173
  // The FULL list: `tallyOutcomes` applies the same group/aborted
@@ -158,8 +175,7 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
158
175
  // it — but `summary.aborted` is only non-zero if it sees them.
159
176
  summary: tallyOutcomes(args.outcomes),
160
177
  }
161
- await Bun.write(outPath, JSON.stringify(payload, null, 2))
162
- return outPath
178
+ return payload
163
179
  }
164
180
 
165
181
  export interface ProfileArgs {