@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. 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
+ }