@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -27,32 +27,27 @@
27
27
 
28
28
  import { withForwardArgs } from '../exec/index.js'
29
29
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
30
- import { formatElapsed, maskedCommand, paint, type ColorSupport } from '../util/index.js'
30
+ import {
31
+ formatElapsed,
32
+ identityColor,
33
+ maskedCommand,
34
+ paint,
35
+ type ColorSupport,
36
+ } from '../util/index.js'
31
37
  import { neverStarted } from './summary.js'
32
38
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
33
39
  import { fileMemo, linkPaths } from './path-links.js'
34
- import { sandboxFixLine } from './sandbox-fix.js'
35
40
 
36
41
  const NO_COLOR: ColorSupport = { enabled: false }
37
42
 
38
43
  const ACCENT = '#06b6d4' // cyan-500 — bullets, remote-hit hint
39
- // Identity hues — deliberately outside the status palette (green /
40
- // red / yellow / cyan) so a task id can never read as an outcome.
41
- // Projects hash to a stable hue (same project = same color in every
42
- // run, list, and region row); tasks keep one fixed hue excluded from
43
- // the project palette so the two halves always read apart.
44
+ // Identity hues (`identityColor`) are outside the status palette (green /
45
+ // red / yellow / cyan) so a task id can never read as an outcome. Each
46
+ // half of an id hashes its own name: same project, same hue; same target,
47
+ // same hue, in every run, list and region row.
44
48
  // Section rules + the summary rule share one frame width.
45
49
  const FRAME_WIDTH = 60
46
50
 
47
- const PROJECT_PALETTE = [
48
- '#a78bfa', // violet-400
49
- '#60a5fa', // blue-400
50
- '#e879f9', // fuchsia-400
51
- '#818cf8', // indigo-400
52
- '#c084fc', // purple-400
53
- '#93c5fd', // blue-300
54
- ] as const
55
- const TASK = '#f472b6' // pink-400 — task part of an id
56
51
  const SUCCESS = '#22c55e' // green-500 — success / fresh
57
52
  const WARN = '#eab308' // yellow-500 — skipped
58
53
  const ERROR = '#ef4444' // red-500 — failed
@@ -209,7 +204,8 @@ function pushOutputSection(
209
204
  function sectionLine(title: string, color: string, colors: ColorSupport): string {
210
205
  const corner = (s: string) => paint('', s, colors, { dim: true })
211
206
  const label = paint(color, title, colors, { bold: true })
212
- const rule = corner('─'.repeat(Math.max(1, FRAME_WIDTH - 4 - title.length)))
207
+ const width = Math.min(FRAME_WIDTH, colors.columns ?? FRAME_WIDTH)
208
+ const rule = corner('─'.repeat(Math.max(1, width - 4 - title.length)))
213
209
  return `${corner('├─')} ${label} ${rule}`
214
210
  }
215
211
 
@@ -222,18 +218,9 @@ function pushBodyLines(lines: string[], text: string): void {
222
218
  for (const line of text.replace(/\n$/, '').split('\n')) lines.push(line)
223
219
  }
224
220
 
225
- /** Stable per-project hue: FNV-style fold of the name into the palette. */
226
- function projectColor(projectName: string): string {
227
- let h = 0
228
- for (let i = 0; i < projectName.length; i++) {
229
- h = (h * 31 + projectName.charCodeAt(i)) >>> 0
230
- }
231
- return PROJECT_PALETTE[h % PROJECT_PALETTE.length]!
232
- }
233
-
234
221
  /**
235
- * `project#task` halves in distinct identity hues (never status
236
- * colors); the dim separator keeps them reading apart. `hueSource`
222
+ * `project#task` halves, each in its name's identity hue (never a status
223
+ * color); the dim separator keeps them reading apart. `hueSource`
237
224
  * exists for truncated display strings: the hue must hash from the
238
225
  * FULL project name so it survives truncation.
239
226
  */
@@ -242,13 +229,14 @@ export function paintIdParts(
242
229
  projectText: string,
243
230
  taskText: string,
244
231
  colors: ColorSupport,
245
- opts: { bold?: boolean } = {},
232
+ opts: { bold?: boolean; sep?: '#' | ':' } = {},
246
233
  ): string {
247
234
  const dim = (t: string) => paint('', t, colors, { dim: true })
235
+ const { sep = '#', ...style } = opts
248
236
  return (
249
- paint(projectColor(hueSource), projectText, colors, opts) +
250
- dim('#') +
251
- paint(TASK, taskText, colors, opts)
237
+ paint(identityColor(hueSource), projectText, colors, style) +
238
+ dim(sep) +
239
+ paint(identityColor(taskText), taskText, colors, style)
252
240
  )
253
241
  }
254
242
 
@@ -544,7 +532,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
544
532
  ...(fix.inputs !== undefined
545
533
  ? [
546
534
  dim(
547
- `and, if the output depends on them, cache.inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
535
+ `and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
548
536
  ),
549
537
  ]
550
538
  : []),
@@ -553,8 +541,10 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
553
541
  : []),
