@vzn/vx-ci 0.0.646 → 0.0.649

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/src/plugin.ts CHANGED
@@ -1,22 +1,17 @@
1
1
  // The `github()` telemetry plugin. Contributes one observe-only sink that
2
2
  // writes the run as a GitHub Actions job summary. Off Actions
3
3
  // (`GITHUB_ACTIONS` not 'true') the factory returns undefined, which
4
- // `plugins` skips: no option check, no hook, no load, so declaring
5
- // `github()` costs nothing on a laptop.
4
+ // `plugins` skips: no hook, no load, so declaring `github()` costs nothing
5
+ // on a laptop. index.ts checks its options everywhere.
6
6
  import { appendFile, stat } from 'node:fs/promises'
7
7
  import { createRequire } from 'node:module'
8
- import {
9
- refuseUnknownOptions,
10
- type PluginOptionKinds,
11
- definePlugin,
12
- UserError,
13
- type VxPlugin,
14
- } from '@vzn/vx'
15
- import type { FetchFn } from './checks.js'
8
+ import { homedir } from 'node:os'
9
+ import path from 'node:path'
10
+ import { definePlugin, type VxPlugin } from '@vzn/vx'
16
11
  import { githubBranch } from './cache-scope.js'
17
12
 
18
- // Every run evaluates `.vx/workspace.ts`; the sink, the summary renderer
19
- // and the Checks API client load only where a summary file is set. The
13
+ // Every run evaluates `.vx/workspace.ts`; the sink and the summary
14
+ // renderer load only where a summary file is set. The
20
15
  // telemetry hook is synchronous, so the module loads by `require`.
21
16
  const loadSink = (): typeof import('./sink.js') => createRequire(import.meta.url)('./sink.js')
22
17
 
@@ -29,16 +24,6 @@ export interface GithubPluginOptions {
29
24
  summaryFile?: string
30
25
  /** Heading for the summary block. Default: `'vx run'`. */
31
26
  title?: string
32
- /**
33
- * Also create a completed check-run on the built commit (Checks API).
34
- * Default: on when the environment carries `GITHUB_TOKEN` +
35
- * `GITHUB_REPOSITORY` + `GITHUB_SHA` (the workflow must grant
36
- * `permissions: checks: write`); set `false` to opt out, `true` to warn
37
- * when the environment is missing instead of silently skipping.
38
- */
39
- checks?: boolean
40
- /** Check-run name. Default: `'vx'`. */
41
- checkName?: string
42
27
  /**
43
28
  * Name the run's cache scope and base commit from the Actions event: none
44
29
  * on the default branch, else `<owner>/<repo>:<branch>` (a fork's names
@@ -47,100 +32,44 @@ export interface GithubPluginOptions {
47
32
  * `false` opts out.
48
33
  */
49
34
  cacheScope?: boolean
50
- /** Test seam — inject the append. Defaults to fs appendFile. */
51
- append?: (file: string, markdown: string) => Promise<void>
52
- /** Test seam — inject the Checks API transport. Defaults to fetch. */
53
- fetchFn?: FetchFn
54
- /**
55
- * Test seam — bytes the summary file already holds. Defaults to its size
56
- * on disk; with `append` injected, 0 (that writer owns the file).
57
- */
58
- sizeOf?: (file: string) => Promise<number>
59
- }
60
-
61
- /**
62
- * Why a string cannot be an HTTP header value, or null. Bun's `fetch`
63
- * refuses a line break, a NUL or a character past Latin-1 once it has
64
- * trimmed the ends (measured on 1.4.2), and quotes the value in its error.
65
- */
66
- function headerValueFault(value: string): string | null {
67
- let past = false
68
- for (const ch of value.trim()) {
69
- const c = ch.codePointAt(0)!
70
- if (c === 0x0a || c === 0x0d || c === 0) return 'a line break or NUL'
71
- if (c > 0xff) past = true
72
- }
73
- return past ? 'a character past Latin-1' : null
74
35
  }
75
36
 
