@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,270 @@
1
+ // The project-config load every consumer of "what tasks exist" shares:
2
+ // `prepareRun` (a run, a plan) and `vx show` (introspection). One code
3
+ // path, so what `show` prints is what a run would see — including the
4
+ // plugin `project` stage, which can give tasks to a package that never
5
+ // wrote a `vx.config.ts`.
6
+
7
+ import type { ProjectConfig } from '../config.js'
8
+ import {
9
+ frozenProjectConfig,
10
+ loadProjectConfigs,
11
+ validateProjectConfig,
12
+ type LoadProjectConfigOptions,
13
+ type LoadReads,
14
+ type Lockfile,
15
+ type PackageGraph,
16
+ type ProjectEntry,
17
+ type ProjectMeta,
18
+ } from '../workspace/index.js'
19
+ import type { WorkspaceConfig } from '../config.js'
20
+ import { Cache, noteSchemaReset } from '../cache/index.js'
21
+ import {
22
+ buildPackageGraph,
23
+ computeWorkspaceFingerprint,
24
+ listProjects,
25
+ loadWorkspace,
26
+ loadWorkspaceConfig,
27
+ resolveCacheDir,
28
+ } from '../workspace/index.js'
29
+ import { parseDependencySpec } from '../graph/index.js'
30
+ import { applyConfigHooks, applyProjectHooks, hasHook } from './plugin-host.js'
31
+ import type { VxPlugin } from './plugin.js'
32
+
33
+ /**
34
+ * The workspace config with the plugin `config` stage applied — the first
35
+ * stage of the pipeline, run before anything is derived from the config.
36
+ * The plugin list itself is already fixed by then.
37
+ */
38
+ export async function loadWorkspacePlugins(
39
+ workspaceRoot: string,
40
+ warn: (message: string) => void,
41
+ ): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
42
+ const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
43
+ const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
44
+ if (workspaceConfig !== null && hasHook(plugins, 'config')) {
45
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn })
46
+ }
47
+ return { workspaceConfig, plugins }
48
+ }
49
+
50
+ export interface LoadProjectsArgs {
51
+ workspaceRoot: string
52
+ cacheDir: string
53
+ plugins: readonly VxPlugin[]
54
+ projectMetas: readonly ProjectMeta[]
55
+ packageGraph: PackageGraph
56
+ /**
57
+ * Projects whose configs must load — `'all'`, or names (unknown and
58
+ * config-less ones are ignored). With `closure`, each seed's transitive
59
+ * package dependencies load too, which bounds `^task` frontier
60
+ * expansion; a `pkg#task` dependsOn entry pulls its project in either
61
+ * way, since the package graph cannot see the cross form.
62
+ */
63
+ seeds: 'all' | Iterable<string>
64
+ closure: boolean
65
+ /** Read configs from the lock instead of evaluating them (`--frozen`). */
66
+ lock: Lockfile | null
67
+ evalCache: LoadProjectConfigOptions['evalCache']
68
+ warn: (message: string) => void
69
+ /**
70
+ * Entries a load in this same process already produced with the same
71
+ * cache dir and lock (the CLI's selection pass, which stages every
72
+ * config to walk `pkg#task` edges). Seeding and scoping run exactly as
73
+ * without it; a project found here is taken as is instead of being
74
+ * evaluated and put through the `project` stage a second time — the
75
+ * stage's cost, and its warnings, once per run. Never carried across
76
+ * runs: a config can change between them.
77
+ */
78
+ staged?: ReadonlyMap<string, ProjectEntry>
79
+ }
80
+
81
+ export interface LoadedProjects {
82
+ projects: Map<string, ProjectEntry>
83
+ /**
84
+ * Every project that can carry tasks: it has a config file, or a plugin
85
+ * fills the `project` stage and may give it tasks. Boundary geometry
86
+ * fences all of them, loaded or not.
87
+ */
88
+ configured: readonly ProjectMeta[]
89
+ }
90
+
91
+ /**
92
+ * SCOPED config loading: configs are programs, and evaluating 1090 of
93
+ * them costs ~200 ms — the dominant fixed cost of small runs. Only the
94
+ * seeds, their closure and any project a `pkg#task` entry names can
95
+ * contribute graph nodes, so only those configs are evaluated. Side
96
+ * effect, deliberate and Turbo-like: a broken config in an unrelated
97
+ * package does not fail a scoped run — it surfaces when that package
98
+ * enters scope.
99
+ */
100
+ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
101
+ const { plugins, packageGraph, lock, workspaceRoot } = args
102
+ // A package with no config file declares no tasks — unless a plugin
103
+ // fills the `project` stage, in which case it is a project the stage may
104
+ // give tasks to (the zero-migration shape: `turbo.json` or `package.json`
105
+ // scripts mapped onto packages that never wrote a `vx.config.ts`). It
106
+ // then loads as `{ tasks: {} }` — nothing to evaluate, nothing to freeze
107
+ // — and the stage runs on that like on any loaded config. With no
108
+ // `project` plugin a plain run never visits (or fences, or seeds) a
109
+ // config-less package.
110
+ const projectStage = hasHook(plugins, 'project')
111
+ const configured = args.projectMetas.filter(
112
+ (m) => projectStage || (typeof m.configPath === 'string' && m.configPath.length > 0),
113
+ )
114
+ const metaByName = new Map<string, ProjectMeta>(configured.map((m) => [m.name, m]))
115
+ const needed = new Set<string>()
116
+ const pending: ProjectMeta[] = []
117
+ // Counted, not read off `pending`: a round empties `pending`, and a guard
118
+ // on its length let every cross edge after the first round walk its
119
+ // whole closure again (1,500 walks at 300 projects, item 932).
120
+ let neededConfigured = 0
121
+ const consider = (name: string): void => {
122
+ if (needed.has(name)) return
123
+ needed.add(name)
124
+ const meta = metaByName.get(name)
125
+ if (meta) {
126
+ pending.push(meta)
127
+ neededConfigured++
128
+ }
129
+ }
130
+ const walked = new Set<string>()
131
+ const considerWithDeps = (name: string): void => {
132
+ consider(name)
133
+ // Every config-bearing project already considered: the closure can
134
+ // add nothing, and asking for it would build the package graph's
135
+ // transitive bitsets — a cost the unscoped run (every project a seed)
136
+ // otherwise never pays. A closure already walked adds nothing either.
137
+ if (neededConfigured === metaByName.size || walked.has(name)) return
138
+ walked.add(name)
139
+ for (const dep of packageGraph.transitiveDeps(name)) consider(dep)
140
+ }
141
+ const seeds = args.seeds === 'all' ? metaByName.keys() : [...args.seeds]
142
+ for (const seed of seeds) consider(seed)
143
+ if (args.closure && pending.length < metaByName.size) {
144
+ for (const seed of args.seeds === 'all' ? metaByName.keys() : seeds) considerWithDeps(seed)
145
+ }
146
+
147
+ // Load in rounds to a fixpoint. A `pkg#task` dependsOn entry names a
148
+ // project the PACKAGE graph cannot reach (the cross form ignores npm
149
+ // deps by design), so its config has to be pulled in — and that config
150
+ // may declare cross edges of its own. The common case (no cross deps)
151
+ // is a single round, identical to loading the closure in one batch.
152
+ const projects = new Map<string, ProjectEntry>()
153
+ while (pending.length > 0) {
154
+ const round = pending.splice(0, pending.length)
155
+ // Only a config FILE is evaluated (or read from the lock); a
156
+ // config-less project in the round starts from an empty task table,
157
+ // and a project the caller already staged is neither.
158
+ const withFile = round.filter(
159
+ (m): m is ProjectMeta & { configPath: string } =>
160
+ typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
161
+ )
162
+ const loaded = lock
163
+ ? await Promise.all(withFile.map((m) => frozenProjectConfig(lock, m, workspaceRoot)))
164
+ : await loadProjectConfigs(
165
+ withFile.map((m) => m.configPath),
166
+ args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
167
+ )
168
+ let next = 0
169
+ for (const meta of round) {
170
+ const pre = args.staged?.get(meta.name)
171
+ if (pre !== undefined) {
172
+ projects.set(meta.name, pre)
173
+ for (const name of crossDepProjects(pre.config)) {
174
+ if (args.closure) considerWithDeps(name)
175
+ else consider(name)
176
+ }
177
+ continue
178
+ }
179
+ const config: ProjectConfig =
180
+ meta.configPath === null ? { tasks: {} } : (loaded[next++] as ProjectConfig)
181
+ if (projectStage) {
182
+ // The `project` stage edits the validated object in place; core
183
+ // re-validates after EACH plugin so a plugin can only produce what
184
+ // the loader accepts from a user, and the refusal names both the
185
+ // config file and the plugin whose edit broke it.
186
+ const where = meta.configPath ?? `${meta.name} (no config file)`
187
+ await applyProjectHooks(
188
+ plugins,
189
+ config,
190
+ {
191
+ workspaceRoot,
192
+ cacheDir: args.cacheDir,
193
+ warn: args.warn,
194
+ name: meta.name,
195
+ dir: meta.dir,
196
+ packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
197
+ projects: args.projectMetas,
198
+ },
199
+ (plugin) => validateProjectConfig(config, `${where} (after plugin '${plugin.name}')`),
200
+ )
201
+ }
202
+ projects.set(meta.name, { name: meta.name, dir: meta.dir, config })
203
+ for (const name of crossDepProjects(config)) {
204
+ if (args.closure) considerWithDeps(name)
205
+ else consider(name)
206
+ }
207
+ }
208
+ }
209
+ return { projects, configured }
210
+ }
211
+
212
+ /** Project names named by a `pkg#task` dependsOn entry anywhere in `config`. */
213
+ function crossDepProjects(config: ProjectConfig): string[] {
214
+ const out: string[] = []
215
+ for (const task of Object.values(config.tasks ?? {})) {
216
+ for (const raw of task.dependsOn ?? []) {
217
+ // A malformed spec is the graph builder's error to report — it names
218
+ // the offending task. Here it just contributes no project.
219
+ if (!raw.includes('#')) continue
220
+ try {
221
+ const spec = parseDependencySpec(raw)
222
+ if (spec.kind === 'cross') out.push(spec.project)
223
+ } catch {
224
+ continue
225
+ }
226
+ }
227
+ }
228
+ return out
229
+ }
230
+
231
+ /**
232
+ * The run path's view of a workspace's projects for a reader — `vx show`,
233
+ * the MCP server, an embedder: discovery, the plugin `config` and
234
+ * `project` stages, and the local cache opened only to serve cached
235
+ * evaluations, so a pure config costs a stat, not an evaluation. `scope`
236
+ * is every project or a list of names; no closure, no lock (a reader
237
+ * reads live, as a default run does). Plugin warnings go to `warn`.
238
+ */
239
+ export async function loadResolvedProjects(
240
+ workspaceRoot: string,
241
+ opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {},
242
+ ): Promise<Map<string, ProjectEntry>> {
243
+ const warn = opts.warn ?? ((): void => {})
244
+ const reads: LoadReads = new Map()
245
+ const metas = await listProjects(await loadWorkspace(workspaceRoot, reads))
246
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
247
+ const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
248
+ const cache = new Cache(cacheDir, { read: true, write: true }, workspaceRoot)
249
+ noteSchemaReset(cache, warn)
250
+ try {
251
+ const loaded = await loadProjects({
252
+ workspaceRoot,
253
+ cacheDir,
254
+ plugins,
255
+ projectMetas: metas,
256
+ packageGraph: buildPackageGraph([...metas]),
257
+ seeds: opts.scope ?? 'all',
258
+ closure: false,
259
+ lock: null,
260
+ evalCache: {
261
+ store: cache,
262
+ workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot, reads),
263
+ },
264
+ warn,
265
+ })
266
+ return loaded.projects
267
+ } finally {
268
+ cache.close()
269
+ }
270
+ }
@@ -70,7 +70,7 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
70
70
  // tells us which of the N stable hashes exist remotely, so we GET only the
