@vzn/vx 0.0.16 → 0.0.18

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 (111) hide show
  1. package/README.md +66 -89
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +88 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +12 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -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 the dashboard's branch column 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
@@ -1,64 +1,66 @@
1
- // Markdown run report (`vx run --report=markdown`). A moon-style table —
2
- // one row per task plus a header line of totals — written verbatim to
3
- // stdout after a run so CI can append it to a step summary
4
- // (`vx run ci --report=markdown >> $GITHUB_STEP_SUMMARY`). Pure: the
5
- // run's finished outcomes in, a markdown string out. No ANSI, no live
6
- // region — keep it machine-clean and diffable.
1
+ // Markdown run report (`vx run --report=markdown` / `--report-file=<path>`).
2
+ // A moon-style table — one row per task plus a header line of totals —
3
+ // rendered after a run so CI can put it in a step summary
4
+ // (`vx run ci --report-file="$GITHUB_STEP_SUMMARY"`). Pure: the run's
5
+ // finished outcomes in, a markdown string out. No ANSI, no live region —
6
+ // keep it machine-clean and diffable.
7
+ //
8
+ // Machine-clean is a property of this STRING, not of stdout: the status
9
+ // logger shares that stream, so redirecting it captures the whole run log
10
+ // too. `--report-file` is the sink that carries the property end to end.
7
11
 
8
- import type { RunResult } from './protocol.js'
9
12
  import type { OutcomeView } from './events.js'
10
13
 
11
14
  /**
12
- * Outcome buckets for the report header. Mirrors the terminal summary's
13
- * `tallyOutcomes` partition (success counts hits; `aborted` is no work
14
- * and excluded), but derives from the serializable `OutcomeView` the CLI
15
- * holds after a run rather than the in-process `TaskOutcome` — so it
16
- * needs no graph back-ref and stays self-contained here.
15
+ * One finished run, reduced to what a report needs. Used to live in
16
+ * `protocol.ts` as the return type of the whole-run `backend` seam; that
17
+ * seam is gone (a run always executes in-process — see
18
+ * `docs/modules/executor.md`), so the shape lives with its only consumer.
17
19
  */
