diff --git a/src/agent-session-runner.ts b/src/agent-session-runner.ts index 964cdb9..9b0061b 100644 --- a/src/agent-session-runner.ts +++ b/src/agent-session-runner.ts @@ -12,6 +12,7 @@ * - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid] */ +import { execSync } from 'child_process'; import { createLLM, toOAITools, LLMMessage } from './llm'; import { AgentConfig } from './agents'; import { executeTool, ToolContext } from './tools'; @@ -28,9 +29,16 @@ export interface OutputLine { export interface SessionRunOptions { sessionId: string; projectId: string; - vibnApiUrl: string; // e.g. https://vibnai.com - appPath: string; // relative path within repo, e.g. "apps/admin" + vibnApiUrl: string; // e.g. https://vibnai.com + appPath: string; // relative path within repo, e.g. "apps/admin" + repoRoot?: string; // absolute path to the git repo root (for auto-commit) isStopped: () => boolean; + // Auto-approve: commit + push + deploy without user confirmation + autoApprove?: boolean; + giteaRepo?: string; // e.g. "mark/sportsy" + coolifyAppUuid?: string; + coolifyApiUrl?: string; + coolifyApiToken?: string; } // ── VIBN DB bridge ──────────────────────────────────────────────────────────── @@ -86,6 +94,76 @@ function extractChangedFile( return { path: displayPath, status: fileStatus }; } +// ── Auto-commit helper ──────────────────────────────────────────────────────── + +async function autoCommitAndDeploy( + opts: SessionRunOptions, + task: string, + emit: (line: OutputLine) => Promise +): Promise { + const repoRoot = opts.repoRoot; + if (!repoRoot || !opts.giteaRepo) { + await emit({ ts: now(), type: 'info', text: 'Auto-approve skipped — no repo root available.' }); + return; + } + + const gitOpts = { cwd: repoRoot, stdio: 'pipe' as const }; + const giteaApiUrl = process.env.GITEA_API_URL || ''; + const giteaUsername = process.env.GITEA_USERNAME || 'agent'; + const giteaToken = process.env.GITEA_API_TOKEN || ''; + + try { + try { + execSync('git config user.email "agent@vibnai.com"', gitOpts); + execSync('git config user.name "VIBN Agent"', gitOpts); + } catch { /* already set */ } + + execSync('git add -A', gitOpts); + + const status = execSync('git status --porcelain', gitOpts).toString().trim(); + if (!status) { + await emit({ ts: now(), type: 'info', text: '✓ No file changes to commit.' }); + await patchSession(opts, { status: 'approved' }); + return; + } + + const commitMsg = `agent: ${task.slice(0, 72)}`; + execSync(`git commit -m ${JSON.stringify(commitMsg)}`, gitOpts); + await emit({ ts: now(), type: 'info', text: `✓ Committed: "${commitMsg}"` }); + + const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git` + .replace('https://', `https://${giteaUsername}:${giteaToken}@`); + execSync(`git push "${authedUrl}" HEAD:main`, gitOpts); + await emit({ ts: now(), type: 'info', text: '✓ Pushed to Gitea.' }); + + // Optional Coolify deploy + let deployed = false; + if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) { + try { + const deployRes = await fetch( + `${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`, + { method: 'POST', headers: { Authorization: `Bearer ${opts.coolifyApiToken}` } } + ); + deployed = deployRes.ok; + if (deployed) await emit({ ts: now(), type: 'info', text: '✓ Deployment triggered.' }); + } catch { /* best-effort */ } + } + + await patchSession(opts, { + status: 'approved', + outputLine: { + ts: now(), type: 'done', + text: `✓ Auto-committed & ${deployed ? 'deployed' : 'pushed'}. No approval needed.`, + }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await emit({ ts: now(), type: 'error', text: `Auto-commit failed: ${msg}` }); + // Fall back to done so user can manually approve + await patchSession(opts, { status: 'done' }); + } +} + // ── Main streaming execution loop ───────────────────────────────────────────── export async function runSessionAgent( @@ -112,7 +190,7 @@ export async function runSessionAgent( You are working inside the monorepo directory: ${opts.appPath} All file paths you use should be relative to this directory unless otherwise specified. When running commands, always cd into ${opts.appPath} first unless already there. -When you are done, do NOT commit directly — leave the changes uncommitted so the user can review and approve them. +Do NOT run git commit or git push — the platform handles committing after you finish. `; const history: LLMMessage[] = [ @@ -221,10 +299,15 @@ When you are done, do NOT commit directly — leave the changes uncommitted so t } await emit({ ts: now(), type: 'done', text: finalText }); - await patchSession(opts, { - status: 'done', - outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' } - }); + + if (opts.autoApprove) { + await autoCommitAndDeploy(opts, task, emit); + } else { + await patchSession(opts, { + status: 'done', + outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' }, + }); + } } // ── Step label helpers ──────────────────────────────────────────────────────── diff --git a/src/server.ts b/src/server.ts index 312210b..6c738bb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -359,14 +359,19 @@ app.post('/webhook/gitea', (req: Request, res: Response) => { const activeSessions = new Map(); app.post('/agent/execute', async (req: Request, res: Response) => { - const { sessionId, projectId, appName, appPath, giteaRepo, task, continueTask } = req.body as { + const { + sessionId, projectId, appName, appPath, giteaRepo, task, continueTask, + autoApprove, coolifyAppUuid, + } = req.body as { sessionId?: string; projectId?: string; appName?: string; appPath?: string; giteaRepo?: string; task?: string; - continueTask?: string; // if set, appended as follow-up to the original task + continueTask?: string; + autoApprove?: boolean; + coolifyAppUuid?: string; }; if (!sessionId || !projectId || !appPath || !task) { @@ -400,6 +405,9 @@ app.post('/agent/execute', async (req: Request, res: Response) => { return; } + // Capture repo root before scoping to appPath — needed for git commit in auto-approve + const repoRoot = ctx.workspaceRoot; + // Scope workspace to the app subdirectory so the agent works there naturally if (appPath) { const path = require('path') as typeof import('path'); @@ -431,7 +439,13 @@ app.post('/agent/execute', async (req: Request, res: Response) => { projectId, vibnApiUrl, appPath, + repoRoot, isStopped: () => sessionState.stopped, + autoApprove: autoApprove ?? true, + giteaRepo, + coolifyAppUuid, + coolifyApiUrl: process.env.COOLIFY_API_URL, + coolifyApiToken: process.env.COOLIFY_API_TOKEN, }) .catch(err => { const msg = err instanceof Error ? err.message : String(err);