@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -0,0 +1,87 @@
1
+ // A symlinked input keys the content behind it, as Nx reads a linked file
2
+ // where it points (owner, 2026-10-16: "Do like nx"). Its own fold stays the
3
+ // blob of its target STRING (file-hashes.ts), so a retarget to equal bytes
4
+ // moves the key; this is what follows it. The target string alone let an
5
+ // edit behind the link replay the old output (Growth, 2026-10-16), wherever
6
+ // the target lies: in the project, elsewhere in the workspace, outside it.
7
+ // A directory link keys every file beneath it, tracked or not, where Nx
8
+ // does not enter one: an edit there is an edit to what the task reads.
9
+ // `--affected` (workspace/affected.ts) and `vx watch` (cli/watch.ts) read
10
+ // the same targets.
11
+
12
+ import { readdirSync, realpathSync, statSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import { xxh3hex } from '../util/index.js'
15
+ import { absentOr } from './key-fold.js'
16
+
17
+ /** What a dangling link, or a loop of links, holds behind it: nothing to read. */
18
+ const NOTHING_BEHIND = ''
19
+
20
+ /**
21
+ * Directories a linked directory's walk never enters, as Nx's walker never
22
+ * does (its HARDCODED_IGNORE_PATTERNS): a link to a workspace would
23
+ * otherwise key the cache each run writes.
24
+ */
25
+ const SKIPPED = ['.git', 'node_modules', '.vx/cache', '.yarn/cache']
26
+ const skipped = (rel: string): boolean => SKIPPED.some((s) => rel === s || rel.endsWith(`/${s}`))
27
+
28
+ /**
29
+ * The files under `dir` a linked directory keys, relative and `/`-separated,
30
+ * sorted: every regular file and link at any depth. A link is listed, never
31
+ * entered (`behindLink` follows it), so a loop of directories ends.
32
+ */
33
+ export function filesBehind(dir: string): string[] {
34
+ const out: string[] = []
35
+ const walk = (abs: string, rel: string): void => {
36
+ let entries
37
+ try {
38
+ entries = readdirSync(abs, { withFileTypes: true })
39
+ } catch {
40
+ return
41
+ }
42
+ for (const e of entries) {
43
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
44
+ if (e.isDirectory()) {
45
+ if (!skipped(childRel)) walk(path.join(abs, e.name), childRel)
46
+ } else if (e.isFile() || e.isSymbolicLink()) out.push(childRel)
47
+ }
48
+ }
49
+ walk(dir, '')
50
+ return out.sort()
51
+ }
52
+
53
+ /**
54
+ * The content behind the symlink at `link`, as one string: a file's identity
55
+ * (`hashFile` of its real path), a directory's files and theirs, or
56
+ * `NOTHING_BEHIND` for a dangling link or a loop. A special file (a FIFO, a
57
+ * device) is named, never read: a read can block. Never memoized: an upstream
58
+ * task may write behind the link between two keys.
59
+ */
60
+ export async function behindLink(
61
+ link: string,
62
+ hashFile: (file: string) => Promise<string>,
63
+ seen: ReadonlySet<string> = new Set(),
64
+ ): Promise<string> {
65
+ let real: string
66
+ let st
67
+ try {
68
+ real = realpathSync(link)
69
+ st = statSync(real)
70
+ } catch {
71
+ return NOTHING_BEHIND
72
+ }
73
+ if (seen.has(real)) return NOTHING_BEHIND
74
+ if (st.isFile()) return `file\0${await hashFile(real).catch(absentOr)}`
75
+ if (!st.isDirectory()) return 'special'
76
+ const inner = new Set(seen).add(real)
77
+ const parts = await Promise.all(
78
+ filesBehind(real).map(async (rel) => {
79
+ const abs = path.join(real, rel)
80
+ const id = await hashFile(abs).catch(absentOr)
81
+ return id.startsWith('120000:')
82
+ ? `${rel}\0${id}\0${await behindLink(abs, hashFile, inner)}`
83
+ : `${rel}\0${id}`
84
+ }),
85
+ )
86
+ return `dir\0${xxh3hex(parts.join('\n'))}`
87
+ }
@@ -9,7 +9,7 @@
9
9
  import type { Database } from 'bun:sqlite'
10
10
  import { xxh3hex } from '../util/index.js'
11
11
  import type { CachePolicy } from './policy.js'
12
- import type { RemoteCacheLayer } from './layered-cache.js'
12
+ import type { ResolvedRemote } from './remote-lane.js'
13
13
  import { scopedKey } from './scoped-cache.js'
14
14
  import { runReadKeys } from './run-store.js'
15
15
 
@@ -37,18 +37,11 @@ export interface PluginCache {
37
37
  getRun(name: string): Promise<Uint8Array | null>
38
38
  }
39
39
 
40
- /** One remote as the plugin cache reaches it: its wire and what the policy lets it do. */
41
- export interface PluginCacheRemote {
42
- readonly remote: RemoteCacheLayer
43
- readonly read: boolean
44
- readonly write: boolean
45
- }
46
-
47
40
  export interface PluginCacheHost {
48
41
  /** The run's index; its store holds `plugin_blobs`. */
49
42
  readonly db: Database
50
43
  readonly policy: CachePolicy
51
- readonly remotes: readonly PluginCacheRemote[]
44
+ readonly remotes: readonly ResolvedRemote[]
52
45
  readonly scope: string | undefined
53
46
  }
54
47
 
@@ -174,7 +167,6 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
174
167
  if (policy.localWrite) localPut(key, bytes)
175
168
  for (const { remote } of writers) {
176
169
  try {
177
- if (await remote.has(key)) continue
178
170
  await remote.put(key, new Blob([bytes]), { durationMs: 0 })
179
171
  } catch {
180
172
  // the other layers still keep it
@@ -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
+ }
@@ -13,11 +13,11 @@ const INSERT_RUNS = `
13
13
  started_at, ended_at,
14
14
  run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
15
15
  cache_hit, attempts, cached,
16
- blocked_by, timed_out, sandbox_violations, not_ready, restored
16
+ blocked_by, timed_out, sandbox_violations, not_ready, restored, remote_error
17
17
  )
18
18
  VALUES `
19
- /** One `runs` row's placeholders: the 22 columns `bindRun` fills. */
20
- const RUN_TUPLE = `(${Array(22).fill('?').join(', ')})`
19
+ /** One `runs` row's placeholders: the 23 columns `bindRun` fills. */
20
+ const RUN_TUPLE = `(${Array(23).fill('?').join(', ')})`
21
21
 
22
22
  /** An INSERT of `n` `runs` rows. */
23
23
  function insertRunsSql(n: number): string {
@@ -25,7 +25,7 @@ function insertRunsSql(n: number): string {
25
25
  }
26
26
 
27
27
  /**
28
- * Rows per INSERT: 40 × 22 variables stays under 999, the oldest default
28
+ * Rows per INSERT: 40 × 23 variables stays under 999, the oldest default
29
29
  * ceiling. A statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
30
30
  * (2026-10-03).
31
31
  */
@@ -80,9 +80,9 @@ export class RunHistory {
80
80
  up_to_date_count, restored_local_count, restored_remote_count,
81
81
  exit_ok,
82
82
  commit_sha, branch, dirty, ci, ci_provider,
83
- host, os, arch, vx_version, tags
83
+ host, os, arch, vx_version
84
84
  )
85
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
86
86
  ON CONFLICT(run_id) DO NOTHING
87
87
  `,
88
88
  )
@@ -204,6 +204,22 @@ export class RunHistory {
204
204
  .immediate()
205
205
  }
206
206
 
207
+ /**
208
+ * The remote failures a run's uploads met after its rows were written:
209
+ * the uploads drain once the history is in (run.ts).
210
+ */
211
+ recordRemoteErrors(
212
+ runId: string,
213
+ errors: readonly (readonly [hash: string, text: string])[],
214
+ ): void {
215
+ const update = this.db.prepare('UPDATE runs SET remote_error = ? WHERE run_id = ? AND hash = ?')
216
+ this.db
217
+ .transaction(() => {
218
+ for (const [hash, text] of errors) update.run(text, runId, hash)
219
+ })
220
+ .immediate()
221
+ }
222
+
207
223
  /**
208
224
  * Retention: the runs table grows by one row per executed task per
209
225
  * invocation — a 2000-task repo accretes ~20k rows in days. 30 days
@@ -220,7 +236,7 @@ export class RunHistory {
220
236
  }
221
237
  }
222
238
 
223
- /** Bind a RunRecord to one `RUN_TUPLE`'s 22 positional parameters. */
239
+ /** Bind a RunRecord to one `RUN_TUPLE`'s 23 positional parameters. */
224
240
  function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
225
241
  return [
226
242
  // The ONE place the no-key sentinel is applied, so the column's
@@ -250,6 +266,7 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
250
266
  run.sandboxViolations ?? null,
251
267
  run.notReady ?? null,
252
268
  run.restored === undefined ? null : run.restored ? 1 : 0,
269
+ run.remoteError ?? null,
253
270
  ]
254
271
  }
255
272
 
@@ -287,6 +304,5 @@ function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
287
304
  inv.os,
288
305
  inv.arch,
289
306
  inv.vxVersion,
290
- inv.tags,
291
307
  ]
292
308
  }
@@ -6,7 +6,7 @@
6
6
  // the cache is free). Plugins read them by name (`ctx.cache.getRun`).
7
7
 
8
8
  import { xxh3hex } from '../util/index.js'
9
- import type { PluginCacheRemote } from './plugin-cache.js'
9
+ import type { ResolvedRemote } from './remote-lane.js'
10
10
  import { scopedKey } from './scoped-cache.js'
11
11
  import { tarPackInto, tarSize } from './tar-stream.js'
12
12
 
@@ -24,7 +24,7 @@ export function runReadKeys(name: string, scope: string | undefined): string[] {
24
24
  }
25
25
 
26
26
  /** Whether some remote takes this run's writes: only then does core keep run bytes. */
27
- export function writesRunStore(remotes: readonly PluginCacheRemote[]): boolean {
27
+ export function writesRunStore(remotes: readonly ResolvedRemote[]): boolean {
28
28
  return remotes.some((r) => r.write)
29
29
  }
30
30
 
@@ -34,7 +34,7 @@ export function writesRunStore(remotes: readonly PluginCacheRemote[]): boolean {
34
34
  * a run keeps here is a record, never its verdict.
35
35
  */
36
36
  export async function putRunEntry(
37
- remotes: readonly PluginCacheRemote[],
37
+ remotes: readonly ResolvedRemote[],
38
38
  scope: string | undefined,
39
39
  name: string,
40
40
  bytes: Uint8Array,
@@ -44,7 +44,7 @@ export async function putRunEntry(
44
44
  for (const { remote, write } of remotes) {
45
45
  if (!write) continue
46
46
  try {
47
- if (!(await remote.has(key))) await remote.put(key, new Blob([bytes]), { durationMs: 0 })
47
+ await remote.put(key, new Blob([bytes]), { durationMs: 0 })
48
48
  kept = true
49
49
  } catch {
50
50
  // the other remotes still keep it
@@ -98,7 +98,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
98
98
  not_ready TEXT,
99
99
  -- v31: on a hit, 1 when outputs were restored, 0 when the disk
100
100
  -- already matched (up-to-date); NULL on every other status.
101
- restored INTEGER
101
+ restored INTEGER,
102
+ -- v40: each remote read or write of the key that failed, as
103
+ -- 'upload: <message>', '; '-joined; NULL when none did.
104
+ remote_error TEXT
102
105
  );
103
106
  -- Two whole-table indexes only, and both APPEND: every row of a run carries the
104
107
  -- same run_id and a started_at newer than everything before it, so
@@ -199,7 +202,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
199
202
  );
200
203
  -- v22 (Tier 3): one header row per vx-run invocation. The runs
201
204
  -- table is per-task; this is the per-invocation record that
202
- -- carries git/CI/host context, the command, tags, and run-level
205
+ -- carries git/CI/host context, the command, and run-level
203
206
  -- counts so the dashboard never reconstructs a header with a
204
207
  -- lossy GROUP BY over runs.
205
208
  CREATE TABLE IF NOT EXISTS main.invocations (
@@ -230,8 +233,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
230
233
  host TEXT,
231
234
  os TEXT,
232
235
  arch TEXT,
233
- vx_version TEXT NOT NULL,
234
- tags TEXT NOT NULL DEFAULT '{}'
236
+ vx_version TEXT NOT NULL
235
237
  );
236
238
  CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
237
239
  CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
@@ -33,7 +33,10 @@ export class ScopedCache implements CacheLayer {
33
33
  readonly local?: CacheLayer['local']
34
34
  readonly hasRemote?: boolean
35
35
  readonly drainUploads?: () => Promise<void>
36
+ readonly stopUploads?: () => void
36
37
  readonly remoteHasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
38
+ /** A scoped run saves under the scope's key, so that is the key asked. */
39
+ readonly probeBeforeUpload?: (hashes: Promise<readonly string[]>) => void
37
40
  readonly getMany?: (
38
41
  hashes: readonly string[],
39
42
  ctx?: (hash: string) => CacheGetContext,
@@ -48,7 +51,11 @@ export class ScopedCache implements CacheLayer {
48
51
  if (inner.local !== undefined) this.local = inner.local
49
52
  if (inner.hasRemote !== undefined) this.hasRemote = inner.hasRemote
50
53
  if (inner.drainUploads !== undefined) this.drainUploads = () => inner.drainUploads!()
54
+ if (inner.stopUploads !== undefined) this.stopUploads = () => inner.stopUploads!()
51
55
  if (inner.remoteHasMany !== undefined) this.remoteHasMany = (h) => this.scopedHasMany(h)
56
+ if (inner.probeBeforeUpload !== undefined)
57
+ this.probeBeforeUpload = (h) =>
58
+ inner.probeBeforeUpload!(h.then((keys) => keys.map((k) => this.own(k))))
52
59
  if (inner.getMany !== undefined) this.getMany = (h, ctx) => this.scopedGetMany(h, ctx)
53
60
  }
54
61