@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -3,6 +3,7 @@ import { detectColors, type ColorSupport } from './colors.js'
3
3
  import {
4
4
  formatFrameClose,
5
5
  formatFrameOpen,
6
+ formatPersistentTailBlock,
6
7
  formatTaskBlock,
7
8
  formatTaskExecutedLine,
8
9
  formatTaskHitLine,
@@ -17,6 +18,9 @@ import {
17
18
  } from './status-line.js'
18
19
  import { formatDuration, formatSummarySection, type RunContext } from './summary.js'
19
20
  import { isGroupTask } from '../graph/index.js'
21
+ import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
22
+ import { isCacheHit } from './telemetry.js'
23
+ import { outcomeWord } from './events.js'
20
24
 
21
25
  export interface Logger {
22
26
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
@@ -41,6 +45,8 @@ export interface Logger {
41
45
  requestedCount?: number
42
46
  /** Run banner context — so the live region footer matches the final summary. */
43
47
  context?: RunContext
48
+ /** The run's canonical start (epoch ms) — equals the final summary's `startedAt`. */
49
+ startedAtMs?: number
44
50
  }): void
45
51
  taskStart?(node: TaskNode): void
46
52
  /** Run finished (any outcome). Idempotent. */
@@ -56,6 +62,11 @@ export interface Logger {
56
62
  * Today's CI behavior; also the programmatic default.
57
63
  * errors-only — only failed tasks print.
58
64
  * none — no per-task output at all.
65
+ * hash-only — one line per task: outcome word, task id, cache key.
66
+ * No frames, no log replay (Turbo `--output-logs
67
+ * hash-only` parity); the end-of-run summary still
68
+ * renders. The line is the run's audit trail: which key
69
+ * each task resolved to, without any build output.
59
70
  * focused — requested nodes stream raw output live (running the
60
71
  * task should feel like running the command directly);
61
72
  * dependency-pulled nodes are silent unless they fail.
@@ -71,7 +82,7 @@ export interface Logger {
71
82
  * line even if stdout happens to be a TTY.
72
83
  */
73
84
  export interface OutputView {
74
- mode: 'full' | 'errors-only' | 'none' | 'focused' | 'broad'
85
+ mode: 'full' | 'errors-only' | 'none' | 'focused' | 'broad' | 'hash-only'
75
86
  gha?: boolean
76
87
  ci?: boolean
77
88
  }
@@ -81,22 +92,47 @@ function truthyEnv(v: string | undefined): boolean {
81
92
  return v !== undefined && v !== '' && v !== '0' && v !== 'false'
82
93
  }
83
94
 
84
- /** The unified outcome vocabulary word for a non-failed outcome. */
85
- function outcomeWord(o: TaskOutcome): string {
86
- switch (o.status) {
87
- case 'success':
88
- return 'success'
89
- case 'cache-hit':
90
- return o.restored === false ? 'up-to-date' : 'restored-local'
91
- case 'cache-hit-remote':
92
- return o.restored === false ? 'up-to-date' : 'restored-remote'
93
- default:
94
- return o.status
95
- }
95
+ // ── GitHub Actions workflow-command escaping ────────────────────────
96
+ // The runner parses `::`-prefixed LINES out of the log, so anything
97
+ // interpolated into one has to be encoded or it changes the command's shape.
98
+ // Both halves are reachable without malice: a task name is an arbitrary TS
99
+ // object key (the loader accepts newlines — `run-report.ts` already escapes
100
+ // them for the same reason), and a task can legitimately print
101
+ // command-shaped text (a test asserting on annotation formatting, a nested
102
+ // tool emitting its own workflow commands).
103
+ // https://docs.github.com/actions/reference/workflow-commands-for-github-actions
104
+
105
+ /** Message text. `%` first, or we would re-escape our own escapes. */
106
+ function ghaData(value: string): string {
107
+ return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
108
+ }
109
+
110
+ /**
111
+ * A property VALUE additionally reserves `:` and `,` — the delimiters
112
+ * between a command's name, its properties and its message. Without this an
113
+ * id containing `::` truncates the annotation mid-title.
114
+ */
115
+ function ghaProperty(value: string): string {
116
+ return ghaData(value).replace(/:/g, '%3A').replace(/,/g, '%2C')
117
+ }
118
+
119
+ /**
120
+ * Fence untrusted text so the runner stops interpreting workflow commands
121
+ * inside it. `token` must be unguessable by the fenced text itself — a fixed
122
+ * one could simply be printed by the task — so it is a per-run UUID.
123
+ */
124
+ function ghaFence(body: string, token: string): string {
125
+ return `::stop-commands::${token}\n${body}::${token}::\n`
96
126
  }
97
127
 
128
+ const cacheWordOf = (n: TaskNode): 'miss' | 'no-cache' =>
129
+ n.config.cache === undefined ? 'no-cache' : 'miss'
130
+
98
131
  export function resolveOutputView(
99
- options: { outputLogs?: 'full' | 'errors-only' | 'none'; flow?: 'focused' | 'broad' },
132
+ options: {
133
+ outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
134
+ flow?: 'focused' | 'broad'
135
+ },
100
136
  env: Record<string, string | undefined> = process.env,
101
137
  ): OutputView {
102
138
  const ci = truthyEnv(env['CI'])
@@ -136,8 +172,6 @@ export function defaultLogger(
136
172
  // a one-liner or streamed output since the last block does.
137
173
  let lineEmitted = false
138
174
  let streamedSinceBlock = false
139
- // Ids whose output went straight to the terminal (focused mode).
140
- const streamed = new Set<string>()
141
175
  // True while the live stream sits mid-line (chunk without trailing
142
176
  // newline) — the frame close must not glue onto partial output.
143
177
  let streamMidLine = false
@@ -153,6 +187,28 @@ export function defaultLogger(
153
187
  return arr.length === 1 ? arr[0]! : arr.join('')
154
188
  }
155
189
 
190
+ // `none` is the ONE mode whose contract guarantees the output is
191
+ // discarded — "no per-task output at all" (docs/cli.md) — so its chunks
192
+ // are dropped on arrival instead of accumulated and thrown away at
193
+ // `taskComplete`. Buffering output the mode promises never to print made
194
+ // `--output-logs none`, the obvious mitigation for a chatty task, cost
195
+ // exactly as much memory as printing it: 400 MB of task stdout drove the
196
+ // vx process to 852 MiB RSS, unbounded and linear, and a warm CACHE HIT
197
+ // paid it too (the stored stdout is replayed through this same path).
198
+ //
199
+ // Deliberately ONLY the modes that PROMISE never to print task output —
200
+ // `none` and `hash-only`. The boundary is the point: `full`, `broad`,
201
+ // `focused` and `errors-only` all PRINT this output — for `errors-only` a
202
+ // failed task's log is precisely what the user asked to see — and
203
+ // silently truncating someone's build log is a worse failure than the
204
+ // memory it costs. So those modes stay unbounded by decision, not by
205
+ // oversight.
206
+ const discardsOutput = view.mode === 'none' || view.mode === 'hash-only'
207
+
208
+ // One per run, and only in GHA mode: the fence token must be something the
209
+ // fenced task output cannot print.
210
+ const ghaToken = view.gha === true ? crypto.randomUUID() : ''
211
+
156
212
  // All stdout flows through the writer so the status line can never
157
213
  // interleave with content. Inert (pure passthrough) on non-TTY
158
214
  // streams and in CI.
@@ -189,6 +245,8 @@ export function defaultLogger(
189
245
  let slots: (WorkerSlot | null)[] = []
190
246
  const slotQueue: WorkerSlot[] = []
191
247
  let succeeded = 0
248
+ // Executed with no `cache` block: never a miss, counted apart.
249
+ let noCache = 0
192
250
  let upToDate = 0
193
251
  let restoredLocal = 0
194
252
  let restoredRemote = 0
@@ -207,6 +265,28 @@ export function defaultLogger(
207
265
  const deferredFailures: string[] = []
208
266
  const pinnedPersistent: string[] = []
209
267
  let flushedFailures = false
268
+ // A persistent task (dev server, watcher, daemon) is the one task kind
269
+ // nothing bounds: a one-shot command's output ends when it exits, but a
270
+ // server writes for as long as the run lasts. So its chunks route into a
271
+ // bounded tail for its WHOLE life, not the unbounded per-task buffers —
272
+ // registered at `taskStart` and drained at `taskComplete` for the frame,
273
+ // then reset to keep capturing what it writes after its outcome landed
274
+ // (which arrives at READY, while the child keeps running) and flushed as
275
+ // one trailing block at runEnd.
276
+ //
277
+ // Registering only at READY was the defect: a `readyWhen` that never
278
+ // matches never reaches `taskComplete`, so the 64 KiB cap never engaged
279
+ // and its output accumulated in the ordinary per-task buffer instead —
280
+ // ~100 MiB/s, with no `exec.timeout` needed to reach it.
281
+ //
282
+ // `outcome` is absent until the task completes. Registration is
283
+ // unconditional (the bound is a memory invariant, not a display choice);
284
+ // only the flush is view-gated.
285
+ const persistentTails = new Map<
286
+ string,
287
+ { node: TaskNode; outcome?: TaskOutcome; out: Tail; err: Tail }
288
+ >()
289
+ let flushedPersistent = false
210
290
 
211
291
  const refresh = (force: boolean): void => {
212
292
  if (statusDead) return
@@ -221,7 +301,8 @@ export function defaultLogger(
221
301
  upToDate,
222
302
  restoredLocal,
223
303
  restoredRemote,
224
- miss: succeeded + failed,
304
+ miss: succeeded + failed - noCache,
305
+ noCache,
225
306
  left: total - done,
226
307
  spread:
227
308
  spreadCount > 0
@@ -325,6 +406,11 @@ export function defaultLogger(
325
406
  emitLine(formatFrameOpen(node, colors))
326
407
  return
327
408
  }
409
+ // Bound a persistent task's output from its FIRST chunk. Waiting for
410
+ // its outcome would leave a never-ready one uncapped forever.
411
+ if (node.config.exec?.persistent !== undefined && !persistentTails.has(node.id)) {
412
+ persistentTails.set(node.id, { node, out: createTail(), err: createTail() })
413
+ }
328
414
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
329
415
  const free = slots.indexOf(null)
330
416
  if (free >= 0) slots[free] = slot
@@ -333,6 +419,38 @@ export function defaultLogger(
333
419
  },
334
420
  runEnd() {
335
421
  killStatus()
422
+ // Persistent tails first: they are context for whatever ran against
423
+ // the server, so they read above the failures they explain. `none`
424
+ // and `errors-only` state their contracts absolutely (no per-task
425
+ // output / only failed tasks print), so they stay silent — the tail
426
+ // is still captured and still bounded, just never printed. Guarded
427
+ // like the failures below: run() calls runEnd twice on the success
428
+ // path (once before the summary, once in its finally), and a
429
+ // kept-alive child keeps writing between the two.
430
+ if (!flushedPersistent) {
431
+ flushedPersistent = true
432
+ if (view.mode !== 'none' && view.mode !== 'errors-only') {
433
+ for (const t of persistentTails.values()) {
434
+ // No outcome means the task never completed — it is still
435
+ // pre-ready, so there is no "since ready" window to render and
436
+ // its buffered output belongs to the frame it never got.
437
+ if (t.outcome === undefined) continue
438
+ emitBlock(
439
+ formatPersistentTailBlock(
440
+ t.node,
441
+ t.outcome,
442
+ { stdout: tailText(t.out), stderr: tailText(t.err) },
443
+ { stdout: t.out.dropped, stderr: t.err.dropped },
444
+ colors,
445
+ ),
446
+ )
447
+ }
448
+ }
449
+ for (const t of persistentTails.values()) {
450
+ resetTail(t.out)
451
+ resetTail(t.err)
452
+ }
453
+ }
336
454
  // Failures end the log: every deferred frame replays here, right
337
455
  // above the summary — the ✗ one-liners marked them in the
338
456
  // stream, the full diagnostics read last where eyes land.
@@ -343,28 +461,50 @@ export function defaultLogger(
343
461
  }
344
462
  },
345
463
  taskStdout(node, chunk) {
464
+ if (discardsOutput) return
346
465
  if (streamsLive(node)) {
347
- streamed.add(node.id)
348
466
  streamedSinceBlock = true
349
467
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
350
468
  writer.write(chunk)
351
469
  return
352
470
  }
471
+ const tail = persistentTails.get(node.id)
472
+ if (tail !== undefined) {
473
+ appendTail(tail.out, chunk)
474
+ return
475
+ }
353
476
  pushChunk(stdoutBuffers, node.id, chunk)
354
477
  },
355
478
  taskStderr(node, chunk) {
479
+ if (discardsOutput) return
356
480
  if (streamsLive(node)) {
357
- streamed.add(node.id)
358
481
  streamedSinceBlock = true
359
482
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
360
483
  writer.write(chunk)
361
484
  return
362
485
  }
486
+ const tail = persistentTails.get(node.id)
487
+ if (tail !== undefined) {
488
+ appendTail(tail.err, chunk)
489
+ return
490
+ }
363
491
  pushChunk(stderrBuffers, node.id, chunk)
364
492
  },
365
493
  taskComplete(node, outcome) {
366
- const stdout = takeChunks(stdoutBuffers, node.id)
367
- const stderr = takeChunks(stderrBuffers, node.id)
494
+ // A persistent task buffered into its bounded tail rather than the
495
+ // per-task buffers (see taskStart), so its frame is drained from
496
+ // there — carrying how much the cap dropped, since a truncated log
497
+ // that reads as complete is worse than one that says what it lost.
498
+ const preReady = persistentTails.get(node.id)
499
+ const stdout = preReady ? tailText(preReady.out) : takeChunks(stdoutBuffers, node.id)
500
+ const stderr = preReady ? tailText(preReady.err) : takeChunks(stderrBuffers, node.id)
501
+ const dropped =
502
+ preReady && (preReady.out.dropped > 0 || preReady.err.dropped > 0)
503
+ ? { droppedStdout: preReady.out.dropped, droppedStderr: preReady.err.dropped }
504
+ : {}
505
+ // The pre-ready window closed either way: a failed task is over, and
506
+ // a ready one starts a fresh post-ready tail below.
507
+ persistentTails.delete(node.id)
368
508
  // An aborted task (child killed by a shutdown signal) reverts
369
509
  // to pending: free its worker slot, but never count or render it
370
510
  // — the run is tearing down and it has no honest outcome.
@@ -383,11 +523,18 @@ export function defaultLogger(
383
523
  done++
384
524
  if (outcome.status === 'failed') {
385
525
  failed++
526
+ if (node.config.cache === undefined) noCache++
386
527
  } else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
387
528
  // A persistent task's outcome arrives at READY; the child
388
529
  // keeps running until the orchestrator SIGTERMs it at run
389
530
  // end — pin it so its liveness stays visible.
390
531
  pinnedPersistent.push(node.id)
532
+ // …and capture what it writes from here on. A live-streaming
533
+ // node already owns the terminal, so buffering it would emit
534
+ // its output twice.
535
+ if (!streamsLive(node)) {
536
+ persistentTails.set(node.id, { node, outcome, out: createTail(), err: createTail() })
537
+ }
391
538
  }
392
539
  // Free the task's slot; the longest-waiting queued task
393
540
  // (if any) takes it over, keeping lowest-index-first reuse.
@@ -400,6 +547,7 @@ export function defaultLogger(
400
547
  switch (outcome.status) {
401
548
  case 'success':
402
549
  succeeded++
550
+ if (node.config.cache === undefined) noCache++
403
551
  break
404
552
  case 'cache-hit':
405
553
  if (outcome.restored === false) upToDate++
@@ -422,14 +570,24 @@ export function defaultLogger(
422
570
  refresh(true)
423
571
  }
424
572
  if (isGroupTask(node)) return
425
- const isHit = outcome.status === 'cache-hit' || outcome.status === 'cache-hit-remote'
573
+ const isHit = isCacheHit(outcome.status)
426
574
  switch (view.mode) {
427
575
  case 'none':
428
576
  return
577
+ case 'hash-only': {
578
+ // The word carries the outcome (skips/failures included); the
579
+ // key is what this mode exists to show — absent for a task
580
+ // that never computed one (skipped, uncacheable).
581
+ const key = outcome.hash === undefined ? '' : ` ${outcome.hash}`
582
+ emitLine(`${outcomeWord(outcome)} ${node.id}${key}`)
583
+ return
584
+ }
429
585
  case 'errors-only':
430
586
  if (outcome.status !== 'failed') return
431
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
432
- deferredFailures.push(formatTaskBlock(node, outcome, { stdout, stderr }, colors))
587
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
588
+ deferredFailures.push(
589
+ formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
590
+ )
433
591
  return
434
592
  case 'broad':
435
593
  // News only: executed work gets a one-liner, failures get
@@ -438,8 +596,10 @@ export function defaultLogger(
438
596
  // surface in the end-of-run summary.
439
597
  if (outcome.status === 'failed') {
440
598
  // ✗ marker now; the full frame replays at runEnd.
441
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
442
- deferredFailures.push(formatTaskBlock(node, outcome, { stdout, stderr }, colors))
599
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
600
+ deferredFailures.push(
601
+ formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
602
+ )
443
603
  } else if (outcome.status === 'success') {
444
604
  emitLine(formatTaskExecutedLine(node, outcome, colors))
445
605
  }
@@ -471,20 +631,24 @@ export function defaultLogger(
471
631
  // to runEnd like everywhere else; everything else emits
472
632
  // ONE atomic block from the buffered output.
473
633
  if (outcome.status === 'failed') {
474
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
475
- deferredFailures.push(formatTaskBlock(node, outcome, { stdout, stderr }, colors))
634
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
635
+ deferredFailures.push(
636
+ formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
637
+ )
476
638
  return
477
639
  }
478
640
  // forceCommand: a requested task's frame shows `$ cmd`
479
641
  // whether it ran or was cached — same frame every run.
480
- emitBlock(formatTaskBlock(node, outcome, { stdout, stderr }, colors, true))
642
+ emitBlock(formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors, true))
481
643
  return
482
644
  }
483
645
  // Dependency-pulled nodes: silent on success; failures get
484
646
  // the ✗ marker now and their frame replayed at runEnd.
485
647
  if (outcome.status === 'failed') {
486
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
487
- deferredFailures.push(formatTaskBlock(node, outcome, { stdout, stderr }, colors))
648
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
649
+ deferredFailures.push(
650
+ formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
651
+ )
488
652
  }
489
653
  return
490
654
  case 'full': {
@@ -502,17 +666,26 @@ export function defaultLogger(
502
666
  emitLine(formatTaskSkippedLine(node, colors))
503
667
  return
504
668
  }
505
- const block = formatTaskBlock(node, outcome, { stdout, stderr }, colors)
669
+ const block = formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors)
506
670
  if (block.length === 0) return
507
671
  if (view.gha) {
672
+ // The block is the task's own output — fenced, so a task that
673
+ // prints `::endgroup::` can't close vx's group early and an
674
+ // `::error` line in its output can't become a real annotation.
675
+ const body = ghaFence(block, ghaToken)
508
676
  // Failed tasks stay pre-expanded in the Actions viewer:
509
677
  // an ::error annotation instead of a collapsed group.
510
678
  if (outcome.status === 'failed') {
511
- emitBlock(`::error title=${node.id}::failed (exit ${outcome.exitCode})\n${block}`)
679
+ // The message is ours (a literal + an integer); only the title
680
+ // carries user input.
681
+ emitBlock(
682
+ `::error title=${ghaProperty(node.id)}::failed (exit ${outcome.exitCode})\n${body}`,
683
+ )
512
684
  } else {
513
685
  emitBlock(
514
- `::group::${node.id} (${outcomeWord(outcome)} ${formatDuration(outcome.durationMs)})\n` +
515
- `${block}::endgroup::\n`,
686
+ `::group::${ghaData(
687
+ `${node.id} (${outcomeWord(outcome)} ${formatDuration(outcome.durationMs)})`,
688
+ )}\n${body}::endgroup::\n`,
516
689
  )
517
690
  }
518
691
  return