@vzn/vx 0.0.495 → 0.0.497

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
@@ -18,16 +18,16 @@
18
18
 
19
19
  <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
20
20
 
21
- | 1,090 packages, 3,270 tasks | vx | Turborepo | Nx |
22
- | -------------------------------- | ---------- | -------------------------- | ------------------------ |
23
- | Cold build: time the runner adds | **9 s** | 1 min 35 s (vx 91% faster) | 31 min (vx 99% faster) |
24
- | Cold build: CPU burned | **34 s** | 1 min 13 s (vx 53% faster) | 114 min (vx 99% faster) |
25
- | Fully cached run | **476 ms** | 760 ms (vx 37% faster) | 3.59 s (vx 86% faster) |
26
- | Overhead per package | **8 ms** | 88 ms (vx 91% faster) | 1,712 ms (vx 99% faster) |
21
+ | 1,090 packages, 3,270 tasks | vx | Turborepo | Nx |
22
+ | -------------------------------- | ---------- | --------------------------- | ------------------------- |
23
+ | Cold build: time the runner adds | **9 s** | 1 min 35 s (vx 11× faster) | 31 min (vx 218× faster) |
24
+ | Cold build: CPU burned | **34 s** | 1 min 13 s (vx 2.1× faster) | 114 min (vx 199× faster) |
25
+ | Fully cached run | **476 ms** | 760 ms (vx 1.5× faster) | 3.59 s (vx 7.5× faster) |
26
+ | Overhead per package | **8 ms** | 88 ms (vx 11× faster) | 1,712 ms (vx 218× faster) |
27
27
 
28
- vx N% faster: vx takes N% less time than that tool (1 − vx ÷ theirs); N% slower: N% more (vx ÷ theirs − 1).
28
+ vx N× faster: that tool takes N times as long as vx (theirs ÷ vx); N× slower: vx takes N times as long (vx ÷ theirs).
29
29
 
30
- Nx's column ran every task through npm run (~200 ms of CPU each; Turbo ran bun run), a harness fault since fixed; with bun, on a 4-core Linux box, Nx took 6m 59s cold and 4.50 s fully cached (vx 47% and 92% faster).
30
+ Nx's column ran every task through npm run (~200 ms of CPU each; Turbo ran bun run), a harness fault since fixed; with bun, on a 4-core Linux box, Nx took 6m 59s cold and 4.50 s fully cached (vx 1.9× and 12× faster).
31
31
 
32
32
  Time added is the wall time over the tasks' own ideal schedule (3 min 38 s).
33
33
  Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.495",
