@vzn/vx 0.0.638 → 0.0.640
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// ownership moves to the run's registry, and this is the one place that
|
|
4
4
|
// decides which of them outlive the graph and how the rest go down.
|
|
5
5
|
|
|
6
|
-
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
6
|
+
import { isGroupTask, isPassStatus, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
7
7
|
import { killGraceMs } from '../util/index.js'
|
|
8
|
-
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
9
|
-
import type { HeldServer } from './options.js'
|
|
8
|
+
import { holdGroups, killTree, signalExitCode, untilGroupsGone } from '../exec/index.js'
|
|
9
|
+
import type { HeldPersistent, HeldServer } from './options.js'
|
|
10
|
+
import { terminateChildren } from './signals.js'
|
|
10
11
|
|
|
11
12
|
type Child = ReturnType<typeof Bun.spawn>
|
|
12
13
|
|
|
@@ -152,3 +153,129 @@ export function takeHeldServers(
|
|
|
152
153
|
}
|
|
153
154
|
return { kept, stale }
|
|
154
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The kept server whose exit ends a foreground session, and its code. A
|
|
159
|
+
* server kept only as a dependency that exits 0 is a daemon that forked
|
|
160
|
+
* and returned (schema.md), not the end of the session: a
|
|
161
|
+
* `db: 'docker compose up -d'` under `app#dev` stopped the dev server
|
|
162
|
+
* right after the summary (WD-3). It ends the wait only as the last.
|
|
163
|
+
*/
|
|
164
|
+
export function sessionEnd(keepAlive: KeepAlive): Promise<{ code: number; i: number }> {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
let left = keepAlive.children.length
|
|
167
|
+
keepAlive.children.forEach((c, i) => {
|
|
168
|
+
void c.exited.then((code) => {
|
|
169
|
+
left--
|
|
170
|
+
const n = keepAlive.nodes[i]!
|
|
171
|
+
if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface HoldArgs {
|
|
178
|
+
forwardArgs: readonly string[] | undefined
|
|
179
|
+
/** Where a held server's output goes. */
|
|
180
|
+
outOf: (id: string) => HeldServer['out']
|
|
181
|
+
warn: (message: string) => void
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The kept servers handed to the caller (`RunOptions.hold`) instead of waited on. */
|
|
185
|
+
export function holdServers(keepAlive: KeepAlive, args: HoldArgs): HeldPersistent {
|
|
186
|
+
const held = keepAlive.children
|
|
187
|
+
// A held server that dies on its own is said, as the foreground's
|
|
188
|
+
// keep-alive says it: `vx watch` sat on "watching" over a dead dev
|
|
189
|
+
// server. The holder's own stop() is not such a death.
|
|
190
|
+
let stopping = false
|
|
191
|
+
// Taken by a later run (`RunOptions.keep`): kept or stopped there.
|
|
192
|
+
const handed = new Set<Child>()
|
|
193
|
+
const servers = new Map<string, HeldServer>()
|
|
194
|
+
keepAlive.nodes.forEach((n, i) => {
|
|
195
|
+
const child = held[i]!
|
|
196
|
+
servers.set(n.id, {
|
|
197
|
+
child,
|
|
198
|
+
config: serverConfig(n, args.forwardArgs),
|
|
199
|
+
out: args.outOf(n.id),
|
|
200
|
+
handOver: () => void handed.add(child),
|
|
201
|
+
})
|
|
202
|
+
// One that died during the graph was said then.
|
|
203
|
+
if (hasEnded(child)) return
|
|
204
|
+
void child.exited.then((code) => {
|
|
205
|
+
if (!stopping && !handed.has(child) && code !== 0)
|
|
206
|
+
args.warn(`vx: ${n.id} exited with code ${code}`)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
return {
|
|
210
|
+
ids: keepAlive.nodes.map((n) => n.id),
|
|
211
|
+
servers,
|
|
212
|
+
stop: (signal) => {
|
|
213
|
+
stopping = true
|
|
214
|
+
return terminateChildren(() => held.filter((c) => !handed.has(c)), signal)
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A server that died on its own before the stop failed, whatever its
|
|
221
|
+
* outcome said when it became ready: the footer counted it a success over
|
|
222
|
+
* the run's red exit (item 1071). Its code is its own, or the shell's
|
|
223
|
+
* report of the signal that took it. In place: the renderer holds this
|
|
224
|
+
* outcome (events carry live refs) and closes the server's output block
|
|
225
|
+
* from it at runEnd, where a copy left it reading `running` under its exit
|
|
226
|
+
* line (WD-10).
|
|
227
|
+
*/
|
|
228
|
+
export function failServer(
|
|
229
|
+
outcomes: ReadonlyMap<string, TaskOutcome>,
|
|
230
|
+
id: string,
|
|
231
|
+
code: number | string,
|
|
232
|
+
): void {
|
|
233
|
+
const o = outcomes.get(id)
|
|
234
|
+
if (o === undefined) return
|
|
235
|
+
o.status = 'failed'
|
|
236
|
+
o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The end of the graph for its servers: which stay up (`selectKeepAlive`),
|
|
241
|
+
* the bounded shutdown of the rest, and each one that died on its own
|
|
242
|
+
* failed. A run that failed elsewhere keeps nothing: `vx run dev --all`
|
|
243
|
+
* with one server that never became ready sat on the others for good, so
|
|
244
|
+
* a script hung, and the Ctrl-C that ended it read 130 over the failure
|
|
245
|
+
* (C-60). A kept server's own crash ends the keep-alive wait as before,
|
|
246
|
+
* and `--continue=always` asked to keep going, and does. The watch loop
|
|
247
|
+
* (`hold`) still holds: a failing test in a cycle stopped the dev server
|
|
248
|
+
* the next change would restart anyway. A kept server that has already
|
|
249
|
+
* died fails too; the keep-alive wait names it.
|
|
250
|
+
*/
|
|
251
|
+
export async function disposeServers(args: {
|
|
252
|
+
registry: ReadonlyMap<string, Child>
|
|
253
|
+
nodes: ReadonlyMap<string, TaskNode>
|
|
254
|
+
outcomes: ReadonlyMap<string, TaskOutcome>
|
|
255
|
+
/** Keep the requested servers up: the CLI foreground, or a hold. */
|
|
256
|
+
keep: boolean
|
|
257
|
+
hold: boolean
|
|
258
|
+
continueAlways: boolean
|
|
259
|
+
/** The servers that had ended when a stop landed; undefined without a stop. */
|
|
260
|
+
endedBeforeStop: ReadonlySet<Child> | undefined
|
|
261
|
+
}): Promise<{ keepAlive: KeepAlive; crashed: CrashedPersistent[] }> {
|
|
262
|
+
const { registry, outcomes, endedBeforeStop } = args
|
|
263
|
+
let keepAlive = selectKeepAlive(registry, args.nodes, args.keep)
|
|
264
|
+
const failedElsewhere =
|
|
265
|
+
![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
|
|
266
|
+
[...registry.values()].some(
|
|
267
|
+
(c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
|
|
268
|
+
)
|
|
269
|
+
if (failedElsewhere && !args.hold && !args.continueAlways) keepAlive = { nodes: [], children: [] }
|
|
270
|
+
const crashed = (await shutdownPersistent(registry, keepAlive.children)).filter(
|
|
271
|
+
(c) => endedBeforeStop?.has(registry.get(c.id)!) ?? true,
|
|
272
|
+
)
|
|
273
|
+
for (const c of crashed) failServer(outcomes, c.id, c.code)
|
|
274
|
+
keepAlive.nodes.forEach((n, i) => {
|
|
275
|
+
const child = keepAlive.children[i]!
|
|
276
|
+
if (!hasEnded(child) || child.exitCode === 0) return
|
|
277
|
+
if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
|
|
278
|
+
failServer(outcomes, n.id, child.exitCode ?? child.signalCode ?? 'unknown')
|
|
279
|
+
})
|
|
280
|
+
return { keepAlive, crashed }
|
|
281
|
+
}
|
|
@@ -11,7 +11,7 @@ import { machineParallelism, UserError } from '../util/index.js'
|
|
|
11
11
|
import { isLocalExecutor, selectExecutor, type TaskExecutor } from '../exec/index.js'
|
|
12
12
|
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
13
13
|
import { resolveDownloadModes } from './download-policy.js'
|
|
14
|
-
import type
|
|
14
|
+
import { type Logger, warnOf } from '../report/index.js'
|
|
15
15
|
import { executorLabel, resolveExecutors } from './plugin-host.js'
|
|
16
16
|
import type { prepareRun } from './prepare.js'
|
|
17
17
|
|
|
@@ -59,7 +59,7 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* A key that folds `
|
|
62
|
+
* A key that folds `inputs.runtime` / `workspaceRuntime` holds what
|
|
63
63
|
* THIS machine answered (`node -v`); a worker runs its own runtime, which no
|
|
64
64
|
* executor can prove equal, and its output saved under this key is a stale
|
|
65
65
|
* hit here (C-2). Such a task runs here. Its dependants are not pinned with
|
|
@@ -68,7 +68,7 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
|
68
68
|
*/
|
|
69
69
|
function withProbedRuntime(nodes: Map<string, TaskNode>, pinned: Set<string>): Set<string> {
|
|
70
70
|
for (const node of nodes.values()) {
|
|
71
|
-
const inputs = node.config.
|
|
71
|
+
const inputs = node.config.inputs
|
|
72
72
|
if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
|
|
73
73
|
pinned.add(node.id)
|
|
74
74
|
}
|
|
@@ -218,11 +218,11 @@ export async function planExecutorOf(
|
|
|
218
218
|
executors = await resolveExecutors(prepared.plugins, {
|
|
219
219
|
workspaceRoot: prepared.workspaceRoot,
|
|
220
220
|
cacheDir: prepared.cacheDir,
|
|
221
|
-
warn: (
|
|
221
|
+
warn: warnOf(log),
|
|
222
222
|
concurrency: machineParallelism(),
|
|
223
223
|
})
|
|
224
224
|
} catch (err) {
|
|
225
|
-
log
|
|
225
|
+
warnOf(log)(
|
|
226
226
|
`[vx] placement not shown — ${err instanceof Error ? err.message : String(err)} (the run would refuse on it)`,
|
|
227
227
|
)
|
|
228
228
|
return {}
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
// Planning: walk the task graph, compute every task's cache key, and
|
|
2
2
|
// probe the cache to predict what would happen if `vx run` actually
|
|
3
|
-
// ran. Used by `--dry
|
|
3
|
+
// ran. Used by `--dry`.
|
|
4
4
|
//
|
|
5
5
|
// No TASK execution happens: no task spawn, no cleanOutputs, no
|
|
6
6
|
// restoreOutputs. Cache probing uses the byte-free `cache.has()`
|
|
7
7
|
// existence probe (a remote layer answers it its own way — no
|
|
8
8
|
// artifact download, no local ingest), so the plan is read-only — with
|
|
9
|
-
// one deliberate exception: `
|
|
9
|
+
// one deliberate exception: `inputs.runtime` / `workspaceRuntime`
|
|
10
10
|
// probe commands DO run, because predicting a task's key requires
|
|
11
11
|
// resolving them (same as a real run). Keep runtime inputs
|
|
12
12
|
// side-effect-free pure probes.
|
|
13
13
|
|
|
14
14
|
import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
15
15
|
import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
|
-
import { ranNoCache } from '
|
|
16
|
+
import { ranNoCache } from '../report/index.js'
|
|
17
17
|
import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
|
|
18
18
|
import type { AffectedReason } from './affected-tasks.js'
|
|
19
|
-
import type { HistoryProvider } from '
|
|
19
|
+
import type { HistoryProvider } from '../history/index.js'
|
|
20
20
|
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
21
21
|
import { keyedOutcome, keyUpstream } from './upstream.js'
|
|
22
22
|
|
|
@@ -139,6 +139,9 @@ export interface PlanArgs {
|
|
|
139
139
|
*/
|
|
140
140
|
export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
141
141
|
const cacheStatusById = new Map<string, CacheStatus>()
|
|
142
|
+
// Keyed first, probed after: a remote that can batch (`hasMany`) is
|
|
143
|
+
// asked once for every key, one that cannot is asked per key.
|
|
144
|
+
const probed = new Map<string, string>()
|
|
142
145
|
|
|
143
146
|
const outcomes = await runGraph({
|
|
144
147
|
nodes: args.nodes,
|
|
@@ -176,13 +179,23 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
176
179
|
} else if (!reads) {
|
|
177
180
|
status = 'miss'
|
|
178
181
|
} else {
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
probed.set(node.id, hash)
|
|
183
|
+
return keyedOutcome(node, hash)
|
|
181
184
|
}
|
|
182
185
|
cacheStatusById.set(node.id, status)
|
|
183
186
|
return keyedOutcome(node, hash)
|
|
184
187
|
},
|
|
185
188
|
})
|
|
189
|
+
if (probed.size > 0) {
|
|
190
|
+
await args.cache.remoteHasMany?.([...new Set(probed.values())])
|
|
191
|
+
for (const [id, hash] of probed) {
|
|
192
|
+
const where = await args.cache.has(hash)
|
|
193
|
+
cacheStatusById.set(
|
|
194
|
+
id,
|
|
195
|
+
where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
186
199
|
|
|
187
200
|
if (args.restorable !== undefined) {
|
|
188
201
|
const restoreTier = new Set(
|
|
@@ -10,13 +10,12 @@
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import {
|
|
12
12
|
type Cache,
|
|
13
|
-
ChainedCache,
|
|
14
13
|
type CacheLayer,
|
|
15
14
|
type CachePolicy,
|
|
16
15
|
LayeredCache,
|
|
17
16
|
absentPluginCache,
|
|
18
17
|
type PluginCache,
|
|
19
|
-
type
|
|
18
|
+
type ResolvedRemote,
|
|
20
19
|
} from '../cache/index.js'
|
|
21
20
|
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
22
21
|
import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
@@ -382,41 +381,81 @@ export async function claimedAffected(
|
|
|
382
381
|
return names
|
|
383
382
|
}
|
|
384
383
|
|
|
384
|
+
/** What a run hands the `schedule` stage: a step per plugin, and where refined weights go. */
|
|
385
|
+
export interface ScheduleRun {
|
|
386
|
+
readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
387
|
+
readonly refined?: (merged: ReadonlyMap<string, number>) => void
|
|
388
|
+
}
|
|
389
|
+
|
|
385
390
|
/**
|
|
386
391
|
* `schedule` stage: the merged priorities map (a later plugin's weight for
|
|
387
|
-
* a task overrides an earlier one's). Empty when no plugin answered.
|
|
392
|
+
* a task overrides an earlier one's). Empty when no plugin answered. Each
|
|
393
|
+
* plugin's call is the step `<plugin>:schedule`. A plugin's `ctx.refine`
|
|
394
|
+
* replaces its own weights and hands `refined` the merge again; with no
|
|
395
|
+
* `refined` (a plan), it is ignored.
|
|
388
396
|
*/
|
|
389
397
|
export async function applyScheduleHooks(
|
|
390
398
|
plugins: readonly VxPlugin[],
|
|
391
399
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
392
|
-
ctx: Omit<ScheduleHookContext, 'cache'>,
|
|
400
|
+
ctx: Omit<ScheduleHookContext, 'cache' | 'refine'>,
|
|
393
401
|
cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
|
|
402
|
+
run: ScheduleRun = {},
|
|
394
403
|
): Promise<ReadonlyMap<string, number>> {
|
|
395
|
-
const
|
|
396
|
-
|
|
404
|
+
const sets: (ReadonlyMap<string, number> | undefined)[] = []
|
|
405
|
+
const merged = (): ReadonlyMap<string, number> => {
|
|
406
|
+
const out = new Map<string, number>()
|
|
407
|
+
for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
|
|
408
|
+
return out
|
|
409
|
+
}
|
|
410
|
+
for (const [i, plugin] of plugins.entries()) {
|
|
397
411
|
if (plugin.schedule === undefined) continue
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
412
|
+
const refine = (weights: ReadonlyMap<string, number>): void => {
|
|
413
|
+
if (run.refined === undefined) return
|
|
414
|
+
try {
|
|
415
|
+
sets[i] = scheduleWeights(plugin, weights, nodes)
|
|
416
|
+
} catch (err) {
|
|
417
|
+
ctx.warn(`[vx] ${err instanceof Error ? err.message : String(err)}; refine ignored`)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
run.refined(merged())
|
|
421
|
+
}
|
|
422
|
+
const own = { ...ctx, cache: cacheFor(plugin.name), refine }
|
|
423
|
+
const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
|
|
424
|
+
safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
|
|
425
|
+
const weights = await (run.step === undefined
|
|
426
|
+
? call()
|
|
427
|
+
: run.step(`${plugin.name}:schedule`, call))
|
|
428
|
+
// A refine that landed while the call ran is the newer set.
|
|
429
|
+
if (weights !== undefined) sets[i] ??= scheduleWeights(plugin, weights, nodes)
|
|
430
|
+
}
|
|
431
|
+
return merged()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** A `schedule` answer's weights for tasks in the graph, refused by name when it is not a Map of finite numbers. */
|
|
435
|
+
function scheduleWeights(
|
|
436
|
+
plugin: VxPlugin,
|
|
437
|
+
weights: unknown,
|
|
438
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
439
|
+
): ReadonlyMap<string, number> {
|
|
440
|
+
// Iterating a string destructures its characters into `[id, w]` pairs
|
|
441
|
+
// that match no task — a plugin returning `'fast'` used to be a no-op
|
|
442
|
+
// with no word said.
|
|
443
|
+
if (!(weights instanceof Map)) {
|
|
444
|
+
throw new UserError(
|
|
445
|
+
`plugin '${plugin.name}' failed in schedule: returned ${describeValue(weights)}, not a Map of task id → weight`,
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
const out = new Map<string, number>()
|
|
449
|
+
for (const [id, w] of weights as Map<unknown, unknown>) {
|
|
450
|
+
if (typeof id !== 'string' || !nodes.has(id)) continue
|
|
451
|
+
if (typeof w !== 'number' || !Number.isFinite(w)) {
|
|
405
452
|
throw new UserError(
|
|
406
|
-
`plugin '${plugin.name}' failed in schedule:
|
|
453
|
+
`plugin '${plugin.name}' failed in schedule: weight for ${id} is not a finite number`,
|
|
407
454
|
)
|
|
408
455
|
}
|
|
409
|
-
|
|
410
|
-
if (!nodes.has(id)) continue
|
|
411
|
-
if (typeof w !== 'number' || !Number.isFinite(w)) {
|
|
412
|
-
throw new UserError(
|
|
413
|
-
`plugin '${plugin.name}' failed in schedule: weight for ${id} is not a finite number`,
|
|
414
|
-
)
|
|
415
|
-
}
|
|
416
|
-
merged.set(id, w)
|
|
417
|
-
}
|
|
456
|
+
out.set(id, w)
|
|
418
457
|
}
|
|
419
|
-
return
|
|
458
|
+
return out
|
|
420
459
|
}
|
|
421
460
|
|
|
422
461
|
/**
|
|
@@ -485,70 +524,39 @@ export interface CacheHost extends CacheContext {
|
|
|
485
524
|
readonly localCache: Cache
|
|
486
525
|
readonly policy: CachePolicy
|
|
487
526
|
/** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
|
|
488
|
-
readonly remotes?:
|
|
527
|
+
readonly remotes?: ResolvedRemote[]
|
|
489
528
|
}
|
|
490
529
|
|
|
491
530
|
/**
|
|
492
531
|
* Collect every plugin's remote (`cache` hook) in declaration order and
|
|
493
532
|
* layer each over the local store: a lookup asks the local store first,
|
|
494
533
|
* then each remote in order (owner, 2026-10-13), and a save reaches every
|
|
495
|
-
* remote. The run's policy, narrowed by a remote's `access`,
|
|
496
|
-
*
|
|
497
|
-
*
|
|
534
|
+
* remote. The run's policy, narrowed by a remote's `access`, gates each
|
|
535
|
+
* remote; a remote's throw is a miss and a warning naming its plugin. No
|
|
536
|
+
* remote → the local store alone.
|
|
498
537
|
*/
|
|
499
538
|
export async function resolveCache(
|
|
500
539
|
plugins: readonly VxPlugin[],
|
|
501
540
|
host: CacheHost,
|
|
502
541
|
): Promise<CacheLayer> {
|
|
503
542
|
const { localCache, policy, remotes, ...ctx } = host
|
|
504
|
-
const
|
|
505
|
-
const owners: string[] = []
|
|
543
|
+
const resolved: ResolvedRemote[] = []
|
|
506
544
|
for (const plugin of plugins) {
|
|
507
545
|
if (plugin.cache === undefined) continue
|
|
508
546
|
const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
509
547
|
if (remote === undefined) continue
|
|
510
|
-
// The hook returned a whole layer until 0.0.637: name the fix, not the
|
|
511
|
-
// three methods such a layer happens to have.
|
|
512
|
-
if (isRecordLike(remote) && typeof remote['restoreOutputs'] === 'function') {
|
|
513
|
-
throw new UserError(
|
|
514
|
-
`plugin '${plugin.name}' returned a cache layer from cache: since vx 0.0.637 the hook ` +
|
|
515
|
-
'returns the remote alone (a RemoteCacheLayer: has, get, put) and core layers it over ' +
|
|
516
|
-
'the local cache — return the remote you passed to LayeredCache',
|
|
517
|
-
)
|
|
518
|
-
}
|
|
519
548
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
520
549
|
const access = remote.access
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
550
|
+
const entry: ResolvedRemote = {
|
|
551
|
+
remote,
|
|
552
|
+
read: policy.remoteRead && access?.read !== false,
|
|
553
|
+
write: policy.remoteWrite && access?.write !== false,
|
|
554
|
+
onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
|
|
525
555
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
new LayeredCache(localCache, remote, {
|
|
529
|
-
policy: own,
|
|
530
|
-
onRemoteError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
|
|
531
|
-
}),
|
|
532
|
-
)
|
|
533
|
-
owners.push(`the cache layer of plugin '${plugin.name}'`)
|
|
556
|
+
resolved.push(entry)
|
|
557
|
+
remotes?.push(entry)
|
|
534
558
|
}
|
|
535
|
-
|
|
536
|
-
if (layers.length === 1) return layers[0]!
|
|
537
|
-
// One line per layer and method: a layer that throws on every lookup
|
|
538
|
-
// would otherwise print once per task.
|
|
539
|
-
const told = new Set<string>()
|
|
540
|
-
return new ChainedCache(layers, (i, method, err) => {
|
|
541
|
-
if (told.has(`${i}\0${method}`)) return
|
|
542
|
-
told.add(`${i}\0${method}`)
|
|
543
|
-
const m = err instanceof Error ? err.message : String(err)
|
|
544
|
-
const then =
|
|
545
|
-
method === 'save' ? 'the other layers still save' : 'a miss there; the next layer answers'
|
|
546
|
-
ctx.warn(`[vx] ${owners[i]} failed in cache ${method}: ${m}; ${then}`)
|
|
547
|
-
})
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function isRecordLike(v: unknown): v is Record<string, unknown> {
|
|
551
|
-
return typeof v === 'object' && v !== null
|
|
559
|
+
return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
|
|
552
560
|
}
|
|
553
561
|
|
|
554
562
|
/** The plugin each resolved executor came from, so a hook of it that throws is named. */
|
|
@@ -581,6 +589,55 @@ export function executorLabel(executor: TaskExecutor): string {
|
|
|
581
589
|
: `plugin '${plugin}' (executor '${executor.name}')`
|
|
582
590
|
}
|
|
583
591
|
|
|
592
|
+
/**
|
|
593
|
+
* Demand: what each executor still has left to run. An executor that
|
|
594
|
+
* provisions per task (a container, an allocation, a pod) otherwise has to
|
|
595
|
+
* guess from `capacity` alone and hold everything until teardown. Built
|
|
596
|
+
* ONLY for executors that asked: with none declared there is no map, no
|
|
597
|
+
* set, and the returned narrower does nothing.
|
|
598
|
+
*
|
|
599
|
+
* The SAME set is handed back every time, narrowed in place: a consumer
|
|
600
|
+
* reading it later sees the truth rather than a stale snapshot. A hint,
|
|
601
|
+
* as `admit` is: one that throws is named once and asked no more, where
|
|
602
|
+
* it surfaced as a bare stack, or mid-run from the completion path
|
|
603
|
+
* (item 1022). Returns the call that drops a finished task's id.
|
|
604
|
+
*/
|
|
605
|
+
export function executorDemand(
|
|
606
|
+
placed: ReadonlyMap<string, TaskExecutor>,
|
|
607
|
+
warn: (message: string) => void,
|
|
608
|
+
): (id: string) => void {
|
|
609
|
+
const demandOf = new Map<TaskExecutor, Set<string>>()
|
|
610
|
+
for (const [id, executor] of placed) {
|
|
611
|
+
if (executor.demand === undefined) continue
|
|
612
|
+
let remaining = demandOf.get(executor)
|
|
613
|
+
if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
|
|
614
|
+
remaining.add(id)
|
|
615
|
+
}
|
|
616
|
+
if (demandOf.size === 0) return () => {}
|
|
617
|
+
const tell = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
|
|
618
|
+
const failed = (err: unknown): void => {
|
|
619
|
+
if (!demandOf.delete(executor)) return
|
|
620
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
621
|
+
warn(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
// An `async demand` that rejects did so where no one listened: an
|
|
625
|
+
// unhandled rejection, a stack, the run killed. Its rejection is a
|
|
626
|
+
// throw like any other.
|
|
627
|
+
const ret: unknown = executor.demand!(remaining)
|
|
628
|
+
if (ret instanceof Promise) ret.catch(failed)
|
|
629
|
+
} catch (err) {
|
|
630
|
+
failed(err)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
for (const [executor, remaining] of demandOf) tell(executor, remaining)
|
|
634
|
+
return (id) => {
|
|
635
|
+
for (const [executor, remaining] of demandOf) {
|
|
636
|
+
if (remaining.delete(id)) tell(executor, remaining)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
584
641
|
/**
|
|
585
642
|
* Collect every plugin's `executor`, in declaration order, with core's
|
|
586
643
|
* own appended at the tail: per task, `selectExecutor` takes the first
|
|
@@ -638,12 +695,15 @@ export async function teardownPlugins(
|
|
|
638
695
|
plugins: readonly VxPlugin[],
|
|
639
696
|
warn: (message: string) => void,
|
|
640
697
|
report?: RunReport,
|
|
698
|
+
step: <T>(id: string, work: () => Promise<T>) => Promise<T> = (_id, work) => work(),
|
|
641
699
|
): Promise<void> {
|
|
642
700
|
for (const plugin of plugins) {
|
|
643
701
|
if (plugin.teardown === undefined) continue
|
|
644
702
|
const ms = teardownTimeoutMs()
|
|
645
703
|
try {
|
|
646
|
-
const settled = await
|
|
704
|
+
const settled = await step(`${plugin.name}:teardown`, () =>
|
|
705
|
+
settleWithin(Promise.resolve(plugin.teardown?.(report)), ms),
|
|
706
|
+
)
|
|
647
707
|
// Same rule as the flush above. A teardown that never settles has left
|
|
648
708
|
// whatever it owns un-released, and the run exits anyway — silence would
|
|
649
709
|
// present that as a clean shutdown.
|
|
@@ -22,10 +22,9 @@ import type { TaskExecutor } from '../exec/index.js'
|
|
|
22
22
|
import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
|
|
23
23
|
import { maskedLine, nearest, UserError } from '../util/index.js'
|
|
24
24
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
25
|
-
import type { EventBus, RunStartInfo } from '
|
|
25
|
+
import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
|
|
26
26
|
import type { CiContext } from './run-context.js'
|
|
27
27
|
import type { TelemetryContext, TelemetrySink } from './telemetry.js'
|
|
28
|
-
import type { Logger } from './logger.js'
|
|
29
28
|
|
|
30
29
|
/**
|
|
31
30
|
* A vx plugin. Contributes any subset of the run-level capabilities —
|
|
@@ -138,6 +137,9 @@ export interface VxPlugin {
|
|
|
138
137
|
* only when a task will start: a run of confirmed local hits restores
|
|
139
138
|
* in no order a weight changes. A later plugin's weight for a task
|
|
140
139
|
* overrides an earlier one's. Return undefined to leave the baseline.
|
|
140
|
+
* The run waits for this answer before its first task, so it should
|
|
141
|
+
* come from what is at hand (local history); better weights that need
|
|
142
|
+
* a fetch go to `ctx.refine` when they arrive, without holding the run.
|
|
141
143
|
*/
|
|
142
144
|
schedule?(
|
|
143
145
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
@@ -245,8 +247,9 @@ export interface VxPlugin {
|
|
|
245
247
|
/**
|
|
246
248
|
* What a finished run hands each plugin's `teardown`: its facts and every
|
|
247
249
|
* task, with the names core kept the task's bytes under. Core keeps them
|
|
248
|
-
* only when a remote takes the run's writes (CI by default): a
|
|
249
|
-
* task
|
|
250
|
+
* only when a remote takes the run's writes (CI by default): the log of a
|
|
251
|
+
* task that saved no entry, and a failed task's log and the files its
|
|
252
|
+
* outputs left when `cache.upload.failures` covers the run. Read
|
|
250
253
|
* them with `ctx.cache.getRun(name)`. Core keeps no manifest of its own; a
|
|
251
254
|
* plugin that wants one writes it from here (`ctx.cache.put`).
|
|
252
255
|
*/
|
|
@@ -449,6 +452,12 @@ export interface ScheduleHookContext extends BaseContext {
|
|
|
449
452
|
readonly branch: string | null
|
|
450
453
|
/** This plugin's entries in the workspace's cache, as `setup` has them. */
|
|
451
454
|
readonly cache: PluginCache
|
|
455
|
+
/**
|
|
456
|
+
* Replace this plugin's weights while the run goes on: the tasks not yet
|
|
457
|
+
* started re-rank at once. For weights that arrive late (timings fetched
|
|
458
|
+
* from a remote). Ignored in a plan and once the run's tasks are done.
|
|
459
|
+
*/
|
|
460
|
+
refine(weights: ReadonlyMap<string, number>): void
|
|
452
461
|
}
|
|
453
462
|
|
|
454
463
|
/** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
|
|
@@ -462,12 +471,10 @@ export interface CacheContext extends BaseContext {
|
|
|
462
471
|
}
|
|
463
472
|
|
|
464
473
|
export interface OutputContext extends BaseContext {
|
|
465
|
-
/** Core would write to a terminal: stdout
|
|
474
|
+
/** Core would write to a terminal: stdout is a TTY. */
|
|
466
475
|
readonly isTTY: boolean
|
|
467
476
|
/** The run is on CI (`CI` set, or a provider core detects). */
|
|
468
477
|
readonly ci: boolean
|
|
469
|
-
/** `--format json`: stdout carries the machine-readable result. */
|
|
470
|
-
readonly json: boolean
|
|
471
478
|
}
|
|
472
479
|
|
|
473
480
|
export interface ExecutorContext extends BaseContext {
|
|
@@ -688,6 +695,8 @@ export interface InstallPluginsArgs {
|
|
|
688
695
|
ci?: boolean
|
|
689
696
|
/** Default null. */
|
|
690
697
|
branch?: string | null
|
|
698
|
+
/** Each setup as the step `<plugin>:setup`; default: untimed. */
|
|
699
|
+
step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
691
700
|
}
|
|
692
701
|
|
|
693
702
|
/**
|
|
@@ -824,7 +833,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
824
833
|
}
|
|
825
834
|
|
|
826
835
|
try {
|
|
827
|
-
await plugin.setup(ctx)
|
|
836
|
+
if (args.step === undefined) await plugin.setup(ctx)
|
|
837
|
+
else await args.step(`${plugin.name}:setup`, async () => plugin.setup?.(ctx))
|
|
828
838
|
} catch (err) {
|
|
829
839
|
// Earlier plugins' subscriptions too: the caller never gets the
|
|
830
840
|
// disposer, and a bus that outlives the run would keep them.
|