automagik-genie
Version:
Self-evolving AI agent orchestration framework with Model Context Protocol support
377 lines (376 loc) • 12.1 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isForgeRunning = isForgeRunning;
exports.waitForForgeReady = waitForForgeReady;
exports.startForgeInBackground = startForgeInBackground;
exports.getRunningTasks = getRunningTasks;
exports.killForgeProcess = killForgeProcess;
exports.stopForge = stopForge;
exports.restartForge = restartForge;
exports.getForgeProcess = getForgeProcess;
const child_process_1 = require("child_process");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
// @ts-ignore - compiled client shipped at project root
const forge_client_js_1 = require("../../../src/lib/forge-client.js");
const service_config_js_1 = require("./service-config.js");
const DEFAULT_BASE_URL = (0, service_config_js_1.getForgeConfig)().baseUrl;
const HEALTH_CHECK_TIMEOUT = 3000; // 3s per health check
const MAX_HEALTH_RETRIES = 3;
// Track Forge child process for graceful shutdown
let forgeChildProcess = null;
/**
* Health check with retry logic and exponential backoff
*/
async function isForgeRunning(baseUrl = DEFAULT_BASE_URL, retries = MAX_HEALTH_RETRIES) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const client = new forge_client_js_1.ForgeClient(baseUrl, process.env.FORGE_TOKEN);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
const ok = await client.healthCheck();
clearTimeout(timeout);
if (ok)
return true;
}
catch (error) {
// Exponential backoff: 100ms, 200ms, 400ms
if (attempt < retries - 1) {
await new Promise(r => setTimeout(r, 100 * Math.pow(2, attempt)));
}
}
}
return false;
}
/**
* Wait for Forge to become ready with progress indication
*/
async function waitForForgeReady(baseUrl = DEFAULT_BASE_URL, timeoutMs = 60000, intervalMs = 500, showProgress = false) {
const start = Date.now();
let lastDot = 0;
let consecutiveFailures = 0;
while (Date.now() - start < timeoutMs) {
const isRunning = await isForgeRunning(baseUrl, 1); // Single attempt per poll
if (isRunning) {
if (showProgress)
process.stderr.write('\n');
return true;
}
consecutiveFailures++;
// Show progress dots every 2 seconds
if (showProgress && Date.now() - lastDot > 2000) {
process.stderr.write('.');
lastDot = Date.now();
}
await new Promise(r => setTimeout(r, intervalMs));
}
if (showProgress)
process.stderr.write('\n');
return false;
}
/**
* Resolve @automagik/forge binary path
*
* With bundledDependencies, Forge is always included in the genie package.
* Node.js module resolution automatically finds it in node_modules.
*/
function resolveForgeBinary() {
try {
// require.resolve() uses Node.js module resolution algorithm
// Works with all package managers (npm, pnpm, yarn, bun)
const forgePath = require.resolve('@automagik/forge/bin/cli.js');
return { ok: true, value: forgePath };
}
catch (error) {
return {
ok: false,
error: new Error('@automagik/forge not found (bundled dependency missing). ' +
'This should not happen - please reinstall: npm install -g automagik-genie@latest')
};
}
}
/**
* Parse port from URL with fallback
*/
function parsePort(baseUrl) {
try {
return new URL(baseUrl).port || '8887';
}
catch {
return '8887';
}
}
/**
* Start Forge in background with comprehensive error handling
*/
function startForgeInBackground(opts) {
const baseUrl = opts.baseUrl || DEFAULT_BASE_URL;
const logDir = opts.logDir;
// Ensure log directory exists
try {
fs_1.default.mkdirSync(logDir, { recursive: true });
}
catch (error) {
return {
ok: false,
error: new Error(`Failed to create log directory: ${error}`)
};
}
const logPath = path_1.default.join(logDir, 'forge.log');
const pidPath = path_1.default.join(logDir, 'forge.pid');
// Resolve binary path
const binaryResult = resolveForgeBinary();
if (!binaryResult.ok) {
const error = 'error' in binaryResult ? binaryResult.error : new Error('Unknown error');
return { ok: false, error };
}
const binPath = binaryResult.value;
const port = parsePort(baseUrl);
// Open log files (will be inherited by child)
let logFd;
try {
logFd = fs_1.default.openSync(logPath, 'a');
}
catch (error) {
return {
ok: false,
error: new Error(`Failed to open log file: ${error}`)
};
}
// Spawn process with error handling
let child;
try {
child = (0, child_process_1.spawn)('node', [binPath], {
env: {
...process.env,
PORT: port,
FORGE_PORT: port,
BACKEND_PORT: port,
HOST: '0.0.0.0'
},
detached: true,
stdio: ['ignore', logFd, logFd]
});
}
catch (error) {
fs_1.default.closeSync(logFd);
return {
ok: false,
error: new Error(`Failed to spawn Forge process: ${error}`)
};
}
// Handle spawn errors
child.on('error', (error) => {
fs_1.default.appendFileSync(logPath, `\n[SPAWN ERROR] ${error}\n`);
});
// Handle early exit (crash during startup)
child.on('exit', (code, signal) => {
if (code !== 0 && code !== null) {
fs_1.default.appendFileSync(logPath, `\n[EARLY EXIT] Process exited with code ${code}, signal ${signal}\n`);
}
});
// Track child process for graceful shutdown
forgeChildProcess = child;
// Detach so it survives parent exit
child.unref();
// Close our handle to log file (child has inherited it)
fs_1.default.closeSync(logFd);
// Write PID file
const pid = child.pid ?? -1;
if (pid > 0) {
try {
fs_1.default.writeFileSync(pidPath, String(pid), 'utf8');
}
catch (error) {
// Non-fatal: log but continue
fs_1.default.appendFileSync(logPath, `\n[WARNING] Failed to write PID file: ${error}\n`);
}
}
return {
ok: true,
value: {
pid,
startTime: Date.now(),
binPath
}
};
}
/**
* Check for running task attempts and return them with URLs
*/
async function getRunningTasks(baseUrl = DEFAULT_BASE_URL) {
try {
const client = new forge_client_js_1.ForgeClient(baseUrl, process.env.FORGE_TOKEN);
// Get all projects
const projects = await client.listProjects();
const runningTasks = [];
// Check each project for running attempts
for (const project of projects) {
const tasks = await client.listTasks(project.id);
for (const task of tasks) {
// Check if task has running attempts
const attempts = await client.listAttempts(project.id, task.id);
const runningAttempts = attempts.filter((a) => a.status === 'running' || a.status === 'pending');
for (const attempt of runningAttempts) {
runningTasks.push({
projectId: project.id,
projectName: project.name || 'Unnamed Project',
taskId: task.id,
taskTitle: task.title || 'Untitled Task',
attemptId: attempt.id,
url: `${baseUrl}/projects/${project.id}/tasks/${task.id}/attempts/${attempt.id}`
});
}
}
}
return runningTasks;
}
catch (error) {
// If we can't check, return empty array (don't block shutdown)
return [];
}
}
/**
* Kill Forge child process immediately (for Ctrl+C shutdown)
* Sends SIGTERM to the entire process group
*/
function killForgeProcess() {
if (!forgeChildProcess || forgeChildProcess.killed) {
return;
}
try {
const pid = forgeChildProcess.pid;
if (pid) {
// Kill the entire process group (negative PID)
// This ensures all child processes are terminated
try {
process.kill(-pid, 'SIGTERM');
}
catch (err) {
// If process group kill fails, try killing the process directly
forgeChildProcess.kill('SIGTERM');
}
}
}
catch (error) {
// Ignore errors - process might already be dead
}
finally {
forgeChildProcess = null;
}
}
/**
* Stop Forge process with verification
*/
async function stopForge(logDir) {
const pidPath = path_1.default.join(logDir, 'forge.pid');
let pid;
try {
const pidStr = fs_1.default.readFileSync(pidPath, 'utf8').trim();
pid = parseInt(pidStr, 10);
if (Number.isNaN(pid) || pid <= 0) {
return false;
}
}
catch {
return false;
}
// Send SIGTERM
try {
process.kill(pid, 'SIGTERM');
}
catch (error) {
// Process might already be dead
return false;
}
// Wait for process to exit (max 5 seconds)
for (let i = 0; i < 50; i++) {
try {
// Check if process still exists (throws if dead)
process.kill(pid, 0);
await new Promise(r => setTimeout(r, 100));
}
catch {
// Process is dead
try {
fs_1.default.unlinkSync(pidPath);
}
catch { }
return true;
}
}
// Force kill if still alive
try {
process.kill(pid, 'SIGKILL');
fs_1.default.unlinkSync(pidPath);
}
catch { }
return true;
}
/**
* Restart Forge with proper shutdown and startup
*/
async function restartForge(opts) {
const baseUrl = opts.baseUrl || DEFAULT_BASE_URL;
const logDir = opts.logDir;
// Check if already running
const wasRunning = await isForgeRunning(baseUrl);
if (wasRunning) {
const stopped = await stopForge(logDir);
if (!stopped) {
return {
ok: false,
error: new Error('Failed to stop existing Forge process')
};
}
// Wait for port to be released
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Start new instance
const startResult = startForgeInBackground(opts);
if (!startResult.ok) {
return startResult;
}
// Wait for ready
const ready = await waitForForgeReady(baseUrl, 60000, 500);
if (!ready) {
return {
ok: false,
error: new Error('Forge did not become ready after restart')
};
}
return startResult;
}
/**
* Get Forge process info
*/
function getForgeProcess(logDir) {
const pidPath = path_1.default.join(logDir, 'forge.pid');
try {
const pidStr = fs_1.default.readFileSync(pidPath, 'utf8').trim();
const pid = parseInt(pidStr, 10);
if (Number.isNaN(pid) || pid <= 0) {
return null;
}
// Verify process is still running
try {
process.kill(pid, 0);
return {
pid,
startTime: 0, // Unknown
binPath: '' // Unknown
};
}
catch {
// Process is dead, clean up PID file
fs_1.default.unlinkSync(pidPath);
return null;
}
}
catch {
return null;
}
}