@vzn/vx 0.0.16 → 0.0.18

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