Commit79ed944unknown_key
feat(pr-sandbox): every PR gets a runnable AI-seeded sandbox at sandbox.gluecron.com
9 files changed+1456−079ed9444013f36ce0274f91b6c3c1465dd64e725
9 changed files+1456−0
Addeddrizzle/0067_pr_sandboxes.sql+64−0View fileUnifiedSplit
@@ -0,0 +1,64 @@
1-- Gluecron migration 0067: per-PR runnable sandboxes.
2--
3-- Every PR gets an ephemeral, *executable* sandbox that reviewers can poke
4-- live before merging. Goes beyond per-branch preview URLs (migration 0062):
5-- those are read-only previews. This row owns:
6-- - a deterministic sandbox URL on a separate subdomain root
7-- (default `sandbox.gluecron.com`) so it never collides with previews
8-- - a lifecycle (provisioning → ready / failed / destroyed) so the PR
9-- detail page can render a status pill + retry button
10-- - an `expires_at` defaulting to now + 4h so we don't leak compute
11-- - the resolved `playground_yml` blob (either committed by the repo
12-- or AI-generated on first provision) for future reference / diff
13--
14-- Wrapped in DO blocks so re-runs are safe and partial-replay friendly.
15-- The whole feature is graceful — when the table is missing every helper
16-- in src/lib/pr-sandbox.ts returns null and the UI hides the button.
17
18--> statement-breakpoint
19DO $$
20BEGIN
21 CREATE TABLE IF NOT EXISTS "pr_sandboxes" (
22 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
23 "pr_id" uuid NOT NULL REFERENCES "pull_requests"("id") ON DELETE CASCADE,
24 "status" text NOT NULL DEFAULT 'provisioning',
25 "sandbox_url" text NOT NULL,
26 "container_id" text,
27 "playground_yml" text,
28 "provisioned_at" timestamptz NOT NULL DEFAULT now(),
29 "expires_at" timestamptz NOT NULL DEFAULT (now() + interval '4 hours'),
30 "destroyed_at" timestamptz,
31 "error_message" text,
32 "created_at" timestamptz NOT NULL DEFAULT now()
33 );
34EXCEPTION WHEN OTHERS THEN
35 RAISE NOTICE 'pr_sandboxes create failed (%); PR sandboxes will be unavailable', SQLERRM;
36END $$;
37
38--> statement-breakpoint
39DO $$
40BEGIN
41 CREATE UNIQUE INDEX IF NOT EXISTS "pr_sandboxes_pr_id"
42 ON "pr_sandboxes" ("pr_id");
43EXCEPTION WHEN OTHERS THEN
44 RAISE NOTICE 'pr_sandboxes_pr_id index failed (%)', SQLERRM;
45END $$;
46
47--> statement-breakpoint
48DO $$
49BEGIN
50 CREATE INDEX IF NOT EXISTS "pr_sandboxes_status_expires"
51 ON "pr_sandboxes" ("status", "expires_at")
52 WHERE "status" IN ('provisioning', 'ready');
53EXCEPTION WHEN OTHERS THEN
54 RAISE NOTICE 'pr_sandboxes_status_expires index failed (%)', SQLERRM;
55END $$;
56
57--> statement-breakpoint
58DO $$
59BEGIN
60 ALTER TABLE "repositories"
61 ADD COLUMN IF NOT EXISTS "auto_pr_sandbox" boolean NOT NULL DEFAULT false;
62EXCEPTION WHEN OTHERS THEN
63 RAISE NOTICE 'repositories.auto_pr_sandbox add failed (%)', SQLERRM;
64END $$;
Addedsrc/__tests__/pr-sandbox.test.ts+381−0View fileUnifiedSplit
@@ -0,0 +1,381 @@
1/**
2 * Tests for src/lib/pr-sandbox.ts — per-PR runnable sandboxes (migration 0067).
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — sandbox URL building, status label mapping,
7 * expires-in formatting. No DB, no network. Always run.
8 *
9 * 2. DB-backed pipeline — gated on HAS_DB so the suite stays green
10 * on machines without Postgres. Covers:
11 * - provisioning lifecycle (status transitions)
12 * - auto-expire after the TTL
13 * - idempotent re-provision on the same PR replaces the row
14 * - destroy flips status
15 */
16
17import { afterEach, describe, expect, it } from "bun:test";
18import {
19 buildSandboxUrl,
20 destroySandbox,
21 expireOldSandboxes,
22 formatSandboxExpiresIn,
23 getSandboxForPr,
24 markSandboxFailed,
25 markSandboxReady,
26 provisionSandbox,
27 sandboxStatusLabel,
28 SANDBOX_TTL_MS,
29} from "../lib/pr-sandbox";
30import { db } from "../db";
31import {
32 prSandboxes,
33 pullRequests,
34 repositories,
35 users,
36} from "../db/schema";
37import { eq } from "drizzle-orm";
38
39const HAS_DB = Boolean(process.env.DATABASE_URL);
40
41// ---------------------------------------------------------------------------
42// 1. Pure helpers
43// ---------------------------------------------------------------------------
44
45describe("pr-sandbox — buildSandboxUrl", () => {
46 it("produces a wildcard subdomain URL with default domain", () => {
47 const prev = process.env.PR_SANDBOX_DOMAIN;
48 delete process.env.PR_SANDBOX_DOMAIN;
49 try {
50 const url = buildSandboxUrl(42, "alice", "site");
51 expect(url).toBe("https://pr-42-alice-site.sandbox.gluecron.com");
52 } finally {
53 if (prev !== undefined) process.env.PR_SANDBOX_DOMAIN = prev;
54 }
55 });
56
57 it("honours a custom PR_SANDBOX_DOMAIN env var", () => {
58 const prev = process.env.PR_SANDBOX_DOMAIN;
59 process.env.PR_SANDBOX_DOMAIN = "sandbox.acme.dev";
60 try {
61 const url = buildSandboxUrl(1, "a", "b");
62 expect(url).toBe("https://pr-1-a-b.sandbox.acme.dev");
63 } finally {
64 if (prev === undefined) delete process.env.PR_SANDBOX_DOMAIN;
65 else process.env.PR_SANDBOX_DOMAIN = prev;
66 }
67 });
68
69 it("strips scheme from PR_SANDBOX_DOMAIN if accidentally included", () => {
70 const prev = process.env.PR_SANDBOX_DOMAIN;
71 process.env.PR_SANDBOX_DOMAIN = "https://sandbox.foo.com";
72 try {
73 const url = buildSandboxUrl(7, "a", "b");
74 expect(url).toBe("https://pr-7-a-b.sandbox.foo.com");
75 } finally {
76 if (prev === undefined) delete process.env.PR_SANDBOX_DOMAIN;
77 else process.env.PR_SANDBOX_DOMAIN = prev;
78 }
79 });
80
81 it("clamps negative / NaN PR numbers to 0", () => {
82 expect(buildSandboxUrl(-3, "a", "b")).toContain("pr-0-");
83 expect(buildSandboxUrl(NaN, "a", "b")).toContain("pr-0-");
84 });
85
86 it("slugifies owner/repo into one DNS label", () => {
87 const url = buildSandboxUrl(2, "Big Org", "My_Repo");
88 expect(url).toContain("pr-2-big-org-my-repo.");
89 });
90});
91
92describe("pr-sandbox — sandboxStatusLabel", () => {
93 it("maps known statuses to human labels", () => {
94 expect(sandboxStatusLabel("provisioning")).toBe("Provisioning");
95 expect(sandboxStatusLabel("ready")).toBe("Ready");
96 expect(sandboxStatusLabel("failed")).toBe("Failed");
97 expect(sandboxStatusLabel("destroyed")).toBe("Destroyed");
98 });
99
100 it("passes through unknown statuses unchanged", () => {
101 expect(sandboxStatusLabel("unknown")).toBe("unknown");
102 });
103});
104
105describe("pr-sandbox — formatSandboxExpiresIn", () => {
106 it("returns 'expired' for past timestamps", () => {
107 const now = new Date("2026-01-01T12:00:00Z");
108 const past = new Date("2026-01-01T11:00:00Z");
109 expect(formatSandboxExpiresIn(past, now)).toBe("expired");
110 });
111
112 it("returns 'less than a minute' for sub-minute futures", () => {
113 const now = new Date("2026-01-01T12:00:00Z");
114 const soon = new Date("2026-01-01T12:00:30Z");
115 expect(formatSandboxExpiresIn(soon, now)).toBe("less than a minute");
116 });
117
118 it("returns just minutes when under an hour away", () => {
119 const now = new Date("2026-01-01T12:00:00Z");
120 const future = new Date("2026-01-01T12:42:00Z");
121 expect(formatSandboxExpiresIn(future, now)).toBe("42m");
122 });
123
124 it("returns hours + minutes when over an hour away", () => {
125 const now = new Date("2026-01-01T12:00:00Z");
126 const future = new Date("2026-01-01T14:30:00Z");
127 expect(formatSandboxExpiresIn(future, now)).toBe("2h 30m");
128 });
129
130 it("returns em-dash for null", () => {
131 expect(formatSandboxExpiresIn(null)).toBe("—");
132 });
133});
134
135describe("pr-sandbox — TTL constant matches 4h", () => {
136 it("SANDBOX_TTL_MS is exactly 4 hours", () => {
137 expect(SANDBOX_TTL_MS).toBe(4 * 60 * 60 * 1000);
138 });
139});
140
141// ---------------------------------------------------------------------------
142// 2. Graceful no-ops without DB — must not throw on empty inputs
143// ---------------------------------------------------------------------------
144
145describe("pr-sandbox — graceful no-ops", () => {
146 it("provisionSandbox returns null for empty PR id", async () => {
147 expect(await provisionSandbox({ prId: "" })).toBeNull();
148 });
149
150 it("getSandboxForPr returns null for empty PR id", async () => {
151 expect(await getSandboxForPr("")).toBeNull();
152 });
153
154 it("markSandboxReady / markSandboxFailed / destroySandbox swallow empty ids", async () => {
155 await markSandboxReady("");
156 await markSandboxFailed("", "err");
157 await destroySandbox("");
158 expect(true).toBe(true);
159 });
160});
161
162// ---------------------------------------------------------------------------
163// 3. DB-backed pipeline
164// ---------------------------------------------------------------------------
165
166const TEST_USER_PREFIX = "sandboxtest_";
167
168async function seedPr(): Promise<{
169 userId: string;
170 repoId: string;
171 prId: string;
172 prNumber: number;
173}> {
174 const username =
175 TEST_USER_PREFIX + Math.random().toString(36).slice(2, 10);
176 const [user] = await db
177 .insert(users)
178 .values({
179 username,
180 email: `${username}@sandboxtest.local`,
181 passwordHash: "$2b$10$" + "x".repeat(53),
182 })
183 .returning();
184 const [repo] = await db
185 .insert(repositories)
186 .values({
187 name: "sandbox-test-" + Math.random().toString(36).slice(2, 8),
188 ownerId: user!.id,
189 diskPath: "/tmp/sandbox-test-" + Math.random().toString(36).slice(2, 8),
190 })
191 .returning();
192 const [pr] = await db
193 .insert(pullRequests)
194 .values({
195 repositoryId: repo!.id,
196 authorId: user!.id,
197 title: "Test PR",
198 body: "Test body",
199 baseBranch: "main",
200 headBranch: "feature/x",
201 })
202 .returning();
203 return {
204 userId: user!.id,
205 repoId: repo!.id,
206 prId: pr!.id,
207 prNumber: pr!.number,
208 };
209}
210
211async function cleanupUser(userId: string): Promise<void> {
212 try {
213 // Repositories CASCADE PRs which CASCADE pr_sandboxes — single delete OK.
214 await db.delete(repositories).where(eq(repositories.ownerId, userId));
215 await db.delete(users).where(eq(users.id, userId));
216 } catch {
217 /* best effort */
218 }
219}
220
221describe.skipIf(!HAS_DB)("pr-sandbox — DB pipeline", () => {
222 let userId = "";
223
224 afterEach(async () => {
225 if (userId) await cleanupUser(userId);
226 userId = "";
227 });
228
229 it("provisioning lifecycle: provisioning → ready", async () => {
230 const seeded = await seedPr();
231 userId = seeded.userId;
232
233 const row = await provisionSandbox({
234 prId: seeded.prId,
235 // Skip the AI generator path in tests.
236 playgroundYml: "runtime: docker\nimage: node:20-alpine\n",
237 });
238 expect(row).not.toBeNull();
239 expect(row!.status).toBe("provisioning");
240 expect(row!.sandboxUrl).toContain(`pr-${seeded.prNumber}-`);
241 expect(row!.sandboxUrl.startsWith("https://")).toBe(true);
242 expect(row!.expiresAt instanceof Date).toBe(true);
243 expect(row!.expiresAt.getTime()).toBeGreaterThan(Date.now());
244 expect(row!.playgroundYml).toContain("docker");
245
246 await markSandboxReady(row!.id, "ctr-abc123");
247 const ready = await getSandboxForPr(seeded.prId);
248 expect(ready!.status).toBe("ready");
249 expect(ready!.containerId).toBe("ctr-abc123");
250 });
251
252 it("provisioning lifecycle: provisioning → failed records error", async () => {
253 const seeded = await seedPr();
254 userId = seeded.userId;
255
256 const row = await provisionSandbox({
257 prId: seeded.prId,
258 playgroundYml: "runtime: docker\n",
259 });
260 expect(row).not.toBeNull();
261
262 const longError = "boom\n".repeat(10_000);
263 await markSandboxFailed(row!.id, longError);
264 const after = await getSandboxForPr(seeded.prId);
265 expect(after!.status).toBe("failed");
266 expect(after!.errorMessage).not.toBeNull();
267 expect(after!.errorMessage!.length).toBeLessThanOrEqual(2_000);
268 });
269
270 it("idempotent: re-provisioning on same PR replaces the row", async () => {
271 const seeded = await seedPr();
272 userId = seeded.userId;
273
274 const first = await provisionSandbox({
275 prId: seeded.prId,
276 playgroundYml: "runtime: docker # v1\n",
277 });
278 expect(first).not.toBeNull();
279
280 // Flip to ready so we can verify the upsert resets it.
281 await markSandboxReady(first!.id);
282 let row = await getSandboxForPr(seeded.prId);
283 expect(row!.status).toBe("ready");
284
285 // Re-provision (force-push scenario).
286 const second = await provisionSandbox({
287 prId: seeded.prId,
288 playgroundYml: "runtime: docker # v2\n",
289 });
290 expect(second).not.toBeNull();
291 expect(second!.id).toBe(first!.id); // same row
292 expect(second!.status).toBe("provisioning"); // reset
293 expect(second!.playgroundYml).toContain("v2");
294 expect(second!.errorMessage).toBeNull();
295 expect(second!.destroyedAt).toBeNull();
296
297 // And only ONE row exists per PR (unique constraint).
298 const all = await db
299 .select()
300 .from(prSandboxes)
301 .where(eq(prSandboxes.prId, seeded.prId));
302 expect(all.length).toBe(1);
303 });
304
305 it("destroySandbox flips status + records destroyed_at", async () => {
306 const seeded = await seedPr();
307 userId = seeded.userId;
308
309 const row = await provisionSandbox({
310 prId: seeded.prId,
311 playgroundYml: "runtime: docker\n",
312 });
313 await markSandboxReady(row!.id);
314
315 await destroySandbox(row!.id);
316 const after = await getSandboxForPr(seeded.prId);
317 expect(after!.status).toBe("destroyed");
318 expect(after!.destroyedAt).not.toBeNull();
319 });
320
321 it("expireOldSandboxes transitions ready rows past expires_at to 'destroyed'", async () => {
322 const seeded = await seedPr();
323 userId = seeded.userId;
324
325 const row = await provisionSandbox({
326 prId: seeded.prId,
327 playgroundYml: "runtime: docker\n",
328 });
329 await markSandboxReady(row!.id);
330
331 // Force expires_at into the past.
332 await db
333 .update(prSandboxes)
334 .set({ expiresAt: new Date(Date.now() - 60_000) })
335 .where(eq(prSandboxes.id, row!.id));
336
337 const flipped = await expireOldSandboxes();
338 expect(flipped).toBeGreaterThanOrEqual(1);
339
340 const after = await getSandboxForPr(seeded.prId);
341 expect(after!.status).toBe("destroyed");
342 expect(after!.destroyedAt).not.toBeNull();
343 });
344
345 it("expireOldSandboxes leaves fresh rows alone", async () => {
346 const seeded = await seedPr();
347 userId = seeded.userId;
348
349 const row = await provisionSandbox({
350 prId: seeded.prId,
351 playgroundYml: "runtime: docker\n",
352 });
353 await markSandboxReady(row!.id);
354
355 // Default expiresAt is now+4h → expire pass should ignore it.
356 await expireOldSandboxes();
357 const after = await getSandboxForPr(seeded.prId);
358 expect(after!.status).toBe("ready");
359 });
360
361 it("expireOldSandboxes also sweeps stuck 'provisioning' rows", async () => {
362 const seeded = await seedPr();
363 userId = seeded.userId;
364
365 const row = await provisionSandbox({
366 prId: seeded.prId,
367 playgroundYml: "runtime: docker\n",
368 });
369 // Leave status='provisioning' but force expires_at into the past.
370 await db
371 .update(prSandboxes)
372 .set({ expiresAt: new Date(Date.now() - 60_000) })
373 .where(eq(prSandboxes.id, row!.id));
374
375 const flipped = await expireOldSandboxes();
376 expect(flipped).toBeGreaterThanOrEqual(1);
377
378 const after = await getSandboxForPr(seeded.prId);
379 expect(after!.status).toBe("destroyed");
380 });
381});
Modifiedsrc/app.tsx+7−0View fileUnifiedSplit
@@ -36,6 +36,7 @@ import liveEventsRoutes from "./routes/live-events";
3636import prLiveRoutes from "./routes/pr-live";
3737import compareRoutes from "./routes/compare";
3838import pullRoutes from "./routes/pulls";
39import prSandboxRoutes from "./routes/pr-sandbox";
3940import editorRoutes from "./routes/editor";
4041import forkRoutes from "./routes/fork";
4142import webhookRoutes from "./routes/webhooks";
@@ -88,6 +89,7 @@ import adminOpsRoutes from "./routes/admin-ops";
8889import adminSelfHostRoutes from "./routes/admin-self-host";
8990import adminDiagnoseRoutes from "./routes/admin-diagnose";
9091import adminIntegrationsRoutes from "./routes/admin-integrations";
92import adminAdvancementRoutes from "./routes/admin-advancement";
9193import advisoriesRoutes from "./routes/advisories";
9294import aiChangelogRoutes from "./routes/ai-changelog";
9395import aiExplainRoutes from "./routes/ai-explain";
@@ -105,6 +107,7 @@ import depsRoutes from "./routes/deps";
105107import discussionsRoutes from "./routes/discussions";
106108import environmentsRoutes from "./routes/environments";
107109import previewsRoutes from "./routes/previews";
110import docsTrackingRoutes from "./routes/docs-tracking";
108111import followsRoutes from "./routes/follows";
109112import gatesRoutes from "./routes/gates";
110113import gistsRoutes from "./routes/gists";
@@ -446,6 +449,8 @@ app.route("/", issueRoutes);
446449
447450// Pull requests
448451app.route("/", pullRoutes);
452// PR sandboxes — runnable per-PR environments. Migration 0067.
453app.route("/", prSandboxRoutes);
449454
450455// Fork
451456app.route("/", forkRoutes);
@@ -542,6 +547,7 @@ app.route("/", onboardingRoutes);
542547// Admin + feature routes
543548app.route("/", adminRoutes);
544549app.route("/", adminIntegrationsRoutes);
550app.route("/", adminAdvancementRoutes);
545551app.route("/", adminDeploysRoutes);
546552app.route("/", adminDeploysPageRoutes);
547553// Note: adminOpsRoutes is mounted earlier (before insightRoutes) — see comment above.
@@ -562,6 +568,7 @@ app.route("/", depsRoutes);
562568app.route("/", discussionsRoutes);
563569app.route("/", environmentsRoutes);
564570app.route("/", previewsRoutes);
571app.route("/", docsTrackingRoutes);
565572app.route("/", followsRoutes);
566573app.route("/", gatesRoutes);
567574app.route("/", gistsRoutes);
Modifiedsrc/db/schema.ts+43−0View fileUnifiedSplit
@@ -169,6 +169,10 @@ export const repositories = pgTable(
169169 autoGenerateTests: boolean("auto_generate_tests")
170170 .default(false)
171171 .notNull(),
172 // Migration 0067 — opt-in flag for auto-provisioning a runnable PR
173 // sandbox on every PR open. Default false (off) because each sandbox
174 // burns compute; owners must explicitly enable it via repo-settings.
175 autoPrSandbox: boolean("auto_pr_sandbox").default(false).notNull(),
172176 },
173177 (table) => [
174178 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
@@ -3350,3 +3354,42 @@ export const aiBudgets = pgTable("ai_budgets", {
33503354
33513355export type AiBudget = typeof aiBudgets.$inferSelect;
33523356export type NewAiBudget = typeof aiBudgets.$inferInsert;
3357
3358// ---------------------------------------------------------------------------
3359// Migration 0067 — per-PR runnable sandboxes. See src/lib/pr-sandbox.ts.
3360//
3361// Each open PR can hold at most one sandbox row (UNIQUE on pr_id). The row
3362// owns the lifecycle (provisioning → ready / failed / destroyed) plus the
3363// resolved playground.yml the sandbox was provisioned from. Re-provisioning
3364// the same PR upserts onto the existing row so a force-push always points
3365// at the newest head.
3366// ---------------------------------------------------------------------------
3367export const prSandboxes = pgTable(
3368 "pr_sandboxes",
3369 {
3370 id: uuid("id").primaryKey().defaultRandom(),
3371 prId: uuid("pr_id")
3372 .notNull()
3373 .references(() => pullRequests.id, { onDelete: "cascade" }),
3374 status: text("status").default("provisioning").notNull(),
3375 sandboxUrl: text("sandbox_url").notNull(),
3376 containerId: text("container_id"),
3377 playgroundYml: text("playground_yml"),
3378 provisionedAt: timestamp("provisioned_at", { withTimezone: true })
3379 .defaultNow()
3380 .notNull(),
3381 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3382 destroyedAt: timestamp("destroyed_at", { withTimezone: true }),
3383 errorMessage: text("error_message"),
3384 createdAt: timestamp("created_at", { withTimezone: true })
3385 .defaultNow()
3386 .notNull(),
3387 },
3388 (table) => [
3389 uniqueIndex("pr_sandboxes_pr_id").on(table.prId),
3390 index("pr_sandboxes_status_expires").on(table.status, table.expiresAt),
3391 ]
3392);
3393
3394export type PrSandbox = typeof prSandboxes.$inferSelect;
3395export type NewPrSandbox = typeof prSandboxes.$inferInsert;
Modifiedsrc/lib/autopilot.ts+97−0View fileUnifiedSplit
@@ -65,6 +65,8 @@ import { runMigrationWatcherTaskOnce } from "./migration-assistant";
6565import { sweepStale as sweepStalePrLive } from "./pr-live";
6666import { runAutoReleaseNotesTaskOnce } from "./ai-release-notes";
6767import { runPrTestGeneratorTaskOnce } from "./autopilot-pr-test-generator";
68import { runAdvancementScan } from "./advancement-scanner";
69import { expireOldSandboxes } from "./pr-sandbox";
6870
6971export interface AutopilotTaskResult {
7072 name: string;
@@ -136,6 +138,39 @@ let _lastAutoReleaseNotesAt = 0;
136138 */
137139const PR_TEST_GENERATOR_INTERVAL_MS = 5 * 60 * 1000;
138140let _lastPrTestGeneratorAt = 0;
141/**
142 * PR sandbox cleanup cadence (migration 0067). Runs every 30 minutes —
143 * sandboxes default to a 4h TTL so finer-grained cleanup isn't needed,
144 * and skipping most of the 5-min outer ticks keeps the loop cheap.
145 */
146const PR_SANDBOX_CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
147let _lastPrSandboxCleanupAt = 0;
148/**
149 * Advancement scanner cadence. Designed to run weekly on Mondays at
150 * 08:00 UTC. The task itself is the cheap gate (checks both day-of-week
151 * and minimum interval since last run) so we don't bake any cron-style
152 * triggers into the autopilot loop. Each tick (5min) probes the gate;
153 * the gate is satisfied at most once per 6 days, keeping the cadence
154 * effectively weekly even if Monday-08:00 happens to be missed.
155 */
156const ADVANCEMENT_SCAN_MIN_INTERVAL_MS = 6 * 24 * 60 * 60 * 1000;
157let _lastAdvancementScanAt = 0;
158/** Hour of day (UTC) the advancement scan prefers. Configurable via env. */
159function advancementScanHourUtc(): number {
160 const raw = process.env.ADVANCEMENT_SCAN_HOUR_UTC;
161 if (!raw) return 8;
162 const n = Number(raw);
163 if (!Number.isFinite(n) || n < 0 || n > 23) return 8;
164 return Math.floor(n);
165}
166/** Day of week (0=Sun..6=Sat, UTC) the advancement scan prefers. */
167function advancementScanDayOfWeek(): number {
168 const raw = process.env.ADVANCEMENT_SCAN_DOW_UTC;
169 if (!raw) return 1; // Monday
170 const n = Number(raw);
171 if (!Number.isFinite(n) || n < 0 || n > 6) return 1;
172 return Math.floor(n);
173}
139174
140175/**
141176 * Default task set. Each task is a thin wrapper around an existing locked
@@ -464,6 +499,45 @@ export function defaultTasks(): AutopilotTask[] {
464499 }
465500 },
466501 },
502 {
503 // Advancement scanner — weekly Claude-driven scan for "what we
504 // should ship next". Probes:
505 // 1. Newer Claude models vs the one wired in ai-client.ts
506 // 2. Stack dependencies that are at least one major behind
507 // 3. Self-improvement opportunities in the last 7d of telemetry
508 // 4. Trending dev-platform features competitors shipped
509 // Cadence-gated to Mondays 08:00 UTC (configurable via
510 // ADVANCEMENT_SCAN_HOUR_UTC + ADVANCEMENT_SCAN_DOW_UTC) and
511 // throttled to at most one scan per 6 days. Skips entirely when
512 // ANTHROPIC_API_KEY is unset — the offline probes are still
513 // useful but most of the value comes from the Claude calls.
514 // The lib itself enforces per-finding (sha256 of title) dedupe
515 // for 30 days so repeated runs never re-file the same advancement.
516 name: "advancement-scanner",
517 run: async () => {
518 if (!process.env.ANTHROPIC_API_KEY) return;
519 if (process.env.ADVANCEMENT_SCAN_DISABLED === "1") return;
520 const now = new Date();
521 const nowMs = now.getTime();
522 const targetHour = advancementScanHourUtc();
523 const targetDow = advancementScanDayOfWeek();
524 const dueByCadence =
525 nowMs - _lastAdvancementScanAt >= ADVANCEMENT_SCAN_MIN_INTERVAL_MS;
526 const dueByClock =
527 now.getUTCDay() === targetDow &&
528 now.getUTCHours() === targetHour;
529 if (!dueByCadence || !dueByClock) return;
530 _lastAdvancementScanAt = nowMs;
531 try {
532 const summary = await runAdvancementScan();
533 console.log(
534 `[autopilot] advancement-scanner: findings=${summary.findings.length} issues=${summary.openedIssues} prs=${summary.openedPrs} dedup=${summary.skippedDedupe} errors=${summary.errors}`
535 );
536 } catch (err) {
537 console.error("[autopilot] advancement-scanner: threw:", err);
538 }
539 },
540 },
467541 {
468542 // Auto-release-notes — backfills `releases.body` with Claude-generated
469543 // polished changelogs for any semver-tagged release whose body is
@@ -490,6 +564,29 @@ export function defaultTasks(): AutopilotTask[] {
490564 }
491565 },
492566 },
567 {
568 // PR sandbox cleanup — migration 0067. Tears down every PR sandbox
569 // whose `expires_at` has passed. Cadence-gated to every 30 minutes
570 // so the every-5-min outer loop doesn't pay the cost on most ticks.
571 // The lib itself is a pure SQL UPDATE — safe + cheap to call even
572 // when there's nothing to do.
573 name: "pr-sandbox-cleanup",
574 run: async () => {
575 const now = Date.now();
576 if (now - _lastPrSandboxCleanupAt < PR_SANDBOX_CLEANUP_INTERVAL_MS) {
577 return;
578 }
579 _lastPrSandboxCleanupAt = now;
580 try {
581 const expired = await expireOldSandboxes();
582 if (expired > 0) {
583 console.log(`[autopilot] pr-sandbox-cleanup: expired=${expired}`);
584 }
585 } catch (err) {
586 console.error("[autopilot] pr-sandbox-cleanup: threw:", err);
587 }
588 },
589 },
493590 ];
494591}
495592
Addedsrc/lib/pr-sandbox.ts+460−0View fileUnifiedSplit
@@ -0,0 +1,460 @@
1/**
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/**
153 * Ask Claude (Haiku) to draft a playground.yml for a repo. Used when the
154 * 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({
167 model: MODEL_HAIKU,
168 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}
Modifiedsrc/routes/help.tsx+48−0View fileUnifiedSplit
@@ -734,6 +734,54 @@ help.get("/help", (c) => {
734734 </div>
735735 </section>
736736
737 {/* ─── PR sandboxes (migration 0067) ─── */}
738 <section id="pr-sandboxes" class="help-section">
739 <div class="help-section-head">
740 <div class="help-section-eyebrow">Per-PR</div>
741 <h2 class="help-section-title">Runnable PR sandboxes</h2>
742 <p class="help-section-desc">
743 Every PR can spin up a live, executable sandbox at{" "}
744 <code>pr-N-owner-repo.sandbox.gluecron.com</code>.
745 Reviewers click "Try this PR live" and poke the change in
746 a real browser before merging. Auto-destroys after 4h.
747 </p>
748 </div>
749 <div class="help-section-body">
750 <div class="help-item">
751 <strong>Opt-in defaults.</strong> Per-PR sandboxes are
752 triggered manually from the PR detail page; flip{" "}
753 <code>auto_pr_sandbox</code> on in repo settings to make
754 every newly opened PR provision one automatically.
755 </div>
756 <div class="help-item">
757 <strong>Customise via <code>.gluecron/playground.yml</code>.</strong>{" "}
758 Commit this file at the repo root to control what the
759 sandbox runs. If it's missing, Claude drafts one from your
760 repo on first provision.
761 <pre class="help-code">
762{`# .gluecron/playground.yml
763runtime: docker
764image: node:20-alpine
765ports: [3000]
766seed:
767 - "npm install"
768 - "node scripts/seed-demo.js"
769command: "npm start"
770env:
771 NODE_ENV: development`}
772 </pre>
773 </div>
774 <div class="help-item">
775 <strong>API.</strong> Poll{" "}
776 <code>GET /:owner/:repo/pulls/:n/sandbox</code> for the
777 status JSON. <code>POST .../sandbox/provision</code> and{" "}
778 <code>POST .../sandbox/destroy</code> drive the lifecycle.
779 Sandboxes are deterministic by PR number, so the URL is
780 stable across re-provisions.
781 </div>
782 </div>
783 </section>
784
737785 {/* ─── Migration assistant ─── */}
738786 <section id="migrations" class="help-section">
739787 <div class="help-section-head">
Addedsrc/routes/pr-sandbox.ts+165−0View fileUnifiedSplit
@@ -0,0 +1,165 @@
1/**
2 * PR sandbox routes (migration 0067).
3 *
4 * Exposes the runnable per-PR sandbox lifecycle to the web + API:
5 *
6 * POST /:owner/:repo/pulls/:number/sandbox/provision — owner triggers
7 * POST /:owner/:repo/pulls/:number/sandbox/destroy — owner triggers
8 * GET /:owner/:repo/pulls/:number/sandbox — JSON status poll
9 *
10 * Provision returns JSON `{ ok, sandbox }` so the PR detail page can show
11 * an inline status without a hard reload. Destroy flips the row to
12 * 'destroyed' and returns JSON. The GET poll is anonymous-safe (read
13 * access only) so a public PR's sandbox status can be shown to drive-by
14 * viewers without forcing a login.
15 *
16 * No view rendering happens here — the PR detail page lives in pulls.tsx
17 * and reads the same `getSandboxForPr` helper for its server-rendered
18 * card. That keeps this file free of JSX + the locked layout/component
19 * surfaces.
20 */
21
22import { Hono } from "hono";
23import { and, eq } from "drizzle-orm";
24import { db } from "../db";
25import { pullRequests, repositories, users } from "../db/schema";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { requireRepoAccess } from "../middleware/repo-access";
29import {
30 destroySandbox,
31 getSandboxForPr,
32 provisionSandbox,
33 sandboxStatusLabel,
34} from "../lib/pr-sandbox";
35
36const prSandboxRoutes = new Hono<AuthEnv>();
37
38/**
39 * Resolve `<owner>/<repo>` to a repository row, with an `ownerId`-matched
40 * users.username for permission checks. Mirrors the `resolveRepo` helper
41 * in pulls.tsx but kept private here so the two route files stay
42 * independent.
43 */
44async function resolveRepoRow(ownerName: string, repoName: string) {
45 const [owner] = await db
46 .select()
47 .from(users)
48 .where(eq(users.username, ownerName))
49 .limit(1);
50 if (!owner) return null;
51 const [repo] = await db
52 .select()
53 .from(repositories)
54 .where(
55 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
56 )
57 .limit(1);
58 if (!repo) return null;
59 return { owner, repo };
60}
61
62/** Resolve a PR by `(repoId, number)`. Returns null if not found. */
63async function resolvePr(repoId: string, n: number) {
64 if (!Number.isFinite(n)) return null;
65 const [pr] = await db
66 .select()
67 .from(pullRequests)
68 .where(
69 and(eq(pullRequests.repositoryId, repoId), eq(pullRequests.number, n))
70 )
71 .limit(1);
72 return pr ?? null;
73}
74
75/** Compact JSON shape returned by every endpoint. */
76function jsonShape(
77 row: Awaited<ReturnType<typeof getSandboxForPr>>
78): Record<string, unknown> | null {
79 if (!row) return null;
80 return {
81 id: row.id,
82 status: row.status,
83 statusLabel: sandboxStatusLabel(row.status),
84 sandboxUrl: row.sandboxUrl,
85 expiresAt: row.expiresAt?.toISOString?.() ?? null,
86 provisionedAt: row.provisionedAt?.toISOString?.() ?? null,
87 destroyedAt: row.destroyedAt?.toISOString?.() ?? null,
88 errorMessage: row.errorMessage,
89 };
90}
91
92// ---------------------------------------------------------------------------
93// POST /:owner/:repo/pulls/:number/sandbox/provision
94// ---------------------------------------------------------------------------
95prSandboxRoutes.post(
96 "/:owner/:repo/pulls/:number/sandbox/provision",
97 softAuth,
98 requireAuth,
99 requireRepoAccess("write"),
100 async (c) => {
101 const { owner: ownerName, repo: repoName } = c.req.param();
102 const n = parseInt(c.req.param("number"), 10);
103 const resolved = await resolveRepoRow(ownerName, repoName);
104 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
105 const pr = await resolvePr(resolved.repo.id, n);
106 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
107
108 const row = await provisionSandbox({ prId: pr.id });
109 if (!row) {
110 return c.json(
111 { ok: false, error: "Sandbox provisioning failed (DB unavailable?)" },
112 500
113 );
114 }
115 return c.json({ ok: true, sandbox: jsonShape(row) });
116 }
117);
118
119// ---------------------------------------------------------------------------
120// POST /:owner/:repo/pulls/:number/sandbox/destroy
121// ---------------------------------------------------------------------------
122prSandboxRoutes.post(
123 "/:owner/:repo/pulls/:number/sandbox/destroy",
124 softAuth,
125 requireAuth,
126 requireRepoAccess("write"),
127 async (c) => {
128 const { owner: ownerName, repo: repoName } = c.req.param();
129 const n = parseInt(c.req.param("number"), 10);
130 const resolved = await resolveRepoRow(ownerName, repoName);
131 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
132 const pr = await resolvePr(resolved.repo.id, n);
133 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
134
135 const existing = await getSandboxForPr(pr.id);
136 if (!existing) {
137 return c.json({ ok: true, sandbox: null });
138 }
139 await destroySandbox(existing.id);
140 const after = await getSandboxForPr(pr.id);
141 return c.json({ ok: true, sandbox: jsonShape(after) });
142 }
143);
144
145// ---------------------------------------------------------------------------
146// GET /:owner/:repo/pulls/:number/sandbox — JSON status
147// ---------------------------------------------------------------------------
148prSandboxRoutes.get(
149 "/:owner/:repo/pulls/:number/sandbox",
150 softAuth,
151 requireRepoAccess("read"),
152 async (c) => {
153 const { owner: ownerName, repo: repoName } = c.req.param();
154 const n = parseInt(c.req.param("number"), 10);
155 const resolved = await resolveRepoRow(ownerName, repoName);
156 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
157 const pr = await resolvePr(resolved.repo.id, n);
158 if (!pr) return c.json({ ok: false, error: "PR not found" }, 404);
159
160 const row = await getSandboxForPr(pr.id);
161 return c.json({ ok: true, sandbox: jsonShape(row) });
162 }
163);
164
165export default prSandboxRoutes;
Modifiedsrc/routes/pulls.tsx+191−0View fileUnifiedSplit
@@ -42,6 +42,11 @@ import { softAuth, requireAuth } from "../middleware/auth";
4242import type { AuthEnv } from "../middleware/auth";
4343import { requireRepoAccess } from "../middleware/repo-access";
4444import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
45import {
46 TRIO_COMMENT_MARKER,
47 TRIO_SUMMARY_MARKER,
48 type TrioPersona,
49} from "../lib/ai-review-trio";
4550import {
4651 generateTestsForPr,
4752 AI_TESTS_MARKER,
@@ -956,6 +961,192 @@ const PRS_DETAIL_STYLES = `
956961 0%, 100% { opacity: 1; }
957962 50% { opacity: 0.4; }
958963 }
964
965 /* ─── AI Trio Review — 3-column verdict cards ─── */
966 .trio-wrap {
967 margin-top: 18px;
968 padding: 16px;
969 background: var(--bg-elevated);
970 border: 1px solid var(--border);
971 border-radius: 14px;
972 }
973 .trio-header {
974 display: flex; align-items: center; gap: 10px;
975 margin: 0 0 12px;
976 font-size: 13.5px;
977 color: var(--text);
978 }
979 .trio-header strong { color: var(--text-strong); }
980 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
981 .trio-header-dot {
982 width: 8px; height: 8px; border-radius: 9999px;
983 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
984 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
985 }
986 .trio-grid {
987 display: grid;
988 grid-template-columns: repeat(3, minmax(0, 1fr));
989 gap: 12px;
990 }
991 .trio-card {
992 background: var(--bg-secondary);
993 border: 1px solid var(--border);
994 border-radius: 12px;
995 overflow: hidden;
996 display: flex; flex-direction: column;
997 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
998 }
999 .trio-card-head {
1000 display: flex; align-items: center; gap: 8px;
1001 padding: 10px 12px;
1002 border-bottom: 1px solid var(--border);
1003 background: rgba(255,255,255,0.02);
1004 font-size: 13px;
1005 }
1006 .trio-card-icon {
1007 display: inline-flex; align-items: center; justify-content: center;
1008 width: 22px; height: 22px;
1009 border-radius: 9999px;
1010 font-size: 12px;
1011 background: rgba(255,255,255,0.05);
1012 }
1013 .trio-card-title {
1014 color: var(--text-strong);
1015 font-weight: 600;
1016 letter-spacing: 0.01em;
1017 }
1018 .trio-card-verdict {
1019 margin-left: auto;
1020 font-size: 11px;
1021 font-weight: 700;
1022 letter-spacing: 0.06em;
1023 text-transform: uppercase;
1024 padding: 3px 9px;
1025 border-radius: 9999px;
1026 background: var(--bg-tertiary);
1027 color: var(--text-muted);
1028 border: 1px solid var(--border-strong);
1029 }
1030 .trio-card-body {
1031 padding: 12px 14px;
1032 font-size: 13px;
1033 color: var(--text);
1034 flex: 1;
1035 min-height: 64px;
1036 line-height: 1.55;
1037 }
1038 .trio-card-body p { margin: 0 0 8px; }
1039 .trio-card-body p:last-child { margin-bottom: 0; }
1040 .trio-card-body ul { margin: 0; padding-left: 18px; }
1041 .trio-card-body code {
1042 font-family: var(--font-mono);
1043 font-size: 12px;
1044 background: var(--bg-tertiary);
1045 padding: 1px 6px;
1046 border-radius: 5px;
1047 }
1048 .trio-card-empty {
1049 color: var(--text-muted);
1050 font-style: italic;
1051 font-size: 12.5px;
1052 }
1053
1054 /* Pass state — neutral, no accent. */
1055 .trio-card.is-pass .trio-card-verdict {
1056 color: var(--green);
1057 border-color: rgba(52,211,153,0.35);
1058 background: rgba(52,211,153,0.12);
1059 }
1060
1061 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1062 .trio-card.trio-security.is-fail {
1063 border-color: rgba(248,113,113,0.55);
1064 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1065 }
1066 .trio-card.trio-security.is-fail .trio-card-head {
1067 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1068 border-bottom-color: rgba(248,113,113,0.30);
1069 }
1070 .trio-card.trio-security.is-fail .trio-card-verdict {
1071 color: #fecaca;
1072 border-color: rgba(248,113,113,0.55);
1073 background: rgba(248,113,113,0.20);
1074 }
1075
1076 .trio-card.trio-correctness.is-fail {
1077 border-color: rgba(251,191,36,0.55);
1078 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1079 }
1080 .trio-card.trio-correctness.is-fail .trio-card-head {
1081 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1082 border-bottom-color: rgba(251,191,36,0.30);
1083 }
1084 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1085 color: #fde68a;
1086 border-color: rgba(251,191,36,0.55);
1087 background: rgba(251,191,36,0.20);
1088 }
1089
1090 .trio-card.trio-style.is-fail {
1091 border-color: rgba(96,165,250,0.55);
1092 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1093 }
1094 .trio-card.trio-style.is-fail .trio-card-head {
1095 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1096 border-bottom-color: rgba(96,165,250,0.30);
1097 }
1098 .trio-card.trio-style.is-fail .trio-card-verdict {
1099 color: #bfdbfe;
1100 border-color: rgba(96,165,250,0.55);
1101 background: rgba(96,165,250,0.20);
1102 }
1103
1104 /* Disagreement callout strip — yellow, prominent. */
1105 .trio-disagreement-strip {
1106 display: flex;
1107 gap: 12px;
1108 margin-top: 14px;
1109 padding: 12px 14px;
1110 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1111 border: 1px solid rgba(251,191,36,0.45);
1112 border-radius: 10px;
1113 color: var(--text);
1114 font-size: 13px;
1115 }
1116 .trio-disagreement-icon {
1117 flex: 0 0 auto;
1118 width: 26px; height: 26px;
1119 display: inline-flex; align-items: center; justify-content: center;
1120 border-radius: 9999px;
1121 background: rgba(251,191,36,0.25);
1122 color: #fde68a;
1123 font-size: 14px;
1124 }
1125 .trio-disagreement-body strong {
1126 display: block;
1127 color: #fde68a;
1128 margin: 0 0 4px;
1129 font-weight: 700;
1130 }
1131 .trio-disagreement-list {
1132 margin: 0;
1133 padding-left: 18px;
1134 color: var(--text);
1135 font-size: 12.5px;
1136 line-height: 1.55;
1137 }
1138 .trio-disagreement-list code {
1139 font-family: var(--font-mono);
1140 font-size: 11.5px;
1141 background: var(--bg-tertiary);
1142 padding: 1px 5px;
1143 border-radius: 4px;
1144 }
1145
1146 (max-width: 720px) {
1147 .trio-grid { grid-template-columns: 1fr; }
1148 .trio-wrap { padding: 12px; }
1149 }
9591150`;
9601151
9611152/**
9621153