@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.
- package/README.md +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- 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 +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- 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 +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- 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,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 {
|
|
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
|
|
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
|
+
}
|
package/src/cache/run-history.ts
CHANGED
|
@@ -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
|
|
20
|
-
const RUN_TUPLE = `(${Array(
|
|
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 ×
|
|
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
|
|
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
|
|
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
|
}
|
package/src/cache/run-store.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
package/src/cache/schema.ts
CHANGED
|
@@ -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,
|
|
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
|
|