71
71
  // hits and pre-mark the misses — their lazy `get` then short-circuits with
72
72
  // no network. This collapses N probe waves into 1 and skips every GET that
73
- // would 404. When the remote can't batch (`null` — an older serve, reads
73
+ // would 404. When the remote can't batch (`null` — an older remote, reads
74
74
  // disabled, or a layer that doesn't implement the hook at all), fall back to
75
75
  // prefetching every stable key, exactly as before.
76
76
  let toPrefetch = stableKeys
@@ -78,6 +78,11 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
78
78
  const present = (await args.cache.remoteHasMany?.(uniqueHashes)) ?? null
79
79
  if (present !== null) {
80
80
  args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
81
+ // The pre-mark above already answers a pull for an absent hash from
82
+ // the in-flight map, so on the wire this filter is the pump's
83
+ // economy: deleting it alone GETs nothing extra, deleting both GETs
84
+ // every absent key. What it decides is which hashes the pump asks
85
+ // the layer for, and that is what the row pins (item 643, 645).
81
86
  toPrefetch = stableKeys.filter((k) => present.has(k.hash))
82
87
  if (toPrefetch.length === 0) return
83
88
  }
@@ -92,9 +97,24 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
92
97
  while (next < toPrefetch.length) {
93
98
  const { hash, node } = toPrefetch[next++]!
94
99
  await args.cache
95
- .prefetch(hash, { taskId: node.id, command: node.config.exec?.command ?? '' })
100
+ .prefetch(hash, getContext(node, node.config.exec?.command ?? ''))
96
101
  .catch(() => false)
97
102
  }
