@vzn/vx 0.0.0 → 0.0.14

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,526 @@
1
+ import type { TaskNode, TaskOutcome } from '../graph/index.js'
2
+ import { detectColors, type ColorSupport } from './colors.js'
3
+ import {
4
+ formatFrameClose,
5
+ formatFrameOpen,
6
+ formatTaskBlock,
7
+ formatTaskExecutedLine,
8
+ formatTaskHitLine,
9
+ formatTaskSkippedLine,
10
+ } from './framed-output.js'
11
+ import {
12
+ createOutputWriter,
13
+ formatFailureLine,
14
+ formatStatusRegion,
15
+ type StatusStream,
16
+ type WorkerSlot,
17
+ } from './status-line.js'
18
+ import { formatDuration, formatSummarySection, type RunContext } from './summary.js'
19
+ import { isGroupTask } from '../graph/index.js'
20
+
21
+ export interface Logger {
22
+ /** Header / footer / status text. Written verbatim, one trailing \n added. */
23
+ status(line: string): void
24
+ /** Streamed stdout chunk for a task. Buffered until `taskComplete`. */
25
+ taskStdout(node: TaskNode, chunk: string): void
26
+ /** Streamed stderr chunk for a task. Buffered until `taskComplete`. */
27
+ taskStderr(node: TaskNode, chunk: string): void
28
+ /**
29
+ * Flush a task's buffered output as one framed block. Called once
30
+ * per task on completion (success, failure, cache hit, or skip).
31
+ */
32
+ taskComplete(node: TaskNode, outcome: TaskOutcome): void
33
+ /**
34
+ * Optional lifecycle hooks. The orchestrator calls them when
35
+ * present; the default logger uses them to drive its dynamic
36
+ * status line. Custom loggers can ignore them.
37
+ */
38
+ runStart?(info: {
39
+ total: number
40
+ concurrency?: number
41
+ requestedCount?: number
42
+ /** Run banner context — so the live region footer matches the final summary. */
43
+ context?: RunContext
44
+ }): void
45
+ taskStart?(node: TaskNode): void
46
+ /** Run finished (any outcome). Idempotent. */
47
+ runEnd?(): void
48
+ }
49
+
50
+ /**
51
+ * The default logger's per-task output policy. Resolved once per run
52
+ * from (in priority order) the explicit `--output-logs` override, a
53
+ * truthy `CI` env, and the CLI-detected flow:
54
+ *
55
+ * full — frames for executed work, one-liners for quiet hits.
56
+ * Today's CI behavior; also the programmatic default.
57
+ * errors-only — only failed tasks print.
58
+ * none — no per-task output at all.
59
+ * focused — requested nodes stream raw output live (running the
60
+ * task should feel like running the command directly);
61
+ * dependency-pulled nodes are silent unless they fail.
62
+ * broad — news only: one `success` line per executed task,
63
+ * full frames for failures, silence for cache hits.
64
+ *
65
+ * `gha` (full mode only): wrap each task's block in `::group::` /
66
+ * `::endgroup::` workflow commands so tasks collapse in the GitHub
67
+ * Actions log viewer — except failed tasks, which stay pre-expanded
68
+ * and emit an `::error` annotation instead.
69
+ *
70
+ * `ci`: a truthy CI env was detected. Suppresses the dynamic status
71
+ * line even if stdout happens to be a TTY.
72
+ */
73
+ export interface OutputView {
74
+ mode: 'full' | 'errors-only' | 'none' | 'focused' | 'broad'
75
+ gha?: boolean
76
+ ci?: boolean
77
+ }
78
+
79
+ /** CI=0 / CI=false count as "not CI" — several vendors use CI=true. */
80
+ function truthyEnv(v: string | undefined): boolean {
81
+ return v !== undefined && v !== '' && v !== '0' && v !== 'false'
82
+ }
83
+
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
+ }
96
+ }
97
+
98
+ export function resolveOutputView(
99
+ options: { outputLogs?: 'full' | 'errors-only' | 'none'; flow?: 'focused' | 'broad' },
100
+ env: Record<string, string | undefined> = process.env,
101
+ ): OutputView {
102
+ const ci = truthyEnv(env['CI'])
103
+ const gha = truthyEnv(env['GITHUB_ACTIONS'])
104
+ const mk = (mode: OutputView['mode']): OutputView => ({
105
+ mode,
106
+ ...(mode === 'full' && gha ? { gha: true } : {}),
107
+ ...(ci ? { ci: true } : {}),
108
+ })
109
+ if (options.outputLogs !== undefined) return mk(options.outputLogs)
110
+ if (ci) return mk('full')
111
+ if (options.flow !== undefined) return mk(options.flow)
112
+ return mk('full')
113
+ }
114
+
115
+ export function defaultLogger(
116
+ colors: ColorSupport = detectColors(),
117
+ view: OutputView = { mode: 'full' },
118
+ out: StatusStream = process.stdout,
119
+ opts: { forceFloorMs?: number } = {},
120
+ ): Logger {
121
+ // Per-task buffers, split by stream. Splitting lets the framed-output
122
+ // renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
123
+ // The price: chunks that interleaved at runtime get re-ordered (all
124
+ // stdout before all stderr).
125
+ //
126
+ // Chunks are held as a string[] (push + join on flush) instead of
127
+ // appending via `+=`; concatenating N small chunks via `+=` is O(N²)
128
+ // because each `+=` allocates a fresh string of the full accumulated
129
+ // length. Bun-friendly: join('') is a single contiguous allocation.
130
+ const stdoutBuffers = new Map<string, string[]>()
131
+ const stderrBuffers = new Map<string, string[]>()
132
+ // Separator bookkeeping: blocks are blank-line-delimited on BOTH
133
+ // sides — raw (unprefixed) frame content must never collide with a
134
+ // neighbouring one-liner (owner feedback). A block leaves its own
135
+ // trailing blank, so a block following a block needs no leading one;
136
+ // a one-liner or streamed output since the last block does.
137
+ let lineEmitted = false
138
+ let streamedSinceBlock = false
139
+ // Ids whose output went straight to the terminal (focused mode).
140
+ const streamed = new Set<string>()
141
+ // True while the live stream sits mid-line (chunk without trailing
142
+ // newline) — the frame close must not glue onto partial output.
143
+ let streamMidLine = false
144
+ const pushChunk = (buffers: Map<string, string[]>, id: string, chunk: string): void => {
145
+ const arr = buffers.get(id)
146
+ if (arr) arr.push(chunk)
147
+ else buffers.set(id, [chunk])
148
+ }
149
+ const takeChunks = (buffers: Map<string, string[]>, id: string): string => {
150
+ const arr = buffers.get(id)
151
+ if (!arr) return ''
152
+ buffers.delete(id)
153
+ return arr.length === 1 ? arr[0]! : arr.join('')
154
+ }
155
+
156
+ // All stdout flows through the writer so the status line can never
157
+ // interleave with content. Inert (pure passthrough) on non-TTY
158
+ // streams and in CI.
159
+ const writer = createOutputWriter(out, {
160
+ enabled: view.ci !== true,
161
+ ...(opts.forceFloorMs !== undefined ? { forceFloorMs: opts.forceFloorMs } : {}),
162
+ })
163
+
164
+ // Status-display state, driven by the optional lifecycle hooks.
165
+ let total = 0
166
+ // Live open/close framing only works when ONE requested task owns
167
+ // the terminal between its open and close. With multiple requested
168
+ // tasks streaming concurrently, frames interleave into garbage —
169
+ // so we buffer each requested node and emit an atomic block at
170
+ // completion instead. Default-safe: undefined / 0 / 1 keeps the
171
+ // single-target live experience byte-identical.
172
+ let requestedCount = 1
173
+ // Run banner context — rendered in the live region's summary section
174
+ // so the in-flight footer matches the final summary exactly.
175
+ let runContext: RunContext | undefined
176
+ let done = 0
177
+ let failed = 0
178
+ let startedAtMs = Date.now()
179
+ let ticker: ReturnType<typeof setInterval> | null = null
180
+ let statusDead = !writer.enabled
181
+
182
+ // Every interactive view renders the fixed-height worker region:
183
+ // one row per worker slot so a task's name never moves while it
184
+ // runs (the display derives from the stable worker set, not the
185
+ // churning task set). Sized at runStart from concurrency, capped at
186
+ // 10 rows; excess running tasks queue for a freed slot and surface
187
+ // as `+k more` on the stats line. Focused flow keeps its lifecycle:
188
+ // the region dies the moment a requested node starts streaming.
189
+ let slots: (WorkerSlot | null)[] = []
190
+ const slotQueue: WorkerSlot[] = []
191
+ let succeeded = 0
192
+ let upToDate = 0
193
+ let restoredLocal = 0
194
+ let restoredRemote = 0
195
+ let skippedCount = 0
196
+ // Cache-miss duration spread, accumulated incrementally (the same
197
+ // numbers the final summary computes from the outcome list).
198
+ let spreadMax = 0
199
+ let spreadMin = Infinity
200
+ let spreadSum = 0
201
+ let spreadCount = 0
202
+ // Pinned zones above the worker rows: failures accumulate as they
203
+ // happen; persistent tasks pin at ready (their outcome lands while
204
+ // the child keeps running). Both live until runEnd kills the region.
205
+ // Full failure frames, deferred to runEnd (owner: '✗ line, continue,
206
+ // all full frames at the end'). 'full'/CI keeps frames inline.
207
+ const deferredFailures: string[] = []
208
+ const pinnedPersistent: string[] = []
209
+ let flushedFailures = false
210
+
211
+ const refresh = (force: boolean): void => {
212
+ if (statusDead) return
213
+ // The live summary IS the final summary's section, built from the
214
+ // same formatter so the region visually becomes the printout.
215
+ const summaryLines = formatSummarySection(
216
+ {
217
+ failed,
218
+ successful: succeeded + upToDate + restoredLocal + restoredRemote,
219
+ skipped: skippedCount,
220
+ total,
221
+ upToDate,
222
+ restoredLocal,
223
+ restoredRemote,
224
+ miss: succeeded + failed,
225
+ left: total - done,
226
+ spread:
227
+ spreadCount > 0
228
+ ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
229
+ : null,
230
+ },
231
+ Date.now() - startedAtMs,
232
+ colors,
233
+ runContext,
234
+ )
235
+ writer.setRegion(
236
+ formatStatusRegion(
237
+ {
238
+ pinnedPersistent,
239
+ slots,
240
+ overflow: slotQueue.length,
241
+ nowMs: Date.now(),
242
+ summaryLines,
243
+ },
244
+ colors,
245
+ ),
246
+ { force },
247
+ )
248
+ }
249
+ const killStatus = (): void => {
250
+ if (ticker !== null) {
251
+ clearInterval(ticker)
252
+ ticker = null
253
+ }
254
+ if (statusDead) return
255
+ statusDead = true
256
+ writer.clearStatus()
257
+ }
258
+
259
+ const emitLine = (line: string): void => {
260
+ writer.write(`${line}\n`)
261
+ lineEmitted = true
262
+ }
263
+ // formatTaskBlock returns '' for group tasks (no exec) — skip the
264
+ // write so a stray newline doesn't sneak into the output.
265
+ const emitBlock = (block: string): void => {
266
+ if (block.length === 0) return
267
+ const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
268
+ writer.write(`${lead}${block}\n`)
269
+ lineEmitted = false
270
+ streamedSinceBlock = false
271
+ }
272
+ // Live-frame close lines end a frame the same way emitBlock does:
273
+ // with a trailing blank line, resetting the separator state.
274
+ const emitFrameClose = (line: string): void => {
275
+ writer.write(`${line}\n\n`)
276
+ lineEmitted = false
277
+ streamedSinceBlock = false
278
+ }
279
+
280
+ // Focused mode streams requested nodes' output live and raw —
281
+ // `vx run test` should feel like running the command directly.
282
+ // Cache-hit replay arrives through the same taskStdout path, so it
283
+ // streams identically. Only with a SINGLE requested task, though:
284
+ // concurrent live frames interleave (see requestedCount above), so
285
+ // multiple requested tasks buffer and emit atomic blocks instead.
286
+ // A requested task — or a non-group dep surfaced from a requested
287
+ // group (see markSurfacedDeps) — is a "primary" node the focused
288
+ // view shows. Groups never stream (no output of their own).
289
+ const isPrimary = (node: TaskNode): boolean => node.requested || node.surfaced === true
290
+ const streamsLive = (node: TaskNode): boolean =>
291
+ view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1
292
+
293
+ return {
294
+ status(line) {
295
+ writer.write(`${line}\n`)
296
+ },
297
+ runStart(info) {
298
+ total = info.total
299
+ requestedCount = info.requestedCount ?? requestedCount
300
+ runContext = info.context
301
+ startedAtMs = Date.now()
302
+ const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
303
+ slots = Array.from({ length: cap }, () => null)
304
+ if (writer.enabled && !statusDead && ticker === null) {
305
+ // Keeps the per-worker elapsed time ticking between task events
306
+ // (the time IS the motion — there's no spinner). The writer
307
+ // throttles unforced redraws, and unref means a stray ticker
308
+ // can never hold the process open.
309
+ ticker = setInterval(() => {
310
+ refresh(false)
311
+ }, 100)
312
+ ticker.unref?.()
313
+ }
314
+ refresh(true)
315
+ },
316
+ taskStart(node) {
317
+ if (isGroupTask(node)) return
318
+ // Focused flow: the status line exists for the dependency
319
+ // phase only. The moment a requested node starts streaming,
320
+ // clear it for good — its raw output owns the terminal now.
321
+ if (streamsLive(node)) {
322
+ killStatus()
323
+ // Open the live frame: full task info even when the command
324
+ // streams nothing (or the hit replays nothing).
325
+ emitLine(formatFrameOpen(node, colors))
326
+ return
327
+ }
328
+ const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
329
+ const free = slots.indexOf(null)
330
+ if (free >= 0) slots[free] = slot
331
+ else slotQueue.push(slot)
332
+ refresh(true)
333
+ },
334
+ runEnd() {
335
+ killStatus()
336
+ // Failures end the log: every deferred frame replays here, right
337
+ // above the summary — the ✗ one-liners marked them in the
338
+ // stream, the full diagnostics read last where eyes land.
339
+ // Guarded for repeat runEnd calls.
340
+ if (!flushedFailures && deferredFailures.length > 0) {
341
+ flushedFailures = true
342
+ for (const block of deferredFailures) emitBlock(block)
343
+ }
344
+ },
345
+ taskStdout(node, chunk) {
346
+ if (streamsLive(node)) {
347
+ streamed.add(node.id)
348
+ streamedSinceBlock = true
349
+ if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
350
+ writer.write(chunk)
351
+ return
352
+ }
353
+ pushChunk(stdoutBuffers, node.id, chunk)
354
+ },
355
+ taskStderr(node, chunk) {
356
+ if (streamsLive(node)) {
357
+ streamed.add(node.id)
358
+ streamedSinceBlock = true
359
+ if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
360
+ writer.write(chunk)
361
+ return
362
+ }
363
+ pushChunk(stderrBuffers, node.id, chunk)
364
+ },
365
+ taskComplete(node, outcome) {
366
+ const stdout = takeChunks(stdoutBuffers, node.id)
367
+ const stderr = takeChunks(stderrBuffers, node.id)
368
+ // An aborted task (child killed by a shutdown signal) reverts
369
+ // to pending: free its worker slot, but never count or render it
370
+ // — the run is tearing down and it has no honest outcome.
371
+ if (outcome.status === 'aborted') {
372
+ const si = slots.findIndex((s) => s !== null && s.id === node.id)
373
+ if (si >= 0) slots[si] = slotQueue.shift() ?? null
374
+ else {
375
+ const qi = slotQueue.findIndex((s) => s.id === node.id)
376
+ if (qi >= 0) slotQueue.splice(qi, 1)
377
+ }
378
+ refresh(true)
379
+ return
380
+ }
381
+ // Group tasks (no exec) do no work — no surface prints them.
382
+ if (!isGroupTask(node)) {
383
+ done++
384
+ if (outcome.status === 'failed') {
385
+ failed++
386
+ } else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
387
+ // A persistent task's outcome arrives at READY; the child
388
+ // keeps running until the orchestrator SIGTERMs it at run
389
+ // end — pin it so its liveness stays visible.
390
+ pinnedPersistent.push(node.id)
391
+ }
392
+ // Free the task's slot; the longest-waiting queued task
393
+ // (if any) takes it over, keeping lowest-index-first reuse.
394
+ const si = slots.findIndex((s) => s !== null && s.id === node.id)
395
+ if (si >= 0) slots[si] = slotQueue.shift() ?? null
396
+ else {
397
+ const qi = slotQueue.findIndex((s) => s.id === node.id)
398
+ if (qi >= 0) slotQueue.splice(qi, 1)
399
+ }
400
+ switch (outcome.status) {
401
+ case 'success':
402
+ succeeded++
403
+ break
404
+ case 'cache-hit':
405
+ if (outcome.restored === false) upToDate++
406
+ else restoredLocal++
407
+ break
408
+ case 'cache-hit-remote':
409
+ if (outcome.restored === false) upToDate++
410
+ else restoredRemote++
411
+ break
412
+ case 'skipped':
413
+ skippedCount++
414
+ break
415
+ }
416
+ if (outcome.status === 'success' || outcome.status === 'failed') {
417
+ spreadMax = Math.max(spreadMax, outcome.durationMs)
418
+ spreadMin = Math.min(spreadMin, outcome.durationMs)
419
+ spreadSum += outcome.durationMs
420
+ spreadCount++
421
+ }
422
+ refresh(true)
423
+ }
424
+ if (isGroupTask(node)) return
425
+ const isHit = outcome.status === 'cache-hit' || outcome.status === 'cache-hit-remote'
426
+ switch (view.mode) {
427
+ case 'none':
428
+ return
429
+ case 'errors-only':
430
+ if (outcome.status !== 'failed') return
431
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
432
+ deferredFailures.push(formatTaskBlock(node, outcome, { stdout, stderr }, colors))
433
+ return
434
+ case 'broad':
435
+ // News only: executed work gets a one-liner, failures get
436
+ // the full frame. Hits (including up-to-date) are silent —
437
+ // their replay buffers are deliberately dropped; the counts
438
+ // surface in the end-of-run summary.
439
+ if (outcome.status === 'failed') {
440
+ // ✗ 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))
443
+ } else if (outcome.status === 'success') {
444
+ emitLine(formatTaskExecutedLine(node, outcome, colors))
445
+ }
446
+ return
447
+ case 'focused':
448
+ if (isPrimary(node)) {
449
+ // Skipped tasks never started (upstream failed), so no
450
+ // frame-open fired — and a skip has no output, so a
451
+ // one-liner carries everything a frame would.
452
+ if (outcome.status === 'skipped') {
453
+ emitLine(formatTaskSkippedLine(node, colors))
454
+ return
455
+ }
456
+ if (streamsLive(node)) {
457
+ // Single requested task: output (exec or hit replay)
458
+ // streamed live between the frame-open (taskStart) and
459
+ // this close — full task info for every outcome, cached
460
+ // and up-to-date included (owner: "always full frame
461
+ // for a single task").
462
+ if (streamMidLine) {
463
+ writer.write('\n')
464
+ streamMidLine = false
465
+ }
466
+ emitFrameClose(formatFrameClose(node, outcome, colors))
467
+ return
468
+ }
469
+ // Multiple requested tasks: no live frame was opened (it
470
+ // would interleave with siblings). Failures still defer
471
+ // to runEnd like everywhere else; everything else emits
472
+ // ONE atomic block from the buffered output.
473
+ if (outcome.status === 'failed') {
474
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
475
+ deferredFailures.push(formatTaskBlock(node, outcome, { stdout, stderr }, colors))
476
+ return
477
+ }
478
+ // forceCommand: a requested task's frame shows `$ cmd`
479
+ // whether it ran or was cached — same frame every run.
480
+ emitBlock(formatTaskBlock(node, outcome, { stdout, stderr }, colors, true))
481
+ return
482
+ }
483
+ // Dependency-pulled nodes: silent on success; failures get
484
+ // the ✗ marker now and their frame replayed at runEnd.
485
+ if (outcome.status === 'failed') {
486
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
487
+ deferredFailures.push(formatTaskBlock(node, outcome, { stdout, stderr }, colors))
488
+ }
489
+ return
490
+ case 'full': {
491
+ // Cache hits with nothing to replay compress to ONE line —
492
+ // every task stays visible, but at 2000+ tasks the two-line
493
+ // frames would drown what actually happened. Hits WITH
494
+ // replayed stdout keep their frame (the output is the
495
+ // point); misses/failures are always framed. One-liners
496
+ // stay outside ::group:: — there's nothing to collapse.
497
+ if (isHit && stdout.trim().length === 0 && stderr.trim().length === 0) {
498
+ emitLine(formatTaskHitLine(node, outcome, colors))
499
+ return
500
+ }
501
+ if (outcome.status === 'skipped') {
502
+ emitLine(formatTaskSkippedLine(node, colors))
503
+ return
504
+ }
505
+ const block = formatTaskBlock(node, outcome, { stdout, stderr }, colors)
506
+ if (block.length === 0) return
507
+ if (view.gha) {
508
+ // Failed tasks stay pre-expanded in the Actions viewer:
509
+ // an ::error annotation instead of a collapsed group.
510
+ if (outcome.status === 'failed') {
511
+ emitBlock(`::error title=${node.id}::failed (exit ${outcome.exitCode})\n${block}`)
512
+ } else {
513
+ emitBlock(
514
+ `::group::${node.id} (${outcomeWord(outcome)} ${formatDuration(outcome.durationMs)})\n` +
515
+ `${block}::endgroup::\n`,
516
+ )
517
+ }
518
+ return
519
+ }
520
+ emitBlock(block)
521
+ return
522
+ }
523
+ }
524
+ },
525
+ }
526
+ }