Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame234 lines · 2 contributors
abfa9adClaude1/**
2 * `gluecron/cache@v1` — RESTORE-only cache action (v1 scope).
3 *
4 * Looks up `workflow_run_cache` by (repoId, key, scope='repo') and unpacks
5 * the stored tar archive into `ctx.workspace/<path>`. If no exact key hit,
6 * tries each `restoreKeys` entry as a prefix match ordered by most-recently
7 * used. Sets `cache-hit` output to 'true' or 'false'.
8 *
9 * TODO (v2): cache SAVE on job success. The deferred design is for the
10 * runner to honor a `save-cache: true` flag emitted by this action and
11 * call a `saveCache(ctx, key, path)` helper at end-of-job. Implementing
12 * save inline here is error-prone (we'd need a post-hook) and the spec
13 * explicitly endorsed shipping restore-only for v1. Size cap logic is
14 * stubbed below so it's trivial to wire up later.
15 *
16 * Failure tolerance: any error — DB miss, tar failure, unknown scope —
17 * results in `cache-hit: false` with exitCode 0. Caching is an optimization;
18 * losing it must never break a pipeline.
19 */
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
181export const cacheAction: ActionHandler = {
182 name: "gluecron/cache",
183 version: "v1",
184 async run(ctx): Promise<import("../action-registry").ActionResult> {
185 // Every failure path below returns exitCode 0 with cache-hit=false so
186 // the pipeline keeps flowing. This is load-bearing behaviour.
187 try {
188 const inputs = parseInputs(ctx);
189 if (!inputs) {
190 return {
191 exitCode: 0,
192 outputs: { "cache-hit": "false" },
193 stderr: "cache: missing required inputs `key` and `path` — skipping",
194 };
195 }
196
197 const result = await lookupCache(
198 ctx.repoId,
199 inputs.key,
200 inputs.restoreKeys
201 );
202
203 if (!result.hit) {
204 return {
205 exitCode: 0,
206 outputs: { "cache-hit": "false" },
207 stdout: `cache miss for key=${inputs.key}`,
208 };
209 }
210
211 const dest = join(ctx.workspace, inputs.path);
212 await unpackTar(result.content, dest);
213 await touchLastAccessed(result.id);
214
215 return {
216 exitCode: 0,
217 outputs: {
218 "cache-hit": result.exact ? "true" : "false",
219 "matched-key": result.matchedKey,
220 },
221 stdout: `cache ${result.exact ? "hit" : "partial hit"} for key=${inputs.key} (matched=${result.matchedKey})`,
222 };
223 } catch (err) {
224 // Fail-open: swallow and report as miss.
225 return {
226 exitCode: 0,
227 outputs: { "cache-hit": "false" },
228 stderr:
229 "cache error (non-fatal): " +
230 (err instanceof Error ? err.message : String(err)),
231 };
232 }
233 },
234};