@vzn/vx 0.0.18 → 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 +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- 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 +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- 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/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- 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 +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Where each task runs: the placement of a graph over the resolved
|
|
2
|
+
// executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
|
|
3
|
+
// (pure motion). A task is pinned to this machine when it is persistent,
|
|
4
|
+
// depends on a persistent task, or says `exec.remote: false`; everything
|
|
5
|
+
// else asks the executors in declaration order, and the local floor takes
|
|
6
|
+
// what nothing claimed.
|
|
7
|
+
|
|
8
|
+
import { machineParallelism } from '../util/index.js'
|
|
9
|
+
import { selectExecutor, type TaskExecutor } from '../exec/index.js'
|
|
10
|
+
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
11
|
+
import { resolveDownloadModes } from './download-policy.js'
|
|
12
|
+
import type { Logger } from './logger.js'
|
|
13
|
+
import { resolveExecutors } from './plugin-host.js'
|
|
14
|
+
import type { prepareRun } from './prepare.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A task is pinned to this machine when it is persistent, transitively
|
|
18
|
+
* depends on a persistent task (a worker cannot reach a port on the
|
|
19
|
+
* submitter), or declares `exec.remote: false`.
|
|
20
|
+
*/
|
|
21
|
+
export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
22
|
+
const pinned = new Set<string>()
|
|
23
|
+
const memo = new Map<string, boolean>()
|
|
24
|
+
const visit = (id: string): boolean => {
|
|
25
|
+
const known = memo.get(id)
|
|
26
|
+
if (known !== undefined) return known
|
|
27
|
+
const node = nodes.get(id)
|
|
28
|
+
if (node === undefined) return false
|
|
29
|
+
memo.set(id, false) // cycle guard; the graph builder already rejects cycles
|
|
30
|
+
const result =
|
|
31
|
+
node.config.exec?.persistent !== undefined ||
|
|
32
|
+
node.config.exec?.remote === false ||
|
|
33
|
+
node.deps.some((d) => visit(d))
|
|
34
|
+
memo.set(id, result)
|
|
35
|
+
if (result) pinned.add(id)
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
for (const id of nodes.keys()) visit(id)
|
|
39
|
+
return pinned
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Placements {
|
|
43
|
+
executors: Map<string, TaskExecutor>
|
|
44
|
+
/**
|
|
45
|
+
* `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
|
|
46
|
+
* this machine: never executed, declared outputs never cleaned or
|
|
47
|
+
* restored. The task exists to produce a remote input tree; without a
|
|
48
|
+
* remote pool, dependents use the machine's ambient state exactly as they
|
|
49
|
+
* did before the field existed.
|
|
50
|
+
*/
|
|
51
|
+
remoteOnlyNoop: Set<string>
|
|
52
|
+
/** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
|
|
53
|
+
remoteOnly: Set<string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function placeTasks(
|
|
57
|
+
nodes: Map<string, TaskNode>,
|
|
58
|
+
executors: readonly TaskExecutor[],
|
|
59
|
+
pinAllLocal = false,
|
|
60
|
+
): Placements {
|
|
61
|
+
const pinned = pinnedLocalSet(nodes)
|
|
62
|
+
const placements: Placements = {
|
|
63
|
+
executors: new Map(),
|
|
64
|
+
remoteOnlyNoop: new Set(),
|
|
65
|
+
remoteOnly: new Set(),
|
|
66
|
+
}
|
|
67
|
+
for (const node of nodes.values()) {
|
|
68
|
+
if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
|
|
69
|
+
const executor = selectExecutor(executors, {
|
|
70
|
+
taskId: node.id,
|
|
71
|
+
projectName: node.projectName,
|
|
72
|
+
projectDir: node.projectDir,
|
|
73
|
+
command: node.config.exec!.command,
|
|
74
|
+
pinnedLocal: pinAllLocal || pinned.has(node.id),
|
|
75
|
+
cacheable: node.config.cache !== undefined,
|
|
76
|
+
})
|
|
77
|
+
placements.executors.set(node.id, executor)
|
|
78
|
+
if (node.config.exec?.remote === 'only') {
|
|
79
|
+
// A pinned 'only' task (it transitively depends on a persistent one)
|
|
80
|
+
// lands here too: pinning wins, so it noops rather than shipping.
|
|
81
|
+
if (executor.remote === true) placements.remoteOnly.add(node.id)
|
|
82
|
+
else placements.remoteOnlyNoop.add(node.id)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return placements
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `executorOf` for `planRun`, or nothing. Declining plugins, a single
|
|
90
|
+
* executor, or a resolution error all yield nothing: `--dry` is an
|
|
91
|
+
* inspection command and must not fail over a label. The error is still
|
|
92
|
+
* said, on the status line, in the plugin's name: the run this plan
|
|
93
|
+
* previews would refuse on it, and a plan that hid that would read as
|
|
94
|
+
* "everything lands locally".
|
|
95
|
+
*/
|
|
96
|
+
export async function planExecutorOf(
|
|
97
|
+
prepared: Awaited<ReturnType<typeof prepareRun>>,
|
|
98
|
+
log: Logger,
|
|
99
|
+
policy: 'all' | 'toplevel' | 'none',
|
|
100
|
+
): Promise<{
|
|
101
|
+
executorOf?: (id: string) => string | undefined
|
|
102
|
+
downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
|
|
103
|
+
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
104
|
+
}> {
|
|
105
|
+
let executors: readonly TaskExecutor[]
|
|
106
|
+
try {
|
|
107
|
+
executors = await resolveExecutors(prepared.plugins, {
|
|
108
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
109
|
+
cacheDir: prepared.cacheDir,
|
|
110
|
+
warn: (m: string) => log.status(m),
|
|
111
|
+
concurrency: machineParallelism(),
|
|
112
|
+
})
|
|
113
|
+
} catch (err) {
|
|
114
|
+
log.status(
|
|
115
|
+
`[vx] placement not shown — ${err instanceof Error ? err.message : String(err)} (the run would refuse on it)`,
|
|
116
|
+
)
|
|
117
|
+
return {}
|
|
118
|
+
}
|
|
119
|
+
const placements = placeTasks(prepared.nodes, executors)
|
|
120
|
+
// Download modes need placement regardless of how many executors there
|
|
121
|
+
// are (a single REMOTE one still defers); executor LABELS only earn their
|
|
122
|
+
// column when there is a choice to report.
|
|
123
|
+
const download =
|
|
124
|
+
policy === 'all'
|
|
125
|
+
? undefined
|
|
126
|
+
: resolveDownloadModes({
|
|
127
|
+
nodes: prepared.nodes,
|
|
128
|
+
policy,
|
|
129
|
+
localPlaced: new Set(
|
|
130
|
+
[...prepared.nodes.keys()].filter(
|
|
131
|
+
(id) => placements.executors.get(id)?.remote !== true,
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
remoteOnly: placements.remoteOnly,
|
|
135
|
+
})
|
|
136
|
+
return {
|
|
137
|
+
...(executors.length < 2
|
|
138
|
+
? {}
|
|
139
|
+
: {
|
|
140
|
+
executorOf: (id: string) =>
|
|
141
|
+
placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
|
|
142
|
+
}),
|
|
143
|
+
...(download === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: {
|
|
146
|
+
downloadOf: (id: string) => download.modeOf.get(id),
|
|
147
|
+
downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
|
|
148
|
+
taskId,
|
|
149
|
+
reason,
|
|
150
|
+
})),
|
|
151
|
+
}),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stands in for the executor of a task that was never placed — a group task
|
|
157
|
+
* (runs nothing) or a persistent one (`executePersistentTask` owns it and
|
|
158
|
+
* never reads this field). It THROWS rather than silently picking some
|
|
159
|
+
* executor from the list, so a refactor that routes such a task through the
|
|
160
|
+
* exec path fails loudly instead of shipping a localhost server to a worker.
|
|
161
|
+
*/
|
|
162
|
+
export const UNPLACED_EXECUTOR: TaskExecutor = {
|
|
163
|
+
name: 'unplaced',
|
|
164
|
+
execute: (req) => {
|
|
165
|
+
throw new Error(`internal error: ${req.taskId} reached an executor without being placed`)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
|
|
170
|
+
return executors.some((e) => e.capacity !== undefined)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function poolOfPlacement(
|
|
174
|
+
placements: Placements,
|
|
175
|
+
): (id: string) => { name: string; capacity: number } | undefined {
|
|
176
|
+
return (id) => {
|
|
177
|
+
const executor = placements.executors.get(id)
|
|
178
|
+
return executor?.capacity === undefined
|
|
179
|
+
? undefined
|
|
180
|
+
: { name: executor.name, capacity: executor.capacity }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
// Plugin consultation for the run-level extension points (cache / executor).
|
|
2
|
-
// Each function asks the plugins in order
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
6
7
|
//
|
|
7
|
-
// See docs/design/
|
|
8
|
+
// See docs/design/pipeline-2026-09.md.
|
|
8
9
|
|
|
9
10
|
import { ChainedCache, type CacheLayer } from '../cache/index.js'
|
|
10
|
-
import type
|
|
11
|
+
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
11
12
|
import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
12
13
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
13
14
|
import { detectCycle, type TaskNode } from '../graph/index.js'
|
|
14
|
-
import { MISSING_PLUGIN_HINT } from './missing-plugin.js'
|
|
15
15
|
import type {
|
|
16
16
|
CacheContext,
|
|
17
17
|
ExecutorContext,
|
|
18
|
+
FingerprintChange,
|
|
19
|
+
FingerprintContext,
|
|
18
20
|
GraphHookContext,
|
|
19
21
|
KeyHookContext,
|
|
20
22
|
ProjectHookContext,
|
|
@@ -25,11 +27,57 @@ import type {
|
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Run a capability factory with crash isolation. A throw becomes a clean
|
|
28
|
-
* `UserError` naming the plugin + hook
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* 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.)
|
|
32
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
|
+
|
|
33
81
|
async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>): Promise<T> {
|
|
34
82
|
try {
|
|
35
83
|
return await fn()
|
|
@@ -49,7 +97,7 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
|
|
|
49
97
|
|
|
50
98
|
export function hasHook(
|
|
51
99
|
plugins: readonly VxPlugin[],
|
|
52
|
-
hook: 'config' | 'project' | 'graph' | 'key' | 'schedule',
|
|
100
|
+
hook: 'config' | 'project' | 'graph' | 'key' | 'schedule' | 'admit',
|
|
53
101
|
): boolean {
|
|
54
102
|
for (const p of plugins) if (p[hook] !== undefined) return true
|
|
55
103
|
return false
|
|
@@ -72,18 +120,23 @@ export async function applyProjectHooks(
|
|
|
72
120
|
plugins: readonly VxPlugin[],
|
|
73
121
|
config: ProjectConfig,
|
|
74
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,
|
|
75
125
|
): Promise<void> {
|
|
76
126
|
for (const plugin of plugins) {
|
|
77
127
|
if (plugin.project === undefined) continue
|
|
78
128
|
await safe(plugin, 'project', () => plugin.project!(config, ctx))
|
|
129
|
+
afterEach?.(plugin)
|
|
79
130
|
}
|
|
80
131
|
}
|
|
81
132
|
|
|
82
133
|
/**
|
|
83
134
|
* `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
|
|
85
|
-
* node in the graph, and there is no cycle. A violation is reported
|
|
86
|
-
* the LAST plugin that ran
|
|
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.
|
|
87
140
|
*/
|
|
88
141
|
export async function applyGraphHooks(
|
|
89
142
|
plugins: readonly VxPlugin[],
|
|
@@ -125,6 +178,14 @@ export async function applyKeyHooks(
|
|
|
125
178
|
if (plugin.key === undefined) continue
|
|
126
179
|
const material = await safe(plugin, 'key', () => plugin.key!(node, ctx))
|
|
127
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
|
+
}
|
|
128
189
|
for (const [name, value] of Object.entries(material)) {
|
|
129
190
|
if (typeof value !== 'string') {
|
|
130
191
|
throw new UserError(
|
|
@@ -141,6 +202,50 @@ export async function applyKeyHooks(
|
|
|
141
202
|
}
|
|
142
203
|
}
|
|
143
204
|
|
|
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
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
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.
|
|
224
|
+
*/
|
|
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
|
+
|
|
144
249
|
/**
|
|
145
250
|
* `schedule` stage: the merged priorities map (a later plugin's weight for
|
|
146
251
|
* a task overrides an earlier one's). Empty when no plugin answered.
|
|
@@ -155,6 +260,14 @@ export async function applyScheduleHooks(
|
|
|
155
260
|
if (plugin.schedule === undefined) continue
|
|
156
261
|
const weights = await safe(plugin, 'schedule', () => plugin.schedule!(nodes, ctx))
|
|
157
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`,
|
|
269
|
+
)
|
|
270
|
+
}
|
|
158
271
|
for (const [id, w] of weights) {
|
|
159
272
|
if (!nodes.has(id)) continue
|
|
160
273
|
if (typeof w !== 'number' || !Number.isFinite(w)) {
|
|
@@ -168,13 +281,51 @@ export async function applyScheduleHooks(
|
|
|
168
281
|
return merged
|
|
169
282
|
}
|
|
170
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
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
171
322
|
/**
|
|
172
323
|
* Collect every plugin's `cache` layer in declaration order. One layer is
|
|
173
324
|
* used as is; two or more are chained (lookup walks them, save reaches all;
|
|
174
325
|
* see ChainedCache). A bare local layer that another declared layer already
|
|
175
326
|
* wraps (`layer.local === ctx.localCache`) is dropped, so a remote plugin
|
|
176
|
-
* that layers over the local handle
|
|
177
|
-
*
|
|
327
|
+
* that layers over the local handle does not also write the local store
|
|
328
|
+
* directly. A plugin declaring nothing leaves that store, unwrapped.
|
|
178
329
|
*/
|
|
179
330
|
export async function resolveCache(
|
|
180
331
|
plugins: readonly VxPlugin[],
|
|
@@ -184,25 +335,30 @@ export async function resolveCache(
|
|
|
184
335
|
for (const plugin of plugins) {
|
|
185
336
|
if (plugin.cache === undefined) continue
|
|
186
337
|
const layer = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
187
|
-
if (layer
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
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)
|
|
194
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)
|
|
195
352
|
const wrapsLocal = layers.some((l) => l !== ctx.localCache && l.local === ctx.localCache)
|
|
196
353
|
const distinct = wrapsLocal ? layers.filter((l) => l !== ctx.localCache) : layers
|
|
197
354
|
return distinct.length === 1 ? distinct[0]! : new ChainedCache(distinct)
|
|
198
355
|
}
|
|
199
356
|
|
|
200
357
|
/**
|
|
201
|
-
* Collect every plugin's `executor`, in declaration order
|
|
202
|
-
*
|
|
203
|
-
* that accepts
|
|
204
|
-
*
|
|
205
|
-
* executor is load-bearing, not observational.
|
|
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.
|
|
206
362
|
*/
|
|
207
363
|
export async function resolveExecutors(
|
|
208
364
|
plugins: readonly VxPlugin[],
|
|
@@ -212,16 +368,17 @@ export async function resolveExecutors(
|
|
|
212
368
|
for (const plugin of plugins) {
|
|
213
369
|
if (plugin.executor === undefined) continue
|
|
214
370
|
const executor = await safe(plugin, 'executor', () => plugin.executor!(ctx))
|
|
215
|
-
if (executor
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
throw new UserError(
|
|
222
|
-
`no executor plugin declared${declined.length > 0 ? ` (${declined.join(', ')})` : ''}. ${MISSING_PLUGIN_HINT}`,
|
|
223
|
-
)
|
|
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)
|
|
224
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())
|
|
225
382
|
return executors
|
|
226
383
|
}
|
|
227
384
|
|