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

workflow-artifacts.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.

workflow-artifacts.tsBlame224 lines · 1 contributor
abfa9adClaude1/**
2 * Workflow artifact helpers (Block C1 / Sprint 1 — Agent 6).
3 *
4 * Pure functions for uploading, listing, downloading and deleting workflow
5 * run artifacts. Shared between the REST API (`src/routes/workflow-artifacts.ts`)
6 * and in-process action handlers (e.g. `gluecron/upload-artifact@v1`,
7 * `gluecron/download-artifact@v1` — built by Agent 8).
8 *
9 * Storage contract: `workflow_artifacts.content` is declared as `text` in
10 * drizzle (base64-encoded bytes), even though the underlying column type is
11 * `bytea`. This mismatch is intentional for v1 — see the `bytea` customType
12 * comment in `src/db/schema.ts`. We therefore base64-encode on write and
13 * base64-decode on read.
14 *
15 * These functions never throw. DB/validation failures return
16 * `{ ok: false, error }`.
17 */
18
19import { eq } from "drizzle-orm";
20import { db } from "../db";
21import { workflowArtifacts } from "../db/schema";
22
23/** 100 MiB — matches the REST API cap. */
24export const MAX_ARTIFACT_BYTES = 100 * 1024 * 1024;
25
26const NAME_RE = /^[A-Za-z0-9._-]+$/;
27
28function toBuffer(input: Uint8Array | Buffer): Buffer {
29 if (Buffer.isBuffer(input)) return input;
30 return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
31}
32
33export async function uploadArtifact(args: {
34 runId: string;
35 jobId: string;
36 name: string;
37 content: Uint8Array | Buffer;
38 contentType?: string;
39}): Promise<{ ok: true; artifactId: string } | { ok: false; error: string }> {
40 const { runId, jobId, name, content } = args;
41 const contentType = args.contentType || "application/octet-stream";
42
43 if (!runId || typeof runId !== "string") {
44 return { ok: false, error: "runId is required" };
45 }
46 if (!jobId || typeof jobId !== "string") {
47 return { ok: false, error: "jobId is required" };
48 }
49 if (!name || typeof name !== "string") {
50 return { ok: false, error: "name is required" };
51 }
52 if (name.length > 255) {
53 return { ok: false, error: "name too long (max 255 chars)" };
54 }
55 if (!NAME_RE.test(name)) {
56 return {
57 ok: false,
58 error: "name must match /^[A-Za-z0-9._-]+$/",
59 };
60 }
61
62 const buf = toBuffer(content);
63 if (buf.byteLength > MAX_ARTIFACT_BYTES) {
64 return { ok: false, error: "artifact exceeds 100MB limit" };
65 }
66
67 try {
68 const [row] = await db
69 .insert(workflowArtifacts)
70 .values({
71 runId,
72 jobId,
73 name,
74 sizeBytes: buf.byteLength,
75 contentType,
76 // Stored as base64 text for v1 (see schema comment).
77 content: buf.toString("base64"),
78 })
79 .returning({ id: workflowArtifacts.id });
80
81 if (!row) {
82 return { ok: false, error: "insert returned no row" };
83 }
84 return { ok: true, artifactId: row.id };
85 } catch (err) {
86 console.error("[workflow-artifacts] uploadArtifact:", err);
87 return { ok: false, error: "database error" };
88 }
89}
90
91export async function listArtifacts(
92 runId: string
93): Promise<
94 | {
95 ok: true;
96 artifacts: {
97 id: string;
98 name: string;
99 size: number;
100 contentType: string;
101 createdAt: Date;
102 }[];
103 }
104 | { ok: false; error: string }
105> {
106 if (!runId || typeof runId !== "string") {
107 return { ok: false, error: "runId is required" };
108 }
109 try {
110 const rows = await db
111 .select({
112 id: workflowArtifacts.id,
113 name: workflowArtifacts.name,
114 size: workflowArtifacts.sizeBytes,
115 contentType: workflowArtifacts.contentType,
116 createdAt: workflowArtifacts.createdAt,
117 })
118 .from(workflowArtifacts)
119 .where(eq(workflowArtifacts.runId, runId));
120
121 return { ok: true, artifacts: rows };
122 } catch (err) {
123 console.error("[workflow-artifacts] listArtifacts:", err);
124 return { ok: false, error: "database error" };
125 }
126}
127
128export async function downloadArtifact(
129 artifactId: string
130): Promise<
131 | { ok: true; name: string; contentType: string; content: Buffer }
132 | { ok: false; error: string }
133> {
134 if (!artifactId || typeof artifactId !== "string") {
135 return { ok: false, error: "artifactId is required" };
136 }
137 try {
138 const [row] = await db
139 .select()
140 .from(workflowArtifacts)
141 .where(eq(workflowArtifacts.id, artifactId))
142 .limit(1);
143
144 if (!row) {
145 return { ok: false, error: "not found" };
146 }
147
148 const raw = row.content;
149 const buf = raw ? Buffer.from(raw, "base64") : Buffer.alloc(0);
150 return {
151 ok: true,
152 name: row.name,
153 contentType: row.contentType,
154 content: buf,
155 };
156 } catch (err) {
157 console.error("[workflow-artifacts] downloadArtifact:", err);
158 return { ok: false, error: "database error" };
159 }
160}
161
162export async function deleteArtifact(
163 artifactId: string
164): Promise<{ ok: true } | { ok: false; error: string }> {
165 if (!artifactId || typeof artifactId !== "string") {
166 return { ok: false, error: "artifactId is required" };
167 }
168 try {
169 const res = await db
170 .delete(workflowArtifacts)
171 .where(eq(workflowArtifacts.id, artifactId))
172 .returning({ id: workflowArtifacts.id });
173
174 if (res.length === 0) {
175 return { ok: false, error: "not found" };
176 }
177 return { ok: true };
178 } catch (err) {
179 console.error("[workflow-artifacts] deleteArtifact:", err);
180 return { ok: false, error: "database error" };
181 }
182}
183
184/**
185 * Internal helper for the REST layer: returns just the owning repositoryId
186 * for a run. Kept here so both the API route and any future helpers share
187 * the same lookup without reaching into `workflowRuns` directly from N places.
188 */
189export async function getRunRepositoryId(
190 runId: string
191): Promise<string | null> {
192 try {
193 const { workflowRuns } = await import("../db/schema");
194 const [row] = await db
195 .select({ repositoryId: workflowRuns.repositoryId })
196 .from(workflowRuns)
197 .where(eq(workflowRuns.id, runId))
198 .limit(1);
199 return row ? row.repositoryId : null;
200 } catch (err) {
201 console.error("[workflow-artifacts] getRunRepositoryId:", err);
202 return null;
203 }
204}
205
206/**
207 * Internal helper: look up a single artifact's runId (used by GET/DELETE
208 * endpoints that only receive `:artifactId`).
209 */
210export async function getArtifactRunId(
211 artifactId: string
212): Promise<string | null> {
213 try {
214 const [row] = await db
215 .select({ runId: workflowArtifacts.runId })
216 .from(workflowArtifacts)
217 .where(eq(workflowArtifacts.id, artifactId))
218 .limit(1);
219 return row ? row.runId : null;
220 } catch (err) {
221 console.error("[workflow-artifacts] getArtifactRunId:", err);
222 return null;
223 }
224}