@vzn/vx 0.0.638 → 0.0.639

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 (103) hide show
  1. package/docs/caching.md +44 -15
  2. package/docs/cli.md +168 -345
  3. package/docs/execution.md +18 -43
  4. package/docs/features.md +7 -10
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +47 -49
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/skills/vx/SKILL.md +6 -4
  10. package/src/cache/cache.ts +40 -10
  11. package/src/cache/git-inputs.ts +6 -0
  12. package/src/cache/index.ts +2 -2
  13. package/src/cache/layer.ts +25 -11
  14. package/src/cache/layered-cache.ts +108 -315
  15. package/src/cache/plugin-cache.ts +2 -10
  16. package/src/cache/remote-lane.ts +383 -0
  17. package/src/cache/run-history.ts +24 -8
  18. package/src/cache/run-store.ts +4 -4
  19. package/src/cache/schema.ts +6 -4
  20. package/src/cache/scoped-cache.ts +7 -0
  21. package/src/cli/completions.ts +2 -2
  22. package/src/cli/foreign-flags.ts +10 -22
  23. package/src/cli/help.ts +6 -31
  24. package/src/cli/info.ts +2 -1
  25. package/src/cli/last.ts +6 -3
  26. package/src/cli/plan-format.ts +5 -58
  27. package/src/cli/plugin-commands.ts +2 -2
  28. package/src/cli/run.ts +23 -185
  29. package/src/cli/show.ts +6 -0
  30. package/src/cli/upgrade.ts +123 -39
  31. package/src/cli/watch-cycle.ts +2 -1
  32. package/src/cli/watch.ts +29 -14
  33. package/src/cli/why.ts +4 -4
  34. package/src/config.ts +0 -6
  35. package/src/exec/executor.ts +1 -1
  36. package/src/exec/sandbox-runtime.ts +1 -1
  37. package/src/exec/sandbox-violations.ts +3 -1
  38. package/src/graph/index.ts +9 -1
  39. package/src/graph/priorities.ts +1 -1
  40. package/src/graph/scheduler.ts +4 -16
  41. package/src/graph/task-graph.ts +87 -17
  42. package/src/graph/task-status.ts +95 -0
  43. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  44. package/src/{orchestrator → history}/history.ts +1 -1
  45. package/src/history/index.ts +48 -0
  46. package/src/{orchestrator → history}/metrics.ts +7 -20
  47. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  48. package/src/index.ts +10 -28
  49. package/src/orchestrator/doctor.ts +1 -1
  50. package/src/orchestrator/execute-args.ts +80 -0
  51. package/src/orchestrator/execute-task.ts +21 -386
  52. package/src/orchestrator/fingerprint-watch.ts +2 -2
  53. package/src/orchestrator/index.ts +1 -73
  54. package/src/orchestrator/miss-save.ts +33 -3
  55. package/src/orchestrator/options.ts +1 -36
  56. package/src/orchestrator/output-host.ts +4 -3
  57. package/src/orchestrator/persistent-task.ts +321 -0
  58. package/src/orchestrator/persistent.ts +130 -3
  59. package/src/orchestrator/placement.ts +3 -3
  60. package/src/orchestrator/plan.ts +18 -5
  61. package/src/orchestrator/plugin-host.ts +67 -47
  62. package/src/orchestrator/plugin.ts +6 -6
  63. package/src/orchestrator/prepare.ts +28 -29
  64. package/src/orchestrator/remote-prefetch.ts +14 -1
  65. package/src/orchestrator/run-artifacts.ts +5 -177
  66. package/src/orchestrator/run-context.ts +55 -0
  67. package/src/orchestrator/run-failures.ts +15 -3
  68. package/src/orchestrator/run-records.ts +35 -3
  69. package/src/orchestrator/run-report.ts +81 -179
  70. package/src/orchestrator/run-settings.ts +1 -16
  71. package/src/orchestrator/run-stop.ts +153 -0
  72. package/src/orchestrator/run.ts +260 -601
  73. package/src/orchestrator/sandbox-fix.ts +0 -16
  74. package/src/orchestrator/signals.ts +14 -6
  75. package/src/orchestrator/stable-keys.ts +107 -3
  76. package/src/orchestrator/steps.ts +50 -0
  77. package/src/orchestrator/task-hash.ts +3 -16
  78. package/src/orchestrator/task-log-buffer.ts +1 -1
  79. package/src/orchestrator/task-process.ts +94 -0
  80. package/src/orchestrator/telemetry-host.ts +1 -1
  81. package/src/orchestrator/telemetry.ts +5 -82
  82. package/src/{orchestrator → report}/events.ts +41 -8
  83. package/src/{orchestrator → report}/forecast.ts +35 -2
  84. package/src/{orchestrator → report}/framed-output.ts +39 -33
  85. package/src/report/index.ts +36 -0
  86. package/src/{orchestrator → report}/logger.ts +158 -20
  87. package/src/{orchestrator → report}/status-line.ts +84 -7
  88. package/src/{orchestrator → report}/summary.ts +126 -35
  89. package/src/report/tally.ts +83 -0
  90. package/src/settings.ts +1 -22
  91. package/src/util/colors.ts +44 -4
  92. package/src/util/git-ignored.ts +143 -0
  93. package/src/util/index.ts +19 -4
  94. package/src/util/report.ts +13 -5
  95. package/src/util/settle.ts +14 -39
  96. package/src/util/tail.ts +1 -1
  97. package/src/util/ui.ts +17 -6
  98. package/schemas/summary.json +0 -133
  99. package/src/cache/chained-cache.ts +0 -280
  100. package/src/orchestrator/tally.ts +0 -112
  101. /package/src/{orchestrator → report}/path-links.ts +0 -0
  102. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  103. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  // Planning: walk the task graph, compute every task's cache key, and
