- theia-exec.ts: primary path is now HTTP sync (syncRepoToTheia) via sync-server.js running inside Theia on port 3001 — no docker socket needed - syncRepoToTheia(giteaRepo): POST /sync → Theia git-pulls latest committed code - isTheiaSyncAvailable(): health check before attempting sync - docker exec path preserved for future use when socket is mounted - agent-session-runner: use syncRepoToTheia after auto-commit - server.ts: log both docker exec + HTTP sync status at startup Made-with: Cursor
627 lines
22 KiB
TypeScript
627 lines
22 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 { createJob, getJob, listJobs, updateJob } from './job-store';
|
|
import { runAgent } from './agent-runner';
|
|
import { runSessionAgent } from './agent-session-runner';
|
|
import { AGENTS } from './agents';
|
|
import { ToolContext } from './tools';
|
|
import { PROTECTED_GITEA_REPOS } from './tools/security';
|
|
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
|
|
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
|
|
import { LLMMessage, createLLM } from './llm';
|
|
import { getTheiaContainer, isTheiaSyncAvailable } from './theia-exec';
|
|
|
|
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;
|
|
}
|
|
if (PROTECTED_GITEA_REPOS.has(repo)) {
|
|
throw new Error(
|
|
`SECURITY: Repo "${repo}" is a protected Vibn platform repo. ` +
|
|
`Agents cannot clone or work in this workspace.`
|
|
);
|
|
}
|
|
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 || ''
|
|
},
|
|
memoryUpdates: []
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Routes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Health check
|
|
app.get('/health', (_req: Request, res: Response) => {
|
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// Get server status and job statistics
|
|
app.get('/api/status', (_req: Request, res: Response) => {
|
|
const allJobs = listJobs(Infinity);
|
|
const total_jobs = allJobs.length;
|
|
|
|
const by_status: { [key: string]: number } = {
|
|
queued: 0,
|
|
running: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
};
|
|
for (const job of allJobs) {
|
|
by_status[job.status] = (by_status[job.status] || 0) + 1;
|
|
}
|
|
|
|
const uptime_seconds = Math.floor((new Date().getTime() - startTime.getTime()) / 1000);
|
|
|
|
const agents = Object.values(AGENTS).map(a => a.name);
|
|
|
|
res.json({
|
|
total_jobs,
|
|
by_status,
|
|
uptime_seconds,
|
|
agents,
|
|
});
|
|
});
|
|
|
|
// Submit a new job
|
|
app.post('/api/agent/run', async (req: Request, res: Response) => {
|
|
const { agent: agentName, task, repo } = req.body as { agent?: string; task?: string; repo?: string };
|
|
|
|
if (!agentName || !task) {
|
|
res.status(400).json({ error: '"agent" and "task" are required' });
|
|
return;
|
|
}
|
|
|
|
const agentConfig = AGENTS[agentName];
|
|
if (!agentConfig) {
|
|
const available = Object.keys(AGENTS).join(', ');
|
|
res.status(400).json({ error: `Unknown agent "${agentName}". Available: ${available}` });
|
|
return;
|
|
}
|
|
|
|
const job = createJob(agentName, task, repo);
|
|
res.status(202).json({ jobId: job.id, status: job.status });
|
|
|
|
// Run agent asynchronously
|
|
const ctx = buildContext(repo);
|
|
runAgent(job, agentConfig, task, ctx)
|
|
.then(result => {
|
|
updateJob(job.id, {
|
|
status: 'completed',
|
|
result: result.finalText,
|
|
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
|
|
});
|
|
})
|
|
.catch(err => {
|
|
updateJob(job.id, {
|
|
status: 'failed',
|
|
error: err instanceof Error ? err.message : String(err),
|
|
progress: 'Agent failed'
|
|
});
|
|
});
|
|
});
|
|
|
|
// Check job status
|
|
app.get('/api/jobs/:id', (req: Request, res: Response) => {
|
|
const job = getJob(req.params.id);
|
|
if (!job) {
|
|
res.status(404).json({ error: 'Job not found' });
|
|
return;
|
|
}
|
|
res.json(job);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orchestrator — persistent chat with full project context
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.post('/orchestrator/chat', async (req: Request, res: Response) => {
|
|
const {
|
|
message,
|
|
session_id,
|
|
history,
|
|
knowledge_context
|
|
} = req.body as {
|
|
message?: string;
|
|
session_id?: string;
|
|
history?: LLMMessage[];
|
|
knowledge_context?: string;
|
|
};
|
|
|
|
if (!message) { res.status(400).json({ error: '"message" is required' }); return; }
|
|
|
|
const sessionId = session_id || `session_${Date.now()}`;
|
|
const ctx = buildContext();
|
|
|
|
try {
|
|
const result = await orchestratorChat(sessionId, message, ctx, {
|
|
preloadedHistory: history,
|
|
knowledgeContext: knowledge_context
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
});
|
|
|
|
app.get('/orchestrator/sessions', (_req: Request, res: Response) => {
|
|
res.json(listSessions());
|
|
});
|
|
|
|
app.delete('/orchestrator/sessions/:id', (req: Request, res: Response) => {
|
|
clearSession(req.params.id);
|
|
res.json({ cleared: req.params.id });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Atlas — PRD discovery agent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.post('/atlas/chat', async (req: Request, res: Response) => {
|
|
const {
|
|
message,
|
|
session_id,
|
|
history,
|
|
is_init,
|
|
} = req.body as {
|
|
message?: string;
|
|
session_id?: string;
|
|
history?: LLMMessage[];
|
|
is_init?: boolean;
|
|
};
|
|
|
|
if (!message) { res.status(400).json({ error: '"message" is required' }); return; }
|
|
|
|
const sessionId = session_id || `atlas_${Date.now()}`;
|
|
const ctx = buildContext();
|
|
|
|
try {
|
|
const result = await atlasChat(sessionId, message, ctx, {
|
|
preloadedHistory: history,
|
|
isInit: is_init,
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
});
|
|
|
|
app.get('/atlas/sessions', (_req: Request, res: Response) => {
|
|
res.json(listAtlasSessions());
|
|
});
|
|
|
|
app.delete('/atlas/sessions/:id', (req: Request, res: Response) => {
|
|
clearAtlasSession(req.params.id);
|
|
res.json({ cleared: req.params.id });
|
|
});
|
|
|
|
// List recent jobs
|
|
app.get('/api/jobs', (req: Request, res: Response) => {
|
|
const limit = parseInt((req.query.limit as string) || '20', 10);
|
|
res.json(listJobs(limit));
|
|
});
|
|
|
|
// Gitea webhook endpoint — triggers agent from an issue event
|
|
app.post('/webhook/gitea', (req: Request, res: Response) => {
|
|
const event = req.headers['x-gitea-event'] as string;
|
|
const rawBody = req.body as Buffer;
|
|
|
|
// Verify HMAC-SHA256 signature
|
|
const webhookSecret = process.env.WEBHOOK_SECRET;
|
|
if (webhookSecret) {
|
|
const sig = req.headers['x-gitea-signature'] as string;
|
|
const expected = crypto
|
|
.createHmac('sha256', webhookSecret)
|
|
.update(rawBody)
|
|
.digest('hex');
|
|
if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
|
res.status(401).json({ error: 'Invalid webhook signature' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const body = JSON.parse(rawBody.toString('utf8'));
|
|
|
|
let task: string | null = null;
|
|
let agentName = 'Coder';
|
|
let repo: string | undefined;
|
|
|
|
if (event === 'issues' && body.action === 'opened') {
|
|
const issue = body.issue;
|
|
repo = `${body.repository?.owner?.login}/${body.repository?.name}`;
|
|
const labels: string[] = (issue.labels || []).map((l: any) => l.name as string);
|
|
|
|
if (labels.includes('agent:pm')) {
|
|
agentName = 'PM';
|
|
} else if (labels.includes('agent:marketing')) {
|
|
agentName = 'Marketing';
|
|
} else if (labels.includes('agent:coder')) {
|
|
agentName = 'Coder';
|
|
} else {
|
|
// No agent label — ignore
|
|
res.json({ ignored: true, reason: 'no agent label on issue' });
|
|
return;
|
|
}
|
|
|
|
task = `You have been assigned to resolve a Gitea issue in the repo ${repo}.\n\nIssue #${issue.number}: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}\n\nWhen done, close the issue by calling gitea_close_issue.`;
|
|
} else if (event === 'push') {
|
|
res.json({ ignored: true, reason: 'push events not auto-processed' });
|
|
return;
|
|
} else {
|
|
res.json({ ignored: true, event });
|
|
return;
|
|
}
|
|
|
|
if (!task) {
|
|
res.json({ ignored: true });
|
|
return;
|
|
}
|
|
|
|
const agentConfig = AGENTS[agentName];
|
|
const job = createJob(agentName, task, repo);
|
|
res.status(202).json({ jobId: job.id, agent: agentName, event });
|
|
|
|
const ctx = buildContext(repo);
|
|
runAgent(job, agentConfig, task, ctx)
|
|
.then(result => {
|
|
updateJob(job.id, {
|
|
status: 'completed',
|
|
result: result.finalText,
|
|
progress: `Done — ${result.turns} turns, ${result.toolCallCount} tool calls`
|
|
});
|
|
})
|
|
.catch(err => {
|
|
updateJob(job.id, {
|
|
status: 'failed',
|
|
error: err instanceof Error ? err.message : String(err),
|
|
progress: 'Agent failed'
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent Execute — VIBN Build > Code > Agent tab
|
|
//
|
|
// Receives a task from the VIBN frontend, runs the Coder agent against
|
|
// the project's Gitea repo, and streams progress back to the VIBN DB
|
|
// via PATCH /api/projects/[id]/agent/sessions/[sid].
|
|
//
|
|
// This endpoint returns immediately (202) and runs the agent async so
|
|
// the browser can close without killing the loop.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Track active sessions for stop support
|
|
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!;
|
|
|
|
// Derive the Theia workspace subdir from the giteaRepo slug
|
|
// e.g. "mark/sportsy" → "mark_sportsy", then append appPath
|
|
// So files land at /home/node/workspace/mark_sportsy/apps/admin inside Theia
|
|
const theiaWorkspaceSubdir = giteaRepo
|
|
? giteaRepo.replace('/', '_')
|
|
: undefined;
|
|
|
|
// 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,
|
|
theiaWorkspaceSubdir,
|
|
})
|
|
.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({ ok: true, message: 'Stop signal sent — agent will halt after current step.' });
|
|
} else {
|
|
res.json({ ok: true, message: 'Session not active (may have already completed).' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent Approve — commit and push agent's changes to Gitea, trigger deploy
|
|
//
|
|
// Called by vibn-frontend after the user reviews changed files and clicks
|
|
// "Approve & commit". The agent runner does git add/commit/push in the
|
|
// workspace where the agent was working.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.post('/agent/approve', async (req: Request, res: Response) => {
|
|
const { giteaRepo, commitMessage, coolifyApiUrl, coolifyApiToken, coolifyAppUuid } = req.body as {
|
|
giteaRepo?: string;
|
|
commitMessage?: string;
|
|
coolifyApiUrl?: string;
|
|
coolifyApiToken?: string;
|
|
coolifyAppUuid?: string;
|
|
};
|
|
|
|
if (!giteaRepo || !commitMessage) {
|
|
res.status(400).json({ error: 'giteaRepo and commitMessage are required' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Resolve the workspace root for this repo (does NOT re-clone if already present)
|
|
const workspaceRoot = ensureWorkspace(giteaRepo);
|
|
|
|
// Configure git identity for this commit
|
|
const gitea = {
|
|
username: process.env.GITEA_USERNAME || 'agent',
|
|
apiToken: process.env.GITEA_API_TOKEN || '',
|
|
apiUrl: process.env.GITEA_API_URL || '',
|
|
};
|
|
|
|
const { execSync: exec } = require('child_process') as typeof import('child_process');
|
|
const gitOpts = { cwd: workspaceRoot, stdio: 'pipe' as const };
|
|
|
|
// Ensure git identity
|
|
try {
|
|
exec('git config user.email "agent@vibnai.com"', gitOpts);
|
|
exec('git config user.name "VIBN Agent"', gitOpts);
|
|
} catch { /* already set */ }
|
|
|
|
// Stage all changes
|
|
exec('git add -A', gitOpts);
|
|
|
|
// Check if there is anything to commit
|
|
let status: string;
|
|
try {
|
|
status = exec('git status --porcelain', gitOpts).toString().trim();
|
|
} catch { status = ''; }
|
|
|
|
if (!status) {
|
|
res.json({ ok: true, committed: false, message: 'Nothing to commit — working tree is clean.' });
|
|
return;
|
|
}
|
|
|
|
// Commit
|
|
exec(`git commit -m ${JSON.stringify(commitMessage)}`, gitOpts);
|
|
|
|
// Push — use token auth embedded in remote URL
|
|
const authedUrl = `${gitea.apiUrl}/${giteaRepo}.git`
|
|
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
|
|
exec(`git push "${authedUrl}" HEAD:main`, gitOpts);
|
|
|
|
// Optionally trigger a Coolify redeploy
|
|
let deployed = false;
|
|
if (coolifyApiUrl && coolifyApiToken && coolifyAppUuid) {
|
|
try {
|
|
const deployRes = await fetch(`${coolifyApiUrl}/api/v1/applications/${coolifyAppUuid}/start`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${coolifyApiToken}` },
|
|
});
|
|
deployed = deployRes.ok;
|
|
} catch { /* deploy trigger is best-effort */ }
|
|
}
|
|
|
|
res.json({ ok: true, committed: true, deployed, message: `Committed and pushed: "${commitMessage}"` });
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error('[agent/approve]', msg);
|
|
res.status(500).json({ error: msg });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generate — thin structured-generation endpoint (no session, no system prompt)
|
|
// Use this for one-shot tasks like architecture recommendations.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.post('/generate', async (req: Request, res: Response) => {
|
|
const { prompt, model, region } = req.body as { prompt?: string; model?: string; region?: string };
|
|
if (!prompt) { res.status(400).json({ error: '"prompt" is required' }); return; }
|
|
|
|
// Allow overriding CLAUDE_REGION per-request for testing
|
|
const prevRegion = process.env.CLAUDE_REGION;
|
|
if (region) process.env.CLAUDE_REGION = region;
|
|
|
|
try {
|
|
const llm = createLLM(model ?? 'A', { temperature: 0.3 });
|
|
const messages: import('./llm').LLMMessage[] = [
|
|
{ role: 'user', content: prompt }
|
|
];
|
|
const response = await llm.chat(messages, [], 8192);
|
|
res.json({ reply: response.content ?? '', model: llm.modelId });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err), model });
|
|
} finally {
|
|
if (region) process.env.CLAUDE_REGION = prevRegion ?? '';
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
|
console.error(err.stack);
|
|
res.status(500).json({ error: err.message });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Start
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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');
|
|
}
|
|
// Theia bridge status
|
|
const theiaContainer = getTheiaContainer();
|
|
if (theiaContainer) {
|
|
console.log(`Theia docker exec: active (container: ${theiaContainer})`);
|
|
} else {
|
|
console.log('Theia docker exec: not available (docker socket not mounted) — using HTTP sync instead');
|
|
}
|
|
isTheiaSyncAvailable().then(ok => {
|
|
console.log(`Theia HTTP sync: ${ok ? 'reachable at ' + (process.env.THEIA_SYNC_URL ?? 'http://theia-code-os:3001') : 'not reachable — sync will be skipped'}`);
|
|
});
|
|
});
|