This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-agent-runner/src/server.ts

294 lines
10 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { execSync } from 'child_process';
import { runSessionAgent } from './agent-session-runner';
import { AGENTS } from './agents';
import { ToolContext } from './tools';
const app = express();
app.use(cors());
const startTime = new Date();
// Raw body capture for webhook HMAC — must come before express.json()
app.use('/webhook/gitea', express.raw({ type: '*/*' }));
app.use(express.json());
const PORT = process.env.PORT || 3333;
// ---------------------------------------------------------------------------
// Build ToolContext from environment variables
// ---------------------------------------------------------------------------
function ensureWorkspace(repo?: string): string {
const base = process.env.WORKSPACE_BASE || '/workspaces';
if (!repo) {
const dir = path.join(base, 'default');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
const dir = path.join(base, repo.replace('/', '_'));
const gitea = {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
};
if (!fs.existsSync(path.join(dir, '.git'))) {
fs.mkdirSync(dir, { recursive: true });
const authedUrl = `${gitea.apiUrl}/${repo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
try {
execSync(`git clone "${authedUrl}" "${dir}"`, { stdio: 'pipe' });
} catch {
// Repo may not exist yet — just init
execSync(`git init`, { cwd: dir, stdio: 'pipe' });
execSync(`git remote add origin "${authedUrl}"`, { cwd: dir, stdio: 'pipe' });
}
}
return dir;
}
function buildContext(repo?: string): ToolContext {
const workspaceRoot = ensureWorkspace(repo);
return {
workspaceRoot,
gitea: {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
},
coolify: {
apiUrl: process.env.COOLIFY_API_URL || '',
apiToken: process.env.COOLIFY_API_TOKEN || ''
},
mcpToken: '',
vibnApiUrl: 'http://localhost:3000',
memoryUpdates: []
};
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// GitHub mirror — clone a public GitHub repo and push to Gitea as-is
// ---------------------------------------------------------------------------
app.post('/api/mirror', async (req: Request, res: Response) => {
const { github_url, gitea_repo, project_name, github_token } = req.body as {
github_url?: string;
gitea_repo?: string; // e.g. "mark/opsos"
project_name?: string;
github_token?: string; // PAT for private repos
};
if (!github_url || !gitea_repo) {
res.status(400).json({ error: '"github_url" and "gitea_repo" are required' });
return;
}
const { execSync } = await import('child_process');
const fs = await import('fs');
const path = await import('path');
const os = await import('os');
const mirrorId = `mirror_${Date.now()}`;
const tmpDir = path.join(os.tmpdir(), mirrorId);
const gitea = {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
};
try {
// Build authenticated Gitea push URL
// GITEA_API_URL is like https://git.vibnai.com — strip /api/v1 if present
const giteaBase = gitea.apiUrl.replace(/\/api\/v1\/?$/, '');
const authedPushUrl = `${giteaBase}/${gitea_repo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
console.log(`[mirror] Cloning ${github_url}${tmpDir}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Build authenticated clone URL for private repos
let cloneUrl = github_url;
if (github_token) {
cloneUrl = github_url.replace('https://', `https://${github_token}@`);
}
// Mirror-clone the GitHub repo (preserves all branches + tags)
execSync(`git clone --mirror "${cloneUrl}" "${tmpDir}/.git"`, {
stdio: 'pipe',
timeout: 120_000
});
execSync(`git config --bool core.bare false`, { cwd: tmpDir, stdio: 'pipe' });
execSync(`git checkout`, { cwd: tmpDir, stdio: 'pipe' });
// Point origin at Gitea and push all refs
execSync(`git remote set-url origin "${authedPushUrl}"`, { cwd: tmpDir, stdio: 'pipe' });
execSync(`git push --mirror origin`, { cwd: tmpDir, stdio: 'pipe', timeout: 120_000 });
console.log(`[mirror] Pushed ${gitea_repo} successfully`);
res.json({ success: true, gitea_repo, github_url });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[mirror] Failed:`, msg);
res.status(500).json({ error: 'Mirror failed', details: msg });
} finally {
// Clean up temp dir
try {
const { execSync: rm } = await import('child_process');
rm(`rm -rf "${tmpDir}"`, { stdio: 'pipe' });
} catch { /* best effort */ }
}
});
// List available agents
app.get('/api/agents', (_req: Request, res: Response) => {
const agents = Object.values(AGENTS).map(a => ({
name: a.name,
description: a.description,
tools: a.tools.map(t => t.name)
}));
res.json(agents);
});
const activeSessions = new Map<string, { stopped: boolean }>();
app.post('/agent/execute', async (req: Request, res: Response) => {
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;
autoApprove?: boolean;
coolifyAppUuid?: string;
};
if (!sessionId || !projectId || !appPath || !task) {
res.status(400).json({ error: 'sessionId, projectId, appPath and task are required' });
return;
}
const vibnApiUrl = process.env.VIBN_API_URL ?? 'https://vibnai.com';
// Register session as active
const sessionState = { stopped: false };
activeSessions.set(sessionId, sessionState);
// Respond immediately — execution is async
res.status(202).json({ sessionId, status: 'running' });
// Build workspace context — clone/update the Gitea repo if provided
let ctx: ReturnType<typeof buildContext>;
try {
ctx = buildContext(giteaRepo);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[agent/execute] buildContext failed:', msg);
// Notify VIBN DB of failure
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: msg }),
}).catch(() => {});
activeSessions.delete(sessionId);
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');
ctx.workspaceRoot = path.join(ctx.workspaceRoot, appPath);
const fs = require('fs') as typeof import('fs');
fs.mkdirSync(ctx.workspaceRoot, { recursive: true });
}
const agentConfig = AGENTS['Coder'];
if (!agentConfig) {
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: 'Coder agent not registered' }),
}).catch(() => {});
activeSessions.delete(sessionId);
return;
}
// If continuing a previous task, combine into a single prompt so the agent
// understands what was already attempted.
const effectiveTask = continueTask
? `Original task: ${task}\n\nFollow-up instruction: ${continueTask}`
: task!;
// Run the streaming agent loop (fire and forget)
runSessionAgent(agentConfig, effectiveTask, ctx, {
sessionId,
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);
console.error(`[agent/execute] session ${sessionId} crashed:`, msg);
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: msg }),
}).catch(() => {});
})
.finally(() => {
activeSessions.delete(sessionId);
});
});
app.post('/agent/stop', (req: Request, res: Response) => {
const { sessionId } = req.body as { sessionId?: string };
if (!sessionId) { res.status(400).json({ error: 'sessionId required' }); return; }
const session = activeSessions.get(sessionId);
if (session) {
session.stopped = true;
res.json({ status: 'stopped' });
} else {
res.status(404).json({ error: 'session not found or not running' });
}
});
app.listen(PORT, () => {
console.log(`AgentRunner listening on port ${PORT}`);
console.log(`Agents available: ${Object.keys(AGENTS).join(', ')}`);
if (!process.env.GOOGLE_API_KEY) {
console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail');
}
});