@vzn/vx 0.0.18 → 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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -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
+ }
@@ -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
@@ -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,26 @@ 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 } : {}),
46
67
  // hrtime spans are bigints → emit as strings so JSON.parse on
47
68
  // the consumer side doesn't truncate the ns precision.
48
69
  ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
@@ -65,6 +86,7 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
65
86
  // exit — the terminal has said so in its Aborted section all along, and a
66
87
  // parser must not be told less than a human is.
67
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]))
68
90
  const payload = {
69
91
  runId: args.runId,
70
92
  // The run-level verdict, first: a consumer gating on this artifact must
@@ -74,8 +96,8 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
74
96
  startedAt: new Date(args.startedAtMs).toISOString(),
75
97
  endedAt: new Date(args.endedAtMs).toISOString(),
76
98
  totalMs: args.totalMs,
77
- tasks: counted.map(taskEntry),
78
- aborted: aborted.map(taskEntry),
99
+ tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
100
+ aborted: aborted.map((o) => taskEntry(o)),
79
101
  // The FULL list: `tallyOutcomes` applies the same group/aborted
80
102
  // exclusions internally, so every counted bucket is unchanged by passing
81
103
  // it — but `summary.aborted` is only non-zero if it sees them.
@@ -81,7 +81,7 @@ export function captureGitContext(
81
81
  // defaultBranch`) means it is at least never mistaken for trunk, but
82
82
  // every PR collapses into one scope — so one PR's timings feed another's
83
83
  // baseline, cross-branch regression detection counts them as a single
84
- // branch, and the dashboard's branch column reads "HEAD".
84
+ // branch, and a consumer grouping runs by branch reads "HEAD".
85
85
  if (br && br !== 'HEAD') branch = br
86
86
  }
87
87
  } catch {
@@ -0,0 +1,149 @@
1
+ // What a finished run leaves behind: the `runs` rows, the `invocations`
2
+ // header row, and — only when a telemetry sink is active — the per-task
3
+ // telemetry mirror. One pass over the outcome list builds all three, so
4
+ // `invocations.task_count`, the terminal's "N total" and the number of
5
+ // telemetry tasks are the same count by construction (`tallyOutcomes`
6
+ // applies the same group/aborted filter).
7
+
8
+ import type { InvocationRecord, RunRecord } from '../cache/index.js'
9
+ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
10
+ import { VERSION } from '../version.js'
11
+ import type { CiContext, GitContext, HostContext } from './run-context.js'
12
+ import { deriveCacheSource, isCacheHit, type TaskTelemetry } from './telemetry.js'
13
+
14
+ export interface RunRecordsInput {
15
+ outcomes: readonly TaskOutcome[]
16
+ runId: string
17
+ /** Wall-clock ms at run start; every per-task ns offset is anchored to it. */
18
+ startedAtMs: number
19
+ endedAtMs: number
20
+ totalMs: number
21
+ ok: boolean
22
+ command: string
23
+ requestedTasks: readonly string[]
24
+ /** Already compacted (`compactCachePolicy`), the form the header row stores. */
25
+ cachePolicy: string
26
+ concurrency: number
27
+ flow: InvocationRecord['flow']
28
+ forwardArgs?: readonly string[] | undefined
29
+ tags: Record<string, string>
30
+ git: GitContext
31
+ ci: CiContext
32
+ host: HostContext
33
+ /** Build the telemetry mirror; a run with no sink allocates none. */
34
+ withTelemetry: boolean
35
+ }
36
+
37
+ export interface RunRecords {
38
+ runs: RunRecord[]
39
+ invocation: InvocationRecord
40
+ telemetryTasks: TaskTelemetry[]
41
+ }
42
+
43
+ /**
44
+ * Every non-group, non-aborted outcome gets a row. An outcome with NO hash
45
+ * (a `skipped` task never probed the cache; a `persistent` one is never
46
+ * cacheable) used to be dropped because `runs.hash` is NOT NULL — which
47
+ * made a failing persistent task record `0 tasks, 0 failures` on a run the
48
+ * terminal called red, and a failed task with a skipped dependent record
49
+ * 1 of 2. `bindRun` stores `''` for those instead; the key-diff readers
50
+ * guard it.
51
+ */
52
+ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
53
+ const { outcomes, runId, startedAtMs, endedAtMs } = input
54
+ const runs: RunRecord[] = []
55
+ const telemetryTasks: TaskTelemetry[] = []
56
+ let failedCount = 0
57
+ let hitLocalCount = 0
58
+ let hitRemoteCount = 0
59
+ for (const o of outcomes) {
60
+ if (isGroupTask(o.node)) continue
61
+ // aborted (killed by a shutdown signal) isn't a real run.
62
+ if (o.status === 'aborted') continue
63
+ if (input.withTelemetry) telemetryTasks.push(telemetryOf(o))
64
+ runs.push({
65
+ ...(o.hash !== undefined ? { hash: o.hash } : {}),
66
+ project: o.node.projectName,
67
+ task: o.node.taskName,
68
+ status: o.status,
69
+ exitCode: o.exitCode,
70
+ durationMs: o.durationMs,
71
+ ...(input.forwardArgs !== undefined ? { forwardArgs: input.forwardArgs } : {}),
72
+ // Anchor to the REAL per-task wall-clock window: run-start wall time +
73
+ // the task's ns offset (captured for hits and executed tasks alike).
74
+ // The `end - duration` fallback applies to outcomes without an offset —
75
+ // today only `skipped`, which the scheduler finishes synchronously with
76
+ // no span, so it collapses to a zero-width mark at the run's end. Using
77
+ // run-end-minus-duration for EVERYTHING was the old bug that piled every
78
+ // task at the right edge of the timeline.
79
+ startedAt:
80
+ o.wallclockStartNs !== undefined
81
+ ? startedAtMs + Math.round(Number(o.wallclockStartNs) / 1e6)
82
+ : endedAtMs - o.durationMs,
83
+ endedAt:
84
+ o.wallclockEndNs !== undefined
85
+ ? startedAtMs + Math.round(Number(o.wallclockEndNs) / 1e6)
86
+ : endedAtMs,
87
+ runId,
88
+ ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
89
+ ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
90
+ ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
91
+ ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
92
+ cacheHit: isCacheHit(o.status),
93
+ ...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
94
+ cached: o.node.config.cache !== undefined,
95
+ })
96
+ if (o.status === 'failed') failedCount++
97
+ if (o.status === 'cache-hit') hitLocalCount++
98
+ if (o.status === 'cache-hit-remote') hitRemoteCount++
99
+ }
100
+ const invocation: InvocationRecord = {
101
+ runId,
102
+ command: input.command,
103
+ requestedTasks: JSON.stringify([...input.requestedTasks]),
104
+ cachePolicy: input.cachePolicy,
105
+ concurrency: input.concurrency,
106
+ flow: input.flow,
107
+ startedAt: startedAtMs,
108
+ endedAt: endedAtMs,
109
+ totalDurationMs: Math.round(input.totalMs),
110
+ taskCount: runs.length,
111
+ failedCount,
112
+ hitCount: hitLocalCount + hitRemoteCount,
113
+ hitLocalCount,
114
+ hitRemoteCount,
115
+ exitOk: input.ok,
116
+ commitSha: input.git.commitSha,
117
+ branch: input.git.branch,
118
+ dirty: input.git.dirty,
119
+ ci: input.ci.ci,
120
+ ciProvider: input.ci.provider,
121
+ host: input.host.host,
122
+ os: input.host.os,
123
+ arch: input.host.arch,
124
+ vxVersion: VERSION,
125
+ tags: JSON.stringify(input.tags),
126
+ }
127
+ return { runs, invocation, telemetryTasks }
128
+ }
129
+
130
+ function telemetryOf(o: TaskOutcome): TaskTelemetry {
131
+ const t: TaskTelemetry = {
132
+ taskId: o.node.id,
133
+ project: o.node.projectName,
134
+ task: o.node.taskName,
135
+ status: o.status,
136
+ cacheSource: deriveCacheSource(o.status),
137
+ exitCode: o.exitCode,
138
+ durationMs: o.durationMs,
139
+ }
140
+ if (o.hash !== undefined) t.hash = o.hash
141
+ if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
142
+ if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
143
+ if (o.where !== undefined) t.where = o.where
144
+ if (o.outputs !== undefined) t.outputs = o.outputs
145
+ if (o.attempts !== undefined) t.attempts = o.attempts
146
+ if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
147
+ if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
148
+ return t
149
+ }
@@ -89,7 +89,7 @@ function cacheWord(o: OutcomeView): string {
89
89
  return o.restored === false ? 'up-to-date' : 'remote'
90
90
  case 'success':
91
91
  case 'failed':
92
- return 'miss'
92
+ return o.noCache === true ? 'no-cache' : 'miss'
93
93
  default:
94
94
  return '—'
95
95
  }
@@ -109,7 +109,7 @@ function fmtDuration(ms: number): string {
109
109
  * Exported because this file is not the only markdown table describing a run:
110
110
  * the cloud plugin's GitHub job summary renders the same data from the same
111
111
  * unvalidated names and shipped WITHOUT this escape, so a `|` in a task name
112
- * or a `--verify` output path shifted its columns. One definition, so the two
112
+ * or an output path shifted its columns. One definition, so the two
113
113
  * cannot disagree about what a cell may contain.
114
114
  */
115
115
  export function escapeMarkdownCell(value: string): string {