2
2
  // probe the cache to predict what would happen if `vx run` actually
3
- // ran. Used by `--dry-run` (preview output) and `--graph` (DOT export).
3
+ // ran. Used by `--dry`.
4
4
  //
5
5
  // No TASK execution happens: no task spawn, no cleanOutputs, no
6
6
  // restoreOutputs. Cache probing uses the byte-free `cache.has()`
@@ -13,10 +13,10 @@
13
13
 
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
15
  import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
16
- import { ranNoCache } from './events.js'
16
+ import { ranNoCache } from '../report/index.js'
17
17
  import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
18
18
  import type { AffectedReason } from './affected-tasks.js'
19
- import type { HistoryProvider } from './history.js'
19
+ import type { HistoryProvider } from '../history/index.js'
20
20
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
21
21
  import { keyedOutcome, keyUpstream } from './upstream.js'
22
22
 
@@ -139,6 +139,9 @@ export interface PlanArgs {
139
139
  */
140
140
  export async function plan(args: PlanArgs): Promise<RunPlan> {
141
141
  const cacheStatusById = new Map<string, CacheStatus>()
142
+ // Keyed first, probed after: a remote that can batch (`hasMany`) is
143
+ // asked once for every key, one that cannot is asked per key.
144
+ const probed = new Map<string, string>()
142
145
 
143
146
  const outcomes = await runGraph({
144
147
  nodes: args.nodes,
@@ -176,13 +179,23 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
176
179
  } else if (!reads) {
177
180
  status = 'miss'
178
181
  } else {
179
- const where = await args.cache.has(hash)
180
- status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
182
+ probed.set(node.id, hash)
183
+ return keyedOutcome(node, hash)
181
184
  }
182
185
  cacheStatusById.set(node.id, status)
183
186
  return keyedOutcome(node, hash)
184
187
  },
185
188
  })
189
+ if (probed.size > 0) {
190
+ await args.cache.remoteHasMany?.([...new Set(probed.values())])
191
+ for (const [id, hash] of probed) {
192
+ const where = await args.cache.has(hash)
193
+ cacheStatusById.set(
194
+ id,
195
+ where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
196
+ )
197
+ }
198
+ }
186
199
 
