Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

130 lines · 5.3 KBRaw History
1import { randomUUID } from "node:crypto";
2import { createWriteStream } from "node:fs";
3import { rm, stat } from "node:fs/promises";
4import path from "node:path";
5import { Readable } from "node:stream";
6import { pipeline } from "node:stream/promises";
7import type { ReadableStream as WebReadableStream } from "node:stream/web";
8import { errMsg, failure } from "../job.js";
9import { globPaths, inputList, truthyInput } from "../util.js";
10import type { Builtin, BuiltinContext } from "./index.js";
11
12export async function downloadTo(c: BuiltinContext, url: string, file: string): Promise<void> {
13 const res = await c.job.api.download(url);
14 if (!res.body) throw new Error("empty response body");
15 await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file));
16}
17
18export interface RestoreResult {
19 matchedKey?: string;
20 exact: boolean;
21}
22
23/** Restores a cache entry; never throws for transport errors (warns instead). */
24export async function restoreCache(c: BuiltinContext, paths: string[], key: string, restoreKeys: string[], lookupOnly = false): Promise<RestoreResult> {
25 if (!key) throw failure("Input required and not supplied: key");
26 if (paths.length === 0) throw failure("Input required and not supplied: path");
27 if (!c.job.api.online) {
28 c.warn("Cache service is not available in offline mode; skipping restore.");
29 return { exact: false };
30 }
31 let hit: { key: string; url: string } | null;
32 try {
33 hit = await c.job.api.cacheLookup(key, restoreKeys);
34 } catch (e) {
35 c.warn(`Failed to look up cache: ${errMsg(e)}`);
36 return { exact: false };
37 }
38 if (!hit) {
39 c.log(`Cache not found for input keys: ${[key, ...restoreKeys].join(", ")}`);
40 return { exact: false };
41 }
42 const exact = hit.key === key;
43 if (lookupOnly) {
44 c.log(`Cache found and can be restored from key: ${hit.key}`);
45 return { matchedKey: hit.key, exact };
46 }
47 const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`);
48 try {
49 await downloadTo(c, hit.url, file);
50 const size = (await stat(file)).size;
51 c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`);
52 await c.run("tar", ["-xzPf", file], { silent: true });
53 c.log(`Cache restored from key: ${hit.key}`);
54 return { matchedKey: hit.key, exact };
55 } catch (e) {
56 c.warn(`Failed to restore cache: ${errMsg(e)}`);
57 return { exact: false };
58 } finally {
59 await rm(file, { force: true });
60 }
61}
62
63/** Archives `paths` (tar.gz, absolute paths) and uploads it under `key`. */
64export async function saveCache(c: BuiltinContext, paths: string[], key: string): Promise<boolean> {
65 if (!c.job.api.online) {
66 c.warn("Cache service is not available in offline mode; skipping save.");
67 return false;
68 }
69 const resolved = globPaths(c.job.workspace, paths);
70 if (resolved.length === 0) {
71 c.warn("Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.");
72 return false;
73 }
74 const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`);
75 try {
76 await c.run("tar", ["-czPf", file, "--", ...resolved], { silent: true });
77 const size = (await stat(file)).size;
78 c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`);
79 const res = await c.job.api.putFile(`/cache/${encodeURIComponent(key)}`, file);
80 if (res.status === 409) {
81 c.log(`Cache entry with key ${key} already exists; not saving.`);
82 return false;
83 }
84 c.log(`Cache saved with key: ${key}`);
85 return true;
86 } catch (e) {
87 c.warn(`Failed to save cache: ${errMsg(e)}`);
88 return false;
89 } finally {
90 await rm(file, { force: true });
91 }
92}
93
94export const cacheAction: Builtin = async (c) => {
95 const key = c.input("key");
96 const paths = inputList(c.input("path"));
97 const restoreKeys = inputList(c.input("restore-keys"));
98 const r = await restoreCache(c, paths, key, restoreKeys, truthyInput(c.input("lookup-only"), false));
99 c.setOutput("cache-hit", r.exact ? "true" : "false");
100 c.setOutput("cache-primary-key", key);
101 c.setOutput("cache-matched-key", r.matchedKey ?? "");
102 if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) {
103 throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`);
104 }
105 if (r.exact) return;
106 const saveAlways = truthyInput(c.input("save-always"), false);
107 c.registerPost(`Post ${c.name}`, saveAlways ? "always()" : "success()", async (pc) => {
108 await saveCache(pc, paths, key);
109 });
110};
111
112export const cacheRestoreAction: Builtin = async (c) => {
113 const key = c.input("key");
114 const paths = inputList(c.input("path"));
115 const r = await restoreCache(c, paths, key, inputList(c.input("restore-keys")), truthyInput(c.input("lookup-only"), false));
116 c.setOutput("cache-hit", r.exact ? "true" : "false");
117 c.setOutput("cache-primary-key", key);
118 c.setOutput("cache-matched-key", r.matchedKey ?? "");
119 if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) {
120 throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`);
121 }
122};
123
124export const cacheSaveAction: Builtin = async (c) => {
125 const key = c.input("key");
126 if (!key) throw failure("Input required and not supplied: key");
127 const paths = inputList(c.input("path"));
128 if (!paths.length) throw failure("Input required and not supplied: path");
129 await saveCache(c, paths, key);
130};