@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.
@@ -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. 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
+ // 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)) slot().log = name
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 !== null && (await this.args.keep(name, bytes))) slot().outputs = name
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
- private enqueue(job: () => Promise<void>): void {
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) await job()
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
  }
@@ -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
- shouldShortCircuit(nodes, policy, cache) &&
879
- prepared.localCache.holdsNothing()
880
- if (coldLocal) prewarm()
881
- else if (shouldShortCircuit(nodes, policy, cache)) {
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
- cache,
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)) prewarm()
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, prepared.cache)) return {}
1610
+ if (!serves || !shouldShortCircuit(nodes, policy)) return {}
1609
1611
  const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
1610
1612
  const stable = await deriveStableKeys({
1611
1613
  nodes,
@@ -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 = outcome.hash === undefined ? undefined : cacheBucketOf(outcome)
977
- if (bucket !== undefined) buckets.set(outcome.hash!, bucket)
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++
@@ -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 = o.hash === undefined ? undefined : cacheBucketOf(o)
544
- if (bucket !== undefined) out.set(o.hash!, 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
@@ -58,7 +58,10 @@ export {
58
58
  GLOB_WILDCARDS,
59
59
  normalizeBunGlob,
60
60
  normalizeGlob,
61
+ namesNodeModules,
62
+ OUTPUT_NEVER,
61
63
  outputMatcher,
64
+ restorableOutput,
62
65
  printable,
63
66
  relPosix,
64
67
  DOT_VX_GITATTRIBUTES,
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` and nothing
16
- * else in `.vx`, so the workspace config and the lock beside it are
17
- * committed. `vx init` and the cache write it when missing.
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.includes('\\\\')
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`. Resolving it here keeps
79
- // the path as written; only the served load can take it.
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.includes('\\')
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)