@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,259 @@
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 Lockfile,
14
+ type PackageGraph,
15
+ type ProjectEntry,
16
+ type ProjectMeta,
17
+ } from '../workspace/index.js'
18
+ import type { WorkspaceConfig } from '../config.js'
19
+ import { Cache, noteSchemaReset } from '../cache/index.js'
20
+ import {
21
+ buildPackageGraph,
22
+ computeWorkspaceFingerprint,
23
+ listProjects,
24
+ loadWorkspace,
25
+ loadWorkspaceConfig,
26
+ resolveCacheDir,
27
+ } from '../workspace/index.js'
28
+ import { parseDependencySpec } from '../graph/index.js'
29
+ import { applyConfigHooks, applyProjectHooks, hasHook } from './plugin-host.js'
30
+ import type { VxPlugin } from './plugin.js'
31
+
32
+ /**
33
+ * The workspace config with the plugin `config` stage applied — the first
34
+ * stage of the pipeline, run before anything is derived from the config.
35
+ * The plugin list itself is already fixed by then.
36
+ */
37
+ export async function loadWorkspacePlugins(
38
+ workspaceRoot: string,
39
+ warn: (message: string) => void,
40
+ ): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
41
+ const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
42
+ const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
43
+ if (workspaceConfig !== null && hasHook(plugins, 'config')) {
44
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn })
45
+ }
46
+ return { workspaceConfig, plugins }
47
+ }
48
+
49
+ export interface LoadProjectsArgs {
50
+ workspaceRoot: string
51
+ cacheDir: string
52
+ plugins: readonly VxPlugin[]
53
+ projectMetas: readonly ProjectMeta[]
54
+ packageGraph: PackageGraph
55
+ /**
56
+ * Projects whose configs must load — `'all'`, or names (unknown and
57
+ * config-less ones are ignored). With `closure`, each seed's transitive
58
+ * package dependencies load too, which bounds `^task` frontier
59
+ * expansion; a `pkg#task` dependsOn entry pulls its project in either
60
+ * way, since the package graph cannot see the cross form.
61
+ */
62
+ seeds: 'all' | Iterable<string>
63
+ closure: boolean
64
+ /** Read configs from the lock instead of evaluating them (`--frozen`). */
65
+ lock: Lockfile | null
66
+ evalCache: LoadProjectConfigOptions['evalCache']
67
+ warn: (message: string) => void
68
+ /**
69
+ * Entries a load in this same process already produced with the same
70
+ * cache dir and lock (the CLI's selection pass, which stages every
71
+ * config to walk `pkg#task` edges). Seeding and scoping run exactly as
72
+ * without it; a project found here is taken as is instead of being
73
+ * evaluated and put through the `project` stage a second time — the
74
+ * stage's cost, and its warnings, once per run. Never carried across
75
+ * runs: a config can change between them.
76
+ */
77
+ staged?: ReadonlyMap<string, ProjectEntry>
78
+ }
79
+
80
+ export interface LoadedProjects {
81
+ projects: Map<string, ProjectEntry>
82
+ /**
83
+ * Every project that can carry tasks: it has a config file, or a plugin
84
+ * fills the `project` stage and may give it tasks. Boundary geometry
85
+ * fences all of them, loaded or not.
86
+ */
87
+ configured: readonly ProjectMeta[]
88
+ }
89
+
90
+ /**
91
+ * SCOPED config loading: configs are programs, and evaluating 1090 of
92
+ * them costs ~200 ms — the dominant fixed cost of small runs. Only the
93
+ * seeds, their closure and any project a `pkg#task` entry names can
94
+ * contribute graph nodes, so only those configs are evaluated. Side
95
+ * effect, deliberate and Turbo-like: a broken config in an unrelated
96
+ * package does not fail a scoped run — it surfaces when that package
97
+ * enters scope.
98
+ */
99
+ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
100
+ const { plugins, packageGraph, lock, workspaceRoot } = args
101
+ // A package with no config file declares no tasks — unless a plugin
102
+ // fills the `project` stage, in which case it is a project the stage may
103
+ // give tasks to (the zero-migration shape: `turbo.json` or `package.json`
104
+ // scripts mapped onto packages that never wrote a `vx.config.ts`). It
105
+ // then loads as `{ tasks: {} }` — nothing to evaluate, nothing to freeze
106
+ // — and the stage runs on that like on any loaded config. With no
107
+ // `project` plugin a plain run never visits (or fences, or seeds) a
108
+ // config-less package.
109
+ const projectStage = hasHook(plugins, 'project')
110
+ const configured = args.projectMetas.filter(
111
+ (m) => projectStage || (typeof m.configPath === 'string' && m.configPath.length > 0),
112
+ )
113
+ const metaByName = new Map<string, ProjectMeta>(configured.map((m) => [m.name, m]))
114
+ const needed = new Set<string>()
115
+ const pending: ProjectMeta[] = []
116
+ const consider = (name: string): void => {
117
+ if (needed.has(name)) return
118
+ needed.add(name)
119
+ const meta = metaByName.get(name)
120
+ if (meta) pending.push(meta)
121
+ }
122
+ const considerWithDeps = (name: string): void => {
123
+ consider(name)
124
+ // Every config-bearing project already pending: the closure can add
125
+ // nothing, and asking for it would build the package graph's
126
+ // transitive bitsets — a cost the unscoped run (every project a seed)
127
+ // otherwise never pays.
128
+ if (pending.length === metaByName.size) return
129
+ for (const dep of packageGraph.transitiveDeps(name)) consider(dep)
130
+ }
131
+ const seeds = args.seeds === 'all' ? metaByName.keys() : [...args.seeds]
132
+ for (const seed of seeds) consider(seed)
133
+ if (args.closure && pending.length < metaByName.size) {
134
+ for (const seed of args.seeds === 'all' ? metaByName.keys() : seeds) considerWithDeps(seed)
135
+ }
136
+
137
+ // Load in rounds to a fixpoint. A `pkg#task` dependsOn entry names a
138
+ // project the PACKAGE graph cannot reach (the cross form ignores npm
139
+ // deps by design), so its config has to be pulled in — and that config
140
+ // may declare cross edges of its own. The common case (no cross deps)
141
+ // is a single round, identical to loading the closure in one batch.
142
+ const projects = new Map<string, ProjectEntry>()
143
+ while (pending.length > 0) {
144
+ const round = pending.splice(0, pending.length)
145
+ // Only a config FILE is evaluated (or read from the lock); a
146
+ // config-less project in the round starts from an empty task table,
147
+ // and a project the caller already staged is neither.
148
+ const withFile = round.filter(
149
+ (m): m is ProjectMeta & { configPath: string } =>
150
+ typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
151
+ )
152
+ const loaded = lock
153
+ ? await Promise.all(withFile.map((m) => frozenProjectConfig(lock, m, workspaceRoot)))
154
+ : await loadProjectConfigs(
155
+ withFile.map((m) => m.configPath),
156
+ args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
157
+ )
158
+ let next = 0
159
+ for (const meta of round) {
160
+ const pre = args.staged?.get(meta.name)
161
+ if (pre !== undefined) {
162
+ projects.set(meta.name, pre)
163
+ for (const name of crossDepProjects(pre.config)) {
164
+ if (args.closure) considerWithDeps(name)
165
+ else consider(name)
166
+ }
167
+ continue
168
+ }
169
+ const config: ProjectConfig =
170
+ meta.configPath === null ? { tasks: {} } : (loaded[next++] as ProjectConfig)
171
+ if (projectStage) {
172
+ // The `project` stage edits the validated object in place; core
173
+ // re-validates after EACH plugin so a plugin can only produce what
174
+ // the loader accepts from a user, and the refusal names both the
175
+ // config file and the plugin whose edit broke it.
176
+ const where = meta.configPath ?? `${meta.name} (no config file)`
177
+ await applyProjectHooks(
178
+ plugins,
179
+ config,
180
+ {
181
+ workspaceRoot,
182
+ cacheDir: args.cacheDir,
183
+ warn: args.warn,
184
+ name: meta.name,
185
+ dir: meta.dir,
186
+ packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
187
+ projects: args.projectMetas,
188
+ },
189
+ (plugin) => validateProjectConfig(config, `${where} (after plugin '${plugin.name}')`),
190
+ )
191
+ }
192
+ projects.set(meta.name, { name: meta.name, dir: meta.dir, config })
193
+ for (const name of crossDepProjects(config)) {
194
+ if (args.closure) considerWithDeps(name)
195
+ else consider(name)
196
+ }
197
+ }
198
+ }
199
+ return { projects, configured }
200
+ }
201
+
202
+ /** Project names named by a `pkg#task` dependsOn entry anywhere in `config`. */
203
+ function crossDepProjects(config: ProjectConfig): string[] {
204
+ const out: string[] = []
205
+ for (const task of Object.values(config.tasks ?? {})) {
206
+ for (const raw of task.dependsOn ?? []) {
207
+ // A malformed spec is the graph builder's error to report — it names
208
+ // the offending task. Here it just contributes no project.
209
+ if (!raw.includes('#')) continue
210
+ try {
211
+ const spec = parseDependencySpec(raw)
212
+ if (spec.kind === 'cross') out.push(spec.project)
213
+ } catch {
214
+ continue
215
+ }
216
+ }
217
+ }
218
+ return out
219
+ }
220
+
221
+ /**
222
+ * The run path's view of a workspace's projects for a reader — `vx show`,
223
+ * the MCP server, an embedder: discovery, the plugin `config` and
224
+ * `project` stages, and the local cache opened only to serve cached
225
+ * evaluations, so a pure config costs a stat, not an evaluation. `scope`
226
+ * is every project or a list of names; no closure, no lock (a reader
227
+ * reads live, as a default run does). Plugin warnings go to `warn`.
228
+ */
229
+ export async function loadResolvedProjects(
230
+ workspaceRoot: string,
231
+ opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {},
232
+ ): Promise<Map<string, ProjectEntry>> {
233
+ const warn = opts.warn ?? ((): void => {})
234
+ const metas = await listProjects(await loadWorkspace(workspaceRoot))
235
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
236
+ const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
237
+ const cache = new Cache(cacheDir)
238
+ noteSchemaReset(cache, warn)
239
+ try {
240
+ const loaded = await loadProjects({
241
+ workspaceRoot,
242
+ cacheDir,
243
+ plugins,
244
+ projectMetas: metas,
245
+ packageGraph: buildPackageGraph([...metas]),
246
+ seeds: opts.scope ?? 'all',
247
+ closure: false,
248
+ lock: null,
249
+ evalCache: {
250
+ store: cache,
251
+ workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot),
252
+ },
253
+ warn,
254
+ })
255
+ return loaded.projects
256
+ } finally {
257
+ cache.close()
258
+ }
259
+ }
@@ -14,17 +14,20 @@
14
14
  // HARD SCOPE: this runs ONLY when a remote layer is configured. It