98
103
  }
99
104
  await Promise.all(Array.from({ length: workers }, () => pump()))
100
105
  }
106
+
107
+ /**
108
+ * What a cache lookup is told about the task: its id and command for the
109
+ * row a remote hit inserts, and its declared outputs, the only names a
110
+ * remote artifact may carry (item 942). Shared with execute-task's own
111
+ * lookup, the other one that can reach a remote.
112
+ */
113
+ export function getContext(node: TaskNode, command: string) {
114
+ const outputs = node.config.cache?.outputs
115
+ return {
116
+ taskId: node.id,
117
+ command,
118
+ outputs: { files: outputs?.files ?? [], workspaceFiles: outputs?.workspaceFiles ?? [] },
119
+ }
120
+ }
@@ -16,6 +16,7 @@
16
16
  import path from 'node:path'
17
17
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
18
18
  import { tallyOutcomes } from './tally.js'
19
+ import type { FlakyFinding } from './failure-mode.js'
19
20
 
20
21
  export interface SummarizeArgs {
21
22
  /** Empty string → default path; otherwise the explicit file path. */
@@ -29,10 +30,12 @@ export interface SummarizeArgs {
29
30
  /** The run's verdict — the same value the CLI turns into the exit code. */
30
31
  ok: boolean
31
32
  outcomes: readonly TaskOutcome[]
33
+ /** What `detectFlaky` found this run; the footer's Flaky section, typed. */
34
+ flaky?: readonly FlakyFinding[]
32
35
  }
33
36
 
34
37
  /** One task's entry. Shared by `tasks` and `aborted` so they read alike. */
35
- function taskEntry(o: TaskOutcome): Record<string, unknown> {
38
+ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): Record<string, unknown> {
36
39
  return {
37
40
  id: o.node.id,
38
41
  project: o.node.projectName,
@@ -41,8 +44,35 @@ function taskEntry(o: TaskOutcome): Record<string, unknown> {
41
44
  exitCode: o.exitCode,
42
45
  durationMs: o.durationMs,
43
46
  hash: o.hash ?? null,
47
+ // A task with no `cache` block executes every run by design; without
48
+ // the flag a consumer computing a hit rate cannot tell a miss from a
49
+ // task that could never hit (its `hash` is still set — dependents fold
50
+ // it). Present only when true, so every other row is byte-identical.
51
+ ...(o.node.config.cache === undefined ? { noCache: true } : {}),
52
+ // Present only when the run proved the task flaky: this exact key has
53
+ // both passed and failed on record (this run counted), or the task
54
+ // needed a retry. A consumer gating on `failed` can tell a break from a
55
+ // flake without the history; every other row is byte-identical.
56
+ ...(flaky !== undefined
57
+ ? { flaky: { passes: flaky.passes, failures: flaky.failures, attempts: flaky.attempts } }
58
+ : {}),
44
59
  ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
45
60
  ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
61
+ // A hit's row names what the PRODUCING execution used under its own
62
+ // keys, so a consumer never reads a remote worker's peak as this run's.
63
+ ...(o.storedCpuMs !== undefined ? { storedCpuMs: o.storedCpuMs } : {}),
64
+ ...(o.storedPeakRssBytes !== undefined ? { storedPeakRssBytes: o.storedPeakRssBytes } : {}),
65
+ // Present only when an `admit` policy held the task with a worker free.
66
+ ...(o.admissionHeldMs !== undefined ? { admissionHeldMs: o.admissionHeldMs } : {}),
67
+ // A skipped row names the failed (or aborted) task at the root of what
68
+ // blocked it; a fail-fast skip has none.
69
+ ...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
70
+ // A failure vx's own `timeout` killed; absent on every other row.
71
+ ...(o.timedOut === true ? { timedOut: true } : {}),
72
+ // The sandbox's violation count; present only on a task it recorded any for.
73
+ ...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
74
+ // A persistent task that never became ready, and why.
75
+ ...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
46
76
  // hrtime spans are bigints → emit as strings so JSON.parse on
47
77
  // the consumer side doesn't truncate the ns precision.
48
78
  ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
@@ -65,6 +95,7 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
65
95
  // exit — the terminal has said so in its Aborted section all along, and a
66
96
  // parser must not be told less than a human is.
67
97
  const aborted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status === 'aborted')
98
+ const flakyById = new Map((args.flaky ?? []).map((f) => [f.taskId, f]))
68
99
  const payload = {
69
100
  runId: args.runId,
70
101
  // The run-level verdict, first: a consumer gating on this artifact must
@@ -74,8 +105,8 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
74
105
  startedAt: new Date(args.startedAtMs).toISOString(),
75
106
  endedAt: new Date(args.endedAtMs).toISOString(),
76
107
  totalMs: args.totalMs,
77
- tasks: counted.map(taskEntry),
78
- aborted: aborted.map(taskEntry),
108
+ tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
109
+ aborted: aborted.map((o) => taskEntry(o)),
79
110
  // The FULL list: `tallyOutcomes` applies the same group/aborted
80
111
  // exclusions internally, so every counted bucket is unchanged by passing
81
112
  // it — but `summary.aborted` is only non-zero if it sees them.
@@ -11,7 +11,7 @@
11
11
  import fs from 'node:fs'
12
12
  import os from 'node:os'
13
13
  import path from 'node:path'
14
- import { ulid, xxh3hex } from '../util/index.js'
14
+ import { executablePath, ulid, xxh3hex } from '../util/index.js'
15
15
 
16
16
  export interface GitContext {
17
17
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -65,7 +65,15 @@ export function captureGitContext(
65
65
  }
66
66
  try {
67
67
  const proc = Bun.spawnSync({
68
- cmd: ['git', '-C', workspaceRoot, 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD'],
68
+ cmd: [
69
+ executablePath('git'),
70
+ '-C',
71
+ workspaceRoot,
72
+ 'rev-parse',
73
+ 'HEAD',
74
+ '--abbrev-ref',
75
+ 'HEAD',
76
+ ],
69
77
  stdout: 'pipe',
70
78
  stderr: 'ignore',
71
79
  })
@@ -81,7 +89,7 @@ export function captureGitContext(
81
89
  // defaultBranch`) means it is at least never mistaken for trunk, but
82
90
  // every PR collapses into one scope — so one PR's timings feed another's
83
91
  // baseline, cross-branch regression detection counts them as a single
84
- // branch, and the dashboard's branch column reads "HEAD".
92
+ // branch, and a consumer grouping runs by branch reads "HEAD".
85
93
  if (br && br !== 'HEAD') branch = br
86
94
  }
87
95
  } catch {
@@ -205,7 +213,14 @@ export function captureDefaultBranch(
205
213
 
206
214
  try {
207
215
  const proc = Bun.spawnSync({
208
- cmd: ['git', '-C', workspaceRoot, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
216
+ cmd: [
217
+ executablePath('git'),
218
+ '-C',
219
+ workspaceRoot,
220
+ 'symbolic-ref',
221
+ '--short',
222
+ 'refs/remotes/origin/HEAD',
223
+ ],
209
224
  stdout: 'pipe',
210
225
  stderr: 'ignore',
211
226
  })
@@ -310,7 +325,7 @@ export function captureWorkspaceIdentity(workspaceRoot: string): WorkspaceIdenti
310
325
  // rewrites, so two developers mirroring the same repo through
311
326
  // different proxies would derive different workspace ids. The raw
312
327
  // configured URL is the identity.
313
- cmd: ['git', '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
328
+ cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
314
329
  stdout: 'pipe',
315
330
  stderr: 'ignore',
316
331
  })