feat(devcontainer): auto-clone Gitea repo + auto-commit on each AI turn

The smoke test caught the biggest beta-blocker yet: everything the AI
writes inside the dev container was invisible in the UI because the
Product/Hosting/Infrastructure tabs all read from Gitea + Coolify, not
from the dev container's volume. Plan tab worked; nothing else did.

Two-part fix:

1. lib/dev-container-git.ts — new module with two helpers:
   - ensureProjectRepoCloned(): clones the project's Gitea repo into
     /workspace/<slug>/ using the AI's gitea token, embedding the auth
     into the remote URL so subsequent pushes work without prompts.
     Idempotent: tri-state probe handles 'git' (real repo, no-op),
     'dir' (path exists from pre-fix AI work, init in place), and
     'absent' (full clone). Has an empty-repo fallback for fresh Gitea
     repos where 'git clone' warns and produces nothing checked out.
   - commitAndPushIfDirty(): stages all changes under /workspace/<slug>,
     commits with a one-line message + pushes to origin. Bails fast
     with reason='clean' when there's nothing to commit. Never throws.

2. app/api/chat/route.ts wiring:
   - Pre-loop: fire-and-forget ensureProjectRepoCloned so the repo is
     on disk before the AI's first filesystem-mutating tool call.
   - Post-loop: fire-and-forget commitAndPushIfDirty after the assistant
     message is persisted; commit message is the assistant's first
     sentence (≤180 chars) or 'AI checkpoint' fallback.
   - System prompt now tells the AI: project repo is at /workspace/<slug>,
     write everything you want in the UI under that path, and don't
     manually commit (harness handles it).

Cred plumbing: GITEA_API_URL/GITEA_USERNAME/GITEA_API_TOKEN are read
from process.env in the harness; the dev container never sees the
token outside of the embedded URL. Same blast radius as shell.exec.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-01 14:02:16 -07:00
parent 8c5fbad782
commit 836733536e
2 changed files with 408 additions and 1 deletions

View File

