@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- 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/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- 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/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -1,31 +1,83 @@
|
|
|
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, 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/
|
|
8
|
+
// See docs/design/pipeline-2026-09.md.
|
|
7
9
|
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import type
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* logs-and-skips instead
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
141
|
+
export async function applyGraphHooks(
|
|
45
142
|
plugins: readonly VxPlugin[],
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
): Promise<
|
|
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.
|
|
51
|
-
|
|
52
|
-
|
|
148
|
+
if (plugin.graph === undefined) continue
|
|
149
|
+
await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
|
|
150
|
+
last = plugin
|
|
53
151
|
}
|
|
54
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
170
|
+
export async function applyKeyHooks(
|
|
64
171
|
plugins: readonly VxPlugin[],
|
|
65
|
-
|
|
66
|
-
ctx:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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
|
-
|
|
95
|
-
ctx:
|
|
96
|
-
): Promise<
|
|
97
|
-
const
|
|
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.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
/**
|
|
133
|
-
*
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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()),
|
|
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)}`,
|