@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,31 +1,83 @@
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, and CORE IS THE TAIL: running
3
+ // here and caching here are what those words mean in the absence of a
4
+ // plugin placing work elsewhere or storing it somewhere else. A plugin
5
+ // goes in front — it takes what it accepts, and what it declines lands on
6
+ // the floor. No plugin is required for a plain local run.
5
7
  //
6
- // See docs/design/core-cloud-split-2026-06.md §5.1.
8
+ // See docs/design/pipeline-2026-09.md.
7
9
 
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'
10
+ import { ChainedCache, type CacheLayer } from '../cache/index.js'
11
+ import { localExecutor, type TaskExecutor } from '../exec/index.js'
12
+ import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
13
+ import type { ProjectConfig, WorkspaceConfig } from '../config.js'
14
+ import { detectCycle, type TaskNode } from '../graph/index.js'
13
15
  import type {
14
- BackendContext,
15
16
  CacheContext,
16
- EventSink,
17
- EventSinkContext,
17
+ ExecutorContext,
18
+ FingerprintChange,
19
+ FingerprintContext,
20
+ GraphHookContext,
21
+ KeyHookContext,
22
+ ProjectHookContext,
23
+ ScheduleHookContext,
18
24
  VxPlugin,
25
+ WorkspaceHookContext,
19
26
  } from './plugin.js'
20
- import type { RunBackend } from './protocol.js'
21
27
 
22
28
  /**
23
29
  * 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).
30
+ * `UserError` naming the plugin + hook: every stage and capability resolved
31
+ * here is load-bearing, so a broken one must abort with a clear message,
32
+ * never silently degrade. (Telemetry sinks are the observe-only exception,
33
+ * and telemetry-host.ts logs-and-skips them instead.)
28
34
  */
35
+ /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
36
+ function describeValue(v: unknown): string {
37
+ if (v === null) return 'null'
38
+ if (Array.isArray(v)) return 'an array'
39
+ const t = typeof v
40
+ return t === 'object' ? 'an object' : `a ${t}`
41
+ }
42
+
43
+ /** What a capability hook handed back must be what the seam runs. */
44
+ /** Every method `CacheLayer` requires (the optional ones are probed with `?.`). */
45
+ export const CACHE_LAYER_METHODS: readonly string[] = [
46
+ 'key',
47
+ 'get',
48
+ 'has',
49
+ 'prefetch',
50
+ 'loadOutputFilesBatch',
51
+ 'isOutputsCurrent',
52
+ 'restoreOutputs',
53
+ 'save',
54
+ 'ingest',
55
+ 'recordRunBundle',
56
+ 'stats',
57
+ 'hashFile',
58
+ 'outputsPath',
59
+ 'prune',
60
+ 'close',
61
+ ]
62
+
63
+ function assertShape(
64
+ plugin: VxPlugin,
65
+ hook: string,
66
+ value: unknown,
67
+ methods: readonly string[],
68
+ what: string,
69
+ ): void {
70
+ const missing =
71
+ value === null || typeof value !== 'object'
72
+ ? methods
73
+ : methods.filter((m) => typeof (value as Record<string, unknown>)[m] !== 'function')
74
+ if (missing.length === 0) return
75
+ throw new UserError(
76
+ `plugin '${plugin.name}' returned from ${hook} something that is not ${what}: ` +
77
+ `missing ${missing.map((m) => `${m}()`).join(', ')}`,
78
+ )
79
+ }
80
+
29
81
  async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>): Promise<T> {
30
82
  try {
31
83
  return await fn()
@@ -36,146 +88,331 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
36
88
  }
37
89
  }
38
90
 
