Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

dev-env.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

dev-env.tsBlame613 lines · 1 contributor
9b3a183Claude1/**
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/**
a5705cfClaude169 * Ask Claude (Sonnet) to draft a `.gluecron/dev.yml` for a repo. Used when
9b3a183Claude170 * 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({
a5705cfClaude178 model: MODEL_SONNET,
9b3a183Claude179 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}