@vzn/vx 0.0.0 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,197 @@
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, RemoteCacheLayer } 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
+ * `fingerprint` computes + ships an output-tree fingerprint on executed
76
+ * tasks (no re-run — the cross-machine diff feed; `--verify`/`=all` set
77
+ * it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`) exempts
78
+ * known-nondeterministic tasks from failing the run. A pure side-channel —
79
+ * never folded into a cache key; the re-run never saves. Undefined = off
80
+ * (a plain run is byte-identical).
81
+ */
82
+ verify?: {
83
+ determinism: boolean
84
+ inputs: boolean
85
+ fingerprint: boolean
86
+ allow: ReadonlySet<string>
87
+ }
88
+ /**
89
+ * Run-level default task timeout (ms), for tasks that declare no
90
+ * `exec.timeout`. Highest of the run-level defaults: `--timeout` /
91
+ * this option → `VX_TASK_TIMEOUT` env → workspace `timeout`. Per-task
92
+ * `exec.timeout` always wins. Threaded as an option only — never
93
+ * folded into any cache key (a timed-out task fails and is never
94
+ * cached), so a `--timeout` run cache-hits a plain run's entry.
95
+ */
96
+ timeout?: number
97
+ /**
98
+ * Memory budget (bytes) that per-task `exec.resources.memory`
99
+ * reservations pack against (`--memory <size>`). Defaults to
100
+ * `os.totalmem()` — override it in cgroup-limited containers, where
101
+ * totalmem() reports the HOST's RAM, not the limit. A per-run
102
+ * scheduling knob only — never folded into any cache key.
103
+ */
104
+ memory?: number
105
+ /**
106
+ * Failure propagation: 'never' stops dispatch on the first failure
107
+ * (in-flight tasks finish; everything queued skips), 'deps-ok'
108
+ * (default) skips only a failure's dependents, 'always' runs
109
+ * dependents even when an upstream failed — sound under pure-input
110
+ * hashing (failed outcomes carry the upstream's input key). CLI:
111
+ * `--continue[=never|deps-ok|always]`; bare `--continue` = 'always'.
112
+ */
113
+ continueMode?: ContinueMode
114
+ /**
115
+ * Filter `dependsOn` expansion. `'all'` drops every edge (just the
116
+ * requested task runs). A string array drops only those task names
117
+ * from both `self` and `dependencies` buckets.
118
+ */
119
+ excludeDependencies?: 'all' | readonly string[]
120
+ /** Forwarded to the last step of each task's exec array (shell-quoted). */
121
+ forwardArgs?: readonly string[]
122
+ /**
123
+ * If set, write a per-run JSON summary at end of run. Empty string
124
+ * picks the default path `<cacheDir>/runs/<run_id>.json`; anything
125
+ * else is treated as the literal file path (cwd-relative).
126
+ */
127
+ summarize?: string
128
+ /**
129
+ * If set, write a Chrome-trace JSON profile of the run's wallclock
130
+ * spans. Path is cwd-relative. Default `profile.json` is selected
131
+ * by the CLI parser, not here.
132
+ */
133
+ profile?: string
134
+ /**
135
+ * Install SIGINT/SIGTERM handlers for the run's duration: forward
136
+ * SIGTERM to every live child, close the cache, exit 128+signo
137
+ * (130/143). Default true. The watch loop disables this — it owns
138
+ * signal disposition for its whole lifetime and a cycle must never
139
+ * exit the process out from under it.
140
+ */
141
+ handleSignals?: boolean
142
+ log?: Logger
143
+ /**
144
+ * Inject the run's event bus. When provided, the orchestrator emits
145
+ * onto it instead of creating its own — letting a caller subscribe a
146
+ * surface (e.g. the `--ui` devframe dev server) BEFORE the run starts
147
+ * emitting. The terminal renderer is still attached as a subscriber, so
148
+ * default output is unchanged. Default: a fresh internal bus.
149
+ */
150
+ bus?: EventBus
151
+ /**
152
+ * Shared in-flight execution registry, keyed by task hash. Supplied by a
153
+ * long-lived service (`vx serve`) so concurrent runs DEDUP work: a task
154
+ * already executing for one run is awaited by another (which then
155
+ * restores the just-saved artifact from cache) instead of re-running.
156
+ * A stateless `vx run` passes none and is byte-identical to before.
157
+ */
158
+ inflight?: Map<string, Promise<void>>
159
+ /**
160
+ * `--tag k=v` pairs (Tier-3). Persisted verbatim on the run's
161
+ * `invocations` row (JSON object) so dashboards can filter runs by
162
+ * label. CLI parsing is the CLI's job; the orchestrator just records.
163
+ */
164
+ tags?: Record<string, string>
165
+ /**
166
+ * Additional observe-only telemetry sinks, merged with whatever the
167
+ * workspace plugins contribute. The embedder seam: a host executing
168
+ * runs on behalf of others (a serve recording delegated runs
169
+ * into its ingest store) attaches a sink without needing a workspace
170
+ * plugin. Undefined → zero cost, identical to before; the telemetry
171
+ * host's zero-sink invariant still applies when both sources are
172
+ * empty.
173
+ */
174
+ telemetrySinks?: readonly TelemetrySink[]
175
+ /**
176
+ * The raw command string for the `invocations` row (e.g.
177
+ * `'vx run build test --all'`). When absent, run() falls back to
178
+ * `process.argv.slice(1).join(' ')` so programmatic callers still
179
+ * record something useful.
180
+ */
181
+ command?: string
182
+ /**
183
+ * An injected remote cache layer — the embedder seam mirroring
184
+ * `telemetrySinks`: a host that already holds a wire client (a
185
+ * distribution agent, a serve executing on behalf of a submitter)
186
+ * passes it here and run() composes `LayeredCache(local, injected)`.
187
+ * Explicit injection WINS over the plugin `cache` capability (the
188
+ * host knows best; prevents double-wrapping when the workspace also
189
+ * declares a cache plugin). Undefined → identical to before.
190
+ */
191
+ remoteCache?: RemoteCacheLayer
192
+ }
193
+
194
+ export interface RunSummary {
195
+ ok: boolean
196
+ outcomes: TaskOutcome[]
197
+ }
@@ -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,185 @@
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` (the plain local cache — core
60
+ * ships no wire client; a remote layer comes from a plugin or from
61
+ * `RunOptions.remoteCache`). A broken cache factory aborts.
62
+ */
63
+ export async function resolveCache(
64
+ plugins: readonly VxPlugin[],
65
+ _localCache: Cache,
66
+ ctx: CacheContext,
67
+ _log: Logger,
68
+ fallback: () => CacheLayer,
69
+ ): Promise<CacheLayer> {
70
+ for (const plugin of plugins) {
71
+ if (plugin.cache === undefined) continue
72
+ const cache = await safe(plugin, 'cache', () => plugin.cache!(ctx))
73
+ if (cache !== undefined) return cache
74
+ }
75
+ return fallback()
76
+ }
77
+
78
+ export interface SubscribedEventSinks {
79
+ /** Removes every bus subscription. Called in run()'s finally. */
80
+ dispose(): void
81
+ /** The installed sinks (with the owning plugin's name for warnings),
82
+ * so run() can await each sink's `flush()` at end-of-run. */
83
+ sinks: ReadonlyArray<{ pluginName: string; sink: EventSink }>
84
+ }
85
+
86
+ /**
87
+ * Subscribe every plugin's `eventSink` to the bus via `wireForwarder`,
88
+ * each isolated so a throwing sink can never break the run. Additive: with
89
+ * no eventSink plugin nothing subscribes and behavior is unchanged.
90
+ * Returns the disposer plus the installed sinks (for the end-of-run flush).
91
+ */
92
+ export async function subscribeEventSinks(
93
+ plugins: readonly VxPlugin[],
94
+ bus: EventBus,
95
+ ctx: EventSinkContext,
96
+ ): Promise<SubscribedEventSinks> {
97
+ const disposers: Array<() => void> = []
98
+ const sinks: Array<{ pluginName: string; sink: EventSink }> = []
99
+ for (const plugin of plugins) {
100
+ if (plugin.eventSink === undefined) continue
101
+ let sink
102
+ try {
103
+ sink = await plugin.eventSink(ctx)
104
+ } catch (err) {
105
+ ctx.warn(
106
+ `[vx] plugin '${plugin.name}' eventSink failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
107
+ )
108
+ continue
109
+ }
110
+ if (sink === undefined) continue
111
+ sinks.push({ pluginName: plugin.name, sink })
112
+ disposers.push(
113
+ bus.subscribe(
114
+ wireForwarder((event) => {
115
+ try {
116
+ sink.onEvent(event)
117
+ } catch {
118
+ // observability is isolated — a sink fault can't break the run
119
+ }
120
+ }),
121
+ ),
122
+ )
123
+ }
124
+ return {
125
+ dispose: () => {
126
+ for (const dispose of disposers) dispose()
127
+ },
128
+ sinks,
129
+ }
130
+ }
131
+
132
+ /** Upper bound on each teardown()/flush() await — a wedged plugin must
133
+ * not hold the run's exit hostage. */
134
+ const TEARDOWN_TIMEOUT_MS = 3000
135
+
136
+ async function settleWithin(p: Promise<unknown>, ms: number): Promise<void> {
137
+ let timer: ReturnType<typeof setTimeout> | undefined
138
+ const deadline = new Promise<void>((resolve) => {
139
+ timer = setTimeout(resolve, ms)
140
+ })
141
+ try {
142
+ await Promise.race([p, deadline])
143
+ } finally {
144
+ clearTimeout(timer)
145
+ // A rejection landing after the deadline won already resolved the
146
+ // race must not surface as an unhandled-rejection crash.
147
+ void p.catch(() => {})
148
+ }
149
+ }
150
+
151
+ /**
152
+ * End-of-run plugin lifecycle: await each event sink's optional
153
+ * `flush()` (its last chance to ship buffered records), then each
154
+ * plugin's optional `teardown()`. Crash-isolated — a throwing
155
+ * flush/teardown is logged and skipped, never propagated — and each
156
+ * call is time-bounded at {@link TEARDOWN_TIMEOUT_MS}. Runs on the
157
+ * normal completion path only; the finally-path disposers just
158
+ * unsubscribe.
159
+ */
160
+ export async function teardownPlugins(
161
+ plugins: readonly VxPlugin[],
162
+ sinks: ReadonlyArray<{ pluginName: string; sink: EventSink }>,
163
+ warn: (message: string) => void,
164
+ ): Promise<void> {
165
+ for (const { pluginName, sink } of sinks) {
166
+ if (sink.flush === undefined) continue
167
+ try {
168
+ await settleWithin(Promise.resolve(sink.flush()), TEARDOWN_TIMEOUT_MS)
169
+ } catch (err) {
170
+ warn(
171
+ `[vx] plugin '${pluginName}' event sink flush failed: ${err instanceof Error ? err.message : String(err)}`,
172
+ )
173
+ }
174
+ }
175
+ for (const plugin of plugins) {
176
+ if (plugin.teardown === undefined) continue
177
+ try {
178
+ await settleWithin(Promise.resolve(plugin.teardown()), TEARDOWN_TIMEOUT_MS)
179
+ } catch (err) {
180
+ warn(
181
+ `[vx] plugin '${plugin.name}' teardown failed: ${err instanceof Error ? err.message : String(err)}`,
182
+ )
183
+ }
184
+ }
185
+ }