@vzn/vx 0.0.640 → 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/docs/caching.md +47 -23
- package/docs/cli.md +10 -5
- package/docs/execution.md +7 -6
- package/docs/features.md +1 -1
- package/docs/schema.md +19 -12
- package/docs/security.md +4 -3
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +42 -18
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +81 -35
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/help.ts +7 -1
- package/src/cli/upgrade.ts +37 -2
- package/src/cli/watch-filter.ts +1 -6
- package/src/cli/watch-fs.ts +2 -1
- package/src/handoff.ts +5 -0
- package/src/index.ts +1 -1
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/local-shortcircuit.ts +8 -0
- package/src/orchestrator/plugin-host.ts +4 -5
- package/src/orchestrator/run-report.ts +30 -9
- package/src/orchestrator/run.ts +25 -23
- package/src/report/logger.ts +5 -2
- package/src/report/summary.ts +6 -4
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +38 -4
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/project-loader.ts +5 -4
|
@@ -1,11 +1,14 @@
|
|
|
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
3
|
// log and the files its outputs left (`cache.upload.failures`), and the
|
|
4
|
-
// log of a task that saved no entry
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// log of a task that saved no entry, on every run (M, 2026-10-16: "Always
|
|
5
|
+
// keep"). Each task's bytes leave when it ends, off its path, like its
|
|
6
|
+
// entry's upload (owner 2026-10-16); the run only waits for them where it
|
|
7
|
+
// waits for every upload, and the footer counts them as the task's remote
|
|
8
|
+
// traffic.
|
|
7
9
|
|
|
8
10
|
import {
|
|
11
|
+
type RemoteTraffic,
|
|
9
12
|
packRunOutputs,
|
|
10
13
|
resolveOutputs,
|
|
11
14
|
resolveWorkspaceOutputs,
|
|
@@ -27,6 +30,8 @@ export interface RunKeeperArgs {
|
|
|
27
30
|
readonly keep: KeepRunEntry
|
|
28
31
|
/** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
|
|
29
32
|
readonly failures: boolean
|
|
33
|
+
/** The footer's remote traffic: each kept task counts under its id. */
|
|
34
|
+
readonly traffic: RemoteTraffic
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/** Whether a run on CI or off it keeps failures, by `cache.upload.failures` (default `'ci'`). */
|
|
@@ -81,8 +86,10 @@ export class RunKeeper {
|
|
|
81
86
|
if (text.length > 0) {
|
|
82
87
|
const name = runEntryName(runId, id, 'log')
|
|
83
88
|
const bytes = new TextEncoder().encode(text)
|
|
84
|
-
this.enqueue(async () => {
|
|
85
|
-
if (await this.args.keep(name, bytes))
|
|
89
|
+
this.enqueue(id, async () => {
|
|
90
|
+
if (!(await this.args.keep(name, bytes))) return false
|
|
91
|
+
slot().log = name
|
|
92
|
+
return true
|
|
86
93
|
})
|
|
87
94
|
}
|
|
88
95
|
}
|
|
@@ -94,9 +101,11 @@ export class RunKeeper {
|
|
|
94
101
|
this.args.nestedDirsByProject.get(o.node.projectName) ?? [],
|
|
95
102
|
).then((paths) => packRunOutputs(workspaceRoot, paths))
|
|
96
103
|
const name = runEntryName(runId, id, 'outputs')
|
|
97
|
-
this.enqueue(async () => {
|
|
104
|
+
this.enqueue(id, async () => {
|
|
98
105
|
const bytes = await packed
|
|
99
|
-
if (bytes
|
|
106
|
+
if (bytes === null || !(await this.args.keep(name, bytes))) return false
|
|
107
|
+
slot().outputs = name
|
|
108
|
+
return true
|
|
100
109
|
})
|
|
101
110
|
}
|
|
102
111
|
}
|
|
@@ -117,16 +126,28 @@ export class RunKeeper {
|
|
|
117
126
|
return this.kept.get(id)
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
|
|
129
|
+
/** Queue `job`, which writes `id`'s bytes; a write that lands counts as `id`'s upload. */
|
|
130
|
+
private enqueue(id: string, job: () => Promise<boolean>): void {
|
|
121
131
|
let done!: () => void
|
|
122
132
|
const p = new Promise<void>((resolve) => (done = resolve))
|
|
123
133
|
this.pending.add(p)
|
|
134
|
+
const { traffic } = this.args
|
|
135
|
+
let key = traffic.keys.get(id)
|
|
136
|
+
if (key === undefined)
|
|
137
|
+
traffic.keys.set(id, (key = { uploading: 0, landed: false, failures: [] }))
|
|
138
|
+
const counted = key
|
|
139
|
+
counted.uploading++
|
|
140
|
+
traffic.inFlight++
|
|
124
141
|
this.queue.push(async () => {
|
|
125
142
|
try {
|
|
126
|
-
if (!this.stopped
|
|
143
|
+
if (!this.stopped && (await job())) counted.landed = true
|
|
127
144
|
} catch {
|
|
128
145
|
// a record, never the run's verdict
|
|
129
146
|
} finally {
|
|
147
|
+
counted.uploading--
|
|
148
|
+
traffic.inFlight--
|
|
149
|
+
if (counted.uploading === 0 && !counted.landed && counted.failures.length === 0)
|
|
150
|
+
traffic.keys.delete(id)
|
|
130
151
|
this.pending.delete(p)
|
|
131
152
|
done()
|
|
132
153
|
}
|
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 {
|
|
@@ -162,17 +162,14 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
|
|
|
162
162
|
/**
|
|
163
163
|
* The perf firewall for the local short-circuit. Always-on; the only
|
|
164
164
|
* gates are correctness/no-op gates:
|
|
165
|
-
* - LOCAL-ONLY cache. Behind a remote layer, `cache.get` is a remote
|
|
166
|
-
* READ-THROUGH — the up-front classify is awaited before scheduling,
|
|
167
|
-
* so N remote GETs would land on the critical path before any task
|
|
168
|
-
* starts. Remote runs are owned by `startRemotePrefetch`, which
|
|
169
|
-
* overlaps the GETs with execution instead (fire-and-forget) and
|
|
170
|
-
* ingests hits into local for execute-task's lazy probe. The gate asks
|
|
171
|
-
* the layer (`hasRemote`), not `instanceof LayeredCache` — a
|
|
172
|
-
* third-party remote layer stalls on the classify just as hard.
|
|
173
165
|
* - the policy reads locally (a `--no-cache`/`--force`/`--cache-policy=local:`
|
|
174
166
|
* run reads nothing locally → nothing to restore → skip);
|
|
175
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).
|
|
176
173
|
* A flat graph has no ordering to bypass, but the classify pass is worth
|
|
177
174
|
* paying for anyway: it probes every stable key through ONE batched
|
|
178
175
|
* `getMany` instead of a `cache.get` per task inside the run, which
|
|
@@ -180,12 +177,7 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
|
|
|
180
177
|
* (2026-09-03, interleaved arms). The per-task correctness gates (stable
|
|
181
178
|
* key, no workspace-outputs in graph) live in `startLocalShortCircuit`.
|
|
182
179
|
*/
|
|
183
|
-
export function shouldShortCircuit(
|
|
184
|
-
nodes: Map<string, TaskNode>,
|
|
185
|
-
policy: CachePolicy,
|
|
186
|
-
cache: CacheLayer,
|
|
187
|
-
): boolean {
|
|
188
|
-
if (cache.hasRemote === true) return false
|
|
180
|
+
export function shouldShortCircuit(nodes: Map<string, TaskNode>, policy: CachePolicy): boolean {
|
|
189
181
|
if (!policy.localRead) return false
|
|
190
182
|
return nodes.size > 0
|
|
191
183
|
}
|
|
@@ -450,6 +442,8 @@ async function runOnBus(
|
|
|
450
442
|
// carries it so analytics queries can group by invocation; plugins get it
|
|
451
443
|
// in their teardown's `RunReport`.
|
|
452
444
|
const runId = ulid()
|
|
445
|
+
// A killed run's restore staging (`.vx/tmp/<pid>-0`), once per run.
|
|
446
|
+
sweepStaging(prepared.workspaceRoot)
|
|
453
447
|
const head = captureGitContext(prepared.workspaceRoot)
|
|
454
448
|
const ciContext = detectCi(process.env)
|
|
455
449
|
const keepRun =
|
|
@@ -470,6 +464,7 @@ async function runOnBus(
|
|
|
470
464
|
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
471
465
|
keep: prepared.keepRunEntry,
|
|
472
466
|
failures: keepRun.failures,
|
|
467
|
+
traffic: prepared.localCache.traffic,
|
|
473
468
|
})
|
|
474
469
|
try {
|
|
475
470
|
disposePlugins = await installPlugins({
|
|
@@ -873,16 +868,22 @@ async function runOnBus(
|
|
|
873
868
|
// An empty local cache answers every probe with a miss: each task
|
|
874
869
|
// derives its key as it starts instead (~100 ms before a cold 1,090-
|
|
875
870
|
// package run's first spawn), and every sandboxed task runs.
|
|
871
|
+
const remote = cache.hasRemote === true
|
|
876
872
|
const coldLocal =
|
|
877
|
-
cache === prepared.localCache
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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)) {
|
|
882
880
|
shortCircuit = await step('vx:keys', () =>
|
|
883
881
|
startLocalShortCircuit({
|
|
884
882
|
nodes,
|
|
885
|
-
|
|
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,
|
|
886
887
|
workspaceRoot,
|
|
887
888
|
claimedFiles: fingerprintWatch.names,
|
|
888
889
|
forwardArgs: options.forwardArgs,
|
|
@@ -893,7 +894,8 @@ async function runOnBus(
|
|
|
893
894
|
}),
|
|
894
895
|
)
|
|
895
896
|
// A confirmed miss runs too.
|
|
896
|
-
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()
|
|
897
899
|
}
|
|
898
900
|
|
|
899
901
|
// Whether this task runs behind a failure (`continueMode: 'always'`
|
|
@@ -1605,7 +1607,7 @@ async function planRestorable(
|
|
|
1605
1607
|
const serves = [...nodes.values()].some(
|
|
1606
1608
|
(n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
|
|
1607
1609
|
)
|
|
1608
|
-
if (!serves || !shouldShortCircuit(nodes, policy
|
|
1610
|
+
if (!serves || !shouldShortCircuit(nodes, policy)) return {}
|
|
1609
1611
|
const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
|
|
1610
1612
|
const stable = await deriveStableKeys({
|
|
1611
1613
|
nodes,
|
package/src/report/logger.ts
CHANGED
|
@@ -973,8 +973,11 @@ export function defaultLogger(
|
|
|
973
973
|
const qi = slotQueue.findIndex((s) => s.id === node.id)
|
|
974
974
|
if (qi >= 0) slotQueue.splice(qi, 1)
|
|
975
975
|
}
|
|
976
|
-
const bucket =
|
|
977
|
-
if (bucket !== undefined)
|
|
976
|
+
const bucket = cacheBucketOf(outcome)
|
|
977
|
+
if (bucket !== undefined) {
|
|
978
|
+
if (outcome.hash !== undefined) buckets.set(outcome.hash, bucket)
|
|
979
|
+
buckets.set(node.id, bucket)
|
|
980
|
+
}
|
|
978
981
|
switch (outcome.status) {
|
|
979
982
|
case 'success':
|
|
980
983
|
succeeded++
|
package/src/report/summary.ts
CHANGED
|
@@ -110,7 +110,7 @@ export interface SummaryStats {
|
|
|
110
110
|
savedMs?: number
|
|
111
111
|
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
112
112
|
emptyGroups?: readonly string[]
|
|
113
|
-
/** Each finished task's cache bucket by its key, so its remote traffic can move it. */
|
|
113
|
+
/** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
|
|
114
114
|
buckets?: ReadonlyMap<string, CacheBucket>
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -536,12 +536,14 @@ export function formatRunSummary(
|
|
|
536
536
|
)
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
-
/** Each finished task's cache bucket by its key. */
|
|
539
|
+
/** Each finished task's cache bucket by its key and by its id. */
|
|
540
540
|
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
|
|
541
541
|
const out = new Map<string, CacheBucket>()
|
|
542
542
|
for (const o of outcomes) {
|
|
543
|
-
const bucket =
|
|
544
|
-
if (bucket
|
|
543
|
+
const bucket = cacheBucketOf(o)
|
|
544
|
+
if (bucket === undefined) continue
|
|
545
|
+
if (o.hash !== undefined) out.set(o.hash, bucket)
|
|
546
|
+
out.set(o.node.id, bucket)
|
|
545
547
|
}
|
|
546
548
|
return out
|
|
547
549
|
}
|
package/src/util/index.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -12,11 +12,12 @@ export function toPosix(p: string): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* `.vx/.gitignore`: git ignores the machine-local `.vx/cache`
|
|
16
|
-
* else in `.vx`, so the workspace
|
|
17
|
-
* committed. `vx init` and the cache
|
|
15
|
+
* `.vx/.gitignore`: git ignores the machine-local `.vx/cache`, `.vx/tmp`
|
|
16
|
+
* (where restores stage) and nothing else in `.vx`, so the workspace
|
|
17
|
+
* config and the lock beside it are committed. `vx init` and the cache
|
|
18
|
+
* write it when missing.
|
|
18
19
|
*/
|
|
19
|
-
export const DOT_VX_GITIGNORE = '/cache\n'
|
|
20
|
+
export const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
|
|
20
21
|
|
|
21
22
|
/** `.vx/.gitattributes`' text: GitHub collapses the machine lock in a diff. */
|
|
22
23
|
export const DOT_VX_GITATTRIBUTES = 'projects.lock.json linguist-generated=true\n'
|
|
@@ -385,6 +386,39 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
|
|
|
385
386
|
return (rel) => pos(rel) && !neg(rel)
|
|
386
387
|
}
|
|
387
388
|
|
|
389
|
+
/**
|
|
390
|
+
* What no output glob reaches. An output is otherwise taken as written —
|
|
391
|
+
* `node_modules/**` IS an install task's output — but no task produces
|
|
392
|
+
* these, and a root project declaring `**` would otherwise wipe its
|
|
393
|
+
* repository and the cache it is restoring from (2026-09-16). `.vx` whole
|
|
394
|
+
* and each `vx.lock.json` are vx's own: as outputs, the clean deleted an
|
|
395
|
+
* uncommitted workspace config and a hit restored a lock or ignore file the
|
|
396
|
+
* key does not see over the current one.
|
|
397
|
+
*/
|
|
398
|
+
export const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
399
|
+
|
|
400
|
+
/** Whether a positive glob in `globs` names a `node_modules` directory. */
|
|
401
|
+
export function namesNodeModules(globs: readonly string[]): boolean {
|
|
402
|
+
return globs.some(
|
|
403
|
+
(g) => !g.startsWith('!') && normalizeGlob(g).split('/').includes('node_modules'),
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Whether a restore may write `rel`: one of the outputs `globs` declare,
|
|
409
|
+
* never under `OUTPUT_NEVER`, and under `node_modules` only where a glob
|
|
410
|
+
* names it — what the save side could have packed (`resolveOutputs`). An
|
|
411
|
+
* artifact naming anything else was not written by this task's save, and
|
|
412
|
+
* none of it is restored (2026-10-16).
|
|
413
|
+
*/
|
|
414
|
+
export function restorableOutput(globs: readonly string[]): (rel: string) => boolean {
|
|
415
|
+
const isOutput = outputMatcher(globs)
|
|
416
|
+
const never = anyTaskGlob(
|
|
417
|
+
namesNodeModules(globs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**'],
|
|
418
|
+
)
|
|
419
|
+
return (rel) => isOutput(rel) && !never(rel)
|
|
420
|
+
}
|
|
421
|
+
|
|
388
422
|
/**
|
|
389
423
|
* `pattern` with its first brace group expanded, recursively, while that
|
|
390
424
|
* group holds a `/`; any other pattern as itself, since `Bun.Glob`
|
|
@@ -103,7 +103,7 @@ const nonJsonPaths = ${nonJsonPaths.toString()}
|
|
|
103
103
|
throw new Error('process.exit(' + (code ?? '') + ') in a config: ' + ${JSON.stringify(CONFIG_EXIT)})
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
// Bun's resolver reads \\ as a separator, so a config under a\\b is served
|
|
106
|
+
// Bun's resolver reads \\ as a separator and ? as a query, so a config under a\\b or a?b is served
|
|
107
107
|
// from the path as written (project-loader.ts's LITERAL_QUERY), read now.
|
|
108
108
|
let literal = false
|
|
109
109
|
const serveLiteral = () => {
|
|
@@ -172,7 +172,7 @@ self.onmessage = async (e) => {
|
|
|
172
172
|
if (umaskIn !== null && globalThis.process.umask() !== umaskIn) globalThis.process.umask(umaskIn)
|
|
173
173
|
}
|
|
174
174
|
try {
|
|
175
|
-
const literalPath = path
|
|
175
|
+
const literalPath = /[\\\\?]/.test(path)
|
|
176
176
|
if (literalPath) serveLiteral()
|
|
177
177
|
const ns = await import(literalPath ? path + '?vx-literal' : path)
|
|
178
178
|
// Awaited as the in-process load's async return flattens it: a Promise
|
|
@@ -75,8 +75,9 @@ let serving = false
|
|
|
75
75
|
const HELD_QUERY = /\?vx-(?:held|literal)=([0-9a-f]+)$/
|
|
76
76
|
|
|
77
77
|
// Bun's resolver reads `\` as a separator, even in a file: URL, so a
|
|
78
|
-
// config under `a\b` was looked for under `a/b
|
|
79
|
-
//
|
|
78
|
+
// config under `a\b` was looked for under `a/b`, and `?` as a query, so
|
|
79
|
+
// one under `a?b` was looked for under `a`. Resolving it here keeps the
|
|
80
|
+
// path as written; only the served load can take it.
|
|
80
81
|
const LITERAL_QUERY = /\?vx-literal=[0-9a-f]+$/
|
|
81
82
|
|
|
82
83
|
function serveHeldSources(): void {
|
|
@@ -148,14 +149,14 @@ async function loadDefaultExport(
|
|
|
148
149
|
// URL yet, and a repeat load (the one that could replay an evaluation
|
|
149
150
|
// made under earlier env values) re-evaluates in a worker instead
|
|
150
151
|
// (`loadedConfigs`, item 678).
|
|
151
|
-
const literal = configPath
|
|
152
|
+
const literal = /[\\?]/.test(configPath)
|
|
152
153
|
const source =
|
|
153
154
|
kind === 'Project' || literal
|
|
154
155
|
? servableSource(bytes, /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js')
|
|
155
156
|
: null
|
|
156
157
|
if (literal && source === null) {
|
|
157
158
|
throw new UserError(
|
|
158
|
-
`${kind} config ${configPath} sits under a path that holds a backslash, which Bun loads only as an ES module in UTF-8`,
|
|
159
|
+
`${kind} config ${configPath} sits under a path that holds ${configPath.includes('\\') ? 'a backslash' : 'a question mark'}, which Bun loads only as an ES module in UTF-8`,
|
|
159
160
|
)
|
|
160
161
|
}
|
|
161
162
|
const hash = xxh3hex(bytes)
|