@vzn/vx-migrate 0.0.644 → 0.0.646
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/LICENSE +1 -1
- package/README.md +14 -16
- package/package.json +2 -2
- package/src/adoption-plugin.ts +9 -11
- package/src/index.ts +36 -10
- package/src/mapping-cache.ts +14 -6
- package/src/migrate-turbo.ts +2 -6
- package/src/migrate.ts +5 -14
- package/src/nx/nx-map.ts +30 -15
- package/src/nx/nx-upstream.ts +5 -5
- package/src/nx-cache/index.ts +13 -18
- package/src/shared-outputs.ts +16 -10
- package/src/tracked-outputs.ts +10 -6
- package/src/turbo/index.ts +16 -10
- package/src/turbo/turbo-map.ts +8 -177
- package/src/turbo/turbo-reads.ts +183 -0
- package/src/turbo-cache/index.ts +107 -57
- package/src/turbo-cache/oidc.ts +31 -16
- package/src/turbo-cache/scope.ts +89 -0
- package/src/remote-scope.ts +0 -12
package/src/turbo-cache/index.ts
CHANGED
|
@@ -16,16 +16,17 @@ import { unlink } from 'node:fs/promises'
|
|
|
16
16
|
import { tmpdir } from 'node:os'
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import {
|
|
19
|
+
type ArtifactInfo,
|
|
19
20
|
definePlugin,
|
|
21
|
+
type LentAccess,
|
|
20
22
|
type RemoteCacheLayer,
|
|
21
|
-
type RemoteKey,
|
|
22
23
|
type RemoteOpenContext,
|
|
23
24
|
type VxPlugin,
|
|
24
25
|
refuseUnknownOptions,
|
|
25
26
|
type PluginOptionKinds,
|
|
26
27
|
} from '@vzn/vx'
|
|
27
28
|
import { deadline } from '../remote-deadline.js'
|
|
28
|
-
import {
|
|
29
|
+
import { keyedByRef, readScopes, remoteScope, type RemoteScope } from './scope.js'
|
|
29
30
|
import { OutageBreaker } from '../remote-breaker.js'
|
|
30
31
|
import { Cooldown, withRetry } from '../remote-retry.js'
|
|
31
32
|
import {
|
|
@@ -82,6 +83,14 @@ export interface TurboCacheOptions {
|
|
|
82
83
|
* `remoteCache.preflight`; default off.
|
|
83
84
|
*/
|
|
84
85
|
preflight?: boolean
|
|
86
|
+
/**
|
|
87
|
+
* How branches stay apart (default `'header'`): every read names the run's
|
|
88
|
+
* refs in `x-artifact-read-scopes`, for a server that files writes by the
|
|
89
|
+
* CI token. `'key'` also folds the run's ref into each key and reads each
|
|
90
|
+
* ref's key in order, for a server that ignores the header. The server's
|
|
91
|
+
* OIDC policy alone decides where a write lands.
|
|
92
|
+
*/
|
|
93
|
+
scope?: RemoteScope
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
/** turbo.json's `remoteCache` block: the source below Turbo's environment, as in Turbo. */
|
|
@@ -128,6 +137,17 @@ export const MIN_SIGNATURE_KEY_LENGTH = 32
|
|
|
128
137
|
// Bun's own hasher and the global `crypto`, not `node:crypto`: importing that
|
|
129
138
|
// module cost every `@vzn/vx-migrate` user 6.6 ms at startup, remote cache
|
|
130
139
|
// or not (G-121).
|
|
140
|
+
/** A size and a duration as the wire spells them (a header string, a JSON number), kept when they are counts. */
|
|
141
|
+
function infoOf(size: unknown, durationMs: unknown): ArtifactInfo {
|
|
142
|
+
const count = (v: unknown): number | undefined => {
|
|
143
|
+
const n = typeof v === 'string' && v !== '' ? Number(v) : v
|
|
144
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : undefined
|
|
145
|
+
}
|
|
146
|
+
const s = count(size)
|
|
147
|
+
const d = count(durationMs)
|
|
148
|
+
return { ...(s === undefined ? {} : { size: s }), ...(d === undefined ? {} : { durationMs: d }) }
|
|
149
|
+
}
|
|
150
|
+
|
|
131
151
|
function updateLength(mac: Bun.CryptoHasher, byteLength: number): void {
|
|
132
152
|
const len = Buffer.alloc(8)
|
|
133
153
|
len.writeBigUInt64LE(BigInt(byteLength))
|
|
@@ -409,6 +429,9 @@ async function boundedText(res: Response, max: number): Promise<string | null> {
|
|
|
409
429
|
*/
|
|
410
430
|
const MAX_SIGNED_BODY = 2 * 1024 ** 3 + ((2 * 1024 ** 3) >> 8) + 64 * 1024
|
|
411
431
|
|
|
432
|
+
/** A lent token ending sooner than this is exchanged again first, so an agent's request never carries a spent one. */
|
|
433
|
+
const LEND_MARGIN_MS = 5 * 60_000
|
|
434
|
+
|
|
412
435
|
/**
|
|
413
436
|
* The seam implementation: `has` is HEAD, `hasMany` is the batch query,
|
|
414
437
|
* `get`/`put` carry `x-artifact-duration` (and the tag when signing). An
|
|
@@ -445,11 +468,19 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
445
468
|
private token: string | undefined
|
|
446
469
|
/** Set by `open` when the token came from the keychain: a refusal deletes it. */
|
|
447
470
|
private forget: (() => Promise<void>) | undefined
|
|
448
|
-
/** The
|
|
449
|
-
private exchanged: string
|
|
471
|
+
/** The tokens OIDC exchanges gave, the last one current, each revoked when the run ends. */
|
|
472
|
+
private exchanged: string[] = []
|
|
473
|
+
/** When the current exchanged token ends (epoch ms), when the server said. */
|
|
474
|
+
private expiresAt: number | undefined
|
|
475
|
+
/** Set by `open` when this machine has a lent token and no token of its own: read per request. */
|
|
476
|
+
private borrowed: (() => string | undefined) | undefined
|
|
477
|
+
/** The token a run running its tasks here lent (`CacheContext.lent`). */
|
|
478
|
+
lent: (() => string | undefined) | undefined
|
|
450
479
|
private readonly keychainRemote: KeychainRemote
|
|
451
480
|
/** The remote's rate limit, which every request of this client waits out. */
|
|
452
481
|
private readonly cooldown = new Cooldown()
|
|
482
|
+
/** Headers every read carries: the refs it may be answered from (`readScopes`). */
|
|
483
|
+
reads: Record<string, string> = {}
|
|
453
484
|
constructor(
|
|
454
485
|
private readonly config: TurboCacheConfig,
|
|
455
486
|
private readonly fetchImpl: typeof fetch = fetch,
|
|
@@ -470,26 +501,59 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
470
501
|
}
|
|
471
502
|
|
|
472
503
|
async open(ctx: RemoteOpenContext): Promise<void> {
|
|
473
|
-
|
|
504
|
+
const lent = this.lent?.() === undefined ? undefined : this.lent
|
|
505
|
+
if (this.config.oidcMissing && lent === undefined)
|
|
474
506
|
ctx.warn(
|
|
475
507
|
'oidc: GitHub gave this job no OIDC token: it needs `permissions: id-token: write` (a fork or Dependabot pull request never gets one)',
|
|
476
508
|
)
|
|
477
509
|
if (this.token !== undefined) return
|
|
510
|
+
// A lent token beats this machine's own OIDC: the server files a write
|
|
511
|
+
// by the token, so an agent's own (another workflow, another ref)
|
|
512
|
+
// filed the task where its run could not read it.
|
|
513
|
+
if (lent !== undefined) {
|
|
514
|
+
this.borrowed = lent
|
|
515
|
+
return
|
|
516
|
+
}
|
|
478
517
|
if (this.config.oidc !== undefined) {
|
|
479
518
|
try {
|
|
480
|
-
|
|
519
|
+
await this.exchange()
|
|
481
520
|
} catch (err) {
|
|
482
521
|
throw new Error(
|
|
483
522
|
`oidc: ${err instanceof Error ? err.message : String(err)}; local cache only this run`,
|
|
484
523
|
)
|
|
485
524
|
}
|
|
486
|
-
this.exchanged = this.token
|
|
487
525
|
return
|
|
488
526
|
}
|
|
489
527
|
this.token = await keychainToken(ctx, this.keychainRemote)
|
|
490
528
|
this.forget = () => ctx.forgetSecret({ key: this.keychainRemote.key })
|
|
491
529
|
}
|
|
492
530
|
|
|
531
|
+
private async exchange(): Promise<void> {
|
|
532
|
+
const got = await exchangeOidc(this.config.oidc!, this.fetchImpl, this.wait)
|
|
533
|
+
this.token = got.token
|
|
534
|
+
this.expiresAt = got.expiresAt
|
|
535
|
+
this.exchanged.push(got.token)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The token an OIDC exchange gave this run, for a machine that runs its
|
|
540
|
+
* tasks (`RemoteCacheLayer.lend`); exchanged again when it ends within
|
|
541
|
+
* five minutes. A token of any other kind is never lent.
|
|
542
|
+
*/
|
|
543
|
+
async lend(): Promise<LentAccess | undefined> {
|
|
544
|
+
if (this.exchanged.length === 0) return undefined
|
|
545
|
+
if (this.expiresAt !== undefined && this.expiresAt - Date.now() < LEND_MARGIN_MS)
|
|
546
|
+
await this.exchange()
|
|
547
|
+
return {
|
|
548
|
+
token: this.token!,
|
|
549
|
+
...(this.expiresAt === undefined ? {} : { expiresAt: this.expiresAt }),
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private bearer(): string | undefined {
|
|
554
|
+
return this.borrowed?.() ?? this.token
|
|
555
|
+
}
|
|
556
|
+
|
|
493
557
|
private url(pathname: string): string {
|
|
494
558
|
const u = new URL(`${this.config.apiUrl}/v8/artifacts${pathname}`)
|
|
495
559
|
// Turbo sends a team id only in Vercel's `team_` form (its API client's
|
|
@@ -502,7 +566,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
502
566
|
|
|
503
567
|
private headers(extra: Record<string, string> = {}, auth = true): Record<string, string> {
|
|
504
568
|
return {
|
|
505
|
-
...(auth ? { Authorization: `Bearer ${this.
|
|
569
|
+
...(auth ? { Authorization: `Bearer ${this.bearer()}` } : {}),
|
|
506
570
|
'x-artifact-client-interactive': process.stdout.isTTY ? '1' : '0',
|
|
507
571
|
...extra,
|
|
508
572
|
}
|
|
@@ -529,7 +593,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
529
593
|
const res = await this.fetchImpl(url, {
|
|
530
594
|
method: 'OPTIONS',
|
|
531
595
|
headers: {
|
|
532
|
-
Authorization: `Bearer ${this.
|
|
596
|
+
Authorization: `Bearer ${this.bearer()}`,
|
|
533
597
|
'Access-Control-Request-Method': method === 'HEAD' ? 'GET' : method,
|
|
534
598
|
'Access-Control-Request-Headers': ['Authorization', ...Object.keys(headers)].join(', '),
|
|
535
599
|
},
|
|
@@ -558,7 +622,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
558
622
|
pathname: string,
|
|
559
623
|
init: { body?: Blob | string; headers?: Record<string, string>; timeoutMs?: number } = {},
|
|
560
624
|
): Promise<Response | undefined> {
|
|
561
|
-
if (this.
|
|
625
|
+
if (this.bearer() === undefined) return undefined
|
|
562
626
|
const timeoutMs = init.timeoutMs ?? this.config.timeoutMs
|
|
563
627
|
// 0 is Turbo's "no deadline".
|
|
564
628
|
const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
|
|
@@ -608,49 +672,33 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
608
672
|
return res
|
|
609
673
|
}
|
|
610
674
|
|
|
611
|
-
/** Revoke
|
|
675
|
+
/** Revoke each token this run exchanged, once; a throw is the caller's warning. */
|
|
612
676
|
async revoke(): Promise<void> {
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
|
|
677
|
+
const tokens = this.exchanged.splice(0)
|
|
678
|
+
for (const token of tokens)
|
|
679
|
+
await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
|
|
617
680
|
}
|
|
618
681
|
|
|
619
|
-
async has(hash: string
|
|
620
|
-
if (this.disabled) return
|
|
621
|
-
const res = await this.request('HEAD', `/${hash}`, { headers:
|
|
622
|
-
if (res === undefined) return
|
|
623
|
-
if (res.status === 200)
|
|
682
|
+
async has(hash: string): Promise<ArtifactInfo | null> {
|
|
683
|
+
if (this.disabled) return null
|
|
684
|
+
const res = await this.request('HEAD', `/${hash}`, { headers: this.reads })
|
|
685
|
+
if (res === undefined) return null
|
|
686
|
+
if (res.status === 200)
|
|
687
|
+
return infoOf(res.headers.get('content-length'), res.headers.get('x-artifact-duration'))
|
|
624
688
|
if (res.status === 404) {
|
|
625
689
|
this.absent.add(hash)
|
|
626
|
-
return
|
|
690
|
+
return null
|
|
627
691
|
}
|
|
628
692
|
throw new Error(`HTTP ${res.status}`)
|
|
629
693
|
}
|
|
630
694
|
|
|
631
|
-
async hasMany(hashes: readonly string[]
|
|
632
|
-
if (this.disabled || hashes.length === 0) return this.disabled ? new
|
|
633
|
-
// One query names one scope, so a batch asks once per scope in it.
|
|
634
|
-
const byScope = new Map<string | undefined, string[]>()
|
|
635
|
-
hashes.forEach((h, i) => {
|
|
636
|
-
const scope = at?.[i]?.scope
|
|
637
|
-
byScope.set(scope, [...(byScope.get(scope) ?? []), h])
|
|
638
|
-
})
|
|
639
|
-
const held = new Set<string>()
|
|
640
|
-
for (const [scope, group] of byScope) {
|
|
641
|
-
const found = await this.queryHeld(group, scope === undefined ? {} : { scope })
|
|
642
|
-
if (found === null) return null
|
|
643
|
-
for (const h of found) held.add(h)
|
|
644
|
-
}
|
|
645
|
-
return held
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
private async queryHeld(hashes: readonly string[], at: RemoteKey): Promise<Set<string> | null> {
|
|
695
|
+
async hasMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
|
|
696
|
+
if (this.disabled || hashes.length === 0) return this.disabled ? new Map() : null
|
|
649
697
|
const res = await this.request('POST', '', {
|
|
650
698
|
body: JSON.stringify({ hashes }),
|
|
651
|
-
headers: { 'Content-Type': 'application/json', ...
|
|
699
|
+
headers: { 'Content-Type': 'application/json', ...this.reads },
|
|
652
700
|
})
|
|
653
|
-
if (res === undefined) return new
|
|
701
|
+
if (res === undefined) return new Map()
|
|
654
702
|
if (res.status !== 200) return null
|
|
655
703
|
// An answer per hash is a few hundred bytes of ArtifactInfo; a reply
|
|
656
704
|
// past this bound is no answer, read no further than the bound (L-9).
|
|
@@ -661,25 +709,24 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
661
709
|
// Each hash answers ArtifactInfo, null, or `{ error: { message } }`; an
|
|
662
710
|
// error entry is not a stored artifact, and it was counted as one (a GET
|
|
663
711
|
// spent on a 404, item 929).
|
|
664
|
-
const held = new
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
712
|
+
const held = new Map<string, ArtifactInfo>()
|
|
713
|
+
for (const h of hashes) {
|
|
714
|
+
const v = info[h]
|
|
715
|
+
if (typeof v !== 'object' || v === null || 'error' in v) this.absent.add(h)
|
|
716
|
+
else {
|
|
717
|
+
const { size, taskDurationMs } = v as { size?: unknown; taskDurationMs?: unknown }
|
|
718
|
+
held.set(h, infoOf(size, taskDurationMs))
|
|
719
|
+
}
|
|
720
|
+
}
|
|
671
721
|
return held
|
|
672
722
|
}
|
|
673
723
|
|
|
674
|
-
async get(
|
|
675
|
-
hash: string,
|
|
676
|
-
at?: RemoteKey,
|
|
677
|
-
): Promise<{ body: Response; durationMs: number | undefined } | null> {
|
|
724
|
+
async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
|
|
678
725
|
if (this.disabled) return null
|
|
679
726
|
// A gateway that keys binary media on Accept base64-encodes a `*/*`
|
|
680
727
|
// response, and every hit read as a corrupt artifact (nx#33092's class).
|
|
681
728
|
const res = await this.request('GET', `/${hash}`, {
|
|
682
|
-
headers: { Accept: 'application/octet-stream', ...
|
|
729
|
+
headers: { Accept: 'application/octet-stream', ...this.reads },
|
|
683
730
|
})
|
|
684
731
|
if (res === undefined) return null
|
|
685
732
|
if (res.status !== 200) {
|
|
@@ -737,14 +784,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
737
784
|
return new Response(unlinkingStream(temp))
|
|
738
785
|
}
|
|
739
786
|
|
|
740
|
-
async put(hash: string, body: Blob, meta: { durationMs: number }
|
|
787
|
+
async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
741
788
|
if (this.disabled || this.writesRefused) return
|
|
742
789
|
if (!this.absent.has(hash)) {
|
|
743
790
|
// Part of the upload, so under its deadline. Any answer but 200 or 404
|
|
744
791
|
// uploads: a lost store costs more. A server the HEAD cannot reach
|
|
745
792
|
// fails the put, as the PUT would.
|
|
746
793
|
const head = await this.request('HEAD', `/${hash}`, {
|
|
747
|
-
headers:
|
|
794
|
+
headers: this.reads,
|
|
748
795
|
timeoutMs: this.config.uploadTimeoutMs,
|
|
749
796
|
})
|
|
750
797
|
if (head === undefined || head.status === 200) return
|
|
@@ -754,7 +801,6 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
754
801
|
'Content-Type': 'application/octet-stream',
|
|
755
802
|
'Content-Length': String(body.size),
|
|
756
803
|
'x-artifact-duration': String(Math.max(0, Math.round(meta.durationMs))),
|
|
757
|
-
...scopeHeader(meta),
|
|
758
804
|
}
|
|
759
805
|
if (this.key !== undefined) {
|
|
760
806
|
headers['x-artifact-tag'] = await artifactTag(this.key, hash, this.config.teamId ?? '', body)
|
|
@@ -824,6 +870,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
|
|
|
824
870
|
retries: 'number',
|
|
825
871
|
preflight: 'boolean',
|
|
826
872
|
oidc: 'any',
|
|
873
|
+
scope: 'string',
|
|
827
874
|
}
|
|
828
875
|
|
|
829
876
|
/**
|
|
@@ -837,6 +884,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
|
|
|
837
884
|
*/
|
|
838
885
|
export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
|
|
839
886
|
refuseUnknownOptions('turboCache()', options, TURBO_CACHE_KEYS)
|
|
887
|
+
const scope = remoteScope('turbo-cache', options.scope)
|
|
840
888
|
// The run's layer and warn, for `teardown` to revoke an exchanged token.
|
|
841
889
|
let opened: { layer: TurboRemoteCache; warn: (m: string) => void } | undefined
|
|
842
890
|
return definePlugin(import.meta, {
|
|
@@ -852,8 +900,10 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
|
|
|
852
900
|
// cache directory, which the sandbox walls, not the shared temp dir a
|
|
853
901
|
// sandboxed task may read (L-45).
|
|
854
902
|
const layer = new TurboRemoteCache(config, fetch, ctx.cacheDir)
|
|
903
|
+
layer.reads = readScopes(ctx.refs)
|
|
904
|
+
layer.lent = ctx.lent
|
|
855
905
|
if (config.oidc !== undefined) opened = { layer, warn: ctx.warn }
|
|
856
|
-
return layer
|
|
906
|
+
return scope === 'key' ? keyedByRef(layer, ctx.refs) : layer
|
|
857
907
|
},
|
|
858
908
|
async teardown() {
|
|
859
909
|
const run = opened
|
package/src/turbo-cache/oidc.ts
CHANGED
|
@@ -111,12 +111,18 @@ async function field(res: Response, name: string, what: string): Promise<string>
|
|
|
111
111
|
return value
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** An exchanged token, and when it ends (epoch ms) when the server says. */
|
|
115
|
+
export interface Exchanged {
|
|
116
|
+
readonly token: string
|
|
117
|
+
readonly expiresAt?: number
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
/** The job's OIDC token, exchanged for the cache's access token. Errors never quote a token. */
|
|
115
121
|
export async function exchangeOidc(
|
|
116
122
|
cfg: OidcConfig,
|
|
117
123
|
fetchImpl: typeof fetch = fetch,
|
|
118
124
|
wait: (ms: number) => Promise<void> = Bun.sleep,
|
|
119
|
-
): Promise<
|
|
125
|
+
): Promise<Exchanged> {
|
|
120
126
|
const idUrl = new URL(cfg.idTokenUrl)
|
|
121
127
|
if (cfg.audience !== undefined) idUrl.searchParams.set('audience', cfg.audience)
|
|
122
128
|
const idToken = await field(
|
|
@@ -129,22 +135,31 @@ export async function exchangeOidc(
|
|
|
129
135
|
'value',
|
|
130
136
|
`GitHub's OIDC endpoint (${where(cfg.idTokenUrl)})`,
|
|
131
137
|
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
wait,
|
|
144
|
-
),
|
|
145
|
-
'access_token',
|
|
146
|
-
`the token exchange (${where(cfg.tokenUrl)})`,
|
|
138
|
+
const res = await send(
|
|
139
|
+
fetchImpl,
|
|
140
|
+
cfg.tokenUrl,
|
|
141
|
+
form({
|
|
142
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
143
|
+
subject_token: idToken,
|
|
144
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
|
|
145
|
+
requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
|
146
|
+
...cfg.params,
|
|
147
|
+
}),
|
|
148
|
+
wait,
|
|
147
149
|
)
|
|
150
|
+
const what = `the token exchange (${where(cfg.tokenUrl)})`
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
await res.body?.cancel()
|
|
153
|
+
throw new Error(`${what} answered HTTP ${res.status}`)
|
|
154
|
+
}
|
|
155
|
+
const body = (await res.json().catch(() => null)) as Record<string, unknown> | null
|
|
156
|
+
const token = body?.['access_token']
|
|
157
|
+
if (typeof token !== 'string' || token === '') throw new Error(`${what} sent no access_token`)
|
|
158
|
+
const ttl = body?.['expires_in']
|
|
159
|
+
return {
|
|
160
|
+
token,
|
|
161
|
+
...(typeof ttl === 'number' && ttl > 0 ? { expiresAt: Date.now() + ttl * 1000 } : {}),
|
|
162
|
+
}
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
/** Revoke the exchanged token; a throw is the caller's warning. */
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ArtifactInfo, RemoteCacheLayer } from '@vzn/vx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a remote keeps branches apart (2026-10-20). `'header'`: plain keys,
|
|
5
|
+
* and every read names the refs it may be answered from, in order, in
|
|
6
|
+
* `x-artifact-read-scopes`; a server that files writes by the caller's CI
|
|
7
|
+
* token reads them, any other ignores it. `'key'`: each entry's key also
|
|
8
|
+
* folds a ref, for a server that keeps one bucket; a read asks each ref's
|
|
9
|
+
* key and the first hit wins.
|
|
10
|
+
*/
|
|
11
|
+
export type RemoteScope = 'header' | 'key'
|
|
12
|
+
|
|
13
|
+
export function remoteScope(name: string, scope: unknown): RemoteScope {
|
|
14
|
+
if (scope === undefined || scope === 'header' || scope === 'key') return scope ?? 'header'
|
|
15
|
+
throw new Error(`vx/${name}: scope must be 'header' or 'key', got ${JSON.stringify(scope)}`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A server caps the read header at this many refs (the server rules), so vx never sends more. */
|
|
19
|
+
const MAX_READ_SCOPES = 8
|
|
20
|
+
|
|
21
|
+
/** The read header: the refs in order. Writes carry none: the server files them by the token. */
|
|
22
|
+
export function readScopes(refs: readonly string[]): Record<string, string> {
|
|
23
|
+
return refs.length === 0
|
|
24
|
+
? {}
|
|
25
|
+
: { 'x-artifact-read-scopes': refs.slice(0, MAX_READ_SCOPES).join(', ') }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The key `ref` stores `hash` under: 16 hex, task-key shaped, so every wire takes it. */
|
|
29
|
+
export function refKey(ref: string, hash: string): string {
|
|
30
|
+
return Bun.hash.xxHash3(`vx-cache-ref\0${ref}\0${hash}`).toString(16).padStart(16, '0')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const QUEUE = /^refs\/heads\/gh-readonly-queue\//
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `inner` keyed by ref: a write lands under the run's own ref's key (a
|
|
37
|
+
* merge queue's, its base branch's, which its results become), a read asks
|
|
38
|
+
* each ref's key in order. A merge queue's own ref holds nothing, so it is
|
|
39
|
+
* never asked.
|
|
40
|
+
*/
|
|
41
|
+
export function keyedByRef(inner: RemoteCacheLayer, allRefs: readonly string[]): RemoteCacheLayer {
|
|
42
|
+
const refs = QUEUE.test(allRefs[0] ?? '') ? allRefs.slice(1) : allRefs
|
|
43
|
+
const write = refs[0]
|
|
44
|
+
if (write === undefined) return inner
|
|
45
|
+
/** Task key → the stored key a batch probe found it under. */
|
|
46
|
+
const found = new Map<string, string>()
|
|
47
|
+
const hasMany = inner.hasMany
|
|
48
|
+
const firstHeld = async (hash: string): Promise<ArtifactInfo | null> => {
|
|
49
|
+
const known = found.get(hash)
|
|
50
|
+
if (known !== undefined) return inner.has(known)
|
|
51
|
+
for (const ref of refs) {
|
|
52
|
+
const info = await inner.has(refKey(ref, hash))
|
|
53
|
+
if (info !== null) return info
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
...(inner.endpoint !== undefined ? { endpoint: inner.endpoint } : {}),
|
|
59
|
+
...(inner.open !== undefined ? { open: (ctx) => inner.open!(ctx) } : {}),
|
|
60
|
+
has: firstHeld,
|
|
61
|
+
...(hasMany !== undefined
|
|
62
|
+
? {
|
|
63
|
+
hasMany: async (hashes) => {
|
|
64
|
+
const keys = hashes.map((h) => refs.map((r) => refKey(r, h)))
|
|
65
|
+
const held = await hasMany.call(inner, keys.flat())
|
|
66
|
+
if (held === null) return null
|
|
67
|
+
const out = new Map<string, ArtifactInfo>()
|
|
68
|
+
hashes.forEach((h, i) => {
|
|
69
|
+
const key = keys[i]!.find((k) => held.has(k))
|
|
70
|
+
if (key === undefined) return
|
|
71
|
+
found.set(h, key)
|
|
72
|
+
out.set(h, held.get(key)!)
|
|
73
|
+
})
|
|
74
|
+
return out
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
get: async (hash) => {
|
|
79
|
+
const known = found.get(hash)
|
|
80
|
+
if (known !== undefined) return inner.get(known)
|
|
81
|
+
for (const ref of refs) {
|
|
82
|
+
const hit = await inner.get(refKey(ref, hash))
|
|
83
|
+
if (hit !== null) return hit
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
},
|
|
87
|
+
put: (hash, body, meta) => inner.put(refKey(write, hash), body, meta),
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/remote-scope.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { RemoteKey } from '@vzn/vx'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The bucket a request means (owner, 2026-10-18): `trusted` for a task's
|
|
5
|
-
* own key, else the branch scope its key was derived in. It only states
|
|
6
|
-
* intent; a server checks it against the caller's identity (a CI ID
|
|
7
|
-
* token). A request without it, from an older client, leaves the layering
|
|
8
|
-
* to the server. Shared by `turboCache()` and `nxCache()`.
|
|
9
|
-
*/
|
|
10
|
-
export function scopeHeader(at: RemoteKey | undefined): Record<string, string> {
|
|
11
|
-
return { 'x-vx-cache-scope': at?.scope ?? 'trusted' }
|
|
12
|
-
}
|