360 lines
11 KiB
TypeScript
360 lines
11 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: process.env.VIBN_API_URL ?? "https://vibnai.com",
|
|
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: rawAppPath,
|
|
giteaRepo,
|
|
task,
|
|
continueTask,
|
|
autoApprove,
|
|
coolifyAppUuid,
|
|
mcpToken,
|
|
} = req.body as {
|
|
sessionId?: string;
|
|
projectId?: string;
|
|
appName?: string;
|
|
appPath?: string;
|
|
giteaRepo?: string;
|
|
task?: string;
|
|
continueTask?: string;
|
|
autoApprove?: boolean;
|
|
coolifyAppUuid?: string;
|
|
mcpToken?: string;
|
|
};
|
|
|
|
const appPath =
|
|
rawAppPath === undefined || rawAppPath === null || rawAppPath === ""
|
|
? "."
|
|
: rawAppPath;
|
|
|
|
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";
|
|
|
|
const patchHeaders = {
|
|
"Content-Type": "application/json",
|
|
...(process.env.AGENT_RUNNER_SECRET
|
|
? { "x-agent-runner-secret": process.env.AGENT_RUNNER_SECRET }
|
|
: {}),
|
|
};
|
|
|
|
// 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: patchHeaders,
|
|
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;
|
|
|
|
// Wire the ToolContext so its tools can call back into the VIBN frontend MCP
|
|
// with the right URL and auth. buildContext() defaults these to safe values,
|
|
// but the authoritative ones come from env (VIBN_API_URL) and the frontend
|
|
// (mcpToken passed in the /agent/execute body). Without this, tools fetch
|
|
// http://localhost:3000 with no token and fail with "fetch failed".
|
|
ctx.vibnApiUrl = vibnApiUrl;
|
|
ctx.mcpToken = mcpToken ?? ctx.mcpToken;
|
|
ctx.projectId = projectId;
|
|
|
|
// 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: patchHeaders,
|
|
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: patchHeaders,
|
|
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");
|
|
}
|
|
});
|