91
+ // --- Pipeline stages ---------------------------------------------------------
92
+ //
93
+ // Each stage hands plugins the object core is about to use, in declaration
94
+ // order, and the caller re-validates afterwards. `hasHook` is the zero-cost
95
+ // gate: with no plugin declaring a stage the caller skips the loop AND the
96
+ // re-validation, so a workspace without pipeline plugins pays nothing.
97
+
98
+ export function hasHook(
99
+ plugins: readonly VxPlugin[],
100
+ hook: 'config' | 'project' | 'graph' | 'key' | 'schedule' | 'admit',
101
+ ): boolean {
102
+ for (const p of plugins) if (p[hook] !== undefined) return true
103
+ return false
104
+ }
105
+
106
+ /** `config` stage: every plugin edits the workspace config in place. */
107
+ export async function applyConfigHooks(
108
+ plugins: readonly VxPlugin[],
109
+ workspace: WorkspaceConfig,
110
+ ctx: WorkspaceHookContext,
111
+ ): Promise<void> {
112
+ for (const plugin of plugins) {
113
+ if (plugin.config === undefined) continue
114
+ await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
115
+ }
116
+ }
117
+
118
+ /** `project` stage: every plugin edits one project's config in place. */
119
+ export async function applyProjectHooks(
120
+ plugins: readonly VxPlugin[],
121
+ config: ProjectConfig,
122
+ ctx: ProjectHookContext,
123
+ /** Runs after EACH plugin's edit, so a refusal can name the plugin that made it. */
124
+ afterEach?: (plugin: VxPlugin) => void,
125
+ ): Promise<void> {
126
+ for (const plugin of plugins) {
127
+ if (plugin.project === undefined) continue
128
+ await safe(plugin, 'project', () => plugin.project!(config, ctx))
129
+ afterEach?.(plugin)
130
+ }
131
+ }
132
+
39
133
  /**
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.
134
+ * `graph` stage: every plugin edits the task graph in place, then the graph
135
+ * is checked ONCE the way the builder checks its own output — every dep
136
+ * names a node in the graph, and there is no cycle. A violation is reported
137
+ * against the LAST plugin that ran: usually the one whose edit made it so,
138
+ * but an earlier plugin's edit that a later one left in place is blamed on
139
+ * the later one, since nothing is checked between plugins.
43
140
  */
44
- export async function resolveBackend(
141
+ export async function applyGraphHooks(
45
142
  plugins: readonly VxPlugin[],
46
- ctx: BackendContext,
47
- fallback: () => Promise<RunBackend>,
48
- ): Promise<RunBackend> {
143
+ nodes: Map<string, TaskNode>,
144
+ ctx: GraphHookContext,
145
+ ): Promise<void> {
146
+ let last: VxPlugin | undefined
49
147
  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
148
+ if (plugin.graph === undefined) continue
149
+ await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
150
+ last = plugin
53
151
  }
54
- return await fallback()
152
+ if (last === undefined) return
153
+ await safe(last, 'graph', () => {
154
+ for (const node of nodes.values()) {
155
+ for (const dep of node.deps) {
156
+ if (!nodes.has(dep)) {
157
+ throw new Error(`${node.id} depends on '${dep}', which is not a task in this run's graph`)
158
+ }
159
+ }
160
+ }
161
+ detectCycle(nodes)
162
+ })
55
163
  }
56
164
 
57
165
  /**
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.
166
+ * `key` stage: every plugin may add `{ name: value }` material to every
167
+ * task. Stored on the node as sorted `[plugin/name, value]` pairs so the
168
+ * fold is order-independent and `vx why` can name the contributor.
62
169
  */
