@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -0,0 +1,240 @@
1
+ // A plugin's own entries in the cache the workspace declares (`ctx.cache`,
2
+ // owner 2026-10-14): the local store first, then each remote in declared
3
+ // order, under the run's policy and scope. The plugin speaks names; core
4
+ // derives the key, so no plugin reaches another's entries or a task's. An
5
+ // entry is written once and never replaced (the cache is immutable). Every
6
+ // failure is an absent answer, never a warning (M: no extra runtime
7
+ // warnings): what a plugin keeps here is a hint.
8
+
9
+ import type { Database } from 'bun:sqlite'
10
+ import { xxh3hex } from '../util/index.js'
11
+ import type { CachePolicy } from './policy.js'
12
+ import type { ResolvedRemote } from './remote-lane.js'
13
+ import { scopedKey } from './scoped-cache.js'
14
+ import { runReadKeys } from './run-store.js'
15
+
16
+ /** The largest entry kept or read, in bytes: a remote's larger body is a miss, read no further. */
17
+ export const PLUGIN_ENTRY_MAX_BYTES = 1024 * 1024
18
+
19
+ /** What a plugin's `ctx.cache` offers. Keys are the plugin's own names. */
20
+ export interface PluginCache {
21
+ /** The bytes stored under `key`, from the first layer holding them; null when none does. */
22
+ get(key: string): Promise<Uint8Array | null>
23
+ /** Store `bytes` under `key` in every layer the policy writes, where it is not already; empty bytes store nothing. */
24
+ put(key: string, bytes: Uint8Array): Promise<void>
25
+ /** Whether some layer holds `key`; null when no layer could answer. */
26
+ has(key: string): Promise<boolean | null>
27
+ /**
28
+ * The subset of `keys` some layer holds, in one probe per layer; null
29
+ * when a readable remote has no batch probe: ask `get` one at a time.
30
+ */
31
+ hasMany(keys: readonly string[]): Promise<Set<string> | null>
32
+ /**
33
+ * What core kept of a run under `name` (a `log` or `outputs` name its
34
+ * `RunReport` hands `teardown`), from the first readable remote holding
35
+ * it; null when none does. Uncapped: core wrote it.
36
+ */
37
+ getRun(name: string): Promise<Uint8Array | null>
38
+ }
39
+
40
+ export interface PluginCacheHost {
41
+ /** The run's index; its store holds `plugin_blobs`. */
42
+ readonly db: Database
43
+ readonly policy: CachePolicy
44
+ readonly remotes: readonly ResolvedRemote[]
45
+ readonly scope: string | undefined
46
+ }
47
+
48
+ /** The key `name` stores under for `plugin`: task-key shaped, so every wire stores it unchanged. */
49
+ function entryKey(plugin: string, name: string): string {
50
+ return xxh3hex(`vx-plugin\0${plugin}\0${name}`)
51
+ }
52
+
53
+ /**
54
+ * A remote body's bytes; null past the cap (read no further than it) and for
55
+ * an empty or odd body, which kept would hide every later good one: an entry
56
+ * is written once (Security, #3580).
57
+ */
58
+ async function capped(body: unknown, max = PLUGIN_ENTRY_MAX_BYTES): Promise<Uint8Array | null> {
59
+ if (body instanceof Blob) {
60
+ if (body.size === 0 || body.size > max) return null
61
+ return new Uint8Array(await body.arrayBuffer())
62
+ }
63
+ if (!(body instanceof Response) || body.body === null) return null
64
+ const reader = body.body.getReader()
65
+ const chunks: Uint8Array[] = []
66
+ let size = 0
67
+ for (;;) {
68
+ const { done, value } = await reader.read()
69
+ if (done) break
70
+ size += value.byteLength
71
+ if (size > max) {
72
+ await reader.cancel().catch(() => {})
73
+ return null
74
+ }
75
+ chunks.push(value)
76
+ }
77
+ if (size === 0) return null
78
+ const out = new Uint8Array(size)
79
+ let at = 0
80
+ for (const c of chunks) {
81
+ out.set(c, at)
82
+ at += c.byteLength
83
+ }
84
+ return out
85
+ }
86
+
87
+ /**
88
+ * `plugin`'s view of the cache. A scoped run reads the default key, then
89
+ * the scope's, and writes only the scope's, as tasks do (`ScopedCache`).
90
+ */
91
+ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache {
92
+ const { db, policy, scope } = host
93
+ const readers = host.remotes.filter((r) => r.read)
94
+ const writers = host.remotes.filter((r) => r.write)
95
+ const reads = (name: string): string[] => {
96
+ const key = entryKey(plugin, name)
97
+ return scope === undefined ? [key] : [key, scopedKey(scope, key)]
98
+ }
99
+ const writeKey = (name: string): string => {
100
+ const key = entryKey(plugin, name)
101
+ return scope === undefined ? key : scopedKey(scope, key)
102
+ }
103
+
104
+ const localGet = (key: string): Uint8Array | null => {
105
+ if (!policy.localRead) return null
106
+ try {
107
+ const row = db.query('SELECT bytes FROM plugin_blobs WHERE key = ?').get(key) as {
108
+ bytes: Uint8Array
109
+ } | null
110
+ return row === null ? null : new Uint8Array(row.bytes)
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+ const localHas = (keys: readonly string[]): Set<string> => {
116
+ const found = new Set<string>()
117
+ if (!policy.localRead || keys.length === 0) return found
118
+ try {
119
+ const rows = db
120
+ .query(`SELECT key FROM plugin_blobs WHERE key IN (${keys.map(() => '?').join(', ')})`)
121
+ .all(...keys) as Array<{ key: string }>
122
+ for (const r of rows) found.add(r.key)
123
+ } catch {
124
+ // an unreadable index holds nothing
125
+ }
126
+ return found
127
+ }
128
+ const localPut = (key: string, bytes: Uint8Array): void => {
129
+ try {
130
+ db.query('INSERT OR IGNORE INTO plugin_blobs(key, bytes, at) VALUES (?, ?, ?)').run(
131
+ key,
132
+ bytes,
133
+ Date.now(),
134
+ )
135
+ } catch {
136
+ // a read-only or busy index keeps nothing
137
+ }
138
+ }
139
+
140
+ return {
141
+ async get(name) {
142
+ const keys = reads(name)
143
+ for (const key of keys) {
144
+ const hit = localGet(key)
145
+ if (hit !== null) return hit
146
+ }
147
+ for (const { remote } of readers) {
148
+ for (const key of keys) {
149
+ try {
150
+ const got = await remote.get(key)
151
+ if (got === null) continue
152
+ const bytes = await capped(got.body)
153
+ if (bytes === null) continue
154
+ if (policy.localWrite) localPut(key, bytes)
155
+ return bytes
156
+ } catch {
157
+ // a failing remote is a miss there
158
+ }
159
+ }
160
+ }
161
+ return null
162
+ },
163
+
164
+ async put(name, bytes) {
165
+ if (bytes.byteLength === 0 || bytes.byteLength > PLUGIN_ENTRY_MAX_BYTES) return
166
+ const key = writeKey(name)
167
+ if (policy.localWrite) localPut(key, bytes)
168
+ for (const { remote } of writers) {
169
+ try {
170
+ await remote.put(key, new Blob([bytes]), { durationMs: 0 })
171
+ } catch {
172
+ // the other layers still keep it
173
+ }
174
+ }
175
+ },
176
+
177
+ async has(name) {
178
+ const keys = reads(name)
179
+ if (localHas(keys).size > 0) return true
180
+ let answered = policy.localRead
181
+ for (const { remote } of readers) {
182
+ for (const key of keys) {
183
+ try {
184
+ if (await remote.has(key)) return true
185
+ answered = true
186
+ } catch {
187
+ // no answer from this remote
188
+ }
189
+ }
190
+ }
191
+ return answered ? false : null
192
+ },
193
+
194
+ async hasMany(names) {
195
+ const byKey = new Map<string, string>()
196
+ for (const name of names) for (const key of reads(name)) byKey.set(key, name)
197
+ const keys = [...byKey.keys()]
198
+ const found = new Set<string>()
199
+ for (const key of localHas(keys)) found.add(byKey.get(key)!)
200
+ for (const { remote } of readers) {
201
+ const ask = keys.filter((k) => !found.has(byKey.get(k)!))
202
+ if (ask.length === 0) break
203
+ if (remote.hasMany === undefined) return null
204
+ try {
205
+ const got: unknown = await remote.hasMany(ask)
206
+ if (!(got instanceof Set)) return null
207
+ for (const key of got) if (byKey.has(key as string)) found.add(byKey.get(key as string)!)
208
+ } catch {
209
+ return null
210
+ }
211
+ }
212
+ return found
213
+ },
214
+
215
+ async getRun(name) {
216
+ for (const { remote } of readers) {
217
+ for (const key of runReadKeys(name, scope)) {
218
+ try {
219
+ const got = await remote.get(key)
220
+ if (got === null) continue
221
+ const bytes = await capped(got.body, Infinity)
222
+ if (bytes !== null) return bytes
223
+ } catch {
224
+ // a failing remote is a miss there
225
+ }
226
+ }
227
+ }
228
+ return null
229
+ },
230
+ }
231
+ }
232
+
233
+ /** A cache with no layer: every answer absent. What a context built outside a run holds. */
234
+ export const absentPluginCache: PluginCache = {
235
+ get: async () => null,
236
+ put: async () => {},
237
+ has: async () => null,
238
+ hasMany: async () => null,
239
+ getRun: async () => null,
240
+ }
@@ -90,6 +90,15 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
90
90
  return out
91
91
  }
92
92
 
93
+ /**
94
+ * The `--cache-policy` spec for `p`, both layers named, so the line
95
+ * `vx last` shows pastes back as the flag: `local:rw,remote:r`.
96
+ */
97
+ export function formatCachePolicy(p: CachePolicy): string {
98
+ const flags = (r: boolean, w: boolean): string => `${r ? 'r' : ''}${w ? 'w' : ''}`
99
+ return `local:${flags(p.localRead, p.localWrite)},remote:${flags(p.remoteRead, p.remoteWrite)}`
100
+ }
101
+
93
102
  /** `--no-cache` and `--force`, which still apply over whichever spec set the base. */
94
103
  export interface CacheFlags {
95
104
  /** `--no-cache`: every axis off. Beats `force`. */
@@ -0,0 +1,383 @@
1
+ // One declared remote as `LayeredCache` reaches it: what the run lets it
2
+ // do, the boundary its answers cross (a plugin's shape is checked, its throw
3
+ // becomes a miss), its own upload pool, and its failures said once per
4
+ // class with the endpoint named. The order across remotes, the local store
5
+ // and provenance are `LayeredCache`'s; nothing here knows another remote.
6
+
7
+ import type { RemoteKeyTraffic, RemoteTraffic } from './cache.js'
8
+ import type { RemoteCacheLayer } from './layered-cache.js'
9
+
10
+ /** One remote, resolved: its wire and what the run's policy and its `access` let it do. */
11
+ export interface ResolvedRemote {
12
+ readonly remote: RemoteCacheLayer
13
+ readonly read: boolean
14
+ readonly write: boolean
15
+ /** Told of each failure this remote degrades to a miss; the default writes stderr. */
16
+ readonly onError?: (err: Error) => void
17
+ }
18
+
19
+ /**
20
+ * Cap on concurrent background PUTs per remote. Keeps a burst of cache
21
+ * misses from opening one socket per task; excess uploads queue and drain
22
+ * FIFO.
23
+ */
24
+ const UPLOAD_CONCURRENCY = 4
25
+
26
+ /** The seam call a failure happened in, as the warning names it. */
27
+ export type RemoteOp = 'probe' | 'download' | 'upload'
28
+
29
+ const PREPOSITION: Record<RemoteOp, string> = { probe: 'at', download: 'from', upload: 'to' }
30
+
31
+ /**
32
+ * What a remote layer resolves is a plugin's, so its shape is a boundary
33
+ * (item 252): a `get` that resolved `{ body: 'abc' }` was reported as
34
+ * "corrupt artifact … not a readable archive" — the bytes blamed for the
35
+ * plugin's shape — and a `hasMany` that resolved an array reached the
36
+ * prefetch pass's `.has()`. Named here and degraded to a miss, like every
37
+ * other remote failure.
38
+ */
39
+ function invalidRemoteResult(what: string): Error {
40
+ return new Error(
41
+ `remote cache layer returned an invalid result: ${what} — a plugin bug, degraded to a miss`,
42
+ )
43
+ }
44
+
45
+ function isBody(value: unknown): value is Blob | Response {
46
+ return value instanceof Blob || value instanceof Response
47
+ }
48
+
49
+ function describeValue(value: unknown): string {
50
+ if (value === null) return 'null'
51
+ if (Array.isArray(value)) return 'an array'
52
+ // The pre-stream contract's shape, named so an unported layer reads its fix.
53
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
54
+ return `a ${value.constructor.name}`
55
+ return typeof value === 'object' ? 'an object' : typeof value
56
+ }
57
+
58
+ /** The layer's `endpoint` as it may be printed: no userinfo, query or fragment. */
59
+ function printableEndpoint(endpoint: unknown): string | undefined {
60
+ if (typeof endpoint !== 'string' || endpoint === '') return undefined
61
+ if (!URL.canParse(endpoint)) return endpoint
62
+ const u = new URL(endpoint)
63
+ if (u.username === '' && u.password === '' && u.search === '' && u.hash === '') return endpoint
64
+ u.username = ''
65
+ u.password = ''
66
+ u.search = ''
67
+ u.hash = ''
68
+ return u.toString()
69
+ }
70
+
71
+ /**
72
+ * What makes two failures "the same way": the error's `code` when it
73
+ * carries one (a gRPC status, an errno, Bun's `ConnectionRefused`), because
74
+ * gRPC writes the elapsed time into its message and no two deadline messages
75
+ * match; else the message with the artifact's hash taken out, so a 500 on
76
+ * one hash and a 500 on the next are one class. The operation is not part of
77
+ * it: an unreachable server fails the probe, the download and the upload
78
+ * alike, and that is one fact.
79
+ */
80
+ function failureClass(err: Error, hash: string | undefined): string {
81
+ const code = (err as { code?: unknown }).code
82
+ if (typeof code === 'string' || typeof code === 'number') return `${err.name}\0${code}`
83
+ return hash === undefined ? err.message : err.message.replaceAll(hash, '\0')
84
+ }
85
+
86
+ /** A batch probe names no one hash, so its line counts them. */
87
+ function batchOf(hashes: readonly string[]): { batch: string } {
88
+ return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
89
+ }
90
+
91
+ export class RemoteLane {
92
+ readonly read: boolean
93
+ readonly write: boolean
94
+ private readonly remote: RemoteCacheLayer
95
+ private readonly endpoint: string | undefined
96
+ private readonly onError: ((err: Error) => void) | undefined
97
+
98
+ /**
99
+ * Hashes a batch probe found absent here, so a later fetch skips this
100
+ * remote without a round-trip. Same point-in-time staleness as the probe.
101
+ */
102
+ private readonly absent = new Set<string>()
103
+ /** What a `hasMany` said this remote holds, as `absent` records the rest. */
104
+ private readonly held = new Set<string>()
105
+
106
+ /**
107
+ * A remote the run writes but does not read (`--force`): which of the
108
+ * run's up-front keys it already holds, from one `hasMany`. `found` is
109
+ * null when the probe failed.
110
+ */
111
+ private presence: Promise<{ asked: Set<string>; found: Set<string> | null } | null> | undefined
112
+
113
+ private readonly uploadQueue: Array<() => Promise<void>> = []
114
+ private activeUploads = 0
115
+ private drainWaiters: Array<() => void> = []
116
+ /** Set by `stop()`: the run was stopped, so no upload starts and the drain waits on none. */
117
+ private stopped = false
118
+ private onStop: () => void = () => {}
119
+ private readonly stopping = new Promise<void>((resolve) => {
120
+ this.onStop = resolve
121
+ })
122
+
123
+ /**
124
+ * One entry per failure class said: its first line went out, `repeats`
125
+ * counts the ones held back. A run's requests are concurrent, so an
126
+ * unreachable server otherwise printed its bare runtime message once per
127
+ * request (three lines for one task over the Turbo wire, item 749).
128
+ */
129
+ private readonly reported = new Map<string, { cause: string; repeats: number }>()
130
+
131
+ constructor(
132
+ resolved: ResolvedRemote,
133
+ private readonly traffic: RemoteTraffic,
134
+ ) {
135
+ this.remote = resolved.remote
136
+ this.read = resolved.read
137
+ this.write = resolved.write
138
+ this.onError = resolved.onError
139
+ this.endpoint = printableEndpoint(resolved.remote.endpoint)
140
+ }
141
+
142
+ /**
143
+ * Existence probe; a failure is a miss. A key a `hasMany` this run
144
+ * answered for is not asked again: `--dry` batches its keys first.
145
+ */
146
+ async has(hash: string): Promise<boolean> {
147
+ if (this.held.has(hash)) return true
148
+ if (this.absent.has(hash)) return false
149
+ try {
150
+ return Boolean(await this.remote.has(hash))
151
+ } catch (err) {
152
+ this.report('probe', hash, err)
153
+ return false
154
+ }
155
+ }
156
+
157
+ /**
158
+ * The subset of `hashes` stored here in one round-trip, recording the
159
+ * rest as absent; `null` when this remote cannot batch or the probe
160
+ * failed, which the caller reads as "no batch info".
161
+ */
162
+ async hasMany(hashes: readonly string[]): Promise<Set<string> | null> {
163
+ if (this.remote.hasMany === undefined) return null
164
+ let found: unknown
165
+ try {
166
+ found = await this.remote.hasMany(hashes)
167
+ } catch (err) {
168
+ this.report('probe', batchOf(hashes), err)
169
+ return null
170
+ }
171
+ if (found === null || found === undefined) return null
172
+ if (!(found instanceof Set)) {
173
+ this.report(
174
+ 'probe',
175
+ batchOf(hashes),
176
+ invalidRemoteResult(
177
+ `hasMany() resolved ${describeValue(found)} (expected a Set of the hashes present, or null)`,
178
+ ),
179
+ )
180
+ return null
181
+ }
182
+ const set = found as Set<string>
183
+ for (const h of hashes) (set.has(h) ? this.held : this.absent).add(h)
184
+ return set
185
+ }
186
+
187
+ /** The artifact's body, or null on a miss, a known absence, a failure or a malformed answer. */
188
+ async get(hash: string): Promise<{ body: Blob | Response; durationMs?: number } | null> {
189
+ if (this.absent.has(hash)) return null
190
+ let result: unknown
191
+ try {
192
+ result = await this.remote.get(hash)
193
+ } catch (err) {
194
+ this.report('download', hash, err)
195
+ return null
196
+ }
197
+ if (!result) return null
198
+ if (typeof result !== 'object' || !isBody((result as { body?: unknown }).body)) {
199
+ const shape =
200
+ typeof result !== 'object'
201
+ ? describeValue(result)
202
+ : `body is ${describeValue((result as { body?: unknown }).body)}`
203
+ this.report(
204
+ 'download',
205
+ hash,
206
+ invalidRemoteResult(
207
+ `get() resolved ${shape} (expected { body: Blob | Response, durationMs } or null)`,
208
+ ),
209
+ )
210
+ return null
211
+ }
212
+ return result as { body: Blob | Response; durationMs?: number }
213
+ }
214
+
215
+ /**
216
+ * Start the batch probe `presence` reads. A lane that reads, never
217
+ * writes, or cannot batch (no `hasMany`) ignores it: each upload then
218
+ * asks `has` for its own key.
219
+ */
220
+ probeBeforeUpload(hashes: Promise<readonly string[]>): void {
221
+ if (this.read || !this.write || this.remote.hasMany === undefined) return
222
+ this.presence = hashes.then(
223
+ async (asked) =>
224
+ asked.length === 0 ? null : { asked: new Set(asked), found: await this.hasMany(asked) },
225
+ () => null,
226
+ )
227
+ }
228
+
229
+ /**
230
+ * Whether this remote already holds `hash`, asked only by a lane the run
231
+ * does not read: an entry is immutable, so a key it holds is the same
232
+ * bytes and is not sent again. Answered from the batch probe when it
233
+ * covered the key, else by one `has`; a failure reads as absent, and the
234
+ * upload goes as before.
235
+ */
236
+ private async holds(hash: string): Promise<boolean> {
237
+ const probed = await this.presence
238
+ if (probed !== undefined && probed !== null && probed.found !== null && probed.asked.has(hash))
239
+ return probed.found.has(hash)
240
+ return this.has(hash)
241
+ }
242
+
243
+ /** Queue a PUT in this remote's bounded pool; `body` is asked for when the job starts. */
244
+ upload(
245
+ hash: string,
246
+ durationMs: number,
247
+ body: () => { body: Blob; release?: () => Promise<void> },
248
+ tally: { count: number; bytes: number; ms: number; failed: number },
249
+ ): void {
250
+ const key = this.trafficOf(hash)
251
+ key.uploading++
252
+ this.traffic.inFlight++
253
+ this.uploadQueue.push(async () => {
254
+ let release: (() => Promise<void>) | undefined
255
+ try {
256
+ if (this.stopped) return
257
+ if (!this.read && (await this.holds(hash))) return
258
+ if (this.stopped) return
259
+ const start = performance.now()
260
+ const got = body()
261
+ release = got.release
262
+ await this.remote.put(hash, got.body, { durationMs })
263
+ tally.count++
264
+ tally.bytes += got.body.size
265
+ tally.ms += Math.round(performance.now() - start)
266
+ key.landed = true
267
+ } catch (err) {
268
+ tally.failed++
269
+ this.report('upload', hash, err)
270
+ } finally {
271
+ await release?.()
272
+ key.uploading--
273
+ this.traffic.inFlight--
274
+ // Nothing sent and nothing failed (the remote held it, or the run
275
+ // stopped first): no traffic, so the footer counts no upload.
276
+ if (key.uploading === 0 && !key.landed && key.failures.length === 0)
277
+ this.traffic.keys.delete(hash)
278
+ }
279
+ })
280
+ this.pump()
281
+ }
282
+
283
+ /** Resolves once every queued and in-flight upload has settled, or the run was stopped. */
284
+ async drain(): Promise<void> {
285
+ // A probe no upload waited on still runs: settled before the run tears
286
+ // the plugin down.
287
+ await Promise.race([this.presence, this.stopping])
288
+ if (this.stopped || (this.activeUploads === 0 && this.uploadQueue.length === 0)) return
289
+ await new Promise<void>((resolve) => this.drainWaiters.push(resolve))
290
+ }
291
+
292
+ /**
293
+ * The run was stopped (Ctrl-C, SIGTERM, an embedder's abort): queued
294
+ * uploads never start and the drain stops waiting on the ones in flight,
295
+ * which a plugin cannot be asked to cancel. A slow remote held a stopped
296
+ * run past the signal's exit bound, which then exited with no footer.
297
+ */
298
+ stop(): void {
299
+ this.stopped = true
300
+ this.onStop()
301
+ for (const resolve of this.drainWaiters.splice(0)) resolve()
302
+ }
303
+
304
+ /** Say the held-back repeats; the run drained first, so the counts are final. */
305
+ close(): void {
306
+ for (const { cause, repeats } of this.reported.values()) {
307
+ if (repeats === 0) continue
308
+ this.emit(
309
+ new Error(
310
+ `${repeats} more request${repeats === 1 ? '' : 's'} failed the same way: ${cause}`,
311
+ ),
312
+ )
313
+ }
314
+ this.reported.clear()
315
+ }
316
+
317
+ /**
318
+ * `subject` is the artifact's hash, or the batch for a `hasMany`. The
319
+ * wire's own message says what went wrong but not on what or where: an
320
+ * upload deadline read `The operation timed out.` with no upload, hash or
321
+ * server in it (item 749).
322
+ */
323
+ report(op: RemoteOp, subject: string | { batch: string }, err: unknown): void {
324
+ const e = err instanceof Error ? err : new Error(String(err))
325
+ const hash = typeof subject === 'string' ? subject : undefined
326
+ // A batch probe's failure is no one task's: each of its hashes is asked
327
+ // again on its own, and that ask is the one counted.
328
+ if (hash !== undefined) {
329
+ const failures = this.trafficOf(hash).failures
330
+ const said = `${op}: ${e.message}`
331
+ if (!failures.includes(said)) failures.push(said)
332
+ }
333
+ const cls = failureClass(e, hash)
334
+ const seen = this.reported.get(cls)
335
+ if (seen !== undefined) {
336
+ seen.repeats++
337
+ return
338
+ }
339
+ this.reported.set(cls, { cause: e.message, repeats: 0 })
340
+ const what = typeof subject === 'string' ? subject : subject.batch
341
+ const where = this.endpoint === undefined ? '' : ` ${PREPOSITION[op]} ${this.endpoint}`
342
+ this.emit(new Error(`${op} ${what}${where} failed: ${e.message}`, { cause: e }))
343
+ }
344
+
345
+ private trafficOf(hash: string): RemoteKeyTraffic {
346
+ let key = this.traffic.keys.get(hash)
347
+ if (key === undefined) {
348
+ key = { uploading: 0, landed: false, failures: [] }
349
+ this.traffic.keys.set(hash, key)
350
+ }
351
+ return key
352
+ }
353
+
354
+ private pump(): void {
355
+ while (this.activeUploads < UPLOAD_CONCURRENCY && this.uploadQueue.length > 0) {
356
+ const job = this.uploadQueue.shift()!
357
+ this.activeUploads++
358
+ // Jobs never reject (each wraps its PUT in the never-fail guard).
359
+ void job().finally(() => {
360
+ this.activeUploads--
361
+ this.pump()
362
+ })
363
+ }
364
+ if (this.activeUploads === 0 && this.uploadQueue.length === 0 && this.drainWaiters.length > 0) {
365
+ const waiters = this.drainWaiters
366
+ this.drainWaiters = []
367
+ for (const w of waiters) w()
368
+ }
369
+ }
370
+
371
+ private emit(e: Error): void {
372
+ // The remote cache is fully optional: NO remote failure — a 500, a
373
+ // network drop, a corrupt artifact, even a throwing reporter — may
374
+ // ever fail the run. A buggy reporter can't turn an optional-cache
375
+ // hiccup into a run failure.
376
+ try {
377
+ if (this.onError) this.onError(e)
378
+ else process.stderr.write(`[vx] remote cache: ${e.message}\n`)
379
+ } catch {
380
+ // swallow — reporting must never escalate
381
+ }
382
+ }
383
+ }