@vzn/vx-ci 0.0.651 → 0.0.653

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 CHANGED
@@ -24,22 +24,27 @@ export default defineWorkspace({
24
24
  That's the whole setup. On a GitHub Actions runner (`GITHUB_STEP_SUMMARY`
25
25
  set) every `vx run` appends a summary block, on its own line after
26
26
  anything already written in the step: verdict headline, stats
27
- (tasks / executed / cache hits / duration, and the ideal time an executor says
28
- the run could have taken, as `@vzn/vx-dte` replays it), failures called out above the
27
+ (tasks / executed / cache hits / aborted and not run when a stop cut the run short / duration, and the ideal time an executor says
28
+ the run could have taken, as `@vzn/vx-dte` replays it), the workers
29
+ that would have run it best (`@vzn/vx-dte`'s "Workers to declare"), failures called out above the
29
30
  per-task table (rows by status, failures first, then the longest first) with their exit code, the signal an exit above 128
30
31
  stands for (`exit 137 (128 + SIGKILL)`, as the run's own frame and
31
32
  `vx last` say it), a timeout as `timed out, exit 143`, a persistent task
32
- that never became ready as `never ready (timed out)`, a sandboxed task's
33
- violation count, and the tasks each failure blocked. A server a
33
+ that never became ready as `never ready (timed out)`, a hit whose restore
34
+ failed as `cache restore failed` (a failure, not executed and not a hit), a task whose
35
+ command exited 0 but whose save vx refused as `cache save failed`, a sandboxed task's
36
+ violation count, and the tasks each failure blocked. A task that retried
37
+ or proved flaky carries the run's own row mark after its status
38
+ (`✅ ran · flaky - failed 1× before · 2 attempts`). A server a
34
39
  DTE run carried to its dependants' workers reads `served`,
35
- with no duration of its own. When an executor
40
+ with no duration of its own, as a skipped task has none. When an executor
36
41
  ran the run on other machines (`@vzn/vx-dte`'s workers), a Workers table
37
42
  shows each one's status, when it was asked for, its total time (warm-up
38
43
  plus online), its warm-up, its time online after the warm-up and the part
39
44
  of that with no task on it (a task counts from its send to its result,
40
45
  restore and save included), its tasks, the cores
41
46
  its tasks were expected to use (their last run's) beside the cores it used, its peak RAM of its memory, and
42
- its utilization, under a line
47
+ its machine's cpu utilization from its first task's send to its last one's end, under a line
43
48
  of the expected and used share of all their core-time (the expected share over the workers whose executor reports one). Tasks the executor left to the run's own machine get a `this machine` row, and a line splits the executed count between workers and this machine. A footer line
44
49
  names the vx version, the command (what follows `--` counted, not
45
50
  quoted), tasks passed and outputs restored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-ci",
3
- "version": "0.0.651",
3
+ "version": "0.0.653",
4
4
  "license": "MIT",
5
5
  "description": "GitHub Actions and GitLab CI integration for vx — a job summary on Actions and the branch cache scope on both.",
6
6
  "keywords": [
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "peerDependencies": {
36
- "@vzn/vx": "^0.0.651"
36
+ "@vzn/vx": "^0.0.653"
37
37
  },
38
38
  "repository": {
39
39
  "type": "git",
package/src/setup.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // verb is the plugin's, so a repo on neither CI has none.
6
6
 
7
7
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
8
- import { mkdir, writeFile } from 'node:fs/promises'
8
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
11
  import { ui, UserError } from '@vzn/vx'
@@ -124,12 +124,20 @@ export async function setup(o: SetupOptions): Promise<number> {
124
124
  await run(['apparmor_parser', '-R', '/etc/apparmor.d/bwrap-userns-restrict'])
125
125
  await run(['rm', '/etc/apparmor.d/bwrap-userns-restrict'])
126
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'])
127
+ // A fresh directory, only this user's: at tmpdir/vx-bwrap-<pid>.apparmor,
128
+ // a guessable name, a planted symlink took the write and root copied
129
+ // whatever that file held into /etc/apparmor.d.
130
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'vx-bwrap-'))
131
+ try {
132
+ const profile = path.join(dir, 'bwrap.apparmor')
133
+ await writeFile(
134
+ profile,
135
+ 'abi <abi/4.0>,\ninclude <tunables/global>\nprofile bwrap /usr/bin/bwrap flags=(unconfined) {\n userns,\n}\n',
136
+ )
137
+ await run(['cp', profile, '/etc/apparmor.d/bwrap'])
138
+ } finally {
139
+ await rm(dir, { recursive: true, force: true })
140
+ }
133
141
  await run(['apparmor_parser', '-r', '/etc/apparmor.d/bwrap'])
134
142
  }
135
143
  // GitLab keeps it in the checkout, where a root task's `**` keyed the
package/src/summary.ts CHANGED
@@ -43,6 +43,30 @@ function statusLabel(t: TaskTelemetry): string {
43
43
  return STATUS_LABEL[t.status] ?? t.status
44
44
  }
45
45
 
46
+ /**
47
+ * A retried or flaky task's mark, in the words of the run's own row
48
+ * (`flaky - failed 1× before · 2 attempts`): a pass that needed a retry
49
+ * read as a plain `ran`. Numbers only, so no escape.
50
+ */
51
+ function flakyMark(t: TaskTelemetry): string {
52
+ const attempts = (t.attempts ?? 1) > 1 ? `${t.attempts} attempts` : undefined
53
+ if (t.flaky === undefined) return attempts === undefined ? '' : ` · ${attempts}`
54
+ const said: string[] = []
55
+ if (t.status === 'failed' && t.flaky.passes > 0) said.push(`passed ${t.flaky.passes}× before`)
56
+ if (t.status !== 'failed' && t.flaky.failures > 0) said.push(`failed ${t.flaky.failures}× before`)
57
+ if (attempts !== undefined) said.push(attempts)
58
+ return said.length === 0 ? ' · flaky' : ` · flaky - ${said.join(' · ')}`
59
+ }
60
+
61
+ /**
62
+ * Whether the task ran its command here or on a worker. A carried server ran
63
+ * inside its dependants, on their worker; a failure in no cache column
64
+ * (`cacheSource: 'none'`) is a hit whose restore failed, which ran nothing.
65
+ */
66
+ const executed = (t: TaskTelemetry): boolean =>
67
+ (t.status === 'success' || (t.status === 'failed' && t.cacheSource !== 'none')) &&
68
+ t.carried !== true
69
+
46
70
  /** Rows by status label, in this order; any other after them. */
47
71
  const ORDER = [
48
72
  '❌ failed',
@@ -143,22 +167,21 @@ function codeSpan(raw: string): string {
143
167
  /** Render the whole job summary. Deterministic for a given record. */
144
168
  export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): string {
145
169
  const failed = summary.tasks.filter((t) => t.status === 'failed')
146
- const cancelled = !summary.exitOk && summary.failedCount === 0 && summary.abortedCount > 0
170
+ const notRun = summary.notRunCount ?? 0
171
+ const cancelled =
172
+ !summary.exitOk && summary.failedCount === 0 && summary.abortedCount + notRun > 0
147
173
  const verdict = summary.exitOk ? '✅' : cancelled ? '⏹️' : '❌'
148
174
  const lines: string[] = []
149
175
  lines.push(`## ${verdict} ${title}`)
150
176
  lines.push('')
151
- // A carried server ran inside its dependants, on their worker.
152
- const executed = summary.tasks.filter(
153
- (t) => (t.status === 'success' || t.status === 'failed') && t.carried !== true,
154
- ).length
155
177
  const stats = [
156
178
  `**${summary.taskCount}** task${summary.taskCount === 1 ? '' : 's'}`,
157
- `**${executed}** executed`,
179
+ `**${summary.tasks.filter(executed).length}** executed`,
158
180
  `**${summary.hitCount}** cache hit${summary.hitCount === 1 ? '' : 's'}` +
159
181
  (summary.hitCount > 0 ? ` ${hitSplit(summary)}` : ''),
160
182
  ...(summary.failedCount > 0 ? [`**${summary.failedCount}** failed`] : []),
161
183
  ...(summary.abortedCount > 0 ? [`**${summary.abortedCount}** aborted`] : []),
184
+ ...(notRun > 0 ? [`**${notRun}** not run`] : []),
162
185
  // The run played perfectly on what each task took, beside the real time (2026-10-23).
163
186
  summary.idealMs === undefined
164
187
  ? fmtMs(summary.totalDurationMs)
@@ -189,7 +212,7 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
189
212
  const signal = exitSignal(t.exitCode)
190
213
  const blocked = blockedBy.get(t.taskId) ?? []
191
214
  lines.push(
192
- `- **${escapeMarkdownCell(escapeInline(t.taskId))}** — ${t.notReady !== undefined ? `never ready (${t.notReady === 'timeout' ? 'timed out' : t.notReady === 'exited' ? 'exited' : 'spawn failed'}), exit ${t.exitCode}` : t.timedOut === true ? `timed out, exit ${t.exitCode}` : `exit ${t.exitCode}${signal === undefined ? '' : ` (128 + ${signal})`}`}${t.sandboxViolations !== undefined && t.sandboxViolations > 0 ? ` · ${t.sandboxViolations} sandbox violation${t.sandboxViolations === 1 ? '' : 's'}` : ''}${blocked.length > 0 ? ` · blocked ${blocked.join(', ')}` : ''}`,
215
+ `- **${escapeMarkdownCell(escapeInline(t.taskId))}** — ${t.cacheSource === 'none' ? 'cache restore failed' : t.notReady !== undefined ? `never ready (${t.notReady === 'timeout' ? 'timed out' : t.notReady === 'exited' ? 'exited' : 'spawn failed'}), exit ${t.exitCode}` : t.timedOut === true ? `timed out, exit ${t.exitCode}` : t.exitCode === 0 ? 'cache save failed' : `exit ${t.exitCode}${signal === undefined ? '' : ` (128 + ${signal})`}`}${t.sandboxViolations !== undefined && t.sandboxViolations > 0 ? ` · ${t.sandboxViolations} sandbox violation${t.sandboxViolations === 1 ? '' : 's'}` : ''}${blocked.length > 0 ? ` · blocked ${blocked.join(', ')}` : ''}`,
193
216
  )
194
217
  }
195
218
  lines.push('')
@@ -198,6 +221,9 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
198
221
  if (summary.machines !== undefined && summary.machines.length > 0) {
199
222
  lines.push(...renderWorkers(summary.machines, summary.tasks), '')
200
223
  }
224
+ if (summary.advice !== undefined && summary.advice.length > 0) {
225
+ lines.push(...renderAdvice(summary.advice), '')
226
+ }
201
227
 
202
228
  const header = ['Task', 'Status', 'Duration']
203
229
  lines.push(`| ${header.join(' | ')} |`)
@@ -212,8 +238,8 @@ export function renderJobSummary(summary: RunSummaryRecord, title = 'vx run'): s
212
238
  for (const t of ordered) {
213
239
  const cells = [
214
240
  escapeMarkdownCell(escapeInline(t.taskId)),
215
- statusLabel(t),
216
- t.carried === true ? '' : fmtMs(t.durationMs),
241
+ statusLabel(t) + flakyMark(t),
242
+ t.carried === true || t.status === 'skipped' ? '' : fmtMs(t.durationMs),
217
243
  ]
218
244
  lines.push(`| ${cells.join(' | ')} |`)
219
245
  }
@@ -237,6 +263,36 @@ const tasksCell = (ok: number, failed: number): string =>
237
263
 
238
264
  const pct = (x: number): string => `${Math.round(x * 100)}%`
239
265
 
266
+ /** What each `WorkerAdvice` played. */
267
+ const ADVICE = {
268
+ declared: 'The fewest of the declared sizes',
269
+ more: "Past the sizes' max",
270
+ any: 'Any cores, least core time',
271
+ } as const
272
+
273
+ /**
274
+ * The workers that would have run it best (2026-11-05: "So I can add more
275
+ * or change their specs"): played again from what each task took.
276
+ */
277
+ function renderAdvice(advice: NonNullable<RunSummaryRecord['advice']>): string[] {
278
+ const lines = [
279
+ '### Workers to declare',
280
+ '',
281
+ '| Played on | Workers | Ends at |',
282
+ '| --- | --- | --- |',
283
+ ]
284
+ for (const a of advice)
285
+ lines.push(
286
+ `| ${ADVICE[a.of]} | ${a.workers
287
+ .map(
288
+ (w) =>
289
+ `${w.count} × ${w.size === undefined ? '' : `${escapeMarkdownCell(escapeInline(w.size))} `}(${w.cores} cores, ${gb(w.memoryBytes)} GB${w.platform === undefined ? '' : `, ${escapeMarkdownCell(escapeInline(w.platform))}`})`,
290
+ )
291
+ .join(' + ')} | ${fmtMs(a.endMs)} |`,
292
+ )
293
+ return lines
294
+ }
295
+
240
296
  /**
241
297
  * The workers an executor ran the run on (vx-dte's): the cores its tasks
242
298
  * booked beside the cores they used, each averaged over the time the
@@ -285,11 +341,8 @@ function renderWorkers(
285
341
  )
286
342
  }
287
343
  // 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
- )
344
+ // a task that did is named, a bug to see.
345
+ const ran = tasks.filter(executed)
293
346
  const here = ran.filter((t) => t.where === undefined)
294
347
  if (here.length > 0) {
295
348
  const failed = here.filter((t) => t.status === 'failed').length