@vzn/vx 0.0.639 → 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 +111 -83
- package/docs/cli.md +70 -40
- package/docs/execution.md +4 -4
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +235 -180
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/cache/cache.ts +4 -1
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +95 -19
- 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 +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/index.ts +1 -1
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +62 -22
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +124 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +51 -21
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/status-line.ts +7 -3
- package/src/settings.ts +11 -17
- package/src/util/paths.ts +1 -1
- 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
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// The report every plugin's teardown reads, and the run's own bytes kept
|
|
2
2
|
// in a remote that takes its writes (owner 2026-10-15): a failed task's
|
|
3
|
-
// log and the files its outputs left,
|
|
3
|
+
// log and the files its outputs left (`cache.upload.failures`), and the
|
|
4
|
+
// log of a task that saved no entry. Each task's bytes leave when it ends,
|
|
5
|
+
// off its path, like its entry's upload (owner 2026-10-16); the run only
|
|
6
|
+
// waits for them where it waits for every upload.
|
|
4
7
|
|
|
5
8
|
import {
|
|
6
9
|
packRunOutputs,
|
|
@@ -14,17 +17,27 @@ import { relPosix } from '../util/index.js'
|
|
|
14
17
|
import { decodeOutputLog } from './output-log.js'
|
|
15
18
|
import type { RunReport, RunReportTask } from './plugin.js'
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
/** Stores one run entry; resolves whether a remote kept it. */
|
|
21
|
+
export type KeepRunEntry = (name: string, bytes: Uint8Array) => Promise<boolean>
|
|
22
|
+
|
|
23
|
+
export interface RunKeeperArgs {
|
|
18
24
|
readonly runId: string
|
|
19
|
-
readonly commit: string | null
|
|
20
|
-
readonly ciRun: CiContext
|
|
21
|
-
readonly ok: boolean
|
|
22
25
|
readonly workspaceRoot: string
|
|
23
26
|
readonly nestedDirsByProject: ReadonlyMap<string, string[]>
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
readonly keep: KeepRunEntry
|
|
28
|
+
/** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
|
|
29
|
+
readonly failures: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a run on CI or off it keeps failures, by `cache.upload.failures` (default `'ci'`). */
|
|
33
|
+
export function keepsFailures(where: unknown, ci: boolean): boolean {
|
|
34
|
+
const at = where ?? 'ci'
|
|
35
|
+
return at === 'always' || (at === 'ci') === ci
|
|
26
36
|
}
|
|
27
37
|
|
|
38
|
+
/** Concurrent run-entry writes; more queue. */
|
|
39
|
+
const KEEP_CONCURRENCY = 8
|
|
40
|
+
|
|
28
41
|
/** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
|
|
29
42
|
async function outputsLeft(
|
|
30
43
|
node: TaskNode,
|
|
@@ -40,52 +53,117 @@ async function outputsLeft(
|
|
|
40
53
|
return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
|
|
41
54
|
}
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
/** Keeps each task's run bytes as it ends, and names them in the run's report. */
|
|
57
|
+
export class RunKeeper {
|
|
58
|
+
private readonly kept = new Map<string, { log?: string; outputs?: string }>()
|
|
59
|
+
private readonly queue: (() => Promise<void>)[] = []
|
|
60
|
+
private readonly pending = new Set<Promise<void>>()
|
|
61
|
+
private active = 0
|
|
62
|
+
private stopped = false
|
|
63
|
+
|
|
64
|
+
constructor(private readonly args: RunKeeperArgs) {}
|
|
65
|
+
|
|
66
|
+
/** A task ended: queue what of it the run keeps. */
|
|
67
|
+
taskDone(o: TaskOutcome): void {
|
|
68
|
+
if (this.stopped || isGroupTask(o.node)) return
|
|
69
|
+
const { runId, workspaceRoot } = this.args
|
|
70
|
+
const id = o.node.id
|
|
71
|
+
const slot = (): { log?: string; outputs?: string } => {
|
|
72
|
+
let s = this.kept.get(id)
|
|
73
|
+
if (s === undefined) this.kept.set(id, (s = {}))
|
|
74
|
+
return s
|
|
57
75
|
}
|
|
58
|
-
tasks.push(task)
|
|
59
|
-
if (keep === undefined) continue
|
|
60
76
|
const runLog = o.runLog
|
|
61
77
|
if (runLog !== undefined) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
78
|
+
const text = decodeOutputLog(runLog)
|
|
79
|
+
.map((c) => c.text)
|
|
80
|
+
.join('')
|
|
81
|
+
if (text.length > 0) {
|
|
82
|
+
const name = runEntryName(runId, id, 'log')
|
|
83
|
+
const bytes = new TextEncoder().encode(text)
|
|
84
|
+
this.enqueue(async () => {
|
|
85
|
+
if (await this.args.keep(name, bytes)) slot().log = name
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (this.args.failures && o.status === 'failed' && o.where === undefined) {
|
|
90
|
+
// Read now: a later task may write where this one's outputs lie.
|
|
91
|
+
const packed = outputsLeft(
|
|
92
|
+
o.node,
|
|
93
|
+
workspaceRoot,
|
|
94
|
+
this.args.nestedDirsByProject.get(o.node.projectName) ?? [],
|
|
95
|
+
).then((paths) => packRunOutputs(workspaceRoot, paths))
|
|
96
|
+
const name = runEntryName(runId, id, 'outputs')
|
|
97
|
+
this.enqueue(async () => {
|
|
98
|
+
const bytes = await packed
|
|
99
|
+
if (bytes !== null && (await this.args.keep(name, bytes))) slot().outputs = name
|
|
69
100
|
})
|
|
70
101
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolves once every queued write has settled, or the run was stopped. */
|
|
105
|
+
async settled(): Promise<void> {
|
|
106
|
+
while (!this.stopped && this.pending.size > 0) await Promise.all(this.pending)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A stopped run starts no more writes and stops waiting on those in flight. */
|
|
110
|
+
stop(): void {
|
|
111
|
+
this.stopped = true
|
|
112
|
+
this.queue.length = 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What was kept of each task, by id, for `reportOf`. */
|
|
116
|
+
keptOf(id: string): { log?: string; outputs?: string } | undefined {
|
|
117
|
+
return this.kept.get(id)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private enqueue(job: () => Promise<void>): void {
|
|
121
|
+
let done!: () => void
|
|
122
|
+
const p = new Promise<void>((resolve) => (done = resolve))
|
|
123
|
+
this.pending.add(p)
|
|
124
|
+
this.queue.push(async () => {
|
|
125
|
+
try {
|
|
126
|
+
if (!this.stopped) await job()
|
|
127
|
+
} catch {
|
|
128
|
+
// a record, never the run's verdict
|
|
129
|
+
} finally {
|
|
130
|
+
this.pending.delete(p)
|
|
131
|
+
done()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
this.pump()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private pump(): void {
|
|
138
|
+
while (this.active < KEEP_CONCURRENCY && this.queue.length > 0) {
|
|
139
|
+
const job = this.queue.shift()!
|
|
140
|
+
this.active++
|
|
141
|
+
void job().finally(() => {
|
|
142
|
+
this.active--
|
|
143
|
+
this.pump()
|
|
84
144
|
})
|
|
85
145
|
}
|
|
86
146
|
}
|
|
87
|
-
|
|
88
|
-
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The report: every task, with the names of what `keeper` kept of it. */
|
|
150
|
+
export function reportOf(
|
|
151
|
+
final: readonly TaskOutcome[],
|
|
152
|
+
run: { runId: string; commit: string | null; ciRun: CiContext; ok: boolean },
|
|
153
|
+
keeper: RunKeeper | undefined,
|
|
154
|
+
): RunReport {
|
|
155
|
+
const tasks: RunReportTask[] = []
|
|
156
|
+
for (const o of final) {
|
|
157
|
+
if (isGroupTask(o.node)) continue
|
|
158
|
+
const kept = keeper?.keptOf(o.node.id)
|
|
159
|
+
tasks.push({
|
|
160
|
+
id: o.node.id,
|
|
161
|
+
status: o.status,
|
|
162
|
+
durationMs: Math.round(o.durationMs),
|
|
163
|
+
...(o.hash !== undefined ? { key: o.hash } : {}),
|
|
164
|
+
...(kept?.log !== undefined ? { log: kept.log } : {}),
|
|
165
|
+
...(kept?.outputs !== undefined ? { outputs: kept.outputs } : {}),
|
|
166
|
+
})
|
|
89
167
|
}
|
|
90
|
-
return { runId, commit:
|
|
168
|
+
return { runId: run.runId, commit: run.commit, ciRun: run.ciRun, ok: run.ok, tasks }
|
|
91
169
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The steps a run did not wait on (`vx:prefetch`), for `vx last` (M,
|
|
2
|
+
// 2026-10-16): they never show in a run's output. One file per run that
|
|
3
|
+
// had one, `<cacheDir>/steps/<runId>.json`, the newest 50 kept, as
|
|
4
|
+
// run-failures.ts keeps failed output: files, not cache.db, so the index
|
|
5
|
+
// layout stays as it was. A run with none writes nothing.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { isFsRefusal } from '../util/index.js'
|
|
10
|
+
|
|
11
|
+
/** Runs whose background steps are kept; run ids are UUIDv7, so names sort by age. */
|
|
12
|
+
const KEPT = 50
|
|
13
|
+
|
|
14
|
+
/** One of vx's own steps that ran beside the tasks. */
|
|
15
|
+
export interface RunStep {
|
|
16
|
+
id: string
|
|
17
|
+
durationMs: number
|
|
18
|
+
ok: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const dirOf = (cacheDir: string): string => path.join(cacheDir, 'steps')
|
|
22
|
+
|
|
23
|
+
/** Keep `steps` for `runId`; a refused write costs only them. */
|
|
24
|
+
export function writeRunSteps(
|
|
25
|
+
cacheDir: string,
|
|
26
|
+
runId: string,
|
|
27
|
+
steps: readonly RunStep[],
|
|
28
|
+
warn: (message: string) => void,
|
|
29
|
+
): void {
|
|
30
|
+
if (steps.length === 0) return
|
|
31
|
+
try {
|
|
32
|
+
const dir = dirOf(cacheDir)
|
|
33
|
+
mkdirSync(dir, { recursive: true })
|
|
34
|
+
const file = path.join(dir, `${runId}.json`)
|
|
35
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
36
|
+
writeFileSync(tmp, JSON.stringify(steps))
|
|
37
|
+
renameSync(tmp, file)
|
|
38
|
+
const names = readdirSync(dir)
|
|
39
|
+
.filter((n) => n.endsWith('.json'))
|
|
40
|
+
.sort()
|
|
41
|
+
for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (!isFsRefusal(err)) throw err
|
|
44
|
+
warn(`[vx] background steps not kept: ${err.message} — the verdict above stands`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The background steps `runId` kept; none when it had none or they are gone. */
|
|
49
|
+
export function runSteps(cacheDir: string, runId: string): RunStep[] {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(
|
|
52
|
+
readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8'),
|
|
53
|
+
) as RunStep[]
|
|
54
|
+
} catch {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -76,9 +76,10 @@ import {
|
|
|
76
76
|
warnOf,
|
|
77
77
|
} from '../report/index.js'
|
|
78
78
|
import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
|
|
79
|
-
import {
|
|
79
|
+
import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
|
|
80
80
|
import { buildAdmission, executorDemand, resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
81
81
|
import { createSteps } from './steps.js'
|
|
82
|
+
import { writeRunSteps } from './run-steps.js'
|
|
82
83
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
83
84
|
import { claimOutput, guardedOutput } from './output-host.js'
|
|
84
85
|
import { assembleRunSummary } from './telemetry.js'
|
|
@@ -358,7 +359,8 @@ async function runOnBus(
|
|
|
358
359
|
takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
|
|
359
360
|
): Promise<RunSummary> {
|
|
360
361
|
const log = busLogger(bus)
|
|
361
|
-
const
|
|
362
|
+
const steps = createSteps(log, showsSteps)
|
|
363
|
+
const { step, stepSync } = steps
|
|
362
364
|
// A server's output goes through a cell of its own: a later watch cycle
|
|
363
365
|
// that keeps the server points it at that cycle's bus (`RunOptions.keep`).
|
|
364
366
|
const serverOuts = new Map<string, { bus: EventBus }>()
|
|
@@ -450,7 +452,25 @@ async function runOnBus(
|
|
|
450
452
|
const runId = ulid()
|
|
451
453
|
const head = captureGitContext(prepared.workspaceRoot)
|
|
452
454
|
const ciContext = detectCi(process.env)
|
|
453
|
-
const
|
|
455
|
+
const keepRun =
|
|
456
|
+
prepared.keepRunEntry === undefined
|
|
457
|
+
? undefined
|
|
458
|
+
: {
|
|
459
|
+
failures: keepsFailures(
|
|
460
|
+
resolveSetting('cache.upload.failures', prepared.workspaceConfig).value,
|
|
461
|
+
ciContext.ci,
|
|
462
|
+
),
|
|
463
|
+
}
|
|
464
|
+
const keeper =
|
|
465
|
+
prepared.keepRunEntry === undefined || keepRun === undefined
|
|
466
|
+
? undefined
|
|
467
|
+
: new RunKeeper({
|
|
468
|
+
runId,
|
|
469
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
470
|
+
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
471
|
+
keep: prepared.keepRunEntry,
|
|
472
|
+
failures: keepRun.failures,
|
|
473
|
+
})
|
|
454
474
|
try {
|
|
455
475
|
disposePlugins = await installPlugins({
|
|
456
476
|
plugins: prepared.plugins,
|
|
@@ -579,6 +599,7 @@ async function runOnBus(
|
|
|
579
599
|
onStop: () => {
|
|
580
600
|
stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
|
|
581
601
|
cache.stopUploads?.()
|
|
602
|
+
keeper?.stop()
|
|
582
603
|
},
|
|
583
604
|
})
|
|
584
605
|
const { liveChildren, persistentRegistry } = stop
|
|
@@ -823,7 +844,7 @@ async function runOnBus(
|
|
|
823
844
|
// unchanged (no upfront key pass, no local probing).
|
|
824
845
|
let prefetchDone: Promise<void> = Promise.resolve()
|
|
825
846
|
if (prepared.hasRemoteLayer) {
|
|
826
|
-
prefetchDone =
|
|
847
|
+
prefetchDone = steps.background('vx:prefetch', () =>
|
|
827
848
|
startRemotePrefetch({
|
|
828
849
|
nodes,
|
|
829
850
|
cache,
|
|
@@ -938,7 +959,7 @@ async function runOnBus(
|
|
|
938
959
|
if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
|
|
939
960
|
if (telemetry?.track !== undefined) scope.track = telemetry.track
|
|
940
961
|
if (explainMiss !== undefined) scope.explainMiss = explainMiss
|
|
941
|
-
if (
|
|
962
|
+
if (keepRun !== undefined) scope.keepRun = keepRun
|
|
942
963
|
if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
|
|
943
964
|
const argsOf = executeArgsOf(scope, {
|
|
944
965
|
log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
|
|
@@ -986,11 +1007,22 @@ async function runOnBus(
|
|
|
986
1007
|
mark('classify + probe')
|
|
987
1008
|
// A run whose every task is a confirmed local hit dispatches only
|
|
988
1009
|
// restores, which no weight orders: the `schedule` stage is not asked.
|
|
1010
|
+
// A plugin's later weights (`ctx.refine`) re-rank the ready queues
|
|
1011
|
+
// while the graph runs; any that land before it starts, at its start.
|
|
1012
|
+
let reprioritize: ((weights: ReadonlyMap<string, number>) => void) | undefined
|
|
1013
|
+
let refinedEarly: ReadonlyMap<string, number> | undefined
|
|
1014
|
+
let graphOver = false
|
|
989
1015
|
const priorities = [...nodes.values()].every(
|
|
990
1016
|
(n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
|
|
991
1017
|
)
|
|
992
1018
|
? new Map<string, number>()
|
|
993
|
-
: await
|
|
1019
|
+
: await prepared.priorities({
|
|
1020
|
+
step,
|
|
1021
|
+
refined: (merged) => {
|
|
1022
|
+
if (reprioritize !== undefined) reprioritize(merged)
|
|
1023
|
+
else if (!graphOver) refinedEarly = merged
|
|
1024
|
+
},
|
|
1025
|
+
})
|
|
994
1026
|
mark('schedule')
|
|
995
1027
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
996
1028
|
// rows land, so the row that prints next carries the verdict: nothing
|
|
@@ -1047,6 +1079,7 @@ async function runOnBus(
|
|
|
1047
1079
|
if (cacheOff) o.cacheOff = true
|
|
1048
1080
|
taint.settled(o)
|
|
1049
1081
|
judgeFlaky(o)
|
|
1082
|
+
keeper?.taskDone(o)
|
|
1050
1083
|
log.taskComplete(o.node, o)
|
|
1051
1084
|
narrowDemand(o.node.id)
|
|
1052
1085
|
},
|
|
@@ -1070,7 +1103,7 @@ async function runOnBus(
|
|
|
1070
1103
|
...(options.continueMode !== 'always' && taintSeeds.size === 0
|
|
1071
1104
|
? {
|
|
1072
1105
|
settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
|
|
1073
|
-
isGroupTask(node) && node.config.
|
|
1106
|
+
isGroupTask(node) && node.config.inputs === undefined
|
|
1074
1107
|
? unkeyedGroupOutcome(
|
|
1075
1108
|
node,
|
|
1076
1109
|
keyUpstream(node, upstream),
|
|
@@ -1083,6 +1116,10 @@ async function runOnBus(
|
|
|
1083
1116
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1084
1117
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1085
1118
|
...(priorities.size > 0 ? { priorities } : {}),
|
|
1119
|
+
reprioritize: (apply) => {
|
|
1120
|
+
if (refinedEarly !== undefined) apply(refinedEarly)
|
|
1121
|
+
reprioritize = apply
|
|
1122
|
+
},
|
|
1086
1123
|
// The baseline under them; `most-work` when the workspace names none.
|
|
1087
1124
|
...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
|
|
1088
1125
|
// Local short-circuit: confirmed stable local hits the scheduler
|
|
@@ -1091,6 +1128,8 @@ async function runOnBus(
|
|
|
1091
1128
|
restoreTier: shortCircuit.restoreTier,
|
|
1092
1129
|
})
|
|
1093
1130
|
stop.graphDone()
|
|
1131
|
+
reprioritize = undefined
|
|
1132
|
+
graphOver = true
|
|
1094
1133
|
|
|
1095
1134
|
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
1096
1135
|
// of the rest, before the summary prints. Scoped to the real CLI
|
|
@@ -1250,22 +1289,13 @@ async function runOnBus(
|
|
|
1250
1289
|
// nothing the suite could see, because this gate is the rule.
|
|
1251
1290
|
await step('vx:uploads', async () => {
|
|
1252
1291
|
await prefetchDone
|
|
1253
|
-
await cache.drainUploads?.()
|
|
1292
|
+
await Promise.all([cache.drainUploads?.(), keeper?.settled()])
|
|
1254
1293
|
})
|
|
1294
|
+
writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
|
|
1255
1295
|
// An upload that failed after the rows went in: its row says so too.
|
|
1256
1296
|
if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
|
|
1257
1297
|
recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
|
|
1258
|
-
const report =
|
|
1259
|
-
keepRunBytes(list, {
|
|
1260
|
-
runId,
|
|
1261
|
-
commit: head.commitSha,
|
|
1262
|
-
ciRun: ciContext,
|
|
1263
|
-
ok,
|
|
1264
|
-
workspaceRoot: prepared.workspaceRoot,
|
|
1265
|
-
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
1266
|
-
keep: prepared.keepRunEntry,
|
|
1267
|
-
}),
|
|
1268
|
-
)
|
|
1298
|
+
const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
|
|
1269
1299
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1270
1300
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
1271
1301
|
// whole block is skipped and the run is byte-identical to before.
|
|
@@ -1285,7 +1315,7 @@ async function runOnBus(
|
|
|
1285
1315
|
})
|
|
1286
1316
|
telemetry.emitSummary(summary)
|
|
1287
1317
|
const sinks = telemetry
|
|
1288
|
-
await
|
|
1318
|
+
await sinks.flush(step)
|
|
1289
1319
|
}
|
|
1290
1320
|
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
1291
1321
|
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
@@ -1477,7 +1507,7 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
|
|
|
1477
1507
|
* task. Used by `--dry`.
|
|
1478
1508
|
*
|
|
1479
1509
|
* Side-effects are limited to opening + closing the local Cache handle
|
|
1480
|
-
* (and running `
|
|
1510
|
+
* (and running `inputs.runtime` probe commands, which key
|
|
1481
1511
|
* derivation requires). Cache probing is the byte-free `cache.has()`
|
|
1482
1512
|
* existence check — no artifact download, no ingest, no accessed_at bump.
|
|
1483
1513
|
*/
|
|
@@ -85,7 +85,7 @@ export function sandboxFix(
|
|
|
85
85
|
projectDir: string,
|
|
86
86
|
mask: (text: string) => string = (t) => t,
|
|
87
87
|
/**
|
|
88
|
-
* The task's `
|
|
88
|
+
* The task's `inputs.files`, undefined when it does not cache: a
|
|
89
89
|
* write under one is granted exactly, and a read of the project no glob
|
|
90
90
|
* matches is named for the key too.
|
|
91
91
|
*/
|
|
@@ -163,7 +163,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
|
163
163
|
* cached path (through the executor) and the persistent path (spawned here).
|
|
164
164
|
*
|
|
165
165
|
* The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
|
|
166
|
-
* are two different questions: `
|
|
166
|
+
* are two different questions: `inputs` says what INVALIDATES the
|
|
167
167
|
* task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
|
|
168
168
|
* coupled them in both directions — a declaration added for caching
|
|
169
169
|
* silently widened the sandbox, and a path the task needed had to be
|
|
@@ -476,7 +476,7 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
|
|
|
476
476
|
`${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
|
|
477
477
|
`reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
|
|
478
478
|
`\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
|
|
479
|
-
`\`
|
|
479
|
+
`\`inputs.workspaceFiles\`).`
|
|
480
480
|
)
|
|
481
481
|
}
|
|
482
482
|
|
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
// out so the two callers can never drift on the stability gate.
|
|
6
6
|
//
|
|
7
7
|
// A task's key is "stable" only if its inputs can't be altered by an
|
|
8
|
-
// upstream's outputs (a `
|
|
8
|
+
// upstream's outputs (a `inputs.files` glob that could match a
|
|
9
9
|
// sibling's declared output has a PRELIMINARY key until that upstream
|
|
10
10
|
// runs — probing it would target the wrong artifact). Unstable tasks
|
|
11
11
|
// fall back to lazy read-through in execute-task, which is always
|
|
12
12
|
// correct. The check is conservative: when unsure, treat as unstable.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
foldedDeps,
|
|
16
|
+
isGroupTask,
|
|
17
|
+
outputTakenBack,
|
|
18
|
+
type TaskNode,
|
|
19
|
+
type TaskOutcome,
|
|
20
|
+
} from '../graph/index.js'
|
|
15
21
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
22
|
import {
|
|
17
23
|
checkIgnore,
|
|
@@ -23,7 +29,6 @@ import {
|
|
|
23
29
|
wholeIgnored,
|
|
24
30
|
} from '../util/index.js'
|
|
25
31
|
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
26
|
-
import { foldedDeps } from './keyed-projects.js'
|
|
27
32
|
import {
|
|
28
33
|
computeGroupHash,
|
|
29
34
|
computeTaskHash,
|
|
@@ -129,12 +134,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
129
134
|
const unfoldedById = new Map<string, ProjectSet>()
|
|
130
135
|
const wsRewriters = new Set<string>()
|
|
131
136
|
// A cached rewriter matters only to a key that leaves a dependency's key
|
|
132
|
-
// out: a `
|
|
137
|
+
// out: a `inputs.tasks` filter. With none in the graph every edge
|
|
133
138
|
// folds and the sets above stay empty, so they are not built (about 2 ms,
|
|
134
139
|
// median, of a 27 ms memoised walk over the 3,000-task bench; item 750).
|
|
135
140
|
let anyUnfolded = false
|
|
136
141
|
for (const n of args.nodes.values()) {
|
|
137
|
-
if (n.config.
|
|
142
|
+
if (n.config.inputs?.tasks !== undefined) {
|
|
138
143
|
anyUnfolded = true
|
|
139
144
|
break
|
|
140
145
|
}
|
|
@@ -241,7 +246,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
241
246
|
// cascade (and forward their producers, above). They inherit
|
|
242
247
|
// instability from any unstable member.
|
|
243
248
|
// `computeGroupKey`, with the unkeyed branch kept synchronous.
|
|
244
|
-
if (node.config.
|
|
249
|
+
if (node.config.inputs === undefined) {
|
|
245
250
|
const key = computeGroupHash(upstream)
|
|
246
251
|
keyById.set(id, key)
|
|
247
252
|
args.groupKeys?.set(id, { key, upstream })
|
|
@@ -293,9 +298,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
293
298
|
|
|
294
299
|
const cacheEnabled = node.config.cache !== undefined
|
|
295
300
|
if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
|
|
296
|
-
// An uncached key
|
|
297
|
-
//
|
|
298
|
-
// write into this project, or anywhere, leaves the key preliminary.
|
|
301
|
+
// An uncached key stays preliminary behind any upstream that may write
|
|
302
|
+
// into this project, or anywhere: none of its writes is declared.
|
|
299
303
|
else if (
|
|
300
304
|
!cacheEnabled &&
|
|
301
305
|
!unstable &&
|
|
@@ -332,7 +336,7 @@ export function gatedOutputs(
|
|
|
332
336
|
}
|
|
333
337
|
if (outputsOf.size === 0) return out
|
|
334
338
|
for (const n of nodes.values()) {
|
|
335
|
-
const inputs = n.config.
|
|
339
|
+
const inputs = n.config.inputs?.files
|
|
336
340
|
const outputs = outputsOf.get(n.projectName)
|
|
337
341
|
if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
|
|
338
342
|
let back: string[] | undefined
|
|
@@ -396,7 +400,7 @@ function readableOutputs(
|
|
|
396
400
|
const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
|
|
397
401
|
|
|
398
402
|
/**
|
|
399
|
-
* The cached tasks whose `
|
|
403
|
+
* The cached tasks whose `inputs.runtime` / `workspaceRuntime` probes
|
|
400
404
|
* may read what an upstream writes this run while the key folds nothing of
|
|
401
405
|
* that upstream (X-34). A probe is a shell command and may read anywhere,
|
|
402
406
|
* so no glob bounds it, and its answer taken before the writer ran is the
|
|
@@ -424,8 +428,8 @@ export function probesAfterWrites(
|
|
|
424
428
|
const late = new Set<string>()
|
|
425
429
|
probesAfterWritesOf.set(nodes, late)
|
|
426
430
|
const probes = (n: TaskNode): boolean =>
|
|
427
|
-
(n.config.
|
|
428
|
-
(n.config.
|
|
431
|
+
(n.config.inputs?.runtime?.length ?? 0) > 0 ||
|
|
432
|
+
(n.config.inputs?.workspaceRuntime?.length ?? 0) > 0
|
|
429
433
|
let anyProbe = false
|
|
430
434
|
for (const n of nodes.values()) {
|
|
431
435
|
if (probes(n)) {
|
|
@@ -455,7 +459,7 @@ export function probesAfterWrites(
|
|
|
455
459
|
let writer = false
|
|
456
460
|
let unfolded = false
|
|
457
461
|
// No filter and no order-only edge: the key folds every dependency.
|
|
458
|
-
const foldsAll = node.orderOnly === undefined && node.config.
|
|
462
|
+
const foldsAll = node.orderOnly === undefined && node.config.inputs?.tasks === undefined
|
|
459
463
|
let folds: Set<string> | undefined
|
|
460
464
|
for (const dep of node.deps) {
|
|
461
465
|
if (!nodes.has(dep)) continue
|
|
@@ -483,7 +487,7 @@ function foldedBy(
|
|
|
483
487
|
upstream: TaskOutcome[],
|
|
484
488
|
keyById: Map<string, string>,
|
|
485
489
|
): (dep: string) => boolean {
|
|
486
|
-
const filter = node.config.
|
|
490
|
+
const filter = node.config.inputs?.tasks
|
|
487
491
|
if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
|
|
488
492
|
const hashes = new Set(
|
|
489
493
|
filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
|
|
@@ -525,7 +529,7 @@ function synthUpstream(
|
|
|
525
529
|
* `outputs.files` — direct or transitive — makes the key
|
|
526
530
|
* preliminary. Project boundaries are hard, so only SAME-project
|
|
527
531
|
* outputs can reach a project-relative input.
|
|
528
|
-
* - `
|
|
532
|
+
* - `inputs.workspaceFiles` is boundary-free: it can read ANY
|
|
529
533
|
* project's dir (so any upstream `outputs.files`, in any project,
|
|
530
534
|
* matters) or a root-anchored location (so any upstream
|
|
531
535
|
* `outputs.workspaceFiles` matters). Either → preliminary key. This
|
|
@@ -551,9 +555,9 @@ export function dependsOnSiblingOutputs(
|
|
|
551
555
|
},
|
|
552
556
|
): boolean {
|
|
553
557
|
const cache = node.config.cache
|
|
554
|
-
|
|
555
|
-
//
|
|
556
|
-
if (
|
|
558
|
+
const declared = node.config.inputs
|
|
559
|
+
// A task with no inputs has no key to take up front.
|
|
560
|
+
if (declared === undefined) return false
|
|
557
561
|
if (upstreamOutputProjects.has(node.projectName)) {
|
|
558
562
|
// A same-project producer whose writes are all declared reaches this
|
|
559
563
|
// task's inputs only where the globs can meet: a `test` reading `src/**`
|
|
@@ -562,15 +566,21 @@ export function dependsOnSiblingOutputs(
|
|
|
562
566
|
// projects (A-20). Undeclared writes, and a rewriter this key does not
|
|
563
567
|
// fold, may land anywhere: those stay project-wide.
|
|
564
568
|
if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
|
|
565
|
-
const inputs =
|
|
569
|
+
const inputs = declared.files
|
|
566
570
|
const outputs = sameProject.outputsOf.get(node.projectName) ?? []
|
|
567
|
-
if (
|
|
571
|
+
if (outputs.length === 0) return true
|
|
568
572
|
// An output the reader's own `!` inputs take back whole is not in its
|
|
569
573
|
// key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
|
|
570
|
-
// before and after it, which is what `
|
|
571
|
-
//
|
|
574
|
+
// before and after it, which is what `vx lock` asks a config to write
|
|
575
|
+
// (X-54).
|
|
572
576
|
// Nor is an output git ignores whole: a key reads only what git lists.
|
|
573
|
-
|
|
577
|
+
// Unless an entry starts inside it (`dist/**`): naming an ignored path
|
|
578
|
+
// makes it an input (`ignoredReach` in cache/inputs.ts).
|
|
579
|
+
const readable = withNamedIgnored(
|
|
580
|
+
inputs,
|
|
581
|
+
outputs,
|
|
582
|
+
sameProject.readableOf?.get(node.projectName) ?? outputs,
|
|
583
|
+
)
|
|
574
584
|
const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
575
585
|
const reach = back.length === 0 ? readable : readable.filter((o) => !outputTakenBack(o, back))
|
|
576
586
|
if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
|
|
@@ -578,7 +588,7 @@ export function dependsOnSiblingOutputs(
|
|
|
578
588
|
// that producer, its restore raced the producer's clean and extract
|
|
579
589
|
// (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
|
|
580
590
|
// `outputsOf` holds this task's own globs too; set them aside once.
|
|
581
|
-
const own = cache
|
|
591
|
+
const own = cache?.outputs.files ?? []
|
|
582
592
|
if (own.length > 0) {
|
|
583
593
|
const others = [...outputs]
|
|
584
594
|
for (const g of own) {
|
|
@@ -597,7 +607,7 @@ export function dependsOnSiblingOutputs(
|
|
|
597
607
|
// `app` read `gen/**` project-relative), so the mere presence of a
|
|
598
608
|
// workspace-output producer upstream makes the key preliminary.
|
|
599
609
|
if (hasWsOutputUpstream) return true
|
|
600
|
-
const wsInputs =
|
|
610
|
+
const wsInputs = declared.workspaceFiles ?? []
|
|
601
611
|
if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
|
|
602
612
|
// Without the dirs the old answer stands: a workspace reader over any
|
|
603
613
|
// producer is preliminary. With them, only a reader whose globs can
|
|
@@ -617,6 +627,29 @@ export function dependsOnSiblingOutputs(
|
|
|
617
627
|
return false
|
|
618
628
|
}
|
|
619
629
|
|
|
630
|
+
/**
|
|
631
|
+
* `readable`, plus each output git ignores whole that a positive entry of
|
|
632
|
+
* `inputs` starts inside: its literal prefix at or below the output's.
|
|
633
|
+
*/
|
|
634
|
+
function withNamedIgnored(
|
|
635
|
+
inputs: readonly string[],
|
|
636
|
+
outputs: readonly string[],
|
|
637
|
+
readable: readonly string[],
|
|
638
|
+
): readonly string[] {
|
|
639
|
+
if (readable.length === outputs.length) return readable
|
|
640
|
+
const named = outputs.filter((o) => {
|
|
641
|
+
if (readable.includes(o)) return false
|
|
642
|
+
const at = literalPrefix(o)
|
|
643
|
+
if (at === '') return false
|
|
644
|
+
return inputs.some((g) => {
|
|
645
|
+
if (g.startsWith('!')) return false
|
|
646
|
+
const p = literalPrefix(g)
|
|
647
|
+
return p === at || p.startsWith(`${at}/`)
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
return named.length === 0 ? readable : [...readable, ...named]
|
|
651
|
+
}
|
|
652
|
+
|
|
620
653
|
/**
|
|
621
654
|
* Can any positive `workspaceFiles` entry match a path inside one of
|
|
622
655
|
* `dirs` (workspace-relative project dirs)? An entry's reach is its
|