3
+ "version": "0.0.497",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.495",
25
- "@vzn/vx-linux-arm64": "0.0.495",
26
- "@vzn/vx-darwin-x64": "0.0.495",
27
- "@vzn/vx-darwin-arm64": "0.0.495"
24
+ "@vzn/vx-linux-x64": "0.0.497",
25
+ "@vzn/vx-linux-arm64": "0.0.497",
26
+ "@vzn/vx-darwin-x64": "0.0.497",
27
+ "@vzn/vx-darwin-arm64": "0.0.497"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -195,7 +195,7 @@ export interface ScheduleOptions {
195
195
  /**
196
196
  * Where the line naming a rejected `execute` goes, before its failed
197
197
  * outcome lands. Absent = stderr. The run gives it the task's own
198
- * stderr, so its frame and the failure recap carry the reason.
198
+ * stderr, so its frame carries the reason.
199
199
  */
200
200
  onError?: (node: TaskNode, line: string) => void
201
201
  /**
@@ -30,7 +30,6 @@ import { maskedCommand } from '../util/index.js'
30
30
  import { paint, type ColorSupport } from './colors.js'
31
31
  import { formatDuration } from './summary.js'
32
32
  import { outcomeLabel, skippedReason } from './events.js'
33
- import type { RecapTail } from './failure-recap.js'
34
33
 
35
34
  const NO_COLOR: ColorSupport = { enabled: false }
36
35
 
@@ -498,60 +497,6 @@ export function formatPersistentList(
498
497
  )
499
498
  }
500
499
 
501
- /** One failed task's section of the end-of-run recap. */
502
- export interface RecapEntry {
503
- node: TaskNode
504
- outcome: TaskOutcome
505
- tail: RecapTail
506
- /** Characters a bounded capture (a persistent task's) dropped before the tail saw them. */
507
- droppedChars: number
508
- }
509
-
510
- /**
511
- * The run's last block: each failed task's last lines, under the failure's
512
- * label, then the ids of the failures past the tail limit. The lines are
513
- * raw, like a frame's, and `fence` wraps them where a task's text could be
514
- * read as something other than text (GitHub Actions' workflow commands).
515
- */
516
- export function formatFailureRecap(
517
- entries: readonly RecapEntry[],
518
- more: readonly string[],
519
- colors: ColorSupport = NO_COLOR,
520
- fence: (lines: string[]) => string[] = (lines) => lines,
521
- ): string[] {
522
- const total = entries.length + more.length
523
- const dim = (s: string) => paint('', s, colors, { dim: true })
524
- const lines = [
525
- '',
526
- ` Failed: ${total} task${total === 1 ? '' : 's'} — the last lines ${total === 1 ? 'it' : 'each one'} printed`,
527
- ]
528
- for (const e of entries) {
529
- lines.push(
530
- '',
531
- ` ${paint(ERROR, '◼︎', colors)} ${paintTaskId(e.node, colors, { bold: true })} ${dim('—')} ${paint(ERROR, outcomeLabel(e.outcome), colors, { bold: true })}`,
532
- )
533
- const cut: string[] = []
534
- const { earlierLines, cutBytes } = e.tail
535
- if (earlierLines > 0)
536
- cut.push(`${count(earlierLines)} earlier line${earlierLines === 1 ? '' : 's'}`)
537
- if (cutBytes > 0) cut.push(`${count(cutBytes)} bytes cut from the start of the line below`)
538
- if (e.droppedChars > 0) {
539
- cut.push(`the capture dropped ${count(e.droppedChars)} characters of the task's output`)
540
- }
541
- if (cut.length > 0) lines.push(dim(` … ${cut.join(', and ')}`))
542
- if (e.tail.text.length === 0) lines.push(dim(' (no output)'))
543
- else lines.push(...fence(e.tail.text.split('\n')))
544
- }
545
- if (more.length > 0) {
546
- lines.push('', ` … and ${more.length} more failed: ${more.join(', ')}`)
547
- }
548
- return lines
549
- }
550
-
551
- function count(n: number): string {
552
- return n.toLocaleString('en-US')
553
- }
554
-
555
500
  function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
556
501
  const shortHash = o.hash ? o.hash.slice(0, 8) : ''
557
502
  const dim = (s: string) => paint('', s, colors, { dim: true })
@@ -1,7 +1,6 @@
1
1
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
2
2
  import { detectColors, type ColorSupport } from './colors.js'
3
3
  import {
4
- formatFailureRecap,
5
4
  formatFrameClose,
6
5
  formatFrameOpen,
7
6
  formatKeptLines,
@@ -10,15 +9,7 @@ import {
10
9
  formatTaskExecutedLine,
11
10
  formatTaskHitLine,
12
11
  formatTaskSkippedLine,
13
- type RecapEntry,
14
12
  } from './framed-output.js'
15
- import {
16
- appendRecapRing,
17
- createRecapRing,
18
- RECAP_TASKS,
19
- recapTail,
20
- type RecapRing,
21
- } from './failure-recap.js'
22
13
  import {
23
14
  createOutputWriter,
24
15
  formatFailureLine,
@@ -63,13 +54,8 @@ export interface Logger {
63
54
  runEnd?(): void
64
55
  }
65
56
 
66
- /** The logger `run()` builds when none is passed, and the one that owns the recap. */
57
+ /** The logger `run()` builds when none is passed. */
67
58
  export interface DefaultLogger extends Logger {
68
- /**
69
- * The run's last block: each failed task's last lines (item 706). Empty
70
- * when nothing failed, and in the modes that promise no task output.
71
- */
72
- failureRecap(): string[]
73
59
  /**
74
60
  * Hand over any output still coalesced, and write straight through from
75
61
  * now on. `runEnd` does it; the run's own `finally` does it again for a
@@ -352,13 +338,6 @@ export function defaultLogger(
352
338
  }
353
339
  const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
354
340
  flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
355
- // The recap's material, decided at each failure so nothing is held for a
356
- // task that passed. A buffered task's tail is read off the buffers the
357
- // frame drains; a live-streamed one keeps no buffer, so a bounded ring
358
- // follows it from its frame-open until its outcome.
359
- const recapEntries: RecapEntry[] = []
360
- const recapMore: string[] = []
361
- const liveRings = new Map<string, RecapRing>()
362
341
 
363
342
  const refresh = (force: boolean): void => {
364
343
  if (statusDead) return
@@ -457,20 +436,6 @@ export function defaultLogger(
457
436
  flushKept()
458
437
  writer.settle()
459
438
  },
460
- failureRecap() {
461
- if (recapEntries.length === 0) return []
462
- // Fenced like a frame: a tail is the task's own text, and a line of it
463
- // must not become a workflow command. Never inside a group, so it reads
464
- // with every group collapsed.
465
- const fence =
466
- view.gha === true
467
- ? (lines: string[]) =>
468
- ghaFence(`${lines.join('\n')}\n`, ghaToken)
469
- .slice(0, -1)
470
- .split('\n')
471
- : undefined
472
- return formatFailureRecap(recapEntries, recapMore, colors, fence)
473
- },
474
439
  status(line) {
475
440
  writer.write(`${line}\n`)
476
441
  },
@@ -508,7 +473,6 @@ export function defaultLogger(
508
473
  // Open the live frame: full task info even when the command
509
474
  // streams nothing (or the hit replays nothing).
510
475
  emitLine(formatFrameOpen(node, colors))
511
- liveRings.set(node.id, createRecapRing())
512
476
  return
513
477
  }
514
478
  // Bound a persistent task's output from its FIRST chunk. Waiting for
@@ -578,8 +542,6 @@ export function defaultLogger(
578
542
  streamedSinceBlock = true
579
543
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
580
544
  writer.write(chunk)
581
- const ring = liveRings.get(node.id)
582
- if (ring !== undefined) appendRecapRing(ring, chunk)
583
545
  return
584
546
  }
585
547
  const tail = persistentTails.get(node.id)
@@ -596,8 +558,6 @@ export function defaultLogger(
596
558
  streamedSinceBlock = true
597
559
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
598
560
  writer.write(chunk)
599
- const ring = liveRings.get(node.id)
600
- if (ring !== undefined) appendRecapRing(ring, chunk)
601
561
  return
602
562
  }
603
563
  const tail = persistentTails.get(node.id)
@@ -623,28 +583,6 @@ export function defaultLogger(
623
583
  // The pre-ready window closed either way: a failed task is over, and
624
584
  // a ready one starts a fresh post-ready tail below.
625
585
  persistentTails.delete(node.id)
626
- const liveRing = liveRings.get(node.id)
627
- liveRings.delete(node.id)
628
- if (outcome.status === 'failed' && !discardsOutput) {
629
- if (recapEntries.length === RECAP_TASKS) recapMore.push(node.id)
630
- else {
631
- let ring = liveRing
632
- if (ring === undefined) {
633
- ring = createRecapRing()
634
- appendRecapRing(ring, stdout)
635
- if (stdout.length > 0 && stderr.length > 0 && !stdout.endsWith('\n')) {
636
- appendRecapRing(ring, '\n')
637
- }
638
- appendRecapRing(ring, stderr)
639
- }
640
- recapEntries.push({
641
- node,
642
- outcome,
643
- tail: recapTail(ring),
644
- droppedChars: (preReady?.out.dropped ?? 0) + (preReady?.err.dropped ?? 0),
645
- })
646
- }
647
- }
648
586
  // An aborted task (child killed by a shutdown signal) reverts
649
587
  // to pending: free its worker slot, but never count or render it
650
588
  // — the run is tearing down and it has no honest outcome.
@@ -319,7 +319,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
319
319
  }
320
320
  let held = false
321
321
  try {
322
- const summary = await runOnBus(options, bus, colors, () => terminal?.failureRecap() ?? [])
322
+ const summary = await runOnBus(options, bus, colors)
323
323
  if (summary.persistent === undefined) return summary
324
324
  // Servers handed back still running still write: `vx watch dev` printed
325
325
  // none of its server's log while it idled, the renderer gone with this
@@ -342,7 +342,6 @@ async function runOnBus(
342
342
  options: RunOptions,
343
343
  bus: EventBus,
344
344
  colors: ColorSupport,
345
- failureRecap: () => string[],
346
345
  ): Promise<RunSummary> {
347
346
  const log = busLogger(bus)
348
347
 
@@ -1094,8 +1093,6 @@ async function runOnBus(
1094
1093
  ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
1095
1094
  )
1096
1095
  }
1097
- // Last, where a long log's reader lands: each failure's own last lines.
1098
- for (const line of failureRecap()) log.status(line)
1099
1096
 
1100
1097
  // Optional artifacts. Errors are surfaced to the user but don't
1101
1098
  // change the run's exit code — the run already happened.
@@ -1,121 +0,0 @@
1
- // The end of each failed task's output, kept for the run's last lines
2
- // (item 706). A failure's frame prints when the task finishes, which in a
3
- // long CI log is thousands of lines above the end, and GitHub's API returns
4
- // only a job log's last 5,000 lines: the summary is the one place a reader
5
- // reliably looks, so the failure's own words are repeated there.
6
- //
7
- // The capture is a ring over the output's END: it keeps at most
8
- // RECAP_TASK_BYTES characters (and a newline) however much a task prints,
9
- // and counts what it evicts, so the recap can say how much it is not
10
- // showing.
11
-
12
- /** Lines of each failed task's output the recap repeats. */
13
- const RECAP_LINES = 30
14
- /** The byte cap on one task's repeated lines. */
15
- const RECAP_TASK_BYTES = 8 * 1024
16
- /**
17
- * Tasks that get a tail; the rest are named by id. Five tails at the
18
- * per-task cap is 40 KiB, so the recap stays under 64 KiB with no cap
19
- * of its own.
20
- */
21
- export const RECAP_TASKS = 5
22
-
23
- export interface RecapRing {
24
- chunks: string[]
25
- chars: number
26
- /** Newlines evicted from the head. */
27
- cutLines: number
28
- /** Bytes evicted from the line the kept text starts with; 0 when it starts a line. */
29
- cutPartialBytes: number
30
- }
31
-
32
- export interface RecapTail {
33
- /** The last lines, at most RECAP_LINES of them and RECAP_TASK_BYTES bytes. */
34
- text: string
35
- /** Whole lines above `text`. */
36
- earlierLines: number
37
- /** Bytes cut from the start of `text`'s first line. */
38
- cutBytes: number
39
- }
40
-
41
- export function createRecapRing(): RecapRing {
42
- return { chunks: [], chars: 0, cutLines: 0, cutPartialBytes: 0 }
43
- }
44
-
45
- // The ring counts characters, the cap bytes: a UTF-16 unit is at least one
46
- // UTF-8 byte, so the last RECAP_TASK_BYTES characters always hold the last
47
- // RECAP_TASK_BYTES bytes. One more for the final newline the tail drops.
48
- const RING_CHARS = RECAP_TASK_BYTES + 1
49
-
50
- export function appendRecapRing(r: RecapRing, chunk: string): void {
51
- if (chunk.length === 0) return
52
- r.chunks.push(chunk)
53
- r.chars += chunk.length
54
- // A whole chunk goes only when what follows it still fills the ring:
55
- // evicting past the cap would leave the recap fewer lines than it shows.
56
- while (r.chunks.length > 1 && r.chars - r.chunks[0]!.length >= RING_CHARS) {
57
- const gone = r.chunks.shift()!
58
- r.chars -= gone.length
59
- evict(r, gone, gone.length)
60
- }
61
- if (r.chars > RING_CHARS) {
62
- const head = r.chunks[0]!
63
- const at = r.chars - RING_CHARS
64
- evict(r, head, at)
65
- r.chunks[0] = head.slice(at)
66
- r.chars = RING_CHARS
67
- }
68
- }
69
-
70
- function evict(r: RecapRing, s: string, end: number): void {
71
- const last = s.lastIndexOf('\n', end - 1)
72
- const partial = Buffer.byteLength(s.slice(last + 1, end))
73
- if (last < 0) {
74
- r.cutPartialBytes += partial
75
- return
76
- }
77
- r.cutLines += countNewlines(s, last + 1)
78
- r.cutPartialBytes = partial
79
- }
80
-
81
- function countNewlines(s: string, end: number): number {
82
- let n = 0
83
- for (let i = s.indexOf('\n'); i !== -1 && i < end; i = s.indexOf('\n', i + 1)) n++
84
- return n
85
- }
86
-
87
- /**
88
- * The recap's view of what the ring kept: its last RECAP_LINES lines, then
89
- * its last RECAP_TASK_BYTES bytes (cut on a character boundary). The text
90
- * is a fresh copy, so it holds nothing of the chunks it came from.
91
- */
92
- export function recapTail(r: RecapRing): RecapTail {
93
- const t = r.chunks.join('')
94
- const end = t.endsWith('\n') ? t.length - 1 : t.length
95
- let start = 0
96
- for (let pos = end, taken = 1; ; pos = start - 1, taken++) {
97
- const nl = pos === 0 ? -1 : t.lastIndexOf('\n', pos - 1)
98
- if (nl < 0) {
99
- start = 0
100
- break
101
- }
102
- start = nl + 1
103
- if (taken === RECAP_LINES) break
104
- }
105
- let earlierLines = r.cutLines + countNewlines(t, start)
106
- let cutBytes = start === 0 ? r.cutPartialBytes : 0
107
- const bytes = Buffer.from(t.slice(start, end), 'utf8')
108
- if (bytes.length <= RECAP_TASK_BYTES)
109
- return { text: bytes.toString('utf8'), earlierLines, cutBytes }
110
- let at = bytes.length - RECAP_TASK_BYTES
111
- while ((bytes[at]! & 0xc0) === 0x80) at++
112
- const lastNl = bytes.lastIndexOf(0x0a, at - 1)
113
- if (lastNl < 0) cutBytes += at
114
- else {
115
- for (let i = bytes.indexOf(0x0a); i !== -1 && i <= lastNl; i = bytes.indexOf(0x0a, i + 1)) {
116
- earlierLines++
117
- }
118
- cutBytes = at - lastNl - 1
119
- }
120
- return { text: bytes.subarray(at).toString('utf8'), earlierLines, cutBytes }
121
- }