@vzn/vx 0.0.652 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -26,11 +26,18 @@
26
26
  // `--ui=stream` mode.
27
27
 
28
28
  import { withForwardArgs } from '../exec/index.js'
29
- import { isCacheHit, isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
+ import {
30
+ isCacheHit,
31
+ isGroupTask,
32
+ isKeyFailed,
33
+ type TaskNode,
34
+ type TaskOutcome,
35
+ } from '../graph/index.js'
30
36
  import { formatElapsed, maskedCommand, printable } from '../util/index.js'
31
37
  import * as ui from '../ui/index.js'
32
38
  import {
33
39
  fileMemo,
40
+ GUTTER,
34
41
  hasEscape,
35
42
  idParts,
36
43
  lastRedraw,
@@ -99,7 +106,7 @@ export function formatTaskBlock(
99
106
  // (success and failed). Cache hits normally skip it (the stored
100
107
  // output is the interesting part), but `forceCommand` keeps it for
101
108
  // focused requested tasks so their frame is hit/miss-identical.
102
- if (forceCommand || outcome.status === 'success' || outcome.status === 'failed') {
109
+ if (forceCommand || ranCommand(outcome)) {
103
110
  // No section label for the command (owner cut it) — the dim `$ `
104
111
  // line under the header reads as the command on its own.
105
112
  lines.push('', corner(`$ ${shownCommand(node, forwardArgs)}`), '')
@@ -120,8 +127,10 @@ export function formatTaskBlock(
120
127
 
121
128
  lines.push(
122
129
  `${corner('└─')} ${mark}${idPainted}${whereNote(outcome, colors)} ${corner('──')}${running ? runningFooter(outcome, colors) : formatBlockFooter(outcome, colors)}`,
130
+ '',
123
131
  )
124
- return lines.join('\n') + '\n'
132
+ // Not `+ '\n'`: a rope over a 100 MB frame was flattened into a copy.
133
+ return lines.join('\n')
125
134
  }
126
135
 
127
136
  /** What a bounded capture dropped from its head, so a capped log never reads as whole. */
@@ -172,27 +181,41 @@ function pushOutputSection(
172
181
  if (lost > 0) {
173
182
  lines.push(droppedNote(lost, colors))
174
183
  }
175
- const body: Array<{ text: string; err: boolean }> = []
184
+ const emit = (line: string, err: boolean): void => {
185
+ const text = link === undefined ? line : link(line)
186
+ if (!err || !colors.enabled) lines.push(text)
187
+ else lines.push(hasEscape(line) ? text : paint('error', text, colors))
188
+ }
189
+ // A run of whole lines nothing restyles is pushed as one slice, its
190
+ // newlines inside: an object and a string per line peaked a 50 MB log's
191
+ // frame at ~11x its size.
176
192
  let open: { parts: string[]; err: boolean } | null = null
177
193
  for (const c of output) {
178
- let rest = c.text
179
- while (rest.length > 0) {
180
- const nl = rest.indexOf('\n')
181
- const piece = nl < 0 ? rest : rest.slice(0, nl)
182
- if (open === null) open = { parts: [], err: c.err }
183
- open.parts.push(piece)
184
- if (nl < 0) break
185
- body.push({ text: open.parts.join(''), err: open.err })
194
+ const text = c.text
195
+ let at = 0
196
+ if (open !== null) {
197
+ const nl = text.indexOf('\n')
198
+ open.parts.push(nl < 0 ? text : text.slice(0, nl))
199
+ if (nl < 0) continue
200
+ emit(open.parts.join(''), open.err)
186
201
  open = null
187
- rest = rest.slice(nl + 1)
202
+ at = nl + 1
188
203
  }
204
+ if (link === undefined && (!c.err || !colors.enabled)) {
205
+ const last = text.lastIndexOf('\n')
206
+ if (last >= at) {
207
+ lines.push(text.slice(at, last))
208
+ at = last + 1
209
+ }
210
+ } else {
211
+ for (let nl = text.indexOf('\n', at); nl >= 0; nl = text.indexOf('\n', at)) {
212
+ emit(text.slice(at, nl), c.err)
213
+ at = nl + 1
214
+ }
215
+ }
216
+ if (at < text.length) open = { parts: [text.slice(at)], err: c.err }
189
217
  }
190
- if (open !== null) body.push({ text: open.parts.join(''), err: open.err })
191
- for (const l of body) {
192
- const text = link === undefined ? l.text : link(l.text)
193
- if (!l.err || !colors.enabled) lines.push(text)
194
- else lines.push(hasEscape(l.text) ? text : paint('error', text, colors))
195
- }
218
+ if (open !== null) emit(open.parts.join(''), open.err)
196
219
  const styled = output.findLast((c) => hasEscape(c.text))
197
220
  if (styled !== undefined && leavesStyle(styled.text)) lines.push(`${lines.pop()}${STYLE_END}`)
198
221
  lines.push('')
@@ -251,14 +274,13 @@ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolea
251
274
  return paintId(node.projectName, node.taskName, colors, opts)
252
275
  }
253
276
 
254
- // ── Reported-line grid: glyph · time · status · cache · name ────────
255
- // glyph SHAPE encodes the cache axis (⏺ miss · ► fresh · ⇢ local · ⇣
256
- // remote; ◼ failed · ⊘ skipped · ✗ aborted · ◌ not run), glyph COLOR encodes the
257
- // task axis (green/red/yellow/cyan). The status and cache WORDS spell
258
- // the two axes out, each in its own color. Time is right-aligned in a
277
+ // ── Reported-line grid: status · cache · usual · time · … · name ────────
278
+ // No glyph (2026-11-06): the status word, first at the Summary's value
279
+ // column, is the task axis and the cache word the cache axis, each in
280
+ // its own color. Time is right-aligned in a
259
281
  // fixed cell so durations line up and a ticking elapsed never shifts
260
282
  // the row. All detail (exit code, output) lives in the framed block.
261
- export const TIME_COL = 7 // "0ms" … "999.99s", then "16m 40s" … "99m 59s"
283
+ const TIME_COL = 7 // "0ms" … "999.99s", then "16m 40s" … "99m 59s"
262
284
  const STATUS_COL = 7 // "success" / "running" / "skipped" / "aborted" / "not run"
263
285
  const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
264
286
  const BLOCKED_COL = 7 // `blocks`: how many tasks are downstream of the row's
@@ -279,7 +301,8 @@ function pad(n: number): string {
279
301
 
280
302
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
281
303
  function timeCell(ms: number | null, colors: ColorSupport): string {
282
- let raw = ms === null ? '' : formatElapsed(ms)
304
+ if (ms === null) return pad(TIME_COL - 1) + paint('muted', UNKNOWN, colors)
305
+ let raw = formatElapsed(ms)
283
306
  // `1000.00s` is wider than the cell; minutes fit it up to `99m 59s`.
284
307
  if (ms !== null && raw.length > TIME_COL) raw = formatElapsed(ms, true)
285
308
  // The time is the row's fact (value level); the cells around it are meta.
@@ -296,24 +319,6 @@ function cell(text: string, color: Style | '', width: number, colors: ColorSuppo
296
319
  return painted + pad(width - text.length)
297
320
  }
298
321
 
299
- /** The cache-shape glyph (task-shape for non-success). */
300
- function glyphShape(o: TaskOutcome): string {
301
- switch (o.status) {
302
- case 'success':
303
- return '⏺\uFE0E' // ⏺ miss (ran), text-presentation (narrow)
304
- case 'cache-hit':
305
- return o.restored === false ? '\u25ba' : '\u21e2' // ► fresh : ⇢ local
306
- case 'cache-hit-remote':
307
- return o.restored === false ? '\u25ba' : '\u21e3' // ► fresh : ⇣ remote
308
- case 'failed':
309
- return '\u25fc\uFE0E' // ◼ failed, text-presentation (narrow)
310
- case 'skipped':
311
- return '\u2298' // ⊘ skipped
312
- default:
313
- return '⏺\uFE0E'
314
- }
315
- }
316
-
317
322
  /** Task-axis word + color (a cache hit is a success). */
318
323
  function statusOf(o: TaskOutcome): { word: string; color: Style } {
319
324
  switch (o.status) {
@@ -326,8 +331,19 @@ function statusOf(o: TaskOutcome): { word: string; color: Style } {
326
331
  }
327
332
  }
328
333
 
329
- /** Cache-axis word + color. Empty for states that never reached the cache. */
334
+ /** Whether the task ran its command: a hit whose restore failed did not, nor a task with no key. */
335
+ function ranCommand(o: TaskOutcome): boolean {
336
+ return (
337
+ o.status === 'success' || (o.status === 'failed' && o.restoreFailed !== true && !isKeyFailed(o))
338
+ )
339
+ }
340
+
341
+ /**
342
+ * Cache-axis word + color. Empty for states that never reached the cache,
343
+ * a hit whose restore failed (neither a miss nor a hit) and a task with no key.
344
+ */
330
345
  function cacheOf(o: TaskOutcome): { word: string; color: Style } {
346
+ if (o.restoreFailed === true || isKeyFailed(o)) return { word: '', color: 'muted' }
331
347
  switch (o.status) {
332
348
  case 'success':
333
349
  case 'failed':
@@ -346,20 +362,14 @@ function cacheOf(o: TaskOutcome): { word: string; color: Style } {
346
362
  }
347
363
  }
348
364
 
349
- /** The painted status glyph (shape = cache axis, color = task axis). */
350
- function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
351
- return paint(statusOf(o).color, glyphShape(o), colors)
352
- }
353
-
354
365
  /**
355
- * One reported task row on the list grid: `<glyph> <time> <usual>
356
- * <status> <cache> <cpu> <mem> <blocks> <worker> <name>`. `glyph` is
357
- * pre-painted; status/cache are raw text + color, padded to their
366
+ * One reported task row on the list grid: `<status> <cache> <usual>
367
+ * <time> <cpu> <mem> <outputs> <blocks> <worker> <mark> <name>`, the status at
368
+ * the Summary's value column, no glyph (2026-11-06). Status/cache are raw text + color, padded to their
358
369
  * columns; `paintedId` carries identity coloring. `ms = null` blanks the
359
370
  * time (a skip never ran); `cache = ''` and an absent cell blank theirs.
360
371
  */
361
372
  export function formatTaskRow(
362
- glyph: string,
363
373
  ms: number | null,
364
374
  status: string,
365
375
  statusColor: Style | '',
@@ -369,35 +379,43 @@ export function formatTaskRow(
369
379
  colors: ColorSupport = NO_COLOR,
370
380
  cells: RowCells = {},
371
381
  ): string {
372
- const st = cell(status, statusColor, STATUS_COL, colors)
373
- // `aborting` runs one into the cache cell, which is blank beside it.
374
- const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
375
- const usual = cells.usual ?? { text: '', painted: '' }
382
+ // Every cell says something: one with nothing in it reads `—` (2026-11-06).
383
+ const st = cell(status || UNKNOWN, statusColor, STATUS_COL, colors)
384
+ // `aborting` runs one into the cache cell.
385
+ const ca = cell(
386
+ cache || UNKNOWN,
387
+ cache === '' ? '' : cacheColor,
388
+ CACHE_COL - Math.max(0, status.length - STATUS_COL),
389
+ colors,
390
+ )
391
+ const usual =
392
+ cells.usual === undefined || cells.usual.text === '' ? unknownUsual(colors) : cells.usual
376
393
  const us = `${pad(USUAL_COL - usual.text.length)}${usual.painted}`
377
- const cpu =
378
- cells.cpu === undefined ? '' : cells.cpu === null ? UNKNOWN : `${Math.round(cells.cpu * 100)}%`
379
- const mem =
380
- cells.memBytes === undefined
381
- ? ''
382
- : cells.memBytes === null
383
- ? UNKNOWN
384
- : formatSize(cells.memBytes)
385
- const out = cells.outputBytes === undefined ? '' : formatSize(cells.outputBytes)
386
- const blocks = cells.blocks ?? ''
387
- const worker = workerName(cells.worker ?? '')
394
+ const cpu = cells.cpu == null ? UNKNOWN : `${Math.round(cells.cpu * 100)}%`
395
+ const mem = cells.memBytes == null ? UNKNOWN : formatSize(cells.memBytes)
396
+ const out = cells.outputBytes == null ? UNKNOWN : formatSize(cells.outputBytes)
397
+ const blocks = cells.blocks || UNKNOWN
398
+ const worker = workerName(cells.worker || UNKNOWN)
388
399
  return (
389
- ` ${glyph} ${timeCell(ms, colors)} ${us} ${st} ${ca} ` +
400
+ `${ROW_LEAD}${st} ${ca} ${us} ${timeCell(ms, colors)} ` +
390
401
  `${right(cpu, CPU_COL, colors)} ${right(mem, MEM_COL, colors)} ${right(out, OUT_COL, colors)} ` +
391
- `${pad(BLOCKED_COL - blocks.length)}${paint(blocks === '0' ? 'muted' : 'value', blocks, colors)} ` +
392
- `${placeCell(worker, colors)}${pad(WORKER_COL - worker.length)} ${paintedId}`
402
+ `${pad(BLOCKED_COL - blocks.length)}${paint(blocks === '0' || blocks === UNKNOWN ? 'muted' : 'value', blocks, colors)} ` +
403
+ `${placeCell(worker, colors)}${pad(WORKER_COL - worker.length)} ` +
404
+ `${cells.critical === true ? paint('warn', CRITICAL_MARK, colors) : ' '} ${paintedId}`
393
405
  )
394
406
  }
395
407
 
408
+ /** A cell with nothing in it: unknown, or not applying to the row (a hit ran nothing). */
409
+ const UNKNOWN = '\u2014'
410
+
396
411
  /**
397
- * A cell vx cannot know: a peak no look saw, a task history never timed.
398
- * Blank is a cell that does not apply (a hit ran nothing).
412
+ * The critical path's mark, before the task's name: the row's task held the
413
+ * run's end while others ran beside it. One glyph, yellow (2026-11-06).
399
414
  */
400
- const UNKNOWN = '\u2014'
415
+ const CRITICAL_MARK = '\u25c6'
416
+
417
+ /** A grid row's lead: the gutter, so a row starts where a field's value does. */
418
+ const ROW_LEAD = ' '.repeat(GUTTER)
401
419
 
402
420
  /** A row's cells past time, status and cache; each blank when absent, `—` when null. */
403
421
  export interface RowCells {
@@ -408,11 +426,13 @@ export interface RowCells {
408
426
  /** Resident memory now (live) or at its peak (after). */
409
427
  memBytes?: number | null
410
428
  /** The bytes of the task's outputs: what its save stored, or its hit restored. */
411
- outputBytes?: number
429
+ outputBytes?: number | null
412
430
  /** How many tasks are downstream. */
413
431
  blocks?: string
414
432
  /** The machine it runs or ran on: `local`, or the executor's name for it. */
415
433
  worker?: string
434
+ /** On the critical path: `◆` before the task's name. */
435
+ critical?: boolean
416
436
  }
417
437
 
418
438
  function right(text: string, width: number, colors: ColorSupport): string {
@@ -435,7 +455,7 @@ export function formatSize(bytes: number): string {
435
455
  const placeCells = new WeakMap<ColorSupport, Map<string, string>>()
436
456
 
437
457
  function placeCell(worker: string, colors: ColorSupport): string {
438
- if (worker === '') return ''
458
+ if (worker === UNKNOWN) return paint('muted', UNKNOWN, colors)
439
459
  let byName = placeCells.get(colors)
440
460
  if (byName === undefined) placeCells.set(colors, (byName = new Map()))
441
461
  let painted = byName.get(worker)
@@ -497,10 +517,10 @@ export function formatRunningHeader(colors: ColorSupport = NO_COLOR): string {
497
517
 
498
518
  function header(usual: string, colors: ColorSupport): string {
499
519
  const head =
500
- ` ${'time'.padStart(TIME_COL)} ${usual.padStart(USUAL_COL)} ` +
501
- `${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${'cpu'.padStart(CPU_COL)} ` +
502
- `${'mem'.padStart(MEM_COL)} ${'outputs'.padStart(OUT_COL)} ${'blocks'.padStart(BLOCKED_COL)} ${'worker'.padEnd(WORKER_COL)} task`
503
- return paint('label', head, colors)
520
+ `${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${usual.padStart(USUAL_COL)} ` +
521
+ `${'time'.padStart(TIME_COL)} ${'cpu'.padStart(CPU_COL)} ` +
522
+ `${'mem'.padStart(MEM_COL)} ${'outputs'.padStart(OUT_COL)} ${'blocks'.padStart(BLOCKED_COL)} ${'worker'.padEnd(WORKER_COL)} task`
523
+ return paint('muted', head, colors)
504
524
  }
505
525
 
506
526
  /**
@@ -508,7 +528,7 @@ function header(usual: string, colors: ColorSupport): string {
508
528
  * body, and `vx last`'s, so a replay reads as the run did.
509
529
  */
510
530
  export function taskList(rows: readonly ((colors: ColorSupport) => string)[]): ui.Block {
511
- return { lines: (c) => [formatListHeader(c), ...rows.map((row) => row(c))] }
531
+ return { head: formatListHeader, lines: (c) => rows.map((row) => row(c)) }
512
532
  }
513
533
 
514
534
  /** What history keeps of a task's row: `vx last` draws it again from this. */
@@ -517,6 +537,8 @@ export interface RecordedRow {
517
537
  task: string
518
538
  status: string
519
539
  durationMs: number
540
+ /** Null on a cached task's failure: its hit's restore failed. */
541
+ cacheHit: boolean | null
520
542
  restored: boolean | null
521
543
  cached: boolean | null
522
544
  cpuMs: number | null
@@ -547,6 +569,9 @@ export function formatRecordedRow(
547
569
  node,
548
570
  status: r.status,
549
571
  durationMs: r.durationMs,
572
+ ...(r.status === 'failed' && r.cached === true && r.cacheHit === null
573
+ ? { restoreFailed: true }
574
+ : {}),
550
575
  ...(r.restored !== null ? { restored: r.restored } : {}),
551
576
  ...(r.cpuMs !== null ? { cpuMs: r.cpuMs } : {}),
552
577
  ...(r.peakRssBytes !== null ? { peakRssBytes: r.peakRssBytes } : {}),
@@ -582,14 +607,13 @@ export function formatExpected(ms: number): string {
582
607
  /** A task `--dry` lists: what the run would do with it, on the running list's grid. */
583
608
  export interface PlannedRow {
584
609
  node: TaskNode
585
- glyph: string
586
610
  status: string
587
- /** The glyph's and the status word's hue. */
611
+ /** The status word's hue. */
588
612
  tone: Style
589
613
  cache: string
590
614
  cacheTone: Style | ''
591
- /** Its usual time, from history. */
592
- usualMs?: number
615
+ /** Its usual time, from history; null for a task that runs and history never timed. */
616
+ usualMs?: number | null
593
617
  /** What a remote hit would download. */
594
618
  outputBytes?: number
595
619
  /** How many tasks are downstream of it. */
@@ -602,12 +626,16 @@ export interface PlannedRow {
602
626
  /** `--dry`'s list: the running list's header and grid, so a plan reads as the run would. */
603
627
  export function planList(rows: readonly PlannedRow[]): ui.Block {
604
628
  return {
605
- lines: (c) => [
606
- formatRunningHeader(c),
607
- ...rows.map((r) => {
608
- const usual = r.usualMs === undefined ? undefined : formatExpected(r.usualMs)
629
+ head: formatRunningHeader,
630
+ lines: (c) =>
631
+ rows.map((r) => {
632
+ const usual =
633
+ r.usualMs === undefined
634
+ ? undefined
635
+ : r.usualMs === null
636
+ ? UNKNOWN
637
+ : formatExpected(r.usualMs)
609
638
  const row = formatTaskRow(
610
- paint(r.tone, r.glyph, c),
611
639
  null,
612
640
  r.status,
613
641
  r.tone,
@@ -626,7 +654,6 @@ export function planList(rows: readonly PlannedRow[]): ui.Block {
626
654
  )
627
655
  return r.note === '' ? row : `${row} ${paint('muted', r.note, c)}`
628
656
  }),
629
- ],
630
657
  }
631
658
  }
632
659
 
@@ -638,9 +665,14 @@ export function formatTaskHitLine(
638
665
  onPath = false,
639
666
  blocks?: number,
640
667
  ): string {
641
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, {
642
- ...ranCells(o, blocks),
643
- })
668
+ return formatOutcomeRow(
669
+ o,
670
+ paintTaskId(node, colors),
671
+ o.durationMs,
672
+ colors,
673
+ onPath,
674
+ ranCells(o, blocks),
675
+ )
644
676
  }
645
677
 
646
678
  /**
@@ -667,13 +699,10 @@ export function formatTaskExecutedLine(
667
699
  blocks?: number,
668
700
  usualMs?: number | null,
669
701
  ): string {
670
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, {
671
- ...executedCells(o, blocks),
672
- // A hit's time is a restore's, not the task's: nothing to compare.
673
- ...(o.status === 'success' || o.status === 'failed'
674
- ? { usual: usualCell(o.durationMs, usualMs, colors) }
675
- : {}),
676
- })
702
+ const cells = executedCells(o, blocks)
703
+ // A hit's time is a restore's, not the task's: nothing to compare.
704
+ if (ranCommand(o)) cells.usual = usualCell(o.durationMs, usualMs, colors)
705
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, cells)
677
706
  }
678
707
 
679
708
  /**
@@ -682,26 +711,28 @@ export function formatTaskExecutedLine(
682
711
  */
683
712
  export function executedCells(o: TaskOutcome, blocks: number | undefined): RowCells {
684
713
  const cores = coresOf(o)
685
- const ran = o.status === 'success' || o.status === 'failed'
686
- return {
687
- ...ranCells(o, blocks),
688
- ...(cores !== undefined ? { cpu: cores } : ran ? { cpu: null } : {}),
689
- ...(o.peakRssBytes !== undefined
690
- ? { memBytes: o.peakRssBytes }
691
- : ran
692
- ? { memBytes: null }
693
- : {}),
694
- }
714
+ const ran = ranCommand(o)
715
+ const cells = ranCells(o, blocks)
716
+ if (cores !== undefined) cells.cpu = cores
717
+ else if (ran) cells.cpu = null
718
+ if (o.peakRssBytes !== undefined) cells.memBytes = o.peakRssBytes
719
+ else if (ran) cells.memBytes = null
720
+ return cells
695
721
  }
696
722
 
697
- /** The cells every row of a task that reached a machine carries. */
723
+ /**
724
+ * The cells every row of a task that reached a machine carries. Assigned,
725
+ * not spread: a warm run builds one per hit, and the spreads, copied again
726
+ * by the caller, were ~4 ms of 8,002 hits' rows (2026-11-07).
727
+ */
698
728
  function ranCells(o: TaskOutcome, blocks: number | undefined): RowCells {
699
- return {
700
- // A skip reached no machine.
701
- ...(o.status === 'skipped' ? {} : { worker: o.where ?? 'local' }),
702
- ...(blocks !== undefined ? { blocks: String(blocks) } : {}),
703
- ...(o.outputBytes !== undefined ? { outputBytes: o.outputBytes } : {}),
704
- }
729
+ const cells: RowCells = {}
730
+ // A skip reached no machine.
731
+ if (o.status !== 'skipped') cells.worker = o.where ?? 'local'
732
+ if (blocks !== undefined) cells.blocks = String(blocks)
733
+ // A row with no outputs size says so (`—`); blank read as a column left out.
734
+ if (o.status !== 'skipped') cells.outputBytes = o.outputBytes ?? null
735
+ return cells
705
736
  }
706
737
 
707
738
  /** Shared: render any outcome on the grid. */
@@ -717,7 +748,6 @@ function formatOutcomeRow(
717
748
  const ca = cacheOf(o)
718
749
  return (
719
750
  formatTaskRow(
720
- taskGlyph(o, colors),
721
751
  ms,
722
752
  st.word,
723
753
  st.color,
@@ -725,19 +755,11 @@ function formatOutcomeRow(
725
755
  ca.color,
726
756
  paintedId,
727
757
  colors,
728
- cells,
729
- ) +
730
- (onPath ? pathNote(colors) : '') +
731
- flakyNote(o, colors)
758
+ onPath ? { ...cells, critical: true } : cells,
759
+ ) + flakyNote(o, colors)
732
760
  )
733
761
  }
734
762
 
735
- /** `critical path`: the row's task held the run's end while others ran beside it. */
736
- export function pathNote(colors: ColorSupport = NO_COLOR): string {
737
- // A fact worth knowing, not a failure: never red.
738
- return ` ${paint('info', 'critical path', colors)}`
739
- }
740
-
741
763
  /** Where an executor ran the task, when not here, right after its id: `@small#2` (2026-10-21). */
742
764
  function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
743
765
  return o.where === undefined ? '' : placeNote(o.where, colors)
@@ -754,10 +776,14 @@ function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
754
776
  /**
755
777
  * The dim note a flaky task's row and frame carry (`flaky - failed 1×
756
778
  * before`): nothing prints below the footer, so the task's own line is
757
- * where the reader learns it. '' when the run did not prove it flaky.
779
+ * where the reader learns it. Not proved flaky: its attempts, when more
780
+ * than one; else ''.
758
781
  */
759
782
  export function flakyNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
760
- if (o.flaky === undefined) return ''
783
+ // A retry with no key to prove a flake (no `cache: true`) still ran:
784
+ // the row is the only place a live run says so (`vx last` did).
785
+ if (o.flaky === undefined)
786
+ return (o.attempts ?? 1) > 1 ? ` ${paint('muted', `${o.attempts} attempts`, colors)}` : ''
761
787
  const said: string[] = []
762
788
  if (o.status === 'failed' && o.flaky.passes > 0)
763
789
  said.push(`passed ${o.flaky.passes}\u00d7 before`)
@@ -784,7 +810,6 @@ export function formatTaskSkippedLine(
784
810
  const suffix = why === '' ? '' : ` ${paint('muted', `• ${why}`, colors)}`
785
811
  return (
786
812
  formatTaskRow(
787
- paint('warn', '\u2298', colors),
788
813
  null,
789
814
  'skipped',
790
815
  'warn',
@@ -811,29 +836,9 @@ export function formatTaskAbortedLine(
811
836
  const id = paintTaskId(node, colors)
812
837
  const bl: RowCells = blocks === undefined ? {} : { blocks: String(blocks) }
813
838
  if (neverStarted(o)) {
814
- return formatTaskRow(
815
- paint('muted', '\u25cc', colors),
816
- null,
817
- 'not run',
818
- '',
819
- '',
820
- '',
821
- id,
822
- colors,
823
- bl,
824
- )
839
+ return formatTaskRow(null, 'not run', '', '', '', id, colors, bl)
825
840
  }
826
- return formatTaskRow(
827
- paint('aborted', '\u2717', colors),
828
- o.durationMs,
829
- 'aborted',
830
- 'aborted',
831
- '',
832
- '',
833
- id,
834
- colors,
835
- ranCells(o, blocks),
836
- )
841
+ return formatTaskRow(o.durationMs, 'aborted', 'aborted', '', '', id, colors, ranCells(o, blocks))
837
842
  }
838
843
 
839
844
  /**
@@ -889,7 +894,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
889
894
  ...(fix.inputs !== undefined
890
895
  ? [
891
896
  dim(
892
- `and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${printable(p)}'`).join(', ')}]`,
897
+ `and, if the output depends on them, inputs.files: [${fix.inputs.map(jsString).join(', ')}]`,
893
898
  ),
894
899
  ]
895
900
  : []),
@@ -1062,6 +1067,9 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
1062
1067
  }
1063
1068
  }
1064
1069
 
1070
+ /** A fix path as the single-quoted JS literal the config line pastes. */
1071
+ const jsString = (p: string): string => `'${printable(p.replace(/[\\']/g, '\\$&'))}'`
1072
+
1065
1073
  /**
1066
1074
  * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
1067
1075
  * or undefined when every denial was a withheld credential path.
@@ -1070,7 +1078,7 @@ function sandboxFixLine(f: NonNullable<TaskOutcome['sandboxFix']>): string | und
1070
1078
  const fix = Object.entries(f).filter(([k]) => k !== 'withheld' && k !== 'inputs')
1071
1079
  if (fix.length === 0) return undefined
1072
1080
  const parts = fix.map(
1073
- ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${printable(p)}'`).join(', ')}]`,
1081
+ ([k, list]: [string, string[]]) => `${k}: [${list.map(jsString).join(', ')}]`,
1074
1082
  )
1075
1083
  return `exec.sandbox.allow: { ${parts.join(', ')} }`
1076
1084
  }
@@ -33,6 +33,7 @@ export {
33
33
  export {
34
34
  formatPersistentList,
35
35
  formatRecordedRow,
36
+ formatSize,
36
37
  planList,
37
38
  type PlannedRow,
38
39
  taskList,
@@ -40,4 +41,4 @@ export {
40
41
  export { createForecast } from './forecast.js'
41
42
  export { plainOutput } from './plain-output.js'
42
43
  export type { LiveUsage } from './status-line.js'
43
- export { formatOutcomeTable, formatRunSummary } from './summary.js'
44
+ export { formatOutcomeTable, formatRunSummary, neverStarted } from './summary.js'