@vzn/vx 0.0.0 → 0.0.14
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 +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- package/src/workspace/workspace.ts +176 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// LayeredCache — composes the local cache with a remote HTTP cache.
|
|
2
|
+
//
|
|
3
|
+
// Read path: try local. On miss, try remote; on remote hit, ingest
|
|
4
|
+
// the artifact bytes into local so the next read is a local hit.
|
|
5
|
+
//
|
|
6
|
+
// Write path: write to local synchronously. Upload the local artifact
|
|
7
|
+
// to remote as a fire-and-forget background task; failures log a
|
|
8
|
+
// warning but never fail the user's run (the task already succeeded;
|
|
9
|
+
// the only loss is the remote cache entry).
|
|
10
|
+
//
|
|
11
|
+
// The local and remote layers share the SAME artifact format — the
|
|
12
|
+
// `<hash>.tar.zst` bytes ship across the wire verbatim. Metadata
|
|
13
|
+
// (taskId, command, durationMs) travels separately: the caller
|
|
14
|
+
// supplies it to `get()` via the `ctx` arg, and the remote layer
|
|
15
|
+
// surfaces `durationMs` from its response. No stage dirs, no
|
|
16
|
+
// meta.json, no tar.gz wrapping — the artifact is what it is.
|
|
17
|
+
|
|
18
|
+
import type { Cache } from './cache.js'
|
|
19
|
+
import type {
|
|
20
|
+
CacheEntry,
|
|
21
|
+
CacheGetContext,
|
|
22
|
+
CacheKeyInput,
|
|
23
|
+
CacheLayer,
|
|
24
|
+
CachePolicy,
|
|
25
|
+
CacheStats,
|
|
26
|
+
IngestMeta,
|
|
27
|
+
InvocationRecord,
|
|
28
|
+
OutputFileRow,
|
|
29
|
+
PruneOptions,
|
|
30
|
+
PruneResult,
|
|
31
|
+
RunRecord,
|
|
32
|
+
SaveArgs,
|
|
33
|
+
} from './cache.js'
|
|
34
|
+
import { FULL_CACHE_POLICY } from './cache.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a remote cache layer must provide — THE plugin seam for remote
|
|
38
|
+
* caching. Core ships no wire client; a plugin's `cache` capability (or an
|
|
39
|
+
* embedder via `RunOptions.remoteCache`) supplies an implementation speaking
|
|
40
|
+
* whatever protocol it wants, and `LayeredCache` owns everything else:
|
|
41
|
+
* policy gating, in-flight dedup, remote provenance, and the never-fail
|
|
42
|
+
* contract (implementations THROW on failure; LayeredCache degrades every
|
|
43
|
+
* 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.
|
|
46
|
+
*/
|
|
47
|
+
export interface RemoteCacheLayer {
|
|
48
|
+
/** Existence probe (drives the plan path's `--dry` remote prediction). */
|
|
49
|
+
has(hash: string): Promise<boolean>
|
|
50
|
+
/** Fetch an artifact's bytes; `null` = miss. `durationMs` is the
|
|
51
|
+
* producing task's duration when the wire carries it. */
|
|
52
|
+
get(hash: string): Promise<{ body: ArrayBuffer; durationMs: number | undefined } | null>
|
|
53
|
+
/** Store an artifact (fire-and-forget from LayeredCache's perspective). */
|
|
54
|
+
put(hash: string, body: ArrayBuffer | Uint8Array, meta: { durationMs: number }): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cap on concurrent background PUTs. Keeps a burst of cache misses from
|
|
59
|
+
* opening one socket per task; excess uploads queue and drain FIFO.
|
|
60
|
+
*/
|
|
61
|
+
const UPLOAD_CONCURRENCY = 4
|
|
62
|
+
|
|
63
|
+
export interface LayeredCacheOptions {
|
|
64
|
+
/** Called for remote-related errors that the layer suppresses. */
|
|
65
|
+
onRemoteError?: (err: Error) => void
|
|
66
|
+
/**
|
|
67
|
+
* The 4-axis read/write policy. The local slice (read/write) is
|
|
68
|
+
* already applied to the inner `Cache` by the caller; this layer reads
|
|
69
|
+
* `remoteRead` / `remoteWrite` to gate its OWN remote operations.
|
|
70
|
+
* Default: everything on.
|
|
71
|
+
*/
|
|
72
|
+
policy?: CachePolicy
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class LayeredCache implements CacheLayer {
|
|
76
|
+
/**
|
|
77
|
+
* In-flight remote pulls keyed by hash. `prefetch` and `get` both go
|
|
78
|
+
* through here, so a key probed concurrently by both resolves a
|
|
79
|
+
* SINGLE remote GET. Each promise resolves `true` iff the artifact
|
|
80
|
+
* was successfully ingested into local. Entries are retained for the
|
|
81
|
+
* run's lifetime: a settled `false` records "remote already had no
|
|
82
|
+
* such artifact (or it was corrupt)", which lets `get` skip a second
|
|
83
|
+
* lazy probe of the same dead hash. The map is bounded by the number
|
|
84
|
+
* of distinct task keys in a run — negligible.
|
|
85
|
+
*/
|
|
86
|
+
private readonly inflight = new Map<string, Promise<boolean>>()
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Hashes whose local artifact was materialized FROM the remote layer
|
|
90
|
+
* this run (by `prefetch` or `get`'s read-through). A later `get`
|
|
91
|
+
* finds them as a local hit, but the work was still saved by the
|
|
92
|
+
* remote cache — so we flip `source` to `'remote'` and the
|
|
93
|
+
* orchestrator reports `cache-hit-remote`. Without this, a prefetch
|
|
94
|
+
* followed by a `get` would mislabel a genuine remote hit as local.
|
|
95
|
+
*/
|
|
96
|
+
private readonly remoteSourced = new Set<string>()
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Background write-through uploads. `save()` returns after the local
|
|
100
|
+
* write + byte capture; the PUT runs here so a cache-miss task never
|
|
101
|
+
* holds its scheduler worker slot for the upload round-trip. Bounded
|
|
102
|
+
* at UPLOAD_CONCURRENCY; `run()` awaits `drainUploads()` before
|
|
103
|
+
* `cache.close()`.
|
|
104
|
+
*/
|
|
105
|
+
private readonly uploadQueue: Array<() => Promise<void>> = []
|
|
106
|
+
private activeUploads = 0
|
|
107
|
+
private drainWaiters: Array<() => void> = []
|
|
108
|
+
|
|
109
|
+
private readonly policy: CachePolicy
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
private readonly local: Cache,
|
|
113
|
+
private readonly remote: RemoteCacheLayer,
|
|
114
|
+
private readonly options: LayeredCacheOptions = {},
|
|
115
|
+
) {
|
|
116
|
+
this.policy = options.policy ?? FULL_CACHE_POLICY
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async key(input: CacheKeyInput): Promise<string> {
|
|
120
|
+
return await this.local.key(input)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
124
|
+
// No-op when remote reads are off — there's nothing to warm from.
|
|
125
|
+
if (!this.policy.remoteRead) return false
|
|
126
|
+
return await this.pullFromRemote(hash, ctx)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
130
|
+
const localHit = await this.local.get(hash, ctx)
|
|
131
|
+
if (localHit) {
|
|
132
|
+
// A prefetch this run may have materialized this entry FROM
|
|
133
|
+
// remote; the local row exists now, but the work was saved by
|
|
134
|
+
// the remote cache, so the provenance stays 'remote'.
|
|
135
|
+
return this.remoteSourced.has(hash) ? { ...localHit, source: 'remote' } : localHit
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Remote reads disabled (e.g. `--cache=remote:`): a local miss is a
|
|
139
|
+
// real miss; never touch the remote layer.
|
|
140
|
+
if (!this.policy.remoteRead) return null
|
|
141
|
+
|
|
142
|
+
// A prefetch may already have probed this hash (resolved) or be
|
|
143
|
+
// mid-flight. Awaiting the shared promise guarantees AT MOST ONE
|
|
144
|
+
// remote GET per key: a settled `false` means remote had nothing
|
|
145
|
+
// (no second probe), and a settled/in-flight `true` means the
|
|
146
|
+
// artifact was/will-be ingested locally — re-read below.
|
|
147
|
+
const ingested = await this.pullFromRemote(hash, ctx)
|
|
148
|
+
if (!ingested) return null
|
|
149
|
+
|
|
150
|
+
// The artifact is now in local, but this *lookup* was a remote
|
|
151
|
+
// hit — flip the source so callers can distinguish "saved work
|
|
152
|
+
// via the remote cache" from "saved work via a prior local run".
|
|
153
|
+
const materialized = await this.local.get(hash, ctx)
|
|
154
|
+
return materialized ? { ...materialized, source: 'remote' } : null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Existence probe: local first, then a remote HEAD — no body
|
|
158
|
+
// transfer, no ingest, so the plan path stays read-only even against
|
|
159
|
+
// a remote cache. Remote errors degrade to a miss (never throw).
|
|
160
|
+
async has(hash: string): Promise<'local' | 'remote' | null> {
|
|
161
|
+
if ((await this.local.has(hash)) === 'local') return 'local'
|
|
162
|
+
if (!this.policy.remoteRead) return null
|
|
163
|
+
try {
|
|
164
|
+
return (await this.remote.has(hash)) ? 'remote' : null
|
|
165
|
+
} catch (err) {
|
|
166
|
+
this.reportRemoteError(err)
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Single implementation of the remote read-through, shared by
|
|
173
|
+
* `prefetch` and `get`. Idempotent per hash via `inflight`: the first
|
|
174
|
+
* caller starts the GET + validate + ingest; concurrent and later
|
|
175
|
+
* callers await the same promise. Resolves `true` when the artifact
|
|
176
|
+
* ends up in local, `false` on a remote miss / error / corruption
|
|
177
|
+
* (degrades to a cache miss; the error is reported, never thrown).
|
|
178
|
+
*/
|
|
179
|
+
private pullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
180
|
+
const existing = this.inflight.get(hash)
|
|
181
|
+
if (existing) return existing
|
|
182
|
+
const p = this.doPullFromRemote(hash, ctx)
|
|
183
|
+
this.inflight.set(hash, p)
|
|
184
|
+
return p
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async doPullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
188
|
+
let remoteResult
|
|
189
|
+
try {
|
|
190
|
+
remoteResult = await this.remote.get(hash)
|
|
191
|
+
} catch (err) {
|
|
192
|
+
this.reportRemoteError(err)
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
if (!remoteResult) return false
|
|
196
|
+
|
|
197
|
+
// Ingest the remote bytes into local using the caller-supplied
|
|
198
|
+
// taskId/command plus the remote-reported durationMs. The remote
|
|
199
|
+
// layer carries durationMs as an HTTP header (x-artifact-duration);
|
|
200
|
+
// taskId + command come from the orchestrator's TaskNode in scope.
|
|
201
|
+
// Without `ctx`, we can't populate a meaningful entries row, so
|
|
202
|
+
// ingest with placeholders — caller-side typing nudges everyone
|
|
203
|
+
// toward passing ctx.
|
|
204
|
+
const meta: IngestMeta = {
|
|
205
|
+
taskId: ctx?.taskId ?? `${hash}#unknown`,
|
|
206
|
+
command: ctx?.command ?? '',
|
|
207
|
+
durationMs: remoteResult.durationMs ?? 0,
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await this.local.ingest(hash, new Uint8Array(remoteResult.body), meta)
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// The bytes came off the network — a corrupt/truncated remote
|
|
213
|
+
// artifact must degrade to a cache miss (task re-executes), not
|
|
214
|
+
// crash the run. Local-layer reads outside this block still
|
|
215
|
+
// propagate: local corruption is a real fault, not a network one.
|
|
216
|
+
this.reportRemoteError(err)
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
this.remoteSourced.add(hash)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
outputsPath(hash: string): string {
|
|
224
|
+
return this.local.outputsPath(hash)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async hashFile(filePath: string): Promise<string> {
|
|
228
|
+
return await this.local.hashFile(filePath)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
|
|
232
|
+
await this.local.restoreOutputs(hash, projectDir, workspaceRoot)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async save(args: SaveArgs): Promise<void> {
|
|
236
|
+
// Local write honors its own gate inside Cache.save (no-op when
|
|
237
|
+
// local writes are disabled).
|
|
238
|
+
await this.local.save(args)
|
|
239
|
+
if (!this.policy.remoteWrite) return
|
|
240
|
+
// Write-through upload, OFF the task's critical path. The bytes are
|
|
241
|
+
// captured NOW — read from the artifact the local layer just wrote
|
|
242
|
+
// (same format on both sides, no repacking), or, when local writes
|
|
243
|
+
// are disabled (`--cache=local:,remote:rw`), packed in memory while
|
|
244
|
+
// the output files are guaranteed still on disk. The PUT itself then
|
|
245
|
+
// runs in the bounded background pool so the task's worker slot is
|
|
246
|
+
// released immediately; `run()` awaits `drainUploads()` before
|
|
247
|
+
// closing the cache. Errors are logged, not propagated: the task
|
|
248
|
+
// already succeeded; we don't fail it on cache-server issues.
|
|
249
|
+
let bytes: Uint8Array
|
|
250
|
+
try {
|
|
251
|
+
bytes = this.local.localWritesEnabled
|
|
252
|
+
? await Bun.file(this.local.outputsPath(args.hash)).bytes()
|
|
253
|
+
: await this.local.packArtifactBytes(args)
|
|
254
|
+
} catch (err) {
|
|
255
|
+
this.reportRemoteError(err)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
const durationMs = args.entry.durationMs
|
|
259
|
+
this.enqueueUpload(async () => {
|
|
260
|
+
try {
|
|
261
|
+
await this.remote.put(args.hash, bytes, { durationMs })
|
|
262
|
+
} catch (err) {
|
|
263
|
+
this.reportRemoteError(err)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Resolves once every queued + in-flight background upload settles.
|
|
270
|
+
* `run()` calls this next to the prefetch drain, before
|
|
271
|
+
* `cache.close()` — an upload reading state after close would race.
|
|
272
|
+
*/
|
|
273
|
+
async drainUploads(): Promise<void> {
|
|
274
|
+
if (this.activeUploads === 0 && this.uploadQueue.length === 0) return
|
|
275
|
+
await new Promise<void>((resolve) => this.drainWaiters.push(resolve))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private enqueueUpload(job: () => Promise<void>): void {
|
|
279
|
+
this.uploadQueue.push(job)
|
|
280
|
+
this.pumpUploads()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private pumpUploads(): void {
|
|
284
|
+
while (this.activeUploads < UPLOAD_CONCURRENCY && this.uploadQueue.length > 0) {
|
|
285
|
+
const job = this.uploadQueue.shift()!
|
|
286
|
+
this.activeUploads++
|
|
287
|
+
// Jobs never reject (each wraps its PUT in the never-fail guard).
|
|
288
|
+
void job().finally(() => {
|
|
289
|
+
this.activeUploads--
|
|
290
|
+
this.pumpUploads()
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
if (this.activeUploads === 0 && this.uploadQueue.length === 0 && this.drainWaiters.length > 0) {
|
|
294
|
+
const waiters = this.drainWaiters
|
|
295
|
+
this.drainWaiters = []
|
|
296
|
+
for (const w of waiters) w()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
|
|
301
|
+
await this.local.ingest(hash, compressed, meta)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
305
|
+
// Output-file fingerprints live in the local SQLite layer only —
|
|
306
|
+
// they describe the state on this machine's filesystem.
|
|
307
|
+
return this.local.loadOutputFilesBatch(hashes)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
|
|
311
|
+
return this.local.isOutputsCurrent(projectDir, expected)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
recordRun(run: RunRecord): void {
|
|
315
|
+
this.local.recordRun(run)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
recordRuns(runs: readonly RunRecord[]): void {
|
|
319
|
+
this.local.recordRuns(runs)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
323
|
+
this.local.recordRunBundle(bundle)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
stats(): CacheStats {
|
|
327
|
+
return this.local.stats()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async prune(options: PruneOptions): Promise<PruneResult> {
|
|
331
|
+
return await this.local.prune(options)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
close(): void {
|
|
335
|
+
this.local.close()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private reportRemoteError(err: unknown): void {
|
|
339
|
+
const e = err instanceof Error ? err : new Error(String(err))
|
|
340
|
+
// The remote cache is fully optional: NO remote failure — a 500, a
|
|
341
|
+
// network drop, a corrupt artifact, even a throwing onRemoteError
|
|
342
|
+
// callback — may ever fail the run. We report and degrade to a
|
|
343
|
+
// cache miss. The callback is guarded so a buggy reporter can't
|
|
344
|
+
// turn an optional-cache hiccup into a run failure.
|
|
345
|
+
try {
|
|
346
|
+
if (this.options.onRemoteError) {
|
|
347
|
+
this.options.onRemoteError(e)
|
|
348
|
+
} else {
|
|
349
|
+
process.stderr.write(`[vx] remote cache: ${e.message}\n`)
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// swallow — reporting must never escalate
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|