@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,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
|
+
}
|