diff --git a/app/api/admin/path-b/autosave/route.ts b/app/api/admin/path-b/autosave/route.ts new file mode 100644 index 00000000..f2bb4238 --- /dev/null +++ b/app/api/admin/path-b/autosave/route.ts @@ -0,0 +1,98 @@ +/** + * Workspace autosave trigger. + * + * POST /api/admin/path-b/autosave + * Headers: Authorization: Bearer + * Body: { projectId: string, projectSlug: string } + * + * Pushes /workspace inside the project's dev container to a + * `vibn-autosave/main` branch in Gitea. Throttled to once per 5 min + * per project so we don't hammer Gitea on every chat turn. + * + * Two intended callers: + * 1. Chat post-turn hook (best-effort fire-and-forget). + * 2. Cron sweep every 5 min as a backstop. + * + * The autosave branch is force-pushed; never collides with `main`. + * Treat this as a recovery point, not history — the user's real + * commits go through the `ship` tool. + */ + +import { NextResponse } from 'next/server'; +import { autosaveWorkspace } from '@/lib/dev-container'; +import { query } from '@/lib/db-postgres'; +import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; + +export async function POST(request: Request) { + const auth = request.headers.get('authorization') ?? ''; + const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''; + if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + let body: { projectId?: string; projectSlug?: string; sweep?: boolean }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + // Single-project mode. + if (body.projectId) { + const projectId = String(body.projectId); + const row = await query<{ slug: string; data: any; workspace: string }>( + `SELECT slug, data, workspace FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId], + ); + if (row.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + const ws = await getOrCreateProvisionedWorkspace({ + userId: row[0].data?.userId ?? '', + email: row[0].data?.ownerEmail ?? '', + displayName: row[0].workspace, + }).catch(() => null); + if (!ws) { + return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 }); + } + const result = await autosaveWorkspace({ + projectId, + projectSlug: row[0].slug, + workspace: ws, + }); + return NextResponse.json({ result }); + } + + // Sweep mode: autosave every project with a running dev container. + if (body.sweep) { + const rows = await query<{ project_id: string; workspace: string }>( + `SELECT project_id, workspace FROM fs_project_dev_containers WHERE state = 'running'`, + [], + ); + const out: Array<{ projectId: string; ran: boolean; reason: string }> = []; + for (const r of rows) { + const proj = await query<{ slug: string; data: any }>( + `SELECT slug, data FROM fs_projects WHERE id = $1 LIMIT 1`, + [r.project_id], + ); + if (proj.length === 0) continue; + const ws = await getOrCreateProvisionedWorkspace({ + userId: proj[0].data?.userId ?? '', + email: proj[0].data?.ownerEmail ?? '', + displayName: r.workspace, + }).catch(() => null); + if (!ws) continue; + const res = await autosaveWorkspace({ + projectId: r.project_id, + projectSlug: proj[0].slug, + workspace: ws, + }).catch(err => ({ ran: false, reason: err instanceof Error ? err.message : String(err) })); + out.push({ projectId: r.project_id, ran: res.ran, reason: res.reason }); + } + return NextResponse.json({ result: { swept: out.length, out } }); + } + + return NextResponse.json( + { error: 'Provide either { projectId } or { sweep: true }' }, + { status: 400 }, + ); +} diff --git a/app/api/admin/path-b/idle-sweep/route.ts b/app/api/admin/path-b/idle-sweep/route.ts new file mode 100644 index 00000000..45dc6bf5 --- /dev/null +++ b/app/api/admin/path-b/idle-sweep/route.ts @@ -0,0 +1,33 @@ +/** + * Idle-suspend sweep for Path B dev containers. + * + * POST /api/admin/path-b/idle-sweep[?minutes=30] + * Headers: Authorization: Bearer + * + * Suspends every running dev container whose `last_active_at` is older + * than `minutes` (default 30). Idempotent — re-runs harmlessly. + * + * Wire this to a cron (every 5 min) once the frontend is stable: + * */5 * * * * curl -fsS -X POST -H "Authorization: Bearer $SECRET" \ + * https://vibnai.com/api/admin/path-b/idle-sweep + * + * Saves money (suspended containers don't bill compute) without + * destroying state — the workspace volume + cache volume persist, and + * the next shell.exec call resumes the service in <5s. + */ + +import { NextResponse } from 'next/server'; +import { suspendIdleContainers } from '@/lib/dev-container'; + +export async function POST(request: Request) { + const auth = request.headers.get('authorization') ?? ''; + const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''; + if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const url = new URL(request.url); + const minStr = url.searchParams.get('minutes'); + const minutes = minStr && Number.isFinite(Number(minStr)) ? Math.max(5, Number(minStr)) : 30; + const result = await suspendIdleContainers(minutes); + return NextResponse.json({ result, idleMinutes: minutes }); +} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 12b28963..f6722260 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -42,6 +42,11 @@ import { execInDevContainer, getDevContainerStatus, suspendDevContainer, + startDevServer, + stopDevServer, + listDevServers, + tailDevServerLog, + autosaveWorkspace, } from '@/lib/dev-container'; import { isPathBDisabled } from '@/lib/feature-flags'; import { @@ -118,7 +123,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; export async function GET() { return NextResponse.json({ name: 'vibn-mcp', - version: '2.5.0', + version: '2.6.0', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', @@ -191,6 +196,11 @@ export async function GET() { 'fs.delete', 'fs.glob', 'fs.grep', + 'dev_server.start', + 'dev_server.stop', + 'dev_server.list', + 'dev_server.logs', + 'ship', ], }, }, @@ -359,6 +369,17 @@ export async function POST(request: Request) { case 'fs.grep': return await toolFsGrep(principal, params); + case 'dev_server.start': + return await toolDevServerStart(principal, params); + case 'dev_server.stop': + return await toolDevServerStop(principal, params); + case 'dev_server.list': + return await toolDevServerList(principal, params); + case 'dev_server.logs': + return await toolDevServerLogs(principal, params); + case 'ship': + return await toolShip(principal, params); + default: return NextResponse.json( { error: `Unknown tool "${action}"` }, @@ -3255,3 +3276,228 @@ async function toolFsGrep(principal: Principal, params: Record) { result: { pattern, cwd, glob, matches: r.stdout, truncated: r.truncated }, }); } + +// ── dev_server.* ───────────────────────────────────────────────────── + +async function toolDevServerStart(principal: Principal, params: Record) { + const guard = await pathBGuard(); + if (guard) return guard; + const project = await resolveProjectOr404(principal, params); + if (project instanceof NextResponse) return project; + const command = String(params.command ?? '').trim(); + const port = Number(params.port); + if (!command || !Number.isFinite(port) || port < 1 || port > 65535) { + return NextResponse.json( + { error: 'Params "command" (string) and "port" (1-65535) are required' }, + { status: 400 }, + ); + } + await ensureDevContainer({ + projectId: project.id, + projectSlug: project.slug, + projectName: project.name, + workspace: principal.workspace, + }); + try { + const row = await startDevServer({ + projectId: project.id, + projectSlug: project.slug, + command, + port, + name: typeof params.name === 'string' ? params.name : undefined, + workspace: principal.workspace, + }); + return NextResponse.json({ + result: { + id: row.id, + name: row.name, + port: row.port, + pid: row.pid, + previewUrl: row.preview_url, + state: row.state, + note: + 'Preview URL is reserved but Traefik wildcard wiring is staged for week 2 (see /vibn-dev/PREVIEWS.md). ' + + 'In the meantime, the server is reachable from inside the container at http://localhost:' + + row.port + + ' — use shell.exec curl to verify it boots.', + }, + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} + +async function toolDevServerStop(principal: Principal, params: Record) { + const project = await resolveProjectOr404(principal, params); + if (project instanceof NextResponse) return project; + const id = String(params.id ?? '').trim(); + if (!id) return NextResponse.json({ error: 'Param "id" is required' }, { status: 400 }); + try { + await stopDevServer(project.id, id); + return NextResponse.json({ result: { ok: true, id, state: 'stopped' } }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } +} + +async function toolDevServerList(principal: Principal, params: Record) { + const project = await resolveProjectOr404(principal, params); + if (project instanceof NextResponse) return project; + const rows = await listDevServers(project.id); + return NextResponse.json({ + result: rows.map(r => ({ + id: r.id, + name: r.name, + command: r.command, + port: r.port, + pid: r.pid, + previewUrl: r.preview_url, + state: r.state, + startedAt: r.started_at, + })), + }); +} + +async function toolDevServerLogs(principal: Principal, params: Record) { + const guard = await pathBGuard(); + if (guard) return guard; + const project = await resolveProjectOr404(principal, params); + if (project instanceof NextResponse) return project; + const id = String(params.id ?? '').trim(); + if (!id) return NextResponse.json({ error: 'Param "id" is required' }, { status: 400 }); + const lines = Number.isFinite(Number(params.lines)) ? Number(params.lines) : 200; + const log = await tailDevServerLog(project.id, id, lines); + return NextResponse.json({ result: { id, log } }); +} + +// ── ship ───────────────────────────────────────────────────────────── +// +// "Graduate to production." Pushes /workspace to the project's main +// Gitea branch and triggers a Coolify production deployment if the +// project is wired to one (apps_create-style). + +async function toolShip(principal: Principal, params: Record) { + const guard = await pathBGuard(); + if (guard) return guard; + const project = await resolveProjectOr404(principal, params); + if (project instanceof NextResponse) return project; + + const message = + typeof params.commitMsg === 'string' && params.commitMsg.trim() + ? params.commitMsg.trim() + : `ship: ${new Date().toISOString()}`; + const repo = (typeof params.repo === 'string' && params.repo.trim()) || project.slug; + const branch = + typeof params.branch === 'string' && params.branch.trim() ? params.branch.trim() : 'main'; + + // Pre-req: dev container exists. (No silent ensure here — `ship` is a + // significant action; if there's no container there's nothing to ship.) + const status = await getDevContainerStatus(project.id); + if (!status.exists) { + return NextResponse.json( + { + error: + 'No dev container for this project — nothing to ship. Use shell.exec to scaffold first.', + }, + { status: 400 }, + ); + } + + // git add/commit/push. We init+remote-add if the repo has no .git + // yet, using the workspace bot's PAT. + const creds = getWorkspaceBotCredentials(principal.workspace); + if (!creds) { + return NextResponse.json( + { error: 'Workspace has no Gitea bot yet; cannot push.' }, + { status: 503 }, + ); + } + const apiHost = new URL(GITEA_API_URL).host; + const remote = `https://${creds.username}:${creds.token}@${apiHost}/${creds.org}/${repo}.git`; + + const cmd = `set -e +cd /workspace +if [ ! -d .git ]; then + git init -q + git checkout -b ${shq(branch)} +fi +git config user.email vibn-bot@vibnai.com +git config user.name 'Vibn Bot' +git remote remove origin 2>/dev/null || true +git remote add origin ${shq(remote)} +git add -A +if git diff --cached --quiet HEAD 2>/dev/null; then + echo '(no changes to commit)' +else + git commit -q -m ${shq(message)} +fi +git push -u origin HEAD:${shq(branch)} 2>&1 | tail -5`; + + let pushOutput = ''; + try { + const r = await execInDevContainer({ + projectId: project.id, + command: cmd, + timeoutMs: 60_000, + }); + pushOutput = (r.stdout + r.stderr).trim(); + if (r.code !== 0) { + return NextResponse.json( + { error: `git push failed: ${pushOutput}` }, + { status: 500 }, + ); + } + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ); + } + + // Trigger Coolify deploy if the project is linked to one. + let deploymentUuid: string | null = null; + const linkedAppUuid = + typeof project.data?.coolifyAppUuid === 'string' && project.data.coolifyAppUuid.trim() + ? project.data.coolifyAppUuid.trim() + : null; + if (linkedAppUuid && Boolean(params.deploy ?? true)) { + try { + const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace); + await getApplicationInWorkspace(linkedAppUuid, ownedUuids); + const dep = await deployApplication(linkedAppUuid, { force: false }); + deploymentUuid = dep.deployment_uuid; + } catch (err) { + return NextResponse.json({ + result: { + pushed: true, + pushOutput, + deploymentTriggered: false, + deployError: err instanceof Error ? err.message : String(err), + }, + }); + } + } + + return NextResponse.json({ + result: { + repo, + branch, + message, + pushed: true, + pushOutput, + deploymentTriggered: Boolean(deploymentUuid), + deploymentUuid, + hint: deploymentUuid + ? 'Deploy in progress; poll apps_deployments to track.' + : linkedAppUuid + ? 'Deploy was skipped (deploy=false).' + : 'No Coolify app linked to this project yet — call apps_create to wire one up before the next ship.', + }, + }); +} diff --git a/lib/ai/vibn-tools.ts b/lib/ai/vibn-tools.ts index 7d6cada2..27e77fa6 100644 --- a/lib/ai/vibn-tools.ts +++ b/lib/ai/vibn-tools.ts @@ -787,6 +787,83 @@ Auto-domain {name}.{workspace}.vibnai.com is assigned automatically.`, }, }, + // ── Path B: dev servers (preview URLs) ──────────────────────────────────── + + { + name: 'dev_server_start', + description: + 'Launch a long-running process inside the dev container (e.g. `npm run dev`, `python -m http.server`). ' + + 'Returns a preview URL the user can open in a browser. The process keeps running across shell.exec calls. ' + + 'IMPORTANT: bind your server to 0.0.0.0 — we set HOST=0.0.0.0 + PORT= automatically, but verify the framework respects them.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + command: { type: 'STRING', description: 'Shell command to run (e.g. "npm run dev").' }, + port: { type: 'NUMBER', description: 'TCP port the server will listen on (1-65535).' }, + name: { type: 'STRING', description: 'Optional friendly name for the server (used in the preview subdomain).' }, + }, + required: ['projectId', 'command', 'port'], + }, + }, + { + name: 'dev_server_stop', + description: 'Kill a previously-started dev server by id.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + id: { type: 'STRING', description: 'Dev server id from dev_server_start.' }, + }, + required: ['projectId', 'id'], + }, + }, + { + name: 'dev_server_list', + description: 'List active (non-stopped) dev servers for a project.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + }, + required: ['projectId'], + }, + }, + { + name: 'dev_server_logs', + description: 'Tail recent stdout+stderr from a dev server (default last 200 lines).', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + id: { type: 'STRING', description: 'Dev server id.' }, + lines: { type: 'NUMBER', description: 'Number of trailing lines (1-2000, default 200).' }, + }, + required: ['projectId', 'id'], + }, + }, + + // ── Path B: ship to production ───────────────────────────────────────────── + + { + name: 'ship', + description: + 'Graduate the project from dev container to production. Commits everything in /workspace, pushes to the project Gitea repo, ' + + 'and triggers a Coolify production deploy if the project is linked to one. Use when the user says "ship it", "deploy this", ' + + 'or after a stable working state has been verified via dev_server_*. Pass `commitMsg` for a meaningful commit; otherwise an ISO-timestamp message is used.', + parameters: { + type: 'OBJECT', + properties: { + projectId: { type: 'STRING', description: 'The Vibn project ID.' }, + commitMsg: { type: 'STRING', description: 'Commit message (default: "ship: ").' }, + repo: { type: 'STRING', description: 'Repo name in workspace org (defaults to project slug).' }, + branch: { type: 'STRING', description: 'Branch to push to (default "main").' }, + deploy: { type: 'BOOLEAN', description: 'Trigger Coolify deploy after push (default true).' }, + }, + required: ['projectId'], + }, + }, + // ── Non-MCP: GitHub & web ───────────────────────────────────────────────── { diff --git a/lib/dev-container.ts b/lib/dev-container.ts index eb90061e..2d4a8818 100644 --- a/lib/dev-container.ts +++ b/lib/dev-container.ts @@ -114,9 +114,20 @@ export async function getDevContainerRow(projectId: string): Promise.log, and remember +// the PID + port in fs_dev_servers so subsequent calls can stop or +// list them. +// +// Preview URLs are exposed via Traefik's "host" router using the +// internal Coolify network (the dev container's primary bridge IP is +// reachable from Traefik). Full Traefik wildcard wiring lands in +// /vibn-dev/PREVIEWS.md and a separate Traefik config commit; this +// module just records the URL we WILL serve at, so the caller can +// hand it back to the chat. + +let devServersTableReady = false; +async function ensureDevServersTable(): Promise { + if (devServersTableReady) return; + await query( + `CREATE TABLE IF NOT EXISTS fs_dev_servers ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES fs_project_dev_containers(project_id) ON DELETE CASCADE, + workspace TEXT NOT NULL, + name TEXT NOT NULL, + command TEXT NOT NULL, + port INTEGER NOT NULL, + pid INTEGER, + preview_url TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'starting', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + stopped_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS fs_dev_servers_project_idx ON fs_dev_servers (project_id, state);`, + [], + ); + devServersTableReady = true; +} + +export interface DevServerRow { + id: string; + project_id: string; + workspace: string; + name: string; + command: string; + port: number; + pid: number | null; + preview_url: string; + state: 'starting' | 'running' | 'stopped' | 'failed'; + started_at: Date; + stopped_at: Date | null; +} + +const PREVIEW_DOMAIN_BASE = + process.env.VIBN_PREVIEW_DOMAIN_BASE ?? 'preview.vibnai.com'; + +function randomToken(bytes = 4): string { + const buf = Buffer.alloc(bytes); + for (let i = 0; i < bytes; i++) buf[i] = Math.floor(Math.random() * 256); + return buf.toString('hex'); +} + +function buildPreviewUrl(projectSlug: string, name: string): string { + // Random suffix per server so URLs aren't guessable. Subdomain is + // --. (kept under 63 chars for DNS). + const safe = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 20); + const sub = `${safe(name)}-${safe(projectSlug)}-${randomToken()}`; + return `https://${sub}.${PREVIEW_DOMAIN_BASE}`; +} + +export interface StartDevServerOpts { + projectId: string; + projectSlug: string; + command: string; + port: number; + name?: string; + workspace: VibnWorkspace; +} + +export async function startDevServer(opts: StartDevServerOpts): Promise { + await ensureDevServersTable(); + const id = `ds_${randomToken(6)}`; + const name = opts.name ?? `port-${opts.port}`; + const previewUrl = buildPreviewUrl(opts.projectSlug, name); + const logFile = `/var/log/vibn-dev/${id}.log`; + + // nohup the command, capture PID. We pin the listening interface to + // 0.0.0.0 by injecting HOST=0.0.0.0 (handles Vite/Next/Express); we + // also export PORT so frameworks that read it pick it up. + const launch = + `mkdir -p /var/log/vibn-dev && ` + + `cd /workspace && ` + + `nohup env HOST=0.0.0.0 PORT=${opts.port} VIBN_DEV_SERVER_ID=${id} ` + + `bash -lc ${shellEscape(opts.command)} > ${logFile} 2>&1 & ` + + `echo $!`; + + const result = await execInDevContainer({ + projectId: opts.projectId, + command: launch, + timeoutMs: 5_000, + }); + const pid = parseInt(result.stdout.trim(), 10); + + await query( + `INSERT INTO fs_dev_servers + (id, project_id, workspace, name, command, port, pid, preview_url, state) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + id, + opts.projectId, + opts.workspace.slug, + name, + opts.command, + opts.port, + Number.isFinite(pid) ? pid : null, + previewUrl, + 'starting', + ], + ); + + return { + id, + project_id: opts.projectId, + workspace: opts.workspace.slug, + name, + command: opts.command, + port: opts.port, + pid: Number.isFinite(pid) ? pid : null, + preview_url: previewUrl, + state: 'starting', + started_at: new Date(), + stopped_at: null, + }; +} + +export async function listDevServers(projectId: string): Promise { + await ensureDevServersTable(); + return query( + `SELECT * FROM fs_dev_servers WHERE project_id = $1 AND state != 'stopped' ORDER BY started_at DESC`, + [projectId], + ); +} + +export async function stopDevServer(projectId: string, id: string): Promise { + await ensureDevServersTable(); + const row = await queryOne( + `SELECT * FROM fs_dev_servers WHERE id = $1 AND project_id = $2 LIMIT 1`, + [id, projectId], + ); + if (!row) throw new Error(`Dev server ${id} not found`); + if (row.pid) { + try { + await execInDevContainer({ + projectId, + command: `kill ${row.pid} 2>/dev/null || true`, + timeoutMs: 3_000, + }); + } catch {} + } + await query( + `UPDATE fs_dev_servers SET state = 'stopped', stopped_at = now() WHERE id = $1`, + [id], + ); +} + +export async function tailDevServerLog( + projectId: string, + id: string, + lines = 200, +): Promise { + const r = await execInDevContainer({ + projectId, + command: `tail -n ${Math.max(1, Math.min(2000, lines))} /var/log/vibn-dev/${id}.log 2>/dev/null || echo '(no log yet)'`, + timeoutMs: 5_000, + }); + return r.stdout; +} + +// ── Auto-push autosave ─────────────────────────────────────────────── +// +// Treats Gitea as the canonical store; the container disk is ephemeral. +// On every chat turn (or every 5 min, whichever comes first) we push +// /workspace to a `vibn-autosave/main` branch in the project's repo. +// +// We don't try to be clever about what changed — just `git add -A && +// git commit --allow-empty -m "autosave $(date)" && git push`. If the +// repo doesn't exist yet (fresh project, no `git init` done), we skip +// silently — the AI is responsible for `git init`+ first push when it +// scaffolds. + +export interface AutosaveOpts { + projectId: string; + projectSlug: string; + workspace: VibnWorkspace; + /** Repo name in the workspace's Gitea org. Defaults to projectSlug. */ + repo?: string; + /** Min interval between autosaves (default 5 min). */ + minIntervalMs?: number; +} + +export async function autosaveWorkspace(opts: AutosaveOpts): Promise<{ + ran: boolean; + reason: string; + pushedAt?: Date; +}> { + const row = await getDevContainerRow(opts.projectId); + if (!row) return { ran: false, reason: 'no dev container' }; + if (row.state !== 'running') return { ran: false, reason: `state=${row.state}` }; + + // Throttle: don't autosave more than once per minIntervalMs. + const minInterval = opts.minIntervalMs ?? 5 * 60_000; + const last = await queryOne<{ pushed_at: Date }>( + `SELECT pushed_at FROM fs_dev_autosaves WHERE project_id = $1 ORDER BY pushed_at DESC LIMIT 1`, + [opts.projectId], + ).catch(() => null); + if (last && Date.now() - new Date(last.pushed_at).getTime() < minInterval) { + return { ran: false, reason: 'throttled' }; + } + + await ensureAutosavesTable(); + + // The git config + remote set-url is idempotent; PAT lives in the + // container's .netrc. Initial scaffold (init+add+commit+remote add) + // runs only when the repo doesn't have git yet. + const repo = opts.repo ?? opts.projectSlug; + const cmd = `set -e +cd /workspace +if [ ! -d .git ]; then + echo '(no .git, skipping autosave)' + exit 0 +fi +git config user.email vibn-bot@vibnai.com +git config user.name 'Vibn Autosave' +# Force push to the autosave branch — never collides with main. +git checkout -B vibn-autosave/main 2>&1 | tail -1 +git add -A +if git diff --cached --quiet; then + echo '(no changes)' +else + git commit -m "autosave $(date -Is)" --quiet +fi +git push -f origin vibn-autosave/main 2>&1 | tail -3`; + + try { + const r = await execInDevContainer({ + projectId: opts.projectId, + command: cmd, + timeoutMs: 30_000, + }); + await query( + `INSERT INTO fs_dev_autosaves (project_id, workspace, repo, output, code) + VALUES ($1, $2, $3, $4, $5)`, + [opts.projectId, opts.workspace.slug, repo, (r.stdout + r.stderr).slice(0, 4000), r.code], + ); + return { ran: true, reason: 'pushed', pushedAt: new Date() }; + } catch (err) { + return { ran: false, reason: err instanceof Error ? err.message : String(err) }; + } +} + +let autosavesTableReady = false; +async function ensureAutosavesTable(): Promise { + if (autosavesTableReady) return; + await query( + `CREATE TABLE IF NOT EXISTS fs_dev_autosaves ( + id BIGSERIAL PRIMARY KEY, + project_id TEXT NOT NULL, + workspace TEXT NOT NULL, + repo TEXT NOT NULL, + output TEXT, + code INTEGER, + pushed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS fs_dev_autosaves_project_idx ON fs_dev_autosaves (project_id, pushed_at DESC);`, + [], + ); + autosavesTableReady = true; +} + +// ── Idle suspend ───────────────────────────────────────────────────── + +export interface IdleSweepResult { + scanned: number; + suspended: Array<{ projectId: string; idleMin: number }>; + errors: Array<{ projectId: string; error: string }>; +} + +/** + * Suspend any running dev containers that haven't been touched in + * `idleMinutes` minutes. Intended for a once-per-5-min cron. Idempotent: + * re-running is a no-op for already-suspended containers. + */ +export async function suspendIdleContainers(idleMinutes = 30): Promise { + await ensureDevContainersTable(); + const cutoff = new Date(Date.now() - idleMinutes * 60_000); + const rows = await query( + `SELECT * FROM fs_project_dev_containers + WHERE state = 'running' AND last_active_at < $1`, + [cutoff], + ); + const result: IdleSweepResult = { scanned: rows.length, suspended: [], errors: [] }; + for (const r of rows) { + try { + await suspendDevContainer(r.project_id); + const idleMin = Math.floor((Date.now() - new Date(r.last_active_at).getTime()) / 60_000); + result.suspended.push({ projectId: r.project_id, idleMin }); + } catch (err) { + result.errors.push({ + projectId: r.project_id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return result; +}