@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
@@ -41,7 +41,12 @@ import {
41
41
  maskCaptured,
42
42
  BoundedCapture,
43
43
  } from '../exec/index.js'
44
- import { encodeOutputLog } from './output-log.js'
44
+ import {
45
+ encodeOutputLog,
46
+ FAILED_OUTPUT_HEAD_CHARS,
47
+ FAILED_OUTPUT_TAIL_CHARS,
48
+ failedOutputLog,
49
+ } from './output-log.js'
45
50
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
46
51
  import {
47
52
  killGraceMs,
@@ -260,9 +265,87 @@ export interface ExecuteArgs {
260
265
  export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
261
266
  if (isGroupTask(args.node)) return executeGroupTask(args)
262
267
  if (args.node.config.exec?.persistent !== undefined) return executePersistentTask(args)
268
+ // A probed hit needs none of the run path's set-up: the upstream folding,
269
+ // timers and closures `executeCachedTask` builds on entry were ~15 µs of
270
+ // each of a warm run's hits (X-193). The same branch there handles it
271
+ // when this one does not apply.
272
+ const probed = args.preProbed
273
+ if (probed?.hit != null && readsCache(args) && args.remoteOnlyNoop !== true) {
274
+ return restoreProbed(
275
+ args,
276
+ probed.hash,
277
+ probed.hit,
278
+ process.hrtime.bigint() - args.runStartHrTimeNs,
279
+ )
280
+ }
263
281
  return executeCachedTask(args)
264
282
  }
265
283
 
284
+ /** Whether this task reads the cache: a cached, not remote-only task with a read axis on. */
285
+ function readsCache(args: ExecuteArgs): boolean {
286
+ const policy = args.cachePolicy ?? FULL_CACHE_POLICY
287
+ return (
288
+ args.remoteOnly !== true &&
289
+ args.node.config.cache !== undefined &&
290
+ (policy.localRead || policy.remoteRead)
291
+ )
292
+ }
293
+
294
+ /**
295
+ * Restore the up-front probe's hit, or demote it. A key taken before a task
296
+ * rewrote the lockfile names an install the tree no longer holds, and the
297
+ * lazy path never probes one (X-124): demoted like a vanished hit, it runs
298
+ * dep-gated. The hit may be restoring AHEAD of this task's deps, and a
299
+ * command run now would build from outputs they have not written yet and
300
+ * save that under the good key: the scheduler runs it again once they are
301
+ * done (admission drops the probe).
302
+ */
303
+ async function restoreProbed(
304
+ args: ExecuteArgs,
305
+ hash: string,
306
+ hit: CacheEntry,
307
+ taskStartNs: bigint,
308
+ ): Promise<TaskOutcome> {
309
+ const cacheOpStart = performance.now()
310
+ const restored = fingerprintMoved(args)
311
+ ? null
312
+ : await restoreOrMiss(args, hash, hit, cacheOpStart, taskStartNs)
313
+ if (restored !== null) return restored
314
+ throw new RestoreDemoted(args.node.id)
315
+ }
316
+
317
+ /**
318
+ * A hit restored, or null when its artifact was removed after the probe (a
319
+ * prune, another workspace's retention on a shared cache directory): a
320
+ * miss. The restore has wiped the declared outputs by then; the run path
321
+ * cleans them again and the task writes them.
322
+ */
323
+ async function restoreOrMiss(
324
+ args: ExecuteArgs,
325
+ hash: string,
326
+ hit: CacheEntry,
327
+ cacheOpStart: number,
328
+ taskStartNs: bigint,
329
+ ): Promise<TaskOutcome | null> {
330
+ try {
331
+ return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
332
+ } catch (err) {
333
+ if (!(err instanceof ArtifactVanishedError)) throw err
334
+ args.log.status(`[vx] ${args.node.id}: ${err.message} — running it`)
335
+ return null
336
+ }
337
+ }
338
+
339
+ /**
340
+ * The workspace fingerprint every key here folded has moved (a task
341
+ * rewrote the lockfile), said once per run.
342
+ */
343
+ function fingerprintMoved(args: ExecuteArgs): boolean {
344
+ if (args.fingerprintWatch?.moved() === undefined) return false
345
+ args.fingerprintWatch.say(args.log)
346
+ return true
347
+ }
348
+
266
349
  /**
267
350
  * Group task: no `exec`. The scheduler has already ensured every
268
351
  * dependency completed; we just return success with a hash rolled up
@@ -270,35 +353,65 @@ export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
270
353
  * through us.
271
354
  */
272
355
  async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
356
+ if (args.node.config.cache === undefined) {
357
+ return unkeyedGroupOutcome(
358
+ args.node,
359
+ args.upstream,
360
+ args.upfrontGroupKey,
361
+ args.runStartHrTimeNs,
362
+ )
363
+ }
273
364
  const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
274
- // `computeGroupKey`: an unkeyed group's key is its upstream's alone, so
275
- // the up-front one stands when that upstream is unchanged.
276
- const upfront = args.upfrontGroupKey
365
+ const hash = await computeTaskHash({
366
+ node: args.node,
367
+ upstream: args.upstream,
368
+ workspaceRoot: args.workspaceRoot,
369
+ workspaceFingerprint: args.workspaceFingerprint,
370
+ cache: args.cache,
371
+ forwardArgs: args.forwardArgs,
372
+ nestedProjectDirs: args.nestedProjectDirs,
373
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
374
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
375
+ })
376
+ return groupOutcome(args.node, args.upstream, hash, wallclockNs)
377
+ }
378
+
379
+ /**
380
+ * An unkeyed group's outcome (no `cache`, no key of its own): its key is
381
+ * its upstream's alone (`computeGroupKey`), so the up-front one stands when
382
+ * that upstream is unchanged. Synchronous: the scheduler settles such a
383
+ * group in place (`ScheduleOptions.settleNow`), and `upstream` is the key's
384
+ * (`keyUpstream`).
385
+ */
386
+ export function unkeyedGroupOutcome(
387
+ node: TaskNode,
388
+ upstream: TaskOutcome[],
389
+ upfront: UpfrontGroupKey | undefined,
390
+ runStartHrTimeNs: bigint,
391
+ ): TaskOutcome {
392
+ const wallclockNs = process.hrtime.bigint() - runStartHrTimeNs
277
393
  const hash =
278
- args.node.config.cache === undefined
279
- ? upfront !== undefined && sameUpstream(args.upstream, upfront.upstream)
280
- ? upfront.key
281
- : computeGroupHash(args.upstream)
282
- : await computeTaskHash({
283
- node: args.node,
284
- upstream: args.upstream,
285
- workspaceRoot: args.workspaceRoot,
286
- workspaceFingerprint: args.workspaceFingerprint,
287
- cache: args.cache,
288
- forwardArgs: args.forwardArgs,
289
- nestedProjectDirs: args.nestedProjectDirs,
290
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
291
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
292
- })
394
+ upfront !== undefined && sameUpstream(upstream, upfront.upstream)
395
+ ? upfront.key
396
+ : computeGroupHash(upstream)
397
+ return groupOutcome(node, upstream, hash, wallclockNs)
398
+ }
399
+
400
+ function groupOutcome(
401
+ node: TaskNode,
402
+ upstream: TaskOutcome[],
403
+ hash: string,
404
+ wallclockNs: bigint,
405
+ ): TaskOutcome {
293
406
  return {
294
- node: args.node,
407
+ node,
295
408
  status: 'success',
296
409
  exitCode: 0,
297
410
  durationMs: 0,
298
411
  hash,
299
412
  // What this group stands for. A dependent expands it to describe the
300
413
  // real tasks in its input closure — see `TaskOutcome.groupUpstream`.
301
- groupUpstream: args.upstream,
414
+ groupUpstream: upstream,
302
415
  wallclockStartNs: wallclockNs,
303
416
  wallclockEndNs: wallclockNs,
304
417
  }
@@ -823,40 +936,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
823
936
  // the probe already ran — restore its hit, or (null hit) fall straight
824
937
  // to the run path as a known stable miss. No second cache.get.
825
938
  if (willRead) {
826
- const cacheOpStart = performance.now()
827
- // A hit whose artifact was removed after the probe (a prune, another
828
- // workspace's retention on a shared cache directory) is a miss. The
829
- // restore has wiped the declared outputs by then; the run path cleans
830
- // them again and the task writes them.
831
- const restoreOrMiss = async (hit: CacheEntry): Promise<TaskOutcome | null> => {
832
- try {
833
- return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
834
- } catch (err) {
835
- if (!(err instanceof ArtifactVanishedError)) throw err
836
- log.status(`[vx] ${node.id}: ${err.message} — running it`)
837
- return null
838
- }
839
- }
840
939
  if (preProbed !== undefined) {
841
- if (preProbed.hit !== null) {
842
- // A key taken before a task rewrote the lockfile names an install
843
- // the tree no longer holds, and the lazy path below never probes
844
- // one (X-124): demoted like a vanished hit, it runs dep-gated.
845
- const restored = fingerprintMoved() ? null : await restoreOrMiss(preProbed.hit)
846
- if (restored !== null) return restored
847
- // The up-front probe's hit may be restoring AHEAD of this task's
848
- // deps, and a command run now would build from outputs they have
849
- // not written yet and save that under the good key. The scheduler
850
- // runs it again once they are done (admission drops the probe).
851
- throw new RestoreDemoted(node.id)
852
- }
940
+ if (preProbed.hit !== null) return restoreProbed(args, hash, preProbed.hit, taskStartNs)
853
941
  // Confirmed stable miss — skip the probe, fall through to run.
854
- } else if (!fingerprintMoved()) {
942
+ } else if (!fingerprintMoved(args)) {
943
+ const cacheOpStart = performance.now()
855
944
  const endProbe = span('cache.get')
856
945
  const hit = await cache.get(hash, getContext(node, step.command))
857
946
  endProbe()
858
947
  if (hit) {
859
- const restored = await restoreOrMiss(hit)
948
+ const restored = await restoreOrMiss(args, hash, hit, cacheOpStart, taskStartNs)
860
949
  if (restored !== null) return restored
861
950
  }
862
951
  }
@@ -1198,7 +1287,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1198
1287
  // and what the cache keeps of it (L-11); null when there are none.
1199
1288
  const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
1200
1289
  let flushMasked = (): void => {}
1201
- // The attempt's output as the logger got it (masked), for the entry.
1290
+ // The attempt's output as the logger got it (masked): the entry's when
1291
+ // the task saves, else only what a failure reports (`failedOutput`).
1202
1292
  let output: BoundedCapture | undefined
1203
1293
  const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
1204
1294
  const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
@@ -1290,14 +1380,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1290
1380
  }
1291
1381
 
1292
1382
  async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1293
- const kept = willSave ? new BoundedCapture() : undefined
1383
+ const kept = willSave
1384
+ ? new BoundedCapture()
1385
+ : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1294
1386
  output = kept
1295
1387
  const toOut = (t: string): void => {
1296
- kept?.push(t)
1388
+ kept.push(t)
1297
1389
  log.taskStdout(node, t)
1298
1390
  }
1299
1391
  const toErr = (t: string): void => {
1300
- kept?.push(t, true)
1392
+ kept.push(t, true)
1301
1393
  log.taskStderr(node, t)
1302
1394
  }
1303
1395
  const out = secrets && maskedEmitter(secrets, toOut)
@@ -1436,16 +1528,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1436
1528
  }