63
- export async function resolveCache(
170
+ export async function applyKeyHooks(
64
171
  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
172
+ nodes: Map<string, TaskNode>,
173
+ ctx: KeyHookContext,
174
+ ): Promise<void> {
175
+ for (const node of nodes.values()) {
176
+ const parts: Array<readonly [string, string]> = []
177
+ for (const plugin of plugins) {
178
+ if (plugin.key === undefined) continue
179
+ const material = await safe(plugin, 'key', () => plugin.key!(node, ctx))
180
+ if (material === undefined) continue
181
+ // `Object.entries` over a string yields its characters as string
182
+ // values, so a plugin returning `'v22'` used to fold parts named
183
+ // '0', '1', '2' into every key — silently, and permanently.
184
+ if (typeof material !== 'object' || material === null || Array.isArray(material)) {
185
+ throw new UserError(
186
+ `plugin '${plugin.name}' failed in key: returned ${describeValue(material)}, not a record of string values`,
187
+ )
188
+ }
189
+ for (const [name, value] of Object.entries(material)) {
190
+ if (typeof value !== 'string') {
191
+ throw new UserError(
192
+ `plugin '${plugin.name}' failed in key: value for '${name}' on ${node.id} is not a string`,
193
+ )
194
+ }
195
+ parts.push([`${plugin.name}/${name}`, value])
196
+ }
197
+ }
198
+ if (parts.length > 0) {
199
+ parts.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
200
+ node.keyParts = parts
201
+ }
74
202
  }
75
- return fallback()
76
203
  }
77
204
 
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 }>
205
+ /**
206
+ * The fingerprint files the plugins claim (`VxPlugin.fingerprint`), each
207
+ * with its one claimant. The schema refused a second claimant and an
208
+ * unknown name, so this only indexes.
209
+ */
210
+ export function fingerprintClaims(plugins: readonly VxPlugin[]): ReadonlyMap<string, VxPlugin> {
211
+ const claims = new Map<string, VxPlugin>()
212
+ for (const p of plugins) {
213
+ if (p.fingerprint === undefined) continue
214
+ for (const f of p.fingerprint.files) claims.set(f, p)
215
+ }
216
+ return claims
84
217
  }
85
218
 
86
219
  /**
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).
220
+ * Ask a claimed file's plugin which projects a change to it affects.
221
+ * `undefined` is "every project" — the claimant could not tell — and a
222
+ * non-iterable answer is refused by name, so a plugin returning `'all'`
223
+ * cannot select the projects spelled a, l, l.
91
224
  */
