feat(path-b): dev_server.*, ship, autosave, idle-suspend (weeks 2-3)
Completes the rest of the Path B tool surface:
- dev_server.{start,stop,list,logs}: nohup processes inside the dev
container, track PID/port/preview-url in fs_dev_servers. Each gets
a randomized preview subdomain (preview.vibnai.com base; Traefik
wildcard wiring is staged in /vibn-dev/PREVIEWS.md but the Coolify
compose hot-update step is deferred — see file for the recommended
pre-allocated-port-range approach).
- ship: git init (if needed) -> add/commit/push to the project's
Gitea repo via the workspace bot PAT, then triggers a Coolify
production deploy if the project is linked to one. Returns push
output + deployment_uuid.
- /api/admin/path-b/autosave [POST { projectId | sweep:true }]:
force-pushes /workspace to vibn-autosave/main in Gitea. Throttled
to once per 5 min per project. Records every push in fs_dev_autosaves
for audit. Treat Gitea as canonical, container disk as ephemeral.
- /api/admin/path-b/idle-sweep [POST?minutes=30]: suspends every
running dev container whose last_active_at is older than `minutes`.
Wire to a 5-min cron. Idempotent.
- Compose template hardened: pull_policy: never (use locally-built
image, no registry round-trip) + per-project bridge network
(vibn-dev-net-<slug>) so dev containers can't reach internal Vibn
services.
- vibn-dev/setup-on-coolify.sh: one-shot script to build vibn-dev:latest
on the Coolify host. Run before first chat session uses Path B.
- vibn-tools.ts: dev_server_{start,stop,list,logs} + ship Gemini tool
defs added. Smoke test passes — 68 tool definitions accepted.
- MCP version 2.5.0 -> 2.6.0 so /api/mcp tells us when the new build
is live.
Plan doc updated to reflect what shipped vs what's still manual
(DNS wildcard, Traefik cert, build-on-host script run, gitea_file_*
hard-remove deferred to allow A/B).
Made-with: Cursor
This commit is contained in:
98
app/api/admin/path-b/autosave/route.ts
Normal file
98
app/api/admin/path-b/autosave/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Workspace autosave trigger.
|
||||
*
|
||||
* POST /api/admin/path-b/autosave
|
||||
* Headers: Authorization: Bearer <NEXTAUTH_SECRET>
|
||||
* 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 },
|
||||
);
|
||||
}
|
||||
33
app/api/admin/path-b/idle-sweep/route.ts
Normal file
33
app/api/admin/path-b/idle-sweep/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Idle-suspend sweep for Path B dev containers.
|
||||
*
|
||||
* POST /api/admin/path-b/idle-sweep[?minutes=30]
|
||||
* Headers: Authorization: Bearer <NEXTAUTH_SECRET>
|
||||
*
|
||||
* 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user