@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -24,13 +24,14 @@ 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,
31
31
  MAX_SCOPED_PATHSPECS,
32
32
  startGitEnumeration,
33
33
  lazyGitEnumeration,
34
+ measureFsClockLag,
34
35
  } from '../cache/index.js'
35
36
  import {
36
37
  buildPackageGraph,
@@ -50,6 +51,7 @@ import {
50
51
  expandRequested,
51
52
  type GraphRules,
52
53
  isGroupTask,
54
+ refuseFiltersThroughGroups,
53
55
  type TaskNode,
54
56
  undeclaredDepsError,
55
57
  unresolvedRequests,
@@ -61,6 +63,7 @@ import {
61
63
  hasHook,
62
64
  resolveCache,
63
65
  teardownPlugins,
66
+ type ScheduleRun,
64
67
  } from './plugin-host.js'
65
68
  import {
66
69
  discoverProjects,
@@ -78,11 +81,11 @@ import { applyGraphStage } from './graph-stage.js'
78
81
  import { keyExcludedDependencies } from './excluded-keys.js'
79
82
  import { FingerprintWatch } from './fingerprint-watch.js'
80
83
  import type { VxPlugin } from './plugin.js'
84
+ import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
81
85
  import { currentBranch } from './run-context.js'
82
86
  import { createHashCache, type HashCache } from './task-hash.js'
83
- import type { Logger } from './logger.js'
87
+ import { type Logger, warnOf } from '../report/index.js'
84
88
  import type { RunOptions } from './options.js'
85
- import { refuseFiltersThroughGroups } from './upstream.js'
86
89
  import { runRules } from '../settings.js'
87
90
 
88
91
  export interface PreparedRun {
@@ -113,9 +116,10 @@ export interface PreparedRun {
113
116
  * merged over the structural baseline by the scheduler). Empty when no
114
117
  * plugin declares the stage. Asked only by a run with a task to start:
115
118
  * the stage reads history (148 ms at 8,002 tasks) to order work a
116
- * nothing-changed run never dispatches. Runs the stage once.
119
+ * nothing-changed run never dispatches. Runs the stage once: `run`
120
+ * times each plugin's call and hears its later `ctx.refine` sets.
117
121
  */
118
- priorities: () => Promise<ReadonlyMap<string, number>>
122
+ priorities: (run?: ScheduleRun) => Promise<ReadonlyMap<string, number>>
119
123
  /**
120
124
  * The run's branch (`run.branch`, else git's, else the CI provider's):
121
125
  * what its history records and the timings a forecast reads first.
@@ -241,9 +245,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
241
245
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
242
246
  lazyGitEnumeration(workspaceRoot)
243
247
  if (wholeTree) void git.start()
244
- const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
245
- log.status(m),
246
- )
248
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
247
249
  mark('workspace config')
248
250
  let learnedBranch: string | null | undefined
249
251
  const branch = (): string | null =>
@@ -257,20 +259,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
257
259
  const cacheDir = options.cacheDir
258
260
  ? path.resolve(options.cwd, options.cacheDir)
259
261
  : resolveCacheDir(workspaceRoot, workspaceConfig)
262
+ // Before any racy window is judged: a file system behind this process's
263
+ // clock dated a mid-command edit before the command (fs-clock.ts).
264
+ measureFsClockLag(cacheDir)
260
265
  // Asked now, read when the cache opens: a repository with no remote is
261
266
  // named by its root commit, a git spawn the discovery below overlaps.
262
267
  const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
263
268
  const projectMetas =
264
269
  reused !== undefined
265
270
  ? reused.projects
266
- : await discoverProjects(
267
- workspaceRoot,
268
- readLock,
269
- plugins,
270
- cacheDir,
271
- (m) => log.status(m),
272
- git,
273
- )
271
+ : await discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
274
272
  mark('discover projects')
275
273
 
276
274
  // SCOPED config loading: configs are programs, and evaluating 1090
@@ -368,7 +366,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
368
366
  projectMetas,
369
367
  packageGraph,
370
368
  configs: readLock,
371
- warn: (m: string) => log.status(m),
369
+ warn: warnOf(log),
372
370
  }
373
371
  let loaded: LoadedProjects
374
372
  try {
@@ -464,21 +462,22 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
464
462
  // winning prevents double-wrapping when the workspace also declares a
465
463
  // cache plugin.
466
464
  let cache: CacheLayer
467
- const remotes: PluginCacheRemote[] = []
465
+ const remotes: ResolvedRemote[] = []
468
466
  if (options.remoteCache) {
469
- const { remoteCache } = options
470
- remotes.push({ remote: remoteCache, read: policy.remoteRead, write: policy.remoteWrite })
467
+ remotes.push({
468
+ remote: options.remoteCache,
469
+ read: policy.remoteRead,
470
+ write: policy.remoteWrite,
471
+ onError: (err) => warnOf(log)(`[vx] remote cache: ${err.message}`),
472
+ })
471
473
  }
472
474
  try {
473
475
  cache = options.remoteCache
474
- ? new LayeredCache(localCache, options.remoteCache, {
475
- policy,
476
- onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
477
- })
476
+ ? new LayeredCache(localCache, remotes)
478
477
  : await resolveCache(plugins, {
479
478
  workspaceRoot,
480
479
  cacheDir,
481
- warn: (m) => log.status(m),
480
+ warn: warnOf(log),
482
481
  scope: cacheScope,
483
482
  localCache,
484
483
  policy,
@@ -489,7 +488,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
489
488
  // refused by name; the local handle opened above must not leak with it,
490
489
  // nor what another plugin's factory opened: the factories have run
491
490
  // (C-4, item 1029's contract).
492
- await teardownPlugins(plugins, (m) => log.status(m))
491
+ await teardownPlugins(plugins, warnOf(log))
493
492
  localCache.close()
494
493
  throw err
495
494
  }
@@ -581,7 +580,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
581
580
  workspaceRoot,
582
581
  cacheDir,
583
582
  projectMetas,
584
- warn: (m) => log.status(m),
583
+ warn: warnOf(log),
585
584
  rules: graphRules,
586
585
  })
587
586
  // `--affected` selects over the FINAL graph: an edge or an input a
@@ -675,14 +674,19 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
675
674
  let usesWorkspaceInputs = false
676
675
  for (const n of nodes.values()) {
677
676
  graphDirs.add(n.projectDir)
678
- if ((n.config.cache?.inputs.workspaceFiles?.length ?? 0) > 0) usesWorkspaceInputs = true
677
+ if ((n.config.inputs?.workspaceFiles?.length ?? 0) > 0) usesWorkspaceInputs = true
679
678
  }
680
679
  const projectDirs = [...graphDirs]
681
- const enumeration = await (git.started ??
680
+ const enumerating =
681
+ git.started ??
682
682
  startGitEnumeration(
683
683
  workspaceRoot,
684
684
  gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
685
- ))
685
+ )
686
+ // While git walks: the gate's pass over every task cost 2-8 ms at
687
+ // 1,000 projects when it ran before the spawn.
688
+ const settleIgnored = askIgnoredOutputs(gatedOutputs(nodes, workspaceRoot), workspaceRoot)
689
+ const enumeration = await enumerating
686
690
  await applyGitEnumeration(
687
691
  enumeration,
688
692
  workspaceRoot,
@@ -691,6 +695,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
691
695
  usesWorkspaceInputs,
692
696
  localCache,
693
697
  )
698
+ await settleIgnored(gitFilesCache)
694
699
  mark('git enumeration')
695
700
  if (hasGraphHook && !hooksFirst) await graphStage()
696
701
  // Args after `--` go to requested commands only; with none among them
@@ -706,7 +711,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
706
711
  }
707
712
  }
708
713
  if (hasHook(plugins, 'key')) {
709
- await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
714
+ await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
710
715
  }
711
716
  refuseFiltersThroughGroups(nodes)
712
717
  const exclude = options.excludeDependencies
@@ -744,7 +749,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
744
749
  return own
745
750
  }
746
751
  let scheduled: Promise<ReadonlyMap<string, number>> | undefined
747
- const priorities = (): Promise<ReadonlyMap<string, number>> =>
752
+ const priorities = (run?: ScheduleRun): Promise<ReadonlyMap<string, number>> =>
748
753
  (scheduled ??= hasHook(plugins, 'schedule')
749
754
  ? applyScheduleHooks(
750
755
  plugins,
@@ -752,11 +757,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
752
757
  {
753
758
  workspaceRoot,
754
759
  cacheDir,
755
- warn: (m) => log.status(m),
760
+ warn: warnOf(log),
756
761
  localCache,
757
762
  branch: branch(),
758
763
  },
759
764
  cacheFor,
765
+ run,
760
766
  )
761
767
  : Promise.resolve(new Map()))
762
768
 
@@ -790,7 +796,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
790
796
  empty: nodes.size === 0 ? 'empty-graph' : null,
791
797
  }
792
798
  } catch (err) {
793
- await teardownPlugins(plugins, (m) => log.status(m))
799
+ await teardownPlugins(plugins, warnOf(log))
794
800
  cache.close()
795
801
  throw err
796
802
  }
@@ -258,7 +258,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
258
258
  // Turbo spells the root package `//` (`dependsOn: ['//#lint']`), as vx
259
259
  // names the root project unless something names it; a named root's
260
260
  // spelling is rewritten to its name once, here, for the closure, the
261
- // graph and `cache.inputs.tasks` alike. With no root project it stays,
261
+ // graph and `inputs.tasks` alike. With no root project it stays,
262
262
  // and the graph's refusal says so.
263
263
  const root = path.resolve(workspaceRoot)
264
264
  const rootName = args.projectMetas.find((m) => path.resolve(m.dir) === root)?.name
@@ -357,10 +357,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
357
357
  */
358
358
  const DEFAULT_BUILD: TaskConfig = Object.freeze({
359
359
  dependsOn: Object.freeze(['^build']),
360
- cache: Object.freeze({
361
- inputs: Object.freeze({ files: Object.freeze(['**']) }),
362
- outputs: Object.freeze({ files: Object.freeze([]) }),
363
- }),
360
+ inputs: Object.freeze({ files: Object.freeze(['**']) }),
364
361
  })
365
362
 
366
363
  /** The default `build`, which a menu of the tasks a user declared leaves out. */
@@ -374,7 +371,7 @@ function withDefaultBuild(config: ProjectConfig): ProjectConfig {
374
371
  }
375
372
 
376
373
  /**
377
- * `config` with Turbo's `//#task` (in `dependsOn` and `cache.inputs.tasks`)
374
+ * `config` with Turbo's `//#task` (in `dependsOn` and `inputs.tasks`)
378
375
  * spelled `<root>#task`; the same object when it holds none. A copy, not
379
376
  * an edit: a loaded config may be a cached or a frozen one.
380
377
  */
@@ -386,14 +383,14 @@ function rootSpelled(config: ProjectConfig, rootName: string): ProjectConfig {
386
383
  let tasks: NonNullable<ProjectConfig['tasks']> | undefined
387
384
  for (const [name, task] of Object.entries(config.tasks ?? {})) {
388
385
  const dependsOn = fix(task.dependsOn)
389
- const inputTasks = fix(task.cache?.inputs?.tasks)
390
- if (dependsOn === task.dependsOn && inputTasks === task.cache?.inputs?.tasks) continue
386
+ const inputTasks = fix(task.inputs?.tasks)
387
+ if (dependsOn === task.dependsOn && inputTasks === task.inputs?.tasks) continue
391
388
  tasks ??= { ...config.tasks }
392
389
  tasks[name] = {
393
390
  ...task,
394
391
  ...(dependsOn !== undefined ? { dependsOn: [...dependsOn] } : {}),
395
- ...(task.cache?.inputs !== undefined && inputTasks !== undefined
396
- ? { cache: { ...task.cache, inputs: { ...task.cache.inputs, tasks: [...inputTasks] } } }
392
+ ...(task.inputs !== undefined && inputTasks !== undefined
393
+ ? { inputs: { ...task.inputs, tasks: [...inputTasks] } }
397
394
  : {}),
398
395
  }
399
396
  }
@@ -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
@@ -15,6 +15,8 @@ import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
17
17
  import { resolveSetting } from '../settings.js'
18
+ import { VERSION } from '../version.js'
19
+ import type { RunContextRecord } from './telemetry.js'
18
20
 
19
21
  export interface GitContext {
20
22
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -522,3 +524,56 @@ export function currentBranch(
522
524
  const set = resolveSetting('run.branch', workspace, flag, env).value as string | undefined
523
525
  return set ?? captureGitContext(workspaceRoot, null, env).branch
524
526
  }
527
+
528
+ export interface TelemetryRunContextArgs {
529
+ runId: string
530
+ /** The invocation, already masked. */
531
+ command: string
532
+ requestedTasks: readonly string[]
533
+ cachePolicy: string
534
+ concurrency: number
535
+ flow: RunContextRecord['flow']
536
+ git: GitContext
537
+ ci: CiContext
538
+ host: HostContext
539
+ workspaceRoot: string
540
+ /** `remote.origin.url` as the run's enumeration read it; see `captureWorkspaceIdentity`. */
541
+ originUrl: string | null | undefined
542
+ }
543
+
544
+ /**
545
+ * The run-context record telemetry sinks receive: the invocation header's
546
+ * git/CI/host data shaped as the export contract. Paid only when a sink can
547
+ * exist: the workspace identity may write `.vx/cache/workspace-id`.
548
+ */
549
+ export function telemetryRunContext(a: TelemetryRunContextArgs): RunContextRecord {
550
+ const ws = captureWorkspaceIdentity(a.workspaceRoot, a.originUrl)
551
+ const { ci } = a
552
+ return {
553
+ runId: a.runId,
554
+ vxVersion: VERSION,
555
+ command: a.command,
556
+ requestedTasks: [...a.requestedTasks],
557
+ cachePolicy: a.cachePolicy,
558
+ concurrency: a.concurrency,
559
+ flow: a.flow,
560
+ commitSha: a.git.commitSha,
561
+ branch: a.git.branch,
562
+ defaultBranch: captureDefaultBranch(process.env, a.workspaceRoot),
563
+ dirty: a.git.dirty,
564
+ ci: ci.ci,
565
+ ciProvider: ci.provider,
566
+ ...(ci.runUrl !== undefined ? { ciRunUrl: ci.runUrl } : {}),
567
+ ...(ci.change !== undefined ? { ciChange: ci.change } : {}),
568
+ ...(ci.pipeline !== undefined ? { ciPipeline: ci.pipeline } : {}),
569
+ ...(ci.job !== undefined ? { ciJob: ci.job } : {}),
570
+ ...(ci.attempt !== undefined ? { ciAttempt: ci.attempt } : {}),
571
+ host: a.host.host,
572
+ os: a.host.os,
573
+ arch: a.host.arch,
574
+ workspaceId: ws.id,
575
+ workspaceName: ws.name,
576
+ ...(ws.repository !== undefined ? { repository: ws.repository } : {}),
577
+ ...(ws.path !== undefined ? { workspacePath: ws.path } : {}),
578
+ }
579
+ }
@@ -8,9 +8,9 @@ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync
8
8
  import path from 'node:path'
9
9
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
10
10
  import { decodeOutputLog } from './output-log.js'
11
- import { PASS_STATUSES } from './history.js'
12
- import { splitTaskId } from '../util/index.js'
13
- import { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
11
+ import { PASS_STATUSES } from '../history/index.js'
12
+ import { isFsRefusal, splitTaskId } from '../util/index.js'
13
+ import { fileLocations, fileMemo, type OutputLocation } from '../report/index.js'
14
14
 
15
15
  /** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
16
16
  const KEPT = 50
@@ -78,14 +78,26 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
78
78
  /**
79
79
  * Write the run's failures, when any outcome carries output, and drop all
80
80
  * but the newest `KEPT` files. Atomic: a reader sees the whole file or none.
81
+ * A refused write (a full disk, a read-only cache dir) costs only this
82
+ * output: `warn` says so and the run's verdict stands.
81
83
  */
82
84
  export function writeRunFailures(
83
85
  cacheDir: string,
84
86
  runId: string,
85
87
  outcomes: readonly TaskOutcome[],
88
+ warn: (message: string) => void,
86
89
  ): void {
87
90
  const tasks = failuresOf(outcomes)
88
91
  if (tasks.length === 0) return
92
+ try {
93
+ keep(cacheDir, runId, tasks)
94
+ } catch (err) {
95
+ if (!isFsRefusal(err)) throw err
96
+ warn(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
97
+ }
98
+ }
99
+
100
+ function keep(cacheDir: string, runId: string, tasks: TaskFailure[]): void {
89
101
  const dir = dirOf(cacheDir)
90
102
  mkdirSync(dir, { recursive: true })
91
103
  const file = path.join(dir, `${runId}.json`)
@@ -10,7 +10,8 @@ import type { InvocationRecord, RunRecord } from '../cache/index.js'
10
10
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
11
11
  import { VERSION } from '../version.js'
12
12
  import type { CiContext, GitContext, HostContext } from './run-context.js'
13
- import { isCacheHit, taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
13
+ import { taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
14
+ import { isCacheHit } from '../graph/index.js'
14
15
 
15
16
  export interface RunRecordsInput {
16
17
  outcomes: readonly TaskOutcome[]
@@ -32,12 +33,13 @@ export interface RunRecordsInput {
32
33
  concurrency: number
33
34
  flow: InvocationRecord['flow']
34
35
  forwardArgs?: readonly string[] | undefined
35
- tags: Record<string, string>
36
36
  git: GitContext
37
37
  ci: CiContext
38
38
  host: HostContext
39
39
  /** Build the telemetry mirror; a run with no sink allocates none. */
40
40
  withTelemetry: boolean
41
+ /** The run's remote failures by key, so far (`Cache.traffic`). */
42
+ remoteTraffic?: ReadonlyMap<string, { failures: readonly string[] }>
41
43
  }
42
44
 
43
45
  export interface RunRecords {
@@ -46,6 +48,15 @@ export interface RunRecords {
46
48
  telemetryTasks: TaskTelemetry[]
47
49
  }
48
50
 
51
+ /** A key's remote failures as its `runs` row stores them; none when it met none. */
52
+ function remoteErrorOf(
53
+ traffic: ReadonlyMap<string, { failures: readonly string[] }> | undefined,
54
+ hash: string | undefined,
55
+ ): string | undefined {
56
+ const failures = hash === undefined ? undefined : traffic?.get(hash)?.failures
57
+ return failures === undefined || failures.length === 0 ? undefined : failures.join('; ')
58
+ }
59
+
49
60
  /**
50
61
  * Every non-group, non-aborted outcome gets a row. An outcome with NO hash
51
62
  * (a `skipped` task never probed the cache; a `persistent` one is never
@@ -71,6 +82,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
71
82
  if (o.status === 'aborted') continue
72
83
  if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
73
84
  const blockedBy = o.blockedBy ?? input.tainted?.get(o.node.id)
85
+ const remoteError = remoteErrorOf(input.remoteTraffic, o.hash)
74
86
  runs.push({
75
87
  ...(o.hash !== undefined ? { hash: o.hash } : {}),
76
88
  project: o.node.projectName,
@@ -113,6 +125,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
113
125
  ...(o.timedOut === true ? { timedOut: true as const } : {}),
114
126
  ...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
115
127
  ...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
128
+ ...(remoteError !== undefined ? { remoteError } : {}),
116
129
  })
117
130
  if (o.status === 'failed') failedCount++
118
131
  if (o.status === 'cache-hit') hitLocalCount++
@@ -151,7 +164,26 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
151
164
  os: input.host.os,
152
165
  arch: input.host.arch,
153
166
  vxVersion: VERSION,
154
- tags: JSON.stringify(input.tags),
155
167
  }
156
168
  return { runs, invocation, telemetryTasks }
157
169
  }
170
+
171
+ /**
172
+ * The remote failures uploads met after the run's rows went in (the uploads
173
+ * drain later): each row whose text is now short of its key's is rewritten.
174
+ */
175
+ export function recordLateRemoteErrors(
176
+ cache: {
177
+ readonly traffic: { readonly keys: ReadonlyMap<string, { failures: readonly string[] }> }
178
+ recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void
179
+ },
180
+ runs: readonly RunRecord[],
181
+ runId: string,
182
+ ): void {
183
+ const late: [string, string][] = []
184
+ for (const r of runs) {
185
+ const now = remoteErrorOf(cache.traffic.keys, r.hash)
186
+ if (now !== undefined && now !== r.remoteError) late.push([r.hash!, now])
187
+ }
188
+ if (late.length > 0) cache.recordRemoteErrors(runId, late)
189
+ }