18
- interface ReportTally {
19
- total: number
20
- successful: number
21
- failed: number
22
- skipped: number
23
- cached: number
20
+ export interface RunResult {
21
+ ok: boolean
22
+ outcomes: OutcomeView[]
23
+ }
24
+ import { tallyViews, type Tally } from './tally.js'
25
+
26
+ /**
27
+ * The report header's numbers. The outcome BUCKETS come from the shared
28
+ * `tallyViews` — the same rules the terminal summary and `--summarize` use,
29
+ * so three surfaces describing one run cannot disagree. Only the two
30
+ * durations are report-specific.
31
+ *
32
+ * This used to be a private copy of the partition, and it drifted: it had no
33
+ * group filter (it could not have one — `OutcomeView` had no `isGroup`), so
34
+ * every organizational node was counted as a successful task and rendered as
35
+ * a row claiming `success | miss | 0ms`.
36
+ */
37
+ interface ReportTally extends Tally {
24
38
  /** Wall-clock ms summed over tasks that actually executed (misses). */
25
39
  executedMs: number
26
- /** Wall-clock ms summed over cache hits — the work the cache skipped. */
40
+ /**
41
+ * Ms of work the cache SKIPPED — the sum of the hits' STORED exec times.
42
+ * Deliberately not `durationMs`, which for a hit is the restore this run
43
+ * paid: summing that reported a 2.01 s task as "6ms saved".
44
+ */
27
45
  savedMs: number
28
46
  }
29
47
 
30
48
  function tally(outcomes: readonly OutcomeView[]): ReportTally {
31
- const t: ReportTally = {
32
- total: 0,
33
- successful: 0,
34
- failed: 0,
35
- skipped: 0,
36
- cached: 0,
37
- executedMs: 0,
38
- savedMs: 0,
39
- }
49
+ const t: ReportTally = { ...tallyViews(outcomes), executedMs: 0, savedMs: 0 }
40
50
  for (const o of outcomes) {
41
- // A child killed by a shutdown signal (Ctrl-C teardown) is no work —
42
- // excluded from totals and every bucket, like the terminal tally.
43
- if (o.status === 'aborted') continue
44
- t.total++
51
+ if (o.isGroup === true) continue
45
52
  switch (o.status) {
46
53
  case 'success':
47
- t.successful++
54
+ case 'failed':
48
55
  t.executedMs += o.durationMs
49
56
  break
50
57
  case 'cache-hit':
51
58
  case 'cache-hit-remote':
52
- t.successful++
53
- t.cached++
54
- t.savedMs += o.durationMs
55
- break
56
- case 'failed':
57
- t.failed++
58
- t.executedMs += o.durationMs
59
- break
60
- case 'skipped':
61
- t.skipped++
59
+ // An outcome that doesn't know what it skipped contributes nothing:
60
+ // better to omit the claim than to substantiate it with the restore
61
+ // cost. (Only reachable across a version skew — every hit this
62
+ // binary produces carries the stored duration.)
63
+ t.savedMs += o.storedDurationMs ?? 0
62
64
  break
63
65
  }
64
66
  }
@@ -98,6 +100,24 @@ function fmtDuration(ms: number): string {
98
100
  return `${(ms / 1000).toFixed(2)}s`
99
101
  }
100
102
 
103
+ /**
104
+ * Make a value safe inside a GFM table cell. Task names are arbitrary TS
105
+ * object keys and the loader accepts `|` and newlines, either of which
106
+ * silently breaks the table on the consumer (`>> $GITHUB_STEP_SUMMARY`):
107
+ * a bare pipe adds a column, a newline splits the row.
108
+ *
109
+ * Exported because this file is not the only markdown table describing a run:
110
+ * the cloud plugin's GitHub job summary renders the same data from the same
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
113
+ * cannot disagree about what a cell may contain.
114
+ */
115
+ export function escapeMarkdownCell(value: string): string {
116
+ return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
117
+ }
118
+
119
+ const cell = escapeMarkdownCell
120
+
101
121
  /**
102
122
  * Render the finished run as a markdown report. A header line of totals
103
123
  * followed by a per-task table. Self-contained — everything is derived
@@ -105,13 +125,15 @@ function fmtDuration(ms: number): string {
105
125
  */
106
126
  export function formatRunReportMarkdown(result: RunResult): string {
107
127
  const t = tally(result.outcomes)
128
+ const cached = t.cachedLocal + t.cachedRemote
108
129
  const parts = [
109
130
  `**${t.total} task${t.total === 1 ? '' : 's'}**`,
110
131
  `${t.successful} success`,
111
132
  `${t.failed} failed`,
112
- `${t.cached} cached`,
133
+ `${cached} cached`,
113
134
  ]
114
135
  if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
136
+ if (t.aborted > 0) parts.push(`${t.aborted} aborted`)
115
137
  parts.push(`${fmtDuration(t.executedMs)} total`)
116
138
  if (t.savedMs > 0) parts.push(`${fmtDuration(t.savedMs)} saved`)
117
139
 
@@ -123,9 +145,14 @@ export function formatRunReportMarkdown(result: RunResult): string {
123
145
  lines.push('| Task | Status | Cache | Duration |')
124
146
  lines.push('| --- | --- | --- | --- |')
125
147
  for (const o of result.outcomes) {
126
- if (o.status === 'aborted') continue
148
+ // A group node did no work: no command, no cache decision, 0ms. Rendering
149
+ // one as `success | miss | 0ms` invents a task the user never wrote a
150
+ // command for. Same exclusion the header's tally makes.
151
+ if (o.isGroup === true) continue
127
152
  lines.push(
128
- `| ${o.taskId} | ${statusWord(o)} | ${cacheWord(o)} | ${fmtDuration(o.durationMs)} |`,
153
+ `| ${cell(o.taskId)} | ${cell(statusWord(o))} | ${cell(cacheWord(o))} | ${cell(
154
+ fmtDuration(o.durationMs),
155
+ )} |`,
129
156
  )
130
157
  }
131
158
  return lines.join('\n') + '\n'