187
200
  if (args.restorable !== undefined) {
188
201
  const restoreTier = new Set(
@@ -10,13 +10,12 @@
10
10
  import path from 'node:path'
11
11
  import {
12
12
  type Cache,
13
- ChainedCache,
14
13
  type CacheLayer,
15
14
  type CachePolicy,
16
15
  LayeredCache,
17
16
  absentPluginCache,
18
17
  type PluginCache,
19
- type PluginCacheRemote,
18
+ type ResolvedRemote,
20
19
  } from '../cache/index.js'
21
20
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
22
21
  import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
@@ -485,70 +484,39 @@ export interface CacheHost extends CacheContext {
485
484
  readonly localCache: Cache
486
485
  readonly policy: CachePolicy
487
486
  /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
488
- readonly remotes?: PluginCacheRemote[]
487
+ readonly remotes?: ResolvedRemote[]
489
488
  }
490
489
 
491
490
  /**
492
491
  * Collect every plugin's remote (`cache` hook) in declaration order and
493
492
  * layer each over the local store: a lookup asks the local store first,
494
493
  * then each remote in order (owner, 2026-10-13), and a save reaches every
495
- * remote. The run's policy, narrowed by a remote's `access`, and its scope
496
- * gate each remote; a remote's throw is a miss and a warning naming its
497
- * plugin. No remote → the local store alone.
494
+ * remote. The run's policy, narrowed by a remote's `access`, gates each
495
+ * remote; a remote's throw is a miss and a warning naming its plugin. No
496
+ * remote → the local store alone.
498
497
  */
499
498
  export async function resolveCache(
500
499
  plugins: readonly VxPlugin[],
501
500
  host: CacheHost,
502
501
  ): Promise<CacheLayer> {
503
502
  const { localCache, policy, remotes, ...ctx } = host
504
- const layers: CacheLayer[] = []
505
- const owners: string[] = []
503
+ const resolved: ResolvedRemote[] = []
506
504
  for (const plugin of plugins) {
507
505
  if (plugin.cache === undefined) continue
508
506
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
509
507
  if (remote === undefined) continue
510
- // The hook returned a whole layer until 0.0.637: name the fix, not the
511
- // three methods such a layer happens to have.
512
- if (isRecordLike(remote) && typeof remote['restoreOutputs'] === 'function') {
513
- throw new UserError(
514
- `plugin '${plugin.name}' returned a cache layer from cache: since vx 0.0.637 the hook ` +
515
- 'returns the remote alone (a RemoteCacheLayer: has, get, put) and core layers it over ' +
516
- 'the local cache — return the remote you passed to LayeredCache',
517
- )
518
- }
519
508
  assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
520
509
  const access = remote.access
521
- const own: CachePolicy = {
522
- ...policy,
523
- remoteRead: policy.remoteRead && access?.read !== false,
524
- remoteWrite: policy.remoteWrite && access?.write !== false,
510
+ const entry: ResolvedRemote = {
511
+ remote,
512
+ read: policy.remoteRead && access?.read !== false,
513
+ write: policy.remoteWrite && access?.write !== false,
514
+ onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
525
515
  }
526
- remotes?.push({ remote, read: own.remoteRead, write: own.remoteWrite })
527
- layers.push(
528
- new LayeredCache(localCache, remote, {
529
- policy: own,
530
- onRemoteError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
531
- }),
532
- )
533
- owners.push(`the cache layer of plugin '${plugin.name}'`)
516
+ resolved.push(entry)
517
+ remotes?.push(entry)
534
518
  }
535
- if (layers.length === 0) return localCache
536
- if (layers.length === 1) return layers[0]!
537
- // One line per layer and method: a layer that throws on every lookup
538
- // would otherwise print once per task.
539
- const told = new Set<string>()
540
- return new ChainedCache(layers, (i, method, err) => {
541
- if (told.has(`${i}\0${method}`)) return
542
- told.add(`${i}\0${method}`)
543
- const m = err instanceof Error ? err.message : String(err)
544
- const then =
545
- method === 'save' ? 'the other layers still save' : 'a miss there; the next layer answers'
546
- ctx.warn(`[vx] ${owners[i]} failed in cache ${method}: ${m}; ${then}`)
547
- })
548
- }
549
-
550
- function isRecordLike(v: unknown): v is Record<string, unknown> {
551
- return typeof v === 'object' && v !== null
519
+ return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
552
520
  }
553
521
 
554
522
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
@@ -581,6 +549,55 @@ export function executorLabel(executor: TaskExecutor): string {
581
549
  : `plugin '${plugin}' (executor '${executor.name}')`
582
550
  }
583
551
 
552
+ /**
553
+ * Demand: what each executor still has left to run. An executor that
554
+ * provisions per task (a container, an allocation, a pod) otherwise has to
555
+ * guess from `capacity` alone and hold everything until teardown. Built
556
+ * ONLY for executors that asked: with none declared there is no map, no
557
+ * set, and the returned narrower does nothing.
558
+ *
559
+ * The SAME set is handed back every time, narrowed in place: a consumer
560
+ * reading it later sees the truth rather than a stale snapshot. A hint,
561
+ * as `admit` is: one that throws is named once and asked no more, where
562
+ * it surfaced as a bare stack, or mid-run from the completion path
563
+ * (item 1022). Returns the call that drops a finished task's id.
564
+ */
565
+ export function executorDemand(
566
+ placed: ReadonlyMap<string, TaskExecutor>,
567
+ warn: (message: string) => void,
568
+ ): (id: string) => void {
569
+ const demandOf = new Map<TaskExecutor, Set<string>>()
570
+ for (const [id, executor] of placed) {
571
+ if (executor.demand === undefined) continue
572
+ let remaining = demandOf.get(executor)
573
+ if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
574
+ remaining.add(id)
575
+ }
576
+ if (demandOf.size === 0) return () => {}
577
+ const tell = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
578
+ const failed = (err: unknown): void => {
579
+ if (!demandOf.delete(executor)) return
580
+ const m = err instanceof Error ? err.message : String(err)
581
+ warn(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
582
+ }
583
+ try {
584
+ // An `async demand` that rejects did so where no one listened: an
585
+ // unhandled rejection, a stack, the run killed. Its rejection is a
586
+ // throw like any other.
587
+ const ret: unknown = executor.demand!(remaining)
588
+ if (ret instanceof Promise) ret.catch(failed)
589
+ } catch (err) {
590
+ failed(err)
591
+ }
592
+ }
593
+ for (const [executor, remaining] of demandOf) tell(executor, remaining)
594
+ return (id) => {
595
+ for (const [executor, remaining] of demandOf) {
596
+ if (remaining.delete(id)) tell(executor, remaining)
597
+ }
598
+ }
599
+ }
600
+
584
601
  /**
585
602
  * Collect every plugin's `executor`, in declaration order, with core's
586
603
  * own appended at the tail: per task, `selectExecutor` takes the first
@@ -638,12 +655,15 @@ export async function teardownPlugins(
638
655
  plugins: readonly VxPlugin[],
639
656
  warn: (message: string) => void,
640
657
  report?: RunReport,
658
+ step: <T>(id: string, work: () => Promise<T>) => Promise<T> = (_id, work) => work(),
641
659
  ): Promise<void> {
642
660
  for (const plugin of plugins) {
643
661
  if (plugin.teardown === undefined) continue
644
662
  const ms = teardownTimeoutMs()
645
663
  try {
646
- const settled = await settleWithin(Promise.resolve(plugin.teardown(report)), ms)
664
+ const settled = await step(`${plugin.name}:teardown`, () =>
665
+ settleWithin(Promise.resolve(plugin.teardown?.(report)), ms),
666
+ )
647
667
  // Same rule as the flush above. A teardown that never settles has left
648
668
  // whatever it owns un-released, and the run exits anyway — silence would
649
669
  // present that as a clean shutdown.
@@ -22,10 +22,9 @@ import type { TaskExecutor } from '../exec/index.js'
22
22
  import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
23
23
  import { maskedLine, nearest, UserError } from '../util/index.js'
24
24
  import type { ProjectMeta } from '../workspace/index.js'
25
- import type { EventBus, RunStartInfo } from './events.js'
25
+ import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
26
26
  import type { CiContext } from './run-context.js'
27
27
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
28
- import type { Logger } from './logger.js'
29
28
 
30
29
  /**
31
30
  * A vx plugin. Contributes any subset of the run-level capabilities —
@@ -462,12 +461,10 @@ export interface CacheContext extends BaseContext {
462
461
  }
463
462
 
464
463
  export interface OutputContext extends BaseContext {
465
- /** Core would write to a terminal: stdout (stderr under `json`) is a TTY. */
464
+ /** Core would write to a terminal: stdout is a TTY. */
466
465
  readonly isTTY: boolean
467
466
  /** The run is on CI (`CI` set, or a provider core detects). */
468
467
  readonly ci: boolean
469
- /** `--format json`: stdout carries the machine-readable result. */
470
- readonly json: boolean
471
468
  }
472
469
 
473
470
  export interface ExecutorContext extends BaseContext {
@@ -688,6 +685,8 @@ export interface InstallPluginsArgs {
688
685
  ci?: boolean
689
686
  /** Default null. */
690
687
  branch?: string | null
688
+ /** Each setup as the step `<plugin>:setup`; default: untimed. */
689
+ step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
691
690
  }
692
691
 
693
692
  /**
@@ -824,7 +823,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
824
823
  }
825
824
 
826
825
  try {
827
- await plugin.setup(ctx)
826
+ if (args.step === undefined) await plugin.setup(ctx)
827
+ else await args.step(`${plugin.name}:setup`, async () => plugin.setup?.(ctx))
828
828
  } catch (err) {
829
829
  // Earlier plugins' subscriptions too: the caller never gets the
830
830
  // disposer, and a bus that outlives the run would keep them.
@@ -24,7 +24,7 @@ import {
24
24
  putRunEntry,
25
25
  writesRunStore,
26
26
  type PluginCache,
27
- type PluginCacheRemote,
27
+ type ResolvedRemote,
28
28
  ScopedCache,
29
29
  applyGitEnumeration,
30
30
  gitPathspecs,
@@ -78,9 +78,10 @@ import { applyGraphStage } from './graph-stage.js'
78
78
  import { keyExcludedDependencies } from './excluded-keys.js'
79
79
  import { FingerprintWatch } from './fingerprint-watch.js'
80
80
  import type { VxPlugin } from './plugin.js'
81
+ import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
81
82
  import { currentBranch } from './run-context.js'
82
83
  import { createHashCache, type HashCache } from './task-hash.js'
83
- import type { Logger } from './logger.js'
84
+ import { type Logger, warnOf } from '../report/index.js'
84
85
  import type { RunOptions } from './options.js'
85
86
  import { refuseFiltersThroughGroups } from './upstream.js'
86
87
  import { runRules } from '../settings.js'
@@ -241,9 +242,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
241
242
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
242
243
  lazyGitEnumeration(workspaceRoot)
243
244
  if (wholeTree) void git.start()
244
- const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
245
- log.status(m),
246
- )
245
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
247
246
  mark('workspace config')
248
247
  let learnedBranch: string | null | undefined
249
248
  const branch = (): string | null =>
@@ -263,14 +262,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
263
262
  const projectMetas =
264
263
  reused !== undefined
265
264
  ? reused.projects
266
- : await discoverProjects(
267
- workspaceRoot,
268
- readLock,
269
- plugins,
270
- cacheDir,
271
- (m) => log.status(m),
272
- git,
273
- )
265
+ : await discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
274
266
  mark('discover projects')
275
267
 
276
268
  // SCOPED config loading: configs are programs, and evaluating 1090
@@ -368,7 +360,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
368
360
  projectMetas,
369
361
  packageGraph,
370
362
  configs: readLock,
371
- warn: (m: string) => log.status(m),
363
+ warn: warnOf(log),
372
364
  }
373
365
  let loaded: LoadedProjects
374
366
  try {
@@ -464,21 +456,22 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
464
456
  // winning prevents double-wrapping when the workspace also declares a
465
457
  // cache plugin.
466
458
  let cache: CacheLayer
467
- const remotes: PluginCacheRemote[] = []
459
+ const remotes: ResolvedRemote[] = []
468
460
  if (options.remoteCache) {
469
- const { remoteCache } = options
470
- remotes.push({ remote: remoteCache, read: policy.remoteRead, write: policy.remoteWrite })
461
+ remotes.push({
462
+ remote: options.remoteCache,
463
+ read: policy.remoteRead,
464
+ write: policy.remoteWrite,
465
+ onError: (err) => warnOf(log)(`[vx] remote cache: ${err.message}`),
466
+ })
471
467
  }
472
468
  try {
473
469
  cache = options.remoteCache
474
- ? new LayeredCache(localCache, options.remoteCache, {
475
- policy,
476
- onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
477
- })
470
+ ? new LayeredCache(localCache, remotes)
478
471
  : await resolveCache(plugins, {
479
472
  workspaceRoot,
480
473
  cacheDir,
481
- warn: (m) => log.status(m),
474
+ warn: warnOf(log),
482
475
  scope: cacheScope,
483
476
  localCache,
484
477
  policy,
@@ -489,7 +482,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
489
482
  // refused by name; the local handle opened above must not leak with it,
490
483
  // nor what another plugin's factory opened: the factories have run
491
484
  // (C-4, item 1029's contract).
492
- await teardownPlugins(plugins, (m) => log.status(m))
485
+ await teardownPlugins(plugins, warnOf(log))
493
486
  localCache.close()
494
487
  throw err
495
488
  }
@@ -581,7 +574,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
581
574
  workspaceRoot,
582
575
  cacheDir,
583
576
  projectMetas,
584
- warn: (m) => log.status(m),
577
+ warn: warnOf(log),
585
578
  rules: graphRules,
586
579
  })
587
580
  // `--affected` selects over the FINAL graph: an edge or an input a
@@ -678,11 +671,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
678
671
  if ((n.config.cache?.inputs.workspaceFiles?.length ?? 0) > 0) usesWorkspaceInputs = true
679
672
  }
680
673
  const projectDirs = [...graphDirs]
681
- const enumeration = await (git.started ??
674
+ const enumerating =
675
+ git.started ??
682
676
  startGitEnumeration(
683
677
  workspaceRoot,
684
678
  gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
685
- ))
679
+ )
680
+ // While git walks: the gate's pass over every task cost 2-8 ms at
681
+ // 1,000 projects when it ran before the spawn.
682
+ const settleIgnored = askIgnoredOutputs(gatedOutputs(nodes, workspaceRoot), workspaceRoot)
683
+ const enumeration = await enumerating
686
684
  await applyGitEnumeration(
687
685
  enumeration,
688
686
  workspaceRoot,
@@ -691,6 +689,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
691
689
  usesWorkspaceInputs,
692
690
  localCache,
693
691
  )
692
+ await settleIgnored(gitFilesCache)
694
693
  mark('git enumeration')
695
694
  if (hasGraphHook && !hooksFirst) await graphStage()
696
695
  // Args after `--` go to requested commands only; with none among them
@@ -706,7 +705,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
706
705
  }
707
706
  }
708
707
  if (hasHook(plugins, 'key')) {
709
- await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
708
+ await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
710
709
  }
711
710
  refuseFiltersThroughGroups(nodes)
712
711
  const exclude = options.excludeDependencies
@@ -752,7 +751,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
752
751
  {
753
752
  workspaceRoot,
754
753
  cacheDir,
755
- warn: (m) => log.status(m),
754
+ warn: warnOf(log),
756
755
  localCache,
757
756
  branch: branch(),
758
757
  },
@@ -790,7 +789,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
790
789
  empty: nodes.size === 0 ? 'empty-graph' : null,
791
790
  }
792
791
  } catch (err) {
793
- await teardownPlugins(plugins, (m) => log.status(m))
792
+ await teardownPlugins(plugins, warnOf(log))
794
793
  cache.close()
795
794
  throw err
796
795
  }
@@ -41,6 +41,11 @@ export interface PrefetchArgs {
41
41
  concurrency: number
42
42
  /** When false (remote reads disabled), no prefetch fires. */
43
43
  remoteRead: boolean
44
+ /**
45
+ * With reads off and writes on (`--force`), the keys go to
46
+ * `probeBeforeUpload` instead, so a key a remote holds is not re-sent.
47
+ */
48
+ remoteWrite: boolean
44
49
  }
45
50
 
46
51
  /**
@@ -55,7 +60,15 @@ export interface PrefetchArgs {
55
60
  * is always correct.
56
61
  */
57
62
  export function startRemotePrefetch(args: PrefetchArgs): Promise<void> {
58
- if (!args.remoteRead) return Promise.resolve()
63
+ if (!args.remoteRead) {
64
+ if (!args.remoteWrite || args.cache.probeBeforeUpload === undefined) return Promise.resolve()
65
+ const hashes = deriveStableKeys(args).then((keys) => [...new Set(keys.map((k) => k.hash))])
66
+ args.cache.probeBeforeUpload(hashes)
67
+ return hashes.then(
68
+ () => {},
69
+ () => {},
70
+ )
71
+ }
59
72
  // Detached from EXECUTION: the caller does NOT await this before
60
73
  // scheduling, so prefetch network latency overlaps the run. But the
61
74
  // caller DOES await the returned handle before closing the cache, so
@@ -1,182 +1,10 @@
1
- // Optional per-run output artifacts:
2
- //
3
- // --summarize[=<path>] → JSON describing every task's outcome
4
- // (id, status, exit code, durations, hash,
5
- // cpu_ms, peak_rss_bytes, hrtime spans).
6
- // Default path: <cacheDir>/runs/<run_id>.json.
7
- //
8
- // --profile[=<path>] → Chrome-trace JSON of the run. Each task is
9
- // a single complete event (`ph: 'X'`) with
10
- // ts / dur in microseconds derived from the
11
- // hrtime spans the runner already captures.
12
- // Default path: profile.json (cwd-relative).
13
- //
14
- // Both are no-ops if the corresponding RunOptions field is undefined.
1
+ // `--profile[=<path>]`: Chrome-trace JSON of the run. Each task is a single
2
+ // complete event (`ph: 'X'`) with ts / dur in microseconds derived from the
3
+ // hrtime spans the runner already captures. Default path: profile.json
4
+ // (cwd-relative).
15
5
 
16
6
  import path from 'node:path'
17
- import { isGroupTask, type TaskOutcome } from '../graph/index.js'
18
- import { tallyOutcomes, type Tally } from './tally.js'
19
- import type { FlakyFinding } from './failure-mode.js'
20
- import { isCacheHit } from './telemetry.js'
21
- import { ranNoCache } from './events.js'
22
-
23
- export interface SummarizeArgs {
24
- /** Empty string → default path; otherwise the explicit file path. */
25
- target: string
26
- cacheDir: string
27
- cwd: string
28
- runId: string
29
- startedAtMs: number
30
- endedAtMs: number
31
- totalMs: number
32
- /** The run's verdict — the same value the CLI turns into the exit code. */
33
- ok: boolean
34
- /** The process's exit code where it is not `ok ? 0 : 1`: a stopping signal's. */
35
- exitCode?: number
36
- outcomes: readonly TaskOutcome[]
37
- /** What `detectFlaky` found this run; each row's flaky note, typed. */
38
- flaky?: readonly FlakyFinding[]
39
- }
40
-
41
- /** One task of `--summarize`: the wire, stated by `schemas/summary.json`. */
42
- export interface SummaryTaskJson {
43
- id: string
44
- project: string
45
- task: string
46
- status: TaskOutcome['status']
47
- exitCode: number
48
- durationMs: number
49
- hash: string | null
50
- restored?: boolean
51
- noCache?: true
52
- flaky?: { passes: number; failures: number; attempts: number }
53
- cpuMs?: number
54
- peakRssBytes?: number
55
- storedDurationMs?: number
56
- storedCpuMs?: number
57
- storedPeakRssBytes?: number
58
- admissionHeldMs?: number
59
- blockedBy?: string
60
- timedOut?: true
61
- sandboxViolations?: number
62
- notReady?: 'timeout' | 'exited' | 'spawn'
63
- wallclockStartNs?: string
64
- wallclockEndNs?: string
65
- }
66
-
67
- /** The `--summarize` document. */
68
- export interface RunSummaryJson {
69
- runId: string
70
- ok: boolean
71
- exitCode: number
72
- startedAt: string
73
- endedAt: string
74
- totalMs: number
75
- /** What the hits skipped: the sum of their `storedDurationMs`. */
76
- savedMs: number
77
- tasks: SummaryTaskJson[]
78
- aborted: SummaryTaskJson[]
79
- summary: Tally
80
- }
81
-
82
- /** One task's entry. Shared by `tasks` and `aborted` so they read alike. */
83
- function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
84
- return {
85
- id: o.node.id,
86
- project: o.node.projectName,
87
- task: o.node.taskName,
88
- status: o.status,
89
- exitCode: o.exitCode,
90
- durationMs: o.durationMs,
91
- hash: o.hash ?? null,
92
- // On a hit: whether it restored outputs or found them up to date, as
93
- // `summary`'s restoredLocal / restoredRemote / upToDate count it.
94
- ...(isCacheHit(o.status) ? { restored: o.restored === true } : {}),
95
- // A task with no `cache` block executes every run by design; without
96
- // the flag a consumer computing a hit rate cannot tell a miss from a
97
- // task that could never hit (its `hash` is still set — dependents fold
98
- // it). Present only when true, so every other row is byte-identical.
99
- ...(ranNoCache(o) ? { noCache: true } : {}),
100
- // Present only when the run proved the task flaky: this exact key
101
- // failed after it had passed (this run counted), or the task
102
- // needed a retry. A consumer gating on `failed` can tell a break from a
103
- // flake without the history; every other row is byte-identical.
104
- ...(flaky !== undefined
105
- ? { flaky: { passes: flaky.passes, failures: flaky.failures, attempts: flaky.attempts } }
106
- : {}),
107
- ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
108
- ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
109
- // A hit's row names what the PRODUCING execution used under its own
110
- // keys, so a consumer never reads a remote worker's peak as this run's.
111
- ...(o.storedDurationMs !== undefined ? { storedDurationMs: o.storedDurationMs } : {}),
112
- ...(o.storedCpuMs !== undefined ? { storedCpuMs: o.storedCpuMs } : {}),
113
- ...(o.storedPeakRssBytes !== undefined ? { storedPeakRssBytes: o.storedPeakRssBytes } : {}),
114
- // Present only when an `admit` policy held the task with a worker free.
115
- ...(o.admissionHeldMs !== undefined ? { admissionHeldMs: o.admissionHeldMs } : {}),
116
- // A skipped row names the failed (or aborted) task at the root of what
117
- // blocked it; a fail-fast skip has none.
118
- ...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
119
- // A failure vx's own `timeout` killed; absent on every other row.
120
- ...(o.timedOut === true ? { timedOut: true } : {}),
121
- // The sandbox's violation count; present only on a task it recorded any for.
122
- ...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
123
- // A persistent task that never became ready, and why.
124
- ...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
125
- // hrtime spans are bigints → emit as strings so JSON.parse on
126
- // the consumer side doesn't truncate the ns precision.
127
- ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
128
- ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: String(o.wallclockEndNs) } : {}),
129
- }
130
- }
131
-
132
- export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
133
- const outPath =
134
- args.target === ''
135
- ? path.join(args.cacheDir, 'runs', `${args.runId}.json`)
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 {
145
- // `tasks` and `summary` must describe the same population, or one artifact
146
- // contradicts itself (a group task listed in `tasks` but absent from
147
- // `summary.total`). `tallyOutcomes` owns the rule; mirror its filter here.
148
- const counted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status !== 'aborted')
149
- // A task killed by a shutdown signal is in no bucket and no total, yet it
150
- // makes the run red. Listing it separately keeps `tasks.length ===
151
- // summary.total` while leaving the artifact able to explain a non-zero
152
- // exit — the terminal has said so in its Aborted section all along, and a
153
- // parser must not be told less than a human is.
154
- const aborted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status === 'aborted')
155
- const flakyById = new Map((args.flaky ?? []).map((f) => [f.taskId, f]))
156
- const exitCode = args.exitCode ?? (args.ok ? 0 : 1)
157
- const payload: RunSummaryJson = {
158
- runId: args.runId,
159
- // The run-level verdict, first: a consumer gating on this artifact must
160
- // not have to re-derive it by re-implementing the bucket rules.
161
- ok: exitCode === 0,
162
- exitCode,
163
- startedAt: new Date(args.startedAtMs).toISOString(),
164
- endedAt: new Date(args.endedAtMs).toISOString(),
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
- ),
171
- tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
172
- aborted: aborted.map((o) => taskEntry(o)),
173
- // The FULL list: `tallyOutcomes` applies the same group/aborted
174
- // exclusions internally, so every counted bucket is unchanged by passing
175
- // it — but `summary.aborted` is only non-zero if it sees them.
176
- summary: tallyOutcomes(args.outcomes),
177
- }
178
- return payload
179
- }
7
+ import type { TaskOutcome } from '../graph/index.js'
180
8
 
181
9
  export interface ProfileArgs {
182
10
  target: string