@vzn/vx 0.0.651 → 0.0.653

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.
Files changed (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -125,7 +125,7 @@ import type { CacheKeyInput } from './layer.js'
125
125
  // a v48 artifact keeps none, and a hit gave `chmod 700 dist/keys` back as 0755.
126
126
  // v50: both v49 changes in one build (2026-10-29): a v49 entry was written
127
127
  // by a vx that had only one of them.
128
- export const CACHE_VERSION = 'vx-cache-v51'
128
+ export const CACHE_VERSION = 'vx-cache-v52'
129
129
 
130
130
  /**
131
131
  * The digest a key folds for an input gone between its enumeration and its
@@ -142,7 +142,8 @@ export interface CacheEntry {
142
142
  outputDirRows?: OutputDirRow[]
143
143
  /**
144
144
  * The task's output log, always present (may be empty): both streams in
145
- * the order the run printed them (`orchestrator/output-log.ts`).
145
+ * the order the run printed them (`orchestrator/output-log.ts`). A local
146
+ * entry's is a getter that reads it on each access: read it once.
146
147
  */
147
148
  stdout: string
148
149
  storedAt: string
@@ -199,7 +200,7 @@ export interface RunRecord {
199
200
  restored?: boolean
200
201
  attempts?: number // >1 when the task retried; the direct within-run flaky signal
201
202
  /**
202
- * Whether the task declared a `cache` block. An uncached task executes
203
+ * Whether the task declared `cache: true`. An uncached task executes
203
204
  * on every run and its key exists only for dependents to fold, so a
204
205
  * reader (`vx why`, `vx last`) must not present its re-run as a cache
205
206
  * decision. Absent on rows written before the column existed.
@@ -358,7 +359,7 @@ export interface PruneResult {
358
359
 
359
360
  /**
360
361
  * Artifact + `output_files` namespace prefix for workspace-root-
361
- * anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
362
+ * anchored outputs (`outputs.workspaceFiles`). Project outputs
362
363
  * keep their bare project-relative `path` rows; workspace rows store
363
364
  * the full `workspace-outputs/<rel-to-root>` tar entry name as the
364
365
  * discriminator — least-invasive row format, no schema change. A
@@ -467,7 +468,7 @@ export interface CacheGetContext {
467
468
  taskId: string
468
469
  command: string
469
470
  /**
470
- * The task's declared `cache.outputs`. A remote artifact is ingested
471
+ * The task's declared `outputs`. A remote artifact is ingested
471
472
  * only when every file it carries is one of them: bytes off the network
472
473
  * named `outputs/src/in.txt` or `workspace-outputs/.git/hooks/…` wrote
473
474
  * an input and a git hook on a green `cache-hit-remote` (item 942).
@@ -511,11 +512,12 @@ export function isIndexFull(err: unknown): boolean {
511
512
  }
512
513
 
513
514
  /**
514
- * The supplied artifact bytes don't decompress/parse as a vx artifact.
515
- * Thrown by `save`/`ingest` BEFORE anything reaches the final cache
516
- * path — a rejected artifact leaves no `<hash>.tar.zst` and no SQL row.
517
- * The LayeredCache treats this as a remote fault on the remote-hit
518
- * path (degrades to a cache miss).
515
+ * Bytes that are not the artifact their key names. Core's ingest throws
516
+ * it for bytes that do not decompress or parse, before anything is
517
+ * stored; a remote layer that checks its bytes as they pass (a digest, a
518
+ * signature) errors its `get` body with it to say they are wrong there,
519
+ * not cut short. Either way the hit is a miss, and the re-run's save is
520
+ * sent over that remote's copy.
519
521
  */
520
522
  export class CorruptArtifactError extends Error {
521
523
  constructor(
@@ -528,10 +530,15 @@ export class CorruptArtifactError extends Error {
528
530
  }
529
531
  }
530
532
 
533
+ /** A `CorruptArtifactError` by name: a plugin may load its own copy of core. */
534
+ export function isCorruptArtifact(err: unknown): boolean {
535
+ return err instanceof Error && err.name === 'CorruptArtifactError'
536
+ }
537
+
531
538
  /**
532
- * `restoreOutputs` found no artifact where the probe found one: it was
533
- * removed in between — a `vx cache clean` in another shell, or one for
534
- * another workspace sharing the cache directory. The
539
+ * `restoreOutputs` found no artifact where the probe found one: another
540
+ * process removed or replaced it in between — a prune or a clean for a
541
+ * workspace sharing the cache directory, a re-save of the key. The
535
542
  * bytes are not wrong, they are gone, so the entry is a MISS and the task
536
543
  * runs (execute-task.ts); nothing about the cache needs throwing away.
537
544
  * Also thrown, with its `reason`, for an artifact whose bytes ARE wrong
@@ -547,7 +554,8 @@ export class ArtifactVanishedError extends Error {
547
554
  cause?: unknown,
548
555
  ) {
549
556
  super(
550
- reason ?? `its cache artifact ${hash} vanished before the restore (a vx cache clean)`,
557
+ reason ??
558
+ `its cache artifact ${hash} vanished before the restore (another process removed or replaced it)`,
551
559
  cause === undefined ? undefined : { cause },
552
560
  )
553
561
  this.name = 'ArtifactVanishedError'
@@ -804,9 +812,9 @@ export interface CacheLayer {
804
812
  */
805
813
  ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void>
806
814
  /**
807
- * The ONE run-history write: a whole `vx run` atomically, the per-task
808
- * `runs` rows and the one `invocations` header row — in a SINGLE
809
- * transaction (one fsync). Input-fingerprint rows are NOT written here — they
815
+ * The run-history write at a run's end, atomically: the per-task `runs`
816
+ * rows (less the hits a `HistoryStream` already wrote) and the one
817
+ * `invocations` header row — in a SINGLE transaction (one fsync). Input-fingerprint rows are NOT written here — they
810
818
  * ride the entry-save transaction (`save`/`ingest`) so a warm
811
819
  * all-cache-hit run writes nothing.
812
820
  */
@@ -834,6 +842,11 @@ export interface CacheLayer {
834
842
  artifactSize?(hash: string): number | undefined
835
843
  /** The output files' bytes, uncompressed, of the entry this process just saved; asked once. */
836
844
  outputBytes?(hash: string): number | undefined
845
+ /**
846
+ * True when the last `save` of `hash` found the key stored and kept that
847
+ * entry (a `--force` run): nothing of this run's was saved; asked once.
848
+ */
849
+ kept?(hash: string): boolean
837
850
  prune(options: PruneOptions): Promise<PruneResult>
838
851
  close(): void
839
852
  }
@@ -35,7 +35,8 @@ import type {
35
35
  SaveArgs,
36
36
  } from './cache.js'
37
37
  import type { OutputDirRow } from './cache.js'
38
- import { CorruptArtifactError } from './layer.js'
38
+ import { shareSeenHasher } from './cache.js'
39
+ import { isCorruptArtifact } from './layer.js'
39
40
  import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
40
41
  import type { PluginSecrets } from '../util/index.js'
41
42
 
@@ -182,7 +183,9 @@ export interface RemoteCacheLayer {
182
183
  */
183
184
  hasMany?(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null>
184
185
  /**
185
- * Fetch an artifact; `null` = miss. `body` is read once, by core,
186
+ * Fetch an artifact; `null` = miss, `undefined` = no answer (the layer
187
+ * is off, its refusal already said): a miss that keeps the hits the
188
+ * index holds. `body` is read once, by core,
186
189
  * straight into the local cache — an HTTP wire returns its `fetch`
187
190
  * `Response`, a chunked wire `new Response(stream)`, bytes in hand
188
191
  * `new Blob([bytes])`. `durationMs` is the producing task's duration
@@ -190,7 +193,9 @@ export interface RemoteCacheLayer {
190
193
  * nothing and the read is a miss, so a layer can check bytes as they pass
191
194
  * (a digest, a signature) and refuse at the end.
192
195
  */
193
- get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
196
+ get(
197
+ hash: string,
198
+ ): Promise<{ body: Blob | Response; durationMs: number | undefined } | null | undefined>
194
199
  /**
195
200
  * Store an artifact (fire-and-forget from LayeredCache's perspective).
196
201
  * `body` is file-backed (`Bun.file`) when the local store holds the
@@ -198,10 +203,14 @@ export interface RemoteCacheLayer {
198
203
  * read of it sees the same bytes. An entry is immutable: a `put` of a
199
204
  * key the remote holds keeps the stored bytes. Core never asks `has`
200
205
  * first; a wire that would overwrite checks inside, unless told `missing`.
206
+ * `replace`: the remote's copy of `hash` did not read back (a
207
+ * `CorruptArtifactError`, here or on another machine of the run), so
208
+ * these bytes go over it; a wire that cannot write over a held key
209
+ * returns `false`, or the run counts the bad copy fixed.
201
210
  * `false`: nothing was sent and nothing failed (a layer that writes
202
211
  * nothing this run, such as one with no token), so no upload is counted.
203
212
  */
204
- put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void | false>
213
+ put(hash: string, body: Blob, meta: { durationMs: number; replace?: true }): Promise<void | false>
205
214
  /**
206
215
  * The run found `hash` in no remote, and a `put` of it follows: a wire
207
216
  * that checks before it overwrites can skip that request.
@@ -216,6 +225,8 @@ export class LayeredCache implements CacheLayer {
216
225
  private readonly lanes: readonly RemoteLane[]
217
226
  /** Each key `save` uploaded: whether a remote took it (`uploaded`). */
218
227
  private readonly sent = new Map<string, Promise<boolean>>()
228
+ /** Each key whose stored entry `save` kept, until `kept` answers for it. */
229
+ private readonly keptHere = new Set<string>()
219
230
 
220
231
  /**
221
232
  * In-flight remote pulls keyed by hash. `prefetch` and `get` both go
@@ -251,6 +262,7 @@ export class LayeredCache implements CacheLayer {
251
262
  uploadConcurrency?: number,
252
263
  ) {
253
264
  this.lanes = remotes.map((r) => new RemoteLane(r, local.traffic, uploadConcurrency))
265
+ shareSeenHasher(local, this)
254
266
  }
255
267
 
256
268
  // Asked, not kept: a remote whose `open` failed leaves the run after this is built.
@@ -555,7 +567,8 @@ export class LayeredCache implements CacheLayer {
555
567
  lane.report('download', hash, err)
556
568
  // Bad bytes there: the re-run's save sends good ones over them, or
557
569
  // every machine rebuilt the task and none repaired it (2026-10-24).
558
- if (err instanceof CorruptArtifactError) lane.distrust(hash)
570
+ // Core's ingest says so, or the layer's own check of its body does.
571
+ if (isCorruptArtifact(err)) lane.distrust(hash)
559
572
  return false
560
573
  } finally {
561
574
  this.downloads.delete(hash)
@@ -579,6 +592,10 @@ export class LayeredCache implements CacheLayer {
579
592
  return this.local.outputBytes(hash)
580
593
  }
581
594
 
595
+ kept(hash: string): boolean {
596
+ return this.keptHere.delete(hash) || this.local.kept(hash)
597
+ }
598
+
582
599
  hashFile(filePath: string): Promise<string> {
583
600
  return this.local.hashFile(filePath)
584
601
  }
@@ -596,6 +613,8 @@ export class LayeredCache implements CacheLayer {
596
613
  // Local write honors its own gates inside Cache.save (no-op when local
597
614
  // writes are disabled).
598
615
  await this.local.save(args)
616
+ const kept = this.local.kept(args.hash)
617
+ if (kept) this.keptHere.add(args.hash)
599
618
  const writable = this.lanes.filter((l) => l.write)
600
619
  if (writable.length === 0) return
601
620
  // Write-through upload, OFF the task's critical path: each PUT runs in
@@ -618,9 +637,11 @@ export class LayeredCache implements CacheLayer {
618
637
  // while this task's output files are still on disk. Deferring THIS
619
638
  // read would pack whatever the tree happens to hold when the job
620
639
  // runs. Such a run keeps the old memory profile by necessity — the
621
- // bytes exist nowhere else.
640
+ // bytes exist nowhere else. So with an entry the save kept (`--force`):
641
+ // its artifact is an older run's, maybe the corrupt one `--force` was
642
+ // run to replace, and a remote holding none took it for good.
622
643
  let packed: Uint8Array | undefined
623
- if (!this.local.localWritesEnabled) {
644
+ if (!this.local.localWritesEnabled || kept) {
624
645
  try {
625
646
  packed = await this.local.packArtifactBytes(args)
626
647
  } catch (err) {
@@ -11,7 +11,8 @@
11
11
  import { Database } from 'bun:sqlite'
12
12
  import { existsSync } from 'node:fs'
13
13
  import path from 'node:path'
14
- import { closeDb, SCHEMA_VERSION, SQLITE_FCNTL_PERSIST_WAL } from './cache.js'
14
+ import { closeDb, SCHEMA_VERSION } from './cache.js'
15
+ import { SQLITE_FCNTL_PERSIST_WAL } from './schema.js'
15
16
 
16
17
  /** One claimant's parse of one lockfile's content. */
17
18
  export interface LockfileMemo {
@@ -18,6 +18,7 @@ const FILE_PREFIX_MTIME = -2
18
18
  import type { Database } from 'bun:sqlite'
19
19
  import { inHashes, lazyStatement } from './schema.js'
20
20
  import { modeMatches } from './umask.js'
21
+ import { stampGrainMs } from './fs-clock.js'
21
22
  import { lstatSync, readdirSync, readlinkSync, statSync } from 'node:fs'
22
23
  import path from 'node:path'
23
24
  import {
@@ -43,6 +44,15 @@ export const LINK_MODE = 0o120000
43
44
  */
44
45
  export const DIR_MODE = 0o040000
45
46
 
47
+ /**
48
+ * A file system that keeps whole (or FAT's even) seconds stored a restore's
49
+ * millisecond "now" as the second it falls in: that stamp, not a write.
50
+ */
51
+ function secondOf(statMs: number, datedMs: number): boolean {
52
+ const grain = stampGrainMs(statMs)
53
+ return grain > 0 && datedMs >= statMs && datedMs - statMs < grain
54
+ }
55
+
46
56
  /** A link row is current while a link with that target stands there; never followed. */
47
57
  function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean {
48
58
  if (!lstatSync(abs).isSymbolicLink()) return false
@@ -50,6 +60,22 @@ function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean
50
60
  return to.byteLength === e.size && Bun.hash.crc32(to) === e.mtimeMs
51
61
  }
52
62
 
63
+ const FILES_SELECT =
64
+ 'SELECT f.entry_hash, f.path, f.size_bytes, f.mode, f.mtime_ms, s.ino, s.ctime_ms, s.mtime_ms AS stamp_mtime_ms'
65
+ const FILES_STAMPS = 'LEFT JOIN output_stamps s ON s.entry_hash = f.entry_hash AND s.path = f.path'
66
+ const FILES_JOINED = `${FILES_SELECT} FROM json_each(?) q JOIN output_files f ON f.entry_hash = q.value ${FILES_STAMPS}`
67
+ /**
68
+ * A row as this index loads it: with its stamp, the mtime the file carried
69
+ * when stamped, which a restore that wrote the file dated now and so is
70
+ * not the row's saved one.
71
+ */
72
+ interface StampedRow extends OutputFileRow {
73
+ stampMtimeMs?: number
74
+ }
75
+
76
+ const DIRS_JOINED =
77
+ 'SELECT d.entry_hash, d.path, d.mtime_ms FROM json_each(?) q JOIN output_dirs d ON d.entry_hash = q.value'
78
+
53
79
  export class OutputIndex {
54
80
  private readonly insertOutputFile: ReturnType<Database['prepare']>
55
81
  private readonly deleteOutputFiles: ReturnType<Database['prepare']>
@@ -67,8 +93,12 @@ export class OutputIndex {
67
93
  * stage, 47–72 ms at run end (item 622).
68
94
  */
69
95
  private readonly pendingDirs = new Map<string, Array<[string, number]> | null>()
70
- /** Stamps taken and not yet written, flushed with the directory snapshots. */
71
- private readonly pendingStamps = new Map<string, Array<[string, number, number]>>()
96
+ /**
97
+ * Stamps taken and not yet written, flushed with the directory snapshots:
98
+ * inode and ctime by path, so a batch read finds a row's stamp without
99
+ * scanning them all (rows × stamps was ~7 s for a 40,000-file output).
100
+ */
101
+ private readonly pendingStamps = new Map<string, Map<string, [number, number, number]>>()
72
102
  /**
73
103
  * The rows the last batched probe loaded, by hash: a hit's restore and
74
104
  * the stamps after it read their rows here instead of the index, twice
@@ -110,7 +140,7 @@ export class OutputIndex {
110
140
  this.entryExists = lazyStatement(this.db, 'SELECT 1 FROM entries WHERE hash = ?')
111
141
  this.stampOutputFile = lazyStatement(
112
142
  this.db,
113
- 'INSERT INTO output_stamps(entry_hash, path, ino, ctime_ms) VALUES (?, ?, ?, ?) ON CONFLICT(entry_hash, path) DO UPDATE SET ino = excluded.ino, ctime_ms = excluded.ctime_ms',
143
+ 'INSERT INTO output_stamps(entry_hash, path, ino, ctime_ms, mtime_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT(entry_hash, path) DO UPDATE SET ino = excluded.ino, ctime_ms = excluded.ctime_ms, mtime_ms = excluded.mtime_ms',
114
144
  )
115
145
  this.deleteStamps = lazyStatement(this.db, 'DELETE FROM output_stamps WHERE entry_hash = ?')
116
146
  }
@@ -165,12 +195,24 @@ export class OutputIndex {
165
195
  for (const [hash, list] of rows) this.held.set(hash, list)
166
196
  }
167
197
 
198
+ /** The probe's rows of `hash` when held: no read. */
199
+ heldRowsOf(hash: string): readonly OutputFileRow[] | undefined {
200
+ return this.held.get(hash)
201
+ }
202
+
168
203
  /** `hash`'s rows: the probe's when held, else read. Path, size, mode and mtime only. */
169
204
  rowsOf(hash: string): readonly OutputFileRow[] {
170
205
  return this.held.get(hash) ?? this.loadOutputFilesBatch([hash]).get(hash) ?? []
171
206
  }
172
207
 
173
- loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
208
+ /**
209
+ * `distinct`: `hashes` as a JSON array with no hash twice, which the probe
210
+ * has to hand. The rows are then joined from it, not filtered by an `IN`
211
+ * that builds a lookup of every hash first: ~1.5 ms of 8,002 hits, and as
212
+ * much again for `loadOutputDirsBatch` (2026-11-07). A repeated hash
213
+ * would repeat its rows, hence the caller's promise.
214
+ */
215
+ loadOutputFilesBatch(hashes: readonly string[], distinct?: string): Map<string, OutputFileRow[]> {
174
216
  const out = new Map<string, OutputFileRow[]>()
175
217
  if (hashes.length === 0) return out
176
218
  // `db.query` (not `db.prepare`) caches the compiled statement keyed by
@@ -180,11 +222,14 @@ export class OutputIndex {
180
222
  // the stamps taken, not only the ones flushed: overlaid below from
181
223
  // memory. A flush here committed a transaction per read, and a restore
182
224
  // reads its rows twice.
183
- const { test, params } = inHashes(hashes)
184
- const stmt = this.db.query(
185
- `SELECT f.entry_hash, f.path, f.size_bytes, f.mode, f.mtime_ms, s.ino, s.ctime_ms FROM output_files f LEFT JOIN output_stamps s ON s.entry_hash = f.entry_hash AND s.path = f.path WHERE f.entry_hash ${test}`,
186
- )
187
- const rows = stmt.all(...params) as Array<{
225
+ let sql = FILES_JOINED
226
+ let params = [distinct!]
227
+ if (distinct === undefined || hashes.length === 1) {
228
+ const where = inHashes(hashes)
229
+ sql = `${FILES_SELECT} FROM output_files f ${FILES_STAMPS} WHERE f.entry_hash ${where.test}`
230
+ params = where.params
231
+ }
232
+ const rows = this.db.query(sql).all(...params) as Array<{
188
233
  entry_hash: string
189
234
  path: string
190
235
  size_bytes: number
@@ -192,6 +237,7 @@ export class OutputIndex {
192
237
  mtime_ms: number
193
238
  ino: number | null
194
239
  ctime_ms: number | null
240
+ stamp_mtime_ms: number | null
195
241
  }>
196
242
  for (const r of rows) {
197
243
  let list = out.get(r.entry_hash)
@@ -199,31 +245,29 @@ export class OutputIndex {
199
245
  list = []
200
246
  out.set(r.entry_hash, list)
201
247
  }
202
- const row: OutputFileRow = {
248
+ const row: StampedRow = {
203
249
  path: r.path,
204
250
  size: r.size_bytes,
205
251
  mode: r.mode,
206
252
  mtimeMs: r.mtime_ms,
207
253
  }
208
- if (r.ino !== null && r.ctime_ms !== null) {
254
+ if (r.ino !== null && r.ctime_ms !== null && r.stamp_mtime_ms !== null) {
209
255
  row.ino = r.ino
210
256
  row.ctimeMs = r.ctime_ms
257
+ row.stampMtimeMs = r.stamp_mtime_ms
211
258
  }
212
- const pending = this.pendingStamps.get(r.entry_hash)
213
- if (pending !== undefined) {
214
- for (const [rel, ino, ctime] of pending) {
215
- if (rel !== r.path) continue
216
- row.ino = ino
217
- row.ctimeMs = ctime
218
- break
219
- }
259
+ const stamp = this.pendingStamps.get(r.entry_hash)?.get(r.path)
260
+ if (stamp !== undefined) {
261
+ row.ino = stamp[0]
262
+ row.ctimeMs = stamp[1]
263
+ row.stampMtimeMs = stamp[2]
220
264
  }
221
265
  list.push(row)
222
266
  }
223
267
  return out
224
268
  }
225
269
 
226
- isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
270
+ isOutputsCurrent(projectDir: string, expected: readonly StampedRow[]): boolean {
227
271
  // Empty manifest case (a task produced no outputs) → trivially
228
272
  // current; the on-disk tree under projectDir is whatever it was,
229
273
  // and nothing was supposed to land there.
@@ -239,17 +283,20 @@ export class OutputIndex {
239
283
  try {
240
284
  if (e.mode === LINK_MODE) return linkCurrent(path.join(projectDir, e.path), e)
241
285
  const s = statSync(path.join(projectDir, e.path))
242
- // An empty output directory: current while a directory stands there.
243
- if (e.path.endsWith('/')) return s.isDirectory()
286
+ // An empty output directory: current while a directory stands there,
287
+ // with the mode its row records if it records one.
288
+ if (e.path.endsWith('/'))
289
+ return (
290
+ s.isDirectory() && ((e.mode & 0o170000) !== DIR_MODE || modeMatches(s.mode, e.mode))
291
+ )
244
292
  return (
245
293
  s.size === e.size &&
246
294
  modeMatches(s.mode, e.mode) &&
247
295
  // MILLISECOND comparison (sub-ms tolerance for the float
248
- // round-trip through utimes). Save rows carry stat-ms and
249
- // restoreOutputs re-syncs restored files to the row value,
250
- // so equality holds exactly in steady state; legacy
251
- // second-precision rows converge on their first restore.
252
- Math.abs(s.mtimeMs - e.mtimeMs) < 1 &&
296
+ // round-trip through utimes), against the mtime the file carried
297
+ // when stamped: the row's after a save; after a restore, now for
298
+ // a file it wrote and the file's own where its bytes stood.
299
+ Math.abs(s.mtimeMs - (e.stampMtimeMs ?? e.mtimeMs)) < 1 &&
253
300
  // And the file is the one THIS machine last saw under this
254
301
  // entry: two entries whose outputs carry one fixed mtime (a
255
302
  // `tar -x`, `cp -p`, SOURCE_DATE_EPOCH) and one size matched
@@ -260,7 +307,8 @@ export class OutputIndex {
260
307
  // (an ingest, a changed file at stamping) is never current. The
261
308
  // residual: a same-size rewrite, in place or as a new file on the
262
309
  // same inode, inside the coarse clock tick of the stamp, with the
263
- // mtime forged back.
310
+ // mtime forged back or written in that tick (a save's or a
311
+ // restore's file is dated in it).
264
312
  e.ino !== undefined &&
265
313
  s.ino === e.ino &&
266
314
  Math.floor(s.ctimeMs) === e.ctimeMs
@@ -363,16 +411,23 @@ export class OutputIndex {
363
411
  }
364
412
 
365
413
  /**
366
- * Stamp `hash`'s rows with each file's inode and ctime, once a save or
367
- * a restore has left the tree equal to the entry. A file whose size,
368
- * mode or mtime no longer matches its row is left unstamped, so a
369
- * write that landed since is never vouched for; the next hit restores.
370
- * Written with the directory snapshots, in one transaction.
414
+ * Stamp `hash`'s rows with each file's inode, ctime and mtime, once a
415
+ * save or a restore has left the tree equal to the entry. `dated` holds
416
+ * the mtime a restore left each file with, by row path (now for one it
417
+ * wrote); any other file is held to its row's. A file whose size, mode or mtime no
418
+ * longer matches is left unstamped, so a write that landed since is
419
+ * never vouched for; the next hit restores. Written with the directory
420
+ * snapshots, in one transaction.
371
421
  */
372
- recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
422
+ recordOutputStamps(
423
+ hash: string,
424
+ projectDir: string,
425
+ workspaceRoot: string,
426
+ dated?: ReadonlyMap<string, number>,
427
+ ): void {
373
428
  if (!this.memo) return
374
429
  const rows = this.rowsOf(hash)
375
- const stamps: Array<[string, number, number]> = []
430
+ const stamps = new Map<string, [number, number, number]>()
376
431
  for (const e of rows) {
377
432
  if (e.path.endsWith('/') || e.mode === LINK_MODE) continue
378
433
  const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
@@ -381,18 +436,19 @@ export class OutputIndex {
381
436
  : path.join(projectDir, e.path)
382
437
  try {
383
438
  const s = statSync(abs)
439
+ const at = dated?.get(e.path) ?? e.mtimeMs
384
440
  if (
385
441
  s.size === e.size &&
386
442
  modeMatches(s.mode, e.mode) &&
387
- Math.abs(s.mtimeMs - e.mtimeMs) < 1
443
+ (Math.abs(s.mtimeMs - at) < 1 || secondOf(s.mtimeMs, at))
388
444
  ) {
389
- stamps.push([e.path, s.ino, Math.floor(s.ctimeMs)])
445
+ stamps.set(e.path, [s.ino, Math.floor(s.ctimeMs), Math.floor(s.mtimeMs)])
390
446
  }
391
447
  } catch {
392
448
  // gone: left unstamped
393
449
  }
394
450
  }
395
- if (stamps.length > 0) this.pendingStamps.set(hash, stamps)
451
+ if (stamps.size > 0) this.pendingStamps.set(hash, stamps)
396
452
  }
397
453
 
398
454
  /** Land every pending stamp and snapshot in one transaction each; a null snapshot clears its rows. */
@@ -418,7 +474,8 @@ export class OutputIndex {
418
474
  for (const [hash, rows] of stamps) {
419
475
  // A stamp outlives nothing: an entry pruned since has no rows to vouch for.
420
476
  if (this.entryExists.get(hash) === null) continue
421
- for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
477
+ for (const [rel, [ino, ctime, mtime]] of rows)
478
+ this.stampOutputFile.run(hash, rel, ino, ctime, mtime)
422
479
  }
423
480
  })
424
481
  .immediate()
@@ -437,16 +494,21 @@ export class OutputIndex {
437
494
  })()
438
495
  }
439
496
 
440
- loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
497
+ /** `distinct` as `loadOutputFilesBatch` takes it. */
498
+ loadOutputDirsBatch(hashes: readonly string[], distinct?: string): Map<string, OutputDirRow[]> {
441
499
  const out = new Map<string, OutputDirRow[]>()
442
500
  if (hashes.length === 0) return out
443
501
  // A reader in the same process (`vx watch`'s next cycle, a test) sees
444
502
  // what was snapshotted, not what was flushed.
445
503
  this.flushOutputDirs()
446
- const { test, params } = inHashes(hashes)
447
- const rows = this.db
448
- .query(`SELECT entry_hash, path, mtime_ms FROM output_dirs WHERE entry_hash ${test}`)
449
- .all(...params) as Array<{
504
+ let sql = DIRS_JOINED
505
+ let params = [distinct!]
506
+ if (distinct === undefined || hashes.length === 1) {
507
+ const where = inHashes(hashes)
508
+ sql = `SELECT entry_hash, path, mtime_ms FROM output_dirs WHERE entry_hash ${where.test}`
509
+ params = where.params
510
+ }
511
+ const rows = this.db.query(sql).all(...params) as Array<{
450
512
  entry_hash: string
451
513
  path: string
452
514
  mtime_ms: number
@@ -172,7 +172,7 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
172
172
  for (const r of readers) {
173
173
  try {
174
174
  const got = await r.remote.get(key)
175
- if (got === null) continue
175
+ if (got == null) continue
176
176
  const bytes = await capped(got.body)
177
177
  if (bytes === null) continue
178
178
  if (policy.localWrite) localPut(local, bytes)
@@ -245,7 +245,7 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
245
245
  for (const key of runReadKeys(name, scope)) {
246
246
  try {
247
247
  const got = await r.remote.get(key)
248
- if (got === null) continue
248
+ if (got == null) continue
249
249
  const bytes = await capped(got.body, Infinity)
250
250
  if (bytes !== null) return bytes
251
251
  } catch (err) {
@@ -1,7 +1,7 @@
1
1
  // The run-level cache policy: four axes (local/remote × read/write) and
2
2
  // the `--cache-policy=<spec>` grammar that sets them. Pure data + parsing; no I/O.
3
3
 
4
- import { UserError } from '../util/index.js'
4
+ import { nearest, UserError } from '../util/index.js'
5
5
 
6
6
  /**
7
7
  * Independent read/write control over the two cache layers (local +
@@ -63,7 +63,11 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
63
63
  const layer = seg.slice(0, colon)
64
64
  const flags = seg.slice(colon + 1)
65
65
  if (layer !== 'local' && layer !== 'remote') {
66
- throw new UserError(`invalid layer '${layer}': expected 'local' or 'remote'`)
66
+ const near = nearest(layer, ['local', 'remote'])
67
+ throw new UserError(
68
+ `invalid layer '${layer}': expected 'local' or 'remote'` +
69
+ (near === undefined ? '' : ` — did you mean '${near}'?`),
70
+ )
67
71
  }
68
72
  if (seen.has(layer)) {
69
73
  throw new UserError(`layer '${layer}' specified twice`)
@@ -296,6 +296,7 @@ export class RemoteLane {
296
296
  this.report('download', hash, err)
297
297
  return undefined
298
298
  }
299
+ if (result === undefined) return undefined
299
300
  if (!result) {
300
301
  // Gone, whatever a probe said: the re-run's save uploads it.
301
302
  this.held.delete(hash)
@@ -387,7 +388,8 @@ export class RemoteLane {
387
388
  const got = await body()
388
389
  release = got.release
389
390
  if (known) this.remote.missing?.(hash)
390
- if ((await this.remote.put(hash, got.body, { durationMs })) === false) return
391
+ const meta = distrusted ? { durationMs, replace: true as const } : { durationMs }
392
+ if ((await this.remote.put(hash, got.body, meta)) === false) return
391
393
  holds = true
392
394
  tally.count++
393
395
  tally.bytes += got.body.size