@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.
Files changed (111) hide show
  1. package/README.md +66 -89
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +88 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +12 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. 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. 'none':
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 (e.g. the `--ui` devframe dev server) BEFORE the run starts
147
- * emitting. The terminal renderer is still attached as a subscriber, so
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
@@ -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
- return { tasks }
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 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.
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 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'
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
- EventSink,
17
- EventSinkContext,
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
- * (`backend`/`cache`/`setup`) — a broken backend or cache must abort with
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
- * 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(
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
- ctx: BackendContext,
47
- fallback: () => Promise<RunBackend>,
48
- ): Promise<RunBackend> {
73
+ config: ProjectConfig,
74
+ ctx: ProjectHookContext,
75
+ ): Promise<void> {
49
76
  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
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
- * 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.
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 resolveCache(
88
+ export async function applyGraphHooks(
64
89
  plugins: readonly VxPlugin[],
65
- _localCache: Cache,
66
- ctx: CacheContext,
67
- _log: Logger,
68
- fallback: () => CacheLayer,
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.cache === undefined) continue
72
- const cache = await safe(plugin, 'cache', () => plugin.cache!(ctx))
73
- if (cache !== undefined) return cache
95
+ if (plugin.graph === undefined) continue
96
+ await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
97
+ last = plugin
74
98
  }
75
- return fallback()
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
- 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 }>
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
- * 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).
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 subscribeEventSinks(
148
+ export async function applyScheduleHooks(
93
149
  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 }> = []
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.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
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
- /** 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
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
- 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(() => {})
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: 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.
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()), TEARDOWN_TIMEOUT_MS)
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)}`,