@@ -21,6 +21,10 @@ import { callGeminiChat } from '@/lib/ai/gemini-chat';
import { VIBN_TOOL_DEFINITIONS, executeMcpTool } from '@/lib/ai/vibn-tools';
import { detectKnownError, formatRecoveryMessage } from '@/lib/ai/error-recovery';
import { listRecentSentryIssues } from '@/lib/integrations/sentry';
import {
ensureProjectRepoCloned,
commitAndPushIfDirty,
} from '@/lib/dev-container-git';
import type { ChatMessage, ToolCall } from '@/lib/ai/gemini-chat';
// Bumped from 6 to 12 because Path B chains (devcontainer.ensure →
@@ -113,7 +117,11 @@ The user is currently looking at:
- Vision: ${activeProject.productVision ? activeProject.productVision.slice(0, 240) : '(not yet captured)'}
${activeProject.kickoff ? `- Created via: ${activeProject.kickoff.mode} (${JSON.stringify(activeProject.kickoff.sourceData).slice(0, 200)})` : ''}
${decisionsBlock}${tasksBlock}${ideasBlock}
When you call tools that take a \`projectId\`, USE this id (\`${activeProject.id}\`) without asking. When the user says "this project" / "the app" / "deploy it" — they mean THIS project. Switch to a different project only if the user names one explicitly.\n`
When you call tools that take a \`projectId\`, USE this id (\`${activeProject.id}\`) without asking. When the user says "this project" / "the app" / "deploy it" — they mean THIS project. Switch to a different project only if the user names one explicitly.
**Project repo is auto-cloned at \`/workspace/${activeProject.slug ?? '<slug>'}/\` inside the dev container.** That path is the project's Gitea repo. ALL code, docs, configs, and other artifacts you intend the user to see in the Product tab MUST live under that path. Anything you write outside it (e.g. \`/workspace/scratch\`, \`/workspace/some-cloned-other-repo\`) is treated as scratch and is invisible in the UI.
After every assistant turn, the harness automatically runs \`git add -A && git commit && git push\` against \`/workspace/${activeProject.slug ?? '<slug>'}/\`. You do NOT need to commit manually unless the user asks for a specific commit message or you want to checkpoint mid-turn. Don't apologize for "forgetting to commit" — the harness handles it.\n`
: '';
return `You are Vibn AI — the technical co-founder of every Vibn user. You turn ideas into shipped software. Treat their projects like they're your own.
@@ -343,6 +351,33 @@ export async function POST(request: Request) {
}
}
// Make sure the project's Gitea repo is cloned into the dev
// container at /workspace/<slug>/ before the AI runs any
// filesystem-mutating tools. Without this, anything the AI writes
// gets stranded in a scratch volume and is invisible in the
// Product/Hosting/Infrastructure tabs (those tabs read from Gitea
// and Coolify, not from the dev container's volume).
//
// We fire-and-forget on existing projects (the clone is a fast
// no-op when present) and only await on projects that don't have
// a dev container yet — there the AI is about to call
// ensureDevContainer + shell.exec, and we need the repo on disk
// before that exec lands so the AI's writes go into the project
// repo instead of an empty /workspace.
if (
activeProject?.id &&
activeProject?.slug &&
typeof activeProject?.giteaCloneUrl === 'string'
) {
void ensureProjectRepoCloned({
projectId: activeProject.id,
projectSlug: activeProject.slug,
giteaCloneUrl: activeProject.giteaCloneUrl,
}).catch((err) => {
console.warn('[chat] pre-loop ensureProjectRepoCloned failed (non-fatal)', err);
});
}
// Base URL for internal MCP calls
const host = request.headers.get('host') || 'vibnai.com';
const proto = host.startsWith('localhost') ? 'http' : 'https';
@@ -594,6 +629,59 @@ export async function POST(request: Request) {
[thread_id, email, JSON.stringify(finalMsg)],
);
// Fire-and-forget: commit any AI-made filesystem changes to
// the project's Gitea repo and push to origin. This is what
// makes the AI's work appear in the Product tab's Codebases
// view — without it, every fs.write / shell.exec mutation
// stays trapped in the dev container's volume.
//
// Run AFTER the assistant message is persisted because the
// user already saw the reply; a slow push shouldn't block
// the chat. If there's nothing to commit, the helper short-
// circuits with reason='clean' in <1s.
if (
activeProject?.id &&
activeProject?.slug &&
typeof activeProject?.giteaCloneUrl === 'string'
) {
(async () => {
try {
// Best-effort clone in case the pre-loop kick-off was
// racing with container provisioning and never landed.
await ensureProjectRepoCloned({
projectId: activeProject.id,
projectSlug: activeProject.slug,
giteaCloneUrl: activeProject.giteaCloneUrl,
}).catch(() => null);
// Commit message: prefer the assistant's own first
// sentence (one line, ≤200 chars). Falls back to a
// generic checkpoint when the assistant only made
// tool calls without prose.
const firstSentence = (assistantText || '')
.split(/(?<=[.!?])\s+/)[0]
?.trim()
?.slice(0, 180);
const message = firstSentence || 'AI checkpoint';
const result = await commitAndPushIfDirty({
projectId: activeProject.id,
projectSlug: activeProject.slug,
message,
});
if (result.committed) {
console.log(
`[chat] auto-commit project=${activeProject.slug} sha=${result.sha} pushed=${result.pushed}`,
);
} else if (result.reason && result.reason !== 'clean' && result.reason !== 'no_repo') {
console.warn(
`[chat] auto-commit failed project=${activeProject.slug} reason=${result.reason}`,
);
}
} catch (err) {
console.warn('[chat] auto-commit fire-and-forget failed', err);
}
})();
}
// Fire-and-forget: ask Gemini for a 1-2 sentence "what got done"
// summary of the conversation so far, persist it on the thread,
// and use the first user message (truncated) as a stable title