@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,315 @@
|
|
|
1
|
+
// Turbo-style end-of-run summary. Always printed after a `vx run`
|
|
2
|
+
// invocation completes (success or failure). Counts come from the
|
|
3
|
+
// shared `tallyOutcomes` helper, which excludes group tasks — they
|
|
4
|
+
// aren't real work and would inflate "N total" misleadingly.
|
|
5
|
+
|
|
6
|
+
import type { TaskOutcome } from '../graph/index.js'
|
|
7
|
+
import { paint, type ColorSupport } from './colors.js'
|
|
8
|
+
import { tallyOutcomes } from './tally.js'
|
|
9
|
+
import { isGroupTask } from '../graph/index.js'
|
|
10
|
+
|
|
11
|
+
const NO_COLOR: ColorSupport = { enabled: false }
|
|
12
|
+
|
|
13
|
+
const SUCCESS = '#22c55e'
|
|
14
|
+
const WARN = '#eab308'
|
|
15
|
+
// Cache provenance hues (owner): local light blue, remote dark blue —
|
|
16
|
+
// yellow is skipped's color on both meters.
|
|
17
|
+
const LOCAL = '#38bdf8' // sky-400
|
|
18
|
+
const REMOTE = '#2563eb' // blue-600
|
|
19
|
+
const ERROR = '#ef4444'
|
|
20
|
+
|
|
21
|
+
const BAR_WIDTH = 50
|
|
22
|
+
// Brand gradient for the rule: identity violet → pink (the project /
|
|
23
|
+
// task hues), faded across the dash run. Plain dashes when colors
|
|
24
|
+
// are off.
|
|
25
|
+
const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
|
|
26
|
+
const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
|
|
27
|
+
// Rule ends flush with the bars: 2 indent + 8 label + 2 gap + 50 cells.
|
|
28
|
+
const RULE_DASHES = 57
|
|
29
|
+
|
|
30
|
+
/** Total visible rule width: `\u2500 <mark> ` + dashes. Shared by summary + header. */
|
|
31
|
+
export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
|
|
32
|
+
const head = `\u2500 `
|
|
33
|
+
const dashes = '\u2500'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
|
|
34
|
+
if (!colors.enabled) return `${head}${mark} ${dashes}`
|
|
35
|
+
const chunks = 8
|
|
36
|
+
const per = Math.ceil(RULE_DASHES / chunks)
|
|
37
|
+
let out = ''
|
|
38
|
+
for (let i = 0; i < chunks; i++) {
|
|
39
|
+
const seg = dashes.slice(i * per, (i + 1) * per)
|
|
40
|
+
if (seg.length === 0) break
|
|
41
|
+
const f = i / (chunks - 1)
|
|
42
|
+
const hex = `#${GRADIENT_FROM.map((c, j) =>
|
|
43
|
+
Math.round(c + (GRADIENT_TO[j]! - c) * f)
|
|
44
|
+
.toString(16)
|
|
45
|
+
.padStart(2, '0'),
|
|
46
|
+
).join('')}`
|
|
47
|
+
out += paint(hex, seg, colors)
|
|
48
|
+
}
|
|
49
|
+
return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stacked state meter: one ▰ run per bucket, proportional cells via
|
|
54
|
+
* largest-remainder, every non-zero bucket guaranteed at least one
|
|
55
|
+
* cell. Color carries the state; the color-coded numbers ride beside
|
|
56
|
+
* the bar.
|
|
57
|
+
*/
|
|
58
|
+
function segmentBar(
|
|
59
|
+
segments: readonly { n: number; color: string; glyph?: string; dim?: boolean }[],
|
|
60
|
+
colors: ColorSupport,
|
|
61
|
+
): string {
|
|
62
|
+
const total = segments.reduce((sum, seg) => sum + seg.n, 0)
|
|
63
|
+
if (total === 0) return ''
|
|
64
|
+
const exact = segments.map((seg) => (seg.n / total) * BAR_WIDTH)
|
|
65
|
+
const cells = exact.map(Math.floor)
|
|
66
|
+
let leftover = BAR_WIDTH - cells.reduce((sum, c) => sum + c, 0)
|
|
67
|
+
const order = exact
|
|
68
|
+
.map((e, i) => ({ i, frac: e - Math.floor(e) }))
|
|
69
|
+
.sort((x, y) => y.frac - x.frac)
|
|
70
|
+
for (const { i } of order) {
|
|
71
|
+
if (leftover === 0) break
|
|
72
|
+
cells[i]!++
|
|
73
|
+
leftover--
|
|
74
|
+
}
|
|
75
|
+
for (let i = 0; i < segments.length; i++) {
|
|
76
|
+
if (segments[i]!.n > 0 && cells[i] === 0) {
|
|
77
|
+
const biggest = cells.indexOf(Math.max(...cells))
|
|
78
|
+
cells[biggest]!--
|
|
79
|
+
cells[i] = 1
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return segments
|
|
83
|
+
.map((seg, i) =>
|
|
84
|
+
paint(seg.color, (seg.glyph ?? '\u25b0').repeat(cells[i]!), colors, {
|
|
85
|
+
...(seg.dim === true ? { dim: true } : {}),
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
.join('')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Aggregate counts feeding the summary section — buildable from a
|
|
92
|
+
* finished outcome list (formatRunSummary) or incrementally by the
|
|
93
|
+
* live logger, so the in-flight region and the final summary are the
|
|
94
|
+
* SAME section, one filling in as the other will print. */
|
|
95
|
+
export interface SummaryStats {
|
|
96
|
+
failed: number
|
|
97
|
+
/** Ended OK — executed successes AND cache hits. */
|
|
98
|
+
successful: number
|
|
99
|
+
skipped: number
|
|
100
|
+
total: number
|
|
101
|
+
upToDate: number
|
|
102
|
+
restoredLocal: number
|
|
103
|
+
restoredRemote: number
|
|
104
|
+
/** Tasks that executed (success + failed) — the cache misses. */
|
|
105
|
+
miss: number
|
|
106
|
+
/** Still to run (live section only; 0 in the final summary) — renders
|
|
107
|
+
* as a gray ▱ remainder so the live meters FILL toward the final. */
|
|
108
|
+
left?: number
|
|
109
|
+
/** Cache-miss duration spread (only tasks that actually executed). */
|
|
110
|
+
spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run context the footer carries (final summary only — the live region
|
|
115
|
+
* passes none). This is the data the top-of-run header used to show:
|
|
116
|
+
* what the run covered + version + cache mode. Folded into the footer
|
|
117
|
+
* so the run has one banner, at the end, where the eye lands.
|
|
118
|
+
*/
|
|
119
|
+
export interface RunContext {
|
|
120
|
+
version: string
|
|
121
|
+
/** Projects covered by the graph — the "affected" half of the bar. */
|
|
122
|
+
packageCount: number
|
|
123
|
+
/** Worker-pool size for this run; shown on the `info` row. */
|
|
124
|
+
concurrency?: number
|
|
125
|
+
remoteCacheEnabled: boolean
|
|
126
|
+
/** Total projects discovery found — the bar's denominator. */
|
|
127
|
+
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
|
+
}
|
|
137
|
+
|
|
138
|
+
export function formatSummarySection(
|
|
139
|
+
stats: SummaryStats,
|
|
140
|
+
totalMs: number,
|
|
141
|
+
colors: ColorSupport = NO_COLOR,
|
|
142
|
+
context?: RunContext,
|
|
143
|
+
): string[] {
|
|
144
|
+
const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
|
|
145
|
+
const miss = stats.miss
|
|
146
|
+
const left = stats.left ?? 0
|
|
147
|
+
const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
|
|
148
|
+
|
|
149
|
+
const dim = (txt: string) => paint('', txt, colors, { dim: true })
|
|
150
|
+
const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
|
|
151
|
+
const join = (parts: string[]): string => parts.join(` ${dim('\u00b7')} `)
|
|
152
|
+
|
|
153
|
+
// Tasks meter + numbers, fixed order: failed · success · skipped,
|
|
154
|
+
// then a dim total so the legend reads against the whole graph.
|
|
155
|
+
const taskParts: string[] = []
|
|
156
|
+
if (stats.failed > 0)
|
|
157
|
+
taskParts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
|
|
158
|
+
if (stats.successful > 0) taskParts.push(paint(SUCCESS, `${stats.successful} success`, colors))
|
|
159
|
+
if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
|
|
160
|
+
if (stats.total > 0) taskParts.push(dim(`${stats.total} total`))
|
|
161
|
+
const taskBar = segmentBar(
|
|
162
|
+
[
|
|
163
|
+
{ n: stats.failed, color: ERROR },
|
|
164
|
+
{ n: stats.successful, color: SUCCESS },
|
|
165
|
+
{ n: stats.skipped, color: WARN },
|
|
166
|
+
remainder,
|
|
167
|
+
],
|
|
168
|
+
colors,
|
|
169
|
+
)
|
|
170
|
+
// Bars on the label row, color-coded legend on its own line below,
|
|
171
|
+
// indented to the bar column (2 indent + 8 label + 2 gap = 12).
|
|
172
|
+
const legend = (parts: string[]): string => `${' '.repeat(12)}${join(parts)}`
|
|
173
|
+
// Version rides the wordmark rule when run context is present (final
|
|
174
|
+
// footer); the live region passes none and keeps a bare `vx`.
|
|
175
|
+
const lines: string[] = [
|
|
176
|
+
'',
|
|
177
|
+
gradientRule(colors, context !== undefined ? `vx ${context.version}` : 'vx'),
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
// Run context (final footer only): the projects bar leads the meter
|
|
181
|
+
// stack — affected (yellow) vs the rest of the workspace (dim).
|
|
182
|
+
if (context !== undefined) {
|
|
183
|
+
const wsTotal = context.workspaceProjectCount
|
|
184
|
+
if (wsTotal !== undefined && wsTotal > 0) {
|
|
185
|
+
const cells = Math.min(
|
|
186
|
+
BAR_WIDTH,
|
|
187
|
+
Math.max(1, Math.round((context.packageCount / wsTotal) * BAR_WIDTH)),
|
|
188
|
+
)
|
|
189
|
+
const bar =
|
|
190
|
+
paint(WARN, '▰'.repeat(cells), colors) +
|
|
191
|
+
paint('', '▱'.repeat(BAR_WIDTH - cells), colors, { dim: true })
|
|
192
|
+
lines.push(
|
|
193
|
+
row('projects', bar),
|
|
194
|
+
legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (taskBar.length > 0) {
|
|
200
|
+
lines.push(row('tasks', taskBar))
|
|
201
|
+
// No legend line until the first bucket lands (live: all-gray bar).
|
|
202
|
+
if (taskParts.length > 0) lines.push(legend(taskParts))
|
|
203
|
+
} else {
|
|
204
|
+
lines.push(row('tasks', dim('0 tasks')))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Cache meter + numbers, fixed order: miss · up-to-date · local ·
|
|
208
|
+
// remote.
|
|
209
|
+
if (miss + hits + left + stats.skipped > 0) {
|
|
210
|
+
const cacheBar = segmentBar(
|
|
211
|
+
[
|
|
212
|
+
{ n: miss, color: ERROR },
|
|
213
|
+
{ n: stats.upToDate, color: SUCCESS },
|
|
214
|
+
{ n: stats.restoredLocal, color: LOCAL },
|
|
215
|
+
{ n: stats.restoredRemote, color: REMOTE },
|
|
216
|
+
{ n: stats.skipped, color: WARN },
|
|
217
|
+
remainder,
|
|
218
|
+
],
|
|
219
|
+
colors,
|
|
220
|
+
)
|
|
221
|
+
const cacheParts: string[] = []
|
|
222
|
+
if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
|
|
223
|
+
if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
|
|
224
|
+
if (stats.restoredLocal > 0)
|
|
225
|
+
cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
|
|
226
|
+
if (stats.restoredRemote > 0)
|
|
227
|
+
cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
|
|
228
|
+
// Skipped tasks have no cache provenance, but omitting them made
|
|
229
|
+
// the cache legend sum below the tasks legend (owner-reported
|
|
230
|
+
// confusion) — on the bar and in the legend, yellow like the
|
|
231
|
+
// tasks meter.
|
|
232
|
+
if (stats.skipped > 0) cacheParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
|
|
233
|
+
lines.push(row('cache', cacheBar))
|
|
234
|
+
if (cacheParts.length > 0) lines.push(legend(cacheParts))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Cache-miss duration spread (owner: hits' restore times polluted
|
|
238
|
+
// it — only tasks that actually executed count). Dim: context.
|
|
239
|
+
let spread = ''
|
|
240
|
+
if (stats.spread !== null && stats.spread.count > 0) {
|
|
241
|
+
const { maxMs, minMs, sumMs, count } = stats.spread
|
|
242
|
+
spread = ` ${dim(`\u00b7 max ${formatDuration(maxMs)} \u00b7 avg ${formatDuration(sumMs / count)} \u00b7 min ${formatDuration(minMs)}`)}`
|
|
243
|
+
}
|
|
244
|
+
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
245
|
+
// grouped with time under a blank line below the meters.
|
|
246
|
+
if (context !== undefined) {
|
|
247
|
+
const info: string[] = []
|
|
248
|
+
if (context.concurrency !== undefined)
|
|
249
|
+
info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
|
|
250
|
+
info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
|
|
251
|
+
if (context.cpuBudget !== undefined) info.push(`cpu budget ${context.cpuBudget}`)
|
|
252
|
+
if (context.memBudget !== undefined && Number.isFinite(context.memBudget))
|
|
253
|
+
info.push(`mem budget ${formatBudgetBytes(context.memBudget)}`)
|
|
254
|
+
lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
|
|
255
|
+
} else {
|
|
256
|
+
lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
|
|
257
|
+
}
|
|
258
|
+
return lines
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
262
|
+
// (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
|
+
export function formatRunSummary(
|
|
275
|
+
outcomes: readonly TaskOutcome[],
|
|
276
|
+
totalMs: number,
|
|
277
|
+
colors: ColorSupport = NO_COLOR,
|
|
278
|
+
context?: RunContext,
|
|
279
|
+
): string[] {
|
|
280
|
+
const t = tallyOutcomes(outcomes)
|
|
281
|
+
// Spread over executed tasks only — `success`/`failed` statuses ran
|
|
282
|
+
// a command; cache-hit statuses replayed one.
|
|
283
|
+
const durations = outcomes
|
|
284
|
+
.filter((o) => (o.status === 'success' || o.status === 'failed') && !isGroupTask(o.node))
|
|
285
|
+
.map((o) => o.durationMs)
|
|
286
|
+
return formatSummarySection(
|
|
287
|
+
{
|
|
288
|
+
failed: t.failed,
|
|
289
|
+
successful: t.successful,
|
|
290
|
+
skipped: t.skipped,
|
|
291
|
+
total: t.total,
|
|
292
|
+
upToDate: t.upToDate,
|
|
293
|
+
restoredLocal: t.restoredLocal,
|
|
294
|
+
restoredRemote: t.restoredRemote,
|
|
295
|
+
miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote),
|
|
296
|
+
spread:
|
|
297
|
+
durations.length > 0
|
|
298
|
+
? {
|
|
299
|
+
maxMs: Math.max(...durations),
|
|
300
|
+
minMs: Math.min(...durations),
|
|
301
|
+
sumMs: durations.reduce((sum, d) => sum + d, 0),
|
|
302
|
+
count: durations.length,
|
|
303
|
+
}
|
|
304
|
+
: null,
|
|
305
|
+
},
|
|
306
|
+
totalMs,
|
|
307
|
+
colors,
|
|
308
|
+
context,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function formatDuration(ms: number): string {
|
|
313
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
314
|
+
return `${(ms / 1000).toFixed(2)}s`
|
|
315
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Shared outcome-tally helper. Both `summary.ts` (end-of-run terminal
|
|
2
|
+
// block) and `run-artifacts.ts` (`--summarize` JSON) count the same
|
|
3
|
+
// numbers; centralising it keeps the two surfaces consistent.
|
|
4
|
+
|
|
5
|
+
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
6
|
+
|
|
7
|
+
export interface Tally {
|
|
8
|
+
successful: number
|
|
9
|
+
failed: number
|
|
10
|
+
skipped: number
|
|
11
|
+
cachedLocal: number
|
|
12
|
+
/** Hits that materialized files from the local artifact. */
|
|
13
|
+
restoredLocal: number
|
|
14
|
+
/** Hits that materialized files pulled from the remote layer. */
|
|
15
|
+
restoredRemote: number
|
|
16
|
+
/** Cache hits whose tree already matched — nothing was written. */
|
|
17
|
+
upToDate: number
|
|
18
|
+
cachedRemote: number
|
|
19
|
+
/** Tasks counted toward `total`. Group tasks are excluded by default. */
|
|
20
|
+
total: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Walk the outcome list and return a `Tally`. Group tasks (no `exec`)
|
|
25
|
+
* are filtered out — they aren't real work, and including them
|
|
26
|
+
* inflates the per-run "N total" count in ways that mislead the user
|
|
27
|
+
* ("3 of 4 cached" when one was a group that ran nothing).
|
|
28
|
+
*
|
|
29
|
+
* Status bucket rules:
|
|
30
|
+
* `success` → +successful
|
|
31
|
+
* `cache-hit` → +successful, +cachedLocal
|
|
32
|
+
* `cache-hit-remote` → +successful, +cachedRemote
|
|
33
|
+
* `failed` → +failed
|
|
34
|
+
* `skipped` → +skipped
|
|
35
|
+
*/
|
|
36
|
+
export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
|
|
37
|
+
const t: Tally = {
|
|
38
|
+
successful: 0,
|
|
39
|
+
failed: 0,
|
|
40
|
+
skipped: 0,
|
|
41
|
+
cachedLocal: 0,
|
|
42
|
+
restoredLocal: 0,
|
|
43
|
+
restoredRemote: 0,
|
|
44
|
+
upToDate: 0,
|
|
45
|
+
cachedRemote: 0,
|
|
46
|
+
total: 0,
|
|
47
|
+
}
|
|
48
|
+
for (const o of outcomes) {
|
|
49
|
+
if (isGroupTask(o.node)) continue
|
|
50
|
+
// aborted (killed by a shutdown signal) is no work — excluded
|
|
51
|
+
// from totals and every bucket, exactly like a group task.
|
|
52
|
+
if (o.status === 'aborted') continue
|
|
53
|
+
t.total++
|
|
54
|
+
if (o.status === 'success') t.successful++
|
|
55
|
+
else if (o.status === 'cache-hit') {
|
|
56
|
+
t.successful++
|
|
57
|
+
t.cachedLocal++
|
|
58
|
+
if (o.restored === true) t.restoredLocal++
|
|
59
|
+
else t.upToDate++
|
|
60
|
+
} else if (o.status === 'cache-hit-remote') {
|
|
61
|
+
t.successful++
|
|
62
|
+
t.cachedRemote++
|
|
63
|
+
if (o.restored === true) t.restoredRemote++
|
|
64
|
+
else t.upToDate++
|
|
65
|
+
} else if (o.status === 'failed') t.failed++
|
|
66
|
+
else if (o.status === 'skipped') t.skipped++
|
|
67
|
+
}
|
|
68
|
+
return t
|
|
69
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import type { TaskConfig, CacheConfig } from '../config.js'
|
|
3
|
+
import { type CacheLayer, resolveInputs, type GitFilesCache } from '../cache/index.js'
|
|
4
|
+
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
5
|
+
import { xxh3hex } from '../util/index.js'
|
|
6
|
+
import { filterUpstreamHashes } from './upstream.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-run memoization caches for the two derived hashes that don't
|
|
10
|
+
* change across tasks within one run:
|
|
11
|
+
* - `packageJson`: keyed by absolute projectDir. Every task in a
|
|
12
|
+
* project re-reads the same `package.json`; without this, a
|
|
13
|
+
* monorepo with N projects × M tasks per project does N×M reads
|
|
14
|
+
* of the same bytes.
|
|
15
|
+
* - `taskConfig`: keyed by the resolved-config object reference.
|
|
16
|
+
* Each task's config is created once at prepareRun time; the
|
|
17
|
+
* JSON.stringify + xxh3 of it is deterministic. WeakMap so
|
|
18
|
+
* entries free when the orchestrator is done.
|
|
19
|
+
*
|
|
20
|
+
* Both fields are optional — the helpers fall back to computing
|
|
21
|
+
* fresh when the cache is missing.
|
|
22
|
+
*/
|
|
23
|
+
export interface HashCache {
|
|
24
|
+
packageJson: Map<string, Promise<string>>
|
|
25
|
+
taskConfig: WeakMap<TaskConfig, string>
|
|
26
|
+
runtime: Map<string, Promise<string>>
|
|
27
|
+
workspaceRuntime: Map<string, Promise<string>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createHashCache(): HashCache {
|
|
31
|
+
return {
|
|
32
|
+
packageJson: new Map(),
|
|
33
|
+
taskConfig: new WeakMap(),
|
|
34
|
+
runtime: new Map(),
|
|
35
|
+
workspaceRuntime: new Map(),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One cache-key component captured at hash time. On a cache MISS the
|
|
41
|
+
* orchestrator persists these to `entry_inputs` (keyed by the entry
|
|
42
|
+
* hash, inside the save transaction) so a later run can diff its inputs
|
|
43
|
+
* against this one (the Tier-3 "why did this re-run?" moat). A cache
|
|
44
|
+
* HIT never saves, so it captures nothing — the warm path is free.
|
|
45
|
+
* Mirrors `Cache.key()`'s fold-site rows one-for-one.
|
|
46
|
+
*/
|
|
47
|
+
export interface TaskInputComponent {
|
|
48
|
+
kind: string
|
|
49
|
+
name: string
|
|
50
|
+
hash: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ComputeHashArgs {
|
|
54
|
+
node: TaskNode
|
|
55
|
+
upstream: TaskOutcome[]
|
|
56
|
+
workspaceRoot: string
|
|
57
|
+
workspaceFingerprint: string
|
|
58
|
+
cache: CacheLayer
|
|
59
|
+
forwardArgs?: readonly string[] | undefined
|
|
60
|
+
nestedProjectDirs: string[]
|
|
61
|
+
gitFilesCache?: GitFilesCache
|
|
62
|
+
hashCache?: HashCache
|
|
63
|
+
/**
|
|
64
|
+
* When provided, the resolved `CacheKeyInput` components are pushed
|
|
65
|
+
* here (one per key contribution) so the caller can persist them.
|
|
66
|
+
* No effect on the returned hash — pure capture of values `key()`
|
|
67
|
+
* already folds. The push happens inside `cache.key()` at each fold
|
|
68
|
+
* site, so the recorded set can't drift from the key.
|
|
69
|
+
*/
|
|
70
|
+
captureInto?: TaskInputComponent[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve every input the cache key depends on (file content, env
|
|
75
|
+
* values, upstream hashes, config bytes, project package.json bytes,
|
|
76
|
+
* forwardArgs) and ask the cache layer to combine them. Used by both
|
|
77
|
+
* `executeTask` for the real run and `plan()` for `--dry-run` /
|
|
78
|
+
* `--graph` previews.
|
|
79
|
+
*
|
|
80
|
+
* Caller is responsible for handling the group-task case before
|
|
81
|
+
* calling this — groups have no `exec` and no `cache.inputs`, so the
|
|
82
|
+
* key-derivation steps below would all fall back to defaults.
|
|
83
|
+
* `computeGroupHash` is exported for that purpose.
|
|
84
|
+
*/
|
|
85
|
+
export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
86
|
+
const cfg = args.node.config
|
|
87
|
+
const cacheCfg: CacheConfig | undefined = cfg.cache
|
|
88
|
+
const outputs = cacheCfg?.outputs.files ?? []
|
|
89
|
+
|
|
90
|
+
const resolved = await resolveInputs({
|
|
91
|
+
projectDir: args.node.projectDir,
|
|
92
|
+
workspaceRoot: args.workspaceRoot,
|
|
93
|
+
envSource: process.env,
|
|
94
|
+
inputs: cacheCfg?.inputs,
|
|
95
|
+
ownOutputs: outputs,
|
|
96
|
+
ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
|
|
97
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
98
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
99
|
+
...(args.hashCache !== undefined
|
|
100
|
+
? {
|
|
101
|
+
runtimeCache: args.hashCache.runtime,
|
|
102
|
+
workspaceRuntimeCache: args.hashCache.workspaceRuntime,
|
|
103
|
+
}
|
|
104
|
+
: {}),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const upstreamPairs = filterUpstreamHashes(
|
|
108
|
+
args.upstream,
|
|
109
|
+
cacheCfg?.inputs?.tasks,
|
|
110
|
+
args.node.projectName,
|
|
111
|
+
args.node.id,
|
|
112
|
+
)
|
|
113
|
+
const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
|
|
114
|
+
// hash → upstream task id, for capture-row naming only (not folded).
|
|
115
|
+
const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
|
|
116
|
+
// Trusted index-OID map for this project (populated by the run's
|
|
117
|
+
// bulk `git ls-files -s` + `git status` pass). Mapped files skip
|
|
118
|
+
// hashFile entirely; everything else falls back to the identical
|
|
119
|
+
// in-process blob-OID computation. Tasks declaring workspaceFiles
|
|
120
|
+
// also merge the workspace-wide partition's OIDs (keyed by abs
|
|
121
|
+
// path, so the two maps agree wherever they overlap).
|
|
122
|
+
let fileHashes = args.gitFilesCache?.oidsFor(args.node.projectDir)
|
|
123
|
+
if ((cacheCfg?.inputs.workspaceFiles?.length ?? 0) > 0) {
|
|
124
|
+
const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
125
|
+
if (wsOids !== undefined && wsOids !== fileHashes) {
|
|
126
|
+
fileHashes = fileHashes === undefined ? wsOids : new Map([...wsOids, ...fileHashes])
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const taskConfigHash = hashTaskConfig(cfg, args.hashCache)
|
|
130
|
+
const projectPackageJsonHash = await hashProjectPackageJson(
|
|
131
|
+
args.node.projectDir,
|
|
132
|
+
args.cache,
|
|
133
|
+
args.hashCache,
|
|
134
|
+
fileHashes,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const effectiveForwardArgs = args.node.requested ? (args.forwardArgs ?? []) : []
|
|
138
|
+
|
|
139
|
+
return await args.cache.key({
|
|
140
|
+
taskId: args.node.id,
|
|
141
|
+
taskConfigHash,
|
|
142
|
+
projectPackageJsonHash,
|
|
143
|
+
envValues: resolved.envValues,
|
|
144
|
+
runtimeValues: resolved.runtimeValues,
|
|
145
|
+
workspaceRuntimeValues: resolved.workspaceRuntimeValues,
|
|
146
|
+
inputFiles: resolved.files,
|
|
147
|
+
workspaceRoot: args.workspaceRoot,
|
|
148
|
+
upstreamHashes,
|
|
149
|
+
upstreamIds,
|
|
150
|
+
workspaceFingerprint: args.workspaceFingerprint,
|
|
151
|
+
forwardArgs: effectiveForwardArgs,
|
|
152
|
+
...(fileHashes !== undefined ? { fileHashes } : {}),
|
|
153
|
+
...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Hash the resolved task config. Folds every config-time decision
|
|
159
|
+
* (command, env names, dependsOn, cache directives, outputs,
|
|
160
|
+
* passThroughEnv list, etc.) into the cache key. Imported values
|
|
161
|
+
* participate because the loader has already baked them into the
|
|
162
|
+
* resolved object before we serialize.
|
|
163
|
+
*
|
|
164
|
+
* The schema is JSON-serializable by construction (no functions in
|
|
165
|
+
* fields). The `hashCache.taskConfig` WeakMap is consulted first —
|
|
166
|
+
* each task's config object is created once per run, so a hit there
|
|
167
|
+
* skips the JSON.stringify + xxh3 entirely.
|
|
168
|
+
*/
|
|
169
|
+
function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
170
|
+
if (hashCache) {
|
|
171
|
+
const cached = hashCache.taskConfig.get(cfg)
|
|
172
|
+
if (cached !== undefined) return cached
|
|
173
|
+
const hash = xxh3hex(JSON.stringify(hashableConfig(cfg)))
|
|
174
|
+
hashCache.taskConfig.set(cfg, hash)
|
|
175
|
+
return hash
|
|
176
|
+
}
|
|
177
|
+
return xxh3hex(JSON.stringify(hashableConfig(cfg)))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Project the config for hashing: `exec.resources` is a pure scheduling
|
|
182
|
+
* hint with zero effect on outputs, so it's stripped — tuning a
|
|
183
|
+
* reservation never busts a cache. A config that declares none takes the
|
|
184
|
+
* fast path and stringifies byte-identically to before the field existed
|
|
185
|
+
* (why this needs no CACHE_VERSION bump). `timeout`/`retries` stay folded
|
|
186
|
+
* — their keys are distinct by design (see the decision log); stripping
|
|
187
|
+
* them retroactively would bump CACHE_VERSION.
|
|
188
|
+
*/
|
|
189
|
+
function hashableConfig(cfg: TaskConfig): unknown {
|
|
190
|
+
if (cfg.exec?.resources === undefined) return cfg
|
|
191
|
+
const { resources: _resources, ...execRest } = cfg.exec
|
|
192
|
+
return { ...cfg, exec: execRest }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Derive a stable hash for a group task (no `exec`) from its upstream
|
|
197
|
+
* outcomes. Lets downstream tasks that filter `inputs.tasks` to
|
|
198
|
+
* include a group still invalidate naturally when anything beneath
|
|
199
|
+
* the group changes. Sorted so the hash is order-independent. Empty
|
|
200
|
+
* group (no upstream) yields a fixed sentinel hash.
|
|
201
|
+
*/
|
|
202
|
+
export function computeGroupHash(upstream: TaskOutcome[]): string {
|
|
203
|
+
const ids = upstream
|
|
204
|
+
.map((u) => `${u.node.id}:${u.hash ?? ''}`)
|
|
205
|
+
.sort()
|
|
206
|
+
.join('|')
|
|
207
|
+
return xxh3hex(`group|${ids}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Hash the project's `package.json` bytes. Folded into every task's
|
|
212
|
+
* cache key in that project so dep changes (devDependencies, scripts,
|
|
213
|
+
* version bumps) invalidate the project's tasks even when
|
|
214
|
+
* `cache.inputs.files` is narrow and doesn't cover the file.
|
|
215
|
+
*
|
|
216
|
+
* Matches Turbo and Nx's "implicit dependencies" behavior. Returns
|
|
217
|
+
* '' for the edge case of a project without a package.json (impossible
|
|
218
|
+
* in practice — workspace discovery requires one).
|
|
219
|
+
*
|
|
220
|
+
* Two optimizations: routed through `Cache.hashFile` so the mtime+size
|
|
221
|
+
* fast path applies (an unchanged pkg.json takes a stat + SQLite SELECT
|
|
222
|
+
* instead of a full file read). And memoized within-run per projectDir
|
|
223
|
+
* so a monorepo with 100 projects × 3 tasks each does 100 lookups, not
|
|
224
|
+
* 300.
|
|
225
|
+
*/
|
|
226
|
+
async function hashProjectPackageJson(
|
|
227
|
+
projectDir: string,
|
|
228
|
+
cache: CacheLayer,
|
|
229
|
+
hashCache?: HashCache,
|
|
230
|
+
fileHashes?: ReadonlyMap<string, string>,
|
|
231
|
+
): Promise<string> {
|
|
232
|
+
const cached = hashCache?.packageJson.get(projectDir)
|
|
233
|
+
if (cached !== undefined) return cached
|
|
234
|
+
const promise = doHashProjectPackageJson(projectDir, cache, fileHashes)
|
|
235
|
+
hashCache?.packageJson.set(projectDir, promise)
|
|
236
|
+
return promise
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function doHashProjectPackageJson(
|
|
240
|
+
projectDir: string,
|
|
241
|
+
cache: CacheLayer,
|
|
242
|
+
fileHashes?: ReadonlyMap<string, string>,
|
|
243
|
+
): Promise<string> {
|
|
244
|
+
const filePath = path.join(projectDir, 'package.json')
|
|
245
|
+
// Clean tracked package.json: its index OID is already in hand —
|
|
246
|
+
// no exists probe, no stat, no SQLite.
|
|
247
|
+
const oid = fileHashes?.get(filePath)
|
|
248
|
+
if (oid !== undefined) return oid
|
|
249
|
+
if (!(await Bun.file(filePath).exists())) return ''
|
|
250
|
+
// Route through the cache layer's mtime+size fast path so the
|
|
251
|
+
// typical re-run sees a stat + SQLite lookup instead of a file read.
|
|
252
|
+
return await cache.hashFile(filePath)
|
|
253
|
+
}
|