@vzn/vx 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.
Files changed (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -6,12 +6,19 @@
6
6
 
7
7
  import { statSync } from 'node:fs'
8
8
  import path from 'node:path'
9
+ import { pathToFileURL } from 'node:url'
9
10
 
10
11
  // `src/a.ts`, `./a.ts:12`, `/abs/a.ts:12:5`, tsc's `src/a.ts(12,5)`. Not
11
12
  // after a word, dot, slash or `@`, so a URL's path or a scoped name's tail
12
- // never starts a match. Groups: file, then line and column of either form.
13
- const PATH =
14
- /(?<![\w./@+-])((?:\/|\.{1,2}\/)?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,9})(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))?/g
13
+ // never starts a match. Colour codes (`\x1b[4m`) are invisible: a path one
14
+ // starts is linked (tasks run with FORCE_COLOR), one it splits from its
15
+ // directory is not. Groups: file, then line and column of either form.
16
+ const SGR = String.raw`\x1b\[[\d;]*m`
17
+ const PATH = new RegExp(
18
+ String.raw`(?<!\x1b\[[\d;]*)(?:(?<![\w./@+-])|(?<=${SGR}))(?<![\w./@+-](?<!${SGR})(?:${SGR})+)` +
19
+ String.raw`((?:\/|\.{1,2}\/)?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,9})(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))?`,
20
+ 'g',
21
+ )
15
22
 
16
23
  /** A file a task's output names; `file` absolute. */
