Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

cache-action.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

cache-action.tsBlame339 lines · 2 contributors
abfa9adClaude1/**
f534efeClaude2 * `gluecron/cache@v1` — cache action with RESTORE (load) and SAVE sides.
abfa9adClaude3 *
f534efeClaude4 * RESTORE: looks up `workflow_run_cache` by (repoId, key, scope='repo') and
5 * unpacks the stored tar archive into `ctx.workspace/<path>`. If no exact key
6 * hit, tries each `restoreKeys` entry as a prefix match ordered by
7 * most-recently used. Sets `cache-hit` output to 'true' or 'false'.
abfa9adClaude8 *
f534efeClaude9 * SAVE: called by the workflow runner after a job's steps all succeed.
10 * Tarballs the `path` list relative to `workdir` and upserts the archive into
11 * `workflow_run_cache`. On key conflict the existing row is replaced so the
12 * runner always ends up with the freshest tarball for a given key. Size cap is
13 * enforced before the DB write; oversize payloads are logged and silently
14 * dropped so CI never fails due to cache infrastructure.
abfa9adClaude15 *
16 * Failure tolerance: any error — DB miss, tar failure, unknown scope —
f534efeClaude17 * results in `cache-hit: false` (on restore) or a silent no-op (on save).
18 * Caching is an optimization; losing it must never break a pipeline.
abfa9adClaude19 */
20
21import { and, eq, isNull, sql } from "drizzle-orm";
22import { mkdir } from "fs/promises";
23import { join } from "path";
24import { tmpdir } from "os";
25import type { ActionHandler, ActionContext } from "../action-registry";
26import { db } from "../../db";
27import { workflowRunCache } from "../../db/schema";
28
29// 100MB cap — reserved for the eventual save path. Kept here so both
30// halves of the action share the constant when v2 lands.
31export const MAX_CACHE_BYTES = 100 * 1024 * 1024;
32
33function parseInputs(ctx: ActionContext): {
34 key: string;
35 path: string;
36 restoreKeys: string[];
37} | null {
38 const w = ctx.with || {};
39 const key = typeof w.key === "string" ? w.key : "";
40 const path = typeof w.path === "string" ? w.path : "";
41 const restoreKeysRaw = w.restoreKeys ?? w["restore-keys"];
42 const restoreKeys = Array.isArray(restoreKeysRaw)
43 ? restoreKeysRaw.filter((k): k is string => typeof k === "string")
44 : [];
45 if (!key || !path) return null;
46 return { key, path, restoreKeys };
47}
48
49/**
50 * Find a cache row for this repo matching either the exact key or any
51 * of the prefix restoreKeys. Returns the first hit (prefix matches are
52 * ordered by `last_accessed_at DESC`).
53 */
54async function lookupCache(
55 repoId: string,
56 key: string,
57 restoreKeys: string[]
58): Promise<
59 | { hit: true; id: string; content: Buffer; matchedKey: string; exact: boolean }
60 | { hit: false }
61> {
62 // Exact match first.
63 const exact = await db
64 .select()
65 .from(workflowRunCache)
66 .where(
67 and(
68 eq(workflowRunCache.repositoryId, repoId),
69 eq(workflowRunCache.cacheKey, key),
70 eq(workflowRunCache.scope, "repo"),
71 isNull(workflowRunCache.scopeRef)
72 )
73 )
74 .limit(1);
75 const exactRow = exact[0];
76 if (exactRow) {
77 return {
78 hit: true,
79 id: exactRow.id,
80 content: normalizeBytea(exactRow.content),
81 matchedKey: exactRow.cacheKey,
82 exact: true,
83 };
84 }
85
86 // Prefix fallbacks (LRU order).
87 for (const prefix of restoreKeys) {
88 if (!prefix) continue;
89 const rows = await db
90 .select()
91 .from(workflowRunCache)
92 .where(
93 and(
94 eq(workflowRunCache.repositoryId, repoId),
95 eq(workflowRunCache.scope, "repo"),
96 isNull(workflowRunCache.scopeRef),
97 sql`${workflowRunCache.cacheKey} LIKE ${prefix + "%"}`
98 )
99 )
100 .orderBy(sql`${workflowRunCache.lastAccessedAt} DESC`)
101 .limit(1);
102 const row = rows[0];
103 if (row) {
104 return {
105 hit: true,
106 id: row.id,
107 content: normalizeBytea(row.content),
108 matchedKey: row.cacheKey,
109 exact: false,
110 };
111 }
112 }
113
114 return { hit: false };
115}
116
117/**
118 * `content` arrives as a Buffer, Uint8Array, or (if the driver ran through
119 * text serialization) a base64/hex string. Normalize to Buffer.
120 */
121function normalizeBytea(raw: unknown): Buffer {
122 if (Buffer.isBuffer(raw)) return raw;
123 if (raw instanceof Uint8Array) return Buffer.from(raw);
124 if (typeof raw === "string") {
125 // Postgres `bytea` text encoding can be `\x…` hex. Handle defensively.
126 if (raw.startsWith("\\x")) return Buffer.from(raw.slice(2), "hex");
127 // Otherwise assume base64 (matches workflow-artifacts convention).
128 try {
129 return Buffer.from(raw, "base64");
130 } catch {
131 return Buffer.alloc(0);
132 }
133 }
134 return Buffer.alloc(0);
135}
136
137async function unpackTar(content: Buffer, destDir: string): Promise<void> {
138 await mkdir(destDir, { recursive: true });
139 // Write the archive to a tmp file then untar. Piping through stdin works
140 // but a tmp file avoids subtle Bun subprocess stdin EAGAIN edge cases.
141 const tmpPath = join(
142 tmpdir(),
2c3ba6ecopilot-swe-agent[bot]143 `gluecron-cache-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar`
abfa9adClaude144 );
145 await Bun.write(tmpPath, content);
146 try {
147 const proc = Bun.spawn(["tar", "-xf", tmpPath, "-C", destDir], {
148 stdout: "pipe",
149 stderr: "pipe",
150 });
151 await proc.exited;
152 } finally {
153 try {
154 await Bun.file(tmpPath).exists();
155 // Best-effort cleanup; ignore errors.
a28cedeClaude156 await import("fs/promises")
157 .then((fs) => fs.unlink(tmpPath))
158 .catch((err) => {
159 console.warn(
160 `[cache-action] tmpPath cleanup failed for ${tmpPath}:`,
161 err instanceof Error ? err.message : err
162 );
163 });
abfa9adClaude164 } catch {
165 /* noop */
166 }
167 }
168}
169
170async function touchLastAccessed(id: string): Promise<void> {
171 try {
172 await db
173 .update(workflowRunCache)
174 .set({ lastAccessedAt: new Date() })
175 .where(eq(workflowRunCache.id, id));
176 } catch {
177 // Non-fatal — LRU accuracy is best-effort.
178 }
179}
180
f534efeClaude181/**
182 * Pack `paths` (relative to `workdir`) into a gzip-compressed tar archive and
183 * upsert it into `workflow_run_cache` under `key` for `repoId`.
184 *
185 * On key conflict the existing row is replaced. This mirrors how GitHub Actions
186 * handles re-runs with the same key: the freshest content wins.
187 *
188 * The archive is written to a temp file first (avoids EAGAIN edge cases on
189 * Bun's subprocess stdin pipe) then read back into a Buffer for the DB write.
190 * Callers must treat any thrown error as non-fatal — caching failures must
191 * never abort a pipeline.
192 */
193export async function saveCacheEntry(
194 repoId: string,
195 key: string,
196 paths: string[],
197 workdir: string
198): Promise<void> {
199 const validPaths = paths.filter((p) => typeof p === "string" && p.length > 0);
200 if (!key || validPaths.length === 0) return;
201
202 const tmpPath = join(
203 tmpdir(),
204 `gluecron-cache-save-${Date.now()}-${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}.tar.gz`
205 );
206
207 try {
208 const proc = Bun.spawn(
209 ["tar", "-czf", tmpPath, "--", ...validPaths],
210 {
211 cwd: workdir,
212 stdout: "pipe",
213 stderr: "pipe",
214 }
215 );
216 await proc.exited;
217
218 const file = Bun.file(tmpPath);
219 const exists = await file.exists();
220 if (!exists) return;
221
222 const arrayBuf = await file.arrayBuffer();
223 const content = Buffer.from(arrayBuf);
224
225 // Drop oversized archives silently so a large workspace never blocks CI.
226 if (content.byteLength > MAX_CACHE_BYTES) {
227 console.warn(
228 `[cache-action] save skipped: archive for key=${key} is ${content.byteLength} bytes, exceeds cap ${MAX_CACHE_BYTES}`
229 );
230 return;
231 }
232
233 const contentHash = Buffer.from(
234 await crypto.subtle.digest("SHA-256", content)
235 ).toString("hex");
236
237 // Manual upsert: the unique index includes nullable `scope_ref` so
238 // Postgres `ON CONFLICT (…, scope_ref)` won't fire when scope_ref IS NULL
239 // (each NULL is distinct in a standard B-tree index). We do a targeted
240 // UPDATE first; if zero rows were touched we INSERT instead.
241 const now = new Date();
242 const existing = await db
243 .select({ id: workflowRunCache.id })
244 .from(workflowRunCache)
245 .where(
246 and(
247 eq(workflowRunCache.repositoryId, repoId),
248 eq(workflowRunCache.cacheKey, key),
249 eq(workflowRunCache.scope, "repo"),
250 isNull(workflowRunCache.scopeRef)
251 )
252 )
253 .limit(1);
254
255 if (existing[0]) {
256 await db
257 .update(workflowRunCache)
258 .set({
259 content,
260 contentHash,
261 sizeBytes: content.byteLength,
262 lastAccessedAt: now,
263 })
264 .where(eq(workflowRunCache.id, existing[0].id));
265 } else {
266 await db.insert(workflowRunCache).values({
267 repositoryId: repoId,
268 cacheKey: key,
269 scope: "repo",
270 scopeRef: null,
271 content,
272 contentHash,
273 sizeBytes: content.byteLength,
274 lastAccessedAt: now,
275 });
276 }
277 } finally {
278 await import("fs/promises")
279 .then((fs) => fs.unlink(tmpPath))
280 .catch(() => {
281 // Best-effort cleanup.
282 });
283 }
284}
285
abfa9adClaude286export const cacheAction: ActionHandler = {
287 name: "gluecron/cache",
288 version: "v1",
289 async run(ctx): Promise<import("../action-registry").ActionResult> {
290 // Every failure path below returns exitCode 0 with cache-hit=false so
291 // the pipeline keeps flowing. This is load-bearing behaviour.
292 try {
293 const inputs = parseInputs(ctx);
294 if (!inputs) {
295 return {
296 exitCode: 0,
297 outputs: { "cache-hit": "false" },
298 stderr: "cache: missing required inputs `key` and `path` — skipping",
299 };
300 }
301
302 const result = await lookupCache(
303 ctx.repoId,
304 inputs.key,
305 inputs.restoreKeys
306 );
307
308 if (!result.hit) {
309 return {
310 exitCode: 0,
311 outputs: { "cache-hit": "false" },
312 stdout: `cache miss for key=${inputs.key}`,
313 };
314 }
315
316 const dest = join(ctx.workspace, inputs.path);
317 await unpackTar(result.content, dest);
318 await touchLastAccessed(result.id);
319
320 return {
321 exitCode: 0,
322 outputs: {
323 "cache-hit": result.exact ? "true" : "false",
324 "matched-key": result.matchedKey,
325 },
326 stdout: `cache ${result.exact ? "hit" : "partial hit"} for key=${inputs.key} (matched=${result.matchedKey})`,
327 };
328 } catch (err) {
329 // Fail-open: swallow and report as miss.
330 return {
331 exitCode: 0,
332 outputs: { "cache-hit": "false" },
333 stderr:
334 "cache error (non-fatal): " +
335 (err instanceof Error ? err.message : String(err)),
336 };
337 }
338 },
339};