| 1 | import { env } from "cloudflare:workers"; |
| 2 | |
| 3 | import type { RepoPath, Viewer } from "@g1t/contracts"; |
| 4 | |
| 5 | import { actions } from "./services.server"; |
| 6 | |
| 7 | /** |
| 8 | * A run's artifacts, for its page. The actions service lists them (their |
| 9 | * bytes are in R2, downloaded through the API's signed links); artifacts an |
| 10 | * older runner kept in KV (`a/{run}/{name}`, its bytes in chunks `…#0`, |
| 11 | * `…#1`) are listed too until KV expires them. |
| 12 | */ |
| 13 | export type ArtifactRow = { |
| 14 | /** The artifact's number; null for one kept in KV. */ |
| 15 | id: number | null; |
| 16 | name: string; |
| 17 | size: number; |
| 18 | /** When it goes, RFC 3339; null for one kept in KV (14 days after it was made). */ |
| 19 | expiresAt: string | null; |
| 20 | createdAt: string; |
| 21 | }; |
| 22 | |
| 23 | type Meta = { size: number; chunks: number; at: number; name: string }; |
| 24 | |
| 25 | async function kvArtifacts(run: string): Promise<ArtifactRow[]> { |
| 26 | const listed = await env.BLOBS.list<Meta>({ prefix: `a/${run}/` }); |
| 27 | return listed.keys |
| 28 | .filter((key) => !key.name.includes("#") && key.metadata) |
| 29 | .map((key) => ({ |
| 30 | id: null, |
| 31 | name: key.metadata!.name, |
| 32 | size: key.metadata!.size, |
| 33 | expiresAt: new Date(key.metadata!.at + 14 * 86_400_000).toISOString(), |
| 34 | createdAt: new Date(key.metadata!.at).toISOString(), |
| 35 | })); |
| 36 | } |
| 37 | |
| 38 | export async function listArtifacts(repo: RepoPath, viewer: Viewer, run: string): Promise<ArtifactRow[]> { |
| 39 | const [kept, legacy] = await Promise.all([ |
| 40 | actions.artifacts(repo, viewer, { run, per_page: 100 }), |
| 41 | kvArtifacts(run).catch(() => []), |
| 42 | ]); |
| 43 | const rows: ArtifactRow[] = kept.ok |
| 44 | ? kept.value.artifacts.map((a) => ({ id: a.id, name: a.name, size: a.size, expiresAt: a.expires_at, createdAt: a.created_at })) |
| 45 | : []; |
| 46 | for (const row of legacy) { |
| 47 | if (!rows.some((r) => r.name === row.name)) rows.push(row); |
| 48 | } |
| 49 | return rows.sort((a, b) => a.name.localeCompare(b.name)); |
| 50 | } |
| 51 | |
| 52 | export async function readArtifact(run: string, name: string): Promise<Uint8Array | null> { |
| 53 | const base = `a/${run}/${name}`; |
| 54 | const meta = await env.BLOBS.get<Meta>(base, "json"); |
| 55 | if (!meta) return null; |
| 56 | const parts = await Promise.all( |
| 57 | Array.from({ length: meta.chunks }, (_, index) => env.BLOBS.get(`${base}#${index}`, "arrayBuffer")), |
| 58 | ); |
| 59 | if (parts.some((part) => part == null)) return null; |
| 60 | const out = new Uint8Array(meta.size); |
| 61 | let offset = 0; |
| 62 | for (const part of parts as ArrayBuffer[]) { |
| 63 | out.set(new Uint8Array(part), offset); |
| 64 | offset += part.byteLength; |
| 65 | } |
| 66 | return out; |
| 67 | } |