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

pr-sandbox.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.

pr-sandbox.tsBlame460 lines · 1 contributor
79ed944Claude1/**
2 * Per-PR runnable sandboxes (migration 0067).
3 *
4 * Every open PR can be reified into an *executable* sandbox so reviewers
5 * try the change live before merging instead of pulling the branch
6 * locally. The sandbox URL is computed deterministically from PR number +
7 * owner + repo so we can render it the moment a sandbox is enqueued —
8 * even while the underlying container is still spinning up.
9 *
10 * https://pr-<n>-<owner>-<repo>.sandbox.gluecron.com
11 *
12 * The domain suffix is configurable via `PR_SANDBOX_DOMAIN` so self-hosted
13 * installs can point it at their own wildcard subdomain. The default is
14 * intentionally distinct from `preview.gluecron.com` (migration 0062) so
15 * read-only previews and runnable sandboxes never collide on the same
16 * hostname.
17 *
18 * Philosophy (mirrors branch-previews.ts): never throw — every DB call is
19 * wrapped in try/catch so a Postgres outage cannot break the PR-detail
20 * render path. Callers fire-and-forget where possible.
21 *
22 * Lifecycle:
23 * provisioning → ready (sandbox spun up; URL is live)
24 * provisioning → failed (build/spin-up errored)
25 * ready → destroyed (manual or autopilot teardown past TTL)
26 *
27 * Idempotency: provisioning the same PR a second time UPSERTs onto the
28 * existing row (unique index on pr_id). Status flips back to
29 * 'provisioning' and the prior error_message is cleared — i.e. a force-push
30 * always points at the freshest head.
31 */
32
33import { and, eq, lt } from "drizzle-orm";
34import { db } from "../db";
35import { prSandboxes, pullRequests, repositories, users } from "../db/schema";
36import type { PrSandbox } from "../db/schema";
37import { slugifyForUrl } from "./branch-previews";
38import { getBlob } from "../git/repository";
39import { getAnthropic, isAiAvailable, MODEL_HAIKU, extractText } from "./ai-client";
40
41// ---------------------------------------------------------------------------
42// Tunables
43// ---------------------------------------------------------------------------
44
45/** TTL since the moment a sandbox was provisioned. Mirrors the SQL default. */
46export const SANDBOX_TTL_MS = 4 * 60 * 60 * 1000;
47
48/** Cap for `error_message` so a giant stack trace can't blow up the row. */
49const ERROR_MESSAGE_CAP = 2_000;
50
51/** Default playground.yml when no file is committed AND AI is unavailable. */
52const DEFAULT_PLAYGROUND_YML = `# .gluecron/playground.yml — auto-generated default.
53# Customize and commit this file under .gluecron/playground.yml on your
54# repo to control how PR sandboxes are provisioned.
55runtime: docker
56image: node:20-alpine
57ports: [3000]
58seed:
59 - "npm install"
60command: "npm start"
61env:
62 NODE_ENV: development
63`;
64
65// ---------------------------------------------------------------------------
66// URL helpers
67// ---------------------------------------------------------------------------
68
69/**
70 * Compute the sandbox URL for `<owner>/<repo>` PR `#n`. Pure — exported
71 * so route handlers + the UI can render it without going through the DB.
72 */
73export function buildSandboxUrl(
74 prNumber: number,
75 ownerName: string,
76 repoName: string
77): string {
78 const domain = (
79 process.env.PR_SANDBOX_DOMAIN || "sandbox.gluecron.com"
80 ).replace(/^https?:\/\//, "");
81 const repoSlug = slugifyForUrl(`${ownerName}-${repoName}`);
82 const n = Number.isFinite(prNumber) ? Math.max(0, Math.floor(prNumber)) : 0;
83 return `https://pr-${n}-${repoSlug}.${domain}`;
84}
85
86/** Visible string for the status pill. */
87export function sandboxStatusLabel(status: string): string {
88 switch (status) {
89 case "provisioning":
90 return "Provisioning";
91 case "ready":
92 return "Ready";
93 case "failed":
94 return "Failed";
95 case "destroyed":
96 return "Destroyed";
97 default:
98 return status;
99 }
100}
101
102/** Compute "expires in" label — pure helper used by the UI. */
103export function formatSandboxExpiresIn(
104 expiresAt: Date | null | undefined,
105 now: Date = new Date()
106): string {
107 if (!expiresAt) return "—";
108 const ms = expiresAt.getTime() - now.getTime();
109 if (ms <= 0) return "expired";
110 const minutes = Math.floor(ms / 60_000);
111 if (minutes < 1) return "less than a minute";
112 const hours = Math.floor(minutes / 60);
113 const mins = minutes % 60;
114 if (hours <= 0) return `${minutes}m`;
115 return `${hours}h ${mins}m`;
116}
117
118// ---------------------------------------------------------------------------
119// playground.yml resolution
120// ---------------------------------------------------------------------------
121
122/**
123 * Read `.gluecron/playground.yml` from the PR's head branch. Returns the
124 * file contents, or `null` if the file isn't in the tree (either the repo
125 * hasn't opted-in or the branch doesn't exist yet).
126 *
127 * Pure git read — wrapped in try/catch so a missing repo on disk degrades
128 * to `null` instead of throwing into the caller.
129 */
130export async function readPlaygroundYml(
131 ownerName: string,
132 repoName: string,
133 ref: string
134): Promise<string | null> {
135 if (!ownerName || !repoName || !ref) return null;
136 try {
137 const blob = await getBlob(
138 ownerName,
139 repoName,
140 ref,
141 ".gluecron/playground.yml"
142 );
143 if (!blob || blob.isBinary) return null;
144 const content = (blob.content || "").trim();
145 if (!content) return null;
146 return blob.content;
147 } catch {
148 return null;
149 }
150}
151
152/**
a5705cfClaude153 * Ask Claude (Sonnet) to draft a playground.yml for a repo. Used when the
79ed944Claude154 * repo hasn't committed one. Returns the YAML body, or
155 * `DEFAULT_PLAYGROUND_YML` if AI is unavailable / errors. Never throws.
156 *
157 * `repoHint` is a short blurb — typically `"<owner>/<repo>"` plus the PR
158 * title — to give Claude enough context to pick a sensible runtime.
159 */
160export async function generatePlaygroundYml(
161 repoHint: string
162): Promise<string> {
163 if (!isAiAvailable()) return DEFAULT_PLAYGROUND_YML;
164 try {
165 const client = getAnthropic();
166 const message = await client.messages.create({
a5705cfClaude167 model: MODEL_SONNET,
79ed944Claude168 max_tokens: 800,
169 messages: [
170 {
171 role: "user",
172 content:
173 "Generate a `playground.yml` for the following repo so PR " +
174 "reviewers can try the change live in a sandbox container. " +
175 "Output ONLY YAML — no prose, no code fences. Required keys: " +
176 "`runtime` (docker), `image`, `ports` (array), `seed` " +
177 "(array of shell commands run once at startup), `command` " +
178 "(the long-running process), `env` (map). Pick conservative " +
179 "defaults if unsure.\n\nRepo: " +
180 repoHint,
181 },
182 ],
183 });
184 const text = extractText(message).trim();
185 if (!text) return DEFAULT_PLAYGROUND_YML;
186 // Strip ``` fences if the model included them.
187 const cleaned = text
188 .replace(/^```(?:yaml|yml)?\s*/i, "")
189 .replace(/```\s*$/i, "")
190 .trim();
191 return cleaned || DEFAULT_PLAYGROUND_YML;
192 } catch (err) {
193 console.warn(
194 "[pr-sandbox] generatePlaygroundYml failed; using default:",
195 err instanceof Error ? err.message : err
196 );
197 return DEFAULT_PLAYGROUND_YML;
198 }
199}
200
201// ---------------------------------------------------------------------------
202// Core lifecycle
203// ---------------------------------------------------------------------------
204
205export interface ProvisionArgs {
206 prId: string;
207 /** Override the "now" clock — only used by tests. */
208 now?: () => Date;
209 /** Override the URL — only used by tests. */
210 sandboxUrl?: string;
211 /** Override the resolved YAML — only used by tests so we don't call AI. */
212 playgroundYml?: string;
213}
214
215/**
216 * Provision (or re-provision) a sandbox for the given PR.
217 *
218 * Resolves the PR's head branch + owner/repo, computes the deterministic
219 * sandbox URL, reads `.gluecron/playground.yml` from the head if present
220 * (otherwise asks Claude to draft one), and upserts the row. Status starts
221 * at `provisioning` — a downstream worker is responsible for flipping it
222 * to `ready` / `failed` once the underlying container is up.
223 *
224 * Returns the row, or `null` if the PR doesn't exist or the DB is down.
225 */
226export async function provisionSandbox(
227 args: ProvisionArgs
228): Promise<PrSandbox | null> {
229 if (!args.prId) return null;
230 const now = (args.now ?? (() => new Date()))();
231 const expiresAt = new Date(now.getTime() + SANDBOX_TTL_MS);
232
233 let resolved: {
234 prNumber: number;
235 headBranch: string;
236 ownerName: string;
237 repoName: string;
238 } | null = null;
239
240 try {
241 const [row] = await db
242 .select({
243 prNumber: pullRequests.number,
244 headBranch: pullRequests.headBranch,
245 ownerId: repositories.ownerId,
246 repoName: repositories.name,
247 })
248 .from(pullRequests)
249 .innerJoin(
250 repositories,
251 eq(pullRequests.repositoryId, repositories.id)
252 )
253 .where(eq(pullRequests.id, args.prId))
254 .limit(1);
255 if (!row) return null;
256 const [owner] = await db
257 .select({ username: users.username })
258 .from(users)
259 .where(eq(users.id, row.ownerId))
260 .limit(1);
261 if (!owner) return null;
262 resolved = {
263 prNumber: row.prNumber,
264 headBranch: row.headBranch,
265 ownerName: owner.username,
266 repoName: row.repoName,
267 };
268 } catch (err) {
269 console.warn(
270 "[pr-sandbox] resolve PR failed:",
271 err instanceof Error ? err.message : err
272 );
273 return null;
274 }
275
276 const url =
277 args.sandboxUrl ??
278 buildSandboxUrl(resolved.prNumber, resolved.ownerName, resolved.repoName);
279
280 // Resolve playground.yml — committed file wins, else ask AI, else default.
281 let yml = args.playgroundYml;
282 if (yml === undefined) {
283 const fromGit = await readPlaygroundYml(
284 resolved.ownerName,
285 resolved.repoName,
286 resolved.headBranch
287 );
288 yml =
289 fromGit ??
290 (await generatePlaygroundYml(
291 `${resolved.ownerName}/${resolved.repoName} (PR #${resolved.prNumber})`
292 ));
293 }
294
295 try {
296 const [row] = await db
297 .insert(prSandboxes)
298 .values({
299 prId: args.prId,
300 status: "provisioning",
301 sandboxUrl: url,
302 playgroundYml: yml,
303 provisionedAt: now,
304 expiresAt,
305 destroyedAt: null,
306 errorMessage: null,
307 })
308 .onConflictDoUpdate({
309 target: prSandboxes.prId,
310 set: {
311 status: "provisioning",
312 sandboxUrl: url,
313 playgroundYml: yml,
314 provisionedAt: now,
315 expiresAt,
316 destroyedAt: null,
317 errorMessage: null,
318 },
319 })
320 .returning();
321 return row ?? null;
322 } catch (err) {
323 console.warn(
324 "[pr-sandbox] upsert failed:",
325 err instanceof Error ? err.message : err
326 );
327 return null;
328 }
329}
330
331/**
332 * Mark a sandbox as successfully spun up.
333 */
334export async function markSandboxReady(
335 id: string,
336 containerId?: string
337): Promise<void> {
338 if (!id) return;
339 try {
340 await db
341 .update(prSandboxes)
342 .set({
343 status: "ready",
344 errorMessage: null,
345 ...(containerId ? { containerId } : {}),
346 })
347 .where(eq(prSandboxes.id, id));
348 } catch (err) {
349 console.warn(
350 "[pr-sandbox] markReady failed:",
351 err instanceof Error ? err.message : err
352 );
353 }
354}
355
356/**
357 * Mark a sandbox as failed and record the error (truncated).
358 */
359export async function markSandboxFailed(
360 id: string,
361 error: string
362): Promise<void> {
363 if (!id) return;
364 try {
365 await db
366 .update(prSandboxes)
367 .set({
368 status: "failed",
369 errorMessage: (error || "").slice(0, ERROR_MESSAGE_CAP),
370 })
371 .where(eq(prSandboxes.id, id));
372 } catch (err) {
373 console.warn(
374 "[pr-sandbox] markFailed failed:",
375 err instanceof Error ? err.message : err
376 );
377 }
378}
379
380/**
381 * Destroy a sandbox (status='destroyed' + destroyed_at = now). Idempotent.
382 * A real implementation would also tell the hoster to tear down the
383 * container; for v1 we just flip the row.
384 */
385export async function destroySandbox(
386 id: string,
387 now: () => Date = () => new Date()
388): Promise<void> {
389 if (!id) return;
390 try {
391 await db
392 .update(prSandboxes)
393 .set({ status: "destroyed", destroyedAt: now() })
394 .where(eq(prSandboxes.id, id));
395 } catch (err) {
396 console.warn(
397 "[pr-sandbox] destroy failed:",
398 err instanceof Error ? err.message : err
399 );
400 }
401}
402
403/**
404 * Look up the sandbox row for a PR, or null if there isn't one. Used by
405 * the PR detail page + the JSON status endpoint.
406 */
407export async function getSandboxForPr(
408 prId: string
409): Promise<PrSandbox | null> {
410 if (!prId) return null;
411 try {
412 const [row] = await db
413 .select()
414 .from(prSandboxes)
415 .where(eq(prSandboxes.prId, prId))
416 .limit(1);
417 return row ?? null;
418 } catch {
419 return null;
420 }
421}
422
423/**
424 * Autopilot task: tear down every sandbox whose `expires_at` is in the
425 * past. Already-destroyed/failed rows are skipped so the loop stays cheap.
426 * Returns the number of rows transitioned for observability.
427 */
428export async function expireOldSandboxes(
429 now: () => Date = () => new Date()
430): Promise<number> {
431 try {
432 const ready = await db
433 .update(prSandboxes)
434 .set({ status: "destroyed", destroyedAt: now() })
435 .where(
436 and(
437 lt(prSandboxes.expiresAt, now()),
438 eq(prSandboxes.status, "ready")
439 )
440 )
441 .returning({ id: prSandboxes.id });
442 const stuck = await db
443 .update(prSandboxes)
444 .set({ status: "destroyed", destroyedAt: now() })
445 .where(
446 and(
447 lt(prSandboxes.expiresAt, now()),
448 eq(prSandboxes.status, "provisioning")
449 )
450 )
451 .returning({ id: prSandboxes.id });
452 return ready.length + stuck.length;
453 } catch (err) {
454 console.warn(
455 "[pr-sandbox] expireOldSandboxes failed:",
456 err instanceof Error ? err.message : err
457 );
458 return 0;
459 }
460}