92
- export async function subscribeEventSinks(
225
+ export async function claimedAffected(
226
+ plugin: VxPlugin,
227
+ change: FingerprintChange,
228
+ ctx: FingerprintContext,
229
+ ): Promise<Set<string> | undefined> {
230
+ const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
231
+ if (answer === undefined) return undefined
232
+ if (typeof answer === 'string' || typeof answer !== 'object' || answer === null) {
233
+ throw new UserError(
234
+ `plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
235
+ )
236
+ }
237
+ const names = new Set<string>()
238
+ for (const name of answer as Iterable<unknown>) {
239
+ if (typeof name !== 'string') {
240
+ throw new UserError(
241
+ `plugin '${plugin.name}' failed in fingerprint: affected project ${describeValue(name)} is not a name`,
242
+ )
243
+ }
244
+ names.add(name)
245
+ }
246
+ return names
247
+ }
248
+
249
+ /**
250
+ * `schedule` stage: the merged priorities map (a later plugin's weight for
251
+ * a task overrides an earlier one's). Empty when no plugin answered.
252
+ */
253
+ export async function applyScheduleHooks(
93
254
  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 }> = []
255
+ nodes: ReadonlyMap<string, TaskNode>,
256
+ ctx: ScheduleHookContext,
257
+ ): Promise<ReadonlyMap<string, number>> {
258
+ const merged = new Map<string, number>()
99
259
  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)}`,
260
+ if (plugin.schedule === undefined) continue
261
+ const weights = await safe(plugin, 'schedule', () => plugin.schedule!(nodes, ctx))
262
+ if (weights === undefined) continue
263
+ // Iterating a string destructures its characters into `[id, w]` pairs
264
+ // that match no task — a plugin returning `'fast'` used to be a no-op
265
+ // with no word said.
266
+ if (!(weights instanceof Map)) {
267
+ throw new UserError(
268
+ `plugin '${plugin.name}' failed in schedule: returned ${describeValue(weights)}, not a Map of task id → weight`,
107
269
  )
108
- continue
109
270
  }
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
- )
271
+ for (const [id, w] of weights) {
272
+ if (!nodes.has(id)) continue
273
+ if (typeof w !== 'number' || !Number.isFinite(w)) {
274
+ throw new UserError(
275
+ `plugin '${plugin.name}' failed in schedule: weight for ${id} is not a finite number`,
276
+ )
277
+ }
278
+ merged.set(id, w)
279
+ }
123
280
  }
124
- return {
125
- dispose: () => {
126
- for (const dispose of disposers) dispose()
127
- },
128
- sinks,
281
+ return merged
282
+ }
283
+
284
+ /**
285
+ * `admit` stage, built once per run into the predicate the scheduler asks
286
+ * at every local dispatch: every plugin that answers must admit. A plugin
287
+ * that throws is reported once and admits from then on — a policy never
288
+ * breaks a run — so the predicate is never the reason a task hangs.
289
+ * Undefined when no plugin answers, which keeps the scheduler on its
290
+ * count-only path.
291
+ */
292
+ export function buildAdmission(
293
+ plugins: readonly VxPlugin[],
294
+ nodes: ReadonlyMap<string, TaskNode>,
295
+ concurrency: number,
296
+ warn: (message: string) => void,
297
+ ): ((id: string, running: ReadonlySet<string>) => boolean) | undefined {
298
+ const answering = plugins.filter((p) => p.admit !== undefined)
299
+ if (answering.length === 0) return undefined
300
+ const broken = new Set<VxPlugin>()
301
+ return (id, running) => {
302
+ const task = nodes.get(id)
303
+ if (task === undefined) return true
304
+ const ctx = {
305
+ running: [...running].flatMap((r) => nodes.get(r) ?? []),
306
+ concurrency,
307
+ }
308
+ for (const plugin of answering) {
309
+ if (broken.has(plugin)) continue
310
+ try {
311
+ if (plugin.admit!(task, ctx) === false) return false
312
+ } catch (err) {
313
+ broken.add(plugin)
314
+ const m = err instanceof Error ? err.message : String(err)
315
+ warn(`plugin '${plugin.name}' failed in admit: ${m}; admitting every task from here on`)
316
+ }
317
+ }
318
+ return true
129
319
  }
130
320
  }
131
321
 
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
322
+ /**
323
+ * Collect every plugin's `cache` layer in declaration order. One layer is
324
+ * used as is; two or more are chained (lookup walks them, save reaches all;
325
+ * see ChainedCache). A bare local layer that another declared layer already
326
+ * wraps (`layer.local === ctx.localCache`) is dropped, so a remote plugin
327
+ * that layers over the local handle does not also write the local store
328
+ * directly. A plugin declaring nothing leaves that store, unwrapped.
329
+ */
330
+ export async function resolveCache(
331
+ plugins: readonly VxPlugin[],
332
+ ctx: CacheContext,
333
+ ): Promise<CacheLayer> {
334
+ const layers: CacheLayer[] = []
335
+ for (const plugin of plugins) {
336
+ if (plugin.cache === undefined) continue
337
+ const layer = await safe(plugin, 'cache', () => plugin.cache!(ctx))
338
+ if (layer === undefined) continue
339
+ // The seam's WHOLE contract, checked once here: a layer missing a
340
+ // method otherwise failed at the first task that reached it — an
341
+ // internal TypeError deep in the chain, naming neither the plugin nor
342
+ // the hook. Five names were checked before, so a layer with those five
343
+ // passed and died at its first hit inside restoreOutputs.
344
+ assertShape(plugin, 'cache', layer, CACHE_LAYER_METHODS, 'a cache layer')
345
+ layers.push(layer)
346
+ }
347
+ // Core's own store is the TAIL of the chain — the floor under every
348
+ // lookup, not a plugin a workspace has to declare. A layer that WRAPS
349
+ // the local handle subsumes it below, so a remote plugin does not write
350
+ // the local store twice.
351
+ if (!layers.includes(ctx.localCache)) layers.push(ctx.localCache)
352
+ const wrapsLocal = layers.some((l) => l !== ctx.localCache && l.local === ctx.localCache)
353
+ const distinct = wrapsLocal ? layers.filter((l) => l !== ctx.localCache) : layers
354
+ return distinct.length === 1 ? distinct[0]! : new ChainedCache(distinct)
355
+ }
135
356
 
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(() => {})
357
+ /**
358
+ * Collect every plugin's `executor`, in declaration order, with core's
359
+ * own appended at the tail: per task, `selectExecutor` takes the first
360
+ * that accepts, and what every plugin declines runs here. A broken
361
+ * factory aborts — an executor is load-bearing, not observational.
362
+ */
363
+ export async function resolveExecutors(
364
+ plugins: readonly VxPlugin[],
365
+ ctx: ExecutorContext,
366
+ ): Promise<TaskExecutor[]> {
367
+ const executors: TaskExecutor[] = []
368
+ for (const plugin of plugins) {
369
+ if (plugin.executor === undefined) continue
370
+ const executor = await safe(plugin, 'executor', () => plugin.executor!(ctx))
371
+ if (executor === undefined) continue
372
+ assertShape(plugin, 'executor', executor, ['execute'], 'an executor')
373
+ if (typeof executor.name !== 'string' || executor.name.length === 0) {
374
+ throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
375
+ }
376
+ executors.push(executor)
148
377
  }
378
+ // Core's own executor is the TAIL of every list, so a plugin executor
379
+ // that declines a task hands it back to this machine rather than
380
+ // failing the run.
381
+ executors.push(localExecutor())
382
+ return executors
149
383
  }
150
384
 
151
385
  /**
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.
386
+ * End-of-run plugin lifecycle: each plugin's optional `teardown()`, in
387
+ * declaration order. Crash-isolated — a throwing teardown is logged and
388
+ * skipped, never propagated — and each call is time-bounded by
389
+ * {@link teardownTimeoutMs}. Runs on the normal completion path only; the
390
+ * finally-path disposers just unsubscribe. (Telemetry sinks flush before
391
+ * this, in telemetry-host.ts, with the same reporting rule.)
392
+ *
393
+ * The bound is PER CALL and the calls are sequential, so the worst case
394
+ * composes: measured at the 3s default, 1/2/3 simultaneously-hung plugins
395
+ * cost 3.0/6.0/9.0s. That is deliberate rather than overlooked — the
396
+ * telemetry sibling races its sinks concurrently, but a plugin's teardown
397
+ * may release something a later one still holds, and declaration order is
398
+ * the contract this file keeps elsewhere. Each hung call names itself, so
399
+ * the delay is attributable instead of mysterious.
159
400
  */
160
401
  export async function teardownPlugins(
161
402
  plugins: readonly VxPlugin[],
162
- sinks: ReadonlyArray<{ pluginName: string; sink: EventSink }>,
163
403
  warn: (message: string) => void,
164
404
  ): 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
405
  for (const plugin of plugins) {
176
406
  if (plugin.teardown === undefined) continue
407
+ const ms = teardownTimeoutMs()
177
408
  try {
178
- await settleWithin(Promise.resolve(plugin.teardown()), TEARDOWN_TIMEOUT_MS)
409
+ const settled = await settleWithin(Promise.resolve(plugin.teardown()), ms)
410
+ // Same rule as the flush above. A teardown that never settles has left
411
+ // whatever it owns un-released, and the run exits anyway — silence would
412
+ // present that as a clean shutdown.
413
+ if (!settled) {
414
+ warn(`[vx] plugin '${plugin.name}' teardown timed out after ${ms}ms; it did not complete`)
415
+ }
179
416
  } catch (err) {
180
417
  warn(
181
418
  `[vx] plugin '${plugin.name}' teardown failed: ${err instanceof Error ? err.message : String(err)}`,