17
24
  export interface OutputLocation {
@@ -29,7 +36,8 @@ export function linkPaths(text: string, dir: string, isFile: (abs: string) => bo
29
36
  return text.replace(PATH, (match: string, file: string) => {
30
37
  const abs = path.resolve(dir, file)
31
38
  if (!isFile(abs)) return match
32
- return `\x1b]8;;file://${encodeURI(abs)}\x1b\\${match}\x1b]8;;\x1b\\`
39
+ // Not `encodeURI`: it keeps a `#` or `?`, which the URL reads as its fragment or query.
40
+ return `\x1b]8;;${pathToFileURL(abs).href}\x1b\\${match}\x1b]8;;\x1b\\`
33
41
  })
34
42
  }
35
43
 
@@ -1,11 +1,12 @@
1
1
  import type { TaskNode } from '../graph/index.js'
2
2
  import type { Logger } from './logger.js'
3
3
 
4
- // A prefix that may yet complete as a CSI or OSC sequence. An escape cut
5
- // at a chunk's end is held for the next chunk: stripped alone, its
6
- // remainder (`1m`) reached the log as text.
4
+ // A prefix that may yet complete as a CSI, OSC or charset sequence
5
+ // (`tput sgr0` writes `\x1b(B`). An escape cut at a chunk's end is held for
6
+ // the next chunk: stripped alone, its remainder (`1m`, `B`) reached the log
7
+ // as text.
7
8
  // eslint-disable-next-line no-control-regex -- ESC is the point
8
- const PARTIAL_ESCAPE = /^\x1b(?:\[[0-?]*[ -/]*|\][^\x07\x1b]*\x1b?)?$/
9
+ const PARTIAL_ESCAPE = /^\x1b(?:\[[0-?]*[ -/]*|\][^\x07\x1b]*\x1b?|[ -/]+)?$/
9
10
 
10
11
  /**
11
12
  * `sink` with terminal escapes stripped from task output. Tasks run with
@@ -34,12 +35,19 @@ export function plainOutput(sink: Logger): Logger {
34
35
  if (text !== '') write(node, text)
35
36
  }
36
37
  // Delegated one by one, never spread: an embedder's logger may be a class
37
- // whose methods live on its prototype and read `this`.
38
+ // whose methods live on its prototype and read `this`. Every hook the sink
39
+ // has: a dropped `warn` put warnings on stdout, a dropped `runDone` left
40
+ // the live region under the footer.
38
41
  return {
39
42
  status: (line) => sink.status(line),
43
+ ...(sink.warn && { warn: (line) => sink.warn!(line) }),
40
44
  ...(sink.runStart && { runStart: (info) => sink.runStart!(info) }),
41
45
  ...(sink.taskStart && { taskStart: (node) => sink.taskStart!(node) }),
42
46
  ...(sink.runEnd && { runEnd: () => sink.runEnd!() }),
47
+ ...(sink.stepStart && { stepStart: (id) => sink.stepStart!(id) }),
48
+ ...(sink.stepEnd && { stepEnd: (id, info) => sink.stepEnd!(id, info) }),
49
+ ...(sink.runStop && { runStop: () => sink.runStop!() }),
50
+ ...(sink.runDone && { runDone: () => sink.runDone!() }),
43
51
  taskStdout(node, chunk) {
44
52
  const text = strip(`${node.id}\0out`, chunk)
45
53
  if (text !== '' || chunk === '') sink.taskStdout(node, text)
@@ -5,14 +5,13 @@
5
5
  // ordinary write erases the status display first, writes its content,
6
6
  // then redraws, so the display can never interleave with task output.
7
7
 
8
- import { dumbTerminal, paint, ui, type ColorSupport } from '../util/index.js'
8
+ import { dumbTerminal, paint, type ColorSupport } from '../util/index.js'
9
9
  import {
10
10
  formatListHeader,
11
11
  formatTaskRow,
12
12
  paintIdParts,
13
13
  pathNote,
14
14
  placeNote,
15
- TIME_COL,
16
15
  } from './framed-output.js'
17
16
  import type { BlockedTask } from './forecast.js'
18
17
 
@@ -39,6 +38,9 @@ export interface StatusStream {
39
38
  rows?: number
40
39
  }
41
40
 
41
+ /** A region's lines, or what builds them when it is drawn. */
42
+ export type Region = readonly string[] | (() => readonly string[])
43
+
42
44
  export interface OutputWriter {
43
45
  /** True only when the stream is a TTY and the run isn't CI. */
44
46
  readonly enabled: boolean
@@ -56,9 +58,11 @@ export interface OutputWriter {
56
58
  setStatus(line: string, opts?: { force?: boolean }): void
57
59
  /**
58
60
  * Replace the multi-line status region. Same throttle contract as
59
- * `setStatus`; the two share one display slot.
61
+ * `setStatus`; the two share one display slot. A function is called
62
+ * when the region is next drawn, once: a set the throttle coalesces
63
+ * builds nothing.
60
64
  */
61
- setRegion(lines: readonly string[], opts?: { force?: boolean }): void
65
+ setRegion(lines: Region, opts?: { force?: boolean }): void
62
66
  /** Hand over what `coalesce` holds and write straight through from now on. */
63
67
  settle(): void
64
68
  /** Permanently remove the status display; later set* is a no-op. */
@@ -114,7 +118,7 @@ export function createOutputWriter(
114
118
  stream.write(text)
115
119
  }
116
120
 
117
- let current: readonly string[] | null = null
121
+ let current: Region | null = null
118
122
  let shown = false
119
123
  // PHYSICAL rows the shown region occupies, not logical lines — see
120
124
  // `regionRows`.
@@ -200,13 +204,18 @@ export function createOutputWriter(
200
204
  // only repeat these bytes.
201
205
  cancelTrailing()
202
206
  const erase = shown ? eraseSeq() : CLEAR
207
+ // Built here, not at the set: a warm run's task events set the
208
+ // region hundreds of times inside one floor, and each build was a
209
+ // forecast pass and a summary render no frame showed (200 hits on a
210
+ // terminal: run graph 62 → 9 ms). Kept built: a write redraws it.
211
+ if (typeof current === 'function') current = current()
203
212
  const lines = fitted(current!)
204
213
  stream.write(erase + lines.join('\n'))
205
214
  shown = true
206
215
  shownRows = regionRows(lines)
207
216
  lastDraw = now()
208
217
  }
209
- const set = (lines: readonly string[], o: { force?: boolean }): void => {
218
+ const set = (lines: Region, o: { force?: boolean }): void => {
210
219
  if (!enabled || dead) return
211
220
  current = lines
212
221
  if (!atLineStart || holds > 0) return
@@ -313,8 +322,6 @@ export function createOutputWriter(
313
322
  }
314
323
  }
315
324
 
316
- const IDLE = '#6b7280'
317
-
318
325
  export interface WorkerSlot {
319
326
  id: string
320
327
  startedMs: number
@@ -323,8 +330,6 @@ export interface WorkerSlot {
323
330
  }
324
331
 
325
332
  export interface StatusRegionState {
326
- /** The terminal's height: a region taller folds its rows, never its summary. */
327
- rows?: number
328
333
  /** The running task at the head of the longest unfinished chain. */
329
334
  critical?: string
330
335
  /** Where a running task runs, when not here (`TaskExecutor.running`); its row shows it. */
@@ -338,15 +343,23 @@ export interface StatusRegionState {
338
343
  * the honest end of their life.
339
344
  */
340
345
  pinnedPersistent: readonly string[]
341
- /** Fixed-size slot array; null = idle worker. Index = display row. */
346
+ /** The running tasks by slot, null for a free one; a task past the slots follows them. */
342
347
  slots: readonly (WorkerSlot | null)[]
343
- /** The blocked task an idle slot's row holds instead of `idle`, by the same index. */
344
- blocked?: readonly (BlockedTask | null)[]
348
+ /** The list's height: one row per worker, whatever runs, so it never jumps. */
349
+ size?: number
350
+ /**
351
+ * The tasks the scheduler has picked for the next free workers, each
352
+ * nested under the running task it waits on, in the rows no running task
353
+ * takes; nothing further ahead.
354
+ */
355
+ next?: readonly BlockedTask[]
356
+ /** Each running task's count of the tasks it holds back: its `blocked` cell. */
357
+ blockedBy?: ReadonlyMap<string, number>
345
358
  /** The run was stopped: running rows say `aborting`, idle rows leave. */
346
359
  stopping?: boolean
347
360
  /**
348
361
  * vx's own steps still running, shown below the worker rows: no worker
349
- * slot or `--concurrency` bounds them (M, 2026-10-15).
362
+ * slot or `--concurrency` bounds them (2026-10-15).
350
363
  */
351
364
  steps?: readonly WorkerSlot[]
352
365
  /** Clock value used for per-slot elapsed (injectable for tests). */
@@ -401,7 +414,7 @@ const INTERNAL = 'internal'
401
414
  /**
402
415
  * The list row of one of vx's own steps (`vx:uploads`), on the task grid
403
416
  * with `internal` in the cache column: a step is no task and caches
404
- * nothing (M, 2026-10-16: `4.60s success internal vx:keep-run`).
417
+ * nothing (2026-10-16: `4.60s success internal vx:keep-run`).
405
418
  */
406
419
  export function formatStepLine(
407
420
  id: string,
@@ -450,31 +463,39 @@ export function formatStatusRegion(
450
463
  '',
451
464
  paintPinnedId(id, colors),
452
465
  colors,
466
+ // A blank `blocked` cell, so the id sits under the header's `task`.
467
+ '',
453
468
  ),
454
469
  )
455
470
  }
456
471
 
457
- // Worker rows: NO glyph — the live elapsed time leads (no spinner;
458
- // the ticking time IS the motion), then `running`, full id (never
459
- // truncated — name is the last column). Idle rows hold their slot's
460
- // place, dim, aligned under the status column: the blocked task the row
461
- // holds and what holds it back, else `idle`.
462
- // One task running has no path to compare against.
463
- const busy = s.slots.filter((x) => x !== null && x.waiting !== true).length
464
- for (const [row, slot] of s.slots.entries()) {
465
- // Placed by an executor (DTE), the list is what runs: blocked tasks are
466
- // the tasks line's count alone (M, 2026-10-21).
467
- if (slot === null && (s.stopping === true || s.where !== undefined)) continue
468
- if (slot === null) {
469
- const w = s.blocked?.[row]
470
- lines.push(
471
- w == null
472
- ? `${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`
473
- : formatTaskRow(' ', null, 'blocked', '', '', '', paintPinnedId(w.id, colors), colors) +
474
- ` ${dim(`by ${w.by}`)}`,
475
- )
476
- continue
472
+ // Running rows: NO glyph — the live elapsed time leads (no spinner;
473
+ // the ticking time IS the motion), then `running`, the usual duration,
474
+ // how many tasks wait on it, and the full id (never truncated — name is
475
+ // the last column). The rows no task runs in hold the tasks planned
476
+ // next, each nested under what it waits on; the rest stay blank, so the
477
+ // list keeps its height.
478
+ const running = s.slots.filter((x): x is WorkerSlot => x !== null && x.waiting !== true)
479
+ // Placed by an executor (DTE), the list is what runs: blocked tasks are
480
+ // the tasks line's count alone, and its size is no machine's.
481
+ const placed = s.where !== undefined
482
+ const free = Math.max(0, (s.size ?? 0) - running.length)
483
+ const nested = new Map<string, BlockedTask[]>()
484
+ if (!placed && s.stopping !== true) {
485
+ const ids = new Set(running.map((x) => x.id))
486
+ let n = 0
487
+ for (const b of s.next ?? []) {
488
+ if (n === free) break
489
+ if (!ids.has(b.by)) continue
490
+ let list = nested.get(b.by)
491
+ if (list === undefined) nested.set(b.by, (list = []))
492
+ list.push(b)
493
+ n++
477
494
  }
495
+ }
496
+ let rows = 0
497
+ for (const slot of running) {
498
+ rows++
478
499
  if (s.stopping === true) {
479
500
  lines.push(
480
501
  formatTaskRow(
@@ -486,15 +507,12 @@ export function formatStatusRegion(
486
507
  '',
487
508
  paintPinnedId(slot.id, colors),
488
509
  colors,
510
+ '',
489
511
  ),
490
512
  )
491
513
  continue
492
514
  }
493
- // Handed to an executor that has no machine on it yet: no row, only
494
- // the tasks line's `scheduled` (M, 2026-10-21).
495
- if (slot.waiting === true) continue
496
515
  const at = s.where?.(slot.id)
497
- const critical = s.critical === slot.id && busy > 1
498
516
  const usual = s.expected?.get(slot.id)
499
517
  lines.push(
500
518
  formatTaskRow(
@@ -506,11 +524,29 @@ export function formatStatusRegion(
506
524
  '',
507
525
  paintPinnedId(slot.id, colors),
508
526
  colors,
527
+ String(s.blockedBy?.get(slot.id) ?? 0),
509
528
  ) +
510
- (critical ? pathNote(colors) : '') +
529
+ (s.critical === slot.id ? pathNote(colors) : '') +
511
530
  (at != null ? placeNote(at, colors) : ''),
512
531
  )
532
+ for (const b of nested.get(slot.id) ?? []) {
533
+ rows++
534
+ lines.push(
535
+ formatTaskRow(
536
+ ' ',
537
+ null,
538
+ 'blocked',
539
+ '',
540
+ '',
541
+ '',
542
+ `${dim('\u2514')} ${paintPinnedId(b.id, colors)}`,
543
+ colors,
544
+ '',
545
+ ),
546
+ )
547
+ }
513
548
  }
549
+ if (!placed) for (; rows < (s.size ?? 0); rows++) lines.push('')
514
550
  for (const step of s.steps ?? []) {
515
551
  lines.push(
516
552
  formatTaskRow(
@@ -522,20 +558,11 @@ export function formatStatusRegion(
522
558
  '',
523
559
  paintPinnedId(step.id, colors),
524
560
  colors,
561
+ '',
525
562
  ),
526
563
  )
527
564
  }
528
- if (lines.length > 1) lines.splice(1, 0, formatListHeader(s.where !== undefined, colors))
529
- // A region taller than the terminal (a row spared) folds the rows past
530
- // what fits into one line: the writer would cut the summary at its
531
- // foot, and lines never printed cannot be scrolled to (M, 2026-10-21).
532
- const room = s.rows === undefined ? Infinity : s.rows - 1 - s.summaryLines.length
533
- if (lines.length > room && room >= 3) {
534
- const kept = room - 1
535
- const more = lines.length - kept
536
- lines.length = kept
537
- lines.push(`${' '.repeat(3 + TIME_COL + 1)}${dim(`+${more} more`)}`)
538
- }
565
+ if (lines.length > 1) lines.splice(1, 0, formatListHeader(placed, colors, true))
539
566
  // The live summary section — identical shape to the final printout,
540
567
  // so the region visually BECOMES the summary when the run ends.
541
568
  lines.push(...s.summaryLines)
@@ -544,11 +571,12 @@ export function formatStatusRegion(
544
571
 
545
572
  /**
546
573
  * A running task's usual duration, fixed while the elapsed time counts up
547
- * toward it (M, 2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
574
+ * toward it (2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
548
575
  * column below 100 minutes.
549
576
  */
550
577
  function formatExpected(ms: number): string {
551
- if (ms < 1000) return `~${Math.round(ms)}ms`
578
+ // The tier by the rounded value: 999.6 ms printed `~1000ms` (item 1034's class).
579
+ if (Math.round(ms) < 1000) return `~${Math.round(ms)}ms`
552
580
  if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
553
581
  const s = Math.round(ms / 1000)
554
582
  if (s < 60) return `~${s}s`
@@ -13,6 +13,7 @@ import {
13
13
  formatBytes,
14
14
  formatElapsed,
15
15
  gradientRule,
16
+ identityColor,
16
17
  paint,
17
18
  SUCCESS,
18
19
  ui,
@@ -113,10 +114,12 @@ export interface SummaryStats {
113
114
  held?: { count: number; sumMs: number }
114
115
  /** Live section only: the forecast of what is left, from history. */
115
116
  etaMs?: number
117
+ /** Live section only: how long the run has gone past that forecast. */
118
+ overMs?: number
116
119
  /** A run of groups only: their names, so `0 tasks` says why. */
117
120
  emptyGroups?: readonly string[]
118
- /** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
119
- buckets?: ReadonlyMap<string, CacheBucket>
121
+ /** Each finished task's id and cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
122
+ buckets?: ReadonlyMap<string, TaskBucket>
120
123
  }
121
124
 
122
125
  /** The cache bar segment a finished task fills before its remote traffic is weighed. */
@@ -129,6 +132,12 @@ export type CacheBucket =
129
132
  | 'saved'
130
133
  | 'notSaved'
131
134
 
135
+ /** A finished task's id and cache bucket, as `SummaryStats.buckets` holds them. */
136
+ export interface TaskBucket {
137
+ task: string
138
+ bucket: CacheBucket
139
+ }
140
+
132
141
  /** A finished task's own cache bucket; none for a group, a skip or an abort. */
133
142
  export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
134
143
  if (isGroupTask(o.node)) return undefined
@@ -152,10 +161,12 @@ export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
152
161
  }
153
162
  }
154
163
 
155
- type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', number>
164
+ type RemoteState = 'uploaded' | 'uploading' | 'remoteFailed'
165
+ const REMOTE_RANK: Record<RemoteState, number> = { uploaded: 0, uploading: 1, remoteFailed: 2 }
166
+ type Buckets = Record<CacheBucket | RemoteState, number>
156
167
 
157
168
  /**
158
- * The cache bar's segments, each task in one (M, 2026-10-15). With layers
169
+ * The cache bar's segments, each task in one (2026-10-15). With layers
159
170
  * named, a saved task leaves the misses, and a task with remote traffic
160
171
  * leaves its own bucket for its strongest remote state: a failed read or
161
172
  * write, then an upload still running, then one that landed. Without, the
@@ -164,7 +175,7 @@ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', n
164
175
  function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
165
176
  const saved = layers === undefined ? 0 : (stats.saved ?? 0)
166
177
  // Its own yellow segment, as its legend count is: in the misses' red it
167
- // was a cell with no count under it (M, 2026-10-21).
178
+ // was a cell with no count under it (2026-10-21).
168
179
  const notSaved = layers === undefined ? 0 : (stats.notSaved ?? 0)
169
180
  const b: Buckets = {
170
181
  miss: stats.miss - saved - notSaved,
@@ -179,13 +190,20 @@ function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets
179
190
  remoteFailed: 0,
180
191
  }
181
192
  if (layers?.remote !== true || stats.buckets === undefined) return b
182
- for (const [hash, k] of layers.traffic.keys) {
183
- const from = stats.buckets.get(hash)
193
+ // A task's traffic may sit under its key (its entry) and its id (its run
194
+ // bytes): it moves once, to the strongest state of the two.
195
+ const moved = new Map<string, RemoteState>()
196
+ for (const [key, k] of layers.traffic.keys) {
197
+ const from = stats.buckets.get(key)
184
198
  // Still running: it counts once it ends.
185
199
  if (from === undefined) continue
186
- b[from]--
187
- b[k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded']++
200
+ const state: RemoteState =
201
+ k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded'
202
+ const prior = moved.get(from.task)
203
+ if (prior === undefined) b[from.bucket]--
204
+ if (prior === undefined || REMOTE_RANK[state] > REMOTE_RANK[prior]) moved.set(from.task, state)
188
205
  }
206
+ for (const state of moved.values()) b[state]++
189
207
  return b
190
208
  }
191
209
 
@@ -234,6 +252,8 @@ export interface RunContext {
234
252
  }
235
253
  /** Total projects discovery found — the bar's denominator. */
236
254
  workspaceProjectCount?: number
255
+ /** The run's warnings no task owns, as they were said: the footer's `warn` row. */
256
+ warnings?: () => readonly string[]
237
257
  }
238
258
 
239
259
  export function formatSummarySection(
@@ -264,7 +284,7 @@ export function formatSummarySection(
264
284
 
265
285
  // Tasks meter + numbers, fixed order: failed · success · skipped ·
266
286
  // aborted · not run, then a dim total, last on its line. Every task in
267
- // the run counts, each state its own segment (M, 2026-10-14).
287
+ // the run counts, each state its own segment (2026-10-14).
268
288
  const runTotal = stats.total + aborted + notRun
269
289
  const taskParts: string[] = []
270
290
  if (stats.failed > 0)
@@ -276,6 +296,8 @@ export function formatSummarySection(
276
296
  if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
277
297
  if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
278
298
  if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
299
+ // What is not done yet, running included: the count the bar's unfilled part shows.
300
+ if (left > 0) taskParts.push(dim(`${left} left`))
279
301
  if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
280
302
  const taskBar = segmentBar(
281
303
  [
@@ -307,10 +329,10 @@ export function formatSummarySection(
307
329
  const wsTotal = context.workspaceProjectCount
308
330
  if (wsTotal !== undefined && wsTotal > 0) {
309
331
  const width = barWidth(colors)
310
- const cells = Math.min(
311
- width,
312
- Math.max(1, Math.round((context.packageCount / wsTotal) * width)),
313
- )
332
+ const cells =
333
+ context.packageCount === 0
334
+ ? 0
335
+ : Math.min(width, Math.max(1, Math.round((context.packageCount / wsTotal) * width)))
314
336
  const bar =
315
337
  paint(WARN, '▰'.repeat(cells), colors) +
316
338
  paint('', '▱'.repeat(width - cells), colors, { dim: true })
@@ -362,7 +384,7 @@ export function formatSummarySection(
362
384
  )
363
385
  const cacheParts: string[] = []
364
386
  if (layers !== undefined) {
365
- // Per layer (M, 2026-10-15): `✓` fresh (outputs already on disk, from
387
+ // Per layer (2026-10-15): `✓` fresh (outputs already on disk, from
366
388
  // either layer) and `∅` no cache, both local; `↓` restored from a
367
389
  // layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
368
390
  // uploading. Each count is its bar segment's, glyph and number in its colour.
@@ -374,7 +396,7 @@ export function formatSummarySection(
374
396
  ...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
375
397
  ...(layers.localWrite ? n(b.saved, '\u2191', LOCAL) : []),
376
398
  ]
377
- // The layer words are dim; only the counts carry colour (M, 2026-10-15).
399
+ // The layer words are dim; only the counts carry colour (2026-10-15).
378
400
  if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
379
401
  // Each transfer count is followed by its bytes, dim: what the
380
402
  // remote moved is the cost a hit or an upload paid.
@@ -408,7 +430,7 @@ export function formatSummarySection(
408
430
  }
409
431
  // Skipped tasks ride the bar, yellow like the tasks meter, so the two
410
432
  // bars fill alike; their count is the tasks legend's alone (the footer
411
- // never says a fact twice, M, 2026-10-14).
433
+ // never says a fact twice, 2026-10-14).
412
434
  lines.push(row('cache', cacheBar))
413
435
  if (cacheParts.length > 0) lines.push(legend(cacheParts))
414
436
  }
@@ -416,7 +438,7 @@ export function formatSummarySection(
416
438
  // Cache-miss duration spread (owner: hits' restore times polluted
417
439
  // it — only tasks that actually executed count). Dim: context.
418
440
  // A value already on the row is not said again: one executed task read
419
- // as its own max, avg and min (M, 2026-10-14). Tasks that all took the
441
+ // as its own max, avg and min (2026-10-14). Tasks that all took the
420
442
  // same time have no spread to show.
421
443
  const total = formatElapsed(totalMs)
422
444
  const said = new Set([total])
@@ -442,6 +464,8 @@ export function formatSummarySection(
442
464
  // that is about the future.
443
465
  if (stats.etaMs !== undefined)
444
466
  timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
467
+ else if (stats.overMs !== undefined)
468
+ timeParts.push(paint(ACCENT, `${formatOver(stats.overMs)} over`, colors))
445
469
  for (const p of spreadParts) timeParts.push(dim(p))
446
470
  const timeRow = row('time', join(timeParts))
447
471
  // Run-shape footer (final summary only): worker pool + cache mode,
@@ -480,13 +504,33 @@ export function formatSummarySection(
480
504
  )
481
505
  }
482
506
  if (context.runId !== undefined) info.push(`run ${context.runId}`)
483
- lines.push('', row('info', join(info)), timeRow)
507
+ lines.push('', ...warnRows(context.warnings?.() ?? [], row, colors))
508
+ lines.push(row('info', join(info)), timeRow)
484
509
  } else {
485
510
  lines.push('', timeRow)
486
511
  }
487
512
  return lines
488
513
  }
489
514
 
515
+ /**
516
+ * A long run scrolls its warnings (a remote cache's 403, a plugin's
517
+ * failed flush) out of sight, and the footer read as a clean run
518
+ * (2026-10-27). The footer counts the distinct ones; it does not repeat
519
+ * them, so a warning stays one line in a log (a refused token costs
520
+ * ONE line, tests/remote-cache-degrade.test.ts in @vzn/vx-migrate).
521
+ */
522
+ function warnRows(
523
+ warnings: readonly string[],
524
+ row: (label: string, value: string) => string,
525
+ colors: ColorSupport,
526
+ ): string[] {
527
+ const n = new Set(
528
+ warnings.map((w) => Bun.stripANSI(w).split('\n')[0]!.trim()).filter((w) => w !== ''),
529
+ ).size
530
+ if (n === 0) return []
531
+ return [row('warn', paint(WARN, `${n} ${n === 1 ? 'warning' : 'warnings'} above`, colors))]
532
+ }
533
+
490
534
  /**
491
535
  * A forecast, said as coarsely as it is known: `~4s`, `~2m 05s`, `<1s`
492
536
  * once the history says the end is due.
@@ -498,16 +542,27 @@ function formatEta(ms: number): string {
498
542
  return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
499
543
  }
500
544
 
545
+ function formatOver(ms: number): string {
546
+ const s = Math.max(1, Math.ceil(ms / 1000))
547
+ return s < 60 ? `+${s}s` : `+${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
548
+ }
549
+
501
550
  /**
502
551
  * The `--verbosity 1` per-task table (`RunOptions.summaryTable`). Groups are
503
- * left out as the footer and `--report` leave them: a group ran no command.
552
+ * left out as the footer leaves them: a group ran no command. Painted in the
553
+ * list's vocabulary: header bold violet, ids in their hues, status in its
554
+ * state colour, rule and durations dim.
504
555
  */
505
- export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
556
+ export function formatOutcomeTable(
557
+ outcomes: readonly TaskOutcome[],
558
+ colors: ColorSupport = NO_COLOR,
559
+ ): string[] {
506
560
  const rows = outcomes
507
561
  .filter((o) => !isGroupTask(o.node))
508
562
  .map((o) => {
509
563
  const v = projectOutcome(o)
510
- return { task: v.taskId, status: outcomeLabel(v), duration: `${v.durationMs}ms` }
564
+ const status = neverStarted(o) ? 'not run' : outcomeLabel(v)
565
+ return { o, task: v.taskId, status, duration: `${v.durationMs}ms` }
511
566
  })
512
567
  if (rows.length === 0) return []
513
568
  const widths = {
@@ -515,17 +570,43 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
515
570
  status: Math.max(6, ...rows.map((r) => r.status.length)),
516
571
  duration: Math.max(8, ...rows.map((r) => r.duration.length)),
517
572
  }
518
- const line = (task: string, status: string, duration: string) =>
519
- `${task.padEnd(widths.task)} ${status.padEnd(widths.status)} ${duration.padStart(widths.duration)}`
520
- const header = line('TASK', 'STATUS', 'DURATION')
573
+ const pad = (text: string, width: number) => ' '.repeat(Math.max(0, width - text.length))
574
+ const header = `${'TASK'.padEnd(widths.task)} ${'STATUS'.padEnd(widths.status)} ${'DURATION'.padStart(widths.duration)}`
575
+ const dim = (t: string) => paint('', t, colors, { dim: true })
521
576
  return [
522
577
  '',
523
- header,
524
- '-'.repeat(header.length),
525
- ...rows.map((r) => line(r.task, r.status, r.duration)),
578
+ paint(TABLE_HEADER, header, colors, { bold: true }),
579
+ dim('-'.repeat(header.length)),
580
+ ...rows.map((r) => {
581
+ const { node } = r.o
582
+ const id =
583
+ node.projectName !== undefined && node.taskName !== undefined
584
+ ? paint(identityColor(node.projectName), node.projectName, colors) +
585
+ dim('#') +
586
+ paint(identityColor(node.taskName), node.taskName, colors)
587
+ : r.task
588
+ const status = paint(tableStatusColor(r.o), r.status, colors)
589
+ return `${id}${pad(r.task, widths.task)} ${status}${pad(r.status, widths.status)} ${pad(r.duration, widths.duration)}${dim(r.duration)}`
590
+ }),
526
591
  ]
527
592
  }
528
593
 
594
+ /** violet-400, as the task list's header. */
595
+ const TABLE_HEADER = '#a78bfa'
596
+
597
+ function tableStatusColor(o: TaskOutcome): string {
598
+ switch (o.status) {
599
+ case 'failed':
600
+ return ERROR
601
+ case 'skipped':
602
+ return WARN
603
+ case 'aborted':
604
+ return neverStarted(o) ? NOT_RUN : ABORTED
605
+ default:
606
+ return SUCCESS
607
+ }
608
+ }
609
+
529
610
  // Local byte formatter — the orchestrator can't import cli/format.ts
530
611
  // (module boundary), and the footer only needs one compact form.
531
612
  /** Ran here with a `cache` block, succeeded, and its result was not saved. */
@@ -598,13 +679,14 @@ export function formatRunSummary(
598
679
  }
599
680
 
600
681
  /** Each finished task's cache bucket by its key and by its id. */
601
- function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
602
- const out = new Map<string, CacheBucket>()
682
+ function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, TaskBucket> {
683
+ const out = new Map<string, TaskBucket>()
603
684
  for (const o of outcomes) {
604
685
  const bucket = cacheBucketOf(o)
605
686
  if (bucket === undefined) continue
606
- if (o.hash !== undefined) out.set(o.hash, bucket)
607
- out.set(o.node.id, bucket)
687
+ const entry = { task: o.node.id, bucket }
688
+ if (o.hash !== undefined) out.set(o.hash, entry)
689
+ out.set(o.node.id, entry)
608
690
  }
609
691
  return out
610
692
  }