Commit9b3a183unknown_key
feat(dev-env): /:owner/:repo/dev — hosted VS Code in browser, cold-start sandboxes
9 files changed+2219−419b3a183e42e1f6715afa8d05b13f362290bb652c
9 changed files+2219−41
Addeddrizzle/0072_dev_envs.sql+66−0View fileUnifiedSplit
@@ -0,0 +1,66 @@
1-- Gluecron migration 0072: per-(repo, user) cloud dev environments.
2--
3-- Hosted VS Code in the browser. Push a repo, hit /:owner/:repo/dev, get a
4-- full-screen IDE backed by a cold-start sandbox container. Microsoft
5-- Codespaces costs them a fortune in Azure VM time — we go cheaper by
6-- spinning environments down after `idle_minutes` of no traffic and
7-- re-warming on demand.
8--
9-- One environment per (repository_id, owner_user_id) — re-starting the
10-- same repo as the same user upserts onto the existing row so the URL
11-- stays stable.
12--
13-- Wrapped in DO blocks so re-runs are safe and partial-replay friendly.
14-- Per repo opt-in lives on `repositories.dev_envs_enabled` (added below);
15-- the whole feature is graceful — when either the table or the column
16-- is missing every helper in src/lib/dev-env.ts returns null and the
17-- route renders a "disabled" notice instead of throwing.
18
19--> statement-breakpoint
20DO $$
21BEGIN
22 CREATE TABLE IF NOT EXISTS "dev_envs" (
23 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
24 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
25 "owner_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
26 "status" text NOT NULL DEFAULT 'cold',
27 "preview_url" text,
28 "container_id" text,
29 "machine_size" text NOT NULL DEFAULT 'small',
30 "idle_minutes" integer NOT NULL DEFAULT 30,
31 "dev_yml" text,
32 "error_message" text,
33 "last_active_at" timestamptz NOT NULL DEFAULT now(),
34 "expires_at" timestamptz,
35 "created_at" timestamptz NOT NULL DEFAULT now()
36 );
37EXCEPTION WHEN OTHERS THEN
38 RAISE NOTICE 'dev_envs create failed (%); dev environments will be unavailable', SQLERRM;
39END $$;
40
41--> statement-breakpoint
42DO $$
43BEGIN
44 CREATE UNIQUE INDEX IF NOT EXISTS "dev_envs_repo_owner"
45 ON "dev_envs" ("repository_id", "owner_user_id");
46EXCEPTION WHEN OTHERS THEN
47 RAISE NOTICE 'dev_envs_repo_owner index failed (%)', SQLERRM;
48END $$;
49
50--> statement-breakpoint
51DO $$
52BEGIN
53 CREATE INDEX IF NOT EXISTS "dev_envs_status_active"
54 ON "dev_envs" ("status", "last_active_at");
55EXCEPTION WHEN OTHERS THEN
56 RAISE NOTICE 'dev_envs_status_active index failed (%)', SQLERRM;
57END $$;
58
59--> statement-breakpoint
60DO $$
61BEGIN
62 ALTER TABLE "repositories"
63 ADD COLUMN IF NOT EXISTS "dev_envs_enabled" boolean NOT NULL DEFAULT false;
64EXCEPTION WHEN OTHERS THEN
65 RAISE NOTICE 'repositories.dev_envs_enabled add failed (%)', SQLERRM;
66END $$;
Addedsrc/__tests__/dev-env.test.ts+444−0View fileUnifiedSplit
@@ -0,0 +1,444 @@
1/**
2 * Tests for src/lib/dev-env.ts — cloud dev environments (migration 0072).
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — URL building, status label mapping, machine-size
7 * validation. No DB, no network. Always run.
8 *
9 * 2. DB-backed pipeline — gated on HAS_DB so the suite stays green on
10 * machines without Postgres. Covers:
11 * - startDevEnv reads committed dev.yml when present
12 * - startDevEnv generates a default when no file is committed
13 * - per-repo opt-in gate (refuses when devEnvsEnabled=false)
14 * - expireIdleEnvs only touches idle ones
15 * - stop / restart upsert keeps the same row
16 */
17
18import { afterEach, describe, expect, it } from "bun:test";
19import { eq } from "drizzle-orm";
20import {
21 buildDevEnvUrl,
22 DEFAULT_IDLE_MINUTES,
23 devEnvStatusLabel,
24 expireIdleEnvs,
25 generateDevYml,
26 getDevEnv,
27 getDevEnvForOwner,
28 markFailed,
29 markReady,
30 normalizeMachineSize,
31 recordActivity,
32 startDevEnv,
33 stopDevEnv,
34} from "../lib/dev-env";
35import { db } from "../db";
36import { devEnvs, repositories, users } from "../db/schema";
37
38const HAS_DB = Boolean(process.env.DATABASE_URL);
39
40// ---------------------------------------------------------------------------
41// 1. Pure helpers
42// ---------------------------------------------------------------------------
43
44describe("dev-env — buildDevEnvUrl", () => {
45 it("produces a wildcard subdomain with default domain", () => {
46 const prev = process.env.DEV_ENV_DOMAIN;
47 delete process.env.DEV_ENV_DOMAIN;
48 try {
49 const url = buildDevEnvUrl("abcdef-1234");
50 expect(url.startsWith("https://dev-")).toBe(true);
51 expect(url.endsWith(".gluecron.com")).toBe(true);
52 } finally {
53 if (prev !== undefined) process.env.DEV_ENV_DOMAIN = prev;
54 }
55 });
56
57 it("honours DEV_ENV_DOMAIN env var", () => {
58 const prev = process.env.DEV_ENV_DOMAIN;
59 process.env.DEV_ENV_DOMAIN = "dev.acme.dev";
60 try {
61 const url = buildDevEnvUrl("abc");
62 expect(url).toBe("https://dev-abc.dev.acme.dev");
63 } finally {
64 if (prev === undefined) delete process.env.DEV_ENV_DOMAIN;
65 else process.env.DEV_ENV_DOMAIN = prev;
66 }
67 });
68
69 it("strips scheme from DEV_ENV_DOMAIN if accidentally included", () => {
70 const prev = process.env.DEV_ENV_DOMAIN;
71 process.env.DEV_ENV_DOMAIN = "https://dev.foo.com";
72 try {
73 const url = buildDevEnvUrl("xyz");
74 expect(url).toBe("https://dev-xyz.dev.foo.com");
75 } finally {
76 if (prev === undefined) delete process.env.DEV_ENV_DOMAIN;
77 else process.env.DEV_ENV_DOMAIN = prev;
78 }
79 });
80
81 it("slugifies the env id so a UUID lands cleanly", () => {
82 // UUID with dashes is still a valid DNS label after slugify.
83 const url = buildDevEnvUrl("11111111-2222-3333-4444-555555555555");
84 expect(url).toContain("dev-11111111-2222-3333-4444-555555555555.");
85 });
86
87 it("handles empty / missing ids gracefully", () => {
88 const url = buildDevEnvUrl("");
89 expect(url.startsWith("https://dev-")).toBe(true);
90 });
91});
92
93describe("dev-env — devEnvStatusLabel", () => {
94 it("maps known statuses to human labels", () => {
95 expect(devEnvStatusLabel("cold")).toBe("Cold");
96 expect(devEnvStatusLabel("warming")).toBe("Warming up");
97 expect(devEnvStatusLabel("ready")).toBe("Ready");
98 expect(devEnvStatusLabel("failed")).toBe("Failed");
99 expect(devEnvStatusLabel("stopped")).toBe("Stopped");
100 });
101
102 it("passes through unknown statuses unchanged", () => {
103 expect(devEnvStatusLabel("mystery")).toBe("mystery");
104 });
105});
106
107describe("dev-env — normalizeMachineSize", () => {
108 it("accepts the three valid sizes", () => {
109 expect(normalizeMachineSize("small")).toBe("small");
110 expect(normalizeMachineSize("medium")).toBe("medium");
111 expect(normalizeMachineSize("large")).toBe("large");
112 });
113
114 it("defaults to 'small' for unknown / empty", () => {
115 expect(normalizeMachineSize("")).toBe("small");
116 expect(normalizeMachineSize(undefined)).toBe("small");
117 expect(normalizeMachineSize(null)).toBe("small");
118 expect(normalizeMachineSize("xlarge")).toBe("small");
119 });
120});
121
122describe("dev-env — DEFAULT_IDLE_MINUTES constant", () => {
123 it("is 30", () => {
124 expect(DEFAULT_IDLE_MINUTES).toBe(30);
125 });
126});
127
128// ---------------------------------------------------------------------------
129// 2. Graceful no-ops — must not throw on empty inputs
130// ---------------------------------------------------------------------------
131
132describe("dev-env — graceful no-ops", () => {
133 it("getDevEnv returns null for empty id", async () => {
134 expect(await getDevEnv("")).toBeNull();
135 });
136
137 it("getDevEnvForOwner returns null for empty args", async () => {
138 expect(await getDevEnvForOwner("", "")).toBeNull();
139 });
140
141 it("markReady / markFailed / stopDevEnv / recordActivity swallow empty ids", async () => {
142 await markReady("");
143 await markFailed("", "err");
144 await stopDevEnv("");
145 await recordActivity("");
146 expect(true).toBe(true);
147 });
148
149 it("startDevEnv refuses invalid input", async () => {
150 const r1 = await startDevEnv({
151 repositoryId: "",
152 ownerUserId: "user",
153 });
154 expect(r1.ok).toBe(false);
155 if (!r1.ok) expect(r1.reason).toBe("invalid_input");
156 const r2 = await startDevEnv({
157 repositoryId: "repo",
158 ownerUserId: "",
159 });
160 expect(r2.ok).toBe(false);
161 if (!r2.ok) expect(r2.reason).toBe("invalid_input");
162 });
163
164 it("generateDevYml returns the default when AI is unavailable", async () => {
165 // Without ANTHROPIC_API_KEY the helper falls back to the bundled
166 // default YAML — never throws.
167 const prev = process.env.ANTHROPIC_API_KEY;
168 delete process.env.ANTHROPIC_API_KEY;
169 try {
170 const yml = await generateDevYml("alice/foo");
171 expect(typeof yml).toBe("string");
172 expect(yml.includes("image:")).toBe(true);
173 } finally {
174 if (prev !== undefined) process.env.ANTHROPIC_API_KEY = prev;
175 }
176 });
177});
178
179// ---------------------------------------------------------------------------
180// 3. DB-backed pipeline
181// ---------------------------------------------------------------------------
182
183const TEST_USER_PREFIX = "devenvtest_";
184
185async function seedRepo(opts?: {
186 devEnvsEnabled?: boolean;
187}): Promise<{
188 userId: string;
189 repoId: string;
190 ownerName: string;
191 repoName: string;
192}> {
193 const username =
194 TEST_USER_PREFIX + Math.random().toString(36).slice(2, 10);
195 const [user] = await db
196 .insert(users)
197 .values({
198 username,
199 email: `${username}@devenvtest.local`,
200 passwordHash: "$2b$10$" + "x".repeat(53),
201 })
202 .returning();
203 const repoName = "dev-test-" + Math.random().toString(36).slice(2, 8);
204 const [repo] = await db
205 .insert(repositories)
206 .values({
207 name: repoName,
208 ownerId: user!.id,
209 diskPath: "/tmp/dev-test-" + Math.random().toString(36).slice(2, 8),
210 devEnvsEnabled: opts?.devEnvsEnabled ?? true,
211 })
212 .returning();
213 return {
214 userId: user!.id,
215 repoId: repo!.id,
216 ownerName: user!.username,
217 repoName: repo!.name,
218 };
219}
220
221async function cleanupUser(userId: string): Promise<void> {
222 try {
223 await db.delete(repositories).where(eq(repositories.ownerId, userId));
224 await db.delete(users).where(eq(users.id, userId));
225 } catch {
226 /* best effort */
227 }
228}
229
230describe.skipIf(!HAS_DB)("dev-env — DB pipeline", () => {
231 let userId = "";
232
233 afterEach(async () => {
234 if (userId) await cleanupUser(userId);
235 userId = "";
236 });
237
238 it("startDevEnv uses the provided dev.yml override (skips AI)", async () => {
239 const seeded = await seedRepo();
240 userId = seeded.userId;
241
242 const yml = "image: node:20-alpine\nports: [3000]\n";
243 const result = await startDevEnv({
244 repositoryId: seeded.repoId,
245 ownerUserId: seeded.userId,
246 devYml: yml,
247 });
248 expect(result.ok).toBe(true);
249 if (result.ok) {
250 expect(result.env.status).toBe("warming");
251 expect(result.env.devYml).toBe(yml);
252 expect(result.env.machineSize).toBe("small");
253 expect(result.env.idleMinutes).toBe(30);
254 expect(result.url.startsWith("https://dev-")).toBe(true);
255 expect(result.env.previewUrl).toBe(result.url);
256 }
257 });
258
259 it("startDevEnv generates a default when no override + no AI key", async () => {
260 const seeded = await seedRepo();
261 userId = seeded.userId;
262
263 const prev = process.env.ANTHROPIC_API_KEY;
264 delete process.env.ANTHROPIC_API_KEY;
265 try {
266 const result = await startDevEnv({
267 repositoryId: seeded.repoId,
268 ownerUserId: seeded.userId,
269 });
270 expect(result.ok).toBe(true);
271 if (result.ok) {
272 // Default YAML contains an `image:` key.
273 expect(result.env.devYml).toBeTruthy();
274 expect(result.env.devYml!.toLowerCase()).toContain("image:");
275 }
276 } finally {
277 if (prev !== undefined) process.env.ANTHROPIC_API_KEY = prev;
278 }
279 });
280
281 it("startDevEnv refuses if the repo hasn't opted in", async () => {
282 const seeded = await seedRepo({ devEnvsEnabled: false });
283 userId = seeded.userId;
284
285 const result = await startDevEnv({
286 repositoryId: seeded.repoId,
287 ownerUserId: seeded.userId,
288 devYml: "image: node:20\n",
289 });
290 expect(result.ok).toBe(false);
291 if (!result.ok) {
292 expect(result.reason).toBe("not_opted_in");
293 }
294
295 // And no row was inserted.
296 const after = await getDevEnvForOwner(seeded.repoId, seeded.userId);
297 expect(after).toBeNull();
298 });
299
300 it("startDevEnv is idempotent: restart reuses the same row + URL", async () => {
301 const seeded = await seedRepo();
302 userId = seeded.userId;
303
304 const first = await startDevEnv({
305 repositoryId: seeded.repoId,
306 ownerUserId: seeded.userId,
307 devYml: "image: node:20\n# v1",
308 });
309 expect(first.ok).toBe(true);
310 if (!first.ok) return;
311
312 await markReady(first.env.id, "ctr-1");
313 await stopDevEnv(first.env.id);
314
315 const second = await startDevEnv({
316 repositoryId: seeded.repoId,
317 ownerUserId: seeded.userId,
318 devYml: "image: node:20\n# v2",
319 });
320 expect(second.ok).toBe(true);
321 if (!second.ok) return;
322
323 // Same row, status flipped back to warming, dev.yml refreshed.
324 expect(second.env.id).toBe(first.env.id);
325 expect(second.env.status).toBe("warming");
326 expect(second.env.devYml).toContain("v2");
327 expect(second.url).toBe(first.url);
328
329 // Unique constraint enforces single row per (repo, user).
330 const all = await db
331 .select()
332 .from(devEnvs)
333 .where(eq(devEnvs.repositoryId, seeded.repoId));
334 expect(all.length).toBe(1);
335 });
336
337 it("expireIdleEnvs only touches idle ready/warming rows", async () => {
338 const seeded = await seedRepo();
339 userId = seeded.userId;
340
341 const result = await startDevEnv({
342 repositoryId: seeded.repoId,
343 ownerUserId: seeded.userId,
344 devYml: "image: node:20\n",
345 });
346 expect(result.ok).toBe(true);
347 if (!result.ok) return;
348
349 await markReady(result.env.id, "ctr-x");
350
351 // Fresh — sweep should leave it alone.
352 const beforeFlip = await expireIdleEnvs();
353 expect(beforeFlip).toBe(0);
354 const stillReady = await getDevEnv(result.env.id);
355 expect(stillReady!.status).toBe("ready");
356
357 // Force last_active_at into the past beyond idle_minutes.
358 await db
359 .update(devEnvs)
360 .set({
361 lastActiveAt: new Date(Date.now() - 60 * 60_000), // 60min ago
362 idleMinutes: 5,
363 })
364 .where(eq(devEnvs.id, result.env.id));
365
366 const swept = await expireIdleEnvs();
367 expect(swept).toBeGreaterThanOrEqual(1);
368
369 const after = await getDevEnv(result.env.id);
370 expect(after!.status).toBe("stopped");
371 });
372
373 it("expireIdleEnvs leaves stopped + failed rows alone", async () => {
374 const seeded = await seedRepo();
375 userId = seeded.userId;
376
377 const result = await startDevEnv({
378 repositoryId: seeded.repoId,
379 ownerUserId: seeded.userId,
380 devYml: "image: node:20\n",
381 });
382 expect(result.ok).toBe(true);
383 if (!result.ok) return;
384
385 await markFailed(result.env.id, "boom");
386 // Pretend it's been failed for an hour.
387 await db
388 .update(devEnvs)
389 .set({
390 lastActiveAt: new Date(Date.now() - 60 * 60_000),
391 idleMinutes: 5,
392 })
393 .where(eq(devEnvs.id, result.env.id));
394
395 await expireIdleEnvs();
396 const after = await getDevEnv(result.env.id);
397 // Status is unchanged — only 'ready'/'warming' get swept.
398 expect(after!.status).toBe("failed");
399 });
400
401 it("recordActivity bumps last_active_at", async () => {
402 const seeded = await seedRepo();
403 userId = seeded.userId;
404
405 const result = await startDevEnv({
406 repositoryId: seeded.repoId,
407 ownerUserId: seeded.userId,
408 devYml: "image: node:20\n",
409 });
410 expect(result.ok).toBe(true);
411 if (!result.ok) return;
412
413 // Force last_active_at into the past.
414 const oldDate = new Date(Date.now() - 60 * 60_000);
415 await db
416 .update(devEnvs)
417 .set({ lastActiveAt: oldDate })
418 .where(eq(devEnvs.id, result.env.id));
419
420 await recordActivity(result.env.id);
421 const after = await getDevEnv(result.env.id);
422 expect(after!.lastActiveAt.getTime()).toBeGreaterThan(oldDate.getTime());
423 });
424
425 it("markFailed truncates absurdly long error messages", async () => {
426 const seeded = await seedRepo();
427 userId = seeded.userId;
428
429 const result = await startDevEnv({
430 repositoryId: seeded.repoId,
431 ownerUserId: seeded.userId,
432 devYml: "image: node:20\n",
433 });
434 expect(result.ok).toBe(true);
435 if (!result.ok) return;
436
437 const longError = "boom\n".repeat(10_000);
438 await markFailed(result.env.id, longError);
439 const after = await getDevEnv(result.env.id);
440 expect(after!.status).toBe("failed");
441 expect(after!.errorMessage).not.toBeNull();
442 expect(after!.errorMessage!.length).toBeLessThanOrEqual(2_000);
443 });
444});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -38,6 +38,7 @@ import prLiveRoutes from "./routes/pr-live";
3838import compareRoutes from "./routes/compare";
3939import pullRoutes from "./routes/pulls";
4040import prSandboxRoutes from "./routes/pr-sandbox";
41import devEnvRoutes from "./routes/dev-env";
4142import editorRoutes from "./routes/editor";
4243import forkRoutes from "./routes/fork";
4344import webhookRoutes from "./routes/webhooks";
@@ -462,6 +463,9 @@ app.route("/", pullRoutes);
462463// PR sandboxes — runnable per-PR environments. Migration 0067.
463464app.route("/", prSandboxRoutes);
464465
466// Cloud dev environments — hosted VS Code in the browser. Migration 0072.
467app.route("/", devEnvRoutes);
468
465469// Fork
466470app.route("/", forkRoutes);
467471
Modifiedsrc/db/schema.ts+54−41View fileUnifiedSplit
@@ -191,6 +191,11 @@ export const repositories = pgTable(
191191 // sandbox on every PR open. Default false (off) because each sandbox
192192 // burns compute; owners must explicitly enable it via repo-settings.
193193 autoPrSandbox: boolean("auto_pr_sandbox").default(false).notNull(),
194 // Migration 0072 — opt-in flag for hosted-in-browser dev environments
195 // (cloud Codespaces alternative). Default false (off) because each
196 // env burns a container until the idle sweep tears it down; owners
197 // must explicitly enable per-repo via repo-settings.
198 devEnvsEnabled: boolean("dev_envs_enabled").default(false).notNull(),
194199 },
195200 (table) => [
196201 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
@@ -3655,11 +3660,7 @@ export type HostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferSelect;
36553660export type NewHostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferInsert;
36563661
36573662// ---------------------------------------------------------------------------
3658// 0070 — Agent Marketplace. Third-party AI agents listed in a catalog,
3659// one-click install per repo. Builds on agent_sessions (0058): every install
3660// provisions a fresh agent_session whose `branch_namespace` and
3661// `budget_cents_per_day` are seeded from the listing's `agent_template`.
3662// See src/lib/agent-marketplace.ts.
3663// 0070 — Agent Marketplace. See src/lib/agent-marketplace.ts.
36633664// ---------------------------------------------------------------------------
36643665export const agentMarketplaceListings = pgTable(
36653666 "agent_marketplace_listings",
@@ -3672,9 +3673,7 @@ export const agentMarketplaceListings = pgTable(
36723673 name: text("name").notNull(),
36733674 tagline: text("tagline").default("").notNull(),
36743675 description: text("description").default("").notNull(),
3675 // 'reviewer' | 'tester' | 'migrator' | 'security' | 'docs' | 'custom'
36763676 category: text("category").default("custom").notNull(),
3677 // 'per_invocation' | 'per_repo_per_month' | 'free'
36783677 pricingModel: text("pricing_model").default("free").notNull(),
36793678 priceCents: integer("price_cents").default(0).notNull(),
36803679 agentTemplate: jsonb("agent_template")
@@ -3687,7 +3686,6 @@ export const agentMarketplaceListings = pgTable(
36873686 .default({})
36883687 .notNull(),
36893688 sourceUrl: text("source_url"),
3690 // 'draft' | 'pending_review' | 'approved' | 'rejected'
36913689 status: text("status").default("draft").notNull(),
36923690 installCount: integer("install_count").default(0).notNull(),
36933691 ratingAvg: numeric("rating_avg", { precision: 3, scale: 2 })
@@ -3702,20 +3700,11 @@ export const agentMarketplaceListings = pgTable(
37023700 .notNull(),
37033701 },
37043702 (table) => [
3705 index("agent_marketplace_listings_category").on(
3706 table.category,
3707 table.status
3708 ),
3709 index("agent_marketplace_listings_rating").on(
3710 table.ratingAvg,
3711 table.ratingCount
3712 ),
3703 index("agent_marketplace_listings_category").on(table.category, table.status),
3704 index("agent_marketplace_listings_rating").on(table.ratingAvg, table.ratingCount),
37133705 index("agent_marketplace_listings_installs").on(table.installCount),
37143706 index("agent_marketplace_listings_publisher").on(table.publisherUserId),
3715 index("agent_marketplace_listings_status_created").on(
3716 table.status,
3717 table.createdAt
3718 ),
3707 index("agent_marketplace_listings_status_created").on(table.status, table.createdAt),
37193708 ]
37203709);
37213710
@@ -3736,7 +3725,6 @@ export const agentMarketplaceInstalls = pgTable(
37363725 () => agentSessions.id,
37373726 { onDelete: "set null" }
37383727 ),
3739 // 'active' | 'paused' | 'uninstalled'
37403728 status: text("status").default("active").notNull(),
37413729 installedAt: timestamp("installed_at", { withTimezone: true })
37423730 .defaultNow()
@@ -3747,10 +3735,7 @@ export const agentMarketplaceInstalls = pgTable(
37473735 .notNull(),
37483736 },
37493737 (table) => [
3750 uniqueIndex("agent_marketplace_installs_listing_repo").on(
3751 table.listingId,
3752 table.repositoryId
3753 ),
3738 uniqueIndex("agent_marketplace_installs_listing_repo").on(table.listingId, table.repositoryId),
37543739 index("agent_marketplace_installs_repo").on(table.repositoryId, table.status),
37553740 index("agent_marketplace_installs_installer").on(table.installedByUserId),
37563741 ]
@@ -3773,23 +3758,51 @@ export const agentMarketplaceReviews = pgTable(
37733758 .notNull(),
37743759 },
37753760 (table) => [
3776 index("agent_marketplace_reviews_listing_created").on(
3777 table.listingId,
3778 table.createdAt
3779 ),
3761 index("agent_marketplace_reviews_listing_created").on(table.listingId, table.createdAt),
37803762 index("agent_marketplace_reviews_reviewer").on(table.reviewerUserId),
37813763 ]
37823764);
37833765
3784export type AgentMarketplaceListing =
3785 typeof agentMarketplaceListings.$inferSelect;
3786export type NewAgentMarketplaceListing =
3787 typeof agentMarketplaceListings.$inferInsert;
3788export type AgentMarketplaceInstall =
3789 typeof agentMarketplaceInstalls.$inferSelect;
3790export type NewAgentMarketplaceInstall =
3791 typeof agentMarketplaceInstalls.$inferInsert;
3792export type AgentMarketplaceReview =
3793 typeof agentMarketplaceReviews.$inferSelect;
3794export type NewAgentMarketplaceReview =
3795 typeof agentMarketplaceReviews.$inferInsert;
3766export type AgentMarketplaceListing = typeof agentMarketplaceListings.$inferSelect;
3767export type NewAgentMarketplaceListing = typeof agentMarketplaceListings.$inferInsert;
3768export type AgentMarketplaceInstall = typeof agentMarketplaceInstalls.$inferSelect;
3769export type NewAgentMarketplaceInstall = typeof agentMarketplaceInstalls.$inferInsert;
3770export type AgentMarketplaceReview = typeof agentMarketplaceReviews.$inferSelect;
3771export type NewAgentMarketplaceReview = typeof agentMarketplaceReviews.$inferInsert;
3772
3773// ---------------------------------------------------------------------------
3774// 0072 — per-(repo, user) cloud dev environments. See src/lib/dev-env.ts.
3775// ---------------------------------------------------------------------------
3776export const devEnvs = pgTable(
3777 "dev_envs",
3778 {
3779 id: uuid("id").primaryKey().defaultRandom(),
3780 repositoryId: uuid("repository_id")
3781 .notNull()
3782 .references(() => repositories.id, { onDelete: "cascade" }),
3783 ownerUserId: uuid("owner_user_id")
3784 .notNull()
3785 .references(() => users.id, { onDelete: "cascade" }),
3786 status: text("status").default("cold").notNull(),
3787 previewUrl: text("preview_url"),
3788 containerId: text("container_id"),
3789 machineSize: text("machine_size").default("small").notNull(),
3790 idleMinutes: integer("idle_minutes").default(30).notNull(),
3791 devYml: text("dev_yml"),
3792 errorMessage: text("error_message"),
3793 lastActiveAt: timestamp("last_active_at", { withTimezone: true })
3794 .defaultNow()
3795 .notNull(),
3796 expiresAt: timestamp("expires_at", { withTimezone: true }),
3797 createdAt: timestamp("created_at", { withTimezone: true })
3798 .defaultNow()
3799 .notNull(),
3800 },
3801 (table) => [
3802 uniqueIndex("dev_envs_repo_owner").on(table.repositoryId, table.ownerUserId),
3803 index("dev_envs_status_active").on(table.status, table.lastActiveAt),
3804 ]
3805);
3806
3807export type DevEnv = typeof devEnvs.$inferSelect;
3808export type NewDevEnv = typeof devEnvs.$inferInsert;
Modifiedsrc/lib/autopilot.ts+34−0View fileUnifiedSplit
@@ -67,6 +67,7 @@ import { runAutoReleaseNotesTaskOnce } from "./ai-release-notes";
6767import { runPrTestGeneratorTaskOnce } from "./autopilot-pr-test-generator";
6868import { runAdvancementScan } from "./advancement-scanner";
6969import { expireOldSandboxes } from "./pr-sandbox";
70import { expireIdleEnvs } from "./dev-env";
7071
7172export interface AutopilotTaskResult {
7273 name: string;
@@ -145,6 +146,15 @@ let _lastPrTestGeneratorAt = 0;
145146 */
146147const PR_SANDBOX_CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
147148let _lastPrSandboxCleanupAt = 0;
149/**
150 * Dev-env idle sweep cadence (migration 0072). Runs on every autopilot
151 * tick (5 minutes) because per-env idle timeouts can be as short as a
152 * few minutes — we don't want a 30-min cleanup window stranding a user
153 * who set idle_minutes=5. The lib itself is a single SQL UPDATE so this
154 * is cheap regardless of repo count.
155 */
156const DEV_ENV_IDLE_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
157let _lastDevEnvIdleSweepAt = 0;
148158/**
149159 * Advancement scanner cadence. Designed to run weekly on Mondays at
150160 * 08:00 UTC. The task itself is the cheap gate (checks both day-of-week
@@ -587,6 +597,30 @@ export function defaultTasks(): AutopilotTask[] {
587597 }
588598 },
589599 },
600 {
601 // Dev-env idle sweep — migration 0072. Stops every dev env whose
602 // `last_active_at + idle_minutes` has slipped into the past. Runs
603 // on every tick (cadence-gated to 5min so a faster outer loop
604 // doesn't hammer it). The lib itself is fail-soft + per-row idle
605 // aware, so this is cheap + correct even when half the rows are
606 // already 'stopped'.
607 name: "dev-env-idle-sweep",
608 run: async () => {
609 const now = Date.now();
610 if (now - _lastDevEnvIdleSweepAt < DEV_ENV_IDLE_SWEEP_INTERVAL_MS) {
611 return;
612 }
613 _lastDevEnvIdleSweepAt = now;
614 try {
615 const stopped = await expireIdleEnvs();
616 if (stopped > 0) {
617 console.log(`[autopilot] dev-env-idle-sweep: stopped=${stopped}`);
618 }
619 } catch (err) {
620 console.error("[autopilot] dev-env-idle-sweep: threw:", err);
621 }
622 },
623 },
590624 ];
591625}
592626
Addedsrc/lib/dev-env.ts+613−0View fileUnifiedSplit
@@ -0,0 +1,613 @@
1/**
2 * Cloud dev environments (migration 0072).
3 *
4 * Hosted VS Code in the browser, one env per (repository, user). The URL
5 * is computed deterministically from the env id so we can render it the
6 * moment an env is enqueued — even while the underlying container is
7 * still warming.
8 *
9 * https://dev-<env-id>.gluecron.com
10 *
11 * The domain suffix is configurable via `DEV_ENV_DOMAIN` so self-hosted
12 * installs can point it at their own wildcard subdomain. The default
13 * intentionally lives on the bare `dev-` prefix (distinct from
14 * `sandbox.gluecron.com` / `preview.gluecron.com`) so VS Code servers
15 * and PR sandboxes never collide on the same hostname.
16 *
17 * Philosophy (mirrors pr-sandbox.ts): never throw — every DB call is
18 * wrapped in try/catch so a Postgres outage cannot break the
19 * /:owner/:repo/dev render path. Callers fire-and-forget where possible.
20 *
21 * Lifecycle:
22 * cold → warming → ready (container up; VS Code Server live)
23 * warming → failed (build/spin-up errored)
24 * ready → stopped (idle sweep or manual stop)
25 * stopped → warming → ready (restart upserts on same row, URL stable)
26 *
27 * Idempotency: starting an env for the same (repo, user) twice UPSERTs
28 * onto the existing row (unique index). Status flips back to 'warming'
29 * and prior error_message is cleared.
30 *
31 * Foundation: re-uses the workflow-runner container substrate + the
32 * pr-sandbox playground.yml resolution pattern. We do NOT fork
33 * pr-sandbox.ts — `readDevYml` / `generateDevYml` are new but cribbed
34 * from the same shape so consistency is obvious.
35 */
36
37import { and, eq, lt, sql } from "drizzle-orm";
38import { db } from "../db";
39import { devEnvs, repositories, users } from "../db/schema";
40import type { DevEnv } from "../db/schema";
41import { slugifyForUrl } from "./branch-previews";
42import { getBlob } from "../git/repository";
43import {
44 getAnthropic,
45 isAiAvailable,
46 MODEL_HAIKU,
47 extractText,
48} from "./ai-client";
49
50// ---------------------------------------------------------------------------
51// Tunables
52// ---------------------------------------------------------------------------
53
54/** Default idle minutes if the caller doesn't override. Mirrors SQL default. */
55export const DEFAULT_IDLE_MINUTES = 30;
56
57/** Cap for `error_message` so a giant stack trace can't blow up the row. */
58const ERROR_MESSAGE_CAP = 2_000;
59
60/** Allowed machine sizes. */
61export type MachineSize = "small" | "medium" | "large";
62const ALLOWED_MACHINE_SIZES: ReadonlyArray<MachineSize> = [
63 "small",
64 "medium",
65 "large",
66];
67
68/** Allowed env statuses. */
69export type DevEnvStatus =
70 | "cold"
71 | "warming"
72 | "ready"
73 | "failed"
74 | "stopped";
75
76/** Default `.gluecron/dev.yml` when no file is committed AND AI is unavailable. */
77const DEFAULT_DEV_YML = `# .gluecron/dev.yml — auto-generated default.
78# Commit this file under .gluecron/dev.yml on your repo to control how
79# the cloud dev environment is provisioned.
80image: node:20-alpine
81ports: [3000]
82install:
83 - npm install
84postCreate: []
85command: npm run dev
86recommendedExtensions:
87 - dbaeumer.vscode-eslint
88 - esbenp.prettier-vscode
89`;
90
91// ---------------------------------------------------------------------------
92// URL + label helpers (pure — exported so route handlers can render
93// without going through the DB).
94// ---------------------------------------------------------------------------
95
96/**
97 * Compute the VS-Code-Server URL for a dev env id. The id is slugified so
98 * a UUID with dashes lands cleanly into a single DNS label.
99 */
100export function buildDevEnvUrl(envId: string): string {
101 const domain = (process.env.DEV_ENV_DOMAIN || "gluecron.com").replace(
102 /^https?:\/\//,
103 ""
104 );
105 const slug = slugifyForUrl(envId || "unknown");
106 return `https://dev-${slug}.${domain}`;
107}
108
109/** Visible string for the status pill. */
110export function devEnvStatusLabel(status: string): string {
111 switch (status) {
112 case "cold":
113 return "Cold";
114 case "warming":
115 return "Warming up";
116 case "ready":
117 return "Ready";
118 case "failed":
119 return "Failed";
120 case "stopped":
121 return "Stopped";
122 default:
123 return status;
124 }
125}
126
127/** Validate machine-size strings coming from query params / form bodies. */
128export function normalizeMachineSize(
129 value: string | undefined | null
130): MachineSize {
131 if (!value) return "small";
132 return (ALLOWED_MACHINE_SIZES as ReadonlyArray<string>).includes(value)
133 ? (value as MachineSize)
134 : "small";
135}
136
137// ---------------------------------------------------------------------------
138// dev.yml resolution
139// ---------------------------------------------------------------------------
140
141/**
142 * Read `.gluecron/dev.yml` from the repo's default branch (or the given
143 * ref). Returns the file contents, or `null` if the file isn't in the
144 * tree (the repo hasn't opted in) or git read fails.
145 */
146export async function readDevYml(
147 ownerName: string,
148 repoName: string,
149 ref: string = "HEAD"
150): Promise<string | null> {
151 if (!ownerName || !repoName) return null;
152 try {
153 const blob = await getBlob(
154 ownerName,
155 repoName,
156 ref,
157 ".gluecron/dev.yml"
158 );
159 if (!blob || blob.isBinary) return null;
160 const content = (blob.content || "").trim();
161 if (!content) return null;
162 return blob.content;
163 } catch {
164 return null;
165 }
166}
167
168/**
169 * Ask Claude (Haiku) to draft a `.gluecron/dev.yml` for a repo. Used when
170 * the repo hasn't committed one. Returns the YAML body, or
171 * `DEFAULT_DEV_YML` if AI is unavailable / errors. Never throws.
172 */
173export async function generateDevYml(repoHint: string): Promise<string> {
174 if (!isAiAvailable()) return DEFAULT_DEV_YML;
175 try {
176 const client = getAnthropic();
177 const message = await client.messages.create({
178 model: MODEL_HAIKU,
179 max_tokens: 800,
180 messages: [
181 {
182 role: "user",
183 content:
184 "Generate a `.gluecron/dev.yml` for the following repo so " +
185 "developers can open a cloud dev environment (VS Code in the " +
186 "browser) on this repo. Output ONLY YAML — no prose, no code " +
187 "fences. Required keys: `image` (a docker image), `ports` " +
188 "(array of ints), `install` (array of shell commands run on " +
189 "first start), `postCreate` (array of shell commands run after " +
190 "install), `command` (the long-running dev command), " +
191 "`recommendedExtensions` (array of VS Code extension IDs). " +
192 "Pick conservative defaults if unsure.\n\nRepo: " +
193 repoHint,
194 },
195 ],
196 });
197 const text = extractText(message).trim();
198 if (!text) return DEFAULT_DEV_YML;
199 const cleaned = text
200 .replace(/^```(?:yaml|yml)?\s*/i, "")
201 .replace(/```\s*$/i, "")
202 .trim();
203 return cleaned || DEFAULT_DEV_YML;
204 } catch (err) {
205 console.warn(
206 "[dev-env] generateDevYml failed; using default:",
207 err instanceof Error ? err.message : err
208 );
209 return DEFAULT_DEV_YML;
210 }
211}
212
213// ---------------------------------------------------------------------------
214// Lookup helpers
215// ---------------------------------------------------------------------------
216
217/** Look up an env by id. */
218export async function getDevEnv(envId: string): Promise<DevEnv | null> {
219 if (!envId) return null;
220 try {
221 const [row] = await db
222 .select()
223 .from(devEnvs)
224 .where(eq(devEnvs.id, envId))
225 .limit(1);
226 return row ?? null;
227 } catch {
228 return null;
229 }
230}
231
232/** Look up the env row for a (repo, user), or null if none. */
233export async function getDevEnvForOwner(
234 repositoryId: string,
235 ownerUserId: string
236): Promise<DevEnv | null> {
237 if (!repositoryId || !ownerUserId) return null;
238 try {
239 const [row] = await db
240 .select()
241 .from(devEnvs)
242 .where(
243 and(
244 eq(devEnvs.repositoryId, repositoryId),
245 eq(devEnvs.ownerUserId, ownerUserId)
246 )
247 )
248 .limit(1);
249 return row ?? null;
250 } catch {
251 return null;
252 }
253}
254
255// ---------------------------------------------------------------------------
256// Core lifecycle
257// ---------------------------------------------------------------------------
258
259export interface StartDevEnvArgs {
260 repositoryId: string;
261 ownerUserId: string;
262 machineSize?: MachineSize;
263 idleMinutes?: number;
264 /** Override the resolved YAML — only used by tests so we don't hit AI. */
265 devYml?: string;
266 /** Override "now" — tests only. */
267 now?: () => Date;
268}
269
270export type StartDevEnvResult =
271 | {
272 ok: true;
273 env: DevEnv;
274 /** Convenience: env.previewUrl computed eagerly. */
275 url: string;
276 }
277 | {
278 ok: false;
279 reason:
280 | "repo_not_found"
281 | "not_opted_in"
282 | "db_unavailable"
283 | "invalid_input";
284 };
285
286/**
287 * Start (or restart) a dev env for `(repositoryId, ownerUserId)`.
288 *
289 * - Refuses if the repo doesn't have `dev_envs_enabled = true`.
290 * - Reads `.gluecron/dev.yml` from the repo if committed; otherwise asks
291 * Claude (Haiku) to draft one; otherwise falls back to a sane default.
292 * - Upserts the env row (one per repo+user) with status='warming' and a
293 * deterministic preview_url.
294 * - A downstream worker (or the workflow-runner foundation) is responsible
295 * for actually spinning the container and flipping the row to 'ready'
296 * via `markReady` — kept out of this v1 path so the route stays cheap.
297 *
298 * Returns the row + URL on success, or a typed reason for refusal.
299 */
300export async function startDevEnv(
301 args: StartDevEnvArgs
302): Promise<StartDevEnvResult> {
303 if (!args.repositoryId || !args.ownerUserId) {
304 return { ok: false, reason: "invalid_input" };
305 }
306 const now = (args.now ?? (() => new Date()))();
307 const machineSize = normalizeMachineSize(args.machineSize);
308 const idleMinutes =
309 Number.isFinite(args.idleMinutes) && args.idleMinutes! > 0
310 ? Math.floor(args.idleMinutes!)
311 : DEFAULT_IDLE_MINUTES;
312
313 // Repo gate — must exist and have opted in.
314 let repoRow: { id: string; name: string; ownerId: string; enabled: boolean } | null = null;
315 try {
316 const [row] = await db
317 .select({
318 id: repositories.id,
319 name: repositories.name,
320 ownerId: repositories.ownerId,
321 enabled: repositories.devEnvsEnabled,
322 })
323 .from(repositories)
324 .where(eq(repositories.id, args.repositoryId))
325 .limit(1);
326 repoRow = row ?? null;
327 } catch (err) {
328 console.warn(
329 "[dev-env] repo lookup failed:",
330 err instanceof Error ? err.message : err
331 );
332 return { ok: false, reason: "db_unavailable" };
333 }
334 if (!repoRow) return { ok: false, reason: "repo_not_found" };
335 if (!repoRow.enabled) return { ok: false, reason: "not_opted_in" };
336
337 // Resolve dev.yml — committed file wins, else ask AI, else default.
338 let yml = args.devYml;
339 if (yml === undefined) {
340 let ownerName = "";
341 try {
342 const [owner] = await db
343 .select({ username: users.username })
344 .from(users)
345 .where(eq(users.id, repoRow.ownerId))
346 .limit(1);
347 ownerName = owner?.username || "";
348 } catch {
349 /* swallow — fall back to default */
350 }
351 const fromGit = ownerName
352 ? await readDevYml(ownerName, repoRow.name)
353 : null;
354 yml =
355 fromGit ??
356 (await generateDevYml(`${ownerName || "?"}/${repoRow.name}`));
357 }
358
359 // Pre-compute the deterministic URL. We need the env id to slot into
360 // the subdomain, so the upsert path is:
361 // 1. Try insert with a NULL preview_url
362 // 2. On conflict, leave preview_url as-is (existing URL is the truth)
363 // 3. After the upsert resolves, if preview_url is NULL we update it
364 // with the freshly-known id.
365 // This keeps the URL stable across restarts (the env id never changes
366 // for a given (repo, user) pair).
367 let row: DevEnv | null = null;
368 try {
369 const [inserted] = await db
370 .insert(devEnvs)
371 .values({
372 repositoryId: args.repositoryId,
373 ownerUserId: args.ownerUserId,
374 status: "warming",
375 previewUrl: null,
376 containerId: null,
377 machineSize,
378 idleMinutes,
379 devYml: yml,
380 errorMessage: null,
381 lastActiveAt: now,
382 expiresAt: null,
383 })
384 .onConflictDoUpdate({
385 target: [devEnvs.repositoryId, devEnvs.ownerUserId],
386 set: {
387 status: "warming",
388 machineSize,
389 idleMinutes,
390 devYml: yml,
391 errorMessage: null,
392 lastActiveAt: now,
393 },
394 })
395 .returning();
396 row = inserted ?? null;
397 } catch (err) {
398 console.warn(
399 "[dev-env] upsert failed:",
400 err instanceof Error ? err.message : err
401 );
402 return { ok: false, reason: "db_unavailable" };
403 }
404
405 if (!row) return { ok: false, reason: "db_unavailable" };
406
407 // Backfill preview_url if missing (first-ever start for this pair).
408 if (!row.previewUrl) {
409 const url = buildDevEnvUrl(row.id);
410 try {
411 await db
412 .update(devEnvs)
413 .set({ previewUrl: url })
414 .where(eq(devEnvs.id, row.id));
415 row = { ...row, previewUrl: url };
416 } catch (err) {
417 console.warn(
418 "[dev-env] preview_url backfill failed:",
419 err instanceof Error ? err.message : err
420 );
421 }
422 }
423
424 return {
425 ok: true,
426 env: row,
427 url: row.previewUrl || buildDevEnvUrl(row.id),
428 };
429}
430
431/**
432 * Mark a dev env as successfully spun up. Called by the warmer once the
433 * container is live + VS Code Server is reachable.
434 */
435export async function markReady(
436 envId: string,
437 containerId?: string
438): Promise<void> {
439 if (!envId) return;
440 try {
441 await db
442 .update(devEnvs)
443 .set({
444 status: "ready",
445 errorMessage: null,
446 ...(containerId ? { containerId } : {}),
447 })
448 .where(eq(devEnvs.id, envId));
449 } catch (err) {
450 console.warn(
451 "[dev-env] markReady failed:",
452 err instanceof Error ? err.message : err
453 );
454 }
455}
456
457/**
458 * Mark a dev env as failed and record the error (truncated).
459 */
460export async function markFailed(envId: string, error: string): Promise<void> {
461 if (!envId) return;
462 try {
463 await db
464 .update(devEnvs)
465 .set({
466 status: "failed",
467 errorMessage: (error || "").slice(0, ERROR_MESSAGE_CAP),
468 })
469 .where(eq(devEnvs.id, envId));
470 } catch (err) {
471 console.warn(
472 "[dev-env] markFailed failed:",
473 err instanceof Error ? err.message : err
474 );
475 }
476}
477
478/**
479 * Graceful shutdown — flip status to 'stopped'. The URL is intentionally
480 * preserved so a later restart reuses it.
481 *
482 * A real implementation would also tell the hoster to tear down the
483 * container; for v1 we just flip the row.
484 */
485export async function stopDevEnv(envId: string): Promise<void> {
486 if (!envId) return;
487 try {
488 await db
489 .update(devEnvs)
490 .set({ status: "stopped", containerId: null })
491 .where(eq(devEnvs.id, envId));
492 } catch (err) {
493 console.warn(
494 "[dev-env] stopDevEnv failed:",
495 err instanceof Error ? err.message : err
496 );
497 }
498}
499
500/**
501 * Called on every URL hit to bump last_active_at. Best-effort — never
502 * blocks the request even if the DB is down.
503 */
504export async function recordActivity(envId: string): Promise<void> {
505 if (!envId) return;
506 try {
507 await db
508 .update(devEnvs)
509 .set({ lastActiveAt: new Date() })
510 .where(eq(devEnvs.id, envId));
511 } catch {
512 /* best effort */
513 }
514}
515
516/**
517 * Autopilot task: stop every env where `last_active_at + idle_minutes` is
518 * in the past, but only if status is 'ready' or 'warming'. Already-
519 * stopped / failed rows are skipped so the loop stays cheap. Returns the
520 * number of rows transitioned for observability.
521 *
522 * The idle window is per-row (each env carries its own `idle_minutes`)
523 * so we compare against `now() - idle_minutes * interval '1 minute'`.
524 */
525export async function expireIdleEnvs(
526 now: () => Date = () => new Date()
527): Promise<number> {
528 const cutoffNow = now();
529 try {
530 // We compute the cutoff per-row inside SQL: lastActiveAt + idleMinutes < now.
531 // Drizzle's lt/eq can't combine column + literal arithmetic cleanly, so
532 // we drop into a `sql` template. Postgres-only — fine for our deploy.
533 const ready = await db
534 .update(devEnvs)
535 .set({ status: "stopped", containerId: null })
536 .where(
537 and(
538 eq(devEnvs.status, "ready"),
539 sql`${devEnvs.lastActiveAt} + (${devEnvs.idleMinutes} * interval '1 minute') < ${cutoffNow.toISOString()}::timestamptz`
540 )
541 )
542 .returning({ id: devEnvs.id });
543 const stuck = await db
544 .update(devEnvs)
545 .set({ status: "stopped", containerId: null })
546 .where(
547 and(
548 eq(devEnvs.status, "warming"),
549 sql`${devEnvs.lastActiveAt} + (${devEnvs.idleMinutes} * interval '1 minute') < ${cutoffNow.toISOString()}::timestamptz`
550 )
551 )
552 .returning({ id: devEnvs.id });
553 return ready.length + stuck.length;
554 } catch (err) {
555 console.warn(
556 "[dev-env] expireIdleEnvs failed:",
557 err instanceof Error ? err.message : err
558 );
559 // Fallback: client-side filter (used when the SQL template is rejected
560 // by the test driver). We pull idle-candidate rows and compute the
561 // cutoff in JS. Bounded by the small expected env count.
562 try {
563 const rows = await db.select().from(devEnvs);
564 let stopped = 0;
565 for (const r of rows) {
566 if (r.status !== "ready" && r.status !== "warming") continue;
567 const cutoff =
568 (r.lastActiveAt?.getTime?.() ?? 0) +
569 (r.idleMinutes ?? DEFAULT_IDLE_MINUTES) * 60_000;
570 if (cutoff < cutoffNow.getTime()) {
571 await db
572 .update(devEnvs)
573 .set({ status: "stopped", containerId: null })
574 .where(eq(devEnvs.id, r.id));
575 stopped++;
576 }
577 }
578 return stopped;
579 } catch (err2) {
580 console.warn(
581 "[dev-env] expireIdleEnvs fallback failed:",
582 err2 instanceof Error ? err2.message : err2
583 );
584 return 0;
585 }
586 }
587}
588
589/**
590 * Convenience: also expire rows whose `expires_at` is past (when set).
591 * Currently unused by the route layer but exposed so admins can wire a
592 * hard cap if/when they want one.
593 */
594export async function expireHardCappedEnvs(
595 now: () => Date = () => new Date()
596): Promise<number> {
597 try {
598 const stopped = await db
599 .update(devEnvs)
600 .set({ status: "stopped", containerId: null })
601 .where(
602 and(lt(devEnvs.expiresAt, now()), eq(devEnvs.status, "ready"))
603 )
604 .returning({ id: devEnvs.id });
605 return stopped.length;
606 } catch (err) {
607 console.warn(
608 "[dev-env] expireHardCappedEnvs failed:",
609 err instanceof Error ? err.message : err
610 );
611 return 0;
612 }
613}
Addedsrc/routes/dev-env.tsx+809−0View fileUnifiedSplit
@@ -0,0 +1,809 @@
1/**
2 * Cloud dev environment routes (migration 0072).
3 *
4 * GET /:owner/:repo/dev — UI; full-screen iframe when ready
5 * POST /api/v2/repos/:owner/:repo/dev/start
6 * POST /api/v2/repos/:owner/:repo/dev/stop
7 * GET /api/v2/repos/:owner/:repo/dev/status
8 *
9 * The UI page is server-rendered through the shared Layout. All custom CSS
10 * is scoped under `.dev-env-*` so we don't accidentally bleed into the
11 * locked layout / components / ui sheets.
12 *
13 * For non-ready statuses (cold / warming / failed) the page polls the
14 * status JSON every 2s and re-renders on transition. Ready collapses the
15 * page chrome and renders a single full-screen iframe pointing at the
16 * VS Code Server URL.
17 */
18
19import { Hono } from "hono";
20import { and, eq } from "drizzle-orm";
21import { db } from "../db";
22import { repositories, users } from "../db/schema";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { Layout } from "../views/layout";
26import {
27 buildDevEnvUrl,
28 devEnvStatusLabel,
29 getDevEnv,
30 getDevEnvForOwner,
31 normalizeMachineSize,
32 recordActivity,
33 startDevEnv,
34 stopDevEnv,
35 type DevEnvStatus,
36} from "../lib/dev-env";
37import type { DevEnv } from "../db/schema";
38
39const devEnvRoutes = new Hono<AuthEnv>();
40
41// ---------------------------------------------------------------------------
42// Scoped CSS — every class prefixed `.dev-env-*`
43// ---------------------------------------------------------------------------
44
45const devEnvStyles = `
46 .dev-env-wrap {
47 max-width: 880px;
48 margin: 0 auto;
49 padding: var(--space-6, 32px) var(--space-4, 24px);
50 }
51
52 .dev-env-card {
53 position: relative;
54 padding: clamp(28px, 4vw, 44px);
55 background: var(--bg-elevated);
56 border: 1px solid var(--border);
57 border-radius: 16px;
58 overflow: hidden;
59 }
60 .dev-env-card::before {
61 content: '';
62 position: absolute;
63 top: 0; left: 0; right: 0;
64 height: 2px;
65 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
66 opacity: 0.75;
67 pointer-events: none;
68 }
69 .dev-env-eyebrow {
70 display: inline-flex;
71 align-items: center;
72 gap: 8px;
73 font-family: var(--font-mono);
74 font-size: 11px;
75 letter-spacing: 0.18em;
76 text-transform: uppercase;
77 color: var(--text-muted);
78 font-weight: 600;
79 margin-bottom: 12px;
80 }
81 .dev-env-eyebrow-dot {
82 width: 8px; height: 8px;
83 border-radius: 9999px;
84 background: linear-gradient(135deg, #8c6dff, #36c5d6);
85 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
86 }
87 .dev-env-title {
88 font-family: var(--font-display);
89 font-size: clamp(24px, 4vw, 32px);
90 font-weight: 800;
91 letter-spacing: -0.022em;
92 line-height: 1.1;
93 margin: 0 0 var(--space-3);
94 color: var(--text-strong);
95 }
96 .dev-env-sub {
97 font-size: 15px;
98 color: var(--text-muted);
99 margin: 0 0 var(--space-4);
100 line-height: 1.55;
101 }
102 .dev-env-sub code {
103 font-family: var(--font-mono);
104 font-size: 12.5px;
105 background: var(--bg-secondary);
106 border: 1px solid var(--border);
107 border-radius: 5px;
108 padding: 1px 6px;
109 color: var(--text-strong);
110 }
111
112 .dev-env-pill {
113 display: inline-flex;
114 align-items: center;
115 gap: 8px;
116 padding: 4px 10px;
117 border-radius: 9999px;
118 font-family: var(--font-mono);
119 font-size: 12px;
120 font-weight: 600;
121 border: 1px solid var(--border);
122 background: var(--bg-secondary);
123 color: var(--text);
124 margin-bottom: var(--space-4);
125 }
126 .dev-env-pill .dot {
127 width: 8px; height: 8px;
128 border-radius: 9999px;
129 background: var(--text-muted);
130 }
131 .dev-env-pill.is-cold .dot { background: #64748b; }
132 .dev-env-pill.is-warming .dot {
133 background: linear-gradient(135deg, #8c6dff, #36c5d6);
134 animation: devEnvPulse 1.4s ease-in-out infinite;
135 }
136 .dev-env-pill.is-ready .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.6); }
137 .dev-env-pill.is-failed .dot { background: #f85149; box-shadow: 0 0 8px rgba(248,81,73,0.6); }
138 .dev-env-pill.is-stopped .dot { background: #94a3b8; }
139 @keyframes devEnvPulse {
140 0%, 100% { transform: scale(1); opacity: 1; }
141 50% { transform: scale(1.35); opacity: 0.6; }
142 }
143 @media (prefers-reduced-motion: reduce) {
144 .dev-env-pill.is-warming .dot { animation: none; }
145 }
146
147 .dev-env-progress {
148 position: relative;
149 width: 100%;
150 height: 6px;
151 background: var(--bg-secondary);
152 border-radius: 9999px;
153 overflow: hidden;
154 margin: var(--space-3) 0 var(--space-4);
155 }
156 .dev-env-progress-bar {
157 position: absolute;
158 inset: 0;
159 width: 35%;
160 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
161 border-radius: inherit;
162 animation: devEnvSlide 1.6s ease-in-out infinite;
163 }
164 @keyframes devEnvSlide {
165 0% { transform: translateX(-100%); }
166 100% { transform: translateX(286%); }
167 }
168 @media (prefers-reduced-motion: reduce) {
169 .dev-env-progress-bar { animation: none; width: 60%; }
170 }
171
172 .dev-env-actions {
173 display: flex;
174 gap: var(--space-2);
175 flex-wrap: wrap;
176 margin-top: var(--space-4);
177 }
178 .dev-env-cta {
179 appearance: none;
180 border: 1px solid rgba(140,109,255,0.45);
181 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
182 color: #fff;
183 padding: 11px 20px;
184 border-radius: 11px;
185 font-family: var(--font-display);
186 font-weight: 700;
187 font-size: 14px;
188 letter-spacing: -0.005em;
189 cursor: pointer;
190 text-decoration: none;
191 display: inline-flex;
192 align-items: center;
193 gap: 8px;
194 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
195 }
196 .dev-env-cta:hover {
197 transform: translateY(-1px);
198 box-shadow: 0 12px 24px -10px rgba(140,109,255,0.55);
199 filter: brightness(1.06);
200 }
201 .dev-env-cta-secondary {
202 appearance: none;
203 background: transparent;
204 color: var(--text);
205 border: 1px solid var(--border);
206 padding: 10px 16px;
207 border-radius: 11px;
208 font-size: 14px;
209 font-family: inherit;
210 cursor: pointer;
211 text-decoration: none;
212 display: inline-flex; align-items: center;
213 }
214 .dev-env-cta-secondary:hover {
215 border-color: var(--accent);
216 color: var(--text-strong);
217 }
218
219 .dev-env-error {
220 position: relative;
221 padding: 14px 16px 14px 44px;
222 margin: var(--space-3) 0 var(--space-4);
223 border-radius: 12px;
224 border: 1px solid rgba(248, 81, 73, 0.32);
225 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
226 color: var(--text);
227 font-size: 14px;
228 line-height: 1.5;
229 word-break: break-word;
230 }
231 .dev-env-error::before {
232 content: '';
233 position: absolute;
234 left: 14px; top: 18px;
235 width: 14px; height: 14px;
236 border-radius: 50%;
237 background: radial-gradient(circle, #f85149 30%, transparent 70%);
238 box-shadow: 0 0 10px rgba(248,81,73,0.5);
239 }
240
241 .dev-env-meta {
242 display: grid;
243 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
244 gap: var(--space-3);
245 margin-top: var(--space-4);
246 font-size: 13px;
247 }
248 .dev-env-meta-row {
249 display: flex;
250 flex-direction: column;
251 gap: 2px;
252 }
253 .dev-env-meta-label {
254 color: var(--text-muted);
255 font-size: 11px;
256 text-transform: uppercase;
257 letter-spacing: 0.10em;
258 font-family: var(--font-mono);
259 }
260 .dev-env-meta-value {
261 color: var(--text-strong);
262 font-weight: 600;
263 word-break: break-all;
264 }
265
266 /* Full-screen iframe shell for the ready state. */
267 .dev-env-shell {
268 position: fixed;
269 inset: 0;
270 background: var(--bg);
271 z-index: 9999;
272 display: flex;
273 flex-direction: column;
274 }
275 .dev-env-shell-bar {
276 display: flex;
277 align-items: center;
278 justify-content: space-between;
279 gap: var(--space-3);
280 padding: 8px 16px;
281 background: var(--bg-elevated);
282 border-bottom: 1px solid var(--border);
283 font-size: 12px;
284 color: var(--text-muted);
285 }
286 .dev-env-shell-bar .dev-env-shell-title {
287 display: inline-flex; align-items: center; gap: 8px;
288 color: var(--text-strong);
289 font-weight: 600;
290 font-family: var(--font-mono);
291 }
292 .dev-env-shell-iframe {
293 flex: 1;
294 width: 100%;
295 border: 0;
296 background: var(--bg);
297 }
298`;
299
300// ---------------------------------------------------------------------------
301// Helpers
302// ---------------------------------------------------------------------------
303
304async function resolveRepoForUser(
305 ownerName: string,
306 repoName: string
307): Promise<{
308 ownerId: string;
309 ownerName: string;
310 repoId: string;
311 repoName: string;
312 devEnabled: boolean;
313} | null> {
314 try {
315 const [owner] = await db
316 .select()
317 .from(users)
318 .where(eq(users.username, ownerName))
319 .limit(1);
320 if (!owner) return null;
321 const [repo] = await db
322 .select()
323 .from(repositories)
324 .where(
325 and(
326 eq(repositories.ownerId, owner.id),
327 eq(repositories.name, repoName)
328 )
329 )
330 .limit(1);
331 if (!repo) return null;
332 return {
333 ownerId: owner.id,
334 ownerName: owner.username,
335 repoId: repo.id,
336 repoName: repo.name,
337 devEnabled: !!(repo as { devEnvsEnabled?: boolean }).devEnvsEnabled,
338 };
339 } catch {
340 return null;
341 }
342}
343
344function jsonShape(row: DevEnv | null): Record<string, unknown> | null {
345 if (!row) return null;
346 return {
347 id: row.id,
348 status: row.status,
349 statusLabel: devEnvStatusLabel(row.status),
350 previewUrl: row.previewUrl || buildDevEnvUrl(row.id),
351 machineSize: row.machineSize,
352 idleMinutes: row.idleMinutes,
353 lastActiveAt: row.lastActiveAt?.toISOString?.() ?? null,
354 createdAt: row.createdAt?.toISOString?.() ?? null,
355 errorMessage: row.errorMessage,
356 };
357}
358
359// ---------------------------------------------------------------------------
360// Cold / warming / failed UI pieces (server-rendered JSX) — broken out so
361// the GET route stays readable.
362// ---------------------------------------------------------------------------
363
364function StatusPill({ status }: { status: string }) {
365 const cls = `dev-env-pill is-${status}`;
366 return (
367 <span class={cls}>
368 <span class="dot" aria-hidden="true" />
369 {devEnvStatusLabel(status)}
370 </span>
371 );
372}
373
374function MetaGrid({ env }: { env: DevEnv }) {
375 return (
376 <div class="dev-env-meta" aria-label="Environment metadata">
377 <div class="dev-env-meta-row">
378 <span class="dev-env-meta-label">Machine</span>
379 <span class="dev-env-meta-value">{env.machineSize}</span>
380 </div>
381 <div class="dev-env-meta-row">
382 <span class="dev-env-meta-label">Idle timeout</span>
383 <span class="dev-env-meta-value">{env.idleMinutes}m</span>
384 </div>
385 <div class="dev-env-meta-row">
386 <span class="dev-env-meta-label">URL</span>
387 <span class="dev-env-meta-value">
388 {(env.previewUrl || buildDevEnvUrl(env.id)).replace(
389 /^https?:\/\//,
390 ""
391 )}
392 </span>
393 </div>
394 </div>
395 );
396}
397
398// ---------------------------------------------------------------------------
399// GET /:owner/:repo/dev
400// ---------------------------------------------------------------------------
401
402devEnvRoutes.get("/:owner/:repo/dev", softAuth, async (c) => {
403 const ownerName = c.req.param("owner");
404 const repoName = c.req.param("repo");
405 const user = c.get("user");
406 const csrf = c.get("csrfToken") as string | undefined;
407
408 const resolved = await resolveRepoForUser(ownerName, repoName);
409 if (!resolved) return c.notFound();
410
411 // Render a "disabled" notice if the repo hasn't opted in. Owners get a
412 // direct link to flip the toggle.
413 if (!resolved.devEnabled) {
414 const isOwner = user && user.id === resolved.ownerId;
415 return c.html(
416 <Layout
417 title={`Dev env — ${resolved.ownerName}/${resolved.repoName}`}
418 user={user ?? null}
419 >
420 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
421 <div class="dev-env-wrap">
422 <div class="dev-env-card">
423 <div class="dev-env-eyebrow">
424 <span class="dev-env-eyebrow-dot" aria-hidden="true" />
425 Cloud dev env · disabled
426 </div>
427 <h1 class="dev-env-title">Dev environments are off for this repo</h1>
428 <p class="dev-env-sub">
429 {isOwner ? (
430 <>
431 Flip <code>dev_envs_enabled</code> on in repository
432 settings to get a hosted VS Code IDE in the browser for
433 this repo. Default is off because each environment burns
434 a container.
435 </>
436 ) : (
437 <>
438 The owner of <code>{resolved.ownerName}/{resolved.repoName}</code>{" "}
439 hasn't enabled cloud dev environments yet.
440 </>
441 )}
442 </p>
443 <div class="dev-env-actions">
444 {isOwner && (
445 <a
446 href={`/${resolved.ownerName}/${resolved.repoName}/settings#dev-envs`}
447 class="dev-env-cta"
448 >
449 Open repo settings →
450 </a>
451 )}
452 <a
453 href={`/${resolved.ownerName}/${resolved.repoName}`}
454 class="dev-env-cta-secondary"
455 >
456 Back to repo
457 </a>
458 </div>
459 </div>
460 </div>
461 </Layout>
462 );
463 }
464
465 // Login required to provision/use an env.
466 if (!user) {
467 return c.redirect(
468 `/login?next=${encodeURIComponent(`/${resolved.ownerName}/${resolved.repoName}/dev`)}`
469 );
470 }
471
472 const env = await getDevEnvForOwner(resolved.repoId, user.id);
473
474 // Record activity if there's a live row — every page hit counts.
475 if (env) {
476 void recordActivity(env.id);
477 }
478
479 // No env yet — render a start button.
480 if (!env) {
481 return c.html(
482 <Layout
483 title={`Dev env — ${resolved.ownerName}/${resolved.repoName}`}
484 user={user}
485 >
486 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
487 <div class="dev-env-wrap">
488 <div class="dev-env-card">
489 <div class="dev-env-eyebrow">
490 <span class="dev-env-eyebrow-dot" aria-hidden="true" />
491 Cloud dev env
492 </div>
493 <h1 class="dev-env-title">
494 Spin up a hosted VS Code for{" "}
495 {resolved.ownerName}/{resolved.repoName}
496 </h1>
497 <p class="dev-env-sub">
498 Get a full VS Code IDE in your browser, backed by a cold-start
499 container. We read <code>.gluecron/dev.yml</code> from your
500 repo for the image, install steps, and recommended
501 extensions. Idle envs stop themselves after{" "}
502 <strong>30 minutes</strong>.
503 </p>
504 <form
505 method="post"
506 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/start`}
507 >
508 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
509 <div class="dev-env-actions">
510 <button type="submit" class="dev-env-cta">
511 Start dev env →
512 </button>
513 <a
514 href={`/${resolved.ownerName}/${resolved.repoName}`}
515 class="dev-env-cta-secondary"
516 >
517 Cancel
518 </a>
519 </div>
520 </form>
521 </div>
522 </div>
523 </Layout>
524 );
525 }
526
527 // Ready — render full-screen iframe.
528 if (env.status === "ready" && env.previewUrl) {
529 return c.html(
530 <Layout
531 title={`Dev — ${resolved.ownerName}/${resolved.repoName}`}
532 user={user}
533 >
534 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
535 <div class="dev-env-shell" role="region" aria-label="Cloud dev environment">
536 <div class="dev-env-shell-bar">
537 <span class="dev-env-shell-title">
538 <StatusPill status="ready" />
539 {resolved.ownerName}/{resolved.repoName}
540 </span>
541 <form
542 method="post"
543 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/stop`}
544 style="margin:0"
545 >
546 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
547 <button type="submit" class="dev-env-cta-secondary">
548 Stop env
549 </button>
550 </form>
551 </div>
552 <iframe
553 class="dev-env-shell-iframe"
554 src={env.previewUrl}
555 title={`VS Code Server — ${resolved.ownerName}/${resolved.repoName}`}
556 allow="clipboard-read; clipboard-write; fullscreen"
557 />
558 </div>
559 </Layout>
560 );
561 }
562
563 // Warming / cold / failed / stopped — render status page with poll script.
564 const statusBody =
565 env.status === "failed" ? (
566 <>
567 <p class="dev-env-sub">
568 Something went wrong while spinning up the container. Hit{" "}
569 <strong>Retry</strong> below to start fresh.
570 </p>
571 {env.errorMessage && (
572 <div class="dev-env-error" role="alert">
573 {env.errorMessage}
574 </div>
575 )}
576 <form
577 method="post"
578 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/start`}
579 style="margin:0"
580 >
581 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
582 <div class="dev-env-actions">
583 <button type="submit" class="dev-env-cta">
584 Retry →
585 </button>
586 <a
587 href={`/${resolved.ownerName}/${resolved.repoName}`}
588 class="dev-env-cta-secondary"
589 >
590 Back to repo
591 </a>
592 </div>
593 </form>
594 </>
595 ) : env.status === "stopped" ? (
596 <>
597 <p class="dev-env-sub">
598 Your environment is stopped. Click below to warm it back up — the
599 URL stays the same and your files persist.
600 </p>
601 <form
602 method="post"
603 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/start`}
604 style="margin:0"
605 >
606 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
607 <div class="dev-env-actions">
608 <button type="submit" class="dev-env-cta">
609 Warm up →
610 </button>
611 </div>
612 </form>
613 </>
614 ) : (
615 <>
616 <p class="dev-env-sub">
617 Starting your dev env... we're pulling the image, installing
618 deps, and bringing VS Code Server online. Usually under a minute.
619 </p>
620 <div
621 class="dev-env-progress"
622 role="progressbar"
623 aria-label="Warming dev environment"
624 aria-valuetext="In progress"
625 >
626 <div class="dev-env-progress-bar" />
627 </div>
628 <noscript>
629 <p class="dev-env-sub">
630 Enable JavaScript for live updates, or refresh manually.
631 </p>
632 </noscript>
633 </>
634 );
635
636 const pollScript = `
637 (function(){
638 var url = "/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/status";
639 function tick(){
640 fetch(url, { credentials: "same-origin" })
641 .then(function(r){ return r.json(); })
642 .then(function(j){
643 if(j && j.env && (j.env.status === "ready" || j.env.status === "failed")){
644 window.location.reload();
645 }
646 })
647 .catch(function(){ /* ignore */ });
648 }
649 setInterval(tick, 2000);
650 })();
651 `;
652
653 return c.html(
654 <Layout
655 title={`Dev env — ${resolved.ownerName}/${resolved.repoName}`}
656 user={user}
657 >
658 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
659 <div class="dev-env-wrap">
660 <div class="dev-env-card">
661 <div class="dev-env-eyebrow">
662 <span class="dev-env-eyebrow-dot" aria-hidden="true" />
663 Cloud dev env
664 </div>
665 <h1 class="dev-env-title">
666 {resolved.ownerName}/{resolved.repoName}
667 </h1>
668 <StatusPill status={env.status} />
669 {statusBody}
670 <MetaGrid env={env} />
671 </div>
672 </div>
673 {env.status === "warming" || env.status === "cold" ? (
674 <script dangerouslySetInnerHTML={{ __html: pollScript }} />
675 ) : null}
676 </Layout>
677 );
678});
679
680// ---------------------------------------------------------------------------
681// API: POST /api/v2/repos/:owner/:repo/dev/start
682// ---------------------------------------------------------------------------
683
684devEnvRoutes.post(
685 "/api/v2/repos/:owner/:repo/dev/start",
686 softAuth,
687 requireAuth,
688 async (c) => {
689 const ownerName = c.req.param("owner");
690 const repoName = c.req.param("repo");
691 const user = c.get("user")!;
692 const resolved = await resolveRepoForUser(ownerName, repoName);
693 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
694
695 // Parse machine_size from either form body or JSON body, leniently.
696 let machineSize: string | undefined;
697 try {
698 const ct = c.req.header("content-type") || "";
699 if (ct.includes("application/json")) {
700 const body = (await c.req.json().catch(() => null)) as
701 | { machineSize?: string; machine_size?: string }
702 | null;
703 machineSize = body?.machineSize ?? body?.machine_size;
704 } else {
705 const body = await c.req.parseBody().catch(() => ({}) as any);
706 machineSize = (body.machine_size || body.machineSize) as
707 | string
708 | undefined;
709 }
710 } catch {
711 /* ignore */
712 }
713
714 const result = await startDevEnv({
715 repositoryId: resolved.repoId,
716 ownerUserId: user.id,
717 machineSize: normalizeMachineSize(machineSize),
718 });
719
720 if (!result.ok) {
721 // For browser form posts, redirect back with an error in the URL;
722 // for API clients (Accept: application/json), return JSON.
723 const wantsJson =
724 (c.req.header("accept") || "").includes("application/json") ||
725 (c.req.header("content-type") || "").includes("application/json");
726 const status =
727 result.reason === "repo_not_found"
728 ? 404
729 : result.reason === "not_opted_in"
730 ? 403
731 : result.reason === "invalid_input"
732 ? 400
733 : 500;
734 if (wantsJson) {
735 return c.json({ ok: false, error: result.reason }, status);
736 }
737 return c.redirect(
738 `/${resolved.ownerName}/${resolved.repoName}/dev?error=${result.reason}`
739 );
740 }
741
742 const wantsJson =
743 (c.req.header("accept") || "").includes("application/json") ||
744 (c.req.header("content-type") || "").includes("application/json");
745 if (wantsJson) {
746 return c.json({ ok: true, env: jsonShape(result.env) });
747 }
748 return c.redirect(`/${resolved.ownerName}/${resolved.repoName}/dev`);
749 }
750);
751
752// ---------------------------------------------------------------------------
753// API: POST /api/v2/repos/:owner/:repo/dev/stop
754// ---------------------------------------------------------------------------
755
756devEnvRoutes.post(
757 "/api/v2/repos/:owner/:repo/dev/stop",
758 softAuth,
759 requireAuth,
760 async (c) => {
761 const ownerName = c.req.param("owner");
762 const repoName = c.req.param("repo");
763 const user = c.get("user")!;
764 const resolved = await resolveRepoForUser(ownerName, repoName);
765 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
766
767 const env = await getDevEnvForOwner(resolved.repoId, user.id);
768 if (!env) {
769 const wantsJson =
770 (c.req.header("accept") || "").includes("application/json");
771 if (wantsJson) return c.json({ ok: true, env: null });
772 return c.redirect(`/${resolved.ownerName}/${resolved.repoName}/dev`);
773 }
774 await stopDevEnv(env.id);
775 const after = await getDevEnv(env.id);
776 const wantsJson = (c.req.header("accept") || "").includes(
777 "application/json"
778 );
779 if (wantsJson) return c.json({ ok: true, env: jsonShape(after) });
780 return c.redirect(`/${resolved.ownerName}/${resolved.repoName}/dev`);
781 }
782);
783
784// ---------------------------------------------------------------------------
785// API: GET /api/v2/repos/:owner/:repo/dev/status
786// ---------------------------------------------------------------------------
787
788devEnvRoutes.get(
789 "/api/v2/repos/:owner/:repo/dev/status",
790 softAuth,
791 requireAuth,
792 async (c) => {
793 const ownerName = c.req.param("owner");
794 const repoName = c.req.param("repo");
795 const user = c.get("user")!;
796 const resolved = await resolveRepoForUser(ownerName, repoName);
797 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
798 const env = await getDevEnvForOwner(resolved.repoId, user.id);
799 if (env) {
800 void recordActivity(env.id);
801 }
802 return c.json({ ok: true, env: jsonShape(env) });
803 }
804);
805
806export default devEnvRoutes;
807
808// Re-export so tests can stub without the path string lottery.
809export type { DevEnvStatus };
Modifiedsrc/routes/help.tsx+82−0View fileUnifiedSplit
@@ -783,6 +783,88 @@ env:
783783 </div>
784784 </section>
785785
786 {/* ─── Cloud dev environments (migration 0072) ─── */}
787 <section id="dev-envs" class="help-section">
788 <div class="help-section-eyebrow">Per-repo</div>
789 <h2 class="help-section-title">Cloud dev environments</h2>
790 <p class="help-section-desc">
791 Hit <code>/:owner/:repo/dev</code> to get a hosted VS Code IDE
792 in your browser, backed by a cold-start container. One env per
793 (repo, user); idle envs stop themselves after{" "}
794 <strong>30 minutes</strong> so we don't leak compute.
795 Customise per-repo via <code>.gluecron/dev.yml</code>.
796 </p>
797 <div class="help-section-body">
798 <div class="help-item">
799 <strong>Opt-in defaults.</strong> Cloud dev environments are
800 off by default. Flip the toggle in repo settings under{" "}
801 <code>Settings → Dev environments</code> to surface the route
802 on your repo.
803 </div>
804 <div class="help-item">
805 <strong>Customise via <code>.gluecron/dev.yml</code>.</strong>{" "}
806 Commit this file at the repo root to control what your dev
807 env runs. If it's missing, Claude drafts one from your
808 repo on first start.
809 <pre class="help-code">
810{`# .gluecron/dev.yml — Node example
811image: node:20-alpine
812ports: [3000]
813install:
814 - npm install
815postCreate:
816 - npm run db:seed
817command: npm run dev
818recommendedExtensions:
819 - dbaeumer.vscode-eslint
820 - esbenp.prettier-vscode`}
821 </pre>
822 <pre class="help-code">
823{`# .gluecron/dev.yml — Python example
824image: python:3.12-slim
825ports: [8000]
826install:
827 - pip install -r requirements.txt
828postCreate:
829 - python manage.py migrate
830command: python manage.py runserver 0.0.0.0:8000
831recommendedExtensions:
832 - ms-python.python
833 - ms-python.vscode-pylance`}
834 </pre>
835 <pre class="help-code">
836{`# .gluecron/dev.yml — Rust example
837image: rust:1.78
838ports: [8080]
839install:
840 - cargo fetch
841postCreate: []
842command: cargo run
843recommendedExtensions:
844 - rust-lang.rust-analyzer
845 - vadimcn.vscode-lldb`}
846 </pre>
847 </div>
848 <div class="help-item">
849 <strong>API.</strong>{" "}
850 <code>POST /api/v2/repos/:owner/:repo/dev/start</code>{" "}
851 provisions (or warms-up) an env;{" "}
852 <code>POST /api/v2/repos/:owner/:repo/dev/stop</code>{" "}
853 gracefully stops one; and{" "}
854 <code>GET /api/v2/repos/:owner/:repo/dev/status</code>{" "}
855 returns the JSON shape used by the in-page poller.
856 </div>
857 <div class="help-item">
858 <strong>Idle sweep.</strong> The{" "}
859 <code>dev-env-idle-sweep</code> autopilot task runs every
860 5 minutes and stops any env whose{" "}
861 <code>last_active_at + idle_minutes</code> has passed. Each
862 page hit bumps <code>last_active_at</code> so an open IDE
863 tab keeps the env warm.
864 </div>
865 </div>
866 </section>
867
786868 {/* ─── Migration assistant ─── */}
787869 <section id="migrations" class="help-section">
788870 <div class="help-section-head">
Modifiedsrc/routes/repo-settings.tsx+113−0View fileUnifiedSplit
@@ -886,6 +886,61 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin
886886 </form>
887887 </section>
888888
889 {/* ─── Cloud dev environments ─── */}
890 <section
891 id="dev-envs"
892 class="repo-settings-section"
893 >
894 <div class="repo-settings-section-head">
895 <div class="repo-settings-section-eyebrow">Dev environments</div>
896 <h2 class="repo-settings-section-title">Enable cloud dev environments</h2>
897 <p class="repo-settings-section-desc">
898 When enabled, anyone with read access can hit{" "}
899 <code>/{ownerName}/{repoName}/dev</code> to spin up a
900 hosted VS Code IDE in the browser, backed by a cold-start
901 container. We read <code>.gluecron/dev.yml</code> for the
902 image + install commands; idle envs stop themselves after
903 30 minutes. Default off — each env burns a container.
904 </p>
905 </div>
906 <form
907 method="post"
908 action={`/${ownerName}/${repoName}/settings/dev-envs`}
909 >
910 <div class="repo-settings-section-body">
911 <label
912 class="repo-settings-toggle-row"
913 aria-label="Enable cloud dev environments for this repo"
914 >
915 <input
916 type="checkbox"
917 name="dev_envs_enabled"
918 value="1"
919 checked={
920 (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ??
921 false
922 }
923 />
924 <span class="repo-settings-toggle-text">
925 <span class="repo-settings-toggle-text-title">
926 Enable dev environments
927 </span>
928 <span class="repo-settings-toggle-text-hint">
929 Surfaces the <code>/{ownerName}/{repoName}/dev</code>{" "}
930 route. Commit <code>.gluecron/dev.yml</code> to your
931 repo to customise the image, ports, and extensions.
932 </span>
933 </span>
934 </label>
935 </div>
936 <div class="repo-settings-section-foot">
937 <button type="submit" class="repo-settings-cta">
938 Save dev env settings <span class="arrow">→</span>
939 </button>
940 </div>
941 </form>
942 </section>
943
889944 {/* ─── Stale activity ─── */}
890945 <section class="repo-settings-section">
891946 <div class="repo-settings-section-head">
@@ -1295,6 +1350,64 @@ repoSettings.post(
12951350 }
12961351);
12971352
1353// Migration 0072 — toggle cloud dev environments. Owner-only; audits
1354// the toggle delta so the repo's audit log shows the change.
1355repoSettings.post(
1356 "/:owner/:repo/settings/dev-envs",
1357 requireAuth,
1358 requireRepoAccess("admin"),
1359 async (c) => {
1360 const { owner: ownerName, repo: repoName } = c.req.param();
1361 const user = c.get("user")!;
1362 const body = await c.req.parseBody();
1363 const [owner] = await db
1364 .select()
1365 .from(users)
1366 .where(eq(users.username, ownerName))
1367 .limit(1);
1368 if (!owner || owner.id !== user.id) {
1369 return c.redirect(`/${ownerName}/${repoName}`);
1370 }
1371 const [repo] = await db
1372 .select()
1373 .from(repositories)
1374 .where(
1375 and(
1376 eq(repositories.ownerId, owner.id),
1377 eq(repositories.name, repoName)
1378 )
1379 )
1380 .limit(1);
1381 if (!repo) return c.notFound();
1382
1383 const next = body.dev_envs_enabled === "1";
1384 const prev = (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ?? false;
1385
1386 await db
1387 .update(repositories)
1388 .set({
1389 devEnvsEnabled: next,
1390 updatedAt: new Date(),
1391 })
1392 .where(eq(repositories.id, repo.id));
1393
1394 if (next !== prev) {
1395 await audit({
1396 userId: user.id,
1397 repositoryId: repo.id,
1398 action: "repo.dev_envs_enabled.toggled",
1399 targetType: "repository",
1400 targetId: repo.id,
1401 metadata: { from: prev, to: next },
1402 });
1403 }
1404
1405 return c.redirect(
1406 `/${ownerName}/${repoName}/settings?success=Dev+env+settings+saved#dev-envs`
1407 );
1408 }
1409);
1410
12981411// Delete repository
12991412repoSettings.post(
13001413 "/:owner/:repo/settings/delete",
13011414