feat: auto-approve — commit + deploy on session completion

- SessionRunOptions: autoApprove, giteaRepo, repoRoot, coolifyAppUuid/Url/Token
- autoCommitAndDeploy(): git add -A, commit with "agent: <task>", push,
  trigger Coolify deploy, PATCH session → approved
- Falls back to done status if commit fails so manual approve still works
- /agent/execute: captures repoRoot before appPath scoping, defaults
  autoApprove to true, passes coolify params from env
- System prompt: "do NOT commit" → "platform handles committing"

Made-with: Cursor
This commit is contained in:
2026-03-07 13:17:25 -08:00
parent 551fdb9e54
commit 214e8c1037
2 changed files with 106 additions and 9 deletions

View File

@@ -12,6 +12,7 @@
* - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid] * - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid]
*/ */
import { execSync } from 'child_process';
import { createLLM, toOAITools, LLMMessage } from './llm'; import { createLLM, toOAITools, LLMMessage } from './llm';
import { AgentConfig } from './agents'; import { AgentConfig } from './agents';
import { executeTool, ToolContext } from './tools'; import { executeTool, ToolContext } from './tools';
@@ -28,9 +29,16 @@ export interface OutputLine {
export interface SessionRunOptions { export interface SessionRunOptions {
sessionId: string; sessionId: string;
projectId: string; projectId: string;
vibnApiUrl: string; // e.g. https://vibnai.com vibnApiUrl: string; // e.g. https://vibnai.com
appPath: string; // relative path within repo, e.g. "apps/admin" appPath: string; // relative path within repo, e.g. "apps/admin"
repoRoot?: string; // absolute path to the git repo root (for auto-commit)
isStopped: () => boolean; 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 ──────────────────────────────────────────────────────────── // ── VIBN DB bridge ────────────────────────────────────────────────────────────
@@ -86,6 +94,76 @@ function extractChangedFile(
return { path: displayPath, status: fileStatus }; return { path: displayPath, status: fileStatus };
} }
// ── Auto-commit helper ────────────────────────────────────────────────────────
async function autoCommitAndDeploy(
opts: SessionRunOptions,
task: string,
emit: (line: OutputLine) => Promise<void>
): Promise<void> {
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 ───────────────────────────────────────────── // ── Main streaming execution loop ─────────────────────────────────────────────
export async function runSessionAgent( export async function runSessionAgent(
@@ -112,7 +190,7 @@ export async function runSessionAgent(
You are working inside the monorepo directory: ${opts.appPath} You are working inside the monorepo directory: ${opts.appPath}
All file paths you use should be relative to this directory unless otherwise specified. 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 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[] = [ 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 emit({ ts: now(), type: 'done', text: finalText });
await patchSession(opts, {
status: 'done', if (opts.autoApprove) {
outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' } 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 ──────────────────────────────────────────────────────── // ── Step label helpers ────────────────────────────────────────────────────────

View File

@@ -359,14 +359,19 @@ app.post('/webhook/gitea', (req: Request, res: Response) => {
const activeSessions = new Map<string, { stopped: boolean }>(); const activeSessions = new Map<string, { stopped: boolean }>();
app.post('/agent/execute', async (req: Request, res: Response) => { 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; sessionId?: string;
projectId?: string; projectId?: string;
appName?: string; appName?: string;
appPath?: string; appPath?: string;
giteaRepo?: string; giteaRepo?: string;
task?: 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) { if (!sessionId || !projectId || !appPath || !task) {
@@ -400,6 +405,9 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
return; 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 // Scope workspace to the app subdirectory so the agent works there naturally
if (appPath) { if (appPath) {
const path = require('path') as typeof import('path'); const path = require('path') as typeof import('path');
@@ -431,7 +439,13 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
projectId, projectId,
vibnApiUrl, vibnApiUrl,
appPath, appPath,
repoRoot,
isStopped: () => sessionState.stopped, isStopped: () => sessionState.stopped,
autoApprove: autoApprove ?? true,
giteaRepo,
coolifyAppUuid,
coolifyApiUrl: process.env.COOLIFY_API_URL,
coolifyApiToken: process.env.COOLIFY_API_TOKEN,
}) })
.catch(err => { .catch(err => {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);