@vzn/vx 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  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 +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -12,7 +12,6 @@ import {
12
12
  executedCells,
13
13
  formatTaskExecutedLine,
14
14
  flakyNote,
15
- pathNote,
16
15
  formatTaskHashLine,
17
16
  formatTaskHitLine,
18
17
  formatTaskSkippedLine,
@@ -32,12 +31,14 @@ import {
32
31
  type RunContext,
33
32
  type TaskBucket,
34
33
  } from './summary.js'
35
- import { isGroupTask } from '../graph/index.js'
34
+ import { isGroupTask, isKeyFailed } from '../graph/index.js'
36
35
  import {
37
36
  appendTail,
38
37
  createTail,
39
38
  formatElapsed,
39
+ maskStreams,
40
40
  resetTail,
41
+ secretMask,
41
42
  tailText,
42
43
  type Tail,
43
44
  envFlagOn,
@@ -58,7 +59,7 @@ import {
58
59
  type StatusStream,
59
60
  } from '../ui/index.js'
60
61
  import { isCacheHit } from '../graph/index.js'
61
- import { failedLabel, outcomeWord, ranNoCache } from './events.js'
62
+ import { outcomeLabel, outcomeWord, ranNoCache } from './events.js'
62
63
  import type { BlockedTask, Forecast } from './forecast.js'
63
64
 
64
65
  export interface Logger {
@@ -204,10 +205,17 @@ function ghaProperty(value: string): string {
204
205
  * one could simply be printed by the task — so it is a per-run UUID.
205
206
  */
206
207
  function ghaFence(body: string, token: string): string {
207
- return `::stop-commands::${token}\n${body}::${token}::\n`
208
+ const [open, close] = ghaFenceEnds(token)
209
+ return `${open}${body}${close}`
208
210
  }
209
211
 
210
- const cacheWordOf = (o: TaskOutcome): 'miss' | 'no-cache' => (ranNoCache(o) ? 'no-cache' : 'miss')
212
+ /** The lines `ghaFence` puts before and after the text. */
213
+ function ghaFenceEnds(token: string): [open: string, close: string] {
214
+ return [`::stop-commands::${token}\n`, `::${token}::\n`]
215
+ }
216
+
217
+ const cacheWordOf = (o: TaskOutcome): 'miss' | 'no-cache' | '' =>
218
+ o.restoreFailed === true || isKeyFailed(o) ? '' : ranNoCache(o) ? 'no-cache' : 'miss'
211
219
 
212
220
  export function resolveOutputView(
213
221
  options: {
@@ -239,6 +247,9 @@ const NOTIFY_AFTER_MS = 10_000
239
247
  // A step of vx's own quicker than this shows nowhere (2026-10-15).
240
248
  const STEP_SHOWN_MS = 150
241
249
 
250
+ /** A frame this long is written in pieces, never copied into one string. */
251
+ const PIECEWISE_CHARS = 1024 * 1024
252
+
242
253
  export function defaultLogger(
243
254
  colors: ColorSupport = detectColors(),
244
255
  view: OutputView = { mode: 'full' },
@@ -373,15 +384,19 @@ export function defaultLogger(
373
384
  let slots: (WorkerSlot | null)[] = []
374
385
  const slotQueue: WorkerSlot[] = []
375
386
  let succeeded = 0
376
- // Executed with no `cache` block: never a miss, counted apart.
387
+ // Executed without `cache: true`: never a miss, counted apart.
377
388
  let noCache = 0
378
389
  let upToDate = 0
379
390
  let restoredLocal = 0
380
391
  let restoredRemote = 0
392
+ // Hits whose restore failed: failed, and in no cache count.
393
+ let restoreFailed = 0
394
+ // A task with no key asked no cache: not a miss.
395
+ let keyFailed = 0
381
396
  let saved = 0
382
397
  let notSaved = 0
383
398
  let skippedCount = 0
384
- // Each finished task's cache bucket by its key: its remote traffic moves it.
399
+ // Each finished task's cache bucket by its key, with a remote: its traffic moves it.
385
400
  const buckets = new Map<string, TaskBucket>()
386
401
  // The graph is done and vx's own closing steps run: the region stays,
387
402
  // footer and step rows, until the footer replaces it (2026-10-15).
@@ -405,7 +420,7 @@ export function defaultLogger(
405
420
  // The pinned zone above the worker rows: persistent tasks pin at
406
421
  // ready (their outcome lands while the child keeps running) and live
407
422
  // until runEnd kills the region. A failure is not pinned: it is a
408
- // permanent ◼ row the moment it happens, and its full frame is
423
+ // permanent failed row the moment it happens, and its full frame is
409
424
  // deferred to runEnd (owner: '✗ line, continue, all full frames at
410
425
  // the end'). 'full'/CI keeps frames inline.
411
426
  const deferredFailures: string[] = []
@@ -566,8 +581,9 @@ export function defaultLogger(
566
581
  restoredRemote,
567
582
  saved,
568
583
  notSaved,
569
- miss: succeeded + failed - noCache,
584
+ miss: succeeded + failed - noCache - restoreFailed - keyFailed,
570
585
  noCache,
586
+ ...(restoreFailed > 0 ? { restoreFailed } : {}),
571
587
  left: total - done - stopped - notRun,
572
588
  ...(running > 0 ? { running } : {}),
573
589
  ...(scheduled > 0 ? { scheduled } : {}),
@@ -676,8 +692,11 @@ export function defaultLogger(
676
692
  if (chunk.length > 0) endsBlank = chunk.endsWith('\n\n')
677
693
  if (!logOpened && chunk.length > 0) {
678
694
  logOpened = true
679
- writer.write(`\n${chunk}`)
680
- return
695
+ if (chunk.length < PIECEWISE_CHARS) {
696
+ writer.write(`\n${chunk}`)
697
+ return
698
+ }
699
+ writer.write('\n')
681
700
  }
682
701
  writer.write(chunk)
683
702
  }
@@ -706,8 +725,7 @@ export function defaultLogger(
706
725
  if (panelOpen === 'Warnings') closePanel()
707
726
  inPanel = true
708
727
  if (panelOpen !== 'Tasks') {
709
- emitLine(ui.panelTop('Tasks', colors))
710
- emitLine(formatListHeader(colors))
728
+ emitLine(ui.panelTop('Tasks', colors, undefined, undefined, formatListHeader(colors)))
711
729
  panelOpen = 'Tasks'
712
730
  }
713
731
  emitLine(line)
@@ -715,11 +733,20 @@ export function defaultLogger(
715
733
  }
716
734
  // formatTaskBlock returns '' for group tasks (no exec) — skip the
717
735
  // write so a stray newline doesn't sneak into the output.
718
- const emitBlock = (block: string): void => {
736
+ // `head` and `tail` wrap the block. A big one is written in pieces:
737
+ // joined into one string, a frame of a 100 MB log was copied whole again
738
+ // (200 MB, two bytes a character once the frame's corners are in it).
739
+ const emitBlock = (block: string, head = '', tail = ''): void => {
719
740
  if (block.length === 0) return
720
741
  endStreamLine()
721
742
  const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
722
- taskWrite(`${lead}${block}\n`)
743
+ if (block.length < PIECEWISE_CHARS) taskWrite(`${lead}${head}${block}${tail}\n`)
744
+ else {
745
+ logWrite(lead + head)
746
+ logWrite(block)
747
+ logWrite(`${tail}\n`)
748
+ endsBlank = (tail || block).endsWith('\n')
749
+ }
723
750
  lineEmitted = false
724
751
  streamedSinceBlock = false
725
752
  }
@@ -760,10 +787,12 @@ export function defaultLogger(
760
787
  const failureRow = (node: TaskNode, outcome: TaskOutcome): string =>
761
788
  formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(outcome), {
762
789
  ...executedCells(outcome, blocksOf(node.id)),
763
- usual: usualCell(outcome.durationMs, usualMsOf(node.id), colors),
764
- }) +
765
- (onPath.has(node.id) ? pathNote(colors) : '') +
766
- flakyNote(outcome, colors)
790
+ // A failed restore ran no command: nothing to compare.
791
+ ...(outcome.restoreFailed === true
792
+ ? {}
793
+ : { usual: usualCell(outcome.durationMs, usualMsOf(node.id), colors) }),
794
+ ...(onPath.has(node.id) ? { critical: true } : {}),
795
+ }) + flakyNote(outcome, colors)
767
796
 
768
797
  return {
769
798
  showsSteps: writer.enabled,
@@ -780,8 +809,13 @@ export function defaultLogger(
780
809
  // summary row or "exited with code" can follow its partial line.
781
810
  endStreamLine()
782
811
  closePanel()
783
- if (line === '' && endsBlank) return
784
- endsBlank = line === ''
812
+ // A block's own trailing blank stands for the first blank status line; a second one is meant
813
+ // (two before the Summary).
814
+ if (line === '' && endsBlank) {
815
+ endsBlank = false
816
+ return
817
+ }
818
+ endsBlank = false
785
819
  writer.write(`${line}\n`)
786
820
  },
787
821
  warn(line) {
@@ -957,7 +991,7 @@ export function defaultLogger(
957
991
  }
958
992
  }
959
993
  // Failures end the log: every deferred frame replays here, right
960
- // above the summary — the ◼ rows marked them in the stream,
994
+ // above the summary — the failed rows marked them in the stream,
961
995
  // the full diagnostics read last where eyes land.
962
996
  // Guarded for repeat runEnd calls.
963
997
  if (!flushedFailures && deferredFailures.length > 0) {
@@ -1008,12 +1042,23 @@ export function defaultLogger(
1008
1042
  const preReady = persistentTails.get(node.id)
1009
1043
  // In the order the task wrote it; a bounded tail kept its streams
1010
1044
  // apart, so its stdout reads first.
1011
- const output: OutputChunk[] = preReady
1045
+ let output: OutputChunk[] = preReady
1012
1046
  ? [
1013
1047
  { text: tailText(preReady.out), err: false },
1014
1048
  { text: tailText(preReady.err), err: true },
1015
1049
  ]
1016
1050
  : takeChunks(node.id)
1051
+ // The live mask lets a held piece go after a pause, so a value the
1052
+ // task wrote in two pieces arrived as two clean chunks. The frame
1053
+ // prints now with the whole text in hand: each stream is masked as
1054
+ // a whole, as the stored log is. A hit's replay already was.
1055
+ if (!isCacheHit(outcome.status) && output.some((c) => c.text.length > 0)) {
1056
+ const mask = secretMask(
1057
+ [process.env, node.config.exec?.env?.define],
1058
+ node.config.exec?.env?.secret,
1059
+ )
1060
+ if (mask !== null) output = maskStreams(output, mask)
1061
+ }
1017
1062
  const dropped =
1018
1063
  preReady && (preReady.out.dropped > 0 || preReady.err.dropped > 0)
1019
1064
  ? { droppedStdout: preReady.out.dropped, droppedStderr: preReady.err.dropped }
@@ -1041,9 +1086,13 @@ export function defaultLogger(
1041
1086
  // the terminal where a frame would have: a task that traps Ctrl-C
1042
1087
  // and exits 0 is aborted since item 962, and its frame was dropped
1043
1088
  // with the count. A task that never started opened no frame.
1089
+ // A list view prints a failure's output; a killed task's partial
1090
+ // output is what tells where a run hung, so it prints too.
1044
1091
  const framed =
1045
1092
  !neverStarted(outcome) &&
1046
1093
  (view.mode === 'full' ||
1094
+ view.mode === 'broad' ||
1095
+ view.mode === 'errors-only' ||
1047
1096
  (view.mode === 'focused' && isPrimary(node)) ||
1048
1097
  (framesTerminal && holdsTerminal(node)))
1049
1098
  if (framed && streamsLive(node)) {
@@ -1051,6 +1100,23 @@ export function defaultLogger(
1051
1100
  return
1052
1101
  }
1053
1102
  if (framed && output.some((c) => c.text.length > 0)) {
1103
+ // A list view treats it as a failure: the row now, the frame
1104
+ // with the deferred failures above the summary.
1105
+ if (view.mode === 'broad' || view.mode === 'errors-only') {
1106
+ emitRow(formatTaskAbortedLine(node, outcome, colors, blocksOf(node.id)))
1107
+ deferredFailures.push(
1108
+ formatTaskBlock(
1109
+ node,
1110
+ outcome,
1111
+ { output, ...dropped },
1112
+ colors,
1113
+ true,
1114
+ forwardArgs,
1115
+ links,
1116
+ ),
1117
+ )
1118
+ return
1119
+ }
1054
1120
  emitBlock(
1055
1121
  fenced(
1056
1122
  formatTaskBlock(
@@ -1078,7 +1144,9 @@ export function defaultLogger(
1078
1144
  done++
1079
1145
  if (outcome.status === 'failed') {
1080
1146
  failed++
1081
- if (ranNoCache(outcome)) noCache++
1147
+ if (outcome.restoreFailed === true) restoreFailed++
1148
+ else if (isKeyFailed(outcome)) keyFailed++
1149
+ else if (ranNoCache(outcome)) noCache++
1082
1150
  } else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
1083
1151
  // A persistent task's outcome arrives at READY; the child
1084
1152
  // keeps running until the orchestrator SIGTERMs it at run
@@ -1101,7 +1169,9 @@ export function defaultLogger(
1101
1169
  if (qi >= 0) slotQueue.splice(qi, 1)
1102
1170
  }
1103
1171
  }
1104
- const bucket = cacheBucketOf(outcome)
1172
+ // Only a remote's traffic moves a bucket: without one, an entry per
1173
+ // task was held for the run and never read.
1174
+ const bucket = runContext?.cache?.remote === true ? cacheBucketOf(outcome) : undefined
1105
1175
  if (bucket !== undefined) {
1106
1176
  const entry = { task: node.id, bucket }
1107
1177
  if (outcome.hash !== undefined) buckets.set(outcome.hash, entry)
@@ -1126,7 +1196,10 @@ export function defaultLogger(
1126
1196
  skippedCount++
1127
1197
  break
1128
1198
  }
1129
- if (outcome.status === 'success' || outcome.status === 'failed') {
1199
+ if (
1200
+ outcome.status === 'success' ||
1201
+ (outcome.status === 'failed' && outcome.restoreFailed !== true)
1202
+ ) {
1130
1203
  spreadMax = Math.max(spreadMax, outcome.durationMs)
1131
1204
  spreadMin = Math.min(spreadMin, outcome.durationMs)
1132
1205
  spreadSum += outcome.durationMs
@@ -1175,7 +1248,7 @@ export function defaultLogger(
1175
1248
  // their replay buffers are deliberately dropped; the counts
1176
1249
  // surface in the end-of-run summary.
1177
1250
  if (outcome.status === 'failed') {
1178
- // ◼ row now; the full frame replays at runEnd.
1251
+ // failed row now; the full frame replays at runEnd.
1179
1252
  emitRow(failureRow(node, outcome))
1180
1253
  deferredFailures.push(
1181
1254
  formatTaskBlock(
@@ -1258,7 +1331,7 @@ export function defaultLogger(
1258
1331
  return
1259
1332
  }
1260
1333
  // Dependency-pulled nodes: silent on success; a skip gets its
1261
- // row, failures the ◼ row now and their frame replayed at runEnd.
1334
+ // row, failures the failed row now and their frame replayed at runEnd.
1262
1335
  if (outcome.status === 'skipped') {
1263
1336
  emitRow(formatTaskSkippedLine(node, colors, outcome, blocksOf(node.id)))
1264
1337
  } else if (outcome.status === 'failed') {
@@ -1307,20 +1380,24 @@ export function defaultLogger(
1307
1380
  // The block is the task's own output — fenced, so a task that
1308
1381
  // prints `::endgroup::` can't close vx's group early and an
1309
1382
  // `::error` line in its output can't become a real annotation.
1310
- const body = ghaFence(block, ghaToken)
1383
+ const [open, close] = ghaFenceEnds(ghaToken)
1311
1384
  // Failed tasks stay pre-expanded in the Actions viewer:
1312
1385
  // an ::error annotation instead of a collapsed group.
1313
1386
  if (outcome.status === 'failed') {
1314
1387
  // The message is ours (a literal, an integer and a signal
1315
1388
  // name); only the title carries user input.
1316
1389
  emitBlock(
1317
- `::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady, outcome.inputMoved)}\n${body}`,
1390
+ block,
1391
+ `::error title=${ghaProperty(node.id)}::${outcomeLabel(outcome)}\n${open}`,
1392
+ close,
1318
1393
  )
1319
1394
  } else {
1320
1395
  emitBlock(
1396
+ block,
1321
1397
  `::group::${ghaData(
1322
1398
  `${node.id} (${outcomeWord(outcome)} ${formatElapsed(outcome.durationMs)})`,
1323
- )}\n${body}::endgroup::\n`,
1399
+ )}\n${open}`,
1400
+ `${close}::endgroup::\n`,
1324
1401
  )
1325
1402
  }
1326
1403
  return
@@ -1,12 +1,16 @@
1
1
  import type { TaskNode } from '../graph/index.js'
2
+ import { stripEscapes } from '../util/index.js'
2
3
  import type { Logger } from './logger.js'
3
4
 
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.
5
+ // A prefix that may yet complete as a CSI, string (OSC, DCS, SOS, PM, APC)
6
+ // or charset sequence (`tput sgr0` writes `\x1b(B`). An escape cut at a
7
+ // chunk's end is held for the next chunk: stripped alone, its remainder
8
+ // (`1m`, `B`) reached the log as text. A string escape ends at its line
9
+ // (stripEscapes), so it is held only within one, and only up to HOLD_MAX:
10
+ // an unbounded hold joined every later chunk and lost them at the task's end.
8
11
  // eslint-disable-next-line no-control-regex -- ESC is the point
9
- const PARTIAL_ESCAPE = /^\x1b(?:\[[0-?]*[ -/]*|\][^\x07\x1b]*\x1b?|[ -/]+)?$/
12
+ const PARTIAL_ESCAPE = /^\x1b(?:\[[0-?]*[ -/]*|[\]P_^X][^\x07\x1b\n]*\x1b?|[ -/]+)?$/
13
+ const HOLD_MAX = 4096
10
14
 
11
15
  /**
12
16
  * `sink` with terminal escapes stripped from task output. Tasks run with
@@ -21,17 +25,17 @@ export function plainOutput(sink: Logger): Logger {
21
25
  if (prior !== undefined) held.delete(key)
22
26
  const esc = text.lastIndexOf('\x1b')
23
27
  if (esc === -1) return text
24
- if (PARTIAL_ESCAPE.test(text.slice(esc))) {
28
+ if (text.length - esc <= HOLD_MAX && PARTIAL_ESCAPE.test(text.slice(esc))) {
25
29
  held.set(key, text.slice(esc))
26
- return Bun.stripANSI(text.slice(0, esc))
30
+ return stripEscapes(text.slice(0, esc))
27
31
  }
28
- return Bun.stripANSI(text)
32
+ return stripEscapes(text)
29
33
  }
30
34
  const flush = (node: TaskNode, key: string, write: (n: TaskNode, c: string) => void): void => {
31
35
  const rest = held.get(key)
32
36
  if (rest === undefined) return
33
37
  held.delete(key)
34
- const text = Bun.stripANSI(rest)
38
+ const text = stripEscapes(rest)
35
39
  if (text !== '') write(node, text)
36
40
  }
37
41
  // Delegated one by one, never spread: an embedder's logger may be a class
@@ -57,6 +61,8 @@ export function plainOutput(sink: Logger): Logger {
57
61
  if (text !== '' || chunk === '') sink.taskStderr(node, text)
58
62
  },
59
63
  taskComplete(node, outcome) {
64
+ // Nearly every task ends with no escape held: its keys go unbuilt.
65
+ if (held.size === 0) return sink.taskComplete(node, outcome)
60
66
  flush(node, `${node.id}\0out`, (n, c) => sink.taskStdout(n, c))
61
67
  flush(node, `${node.id}\0err`, (n, c) => sink.taskStderr(n, c))
62
68
  sink.taskComplete(node, outcome)
@@ -1,13 +1,11 @@
1
1
  // The live region's panels: Warnings, Running and the live Summary,
2
2
  // redrawn in place under the log by the ui module's writer.
3
3
 
4
- import { paint, panelTop, type ColorSupport } from '../ui/index.js'
4
+ import { divider, paint, type ColorSupport } from '../ui/index.js'
5
5
  import {
6
6
  formatExpected,
7
- formatRunningHeader,
8
7
  formatTaskRow,
9
8
  paintIdParts,
10
- pathNote,
11
9
  type RowCells,
12
10
  unknownUsual,
13
11
  } from './framed-output.js'
@@ -93,18 +91,18 @@ function paintPinnedId(id: string, colors: ColorSupport): string {
93
91
  /**
94
92
  * Permanent one-liner logged the moment a task fails (owner design:
95
93
  * "log the failure and continue, full frames at the end"). On the grid:
96
- * red ◼ glyph + exec time + `failed` + `miss` + id. The exit code and
94
+ * `failed` in red + exec time + `miss` + id. The exit code and
97
95
  * output live in the framed block that replays at runEnd, not the line.
96
+ * A hit whose restore failed has `''`, shown `—`: neither a miss nor a hit.
98
97
  */
99
98
  export function formatFailureLine(
100
99
  id: string,
101
100
  durationMs: number | null,
102
101
  colors: ColorSupport = NO_COLOR,
103
- cacheWord: 'miss' | 'no-cache' = 'miss',
102
+ cacheWord: 'miss' | 'no-cache' | '' = 'miss',
104
103
  cells: RowCells = {},
105
104
  ): string {
106
105
  return formatTaskRow(
107
- paint('error', '\u25fc\uFE0E', colors),
108
106
  durationMs,
109
107
  'failed',
110
108
  'error',
@@ -131,7 +129,6 @@ export function formatStepLine(
131
129
  colors: ColorSupport = NO_COLOR,
132
130
  ): string {
133
131
  return formatTaskRow(
134
- ok ? paint('ok', '\u23fa\uFE0E', colors) : paint('error', '\u25fc\uFE0E', colors),
135
132
  durationMs,
136
133
  ok ? 'success' : 'failed',
137
134
  ok ? 'ok' : 'error',
@@ -144,9 +141,6 @@ export function formatStepLine(
144
141
  }
145
142
 
146
143
  /** `rows` in a panel titled `title`, counted. */
147
- function panel(title: string, count: string, rows: readonly string[], c: ColorSupport): string[] {
148
- return [panelTop(title, c, count), ...rows]
149
- }
150
144
 
151
145
  /**
152
146
  * Worker region: pinned ready persistent tasks, one row per worker
@@ -171,26 +165,18 @@ export function formatStatusRegion(
171
165
  }
172
166
  // The Running panel's rows; its box and header go round them below.
173
167
  const lines: string[] = []
174
- // Persistent (dev-server) rows: ▸ glyph, no elapsed, `running`, no
168
+ // Persistent (dev-server) rows: `running`, no elapsed, no
175
169
  // cache state (it's still alive).
176
170
  for (const id of s.pinnedPersistent) {
177
171
  lines.push(
178
- formatTaskRow(
179
- paint('info', '▸', colors),
180
- null,
181
- 'running',
182
- 'info',
183
- '',
184
- '',
185
- paintPinnedId(id, colors),
186
- colors,
187
- { worker: 'local' },
188
- ),
172
+ formatTaskRow(null, 'running', 'warn', '', '', paintPinnedId(id, colors), colors, {
173
+ worker: 'local',
174
+ }),
189
175
  )
190
176
  }
191
177
 
192
- // Running rows: NO glyph — the live elapsed time leads (no spinner;
193
- // the ticking time IS the motion), then `running`, the usual duration,
178
+ // Running rows: `running` in yellow, then the live elapsed time (no
179
+ // spinner; the ticking time IS the motion), the usual duration,
194
180
  // how many tasks wait on it, and the full id (never truncated — name is
195
181
  // the last column). The rows no task runs in hold the tasks planned
196
182
  // next, each nested under what it waits on. No blank rows pad the list
@@ -218,7 +204,6 @@ export function formatStatusRegion(
218
204
  if (s.stopping === true) {
219
205
  lines.push(
220
206
  formatTaskRow(
221
- ' ',
222
207
  Math.max(0, s.nowMs - slot.startedMs),
223
208
  'aborting',
224
209
  'warn',
@@ -233,28 +218,26 @@ export function formatStatusRegion(
233
218
  }
234
219
  const at = s.where?.(slot.id)
235
220
  const use = s.usage?.(slot.id)
236
- lines.push(
237
- formatTaskRow(
238
- ' ',
239
- Math.max(0, s.nowMs - slot.startedMs),
240
- 'running',
241
- 'info',
242
- slot.cache ?? 'miss',
243
- '',
244
- paintPinnedId(slot.id, colors),
245
- colors,
246
- {
247
- usual: usualOf(slot.id),
248
- ...(use !== undefined ? { cpu: use.cores, memBytes: use.rssBytes } : {}),
249
- blocks: String(s.blockedBy?.get(slot.id) ?? 0),
250
- worker: at ?? 'local',
251
- },
252
- ) + (s.critical === slot.id ? pathNote(colors) : ''),
221
+ const row = formatTaskRow(
222
+ Math.max(0, s.nowMs - slot.startedMs),
223
+ 'running',
224
+ 'warn',
225
+ slot.cache ?? 'miss',
226
+ '',
227
+ paintPinnedId(slot.id, colors),
228
+ colors,
229
+ {
230
+ usual: usualOf(slot.id),
231
+ ...(use !== undefined ? { cpu: use.cores, memBytes: use.rssBytes } : {}),
232
+ blocks: String(s.blockedBy?.get(slot.id) ?? 0),
233
+ worker: at ?? 'local',
234
+ ...(s.critical === slot.id ? { critical: true } : {}),
235
+ },
253
236
  )
237
+ lines.push(row)
254
238
  for (const b of nested.get(slot.id) ?? []) {
255
239
  lines.push(
256
240
  formatTaskRow(
257
- ' ',
258
241
  null,
259
242
  'blocked',
260
243
  '',
@@ -270,10 +253,9 @@ export function formatStatusRegion(
270
253
  for (const step of s.steps ?? []) {
271
254
  lines.push(
272
255
  formatTaskRow(
273
- ' ',
274
256
  Math.max(0, s.nowMs - step.startedMs),
275
257
  'running',
276
- 'info',
258
+ 'warn',
277
259
  INTERNAL,
278
260
  '',
279
261
  paintPinnedId(step.id, colors),
@@ -285,16 +267,7 @@ export function formatStatusRegion(
285
267
  // Panels top to bottom: what runs now, then the live Summary, the same panel the final printout is, so the region
286
268
  // BECOMES the summary when the run ends.
287
269
  const out: string[] = []
288
- if (lines.length > 0)
289
- out.push(
290
- '',
291
- ...panel(
292
- 'Running',
293
- String(s.pinnedPersistent.length + running.length + (s.steps?.length ?? 0)),
294
- [formatRunningHeader(colors), ...lines],
295
- colors,
296
- ),
297
- )
270
+ if (lines.length > 0) out.push(divider(colors), '', ...lines)
298
271
  out.push(...s.summaryLines)
299
272
  return out
300
273
  }