76
37
  /** Each option `GithubPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
77
- const GITHUB_PLUGIN_KEYS: PluginOptionKinds<GithubPluginOptions> = {
78
- summaryFile: 'string',
79
- title: 'string',
80
- checks: 'boolean',
81
- checkName: 'string',
82
- cacheScope: 'boolean',
83
- append: 'function',
84
- fetchFn: 'function',
85
- sizeOf: 'function',
86
- }
87
-
88
38
  export function github(options: GithubPluginOptions = {}): VxPlugin | undefined {
89
39
  if (process.env['GITHUB_ACTIONS'] !== 'true') return undefined
90
- refuseUnknownOptions('github()', options, GITHUB_PLUGIN_KEYS)
91
- // '' passes the kind check but reads wrong: `??` keeps it, so summaryFile
92
- // declined instead of using GITHUB_STEP_SUMMARY, checkName POSTed a name
93
- // GitHub refuses (422) and title rendered an empty heading.
94
- for (const key of ['summaryFile', 'title', 'checkName'] as const) {
95
- if (options[key] === '')
96
- throw new UserError(`github() option "${key}" must be a non-empty string, got ""`)
97
- }
40
+ // Its options were checked by index.ts's github(), the only caller.
98
41
  return definePlugin(import.meta, {
99
- async config(_workspace, ctx) {
42
+ commands: {
43
+ // Kept where actions/cache can save it: a hit installs the
44
+ // packages from it, no index fetch (14 to 52 s, 2026-10-22).
45
+ setup: {
46
+ description: 'github: install and prove the sandbox runtime on this runner',
47
+ run: async (argv) =>
48
+ (await import('./setup.js')).runSetup(
49
+ 'github',
50
+ argv,
51
+ path.join(homedir(), '.cache', 'vx', 'setup-debs'),
52
+ ),
53
+ },
54
+ },
55
+ async config(workspace, ctx) {
100
56
  if (options.cacheScope === false) return
101
- ctx.setBranch(await githubBranch(process.env))
57
+ ctx.setBranch(await githubBranch(process.env, workspace.baseBranch))
102
58
  },
103
59
  telemetry(ctx) {
104
60
  const file = options.summaryFile ?? process.env['GITHUB_STEP_SUMMARY']
105
61
  if (file === undefined || file === '') return undefined
106
- const append = options.append ?? (async (f: string, md: string) => appendFile(f, md, 'utf8'))
107
- const { GithubSummarySink, resolveCheckRunEnv } = loadSink()
108
- let check: ConstructorParameters<typeof GithubSummarySink>[4]
109
- if (options.checks !== false) {
110
- const env = resolveCheckRunEnv(process.env)
111
- // fetch refuses a token no header can carry and QUOTES it in its
112
- // error, which the check-run warning printed (item 928).
113
- const fault = env === null ? null : headerValueFault(env.token)
114
- if (fault !== null) {
115
- ctx.warn(
116
- `vx-ci: GITHUB_TOKEN holds ${fault}, which no HTTP header can carry — no check-run will be created (the token is not printed)`,
117
- )
118
- } else if (env !== null) {
119
- check = {
120
- env,
121
- name: options.checkName ?? 'vx',
122
- fetchFn: options.fetchFn ?? (fetch as unknown as FetchFn),
123
- }
124
- } else if (options.checks === true) {
125
- ctx.warn(
126
- 'vx-ci: checks requested but GITHUB_TOKEN / GITHUB_REPOSITORY / GITHUB_SHA are not all set — no check-run will be created',
127
- )
128
- }
129
- }
62
+ const { GithubSummarySink } = loadSink()
130
63
  return new GithubSummarySink(
131
64
  file,
132
65
  options.title ?? 'vx run',
133
- append,
66
+ (f, md) => appendFile(f, md, 'utf8'),
134
67
  (m) => ctx.warn(m),
135
- check,
136
- options.sizeOf ??
137
- (options.append !== undefined
138
- ? async () => 0
139
- : (f: string) =>
140
- stat(f).then(
141
- (st) => st.size,
142
- () => 0,
143
- )),
68
+ (f) =>
69
+ stat(f).then(
70
+ (st) => st.size,
71
+ () => 0,
72
+ ),
144
73
  )
145
74
  },
146
75
  })
package/src/setup.ts ADDED
@@ -0,0 +1,208 @@
1
+ // `vx setup github` / `vx setup gitlab`: what a CI machine needs before
2
+ // `vx run` can run a sandboxed task. On Linux: bubblewrap, socat, strace
3
+ // and ripgrep, bwrap allowed its user namespaces, and a proof it runs. On
4
+ // macOS the sandbox is built in. A step of the job, before vx runs; the
5
+ // verb is the plugin's, so a repo on neither CI has none.
6
+
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
8
+ import { mkdir, writeFile } from 'node:fs/promises'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import { ui, UserError } from '@vzn/vx'
12
+
13
+ const PACKAGES = ['bubblewrap', 'socat', 'strace', 'ripgrep']
14
+ const BINARIES = ['bwrap', 'socat', 'strace', 'rg']
15
+
16
+ /** A command's exit code; it writes to this process's stdout and stderr. */
17
+ export type Shell = (argv: readonly string[]) => Promise<number>
18
+
19
+ const shell: Shell = (argv) =>
20
+ Bun.spawn([...argv], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }).exited
21
+
22
+ export interface SetupOptions {
23
+ readonly ci: 'github' | 'gitlab'
24
+ /** Where fetched packages are kept, for the job's cache to save and restore. */
25
+ readonly debs: string
26
+ readonly platform?: NodeJS.Platform
27
+ readonly root?: boolean
28
+ readonly sh?: Shell
29
+ readonly has?: (bin: string) => boolean
30
+ /** GitHub's step output file: `fetched=true` when packages were fetched. */
31
+ readonly output?: string | undefined
32
+ readonly print?: (line: string) => void
33
+ /** Whether the kernel lets a program make user namespaces only under an AppArmor profile. */
34
+ readonly restricted?: () => boolean
35
+ }
36
+
37
+ const userns = (): boolean => {
38
+ try {
39
+ return (
40
+ readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1'
41
+ )
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ export async function setup(o: SetupOptions): Promise<number> {
48
+ const print = o.print ?? ((l: string) => ui.say('info', l, 'out'))
49
+ const platform = o.platform ?? process.platform
50
+ if (platform === 'darwin') {
51
+ print('vx setup: macOS has its sandbox built in; nothing to set up')
52
+ return 0
53
+ }
54
+ if (platform !== 'linux') throw new UserError(`vx setup: ${platform} has no vx sandbox`)
55
+ const sh = o.sh ?? shell
56
+ const has = o.has ?? ((bin: string) => Bun.which(bin) !== null)
57
+ const sudo = (o.root ?? process.getuid?.() === 0) ? [] : ['sudo']
58
+ const run = async (argv: readonly string[]): Promise<void> => {
59
+ const code = await sh([...sudo, ...argv])
60
+ if (code !== 0) throw new UserError(`vx setup: \`${argv.join(' ')}\` exited ${code}`)
61
+ }
62
+ let fetched = false
63
+ const missing = BINARIES.filter((b) => !has(b))
64
+ const debs = readdir(o.debs).filter((f) => f.endsWith('.deb'))
65
+ // What the job's cache restored, else fetched: kept from an older
66
+ // image, they may not install, and a set fetched where the image had a
67
+ // package already holds no deb of it.
68
+ const kept =
69
+ missing.length > 0 &&
70
+ debs.length > 0 &&
71
+ has('dpkg') &&
72
+ (await sh([...sudo, 'dpkg', '-i', ...debs.map((f) => path.join(o.debs, f))])) === 0 &&
73
+ BINARIES.every((b) => has(b))
74
+ if (missing.length > 0 && !kept) {
75
+ if (has('apt-get')) {
76
+ await run(['rm', '-rf', o.debs])
77
+ // man-db re-indexes every page on each install: seconds for nothing.
78
+ await run(['rm', '-f', '/var/lib/man-db/auto-update'])
79
+ // Only the distribution's own index is needed: a third-party source
80
+ // (Chrome, Microsoft on GitHub's images) serving a stale index fails
81
+ // `apt-get update` before vx runs at all (2026-09-09).
82
+ if (existsSync('/etc/apt/sources.list.d/ubuntu.sources'))
83
+ await run([
84
+ 'find',
85
+ '/etc/apt/sources.list.d',
86
+ '-type',
87
+ 'f',
88
+ '!',
89
+ '-name',
90
+ 'ubuntu.sources',
91
+ '-delete',
92
+ ])
93
+ await run(['apt-get', 'update'])
94
+ await mkdir(path.join(o.debs, 'partial'), { recursive: true })
95
+ await run([
96
+ 'apt-get',
97
+ 'install',
98
+ '-y',
99
+ '-o',
100
+ `Dir::Cache::Archives=${o.debs}`,
101
+ '-o',
102
+ 'APT::Keep-Downloaded-Packages=true',
103
+ ...PACKAGES,
104
+ ])
105
+ await run(['rm', '-rf', path.join(o.debs, 'partial'), path.join(o.debs, 'lock')])
106
+ await run(['chown', '-R', os.userInfo().username, o.debs])
107
+ fetched = true
108
+ } else if (has('apk')) {
109
+ await run(['apk', 'add', '--no-cache', ...PACKAGES])
110
+ } else {
111
+ throw new UserError(
112
+ `vx setup: ${missing.join(', ')} missing and no apt-get or apk to install ${PACKAGES.join(' ')}`,
113
+ )
114
+ }
115
+ }
116
+ // Ubuntu 24+ lets a program make user namespaces only under an AppArmor
117
+ // profile granting `userns` (discourse.ubuntu.com/t/58007). Ubuntu 26
118
+ // ships bwrap-userns-restrict instead, which strips the capabilities of
119
+ // the namespace nested in bwrap's, where the sandbox applies seccomp:
120
+ // every sandboxed task failed "nested userns is capability-restricted"
121
+ // (2026-10-23). bwrap gets the plain profile in its place.
122
+ if ((o.restricted ?? userns)()) {
123
+ if (existsSync('/etc/apparmor.d/bwrap-userns-restrict')) {
124
+ await run(['apparmor_parser', '-R', '/etc/apparmor.d/bwrap-userns-restrict'])
125
+ await run(['rm', '/etc/apparmor.d/bwrap-userns-restrict'])
126
+ }
127
+ const profile = path.join(os.tmpdir(), `vx-bwrap-${process.pid}.apparmor`)
128
+ await writeFile(
129
+ profile,
130
+ 'abi <abi/4.0>,\ninclude <tunables/global>\nprofile bwrap /usr/bin/bwrap flags=(unconfined) {\n userns,\n}\n',
131
+ )
132
+ await run(['cp', profile, '/etc/apparmor.d/bwrap'])
133
+ await run(['apparmor_parser', '-r', '/etc/apparmor.d/bwrap'])
134
+ }
135
+ // GitLab keeps it in the checkout, where a root task's `**` keyed the
136
+ // debs: a job that had them missed every hit saved without them.
137
+ if (existsSync(o.debs)) await writeFile(path.join(o.debs, '.gitignore'), '*\n')
138
+ if (o.output !== undefined && o.output !== '')
139
+ await writeFile(o.output, `fetched=${fetched}\n`, { flag: 'a' })
140
+ // The proof: a sandbox that cannot start fails here, named, not as every
141
+ // sandboxed task's failure later.
142
+ const code = await sh([
143
+ 'bwrap',
144
+ '--ro-bind',
145
+ '/',
146
+ '/',
147
+ '--proc',
148
+ '/proc',
149
+ '--dev',
150
+ '/dev',
151
+ '/bin/true',
152
+ ])
153
+ if (code !== 0)
154
+ throw new UserError(
155
+ `vx setup: bwrap cannot start a sandbox here (exit ${code}): the machine must allow user namespaces`,
156
+ )
157
+ print(`vx setup: the sandbox runs on this ${o.ci === 'github' ? 'runner' : 'job'}`)
158
+ return 0
159
+ }
160
+
161
+ function readdir(dir: string): string[] {
162
+ try {
163
+ return readdirSync(dir)
164
+ } catch {
165
+ return []
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The key to save fetched packages under: this runner image's, after the
171
+ * `vx-setup-debs-<runner.os>-<runner.arch>-` a job restores by. Led by the
172
+ * image alone, no restore ever matched it and every worker fetched again
173
+ * (main, 2026-10-23: 6-55 s of each worker's start).
174
+ */
175
+ export function debsKey(env: Record<string, string | undefined>): string {
176
+ return `vx-setup-debs-${env['RUNNER_OS'] ?? ''}-${env['RUNNER_ARCH'] ?? ''}-${env['ImageOS'] ?? ''}-${env['ImageVersion'] ?? ''}`
177
+ }
178
+
179
+ /** The `setup` verb of `github()` or `gitlab()`: `vx setup <ci> [--debs <dir>]`. */
180
+ export async function runSetup(
181
+ ci: 'github' | 'gitlab',
182
+ argv: readonly string[],
183
+ defaultDebs: string,
184
+ ): Promise<number> {
185
+ const usage = `Usage: vx setup ${ci} [--debs <dir>]`
186
+ // Asked for, the usage is an answer: `vx setup --help` exited 1 (2026-10-26).
187
+ if (argv.includes('--help') || argv.includes('-h')) {
188
+ ui.say('info', usage, 'out')
189
+ return 0
190
+ }
191
+ // The bare usage said nothing of what was wrong with the line typed.
192
+ const wrong = (what: string): never => {
193
+ throw new UserError(`${what}\n${usage}`, 'VX_E_USAGE')
194
+ }
195
+ if (argv[0] === undefined) wrong(`missing the CI: vx setup ${ci}`)
196
+ if (argv[0] !== ci) wrong(`unknown CI: ${argv[0]} (this workspace sets up ${ci})`)
197
+ let debs = defaultDebs
198
+ for (let i = 1; i < argv.length; i++) {
199
+ if (argv[i] !== '--debs') wrong(`unknown argument: ${argv[i]}`)
200
+ if (argv[i + 1] === undefined) wrong('--debs needs a directory')
201
+ debs = path.resolve(argv[++i]!)
202
+ }
203
+ await mkdir(debs, { recursive: true })
204
+ const output = ci === 'github' ? process.env['GITHUB_OUTPUT'] : undefined
205
+ if (output !== undefined && output !== '')
206
+ await writeFile(output, `key=${debsKey(process.env)}\n`, { flag: 'a' })
207
+ return setup({ ci, debs, output })
208
+ }
package/src/sink.ts CHANGED
@@ -1,11 +1,8 @@
1
1
  // The job summary sink: loaded only on a runner that sets a summary file,
2
2
  // so a run elsewhere pays no module of it.
3
3
  import { type RunSummaryRecord, type TelemetrySink } from '@vzn/vx'
4
- import { buildCheckRunPayload, postCheckRun, type CheckRunEnv, type FetchFn } from './checks.js'
5
4
  import { clampJobSummary, MAX_JOB_SUMMARY_BYTES, renderJobSummary } from './summary.js'
6
5
 
7
- export { resolveCheckRunEnv } from './checks.js'
8
-
9
6
  export class GithubSummarySink implements TelemetrySink {
10
7
  readonly name = 'github-job-summary'
11
8
  /** Summary-only: no streaming records at all — zero per-event cost. */
@@ -17,7 +14,6 @@ export class GithubSummarySink implements TelemetrySink {
17
14
  private readonly title: string,
18
15
  private readonly append: (file: string, markdown: string) => Promise<void>,
19
16
  private readonly warn: (m: string) => void,
20
- private readonly check?: { env: CheckRunEnv; name: string; fetchFn: FetchFn },
21
17
  /** Bytes the summary file already holds. */
22
18
  private readonly sizeOf: (file: string) => Promise<number> = async () => 0,
23
19
  ) {}
@@ -27,20 +23,15 @@ export class GithubSummarySink implements TelemetrySink {
27
23
  this.summary = summary
28
24
  }
29
25
 
30
- async flush(signal?: AbortSignal): Promise<void> {
26
+ async flush(): Promise<void> {
31
27
  if (this.summary === undefined) return
32
28
  const markdown = renderJobSummary(this.summary, this.title)
33
- // The two artifacts are INDEPENDENT, and the plugin already says so in
34
- // one direction: it declines the check-run without a token and still
35
- // writes the summary. The reverse has to hold, or a full disk on the
36
- // runner costs the PR its check — the more visible of the two. Reported,
37
- // not thrown: a telemetry sink may never break a run.
29
+ // Reported, not thrown: a telemetry sink may never break a run.
38
30
  try {
39
31
  // Clamped: past GitHub's 1 MiB cap the runner drops the summary whole,
40
32
  // so a bounded page beats none. The cap covers the step's whole file,
41
33
  // and a page clamped to 1 MiB after another writer's output lost both
42
- // (F-42): the room is what is left. The check-run payload has its own,
43
- // smaller cap and is clamped where it is built.
34
+ // (F-42): the room is what is left.
44
35
  const used = await this.sizeOf(this.file)
45
36
  // After another writer the page starts on its own line: one that left
46
37
  // no final newline ran vx's heading into its paragraph (F-51).
@@ -48,29 +39,15 @@ export class GithubSummarySink implements TelemetrySink {
48
39
  const page = clampJobSummary(markdown, MAX_JOB_SUMMARY_BYTES - used - lead.length)
49
40
  if (page === '') {
50
41
  this.warn(
51
- `vx-ci: ${this.file} already holds ${used} bytes of GitHub's 1 MiB job summary cap — no room for vx's page`,
42
+ `[vx] vx-ci: ${this.file} already holds ${used} bytes of GitHub's 1 MiB job summary cap — no room for vx's page`,
52
43
  )
53
44
  } else {
54
45
  await this.append(this.file, lead + page)
55
46
  }
56
47
  } catch (err) {
57
48
  this.warn(
58
- `vx-ci: could not write the job summary to ${this.file}: ${err instanceof Error ? err.message : String(err)}`,
49
+ `[vx] vx-ci: could not write the job summary to ${this.file}: ${err instanceof Error ? err.message : String(err)}`,
59
50
  )
60
51
  }
61
- if (this.check !== undefined) {
62
- await postCheckRun({
63
- env: this.check.env,
64
- payload: buildCheckRunPayload({
65
- summary: this.summary,
66
- markdown,
67
- name: this.check.name,
68
- sha: this.check.env.sha,
69
- }),
70
- fetchFn: this.check.fetchFn,
71
- warn: this.warn,
72
- ...(signal === undefined ? {} : { signal }),
73
- })
74
- }
75
52
  }
76
53
  }
package/src/summary.ts CHANGED
@@ -21,6 +21,11 @@ const STATUS_LABEL: Record<string, string> = {
21
21
  // Rounded once, then split: rounding the remainder after flooring the
22
22
  // minutes printed 119.7 s as `1m 60s`. The tier is chosen by the rounded
23
23
  // value: 59.96 s printed `60.0s` and 999.6 ms `1000ms` (F-51).
24
+ /** Bytes as gigabytes, one decimal. */
25
+ function gb(bytes: number): string {
26
+ return (bytes / 1e9).toFixed(1)
27
+ }
28
+
24
29
  function fmtMs(ms: number): string {
25
30
  if (Math.round(ms) < 1000) return `${Math.round(ms)}ms`
26
31
  if (Math.round(ms / 100) < 600) return `${(ms / 1000).toFixed(1)}s`
@@ -29,12 +34,31 @@ function fmtMs(ms: number): string {
29
34
  }
30
35
 
31
36
  function statusLabel(t: TaskTelemetry): string {
37
+ // A carried server ran inside each dependant, on its worker: its time is
38
+ // theirs, and a `ran 0ms` row read as a run that did nothing (2026-10-23).
39
+ if (t.carried === true) return '🔗 served'
32
40
  // A hit that found its outputs in place restored nothing: up to date,
33
- // whichever layer answered, as vx's own summary says it.
41
+ // as vx's own summary says it (a remote hit always restored).
34
42
  if (isCacheHit(t.status) && t.restored !== true) return '✔️ up-to-date'
35
43
  return STATUS_LABEL[t.status] ?? t.status
36
44
  }
37
45
 
46
+ /** Rows by status label, in this order; any other after them. */
47
+ const ORDER = [
48
+ '❌ failed',
49
+ '🛑 aborted',
50
+ '✅ ran',
51
+ '🔗 served',
52
+ '⚡ restored',
53
+ '☁️ restored remote',
54
+ '✔️ up-to-date',
55
+ '⏭️ skipped',
56
+ ]
57
+ const rank = (t: TaskTelemetry): number => {
58
+ const i = ORDER.indexOf(statusLabel(t))
59
+ return i === -1 ? ORDER.length : i
60
+ }
61
+
38
62
  /** `(3 up-to-date, 2 restored)`, with the remote share when any came from there. */
39
63
  function hitSplit(s: RunSummaryRecord): string {
40
64
  const restored = s.restoredLocalCount + s.restoredRemoteCount
@@ -63,7 +87,7 @@ export function clampJobSummary(markdown: string, room = MAX_JOB_SUMMARY_BYTES):
63
87
  }
64
88
 
65
89
  /** Cuts `text` to `maxBytes` of UTF-8, `suffix` included, never splitting a character. */
66
- export function truncateUtf8(text: string, maxBytes: number, suffix: string): string {
90
+ function truncateUtf8(text: string, maxBytes: number, suffix: string): string {
67
91
  // The cap is BYTES, and the page is not ASCII: every status label is an
68
92
  // emoji (4 bytes, 2 UTF-16 units) and every separator a `·` or a `—`
69
93
  // (2 and 3 bytes, 1 unit). Measured in `.length`, a clamped page of those
@@ -94,10 +118,11 @@ function escapeMarkdownCell(value: string): string {
94
118
 
95
119
  /**
96
120
  * A task id as inline markdown: `a#*x*` rendered as `a#` and an italic
97
- * `x` (item 1058). Table cells escape their pipes on top of this.
121
+ * `x` (item 1058), `a#~x~` struck through and `&lt;` as `<`. Table cells
122
+ * escape their pipes on top of this.
98
123
  */
99
124
  function escapeInline(s: string): string {
100
- return s.replace(/[\\`*_[\]<>]/g, '\\$&')
125
+ return s.replace(/[\\`*_[\]<>~&]/g, '\\$&')
101
126
  }
102
127
 
103
128
  /**
@@ -123,8 +148,9 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
123
148
  const lines: string[] = []
124
149
  lines.push(`## ${verdict} ${title}`)
125
150
  lines.push('')
151
+ // A carried server ran inside its dependants, on their worker.
126
152
  const executed = summary.tasks.filter(
127
- (t) => t.status === 'success' || t.status === 'failed',
153
+ (t) => (t.status === 'success' || t.status === 'failed') && t.carried !== true,
128
154
  ).length
129
155
  const stats = [
130
156
  `**${summary.taskCount}** task${summary.taskCount === 1 ? '' : 's'}`,
@@ -133,7 +159,10 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
133
159
  (summary.hitCount > 0 ? ` ${hitSplit(summary)}` : ''),
134
160
  ...(summary.failedCount > 0 ? [`**${summary.failedCount}** failed`] : []),
135
161
  ...(summary.abortedCount > 0 ? [`**${summary.abortedCount}** aborted`] : []),
136
- fmtMs(summary.totalDurationMs),
162
+ // The run played perfectly on what each task took, beside the real time (2026-10-23).
163
+ summary.idealMs === undefined
164
+ ? fmtMs(summary.totalDurationMs)
165
+ : `${fmtMs(summary.totalDurationMs)} (ideal ${fmtMs(summary.idealMs)})`,
137
166
  ]
138
167
  lines.push(stats.join(' · '))
139
168
  lines.push('')
@@ -166,14 +195,26 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
166
195
  lines.push('')
167
196
  }
168
197
 
198
+ if (summary.machines !== undefined && summary.machines.length > 0) {
199
+ lines.push(...renderWorkers(summary.machines, summary.tasks), '')
200
+ }
201
+
169
202
  const header = ['Task', 'Status', 'Duration']
170
203
  lines.push(`| ${header.join(' | ')} |`)
171
204
  lines.push(`|${header.map(() => ' --- ').join('|')}|`)
172
- // Failures first (the eye lands on the table's top rows), then execution
173
- // order as delivered.
174
- const ordered = [...failed, ...summary.tasks.filter((t) => t.status !== 'failed')]
205
+ // By status, failures first (the eye lands on the table's top rows),
206
+ // then the longest first (2026-10-23).
207
+ const ordered = [...summary.tasks].sort(
208
+ (a, b) =>
209
+ rank(a) - rank(b) ||
210
+ (b.carried === true ? 0 : b.durationMs) - (a.carried === true ? 0 : a.durationMs),
211
+ )
175
212
  for (const t of ordered) {
176
- const cells = [escapeMarkdownCell(escapeInline(t.taskId)), statusLabel(t), fmtMs(t.durationMs)]
213
+ const cells = [
214
+ escapeMarkdownCell(escapeInline(t.taskId)),
215
+ statusLabel(t),
216
+ t.carried === true ? '' : fmtMs(t.durationMs),
217
+ ]
177
218
  lines.push(`| ${cells.join(' | ')} |`)
178
219
  }
179
220
  lines.push('')
@@ -189,3 +230,81 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
189
230
  lines.push('')
190
231
  return lines.join('\n')
191
232
  }
233
+
234
+ // "13 (1 failed)" read as 13 run in all, of 14 (2026-10-23).
235
+ const tasksCell = (ok: number, failed: number): string =>
236
+ failed > 0 ? `${ok} ok, ${failed} failed` : `${ok}`
237
+
238
+ const pct = (x: number): string => `${Math.round(x * 100)}%`
239
+
240
+ /**
241
+ * The workers an executor ran the run on (vx-dte's): the cores its tasks
242
+ * booked beside the cores they used, each averaged over the time the
243
+ * worker was online, so an idle or overfilled one shows on the page
244
+ * (2026-10-23). "4.0 of 4" read as a peak; it is that average (M:
245
+ * "is this an avg booking?").
246
+ */
247
+ function renderWorkers(
248
+ machines: NonNullable<RunSummaryRecord['machines']>,
249
+ tasks: RunSummaryRecord['tasks'],
250
+ ): string[] {
251
+ const lines = [
252
+ '### Workers',
253
+ '',
254
+ '| Worker | Status | Started at | Total | Warm-up | Online after warm-up | Idle while online | Tasks | Booked cores, avg | Used cores, avg | Peak RAM | Used of all cores |',
255
+ '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
256
+ ]
257
+ let coreMs = 0
258
+ let cpuMs = 0
259
+ // Over the workers that report a booking only: an executor may report
260
+ // none, and counting that as 0 read as tasks booking nothing.
261
+ let bookedCoreMs = 0
262
+ let bookedMs = 0
263
+ for (const m of machines) {
264
+ coreMs += m.cores * m.onlineMs
265
+ cpuMs += m.cpuAvg * m.onlineMs
266
+ if (m.bookedAvg !== undefined) {
267
+ bookedCoreMs += m.cores * m.onlineMs
268
+ bookedMs += m.bookedAvg * m.onlineMs
269
+ }
270
+ lines.push(
271
+ `| ${[
272
+ escapeMarkdownCell(escapeInline(m.name)),
273
+ escapeMarkdownCell(m.status),
274
+ m.startMs === undefined ? '' : fmtMs(m.startMs),
275
+ fmtMs((m.warmUpMs ?? 0) + m.onlineMs),
276
+ m.warmUpMs === undefined ? '' : fmtMs(m.warmUpMs),
277
+ fmtMs(m.onlineMs),
278
+ fmtMs(Math.max(0, m.onlineMs - m.busyMs)),
279
+ tasksCell(m.done, m.failed),
280
+ m.bookedAvg === undefined ? '' : `${m.bookedAvg.toFixed(1)} of ${m.cores}`,
281
+ `${m.cpuAvg.toFixed(1)} of ${m.cores}`,
282
+ `${gb(m.memoryPeakBytes)} of ${gb(m.memoryBytes)} GB`,
283
+ pct(m.utilization),
284
+ ].join(' | ')} |`,
285
+ )
286
+ }
287
+ // Nothing runs on a distributed run's own machine (2026-10-23):
288
+ // a task that did is named, a bug to see. A carried server ran inside its
289
+ // dependants, on their worker.
290
+ const ran = tasks.filter(
291
+ (t) => (t.status === 'success' || t.status === 'failed') && t.carried !== true,
292
+ )
293
+ const here = ran.filter((t) => t.where === undefined)
294
+ if (here.length > 0) {
295
+ const failed = here.filter((t) => t.status === 'failed').length
296
+ lines.push(
297
+ `| this machine | ok | | | | | | ${tasksCell(here.length - failed, failed)} | | | | |`,
298
+ '',
299
+ `${ran.length} executed: ${ran.length - here.length} on workers, ${here.length} on this machine: ${here.map((t) => escapeMarkdownCell(escapeInline(t.taskId))).join(', ')}.`,
300
+ )
301
+ }
302
+ if (coreMs > 0)
303
+ lines.push(
304
+ '',
305
+ bookedCoreMs > 0
306
+ ? `Over their online time, tasks booked ${pct(bookedMs / bookedCoreMs)} of the workers' cores and used ${pct(cpuMs / coreMs)}.`
307
+ : `Over their online time, tasks used ${pct(cpuMs / coreMs)} of the workers' cores.`,
308
+ )
309
+ return lines
310
+ }