feat(path-b): persistent dev containers + shell.exec + fs.* tools
Kicks off Path B (AI_PATH_B_EXECUTION_PLAN.md): each Vibn project gets
its own vibn-dev Coolify service that the AI drives directly via shell
and filesystem tools. Sub-second iteration vs the 5-min Gitea redeploy
loop.
What's in this commit (week 1, slice 1):
- vibn-dev Dockerfile: small Ubuntu base (~500 MB target). git, ripgrep,
python3, mise. Language toolchains lazy-install on first use.
- lib/dev-container.ts: ensureDevContainer / suspend / resume /
execInDevContainer. Backed by a new fs_project_dev_containers table.
- lib/feature-flags.ts + /api/admin/path-b/{disable,enable}: kill switch.
Bearer NEXTAUTH_SECRET flips path_b_disabled, propagates in ~10s.
- New MCP tools wired into /api/mcp: devcontainer.{ensure,status,suspend},
shell.exec, fs.{read,write,edit,list,delete,glob,grep}. All enforce
workspace isolation via fs_projects ownership check.
- vibn-tools.ts: 11 new Gemini tool defs (smoke test passes, 63 total).
- chat system prompt: shell-first guidance; gitea_file_* marked
deprecated for iterative work (still available, removed week 3).
Safety nets baked in:
- pathBGuard() returns 503 from every Path B tool when the kill switch
flips
- fs.* paths locked to /workspace
- ensureResourceInWorkspaceProjects via fs_project_dev_containers PK
- per-project resource limits (1 vCPU, 1 GiB RAM) on the compose spec
Still pending (queued):
- dev_server.* (preview URLs through Traefik)
- ship tool (push to Gitea + trigger prod deploy)
- auto-push autosave to vibn-autosave/main every 5 min
- idle-suspend cron after 30 min inactivity
- HMR-through-Traefik spike
- eval harness
Made-with: Cursor
This commit is contained in:
356
lib/dev-container.ts
Normal file
356
lib/dev-container.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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<DevContainerRow | null> {
|
||||
await ensureDevContainersTable();
|
||||
return queryOne<DevContainerRow>(
|
||||
`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<EnsureDevContainerResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ExecInAppResult> {
|
||||
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 };
|
||||
Reference in New Issue
Block a user