@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -18,8 +18,15 @@ import {
|
|
|
18
18
|
type ResolvedRemote,
|
|
19
19
|
} from '../cache/index.js'
|
|
20
20
|
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
21
|
-
import {
|
|
22
|
-
|
|
21
|
+
import {
|
|
22
|
+
errorCode,
|
|
23
|
+
maskedLine,
|
|
24
|
+
settleWithin,
|
|
25
|
+
teardownTimeoutMs,
|
|
26
|
+
thrownMessage,
|
|
27
|
+
UserError,
|
|
28
|
+
} from '../util/index.js'
|
|
29
|
+
import { PLATFORMS, type ProjectConfig, type WorkspaceConfig } from '../config.js'
|
|
23
30
|
import { checkGraph, type GraphRules, type TaskNode } from '../graph/index.js'
|
|
24
31
|
import type {
|
|
25
32
|
CacheContext,
|
|
@@ -58,6 +65,8 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
|
|
58
65
|
/** Every method a `cache` hook's `RemoteCacheLayer` requires (`hasMany` is optional). */
|
|
59
66
|
export const REMOTE_CACHE_METHODS: readonly string[] = ['has', 'get', 'put']
|
|
60
67
|
|
|
68
|
+
const OPTIONAL_REMOTE_METHODS = ['open', 'lend', 'hasMany', 'missing'] as const
|
|
69
|
+
|
|
61
70
|
/** What a capability hook handed back must be what the seam runs. */
|
|
62
71
|
function assertShape(
|
|
63
72
|
plugin: VxPlugin,
|
|
@@ -91,10 +100,11 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
|
|
|
91
100
|
// Masked here, not by whoever prints it: an embedder of `run()` and
|
|
92
101
|
// a plugin's verb print it as they get it, and the throw quotes what the
|
|
93
102
|
// plugin was told (a remote's reply, a header) (L-11).
|
|
103
|
+
// A plugin's own code survives (cli.md § Error codes): an agent
|
|
104
|
+
// branches on it.
|
|
94
105
|
throw new UserError(
|
|
95
|
-
maskedLine(
|
|
96
|
-
|
|
97
|
-
),
|
|
106
|
+
maskedLine(`plugin '${plugin.name}' failed in ${hook}: ${thrownMessage(err)}`),
|
|
107
|
+
errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
|
|
98
108
|
)
|
|
99
109
|
}
|
|
100
110
|
}
|
|
@@ -267,6 +277,14 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
|
|
|
267
277
|
throw new Error(`${key}'s ${field} is ${shown(fields[field])}, not a name`)
|
|
268
278
|
}
|
|
269
279
|
}
|
|
280
|
+
// A node copied under a new id kept the old taskName: the key read
|
|
281
|
+
// the id, `--exclude-dependencies` the name, and the two disagreed.
|
|
282
|
+
const id = (node as { id?: unknown }).id
|
|
283
|
+
if (id !== `${fields.projectName as string}#${fields.taskName as string}`) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`${key}'s id is ${shown(id)}, not its projectName#taskName (${fields.projectName as string}#${fields.taskName as string})`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
270
288
|
const dir = fields.projectDir
|
|
271
289
|
if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
|
|
272
290
|
throw new Error(`${key}'s projectDir is ${shown(dir)}, not an absolute path`)
|
|
@@ -274,6 +292,10 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
|
|
|
274
292
|
}
|
|
275
293
|
}
|
|
276
294
|
|
|
295
|
+
// With `u`, a well-formed pair is one code point, so `Cs` matches only a lone half.
|
|
296
|
+
const LONE_SURROGATE = /\p{Cs}/u
|
|
297
|
+
const LONE_SURROGATES = /\p{Cs}/gu
|
|
298
|
+
|
|
277
299
|
/**
|
|
278
300
|
* `key` stage: every plugin may add `{ name: value }` material to every
|
|
279
301
|
* task. Stored on the node as sorted `[plugin/name, value]` pairs so the
|
|
@@ -313,6 +335,13 @@ export async function applyKeyHooks(
|
|
|
313
335
|
`plugin '${plugin.name}' failed in key: a name on ${node.id} holds a NUL, the fold's delimiter`,
|
|
314
336
|
)
|
|
315
337
|
}
|
|
338
|
+
// The fold hashes a part's UTF-8, which writes every lone surrogate
|
|
339
|
+
// as U+FFFD: `'\ud800'` and `'\ud801'` folded one key.
|
|
340
|
+
if (LONE_SURROGATE.test(name) || LONE_SURROGATE.test(value)) {
|
|
341
|
+
throw new UserError(
|
|
342
|
+
`plugin '${plugin.name}' failed in key: part '${name.replace(LONE_SURROGATES, '�')}' on ${node.id} holds a lone UTF-16 surrogate, which the key cannot tell from another`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
316
345
|
parts.push([`${plugin.name}/${name}`, value])
|
|
317
346
|
}
|
|
318
347
|
}
|
|
@@ -364,18 +393,35 @@ export async function claimedAffected(
|
|
|
364
393
|
): Promise<Set<string> | undefined> {
|
|
365
394
|
const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
|
|
366
395
|
if (answer === undefined) return undefined
|
|
367
|
-
|
|
396
|
+
// A plain object is no list: `for…of` threw "is not iterable", naming
|
|
397
|
+
// neither the plugin nor the hook.
|
|
398
|
+
if (
|
|
399
|
+
typeof answer === 'string' ||
|
|
400
|
+
typeof answer !== 'object' ||
|
|
401
|
+
answer === null ||
|
|
402
|
+
typeof (answer as Partial<Iterable<unknown>>)[Symbol.iterator] !== 'function'
|
|
403
|
+
) {
|
|
368
404
|
throw new UserError(
|
|
369
405
|
`plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
|
|
370
406
|
)
|
|
371
407
|
}
|
|
372
408
|
const names = new Set<string>()
|
|
373
|
-
|
|
409
|
+
// A name no project carries selected nothing, and the tasks it meant to
|
|
410
|
+
// select did not run, with no word said.
|
|
411
|
+
const known = new Set(ctx.projects.map((p) => p.name))
|
|
412
|
+
// A lazy list (a generator) runs the plugin's code as it is read.
|
|
413
|
+
const read = await safe(plugin, 'fingerprint', () => [...(answer as Iterable<unknown>)])
|
|
414
|
+
for (const name of read) {
|
|
374
415
|
if (typeof name !== 'string') {
|
|
375
416
|
throw new UserError(
|
|
376
417
|
`plugin '${plugin.name}' failed in fingerprint: affected project ${describeValue(name)} is not a name`,
|
|
377
418
|
)
|
|
378
419
|
}
|
|
420
|
+
if (!known.has(name)) {
|
|
421
|
+
throw new UserError(
|
|
422
|
+
`plugin '${plugin.name}' failed in fingerprint: affected project ${JSON.stringify(name)} is not a project in this workspace`,
|
|
423
|
+
)
|
|
424
|
+
}
|
|
379
425
|
names.add(name)
|
|
380
426
|
}
|
|
381
427
|
return names
|
|
@@ -386,21 +432,20 @@ export async function claimedAffected(
|
|
|
386
432
|
* (`FingerprintClaim.extraFiles`); `undefined` when it names none it can
|
|
387
433
|
* answer for, and a plugin that throws is refused by its name.
|
|
388
434
|
*/
|
|
389
|
-
export function claimedExtraFiles(
|
|
435
|
+
export async function claimedExtraFiles(
|
|
390
436
|
plugin: VxPlugin,
|
|
391
437
|
file: string,
|
|
392
438
|
bytes: Uint8Array,
|
|
393
|
-
|
|
439
|
+
ctx: FingerprintContext,
|
|
440
|
+
): Promise<readonly string[] | undefined> {
|
|
394
441
|
const read = plugin.fingerprint?.extraFiles
|
|
395
442
|
if (read === undefined) return undefined
|
|
396
443
|
let names: unknown
|
|
397
444
|
try {
|
|
398
|
-
names = read.call(plugin.fingerprint, file, bytes)
|
|
445
|
+
names = await read.call(plugin.fingerprint, file, bytes, ctx)
|
|
399
446
|
} catch (err) {
|
|
400
447
|
throw new UserError(
|
|
401
|
-
maskedLine(
|
|
402
|
-
`plugin '${plugin.name}' failed in fingerprint: ${err instanceof Error ? err.message : String(err)}`,
|
|
403
|
-
),
|
|
448
|
+
maskedLine(`plugin '${plugin.name}' failed in fingerprint: ${thrownMessage(err)}`),
|
|
404
449
|
)
|
|
405
450
|
}
|
|
406
451
|
if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
|
|
@@ -427,11 +472,16 @@ export interface ScheduleRun {
|
|
|
427
472
|
export async function applyScheduleHooks(
|
|
428
473
|
plugins: readonly VxPlugin[],
|
|
429
474
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
430
|
-
ctx: Omit<ScheduleHookContext, 'cache' | 'refine'>,
|
|
475
|
+
ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining'>,
|
|
431
476
|
cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
|
|
432
477
|
run: ScheduleRun = {},
|
|
433
478
|
): Promise<ReadonlyMap<string, number>> {
|
|
434
479
|
const sets: (ReadonlyMap<string, number> | undefined)[] = []
|
|
480
|
+
const listeners: { plugin: VxPlugin; i: number; listener: () => void }[] = []
|
|
481
|
+
const promised = new Set<Promise<void>>()
|
|
482
|
+
const refining = async (): Promise<void> => {
|
|
483
|
+
while (promised.size > 0) await Promise.all(promised)
|
|
484
|
+
}
|
|
435
485
|
const merged = (): ReadonlyMap<string, number> => {
|
|
436
486
|
const out = new Map<string, number>()
|
|
437
487
|
for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
|
|
@@ -439,17 +489,43 @@ export async function applyScheduleHooks(
|
|
|
439
489
|
}
|
|
440
490
|
for (const [i, plugin] of plugins.entries()) {
|
|
441
491
|
if (plugin.schedule === undefined) continue
|
|
442
|
-
const refine = (
|
|
492
|
+
const refine = (
|
|
493
|
+
weights: ReadonlyMap<string, number> | Promise<ReadonlyMap<string, number> | undefined>,
|
|
494
|
+
): void => {
|
|
495
|
+
if (weights instanceof Promise) {
|
|
496
|
+
const p: Promise<void> = weights
|
|
497
|
+
.then(
|
|
498
|
+
(w) => {
|
|
499
|
+
if (w !== undefined) refine(w)
|
|
500
|
+
},
|
|
501
|
+
(err: unknown) =>
|
|
502
|
+
ctx.warn(`[vx] plugin '${plugin.name}' failed to refine: ${thrownMessage(err)}`),
|
|
503
|
+
)
|
|
504
|
+
.finally(() => promised.delete(p))
|
|
505
|
+
promised.add(p)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
443
508
|
if (run.refined === undefined) return
|
|
444
509
|
try {
|
|
445
510
|
sets[i] = scheduleWeights(plugin, weights, nodes)
|
|
446
511
|
} catch (err) {
|
|
447
|
-
ctx.warn(`[vx] ${
|
|
512
|
+
ctx.warn(`[vx] ${thrownMessage(err)}; refine ignored`)
|
|
448
513
|
return
|
|
449
514
|
}
|
|
450
515
|
run.refined(merged())
|
|
516
|
+
for (const l of listeners) {
|
|
517
|
+
if (l.i === i) continue
|
|
518
|
+
try {
|
|
519
|
+
l.listener()
|
|
520
|
+
} catch (err) {
|
|
521
|
+
ctx.warn(`[vx] plugin '${l.plugin.name}' failed in onRefine: ${thrownMessage(err)}`)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
451
524
|
}
|
|
452
|
-
const
|
|
525
|
+
const onRefine = (listener: () => void): void => {
|
|
526
|
+
if (run.refined !== undefined) listeners.push({ plugin, i, listener })
|
|
527
|
+
}
|
|
528
|
+
const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining }
|
|
453
529
|
const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
|
|
454
530
|
safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
|
|
455
531
|
const weights = await (run.step === undefined
|
|
@@ -529,6 +605,11 @@ export function buildAdmission(
|
|
|
529
605
|
verdict.catch(() => {})
|
|
530
606
|
throw new Error('returned a Promise; admit is synchronous')
|
|
531
607
|
}
|
|
608
|
+
// `0` or `42` admitted beside a running task with no word: a policy
|
|
609
|
+
// answering a count enforced nothing.
|
|
610
|
+
if (verdict !== undefined && typeof verdict !== 'boolean') {
|
|
611
|
+
throw new Error(`returned ${describeValue(verdict)}, not true or false`)
|
|
612
|
+
}
|
|
532
613
|
if (verdict !== false) continue
|
|
533
614
|
if (running.size > 0) return false
|
|
534
615
|
if (!overridden.has(plugin)) {
|
|
@@ -539,7 +620,7 @@ export function buildAdmission(
|
|
|
539
620
|
}
|
|
540
621
|
} catch (err) {
|
|
541
622
|
broken.add(plugin)
|
|
542
|
-
const m =
|
|
623
|
+
const m = thrownMessage(err)
|
|
543
624
|
warn(
|
|
544
625
|
`[vx] plugin '${plugin.name}' failed in admit: ${m}; admitting every task from here on`,
|
|
545
626
|
)
|
|
@@ -557,13 +638,15 @@ export interface CacheHost extends CacheContext {
|
|
|
557
638
|
readonly remotes?: ResolvedRemote[]
|
|
558
639
|
/** Uploads at once, per remote (`cache.concurrency.upload`). */
|
|
559
640
|
readonly uploadConcurrency?: number
|
|
641
|
+
/** What another machine's run lent, by plugin: each plugin's `ctx.lent`. */
|
|
642
|
+
readonly lentBy?: () => Readonly<Record<string, string>> | undefined
|
|
560
643
|
}
|
|
561
644
|
|
|
562
645
|
/**
|
|
563
646
|
* Collect every plugin's remote (`cache` hook) in declaration order and
|
|
564
647
|
* layer each over the local store: a lookup asks the local store first,
|
|
565
|
-
* then each remote in order (
|
|
566
|
-
* remote. The run's policy alone gates each remote (
|
|
648
|
+
* then each remote in order (2026-10-13), and a save reaches every
|
|
649
|
+
* remote. The run's policy alone gates each remote (2026-10-16: a
|
|
567
650
|
* plugin moves bytes, it never decides reads or writes); a remote's throw is a miss and a warning naming its plugin. No
|
|
568
651
|
* remote → the local store alone.
|
|
569
652
|
*/
|
|
@@ -571,13 +654,26 @@ export async function resolveCache(
|
|
|
571
654
|
plugins: readonly VxPlugin[],
|
|
572
655
|
host: CacheHost,
|
|
573
656
|
): Promise<CacheLayer> {
|
|
574
|
-
const { localCache, policy, remotes, uploadConcurrency, ...
|
|
657
|
+
const { localCache, policy, remotes, uploadConcurrency, lentBy, ...base } = host
|
|
575
658
|
const resolved: ResolvedRemote[] = []
|
|
576
659
|
for (const plugin of plugins) {
|
|
577
660
|
if (plugin.cache === undefined) continue
|
|
661
|
+
const ctx: CacheContext =
|
|
662
|
+
lentBy === undefined ? base : { ...base, lent: () => lentBy()?.[plugin.name] }
|
|
578
663
|
const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
579
664
|
if (remote === undefined) continue
|
|
580
665
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
666
|
+
// `hasMany: true` failed every batch probe, and `missing: true` every
|
|
667
|
+
// upload, each said per call as the remote's own fault.
|
|
668
|
+
const odd = OPTIONAL_REMOTE_METHODS.filter(
|
|
669
|
+
(m) => remote[m] !== undefined && typeof remote[m] !== 'function',
|
|
670
|
+
)
|
|
671
|
+
if (odd.length > 0) {
|
|
672
|
+
throw new UserError(
|
|
673
|
+
`plugin '${plugin.name}' returned from cache something that is not a remote cache layer: ` +
|
|
674
|
+
`${odd.join(', ')} ${odd.length === 1 ? 'not a function' : 'not functions'}`,
|
|
675
|
+
)
|
|
676
|
+
}
|
|
581
677
|
const entry: ResolvedRemote = {
|
|
582
678
|
remote,
|
|
583
679
|
plugin: plugin.name,
|
|
@@ -608,9 +704,7 @@ const executorPlugin = new WeakMap<TaskExecutor, string>()
|
|
|
608
704
|
*/
|
|
609
705
|
export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
|
|
610
706
|
if (!executorPlugin.has(executor)) return err
|
|
611
|
-
const named = new UserError(
|
|
612
|
-
`${executorLabel(executor)} failed in execute: ${err instanceof Error ? err.message : String(err)}`,
|
|
613
|
-
)
|
|
707
|
+
const named = new UserError(`${executorLabel(executor)} failed in execute: ${thrownMessage(err)}`)
|
|
614
708
|
named.cause = err
|
|
615
709
|
return named
|
|
616
710
|
}
|
|
@@ -623,23 +717,47 @@ export function executorLabel(executor: TaskExecutor): string {
|
|
|
623
717
|
: `plugin '${plugin}' (executor '${executor.name}')`
|
|
624
718
|
}
|
|
625
719
|
|
|
720
|
+
/** What `executorDemand` returns: the run's three calls into it. */
|
|
721
|
+
export interface ExecutorDemand {
|
|
722
|
+
/** Whether any executor asked: with none, every call does nothing. */
|
|
723
|
+
readonly any: boolean
|
|
724
|
+
/** A task finished: dropped, and its executor told once demand settled. */
|
|
725
|
+
finished(id: string): void
|
|
726
|
+
/**
|
|
727
|
+
* The placed tasks the run's cache answers: dropped, and each executor
|
|
728
|
+
* told its demand for the first time. Before it a finish narrows
|
|
729
|
+
* silently, so an executor never sizes for what restores. The first
|
|
730
|
+
* call counts; the rest do nothing.
|
|
731
|
+
*/
|
|
732
|
+
settle(hits: Iterable<string>, missed?: Iterable<string>): void
|
|
733
|
+
/** A task about to execute: back in if `settle` dropped it (its artifact was gone). */
|
|
734
|
+
executing(id: string): void
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const NO_DEMAND: ExecutorDemand = {
|
|
738
|
+
any: false,
|
|
739
|
+
finished: () => {},
|
|
740
|
+
settle: () => {},
|
|
741
|
+
executing: () => {},
|
|
742
|
+
}
|
|
743
|
+
|
|
626
744
|
/**
|
|
627
745
|
* Demand: what each executor still has left to run. An executor that
|
|
628
746
|
* provisions per task (a container, an allocation, a pod) otherwise has to
|
|
629
747
|
* guess from `capacity` alone and hold everything until teardown. Built
|
|
630
748
|
* ONLY for executors that asked: with none declared there is no map, no
|
|
631
|
-
* set, and
|
|
749
|
+
* set, and every call does nothing.
|
|
632
750
|
*
|
|
633
751
|
* The SAME set is handed back every time, narrowed in place: a consumer
|
|
634
752
|
* reading it later sees the truth rather than a stale snapshot. A hint,
|
|
635
753
|
* as `admit` is: one that throws is named once and asked no more, where
|
|
636
754
|
* it surfaced as a bare stack, or mid-run from the completion path
|
|
637
|
-
* (item 1022).
|
|
755
|
+
* (item 1022).
|
|
638
756
|
*/
|
|
639
757
|
export function executorDemand(
|
|
640
758
|
placed: ReadonlyMap<string, TaskExecutor>,
|
|
641
759
|
warn: (message: string) => void,
|
|
642
|
-
):
|
|
760
|
+
): ExecutorDemand {
|
|
643
761
|
const demandOf = new Map<TaskExecutor, Set<string>>()
|
|
644
762
|
for (const [id, executor] of placed) {
|
|
645
763
|
if (executor.demand === undefined) continue
|
|
@@ -647,31 +765,72 @@ export function executorDemand(
|
|
|
647
765
|
if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
|
|
648
766
|
remaining.add(id)
|
|
649
767
|
}
|
|
650
|
-
if (demandOf.size === 0) return
|
|
768
|
+
if (demandOf.size === 0) return NO_DEMAND
|
|
769
|
+
// Of the placed tasks, those the cache was asked and holds nowhere.
|
|
770
|
+
const missed = new Set<string>()
|
|
651
771
|
const tell = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
|
|
652
772
|
const failed = (err: unknown): void => {
|
|
653
773
|
if (!demandOf.delete(executor)) return
|
|
654
|
-
const m =
|
|
774
|
+
const m = thrownMessage(err)
|
|
655
775
|
warn(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
|
|
656
776
|
}
|
|
657
777
|
try {
|
|
658
778
|
// An `async demand` that rejects did so where no one listened: an
|
|
659
779
|
// unhandled rejection, a stack, the run killed. Its rejection is a
|
|
660
780
|
// throw like any other.
|
|
661
|
-
const ret: unknown = executor.demand!(remaining)
|
|
781
|
+
const ret: unknown = executor.demand!(remaining, missed)
|
|
662
782
|
if (ret instanceof Promise) ret.catch(failed)
|
|
663
783
|
} catch (err) {
|
|
664
784
|
failed(err)
|
|
665
785
|
}
|
|
666
786
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
787
|
+
let settled = false
|
|
788
|
+
const done = new Set<string>()
|
|
789
|
+
const ran = new Set<string>()
|
|
790
|
+
const dropped = new Set<string>()
|
|
791
|
+
return {
|
|
792
|
+
any: true,
|
|
793
|
+
finished(id) {
|
|
794
|
+
done.add(id)
|
|
795
|
+
missed.delete(id)
|
|
796
|
+
for (const [executor, remaining] of demandOf) {
|
|
797
|
+
if (remaining.delete(id) && settled) tell(executor, remaining)
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
settle(hits, misses = []) {
|
|
801
|
+
if (settled) return
|
|
802
|
+
settled = true
|
|
803
|
+
for (const id of misses) if (placed.has(id) && !done.has(id)) missed.add(id)
|
|
804
|
+
for (const id of hits) {
|
|
805
|
+
if (ran.has(id) || done.has(id)) continue
|
|
806
|
+
const executor = placed.get(id)
|
|
807
|
+
if (executor !== undefined && demandOf.get(executor)?.delete(id)) dropped.add(id)
|
|
808
|
+
}
|
|
809
|
+
for (const [executor, remaining] of demandOf) tell(executor, remaining)
|
|
810
|
+
},
|
|
811
|
+
executing(id) {
|
|
812
|
+
ran.add(id)
|
|
813
|
+
if (!dropped.delete(id)) return
|
|
814
|
+
const executor = placed.get(id)!
|
|
815
|
+
const remaining = demandOf.get(executor)
|
|
816
|
+
if (remaining === undefined) return
|
|
817
|
+
remaining.add(id)
|
|
818
|
+
tell(executor, remaining)
|
|
819
|
+
},
|
|
672
820
|
}
|
|
673
821
|
}
|
|
674
822
|
|
|
823
|
+
const EXECUTOR_FLAGS = ['remote', 'sandbox', 'servers', 'runtime', 'exclusive', 'timeouts'] as const
|
|
824
|
+
const EXECUTOR_HOOKS = [
|
|
825
|
+
'accepts',
|
|
826
|
+
'demand',
|
|
827
|
+
'footer',
|
|
828
|
+
'machines',
|
|
829
|
+
'idealMs',
|
|
830
|
+
'profile',
|
|
831
|
+
'running',
|
|
832
|
+
] as const
|
|
833
|
+
|
|
675
834
|
/**
|
|
676
835
|
* Collect every plugin's `executor`, in declaration order, with core's
|
|
677
836
|
* own appended at the tail: per task, `selectExecutor` takes the first
|
|
@@ -698,6 +857,35 @@ export async function resolveExecutors(
|
|
|
698
857
|
`plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
|
|
699
858
|
)
|
|
700
859
|
}
|
|
860
|
+
// Read as `=== true`: `remote: 'yes'` ran a remote executor as a local one.
|
|
861
|
+
for (const flag of EXECUTOR_FLAGS) {
|
|
862
|
+
const v: unknown = executor[flag]
|
|
863
|
+
if (v !== undefined && typeof v !== 'boolean') {
|
|
864
|
+
throw new UserError(
|
|
865
|
+
`plugin '${plugin.name}' returned executor '${executor.name}' with ${flag} ${typeof v === 'string' ? JSON.stringify(v) : typeof v === 'object' && v !== null ? describeValue(v) : String(v as number)}: it must be true or false`,
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const platforms: unknown = executor.platforms
|
|
870
|
+
if (
|
|
871
|
+
platforms !== undefined &&
|
|
872
|
+
!(
|
|
873
|
+
Array.isArray(platforms) &&
|
|
874
|
+
platforms.length > 0 &&
|
|
875
|
+
platforms.every((p) => (PLATFORMS as readonly unknown[]).includes(p))
|
|
876
|
+
)
|
|
877
|
+
) {
|
|
878
|
+
throw new UserError(
|
|
879
|
+
`plugin '${plugin.name}' returned executor '${executor.name}' with platforms ${JSON.stringify(platforms)}: it must be a non-empty array of ${PLATFORMS.join(', ')}`,
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
for (const hook of EXECUTOR_HOOKS) {
|
|
883
|
+
const v: unknown = executor[hook]
|
|
884
|
+
if (v !== undefined && typeof v !== 'function')
|
|
885
|
+
throw new UserError(
|
|
886
|
+
`plugin '${plugin.name}' returned executor '${executor.name}' with ${hook} that is not a function`,
|
|
887
|
+
)
|
|
888
|
+
}
|
|
701
889
|
executorPlugin.set(executor, plugin.name)
|
|
702
890
|
executors.push(executor)
|
|
703
891
|
}
|
|
@@ -710,7 +898,10 @@ export async function resolveExecutors(
|
|
|
710
898
|
|
|
711
899
|
/**
|
|
712
900
|
* End-of-run plugin lifecycle: each plugin's optional `teardown()`, in
|
|
713
|
-
* declaration order
|
|
901
|
+
* declaration order, a plugin that supplies a remote cache after every
|
|
902
|
+
* other: a teardown may write through the cache (`RunReport`), and one
|
|
903
|
+
* that revoked its token first refused the write (main 7bdbf01: the
|
|
904
|
+
* schedule's timings, put after the Vercel token's revoke). Crash-isolated — a throwing teardown is logged and
|
|
714
905
|
* skipped, never propagated — and each call is time-bounded by
|
|
715
906
|
* {@link teardownTimeoutMs}. Runs on every exit of a run once its plugins'
|
|
716
907
|
* factories ran, and of a plan (item 1021); the finally-path disposers
|
|
@@ -731,7 +922,8 @@ export async function teardownPlugins(
|
|
|
731
922
|
report?: RunReport,
|
|
732
923
|
step: <T>(id: string, work: () => Promise<T>) => Promise<T> = (_id, work) => work(),
|
|
733
924
|
): Promise<void> {
|
|
734
|
-
|
|
925
|
+
const last = (p: VxPlugin): number => (p.cache === undefined ? 0 : 1)
|
|
926
|
+
for (const plugin of plugins.toSorted((a, b) => last(a) - last(b))) {
|
|
735
927
|
if (plugin.teardown === undefined) continue
|
|
736
928
|
const ms = teardownTimeoutMs()
|
|
737
929
|
try {
|
|
@@ -745,9 +937,7 @@ export async function teardownPlugins(
|
|
|
745
937
|
warn(`[vx] plugin '${plugin.name}' teardown timed out after ${ms}ms; it did not complete`)
|
|
746
938
|
}
|
|
747
939
|
} catch (err) {
|
|
748
|
-
warn(
|
|
749
|
-
`[vx] plugin '${plugin.name}' failed in teardown: ${err instanceof Error ? err.message : String(err)}`,
|
|
750
|
-
)
|
|
940
|
+
warn(`[vx] plugin '${plugin.name}' failed in teardown: ${thrownMessage(err)}`)
|
|
751
941
|
}
|
|
752
942
|
}
|
|
753
943
|
}
|