| 1 | import { env } from "cloudflare:workers"; |
| 2 | |
| 3 | import type { RepoPath, Viewer } from "@g1t/contracts"; |
| 4 | |
| 5 | import { LEGACY_KV_UNTIL } from "./artifacts"; |
| 6 | import { actions } from "./services.server"; |
| 7 | |
| 8 | /** |
| 9 | * A run's artifacts, for its page. The actions service lists them (their |
| 10 | * bytes are in R2, downloaded through the API's signed links); artifacts an |
| 11 | * older runner kept in KV (`a/{run}/{name}`, its bytes in chunks `…#0`, |
| 12 | * `…#1`) are listed too until KV expires them, for runs the caller has |
| 13 | * already been shown (`legacyArtifactsWorthAsking` in artifacts.ts). |
| 14 | */ |
| 15 | export type ArtifactRow = { |
| 16 | /** The artifact's number; null for one kept in KV. */ |
| 17 | id: number | null; |
| 18 | name: string; |
| 19 | size: number; |
| 20 | /** When it goes, RFC 3339; null for one kept in KV (14 days after it was made). */ |
| 21 | expiresAt: string | null; |
| 22 | createdAt: string; |
| 23 | }; |
| 24 | |
| 25 | type Meta = { size: number; chunks: number; at: number; name: string }; |
| 26 | |
| 27 | async function kvArtifacts(run: string): Promise<ArtifactRow[]> { |
| 28 | const listed = await env.BLOBS.list<Meta>({ prefix: `a/${run}/` }); |
| 29 | return listed.keys |
| 30 | .filter((key) => !key.name.includes("#") && key.metadata) |
| 31 | .map((key) => ({ |
| 32 | id: null, |
| 33 | name: key.metadata!.name, |
| 34 | size: key.metadata!.size, |
| 35 | expiresAt: new Date(key.metadata!.at + 14 * 86_400_000).toISOString(), |
| 36 | createdAt: new Date(key.metadata!.at).toISOString(), |
| 37 | })); |
| 38 | } |
| 39 | |
| 40 | /** A run's artifacts the actions service keeps, which checks who may see them. */ |
| 41 | export async function listArtifacts(repo: RepoPath, viewer: Viewer, run: string): Promise<ArtifactRow[]> { |
| 42 | const kept = await actions.artifacts(repo, viewer, { run, per_page: 100 }); |
| 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 | return rows.sort((a, b) => a.name.localeCompare(b.name)); |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * `rows` with the artifacts an older runner kept in KV for `run` added. |
| 51 | * Only for a run the viewer was allowed to see. Delete after |
| 52 | * 2026-10-22T00:00Z (`LEGACY_KV_UNTIL`). |
| 53 | */ |
| 54 | export async function withLegacyArtifacts(rows: ArtifactRow[], run: string): Promise<ArtifactRow[]> { |
| 55 | if (Date.now() >= LEGACY_KV_UNTIL) return rows; |
| 56 | const legacy = await kvArtifacts(run).catch(() => []); |
| 57 | const all = [...rows]; |
| 58 | for (const row of legacy) { |
| 59 | if (!all.some((r) => r.name === row.name)) all.push(row); |
| 60 | } |
| 61 | return all.sort((a, b) => a.name.localeCompare(b.name)); |
| 62 | } |
| 63 | |
| 64 | export async function readArtifact(run: string, name: string): Promise<Uint8Array | null> { |
| 65 | // Every artifact kept in KV has expired (artifacts.ts). |
| 66 | if (Date.now() >= LEGACY_KV_UNTIL) return null; |
| 67 | const base = `a/${run}/${name}`; |
| 68 | const meta = await env.BLOBS.get<Meta>(base, "json"); |
| 69 | if (!meta) return null; |
| 70 | const parts = await Promise.all( |
| 71 | Array.from({ length: meta.chunks }, (_, index) => env.BLOBS.get(`${base}#${index}`, "arrayBuffer")), |
| 72 | ); |
| 73 | if (parts.some((part) => part == null)) return null; |
| 74 | const out = new Uint8Array(meta.size); |
| 75 | let offset = 0; |
| 76 | for (const part of parts as ArrayBuffer[]) { |
| 77 | out.set(new Uint8Array(part), offset); |
| 78 | offset += part.byteLength; |
| 79 | } |
| 80 | return out; |
| 81 | } |