554
542
  '',
555
543
  ]
544
+ // vx's own notes (`vx: …`) explain a failure; they are not denials.
545
+ const denials = body.filter((l) => !l.startsWith('vx: ')).length
556
546
  return [
557
- sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors),
547
+ sectionLine(denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX', ERROR, colors),
558
548
  '',
559
549
  ...body,
560
550
  '',
@@ -704,3 +694,19 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
704
694
  return o.status
705
695
  }
706
696
  }
697
+
698
+ /**
699
+ * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
700
+ * or undefined when every denial was a withheld credential path.
701
+ */
702
+ function sandboxFixLine({
703
+ withheld: _,
704
+ inputs: __,
705
+ ...fix
706
+ }: NonNullable<TaskOutcome['sandboxFix']>): string | undefined {
707
+ if (Object.keys(fix).length === 0) return undefined
708
+ const parts = Object.entries(fix).map(
709
+ ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
710
+ )
711
+ return `exec.sandbox.allow: { ${parts.join(', ')} }`
712
+ }
@@ -0,0 +1,36 @@
1
+ // What a run tells the people and programs watching it: the event stream
2
+ // every output surface subscribes to, the terminal renderer (framed task
3
+ // output, the status line, the summary, the forecast) and the plain
4
+ // output for non-TTY runs. It renders outcomes it is handed; it never
5
+ // runs a task or reads the cache.
6
+ export {
7
+ busLogger,
8
+ createEventBus,
9
+ type EventBus,
10
+ type OutcomeView,
11
+ outcomeWord,
12
+ projectNode,
13
+ projectOutcome,
14
+ ranNoCache,
15
+ type RunEvent,
16
+ type RunEventSubscriber,
17
+ type RunStartInfo,
18
+ type TaskView,
19
+ terminalSubscriber,
20
+ toWireEvent,
21
+ type WireEvent,
22
+ wireForwarder,
23
+ } from './events.js'
24
+ export {
25
+ type DefaultLogger,
26
+ defaultLogger,
27
+ type Logger,
28
+ type OutputView,
29
+ resolveOutputView,
30
+ warnOf,
31
+ } from './logger.js'
32
+ export { formatPersistentList } from './framed-output.js'
33
+ export { createForecast } from './forecast.js'
34
+ export { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
35
+ export { plainOutput } from './plain-output.js'
36
+ export { formatOutcomeTable, formatRunSummary } from './summary.js'
@@ -17,10 +17,18 @@ import {
17
17
  createOutputWriter,
18
18
  formatFailureLine,
19
19
  formatStatusRegion,
20
+ formatStepLine,
20
21
  type StatusStream,
21
22
  type WorkerSlot,
22
23
  } from './status-line.js'
23
- import { formatSummarySection, neverStarted, ranUnsaved, type RunContext } from './summary.js'
24
+ import {
25
+ cacheBucketOf,
26
+ formatSummarySection,
27
+ neverStarted,
28
+ ranUnsaved,
29
+ type CacheBucket,
30
+ type RunContext,
31
+ } from './summary.js'
24
32
  import { isGroupTask } from '../graph/index.js'
25
33
  import {
26
34
  appendTail,
@@ -32,9 +40,9 @@ import {
32
40
  detectColors,
33
41
  type ColorSupport,
34
42
  } from '../util/index.js'
35
- import { isCacheHit } from './telemetry.js'
43
+ import { isCacheHit } from '../graph/index.js'
36
44
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
37
- import type { Forecast } from './forecast.js'
45
+ import type { BlockedTask, Forecast } from './forecast.js'
38
46
  import {
39
47
  notifyEscape,
40
48
  PROGRESS_CLEAR,
@@ -46,6 +54,12 @@ import {
46
54
  export interface Logger {
47
55
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
48
56
  status(line: string): void
57
+ /**
58
+ * A warning: something worked, but look at it. The default logger writes
59
+ * it to stderr, so stdout carries only the rows and the footer. A logger
60
+ * without it gets warnings through `status`.
61
+ */
62
+ warn?(line: string): void
49
63
  /** Streamed stdout chunk for a task. Buffered until `taskComplete`. */
50
64
  taskStdout(node: TaskNode, chunk: string): void
51
65
  /** Streamed stderr chunk for a task. Buffered until `taskComplete`. */
@@ -70,8 +84,21 @@ export interface Logger {
70
84
  startedAtMs?: number
71
85
  }): void
72
86
  taskStart?(node: TaskNode): void
73
- /** Run finished (any outcome). Idempotent. */
87
+ /** The task graph finished (any outcome); vx's own closing steps follow. Idempotent. */
74
88
  runEnd?(): void
89
+ /**
90
+ * One of vx's own steps (`vx:keys`, `@vzn/vx-otel:setup`) began or ended.
91
+ * Not a task: no stat, history row or telemetry record counts it.
92
+ */
93
+ stepStart?(id: string): void
94
+ stepEnd?(id: string, info: { durationMs: number; ok: boolean }): void
95
+ /** The run's work is done; the footer follows. Idempotent. */
96
+ runDone?(): void
97
+ }
98
+
99
+ /** `log.warn`, or `log.status` for a logger without it. */
100
+ export function warnOf(log: Logger): (line: string) => void {
101
+ return log.warn !== undefined ? (line) => log.warn!(line) : (line) => log.status(line)
75
102
  }
76
103
 
77
104
  /** The logger `run()` builds when none is passed. */
@@ -82,6 +109,12 @@ export interface DefaultLogger extends Logger {
82
109
  * throw that never reached `runEnd`.
83
110
  */
84
111
  settle(): void
112
+ /**
113
+ * Whether vx's own steps show: only beside a live region, on a terminal.
114
+ * A log (CI, a pipe) stays free of rows whose presence is a timing.
115
+ * When false, run() never times a step (zero cost).
116
+ */
117
+ readonly showsSteps: boolean
85
118
  /**
86
119
  * Hand the live region a way to predict the run's end. Called once at
87
120
  * run start; `load` runs only if the region is still live a second in,
@@ -192,6 +225,8 @@ export function resolveOutputView(
192
225
  const FORECAST_AFTER_MS = 1000
193
226
  /** A run shorter than this ends without a desktop notification. */
194
227
  const NOTIFY_AFTER_MS = 10_000
228
+ // A step of vx's own quicker than this shows nowhere (M, 2026-10-15).
229
+ const STEP_SHOWN_MS = 150
195
230
 
196
231
  export function defaultLogger(
197
232
  colors: ColorSupport = detectColors(),
@@ -207,9 +242,12 @@ export function defaultLogger(
207
242
  footer?: boolean
208
243
  /** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
209
244
  env?: Readonly<Record<string, string | undefined>>
245
+ /** Where warnings go; `process.stderr` by default. */
246
+ err?: StatusStream
210
247
  } = {},
211
248
  ): DefaultLogger {
212
249
  const forwardArgs = opts.forwardArgs ?? []
250
+ const errOut = opts.err ?? process.stderr
213
251
  // Per-task buffers, split by stream. Splitting lets the framed-output
214
252
  // renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
215
253
  // The price: chunks that interleaved at runtime get re-ordered (all
@@ -303,6 +341,10 @@ export function defaultLogger(
303
341
  let critical: string | undefined
304
342
  let blocks: ReadonlyMap<string, number> | undefined
305
343
  let expected: ReadonlyMap<string, number> | undefined
344
+ let blocked: readonly BlockedTask[] | undefined
345
+ // The blocked task each idle worker row holds, by slot: a row keeps its
346
+ // task until it starts, in that row, or leaves the list, so nothing hops.
347
+ const heldRows: (string | null)[] = []
306
348
  let etaAtMs = 0
307
349
  const signals = writer.enabled
308
350
  ? terminalSignals(opts.env ?? process.env)
@@ -334,6 +376,15 @@ export function defaultLogger(
334
376
  let notSaved = 0
335
377
  let skippedCount = 0
336
378
  let savedMs = 0
379
+ // Each finished task's cache bucket by its key: its remote traffic moves it.
380
+ const buckets = new Map<string, CacheBucket>()
381
+ // The graph is done and vx's own closing steps run: the region stays,
382
+ // footer and step rows, until the footer replaces it (M, 2026-10-15).
383
+ let tail = false
384
+ // No footer before the run starts: vx's opening steps show alone.
385
+ let started = false
386
+ // vx's own steps running now, by id: a row once one passes STEP_SHOWN_MS.
387
+ const steps = new Map<string, number>()
337
388
  // Cache-miss duration spread, accumulated incrementally (the same
338
389
  // numbers the final summary computes from the outcome list).
339
390
  let spreadMax = 0
@@ -409,7 +460,7 @@ export function defaultLogger(
409
460
  if (statusDead) return
410
461
  const now = Date.now()
411
462
  // Twice a second at most: one pass over the unfinished graph.
412
- if (forecastFn !== undefined && (force || now - etaAtMs >= 500)) {
463
+ if (forecastFn !== undefined && !tail && (force || now - etaAtMs >= 500)) {
413
464
  etaAtMs = now
414
465
  const running = new Map<string, number>()
415
466
  for (const s of slots) if (s !== null) running.set(s.id, s.startedMs)
@@ -419,6 +470,7 @@ export function defaultLogger(
419
470
  critical = f.critical
420
471
  blocks = f.blocks
421
472
  expected = f.expected
473
+ blocked = f.blocked
422
474
  }
423
475
  if (signals.progress && total > 0) {
424
476
  const elapsed = now - startedAtMs
@@ -437,7 +489,7 @@ export function defaultLogger(
437
489
  // The live summary IS the final summary's section, built from the
438
490
  // same formatter so the region visually becomes the printout.
439
491
  const summaryLines =
440
- opts.footer === false
492
+ opts.footer === false || !started
441
493
  ? []
442
494
  : formatSummarySection(
443
495
  {
@@ -454,7 +506,8 @@ export function defaultLogger(
454
506
  noCache,
455
507
  savedMs,
456
508
  left: total - done,
457
- ...(etaMs !== undefined ? { etaMs } : {}),
509
+ ...(etaMs !== undefined && !tail ? { etaMs } : {}),
510
+ buckets,
458
511
  spread:
459
512
  spreadCount > 0
460
513
  ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
@@ -464,12 +517,18 @@ export function defaultLogger(
464
517
  colors,
465
518
  runContext,
466
519
  )
520
+ const held = blocked === undefined ? undefined : holdRows(blocked)
521
+ const shownSteps: WorkerSlot[] = []
522
+ for (const [id, startedMs] of steps)
523
+ if (now - startedMs >= STEP_SHOWN_MS) shownSteps.push({ id, startedMs })
467
524
  writer.setRegion(
468
525
  formatStatusRegion(
469
526
  {
470
527
  pinnedPersistent,
471
528
  slots: slots.slice(0, shownSlots),
529
+ ...(held !== undefined ? { blocked: held } : {}),
472
530
  overflow: slotQueue.length,
531
+ ...(shownSteps.length > 0 ? { steps: shownSteps } : {}),
473
532
  nowMs: Date.now(),
474
533
  summaryLines,
475
534
  ...(blocks !== undefined ? { blocks } : {}),
@@ -481,6 +540,46 @@ export function defaultLogger(
481
540
  { force },
482
541
  )
483
542
  }
543
+ // Each idle worker row holds a blocked task, in scheduler order. A row
544
+ // with none left to hold leaves the region (M, 2026-10-15): blocked tasks
545
+ // only dwindle, so it is never needed again, and the rows below close up.
546
+ const holdRows = (list: readonly BlockedTask[]): (BlockedTask | null)[] => {
547
+ const started = new Set<string>()
548
+ for (const s of slots) if (s !== null) started.add(s.id)
549
+ for (const s of slotQueue) started.add(s.id)
550
+ const open = new Map<string, BlockedTask>()
551
+ for (const b of list) if (!started.has(b.id)) open.set(b.id, b)
552
+ const rows: (BlockedTask | null)[] = []
553
+ for (let i = 0; i < shownSlots; i++) {
554
+ const kept = slots[i] === null ? open.get(heldRows[i] ?? '') : undefined
555
+ rows.push(kept ?? null)
556
+ if (kept !== undefined) open.delete(kept.id)
557
+ }
558
+ const rest = open.values()
559
+ for (let i = 0; i < shownSlots; i++)
560
+ if (slots[i] === null && rows[i] === null) rows[i] = rest.next().value ?? null
561
+ for (let i = shownSlots - 1; i >= 0; i--) {
562
+ if (slots[i] !== null || rows[i] !== null) continue
563
+ rows.splice(i, 1)
564
+ slots.splice(i, 1)
565
+ slots.push(null)
566
+ shownSlots--
567
+ }
568
+ heldRows.length = 0
569
+ for (const r of rows) heldRows.push(r?.id ?? null)
570
+ return rows
571
+ }
572
+ const tick = (): void => {
573
+ if (!writer.enabled || statusDead || ticker !== null) return
574
+ // Keeps the per-worker elapsed time ticking between task events
575
+ // (the time IS the motion — there's no spinner). The writer
576
+ // throttles unforced redraws, and unref means a stray ticker
577
+ // can never hold the process open.
578
+ ticker = setInterval(() => {
579
+ refresh(false)
580
+ }, 100)
581
+ ticker.unref?.()
582
+ }
484
583
  const killStatus = (): void => {
485
584
  if (ticker !== null) {
486
585
  clearInterval(ticker)
@@ -504,7 +603,11 @@ export function defaultLogger(
504
603
  // no task row (`status`). Without a footer, the caller frames the log
505
604
  // (`vx watch` opens each cycle with its own line).
506
605
  let logOpened = opts.footer === false
606
+ // The log ends on a blank line (a block's own): a blank status line
607
+ // after it (the footer's) would make two; one blank line apart is the rule.
608
+ let endsBlank = false
507
609
  const taskWrite = (chunk: string): void => {
610
+ if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
508
611
  if (!logOpened && chunk.length > 0) {
509
612
  logOpened = true
510
613
  writer.write(`\n${chunk}`)
@@ -559,6 +662,7 @@ export function defaultLogger(
559
662
  flakyNote(outcome, colors)
560
663
 
561
664
  return {
665
+ showsSteps: writer.enabled,
562
666
  settle() {
563
667
  // The bus delivers one `run:end`, so the CLI's last word reaches here.
564
668
  flushKept()
@@ -574,28 +678,46 @@ export function defaultLogger(
574
678
  writer.write('\n')
575
679
  streamMidLine = false
576
680
  }
681
+ if (line === '' && endsBlank) return
682
+ endsBlank = line === ''
577
683
  writer.write(`${line}\n`)
578
684
  },
685
+ warn(line) {
686
+ flushKept()
687
+ if (streamMidLine) {
688
+ writer.write('\n')
689
+ streamMidLine = false
690
+ }
691
+ writer.writeTo(errOut, `${line}\n`)
692
+ },
579
693
  runStart(info) {
580
694
  total = info.total
581
695
  requestedCount = info.requestedCount ?? requestedCount
582
696
  runContext = info.context
583
697
  startedAtMs = Date.now()
698
+ started = true
584
699
  const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
585
700
  slots = Array.from({ length: cap }, () => null)
586
701
  shownSlots = 0
587
- if (writer.enabled && !statusDead && ticker === null) {
588
- // Keeps the per-worker elapsed time ticking between task events
589
- // (the time IS the motion — there's no spinner). The writer
590
- // throttles unforced redraws, and unref means a stray ticker
591
- // can never hold the process open.
592
- ticker = setInterval(() => {
593
- refresh(false)
594
- }, 100)
595
- ticker.unref?.()
596
- }
702
+ tick()
597
703
  refresh(true)
598
704
  },
705
+ stepStart(id) {
706
+ steps.set(id, Date.now())
707
+ // Drawn by the ticker once it passes STEP_SHOWN_MS; a quicker one never is.
708
+ tick()
709
+ },
710
+ stepEnd(id, { durationMs, ok }) {
711
+ steps.delete(id)
712
+ if (durationMs < STEP_SHOWN_MS) return
713
+ if (ok ? view.mode !== 'none' && view.mode !== 'errors-only' : view.mode !== 'none')
714
+ emitLine(formatStepLine(id, durationMs, ok, colors))
715
+ refresh(true)
716
+ },
717
+ runDone() {
718
+ tail = false
719
+ killStatus()
720
+ },
599
721
  taskStart(node) {
600
722
  if (isGroupTask(node)) return
601
723
  if (holdsTerminal(node)) {
@@ -619,7 +741,14 @@ export function defaultLogger(
619
741
  persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
620
742
  }
621
743
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
622
- const free = slots.indexOf(null)
744
+ // A blocked task starts in the row that held it; another takes a free
745
+ // row, else the last held one, whose task moves to the next free row.
746
+ const own = heldRows.indexOf(node.id)
747
+ let free = own >= 0 && slots[own] === null ? own : -1
748
+ if (free < 0)
749
+ free = slots.findIndex((s, i) => s === null && i < shownSlots && heldRows[i] == null)
750
+ if (free < 0) free = slots.findLastIndex((s, i) => s === null && i < shownSlots)
751
+ if (free < 0) free = slots.indexOf(null)
623
752
  if (free >= 0) {
624
753
  slots[free] = slot
625
754
  shownSlots = Math.max(shownSlots, free + 1)
@@ -645,7 +774,7 @@ export function defaultLogger(
645
774
  runEnd() {
646
775
  // A long run says it ended where the user is not looking. Once:
647
776
  // the status is dead after the first runEnd.
648
- if (signals.notify && !statusDead && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
777
+ if (signals.notify && !statusDead && !tail && Date.now() - startedAtMs >= NOTIFY_AFTER_MS) {
649
778
  out.write(
650
779
  notifyEscape(
651
780
  failed > 0
@@ -654,7 +783,14 @@ export function defaultLogger(
654
783
  ),
655
784
  )
656
785
  }
657
- killStatus()
786
+ if (!tail && !statusDead) {
787
+ tail = true
788
+ slots = []
789
+ blocked = undefined
790
+ shownSlots = 0
791
+ slotQueue.length = 0
792
+ refresh(true)
793
+ } else if (!tail) killStatus()
658
794
  // Persistent tails first: they are context for whatever ran against
659
795
  // the server, so they read above the failures they explain. `none`
660
796
  // prints no per-task output and `errors-only` only failed tasks: a
@@ -837,6 +973,8 @@ export function defaultLogger(
837
973
  const qi = slotQueue.findIndex((s) => s.id === node.id)
838
974
  if (qi >= 0) slotQueue.splice(qi, 1)
839
975
  }
976
+ const bucket = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
977
+ if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
840
978
  switch (outcome.status) {
841
979
  case 'success':
842
980
  succeeded++
@@ -5,14 +5,16 @@
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 { paint, type ColorSupport } from '../util/index.js'
8
+ import { dumbTerminal, paint, type ColorSupport } from '../util/index.js'
9
9
  import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
10
+ import type { BlockedTask } from './forecast.js'
10
11
 
11
12
  const NO_COLOR: ColorSupport = { enabled: false }
12
13
  // Same palette as framed-output.ts so glyphs and stats agree.
13
14
  const ACCENT = '#06b6d4'
14
15
  const ERROR = '#ef4444'
15
16
  const WARN = '#eab308' // yellow-500, as the summary's
17
+ const SUCCESS = '#22c55e'
16
18
 
17
19
  /** Erase the current line and return the cursor to column 0. */
18
20
  const CLEAR = '\x1b[2K\r'
@@ -35,6 +37,11 @@ export interface OutputWriter {
35
37
  readonly enabled: boolean
36
38
  /** Serialized content write: erase status → content → redraw. */
37
39
  write(chunk: string): void
40
+ /**
41
+ * The same, to another stream on the same terminal (stderr): the region
42
+ * on this one is erased first and redrawn after.
43
+ */
44
+ writeTo(target: StatusStream, chunk: string): void
38
45
  /**
39
46
  * Replace the status line's content. Redraws are throttled to
40
47
  * `minRedrawMs` unless `force` (task start/finish) is set.
@@ -79,7 +86,7 @@ export function createOutputWriter(
79
86
  stream: StatusStream,
80
87
  opts: OutputWriterOptions = {},
81
88
  ): OutputWriter {
82
- const enabled = (opts.enabled ?? true) && stream.isTTY === true
89
+ const enabled = (opts.enabled ?? true) && stream.isTTY === true && !dumbTerminal()
83
90
  const minRedrawMs = opts.minRedrawMs ?? 100
84
91
  const forceFloorMs = opts.forceFloorMs ?? 30
85
92
  const now = opts.now ?? Date.now
@@ -236,6 +243,22 @@ export function createOutputWriter(
236
243
  if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
237
244
  if (!dead && current !== null && atLineStart) draw()
238
245
  },
246
+ writeTo(target, chunk) {
247
+ if (target === stream) return this.write(chunk)
248
+ // Held stdout is older than this chunk.
249
+ flushHeld()
250
+ if (!enabled) {
251
+ target.write(chunk)
252
+ return
253
+ }
254
+ if (shown) {
255
+ stream.write(eraseSeq())
256
+ shown = false
257
+ shownRows = 0
258
+ }
259
+ target.write(chunk)
260
+ if (!dead && current !== null && atLineStart) draw()
261
+ },
239
262
  setStatus(line, o = {}) {
240
263
  set([line], o)
241
264
  },
@@ -283,8 +306,15 @@ export interface StatusRegionState {
283
306
  pinnedPersistent: readonly string[]
284
307
  /** Fixed-size slot array; null = idle worker. Index = display row. */
285
308
  slots: readonly (WorkerSlot | null)[]
309
+ /** The blocked task an idle slot's row holds instead of `idle`, by the same index. */
310
+ blocked?: readonly (BlockedTask | null)[]
286
311
  /** Running tasks beyond the displayed slots. */
287
312
  overflow: number
313
+ /**
314
+ * vx's own steps still running, shown below the worker rows: no worker
315
+ * slot or `--concurrency` bounds them (M, 2026-10-15).
316
+ */
317
+ steps?: readonly WorkerSlot[]
288
318
  /** Clock value used for per-slot elapsed (injectable for tests). */
289
319
  nowMs: number
290
320
  /**
@@ -299,8 +329,12 @@ export interface StatusRegionState {
299
329
  /** Identity-colored id, full (never truncated) — name is the last column. */
300
330
  function paintPinnedId(id: string, colors: ColorSupport): string {
301
331
  const sep = id.indexOf('#')
302
- if (sep < 0) return id
303
- return paintIdParts(id.slice(0, sep), id.slice(0, sep), id.slice(sep + 1), colors)
332
+ if (sep >= 0) return paintIdParts(id.slice(0, sep), id.slice(0, sep), id.slice(sep + 1), colors)
333
+ // One of vx's own steps: `<owner>:<step>`, painted as a task id is.
334
+ const step = id.lastIndexOf(':')
335
+ if (step < 0) return id
336
+ const owner = id.slice(0, step)
337
+ return paintIdParts(owner, owner, id.slice(step + 1), colors, { sep: ':' })
304
338
  }
305
339
 
306
340
  /**
@@ -327,6 +361,32 @@ export function formatFailureLine(
327
361
  )
328
362
  }
329
363
 
364
+ /** A step's cache column, dim as `no-cache` is. */
365
+ const INTERNAL = 'internal'
366
+
367
+ /**
368
+ * The list row of one of vx's own steps (`vx:uploads`), on the task grid
369
+ * with `internal` in the cache column: a step is no task and caches
370
+ * nothing (M, 2026-10-16: `4.60s success internal vx:keep-run`).
371
+ */
372
+ export function formatStepLine(
373
+ id: string,
374
+ durationMs: number,
375
+ ok: boolean,
376
+ colors: ColorSupport = NO_COLOR,
377
+ ): string {
378
+ return formatTaskRow(
379
+ ok ? paint(SUCCESS, '\u23fa\uFE0E', colors) : paint(ERROR, '\u25fc\uFE0E', colors),
380
+ durationMs,
381
+ ok ? 'success' : 'failed',
382
+ ok ? SUCCESS : ERROR,
383
+ INTERNAL,
384
+ '',
385
+ paintPinnedId(id, colors),
386
+ colors,
387
+ )
388
+ }
389
+
330
390
  /**
331
391
  * Worker region: pinned ready persistent tasks, one row per worker
332
392
  * slot — a task stays in its slot for its whole life so names never
@@ -363,12 +423,19 @@ export function formatStatusRegion(
363
423
  // Worker rows: NO glyph — the live elapsed time leads (no spinner;
364
424
  // the ticking time IS the motion), then `running`, full id (never
365
425
  // truncated — name is the last column). Idle rows hold their slot's
366
- // place, dim, aligned under the status column.
426
+ // place, dim, aligned under the status column: the blocked task the row
427
+ // holds and what holds it back, else `idle`.
367
428
  // One task running has no path to compare against.
368
429
  const busy = s.slots.filter((x) => x !== null).length
369
- for (const slot of s.slots) {
430
+ for (const [row, slot] of s.slots.entries()) {
370
431
  if (slot === null) {
371
- lines.push(`${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`)
432
+ const w = s.blocked?.[row]
433
+ lines.push(
434
+ w == null
435
+ ? `${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`
436
+ : formatTaskRow(' ', null, 'blocked', '', '', '', paintPinnedId(w.id, colors), colors) +
437
+ ` ${dim(`by ${w.by}`)}`,
438
+ )
372
439
  continue
373
440
  }
374
441
  // What waits on it, after the id: the row to watch is the one the
@@ -394,6 +461,20 @@ export function formatStatusRegion(
394
461
  if (s.overflow > 0) {
395
462
  lines.push(dim(`\u2026 +${s.overflow} more running`))
396
463
  }
464
+ for (const step of s.steps ?? []) {
465
+ lines.push(
466
+ formatTaskRow(
467
+ ' ',
468
+ Math.max(0, s.nowMs - step.startedMs),
469
+ 'running',
470
+ ACCENT,
471
+ INTERNAL,
472
+ '',
473
+ paintPinnedId(step.id, colors),
474
+ colors,
475
+ ),
476
+ )
477
+ }
397
478
  // The live summary section — identical shape to the final printout,
398
479
  // so the region visually BECOMES the summary when the run ends.
399
480
  lines.push(...s.summaryLines)