@vzn/vx 0.0.0 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,176 @@
1
+ // Run option/summary types live in a leaf file (not the module entry)
2
+ // so internals like prepare.ts can import them without an upward
3
+ // import of index.ts — the module entry must stay cycle-free.
4
+
5
+ import type { CachePolicy } from '../cache/index.js'
6
+ import type { ContinueMode } from '../graph/index.js'
7
+ import type { TaskOutcome } from '../graph/index.js'
8
+ import type { EventBus } from './events.js'
9
+ import type { TelemetrySink } from './telemetry.js'
10
+ import type { Logger } from './logger.js'
11
+
12
+ export interface RunOptions {
13
+ cwd: string
14
+ /**
15
+ * Task specs to run. Each may be a bare task name (`'build'`) —
16
+ * applied across `projects` to every project that declares it —
17
+ * or an anchored `'pkg#task'` — added directly to the requested
18
+ * set regardless of `projects`.
19
+ */
20
+ tasks: readonly string[]
21
+ projects?: string[]
22
+ concurrency?: number
23
+ /**
24
+ * Cache directory override (`--cache-dir <path>`). Absolute, or relative
25
+ * to `cwd`. Takes precedence over the workspace `cacheDir` field and the
26
+ * `.vx/cache` default. A per-run knob — never folded into a cache key.
27
+ */
28
+ cacheDir?: string
29
+ /**
30
+ * Granular cache read/write control across the local + remote layers.
31
+ * Each of the four axes (localRead / localWrite / remoteRead /
32
+ * remoteWrite) is independent. Undefined → {@link FULL_CACHE_POLICY}
33
+ * (everything on). `--no-cache` sets all four false; `--force` sets
34
+ * both reads false (re-execute, still refresh the cache).
35
+ */
36
+ cache?: CachePolicy
37
+ /**
38
+ * CI mode: load configs FROM the committed vx-lock.json instead of
39
+ * evaluating them (frozen-env reproducibility). Requires the lock
40
+ * to exist and pass its content-hash tripwire. Local runs default
41
+ * to live evaluation — byte hashes can't see a config's import
42
+ * closure, so a strict local mode would give false confidence.
43
+ * Pair with `vx lock --check` in CI for the full evaluation audit.
44
+ */
45
+ frozen?: boolean
46
+ /**
47
+ * Explicit per-task output volume for the default logger. ALWAYS
48
+ * overrides the flow default. 'full': frames for work + one-liners
49
+ * for quiet hits. 'errors-only': only failed tasks print. 'none':
50
+ * no per-task output at all. Status lines (header, summary) always
51
+ * print.
52
+ */
53
+ outputLogs?: 'full' | 'errors-only' | 'none'
54
+ /**
55
+ * Run intent, derived by the CLI from selection flags: 'broad' iff
56
+ * `--all` / `--filter` / `--affected` was passed, else 'focused'.
57
+ * Drives the default logger's per-task output policy when neither
58
+ * `outputLogs` nor a truthy CI env overrides it. Undefined
59
+ * (programmatic callers) behaves like today's 'full'.
60
+ */
61
+ flow?: 'focused' | 'broad'
62
+ /**
63
+ * Run-level retry default (`--retry <n>`): max additional attempts
64
+ * after a failed attempt, for tasks that don't declare their own
65
+ * `exec.retries` (explicit config wins, including `retries: 0`).
66
+ * Threaded as an option only — never folded into any cache key, so
67
+ * the same run with and without `--retry` derives identical keys.
68
+ */
69
+ retries?: number
70
+ /**
71
+ * Cache-correctness verification (`vx run --verify`). When set, an
72
+ * executed + cacheable task is re-run after its save and its outputs are
73
+ * content-compared: a divergence means the task is non-hermetic and its
74
+ * cache entry is provably unsafe (see docs/design/cache-correctness).
75
+ * `allow` (from `--verify-allow=<pkg#task>,…`) exempts known-nondeterministic
76
+ * tasks from failing the run. A pure side-channel — never folded into a
77
+ * cache key; the re-run never saves. Undefined = off (a plain run is
78
+ * byte-identical).
79
+ */
80
+ verify?: {
81
+ determinism: boolean
82
+ inputs: boolean
83
+ allow: ReadonlySet<string>
84
+ }
85
+ /**
86
+ * Run-level default task timeout (ms), for tasks that declare no
87
+ * `exec.timeout`. Highest of the run-level defaults: `--timeout` /
88
+ * this option → `VX_TASK_TIMEOUT` env → workspace `timeout`. Per-task
89
+ * `exec.timeout` always wins. Threaded as an option only — never
90
+ * folded into any cache key (a timed-out task fails and is never
91
+ * cached), so a `--timeout` run cache-hits a plain run's entry.
92
+ */
93
+ timeout?: number
94
+ /**
95
+ * Failure propagation: 'never' stops dispatch on the first failure
96
+ * (in-flight tasks finish; everything queued skips), 'deps-ok'
97
+ * (default) skips only a failure's dependents, 'always' runs
98
+ * dependents even when an upstream failed — sound under pure-input
99
+ * hashing (failed outcomes carry the upstream's input key). CLI:
100
+ * `--continue[=never|deps-ok|always]`; bare `--continue` = 'always'.
101
+ */
102
+ continueMode?: ContinueMode
103
+ /**
104
+ * Filter `dependsOn` expansion. `'all'` drops every edge (just the
105
+ * requested task runs). A string array drops only those task names
106
+ * from both `self` and `dependencies` buckets.
107
+ */
108
+ excludeDependencies?: 'all' | readonly string[]
109
+ /** Forwarded to the last step of each task's exec array (shell-quoted). */
110
+ forwardArgs?: readonly string[]
111
+ /**
112
+ * If set, write a per-run JSON summary at end of run. Empty string
113
+ * picks the default path `<cacheDir>/runs/<run_id>.json`; anything
114
+ * else is treated as the literal file path (cwd-relative).
115
+ */
116
+ summarize?: string
117
+ /**
118
+ * If set, write a Chrome-trace JSON profile of the run's wallclock
119
+ * spans. Path is cwd-relative. Default `profile.json` is selected
120
+ * by the CLI parser, not here.
121
+ */
122
+ profile?: string
123
+ /**
124
+ * Install SIGINT/SIGTERM handlers for the run's duration: forward
125
+ * SIGTERM to every live child, close the cache, exit 128+signo
126
+ * (130/143). Default true. The watch loop disables this — it owns
127
+ * signal disposition for its whole lifetime and a cycle must never
128
+ * exit the process out from under it.
129
+ */
130
+ handleSignals?: boolean
131
+ log?: Logger
132
+ /**
133
+ * Inject the run's event bus. When provided, the orchestrator emits
134
+ * onto it instead of creating its own — letting a caller subscribe a
135
+ * surface (e.g. the `--ui` devframe dev server) BEFORE the run starts
136
+ * emitting. The terminal renderer is still attached as a subscriber, so
137
+ * default output is unchanged. Default: a fresh internal bus.
138
+ */
139
+ bus?: EventBus
140
+ /**
141
+ * Shared in-flight execution registry, keyed by task hash. Supplied by a
142
+ * long-lived service (`vx serve`) so concurrent runs DEDUP work: a task
143
+ * already executing for one run is awaited by another (which then
144
+ * restores the just-saved artifact from cache) instead of re-running.
145
+ * A stateless `vx run` passes none and is byte-identical to before.
146
+ */
147
+ inflight?: Map<string, Promise<void>>
148
+ /**
149
+ * `--tag k=v` pairs (Tier-3). Persisted verbatim on the run's
150
+ * `invocations` row (JSON object) so dashboards can filter runs by
151
+ * label. CLI parsing is the CLI's job; the orchestrator just records.
152
+ */
153
+ tags?: Record<string, string>
154
+ /**
155
+ * Additional observe-only telemetry sinks, merged with whatever the
156
+ * workspace plugins contribute. The embedder seam: a host executing
157
+ * runs on behalf of others (a serve recording delegated runs
158
+ * into its ingest store) attaches a sink without needing a workspace
159
+ * plugin. Undefined → zero cost, identical to before; the telemetry
160
+ * host's zero-sink invariant still applies when both sources are
161
+ * empty.
162
+ */
163
+ telemetrySinks?: readonly TelemetrySink[]
164
+ /**
165
+ * The raw command string for the `invocations` row (e.g.
166
+ * `'vx run build test --all'`). When absent, run() falls back to
167
+ * `process.argv.slice(1).join(' ')` so programmatic callers still
168
+ * record something useful.
169
+ */
170
+ command?: string
171
+ }
172
+
173
+ export interface RunSummary {
174
+ ok: boolean
175
+ outcomes: TaskOutcome[]
176
+ }
@@ -0,0 +1,129 @@
1
+ // Planning: walk the task graph, compute every task's cache key, and
2
+ // probe the cache to predict what would happen if `vx run` actually
3
+ // ran. Used by `--dry-run` (preview output) and `--graph` (DOT export).
4
+ //
5
+ // No TASK execution happens: no task spawn, no cleanOutputs, no
6
+ // restoreOutputs. Cache probing uses the byte-free `cache.has()`
7
+ // existence probe (a remote layer answers with an HTTP HEAD — no
8
+ // artifact download, no local ingest), so the plan is read-only — with
9
+ // one deliberate exception: `cache.inputs.runtime` / `workspaceRuntime`
10
+ // probe commands DO run, because predicting a task's key requires
11
+ // resolving them (same as a real run). Keep runtime inputs
12
+ // side-effect-free pure probes.
13
+
14
+ import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
+ import { FULL_CACHE_POLICY } from '../cache/index.js'
16
+ import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
17
+ import { computeGroupHash, computeTaskHash } from './task-hash.js'
18
+
19
+ export type CacheStatus =
20
+ | 'hit-local' // entry exists in local cache
21
+ | 'hit-remote' // entry exists in the remote layer (would be fetched)
22
+ | 'miss' // caching enabled but no entry — would execute
23
+ | 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
24
+ | 'group' // no `exec`; just an aggregator
25
+
26
+ export interface PlannedTask {
27
+ node: TaskNode
28
+ hash: string
29
+ cacheStatus: CacheStatus
30
+ /** Sorted task ids this task depends on (same as TaskNode.deps). */
31
+ deps: readonly string[]
32
+ }
33
+
34
+ export interface RunPlan {
35
+ tasks: PlannedTask[]
36
+ }
37
+
38
+ export interface PlanArgs {
39
+ nodes: Map<string, TaskNode>
40
+ workspaceRoot: string
41
+ workspaceFingerprint: string
42
+ cache: CacheLayer
43
+ cachePolicy?: CachePolicy
44
+ forwardArgs?: readonly string[] | undefined
45
+ nestedDirsByProject: Map<string, string[]>
46
+ gitFilesCache?: GitFilesCache
47
+ hashCache?: import('./task-hash.js').HashCache
48
+ }
49
+
50
+ /**
51
+ * Walk the task graph in topological order and produce a `RunPlan`.
52
+ * We piggyback on the existing `runGraph` scheduler with a planning
53
+ * `execute` closure — gets us topo ordering + upstream propagation
54
+ * for free. Concurrency is capped at 1 because planning is fast and
55
+ * sequential is easier to reason about.
56
+ *
57
+ * For each node we compute the cache key (same key the real run
58
+ * would) and probe `cache.has(hash)` to decide hit vs miss. The probe
59
+ * is a pure existence check — no artifact bytes move, no accessed_at
60
+ * bump, no remote download/ingest.
61
+ */
62
+ export async function plan(args: PlanArgs): Promise<RunPlan> {
63
+ const cacheStatusById = new Map<string, CacheStatus>()
64
+
65
+ const outcomes = await runGraph({
66
+ nodes: args.nodes,
67
+ concurrency: 1,
68
+ execute: async (node, upstream) => {
69
+ if (isGroupTask(node)) {
70
+ cacheStatusById.set(node.id, 'group')
71
+ return planOutcome(node, computeGroupHash(upstream))
72
+ }
73
+
74
+ const hash = await computeTaskHash({
75
+ node,
76
+ upstream,
77
+ workspaceRoot: args.workspaceRoot,
78
+ workspaceFingerprint: args.workspaceFingerprint,
79
+ cache: args.cache,
80
+ forwardArgs: args.forwardArgs,
81
+ nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
82
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
83
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
84
+ })
85
+
86
+ // Prediction keys off READS: a no-read policy (--no-cache /
87
+ // --force / --cache=local:,remote:) would re-execute every task,
88
+ // so we predict misses for it. The probe below routes through the
89
+ // policy-aware cache layer, which itself respects local/remote
90
+ // read gating.
91
+ const policy = args.cachePolicy ?? FULL_CACHE_POLICY
92
+ const cacheEnabled =
93
+ node.config.cache !== undefined && (policy.localRead || policy.remoteRead)
94
+ let status: CacheStatus
95
+ if (!cacheEnabled) {
96
+ status = 'no-cache'
97
+ } else {
98
+ const where = await args.cache.has(hash)
99
+ status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
100
+ }
101
+ cacheStatusById.set(node.id, status)
102
+ return planOutcome(node, hash)
103
+ },
104
+ })
105
+
106
+ const tasks: PlannedTask[] = []
107
+ for (const id of args.nodes.keys()) {
108
+ const o = outcomes.get(id)
109
+ const node = args.nodes.get(id)
110
+ if (!o || !node) continue
111
+ tasks.push({
112
+ node,
113
+ hash: o.hash ?? '',
114
+ cacheStatus: cacheStatusById.get(id) ?? 'no-cache',
115
+ deps: node.deps,
116
+ })
117
+ }
118
+ return { tasks }
119
+ }
120
+
121
+ function planOutcome(node: TaskNode, hash: string): TaskOutcome {
122
+ return {
123
+ node,
124
+ status: 'success',
125
+ exitCode: 0,
126
+ durationMs: 0,
127
+ hash,
128
+ }
129
+ }
@@ -0,0 +1,190 @@
1
+ // Plugin consultation for the three run-level extension points
2
+ // (backend / cache / eventSink). Each function asks the declared plugins
3
+ // in order and falls back to today's exact default, so a workspace with
4
+ // no capability plugin is byte-identical to before the inversion.
5
+ //
6
+ // See docs/design/core-cloud-split-2026-06.md §5.1.
7
+
8
+ import type { Cache, CacheLayer } from '../cache/index.js'
9
+ import { UserError } from '../util/index.js'
10
+ import type { EventBus } from './events.js'
11
+ import { wireForwarder } from './events.js'
12
+ import type { Logger } from './logger.js'
13
+ import type {
14
+ BackendContext,
15
+ CacheContext,
16
+ EventSink,
17
+ EventSinkContext,
18
+ VxPlugin,
19
+ } from './plugin.js'
20
+ import type { RunBackend } from './protocol.js'
21
+
22
+ /**
23
+ * Run a capability factory with crash isolation. A throw becomes a clean
24
+ * `UserError` naming the plugin + hook for the load-bearing capabilities
25
+ * (`backend`/`cache`/`setup`) — a broken backend or cache must abort with
26
+ * a clear message, never silently degrade. For `eventSink` the caller
27
+ * logs-and-skips instead (observability must never break a run).
28
+ */
29
+ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>): Promise<T> {
30
+ try {
31
+ return await fn()
32
+ } catch (err) {
33
+ throw new UserError(
34
+ `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
35
+ )
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Resolve the run backend. First plugin returning a non-undefined
41
+ * `backend` wins (declaration order); otherwise the caller's `fallback`
42
+ * (today's local/serve env-probe). A broken backend factory aborts.
43
+ */
44
+ export async function resolveBackend(
45
+ plugins: readonly VxPlugin[],
46
+ ctx: BackendContext,
47
+ fallback: () => Promise<RunBackend>,
48
+ ): Promise<RunBackend> {
49
+ for (const plugin of plugins) {
50
+ if (plugin.backend === undefined) continue
51
+ const backend = await safe(plugin, 'backend', () => plugin.backend!(ctx))
52
+ if (backend !== undefined) return backend
53
+ }
54
+ return await fallback()
55
+ }
56
+
57
+ /**
58
+ * Resolve the cache layer. First plugin returning a non-undefined `cache`
59
+ * wins; otherwise the caller's `fallback` (today's env-var Turbo-wire
60
+ * `wrapWithRemoteCache`). When a plugin cache wins AND the env vars are
61
+ * also set, a one-line note records that the plugin overrides them
62
+ * (explicit beats ambient). A broken cache factory aborts.
63
+ */
64
+ export async function resolveCache(
65
+ plugins: readonly VxPlugin[],
66
+ _localCache: Cache,
67
+ ctx: CacheContext,
68
+ log: Logger,
69
+ fallback: () => CacheLayer,
70
+ ): Promise<CacheLayer> {
71
+ for (const plugin of plugins) {
72
+ if (plugin.cache === undefined) continue
73
+ const cache = await safe(plugin, 'cache', () => plugin.cache!(ctx))
74
+ if (cache !== undefined) {
75
+ if (process.env.VX_REMOTE_CACHE_URL)
76
+ log.status(`[vx] plugin '${plugin.name}' cache overrides VX_REMOTE_CACHE_*`)
77
+ return cache
78
+ }
79
+ }
80
+ return fallback()
81
+ }
82
+
83
+ export interface SubscribedEventSinks {
84
+ /** Removes every bus subscription. Called in run()'s finally. */
85
+ dispose(): void
86
+ /** The installed sinks (with the owning plugin's name for warnings),
87
+ * so run() can await each sink's `flush()` at end-of-run. */
88
+ sinks: ReadonlyArray<{ pluginName: string; sink: EventSink }>
89
+ }
90
+
91
+ /**
92
+ * Subscribe every plugin's `eventSink` to the bus via `wireForwarder`,
93
+ * each isolated so a throwing sink can never break the run. Additive: with
94
+ * no eventSink plugin nothing subscribes and behavior is unchanged.
95
+ * Returns the disposer plus the installed sinks (for the end-of-run flush).
96
+ */
97
+ export async function subscribeEventSinks(
98
+ plugins: readonly VxPlugin[],
99
+ bus: EventBus,
100
+ ctx: EventSinkContext,
101
+ ): Promise<SubscribedEventSinks> {
102
+ const disposers: Array<() => void> = []
103
+ const sinks: Array<{ pluginName: string; sink: EventSink }> = []
104
+ for (const plugin of plugins) {
105
+ if (plugin.eventSink === undefined) continue
106
+ let sink
107
+ try {
108
+ sink = await plugin.eventSink(ctx)
109
+ } catch (err) {
110
+ ctx.warn(
111
+ `[vx] plugin '${plugin.name}' eventSink failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
112
+ )
113
+ continue
114
+ }
115
+ if (sink === undefined) continue
116
+ sinks.push({ pluginName: plugin.name, sink })
117
+ disposers.push(
118
+ bus.subscribe(
119
+ wireForwarder((event) => {
120
+ try {
121
+ sink.onEvent(event)
122
+ } catch {
123
+ // observability is isolated — a sink fault can't break the run
124
+ }
125
+ }),
126
+ ),
127
+ )
128
+ }
129
+ return {
130
+ dispose: () => {
131
+ for (const dispose of disposers) dispose()
132
+ },
133
+ sinks,
134
+ }
135
+ }
136
+
137
+ /** Upper bound on each teardown()/flush() await — a wedged plugin must
138
+ * not hold the run's exit hostage. */
139
+ const TEARDOWN_TIMEOUT_MS = 3000
140
+
141
+ async function settleWithin(p: Promise<unknown>, ms: number): Promise<void> {
142
+ let timer: ReturnType<typeof setTimeout> | undefined
143
+ const deadline = new Promise<void>((resolve) => {
144
+ timer = setTimeout(resolve, ms)
145
+ })
146
+ try {
147
+ await Promise.race([p, deadline])
148
+ } finally {
149
+ clearTimeout(timer)
150
+ // A rejection landing after the deadline won already resolved the
151
+ // race must not surface as an unhandled-rejection crash.
152
+ void p.catch(() => {})
153
+ }
154
+ }
155
+
156
+ /**
157
+ * End-of-run plugin lifecycle: await each event sink's optional
158
+ * `flush()` (its last chance to ship buffered records), then each
159
+ * plugin's optional `teardown()`. Crash-isolated — a throwing
160
+ * flush/teardown is logged and skipped, never propagated — and each
161
+ * call is time-bounded at {@link TEARDOWN_TIMEOUT_MS}. Runs on the
162
+ * normal completion path only; the finally-path disposers just
163
+ * unsubscribe.
164
+ */
165
+ export async function teardownPlugins(
166
+ plugins: readonly VxPlugin[],
167
+ sinks: ReadonlyArray<{ pluginName: string; sink: EventSink }>,
168
+ warn: (message: string) => void,
169
+ ): Promise<void> {
170
+ for (const { pluginName, sink } of sinks) {
171
+ if (sink.flush === undefined) continue
172
+ try {
173
+ await settleWithin(Promise.resolve(sink.flush()), TEARDOWN_TIMEOUT_MS)
174
+ } catch (err) {
175
+ warn(
176
+ `[vx] plugin '${pluginName}' event sink flush failed: ${err instanceof Error ? err.message : String(err)}`,
177
+ )
178
+ }
179
+ }
180
+ for (const plugin of plugins) {
181
+ if (plugin.teardown === undefined) continue
182
+ try {
183
+ await settleWithin(Promise.resolve(plugin.teardown()), TEARDOWN_TIMEOUT_MS)
184
+ } catch (err) {
185
+ warn(
186
+ `[vx] plugin '${plugin.name}' teardown failed: ${err instanceof Error ? err.message : String(err)}`,
187
+ )
188
+ }
189
+ }
190
+ }