@vzn/vx 0.0.0 → 0.0.12

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 +332 -1
  3. package/launcher.mjs +84 -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 +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.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 +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,291 @@
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
+
130
+ export function formatSummarySection(
131
+ stats: SummaryStats,
132
+ totalMs: number,
133
+ colors: ColorSupport = NO_COLOR,
134
+ context?: RunContext,
135
+ ): string[] {
136
+ const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
137
+ const miss = stats.miss
138
+ const left = stats.left ?? 0
139
+ const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
140
+
141
+ const dim = (txt: string) => paint('', txt, colors, { dim: true })
142
+ const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
143
+ const join = (parts: string[]): string => parts.join(` ${dim('\u00b7')} `)
144
+
145
+ // Tasks meter + numbers, fixed order: failed · success · skipped,
146
+ // then a dim total so the legend reads against the whole graph.
147
+ const taskParts: string[] = []
148
+ if (stats.failed > 0)
149
+ taskParts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
150
+ if (stats.successful > 0) taskParts.push(paint(SUCCESS, `${stats.successful} success`, colors))
151
+ if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
152
+ if (stats.total > 0) taskParts.push(dim(`${stats.total} total`))
153
+ const taskBar = segmentBar(
154
+ [
155
+ { n: stats.failed, color: ERROR },
156
+ { n: stats.successful, color: SUCCESS },
157
+ { n: stats.skipped, color: WARN },
158
+ remainder,
159
+ ],
160
+ colors,
161
+ )
162
+ // Bars on the label row, color-coded legend on its own line below,
163
+ // indented to the bar column (2 indent + 8 label + 2 gap = 12).
164
+ const legend = (parts: string[]): string => `${' '.repeat(12)}${join(parts)}`
165
+ // Version rides the wordmark rule when run context is present (final
166
+ // footer); the live region passes none and keeps a bare `vx`.
167
+ const lines: string[] = [
168
+ '',
169
+ gradientRule(colors, context !== undefined ? `vx ${context.version}` : 'vx'),
170
+ ]
171
+
172
+ // Run context (final footer only): the projects bar leads the meter
173
+ // stack — affected (yellow) vs the rest of the workspace (dim).
174
+ if (context !== undefined) {
175
+ const wsTotal = context.workspaceProjectCount
176
+ if (wsTotal !== undefined && wsTotal > 0) {
177
+ const cells = Math.min(
178
+ BAR_WIDTH,
179
+ Math.max(1, Math.round((context.packageCount / wsTotal) * BAR_WIDTH)),
180
+ )
181
+ const bar =
182
+ paint(WARN, '▰'.repeat(cells), colors) +
183
+ paint('', '▱'.repeat(BAR_WIDTH - cells), colors, { dim: true })
184
+ lines.push(
185
+ row('projects', bar),
186
+ legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
187
+ )
188
+ }
189
+ }
190
+
191
+ if (taskBar.length > 0) {
192
+ lines.push(row('tasks', taskBar))
193
+ // No legend line until the first bucket lands (live: all-gray bar).
194
+ if (taskParts.length > 0) lines.push(legend(taskParts))
195
+ } else {
196
+ lines.push(row('tasks', dim('0 tasks')))
197
+ }
198
+
199
+ // Cache meter + numbers, fixed order: miss · up-to-date · local ·
200
+ // remote.
201
+ if (miss + hits + left + stats.skipped > 0) {
202
+ const cacheBar = segmentBar(
203
+ [
204
+ { n: miss, color: ERROR },
205
+ { n: stats.upToDate, color: SUCCESS },
206
+ { n: stats.restoredLocal, color: LOCAL },
207
+ { n: stats.restoredRemote, color: REMOTE },
208
+ { n: stats.skipped, color: WARN },
209
+ remainder,
210
+ ],
211
+ colors,
212
+ )
213
+ const cacheParts: string[] = []
214
+ if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
215
+ if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
216
+ if (stats.restoredLocal > 0)
217
+ cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
218
+ if (stats.restoredRemote > 0)
219
+ cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
220
+ // Skipped tasks have no cache provenance, but omitting them made
221
+ // the cache legend sum below the tasks legend (owner-reported
222
+ // confusion) — on the bar and in the legend, yellow like the
223
+ // tasks meter.
224
+ if (stats.skipped > 0) cacheParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
225
+ lines.push(row('cache', cacheBar))
226
+ if (cacheParts.length > 0) lines.push(legend(cacheParts))
227
+ }
228
+
229
+ // Cache-miss duration spread (owner: hits' restore times polluted
230
+ // it — only tasks that actually executed count). Dim: context.
231
+ let spread = ''
232
+ if (stats.spread !== null && stats.spread.count > 0) {
233
+ const { maxMs, minMs, sumMs, count } = stats.spread
234
+ spread = ` ${dim(`\u00b7 max ${formatDuration(maxMs)} \u00b7 avg ${formatDuration(sumMs / count)} \u00b7 min ${formatDuration(minMs)}`)}`
235
+ }
236
+ // Run-shape footer (final summary only): worker pool + cache mode,
237
+ // grouped with time under a blank line below the meters.
238
+ if (context !== undefined) {
239
+ const info: string[] = []
240
+ if (context.concurrency !== undefined)
241
+ info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
242
+ info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
243
+ lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
244
+ } else {
245
+ lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
246
+ }
247
+ return lines
248
+ }
249
+
250
+ export function formatRunSummary(
251
+ outcomes: readonly TaskOutcome[],
252
+ totalMs: number,
253
+ colors: ColorSupport = NO_COLOR,
254
+ context?: RunContext,
255
+ ): string[] {
256
+ const t = tallyOutcomes(outcomes)
257
+ // Spread over executed tasks only — `success`/`failed` statuses ran
258
+ // a command; cache-hit statuses replayed one.
259
+ const durations = outcomes
260
+ .filter((o) => (o.status === 'success' || o.status === 'failed') && !isGroupTask(o.node))
261
+ .map((o) => o.durationMs)
262
+ return formatSummarySection(
263
+ {
264
+ failed: t.failed,
265
+ successful: t.successful,
266
+ skipped: t.skipped,
267
+ total: t.total,
268
+ upToDate: t.upToDate,
269
+ restoredLocal: t.restoredLocal,
270
+ restoredRemote: t.restoredRemote,
271
+ miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote),
272
+ spread:
273
+ durations.length > 0
274
+ ? {
275
+ maxMs: Math.max(...durations),
276
+ minMs: Math.min(...durations),
277
+ sumMs: durations.reduce((sum, d) => sum + d, 0),
278
+ count: durations.length,
279
+ }
280
+ : null,
281
+ },
282
+ totalMs,
283
+ colors,
284
+ context,
285
+ )
286
+ }
287
+
288
+ export function formatDuration(ms: number): string {
289
+ if (ms < 1000) return `${Math.round(ms)}ms`
290
+ return `${(ms / 1000).toFixed(2)}s`
291
+ }
@@ -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,238 @@
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(cfg))
174
+ hashCache.taskConfig.set(cfg, hash)
175
+ return hash
176
+ }
177
+ return xxh3hex(JSON.stringify(cfg))
178
+ }
179
+
180
+ /**
181
+ * Derive a stable hash for a group task (no `exec`) from its upstream
182
+ * outcomes. Lets downstream tasks that filter `inputs.tasks` to
183
+ * include a group still invalidate naturally when anything beneath
184
+ * the group changes. Sorted so the hash is order-independent. Empty
185
+ * group (no upstream) yields a fixed sentinel hash.
186
+ */
187
+ export function computeGroupHash(upstream: TaskOutcome[]): string {
188
+ const ids = upstream
189
+ .map((u) => `${u.node.id}:${u.hash ?? ''}`)
190
+ .sort()
191
+ .join('|')
192
+ return xxh3hex(`group|${ids}`)
193
+ }
194
+
195
+ /**
196
+ * Hash the project's `package.json` bytes. Folded into every task's
197
+ * cache key in that project so dep changes (devDependencies, scripts,
198
+ * version bumps) invalidate the project's tasks even when
199
+ * `cache.inputs.files` is narrow and doesn't cover the file.
200
+ *
201
+ * Matches Turbo and Nx's "implicit dependencies" behavior. Returns
202
+ * '' for the edge case of a project without a package.json (impossible
203
+ * in practice — workspace discovery requires one).
204
+ *
205
+ * Two optimizations: routed through `Cache.hashFile` so the mtime+size
206
+ * fast path applies (an unchanged pkg.json takes a stat + SQLite SELECT
207
+ * instead of a full file read). And memoized within-run per projectDir
208
+ * so a monorepo with 100 projects × 3 tasks each does 100 lookups, not
209
+ * 300.
210
+ */
211
+ async function hashProjectPackageJson(
212
+ projectDir: string,
213
+ cache: CacheLayer,
214
+ hashCache?: HashCache,
215
+ fileHashes?: ReadonlyMap<string, string>,
216
+ ): Promise<string> {
217
+ const cached = hashCache?.packageJson.get(projectDir)
218
+ if (cached !== undefined) return cached
219
+ const promise = doHashProjectPackageJson(projectDir, cache, fileHashes)
220
+ hashCache?.packageJson.set(projectDir, promise)
221
+ return promise
222
+ }
223
+
224
+ async function doHashProjectPackageJson(
225
+ projectDir: string,
226
+ cache: CacheLayer,
227
+ fileHashes?: ReadonlyMap<string, string>,
228
+ ): Promise<string> {
229
+ const filePath = path.join(projectDir, 'package.json')
230
+ // Clean tracked package.json: its index OID is already in hand —
231
+ // no exists probe, no stat, no SQLite.
232
+ const oid = fileHashes?.get(filePath)
233
+ if (oid !== undefined) return oid
234
+ if (!(await Bun.file(filePath).exists())) return ''
235
+ // Route through the cache layer's mtime+size fast path so the
236
+ // typical re-run sees a stat + SQLite lookup instead of a file read.
237
+ return await cache.hashFile(filePath)
238
+ }
@@ -0,0 +1,80 @@
1
+ // Telemetry consultation — sibling to plugin-host.ts, for the observe-only
2
+ // `telemetry` capability. Asks each declared plugin for its sink(s),
3
+ // collects them, and (only if at least one exists) wires a TelemetrySource
4
+ // onto the run event bus.
5
+ //
6
+ // PERF INVARIANT: with zero registered sinks, NOTHING subscribes to the bus
7
+ // and the returned handle is a no-op — the hot path is byte-identical to a
8
+ // run with no telemetry plugin. A plugin that declines (returns undefined,
9
+ // e.g. otel() with no OTLP endpoint) contributes no sink, so it costs
10
+ // nothing. This is what keeps telemetry off the critical path entirely.
11
+ //
12
+ // See docs/design/observability-architecture-2026-06.md §1, §3.
13
+
14
+ import type { EventBus } from './events.js'
15
+ import type {
16
+ RunContextRecord,
17
+ RunSummaryRecord,
18
+ TelemetryContext,
19
+ TelemetrySink,
20
+ } from './telemetry.js'
21
+ import { createTelemetrySource } from './telemetry.js'
22
+ import type { VxPlugin } from './plugin.js'
23
+
24
+ /** A wired telemetry handle, present ONLY when at least one sink exists. */
25
+ export interface TelemetryHandle {
26
+ /** Emit the per-run summary to every sink (crash-isolated). */
27
+ emitSummary(summary: RunSummaryRecord): void
28
+ /** Await every sink's flush (crash-isolated, time-bounded by the sink). */
29
+ flush(): Promise<void>
30
+ /** Remove the bus subscription. Idempotent. */
31
+ dispose(): void
32
+ }
33
+
34
+ /**
35
+ * Consult every plugin's `telemetry` capability, collect the sinks, and —
36
+ * only if at least one sink results — create a TelemetrySource and subscribe
37
+ * it to the bus. A plugin that throws during consultation is logged + skipped
38
+ * (observability must never break a run). Returns `undefined` when no sink is
39
+ * contributed, so the bus gains no subscriber AND the caller does zero
40
+ * summary-building work — the no-telemetry hot path stays byte-identical.
41
+ */
42
+ export async function subscribeTelemetry(
43
+ plugins: readonly VxPlugin[],
44
+ bus: EventBus,
45
+ ctx: TelemetryContext,
46
+ run: RunContextRecord,
47
+ extraSinks?: readonly TelemetrySink[],
48
+ ): Promise<TelemetryHandle | undefined> {
49
+ const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
50
+ for (const plugin of plugins) {
51
+ if (plugin.telemetry === undefined) continue
52
+ let result
53
+ try {
54
+ result = await plugin.telemetry(ctx)
55
+ } catch (err) {
56
+ ctx.warn(
57
+ `[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
58
+ )
59
+ continue
60
+ }
61
+ if (result === undefined) continue
62
+ if (Array.isArray(result)) sinks.push(...result)
63
+ else sinks.push(result)
64
+ }
65
+
66
+ if (sinks.length === 0) return undefined
67
+
68
+ const source = createTelemetrySource({ sinks, run })
69
+ const dispose = bus.subscribe(source.subscriber)
70
+ let disposed = false
71
+ return {
72
+ emitSummary: (summary) => source.emitSummary(summary),
73
+ flush: () => source.flush(),
74
+ dispose() {
75
+ if (disposed) return
76
+ disposed = true
77
+ dispose()
78
+ },
79
+ }
80
+ }