@vzn/vx 0.0.12 → 0.0.15
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 +106 -89
- package/launcher.mjs +36 -28
- package/package.json +5 -5
- package/src/cache/cas-backend.ts +2 -2
- package/src/cache/index.ts +1 -2
- package/src/cache/inputs.ts +6 -0
- package/src/cache/layered-cache.ts +22 -2
- package/src/cli/cache.ts +5 -10
- package/src/cli/help.ts +8 -2
- package/src/cli/info.ts +0 -3
- package/src/cli/run.ts +32 -10
- package/src/config.ts +40 -1
- package/src/graph/dependency-spec.ts +16 -0
- package/src/graph/index.ts +10 -1
- package/src/graph/scheduler.ts +230 -35
- package/src/graph/task-graph.ts +69 -17
- package/src/index.ts +29 -4
- package/src/orchestrator/execute-task.ts +66 -10
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/metrics.ts +351 -0
- package/src/orchestrator/options.ts +26 -5
- package/src/orchestrator/plugin-host.ts +5 -10
- package/src/orchestrator/prepare.ts +20 -11
- package/src/orchestrator/protocol.ts +16 -2
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run.ts +45 -0
- package/src/orchestrator/summary.ts +24 -0
- package/src/orchestrator/task-hash.ts +17 -2
- package/src/orchestrator/telemetry.ts +9 -1
- package/src/orchestrator/upstream.ts +34 -6
- package/src/orchestrator/verify.ts +34 -11
- package/src/util/index.ts +1 -0
- package/src/util/size.ts +16 -0
- package/src/workspace/project-loader.ts +43 -1
- package/src/cache/remote-cache.ts +0 -251
- package/src/orchestrator/remote-cache-setup.ts +0 -60
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
// Remote cache HTTP client — speaks the Turborepo /v8/artifacts/ spec.
|
|
2
|
-
//
|
|
3
|
-
// Wire-level only. Knows nothing about local storage, tar formats, or
|
|
4
|
-
// how the orchestrator uses the cache; those concerns live in the
|
|
5
|
-
// LayeredCache. Three operations — GET (read), PUT (write), and HEAD
|
|
6
|
-
// (existence probe for the plan path).
|
|
7
|
-
//
|
|
8
|
-
// Reference servers we want to interop with at the HTTP layer:
|
|
9
|
-
// ducktors/turborepo-remote-cache, Fox32/openturbo-remote-cache,
|
|
10
|
-
// Vercel hosted cache.
|
|
11
|
-
|
|
12
|
-
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
13
|
-
|
|
14
|
-
export interface RemoteCacheConfig {
|
|
15
|
-
/** Base URL of the cache server, e.g. https://cache.example.com */
|
|
16
|
-
baseUrl: string
|
|
17
|
-
/** Bearer token sent as `Authorization: Bearer <token>`. */
|
|
18
|
-
token: string
|
|
19
|
-
/** Optional Turbo-style tenant identifiers; sent as ?teamId=&slug=. */
|
|
20
|
-
teamId?: string
|
|
21
|
-
slug?: string
|
|
22
|
-
/** Per-request timeout in ms. Default 60_000. */
|
|
23
|
-
timeoutMs?: number
|
|
24
|
-
/**
|
|
25
|
-
* HMAC artifact-signing key (Turbo's `remoteCache.signature` scheme).
|
|
26
|
-
* When set, every PUT carries an `x-artifact-tag` over the artifact
|
|
27
|
-
* and every GET response must carry a matching tag — a missing or
|
|
28
|
-
* mismatched tag is a `RemoteCacheError`, never a silent accept.
|
|
29
|
-
*/
|
|
30
|
-
signatureKey?: string
|
|
31
|
-
/**
|
|
32
|
-
* Untrusted per-PR partition id, sent as `x-vx-cache-scope`. A compatible
|
|
33
|
-
* remote-cache server MAY partition untrusted writes by this id so one fork
|
|
34
|
-
* PR can't read or poison another's cache. Ignored by trusted writes and by
|
|
35
|
-
* third-party Turbo servers.
|
|
36
|
-
*/
|
|
37
|
-
cacheScope?: string
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface RemotePutMetadata {
|
|
41
|
-
/** Task duration in ms. Sent as x-artifact-duration. */
|
|
42
|
-
durationMs: number
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface RemoteGetResult {
|
|
46
|
-
body: ArrayBuffer
|
|
47
|
-
durationMs: number | undefined
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Hard cap on a downloaded artifact body (compressed bytes). A malicious or
|
|
52
|
-
* compromised remote (or a MITM answering a GET) must not be able to stream an
|
|
53
|
-
* unbounded body into `res.arrayBuffer()` and exhaust the victim's memory —
|
|
54
|
-
* the download aborts past this and degrades to a cache miss. Mirrors the
|
|
55
|
-
* server-side PUT cap.
|
|
56
|
-
*/
|
|
57
|
-
export const MAX_REMOTE_ARTIFACT_BYTES = 512 * 1024 * 1024
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Read a response body, aborting once cumulative bytes exceed `max`. Streams
|
|
61
|
-
* via the body reader so a lying (or absent) content-length can't defeat the
|
|
62
|
-
* cap. Falls back to `arrayBuffer()` only when the body isn't a stream.
|
|
63
|
-
*
|
|
64
|
-
* Exported for tests: the `max` param lets a unit test exercise the
|
|
65
|
-
* mid-stream abort with a tiny cap instead of a 512 MiB body.
|
|
66
|
-
*/
|
|
67
|
-
export async function readBodyBounded(res: Response, max: number): Promise<ArrayBuffer> {
|
|
68
|
-
const reader = res.body?.getReader()
|
|
69
|
-
if (reader === undefined) return await res.arrayBuffer()
|
|
70
|
-
const chunks: Uint8Array[] = []
|
|
71
|
-
let total = 0
|
|
72
|
-
for (;;) {
|
|
73
|
-
const { done, value } = await reader.read()
|
|
74
|
-
if (done) break
|
|
75
|
-
if (value === undefined) continue
|
|
76
|
-
total += value.byteLength
|
|
77
|
-
if (total > max) {
|
|
78
|
-
await reader.cancel().catch(() => {})
|
|
79
|
-
throw new Error(`artifact exceeds ${max}-byte download cap`)
|
|
80
|
-
}
|
|
81
|
-
chunks.push(value)
|
|
82
|
-
}
|
|
83
|
-
const out = new Uint8Array(total)
|
|
84
|
-
let off = 0
|
|
85
|
-
for (const c of chunks) {
|
|
86
|
-
out.set(c, off)
|
|
87
|
-
off += c.byteLength
|
|
88
|
-
}
|
|
89
|
-
return out.buffer
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export class RemoteCache {
|
|
93
|
-
constructor(private readonly config: RemoteCacheConfig) {}
|
|
94
|
-
|
|
95
|
-
async get(hash: string): Promise<RemoteGetResult | null> {
|
|
96
|
-
const res = await this.fetch('GET', this.artifactUrl(hash))
|
|
97
|
-
if (res.status === 404) return null
|
|
98
|
-
if (!res.ok) throw new RemoteCacheError(`GET ${hash} → ${res.status}`, res.status)
|
|
99
|
-
// Refuse an honestly-declared oversize body before reading a byte.
|
|
100
|
-
const declaredLen = Number(res.headers.get('content-length') ?? '')
|
|
101
|
-
if (Number.isFinite(declaredLen) && declaredLen > MAX_REMOTE_ARTIFACT_BYTES) {
|
|
102
|
-
throw new RemoteCacheError(
|
|
103
|
-
`GET ${hash} → artifact too large (${declaredLen} bytes > ${MAX_REMOTE_ARTIFACT_BYTES} cap)`,
|
|
104
|
-
res.status,
|
|
105
|
-
)
|
|
106
|
-
}
|
|
107
|
-
// A body that ends before the declared content-length (server died
|
|
108
|
-
// mid-transfer) throws from the reader, not from fetch() — wrap it here
|
|
109
|
-
// so callers see a typed RemoteCacheError and the layered cache can
|
|
110
|
-
// degrade it to a miss. The bounded read also aborts a body that lies
|
|
111
|
-
// about (or omits) its length once it crosses the cap.
|
|
112
|
-
let body: ArrayBuffer
|
|
113
|
-
try {
|
|
114
|
-
body = await readBodyBounded(res, MAX_REMOTE_ARTIFACT_BYTES)
|
|
115
|
-
} catch (err) {
|
|
116
|
-
throw new RemoteCacheError(
|
|
117
|
-
`GET ${hash} → body read failed: ${(err as Error).message}`,
|
|
118
|
-
res.status,
|
|
119
|
-
err,
|
|
120
|
-
)
|
|
121
|
-
}
|
|
122
|
-
if (this.config.signatureKey !== undefined) {
|
|
123
|
-
const received = res.headers.get('x-artifact-tag')
|
|
124
|
-
if (received === null) {
|
|
125
|
-
// A signing deployment must not silently accept unsigned
|
|
126
|
-
// artifacts — that would let an attacker strip the tag.
|
|
127
|
-
throw new RemoteCacheError(
|
|
128
|
-
`GET ${hash} → signature verification enabled but response carries no x-artifact-tag`,
|
|
129
|
-
res.status,
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
if (!this.tagMatches(hash, new Uint8Array(body), received)) {
|
|
133
|
-
throw new RemoteCacheError(`GET ${hash} → x-artifact-tag signature mismatch`, res.status)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
body,
|
|
138
|
-
durationMs: parseIntHeader(res.headers.get('x-artifact-duration')),
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Existence probe — Turbo wire `HEAD /v8/artifacts/:hash` (200 =
|
|
144
|
-
* exists, 404 = not). No body transfer; the plan path (`--dry` /
|
|
145
|
-
* `--graph`) predicts remote hits with this so it never downloads
|
|
146
|
-
* artifacts. Other failures throw a `RemoteCacheError` like `get`;
|
|
147
|
-
* the LayeredCache degrades them to a miss.
|
|
148
|
-
*/
|
|
149
|
-
async has(hash: string): Promise<boolean> {
|
|
150
|
-
const res = await this.fetch('HEAD', this.artifactUrl(hash))
|
|
151
|
-
if (res.status === 404) return false
|
|
152
|
-
if (!res.ok) throw new RemoteCacheError(`HEAD ${hash} → ${res.status}`, res.status)
|
|
153
|
-
return true
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async put(hash: string, body: ArrayBuffer | Uint8Array, meta: RemotePutMetadata): Promise<void> {
|
|
157
|
-
const headers: Record<string, string> = {
|
|
158
|
-
'Content-Type': 'application/octet-stream',
|
|
159
|
-
'Content-Length': String(body.byteLength),
|
|
160
|
-
'x-artifact-duration': String(meta.durationMs),
|
|
161
|
-
}
|
|
162
|
-
if (this.config.signatureKey !== undefined) {
|
|
163
|
-
headers['x-artifact-tag'] = this.artifactTag(
|
|
164
|
-
hash,
|
|
165
|
-
body instanceof Uint8Array ? body : new Uint8Array(body),
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const res = await this.fetch('PUT', this.artifactUrl(hash), { body, headers })
|
|
170
|
-
// Any 2xx is success — Turbo-compatible servers answer 200/201/202.
|
|
171
|
-
if (!res.ok) {
|
|
172
|
-
throw new RemoteCacheError(`PUT ${hash} → ${res.status}`, res.status)
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Turbo's construction (crates/turborepo-cache/src/signature_authentication.rs):
|
|
177
|
-
// base64(HMAC-SHA256(key, utf8(hash) || utf8(teamId ?? '') || body)).
|
|
178
|
-
// Byte-compatible with Turbo so signing servers/clients interop.
|
|
179
|
-
private artifactTag(hash: string, body: Uint8Array): string {
|
|
180
|
-
return createHmac('sha256', this.config.signatureKey!)
|
|
181
|
-
.update(hash)
|
|
182
|
-
.update(this.config.teamId ?? '')
|
|
183
|
-
.update(body)
|
|
184
|
-
.digest('base64')
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
private tagMatches(hash: string, body: Uint8Array, received: string): boolean {
|
|
188
|
-
const expected = Buffer.from(this.artifactTag(hash, body), 'base64')
|
|
189
|
-
const actual = Buffer.from(received, 'base64')
|
|
190
|
-
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
private artifactUrl(hash: string): string {
|
|
194
|
-
return this.urlWithTenancy(`${this.config.baseUrl}/v8/artifacts/${encodeURIComponent(hash)}`)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
private urlWithTenancy(base: string): string {
|
|
198
|
-
const params = new URLSearchParams()
|
|
199
|
-
if (this.config.teamId) params.set('teamId', this.config.teamId)
|
|
200
|
-
if (this.config.slug) params.set('slug', this.config.slug)
|
|
201
|
-
const q = params.toString()
|
|
202
|
-
return q ? `${base}?${q}` : base
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private async fetch(
|
|
206
|
-
method: string,
|
|
207
|
-
url: string,
|
|
208
|
-
init?: { body?: ArrayBuffer | Uint8Array | string; headers?: Record<string, string> },
|
|
209
|
-
): Promise<Response> {
|
|
210
|
-
const timeoutMs = this.config.timeoutMs ?? 60_000
|
|
211
|
-
try {
|
|
212
|
-
return await fetch(url, {
|
|
213
|
-
method,
|
|
214
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
215
|
-
headers: {
|
|
216
|
-
Authorization: `Bearer ${this.config.token}`,
|
|
217
|
-
// The untrusted per-PR partition (an optional server-side extension;
|
|
218
|
-
// a third-party Turbo server ignores it). Isolates one fork PR's
|
|
219
|
-
// writes/reads from another's within the untrusted tier.
|
|
220
|
-
...(this.config.cacheScope !== undefined
|
|
221
|
-
? { 'x-vx-cache-scope': this.config.cacheScope }
|
|
222
|
-
: {}),
|
|
223
|
-
...init?.headers,
|
|
224
|
-
},
|
|
225
|
-
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
226
|
-
})
|
|
227
|
-
} catch (err) {
|
|
228
|
-
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
|
|
229
|
-
throw new RemoteCacheError(`${method} ${url} timed out after ${timeoutMs}ms`, 0, err)
|
|
230
|
-
}
|
|
231
|
-
throw new RemoteCacheError(`${method} ${url} failed: ${(err as Error).message}`, 0, err)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export class RemoteCacheError extends Error {
|
|
237
|
-
constructor(
|
|
238
|
-
message: string,
|
|
239
|
-
public readonly status: number,
|
|
240
|
-
public override readonly cause?: unknown,
|
|
241
|
-
) {
|
|
242
|
-
super(message)
|
|
243
|
-
this.name = 'RemoteCacheError'
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function parseIntHeader(v: string | null): number | undefined {
|
|
248
|
-
if (v === null) return undefined
|
|
249
|
-
const n = Number(v)
|
|
250
|
-
return Number.isFinite(n) ? n : undefined
|
|
251
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Cache,
|
|
3
|
-
type CacheLayer,
|
|
4
|
-
type CachePolicy,
|
|
5
|
-
FULL_CACHE_POLICY,
|
|
6
|
-
LayeredCache,
|
|
7
|
-
RemoteCache,
|
|
8
|
-
} from '../cache/index.js'
|
|
9
|
-
import type { Logger } from './logger.js'
|
|
10
|
-
import { resolveCacheScope } from './run-context.js'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Wrap the local cache in a `LayeredCache` when a remote cache is configured
|
|
14
|
-
* via `VX_REMOTE_CACHE_URL` + a token. This is the escape hatch for a
|
|
15
|
-
* THIRD-PARTY, Turbo-compatible cache server. A `cache` plugin can provide a
|
|
16
|
-
* remote cache INTERNALLY instead, in which case this env path isn't
|
|
17
|
-
* needed.
|
|
18
|
-
*
|
|
19
|
-
* **Trust tier follows the token you present** — the server derives
|
|
20
|
-
* trusted/untrusted from the bearer, so the client just presents whichever
|
|
21
|
-
* token it has: `VX_REMOTE_CACHE_TOKEN` for a trusted context, or
|
|
22
|
-
* `VX_REMOTE_CACHE_PR_TOKEN` for a fork PR (reads the trusted cache, writes
|
|
23
|
-
* only the untrusted scope). A fork-PR CI job simply doesn't hold the trusted
|
|
24
|
-
* secret, so "which token you have" IS the tier — no separate trust flag.
|
|
25
|
-
*
|
|
26
|
-
* Optional env: `VX_REMOTE_CACHE_TEAM_ID`, `VX_REMOTE_CACHE_SLUG`
|
|
27
|
-
* (Turbo tenancy query params), `VX_REMOTE_CACHE_TIMEOUT_MS`,
|
|
28
|
-
* `VX_REMOTE_CACHE_SIGNATURE_KEY` (HMAC artifact signing).
|
|
29
|
-
*/
|
|
30
|
-
export function wrapWithRemoteCache(
|
|
31
|
-
local: Cache,
|
|
32
|
-
log: Logger,
|
|
33
|
-
policy: CachePolicy = FULL_CACHE_POLICY,
|
|
34
|
-
): CacheLayer {
|
|
35
|
-
const url = process.env.VX_REMOTE_CACHE_URL
|
|
36
|
-
const token = process.env.VX_REMOTE_CACHE_TOKEN ?? process.env.VX_REMOTE_CACHE_PR_TOKEN
|
|
37
|
-
if (!url || !token) return local
|
|
38
|
-
|
|
39
|
-
const config: ConstructorParameters<typeof RemoteCache>[0] = { baseUrl: url, token }
|
|
40
|
-
const teamId = process.env.VX_REMOTE_CACHE_TEAM_ID
|
|
41
|
-
if (teamId) config.teamId = teamId
|
|
42
|
-
const slug = process.env.VX_REMOTE_CACHE_SLUG
|
|
43
|
-
if (slug) config.slug = slug
|
|
44
|
-
const timeoutMs = process.env.VX_REMOTE_CACHE_TIMEOUT_MS
|
|
45
|
-
if (timeoutMs) {
|
|
46
|
-
const n = Number(timeoutMs)
|
|
47
|
-
if (Number.isFinite(n) && n > 0) config.timeoutMs = n
|
|
48
|
-
}
|
|
49
|
-
const signatureKey = process.env.VX_REMOTE_CACHE_SIGNATURE_KEY
|
|
50
|
-
if (signatureKey) config.signatureKey = signatureKey
|
|
51
|
-
// Untrusted per-PR isolation: a fork PR's writes land in its own sub-scope.
|
|
52
|
-
const cacheScope = resolveCacheScope(process.env)
|
|
53
|
-
if (cacheScope) config.cacheScope = cacheScope
|
|
54
|
-
|
|
55
|
-
log.status(`remote cache: ${url}`)
|
|
56
|
-
return new LayeredCache(local, new RemoteCache(config), {
|
|
57
|
-
onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
|
|
58
|
-
policy,
|
|
59
|
-
})
|
|
60
|
-
}
|