@vzn/vx 0.0.16 → 0.0.21
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 +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- 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/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- 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/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// The shared per-task log capture primitive (task-logs-2026-07 §1). ONE
|
|
2
|
+
// bounded-tail buffer every telemetry sink uses — today `@vzn/vx-otel`'s
|
|
3
|
+
// sink — so the capping rules can't drift between capture sites.
|
|
4
|
+
//
|
|
5
|
+
// It lives in CORE, beside the telemetry contract, for the same reason
|
|
6
|
+
// `assembleRunSummary` does: two sinks that each roll their own bounded buffer
|
|
7
|
+
// is how the retention rule forks, and a forked rule means two sinks disagree
|
|
8
|
+
// about which task's output survived. A sink is the only consumer — core
|
|
9
|
+
// itself never captures logs — but the vocabulary it decides in (`TaskStatus`,
|
|
10
|
+
// `CacheSource`, "a hit's bytes belong to the run that executed") is core's.
|
|
11
|
+
//
|
|
12
|
+
// The law: a log-spewing task can never OOM the capturer. Two caps enforce it:
|
|
13
|
+
// - per task: only the last TASK_LOG_TAIL_CHARS of merged stdout+stderr is
|
|
14
|
+
// retained (chunks evicted from the head, no concatenation until drain);
|
|
15
|
+
// - per run: only RUN_LOG_BUDGET_CHARS of RETAINED completed tails ship, and
|
|
16
|
+
// failed tails are never evicted by successes — a failure's output is the
|
|
17
|
+
// one thing you always want to read.
|
|
18
|
+
//
|
|
19
|
+
// Both caps report themselves: an evicted or head-trimmed task still ships,
|
|
20
|
+
// carrying how much was dropped, so "we truncated this" never reads as "this
|
|
21
|
+
// task printed nothing".
|
|
22
|
+
|
|
23
|
+
import type { TaskStatus } from '../graph/index.js'
|
|
24
|
+
import type { CacheSource } from './telemetry.js'
|
|
25
|
+
|
|
26
|
+
/** Version of the drained-bundle shape below. It is the canonical drained
|
|
27
|
+
* logs format, not one transport's: every sink ships the same object. */
|
|
28
|
+
export const LOG_WIRE_VERSION = 1
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-task tail retained (UTF-16 units — a cheap capture-side proxy; the store
|
|
32
|
+
* records true stored bytes). Merged streams: what a terminal actually shows.
|
|
33
|
+
*/
|
|
34
|
+
export const TASK_LOG_TAIL_CHARS = 128 * 1024
|
|
35
|
+
|
|
36
|
+
/** Total RETAINED completed-task chars a single run ships. */
|
|
37
|
+
export const RUN_LOG_BUDGET_CHARS = 4 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
export interface TaskLogEntry {
|
|
40
|
+
taskId: string
|
|
41
|
+
/** The task's cache key when known — the hit→executed-run resolution key. */
|
|
42
|
+
hash?: string
|
|
43
|
+
status: 'success' | 'failed'
|
|
44
|
+
/** Merged stdout+stderr in arrival order, tail-capped, ANSI preserved. */
|
|
45
|
+
content: string
|
|
46
|
+
/** Chars emitted before capping. */
|
|
47
|
+
charsFull: number
|
|
48
|
+
/** Chars dropped from the head; 0 = complete. */
|
|
49
|
+
truncatedHeadChars: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TaskLogBundle {
|
|
53
|
+
v: typeof LOG_WIRE_VERSION
|
|
54
|
+
runId: string
|
|
55
|
+
workspaceId: string
|
|
56
|
+
tasks: TaskLogEntry[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Retained-until-drain state for one completed task. */
|
|
60
|
+
interface Retained {
|
|
61
|
+
taskId: string
|
|
62
|
+
hash?: string
|
|
63
|
+
status: 'success' | 'failed'
|
|
64
|
+
/** Emptied when the run budget evicts this task; `chars` goes to 0 and the
|
|
65
|
+
* dropped count moves to `truncatedHeadChars` (see `evictToBudget`). */
|
|
66
|
+
chunks: string[]
|
|
67
|
+
chars: number
|
|
68
|
+
charsFull: number
|
|
69
|
+
truncatedHeadChars: number
|
|
70
|
+
/** Insertion order — the eviction tiebreak (oldest first). */
|
|
71
|
+
seq: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** In-flight (not yet ended) accumulation for one task. */
|
|
75
|
+
interface InFlight {
|
|
76
|
+
chunks: string[]
|
|
77
|
+
chars: number
|
|
78
|
+
charsFull: number
|
|
79
|
+
truncatedHeadChars: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bounded per-run capture. `append` keeps a chunk LIST + running char count per
|
|
84
|
+
* task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
|
|
85
|
+
* string concatenation until `drain`, so a cache-hit replay (one big chunk) is
|
|
86
|
+
* one array push, zero copies. `finish` decides retention; `drain` emits the
|
|
87
|
+
* bundle, failures first.
|
|
88
|
+
*/
|
|
89
|
+
/**
|
|
90
|
+
* Per-retained-chunk overhead, in char-equivalents, charged against the RUN
|
|
91
|
+
* budget on top of the characters themselves.
|
|
92
|
+
*
|
|
93
|
+
* The budget exists to bound MEMORY, but a char count is not one: a task that
|
|
94
|
+
* emits a byte at a time (unbuffered progress output) stores the same content
|
|
95
|
+
* as thousands of separate strings, each with its own header and allocation.
|
|
96
|
+
* MEASURED on Bun 1.4: 1 000 000 chars arrive as one chunk for ~1.4 MB of
|
|
97
|
+
* marginal RSS, and as 1 000 000 one-char chunks for ~30 MB — so a 4 MiB
|
|
98
|
+
* char budget could hold ~80 MB of actual memory. Charging each chunk ~24
|
|
99
|
+
* char-equivalents makes the budget track what it is trying to limit; chunky
|
|
100
|
+
* output is unaffected (one 128 KiB chunk pays 24 on 131 072).
|
|
101
|
+
*
|
|
102
|
+
* The per-task TAIL cap deliberately stays a pure char count — it bounds how
|
|
103
|
+
* much a reader is shown, which is a different question from how much the
|
|
104
|
+
* process holds.
|
|
105
|
+
*/
|
|
106
|
+
const CHUNK_OVERHEAD_CHARS = 24
|
|
107
|
+
|
|
108
|
+
/** What one retained entry costs the run budget: content + per-chunk overhead. */
|
|
109
|
+
function budgetCost(chars: number, chunkCount: number): number {
|
|
110
|
+
return chars + chunkCount * CHUNK_OVERHEAD_CHARS
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class TaskLogBuffer {
|
|
114
|
+
private readonly inFlight = new Map<string, InFlight>()
|
|
115
|
+
private readonly retained = new Map<string, Retained>()
|
|
116
|
+
private retainedChars = 0
|
|
117
|
+
private seq = 0
|
|
118
|
+
|
|
119
|
+
append(taskId: string, chunk: string): void {
|
|
120
|
+
if (chunk.length === 0) return
|
|
121
|
+
let acc = this.inFlight.get(taskId)
|
|
122
|
+
if (acc === undefined) {
|
|
123
|
+
acc = { chunks: [], chars: 0, charsFull: 0, truncatedHeadChars: 0 }
|
|
124
|
+
this.inFlight.set(taskId, acc)
|
|
125
|
+
}
|
|
126
|
+
acc.chunks.push(chunk)
|
|
127
|
+
acc.chars += chunk.length
|
|
128
|
+
acc.charsFull += chunk.length
|
|
129
|
+
// Evict whole chunks from the head until under the per-task tail cap. A
|
|
130
|
+
// single over-cap chunk is sliced to its tail (the only place we copy).
|
|
131
|
+
while (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length > 1) {
|
|
132
|
+
const dropped = acc.chunks.shift()!
|
|
133
|
+
acc.chars -= dropped.length
|
|
134
|
+
acc.truncatedHeadChars += dropped.length
|
|
135
|
+
}
|
|
136
|
+
if (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length === 1) {
|
|
137
|
+
const only = acc.chunks[0]!
|
|
138
|
+
const keep = only.slice(only.length - TASK_LOG_TAIL_CHARS)
|
|
139
|
+
acc.truncatedHeadChars += only.length - keep.length
|
|
140
|
+
acc.chunks[0] = keep
|
|
141
|
+
acc.chars = keep.length
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* task.end: decide retention.
|
|
147
|
+
* - cacheSource !== 'miss' (a hit) → DROP: the executed run already stored
|
|
148
|
+
* these bytes; a hit resolves by hash to that run.
|
|
149
|
+
* - 'skipped' / 'aborted' → DROP (no meaningful output; a tearing-down run).
|
|
150
|
+
* - a success/failed MISS → RETAIN the tail, under the run budget: adding a
|
|
151
|
+
* task that would exceed `RUN_LOG_BUDGET_CHARS` evicts oldest retained
|
|
152
|
+
* SUCCESS tails first; failed tails are evicted only by newer failed tails
|
|
153
|
+
* (oldest first) once failures alone exceed the budget.
|
|
154
|
+
*/
|
|
155
|
+
finish(taskId: string, status: TaskStatus, cacheSource: CacheSource, hash?: string): void {
|
|
156
|
+
const acc = this.inFlight.get(taskId)
|
|
157
|
+
this.inFlight.delete(taskId)
|
|
158
|
+
if (cacheSource !== 'miss') return
|
|
159
|
+
if (status !== 'success' && status !== 'failed') return
|
|
160
|
+
if (acc === undefined) return
|
|
161
|
+
|
|
162
|
+
const entry: Retained = {
|
|
163
|
+
taskId,
|
|
164
|
+
...(hash !== undefined ? { hash } : {}),
|
|
165
|
+
status,
|
|
166
|
+
chunks: acc.chunks,
|
|
167
|
+
chars: acc.chars,
|
|
168
|
+
charsFull: acc.charsFull,
|
|
169
|
+
truncatedHeadChars: acc.truncatedHeadChars,
|
|
170
|
+
seq: this.seq++,
|
|
171
|
+
}
|
|
172
|
+
// Replace an earlier retention for the same task id (a retried task ends
|
|
173
|
+
// once per attempt only via the winning outcome, but be defensive).
|
|
174
|
+
const prior = this.retained.get(taskId)
|
|
175
|
+
if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
|
|
176
|
+
this.retained.set(taskId, entry)
|
|
177
|
+
this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
|
|
178
|
+
this.evictToBudget()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Take ONE finished task's retained tail as a wire entry, removing it from
|
|
183
|
+
* the buffer (so a later `drain` won't re-ship it). Used by per-task
|
|
184
|
+
* incremental delivery: `finish()` retains, then the sink `takeEntry()`s and
|
|
185
|
+
* ships that one task. Returns undefined for a task that wasn't retained (a
|
|
186
|
+
* cache hit / skipped / still in-flight / unknown id) — the task result still
|
|
187
|
+
* ships; there is just no log tail to send.
|
|
188
|
+
*/
|
|
189
|
+
takeEntry(taskId: string): TaskLogEntry | undefined {
|
|
190
|
+
const e = this.retained.get(taskId)
|
|
191
|
+
if (e === undefined) return undefined
|
|
192
|
+
this.retained.delete(taskId)
|
|
193
|
+
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
194
|
+
return {
|
|
195
|
+
taskId: e.taskId,
|
|
196
|
+
...(e.hash !== undefined ? { hash: e.hash } : {}),
|
|
197
|
+
status: e.status,
|
|
198
|
+
content: e.chunks.join(''),
|
|
199
|
+
charsFull: e.charsFull,
|
|
200
|
+
truncatedHeadChars: e.truncatedHeadChars,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Everything retained, failures first, ready to ship/store. */
|
|
205
|
+
drain(runId: string, workspaceId: string): TaskLogBundle {
|
|
206
|
+
const entries = [...this.retained.values()].sort((a, b) => {
|
|
207
|
+
// Failures ahead of successes; within a tier, oldest first (stable).
|
|
208
|
+
const fa = a.status === 'failed' ? 0 : 1
|
|
209
|
+
const fb = b.status === 'failed' ? 0 : 1
|
|
210
|
+
return fa !== fb ? fa - fb : a.seq - b.seq
|
|
211
|
+
})
|
|
212
|
+
return {
|
|
213
|
+
v: LOG_WIRE_VERSION,
|
|
214
|
+
runId,
|
|
215
|
+
workspaceId,
|
|
216
|
+
tasks: entries.map((e) => ({
|
|
217
|
+
taskId: e.taskId,
|
|
218
|
+
...(e.hash !== undefined ? { hash: e.hash } : {}),
|
|
219
|
+
status: e.status,
|
|
220
|
+
content: e.chunks.join(''),
|
|
221
|
+
charsFull: e.charsFull,
|
|
222
|
+
truncatedHeadChars: e.truncatedHeadChars,
|
|
223
|
+
})),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Retained-tasks count (test/observability). */
|
|
228
|
+
size(): number {
|
|
229
|
+
return this.retained.size
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Budget charged right now, in char-equivalents (test/observability).
|
|
234
|
+
*
|
|
235
|
+
* Exposed because the charge/release symmetry is the invariant that keeps
|
|
236
|
+
* the cap meaningful and the one that fails SILENTLY: a release that does
|
|
237
|
+
* not match its charge drifts `retainedChars` a little per task, and a
|
|
238
|
+
* long run ends up bounding something other than what it thinks.
|
|
239
|
+
*/
|
|
240
|
+
budgetUsed(): number {
|
|
241
|
+
return this.retainedChars
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
|
|
246
|
+
* (oldest by seq); only when failures ALONE still exceed the budget do the
|
|
247
|
+
* oldest failures go — a failure is never dropped to keep a success.
|
|
248
|
+
*
|
|
249
|
+
* An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
|
|
250
|
+
* = everything it emitted) rather than removed. Dropping the entry makes a
|
|
251
|
+
* budget eviction indistinguishable from a task that printed nothing — the
|
|
252
|
+
* reader gets "no logs captured" either way, which is a confident false
|
|
253
|
+
* statement in the one case where they most need the output (a run with
|
|
254
|
+
* enough failing tasks to blow the budget). A stub costs a few dozen bytes,
|
|
255
|
+
* keeps the `content.length === charsFull - truncatedHeadChars` accounting
|
|
256
|
+
* honest, and renders through the truncation banner the per-task cap
|
|
257
|
+
* already uses — a store that enforces its own run budget should degrade
|
|
258
|
+
* the same way (slice to empty, add the remainder to the truncated count).
|
|
259
|
+
*
|
|
260
|
+
* The cost is a stub per evicted task in the end-of-run bundle: measured at
|
|
261
|
+
* ~190 JSON bytes each, so ~1.9 MiB at the 10k-task scale target and ~9.3 MiB
|
|
262
|
+
* at 50k — a sink that posts the whole bundle in one request sizes its cap
|
|
263
|
+
* against that; one that ships per task as it finishes (`takeEntry`) never
|
|
264
|
+
* sees it.
|
|
265
|
+
*/
|
|
266
|
+
private evictToBudget(): void {
|
|
267
|
+
if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
|
|
268
|
+
const order = [...this.retained.values()].sort((a, b) => {
|
|
269
|
+
const sa = a.status === 'success' ? 0 : 1
|
|
270
|
+
const sb = b.status === 'success' ? 0 : 1
|
|
271
|
+
if (sa !== sb) return sa - sb // successes are dropped first, always
|
|
272
|
+
// Within a tier the tiebreak DIFFERS by status, and the failed one is
|
|
273
|
+
// the point: when failures alone blow the budget, the FIRST failure is
|
|
274
|
+
// usually the root cause and the later ones its cascade, so it must be
|
|
275
|
+
// the last thing stubbed. Successes keep oldest-first — none of them
|
|
276
|
+
// is more interesting than another, and recency is the better guess.
|
|
277
|
+
return a.status === 'failed' ? b.seq - a.seq : a.seq - b.seq
|
|
278
|
+
})
|
|
279
|
+
for (const e of order) {
|
|
280
|
+
if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
|
|
281
|
+
if (e.chars === 0) continue // already a stub — frees nothing
|
|
282
|
+
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
283
|
+
e.truncatedHeadChars += e.chars
|
|
284
|
+
e.chunks = []
|
|
285
|
+
e.chars = 0
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -31,6 +31,28 @@ 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
|
+
// Every handler is optional, so `{ nope: true }` was a valid sink that
|
|
44
|
+
// subscribed to the bus and heard nothing — the plugin was "on" and did
|
|
45
|
+
// nothing. Refused here, which the caller turns into the same warning
|
|
46
|
+
// a throwing hook gets: never a failed run.
|
|
47
|
+
const s = sink as TelemetrySink
|
|
48
|
+
if (typeof s.onRecord !== 'function' && typeof s.onRunSummary !== 'function') {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'telemetry sink handles nothing: neither onRecord nor onRunSummary is a function',
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
return s
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/**
|
|
35
57
|
* Consult every plugin's `telemetry` capability, collect the sinks, and —
|
|
36
58
|
* only if at least one sink results — create a TelemetrySource and subscribe
|
|
@@ -49,23 +71,29 @@ export async function subscribeTelemetry(
|
|
|
49
71
|
const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
|
|
50
72
|
for (const plugin of plugins) {
|
|
51
73
|
if (plugin.telemetry === undefined) continue
|
|
52
|
-
|
|
74
|
+
// A plugin's return value is user input, so it is checked here rather
|
|
75
|
+
// than trusted downstream: `createTelemetrySource` reads `.wants` off
|
|
76
|
+
// every sink immediately, so one `null` in the list used to abort the
|
|
77
|
+
// whole run with a raw TypeError before a single task had run. Staging
|
|
78
|
+
// into `accepted` keeps a partly-bad array all-or-nothing, and the shape
|
|
79
|
+
// check sits INSIDE the try so a throwing `wants` getter is caught too.
|
|
80
|
+
let accepted: TelemetrySink[]
|
|
53
81
|
try {
|
|
54
|
-
result = await plugin.telemetry(ctx)
|
|
82
|
+
const result = await plugin.telemetry(ctx)
|
|
83
|
+
const list = result === undefined ? [] : Array.isArray(result) ? result : [result]
|
|
84
|
+
accepted = list.map((sink) => checkSink(sink))
|
|
55
85
|
} catch (err) {
|
|
56
86
|
ctx.warn(
|
|
57
87
|
`[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
58
88
|
)
|
|
59
89
|
continue
|
|
60
90
|
}
|
|
61
|
-
|
|
62
|
-
if (Array.isArray(result)) sinks.push(...result)
|
|
63
|
-
else sinks.push(result)
|
|
91
|
+
sinks.push(...accepted)
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
if (sinks.length === 0) return undefined
|
|
67
95
|
|
|
68
|
-
const source = createTelemetrySource({ sinks, run })
|
|
96
|
+
const source = createTelemetrySource({ sinks, run, warn: (m) => ctx.warn(m) })
|
|
69
97
|
const dispose = bus.subscribe(source.subscriber)
|
|
70
98
|
let disposed = false
|
|
71
99
|
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
|
-
import type {
|
|
19
|
+
import type { TaskStatus } 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,22 +159,16 @@ 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` 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. */
|
|
100
171
|
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
172
|
/** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
|
|
114
173
|
wallclockStartNs?: string
|
|
115
174
|
wallclockEndNs?: string
|
|
@@ -122,7 +181,16 @@ export interface TaskTelemetry {
|
|
|
122
181
|
* `task.log` records are large and OPT-IN (see `TelemetrySink.wants`).
|
|
123
182
|
*/
|
|
124
183
|
export type TelemetryRecord =
|
|
125
|
-
| {
|
|
184
|
+
| {
|
|
185
|
+
v: number
|
|
186
|
+
kind: 'run.start'
|
|
187
|
+
run: RunContextRecord
|
|
188
|
+
total: number
|
|
189
|
+
ts: number
|
|
190
|
+
/** The run's canonical start (epoch ms) — equals the summary's
|
|
191
|
+
* `startedAt`; a sink derives per-task timing from it during the run. */
|
|
192
|
+
startedAt: number
|
|
193
|
+
}
|
|
126
194
|
| {
|
|
127
195
|
v: number
|
|
128
196
|
kind: 'task.start'
|
|
@@ -166,6 +234,46 @@ export interface RunSummaryRecord {
|
|
|
166
234
|
tasks: readonly TaskTelemetry[]
|
|
167
235
|
}
|
|
168
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Assemble the canonical per-run summary from the per-task telemetry + the
|
|
239
|
+
* run-level timing/verdict. THE one place the `RunSummaryRecord` tallies are
|
|
240
|
+
* computed: both a local `run()` and the distributed controller build their
|
|
241
|
+
* `TaskTelemetry[]` (each via `deriveCacheSource`) and call this, so a
|
|
242
|
+
* distributed run and a local run produce byte-identical summaries and land in
|
|
243
|
+
* the same ingest. The per-task tallies (taskCount / failedCount /
|
|
244
|
+
* hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
|
|
245
|
+
* `exitOk` (the run's overall verdict — which counts skipped tasks beyond the
|
|
246
|
+
* recorded task list) are run-level facts and are passed in.
|
|
247
|
+
*/
|
|
248
|
+
export function assembleRunSummary(
|
|
249
|
+
run: RunContextRecord,
|
|
250
|
+
tasks: readonly TaskTelemetry[],
|
|
251
|
+
timing: { startedAt: number; endedAt: number; totalDurationMs: number; exitOk: boolean },
|
|
252
|
+
): RunSummaryRecord {
|
|
253
|
+
let failedCount = 0
|
|
254
|
+
let hitLocalCount = 0
|
|
255
|
+
let hitRemoteCount = 0
|
|
256
|
+
for (const t of tasks) {
|
|
257
|
+
if (t.status === 'failed') failedCount++
|
|
258
|
+
if (t.cacheSource === 'local') hitLocalCount++
|
|
259
|
+
else if (t.cacheSource === 'remote') hitRemoteCount++
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
v: TELEMETRY_SCHEMA_VERSION,
|
|
263
|
+
run,
|
|
264
|
+
startedAt: timing.startedAt,
|
|
265
|
+
endedAt: timing.endedAt,
|
|
266
|
+
totalDurationMs: timing.totalDurationMs,
|
|
267
|
+
taskCount: tasks.length,
|
|
268
|
+
failedCount,
|
|
269
|
+
hitCount: hitLocalCount + hitRemoteCount,
|
|
270
|
+
hitLocalCount,
|
|
271
|
+
hitRemoteCount,
|
|
272
|
+
exitOk: timing.exitOk,
|
|
273
|
+
tasks,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
169
277
|
/** A telemetry consumer. Observe-only: receives records, holds no run handle. */
|
|
170
278
|
export interface TelemetrySink {
|
|
171
279
|
readonly name?: string
|
|
@@ -180,7 +288,13 @@ export interface TelemetrySink {
|
|
|
180
288
|
onRecord?(record: TelemetryRecord): void
|
|
181
289
|
/** The per-run summary, at run:end. MUST return promptly. */
|
|
182
290
|
onRunSummary?(summary: RunSummaryRecord): void
|
|
183
|
-
/**
|
|
291
|
+
/**
|
|
292
|
+
* Drain buffered data. Awaited at run:end under a deadline — a sink that
|
|
293
|
+
* has not settled by then is abandoned and its buffered records are lost.
|
|
294
|
+
* Losing a slow sink's telemetry is strictly better than the alternative:
|
|
295
|
+
* `run()` never returns, so the cache never closes and `vx` exits 0 on a
|
|
296
|
+
* failed run.
|
|
297
|
+
*/
|
|
184
298
|
flush?(): Promise<void>
|
|
185
299
|
}
|
|
186
300
|
|
|
@@ -199,7 +313,7 @@ export interface TelemetrySource {
|
|
|
199
313
|
readonly subscriber: RunEventSubscriber
|
|
200
314
|
/** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
|
|
201
315
|
emitSummary(summary: RunSummaryRecord): void
|
|
202
|
-
/** Await every sink's `flush()` (each crash-isolated). */
|
|
316
|
+
/** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
|
|
203
317
|
flush(): Promise<void>
|
|
204
318
|
}
|
|
205
319
|
|
|
@@ -224,12 +338,27 @@ const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
|
|
|
224
338
|
export function createTelemetrySource(args: {
|
|
225
339
|
sinks: readonly TelemetrySink[]
|
|
226
340
|
run: RunContextRecord
|
|
341
|
+
/** Where a dropped-flush notice goes; absent = stay silent. */
|
|
342
|
+
warn?: (message: string) => void
|
|
227
343
|
}): TelemetrySource {
|
|
228
|
-
const { sinks, run } = args
|
|
344
|
+
const { sinks, run, warn } = args
|
|
229
345
|
const runId = run.runId
|
|
230
346
|
// 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.
|
|
347
|
+
// here and it's skipped for the rest of the run, FLUSH INCLUDED. Flushing a
|
|
348
|
+
// disabled sink would write a buffer that is incomplete by construction: it
|
|
349
|
+
// stopped being fed records at the moment it threw, so the export would look
|
|
350
|
+
// like a run that simply had fewer tasks.
|
|
232
351
|
const disabled = new Set<TelemetrySink>()
|
|
352
|
+
// Never silently. The standing rule is that a never-fail path must still
|
|
353
|
+
// WARN — telemetry that vanishes without a word is indistinguishable from
|
|
354
|
+
// telemetry nobody configured.
|
|
355
|
+
const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
|
|
356
|
+
if (disabled.has(sink)) return
|
|
357
|
+
disabled.add(sink)
|
|
358
|
+
warn?.(
|
|
359
|
+
`[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
233
362
|
// Precompute which sinks want each kind, so per-event fan-out is a plain
|
|
234
363
|
// array walk with no per-record `wants` scanning.
|
|
235
364
|
const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
|
|
@@ -241,8 +370,8 @@ export function createTelemetrySource(args: {
|
|
|
241
370
|
if (!kinds.includes(record.kind)) continue
|
|
242
371
|
try {
|
|
243
372
|
sink.onRecord(record)
|
|
244
|
-
} catch {
|
|
245
|
-
|
|
373
|
+
} catch (err) {
|
|
374
|
+
disable(sink, 'onRecord', err)
|
|
246
375
|
}
|
|
247
376
|
}
|
|
248
377
|
}
|
|
@@ -258,6 +387,7 @@ export function createTelemetrySource(args: {
|
|
|
258
387
|
run,
|
|
259
388
|
total: event.info.total,
|
|
260
389
|
ts,
|
|
390
|
+
startedAt: event.info.startedAtMs ?? ts,
|
|
261
391
|
})
|
|
262
392
|
return
|
|
263
393
|
case 'task:start': {
|
|
@@ -309,9 +439,9 @@ export function createTelemetrySource(args: {
|
|
|
309
439
|
if (outcome.hash !== undefined) rec.hash = outcome.hash
|
|
310
440
|
if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
|
|
311
441
|
if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
|
|
442
|
+
if (outcome.where !== undefined) rec.where = outcome.where
|
|
443
|
+
if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
|
|
312
444
|
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
445
|
if (outcome.wallclockStartNs !== undefined)
|
|
316
446
|
rec.wallclockStartNs = outcome.wallclockStartNs.toString()
|
|
317
447
|
if (outcome.wallclockEndNs !== undefined)
|
|
@@ -337,22 +467,38 @@ export function createTelemetrySource(args: {
|
|
|
337
467
|
if (disabled.has(sink) || sink.onRunSummary === undefined) continue
|
|
338
468
|
try {
|
|
339
469
|
sink.onRunSummary(summary)
|
|
340
|
-
} catch {
|
|
341
|
-
|
|
470
|
+
} catch (err) {
|
|
471
|
+
disable(sink, 'onRunSummary', err)
|
|
342
472
|
}
|
|
343
473
|
}
|
|
344
474
|
},
|
|
345
475
|
async flush(): Promise<void> {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
476
|
+
const ms = teardownTimeoutMs()
|
|
477
|
+
// Bounded, for the same reason plugin teardown is bounded in
|
|
478
|
+
// plugin-host.ts: run() awaits this BEFORE closeCache() and before it
|
|
479
|
+
// returns, and bin.ts is `process.exit(await run(...))`. A sink whose
|
|
480
|
+
// flush never settles therefore drains the event loop with no exit code
|
|
481
|
+
// pending — Bun exits 0 and a FAILED run reports green, with the cache's
|
|
482
|
+
// accessed_at bumps and every later plugin's teardown lost with it.
|
|
483
|
+
// Sinks race concurrently, so each still gets the whole budget.
|
|
484
|
+
const settled = await settleWithin(
|
|
485
|
+
Promise.all(
|
|
486
|
+
sinks.map(async (sink) => {
|
|
487
|
+
if (disabled.has(sink) || sink.flush === undefined) return
|
|
488
|
+
try {
|
|
489
|
+
await sink.flush()
|
|
490
|
+
} catch (err) {
|
|
491
|
+
// A flush failure can never break the run — but it still costs
|
|
492
|
+
// this sink its export, so say so rather than dropping it.
|
|
493
|
+
warn?.(
|
|
494
|
+
`[vx] telemetry sink '${sink.name}' failed to flush: ${err instanceof Error ? err.message : String(err)}`,
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
}),
|
|
498
|
+
),
|
|
499
|
+
ms,
|
|
355
500
|
)
|
|
501
|
+
if (!settled) warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
|
|
356
502
|
},
|
|
357
503
|
}
|
|
358
504
|
}
|