1437
1529
  }
1438
1530
 
1439
- /**
1440
- * The workspace fingerprint every key here folded has moved (a task
1441
- * rewrote the lockfile), said once per run.
1442
- */
1443
- function fingerprintMoved(): boolean {
1444
- if (args.fingerprintWatch?.moved() === undefined) return false
1445
- args.fingerprintWatch.say(log)
1446
- return true
1447
- }
1448
-
1449
1531
  /**
1450
1532
  * What an additive run saves as its own, or undefined when it removed a
1451
1533
  * file it found: no entry replays a removal (`ownOutputsSince`), so the
@@ -1491,7 +1573,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1491
1573
  * withholds it too: the key folded the old one.
1492
1574
  */
1493
1575
  async function keyStillTrue(): Promise<boolean> {
1494
- if (fingerprintMoved()) {
1576
+ if (fingerprintMoved(args)) {
1495
1577
  unkeyed = true
1496
1578
  return false
1497
1579
  }
@@ -1540,6 +1622,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1540
1622
  : {}),
1541
1623
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1542
1624
  ...(unkeyed ? { unkeyed: true as const } : {}),
1625
+ ...(effectiveExitCode !== 0 && output !== undefined
1626
+ ? { failedOutput: failedOutputLog(output, willSave) }
1627
+ : {}),
1543
1628
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1544
1629
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1545
1630
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
@@ -27,7 +27,9 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
27
27
  * and how often it failed AFTER its first pass (`relapses`). A task with
28
28
  * no cache block keys on its config alone and runs every time, so its key
29
29
  * says nothing about its inputs: no reader counts it, as no run's row
30
- * calls it flaky. Every flakiness reader is a filter over this one
30
+ * calls it flaky. Nor is a row that names a `blocked_by`: it ran behind a
31
+ * failure (`--continue=always`), on bytes its key never named, so its
32
+ * pass or failure is not the key's. Every flakiness reader is a filter over this one
31
33
  * projection.
32
34
  */
33
35
  function keyOutcomesSql(source: string, where = ''): string {
@@ -42,7 +44,7 @@ function keyOutcomesSql(source: string, where = ''): string {
42
44
  CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
43
45
  THEN 1 ELSE 0 END AS passed
44
46
  FROM ${source}
45
- WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0${where}))
47
+ WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL${where}))
46
48
  GROUP BY project, task, hash`
47
49
  }
48
50
 
@@ -148,7 +150,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
148
150
  const rows = db
149
151
  .query(
150
152
  `SELECT DISTINCT project, task, hash FROM runs
