@vzn/vx 0.0.16 → 0.0.18
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/README.md +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The shared per-task log capture primitive (task-logs-2026-07 §1). ONE
|
|
2
|
+
// bounded-tail buffer every telemetry sink uses — the cloud client sink (local
|
|
3
|
+
// runs), the cloud serve sink (delegated runs), the dist scheduler, and the
|
|
4
|
+
// OTLP logs exporter — so the capping rules can't drift between capture sites.
|
|
5
|
+
//
|
|
6
|
+
// It lives in CORE, beside the telemetry contract, for the same reason
|
|
7
|
+
// `assembleRunSummary` does: two sinks that each roll their own bounded buffer
|
|
8
|
+
// is how the retention rule forks, and a forked rule means two sinks disagree
|
|
9
|
+
// about which task's output survived. A sink is the only consumer — core
|
|
10
|
+
// itself never captures logs — but the vocabulary it decides in (`TaskStatus`,
|
|
11
|
+
// `CacheSource`, "a hit's bytes belong to the run that executed") is core's.
|
|
12
|
+
//
|
|
13
|
+
// The law: a log-spewing task can never OOM the capturer. Two caps enforce it:
|
|
14
|
+
// - per task: only the last TASK_LOG_TAIL_CHARS of merged stdout+stderr is
|
|
15
|
+
// retained (chunks evicted from the head, no concatenation until drain);
|
|
16
|
+
// - per run: only RUN_LOG_BUDGET_CHARS of RETAINED completed tails ship, and
|
|
17
|
+
// failed tails are never evicted by successes — a failure's output is the
|
|
18
|
+
// one thing you always want to read.
|
|
19
|
+
//
|
|
20
|
+
// Both caps report themselves: an evicted or head-trimmed task still ships,
|
|
21
|
+
// carrying how much was dropped, so "we truncated this" never reads as "this
|
|
22
|
+
// task printed nothing".
|
|
23
|
+
|
|
24
|
+
import type { TaskStatus } from '../graph/index.js'
|
|
25
|
+
import type { CacheSource } from './telemetry.js'
|
|
26
|
+
|
|
27
|
+
/** Version of the drained-bundle shape below. It is the canonical drained
|
|
28
|
+
* logs format, not one transport's: cloud's `POST /v1/ingest/logs` accepts
|
|
29
|
+
* it verbatim, and any other sink ships the same object. */
|
|
30
|
+
export const LOG_WIRE_VERSION = 1
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-task tail retained (UTF-16 units — a cheap capture-side proxy; the store
|
|
34
|
+
* records true stored bytes). Merged streams: what a terminal actually shows.
|
|
35
|
+
*/
|
|
36
|
+
export const TASK_LOG_TAIL_CHARS = 128 * 1024
|
|
37
|
+
|
|
38
|
+
/** Total RETAINED completed-task chars a single run ships. */
|
|
39
|
+
export const RUN_LOG_BUDGET_CHARS = 4 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
export interface TaskLogEntry {
|
|
42
|
+
taskId: string
|
|
43
|
+
/** The task's cache key when known — the hit→executed-run resolution key. */
|
|
44
|
+
hash?: string
|
|
45
|
+
status: 'success' | 'failed'
|
|
46
|
+
/** Merged stdout+stderr in arrival order, tail-capped, ANSI preserved. */
|
|
47
|
+
content: string
|
|
48
|
+
/** Chars emitted before capping. */
|
|
49
|
+
charsFull: number
|
|
50
|
+
/** Chars dropped from the head; 0 = complete. */
|
|
51
|
+
truncatedHeadChars: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TaskLogBundle {
|
|
55
|
+
v: typeof LOG_WIRE_VERSION
|
|
56
|
+
runId: string
|
|
57
|
+
workspaceId: string
|
|
58
|
+
tasks: TaskLogEntry[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Retained-until-drain state for one completed task. */
|
|
62
|
+
interface Retained {
|
|
63
|
+
taskId: string
|
|
64
|
+
hash?: string
|
|
65
|
+
status: 'success' | 'failed'
|
|
66
|
+
/** Emptied when the run budget evicts this task; `chars` goes to 0 and the
|
|
67
|
+
* dropped count moves to `truncatedHeadChars` (see `evictToBudget`). */
|
|
68
|
+
chunks: string[]
|
|
69
|
+
chars: number
|
|
70
|
+
charsFull: number
|
|
71
|
+
truncatedHeadChars: number
|
|
72
|
+
/** Insertion order — the eviction tiebreak (oldest first). */
|
|
73
|
+
seq: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** In-flight (not yet ended) accumulation for one task. */
|
|
77
|
+
interface InFlight {
|
|
78
|
+
chunks: string[]
|
|
79
|
+
chars: number
|
|
80
|
+
charsFull: number
|
|
81
|
+
truncatedHeadChars: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Bounded per-run capture. `append` keeps a chunk LIST + running char count per
|
|
86
|
+
* task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
|
|
87
|
+
* string concatenation until `drain`, so a cache-hit replay (one big chunk) is
|
|
88
|
+
* one array push, zero copies. `finish` decides retention; `drain` emits the
|
|
89
|
+
* bundle, failures first.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Per-retained-chunk overhead, in char-equivalents, charged against the RUN
|
|
93
|
+
* budget on top of the characters themselves.
|
|
94
|
+
*
|
|
95
|
+
* The budget exists to bound MEMORY, but a char count is not one: a task that
|
|
96
|
+
* emits a byte at a time (unbuffered progress output) stores the same content
|
|
97
|
+
* as thousands of separate strings, each with its own header and allocation.
|
|
98
|
+
* MEASURED on Bun 1.4: 1 000 000 chars arrive as one chunk for ~1.4 MB of
|
|
99
|
+
* marginal RSS, and as 1 000 000 one-char chunks for ~30 MB — so a 4 MiB
|
|
100
|
+
* char budget could hold ~80 MB of actual memory. Charging each chunk ~24
|
|
101
|
+
* char-equivalents makes the budget track what it is trying to limit; chunky
|
|
102
|
+
* output is unaffected (one 128 KiB chunk pays 24 on 131 072).
|
|
103
|
+
*
|
|
104
|
+
* The per-task TAIL cap deliberately stays a pure char count — it bounds how
|
|
105
|
+
* much a reader is shown, which is a different question from how much the
|
|
106
|
+
* process holds.
|
|
107
|
+
*/
|
|
108
|
+
const CHUNK_OVERHEAD_CHARS = 24
|
|
109
|
+
|
|
110
|
+
/** What one retained entry costs the run budget: content + per-chunk overhead. */
|
|
111
|
+
function budgetCost(chars: number, chunkCount: number): number {
|
|
112
|
+
return chars + chunkCount * CHUNK_OVERHEAD_CHARS
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class TaskLogBuffer {
|
|
116
|
+
private readonly inFlight = new Map<string, InFlight>()
|
|
117
|
+
private readonly retained = new Map<string, Retained>()
|
|
118
|
+
private retainedChars = 0
|
|
119
|
+
private seq = 0
|
|
120
|
+
|
|
121
|
+
append(taskId: string, chunk: string): void {
|
|
122
|
+
if (chunk.length === 0) return
|
|
123
|
+
let acc = this.inFlight.get(taskId)
|
|
124
|
+
if (acc === undefined) {
|
|
125
|
+
acc = { chunks: [], chars: 0, charsFull: 0, truncatedHeadChars: 0 }
|
|
126
|
+
this.inFlight.set(taskId, acc)
|
|
127
|
+
}
|
|
128
|
+
acc.chunks.push(chunk)
|
|
129
|
+
acc.chars += chunk.length
|
|
130
|
+
acc.charsFull += chunk.length
|
|
131
|
+
// Evict whole chunks from the head until under the per-task tail cap. A
|
|
132
|
+
// single over-cap chunk is sliced to its tail (the only place we copy).
|
|
133
|
+
while (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length > 1) {
|
|
134
|
+
const dropped = acc.chunks.shift()!
|
|
135
|
+
acc.chars -= dropped.length
|
|
136
|
+
acc.truncatedHeadChars += dropped.length
|
|
137
|
+
}
|
|
138
|
+
if (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length === 1) {
|
|
139
|
+
const only = acc.chunks[0]!
|
|
140
|
+
const keep = only.slice(only.length - TASK_LOG_TAIL_CHARS)
|
|
141
|
+
acc.truncatedHeadChars += only.length - keep.length
|
|
142
|
+
acc.chunks[0] = keep
|
|
143
|
+
acc.chars = keep.length
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* task.end: decide retention.
|
|
149
|
+
* - cacheSource !== 'miss' (a hit) → DROP: the executed run already stored
|
|
150
|
+
* these bytes; a hit resolves by hash to that run.
|
|
151
|
+
* - 'skipped' / 'aborted' → DROP (no meaningful output; a tearing-down run).
|
|
152
|
+
* - a success/failed MISS → RETAIN the tail, under the run budget: adding a
|
|
153
|
+
* task that would exceed `RUN_LOG_BUDGET_CHARS` evicts oldest retained
|
|
154
|
+
* SUCCESS tails first; failed tails are evicted only by newer failed tails
|
|
155
|
+
* (oldest first) once failures alone exceed the budget.
|
|
156
|
+
*/
|
|
157
|
+
finish(taskId: string, status: TaskStatus, cacheSource: CacheSource, hash?: string): void {
|
|
158
|
+
const acc = this.inFlight.get(taskId)
|
|
159
|
+
this.inFlight.delete(taskId)
|
|
160
|
+
if (cacheSource !== 'miss') return
|
|
161
|
+
if (status !== 'success' && status !== 'failed') return
|
|
162
|
+
if (acc === undefined) return
|
|
163
|
+
|
|
164
|
+
const entry: Retained = {
|
|
165
|
+
taskId,
|
|
166
|
+
...(hash !== undefined ? { hash } : {}),
|
|
167
|
+
status,
|
|
168
|
+
chunks: acc.chunks,
|
|
169
|
+
chars: acc.chars,
|
|
170
|
+
charsFull: acc.charsFull,
|
|
171
|
+
truncatedHeadChars: acc.truncatedHeadChars,
|
|
172
|
+
seq: this.seq++,
|
|
173
|
+
}
|
|
174
|
+
// Replace an earlier retention for the same task id (a retried task ends
|
|
175
|
+
// once per attempt only via the winning outcome, but be defensive).
|
|
176
|
+
const prior = this.retained.get(taskId)
|
|
177
|
+
if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
|
|
178
|
+
this.retained.set(taskId, entry)
|
|
179
|
+
this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
|
|
180
|
+
this.evictToBudget()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Take ONE finished task's retained tail as a wire entry, removing it from
|
|
185
|
+
* the buffer (so a later `drain` won't re-ship it). Used by per-task
|
|
186
|
+
* incremental delivery: `finish()` retains, then the sink `takeEntry()`s and
|
|
187
|
+
* ships that one task. Returns undefined for a task that wasn't retained (a
|
|
188
|
+
* cache hit / skipped / still in-flight / unknown id) — the task result still
|
|
189
|
+
* ships; there is just no log tail to send.
|
|
190
|
+
*/
|
|
191
|
+
takeEntry(taskId: string): TaskLogEntry | undefined {
|
|
192
|
+
const e = this.retained.get(taskId)
|
|
193
|
+
if (e === undefined) return undefined
|
|
194
|
+
this.retained.delete(taskId)
|
|
195
|
+
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
196
|
+
return {
|
|
197
|
+
taskId: e.taskId,
|
|
198
|
+
...(e.hash !== undefined ? { hash: e.hash } : {}),
|
|
199
|
+
status: e.status,
|
|
200
|
+
content: e.chunks.join(''),
|
|
201
|
+
charsFull: e.charsFull,
|
|
202
|
+
truncatedHeadChars: e.truncatedHeadChars,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Everything retained, failures first, ready to ship/store. */
|
|
207
|
+
drain(runId: string, workspaceId: string): TaskLogBundle {
|
|
208
|
+
const entries = [...this.retained.values()].sort((a, b) => {
|
|
209
|
+
// Failures ahead of successes; within a tier, oldest first (stable).
|
|
210
|
+
const fa = a.status === 'failed' ? 0 : 1
|
|
211
|
+
const fb = b.status === 'failed' ? 0 : 1
|
|
212
|
+
return fa !== fb ? fa - fb : a.seq - b.seq
|
|
213
|
+
})
|
|
214
|
+
return {
|
|
215
|
+
v: LOG_WIRE_VERSION,
|
|
216
|
+
runId,
|
|
217
|
+
workspaceId,
|
|
218
|
+
tasks: entries.map((e) => ({
|
|
219
|
+
taskId: e.taskId,
|
|
220
|
+
...(e.hash !== undefined ? { hash: e.hash } : {}),
|
|
221
|
+
status: e.status,
|
|
222
|
+
content: e.chunks.join(''),
|
|
223
|
+
charsFull: e.charsFull,
|
|
224
|
+
truncatedHeadChars: e.truncatedHeadChars,
|
|
225
|
+
})),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Retained-tasks count (test/observability). */
|
|
230
|
+
size(): number {
|
|
231
|
+
return this.retained.size
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Budget charged right now, in char-equivalents (test/observability).
|
|
236
|
+
*
|
|
237
|
+
* Exposed because the charge/release symmetry is the invariant that keeps
|
|
238
|
+
* the cap meaningful and the one that fails SILENTLY: a release that does
|
|
239
|
+
* not match its charge drifts `retainedChars` a little per task, and a
|
|
240
|
+
* long run ends up bounding something other than what it thinks.
|
|
241
|
+
*/
|
|
242
|
+
budgetUsed(): number {
|
|
243
|
+
return this.retainedChars
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
|
|
248
|
+
* (oldest by seq); only when failures ALONE still exceed the budget do the
|
|
249
|
+
* oldest failures go — a failure is never dropped to keep a success.
|
|
250
|
+
*
|
|
251
|
+
* An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
|
|
252
|
+
* = everything it emitted) rather than removed. Dropping the entry makes a
|
|
253
|
+
* budget eviction indistinguishable from a task that printed nothing — the
|
|
254
|
+
* reader gets "no logs captured" either way, which is a confident false
|
|
255
|
+
* statement in the one case where they most need the output (a run with
|
|
256
|
+
* enough failing tasks to blow the budget). A stub costs a few dozen bytes,
|
|
257
|
+
* keeps the `content.length === charsFull - truncatedHeadChars` accounting
|
|
258
|
+
* honest, and renders through the truncation banner the per-task cap
|
|
259
|
+
* already uses. It is also what the STORE does when its own run budget runs
|
|
260
|
+
* out (`Analytics.ingestLogs` slices to empty and adds the remainder to
|
|
261
|
+
* `truncated_head`) — the two sides now degrade the same way.
|
|
262
|
+
*
|
|
263
|
+
* The cost is a stub per evicted task in the end-of-run bundle: measured at
|
|
264
|
+
* ~190 JSON bytes each, so ~1.9 MiB at the 10k-task scale target and ~9.3 MiB
|
|
265
|
+
* at 50k, against the 16 MiB `/v1/ingest/logs` cap. A run evicting past ~66k
|
|
266
|
+
* tasks would 413 the batch — remote (6x the scale target), and it does not
|
|
267
|
+
* touch the connected default, which ships per task as it finishes.
|
|
268
|
+
*/
|
|
269
|
+
private evictToBudget(): void {
|
|
270
|
+
if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
|
|
271
|
+
const order = [...this.retained.values()].sort((a, b) => {
|
|
272
|
+
const sa = a.status === 'success' ? 0 : 1
|
|
273
|
+
const sb = b.status === 'success' ? 0 : 1
|
|
274
|
+
if (sa !== sb) return sa - sb // successes are dropped first, always
|
|
275
|
+
// Within a tier the tiebreak DIFFERS by status, and the failed one is
|
|
276
|
+
// the point: when failures alone blow the budget, the FIRST failure is
|
|
277
|
+
// usually the root cause and the later ones its cascade, so it must be
|
|
278
|
+
// the last thing stubbed. Successes keep oldest-first — none of them
|
|
279
|
+
// is more interesting than another, and recency is the better guess.
|
|
280
|
+
return a.status === 'failed' ? b.seq - a.seq : a.seq - b.seq
|
|
281
|
+
})
|
|
282
|
+
for (const e of order) {
|
|
283
|
+
if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
|
|
284
|
+
if (e.chars === 0) continue // already a stub — frees nothing
|
|
285
|
+
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
286
|
+
e.truncatedHeadChars += e.chars
|
|
287
|
+
e.chunks = []
|
|
288
|
+
e.chars = 0
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -31,6 +31,18 @@ export interface TelemetryHandle {
|
|
|
31
31
|
dispose(): void
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Reject anything that is not sink-shaped, naming what arrived. */
|
|
35
|
+
function checkSink(sink: unknown): TelemetrySink {
|
|
36
|
+
if (typeof sink !== 'object' || sink === null) {
|
|
37
|
+
throw new Error(`telemetry sink must be an object, got ${sink === null ? 'null' : typeof sink}`)
|
|
38
|
+
}
|
|
39
|
+
const wants = (sink as TelemetrySink).wants
|
|
40
|
+
if (wants !== undefined && !Array.isArray(wants)) {
|
|
41
|
+
throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
|
|
42
|
+
}
|
|
43
|
+
return sink as TelemetrySink
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/**
|
|
35
47
|
* Consult every plugin's `telemetry` capability, collect the sinks, and —
|
|
36
48
|
* only if at least one sink results — create a TelemetrySource and subscribe
|
|
@@ -49,23 +61,29 @@ export async function subscribeTelemetry(
|
|
|
49
61
|
const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
|
|
50
62
|
for (const plugin of plugins) {
|
|
51
63
|
if (plugin.telemetry === undefined) continue
|
|
52
|
-
|
|
64
|
+
// A plugin's return value is user input, so it is checked here rather
|
|
65
|
+
// than trusted downstream: `createTelemetrySource` reads `.wants` off
|
|
66
|
+
// every sink immediately, so one `null` in the list used to abort the
|
|
67
|
+
// whole run with a raw TypeError before a single task had run. Staging
|
|
68
|
+
// into `accepted` keeps a partly-bad array all-or-nothing, and the shape
|
|
69
|
+
// check sits INSIDE the try so a throwing `wants` getter is caught too.
|
|
70
|
+
let accepted: TelemetrySink[]
|
|
53
71
|
try {
|
|
54
|
-
result = await plugin.telemetry(ctx)
|
|
72
|
+
const result = await plugin.telemetry(ctx)
|
|
73
|
+
const list = result === undefined ? [] : Array.isArray(result) ? result : [result]
|
|
74
|
+
accepted = list.map((sink) => checkSink(sink))
|
|
55
75
|
} catch (err) {
|
|
56
76
|
ctx.warn(
|
|
57
77
|
`[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
58
78
|
)
|
|
59
79
|
continue
|
|
60
80
|
}
|
|
61
|
-
|
|
62
|
-
if (Array.isArray(result)) sinks.push(...result)
|
|
63
|
-
else sinks.push(result)
|
|
81
|
+
sinks.push(...accepted)
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
if (sinks.length === 0) return undefined
|
|
67
85
|
|
|
68
|
-
const source = createTelemetrySource({ sinks, run })
|
|
86
|
+
const source = createTelemetrySource({ sinks, run, warn: (m) => ctx.warn(m) })
|
|
69
87
|
const dispose = bus.subscribe(source.subscriber)
|
|
70
88
|
let disposed = false
|
|
71
89
|
return {
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
// record; its `TelemetryContext` carries read-only metadata and NO mutable
|
|
14
14
|
// run handle (no bus, no Cache, no RunRequest). There is no API path from a
|
|
15
15
|
// sink back into scheduling/caching/exec — telemetry provably cannot change
|
|
16
|
-
// what or how tasks run. Contrast `
|
|
16
|
+
// what or how tasks run. Contrast `cache`/`executor`, which return objects
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
19
|
import type { OutputFingerprint, TaskStatus, VerifyVerdict } from '../graph/index.js'
|
|
20
|
+
import { settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
20
21
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
21
22
|
|
|
22
23
|
/** Bumped when the record shape changes. Readers MUST check `v`. */
|
|
@@ -29,8 +30,9 @@ export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
|
|
|
29
30
|
* Map a task outcome status to its cache source. `success`/`failed` ran
|
|
30
31
|
* (a miss as far as the cache read is concerned); the two cache-hit
|
|
31
32
|
* statuses restored from local/remote; `skipped`/`aborted` never engaged
|
|
32
|
-
* the cache. Pure function — the single place this mapping is decided
|
|
33
|
-
*
|
|
33
|
+
* the cache. Pure function — the single place this mapping is decided;
|
|
34
|
+
* `isCacheHit` below is derived from it rather than re-listing the two
|
|
35
|
+
* hit statuses, so there is one decision, not two that can disagree.
|
|
34
36
|
*/
|
|
35
37
|
export function deriveCacheSource(status: TaskStatus): CacheSource {
|
|
36
38
|
switch (status) {
|
|
@@ -47,6 +49,59 @@ export function deriveCacheSource(status: TaskStatus): CacheSource {
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
|
|
54
|
+
* rather than a `new Set([...])` on purpose: a Record must name every member,
|
|
55
|
+
* so adding a status to the union is a COMPILE error HERE and the omission
|
|
56
|
+
* cannot ship. A Set of string literals has no such tripwire — it silently
|
|
57
|
+
* answers `false` for the new member, which is how ten hand-rolled copies of
|
|
58
|
+
* this vocabulary accumulated across core, cloud and the dashboard.
|
|
59
|
+
*/
|
|
60
|
+
const PASSES: Record<TaskStatus, boolean> = {
|
|
61
|
+
success: true,
|
|
62
|
+
'cache-hit': true,
|
|
63
|
+
'cache-hit-remote': true,
|
|
64
|
+
failed: false,
|
|
65
|
+
skipped: false,
|
|
66
|
+
aborted: false,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
|
|
71
|
+
* `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
|
|
72
|
+
* derived, not a second list that can fall behind. Exported so a consumer that
|
|
73
|
+
* cannot import the type (a test asserting a copy of this vocabulary in
|
|
74
|
+
* another package agrees) can still enumerate the real set.
|
|
75
|
+
*/
|
|
76
|
+
export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
|
|
77
|
+
|
|
78
|
+
const KNOWN_STATUSES: ReadonlySet<string> = new Set(TASK_STATUSES)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Did the task pass? A cache hit counts — it produced the same result without
|
|
82
|
+
* spending the time, which is the whole point. `skipped` and `aborted` do NOT:
|
|
83
|
+
* neither finished on its own terms, so neither can vouch for anything.
|
|
84
|
+
*
|
|
85
|
+
* Takes `string`, not `TaskStatus`, because most callers hold a status that
|
|
86
|
+
* arrived over a wire or out of a database column. An unrecognised string
|
|
87
|
+
* reads as NOT passing — the safe direction, since the alternative is calling
|
|
88
|
+
* a run green on a status this build has never heard of.
|
|
89
|
+
*/
|
|
90
|
+
export function isPassStatus(status: string): boolean {
|
|
91
|
+
return PASSES[status as TaskStatus] === true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Did the task's result come out of the cache (either layer)? Derived from
|
|
96
|
+
* `deriveCacheSource` rather than re-listing the two hit statuses, so the two
|
|
97
|
+
* cannot disagree about what a hit is. Unknown strings read as not-a-hit.
|
|
98
|
+
*/
|
|
99
|
+
export function isCacheHit(status: string): boolean {
|
|
100
|
+
if (!KNOWN_STATUSES.has(status)) return false
|
|
101
|
+
const source = deriveCacheSource(status as TaskStatus)
|
|
102
|
+
return source === 'local' || source === 'remote'
|
|
103
|
+
}
|
|
104
|
+
|
|
50
105
|
/** Identifies which run a record belongs to + its captured context. Maps
|
|
51
106
|
* cleanly onto OTel CI/CD + VCS resource attributes. */
|
|
52
107
|
export interface RunContextRecord {
|
|
@@ -71,6 +126,16 @@ export interface RunContextRecord {
|
|
|
71
126
|
// git / CI / host — straight from run-context.ts.
|
|
72
127
|
commitSha: string | null
|
|
73
128
|
branch: string | null
|
|
129
|
+
/**
|
|
130
|
+
* The repository's DEFAULT (trunk) branch, when detectable (v2 additive).
|
|
131
|
+
* A run is a TRUNK run iff `branch === defaultBranch` (both non-null);
|
|
132
|
+
* everything else is PR / feature-branch work. Consumers use it to keep
|
|
133
|
+
* branch-experiment timings out of the shared scheduling baseline; null
|
|
134
|
+
* (undetectable) means "count all runs" — no regression. Required of a v2
|
|
135
|
+
* producer (every one emits it); a reader parsing an older v1 push, which
|
|
136
|
+
* predates the field, treats absent as null.
|
|
137
|
+
*/
|
|
138
|
+
defaultBranch: string | null
|
|
74
139
|
dirty: boolean | null
|
|
75
140
|
ci: boolean
|
|
76
141
|
ciProvider: string | null
|
|
@@ -94,6 +159,12 @@ export interface TaskTelemetry {
|
|
|
94
159
|
hash?: string
|
|
95
160
|
cpuMs?: number
|
|
96
161
|
peakRssBytes?: number
|
|
162
|
+
/** Executor-reported placement (a worker id) — absent for local runs.
|
|
163
|
+
* Additive-optional, no schema bump (the `attempts`/`verify` precedent). */
|
|
164
|
+
where?: string
|
|
165
|
+
/** `'deferred'` when the outputs stayed remote (`--download=none`).
|
|
166
|
+
* Additive-optional, same no-bump precedent as `where`. */
|
|
167
|
+
outputs?: 'deferred'
|
|
97
168
|
/** Total attempts when the task RETRIED (>1) — set only when `exec.retries`
|
|
98
169
|
* / `--retry` produced more than one attempt. A retried-then-passed task is
|
|
99
170
|
* flaky by definition; this is the telemetry-side flaky signal. */
|
|
@@ -122,7 +193,16 @@ export interface TaskTelemetry {
|
|
|
122
193
|
* `task.log` records are large and OPT-IN (see `TelemetrySink.wants`).
|
|
123
194
|
*/
|
|
124
195
|
export type TelemetryRecord =
|
|
125
|
-
| {
|
|
196
|
+
| {
|
|
197
|
+
v: number
|
|
198
|
+
kind: 'run.start'
|
|
199
|
+
run: RunContextRecord
|
|
200
|
+
total: number
|
|
201
|
+
ts: number
|
|
202
|
+
/** The run's canonical start (epoch ms) — equals the summary's
|
|
203
|
+
* `startedAt`; a sink derives per-task timing from it during the run. */
|
|
204
|
+
startedAt: number
|
|
205
|
+
}
|
|
126
206
|
| {
|
|
127
207
|
v: number
|
|
128
208
|
kind: 'task.start'
|
|
@@ -166,6 +246,46 @@ export interface RunSummaryRecord {
|
|
|
166
246
|
tasks: readonly TaskTelemetry[]
|
|
167
247
|
}
|
|
168
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Assemble the canonical per-run summary from the per-task telemetry + the
|
|
251
|
+
* run-level timing/verdict. THE one place the `RunSummaryRecord` tallies are
|
|
252
|
+
* computed: both a local `run()` and the distributed controller build their
|
|
253
|
+
* `TaskTelemetry[]` (each via `deriveCacheSource`) and call this, so a
|
|
254
|
+
* distributed run and a local run produce byte-identical summaries and land in
|
|
255
|
+
* the same ingest. The per-task tallies (taskCount / failedCount /
|
|
256
|
+
* hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
|
|
257
|
+
* `exitOk` (the run's overall verdict — which counts skipped/verify beyond the
|
|
258
|
+
* recorded task list) are run-level facts and are passed in.
|
|
259
|
+
*/
|
|
260
|
+
export function assembleRunSummary(
|
|
261
|
+
run: RunContextRecord,
|
|
262
|
+
tasks: readonly TaskTelemetry[],
|
|
263
|
+
timing: { startedAt: number; endedAt: number; totalDurationMs: number; exitOk: boolean },
|
|
264
|
+
): RunSummaryRecord {
|
|
265
|
+
let failedCount = 0
|
|
266
|
+
let hitLocalCount = 0
|
|
267
|
+
let hitRemoteCount = 0
|
|
268
|
+
for (const t of tasks) {
|
|
269
|
+
if (t.status === 'failed') failedCount++
|
|
270
|
+
if (t.cacheSource === 'local') hitLocalCount++
|
|
271
|
+
else if (t.cacheSource === 'remote') hitRemoteCount++
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
v: TELEMETRY_SCHEMA_VERSION,
|
|
275
|
+
run,
|
|
276
|
+
startedAt: timing.startedAt,
|
|
277
|
+
endedAt: timing.endedAt,
|
|
278
|
+
totalDurationMs: timing.totalDurationMs,
|
|
279
|
+
taskCount: tasks.length,
|
|
280
|
+
failedCount,
|
|
281
|
+
hitCount: hitLocalCount + hitRemoteCount,
|
|
282
|
+
hitLocalCount,
|
|
283
|
+
hitRemoteCount,
|
|
284
|
+
exitOk: timing.exitOk,
|
|
285
|
+
tasks,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
169
289
|
/** A telemetry consumer. Observe-only: receives records, holds no run handle. */
|
|
170
290
|
export interface TelemetrySink {
|
|
171
291
|
readonly name?: string
|
|
@@ -180,7 +300,13 @@ export interface TelemetrySink {
|
|
|
180
300
|
onRecord?(record: TelemetryRecord): void
|
|
181
301
|
/** The per-run summary, at run:end. MUST return promptly. */
|
|
182
302
|
onRunSummary?(summary: RunSummaryRecord): void
|
|
183
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* Drain buffered data. Awaited at run:end under a deadline — a sink that
|
|
305
|
+
* has not settled by then is abandoned and its buffered records are lost.
|
|
306
|
+
* Losing a slow sink's telemetry is strictly better than the alternative:
|
|
307
|
+
* `run()` never returns, so the cache never closes and `vx` exits 0 on a
|
|
308
|
+
* failed run.
|
|
309
|
+
*/
|
|
184
310
|
flush?(): Promise<void>
|
|
185
311
|
}
|
|
186
312
|
|
|
@@ -199,7 +325,7 @@ export interface TelemetrySource {
|
|
|
199
325
|
readonly subscriber: RunEventSubscriber
|
|
200
326
|
/** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
|
|
201
327
|
emitSummary(summary: RunSummaryRecord): void
|
|
202
|
-
/** Await every sink's `flush()` (each crash-isolated). */
|
|
328
|
+
/** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
|
|
203
329
|
flush(): Promise<void>
|
|
204
330
|
}
|
|
205
331
|
|
|
@@ -224,12 +350,27 @@ const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
|
|
|
224
350
|
export function createTelemetrySource(args: {
|
|
225
351
|
sinks: readonly TelemetrySink[]
|
|
226
352
|
run: RunContextRecord
|
|
353
|
+
/** Where a dropped-flush notice goes; absent = stay silent. */
|
|
354
|
+
warn?: (message: string) => void
|
|
227
355
|
}): TelemetrySource {
|
|
228
|
-
const { sinks, run } = args
|
|
356
|
+
const { sinks, run, warn } = args
|
|
229
357
|
const runId = run.runId
|
|
230
358
|
// 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.
|
|
359
|
+
// here and it's skipped for the rest of the run, FLUSH INCLUDED. Flushing a
|
|
360
|
+
// disabled sink would write a buffer that is incomplete by construction: it
|
|
361
|
+
// stopped being fed records at the moment it threw, so the export would look
|
|
362
|
+
// like a run that simply had fewer tasks.
|
|
232
363
|
const disabled = new Set<TelemetrySink>()
|
|
364
|
+
// Never silently. The standing rule is that a never-fail path must still
|
|
365
|
+
// WARN — telemetry that vanishes without a word is indistinguishable from
|
|
366
|
+
// telemetry nobody configured.
|
|
367
|
+
const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
|
|
368
|
+
if (disabled.has(sink)) return
|
|
369
|
+
disabled.add(sink)
|
|
370
|
+
warn?.(
|
|
371
|
+
`[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
233
374
|
// Precompute which sinks want each kind, so per-event fan-out is a plain
|
|
234
375
|
// array walk with no per-record `wants` scanning.
|
|
235
376
|
const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
|
|
@@ -241,8 +382,8 @@ export function createTelemetrySource(args: {
|
|
|
241
382
|
if (!kinds.includes(record.kind)) continue
|
|
242
383
|
try {
|
|
243
384
|
sink.onRecord(record)
|
|
244
|
-
} catch {
|
|
245
|
-
|
|
385
|
+
} catch (err) {
|
|
386
|
+
disable(sink, 'onRecord', err)
|
|
246
387
|
}
|
|
247
388
|
}
|
|
248
389
|
}
|
|
@@ -258,6 +399,7 @@ export function createTelemetrySource(args: {
|
|
|
258
399
|
run,
|
|
259
400
|
total: event.info.total,
|
|
260
401
|
ts,
|
|
402
|
+
startedAt: event.info.startedAtMs ?? ts,
|
|
261
403
|
})
|
|
262
404
|
return
|
|
263
405
|
case 'task:start': {
|
|
@@ -309,6 +451,8 @@ export function createTelemetrySource(args: {
|
|
|
309
451
|
if (outcome.hash !== undefined) rec.hash = outcome.hash
|
|
310
452
|
if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
|
|
311
453
|
if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
|
|
454
|
+
if (outcome.where !== undefined) rec.where = outcome.where
|
|
455
|
+
if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
|
|
312
456
|
if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
|
|
313
457
|
if (outcome.verify !== undefined) rec.verify = outcome.verify
|
|
314
458
|
if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
|
|
@@ -337,22 +481,38 @@ export function createTelemetrySource(args: {
|
|
|
337
481
|
if (disabled.has(sink) || sink.onRunSummary === undefined) continue
|
|
338
482
|
try {
|
|
339
483
|
sink.onRunSummary(summary)
|
|
340
|
-
} catch {
|
|
341
|
-
|
|
484
|
+
} catch (err) {
|
|
485
|
+
disable(sink, 'onRunSummary', err)
|
|
342
486
|
}
|
|
343
487
|
}
|
|
344
488
|
},
|
|
345
489
|
async flush(): Promise<void> {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
490
|
+
const ms = teardownTimeoutMs()
|
|
491
|
+
// Bounded, for the same reason plugin teardown is bounded in
|
|
492
|
+
// plugin-host.ts: run() awaits this BEFORE closeCache() and before it
|
|
493
|
+
// returns, and bin.ts is `process.exit(await run(...))`. A sink whose
|
|
494
|
+
// flush never settles therefore drains the event loop with no exit code
|
|
495
|
+
// pending — Bun exits 0 and a FAILED run reports green, with the cache's
|
|
496
|
+
// accessed_at bumps and every later plugin's teardown lost with it.
|
|
497
|
+
// Sinks race concurrently, so each still gets the whole budget.
|
|
498
|
+
const settled = await settleWithin(
|
|
499
|
+
Promise.all(
|
|
500
|
+
sinks.map(async (sink) => {
|
|
501
|
+
if (disabled.has(sink) || sink.flush === undefined) return
|
|
502
|
+
try {
|
|
503
|
+
await sink.flush()
|
|
504
|
+
} catch (err) {
|
|
505
|
+
// A flush failure can never break the run — but it still costs
|
|
506
|
+
// this sink its export, so say so rather than dropping it.
|
|
507
|
+
warn?.(
|
|
508
|
+
`[vx] telemetry sink '${sink.name}' failed to flush: ${err instanceof Error ? err.message : String(err)}`,
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
}),
|
|
512
|
+
),
|
|
513
|
+
ms,
|
|
355
514
|
)
|
|
515
|
+
if (!settled) warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
|
|
356
516
|
},
|
|
357
517
|
}
|
|
358
518
|
}
|