15
15
  // NEVER touches the local cache (no local get / no isOutputsCurrent /
16
16
  // no stat pass) — it derives keys and probes REMOTE only. Local-only
17
- // runs are byte-identical to before; the caller gates on `cache
18
- // instanceof LayeredCache`.
17
+ // runs are byte-identical to before; the caller gates on the layer's
18
+ // own `hasRemote`, so a third-party remote layer gets the overlap too.
19
+ // The two batch-probe hooks are OPTIONAL on the contract: a remote layer
20
+ // that implements neither still prefetches, per-hash.
19
21
 
20
22
  import type { TaskNode } from '../graph/index.js'
21
- import type { GitFilesCache, LayeredCache } from '../cache/index.js'
23
+ import type { CacheLayer, GitFilesCache } from '../cache/index.js'
22
24
  import type { HashCache } from './task-hash.js'
23
25
  import { deriveStableKeys } from './stable-keys.js'
24
26
 
25
27
  export interface PrefetchArgs {
26
28
  nodes: Map<string, TaskNode>
27
- cache: LayeredCache
29
+ /** A layer whose `hasRemote` is true — the caller's gate. */
30
+ cache: CacheLayer
28
31
  workspaceRoot: string
29
32
  workspaceFingerprint: string
30
33
  forwardArgs?: readonly string[] | undefined
@@ -67,13 +70,14 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
67
70
  // tells us which of the N stable hashes exist remotely, so we GET only the
68
71
  // hits and pre-mark the misses — their lazy `get` then short-circuits with
69
72
  // no network. This collapses N probe waves into 1 and skips every GET that
70
- // would 404. When the remote can't batch (`null` — an older serve, or reads
71
- // disabled), fall back to prefetching every stable key, exactly as before.
73
+ // would 404. When the remote can't batch (`null` — an older remote, reads
74
+ // disabled, or a layer that doesn't implement the hook at all), fall back to
75
+ // prefetching every stable key, exactly as before.
72
76
  let toPrefetch = stableKeys
73
77
  const uniqueHashes = [...new Set(stableKeys.map((k) => k.hash))]
74
- const present = await args.cache.remoteHasMany(uniqueHashes)
78
+ const present = (await args.cache.remoteHasMany?.(uniqueHashes)) ?? null
75
79
  if (present !== null) {
76
- args.cache.markRemoteAbsent(uniqueHashes.filter((h) => !present.has(h)))
80
+ args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
77
81
  toPrefetch = stableKeys.filter((k) => present.has(k.hash))
78
82
  if (toPrefetch.length === 0) return
79
83
  }
@@ -14,8 +14,9 @@
14
14
  // Both are no-ops if the corresponding RunOptions field is undefined.
15
15
 
16
16
  import path from 'node:path'
17
- import type { TaskOutcome } from '../graph/index.js'
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. */
@@ -26,7 +27,48 @@ export interface SummarizeArgs {
26
27
  startedAtMs: number
27
28
  endedAtMs: number
28
29
  totalMs: number
30
+ /** The run's verdict — the same value the CLI turns into the exit code. */
31
+ ok: boolean
29
32
  outcomes: readonly TaskOutcome[]
33
+ /** What `detectFlaky` found this run; the footer's Flaky section, typed. */
34
+ flaky?: readonly FlakyFinding[]
35
+ }
36
+
37
+ /** One task's entry. Shared by `tasks` and `aborted` so they read alike. */
38
+ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): Record<string, unknown> {
39
+ return {
40
+ id: o.node.id,
41
+ project: o.node.projectName,
42
+ task: o.node.taskName,
43
+ status: o.status,
44
+ exitCode: o.exitCode,
45
+ durationMs: o.durationMs,
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
+ : {}),
59
+ ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
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
+ // hrtime spans are bigints → emit as strings so JSON.parse on
68
+ // the consumer side doesn't truncate the ns precision.
69
+ ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
70
+ ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: String(o.wallclockEndNs) } : {}),
71
+ }
30
72
  }
