@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -19,6 +19,12 @@ const CLEAR = '\x1b[2K\r'
|
|
|
19
19
|
export interface StatusStream {
|
|
20
20
|
write(chunk: string): unknown
|
|
21
21
|
isTTY?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Terminal width in columns. Read fresh on every draw — `process.stdout`
|
|
24
|
+
* updates it on SIGWINCH, so a mid-run resize is picked up. Absent (not a
|
|
25
|
+
* TTY) means the region is never drawn anyway.
|
|
26
|
+
*/
|
|
27
|
+
columns?: number
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export interface OutputWriter {
|
|
@@ -68,7 +74,9 @@ export function createOutputWriter(
|
|
|
68
74
|
|
|
69
75
|
let current: readonly string[] | null = null
|
|
70
76
|
let shown = false
|
|
71
|
-
|
|
77
|
+
// PHYSICAL rows the shown region occupies, not logical lines — see
|
|
78
|
+
// `regionRows`.
|
|
79
|
+
let shownRows = 0
|
|
72
80
|
let dead = false
|
|
73
81
|
let lastDraw = -Infinity
|
|
74
82
|
// Trailing draw scheduled when a forced set lands inside the floor.
|
|
@@ -85,10 +93,41 @@ export function createOutputWriter(
|
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
/**
|
|
97
|
+
* How many PHYSICAL terminal rows `lines` occupies. The terminal wraps
|
|
98
|
+
* any line wider than the viewport, so a region of N logical lines can
|
|
99
|
+
* cover more than N rows — and erasing by the logical count moves the
|
|
100
|
+
* cursor up too few rows, leaving the top of the region on screen. It
|
|
101
|
+
* accumulates, because every redraw is short by the same amount.
|
|
102
|
+
*
|
|
103
|
+
* Both triggers are ordinary: the summary section is a fixed 62 visible
|
|
104
|
+
* columns (narrower terminal ⇒ every bar row wraps), and a task id is
|
|
105
|
+
* deliberately never truncated (long id ⇒ the worker row wraps at any
|
|
106
|
+
* width). Width is read per draw so a mid-run resize is handled. Width
|
|
107
|
+
* must be VISIBLE width — these lines carry ANSI, which occupies no
|
|
108
|
+
* column.
|
|
109
|
+
*
|
|
110
|
+
* Unknown width falls back to the logical count, which is a KNOWN
|
|
111
|
+
* residual rather than a guarantee: a wrapped line then under-erases
|
|
112
|
+
* exactly as it did before this function existed. It is deliberate,
|
|
113
|
+
* because the alternatives are worse — guessing a width over-erases on a
|
|
114
|
+
* wider terminal, and `ESC[J` clears to end of SCREEN, so over-erasing
|
|
115
|
+
* destroys output above the region instead of merely leaving junk below
|
|
116
|
+
* it. Reachable only on a TTY whose winsize ioctl fails, since a non-TTY
|
|
117
|
+
* disables the region outright.
|
|
118
|
+
*/
|
|
119
|
+
const regionRows = (lines: readonly string[]): number => {
|
|
120
|
+
const cols = stream.columns
|
|
121
|
+
if (cols === undefined || cols <= 0) return lines.length
|
|
122
|
+
let rows = 0
|
|
123
|
+
for (const line of lines) rows += Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
|
|
124
|
+
return rows
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Erase sequence for whatever is currently shown. A single row keeps
|
|
128
|
+
// the exact legacy bytes (ESC[2K\r); anything taller moves to its
|
|
129
|
+
// top row and clears to end of screen.
|
|
130
|
+
const eraseSeq = (): string => (shownRows > 1 ? `\r\x1b[${shownRows - 1}A\x1b[J` : CLEAR)
|
|
92
131
|
|
|
93
132
|
const draw = (): void => {
|
|
94
133
|
// Whatever was pending is now painted — the trailing draw would
|
|
@@ -97,7 +136,7 @@ export function createOutputWriter(
|
|
|
97
136
|
const erase = shown ? eraseSeq() : CLEAR
|
|
98
137
|
stream.write(erase + current!.join('\n'))
|
|
99
138
|
shown = true
|
|
100
|
-
|
|
139
|
+
shownRows = regionRows(current!)
|
|
101
140
|
lastDraw = now()
|
|
102
141
|
}
|
|
103
142
|
const set = (lines: readonly string[], o: { force?: boolean }): void => {
|
|
@@ -136,7 +175,7 @@ export function createOutputWriter(
|
|
|
136
175
|
if (shown) {
|
|
137
176
|
stream.write(eraseSeq())
|
|
138
177
|
shown = false
|
|
139
|
-
|
|
178
|
+
shownRows = 0
|
|
140
179
|
}
|
|
141
180
|
stream.write(chunk)
|
|
142
181
|
if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
|
|
@@ -156,7 +195,7 @@ export function createOutputWriter(
|
|
|
156
195
|
if (shown) {
|
|
157
196
|
stream.write(eraseSeq())
|
|
158
197
|
shown = false
|
|
159
|
-
|
|
198
|
+
shownRows = 0
|
|
160
199
|
}
|
|
161
200
|
},
|
|
162
201
|
}
|
|
@@ -209,13 +248,14 @@ export function formatFailureLine(
|
|
|
209
248
|
id: string,
|
|
210
249
|
durationMs: number | null,
|
|
211
250
|
colors: ColorSupport = NO_COLOR,
|
|
251
|
+
cacheWord: 'miss' | 'no-cache' = 'miss',
|
|
212
252
|
): string {
|
|
213
253
|
return formatTaskRow(
|
|
214
254
|
paint(ERROR, '◼\uFE0E', colors),
|
|
215
255
|
durationMs,
|
|
216
256
|
'failed',
|
|
217
257
|
ERROR,
|
|
218
|
-
|
|
258
|
+
cacheWord,
|
|
219
259
|
'',
|
|
220
260
|
paintPinnedId(id, colors),
|
|
221
261
|
colors,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// aren't real work and would inflate "N total" misleadingly.
|
|
5
5
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
|
+
import type { FlakyFinding } from './failure-mode.js'
|
|
7
8
|
import { paint, type ColorSupport } from './colors.js'
|
|
8
9
|
import { tallyOutcomes } from './tally.js'
|
|
9
10
|
import { isGroupTask } from '../graph/index.js'
|
|
@@ -28,7 +29,7 @@ const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
|
|
|
28
29
|
const RULE_DASHES = 57
|
|
29
30
|
|
|
30
31
|
/** Total visible rule width: `\u2500 <mark> ` + dashes. Shared by summary + header. */
|
|
31
|
-
|
|
32
|
+
function gradientRule(colors: ColorSupport, mark = 'vx'): string {
|
|
32
33
|
const head = `\u2500 `
|
|
33
34
|
const dashes = '\u2500'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
|
|
34
35
|
if (!colors.enabled) return `${head}${mark} ${dashes}`
|
|
@@ -101,13 +102,17 @@ export interface SummaryStats {
|
|
|
101
102
|
upToDate: number
|
|
102
103
|
restoredLocal: number
|
|
103
104
|
restoredRemote: number
|
|
104
|
-
/** Tasks that executed (success + failed) — the
|
|
105
|
+
/** Tasks that executed (success + failed) with a `cache` block — the misses. */
|
|
105
106
|
miss: number
|
|
107
|
+
/** Executed with no `cache` block: never consulted the cache, not a miss. */
|
|
108
|
+
noCache?: number
|
|
106
109
|
/** Still to run (live section only; 0 in the final summary) — renders
|
|
107
110
|
* as a gray ▱ remainder so the live meters FILL toward the final. */
|
|
108
111
|
left?: number
|
|
109
112
|
/** Cache-miss duration spread (only tasks that actually executed). */
|
|
110
113
|
spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
|
|
114
|
+
/** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
|
|
115
|
+
held?: { count: number; sumMs: number }
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
/**
|
|
@@ -125,14 +130,6 @@ export interface RunContext {
|
|
|
125
130
|
remoteCacheEnabled: boolean
|
|
126
131
|
/** Total projects discovery found — the bar's denominator. */
|
|
127
132
|
workspaceProjectCount?: number
|
|
128
|
-
/**
|
|
129
|
-
* Resource-admission budgets (`exec.resources`), set ONLY when at
|
|
130
|
-
* least one task declared a reservation — a plain run's footer is
|
|
131
|
-
* byte-identical. Shown on the `info` row so a packed run says what
|
|
132
|
-
* it packed against. memBudget in bytes.
|
|
133
|
-
*/
|
|
134
|
-
cpuBudget?: number
|
|
135
|
-
memBudget?: number
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
export function formatSummarySection(
|
|
@@ -143,6 +140,7 @@ export function formatSummarySection(
|
|
|
143
140
|
): string[] {
|
|
144
141
|
const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
|
|
145
142
|
const miss = stats.miss
|
|
143
|
+
const noCache = stats.noCache ?? 0
|
|
146
144
|
const left = stats.left ?? 0
|
|
147
145
|
const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
|
|
148
146
|
|
|
@@ -206,10 +204,11 @@ export function formatSummarySection(
|
|
|
206
204
|
|
|
207
205
|
// Cache meter + numbers, fixed order: miss · up-to-date · local ·
|
|
208
206
|
// remote.
|
|
209
|
-
if (miss + hits + left + stats.skipped > 0) {
|
|
207
|
+
if (miss + noCache + hits + left + stats.skipped > 0) {
|
|
210
208
|
const cacheBar = segmentBar(
|
|
211
209
|
[
|
|
212
210
|
{ n: miss, color: ERROR },
|
|
211
|
+
{ n: noCache, color: '', dim: true },
|
|
213
212
|
{ n: stats.upToDate, color: SUCCESS },
|
|
214
213
|
{ n: stats.restoredLocal, color: LOCAL },
|
|
215
214
|
{ n: stats.restoredRemote, color: REMOTE },
|
|
@@ -220,6 +219,7 @@ export function formatSummarySection(
|
|
|
220
219
|
)
|
|
221
220
|
const cacheParts: string[] = []
|
|
222
221
|
if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
|
|
222
|
+
if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
223
223
|
if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
|
|
224
224
|
if (stats.restoredLocal > 0)
|
|
225
225
|
cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
|
|
@@ -248,9 +248,14 @@ export function formatSummarySection(
|
|
|
248
248
|
if (context.concurrency !== undefined)
|
|
249
249
|
info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
|
|
250
250
|
info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
// The plugin's hand on the run: a task a policy held with a worker
|
|
252
|
+
// free would otherwise have started; without the line nothing says
|
|
253
|
+
// the policy acted.
|
|
254
|
+
if (stats.held !== undefined && stats.held.count > 0) {
|
|
255
|
+
info.push(
|
|
256
|
+
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'} ${formatDuration(stats.held.sumMs)}`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
254
259
|
lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
|
|
255
260
|
} else {
|
|
256
261
|
lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
|
|
@@ -260,29 +265,28 @@ export function formatSummarySection(
|
|
|
260
265
|
|
|
261
266
|
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
262
267
|
// (module boundary), and the footer only needs one compact form.
|
|
263
|
-
function formatBudgetBytes(bytes: number): string {
|
|
264
|
-
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
265
|
-
let v = bytes
|
|
266
|
-
let u = 0
|
|
267
|
-
while (v >= 1024 && u < units.length - 1) {
|
|
268
|
-
v /= 1024
|
|
269
|
-
u++
|
|
270
|
-
}
|
|
271
|
-
return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`
|
|
272
|
-
}
|
|
273
|
-
|
|
274
268
|
export function formatRunSummary(
|
|
275
269
|
outcomes: readonly TaskOutcome[],
|
|
276
270
|
totalMs: number,
|
|
277
271
|
colors: ColorSupport = NO_COLOR,
|
|
278
272
|
context?: RunContext,
|
|
279
273
|
): string[] {
|
|
274
|
+
// Counted here, not in the Tally: that object is the `--summarize` payload
|
|
275
|
+
// (ten documented fields) and a task with no `cache` block is a terminal
|
|
276
|
+
// distinction — it never consulted the cache, so it is not a miss.
|
|
277
|
+
const noCache = outcomes.filter(
|
|
278
|
+
(o) =>
|
|
279
|
+
!isGroupTask(o.node) &&
|
|
280
|
+
(o.status === 'success' || o.status === 'failed') &&
|
|
281
|
+
o.node.config.cache === undefined,
|
|
282
|
+
).length
|
|
280
283
|
const t = tallyOutcomes(outcomes)
|
|
281
284
|
// Spread over executed tasks only — `success`/`failed` statuses ran
|
|
282
285
|
// a command; cache-hit statuses replayed one.
|
|
283
286
|
const durations = outcomes
|
|
284
287
|
.filter((o) => (o.status === 'success' || o.status === 'failed') && !isGroupTask(o.node))
|
|
285
288
|
.map((o) => o.durationMs)
|
|
289
|
+
const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
|
|
286
290
|
return formatSummarySection(
|
|
287
291
|
{
|
|
288
292
|
failed: t.failed,
|
|
@@ -292,7 +296,8 @@ export function formatRunSummary(
|
|
|
292
296
|
upToDate: t.upToDate,
|
|
293
297
|
restoredLocal: t.restoredLocal,
|
|
294
298
|
restoredRemote: t.restoredRemote,
|
|
295
|
-
miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote),
|
|
299
|
+
miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
|
|
300
|
+
noCache,
|
|
296
301
|
spread:
|
|
297
302
|
durations.length > 0
|
|
298
303
|
? {
|
|
@@ -302,6 +307,14 @@ export function formatRunSummary(
|
|
|
302
307
|
count: durations.length,
|
|
303
308
|
}
|
|
304
309
|
: null,
|
|
310
|
+
...(heldOutcomes.length > 0
|
|
311
|
+
? {
|
|
312
|
+
held: {
|
|
313
|
+
count: heldOutcomes.length,
|
|
314
|
+
sumMs: heldOutcomes.reduce((sum, o) => sum + o.admissionHeldMs!, 0),
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
: {}),
|
|
305
318
|
},
|
|
306
319
|
totalMs,
|
|
307
320
|
colors,
|
|
@@ -309,6 +322,57 @@ export function formatRunSummary(
|
|
|
309
322
|
)
|
|
310
323
|
}
|
|
311
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Post-summary section naming the tasks a shutdown signal killed. An aborted
|
|
327
|
+
* task did no work, so it is in no tally bucket and no history row — but the
|
|
328
|
+
* run still exits non-zero, and without this the user reads a red exit over a
|
|
329
|
+
* fully green summary that names nothing. The interactive Ctrl-C path never
|
|
330
|
+
* gets here (the signal handler exits before any outcome lands), so this only
|
|
331
|
+
* ever prints for a run that reached its summary WITH a task killed by some
|
|
332
|
+
* other signal — an external `kill`, a supervisor, a self-terminating child.
|
|
333
|
+
* Empty when nothing aborted.
|
|
334
|
+
*/
|
|
335
|
+
export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[] {
|
|
336
|
+
const aborted = outcomes.filter((o) => o.status === 'aborted')
|
|
337
|
+
if (aborted.length === 0) return []
|
|
338
|
+
const lines = [
|
|
339
|
+
'',
|
|
340
|
+
` Aborted: ${aborted.length} task${aborted.length === 1 ? '' : 's'} killed by a shutdown signal — not counted above`,
|
|
341
|
+
]
|
|
342
|
+
for (const o of aborted) lines.push(` ✗ ${o.node.id} — exit ${o.exitCode}, nothing cached`)
|
|
343
|
+
return lines
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Post-summary section naming the tasks this run proved flaky: identical
|
|
348
|
+
* inputs, both outcomes on record (or a retry within the run). A red run
|
|
349
|
+
* whose failure has passed on these exact inputs before is not a break to
|
|
350
|
+
* bisect, and a green run that only passed on the second attempt is not
|
|
351
|
+
* green — either way the footer's counts alone would say nothing. Empty
|
|
352
|
+
* when nothing was.
|
|
353
|
+
*/
|
|
354
|
+
export function formatFlakySection(findings: readonly FlakyFinding[]): string[] {
|
|
355
|
+
if (findings.length === 0) return []
|
|
356
|
+
const lines = [
|
|
357
|
+
'',
|
|
358
|
+
` Flaky: ${findings.length} task${findings.length === 1 ? '' : 's'} with the same inputs both passing and failing on record`,
|
|
359
|
+
]
|
|
360
|
+
for (const f of findings) {
|
|
361
|
+
const times = (n: number): string => `${n}\u00d7`
|
|
362
|
+
const history =
|
|
363
|
+
f.status === 'failed'
|
|
364
|
+
? `failed on inputs that passed ${times(f.passes)} before`
|
|
365
|
+
: f.failures > 0
|
|
366
|
+
? `passed on inputs that failed ${times(f.failures)} before`
|
|
367
|
+
: 'passed'
|
|
368
|
+
const retried = f.attempts > 1 ? ` \u00b7 ${f.attempts} attempts this run` : ''
|
|
369
|
+
lines.push(
|
|
370
|
+
` ${f.status === 'failed' ? '\u2717' : '\u2713'} ${f.taskId} \u2014 ${history}${retried}`,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
return lines
|
|
374
|
+
}
|
|
375
|
+
|
|
312
376
|
export function formatDuration(ms: number): string {
|
|
313
377
|
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
314
378
|
return `${(ms / 1000).toFixed(2)}s`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Shared outcome-tally helper.
|
|
2
|
-
//
|
|
3
|
-
// numbers; centralising
|
|
1
|
+
// Shared outcome-tally helper. `summary.ts` (end-of-run terminal block),
|
|
2
|
+
// `run-artifacts.ts` (`--summarize` JSON) and `run-report.ts`
|
|
3
|
+
// (`--report=markdown`) all count the same numbers; centralising the bucket
|
|
4
|
+
// rules keeps the three surfaces from disagreeing about one run.
|
|
4
5
|
|
|
5
6
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
6
7
|
|
|
@@ -16,25 +17,31 @@ export interface Tally {
|
|
|
16
17
|
/** Cache hits whose tree already matched — nothing was written. */
|
|
17
18
|
upToDate: number
|
|
18
19
|
cachedRemote: number
|
|
20
|
+
/**
|
|
21
|
+
* Killed by a shutdown signal. No work, so it joins no outcome bucket and
|
|
22
|
+
* not `total` — but it is counted, because it makes the run red: a red run
|
|
23
|
+
* whose every counted task succeeded is undiagnosable without this.
|
|
24
|
+
*/
|
|
25
|
+
aborted: number
|
|
19
26
|
/** Tasks counted toward `total`. Group tasks are excluded by default. */
|
|
20
27
|
total: number
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `success` → +successful
|
|
31
|
-
* `cache-hit` → +successful, +cachedLocal
|
|
32
|
-
* `cache-hit-remote` → +successful, +cachedRemote
|
|
33
|
-
* `failed` → +failed
|
|
34
|
-
* `skipped` → +skipped
|
|
31
|
+
* The minimum an outcome must expose to be bucketed. The in-process
|
|
32
|
+
* `TaskOutcome` and the serializable `OutcomeView` both satisfy it; they
|
|
33
|
+
* differ only in how they carry group-ness (a live node ref vs. a projected
|
|
34
|
+
* boolean), so each entry point supplies that and the RULE lives in one
|
|
35
|
+
* place. Declared structurally here rather than importing `OutcomeView`,
|
|
36
|
+
* which would close a module cycle (events → summary → tally).
|
|
35
37
|
*/
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
+
export interface TallyItem {
|
|
39
|
+
status: TaskOutcome['status']
|
|
40
|
+
restored?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emptyTally(): Tally {
|
|
44
|
+
return {
|
|
38
45
|
successful: 0,
|
|
39
46
|
failed: 0,
|
|
40
47
|
skipped: 0,
|
|
@@ -43,27 +50,63 @@ export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
|
|
|
43
50
|
restoredRemote: 0,
|
|
44
51
|
upToDate: 0,
|
|
45
52
|
cachedRemote: 0,
|
|
53
|
+
aborted: 0,
|
|
46
54
|
total: 0,
|
|
47
55
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fold ONE outcome into the tally — the single owner of the bucket rules.
|
|
60
|
+
*
|
|
61
|
+
* Group tasks (no `exec`) aren't real work; including them inflates the
|
|
62
|
+
* per-run "N total" count in ways that mislead ("3 of 4 cached" when one was
|
|
63
|
+
* a group that ran nothing).
|
|
64
|
+
*
|
|
65
|
+
* Status bucket rules:
|
|
66
|
+
* `success` → +successful
|
|
67
|
+
* `cache-hit` → +successful, +cachedLocal
|
|
68
|
+
* `cache-hit-remote` → +successful, +cachedRemote
|
|
69
|
+
* `failed` → +failed
|
|
70
|
+
* `skipped` → +skipped
|
|
71
|
+
* `aborted` → +aborted only (no bucket, no total)
|
|
72
|
+
*/
|
|
73
|
+
function fold(t: Tally, o: TallyItem, isGroup: boolean): void {
|
|
74
|
+
if (isGroup) return
|
|
75
|
+
if (o.status === 'aborted') {
|
|
76
|
+
t.aborted++
|
|
77
|
+
return
|
|
67
78
|
}
|
|
79
|
+
t.total++
|
|
80
|
+
if (o.status === 'success') t.successful++
|
|
81
|
+
else if (o.status === 'cache-hit') {
|
|
82
|
+
t.successful++
|
|
83
|
+
t.cachedLocal++
|
|
84
|
+
if (o.restored === true) t.restoredLocal++
|
|
85
|
+
else t.upToDate++
|
|
86
|
+
} else if (o.status === 'cache-hit-remote') {
|
|
87
|
+
t.successful++
|
|
88
|
+
t.cachedRemote++
|
|
89
|
+
if (o.restored === true) t.restoredRemote++
|
|
90
|
+
else t.upToDate++
|
|
91
|
+
} else if (o.status === 'failed') t.failed++
|
|
92
|
+
else if (o.status === 'skipped') t.skipped++
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Walk live outcomes and return a `Tally`. */
|
|
96
|
+
export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
|
|
97
|
+
const t = emptyTally()
|
|
98
|
+
for (const o of outcomes) fold(t, o, isGroupTask(o.node))
|
|
99
|
+
return t
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The same tally over the serializable projection (`OutcomeView`), which
|
|
104
|
+
* carries group-ness as a flag because it has no node back-ref. An absent
|
|
105
|
+
* flag reads as "not a group" — the only outcomes without one predate the
|
|
106
|
+
* field and none of them are groups.
|
|
107
|
+
*/
|
|
108
|
+
export function tallyViews(views: readonly (TallyItem & { isGroup?: boolean })[]): Tally {
|
|
109
|
+
const t = emptyTally()
|
|
110
|
+
for (const v of views) fold(t, v, v.isGroup === true)
|
|
68
111
|
return t
|
|
69
112
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import type { TaskConfig, CacheConfig } from '../config.js'
|
|
3
|
-
import
|
|
3
|
+
import type { WorkspaceFilesCache } from '../cache/index.js'
|
|
4
|
+
import {
|
|
5
|
+
type CacheKeyInput,
|
|
6
|
+
type CacheLayer,
|
|
7
|
+
resolveInputs,
|
|
8
|
+
type GitFilesCache,
|
|
9
|
+
WORKSPACE_OUTPUT_PREFIX,
|
|
10
|
+
} from '../cache/index.js'
|
|
11
|
+
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
4
12
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
5
|
-
import { xxh3hex } from '../util/index.js'
|
|
6
|
-
import { filterUpstreamHashes } from './upstream.js'
|
|
13
|
+
import { span, relPosix, xxh3hex } from '../util/index.js'
|
|
14
|
+
import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* Per-run memoization caches for the two derived hashes that don't
|
|
@@ -25,6 +33,7 @@ export interface HashCache {
|
|
|
25
33
|
taskConfig: WeakMap<TaskConfig, string>
|
|
26
34
|
runtime: Map<string, Promise<string>>
|
|
27
35
|
workspaceRuntime: Map<string, Promise<string>>
|
|
36
|
+
workspaceFiles: WorkspaceFilesCache
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export function createHashCache(): HashCache {
|
|
@@ -33,6 +42,7 @@ export function createHashCache(): HashCache {
|
|
|
33
42
|
taskConfig: new WeakMap(),
|
|
34
43
|
runtime: new Map(),
|
|
35
44
|
workspaceRuntime: new Map(),
|
|
45
|
+
workspaceFiles: new Map(),
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
|
|
@@ -83,6 +93,73 @@ export interface ComputeHashArgs {
|
|
|
83
93
|
* `computeGroupHash` is exported for that purpose.
|
|
84
94
|
*/
|
|
85
95
|
export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
96
|
+
const end = span('task hash')
|
|
97
|
+
try {
|
|
98
|
+
return await args.cache.key(await resolveKeyInput(args))
|
|
99
|
+
} finally {
|
|
100
|
+
end()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The key AND the structured input set behind it, for the executor seam.
|
|
106
|
+
* Miss path only: it re-runs the (memoized) resolution `computeTaskHash`
|
|
107
|
+
* did for the probe and retains the VALUES the key folded — env, runtime
|
|
108
|
+
* output, per-file digests — which `captureInto` deliberately reduces to
|
|
109
|
+
* digests because its rows are persisted. Per-file digests come from the
|
|
110
|
+
* same sources as the fold: the index-OID map for clean tracked files,
|
|
111
|
+
* else `hashFile`'s stat memo (already warm after `key()`).
|
|
112
|
+
*/
|
|
113
|
+
export async function describeTaskInputs(
|
|
114
|
+
args: ComputeHashArgs,
|
|
115
|
+
): Promise<{ hash: string; inputs: TaskInputs }> {
|
|
116
|
+
const input = await resolveKeyInput(args)
|
|
117
|
+
const hash = await args.cache.key(input)
|
|
118
|
+
const sorted = [...input.inputFiles].sort()
|
|
119
|
+
const digests = await Promise.all(
|
|
120
|
+
sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f)),
|
|
121
|
+
)
|
|
122
|
+
const files: InputFile[] = sorted.map((f, i) => ({
|
|
123
|
+
path: relPosix(input.workspaceRoot, f),
|
|
124
|
+
digest: digests[i]!,
|
|
125
|
+
}))
|
|
126
|
+
// Groups expanded: this list describes the INPUT ROOT, not the key, and a
|
|
127
|
+
// group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
|
|
128
|
+
// Sorted by hash so the list — and any action digest derived from it — is
|
|
129
|
+
// independent of dependency declaration order.
|
|
130
|
+
const graft = [...(input.upstreamGraft ?? [])].sort((a, b) =>
|
|
131
|
+
a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0,
|
|
132
|
+
)
|
|
133
|
+
// Output lists come from the local index — one SELECT for every upstream
|
|
134
|
+
// entry. An upstream with no entry (non-cacheable) contributes an empty list.
|
|
135
|
+
const rows = args.cache.loadOutputFilesBatch(graft.map((g) => g.hash))
|
|
136
|
+
const upstream = graft.map((g) => {
|
|
137
|
+
const outputs = (rows.get(g.hash) ?? []).map((r) =>
|
|
138
|
+
r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
139
|
+
? r.path.slice(WORKSPACE_OUTPUT_PREFIX.length)
|
|
140
|
+
: relPosix(input.workspaceRoot, path.join(g.projectDir, r.path)),
|
|
141
|
+
)
|
|
142
|
+
return { taskId: g.taskId, hash: g.hash, outputs }
|
|
143
|
+
})
|
|
144
|
+
return {
|
|
145
|
+
hash,
|
|
146
|
+
inputs: {
|
|
147
|
+
files,
|
|
148
|
+
env: input.envValues.map(([name, value]) => ({ name, value })),
|
|
149
|
+
runtime: (input.runtimeValues ?? []).map(([command, output]) => ({ command, output })),
|
|
150
|
+
workspaceRuntime: (input.workspaceRuntimeValues ?? []).map(([command, output]) => ({
|
|
151
|
+
command,
|
|
152
|
+
output,
|
|
153
|
+
})),
|
|
154
|
+
upstream,
|
|
155
|
+
packageJsonDigest: input.projectPackageJsonHash,
|
|
156
|
+
configDigest: input.taskConfigHash,
|
|
157
|
+
workspaceFingerprint: input.workspaceFingerprint,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
|
|
86
163
|
const cfg = args.node.config
|
|
87
164
|
const cacheCfg: CacheConfig | undefined = cfg.cache
|
|
88
165
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
@@ -100,6 +177,7 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
100
177
|
? {
|
|
101
178
|
runtimeCache: args.hashCache.runtime,
|
|
102
179
|
workspaceRuntimeCache: args.hashCache.workspaceRuntime,
|
|
180
|
+
workspaceFilesCache: args.hashCache.workspaceFiles,
|
|
103
181
|
}
|
|
104
182
|
: {}),
|
|
105
183
|
})
|
|
@@ -113,6 +191,16 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
113
191
|
const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
|
|
114
192
|
// hash → upstream task id, for capture-row naming only (not folded).
|
|
115
193
|
const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
|
|
194
|
+
// The DEPENDENCY closure, groups expanded — for the executor's input root.
|
|
195
|
+
// Deliberately NOT the filtered set above: `cache.inputs.tasks` says which
|
|
196
|
+
// upstream KEYS this task's key folds, and what a task may READ is
|
|
197
|
+
// `dependsOn`. Locally every dependency's outputs are on disk before the
|
|
198
|
+
// command runs however the filter is written, so an executor that ships the
|
|
199
|
+
// input closure has to ship the same thing or the task sees less remotely
|
|
200
|
+
// than it does here. Not folded — see `upstreamGraft`.
|
|
201
|
+
const upstreamGraft = expandGroupUpstream(args.upstream)
|
|
202
|
+
.filter((u) => u.hash !== undefined)
|
|
203
|
+
.map((u) => ({ taskId: u.node.id, hash: u.hash!, projectDir: u.node.projectDir }))
|
|
116
204
|
// Trusted index-OID map for this project (populated by the run's
|
|
117
205
|
// bulk `git ls-files -s` + `git status` pass). Mapped files skip
|
|
118
206
|
// hashFile entirely; everything else falls back to the identical
|
|
@@ -136,7 +224,7 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
136
224
|
|
|
137
225
|
const effectiveForwardArgs = args.node.requested ? (args.forwardArgs ?? []) : []
|
|
138
226
|
|
|
139
|
-
return
|
|
227
|
+
return {
|
|
140
228
|
taskId: args.node.id,
|
|
141
229
|
taskConfigHash,
|
|
142
230
|
projectPackageJsonHash,
|
|
@@ -147,11 +235,13 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
147
235
|
workspaceRoot: args.workspaceRoot,
|
|
148
236
|
upstreamHashes,
|
|
149
237
|
upstreamIds,
|
|
238
|
+
upstreamGraft,
|
|
150
239
|
workspaceFingerprint: args.workspaceFingerprint,
|
|
151
240
|
forwardArgs: effectiveForwardArgs,
|
|
152
241
|
...(fileHashes !== undefined ? { fileHashes } : {}),
|
|
242
|
+
...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
|
|
153
243
|
...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
|
|
154
|
-
}
|
|
244
|
+
}
|
|
155
245
|
}
|
|
156
246
|
|
|
157
247
|
/**
|
|
@@ -178,17 +268,23 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
178
268
|
}
|
|
179
269
|
|
|
180
270
|
/**
|
|
181
|
-
* Project the config for hashing: `exec.
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
271
|
+
* Project the config for hashing: `exec.remote` is a pure PLACEMENT hint
|
|
272
|
+
* with zero effect on outputs, so it is stripped — pinning a task to this
|
|
273
|
+
* machine never busts a cache. It must not: the whole contract of a remote
|
|
274
|
+
* executor is that the same command over the same inputs produces the
|
|
275
|
+
* same outputs, so a key that moved when placement changed would split a
|
|
276
|
+
* laptop from a worker pool over nothing. (`exec.resources` rode the same
|
|
277
|
+
* rule until 2026-09-12, when reservations left the config for the
|
|
278
|
+
* schedule plugin that learns them; a config never declares them now.) A
|
|
279
|
+
* config that declares no `remote` takes the fast path and stringifies
|
|
280
|
+
* byte-identically to before the field existed (why this needs no
|
|
281
|
+
* CACHE_VERSION bump). `timeout`/`retries` stay folded — their keys are
|
|
282
|
+
* distinct by design (see the decision log); stripping them retroactively
|
|
283
|
+
* would bump CACHE_VERSION.
|
|
188
284
|
*/
|
|
189
285
|
function hashableConfig(cfg: TaskConfig): unknown {
|
|
190
|
-
if (cfg.exec?.
|
|
191
|
-
const {
|
|
286
|
+
if (cfg.exec?.remote === undefined) return cfg
|
|
287
|
+
const { remote: _remote, ...execRest } = cfg.exec
|
|
192
288
|
return { ...cfg, exec: execRest }
|
|
193
289
|
}
|
|
194
290
|
|
|
@@ -249,5 +345,5 @@ async function doHashProjectPackageJson(
|
|
|
249
345
|
if (!(await Bun.file(filePath).exists())) return ''
|
|
250
346
|
// Route through the cache layer's mtime+size fast path so the
|
|
251
347
|
// typical re-run sees a stat + SQLite lookup instead of a file read.
|
|
252
|
-
return
|
|
348
|
+
return cache.hashFile(filePath)
|
|
253
349
|
}
|