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.tsBlame614 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,
b271465Claude47 MODEL_SONNET,
9b3a183Claude48 extractText,
49} from "./ai-client";
50
51// ---------------------------------------------------------------------------
52// Tunables
53// ---------------------------------------------------------------------------
54
55/** Default idle minutes if the caller doesn't override. Mirrors SQL default. */
56export const DEFAULT_IDLE_MINUTES = 30;
57
58/** Cap for `error_message` so a giant stack trace can't blow up the row. */
59const ERROR_MESSAGE_CAP = 2_000;
60
61/** Allowed machine sizes. */
62export type MachineSize = "small" | "medium" | "large";
63const ALLOWED_MACHINE_SIZES: ReadonlyArray<MachineSize> = [
64 "small",
65 "medium",
66 "large",
67];
68
69/** Allowed env statuses. */
70export type DevEnvStatus =
71 | "cold"
72 | "warming"
73 | "ready"
74 | "failed"
75 | "stopped";
76
77/** Default `.gluecron/dev.yml` when no file is committed AND AI is unavailable. */
78const DEFAULT_DEV_YML = `# .gluecron/dev.yml — auto-generated default.
79# Commit this file under .gluecron/dev.yml on your repo to control how
80# the cloud dev environment is provisioned.
81image: node:20-alpine
82ports: [3000]
83install:
84 - npm install
85postCreate: []
86command: npm run dev
87recommendedExtensions:
88 - dbaeumer.vscode-eslint
89 - esbenp.prettier-vscode
90`;
91
92// ---------------------------------------------------------------------------
93// URL + label helpers (pure — exported so route handlers can render
94// without going through the DB).
95// ---------------------------------------------------------------------------
96
97/**
98 * Compute the VS-Code-Server URL for a dev env id. The id is slugified so
99 * a UUID with dashes lands cleanly into a single DNS label.
100 */
101export function buildDevEnvUrl(envId: string): string {
102 const domain = (process.env.DEV_ENV_DOMAIN || "gluecron.com").replace(
103 /^https?:\/\//,
104 ""
105 );
106 const slug = slugifyForUrl(envId || "unknown");
107 return `https://dev-${slug}.${domain}`;
108}
109
110/** Visible string for the status pill. */
111export function devEnvStatusLabel(status: string): string {
112 switch (status) {
113 case "cold":
114 return "Cold";
115 case "warming":
116 return "Warming up";
117 case "ready":
118 return "Ready";
119 case "failed":
120 return "Failed";
121 case "stopped":
122 return "Stopped";
123 default:
124 return status;
125 }
126}
127
128/** Validate machine-size strings coming from query params / form bodies. */
129export function normalizeMachineSize(
130 value: string | undefined | null
131): MachineSize {
132 if (!value) return "small";
133 return (ALLOWED_MACHINE_SIZES as ReadonlyArray<string>).includes(value)
134 ? (value as MachineSize)
135 : "small";
136}
137
138// ---------------------------------------------------------------------------
139// dev.yml resolution
140// ---------------------------------------------------------------------------
141
142/**
143 * Read `.gluecron/dev.yml` from the repo's default branch (or the given
144 * ref). Returns the file contents, or `null` if the file isn't in the
145 * tree (the repo hasn't opted in) or git read fails.
146 */
147export async function readDevYml(
148 ownerName: string,
149 repoName: string,
150 ref: string = "HEAD"
151): Promise<string | null> {
152 if (!ownerName || !repoName) return null;
153 try {
154 const blob = await getBlob(
155 ownerName,
156 repoName,
157 ref,
158 ".gluecron/dev.yml"
159 );
160 if (!blob || blob.isBinary) return null;
161 const content = (blob.content || "").trim();
162 if (!content) return null;
163 return blob.content;
164 } catch {
165 return null;
166 }
167}
168
169/**
a5705cfClaude170 * Ask Claude (Sonnet) to draft a `.gluecron/dev.yml` for a repo. Used when
9b3a183Claude171 * the repo hasn't committed one. Returns the YAML body, or
172 * `DEFAULT_DEV_YML` if AI is unavailable / errors. Never throws.
173 */
174export async function generateDevYml(repoHint: string): Promise<string> {
175 if (!isAiAvailable()) return DEFAULT_DEV_YML;
176 try {
177 const client = getAnthropic();
178 const message = await client.messages.create({
a5705cfClaude179 model: MODEL_SONNET,
9b3a183Claude180 max_tokens: 800,
181 messages: [
182 {
183 role: "user",
184 content:
185 "Generate a `.gluecron/dev.yml` for the following repo so " +
186 "developers can open a cloud dev environment (VS Code in the " +
187 "browser) on this repo. Output ONLY YAML — no prose, no code " +
188 "fences. Required keys: `image` (a docker image), `ports` " +
189 "(array of ints), `install` (array of shell commands run on " +
190 "first start), `postCreate` (array of shell commands run after " +
191 "install), `command` (the long-running dev command), " +
192 "`recommendedExtensions` (array of VS Code extension IDs). " +
193 "Pick conservative defaults if unsure.\n\nRepo: " +
194 repoHint,
195 },
196 ],
197 });
198 const text = extractText(message).trim();
199 if (!text) return DEFAULT_DEV_YML;
200 const cleaned = text
201 .replace(/^```(?:yaml|yml)?\s*/i, "")
202 .replace(/```\s*$/i, "")
203 .trim();
204 return cleaned || DEFAULT_DEV_YML;
205 } catch (err) {
206 console.warn(
207 "[dev-env] generateDevYml failed; using default:",
208 err instanceof Error ? err.message : err
209 );
210 return DEFAULT_DEV_YML;
211 }
212}
213
214// ---------------------------------------------------------------------------
215// Lookup helpers
216// ---------------------------------------------------------------------------
217
218/** Look up an env by id. */
219export async function getDevEnv(envId: string): Promise<DevEnv | null> {
220 if (!envId) return null;
221 try {
222 const [row] = await db
223 .select()
224 .from(devEnvs)
225 .where(eq(devEnvs.id, envId))
226 .limit(1);
227 return row ?? null;
228 } catch {
229 return null;
230 }
231}
232
233/** Look up the env row for a (repo, user), or null if none. */
234export async function getDevEnvForOwner(
235 repositoryId: string,
236 ownerUserId: string
237): Promise<DevEnv | null> {
238 if (!repositoryId || !ownerUserId) return null;
239 try {
240 const [row] = await db
241 .select()
242 .from(devEnvs)
243 .where(
244 and(
245 eq(devEnvs.repositoryId, repositoryId),
246 eq(devEnvs.ownerUserId, ownerUserId)
247 )
248 )
249 .limit(1);
250 return row ?? null;
251 } catch {
252 return null;
253 }
254}
255
256// ---------------------------------------------------------------------------
257// Core lifecycle
258// ---------------------------------------------------------------------------
259
260export interface StartDevEnvArgs {
261 repositoryId: string;
262 ownerUserId: string;
263 machineSize?: MachineSize;
264 idleMinutes?: number;
265 /** Override the resolved YAML — only used by tests so we don't hit AI. */
266 devYml?: string;
267 /** Override "now" — tests only. */
268 now?: () => Date;
269}
270
271export type StartDevEnvResult =
272 | {
273 ok: true;
274 env: DevEnv;
275 /** Convenience: env.previewUrl computed eagerly. */
276 url: string;
277 }
278 | {
279 ok: false;
280 reason:
281 | "repo_not_found"
282 | "not_opted_in"
283 | "db_unavailable"
284 | "invalid_input";
285 };
286
287/**
288 * Start (or restart) a dev env for `(repositoryId, ownerUserId)`.
289 *
290 * - Refuses if the repo doesn't have `dev_envs_enabled = true`.
291 * - Reads `.gluecron/dev.yml` from the repo if committed; otherwise asks
292 * Claude (Haiku) to draft one; otherwise falls back to a sane default.
293 * - Upserts the env row (one per repo+user) with status='warming' and a
294 * deterministic preview_url.
295 * - A downstream worker (or the workflow-runner foundation) is responsible
296 * for actually spinning the container and flipping the row to 'ready'
297 * via `markReady` — kept out of this v1 path so the route stays cheap.
298 *
299 * Returns the row + URL on success, or a typed reason for refusal.
300 */
301export async function startDevEnv(
302 args: StartDevEnvArgs
303): Promise<StartDevEnvResult> {
304 if (!args.repositoryId || !args.ownerUserId) {
305 return { ok: false, reason: "invalid_input" };
306 }
307 const now = (args.now ?? (() => new Date()))();
308 const machineSize = normalizeMachineSize(args.machineSize);
309 const idleMinutes =
310 Number.isFinite(args.idleMinutes) && args.idleMinutes! > 0
311 ? Math.floor(args.idleMinutes!)
312 : DEFAULT_IDLE_MINUTES;
313
314 // Repo gate — must exist and have opted in.
315 let repoRow: { id: string; name: string; ownerId: string; enabled: boolean } | null = null;
316 try {
317 const [row] = await db
318 .select({
319 id: repositories.id,
320 name: repositories.name,
321 ownerId: repositories.ownerId,
322 enabled: repositories.devEnvsEnabled,
323 })
324 .from(repositories)
325 .where(eq(repositories.id, args.repositoryId))
326 .limit(1);
327 repoRow = row ?? null;
328 } catch (err) {
329 console.warn(
330 "[dev-env] repo lookup failed:",
331 err instanceof Error ? err.message : err
332 );
333 return { ok: false, reason: "db_unavailable" };
334 }
335 if (!repoRow) return { ok: false, reason: "repo_not_found" };
336 if (!repoRow.enabled) return { ok: false, reason: "not_opted_in" };
337
338 // Resolve dev.yml — committed file wins, else ask AI, else default.
339 let yml = args.devYml;
340 if (yml === undefined) {
341 let ownerName = "";
342 try {
343 const [owner] = await db
344 .select({ username: users.username })
345 .from(users)
346 .where(eq(users.id, repoRow.ownerId))
347 .limit(1);
348 ownerName = owner?.username || "";
349 } catch {
350 /* swallow — fall back to default */
351 }
352 const fromGit = ownerName
353 ? await readDevYml(ownerName, repoRow.name)
354 : null;
355 yml =
356 fromGit ??
357 (await generateDevYml(`${ownerName || "?"}/${repoRow.name}`));
358 }
359
360 // Pre-compute the deterministic URL. We need the env id to slot into
361 // the subdomain, so the upsert path is:
362 // 1. Try insert with a NULL preview_url
363 // 2. On conflict, leave preview_url as-is (existing URL is the truth)
364 // 3. After the upsert resolves, if preview_url is NULL we update it
365 // with the freshly-known id.
366 // This keeps the URL stable across restarts (the env id never changes
367 // for a given (repo, user) pair).
368 let row: DevEnv | null = null;
369 try {
370 const [inserted] = await db
371 .insert(devEnvs)
372 .values({
373 repositoryId: args.repositoryId,
374 ownerUserId: args.ownerUserId,
375 status: "warming",
376 previewUrl: null,
377 containerId: null,
378 machineSize,
379 idleMinutes,
380 devYml: yml,
381 errorMessage: null,
382 lastActiveAt: now,
383 expiresAt: null,
384 })
385 .onConflictDoUpdate({
386 target: [devEnvs.repositoryId, devEnvs.ownerUserId],
387 set: {
388 status: "warming",
389 machineSize,
390 idleMinutes,
391 devYml: yml,
392 errorMessage: null,
393 lastActiveAt: now,
394 },
395 })
396 .returning();
397 row = inserted ?? null;
398 } catch (err) {
399 console.warn(
400 "[dev-env] upsert failed:",
401 err instanceof Error ? err.message : err
402 );
403 return { ok: false, reason: "db_unavailable" };
404 }
405
406 if (!row) return { ok: false, reason: "db_unavailable" };
407
408 // Backfill preview_url if missing (first-ever start for this pair).
409 if (!row.previewUrl) {
410 const url = buildDevEnvUrl(row.id);
411 try {
412 await db
413 .update(devEnvs)
414 .set({ previewUrl: url })
415 .where(eq(devEnvs.id, row.id));
416 row = { ...row, previewUrl: url };
417 } catch (err) {
418 console.warn(
419 "[dev-env] preview_url backfill failed:",
420 err instanceof Error ? err.message : err
421 );
422 }
423 }
424
425 return {
426 ok: true,
427 env: row,
428 url: row.previewUrl || buildDevEnvUrl(row.id),
429 };
430}
431
432/**
433 * Mark a dev env as successfully spun up. Called by the warmer once the
434 * container is live + VS Code Server is reachable.
435 */
436export async function markReady(
437 envId: string,
438 containerId?: string
439): Promise<void> {
440 if (!envId) return;
441 try {
442 await db
443 .update(devEnvs)
444 .set({
445 status: "ready",
446 errorMessage: null,
447 ...(containerId ? { containerId } : {}),
448 })
449 .where(eq(devEnvs.id, envId));
450 } catch (err) {
451 console.warn(
452 "[dev-env] markReady failed:",
453 err instanceof Error ? err.message : err
454 );
455 }
456}
457
458/**
459 * Mark a dev env as failed and record the error (truncated).
460 */
461export async function markFailed(envId: string, error: string): Promise<void> {
462 if (!envId) return;
463 try {
464 await db
465 .update(devEnvs)
466 .set({
467 status: "failed",
468 errorMessage: (error || "").slice(0, ERROR_MESSAGE_CAP),
469 })
470 .where(eq(devEnvs.id, envId));
471 } catch (err) {
472 console.warn(
473 "[dev-env] markFailed failed:",
474 err instanceof Error ? err.message : err
475 );
476 }
477}
478
479/**
480 * Graceful shutdown — flip status to 'stopped'. The URL is intentionally
481 * preserved so a later restart reuses it.
482 *
483 * A real implementation would also tell the hoster to tear down the
484 * container; for v1 we just flip the row.
485 */
486export async function stopDevEnv(envId: string): Promise<void> {
487 if (!envId) return;
488 try {
489 await db
490 .update(devEnvs)
491 .set({ status: "stopped", containerId: null })
492 .where(eq(devEnvs.id, envId));
493 } catch (err) {
494 console.warn(
495 "[dev-env] stopDevEnv failed:",
496 err instanceof Error ? err.message : err
497 );
498 }
499}
500
501/**
502 * Called on every URL hit to bump last_active_at. Best-effort — never
503 * blocks the request even if the DB is down.
504 */
505export async function recordActivity(envId: string): Promise<void> {
506 if (!envId) return;
507 try {
508 await db
509 .update(devEnvs)
510 .set({ lastActiveAt: new Date() })
511 .where(eq(devEnvs.id, envId));
512 } catch {
513 /* best effort */
514 }
515}
516
517/**
518 * Autopilot task: stop every env where `last_active_at + idle_minutes` is
519 * in the past, but only if status is 'ready' or 'warming'. Already-
520 * stopped / failed rows are skipped so the loop stays cheap. Returns the
521 * number of rows transitioned for observability.
522 *
523 * The idle window is per-row (each env carries its own `idle_minutes`)
524 * so we compare against `now() - idle_minutes * interval '1 minute'`.
525 */
526export async function expireIdleEnvs(
527 now: () => Date = () => new Date()
528): Promise<number> {
529 const cutoffNow = now();
530 try {
531 // We compute the cutoff per-row inside SQL: lastActiveAt + idleMinutes < now.
532 // Drizzle's lt/eq can't combine column + literal arithmetic cleanly, so
533 // we drop into a `sql` template. Postgres-only — fine for our deploy.
534 const ready = await db
535 .update(devEnvs)
536 .set({ status: "stopped", containerId: null })
537 .where(
538 and(
539 eq(devEnvs.status, "ready"),
540 sql`${devEnvs.lastActiveAt} + (${devEnvs.idleMinutes} * interval '1 minute') < ${cutoffNow.toISOString()}::timestamptz`
541 )
542 )
543 .returning({ id: devEnvs.id });
544 const stuck = await db
545 .update(devEnvs)
546 .set({ status: "stopped", containerId: null })
547 .where(
548 and(
549 eq(devEnvs.status, "warming"),
550 sql`${devEnvs.lastActiveAt} + (${devEnvs.idleMinutes} * interval '1 minute') < ${cutoffNow.toISOString()}::timestamptz`
551 )
552 )
553 .returning({ id: devEnvs.id });
554 return ready.length + stuck.length;
555 } catch (err) {
556 console.warn(
557 "[dev-env] expireIdleEnvs failed:",
558 err instanceof Error ? err.message : err
559 );
560 // Fallback: client-side filter (used when the SQL template is rejected
561 // by the test driver). We pull idle-candidate rows and compute the
562 // cutoff in JS. Bounded by the small expected env count.
563 try {
564 const rows = await db.select().from(devEnvs);
565 let stopped = 0;
566 for (const r of rows) {
567 if (r.status !== "ready" && r.status !== "warming") continue;
568 const cutoff =
569 (r.lastActiveAt?.getTime?.() ?? 0) +
570 (r.idleMinutes ?? DEFAULT_IDLE_MINUTES) * 60_000;
571 if (cutoff < cutoffNow.getTime()) {
572 await db
573 .update(devEnvs)
574 .set({ status: "stopped", containerId: null })
575 .where(eq(devEnvs.id, r.id));
576 stopped++;
577 }
578 }
579 return stopped;
580 } catch (err2) {
581 console.warn(
582 "[dev-env] expireIdleEnvs fallback failed:",
583 err2 instanceof Error ? err2.message : err2
584 );
585 return 0;
586 }
587 }
588}
589
590/**
591 * Convenience: also expire rows whose `expires_at` is past (when set).
592 * Currently unused by the route layer but exposed so admins can wire a
593 * hard cap if/when they want one.
594 */
595export async function expireHardCappedEnvs(
596 now: () => Date = () => new Date()
597): Promise<number> {
598 try {
599 const stopped = await db
600 .update(devEnvs)
601 .set({ status: "stopped", containerId: null })
602 .where(
603 and(lt(devEnvs.expiresAt, now()), eq(devEnvs.status, "ready"))
604 )
605 .returning({ id: devEnvs.id });
606 return stopped.length;
607 } catch (err) {
608 console.warn(
609 "[dev-env] expireHardCappedEnvs failed:",
610 err instanceof Error ? err.message : err
611 );
612 return 0;
613 }
614}