@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.
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- 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
|
+
}
|