@vzn/vx 0.0.0 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,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
+ }