31
73
 
32
74
  export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
@@ -34,26 +76,31 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
34
76
  args.target === ''
35
77
  ? path.join(args.cacheDir, 'runs', `${args.runId}.json`)
36
78
  : path.resolve(args.cwd, args.target)
79
+ // `tasks` and `summary` must describe the same population, or one artifact
80
+ // contradicts itself (a group task listed in `tasks` but absent from
81
+ // `summary.total`). `tallyOutcomes` owns the rule; mirror its filter here.
82
+ const counted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status !== 'aborted')
83
+ // A task killed by a shutdown signal is in no bucket and no total, yet it
84
+ // makes the run red. Listing it separately keeps `tasks.length ===
85
+ // summary.total` while leaving the artifact able to explain a non-zero
86
+ // exit — the terminal has said so in its Aborted section all along, and a
87
+ // parser must not be told less than a human is.
88
+ const aborted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status === 'aborted')
89
+ const flakyById = new Map((args.flaky ?? []).map((f) => [f.taskId, f]))
37
90
  const payload = {
38
91
  runId: args.runId,
92
+ // The run-level verdict, first: a consumer gating on this artifact must
93
+ // not have to re-derive it by re-implementing the bucket rules.
94
+ ok: args.ok,
95
+ exitCode: args.ok ? 0 : 1,
39
96
  startedAt: new Date(args.startedAtMs).toISOString(),
40
97
  endedAt: new Date(args.endedAtMs).toISOString(),
41
98
  totalMs: args.totalMs,
42
- tasks: args.outcomes.map((o) => ({
43
- id: o.node.id,
44
- project: o.node.projectName,
45
- task: o.node.taskName,
46
- status: o.status,
47
- exitCode: o.exitCode,
48
- durationMs: o.durationMs,
49
- hash: o.hash ?? null,
50
- ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
51
- ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
52
- // hrtime spans are bigints → emit as strings so JSON.parse on
53
- // the consumer side doesn't truncate the ns precision.
54
- ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
55
- ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: String(o.wallclockEndNs) } : {}),
56
- })),
99
+ tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
100
+ aborted: aborted.map((o) => taskEntry(o)),
101
+ // The FULL list: `tallyOutcomes` applies the same group/aborted
102
+ // exclusions internally, so every counted bucket is unchanged by passing
103
+ // it — but `summary.aborted` is only non-zero if it sees them.
57
104
  summary: tallyOutcomes(args.outcomes),
