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:
@@ -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';
|
||||||
@@ -30,7 +31,14 @@ export interface SessionRunOptions {
|
|||||||
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,11 +299,16 @@ 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 });
|
||||||
|
|
||||||
|
if (opts.autoApprove) {
|
||||||
|
await autoCommitAndDeploy(opts, task, emit);
|
||||||
|
} else {
|
||||||
await patchSession(opts, {
|
await patchSession(opts, {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' }
|
outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step label helpers ────────────────────────────────────────────────────────
|
// ── Step label helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user