@vzn/vx 0.0.639 → 0.0.641
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 +158 -106
- package/docs/cli.md +80 -45
- package/docs/execution.md +11 -10
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +252 -190
- package/docs/security.md +5 -4
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +46 -19
- 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 +6 -0
- package/src/cache/inputs.ts +228 -111
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +15 -8
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +7 -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 +132 -21
- package/src/cli/watch-filter.ts +23 -12
- package/src/cli/watch-fs.ts +2 -1
- 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/handoff.ts +5 -0
- package/src/index.ts +2 -2
- 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/hit-restore.ts +101 -27
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +9 -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 +66 -27
- 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 +145 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +76 -44
- 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/logger.ts +5 -2
- package/src/report/status-line.ts +7 -3
- package/src/report/summary.ts +6 -4
- package/src/settings.ts +11 -17
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +39 -5
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/src/workspace/project-loader.ts +5 -4
package/src/orchestrator/run.ts
CHANGED
|
@@ -8,11 +8,11 @@ import { realpathSync } from 'node:fs'
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import {
|
|
10
10
|
Cache,
|
|
11
|
-
type CacheLayer,
|
|
12
11
|
type CachePolicy,
|
|
13
12
|
cachesNothing,
|
|
14
13
|
formatCachePolicy,
|
|
15
14
|
stopRuntimeProbes,
|
|
15
|
+
sweepStaging,
|
|
16
16
|
} from '../cache/index.js'
|
|
17
17
|
import { VERSION } from '../version.js'
|
|
18
18
|
import {
|
|
@@ -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'
|
|
@@ -161,17 +162,14 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
|
|
|
161
162
|
/**
|
|
162
163
|
* The perf firewall for the local short-circuit. Always-on; the only
|
|
163
164
|
* gates are correctness/no-op gates:
|
|
164
|
-
* - LOCAL-ONLY cache. Behind a remote layer, `cache.get` is a remote
|
|
165
|
-
* READ-THROUGH — the up-front classify is awaited before scheduling,
|
|
166
|
-
* so N remote GETs would land on the critical path before any task
|
|
167
|
-
* starts. Remote runs are owned by `startRemotePrefetch`, which
|
|
168
|
-
* overlaps the GETs with execution instead (fire-and-forget) and
|
|
169
|
-
* ingests hits into local for execute-task's lazy probe. The gate asks
|
|
170
|
-
* the layer (`hasRemote`), not `instanceof LayeredCache` — a
|
|
171
|
-
* third-party remote layer stalls on the classify just as hard.
|
|
172
165
|
* - the policy reads locally (a `--no-cache`/`--force`/`--cache-policy=local:`
|
|
173
166
|
* run reads nothing locally → nothing to restore → skip);
|
|
174
167
|
* - there is at least one task.
|
|
168
|
+
* A remote layer does not close it: the local cache sits in front of every
|
|
169
|
+
* remote (#3619), so the classify probes that local store alone and a local
|
|
170
|
+
* hit runs ahead of its dependencies as it does with no remote; the remote
|
|
171
|
+
* GETs stay on `startRemotePrefetch`'s fire-and-forget path (M's solid
|
|
172
|
+
* run, 2026-10-16: a local hit waited 18 s on a dependency it never read).
|
|
175
173
|
* A flat graph has no ordering to bypass, but the classify pass is worth
|
|
176
174
|
* paying for anyway: it probes every stable key through ONE batched
|
|
177
175
|
* `getMany` instead of a `cache.get` per task inside the run, which
|
|
@@ -179,12 +177,7 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
|
|
|
179
177
|
* (2026-09-03, interleaved arms). The per-task correctness gates (stable
|
|
180
178
|
* key, no workspace-outputs in graph) live in `startLocalShortCircuit`.
|
|
181
179
|
*/
|
|
182
|
-
export function shouldShortCircuit(
|
|
183
|
-
nodes: Map<string, TaskNode>,
|
|
184
|
-
policy: CachePolicy,
|
|
185
|
-
cache: CacheLayer,
|
|
186
|
-
): boolean {
|
|
187
|
-
if (cache.hasRemote === true) return false
|
|
180
|
+
export function shouldShortCircuit(nodes: Map<string, TaskNode>, policy: CachePolicy): boolean {
|
|
188
181
|
if (!policy.localRead) return false
|
|
189
182
|
return nodes.size > 0
|
|
190
183
|
}
|
|
@@ -358,7 +351,8 @@ async function runOnBus(
|
|
|
358
351
|
takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
|
|
359
352
|
): Promise<RunSummary> {
|
|
360
353
|
const log = busLogger(bus)
|
|
361
|
-
const
|
|
354
|
+
const steps = createSteps(log, showsSteps)
|
|
355
|
+
const { step, stepSync } = steps
|
|
362
356
|
// A server's output goes through a cell of its own: a later watch cycle
|
|
363
357
|
// that keeps the server points it at that cycle's bus (`RunOptions.keep`).
|
|
364
358
|
const serverOuts = new Map<string, { bus: EventBus }>()
|
|
@@ -448,9 +442,30 @@ async function runOnBus(
|
|
|
448
442
|
// carries it so analytics queries can group by invocation; plugins get it
|
|
449
443
|
// in their teardown's `RunReport`.
|
|
450
444
|
const runId = ulid()
|
|
445
|
+
// A killed run's restore staging (`.vx/tmp/<pid>-0`), once per run.
|
|
446
|
+
sweepStaging(prepared.workspaceRoot)
|
|
451
447
|
const head = captureGitContext(prepared.workspaceRoot)
|
|
452
448
|
const ciContext = detectCi(process.env)
|
|
453
|
-
const
|
|
449
|
+
const keepRun =
|
|
450
|
+
prepared.keepRunEntry === undefined
|
|
451
|
+
? undefined
|
|
452
|
+
: {
|
|
453
|
+
failures: keepsFailures(
|
|
454
|
+
resolveSetting('cache.upload.failures', prepared.workspaceConfig).value,
|
|
455
|
+
ciContext.ci,
|
|
456
|
+
),
|
|
457
|
+
}
|
|
458
|
+
const keeper =
|
|
459
|
+
prepared.keepRunEntry === undefined || keepRun === undefined
|
|
460
|
+
? undefined
|
|
461
|
+
: new RunKeeper({
|
|
462
|
+
runId,
|
|
463
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
464
|
+
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
465
|
+
keep: prepared.keepRunEntry,
|
|
466
|
+
failures: keepRun.failures,
|
|
467
|
+
traffic: prepared.localCache.traffic,
|
|
468
|
+
})
|
|
454
469
|
try {
|
|
455
470
|
disposePlugins = await installPlugins({
|
|
456
471
|
plugins: prepared.plugins,
|
|
@@ -579,6 +594,7 @@ async function runOnBus(
|
|
|
579
594
|
onStop: () => {
|
|
580
595
|
stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
|
|
581
596
|
cache.stopUploads?.()
|
|
597
|
+
keeper?.stop()
|
|
582
598
|
},
|
|
583
599
|
})
|
|
584
600
|
const { liveChildren, persistentRegistry } = stop
|
|
@@ -823,7 +839,7 @@ async function runOnBus(
|
|
|
823
839
|
// unchanged (no upfront key pass, no local probing).
|
|
824
840
|
let prefetchDone: Promise<void> = Promise.resolve()
|
|
825
841
|
if (prepared.hasRemoteLayer) {
|
|
826
|
-
prefetchDone =
|
|
842
|
+
prefetchDone = steps.background('vx:prefetch', () =>
|
|
827
843
|
startRemotePrefetch({
|
|
828
844
|
nodes,
|
|
829
845
|
cache,
|
|
@@ -852,16 +868,22 @@ async function runOnBus(
|
|
|
852
868
|
// An empty local cache answers every probe with a miss: each task
|
|
853
869
|
// derives its key as it starts instead (~100 ms before a cold 1,090-
|
|
854
870
|
// package run's first spawn), and every sandboxed task runs.
|
|
871
|
+
const remote = cache.hasRemote === true
|
|
855
872
|
const coldLocal =
|
|
856
|
-
cache === prepared.localCache
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
873
|
+
shouldShortCircuit(nodes, policy) && (remote || cache === prepared.localCache)
|
|
874
|
+
? prepared.localCache.holdsNothing()
|
|
875
|
+
: false
|
|
876
|
+
// A remote may still hold what an empty local store does not: the
|
|
877
|
+
// prefetch asks it, and no confirmed miss warms the sandbox early.
|
|
878
|
+
if (coldLocal && !remote) prewarm()
|
|
879
|
+
else if (!coldLocal && shouldShortCircuit(nodes, policy)) {
|
|
861
880
|
shortCircuit = await step('vx:keys', () =>
|
|
862
881
|
startLocalShortCircuit({
|
|
863
882
|
nodes,
|
|
864
|
-
|
|
883
|
+
// Behind a remote, the local store alone: its hits are certain
|
|
884
|
+
// now, and a local miss may still be a remote hit.
|
|
885
|
+
cache: remote ? prepared.localCache : cache,
|
|
886
|
+
localOnly: remote,
|
|
865
887
|
workspaceRoot,
|
|
866
888
|
claimedFiles: fingerprintWatch.names,
|
|
867
889
|
forwardArgs: options.forwardArgs,
|
|
@@ -872,7 +894,8 @@ async function runOnBus(
|
|
|
872
894
|
}),
|
|
873
895
|
)
|
|
874
896
|
// A confirmed miss runs too.
|
|
875
|
-
if (sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null))
|
|
897
|
+
if (!remote && sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null))
|
|
898
|
+
prewarm()
|
|
876
899
|
}
|
|
877
900
|
|
|
878
901
|
// Whether this task runs behind a failure (`continueMode: 'always'`
|
|
@@ -938,7 +961,7 @@ async function runOnBus(
|
|
|
938
961
|
if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
|
|
939
962
|
if (telemetry?.track !== undefined) scope.track = telemetry.track
|
|
940
963
|
if (explainMiss !== undefined) scope.explainMiss = explainMiss
|
|
941
|
-
if (
|
|
964
|
+
if (keepRun !== undefined) scope.keepRun = keepRun
|
|
942
965
|
if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
|
|
943
966
|
const argsOf = executeArgsOf(scope, {
|
|
944
967
|
log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
|
|
@@ -986,11 +1009,22 @@ async function runOnBus(
|
|
|
986
1009
|
mark('classify + probe')
|
|
987
1010
|
// A run whose every task is a confirmed local hit dispatches only
|
|
988
1011
|
// restores, which no weight orders: the `schedule` stage is not asked.
|
|
1012
|
+
// A plugin's later weights (`ctx.refine`) re-rank the ready queues
|
|
1013
|
+
// while the graph runs; any that land before it starts, at its start.
|
|
1014
|
+
let reprioritize: ((weights: ReadonlyMap<string, number>) => void) | undefined
|
|
1015
|
+
let refinedEarly: ReadonlyMap<string, number> | undefined
|
|
1016
|
+
let graphOver = false
|
|
989
1017
|
const priorities = [...nodes.values()].every(
|
|
990
1018
|
(n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
|
|
991
1019
|
)
|
|
992
1020
|
? new Map<string, number>()
|
|
993
|
-
: await
|
|
1021
|
+
: await prepared.priorities({
|
|
1022
|
+
step,
|
|
1023
|
+
refined: (merged) => {
|
|
1024
|
+
if (reprioritize !== undefined) reprioritize(merged)
|
|
1025
|
+
else if (!graphOver) refinedEarly = merged
|
|
1026
|
+
},
|
|
1027
|
+
})
|
|
994
1028
|
mark('schedule')
|
|
995
1029
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
996
1030
|
// rows land, so the row that prints next carries the verdict: nothing
|
|
@@ -1047,6 +1081,7 @@ async function runOnBus(
|
|
|
1047
1081
|
if (cacheOff) o.cacheOff = true
|
|
1048
1082
|
taint.settled(o)
|
|
1049
1083
|
judgeFlaky(o)
|
|
1084
|
+
keeper?.taskDone(o)
|
|
1050
1085
|
log.taskComplete(o.node, o)
|
|
1051
1086
|
narrowDemand(o.node.id)
|
|
1052
1087
|
},
|
|
@@ -1070,7 +1105,7 @@ async function runOnBus(
|
|
|
1070
1105
|
...(options.continueMode !== 'always' && taintSeeds.size === 0
|
|
1071
1106
|
? {
|
|
1072
1107
|
settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
|
|
1073
|
-
isGroupTask(node) && node.config.
|
|
1108
|
+
isGroupTask(node) && node.config.inputs === undefined
|
|
1074
1109
|
? unkeyedGroupOutcome(
|
|
1075
1110
|
node,
|
|
1076
1111
|
keyUpstream(node, upstream),
|
|
@@ -1083,6 +1118,10 @@ async function runOnBus(
|
|
|
1083
1118
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1084
1119
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1085
1120
|
...(priorities.size > 0 ? { priorities } : {}),
|
|
1121
|
+
reprioritize: (apply) => {
|
|
1122
|
+
if (refinedEarly !== undefined) apply(refinedEarly)
|
|
1123
|
+
reprioritize = apply
|
|
1124
|
+
},
|
|
1086
1125
|
// The baseline under them; `most-work` when the workspace names none.
|
|
1087
1126
|
...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
|
|
1088
1127
|
// Local short-circuit: confirmed stable local hits the scheduler
|
|
@@ -1091,6 +1130,8 @@ async function runOnBus(
|
|
|
1091
1130
|
restoreTier: shortCircuit.restoreTier,
|
|
1092
1131
|
})
|
|
1093
1132
|
stop.graphDone()
|
|
1133
|
+
reprioritize = undefined
|
|
1134
|
+
graphOver = true
|
|
1094
1135
|
|
|
1095
1136
|
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
1096
1137
|
// of the rest, before the summary prints. Scoped to the real CLI
|
|
@@ -1250,22 +1291,13 @@ async function runOnBus(
|
|
|
1250
1291
|
// nothing the suite could see, because this gate is the rule.
|
|
1251
1292
|
await step('vx:uploads', async () => {
|
|
1252
1293
|
await prefetchDone
|
|
1253
|
-
await cache.drainUploads?.()
|
|
1294
|
+
await Promise.all([cache.drainUploads?.(), keeper?.settled()])
|
|
1254
1295
|
})
|
|
1296
|
+
writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
|
|
1255
1297
|
// An upload that failed after the rows went in: its row says so too.
|
|
1256
1298
|
if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
|
|
1257
1299
|
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
|
-
)
|
|
1300
|
+
const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
|
|
1269
1301
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1270
1302
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
1271
1303
|
// whole block is skipped and the run is byte-identical to before.
|
|
@@ -1285,7 +1317,7 @@ async function runOnBus(
|
|
|
1285
1317
|
})
|
|
1286
1318
|
telemetry.emitSummary(summary)
|
|
1287
1319
|
const sinks = telemetry
|
|
1288
|
-
await
|
|
1320
|
+
await sinks.flush(step)
|
|
1289
1321
|
}
|
|
1290
1322
|
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
1291
1323
|
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
@@ -1477,7 +1509,7 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
|
|
|
1477
1509
|
* task. Used by `--dry`.
|
|
1478
1510
|
*
|
|
1479
1511
|
* Side-effects are limited to opening + closing the local Cache handle
|
|
1480
|
-
* (and running `
|
|
1512
|
+
* (and running `inputs.runtime` probe commands, which key
|
|
1481
1513
|
* derivation requires). Cache probing is the byte-free `cache.has()`
|
|
1482
1514
|
* existence check — no artifact download, no ingest, no accessed_at bump.
|
|
1483
1515
|
*/
|
|
@@ -1575,7 +1607,7 @@ async function planRestorable(
|
|
|
1575
1607
|
const serves = [...nodes.values()].some(
|
|
1576
1608
|
(n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
|
|
1577
1609
|
)
|
|
1578
|
-
if (!serves || !shouldShortCircuit(nodes, policy
|
|
1610
|
+
if (!serves || !shouldShortCircuit(nodes, policy)) return {}
|
|
1579
1611
|
const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
|
|
1580
1612
|
const stable = await deriveStableKeys({
|
|
1581
1613
|
nodes,
|
|
@@ -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
|
|
@@ -3,25 +3,45 @@
|
|
|
3
3
|
// `:teardown`. `:` is no task id's separator, so a step never collides with
|
|
4
4
|
// a project named `vx` or a plugin package's own `#setup` task. A step is
|
|
5
5
|
// not a task: no stat, history row or telemetry record counts it.
|
|
6
|
+
//
|
|
7
|
+
// A run shows only the steps it waits on, before its first task or its end
|
|
8
|
+
// (M, 2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
|
|
9
|
+
// `background` step: never shown, and kept for `vx last` (run-steps.ts).
|
|
6
10
|
import type { Logger } from '../report/index.js'
|
|
11
|
+
import type { RunStep } from './run-steps.js'
|
|
7
12
|
|
|
8
13
|
export interface Steps {
|
|
9
14
|
/** Time `work` as step `id` when steps show; else run it as it is. */
|
|
10
15
|
step<T>(id: string, work: () => Promise<T>): Promise<T>
|
|
11
16
|
stepSync<T>(id: string, work: () => T): T
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
stepSync: (_id, work) => work(),
|
|
17
|
+
/** Time `work`, which the run does not wait on, as step `id` into `background`. */
|
|
18
|
+
background<T>(id: string, work: () => Promise<T>): Promise<T>
|
|
19
|
+
/** The background steps that have ended. */
|
|
20
|
+
readonly ended: readonly RunStep[]
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/** Steps that tell `log`, or none when nothing renders them (zero cost). */
|
|
20
24
|
export function createSteps(log: Logger, shown: boolean): Steps {
|
|
21
|
-
|
|
25
|
+
const ended: RunStep[] = []
|
|
26
|
+
// Timed whether or not anything shows: only a run with a remote starts one.
|
|
27
|
+
const background = async <T>(id: string, work: () => Promise<T>): Promise<T> => {
|
|
28
|
+
const t0 = performance.now()
|
|
29
|
+
let ok = false
|
|
30
|
+
try {
|
|
31
|
+
const value = await work()
|
|
32
|
+
ok = true
|
|
33
|
+
return value
|
|
34
|
+
} finally {
|
|
35
|
+
ended.push({ id, durationMs: Math.round(performance.now() - t0), ok })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!shown)
|
|
39
|
+
return { step: (_id, work) => work(), stepSync: (_id, work) => work(), background, ended }
|
|
22
40
|
const end = (id: string, t0: number, ok: boolean): void =>
|
|
23
41
|
log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
|
|
24
42
|
return {
|
|
43
|
+
background,
|
|
44
|
+
ended,
|
|
25
45
|
async step(id, work) {
|
|
26
46
|
const t0 = performance.now()
|
|
27
47
|
log.stepStart?.(id)
|