58
105
  }
59
106
  await Bun.write(outPath, JSON.stringify(payload, null, 2))
@@ -3,6 +3,10 @@
3
3
  // probes are best-effort — a missing git binary, a non-repo cwd, or an
4
4
  // unset env degrades each field to null and NEVER throws. Telemetry must
5
5
  // not be able to fail a build.
6
+ //
7
+ // Cost: ONE git spawn (`captureGitContext`) on every run, plus two more
8
+ // (`captureWorkspaceIdentity`, `captureDefaultBranch`) only when a plugin
9
+ // declares the `telemetry` capability — nobody else needs those fields.
6
10
 
7
11
  import fs from 'node:fs'
8
12
  import os from 'node:os'
@@ -42,9 +46,23 @@ export interface HostContext {
42
46
  * `dirty: null` when unavailable. Each field degrades to null
43
47
  * independently — telemetry never fails a run.
44
48
  */
45
- export function captureGitContext(workspaceRoot: string, dirty: boolean | null = null): GitContext {
49
+ export function captureGitContext(
50
+ workspaceRoot: string,
51
+ dirty: boolean | null = null,
52
+ env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env,
53
+ ): GitContext {
46
54
  let commitSha: string | null = null
47
55
  let branch: string | null = null
56
+ // Read `.git` directly first: HEAD plus one ref file (or packed-refs) is
57
+ // ~0.1 ms, where the spawn below is ~8 ms — on EVERY run, including a
58
+ // 60 ms warm one. The spawn stays as the fallback for whatever the reader
59
+ // does not understand (a ref format this code has not seen, a gitdir it
60
+ // cannot resolve), so an unusual repo is slower, never wrong.
61
+ const direct = readHeadDirect(workspaceRoot)
62
+ if (direct !== null) {
63
+ branch = direct.branch ?? ciBranch(env)
64
+ return { commitSha: direct.commitSha, branch, dirty }
65
+ }
48
66
  try {
49
67
  const proc = Bun.spawnSync({
50
68
  cmd: ['git', '-C', workspaceRoot, 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD'],
@@ -56,14 +74,154 @@ export function captureGitContext(workspaceRoot: string, dirty: boolean | null =
56
74
  const sha = lines[0]?.trim()
57
75
  const br = lines[1]?.trim()
58
76
  if (sha) commitSha = sha
59
- if (br) branch = br
77
+ // `--abbrev-ref HEAD` answers the literal string `HEAD` when nothing is
78
+ // checked out. Recording that verbatim INVENTS a branch: every detached
79
+ // run shares a pseudo-branch "HEAD", which is how `actions/checkout`
80
+ // leaves a pull_request build. The trust boundary (`branch ===
81
+ // defaultBranch`) means it is at least never mistaken for trunk, but
82
+ // every PR collapses into one scope — so one PR's timings feed another's
83
+ // baseline, cross-branch regression detection counts them as a single
84
+ // branch, and a consumer grouping runs by branch reads "HEAD".
85
+ if (br && br !== 'HEAD') branch = br
60
86
  }
61
87
  } catch {
62
88
  // git unavailable / non-repo: both fields stay null.
63
89
  }
90
+ // A detached CI checkout still knows the branch it came from; the provider
91
+ // says so even though git cannot. Same most-reliable-first ladder as
92
+ // `captureDefaultBranch`, and consulted only when git had no answer — an
93
+ // attached branch is ground truth for what this working tree IS, so a stale
94
+ // exported variable must never relabel it.
95
+ branch ??= ciBranch(env)
64
96
  return { commitSha, branch, dirty }
65
97
  }
66
98
 
99
+ /**
100
+ * `HEAD`'s commit and branch from the `.git` files, or null when anything
101
+ * about the layout is unfamiliar (the caller then spawns git). Handles a
102
+ * `.git` directory, a `.git` FILE (`gitdir: …` — a linked worktree, whose
103
+ * refs live under `commondir`), a symbolic HEAD, a detached HEAD, loose refs
104
+ * and `packed-refs`.
105
+ */
106
+ function readHeadDirect(
107
+ workspaceRoot: string,
108
+ ): { commitSha: string; branch: string | null } | null {
109
+ try {
110
+ let gitDir = path.join(workspaceRoot, '.git')
111
+ const st = fs.statSync(gitDir)
112
+ if (st.isFile()) {
113
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf8'))
114
+ if (m === null) return null
115
+ gitDir = path.resolve(workspaceRoot, m[1]!.trim())
116
+ }
117
+ let commonDir = gitDir
118
+ const commonFile = path.join(gitDir, 'commondir')
119
+ if (fs.existsSync(commonFile)) {
120
+ commonDir = path.resolve(gitDir, fs.readFileSync(commonFile, 'utf8').trim())
121
+ }
122
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
123
+ const SHA = /^[0-9a-f]{40,64}$/
124
+ if (SHA.test(head)) return { commitSha: head, branch: null }
125
+ const ref = /^ref:\s*(refs\/heads\/(.+))$/.exec(head)
126
+ if (ref === null) return null
127
+ const refPath = ref[1]!
128
+ const branch = ref[2]!
129
+ for (const base of [gitDir, commonDir]) {
130
+ const loose = path.join(base, refPath)
131
+ if (fs.existsSync(loose)) {
132
+ const sha = fs.readFileSync(loose, 'utf8').trim()
133
+ return SHA.test(sha) ? { commitSha: sha, branch } : null
134
+ }
135
+ }
136
+ const packed = path.join(commonDir, 'packed-refs')
137
+ if (fs.existsSync(packed)) {
138
+ for (const line of fs.readFileSync(packed, 'utf8').split('\n')) {
139
+ if (line.endsWith(` ${refPath}`)) {
140
+ const sha = line.slice(0, line.length - refPath.length - 1)
141
+ return SHA.test(sha) ? { commitSha: sha, branch } : null
142
+ }
143
+ }
144
+ }
145
+ return null
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * The branch a CI provider says this checkout came from, or null.
153
+ *
154
+ * On a GitHub pull_request `GITHUB_REF_NAME` is `<n>/merge` — the merge ref,
155
+ * not a branch anyone works on — so `GITHUB_HEAD_REF` (the contributor's
156
+ * actual branch, set only for pull_request events) has to win.
157
+ */
158
+ function ciBranch(env: NodeJS.ProcessEnv | Record<string, string | undefined>): string | null {
159
+ for (const key of ['GITHUB_HEAD_REF', 'GITHUB_REF_NAME', 'CI_COMMIT_REF_NAME']) {
160
+ const v = env[key]
161
+ if (typeof v === 'string' && v.trim().length > 0) return v.trim()
162
+ }
163
+ return null
164
+ }
165
+
166
+ /**
167
+ * The repository's DEFAULT (trunk) branch — the axis that separates a
168
+ * mainline run from a PR / feature-branch experiment. A run whose `branch`
169
+ * equals this is a trunk run; everything else is branch work whose timings
170
+ * must NOT pollute the shared scheduling baseline (owner 2026-07-14: "I can
171
+ * be experimenting on a branch increasing task time for all later on… don't
172
+ * count their times into main"). Resolution ladder, most-reliable first:
173
+ * 1. GitLab: `CI_DEFAULT_BRANCH` (the project's configured default).
174
+ * 2. GitHub Actions: the event payload's `repository.default_branch`
175
+ * (present on push AND pull_request events) — one best-effort JSON read.
176
+ * 3. Local / other: `git symbolic-ref --short refs/remotes/origin/HEAD`,
177
+ * stripping the `origin/` prefix.
178
+ * Returns null when none resolve (a detached checkout with no remote HEAD, a
179
+ * non-repo cwd) — the consumer then counts ALL runs, so an undetectable
180
+ * default never regresses today's behavior. Never throws.
181
+ */
182
+ export function captureDefaultBranch(
183
+ env: NodeJS.ProcessEnv | Record<string, string | undefined>,
184
+ workspaceRoot: string,
185
+ ): string | null {
186
+ const gitlab = env['CI_DEFAULT_BRANCH']
187
+ if (typeof gitlab === 'string' && gitlab.trim().length > 0) return gitlab.trim()
188
+
189
+ const eventPath = env['GITHUB_EVENT_PATH']
190
+ if (typeof eventPath === 'string' && eventPath.length > 0) {
191
+ try {
192
+ // Only a regular file. A character device (`/dev/zero`) never reaches
193
+ // EOF, so readFileSync would spin forever — "never throws" is not the
194
+ // same as "never hangs", and a run must not wedge on a stray env var.
195
+ if (!fs.statSync(eventPath).isFile()) throw new Error('not a regular file')
196
+ const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8')) as {
197
+ repository?: { default_branch?: unknown }
198
+ }
199
+ const dflt = payload.repository?.default_branch
200
+ if (typeof dflt === 'string' && dflt.trim().length > 0) return dflt.trim()
201
+ } catch {
202
+ // unreadable / malformed payload — fall through to git.
203
+ }
204
+ }
205
+
206
+ try {
207
+ const proc = Bun.spawnSync({
208
+ cmd: ['git', '-C', workspaceRoot, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
209
+ stdout: 'pipe',
210
+ stderr: 'ignore',
211
+ })
212
+ if (proc.exitCode === 0) {
213
+ const ref = new TextDecoder().decode(proc.stdout).trim()
214
+ // `refs/remotes/origin/HEAD` → `origin/main`; strip the remote prefix.
215
+ const slash = ref.indexOf('/')
216
+ const name = slash >= 0 ? ref.slice(slash + 1) : ref
217
+ if (name.length > 0) return name
218
+ }
219
+ } catch {
220
+ // git unavailable / no remote HEAD ref: null.
221
+ }
222
+ return null
223
+ }
224
+
67
225
  // Recognized CI env vars in match-priority order. The first whose value
68
226
  // is truthy (present and not '0'/'false') decides the provider; a bare
69
227
  // `CI` is the generic fallback.
@@ -91,32 +249,6 @@ export function detectCi(env: NodeJS.ProcessEnv | Record<string, string | undefi
91
249
  return { ci: false, provider: null }
92
250
  }
93
251
 
94
- /**
95
- * The untrusted per-PR cache partition — a stable identity for THIS pull
96
- * request, so one fork PR's untrusted cache writes are isolated from another's
97
- * (no cross-PR pollution or leakage). `VX_CACHE_SCOPE` overrides; otherwise
98
- * derived from the CI PR context (GitHub PR number, GitLab MR iid, else the
99
- * head branch). Returns undefined outside a PR (the serve then falls back to a
100
- * shared untrusted scope). Never throws — sanitized to one safe path segment.
101
- */
102
- export function resolveCacheScope(
103
- env: NodeJS.ProcessEnv | Record<string, string | undefined>,
104
- ): string | undefined {
105
- const safe = (s: string): string => s.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 128)
106
- const override = env['VX_CACHE_SCOPE']
107
- if (isTruthy(override)) return safe(override as string)
108
- // GitHub: a pull_request run has GITHUB_REF = refs/pull/<n>/merge.
109
- const ref = env['GITHUB_REF']
110
- const m = typeof ref === 'string' ? /^refs\/pull\/(\d+)\//.exec(ref) : null
111
- if (m) return `pr-${m[1]}`
112
- const headRef = env['GITHUB_HEAD_REF']
113
- if (isTruthy(headRef)) return `gh-${safe(headRef as string)}`
114
- // GitLab merge request.
115
- const iid = env['CI_MERGE_REQUEST_IID']
116
- if (isTruthy(iid)) return `mr-${safe(iid as string)}`
117
- return undefined
118
- }
119
-
120
252
  /** Host name (null on failure) + platform + arch. */
121
253
  export function captureHostContext(): HostContext {
122
254
  let host: string | null = null
@@ -144,9 +276,15 @@ export interface WorkspaceIdentity {
144
276
  */
145
277
  export function normalizeRemoteUrl(raw: string): string {
146
278
  let s = raw.trim().toLowerCase()
279
+ const hadProtocol = /^[a-z+]+:\/\//.test(s)
147
280
  s = s.replace(/^[a-z+]+:\/\//, '') // protocol
148
281
  s = s.replace(/^[^@/]+@/, '') // user[:pass]@
149
- s = s.replace(/:(\d+\/)/, '/$1') // :port/ → /
282
+ // A `:port` only appears in a protocol-form URL (`ssh://host:2222/path`); the
283
+ // scp shorthand `host:path` has no port. STRIP it (the old code reinserted it
284
+ // as a path segment, so `ssh://host:2222/o/r` and `https://host/o/r` — the
285
+ // SAME repo — derived different workspace ids). Gated on `hadProtocol` so a
286
+ // scp path whose first segment is numeric isn't mistaken for a port.
287
+ if (hadProtocol) s = s.replace(/:\d+(\/|$)/, '$1')
150
288
  s = s.replace(/:/, '/') // scp-style host:path
151
289
  s = s.replace(/\.git$/, '').replace(/\/+$/, '')
152
290
  return s