@vzn/vx 0.0.638 → 0.0.639
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/docs/caching.md +44 -15
- package/docs/cli.md +168 -345
- package/docs/execution.md +18 -43
- package/docs/features.md +7 -10
- package/docs/patterns.md +5 -7
- package/docs/schema.md +47 -49
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/skills/vx/SKILL.md +6 -4
- package/src/cache/cache.ts +40 -10
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +2 -2
- package/src/cache/layer.ts +25 -11
- package/src/cache/layered-cache.ts +108 -315
- 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 +10 -22
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/last.ts +6 -3
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/show.ts +6 -0
- package/src/cli/upgrade.ts +123 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch.ts +29 -14
- package/src/cli/why.ts +4 -4
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +4 -16
- package/src/graph/task-graph.ts +87 -17
- 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 +10 -28
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +21 -386
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +1 -73
- 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 +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +67 -47
- package/src/orchestrator/plugin.ts +6 -6
- package/src/orchestrator/prepare.ts +28 -29
- 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 +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +260 -601
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +5 -82
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -33
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +1 -22
- 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/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/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
package/src/orchestrator/plan.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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()`
|
|
@@ -13,10 +13,10 @@
|
|
|
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'
|
|
@@ -485,70 +484,39 @@ export interface CacheHost extends CacheContext {
|
|
|
485
484
|
readonly localCache: Cache
|
|
486
485
|
readonly policy: CachePolicy
|
|
487
486
|
/** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
|
|
488
|
-
readonly remotes?:
|
|
487
|
+
readonly remotes?: ResolvedRemote[]
|
|
489
488
|
}
|
|
490
489
|
|
|
491
490
|
/**
|
|
492
491
|
* Collect every plugin's remote (`cache` hook) in declaration order and
|
|
493
492
|
* layer each over the local store: a lookup asks the local store first,
|
|
494
493
|
* 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
|
-
*
|
|
494
|
+
* remote. The run's policy, narrowed by a remote's `access`, gates each
|
|
495
|
+
* remote; a remote's throw is a miss and a warning naming its plugin. No
|
|
496
|
+
* remote → the local store alone.
|
|
498
497
|
*/
|
|
499
498
|
export async function resolveCache(
|
|
500
499
|
plugins: readonly VxPlugin[],
|
|
501
500
|
host: CacheHost,
|
|
502
501
|
): Promise<CacheLayer> {
|
|
503
502
|
const { localCache, policy, remotes, ...ctx } = host
|
|
504
|
-
const
|
|
505
|
-
const owners: string[] = []
|
|
503
|
+
const resolved: ResolvedRemote[] = []
|
|
506
504
|
for (const plugin of plugins) {
|
|
507
505
|
if (plugin.cache === undefined) continue
|
|
508
506
|
const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
509
507
|
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
508
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
520
509
|
const access = remote.access
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
510
|
+
const entry: ResolvedRemote = {
|
|
511
|
+
remote,
|
|
512
|
+
read: policy.remoteRead && access?.read !== false,
|
|
513
|
+
write: policy.remoteWrite && access?.write !== false,
|
|
514
|
+
onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
|
|
525
515
|
}
|
|
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}'`)
|
|
516
|
+
resolved.push(entry)
|
|
517
|
+
remotes?.push(entry)
|
|
534
518
|
}
|
|
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
|
|
519
|
+
return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
|
|
552
520
|
}
|
|
553
521
|
|
|
554
522
|
/** The plugin each resolved executor came from, so a hook of it that throws is named. */
|
|
@@ -581,6 +549,55 @@ export function executorLabel(executor: TaskExecutor): string {
|
|
|
581
549
|
: `plugin '${plugin}' (executor '${executor.name}')`
|
|
582
550
|
}
|
|
583
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Demand: what each executor still has left to run. An executor that
|
|
554
|
+
* provisions per task (a container, an allocation, a pod) otherwise has to
|
|
555
|
+
* guess from `capacity` alone and hold everything until teardown. Built
|
|
556
|
+
* ONLY for executors that asked: with none declared there is no map, no
|
|
557
|
+
* set, and the returned narrower does nothing.
|
|
558
|
+
*
|
|
559
|
+
* The SAME set is handed back every time, narrowed in place: a consumer
|
|
560
|
+
* reading it later sees the truth rather than a stale snapshot. A hint,
|
|
561
|
+
* as `admit` is: one that throws is named once and asked no more, where
|
|
562
|
+
* it surfaced as a bare stack, or mid-run from the completion path
|
|
563
|
+
* (item 1022). Returns the call that drops a finished task's id.
|
|
564
|
+
*/
|
|
565
|
+
export function executorDemand(
|
|
566
|
+
placed: ReadonlyMap<string, TaskExecutor>,
|
|
567
|
+
warn: (message: string) => void,
|
|
568
|
+
): (id: string) => void {
|
|
569
|
+
const demandOf = new Map<TaskExecutor, Set<string>>()
|
|
570
|
+
for (const [id, executor] of placed) {
|
|
571
|
+
if (executor.demand === undefined) continue
|
|
572
|
+
let remaining = demandOf.get(executor)
|
|
573
|
+
if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
|
|
574
|
+
remaining.add(id)
|
|
575
|
+
}
|
|
576
|
+
if (demandOf.size === 0) return () => {}
|
|
577
|
+
const tell = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
|
|
578
|
+
const failed = (err: unknown): void => {
|
|
579
|
+
if (!demandOf.delete(executor)) return
|
|
580
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
581
|
+
warn(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
// An `async demand` that rejects did so where no one listened: an
|
|
585
|
+
// unhandled rejection, a stack, the run killed. Its rejection is a
|
|
586
|
+
// throw like any other.
|
|
587
|
+
const ret: unknown = executor.demand!(remaining)
|
|
588
|
+
if (ret instanceof Promise) ret.catch(failed)
|
|
589
|
+
} catch (err) {
|
|
590
|
+
failed(err)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
for (const [executor, remaining] of demandOf) tell(executor, remaining)
|
|
594
|
+
return (id) => {
|
|
595
|
+
for (const [executor, remaining] of demandOf) {
|
|
596
|
+
if (remaining.delete(id)) tell(executor, remaining)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
584
601
|
/**
|
|
585
602
|
* Collect every plugin's `executor`, in declaration order, with core's
|
|
586
603
|
* own appended at the tail: per task, `selectExecutor` takes the first
|
|
@@ -638,12 +655,15 @@ export async function teardownPlugins(
|
|
|
638
655
|
plugins: readonly VxPlugin[],
|
|
639
656
|
warn: (message: string) => void,
|
|
640
657
|
report?: RunReport,
|
|
658
|
+
step: <T>(id: string, work: () => Promise<T>) => Promise<T> = (_id, work) => work(),
|
|
641
659
|
): Promise<void> {
|
|
642
660
|
for (const plugin of plugins) {
|
|
643
661
|
if (plugin.teardown === undefined) continue
|
|
644
662
|
const ms = teardownTimeoutMs()
|
|
645
663
|
try {
|
|
646
|
-
const settled = await
|
|
664
|
+
const settled = await step(`${plugin.name}:teardown`, () =>
|
|
665
|
+
settleWithin(Promise.resolve(plugin.teardown?.(report)), ms),
|
|
666
|
+
)
|
|
647
667
|
// Same rule as the flush above. A teardown that never settles has left
|
|
648
668
|
// whatever it owns un-released, and the run exits anyway — silence would
|
|
649
669
|
// 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 —
|
|
@@ -462,12 +461,10 @@ export interface CacheContext extends BaseContext {
|
|
|
462
461
|
}
|
|
463
462
|
|
|
464
463
|
export interface OutputContext extends BaseContext {
|
|
465
|
-
/** Core would write to a terminal: stdout
|
|
464
|
+
/** Core would write to a terminal: stdout is a TTY. */
|
|
466
465
|
readonly isTTY: boolean
|
|
467
466
|
/** The run is on CI (`CI` set, or a provider core detects). */
|
|
468
467
|
readonly ci: boolean
|
|
469
|
-
/** `--format json`: stdout carries the machine-readable result. */
|
|
470
|
-
readonly json: boolean
|
|
471
468
|
}
|
|
472
469
|
|
|
473
470
|
export interface ExecutorContext extends BaseContext {
|
|
@@ -688,6 +685,8 @@ export interface InstallPluginsArgs {
|
|
|
688
685
|
ci?: boolean
|
|
689
686
|
/** Default null. */
|
|
690
687
|
branch?: string | null
|
|
688
|
+
/** Each setup as the step `<plugin>:setup`; default: untimed. */
|
|
689
|
+
step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
691
690
|
}
|
|
692
691
|
|
|
693
692
|
/**
|
|
@@ -824,7 +823,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
824
823
|
}
|
|
825
824
|
|
|
826
825
|
try {
|
|
827
|
-
await plugin.setup(ctx)
|
|
826
|
+
if (args.step === undefined) await plugin.setup(ctx)
|
|
827
|
+
else await args.step(`${plugin.name}:setup`, async () => plugin.setup?.(ctx))
|
|
828
828
|
} catch (err) {
|
|
829
829
|
// Earlier plugins' subscriptions too: the caller never gets the
|
|
830
830
|
// disposer, and a bus that outlives the run would keep them.
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
putRunEntry,
|
|
25
25
|
writesRunStore,
|
|
26
26
|
type PluginCache,
|
|
27
|
-
type
|
|
27
|
+
type ResolvedRemote,
|
|
28
28
|
ScopedCache,
|
|
29
29
|
applyGitEnumeration,
|
|
30
30
|
gitPathspecs,
|
|
@@ -78,9 +78,10 @@ import { applyGraphStage } from './graph-stage.js'
|
|
|
78
78
|
import { keyExcludedDependencies } from './excluded-keys.js'
|
|
79
79
|
import { FingerprintWatch } from './fingerprint-watch.js'
|
|
80
80
|
import type { VxPlugin } from './plugin.js'
|
|
81
|
+
import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
|
|
81
82
|
import { currentBranch } from './run-context.js'
|
|
82
83
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
83
|
-
import type
|
|
84
|
+
import { type Logger, warnOf } from '../report/index.js'
|
|
84
85
|
import type { RunOptions } from './options.js'
|
|
85
86
|
import { refuseFiltersThroughGroups } from './upstream.js'
|
|
86
87
|
import { runRules } from '../settings.js'
|
|
@@ -241,9 +242,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
241
242
|
(reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
|
|
242
243
|
lazyGitEnumeration(workspaceRoot)
|
|
243
244
|
if (wholeTree) void git.start()
|
|
244
|
-
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (
|
|
245
|
-
log.status(m),
|
|
246
|
-
)
|
|
245
|
+
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
|
|
247
246
|
mark('workspace config')
|
|
248
247
|
let learnedBranch: string | null | undefined
|
|
249
248
|
const branch = (): string | null =>
|
|
@@ -263,14 +262,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
263
262
|
const projectMetas =
|
|
264
263
|
reused !== undefined
|
|
265
264
|
? reused.projects
|
|
266
|
-
: await discoverProjects(
|
|
267
|
-
workspaceRoot,
|
|
268
|
-
readLock,
|
|
269
|
-
plugins,
|
|
270
|
-
cacheDir,
|
|
271
|
-
(m) => log.status(m),
|
|
272
|
-
git,
|
|
273
|
-
)
|
|
265
|
+
: await discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
|
|
274
266
|
mark('discover projects')
|
|
275
267
|
|
|
276
268
|
// SCOPED config loading: configs are programs, and evaluating 1090
|
|
@@ -368,7 +360,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
368
360
|
projectMetas,
|
|
369
361
|
packageGraph,
|
|
370
362
|
configs: readLock,
|
|
371
|
-
warn: (
|
|
363
|
+
warn: warnOf(log),
|
|
372
364
|
}
|
|
373
365
|
let loaded: LoadedProjects
|
|
374
366
|
try {
|
|
@@ -464,21 +456,22 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
464
456
|
// winning prevents double-wrapping when the workspace also declares a
|
|
465
457
|
// cache plugin.
|
|
466
458
|
let cache: CacheLayer
|
|
467
|
-
const remotes:
|
|
459
|
+
const remotes: ResolvedRemote[] = []
|
|
468
460
|
if (options.remoteCache) {
|
|
469
|
-
|
|
470
|
-
|
|
461
|
+
remotes.push({
|
|
462
|
+
remote: options.remoteCache,
|
|
463
|
+
read: policy.remoteRead,
|
|
464
|
+
write: policy.remoteWrite,
|
|
465
|
+
onError: (err) => warnOf(log)(`[vx] remote cache: ${err.message}`),
|
|
466
|
+
})
|
|
471
467
|
}
|
|
472
468
|
try {
|
|
473
469
|
cache = options.remoteCache
|
|
474
|
-
? new LayeredCache(localCache,
|
|
475
|
-
policy,
|
|
476
|
-
onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
|
|
477
|
-
})
|
|
470
|
+
? new LayeredCache(localCache, remotes)
|
|
478
471
|
: await resolveCache(plugins, {
|
|
479
472
|
workspaceRoot,
|
|
480
473
|
cacheDir,
|
|
481
|
-
warn: (
|
|
474
|
+
warn: warnOf(log),
|
|
482
475
|
scope: cacheScope,
|
|
483
476
|
localCache,
|
|
484
477
|
policy,
|
|
@@ -489,7 +482,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
489
482
|
// refused by name; the local handle opened above must not leak with it,
|
|
490
483
|
// nor what another plugin's factory opened: the factories have run
|
|
491
484
|
// (C-4, item 1029's contract).
|
|
492
|
-
await teardownPlugins(plugins, (
|
|
485
|
+
await teardownPlugins(plugins, warnOf(log))
|
|
493
486
|
localCache.close()
|
|
494
487
|
throw err
|
|
495
488
|
}
|
|
@@ -581,7 +574,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
581
574
|
workspaceRoot,
|
|
582
575
|
cacheDir,
|
|
583
576
|
projectMetas,
|
|
584
|
-
warn: (
|
|
577
|
+
warn: warnOf(log),
|
|
585
578
|
rules: graphRules,
|
|
586
579
|
})
|
|
587
580
|
// `--affected` selects over the FINAL graph: an edge or an input a
|
|
@@ -678,11 +671,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
678
671
|
if ((n.config.cache?.inputs.workspaceFiles?.length ?? 0) > 0) usesWorkspaceInputs = true
|
|
679
672
|
}
|
|
680
673
|
const projectDirs = [...graphDirs]
|
|
681
|
-
const
|
|
674
|
+
const enumerating =
|
|
675
|
+
git.started ??
|
|
682
676
|
startGitEnumeration(
|
|
683
677
|
workspaceRoot,
|
|
684
678
|
gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
|
|
685
|
-
)
|
|
679
|
+
)
|
|
680
|
+
// While git walks: the gate's pass over every task cost 2-8 ms at
|
|
681
|
+
// 1,000 projects when it ran before the spawn.
|
|
682
|
+
const settleIgnored = askIgnoredOutputs(gatedOutputs(nodes, workspaceRoot), workspaceRoot)
|
|
683
|
+
const enumeration = await enumerating
|
|
686
684
|
await applyGitEnumeration(
|
|
687
685
|
enumeration,
|
|
688
686
|
workspaceRoot,
|
|
@@ -691,6 +689,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
691
689
|
usesWorkspaceInputs,
|
|
692
690
|
localCache,
|
|
693
691
|
)
|
|
692
|
+
await settleIgnored(gitFilesCache)
|
|
694
693
|
mark('git enumeration')
|
|
695
694
|
if (hasGraphHook && !hooksFirst) await graphStage()
|
|
696
695
|
// Args after `--` go to requested commands only; with none among them
|
|
@@ -706,7 +705,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
706
705
|
}
|
|
707
706
|
}
|
|
708
707
|
if (hasHook(plugins, 'key')) {
|
|
709
|
-
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (
|
|
708
|
+
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
|
|
710
709
|
}
|
|
711
710
|
refuseFiltersThroughGroups(nodes)
|
|
712
711
|
const exclude = options.excludeDependencies
|
|
@@ -752,7 +751,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
752
751
|
{
|
|
753
752
|
workspaceRoot,
|
|
754
753
|
cacheDir,
|
|
755
|
-
warn: (
|
|
754
|
+
warn: warnOf(log),
|
|
756
755
|
localCache,
|
|
757
756
|
branch: branch(),
|
|
758
757
|
},
|
|
@@ -790,7 +789,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
790
789
|
empty: nodes.size === 0 ? 'empty-graph' : null,
|
|
791
790
|
}
|
|
792
791
|
} catch (err) {
|
|
793
|
-
await teardownPlugins(plugins, (
|
|
792
|
+
await teardownPlugins(plugins, warnOf(log))
|
|
794
793
|
cache.close()
|
|
795
794
|
throw err
|
|
796
795
|
}
|
|
@@ -41,6 +41,11 @@ export interface PrefetchArgs {
|
|
|
41
41
|
concurrency: number
|
|
42
42
|
/** When false (remote reads disabled), no prefetch fires. */
|
|
43
43
|
remoteRead: boolean
|
|
44
|
+
/**
|
|
45
|
+
* With reads off and writes on (`--force`), the keys go to
|
|
46
|
+
* `probeBeforeUpload` instead, so a key a remote holds is not re-sent.
|
|
47
|
+
*/
|
|
48
|
+
remoteWrite: boolean
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
/**
|
|
@@ -55,7 +60,15 @@ export interface PrefetchArgs {
|
|
|
55
60
|
* is always correct.
|
|
56
61
|
*/
|
|
57
62
|
export function startRemotePrefetch(args: PrefetchArgs): Promise<void> {
|
|
58
|
-
if (!args.remoteRead)
|
|
63
|
+
if (!args.remoteRead) {
|
|
64
|
+
if (!args.remoteWrite || args.cache.probeBeforeUpload === undefined) return Promise.resolve()
|
|
65
|
+
const hashes = deriveStableKeys(args).then((keys) => [...new Set(keys.map((k) => k.hash))])
|
|
66
|
+
args.cache.probeBeforeUpload(hashes)
|
|
67
|
+
return hashes.then(
|
|
68
|
+
() => {},
|
|
69
|
+
() => {},
|
|
70
|
+
)
|
|
71
|
+
}
|
|
59
72
|
// Detached from EXECUTION: the caller does NOT await this before
|
|
60
73
|
// scheduling, so prefetch network latency overlaps the run. But the
|
|
61
74
|
// caller DOES await the returned handle before closing the cache, so
|
|
@@ -1,182 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// cpu_ms, peak_rss_bytes, hrtime spans).
|
|
6
|
-
// Default path: <cacheDir>/runs/<run_id>.json.
|
|
7
|
-
//
|
|
8
|
-
// --profile[=<path>] → Chrome-trace JSON of the run. Each task is
|
|
9
|
-
// a single complete event (`ph: 'X'`) with
|
|
10
|
-
// ts / dur in microseconds derived from the
|
|
11
|
-
// hrtime spans the runner already captures.
|
|
12
|
-
// Default path: profile.json (cwd-relative).
|
|
13
|
-
//
|
|
14
|
-
// Both are no-ops if the corresponding RunOptions field is undefined.
|
|
1
|
+
// `--profile[=<path>]`: Chrome-trace JSON of the run. Each task is a single
|
|
2
|
+
// complete event (`ph: 'X'`) with ts / dur in microseconds derived from the
|
|
3
|
+
// hrtime spans the runner already captures. Default path: profile.json
|
|
4
|
+
// (cwd-relative).
|
|
15
5
|
|
|
16
6
|
import path from 'node:path'
|
|
17
|
-
import {
|
|
18
|
-
import { tallyOutcomes, type Tally } from './tally.js'
|
|
19
|
-
import type { FlakyFinding } from './failure-mode.js'
|
|
20
|
-
import { isCacheHit } from './telemetry.js'
|
|
21
|
-
import { ranNoCache } from './events.js'
|
|
22
|
-
|
|
23
|
-
export interface SummarizeArgs {
|
|
24
|
-
/** Empty string → default path; otherwise the explicit file path. */
|
|
25
|
-
target: string
|
|
26
|
-
cacheDir: string
|
|
27
|
-
cwd: string
|
|
28
|
-
runId: string
|
|
29
|
-
startedAtMs: number
|
|
30
|
-
endedAtMs: number
|
|
31
|
-
totalMs: number
|
|
32
|
-
/** The run's verdict — the same value the CLI turns into the exit code. */
|
|
33
|
-
ok: boolean
|
|
34
|
-
/** The process's exit code where it is not `ok ? 0 : 1`: a stopping signal's. */
|
|
35
|
-
exitCode?: number
|
|
36
|
-
outcomes: readonly TaskOutcome[]
|
|
37
|
-
/** What `detectFlaky` found this run; each row's flaky note, typed. */
|
|
38
|
-
flaky?: readonly FlakyFinding[]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** One task of `--summarize`: the wire, stated by `schemas/summary.json`. */
|
|
42
|
-
export interface SummaryTaskJson {
|
|
43
|
-
id: string
|
|
44
|
-
project: string
|
|
45
|
-
task: string
|
|
46
|
-
status: TaskOutcome['status']
|
|
47
|
-
exitCode: number
|
|
48
|
-
durationMs: number
|
|
49
|
-
hash: string | null
|
|
50
|
-
restored?: boolean
|
|
51
|
-
noCache?: true
|
|
52
|
-
flaky?: { passes: number; failures: number; attempts: number }
|
|
53
|
-
cpuMs?: number
|
|
54
|
-
peakRssBytes?: number
|
|
55
|
-
storedDurationMs?: number
|
|
56
|
-
storedCpuMs?: number
|
|
57
|
-
storedPeakRssBytes?: number
|
|
58
|
-
admissionHeldMs?: number
|
|
59
|
-
blockedBy?: string
|
|
60
|
-
timedOut?: true
|
|
61
|
-
sandboxViolations?: number
|
|
62
|
-
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
63
|
-
wallclockStartNs?: string
|
|
64
|
-
wallclockEndNs?: string
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The `--summarize` document. */
|
|
68
|
-
export interface RunSummaryJson {
|
|
69
|
-
runId: string
|
|
70
|
-
ok: boolean
|
|
71
|
-
exitCode: number
|
|
72
|
-
startedAt: string
|
|
73
|
-
endedAt: string
|
|
74
|
-
totalMs: number
|
|
75
|
-
/** What the hits skipped: the sum of their `storedDurationMs`. */
|
|
76
|
-
savedMs: number
|
|
77
|
-
tasks: SummaryTaskJson[]
|
|
78
|
-
aborted: SummaryTaskJson[]
|
|
79
|
-
summary: Tally
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** One task's entry. Shared by `tasks` and `aborted` so they read alike. */
|
|
83
|
-
function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
84
|
-
return {
|
|
85
|
-
id: o.node.id,
|
|
86
|
-
project: o.node.projectName,
|
|
87
|
-
task: o.node.taskName,
|
|
88
|
-
status: o.status,
|
|
89
|
-
exitCode: o.exitCode,
|
|
90
|
-
durationMs: o.durationMs,
|
|
91
|
-
hash: o.hash ?? null,
|
|
92
|
-
// On a hit: whether it restored outputs or found them up to date, as
|
|
93
|
-
// `summary`'s restoredLocal / restoredRemote / upToDate count it.
|
|
94
|
-
...(isCacheHit(o.status) ? { restored: o.restored === true } : {}),
|
|
95
|
-
// A task with no `cache` block executes every run by design; without
|
|
96
|
-
// the flag a consumer computing a hit rate cannot tell a miss from a
|
|
97
|
-
// task that could never hit (its `hash` is still set — dependents fold
|
|
98
|
-
// it). Present only when true, so every other row is byte-identical.
|
|
99
|
-
...(ranNoCache(o) ? { noCache: true } : {}),
|
|
100
|
-
// Present only when the run proved the task flaky: this exact key
|
|
101
|
-
// failed after it had passed (this run counted), or the task
|
|
102
|
-
// needed a retry. A consumer gating on `failed` can tell a break from a
|
|
103
|
-
// flake without the history; every other row is byte-identical.
|
|
104
|
-
...(flaky !== undefined
|
|
105
|
-
? { flaky: { passes: flaky.passes, failures: flaky.failures, attempts: flaky.attempts } }
|
|
106
|
-
: {}),
|
|
107
|
-
...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
|
|
108
|
-
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
109
|
-
// A hit's row names what the PRODUCING execution used under its own
|
|
110
|
-
// keys, so a consumer never reads a remote worker's peak as this run's.
|
|
111
|
-
...(o.storedDurationMs !== undefined ? { storedDurationMs: o.storedDurationMs } : {}),
|
|
112
|
-
...(o.storedCpuMs !== undefined ? { storedCpuMs: o.storedCpuMs } : {}),
|
|
113
|
-
...(o.storedPeakRssBytes !== undefined ? { storedPeakRssBytes: o.storedPeakRssBytes } : {}),
|
|
114
|
-
// Present only when an `admit` policy held the task with a worker free.
|
|
115
|
-
...(o.admissionHeldMs !== undefined ? { admissionHeldMs: o.admissionHeldMs } : {}),
|
|
116
|
-
// A skipped row names the failed (or aborted) task at the root of what
|
|
117
|
-
// blocked it; a fail-fast skip has none.
|
|
118
|
-
...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
|
|
119
|
-
// A failure vx's own `timeout` killed; absent on every other row.
|
|
120
|
-
...(o.timedOut === true ? { timedOut: true } : {}),
|
|
121
|
-
// The sandbox's violation count; present only on a task it recorded any for.
|
|
122
|
-
...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
|
|
123
|
-
// A persistent task that never became ready, and why.
|
|
124
|
-
...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
|
|
125
|
-
// hrtime spans are bigints → emit as strings so JSON.parse on
|
|
126
|
-
// the consumer side doesn't truncate the ns precision.
|
|
127
|
-
...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
|
|
128
|
-
...(o.wallclockEndNs !== undefined ? { wallclockEndNs: String(o.wallclockEndNs) } : {}),
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
|
|
133
|
-
const outPath =
|
|
134
|
-
args.target === ''
|
|
135
|
-
? path.join(args.cacheDir, 'runs', `${args.runId}.json`)
|
|
136
|
-
: path.resolve(args.cwd, args.target)
|
|
137
|
-
await Bun.write(outPath, JSON.stringify(runSummaryJson(args), null, 2))
|
|
138
|
-
return outPath
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** The `--summarize` document, which `vx run --format json` prints. */
|
|
142
|
-
export function runSummaryJson(
|
|
143
|
-
args: Omit<SummarizeArgs, 'target' | 'cacheDir' | 'cwd'>,
|
|
144
|
-
): RunSummaryJson {
|
|
145
|
-
// `tasks` and `summary` must describe the same population, or one artifact
|
|
146
|
-
// contradicts itself (a group task listed in `tasks` but absent from
|
|
147
|
-
// `summary.total`). `tallyOutcomes` owns the rule; mirror its filter here.
|
|
148
|
-
const counted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status !== 'aborted')
|
|
149
|
-
// A task killed by a shutdown signal is in no bucket and no total, yet it
|
|
150
|
-
// makes the run red. Listing it separately keeps `tasks.length ===
|
|
151
|
-
// summary.total` while leaving the artifact able to explain a non-zero
|
|
152
|
-
// exit — the terminal has said so in its Aborted section all along, and a
|
|
153
|
-
// parser must not be told less than a human is.
|
|
154
|
-
const aborted = args.outcomes.filter((o) => !isGroupTask(o.node) && o.status === 'aborted')
|
|
155
|
-
const flakyById = new Map((args.flaky ?? []).map((f) => [f.taskId, f]))
|
|
156
|
-
const exitCode = args.exitCode ?? (args.ok ? 0 : 1)
|
|
157
|
-
const payload: RunSummaryJson = {
|
|
158
|
-
runId: args.runId,
|
|
159
|
-
// The run-level verdict, first: a consumer gating on this artifact must
|
|
160
|
-
// not have to re-derive it by re-implementing the bucket rules.
|
|
161
|
-
ok: exitCode === 0,
|
|
162
|
-
exitCode,
|
|
163
|
-
startedAt: new Date(args.startedAtMs).toISOString(),
|
|
164
|
-
endedAt: new Date(args.endedAtMs).toISOString(),
|
|
165
|
-
totalMs: args.totalMs,
|
|
166
|
-
// The summary row's `saved`, by the same rule: hits only, groups aside.
|
|
167
|
-
savedMs: counted.reduce(
|
|
168
|
-
(ms, o) => (isCacheHit(o.status) ? ms + (o.storedDurationMs ?? 0) : ms),
|
|
169
|
-
0,
|
|
170
|
-
),
|
|
171
|
-
tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
|
|
172
|
-
aborted: aborted.map((o) => taskEntry(o)),
|
|
173
|
-
// The FULL list: `tallyOutcomes` applies the same group/aborted
|
|
174
|
-
// exclusions internally, so every counted bucket is unchanged by passing
|
|
175
|
-
// it — but `summary.aborted` is only non-zero if it sees them.
|
|
176
|
-
summary: tallyOutcomes(args.outcomes),
|
|
177
|
-
}
|
|
178
|
-
return payload
|
|
179
|
-
}
|
|
7
|
+
import type { TaskOutcome } from '../graph/index.js'
|
|
180
8
|
|
|
181
9
|
export interface ProfileArgs {
|
|
182
10
|
target: string
|