@vzn/vx 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Shown by every "no <capability> plugin declared" error. */
|
|
2
|
+
export const MISSING_PLUGIN_HINT = `vx runs nothing it was not told to. Declare the plugins in vx.workspace.ts:
|
|
3
|
+
|
|
4
|
+
import { defineWorkspace } from '@vzn/vx'
|
|
5
|
+
import { localExecutorPlugin } from '@vzn/vx/plugins/local-executor'
|
|
6
|
+
import { localCachePlugin } from '@vzn/vx/plugins/local-cache'
|
|
7
|
+
export default defineWorkspace({ plugins: [localExecutorPlugin(), localCachePlugin()] })
|
|
8
|
+
|
|
9
|
+
Put a remote executor or cache plugin BEFORE the local one to prefer it.`
|
|
@@ -46,11 +46,26 @@ export interface RunOptions {
|
|
|
46
46
|
/**
|
|
47
47
|
* Explicit per-task output volume for the default logger. ALWAYS
|
|
48
48
|
* overrides the flow default. 'full': frames for work + one-liners
|
|
49
|
-
* for quiet hits. 'errors-only': only failed tasks print.
|
|
49
|
+
* for quiet hits. 'errors-only': only failed tasks print.
|
|
50
|
+
* 'hash-only': one line per task — outcome, id, cache key — with no
|
|
51
|
+
* log output (Turbo parity). 'none':
|
|
50
52
|
* no per-task output at all. Status lines (header, summary) always
|
|
51
53
|
* print.
|
|
52
54
|
*/
|
|
53
|
-
outputLogs?: 'full' | 'errors-only' | 'none'
|
|
55
|
+
outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
|
|
56
|
+
/**
|
|
57
|
+
* Where a REMOTELY-executed task's outputs land. 'all' (default):
|
|
58
|
+
* every task's outputs are downloaded to this machine as it completes
|
|
59
|
+
* — today's behaviour, byte for byte. 'toplevel': only the tasks you
|
|
60
|
+
* REQUESTED land here; intermediates stay remote. 'none': outputs stay
|
|
61
|
+
* in the remote CAS and are fetched lazily, only when a locally-placed
|
|
62
|
+
* task in the run actually needs them (Bazel's "build without the
|
|
63
|
+
* bytes").
|
|
64
|
+
* Pure transfer tuning: it can never change what a command produces,
|
|
65
|
+
* so it is NEVER folded into a cache key (a RunOption, like --verify).
|
|
66
|
+
* Locally-placed tasks always write in place and ignore this.
|
|
67
|
+
*/
|
|
68
|
+
download?: 'all' | 'toplevel' | 'none'
|
|
54
69
|
/**
|
|
55
70
|
* Run intent, derived by the CLI from selection flags: 'broad' iff
|
|
56
71
|
* `--all` / `--filter` / `--affected` was passed, else 'focused'.
|
|
@@ -143,8 +158,11 @@ export interface RunOptions {
|
|
|
143
158
|
/**
|
|
144
159
|
* Inject the run's event bus. When provided, the orchestrator emits
|
|
145
160
|
* onto it instead of creating its own — letting a caller subscribe a
|
|
146
|
-
* surface
|
|
147
|
-
*
|
|
161
|
+
* surface BEFORE the run starts emitting. This IS the surface seam:
|
|
162
|
+
* core ships the bus and the serializable `WireEvent`, and whatever
|
|
163
|
+
* renders them lives outside. (It used to name a `--ui` devframe dev
|
|
164
|
+
* server; no such flag exists, and core's devframe adapter was removed
|
|
165
|
+
* on 2026-08-26 — one consumer's adapter is not core's job.) The terminal renderer is still attached as a subscriber, so
|
|
148
166
|
* default output is unchanged. Default: a fresh internal bus.
|
|
149
167
|
*/
|
|
150
168
|
bus?: EventBus
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
15
15
|
import { FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
16
|
import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
17
|
+
import type { HistoryProvider } from './history.js'
|
|
17
18
|
import { computeGroupHash, computeTaskHash } from './task-hash.js'
|
|
18
19
|
|
|
19
20
|
export type CacheStatus =
|
|
@@ -29,14 +30,67 @@ export interface PlannedTask {
|
|
|
29
30
|
cacheStatus: CacheStatus
|
|
30
31
|
/** Sorted task ids this task depends on (same as TaskNode.deps). */
|
|
31
32
|
deps: readonly string[]
|
|
33
|
+
/**
|
|
34
|
+
* The task's typical executed duration (wall-clock p50 over its recent
|
|
35
|
+
* non-hit runs in the local cache.db), when history exists. Attached to
|
|
36
|
+
* every real task so a would-run task can show what it usually costs.
|
|
37
|
+
*/
|
|
38
|
+
p50Ms?: number
|
|
39
|
+
/**
|
|
40
|
+
* Name of the executor this task would be PLACED on. Attached only when
|
|
41
|
+
* the workspace declares more than one executor — with a single one every
|
|
42
|
+
* line would carry the same label and say nothing. Absent for group tasks
|
|
43
|
+
* and persistent tasks, which never reach an executor.
|
|
44
|
+
*/
|
|
45
|
+
executor?: string
|
|
46
|
+
/**
|
|
47
|
+
* `'deferred'` when this task's outputs would stay in the remote store
|
|
48
|
+
* (`--download=none` / `toplevel`). Attached only when it is TRUE — an
|
|
49
|
+
* eager task is the default and saying so on every line says nothing.
|
|
50
|
+
*/
|
|
51
|
+
download?: 'deferred'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Time prediction over the plan: what would this run cost? Derived purely
|
|
56
|
+
* from recorded history (each task's p50) — cache hits and groups cost 0
|
|
57
|
+
* (a restore is a tar extract, near-instant next to execution).
|
|
58
|
+
*/
|
|
59
|
+
export interface PlanPrediction {
|
|
60
|
+
/** Predicted wall-clock: the longest dependency chain of would-run cost. */
|
|
61
|
+
wallMs: number
|
|
62
|
+
/** Total predicted execution across would-run tasks (the CPU-time story). */
|
|
63
|
+
workMs: number
|
|
64
|
+
/** Would-run tasks with NO history — their cost is unknown and counted as
|
|
65
|
+
* 0, so wallMs/workMs are lower bounds when this is > 0. */
|
|
66
|
+
unknownCount: number
|
|
32
67
|
}
|
|
33
68
|
|
|
34
69
|
export interface RunPlan {
|
|
35
70
|
tasks: PlannedTask[]
|
|
71
|
+
/** Present when a history provider was available and usable. */
|
|
72
|
+
predicted?: PlanPrediction
|
|
73
|
+
/**
|
|
74
|
+
* Requested task specs that matched no project. Non-empty means the
|
|
75
|
+
* plan was abandoned: planning what WOULD run is meaningless when the
|
|
76
|
+
* equivalent `vx run` would refuse to start.
|
|
77
|
+
*/
|
|
78
|
+
unresolvedTasks?: readonly string[]
|
|
79
|
+
/**
|
|
80
|
+
* Producers `--download` WANTED to defer but could not, mapped to why.
|
|
81
|
+
* Without this the gate is invisible: a user asks for `--download=none`,
|
|
82
|
+
* gets no deferral, and has nothing to read. Empty/absent under
|
|
83
|
+
* `--download=all`, which defers nothing by definition.
|
|
84
|
+
*/
|
|
85
|
+
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
export interface PlanArgs {
|
|
39
89
|
nodes: Map<string, TaskNode>
|
|
90
|
+
/** Per-task `--download` decision, when the policy is not `all`. */
|
|
91
|
+
downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
|
|
92
|
+
/** See `RunPlan.downloadDowngrades`. */
|
|
93
|
+
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
40
94
|
workspaceRoot: string
|
|
41
95
|
workspaceFingerprint: string
|
|
42
96
|
cache: CacheLayer
|
|
@@ -45,6 +99,11 @@ export interface PlanArgs {
|
|
|
45
99
|
nestedDirsByProject: Map<string, string[]>
|
|
46
100
|
gitFilesCache?: GitFilesCache
|
|
47
101
|
hashCache?: import('./task-hash.js').HashCache
|
|
102
|
+
/** When provided, per-task p50s are attached and `RunPlan.predicted` is
|
|
103
|
+
* computed. Failing open: a history error yields a plan without them. */
|
|
104
|
+
history?: HistoryProvider
|
|
105
|
+
/** Placement lookup (`run.ts`), passed only when >1 executor is declared. */
|
|
106
|
+
executorOf?: (id: string) => string | undefined
|
|
48
107
|
}
|
|
49
108
|
|
|
50
109
|
/**
|
|
@@ -108,14 +167,98 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
108
167
|
const o = outcomes.get(id)
|
|
109
168
|
const node = args.nodes.get(id)
|
|
110
169
|
if (!o || !node) continue
|
|
170
|
+
const executor = args.executorOf?.(id)
|
|
111
171
|
tasks.push({
|
|
112
172
|
node,
|
|
113
173
|
hash: o.hash ?? '',
|
|
114
174
|
cacheStatus: cacheStatusById.get(id) ?? 'no-cache',
|
|
115
175
|
deps: node.deps,
|
|
176
|
+
...(executor !== undefined ? { executor } : {}),
|
|
177
|
+
...(args.downloadOf?.(id) === 'deferred' ? { download: 'deferred' as const } : {}),
|
|
116
178
|
})
|
|
117
179
|
}
|
|
118
|
-
|
|
180
|
+
|
|
181
|
+
if (args.history !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
const realIds = tasks.filter((t) => t.cacheStatus !== 'group').map((t) => t.node.id)
|
|
184
|
+
const table = await args.history.loadFor(realIds)
|
|
185
|
+
for (const t of tasks) {
|
|
186
|
+
const p50 = table.get(t.node.id)?.p50DurationMs
|
|
187
|
+
if (p50 !== undefined) t.p50Ms = p50
|
|
188
|
+
}
|
|
189
|
+
return { tasks, predicted: predictPlan(tasks), ...downgradeField(args) }
|
|
190
|
+
} catch {
|
|
191
|
+
// Failing open: prediction is a nicety — a broken history read must
|
|
192
|
+
// never break `--dry`.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { tasks, ...downgradeField(args) }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Carried onto the plan only when the gate actually refused something. */
|
|
199
|
+
function downgradeField(args: PlanArgs): {
|
|
200
|
+
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
201
|
+
} {
|
|
202
|
+
const d = args.downloadDowngrades
|
|
203
|
+
return d !== undefined && d.length > 0 ? { downloadDowngrades: d } : {}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A task the plan expects to EXECUTE (a no-cache task executes every run). */
|
|
207
|
+
function wouldRun(t: PlannedTask): boolean {
|
|
208
|
+
return t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Longest dependency chain of predicted cost, via one pass over a Kahn
|
|
213
|
+
* topological order — no recursion, so a pathologically deep chain can't
|
|
214
|
+
* overflow the stack. Hits + groups cost 0; a would-run task with no
|
|
215
|
+
* history also costs 0 (counted in `unknownCount`, making the totals
|
|
216
|
+
* honest lower bounds).
|
|
217
|
+
*/
|
|
218
|
+
function predictPlan(tasks: PlannedTask[]): PlanPrediction {
|
|
219
|
+
const cost = (t: PlannedTask): number => (wouldRun(t) ? (t.p50Ms ?? 0) : 0)
|
|
220
|
+
const byId = new Map(tasks.map((t) => [t.node.id, t]))
|
|
221
|
+
const dependents = new Map<string, string[]>()
|
|
222
|
+
const indeg = new Map<string, number>()
|
|
223
|
+
for (const t of tasks) {
|
|
224
|
+
const deps = t.deps.filter((d) => byId.has(d))
|
|
225
|
+
indeg.set(t.node.id, deps.length)
|
|
226
|
+
for (const d of deps) {
|
|
227
|
+
const list = dependents.get(d)
|
|
228
|
+
if (list) list.push(t.node.id)
|
|
229
|
+
else dependents.set(d, [t.node.id])
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const dist = new Map<string, number>()
|
|
233
|
+
const queue: string[] = []
|
|
234
|
+
for (const [id, n] of indeg) if (n === 0) queue.push(id)
|
|
235
|
+
let head = 0
|
|
236
|
+
let wallMs = 0
|
|
237
|
+
while (head < queue.length) {
|
|
238
|
+
const id = queue[head++]!
|
|
239
|
+
const t = byId.get(id)!
|
|
240
|
+
let best = 0
|
|
241
|
+
for (const d of t.deps) {
|
|
242
|
+
const v = dist.get(d)
|
|
243
|
+
if (v !== undefined && v > best) best = v
|
|
244
|
+
}
|
|
245
|
+
const v = best + cost(t)
|
|
246
|
+
dist.set(id, v)
|
|
247
|
+
if (v > wallMs) wallMs = v
|
|
248
|
+
for (const dep of dependents.get(id) ?? []) {
|
|
249
|
+
const n = indeg.get(dep)! - 1
|
|
250
|
+
indeg.set(dep, n)
|
|
251
|
+
if (n === 0) queue.push(dep)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let workMs = 0
|
|
255
|
+
let unknownCount = 0
|
|
256
|
+
for (const t of tasks) {
|
|
257
|
+
if (!wouldRun(t)) continue
|
|
258
|
+
if (t.p50Ms === undefined) unknownCount++
|
|
259
|
+
else workMs += t.p50Ms
|
|
260
|
+
}
|
|
261
|
+
return { wallMs, workMs, unknownCount }
|
|
119
262
|
}
|
|
120
263
|
|
|
121
264
|
function planOutcome(node: TaskNode, hash: string): TaskOutcome {
|
|
@@ -1,28 +1,32 @@
|
|
|
1
|
-
// Plugin consultation for the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Plugin consultation for the run-level extension points (cache / executor).
|
|
2
|
+
// Each function asks the plugins in order. Nothing is applied by default:
|
|
3
|
+
// core's own executor and cache are plugins under src/plugins/ that a
|
|
4
|
+
// workspace declares like any other, so a list with no provider for a
|
|
5
|
+
// load-bearing capability fails fast with MISSING_PLUGIN_HINT.
|
|
5
6
|
//
|
|
6
7
|
// See docs/design/core-cloud-split-2026-06.md §5.1.
|
|
7
8
|
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import type
|
|
9
|
+
import { ChainedCache, type CacheLayer } from '../cache/index.js'
|
|
10
|
+
import type { TaskExecutor } from '../exec/index.js'
|
|
11
|
+
import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
12
|
+
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
13
|
+
import { detectCycle, type TaskNode } from '../graph/index.js'
|
|
14
|
+
import { MISSING_PLUGIN_HINT } from './missing-plugin.js'
|
|
13
15
|
import type {
|
|
14
|
-
BackendContext,
|
|
15
16
|
CacheContext,
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
ExecutorContext,
|
|
18
|
+
GraphHookContext,
|
|
19
|
+
KeyHookContext,
|
|
20
|
+
ProjectHookContext,
|
|
21
|
+
ScheduleHookContext,
|
|
18
22
|
VxPlugin,
|
|
23
|
+
WorkspaceHookContext,
|
|
19
24
|
} from './plugin.js'
|
|
20
|
-
import type { RunBackend } from './protocol.js'
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Run a capability factory with crash isolation. A throw becomes a clean
|
|
24
28
|
* `UserError` naming the plugin + hook for the load-bearing capabilities
|
|
25
|
-
* (`
|
|
29
|
+
* (`cache`/`executor`/`setup`) — a broken cache or executor must abort with
|
|
26
30
|
* a clear message, never silently degrade. For `eventSink` the caller
|
|
27
31
|
* logs-and-skips instead (observability must never break a run).
|
|
28
32
|
*/
|
|
@@ -36,146 +40,222 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
|
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// --- Pipeline stages ---------------------------------------------------------
|
|
44
|
+
//
|
|
45
|
+
// Each stage hands plugins the object core is about to use, in declaration
|
|
46
|
+
// order, and the caller re-validates afterwards. `hasHook` is the zero-cost
|
|
47
|
+
// gate: with no plugin declaring a stage the caller skips the loop AND the
|
|
48
|
+
// re-validation, so a workspace without pipeline plugins pays nothing.
|
|
49
|
+
|
|
50
|
+
export function hasHook(
|
|
51
|
+
plugins: readonly VxPlugin[],
|
|
52
|
+
hook: 'config' | 'project' | 'graph' | 'key' | 'schedule',
|
|
53
|
+
): boolean {
|
|
54
|
+
for (const p of plugins) if (p[hook] !== undefined) return true
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `config` stage: every plugin edits the workspace config in place. */
|
|
59
|
+
export async function applyConfigHooks(
|
|
60
|
+
plugins: readonly VxPlugin[],
|
|
61
|
+
workspace: WorkspaceConfig,
|
|
62
|
+
ctx: WorkspaceHookContext,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
for (const plugin of plugins) {
|
|
65
|
+
if (plugin.config === undefined) continue
|
|
66
|
+
await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `project` stage: every plugin edits one project's config in place. */
|
|
71
|
+
export async function applyProjectHooks(
|
|
45
72
|
plugins: readonly VxPlugin[],
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
): Promise<
|
|
73
|
+
config: ProjectConfig,
|
|
74
|
+
ctx: ProjectHookContext,
|
|
75
|
+
): Promise<void> {
|
|
49
76
|
for (const plugin of plugins) {
|
|
50
|
-
if (plugin.
|
|
51
|
-
|
|
52
|
-
if (backend !== undefined) return backend
|
|
77
|
+
if (plugin.project === undefined) continue
|
|
78
|
+
await safe(plugin, 'project', () => plugin.project!(config, ctx))
|
|
53
79
|
}
|
|
54
|
-
return await fallback()
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
83
|
+
* `graph` stage: every plugin edits the task graph in place, then the graph
|
|
84
|
+
* is checked the way the builder checks its own output — every dep names a
|
|
85
|
+
* node in the graph, and there is no cycle. A violation is reported against
|
|
86
|
+
* the LAST plugin that ran, which is the one whose edit made it so.
|
|
62
87
|
*/
|
|
63
|
-
export async function
|
|
88
|
+
export async function applyGraphHooks(
|
|
64
89
|
plugins: readonly VxPlugin[],
|
|
65
|
-
|
|
66
|
-
ctx:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
): Promise<CacheLayer> {
|
|
90
|
+
nodes: Map<string, TaskNode>,
|
|
91
|
+
ctx: GraphHookContext,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
let last: VxPlugin | undefined
|
|
70
94
|
for (const plugin of plugins) {
|
|
71
|
-
if (plugin.
|
|
72
|
-
|
|
73
|
-
|
|
95
|
+
if (plugin.graph === undefined) continue
|
|
96
|
+
await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
|
|
97
|
+
last = plugin
|
|
74
98
|
}
|
|
75
|
-
|
|
99
|
+
if (last === undefined) return
|
|
100
|
+
await safe(last, 'graph', () => {
|
|
101
|
+
for (const node of nodes.values()) {
|
|
102
|
+
for (const dep of node.deps) {
|
|
103
|
+
if (!nodes.has(dep)) {
|
|
104
|
+
throw new Error(`${node.id} depends on '${dep}', which is not a task in this run's graph`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
detectCycle(nodes)
|
|
109
|
+
})
|
|
76
110
|
}
|
|
77
111
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
/**
|
|
113
|
+
* `key` stage: every plugin may add `{ name: value }` material to every
|
|
114
|
+
* task. Stored on the node as sorted `[plugin/name, value]` pairs so the
|
|
115
|
+
* fold is order-independent and `vx why` can name the contributor.
|
|
116
|
+
*/
|
|
117
|
+
export async function applyKeyHooks(
|
|
118
|
+
plugins: readonly VxPlugin[],
|
|
119
|
+
nodes: Map<string, TaskNode>,
|
|
120
|
+
ctx: KeyHookContext,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
for (const node of nodes.values()) {
|
|
123
|
+
const parts: Array<readonly [string, string]> = []
|
|
124
|
+
for (const plugin of plugins) {
|
|
125
|
+
if (plugin.key === undefined) continue
|
|
126
|
+
const material = await safe(plugin, 'key', () => plugin.key!(node, ctx))
|
|
127
|
+
if (material === undefined) continue
|
|
128
|
+
for (const [name, value] of Object.entries(material)) {
|
|
129
|
+
if (typeof value !== 'string') {
|
|
130
|
+
throw new UserError(
|
|
131
|
+
`plugin '${plugin.name}' failed in key: value for '${name}' on ${node.id} is not a string`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
parts.push([`${plugin.name}/${name}`, value])
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (parts.length > 0) {
|
|
138
|
+
parts.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
139
|
+
node.keyParts = parts
|
|
140
|
+
}
|
|
141
|
+
}
|
|
84
142
|
}
|
|
85
143
|
|
|
86
144
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* no eventSink plugin nothing subscribes and behavior is unchanged.
|
|
90
|
-
* Returns the disposer plus the installed sinks (for the end-of-run flush).
|
|
145
|
+
* `schedule` stage: the merged priorities map (a later plugin's weight for
|
|
146
|
+
* a task overrides an earlier one's). Empty when no plugin answered.
|
|
91
147
|
*/
|
|
92
|
-
export async function
|
|
148
|
+
export async function applyScheduleHooks(
|
|
93
149
|
plugins: readonly VxPlugin[],
|
|
94
|
-
|
|
95
|
-
ctx:
|
|
96
|
-
): Promise<
|
|
97
|
-
const
|
|
98
|
-
const sinks: Array<{ pluginName: string; sink: EventSink }> = []
|
|
150
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
151
|
+
ctx: ScheduleHookContext,
|
|
152
|
+
): Promise<ReadonlyMap<string, number>> {
|
|
153
|
+
const merged = new Map<string, number>()
|
|
99
154
|
for (const plugin of plugins) {
|
|
100
|
-
if (plugin.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
155
|
+
if (plugin.schedule === undefined) continue
|
|
156
|
+
const weights = await safe(plugin, 'schedule', () => plugin.schedule!(nodes, ctx))
|
|
157
|
+
if (weights === undefined) continue
|
|
158
|
+
for (const [id, w] of weights) {
|
|
159
|
+
if (!nodes.has(id)) continue
|
|
160
|
+
if (typeof w !== 'number' || !Number.isFinite(w)) {
|
|
161
|
+
throw new UserError(
|
|
162
|
+
`plugin '${plugin.name}' failed in schedule: weight for ${id} is not a finite number`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
merged.set(id, w)
|
|
109
166
|
}
|
|
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
167
|
}
|
|
168
|
+
return merged
|
|
130
169
|
}
|
|
131
170
|
|
|
132
|
-
/**
|
|
133
|
-
*
|
|
134
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Collect every plugin's `cache` layer in declaration order. One layer is
|
|
173
|
+
* used as is; two or more are chained (lookup walks them, save reaches all;
|
|
174
|
+
* see ChainedCache). A bare local layer that another declared layer already
|
|
175
|
+
* wraps (`layer.local === ctx.localCache`) is dropped, so a remote plugin
|
|
176
|
+
* that layers over the local handle composes with `localCachePlugin()`
|
|
177
|
+
* instead of writing the local store twice. No layer at all is a named error.
|
|
178
|
+
*/
|
|
179
|
+
export async function resolveCache(
|
|
180
|
+
plugins: readonly VxPlugin[],
|
|
181
|
+
ctx: CacheContext,
|
|
182
|
+
): Promise<CacheLayer> {
|
|
183
|
+
const layers: CacheLayer[] = []
|
|
184
|
+
for (const plugin of plugins) {
|
|
185
|
+
if (plugin.cache === undefined) continue
|
|
186
|
+
const layer = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
187
|
+
if (layer !== undefined) layers.push(layer)
|
|
188
|
+
}
|
|
189
|
+
if (layers.length === 0) {
|
|
190
|
+
const declined = plugins.filter((p) => p.cache !== undefined).map((p) => `${p.name} declined`)
|
|
191
|
+
throw new UserError(
|
|
192
|
+
`no cache plugin declared${declined.length > 0 ? ` (${declined.join(', ')})` : ''}. ${MISSING_PLUGIN_HINT}`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
const wrapsLocal = layers.some((l) => l !== ctx.localCache && l.local === ctx.localCache)
|
|
196
|
+
const distinct = wrapsLocal ? layers.filter((l) => l !== ctx.localCache) : layers
|
|
197
|
+
return distinct.length === 1 ? distinct[0]! : new ChainedCache(distinct)
|
|
198
|
+
}
|
|
135
199
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Collect every plugin's `executor`, in declaration order. Unlike
|
|
202
|
+
* `cache` this is a LIST: per task, `selectExecutor` takes the first
|
|
203
|
+
* that accepts. An empty list is the same authoring error as a missing
|
|
204
|
+
* cache provider and fails the same way. A broken factory aborts — an
|
|
205
|
+
* executor is load-bearing, not observational.
|
|
206
|
+
*/
|
|
207
|
+
export async function resolveExecutors(
|
|
208
|
+
plugins: readonly VxPlugin[],
|
|
209
|
+
ctx: ExecutorContext,
|
|
210
|
+
): Promise<TaskExecutor[]> {
|
|
211
|
+
const executors: TaskExecutor[] = []
|
|
212
|
+
for (const plugin of plugins) {
|
|
213
|
+
if (plugin.executor === undefined) continue
|
|
214
|
+
const executor = await safe(plugin, 'executor', () => plugin.executor!(ctx))
|
|
215
|
+
if (executor !== undefined) executors.push(executor)
|
|
148
216
|
}
|
|
217
|
+
if (executors.length === 0) {
|
|
218
|
+
const declined = plugins
|
|
219
|
+
.filter((p) => p.executor !== undefined)
|
|
220
|
+
.map((p) => `${p.name} declined`)
|
|
221
|
+
throw new UserError(
|
|
222
|
+
`no executor plugin declared${declined.length > 0 ? ` (${declined.join(', ')})` : ''}. ${MISSING_PLUGIN_HINT}`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
return executors
|
|
149
226
|
}
|
|
150
227
|
|
|
151
228
|
/**
|
|
152
|
-
* End-of-run plugin lifecycle:
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
229
|
+
* End-of-run plugin lifecycle: each plugin's optional `teardown()`, in
|
|
230
|
+
* declaration order. Crash-isolated — a throwing teardown is logged and
|
|
231
|
+
* skipped, never propagated — and each call is time-bounded by
|
|
232
|
+
* {@link teardownTimeoutMs}. Runs on the normal completion path only; the
|
|
233
|
+
* finally-path disposers just unsubscribe. (Telemetry sinks flush before
|
|
234
|
+
* this, in telemetry-host.ts, with the same reporting rule.)
|
|
235
|
+
*
|
|
236
|
+
* The bound is PER CALL and the calls are sequential, so the worst case
|
|
237
|
+
* composes: measured at the 3s default, 1/2/3 simultaneously-hung plugins
|
|
238
|
+
* cost 3.0/6.0/9.0s. That is deliberate rather than overlooked — the
|
|
239
|
+
* telemetry sibling races its sinks concurrently, but a plugin's teardown
|
|
240
|
+
* may release something a later one still holds, and declaration order is
|
|
241
|
+
* the contract this file keeps elsewhere. Each hung call names itself, so
|
|
242
|
+
* the delay is attributable instead of mysterious.
|
|
159
243
|
*/
|
|
160
244
|
export async function teardownPlugins(
|
|
161
245
|
plugins: readonly VxPlugin[],
|
|
162
|
-
sinks: ReadonlyArray<{ pluginName: string; sink: EventSink }>,
|
|
163
246
|
warn: (message: string) => void,
|
|
164
247
|
): 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
248
|
for (const plugin of plugins) {
|
|
176
249
|
if (plugin.teardown === undefined) continue
|
|
250
|
+
const ms = teardownTimeoutMs()
|
|
177
251
|
try {
|
|
178
|
-
await settleWithin(Promise.resolve(plugin.teardown()),
|
|
252
|
+
const settled = await settleWithin(Promise.resolve(plugin.teardown()), ms)
|
|
253
|
+
// Same rule as the flush above. A teardown that never settles has left
|
|
254
|
+
// whatever it owns un-released, and the run exits anyway — silence would
|
|
255
|
+
// present that as a clean shutdown.
|
|
256
|
+
if (!settled) {
|
|
257
|
+
warn(`[vx] plugin '${plugin.name}' teardown timed out after ${ms}ms; it did not complete`)
|
|
258
|
+
}
|
|
179
259
|
} catch (err) {
|
|
180
260
|
warn(
|
|
181
261
|
`[vx] plugin '${plugin.name}' teardown failed: ${err instanceof Error ? err.message : String(err)}`,
|