@vzn/vx 0.0.15 → 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.
- package/README.md +91 -130
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +129 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +27 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
2
|
-
// one row per task plus a header line of totals —
|
|
3
|
-
//
|
|
4
|
-
// (`vx run ci --report=
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
`${
|
|
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
|
-
|
|
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)} | ${
|
|
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'
|