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.tsBlame227 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.
156 await import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => {});
157 } catch {
158 /* noop */
159 }
160 }
161}
162
163async function touchLastAccessed(id: string): Promise<void> {
164 try {
165 await db
166 .update(workflowRunCache)
167 .set({ lastAccessedAt: new Date() })
168 .where(eq(workflowRunCache.id, id));
169 } catch {
170 // Non-fatal — LRU accuracy is best-effort.
171 }
172}
173
174export const cacheAction: ActionHandler = {
175 name: "gluecron/cache",
176 version: "v1",
177 async run(ctx): Promise<import("../action-registry").ActionResult> {
178 // Every failure path below returns exitCode 0 with cache-hit=false so
179 // the pipeline keeps flowing. This is load-bearing behaviour.
180 try {
181 const inputs = parseInputs(ctx);
182 if (!inputs) {
183 return {
184 exitCode: 0,
185 outputs: { "cache-hit": "false" },
186 stderr: "cache: missing required inputs `key` and `path` — skipping",
187 };
188 }
189
190 const result = await lookupCache(
191 ctx.repoId,
192 inputs.key,
193 inputs.restoreKeys
194 );
195
196 if (!result.hit) {
197 return {
198 exitCode: 0,
199 outputs: { "cache-hit": "false" },
200 stdout: `cache miss for key=${inputs.key}`,
201 };
202 }
203
204 const dest = join(ctx.workspace, inputs.path);
205 await unpackTar(result.content, dest);
206 await touchLastAccessed(result.id);
207
208 return {
209 exitCode: 0,
210 outputs: {
211 "cache-hit": result.exact ? "true" : "false",
212 "matched-key": result.matchedKey,
213 },
214 stdout: `cache ${result.exact ? "hit" : "partial hit"} for key=${inputs.key} (matched=${result.matchedKey})`,
215 };
216 } catch (err) {
217 // Fail-open: swallow and report as miss.
218 return {
219 exitCode: 0,
220 outputs: { "cache-hit": "false" },
221 stderr:
222 "cache error (non-fatal): " +
223 (err instanceof Error ? err.message : String(err)),
224 };
225 }
226 },
227};