arela
Version:
AI-powered CTO with multi-agent orchestration, code summarization, visual testing (web + mobile) for blazing fast development.
265 lines (261 loc) • 10.2 kB
JavaScript
import fs from "fs-extra";
import path from "path";
import { execa } from "execa";
import { markInProgress, markCompleted, markFailed, canRunTicket } from "./status.js";
import { getDefaultCompressor } from "../compression/index.js";
const defaultCompressor = getDefaultCompressor();
/**
* Load agent configuration
*/
async function loadAgentConfig(cwd) {
const configPath = path.join(cwd, ".arela", "agents", "config.json");
if (!(await fs.pathExists(configPath))) {
return {};
}
const config = await fs.readJSON(configPath);
return config.agents || {};
}
/**
* Discover all tickets
*/
async function discoverTickets(cwd, agentFilter, ticketFilter) {
const ticketsDir = path.join(cwd, ".arela", "tickets");
if (!(await fs.pathExists(ticketsDir))) {
return [];
}
const tickets = [];
const agents = await fs.readdir(ticketsDir);
for (const agent of agents) {
if (agentFilter && agent !== agentFilter)
continue;
const agentDir = path.join(ticketsDir, agent);
const stat = await fs.stat(agentDir);
if (!stat.isDirectory())
continue;
const files = await fs.readdir(agentDir);
for (const file of files) {
if (!file.endsWith(".md") && !file.endsWith(".yaml"))
continue;
if (file.startsWith("EXAMPLE-"))
continue;
const ticketId = file.replace(/\.(md|yaml)$/, "");
// Filter by specific ticket IDs if provided
if (ticketFilter && ticketFilter.length > 0 && !ticketFilter.includes(ticketId)) {
continue;
}
const ticketPath = path.join(agentDir, file);
// Parse basic ticket info
const ticket = {
id: ticketId,
title: ticketId,
description: "",
agent: agent,
priority: "medium",
complexity: "medium",
status: "pending",
};
tickets.push({ ticket, path: ticketPath });
}
}
return tickets;
}
/**
* Build a proper prompt for AI from ticket content
*/
function buildPromptForTicket(ticketContent, ticket) {
return `You are an AI assistant helping to complete a development ticket.
TICKET ID: ${ticket.id}
AGENT: ${ticket.agent}
PRIORITY: ${ticket.priority}
COMPLEXITY: ${ticket.complexity}
TICKET CONTENT:
${ticketContent}
INSTRUCTIONS:
1. Read the ticket carefully
2. Implement EXACTLY what is requested
3. Follow all acceptance criteria
4. Output clean, working code
5. Include comments explaining your approach
6. If creating files, show the full file path and content
Respond with your implementation now:`;
}
/**
* Run a single ticket
*/
async function runTicket(ticket, ticketPath, agentConfig, cwd, dryRun) {
const startTime = Date.now();
if (dryRun) {
console.log(` [DRY RUN] Would run ${ticket.id}`);
return { success: true, duration: 0, cost: 0 };
}
// Create log directory
const logDir = path.join(cwd, "logs", ticket.agent);
await fs.ensureDir(logDir);
const logPath = path.join(logDir, `${ticket.id}.log`);
// Mark as in progress
await markInProgress(cwd, ticket.id, ticket.agent);
console.log(` ▶ Running ${ticket.id}...`);
try {
// Read ticket content
const ticketContent = await fs.readFile(ticketPath, "utf-8");
// Build proper prompt for AI
const prompt = buildPromptForTicket(ticketContent, ticket);
// Parse agent command
const command = agentConfig.command;
const [cmd, ...args] = command.split(" ");
// Write prompt to temp file
const tempPromptPath = path.join('/tmp', `arela-ticket-${ticket.id}-${Date.now()}.txt`);
await fs.writeFile(tempPromptPath, prompt);
// Run agent with proper prompt
const result = await execa('sh', ['-c', `cat "${tempPromptPath}" | ${command}`], {
cwd,
timeout: 30 * 60 * 1000, // 30 minutes
});
// Save log with prompt and response
const logContent = `=== PROMPT ===\n${prompt}\n\n=== RESPONSE ===\n${result.stdout}\n\n=== STDERR ===\n${result.stderr}`;
await fs.writeFile(logPath, logContent);
// Also save just the response for easy access
const responsePath = path.join(logDir, `${ticket.id}-response.txt`);
await fs.writeFile(responsePath, result.stdout);
// Clean up temp file
await fs.remove(tempPromptPath);
// Calculate duration and cost (rough estimate using compression abstraction)
const duration = Date.now() - startTime;
const estimatedTokens = defaultCompressor.getTokenCount(ticketContent);
const cost = (estimatedTokens / 1000) * agentConfig.cost_per_1k_tokens;
// Mark as completed
await markCompleted(cwd, ticket.id, cost, duration);
console.log(` ✓ Completed ${ticket.id} in ${(duration / 1000).toFixed(1)}s`);
return { success: true, duration, cost };
}
catch (error) {
const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
// Save error log
await fs.writeFile(logPath, errorMessage);
// Mark as failed
await markFailed(cwd, ticket.id, errorMessage);
console.log(` ✗ Failed ${ticket.id}: ${errorMessage}`);
return { success: false, duration, cost: 0 };
}
}
/**
* Run tickets in parallel with concurrency limit
*/
async function runTicketsParallel(tickets, agentConfigs, cwd, dryRun, maxParallel) {
const queue = [...tickets];
const running = [];
while (queue.length > 0 || running.length > 0) {
// Start new tasks up to maxParallel
while (running.length < maxParallel && queue.length > 0) {
const executableTicket = queue.shift();
const agentConfig = agentConfigs[executableTicket.ticket.agent];
if (!agentConfig || !agentConfig.enabled) {
console.log(` ⊘ Skipping ${executableTicket.ticket.id} (agent not configured)`);
continue;
}
const task = runTicket(executableTicket.ticket, executableTicket.path, agentConfig, cwd, dryRun).then(() => {
// Task completed
});
running.push(task);
}
// Wait for at least one to complete
if (running.length > 0) {
await Promise.race(running);
// Remove completed tasks
for (let i = running.length - 1; i >= 0; i--) {
const settled = await Promise.race([
running[i].then(() => true),
Promise.resolve(false),
]);
if (settled) {
running.splice(i, 1);
}
}
}
}
}
/**
* Run tickets sequentially
*/
async function runTicketsSequential(tickets, agentConfigs, cwd, dryRun) {
for (const executableTicket of tickets) {
const agentConfig = agentConfigs[executableTicket.ticket.agent];
if (!agentConfig || !agentConfig.enabled) {
console.log(` ⊘ Skipping ${executableTicket.ticket.id} (agent not configured)`);
continue;
}
await runTicket(executableTicket.ticket, executableTicket.path, agentConfig, cwd, dryRun);
}
}
/**
* Main orchestration function
*/
export async function orchestrate(options) {
const { cwd, agent, tickets, parallel = false, force = false, dryRun = false, maxParallel = 5, } = options;
console.log("\n🚀 Arela Multi-Agent Orchestration\n");
// Load agent configurations
const agentConfigs = await loadAgentConfig(cwd);
if (Object.keys(agentConfigs).length === 0) {
console.log("No agent configurations found");
console.log("Run: npx arela init");
return;
}
// Discover tickets
const allTickets = await discoverTickets(cwd, agent, tickets);
if (allTickets.length === 0) {
console.log("No tickets found");
console.log("Create tickets in .arela/tickets/{agent}/");
return;
}
// Filter tickets that can run
const runnableTickets = [];
for (const executableTicket of allTickets) {
if (await canRunTicket(cwd, executableTicket.ticket.id, force)) {
runnableTickets.push(executableTicket);
}
else {
console.log(` ⊘ Skipping ${executableTicket.ticket.id} (already completed)`);
}
}
if (runnableTickets.length === 0) {
console.log("No tickets to run (all completed)");
console.log("Use --force to re-run completed tickets");
return;
}
console.log(`Found ${runnableTickets.length} ticket(s) to run\n`);
// Group by agent
const ticketsByAgent = {};
for (const executableTicket of runnableTickets) {
const agentName = executableTicket.ticket.agent;
if (!ticketsByAgent[agentName]) {
ticketsByAgent[agentName] = [];
}
ticketsByAgent[agentName].push(executableTicket);
}
// Show summary
for (const [agentName, tickets] of Object.entries(ticketsByAgent)) {
console.log(` ${agentName}: ${tickets.length} ticket(s)`);
}
console.log("");
if (dryRun) {
console.log("DRY RUN MODE - No tickets will actually run\n");
}
// Run tickets
const startTime = Date.now();
if (parallel) {
console.log(`Running in parallel (max ${maxParallel} concurrent)...\n`);
await runTicketsParallel(runnableTickets, agentConfigs, cwd, dryRun, maxParallel);
}
else {
console.log("Running sequentially...\n");
await runTicketsSequential(runnableTickets, agentConfigs, cwd, dryRun);
}
const totalDuration = Date.now() - startTime;
// Show final status
console.log("\n✨ Orchestration Complete!\n");
console.log(`Total time: ${(totalDuration / 1000).toFixed(1)}s`);
console.log("\nView status: npx arela status --verbose");
console.log("View logs: ls -la logs/\n");
}
//# sourceMappingURL=orchestrate.js.map