@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -23,6 +23,7 @@ import type {
23
23
  CacheLayer,
24
24
  CachePolicy,
25
25
  CacheStats,
26
+ CacheStatsOptions,
26
27
  IngestMeta,
27
28
  InvocationRecord,
28
29
  OutputFileRow,
@@ -31,7 +32,7 @@ import type {
31
32
  RunRecord,
32
33
  SaveArgs,
33
34
  } from './cache.js'
34
- import { FULL_CACHE_POLICY } from './cache.js'
35
+ import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
35
36
 
36
37
  /**
37
38
  * What a remote cache layer must provide — THE plugin seam for remote
@@ -41,8 +42,9 @@ import { FULL_CACHE_POLICY } from './cache.js'
41
42
  * policy gating, in-flight dedup, remote provenance, and the never-fail
42
43
  * contract (implementations THROW on failure; LayeredCache degrades every
43
44
  * throw to a cache miss via `onRemoteError`). The artifact bytes are the
44
- * local `<hash>.tar.zst` verbatim. See
45
- * docs/design/native-cache-wire-2026-07.md.
45
+ * local `<hash>.tar.zst` verbatim. The wires live in plugin packages
46
+ * (`@vzn/vx-migrate`'s `turboCache()` and `nxCache()`, `@vzn/vx-reapi`); see
47
+ * docs/modules/layered-cache.md.
46
48
  */
47
49
  export interface RemoteCacheLayer {
48
50
  /** Existence probe (drives the plan path's `--dry` remote prediction). */
@@ -82,6 +84,9 @@ export interface LayeredCacheOptions {
82
84
  }
83
85
 
84
86
  export class LayeredCache implements CacheLayer {
87
+ /** A remote layer is composed in by construction — see `CacheLayer.hasRemote`. */
88
+ readonly hasRemote = true
89
+
85
90
  /**
86
91
  * In-flight remote pulls keyed by hash. `prefetch` and `get` both go
87
92
  * through here, so a key probed concurrently by both resolves a
@@ -118,21 +123,25 @@ export class LayeredCache implements CacheLayer {
118
123
  private readonly policy: CachePolicy
119
124
 
120
125
  constructor(
121
- private readonly local: Cache,
126
+ readonly local: Cache,
122
127
  private readonly remote: RemoteCacheLayer,
123
128
  private readonly options: LayeredCacheOptions = {},
124
129
  ) {
125
130
  this.policy = options.policy ?? FULL_CACHE_POLICY
126
131
  }
127
132
 
128
- async key(input: CacheKeyInput): Promise<string> {
129
- return await this.local.key(input)
133
+ key(input: CacheKeyInput): Promise<string> {
134
+ return this.local.key(input)
130
135
  }
131
136
 
132
137
  async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
133
138
  // No-op when remote reads are off — there's nothing to warm from.
134
139
  if (!this.policy.remoteRead) return false
135
- return await this.pullFromRemote(hash, ctx)
140
+ // The local-first skip lives in doPullFromRemote (the shared choke
141
+ // point): pullFromRemote registers the `inflight` entry SYNCHRONOUSLY,
142
+ // so a concurrent markRemoteAbsent can't clobber a pending pull — a
143
+ // guard done here (behind an async local.has) would reopen that race.
144
+ return this.pullFromRemote(hash, ctx)
136
145
  }
137
146
 
138
147
  /**
@@ -146,6 +155,8 @@ export class LayeredCache implements CacheLayer {
146
155
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
147
156
  if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
148
157
  try {
158
+ // `return await`, deliberately: the catch below is the never-fail
159
+ // contract, and a returned promise's rejection would sail past it.
149
160
  return await this.remote.hasMany(hashes)
150
161
  } catch (err) {
151
162
  this.reportRemoteError(err)
@@ -188,11 +199,23 @@ export class LayeredCache implements CacheLayer {
188
199
  const ingested = await this.pullFromRemote(hash, ctx)
189
200
  if (!ingested) return null
190
201
 
191
- // The artifact is now in local, but this *lookup* was a remote
192
- // hit — flip the source so callers can distinguish "saved work
193
- // via the remote cache" from "saved work via a prior local run".
194
- const materialized = await this.local.get(hash, ctx)
195
- return materialized ? { ...materialized, source: 'remote' } : null
202
+ // Read past the local READ gate: `ingest` is deliberately ungated, so
203
+ // the artifact + index row this pull just wrote exist regardless of
204
+ // policy, and the gate ("don't serve hits from the pre-existing local
205
+ // cache") must not discard them. Going through the gated `get` here
206
+ // made `--cache=local:,remote:rw` download the artifact, throw the hit
207
+ // away, re-execute and re-upload — on every run, forever.
208
+ const materialized = await this.local.getIngested(hash)
209
+ if (!materialized) return null
210
+
211
+ // `remoteSourced` — not merely "the pull returned true" — is what says
212
+ // the remote cache saved this work. `doPullFromRemote` also returns true
213
+ // for its local-first skip, which issues NO remote GET: when a run
214
+ // sharing this cache dir ingests the artifact between the local read
215
+ // above and that skip's `local.has`, stamping 'remote' here reported a
216
+ // purely-local hit as `cache-hit-remote` and inflated the remote
217
+ // hit-rate. Whatever provenance the entry has is the truth.
218
+ return this.remoteSourced.has(hash) ? { ...materialized, source: 'remote' } : materialized
196
219
  }
197
220
 
198
221
  // Existence probe: local first, then a remote HEAD — no body
@@ -226,6 +249,16 @@ export class LayeredCache implements CacheLayer {
226
249
  }
227
250
 
228
251
  private async doPullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
252
+ // Local-first, mirroring get()/has(): if local ALREADY holds the artifact
253
+ // there is nothing to pull — skip the remote GET (a warm-local run would
254
+ // otherwise re-download every artifact it already has) and DON'T mark it
255
+ // `remoteSourced` (that would mislabel a purely-local warm hit as
256
+ // cache-hit-remote and inflate the remote hit-rate). Returning `true` is
257
+ // correct for a get() read-through too: "the artifact is in local" — the
258
+ // caller re-reads it, keeping whatever provenance it already had (local,
259
+ // or remote if a concurrent prefetch set it).
260
+ if ((await this.local.has(hash)) === 'local') return true
261
+
229
262
  let remoteResult
230
263
  try {
231
264
  remoteResult = await this.remote.get(hash)
@@ -265,8 +298,8 @@ export class LayeredCache implements CacheLayer {
265
298
  return this.local.outputsPath(hash)
266
299
  }
267
300
 
268
- async hashFile(filePath: string): Promise<string> {
269
- return await this.local.hashFile(filePath)
301
+ hashFile(filePath: string): Promise<string> {
302
+ return this.local.hashFile(filePath)
270
303
  }
271
304
 
272
305
  async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
@@ -274,32 +307,48 @@ export class LayeredCache implements CacheLayer {
274
307
  }
275
308
 
276
309
  async save(args: SaveArgs): Promise<void> {
277
- // Local write honors its own gate inside Cache.save (no-op when
278
- // local writes are disabled).
310
+ // Local write honors its own gates inside Cache.save (no-op when local
311
+ // writes are disabled, or when ChainedCache marked this a duplicate save
312
+ // to a shared local handle — see `skipLocalWrite` on the contract).
279
313
  await this.local.save(args)
280
314
  if (!this.policy.remoteWrite) return
281
- // Write-through upload, OFF the task's critical path. The bytes are
282
- // captured NOW — read from the artifact the local layer just wrote
283
- // (same format on both sides, no repacking), or, when local writes
284
- // are disabled (`--cache=local:,remote:rw`), packed in memory while
285
- // the output files are guaranteed still on disk. The PUT itself then
286
- // runs in the bounded background pool so the task's worker slot is
287
- // released immediately; `run()` awaits `drainUploads()` before
288
- // closing the cache. Errors are logged, not propagated: the task
289
- // already succeeded; we don't fail it on cache-server issues.
290
- let bytes: Uint8Array
291
- try {
292
- bytes = this.local.localWritesEnabled
293
- ? await Bun.file(this.local.outputsPath(args.hash)).bytes()
294
- : await this.local.packArtifactBytes(args)
295
- } catch (err) {
296
- this.reportRemoteError(err)
297
- return
298
- }
315
+ // Write-through upload, OFF the task's critical path: the PUT runs in
316
+ // the bounded background pool so the task's worker slot is released
317
+ // immediately, and `run()` awaits `drainUploads()` before closing the
318
+ // cache. Errors are logged, not propagated: the task already
319
+ // succeeded; we don't fail it on cache-server issues.
320
+ //
321
+ // The artifact bytes are read INSIDE the job, not here.
322
+ // UPLOAD_CONCURRENCY bounds sockets, not memory: a queued closure
323
+ // holding its own artifact keeps the WHOLE backlog resident, so peak
324
+ // RSS scaled with a run's total miss artifact bytes rather than with
325
+ // the pool — any cold monorepo run whose remote uploads slower than the
326
+ // build produces artifacts held every pending one at once. Reading in
327
+ // the job caps resident artifact bytes at UPLOAD_CONCURRENCY. The
328
+ // artifact is content-addressed and immutable, so a deferred read sees
329
+ // the same bytes; if a concurrent `vx cache prune` removed it first the
330
+ // read throws and this upload is skipped — the never-fail contract.
331
+ const hash = args.hash
299
332
  const durationMs = args.entry.durationMs
333
+ // Local writes disabled (`--cache=local:,remote:rw`): there is no
334
+ // on-disk artifact to read later, so the bytes must be packed NOW,
335
+ // while this task's output files are still on disk. Deferring THIS
336
+ // read would pack whatever the tree happens to hold when the job
337
+ // runs. Such a run keeps the old memory profile by necessity — the
338
+ // bytes exist nowhere else.
339
+ let packed: Uint8Array | undefined
340
+ if (!this.local.localWritesEnabled) {
341
+ try {
342
+ packed = await this.local.packArtifactBytes(args)
343
+ } catch (err) {
344
+ this.reportRemoteError(err)
345
+ return
346
+ }
347
+ }
300
348
  this.enqueueUpload(async () => {
301
349
  try {
302
- await this.remote.put(args.hash, bytes, { durationMs })
350
+ const bytes = packed ?? (await Bun.file(this.local.outputsPath(hash)).bytes())
351
+ await this.remote.put(hash, bytes, { durationMs })
303
352
  } catch (err) {
304
353
  this.reportRemoteError(err)
305
354
  }
@@ -352,24 +401,28 @@ export class LayeredCache implements CacheLayer {
352
401
  return this.local.isOutputsCurrent(projectDir, expected)
353
402
  }
354
403
 
355
- recordRun(run: RunRecord): void {
356
- this.local.recordRun(run)
404
+ recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
405
+ return this.local.recordOutputDirs(hash, projectDir, prefixes)
406
+ }
407
+
408
+ loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
409
+ return this.local.loadOutputDirsBatch(hashes)
357
410
  }
358
411
 
359
- recordRuns(runs: readonly RunRecord[]): void {
360
- this.local.recordRuns(runs)
412
+ outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
413
+ return this.local.outputDirsCurrent(projectDir, rows)
361
414
  }
362
415
 
363
416
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
364
417
  this.local.recordRunBundle(bundle)
365
418
  }
366
419
 
367
- stats(): CacheStats {
368
- return this.local.stats()
420
+ stats(opts?: CacheStatsOptions): CacheStats {
421
+ return this.local.stats(opts)
369
422
  }
370
423
 
371
- async prune(options: PruneOptions): Promise<PruneResult> {
372
- return await this.local.prune(options)
424
+ prune(options: PruneOptions): Promise<PruneResult> {
425
+ return this.local.prune(options)
373
426
  }
374
427
 
375
428
  close(): void {
@@ -0,0 +1,224 @@
1
+ // The output fingerprint index: per-entry `output_files` rows (size, mode,
2
+ // millisecond mtime) and `output_dirs` rows (directory mtimes), and the two
3
+ // proofs a cache hit runs against the tree before deciding not to restore.
4
+ // Owns its statements over the store's handle; `Cache` delegates, and
5
+ // writes the file rows through `replaceFileRows` inside its own save
6
+ // transaction.
7
+
8
+ /** `mtime_ms` of a recorded output prefix that did not exist when the snapshot was taken. */
9
+ const ABSENT_DIR_MTIME = -1
10
+
11
+ import type { Database, SQLQueryBindings } from 'bun:sqlite'
12
+ import { lstatSync, statSync } from 'node:fs'
13
+ import { lstat, readdir } from 'node:fs/promises'
14
+ import path from 'node:path'
15
+ import {
16
+ OUTPUT_DIRS_CAP,
17
+ OUTPUT_DIRS_RACY_MS,
18
+ type OutputDirRow,
19
+ type OutputFileRow,
20
+ } from './layer.js'
21
+
22
+ export class OutputIndex {
23
+ private readonly insertOutputFile: ReturnType<Database['prepare']>
24
+ private readonly deleteOutputFiles: ReturnType<Database['prepare']>
25
+ private readonly insertOutputDir: ReturnType<Database['prepare']>
26
+ private readonly deleteOutputDirs: ReturnType<Database['prepare']>
27
+
28
+ constructor(private readonly db: Database) {
29
+ this.insertOutputFile = this.db.prepare(`
30
+ INSERT INTO output_files(entry_hash, path, size_bytes, mode, mtime_ms)
31
+ VALUES (?, ?, ?, ?, ?)
32
+ ON CONFLICT(entry_hash, path) DO UPDATE SET
33
+ size_bytes = excluded.size_bytes,
34
+ mode = excluded.mode,
35
+ mtime_ms = excluded.mtime_ms
36
+ `)
37
+ this.deleteOutputFiles = this.db.prepare('DELETE FROM output_files WHERE entry_hash = ?')
38
+ this.insertOutputDir = this.db.prepare(
39
+ 'INSERT INTO output_dirs(entry_hash, path, mtime_ms) VALUES (?, ?, ?)',
40
+ )
41
+ this.deleteOutputDirs = this.db.prepare('DELETE FROM output_dirs WHERE entry_hash = ?')
42
+ }
43
+
44
+ /**
45
+ * Replace an entry's file rows. Called INSIDE the store's save transaction,
46
+ * so an entry and its fingerprints land together (an UPDATE on the same
47
+ * hash refreshes rather than appends).
48
+ */
49
+ replaceFileRows(hash: string, rows: ReadonlyArray<[string, number, number, number]>): void {
50
+ this.deleteOutputFiles.run(hash)
51
+ for (const [rel, size, mode, mtime] of rows) {
52
+ this.insertOutputFile.run(hash, rel, size, mode, mtime)
53
+ }
54
+ }
55
+
56
+ loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
57
+ const out = new Map<string, OutputFileRow[]>()
58
+ if (hashes.length === 0) return out
59
+ // Inline placeholders for an IN-list — bun:sqlite doesn't ship
60
+ // rarray, but `IN (?, ?, …)` with N≤~999 is fast and avoids per-
61
+ // hash select.get() overhead. `db.query` (not `db.prepare`) caches the
62
+ // compiled statement keyed by the SQL text — so the dominant single-hash
63
+ // warm-hit path (called up to 3× per hit) reuses one statement instead of
64
+ // recompiling on every call.
65
+ const placeholders = hashes.map(() => '?').join(',')
66
+ const stmt = this.db.query(
67
+ `SELECT entry_hash, path, size_bytes, mode, mtime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
68
+ )
69
+ const rows = stmt.all(...(hashes as readonly SQLQueryBindings[])) as Array<{
70
+ entry_hash: string
71
+ path: string
72
+ size_bytes: number
73
+ mode: number
74
+ mtime_ms: number
75
+ }>
76
+ for (const r of rows) {
77
+ let list = out.get(r.entry_hash)
78
+ if (!list) {
79
+ list = []
80
+ out.set(r.entry_hash, list)
81
+ }
82
+ list.push({ path: r.path, size: r.size_bytes, mode: r.mode, mtimeMs: r.mtime_ms })
83
+ }
84
+ return out
85
+ }
86
+
87
+ async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
88
+ // Empty manifest case (a task produced no outputs) → trivially
89
+ // current; the on-disk tree under projectDir is whatever it was,
90
+ // and nothing was supposed to land there.
91
+ if (expected.length === 0) return true
92
+ // statSync, deliberately. The async form measured faster on 2026-09-02,
93
+ // when a hit walked its whole output tree; since the directory
94
+ // short-circuit (`outputDirsCurrent`) a warm hit stats a handful of
95
+ // paths, and each async stat costs a thread-pool round trip the
96
+ // scheduler cannot overlap away (its concurrency is the worker count).
97
+ // Re-measured 2026-09-09, 1,000 warm hits, interleaved A/B: the run
98
+ // graph stage 100 → 54 ms, the whole process 422 → 368 ms.
99
+ const results = expected.map((e) => {
100
+ try {
101
+ const s = statSync(path.join(projectDir, e.path))
102
+ return (
103
+ s.size === e.size &&
104
+ (s.mode & 0o777) === (e.mode & 0o777) &&
105
+ // MILLISECOND comparison (sub-ms tolerance for the float
106
+ // round-trip through utimes). Save rows carry stat-ms and
107
+ // restoreOutputs re-syncs restored files to the row value,
108
+ // so equality holds exactly in steady state; legacy
109
+ // second-precision rows converge on their first restore.
110
+ // Residual blind spot: a same-size edit landing in the SAME
111
+ // millisecond as the recorded write, or a deliberately
112
+ // forged mtime (touch -r) — the trade every mtime-based
113
+ // skip check accepts.
114
+ Math.abs(s.mtimeMs - e.mtimeMs) < 1
115
+ )
116
+ } catch {
117
+ return false
118
+ }
119
+ })
120
+ return results.every(Boolean)
121
+ }
122
+
123
+ /**
124
+ * Snapshot every directory under each of `prefixes` for `hash`. Called
125
+ * after a save and after a restore, when the tree is known to equal the
126
+ * entry's set. Symlinked directories are not descended (the output walk
127
+ * refuses them too). Over `OUTPUT_DIRS_CAP` directories, or on any
128
+ * error, the rows are cleared and the next hit keeps the walk.
129
+ */
130
+ async recordOutputDirs(
131
+ hash: string,
132
+ projectDir: string,
133
+ prefixes: readonly string[],
134
+ ): Promise<void> {
135
+ const rows: Array<[string, number]> = []
136
+ const walk = async (rel: string, isPrefix = false): Promise<boolean> => {
137
+ const abs = path.join(projectDir, rel)
138
+ let st
139
+ try {
140
+ st = await lstat(abs)
141
+ } catch {
142
+ // A declared prefix the task never produced (`build/**` beside
143
+ // `dist/**`, medusa's `.medusa/**`) is recorded ABSENT: the check
144
+ // then asks that it still not exist. Refusing the snapshot instead
145
+ // made every such task re-glob its outputs on every warm hit — all
146
+ // 83 of medusa's, 210 ms of a 2.5 s no-op (2026-09-11).
147
+ if (isPrefix) {
148
+ rows.push([rel, ABSENT_DIR_MTIME])
149
+ return true
150
+ }
151
+ return false
152
+ }
153
+ if (!st.isDirectory()) return false
154
+ rows.push([rel, st.mtimeMs])
155
+ if (rows.length > OUTPUT_DIRS_CAP) return false
156
+ let entries
157
+ try {
158
+ entries = await readdir(abs, { withFileTypes: true })
159
+ } catch {
160
+ return false
161
+ }
162
+ for (const e of entries) {
163
+ if (e.isDirectory() && !e.isSymbolicLink()) {
164
+ if (!(await walk(`${rel}/${e.name}`))) return false
165
+ }
166
+ }
167
+ return true
168
+ }
169
+ let ok = true
170
+ for (const prefix of prefixes) {
171
+ if (!(await walk(prefix, true))) {
172
+ ok = false
173
+ break
174
+ }
175
+ }
176
+ // All or nothing: a racy directory dropped alone would leave its
177
+ // parent trusted while an addition inside it bumps only the dropped one.
178
+ const youngest = Date.now() - OUTPUT_DIRS_RACY_MS
179
+ if (rows.some(([, mtime]) => mtime > youngest)) ok = false
180
+ this.db.transaction(() => {
181
+ this.deleteOutputDirs.run(hash)
182
+ if (!ok) return
183
+ for (const [rel, mtime] of rows) this.insertOutputDir.run(hash, rel, mtime)
184
+ })()
185
+ }
186
+
187
+ loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
188
+ const out = new Map<string, OutputDirRow[]>()
189
+ if (hashes.length === 0) return out
190
+ const placeholders = hashes.map(() => '?').join(',')
191
+ const rows = this.db
192
+ .query(
193
+ `SELECT entry_hash, path, mtime_ms FROM output_dirs WHERE entry_hash IN (${placeholders})`,
194
+ )
195
+ .all(...(hashes as readonly SQLQueryBindings[])) as Array<{
196
+ entry_hash: string
197
+ path: string
198
+ mtime_ms: number
199
+ }>
200
+ for (const r of rows) {
201
+ const list = out.get(r.entry_hash)
202
+ const row = { path: r.path, mtimeMs: r.mtime_ms }
203
+ if (list) list.push(row)
204
+ else out.set(r.entry_hash, [row])
205
+ }
206
+ return out
207
+ }
208
+
209
+ /** True iff every recorded directory exists with its recorded mtime (ms). Same forged-mtime trade as the file check. */
210
+ async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
211
+ if (rows.length === 0) return false
212
+ const results = rows.map((r) => {
213
+ let st
214
+ try {
215
+ st = lstatSync(path.join(projectDir, r.path))
216
+ } catch {
217
+ return r.mtimeMs === ABSENT_DIR_MTIME
218
+ }
219
+ if (r.mtimeMs === ABSENT_DIR_MTIME) return false
220
+ return st.isDirectory() && Math.abs(st.mtimeMs - r.mtimeMs) < 1
221
+ })
222
+ return results.every(Boolean)
223
+ }
224
+ }
@@ -0,0 +1,84 @@
1
+ // The run-level cache policy: four axes (local/remote × read/write) and
2
+ // the `--cache=<spec>` grammar that sets them. Pure data + parsing; no I/O.
3
+
4
+ import { UserError } from '../util/index.js'
5
+
6
+ /**
7
+ * Independent read/write control over the two cache layers (local +
8
+ * remote). Replaces the old single `noCache` boolean: each axis can be
9
+ * toggled on its own so `--force` (re-execute but still refresh the
10
+ * cache) is distinct from `--no-cache` (disable everything).
11
+ *
12
+ * Only the task-artifact get/save path is gated. `recordRun`, `stats`,
13
+ * `prune`, key derivation, and prefetch-ingest are never affected — they
14
+ * are bookkeeping/analytics that a run policy has no business disabling.
15
+ */
16
+ export interface CachePolicy {
17
+ localRead: boolean
18
+ localWrite: boolean
19
+ remoteRead: boolean
20
+ remoteWrite: boolean
21
+ }
22
+
23
+ /** All four axes on — the default when no cache flag is passed. */
24
+ export const FULL_CACHE_POLICY: CachePolicy = {
25
+ localRead: true,
26
+ localWrite: true,
27
+ remoteRead: true,
28
+ remoteWrite: true,
29
+ }
30
+
31
+ /**
32
+ * Parse a `--cache=<spec>` value into a `CachePolicy`, starting from a
33
+ * base (defaults to {@link FULL_CACHE_POLICY}). The spec is a
34
+ * comma-separated list of `layer:flags` segments where `layer` is
35
+ * `local` or `remote` and `flags` is any subset of `r` (read) and `w`
36
+ * (write), order-independent and possibly empty.
37
+ *
38
+ * A mentioned layer is set EXACTLY to its flags (read = includes `r`,
39
+ * write = includes `w`); an unmentioned layer keeps its base value.
40
+ * So `local:rw,remote:r` = remote read-only, `remote:` = remote fully
41
+ * off, `local:r` = local read-only with remote untouched.
42
+ *
43
+ * Throws a {@link UserError} on an unknown layer, an unknown flag, a
44
+ * duplicated flag, a missing colon, or a repeated layer.
45
+ */
46
+ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_POLICY): CachePolicy {
47
+ const out: CachePolicy = { ...base }
48
+ const seen = new Set<string>()
49
+ for (const rawSeg of spec.split(',')) {
50
+ const seg = rawSeg.trim()
51
+ if (seg.length === 0) continue
52
+ const colon = seg.indexOf(':')
53
+ if (colon < 0) {
54
+ throw new UserError(`invalid --cache segment '${seg}': expected '<layer>:<flags>'`)
55
+ }
56
+ const layer = seg.slice(0, colon)
57
+ const flags = seg.slice(colon + 1)
58
+ if (layer !== 'local' && layer !== 'remote') {
59
+ throw new UserError(`invalid --cache layer '${layer}': expected 'local' or 'remote'`)
60
+ }
61
+ if (seen.has(layer)) {
62
+ throw new UserError(`--cache layer '${layer}' specified twice`)
63
+ }
64
+ seen.add(layer)
65
+ const flagSet = new Set<string>()
66
+ for (const ch of flags) {
67
+ if (ch !== 'r' && ch !== 'w') {
68
+ throw new UserError(`invalid --cache flag '${ch}' for '${layer}': expected 'r' and/or 'w'`)
69
+ }
70
+ if (flagSet.has(ch)) {
71
+ throw new UserError(`--cache flag '${ch}' repeated for '${layer}'`)
72
+ }
73
+ flagSet.add(ch)
74
+ }
75
+ if (layer === 'local') {
76
+ out.localRead = flagSet.has('r')
77
+ out.localWrite = flagSet.has('w')
78
+ } else {
79
+ out.remoteRead = flagSet.has('r')
80
+ out.remoteWrite = flagSet.has('w')
81
+ }
82
+ }
83
+ return out
84
+ }