151
- WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
153
+ WHERE status = 'failed' AND blocked_by IS NULL AND hash IN (${chunk.map(() => '?').join(', ')})`,
152
154
  )
153
155
  .all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
154
156
  for (const r of rows) everFailed.add(keyOf(r))
@@ -31,6 +31,7 @@ import { formatElapsed, maskedCommand } from '../util/index.js'
31
31
  import { paint, type ColorSupport } from './colors.js'
32
32
  import { neverStarted } from './summary.js'
33
33
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
34
+ import { fileMemo, linkPaths } from './path-links.js'
34
35
 
35
36
  const NO_COLOR: ColorSupport = { enabled: false }
36
37
 
@@ -88,6 +89,8 @@ export function formatTaskBlock(
88
89
  // ran or was cached — you asked for it, you see what it would run.
89
90
  forceCommand = false,
90
91
  forwardArgs: readonly string[] = [],
92
+ // The terminal opens OSC 8 links: a failed task's paths become links.
93
+ links = false,
91
94
  ): string {
92
95
  // Group tasks (no `exec`) do no work and have no body — they're
93
96
  // organizational nodes the user wrote so a `vx run ci` invocation
@@ -113,7 +116,16 @@ export function formatTaskBlock(
113
116
  lines.push('', corner(`$ ${shownCommand(node, forwardArgs)}`), '')
114
117
  }
115
118
 
116
- pushOutputSection(lines, output, (body.droppedStdout ?? 0) + (body.droppedStderr ?? 0), colors)
119
+ const isFile = links && outcome.status === 'failed' ? fileMemo() : undefined
120
+ const link =
121
+ isFile === undefined ? undefined : (t: string) => linkPaths(t, node.projectDir, isFile)
122
+ pushOutputSection(
123
+ lines,
124
+ output,
125
+ (body.droppedStdout ?? 0) + (body.droppedStderr ?? 0),
126
+ colors,
127
+ link,
128
+ )
117
129
 
118
130
  lines.push(...violationSection(outcome, colors))
119
131
 
@@ -156,6 +168,7 @@ function pushOutputSection(
156
168
  output: readonly OutputChunk[],
157
169
  lost: number,
158
170
  colors: ColorSupport,
171
+ link?: (line: string) => string,
159
172
  ): void {
160
173
  if (output.every((c) => c.text.trim().length === 0)) return
161
174
  lines.push(sectionLine('OUTPUT', '', colors), '')
@@ -179,8 +192,9 @@ function pushOutputSection(
179
192
  }
180
193
  if (open !== null) body.push({ text: open.parts.join(''), err: open.err })
181
194
  for (const l of body) {
182
- if (!l.err || !colors.enabled) lines.push(l.text)
183
- else lines.push(l.text.includes('\x1b') ? l.text : paint(ERROR, l.text, colors))
195
+ const text = link === undefined ? l.text : link(l.text)
196
+ if (!l.err || !colors.enabled) lines.push(text)
197
+ else lines.push(l.text.includes('\x1b') ? text : paint(ERROR, text, colors))
184
198
  }
185
199
  lines.push('')
186
200
  }
@@ -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) {
@@ -757,6 +757,36 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
757
757
  const cur = loadEntryInputs(db, this_.hash)
758
758
  const old = loadEntryInputs(db, prev.hash)
759
759
 
760
+ // A failed previous run kept no fingerprints; the last run that saved an
761
+ // entry still names what moved since then (the edit an agent made to fix
762
+ // the failure, or the one that broke it).
763
+ if (cur.length > 0 && old.length === 0 && !isPassStatus(prev.status)) {
764
+ const saved = db
765
+ .query(
766
+ `SELECT run_id AS runId, hash FROM runs
767
+ WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
768
+ AND hash IN (SELECT entry_hash FROM entry_inputs)
769
+ ORDER BY id DESC LIMIT 1`,
770
+ )
771
+ .get(project, task, this_.id) as { runId: string | null; hash: string } | undefined
772
+ if (saved) {
773
+ const { entries, unchangedCount } = diffKeyComponents(loadEntryInputs(db, saved.hash), cur)
774
+ const since = `run ${saved.runId}, the last that saved an entry (the previous run ended ${prev.status})`
775
+ return {
776
+ runId,
777
+ taskId,
778
+ found: true,
779
+ previousRunId: saved.runId,
780
+ entries,
781
+ unchangedCount,
782
+ note:
783
+ entries.length > 0
784
+ ? `${entries.length} cache-key component(s) changed since ${since}`
785
+ : `cache key matches ${since}`,
786
+ }
787
+ }
788
+ }
789
+
760
790
  // Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
761
791
  // either side's rows are gone, we can name the hash change but not the
762
792
  // component-level diff.
@@ -803,6 +833,41 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
803
833
  }
804
834
  }
805
835
 
836
+ export interface RootCause {
837
+ /** From the asked task down to the one whose own inputs moved. */
838
+ chain: string[]
839
+ entries: CacheKeyDiff['entries']
840
+ }
841
+
842
+ /**
843
+ * The tasks under `taskId`'s moved upstreams whose OWN key components
844
+ * moved in the same run, each with the chain that carried it up. A task
845
+ * visited once is not walked again (a diamond names its root once).
846
+ */
847
+ export function rootCauses(
848
+ db: Database,
849
+ runId: string,
850
+ taskId: string,
851
+ entries: CacheKeyDiff['entries'],
852
+ ): RootCause[] {
853
+ const roots: RootCause[] = []
854
+ const seen = new Set([taskId])
855
+ const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
856
+ for (const e of moved) {
857
+ if (e.kind !== 'upstream' || seen.has(e.name)) continue
858
+ seen.add(e.name)
859
+ const next = [...chain, e.name]
860
+ const d = cacheKeyDiff(db, runId, e.name)
861
+ if (!d.found) continue
862
+ const own = d.entries.filter((x) => x.kind !== 'upstream')
863
+ if (own.length > 0) roots.push({ chain: next, entries: own })
864
+ walk(next, d.entries)
865
+ }
866
+ }
867
+ walk([taskId], entries)
868
+ return roots
869
+ }
870
+
806
871
  // ---------------------------------------------------------------------------
807
872
  // Run comparison — diff a run against the immediately-previous invocation
808
873
  // ---------------------------------------------------------------------------
@@ -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
+ }