@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,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
+ }
@@ -0,0 +1,358 @@
1
+ // The telemetry contract — THE canonical, versioned data-export shape.
2
+ //
3
+ // This is the one neutral boundary the observability/integration design
4
+ // (docs/design/observability-architecture-2026-06.md) is built around.
5
+ // Core projects its live `RunEvent` stream into these records ONCE, in
6
+ // one place (`createTelemetrySource`), and hands them to every registered
7
+ // `TelemetrySink`. Exporters (OTel, a manual HTTP POST, any third-party sink) all read
8
+ // the SAME records — with the analytics fields + git/CI context already
9
+ // folded in — instead of each re-deriving an ad-hoc shape from the raw,
10
+ // rendering-oriented `WireEvent` stream.
11
+ //
12
+ // A sink is observe-only BY CONSTRUCTION: its only input is an immutable
13
+ // record; its `TelemetryContext` carries read-only metadata and NO mutable
14
+ // run handle (no bus, no Cache, no RunRequest). There is no API path from a
15
+ // sink back into scheduling/caching/exec — telemetry provably cannot change
16
+ // what or how tasks run. Contrast `backend`/`cache`, which return objects
17
+ // core calls INTO; those are the behavior capabilities, kept separate.
18
+
19
+ import type { OutputFingerprint, TaskStatus, VerifyVerdict } from '../graph/index.js'
20
+ import type { RunEvent, RunEventSubscriber } from './events.js'
21
+
22
+ /** Bumped when the record shape changes. Readers MUST check `v`. */
23
+ export const TELEMETRY_SCHEMA_VERSION = 2
24
+
25
+ /** Where a task's result came from, derived ONCE in core from the status. */
26
+ export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
27
+
28
+ /**
29
+ * Map a task outcome status to its cache source. `success`/`failed` ran
30
+ * (a miss as far as the cache read is concerned); the two cache-hit
31
+ * statuses restored from local/remote; `skipped`/`aborted` never engaged
32
+ * the cache. Pure function — the single place this mapping is decided, so
33
+ * no consumer re-implements `status LIKE 'cache-hit%'`.
34
+ */
35
+ export function deriveCacheSource(status: TaskStatus): CacheSource {
36
+ switch (status) {
37
+ case 'cache-hit':
38
+ return 'local'
39
+ case 'cache-hit-remote':
40
+ return 'remote'
41
+ case 'success':
42
+ case 'failed':
43
+ return 'miss'
44
+ case 'skipped':
45
+ case 'aborted':
46
+ return 'none'
47
+ }
48
+ }
49
+
50
+ /** Identifies which run a record belongs to + its captured context. Maps
51
+ * cleanly onto OTel CI/CD + VCS resource attributes. */
52
+ export interface RunContextRecord {
53
+ /** ULID, shared by every record in one `vx run`. */
54
+ runId: string
55
+ vxVersion: string
56
+ /** The invocation command line (process.argv-derived). */
57
+ command: string
58
+ requestedTasks: readonly string[]
59
+ /** Compact cache-policy flags, e.g. `'lR,lW,rR,rW'`. */
60
+ cachePolicy: string
61
+ concurrency: number
62
+ flow: 'focused' | 'broad' | null
63
+ /**
64
+ * Stable workspace identity (v2) — the multi-workspace server key.
65
+ * Derived from the normalized git remote (any checkout of the same
66
+ * repo → same id); see run-context.ts captureWorkspaceIdentity. A v1
67
+ * consumer synthesizes 'default' for pushes that predate it.
68
+ */
69
+ workspaceId: string
70
+ workspaceName: string
71
+ // git / CI / host — straight from run-context.ts.
72
+ commitSha: string | null
73
+ branch: string | null
74
+ dirty: boolean | null
75
+ ci: boolean
76
+ ciProvider: string | null
77
+ host: string | null
78
+ os: string
79
+ arch: string
80
+ /** `--tag k=v` pairs. */
81
+ tags: Readonly<Record<string, string>>
82
+ }
83
+
84
+ /** Denormalized per-task analytics — shared by the streaming `task.end`
85
+ * record and the per-run summary's `tasks[]`. */
86
+ export interface TaskTelemetry {
87
+ taskId: string
88
+ project: string
89
+ task: string
90
+ status: TaskStatus
91
+ cacheSource: CacheSource
92
+ exitCode: number
93
+ durationMs: number
94
+ hash?: string
95
+ cpuMs?: number
96
+ peakRssBytes?: number
97
+ /** Total attempts when the task RETRIED (>1) — set only when `exec.retries`
98
+ * / `--retry` produced more than one attempt. A retried-then-passed task is
99
+ * flaky by definition; this is the telemetry-side flaky signal. */
100
+ attempts?: number
101
+ /** Cache-correctness verdict — set ONLY on a `--verify` run (absent
102
+ * otherwise). A `nondeterministic` verdict means the task's cache entry is
103
+ * unsound (its outputs aren't a pure function of its declared inputs); this
104
+ * is the telemetry-side hermeticity signal a dashboard surfaces. */
105
+ verify?: VerifyVerdict
106
+ /** Output-tree fingerprint — set ONLY under a fingerprinting `--verify*`
107
+ * mode (`--verify` / `=all` / `=fingerprint`), executed tasks only. A
108
+ * connected serve pairs fingerprints for the SAME `hash` across platforms
109
+ * and names diverging outputs (the cross-machine diff). Absent on plain
110
+ * runs — additive-optional, no schema bump (the `attempts`/`verify`
111
+ * precedent). */
112
+ outputFp?: OutputFingerprint
113
+ /** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
114
+ wallclockStartNs?: string
115
+ wallclockEndNs?: string
116
+ }
117
+
118
+ /**
119
+ * A streaming telemetry record — one per lifecycle event. A superset of the
120
+ * rendering-oriented `WireEvent`: it carries the run context + the per-task
121
+ * analytics fields a consumer needs WITHOUT re-deriving from the stream.
122
+ * `task.log` records are large and OPT-IN (see `TelemetrySink.wants`).
123
+ */
124
+ export type TelemetryRecord =
125
+ | { v: number; kind: 'run.start'; run: RunContextRecord; total: number; ts: number }
126
+ | {
127
+ v: number
128
+ kind: 'task.start'
129
+ runId: string
130
+ taskId: string
131
+ project: string
132
+ task: string
133
+ command?: string
134
+ ts: number
135
+ }
136
+ | {
137
+ v: number
138
+ kind: 'task.log'
139
+ runId: string
140
+ taskId: string
141
+ stream: 'stdout' | 'stderr'
142
+ chunk: string
143
+ ts: number
144
+ }
145
+ | ({ v: number; kind: 'task.end'; runId: string; ts: number } & TaskTelemetry)
146
+ | { v: number; kind: 'run.end'; runId: string; ts: number }
147
+
148
+ /**
149
+ * A per-run SUMMARY record — the denormalized invocation header plus the
150
+ * per-task outcome list, emitted once at run:end. An ingesting store can
151
+ * persist a whole run in one write without replaying the stream. The
152
+ * manual-API exporter + a service's ingest endpoint primarily speak this shape.
153
+ */
154
+ export interface RunSummaryRecord {
155
+ v: number
156
+ run: RunContextRecord
157
+ startedAt: number
158
+ endedAt: number
159
+ totalDurationMs: number
160
+ taskCount: number
161
+ failedCount: number
162
+ hitCount: number
163
+ hitLocalCount: number
164
+ hitRemoteCount: number
165
+ exitOk: boolean
166
+ tasks: readonly TaskTelemetry[]
167
+ }
168
+
169
+ /** A telemetry consumer. Observe-only: receives records, holds no run handle. */
170
+ export interface TelemetrySink {
171
+ readonly name?: string
172
+ /**
173
+ * Which streaming record kinds to receive. Default (undefined): all
174
+ * EXCEPT `task.log` (large; most sinks don't want build-log chunks).
175
+ * The source checks this before projecting/cloning, so a sink pays
176
+ * nothing for kinds it declines.
177
+ */
178
+ readonly wants?: ReadonlyArray<TelemetryRecord['kind']>
179
+ /** A streaming record. MUST return promptly — buffer; do NOT await I/O. */
180
+ onRecord?(record: TelemetryRecord): void
181
+ /** The per-run summary, at run:end. MUST return promptly. */
182
+ onRunSummary?(summary: RunSummaryRecord): void
183
+ /** Drain buffered data. Awaited (time-bounded by the sink) at run:end. */
184
+ flush?(): Promise<void>
185
+ }
186
+
187
+ /** Read-only context a sink is created with. No mutable run handle — the
188
+ * isolation guarantee is structural. */
189
+ export interface TelemetryContext {
190
+ readonly workspaceRoot: string
191
+ /** A STRING, not a Cache handle — a sink cannot reach the cache. */
192
+ readonly cacheDir: string
193
+ warn(message: string): void
194
+ }
195
+
196
+ /** A live telemetry source: a bus subscriber + the run-summary emit + flush. */
197
+ export interface TelemetrySource {
198
+ /** Subscribe this to the run event bus to stream records to the sinks. */
199
+ readonly subscriber: RunEventSubscriber
200
+ /** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
201
+ emitSummary(summary: RunSummaryRecord): void
202
+ /** Await every sink's `flush()` (each crash-isolated). */
203
+ flush(): Promise<void>
204
+ }
205
+
206
+ const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
207
+ 'run.start',
208
+ 'task.start',
209
+ 'task.end',
210
+ 'run.end',
211
+ ]
212
+
213
+ /**
214
+ * Build a telemetry source over a fixed set of sinks. The returned
215
+ * `subscriber` projects each `RunEvent` into a `TelemetryRecord` and fans
216
+ * it to the sinks that want its kind, under crash isolation (a throwing
217
+ * sink is disabled for the rest of the run, never propagating into the
218
+ * orchestrator). `task.log` is projected ONLY if some sink opted in — so
219
+ * the large-payload path costs nothing by default.
220
+ *
221
+ * The `run` context (captured once by run.ts) is stamped onto `run.start`
222
+ * and supplies the `runId` every other record carries.
223
+ */
224
+ export function createTelemetrySource(args: {
225
+ sinks: readonly TelemetrySink[]
226
+ run: RunContextRecord
227
+ }): TelemetrySource {
228
+ const { sinks, run } = args
229
+ const runId = run.runId
230
+ // A sink is disabled the first time it throws — its name (or index) goes
231
+ // here and it's skipped for the rest of the run.
232
+ const disabled = new Set<TelemetrySink>()
233
+ // Precompute which sinks want each kind, so per-event fan-out is a plain
234
+ // array walk with no per-record `wants` scanning.
235
+ const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
236
+
237
+ function deliver(record: TelemetryRecord): void {
238
+ for (const sink of sinks) {
239
+ if (disabled.has(sink) || sink.onRecord === undefined) continue
240
+ const kinds = sink.wants ?? DEFAULT_KINDS
241
+ if (!kinds.includes(record.kind)) continue
242
+ try {
243
+ sink.onRecord(record)
244
+ } catch {
245
+ disabled.add(sink)
246
+ }
247
+ }
248
+ }
249
+
250
+ let endEmitted = false
251
+ const subscriber: RunEventSubscriber = (event: RunEvent) => {
252
+ const ts = Date.now()
253
+ switch (event.kind) {
254
+ case 'run:start':
255
+ deliver({
256
+ v: TELEMETRY_SCHEMA_VERSION,
257
+ kind: 'run.start',
258
+ run,
259
+ total: event.info.total,
260
+ ts,
261
+ })
262
+ return
263
+ case 'task:start': {
264
+ const node = event.node
265
+ if (node.config.exec === undefined) return // group task — no command, skip
266
+ const rec: TelemetryRecord = {
267
+ v: TELEMETRY_SCHEMA_VERSION,
268
+ kind: 'task.start',
269
+ runId,
270
+ taskId: node.id,
271
+ project: node.projectName,
272
+ task: node.taskName,
273
+ ts,
274
+ }
275
+ if (node.config.exec.command !== undefined) rec.command = node.config.exec.command
276
+ deliver(rec)
277
+ return
278
+ }
279
+ case 'task:stdout':
280
+ case 'task:stderr': {
281
+ if (!wantsLog) return // nobody wants logs — pay nothing
282
+ deliver({
283
+ v: TELEMETRY_SCHEMA_VERSION,
284
+ kind: 'task.log',
285
+ runId,
286
+ taskId: event.node.id,
287
+ stream: event.kind === 'task:stdout' ? 'stdout' : 'stderr',
288
+ chunk: event.chunk,
289
+ ts,
290
+ })
291
+ return
292
+ }
293
+ case 'task:complete': {
294
+ const { node, outcome } = event
295
+ if (node.config.exec === undefined) return // group task
296
+ const rec: TelemetryRecord = {
297
+ v: TELEMETRY_SCHEMA_VERSION,
298
+ kind: 'task.end',
299
+ runId,
300
+ ts,
301
+ taskId: node.id,
302
+ project: node.projectName,
303
+ task: node.taskName,
304
+ status: outcome.status,
305
+ cacheSource: deriveCacheSource(outcome.status),
306
+ exitCode: outcome.exitCode,
307
+ durationMs: outcome.durationMs,
308
+ }
309
+ if (outcome.hash !== undefined) rec.hash = outcome.hash
310
+ if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
311
+ if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
312
+ if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
313
+ if (outcome.verify !== undefined) rec.verify = outcome.verify
314
+ if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
315
+ if (outcome.wallclockStartNs !== undefined)
316
+ rec.wallclockStartNs = outcome.wallclockStartNs.toString()
317
+ if (outcome.wallclockEndNs !== undefined)
318
+ rec.wallclockEndNs = outcome.wallclockEndNs.toString()
319
+ deliver(rec)
320
+ return
321
+ }
322
+ case 'run:status':
323
+ return // status lines are terminal-rendering noise, not telemetry
324
+ case 'run:end':
325
+ // run() emits run:end twice (normal + finally); emit one record.
326
+ if (endEmitted) return
327
+ endEmitted = true
328
+ deliver({ v: TELEMETRY_SCHEMA_VERSION, kind: 'run.end', runId, ts })
329
+ return
330
+ }
331
+ }
332
+
333
+ return {
334
+ subscriber,
335
+ emitSummary(summary: RunSummaryRecord): void {
336
+ for (const sink of sinks) {
337
+ if (disabled.has(sink) || sink.onRunSummary === undefined) continue
338
+ try {
339
+ sink.onRunSummary(summary)
340
+ } catch {
341
+ disabled.add(sink)
342
+ }
343
+ }
344
+ },
345
+ async flush(): Promise<void> {
346
+ await Promise.all(
347
+ sinks.map(async (sink) => {
348
+ if (sink.flush === undefined) return
349
+ try {
350
+ await sink.flush()
351
+ } catch {
352
+ // a sink's flush failure can never break the run
353
+ }
354
+ }),
355
+ )
356
+ },
357
+ }
358
+ }
@@ -0,0 +1,121 @@
1
+ import {
2
+ DependencySpecError,
3
+ compileTaskPattern,
4
+ isTaskPattern,
5
+ parseDependencySpec,
6
+ type DependencySpec,
7
+ type TaskOutcome,
8
+ } from '../graph/index.js'
9
+ import { UserError } from '../util/index.js'
10
+
11
+ /**
12
+ * Pick which upstream task hashes participate in the current task's
13
+ * cache key, filtered by `cache.inputs.tasks`. The folded value is the
14
+ * upstream's own cache key (its input-based task hash) — pure-input
15
+ * transitive hashing, like Turbo/Nx. An upstream change propagates
16
+ * through its key into every dependent's key. There is deliberately NO
17
+ * output-content folding: an upstream that re-runs but emits identical
18
+ * output still re-runs its dependents (early cutoff was removed —
19
+ * rare in practice, not worth the cascade complexity).
20
+ *
21
+ * Patterns (Turbo/Nx micro-syntax + filter extensions):
22
+ * '*' all same-project upstream
23
+ * '^*' all dep-workspace upstream
24
+ * 'name' same-project task `name`
25
+ * '^name' `name` task in every dep workspace
26
+ * 'pkg#name' specific package's `name` task
27
+ * 'name.*' task-NAME patterns — the task half of any form above may
28
+ * contain `*` (same glob as dependsOn patterns). A filter
29
+ * that matched literally here while dependsOn expanded the
30
+ * same string would silently select ZERO upstream hashes —
31
+ * a stale-hit trap, so the two surfaces share the matcher.
32
+ * '!<form>' exclude — any of the above with a leading `!`
33
+ *
34
+ * Patterns are applied in order; last write wins, so
35
+ * `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
36
+ *
37
+ * Defaults:
38
+ * - `filter === undefined` → all upstream contribute.
39
+ * - `filter === []` → none contribute (fully decoupled).
40
+ *
41
+ * Returns `[upstreamTaskId, hash]` pairs. The hash is the only thing
42
+ * folded into the cache key (the fold sorts by hash, so ordering here
43
+ * doesn't affect derivation); the task id rides along so Tier-3's
44
+ * `entry_inputs` rows can NAME which upstream a hash came from. The
45
+ * filter dedups by hash, as before — two upstream tasks with identical
46
+ * hashes contribute one pair (first id wins; their key contribution is
47
+ * identical anyway).
48
+ */
49
+ export function filterUpstreamHashes(
50
+ upstream: TaskOutcome[],
51
+ filter: readonly string[] | undefined,
52
+ selfProjectName: string,
53
+ selfTaskId: string,
54
+ ): Array<[upstreamTaskId: string, hash: string]> {
55
+ if (filter === undefined) {
56
+ const out: Array<[string, string]> = []
57
+ for (const u of upstream) if (u.hash) out.push([u.node.id, u.hash])
58
+ return out
59
+ }
60
+
61
+ const specs: DependencySpec[] = filter.map((raw) => {
62
+ try {
63
+ return parseDependencySpec(raw)
64
+ } catch (err) {
65
+ if (err instanceof DependencySpecError) {
66
+ throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
67
+ }
68
+ throw err
69
+ }
70
+ })
71
+
72
+ // Per-spec task-name matcher, compiled once (exact compare or pattern).
73
+ const matchers = specs.map((spec) => taskMatcher(spec))
74
+
75
+ // Dedup by hash (the key fold's unit), but remember the first task id
76
+ // seen for each hash so the diff row can name the upstream.
77
+ const selected = new Map<string, string>()
78
+ for (let i = 0; i < specs.length; i++) {
79
+ const spec = specs[i]!
80
+ const taskMatches = matchers[i]!
81
+ for (const u of upstream) {
82
+ if (!u.hash) continue
83
+ const isSelf = u.node.projectName === selfProjectName
84
+ if (!matches(spec, u, isSelf, taskMatches)) continue
85
+ if (spec.negated) selected.delete(u.hash)
86
+ else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
87
+ }
88
+ }
89
+ return [...selected].map(([hash, id]) => [id, hash])
90
+ }
91
+
92
+ /** Exact-name compare, or the shared `*`-glob when the form is a pattern. */
93
+ function taskMatcher(spec: DependencySpec): (name: string) => boolean {
94
+ if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') return () => true
95
+ if (isTaskPattern(spec.task)) {
96
+ const re = compileTaskPattern(spec.task)
97
+ return (name) => re.test(name)
98
+ }
99
+ const exact = spec.task
100
+ return (name) => name === exact
101
+ }
102
+
103
+ function matches(
104
+ spec: DependencySpec,
105
+ u: TaskOutcome,
106
+ isSelf: boolean,
107
+ taskMatches: (name: string) => boolean,
108
+ ): boolean {
109
+ switch (spec.kind) {
110
+ case 'wildcardSelf':
111
+ return isSelf
112
+ case 'wildcardDeps':
113
+ return !isSelf
114
+ case 'self':
115
+ return isSelf && taskMatches(u.node.taskName)
116
+ case 'deps':
117
+ return !isSelf && taskMatches(u.node.taskName)
118
+ case 'cross':
119
+ return u.node.projectName === spec.project && taskMatches(u.node.taskName)
120
+ }
121
+ }