@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- 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.
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
return
|
|
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
|
-
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
269
|
-
return
|
|
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
|
|
278
|
-
//
|
|
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
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// are
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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.
|
|
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
|
-
|
|
356
|
-
this.local.
|
|
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
|
-
|
|
360
|
-
this.local.
|
|
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
|
-
|
|
372
|
-
return
|
|
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
|
+
}
|