Files
vibn-frontend/app/api/admin/path-b/autosave/route.ts
Mark Henderson 41d4d3748f 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
2026-04-28 13:02:35 -07:00

99 lines
3.6 KiB
TypeScript

/**
* 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 },
);
}