@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.
- package/LICENSE +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- 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
|
+
}
|