/** * Per-project AI dev container ("vibn-dev"). * * One Coolify Service per Vibn project, running the `vibn-dev` image. * The AI agent drives it via: * - shell.exec → docker exec into the container (via existing SSH path) * - fs.* → file ops (implemented as `cat` / `tee` / `rm` etc. * inside the container, on top of shell.exec) * - dev_server.* → start long-running processes (week 2) * - ship → git push to Gitea + trigger Coolify deploy (week 2) * * Lifecycle states: * - Not provisioned → ensureDevContainer() creates the Coolify service * - Suspended → Coolify-stopped (saves money). resume() starts it. * - Running → docker exec works. * * Tenant safety: every helper takes a workspace and the caller must have * already verified that the projectId belongs to that workspace via * fs_projects. The exec primitive ALSO verifies the resolved container * UUID is in the workspace's owned Coolify-project set, so a hijacked * projectId can't reach unrelated containers. * * See: AI_PATH_B_EXECUTION_PLAN.md §3. */ import { query, queryOne } from '@/lib/db-postgres'; import { createDockerComposeApp, startService, stopService, getService, } from '@/lib/coolify'; import { execInCoolifyApp, type ExecInAppResult } from '@/lib/coolify-exec'; import { isCoolifySshConfigured } from '@/lib/coolify-ssh'; import { ensureProjectCoolifyProject, getProjectCoolifyUuid, linkResourceToProject, } from '@/lib/projects'; import type { VibnWorkspace } from '@/lib/workspaces'; // ── Configuration ──────────────────────────────────────────────────── /** * Image tag for vibn-dev. Built and pushed from /vibn-dev/Dockerfile. * Override per-environment with VIBN_DEV_IMAGE for staging/canary tags. */ export const VIBN_DEV_IMAGE = process.env.VIBN_DEV_IMAGE ?? 'vibn-dev:latest'; /** Resource caps per dev container. Tweak in env per-tier later. */ const DEFAULT_CPU_LIMIT = process.env.VIBN_DEV_CPU_LIMIT ?? '1'; // 1 vCPU const DEFAULT_MEM_LIMIT = process.env.VIBN_DEV_MEM_LIMIT ?? '1g'; // 1 GiB const DEFAULT_DISK_LIMIT = process.env.VIBN_DEV_DISK_LIMIT ?? '10g'; // soft hint, not enforced by compose // ── Schema ─────────────────────────────────────────────────────────── let devContainersTableReady = false; export async function ensureDevContainersTable(): Promise { if (devContainersTableReady) return; await query( `CREATE TABLE IF NOT EXISTS fs_project_dev_containers ( project_id TEXT PRIMARY KEY, workspace TEXT NOT NULL, service_uuid TEXT NOT NULL, image TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'provisioning', last_active_at TIMESTAMPTZ NOT NULL DEFAULT now(), suspended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS fs_project_dev_containers_ws_idx ON fs_project_dev_containers (workspace); CREATE INDEX IF NOT EXISTS fs_project_dev_containers_active_idx ON fs_project_dev_containers (last_active_at);`, [], ); devContainersTableReady = true; } export interface DevContainerRow { project_id: string; workspace: string; service_uuid: string; image: string; state: 'provisioning' | 'running' | 'suspended' | 'failed'; last_active_at: Date; suspended_at: Date | null; created_at: Date; } export async function getDevContainerRow(projectId: string): Promise { await ensureDevContainersTable(); return queryOne( `SELECT * FROM fs_project_dev_containers WHERE project_id = $1 LIMIT 1`, [projectId], ); } // ── Compose template ───────────────────────────────────────────────── /** * Render the docker-compose.yml that backs a single vibn-dev service. * * Two named volumes are intentional: * - workspace : everything in /workspace (the user's source tree). * Persists across suspends. Backed up to Gitea every * 5 min via the auto-push autosave loop (week 2). * - cache : language-toolchain caches (mise, npm, pip, cargo). * Persists across suspends; per-project (never shared). * * The container has NO Vibn-internal network access. We rely on the * default Coolify-bridge network being isolated from the vibn-postgres * / vibn-frontend bridge. (Network policy hardening lands in week 1 * day 2 alongside the auto-push job.) */ function renderDevCompose(projectSlug: string): string { return `services: vibn-dev: image: ${VIBN_DEV_IMAGE} restart: unless-stopped working_dir: /workspace volumes: - workspace:/workspace - cache:/home/vibn/.cache environment: - VIBN_PROJECT_SLUG=${projectSlug} - VIBN_DEV_CONTAINER=1 deploy: resources: limits: cpus: '${DEFAULT_CPU_LIMIT}' memory: ${DEFAULT_MEM_LIMIT} volumes: workspace: cache: `; } // ── Provisioning ───────────────────────────────────────────────────── export interface EnsureDevContainerOpts { projectId: string; projectSlug: string; projectName?: string; workspace: VibnWorkspace; /** Skip the initial start (provision-only). Default: start it. */ noStart?: boolean; } export interface EnsureDevContainerResult { serviceUuid: string; state: DevContainerRow['state']; created: boolean; } /** * Idempotently ensure a vibn-dev service exists for the given Vibn project. * * - Already provisioned → returns the row, optionally resumes if suspended. * - Not provisioned → ensures the per-project Coolify Project exists, * creates the docker-compose service, links the * resource to the Vibn project, persists the row. * * Safe to call on every chat turn — first call is ~10s, subsequent * calls are a single SELECT. */ export async function ensureDevContainer( opts: EnsureDevContainerOpts, ): Promise { await ensureDevContainersTable(); const existing = await getDevContainerRow(opts.projectId); if (existing) { if (existing.state === 'suspended' && !opts.noStart) { await resumeDevContainer(opts.projectId); return { serviceUuid: existing.service_uuid, state: 'running', created: false }; } return { serviceUuid: existing.service_uuid, state: existing.state, created: false }; } // Need a Coolify project to land the service in. let coolifyProjectUuid = await getProjectCoolifyUuid(opts.projectId, opts.workspace); if (!coolifyProjectUuid) { coolifyProjectUuid = await ensureProjectCoolifyProject( opts.projectId, opts.workspace, { projectSlug: opts.projectSlug, projectName: opts.projectName }, ); } if (!coolifyProjectUuid) { throw new Error( `Could not provision Coolify project for ${opts.projectId}; dev container creation aborted.`, ); } const created = await createDockerComposeApp({ projectUuid: coolifyProjectUuid, name: `vibn-dev-${opts.projectSlug}`, description: `AI dev container for project ${opts.projectName ?? opts.projectSlug}`, composeRaw: renderDevCompose(opts.projectSlug), instantDeploy: !opts.noStart, }); await query( `INSERT INTO fs_project_dev_containers (project_id, workspace, service_uuid, image, state) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (project_id) DO UPDATE SET service_uuid = EXCLUDED.service_uuid, image = EXCLUDED.image, state = EXCLUDED.state`, [ opts.projectId, opts.workspace.slug, created.uuid, VIBN_DEV_IMAGE, opts.noStart ? 'suspended' : 'provisioning', ], ); // Bookkeeping link so apps_list / projects_get see the dev container // under the right Vibn project. try { await linkResourceToProject(opts.projectId, opts.workspace.slug, created.uuid, 'service'); } catch { // best-effort } return { serviceUuid: created.uuid, state: 'provisioning', created: true }; } // ── Lifecycle ──────────────────────────────────────────────────────── export async function suspendDevContainer(projectId: string): Promise { const row = await getDevContainerRow(projectId); if (!row) return; if (row.state === 'suspended') return; await stopService(row.service_uuid); await query( `UPDATE fs_project_dev_containers SET state = 'suspended', suspended_at = now() WHERE project_id = $1`, [projectId], ); } export async function resumeDevContainer(projectId: string): Promise { const row = await getDevContainerRow(projectId); if (!row) throw new Error(`No dev container provisioned for ${projectId}`); if (row.state === 'running') return; await startService(row.service_uuid); await query( `UPDATE fs_project_dev_containers SET state = 'running', suspended_at = NULL, last_active_at = now() WHERE project_id = $1`, [projectId], ); } async function touchActivity(projectId: string): Promise { await query( `UPDATE fs_project_dev_containers SET last_active_at = now() WHERE project_id = $1`, [projectId], ); } // ── Exec primitive ─────────────────────────────────────────────────── export interface DevContainerExecOpts { projectId: string; command: string; cwd?: string; // defaults to /workspace timeoutMs?: number; maxBytes?: number; /** Override the user (default: vibn). Use 'root' only when needed. */ user?: string; /** Extra env vars (k=v lines prepended via `env` builtin). */ env?: Record; } /** * Run a command inside the project's vibn-dev service. * Resumes the container if suspended, then docker-exec's via the * existing SSH primitive. Stdout/stderr/exit-code returned synchronously. * * The caller is responsible for verifying the projectId belongs to the * workspace BEFORE calling this. We re-verify the container UUID via * the exec primitive's own resolution (it queries `docker ps --filter * name={uuid}`), so a mismatched projectId can't reach foreign containers. */ export async function execInDevContainer( opts: DevContainerExecOpts, ): Promise { if (!isCoolifySshConfigured()) { throw new Error( 'shell.exec requires SSH access to the Coolify host; configure COOLIFY_SSH_* envs.', ); } const row = await getDevContainerRow(opts.projectId); if (!row) { throw new Error( `No dev container for project ${opts.projectId}. Call ensureDevContainer() first.`, ); } if (row.state === 'suspended') { await resumeDevContainer(opts.projectId); } const cwd = opts.cwd && opts.cwd.trim() ? opts.cwd.trim() : '/workspace'; const envPrefix = opts.env ? Object.entries(opts.env) .map(([k, v]) => `${shellEscape(k)}=${shellEscape(v)}`) .join(' ') : ''; const wrapped = envPrefix ? `cd ${shellEscape(cwd)} && env ${envPrefix} ${opts.command}` : `cd ${shellEscape(cwd)} && ${opts.command}`; const result = await execInCoolifyApp({ appUuid: row.service_uuid, service: 'vibn-dev', command: wrapped, user: opts.user ?? 'vibn', timeoutMs: opts.timeoutMs, maxBytes: opts.maxBytes, }); await touchActivity(opts.projectId); return result; } function shellEscape(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } // ── Health ─────────────────────────────────────────────────────────── /** * Quick liveness check used by chat startup to decide whether to show * a "spinning up your environment…" banner. */ export async function getDevContainerStatus(projectId: string): Promise<{ exists: boolean; state: DevContainerRow['state'] | 'absent'; serviceUuid: string | null; }> { const row = await getDevContainerRow(projectId); if (!row) return { exists: false, state: 'absent', serviceUuid: null }; // Optional: poke Coolify for fresh state. Skipped for now to keep this // hot path cheap; consumers that care can call getService(uuid) directly. return { exists: true, state: row.state, serviceUuid: row.service_uuid }; } // Re-export getService so route handlers can pull live Coolify status // without taking a separate dependency on lib/coolify. export { getService };