@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.
- package/docs/caching.md +84 -28
- package/docs/cli.md +273 -408
- package/docs/execution.md +19 -44
- package/docs/features.md +13 -15
- package/docs/patterns.md +5 -7
- package/docs/schema.md +68 -61
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +19 -6
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +60 -11
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +10 -2
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +32 -12
- package/src/cache/layered-cache.ts +112 -315
- package/src/cache/plugin-cache.ts +240 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +92 -8
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +44 -13
- package/src/cache/scoped-cache.ts +22 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +11 -7
- package/src/cli/foreign-flags.ts +10 -22
- package/src/cli/help.ts +22 -38
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +8 -6
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +20 -10
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +10 -61
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +31 -191
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +72 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +132 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +73 -34
- package/src/cli/why.ts +14 -5
- package/src/cli/workspace-config.ts +2 -2
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +12 -16
- package/src/graph/task-graph.ts +119 -21
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +20 -11
- package/src/{orchestrator → history}/history.ts +61 -5
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +14 -23
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +21 -29
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +47 -429
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +3 -74
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +77 -47
- package/src/orchestrator/plugin.ts +86 -14
- package/src/orchestrator/prepare.ts +74 -31
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +23 -6
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +36 -4
- package/src/orchestrator/run-report.ts +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +313 -555
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +6 -83
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +166 -21
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +179 -63
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +13 -23
- package/src/{orchestrator → util}/colors.ts +49 -4
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +25 -3
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +39 -0
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +197 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -276
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /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
|
+
}
|
package/src/cache/policy.ts
CHANGED
|
@@ -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
|
+
}
|