diff options
Diffstat (limited to 'admin-panel/src/server')
27 files changed, 2250 insertions, 0 deletions
diff --git a/admin-panel/src/server/agents/base-agent.ts b/admin-panel/src/server/agents/base-agent.ts new file mode 100644 index 0000000..f9e49f7 --- /dev/null +++ b/admin-panel/src/server/agents/base-agent.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { callClaude, type ClaudeResponse } from '../services/claude.service.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, '..', 'templates'); + +export interface AgentContext { + taskId: number; + title: string; + description: string; + plan?: string; + decisionLog?: string; + doneWhen?: string; + implementationNotes?: string; + diff?: string; + testResults?: string; + reviewComments?: string; + branchName?: string; + repoPath?: string; + workspacePath?: string; + fileList?: string; + fileContents?: string; +} + +export interface AgentResult { + success: boolean; + content: string; + tokensUsed: number; + model: string; + updates: Record<string, unknown>; // Fields to update on the task + verdict?: 'approve' | 'reject'; // For review agents + message: string; // Log message +} + +export abstract class BaseAgent { + abstract name: string; + abstract templateFile: string; + abstract model: string; + + protected loadTemplate(): string { + return readFileSync(join(TEMPLATES_DIR, this.templateFile), 'utf-8'); + } + + protected fillTemplate(template: string, ctx: AgentContext): string { + return template + .replace(/\{\{taskId\}\}/g, String(ctx.taskId)) + .replace(/\{\{title\}\}/g, ctx.title || '') + .replace(/\{\{description\}\}/g, ctx.description || '') + .replace(/\{\{plan\}\}/g, ctx.plan || '') + .replace(/\{\{decision_log\}\}/g, ctx.decisionLog || '') + .replace(/\{\{done_when\}\}/g, ctx.doneWhen || '') + .replace(/\{\{implementation_notes\}\}/g, ctx.implementationNotes || '') + .replace(/\{\{diff\}\}/g, ctx.diff || '') + .replace(/\{\{test_results\}\}/g, ctx.testResults || '') + .replace(/\{\{review_comments\}\}/g, ctx.reviewComments || '') + .replace(/\{\{branch_name\}\}/g, ctx.branchName || '') + .replace(/\{\{file_list\}\}/g, ctx.fileList || '') + .replace(/\{\{file_contents\}\}/g, ctx.fileContents || ''); + } + + async execute(ctx: AgentContext): Promise<AgentResult> { + const template = this.loadTemplate(); + const prompt = this.fillTemplate(template, ctx); + + const response = await callClaude({ + model: this.model, + systemPrompt: this.getSystemPrompt(), + userMessage: prompt, + maxTokens: this.getMaxTokens(), + }); + + return this.parseResponse(response, ctx); + } + + protected abstract getSystemPrompt(): string; + protected abstract parseResponse(response: ClaudeResponse, ctx: AgentContext): AgentResult; + + protected getMaxTokens(): number { + return 8192; + } +} diff --git a/admin-panel/src/server/agents/builder.ts b/admin-panel/src/server/agents/builder.ts new file mode 100644 index 0000000..4e555be --- /dev/null +++ b/admin-panel/src/server/agents/builder.ts @@ -0,0 +1,64 @@ +import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js'; +import type { ClaudeResponse } from '../services/claude.service.js'; + +export class BuilderAgent extends BaseAgent { + name = 'builder'; + templateFile = 'builder.md'; + model = 'claude-opus-4-20250514'; + + protected getSystemPrompt(): string { + return 'You are an expert software engineer. Write clean, correct, production-quality code. Follow the plan precisely. Output complete file contents — never use placeholders or ellipses.'; + } + + protected getMaxTokens(): number { + return 16384; + } + + protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult { + const content = response.content; + + // Extract files from ```FILE: path``` blocks + const files = parseFileBlocks(content); + const summary = extractSection(content, 'Summary') || ''; + const implNotes = extractSection(content, 'Implementation Notes') || ''; + + return { + success: true, + content, + tokensUsed: response.tokensUsed, + model: response.model, + updates: { + implementation_notes: implNotes || summary, + status: 'impl_review', + // files are handled by pipeline.service.ts which reads them from content + }, + message: `Implemented ${files.length} file(s)`, + }; + } +} + +export interface FileChange { + path: string; + content: string; +} + +export function parseFileBlocks(text: string): FileChange[] { + const files: FileChange[] = []; + const regex = /```FILE:\s*(.+?)\n([\s\S]*?)```/g; + let match; + + while ((match = regex.exec(text)) !== null) { + files.push({ + path: match[1].trim(), + content: match[2], + }); + } + + return files; +} + +function extractSection(text: string, heading: string): string | null { + const regex = new RegExp(`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=###|$)`, 'i'); + const match = text.match(regex); + return match ? match[1].trim() : null; +} diff --git a/admin-panel/src/server/agents/critic.ts b/admin-panel/src/server/agents/critic.ts new file mode 100644 index 0000000..b0613fb --- /dev/null +++ b/admin-panel/src/server/agents/critic.ts @@ -0,0 +1,55 @@ +import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js'; +import type { ClaudeResponse } from '../services/claude.service.js'; + +export class CriticAgent extends BaseAgent { + name = 'critic'; + templateFile = 'critic.md'; + model = 'claude-sonnet-4-20250514'; + + protected getSystemPrompt(): string { + return 'You are a thorough plan reviewer. Be constructive but rigorous. Approve plans that are solid enough to implement, reject plans with significant gaps or flaws.'; + } + + protected parseResponse(response: ClaudeResponse, ctx: AgentContext): AgentResult { + const content = response.content; + const firstLine = content.split('\n')[0].trim().toUpperCase(); + const approved = firstLine.includes('APPROVE'); + const rejected = firstLine.includes('REJECT'); + const verdict: 'approve' | 'reject' = approved ? 'approve' : 'reject'; + + // Build review entry + const reviewEntry = { + agent: 'critic', + verdict, + content, + timestamp: new Date().toISOString(), + }; + + const updates: Record<string, unknown> = {}; + const currentReviews = ctx.reviewComments ? JSON.parse(ctx.reviewComments || '[]') : []; + currentReviews.push(reviewEntry); + updates.plan_review_comments = JSON.stringify(currentReviews); + + if (approved) { + updates.status = 'impl'; + updates.plan_review_count = (ctx as unknown as Record<string, number>).plan_review_count + ? (ctx as unknown as Record<string, number>).plan_review_count + 1 + : 1; + } else if (rejected) { + updates.status = 'plan'; // Send back to planning + updates.plan_review_count = (ctx as unknown as Record<string, number>).plan_review_count + ? (ctx as unknown as Record<string, number>).plan_review_count + 1 + : 1; + } + + return { + success: true, + content, + tokensUsed: response.tokensUsed, + model: response.model, + updates, + verdict, + message: `Plan review: ${verdict.toUpperCase()}`, + }; + } +} diff --git a/admin-panel/src/server/agents/inspector.ts b/admin-panel/src/server/agents/inspector.ts new file mode 100644 index 0000000..575c34e --- /dev/null +++ b/admin-panel/src/server/agents/inspector.ts @@ -0,0 +1,54 @@ +import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js'; +import type { ClaudeResponse } from '../services/claude.service.js'; + +export class InspectorAgent extends BaseAgent { + name = 'inspector'; + templateFile = 'inspector.md'; + model = 'claude-sonnet-4-20250514'; + + protected getSystemPrompt(): string { + return 'You are a senior code reviewer. Be thorough but pragmatic. Focus on correctness and security. Approve code that is production-ready, reject code with significant issues.'; + } + + protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult { + const content = response.content; + const firstLine = content.split('\n')[0].trim().toUpperCase(); + const approved = firstLine.includes('APPROVE'); + const verdict: 'approve' | 'reject' = approved ? 'approve' : 'reject'; + + const reviewEntry = { + agent: 'inspector', + verdict, + content, + timestamp: new Date().toISOString(), + }; + + const updates: Record<string, unknown> = {}; + + // Parse existing review comments + let currentReviews: unknown[]; + try { + currentReviews = JSON.parse(_ctx.reviewComments || '[]'); + } catch { + currentReviews = []; + } + currentReviews.push(reviewEntry); + updates.review_comments = JSON.stringify(currentReviews); + + if (approved) { + updates.status = 'test'; + } else { + updates.status = 'impl'; // Send back to builder + } + + return { + success: true, + content, + tokensUsed: response.tokensUsed, + model: response.model, + updates, + verdict, + message: `Code review: ${verdict.toUpperCase()}`, + }; + } +} diff --git a/admin-panel/src/server/agents/planner.ts b/admin-panel/src/server/agents/planner.ts new file mode 100644 index 0000000..eb004f9 --- /dev/null +++ b/admin-panel/src/server/agents/planner.ts @@ -0,0 +1,45 @@ +import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js'; +import type { ClaudeResponse } from '../services/claude.service.js'; + +export class PlannerAgent extends BaseAgent { + name = 'planner'; + templateFile = 'planner.md'; + model = 'claude-opus-4-20250514'; + + protected getSystemPrompt(): string { + return 'You are a meticulous software architect who creates clear, actionable implementation plans. Be thorough but concise. Focus on practical steps, not theory.'; + } + + protected getMaxTokens(): number { + return 16384; + } + + protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult { + const content = response.content; + + // Extract sections + const plan = content; + const doneWhen = extractSection(content, 'Done When') || ''; + const decisionLog = extractSection(content, 'Decisions') || ''; + + return { + success: true, + content, + tokensUsed: response.tokensUsed, + model: response.model, + updates: { + plan, + done_when: doneWhen, + decision_log: decisionLog, + status: 'plan_review', + }, + message: 'Generated implementation plan', + }; + } +} + +function extractSection(text: string, heading: string): string | null { + const regex = new RegExp(`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=###|$)`, 'i'); + const match = text.match(regex); + return match ? match[1].trim() : null; +} diff --git a/admin-panel/src/server/agents/ranger.ts b/admin-panel/src/server/agents/ranger.ts new file mode 100644 index 0000000..f1f3918 --- /dev/null +++ b/admin-panel/src/server/agents/ranger.ts @@ -0,0 +1,54 @@ +import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js'; +import type { ClaudeResponse } from '../services/claude.service.js'; + +export class RangerAgent extends BaseAgent { + name = 'ranger'; + templateFile = 'ranger.md'; + model = 'claude-sonnet-4-20250514'; + + protected getSystemPrompt(): string { + return 'You are a QA engineer making the final pass/fail decision. Be fair but thorough. Only pass tasks that genuinely meet their acceptance criteria.'; + } + + protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult { + const content = response.content; + const firstLine = content.split('\n')[0].trim().toUpperCase(); + const passed = firstLine.includes('PASS'); + const verdict: 'approve' | 'reject' = passed ? 'approve' : 'reject'; + + const testEntry = { + agent: 'ranger', + verdict: passed ? 'pass' : 'fail', + content, + timestamp: new Date().toISOString(), + }; + + const updates: Record<string, unknown> = {}; + + let currentResults: unknown[]; + try { + currentResults = JSON.parse(_ctx.testResults || '[]'); + } catch { + currentResults = []; + } + currentResults.push(testEntry); + updates.test_results = JSON.stringify(currentResults); + + if (passed) { + updates.status = 'done'; + updates.completed_at = new Date().toISOString(); + } else { + updates.status = 'impl'; // Send back to builder + } + + return { + success: true, + content, + tokensUsed: response.tokensUsed, + model: response.model, + updates, + verdict, + message: `Final verdict: ${passed ? 'PASS' : 'FAIL'}`, + }; + } +} diff --git a/admin-panel/src/server/agents/shield.ts b/admin-panel/src/server/agents/shield.ts new file mode 100644 index 0000000..2ce0bc1 --- /dev/null +++ b/admin-panel/src/server/agents/shield.ts @@ -0,0 +1,62 @@ +import { BaseAgent, type AgentContext, type AgentResult } from './base-agent.js'; +import type { ClaudeResponse } from '../services/claude.service.js'; + +export class ShieldAgent extends BaseAgent { + name = 'shield'; + templateFile = 'shield.md'; + model = 'claude-sonnet-4-20250514'; + + protected getSystemPrompt(): string { + return 'You are a test engineer who writes thorough, practical tests. Focus on verifying requirements and catching regressions. Use appropriate testing frameworks.'; + } + + protected getMaxTokens(): number { + return 12288; + } + + protected parseResponse(response: ClaudeResponse, _ctx: AgentContext): AgentResult { + const content = response.content; + + // Extract test files + const files = parseTestFiles(content); + const strategy = extractSection(content, 'Test Strategy') || ''; + + return { + success: true, + content, + tokensUsed: response.tokensUsed, + model: response.model, + updates: { + // Test files are written by pipeline service + // Test results will be populated after running + }, + message: `Generated ${files.length} test file(s)`, + }; + } +} + +interface TestFile { + path: string; + content: string; +} + +function parseTestFiles(text: string): TestFile[] { + const files: TestFile[] = []; + const regex = /```FILE:\s*(.+?)\n([\s\S]*?)```/g; + let match; + + while ((match = regex.exec(text)) !== null) { + files.push({ + path: match[1].trim(), + content: match[2], + }); + } + + return files; +} + +function extractSection(text: string, heading: string): string | null { + const regex = new RegExp(`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=###|$)`, 'i'); + const match = text.match(regex); + return match ? match[1].trim() : null; +} diff --git a/admin-panel/src/server/db.ts b/admin-panel/src/server/db.ts new file mode 100644 index 0000000..9c83031 --- /dev/null +++ b/admin-panel/src/server/db.ts @@ -0,0 +1,82 @@ +import Database from 'better-sqlite3'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { mkdirSync, existsSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = join(process.cwd(), 'data'); +const SCHEMA_PATH = join(__dirname, 'schema.sql'); + +const dbCache = new Map<string, Database.Database>(); + +function ensureDataDir(): void { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function loadSchema(): { globalSchema: string; projectSchema: string } { + const full = readFileSync(SCHEMA_PATH, 'utf-8'); + const marker = '-- === PROJECT_SCHEMA ==='; + const idx = full.indexOf(marker); + if (idx === -1) { + return { globalSchema: full, projectSchema: '' }; + } + return { + globalSchema: full.substring(0, idx).trim(), + projectSchema: full.substring(idx + marker.length).trim(), + }; +} + +export function getAdminDb(): Database.Database { + if (dbCache.has('admin')) { + return dbCache.get('admin')!; + } + ensureDataDir(); + const db = new Database(join(DATA_DIR, 'admin.db')); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + const { globalSchema } = loadSchema(); + db.exec(globalSchema); + + dbCache.set('admin', db); + return db; +} + +export function getProjectDb(projectName: string): Database.Database { + if (dbCache.has(projectName)) { + return dbCache.get(projectName)!; + } + ensureDataDir(); + const db = new Database(join(DATA_DIR, `${projectName}.db`)); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + const { projectSchema } = loadSchema(); + db.exec(projectSchema); + + dbCache.set(projectName, db); + return db; +} + +export function closeAll(): void { + for (const [name, db] of dbCache) { + db.close(); + dbCache.delete(name); + } +} + +export function getSetting(key: string): string | undefined { + const db = getAdminDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as + | { value: string } + | undefined; + return row?.value; +} + +export function setSetting(key: string, value: string): void { + const db = getAdminDb(); + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); +} diff --git a/admin-panel/src/server/index.ts b/admin-panel/src/server/index.ts new file mode 100644 index 0000000..f76602e --- /dev/null +++ b/admin-panel/src/server/index.ts @@ -0,0 +1,46 @@ +import express from 'express'; +import { createServer } from 'http'; +import { join, dirname } from 'path'; +import { existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { setupWebSocket } from './ws.js'; +import projectsRouter from './routes/projects.js'; +import tasksRouter from './routes/tasks.js'; +import pipelineRouter from './routes/pipeline.js'; +import gerritRouter from './routes/gerrit.js'; +import dashboardRouter from './routes/dashboard.js'; +import settingsRouter from './routes/settings.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = express(); +const server = createServer(app); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(express.json()); + +// API routes +app.use('/api/projects', projectsRouter); +app.use('/api/projects/:project/tasks', tasksRouter); +app.use('/api/projects/:project/pipeline', pipelineRouter); +app.use('/api/projects/:project/gerrit', gerritRouter); +app.use('/api/dashboard', dashboardRouter); +app.use('/api/settings', settingsRouter); + +// Serve static client in production +const clientDir = join(__dirname, '..', 'client'); +if (existsSync(clientDir)) { + app.use(express.static(clientDir)); + app.get('*', (_req, res) => { + res.sendFile(join(clientDir, 'index.html')); + }); +} + +// WebSocket +setupWebSocket(server); + +server.listen(PORT, () => { + console.log(`Admin panel running on port ${PORT}`); +}); + +export { app, server }; diff --git a/admin-panel/src/server/routes/dashboard.ts b/admin-panel/src/server/routes/dashboard.ts new file mode 100644 index 0000000..3142e8e --- /dev/null +++ b/admin-panel/src/server/routes/dashboard.ts @@ -0,0 +1,138 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; + +const router = Router(); + +interface Project { + id: number; + name: string; + display_name: string; +} + +// GET /api/dashboard/stats +router.get('/stats', (_req: Request, res: Response) => { + const adminDb = getAdminDb(); + const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[]; + + let totalTasks = 0; + let completedToday = 0; + const tasksByStatus: Record<string, number> = {}; + + const today = new Date().toISOString().split('T')[0]; + + for (const project of projects) { + try { + const db = getProjectDb(project.name); + const tasks = db.prepare('SELECT status, completed_at FROM tasks').all() as Array<{ + status: string; + completed_at: string | null; + }>; + + totalTasks += tasks.length; + + for (const task of tasks) { + tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1; + if (task.completed_at && task.completed_at.startsWith(today)) { + completedToday++; + } + } + } catch { + // Project DB might not exist yet + } + } + + res.json({ + totalProjects: projects.length, + totalTasks, + tasksByStatus, + completedToday, + }); +}); + +// GET /api/dashboard/activity +router.get('/activity', (_req: Request, res: Response) => { + const adminDb = getAdminDb(); + const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[]; + + const activity: Array<{ + project: string; + taskId: number; + taskTitle: string; + agent: string; + action: string; + timestamp: string; + }> = []; + + for (const project of projects) { + try { + const db = getProjectDb(project.name); + const tasks = db.prepare('SELECT id, title, agent_log FROM tasks WHERE agent_log != \'[]\'').all() as Array<{ + id: number; + title: string; + agent_log: string; + }>; + + for (const task of tasks) { + try { + const logs = JSON.parse(task.agent_log) as Array<{ + agent: string; + message: string; + timestamp: string; + }>; + for (const log of logs) { + activity.push({ + project: project.name, + taskId: task.id, + taskTitle: task.title, + agent: log.agent, + action: log.message, + timestamp: log.timestamp, + }); + } + } catch { + // Skip malformed logs + } + } + } catch { + // Project DB might not exist + } + } + + // Sort by timestamp descending, take 20 most recent + activity.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + res.json(activity.slice(0, 20)); +}); + +// GET /api/projects/:project/stats +router.get('/projects/:project', (req: Request, res: Response) => { + const { project } = req.params; + + const adminDb = getAdminDb(); + if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + try { + const db = getProjectDb(project as string); + const tasks = db.prepare('SELECT status FROM tasks').all() as Array<{ status: string }>; + const tasksByStatus: Record<string, number> = {}; + for (const task of tasks) { + tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1; + } + + const totalJobs = db.prepare('SELECT COUNT(*) as count FROM pipeline_jobs').get() as { count: number }; + const totalTokens = db.prepare('SELECT SUM(tokens_used) as total FROM pipeline_jobs').get() as { total: number | null }; + + res.json({ + totalTasks: tasks.length, + tasksByStatus, + totalPipelineJobs: totalJobs.count, + totalTokensUsed: totalTokens.total || 0, + }); + } catch { + res.json({ totalTasks: 0, tasksByStatus: {}, totalPipelineJobs: 0, totalTokensUsed: 0 }); + } +}); + +export default router; diff --git a/admin-panel/src/server/routes/gerrit.ts b/admin-panel/src/server/routes/gerrit.ts new file mode 100644 index 0000000..b48ea53 --- /dev/null +++ b/admin-panel/src/server/routes/gerrit.ts @@ -0,0 +1,68 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; +import { getChange, getChangeByNumber } from '../services/gerrit.service.js'; + +const router = Router({ mergeParams: true }); + +type Params = { project: string; taskId: string }; + +// GET /api/projects/:project/gerrit/:taskId +router.get('/:taskId', async (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + + const adminDb = getAdminDb(); + if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT gerrit_change_id, gerrit_change_number FROM tasks WHERE id = ?').get(parseInt(taskId)) as { + gerrit_change_id: string | null; + gerrit_change_number: number | null; + } | undefined; + + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + if (!task.gerrit_change_id && !task.gerrit_change_number) { + res.json({ change: null }); + return; + } + + try { + const change = task.gerrit_change_id + ? await getChange(task.gerrit_change_id) + : task.gerrit_change_number + ? await getChangeByNumber(task.gerrit_change_number) + : null; + res.json({ change }); + } catch (err) { + res.status(502).json({ error: `Gerrit API error: ${(err as Error).message}` }); + } +}); + +// GET /api/projects/:project/diff/:taskId +router.get('/diff/:taskId', (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + + const adminDb = getAdminDb(); + if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT diff FROM tasks WHERE id = ?').get(parseInt(taskId)) as { diff: string | null } | undefined; + + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + res.json({ diff: task.diff || '' }); +}); + +export default router; diff --git a/admin-panel/src/server/routes/pipeline.ts b/admin-panel/src/server/routes/pipeline.ts new file mode 100644 index 0000000..39df35c --- /dev/null +++ b/admin-panel/src/server/routes/pipeline.ts @@ -0,0 +1,82 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb } from '../db.js'; +import { + runFullPipeline, + runStep, + stopPipeline, + getPipelineStatus, + isPipelineRunning, +} from '../services/pipeline.service.js'; + +const router = Router({ mergeParams: true }); + +type Params = { project: string; taskId: string }; + +function projectExists(name: string): boolean { + const db = getAdminDb(); + return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(name); +} + +// POST /api/projects/:project/pipeline/run/:taskId +router.post('/run/:taskId', async (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const id = parseInt(taskId); + if (isPipelineRunning(project, id)) { + res.status(409).json({ error: 'Pipeline already running for this task' }); + return; + } + + // Run async — don't block the response + runFullPipeline(project, id).catch(err => { + console.error(`Pipeline error for ${project}/${taskId}:`, err); + }); + + res.json({ status: 'started' }); +}); + +// POST /api/projects/:project/pipeline/step/:taskId +router.post('/step/:taskId', async (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + try { + await runStep(project, parseInt(taskId)); + res.json({ status: 'completed' }); + } catch (err) { + res.status(400).json({ error: (err as Error).message }); + } +}); + +// POST /api/projects/:project/pipeline/stop/:taskId +router.post('/stop/:taskId', (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + stopPipeline(project, parseInt(taskId)); + res.json({ status: 'stopping' }); +}); + +// GET /api/projects/:project/pipeline/status/:taskId +router.get('/status/:taskId', (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const status = getPipelineStatus(project, parseInt(taskId)); + res.json(status); +}); + +export default router; diff --git a/admin-panel/src/server/routes/projects.ts b/admin-panel/src/server/routes/projects.ts new file mode 100644 index 0000000..b2e63a8 --- /dev/null +++ b/admin-panel/src/server/routes/projects.ts @@ -0,0 +1,138 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; +import { createBareRepo, setRepoDescription, repoExists, deleteRepo } from '../services/git.service.js'; + +const router = Router(); + +interface Project { + id: number; + name: string; + display_name: string; + description: string | null; + repo_name: string; + repo_path: string; + default_branch: string; + created_at: string; + updated_at: string; +} + +// GET /api/projects +router.get('/', (_req: Request, res: Response) => { + const db = getAdminDb(); + const projects = db.prepare('SELECT * FROM projects ORDER BY created_at DESC').all(); + res.json(projects); +}); + +// POST /api/projects +router.post('/', (req: Request, res: Response) => { + const { name, display_name, description } = req.body; + + if (!name || !display_name) { + res.status(400).json({ error: 'name and display_name are required' }); + return; + } + + // Sanitize name: lowercase, alphanumeric + hyphens + const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + if (!safeName) { + res.status(400).json({ error: 'Invalid project name' }); + return; + } + + const repoName = `${safeName}.git`; + const db = getAdminDb(); + + // Check for duplicate + const existing = db.prepare('SELECT id FROM projects WHERE name = ?').get(safeName); + if (existing) { + res.status(409).json({ error: 'Project already exists' }); + return; + } + + // Create bare git repo + const repo = createBareRepo(repoName); + if (description) { + setRepoDescription(repoName, description); + } + + // Insert into admin DB + const result = db.prepare( + 'INSERT INTO projects (name, display_name, description, repo_name, repo_path) VALUES (?, ?, ?, ?, ?)' + ).run(safeName, display_name, description || null, repoName, repo.path); + + // Initialize project DB (creates tables) + getProjectDb(safeName); + + const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json(project); +}); + +// GET /api/projects/:name +router.get('/:name', (req: Request, res: Response) => { + const db = getAdminDb(); + const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined; + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + res.json(project); +}); + +// PATCH /api/projects/:name +router.patch('/:name', (req: Request, res: Response) => { + const db = getAdminDb(); + const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined; + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const { display_name, description } = req.body; + const updates: string[] = []; + const params: unknown[] = []; + + if (display_name !== undefined) { + updates.push('display_name = ?'); + params.push(display_name); + } + if (description !== undefined) { + updates.push('description = ?'); + params.push(description); + if (description) { + setRepoDescription(project.repo_name, description); + } + } + + if (updates.length === 0) { + res.json(project); + return; + } + + updates.push("updated_at = datetime('now')"); + params.push(req.params.name); + + db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE name = ?`).run(...params); + const updated = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name); + res.json(updated); +}); + +// DELETE /api/projects/:name +router.delete('/:name', (req: Request, res: Response) => { + const db = getAdminDb(); + const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined; + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + db.prepare('DELETE FROM projects WHERE name = ?').run(req.params.name); + + // Optionally delete repo (controlled by query param) + if (req.query.delete_repo === 'true') { + deleteRepo(project.repo_name); + } + + res.status(204).send(); +}); + +export default router; diff --git a/admin-panel/src/server/routes/settings.ts b/admin-panel/src/server/routes/settings.ts new file mode 100644 index 0000000..4eed6f1 --- /dev/null +++ b/admin-panel/src/server/routes/settings.ts @@ -0,0 +1,35 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb } from '../db.js'; + +const router = Router(); + +// GET /api/settings +router.get('/', (_req: Request, res: Response) => { + const db = getAdminDb(); + const rows = db.prepare('SELECT key, value FROM settings').all() as Array<{ + key: string; + value: string; + }>; + const settings: Record<string, string> = {}; + for (const row of rows) { + settings[row.key] = row.value; + } + res.json(settings); +}); + +// PATCH /api/settings +router.patch('/', (req: Request, res: Response) => { + const db = getAdminDb(); + const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + + const updates = req.body as Record<string, string>; + for (const [key, value] of Object.entries(updates)) { + if (typeof key === 'string' && typeof value === 'string') { + stmt.run(key, value); + } + } + + res.status(204).send(); +}); + +export default router; diff --git a/admin-panel/src/server/routes/tasks.ts b/admin-panel/src/server/routes/tasks.ts new file mode 100644 index 0000000..1ab1e8d --- /dev/null +++ b/admin-panel/src/server/routes/tasks.ts @@ -0,0 +1,248 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; +import { broadcast } from '../ws.js'; + +const router = Router({ mergeParams: true }); + +type Params = { project: string; id?: string }; + +interface Task { + id: number; + title: string; + status: string; + priority: string; + level: number; + description: string | null; + plan: string | null; + decision_log: string | null; + done_when: string | null; + implementation_notes: string | null; + tags: string; + plan_review_comments: string; + review_comments: string; + test_results: string; + agent_log: string; + current_agent: string | null; + plan_review_count: number; + impl_review_count: number; + branch_name: string | null; + gerrit_change_id: string | null; + gerrit_change_number: number | null; + diff: string | null; + rank: number; + created_at: string; + started_at: string | null; + planned_at: string | null; + reviewed_at: string | null; + tested_at: string | null; + completed_at: string | null; +} + +const VALID_STATUSES = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done']; +const VALID_PRIORITIES = ['high', 'medium', 'low']; + +function getProject(projectName: string): boolean { + const db = getAdminDb(); + return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(projectName); +} + +// GET /api/projects/:project/tasks +router.get('/', (req: Request<Params>, res: Response) => { + const { project } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const tasks = db.prepare('SELECT * FROM tasks ORDER BY rank ASC, id ASC').all(); + res.json(tasks); +}); + +// POST /api/projects/:project/tasks +router.post('/', (req: Request<Params>, res: Response) => { + const { project } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const { title, description, priority, level, tags, status } = req.body; + if (!title) { + res.status(400).json({ error: 'title is required' }); + return; + } + + const db = getProjectDb(project); + + // Get max rank for ordering + const maxRank = (db.prepare('SELECT MAX(rank) as max FROM tasks').get() as { max: number | null })?.max || 0; + + const result = db.prepare( + `INSERT INTO tasks (title, description, priority, level, tags, status, rank) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + title, + description || null, + VALID_PRIORITIES.includes(priority) ? priority : 'medium', + [1, 2, 3].includes(level) ? level : 3, + JSON.stringify(tags || []), + VALID_STATUSES.includes(status) ? status : 'todo', + maxRank + 1 + ); + + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid); + broadcast(project, { type: 'task:created', project, task }); + res.status(201).json(task); +}); + +// GET /api/projects/:project/tasks/:id +router.get('/:id', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + res.json(task); +}); + +// PATCH /api/projects/:project/tasks/:id +router.patch('/:id', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as Task | undefined; + if (!existing) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + const allowedFields = [ + 'title', 'status', 'priority', 'level', 'description', 'plan', + 'decision_log', 'done_when', 'implementation_notes', 'tags', + 'plan_review_comments', 'review_comments', 'test_results', + 'agent_log', 'current_agent', 'plan_review_count', 'impl_review_count', + 'branch_name', 'gerrit_change_id', 'gerrit_change_number', 'diff', 'rank' + ]; + + const updates: string[] = []; + const params: unknown[] = []; + + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + let value = req.body[field]; + + // Validate specific fields + if (field === 'status' && !VALID_STATUSES.includes(value)) continue; + if (field === 'priority' && !VALID_PRIORITIES.includes(value)) continue; + if (field === 'level' && ![1, 2, 3].includes(value)) continue; + + // Stringify JSON fields + if (['tags', 'plan_review_comments', 'review_comments', 'test_results', 'agent_log'].includes(field)) { + value = typeof value === 'string' ? value : JSON.stringify(value); + } + + updates.push(`${field} = ?`); + params.push(value); + } + } + + // Set timestamp fields based on status changes + if (req.body.status) { + const now = new Date().toISOString(); + switch (req.body.status) { + case 'plan': + if (!existing.started_at) { + updates.push('started_at = ?'); + params.push(now); + } + break; + case 'impl': + updates.push('planned_at = ?'); + params.push(now); + break; + case 'test': + updates.push('reviewed_at = ?'); + params.push(now); + break; + case 'done': + updates.push('completed_at = ?'); + params.push(now); + break; + } + } + + if (updates.length === 0) { + res.json(existing); + return; + } + + params.push(id); + db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + broadcast(project, { type: 'task:updated', project, task }); + res.json(task); +}); + +// DELETE /api/projects/:project/tasks/:id +router.delete('/:id', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + db.prepare('DELETE FROM tasks WHERE id = ?').run(id); + broadcast(project, { type: 'task:deleted', project, taskId: Number(id) }); + res.status(204).send(); +}); + +// PATCH /api/projects/:project/tasks/:id/reorder +router.patch('/:id/reorder', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const { rank, status } = req.body; + if (rank === undefined) { + res.status(400).json({ error: 'rank is required' }); + return; + } + + const db = getProjectDb(project); + const updates: string[] = ['rank = ?']; + const params: unknown[] = [rank]; + + if (status && VALID_STATUSES.includes(status)) { + updates.push('status = ?'); + params.push(status); + } + + params.push(id); + db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + broadcast(project, { type: 'task:updated', project, task }); + res.json(task); +}); + +export default router; diff --git a/admin-panel/src/server/schema.sql b/admin-panel/src/server/schema.sql new file mode 100644 index 0000000..36c0e40 --- /dev/null +++ b/admin-panel/src/server/schema.sql @@ -0,0 +1,73 @@ +-- Global database schema (admin.db) + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + repo_name TEXT NOT NULL, + repo_path TEXT NOT NULL, + default_branch TEXT DEFAULT 'main', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Default settings +INSERT OR IGNORE INTO settings (key, value) VALUES + ('anthropic_model_planning', 'claude-opus-4-20250514'), + ('anthropic_model_review', 'claude-sonnet-4-20250514'), + ('anthropic_model_implementation', 'claude-opus-4-20250514'), + ('anthropic_model_testing', 'claude-sonnet-4-20250514'); + +-- Per-project database schema (applied to {project-name}.db) +-- This comment marks where project schema starts; code splits on this marker. +-- === PROJECT_SCHEMA === + +CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'todo', + priority TEXT NOT NULL DEFAULT 'medium', + level INTEGER NOT NULL DEFAULT 3, + description TEXT, + plan TEXT, + decision_log TEXT, + done_when TEXT, + implementation_notes TEXT, + tags TEXT DEFAULT '[]', + plan_review_comments TEXT DEFAULT '[]', + review_comments TEXT DEFAULT '[]', + test_results TEXT DEFAULT '[]', + agent_log TEXT DEFAULT '[]', + current_agent TEXT, + plan_review_count INTEGER DEFAULT 0, + impl_review_count INTEGER DEFAULT 0, + branch_name TEXT, + gerrit_change_id TEXT, + gerrit_change_number INTEGER, + diff TEXT, + rank REAL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + started_at TEXT, + planned_at TEXT, + reviewed_at TEXT, + tested_at TEXT, + completed_at TEXT +); + +CREATE TABLE IF NOT EXISTS pipeline_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL REFERENCES tasks(id), + agent TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + model TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + error TEXT, + tokens_used INTEGER DEFAULT 0 +); diff --git a/admin-panel/src/server/services/claude.service.ts b/admin-panel/src/server/services/claude.service.ts new file mode 100644 index 0000000..b9aae37 --- /dev/null +++ b/admin-panel/src/server/services/claude.service.ts @@ -0,0 +1,47 @@ +import Anthropic from '@anthropic-ai/sdk'; + +let client: Anthropic | null = null; + +function getClient(): Anthropic { + if (!client) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) throw new Error('ANTHROPIC_API_KEY not configured'); + client = new Anthropic({ apiKey }); + } + return client; +} + +export interface ClaudeResponse { + content: string; + tokensUsed: number; + model: string; +} + +export async function callClaude(opts: { + model: string; + systemPrompt: string; + userMessage: string; + maxTokens?: number; +}): Promise<ClaudeResponse> { + const anthropic = getClient(); + + const response = await anthropic.messages.create({ + model: opts.model, + max_tokens: opts.maxTokens || 8192, + system: opts.systemPrompt, + messages: [{ role: 'user', content: opts.userMessage }], + }); + + const content = response.content + .filter(block => block.type === 'text') + .map(block => (block as { type: 'text'; text: string }).text) + .join('\n'); + + const tokensUsed = (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0); + + return { + content, + tokensUsed, + model: opts.model, + }; +} diff --git a/admin-panel/src/server/services/gerrit.service.ts b/admin-panel/src/server/services/gerrit.service.ts new file mode 100644 index 0000000..53c8f13 --- /dev/null +++ b/admin-panel/src/server/services/gerrit.service.ts @@ -0,0 +1,81 @@ +const GERRIT_URL = process.env.GERRIT_URL || 'http://gerrit:8080'; + +export interface GerritChange { + id: string; + change_id: string; + _number: number; + subject: string; + status: string; + created: string; + updated: string; + mergeable?: boolean; + labels?: Record<string, unknown>; +} + +export interface GerritReview { + message: string; + labels?: Record<string, number>; + comments?: Record<string, Array<{ line: number; message: string }>>; +} + +async function gerritFetch(path: string, options?: RequestInit): Promise<unknown> { + const url = `${GERRIT_URL}/a${path}`; + const res = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + ...options, + }); + + if (!res.ok) { + throw new Error(`Gerrit API error: ${res.status} ${res.statusText}`); + } + + const text = await res.text(); + // Gerrit prepends )]}' to JSON responses + const json = text.startsWith(")]}'") ? text.slice(4) : text; + return json ? JSON.parse(json) : null; +} + +export async function getChange(changeId: string): Promise<GerritChange | null> { + try { + return (await gerritFetch(`/changes/${encodeURIComponent(changeId)}`)) as GerritChange; + } catch { + return null; + } +} + +export async function getChangeByNumber(changeNumber: number): Promise<GerritChange | null> { + try { + return (await gerritFetch(`/changes/${changeNumber}`)) as GerritChange; + } catch { + return null; + } +} + +export async function postReview(changeId: string, review: GerritReview): Promise<void> { + await gerritFetch(`/changes/${encodeURIComponent(changeId)}/revisions/current/review`, { + method: 'POST', + body: JSON.stringify(review), + }); +} + +export async function getChangeDiff(changeId: string): Promise<string> { + const result = await gerritFetch( + `/changes/${encodeURIComponent(changeId)}/revisions/current/patch` + ); + // Patch is base64 encoded + if (typeof result === 'string') { + return Buffer.from(result, 'base64').toString('utf-8'); + } + return ''; +} + +export async function queryChanges(query: string): Promise<GerritChange[]> { + try { + return (await gerritFetch(`/changes/?q=${encodeURIComponent(query)}`)) as GerritChange[]; + } catch { + return []; + } +} diff --git a/admin-panel/src/server/services/git.service.ts b/admin-panel/src/server/services/git.service.ts new file mode 100644 index 0000000..990f5df --- /dev/null +++ b/admin-panel/src/server/services/git.service.ts @@ -0,0 +1,142 @@ +import { execSync } from 'child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; + +const REPO_BASE = process.env.GIT_REPO_PATH || '/repos'; + +export interface RepoInfo { + name: string; + path: string; + exists: boolean; +} + +export function createBareRepo(repoName: string, defaultBranch = 'main'): RepoInfo { + const repoPath = join(REPO_BASE, repoName); + + if (existsSync(repoPath)) { + return { name: repoName, path: repoPath, exists: true }; + } + + execSync(`git init --bare "${repoPath}"`, { stdio: 'pipe' }); + execSync(`git -C "${repoPath}" symbolic-ref HEAD refs/heads/${defaultBranch}`, { + stdio: 'pipe', + }); + + // Set description for cgit + const descPath = join(repoPath, 'description'); + execSync(`echo "Repository managed by admin-panel" > "${descPath}"`, { stdio: 'pipe' }); + + // Enable post-update hook for git daemon + const hookPath = join(repoPath, 'hooks', 'post-update'); + if (!existsSync(hookPath)) { + execSync(`cp "${repoPath}/hooks/post-update.sample" "${hookPath}" 2>/dev/null || true`, { + stdio: 'pipe', + }); + } + + // Run git update-server-info for HTTP access + execSync(`git -C "${repoPath}" update-server-info`, { stdio: 'pipe' }); + + return { name: repoName, path: repoPath, exists: false }; +} + +export function setRepoDescription(repoName: string, description: string): void { + const descPath = join(REPO_BASE, repoName, 'description'); + execSync(`echo "${description.replace(/"/g, '\\"')}" > "${descPath}"`, { stdio: 'pipe' }); +} + +export function repoExists(repoName: string): boolean { + return existsSync(join(REPO_BASE, repoName)); +} + +export function deleteRepo(repoName: string): void { + const repoPath = join(REPO_BASE, repoName); + if (existsSync(repoPath)) { + execSync(`rm -rf "${repoPath}"`, { stdio: 'pipe' }); + } +} + +// --- Workspace operations for Builder agent --- + +export async function cloneRepo(bareRepoPath: string, workspacePath: string): Promise<void> { + if (existsSync(workspacePath)) { + // Pull latest instead of re-cloning + execSync(`git -C "${workspacePath}" fetch origin`, { stdio: 'pipe' }); + execSync(`git -C "${workspacePath}" reset --hard origin/main 2>/dev/null || true`, { stdio: 'pipe' }); + return; + } + mkdirSync(workspacePath, { recursive: true }); + execSync(`git clone "${bareRepoPath}" "${workspacePath}"`, { stdio: 'pipe' }); + + // Configure git user for commits + execSync(`git -C "${workspacePath}" config user.email "admin-panel@swave.lol"`, { stdio: 'pipe' }); + execSync(`git -C "${workspacePath}" config user.name "Admin Panel"`, { stdio: 'pipe' }); +} + +export async function createBranch(workspacePath: string, branchName: string): Promise<void> { + // Create or switch to branch + try { + execSync(`git -C "${workspacePath}" checkout -b "${branchName}"`, { stdio: 'pipe' }); + } catch { + // Branch might already exist + execSync(`git -C "${workspacePath}" checkout "${branchName}"`, { stdio: 'pipe' }); + } +} + +export async function writeFiles( + workspacePath: string, + files: Array<{ path: string; content: string }> +): Promise<void> { + for (const file of files) { + const fullPath = join(workspacePath, file.path); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, file.content, 'utf-8'); + } +} + +export async function commitAndDiff( + workspacePath: string, + message: string, + branchName: string +): Promise<string> { + execSync(`git -C "${workspacePath}" add -A`, { stdio: 'pipe' }); + + // Check if there are changes to commit + try { + execSync(`git -C "${workspacePath}" diff --cached --quiet`, { stdio: 'pipe' }); + // No changes + return ''; + } catch { + // There are changes — commit them + } + + execSync(`git -C "${workspacePath}" commit -m "${message.replace(/"/g, '\\"')}"`, { stdio: 'pipe' }); + + // Get diff against main + const diff = execSync( + `git -C "${workspacePath}" diff main..${branchName} 2>/dev/null || git -C "${workspacePath}" diff HEAD~1..HEAD`, + { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 } + ); + + return diff; +} + +export async function pushToGerrit(workspacePath: string): Promise<string> { + const output = execSync( + `git -C "${workspacePath}" push origin HEAD:refs/for/main 2>&1`, + { encoding: 'utf-8' } + ); + return output; +} + +export function getFileList(workspacePath: string): string { + if (!existsSync(workspacePath)) return ''; + try { + return execSync(`find "${workspacePath}" -type f -not -path "*/.git/*" | sort`, { + encoding: 'utf-8', + maxBuffer: 1024 * 1024, + }); + } catch { + return ''; + } +} diff --git a/admin-panel/src/server/services/pipeline.service.ts b/admin-panel/src/server/services/pipeline.service.ts new file mode 100644 index 0000000..afdf422 --- /dev/null +++ b/admin-panel/src/server/services/pipeline.service.ts @@ -0,0 +1,304 @@ +import { getProjectDb } from '../db.js'; +import { broadcast } from '../ws.js'; +import { BaseAgent, type AgentContext } from '../agents/base-agent.js'; +import { PlannerAgent } from '../agents/planner.js'; +import { CriticAgent } from '../agents/critic.js'; +import { BuilderAgent } from '../agents/builder.js'; +import { ShieldAgent } from '../agents/shield.js'; +import { InspectorAgent } from '../agents/inspector.js'; +import { RangerAgent } from '../agents/ranger.js'; +import { parseFileBlocks } from '../agents/builder.js'; +import { cloneRepo, createBranch, writeFiles, commitAndDiff } from './git.service.js'; + +interface Task { + id: number; + title: string; + status: string; + priority: string; + level: number; + description: string | null; + plan: string | null; + decision_log: string | null; + done_when: string | null; + implementation_notes: string | null; + tags: string; + plan_review_comments: string; + review_comments: string; + test_results: string; + agent_log: string; + current_agent: string | null; + plan_review_count: number; + impl_review_count: number; + branch_name: string | null; + diff: string | null; + rank: number; +} + +// Maps status → which agent runs +const STATUS_AGENT_MAP: Record<string, () => BaseAgent> = { + todo: () => new PlannerAgent(), + plan: () => new PlannerAgent(), + plan_review: () => new CriticAgent(), + impl: () => new BuilderAgent(), + impl_review: () => new InspectorAgent(), + test: () => new RangerAgent(), +}; + +// Level determines which steps are skipped +// L1 (quick): plan → impl → done (skip reviews and tests) +// L2 (standard): plan → plan_review → impl → impl_review → done (skip tests) +// L3 (full): all steps +const LEVEL_FLOW: Record<number, string[]> = { + 1: ['todo', 'plan', 'impl', 'done'], + 2: ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'done'], + 3: ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done'], +}; + +const MAX_RETRIES = 3; + +// Track running pipelines so we can stop them +const runningPipelines = new Map<string, { stopped: boolean }>(); + +function pipelineKey(project: string, taskId: number): string { + return `${project}:${taskId}`; +} + +export function isPipelineRunning(project: string, taskId: number): boolean { + return runningPipelines.has(pipelineKey(project, taskId)); +} + +export function stopPipeline(project: string, taskId: number): void { + const key = pipelineKey(project, taskId); + const state = runningPipelines.get(key); + if (state) { + state.stopped = true; + } +} + +export async function runStep(project: string, taskId: number): Promise<void> { + const db = getProjectDb(project); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task | undefined; + if (!task) throw new Error('Task not found'); + + if (task.status === 'done') throw new Error('Task is already done'); + + const agentFactory = STATUS_AGENT_MAP[task.status]; + if (!agentFactory) throw new Error(`No agent for status: ${task.status}`); + + const agent = agentFactory(); + await executeAgent(project, task, agent, db); +} + +export async function runFullPipeline(project: string, taskId: number): Promise<void> { + const key = pipelineKey(project, taskId); + if (runningPipelines.has(key)) throw new Error('Pipeline already running'); + + const state = { stopped: false }; + runningPipelines.set(key, state); + + try { + const db = getProjectDb(project); + let retries = 0; + + while (!state.stopped) { + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task | undefined; + if (!task) break; + if (task.status === 'done') break; + + // Check if this status is in the level flow + const flow = LEVEL_FLOW[task.level] || LEVEL_FLOW[3]; + if (!flow.includes(task.status)) { + // Skip to next valid status in the flow + const currentIdx = flow.indexOf(task.status); + if (currentIdx === -1) break; + } + + const agentFactory = STATUS_AGENT_MAP[task.status]; + if (!agentFactory) break; + + const agent = agentFactory(); + + try { + const result = await executeAgent(project, task, agent, db); + + // Check for review rejections (circuit breaker) + if (result.verdict === 'reject') { + retries++; + if (retries >= MAX_RETRIES) { + broadcast(project, { + type: 'pipeline:error', + project, + taskId, + error: `Circuit breaker: ${MAX_RETRIES} rejections reached. Pipeline stopped.`, + }); + break; + } + } else { + retries = 0; + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + broadcast(project, { + type: 'pipeline:error', + project, + taskId, + error: errorMsg, + }); + break; + } + } + + broadcast(project, { type: 'pipeline:done', project, taskId }); + } finally { + runningPipelines.delete(key); + } +} + +async function executeAgent( + project: string, + task: Task, + agent: BaseAgent, + db: ReturnType<typeof getProjectDb> +): Promise<{ verdict?: 'approve' | 'reject' }> { + // Create pipeline job + const job = db.prepare( + `INSERT INTO pipeline_jobs (task_id, agent, status, model, started_at) + VALUES (?, ?, 'running', ?, datetime('now'))` + ).run(task.id, agent.name, agent.model); + const jobId = job.lastInsertRowid; + + // Update current agent on task + db.prepare('UPDATE tasks SET current_agent = ? WHERE id = ?').run(agent.name, task.id); + broadcast(project, { type: 'pipeline:agent_start', project, taskId: task.id, agent: agent.name }); + + try { + // Build context + const ctx: AgentContext = { + taskId: task.id, + title: task.title, + description: task.description || '', + plan: task.plan || undefined, + decisionLog: task.decision_log || undefined, + doneWhen: task.done_when || undefined, + implementationNotes: task.implementation_notes || undefined, + diff: task.diff || undefined, + testResults: task.test_results || undefined, + reviewComments: task.review_comments || undefined, + branchName: task.branch_name || undefined, + }; + + // Execute agent + const result = await agent.execute(ctx); + + // Handle Builder agent's file changes + if (agent.name === 'builder' && result.content) { + const files = parseFileBlocks(result.content); + if (files.length > 0) { + try { + const branchName = task.branch_name || `kanban/task-${task.id}`; + const adminDb = (await import('../db.js')).getAdminDb(); + const projectRow = adminDb.prepare('SELECT * FROM projects WHERE name = ?').get(project) as { repo_path: string } | undefined; + + if (projectRow) { + const workspacePath = `/app/workspaces/${project}-task-${task.id}`; + await cloneRepo(projectRow.repo_path, workspacePath); + await createBranch(workspacePath, branchName); + await writeFiles(workspacePath, files); + const diff = await commitAndDiff(workspacePath, `feat: ${task.title} [kanban #${task.id}]`, branchName); + result.updates.branch_name = branchName; + result.updates.diff = diff; + } + } catch (gitErr) { + console.error('Git operation failed:', gitErr); + // Don't fail the whole pipeline over git errors + result.updates.implementation_notes = + (result.updates.implementation_notes || '') + + `\n\nGit error: ${gitErr instanceof Error ? gitErr.message : String(gitErr)}`; + } + } + } + + // Apply updates to task + const updates = result.updates; + const setClauses: string[] = []; + const params: unknown[] = []; + + for (const [key, value] of Object.entries(updates)) { + setClauses.push(`${key} = ?`); + params.push(value); + } + + // Clear current_agent + setClauses.push('current_agent = NULL'); + + if (setClauses.length > 0) { + params.push(task.id); + db.prepare(`UPDATE tasks SET ${setClauses.join(', ')} WHERE id = ?`).run(...params); + } + + // Append to agent log + const logEntry = { + agent: agent.name, + message: result.message, + verdict: result.verdict, + tokensUsed: result.tokensUsed, + model: result.model, + timestamp: new Date().toISOString(), + }; + + let agentLog: unknown[]; + try { + agentLog = JSON.parse(task.agent_log || '[]'); + } catch { + agentLog = []; + } + agentLog.push(logEntry); + db.prepare('UPDATE tasks SET agent_log = ? WHERE id = ?').run(JSON.stringify(agentLog), task.id); + + // Update pipeline job + db.prepare( + `UPDATE pipeline_jobs SET status = 'completed', completed_at = datetime('now'), tokens_used = ? WHERE id = ?` + ).run(result.tokensUsed, jobId); + + // Broadcast completion + const updatedTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task.id); + broadcast(project, { + type: 'pipeline:agent_complete', + project, + taskId: task.id, + agent: agent.name, + result: result.message, + }); + broadcast(project, { type: 'task:updated', project, task: updatedTask }); + + return { verdict: result.verdict }; + + } catch (err) { + // Mark job as failed + const errorMsg = err instanceof Error ? err.message : String(err); + db.prepare( + `UPDATE pipeline_jobs SET status = 'failed', completed_at = datetime('now'), error = ? WHERE id = ?` + ).run(errorMsg, jobId); + + // Clear current agent + db.prepare('UPDATE tasks SET current_agent = NULL WHERE id = ?').run(task.id); + + throw err; + } +} + +export function getPipelineStatus(project: string, taskId: number): { + running: boolean; + currentAgent: string | null; + jobs: unknown[]; +} { + const db = getProjectDb(project); + const task = db.prepare('SELECT current_agent FROM tasks WHERE id = ?').get(taskId) as { current_agent: string | null } | undefined; + const jobs = db.prepare('SELECT * FROM pipeline_jobs WHERE task_id = ? ORDER BY id DESC').all(taskId); + + return { + running: isPipelineRunning(project, taskId), + currentAgent: task?.current_agent || null, + jobs, + }; +} diff --git a/admin-panel/src/server/templates/builder.md b/admin-panel/src/server/templates/builder.md new file mode 100644 index 0000000..eee2cf6 --- /dev/null +++ b/admin-panel/src/server/templates/builder.md @@ -0,0 +1,47 @@ +You are a senior software engineer implementing a feature based on an approved plan. + +## Task #{{taskId}}: {{title}} + +### Requirements +{{description}} + +### Approved Plan +{{plan}} + +### Done When +{{done_when}} + +### Current Branch +{{branch_name}} + +### Repository File Listing +{{file_list}} + +### Relevant File Contents +{{file_contents}} + +## Instructions + +Implement the changes described in the plan. For each file that needs to be created or modified, output the COMPLETE file contents. + +## Output Format + +For each file, use this exact format: + +```FILE: path/to/file.ts +(complete file contents here) +``` + +After all files, provide a brief summary: + +### Summary +(what you implemented and any notes) + +### Implementation Notes +(any important details about the implementation) + +Important: +- Output the COMPLETE contents of each file (not just the changes) +- Include ALL imports, types, and code +- Follow existing code style and conventions in the repo +- Do not skip any file that needs changes diff --git a/admin-panel/src/server/templates/critic.md b/admin-panel/src/server/templates/critic.md new file mode 100644 index 0000000..dd68267 --- /dev/null +++ b/admin-panel/src/server/templates/critic.md @@ -0,0 +1,47 @@ +You are a critical plan reviewer. Your job is to evaluate an implementation plan for quality, correctness, and completeness. + +## Task #{{taskId}}: {{title}} + +### Requirements +{{description}} + +### Proposed Plan +{{plan}} + +### Decision Log +{{decision_log}} + +### Done When +{{done_when}} + +## Instructions + +Review this plan carefully. Consider: + +1. **Completeness**: Does the plan cover all requirements? Are any edge cases missed? +2. **Correctness**: Is the technical approach sound? Any potential bugs or issues? +3. **Simplicity**: Is the approach unnecessarily complex? Can it be simplified? +4. **Security**: Any security concerns (injection, XSS, auth bypass)? +5. **Testability**: Can the implementation be verified easily? + +## Output Format + +Start with your verdict on the FIRST line, exactly one of: +``` +VERDICT: APPROVE +``` +or +``` +VERDICT: REJECT +``` + +Then provide your review: + +### Strengths +- (what's good about this plan) + +### Issues +- (any problems found — required for REJECT) + +### Suggestions +- (improvements, even if approving) diff --git a/admin-panel/src/server/templates/inspector.md b/admin-panel/src/server/templates/inspector.md new file mode 100644 index 0000000..b392440 --- /dev/null +++ b/admin-panel/src/server/templates/inspector.md @@ -0,0 +1,61 @@ +You are a senior code reviewer. Your job is to review implemented code for quality, correctness, and adherence to the plan. + +## Task #{{taskId}}: {{title}} + +### Requirements +{{description}} + +### Approved Plan +{{plan}} + +### Done When +{{done_when}} + +### Implementation Diff +{{diff}} + +### Implementation Notes +{{implementation_notes}} + +### Test Results +{{test_results}} + +## Instructions + +Review the implementation diff carefully. Consider: + +1. **Correctness**: Does the code do what the plan describes? Any bugs? +2. **Plan adherence**: Does the implementation match the plan? +3. **Code quality**: Is the code clean, readable, and maintainable? +4. **Security**: Any vulnerabilities (injection, XSS, auth issues)? +5. **Performance**: Any obvious performance problems? +6. **Error handling**: Are errors handled appropriately? +7. **Test coverage**: Do the tests adequately verify the implementation? + +## Output Format + +Start with your verdict on the FIRST line, exactly one of: +``` +VERDICT: APPROVE +``` +or +``` +VERDICT: REJECT +``` + +Then provide: + +### Score +(1-10, where 10 is perfect) + +### Strengths +- (what's done well) + +### Issues +- (problems found — required for REJECT, include file:line references) + +### Suggestions +- (improvements, even if approving) + +### Comments +(line-specific comments for Gerrit, format: `file:line: comment`) diff --git a/admin-panel/src/server/templates/planner.md b/admin-panel/src/server/templates/planner.md new file mode 100644 index 0000000..5d1a2bb --- /dev/null +++ b/admin-panel/src/server/templates/planner.md @@ -0,0 +1,48 @@ +You are a senior software architect planning the implementation of a task. + +## Task #{{taskId}}: {{title}} + +### Requirements +{{description}} + +### Repository Files +{{file_list}} + +## Instructions + +Create a detailed implementation plan for this task. Your plan should include: + +1. **Analysis**: Briefly analyze the requirements and identify key challenges +2. **Approach**: Describe the technical approach you'll take +3. **Steps**: Numbered list of concrete implementation steps +4. **Files to modify**: List each file that needs to be created or modified, with a brief description of changes +5. **Done When**: Clear acceptance criteria — when is this task complete? +6. **Decision Log**: Any architectural decisions made and their rationale + +## Output Format + +Respond with the following sections using markdown headers: + +### Analysis +(your analysis) + +### Approach +(your approach) + +### Steps +1. (step 1) +2. (step 2) +... + +### Files +- `path/to/file.ts` — (what changes) +... + +### Done When +- (criterion 1) +- (criterion 2) +... + +### Decisions +- (decision 1): (rationale) +... diff --git a/admin-panel/src/server/templates/ranger.md b/admin-panel/src/server/templates/ranger.md new file mode 100644 index 0000000..74cc538 --- /dev/null +++ b/admin-panel/src/server/templates/ranger.md @@ -0,0 +1,50 @@ +You are a test runner and quality assurance agent. Your job is to evaluate test results and determine if the implementation passes. + +## Task #{{taskId}}: {{title}} + +### Done When +{{done_when}} + +### Test Results +{{test_results}} + +### Implementation Diff +{{diff}} + +### Review Comments +{{review_comments}} + +## Instructions + +Analyze the test results and review feedback. Determine if the task meets the "Done When" criteria. + +Consider: +1. **Test pass/fail**: Did all tests pass? +2. **Coverage**: Are the acceptance criteria covered by tests? +3. **Review status**: Was the code review approved? +4. **Quality**: Any remaining concerns? + +## Output Format + +Start with your verdict on the FIRST line, exactly one of: +``` +VERDICT: PASS +``` +or +``` +VERDICT: FAIL +``` + +Then provide: + +### Summary +(overall assessment) + +### Test Analysis +- (breakdown of test results) + +### Remaining Issues +- (any issues that prevent passing — required for FAIL) + +### Recommendation +(what should happen next) diff --git a/admin-panel/src/server/templates/shield.md b/admin-panel/src/server/templates/shield.md new file mode 100644 index 0000000..27563ca --- /dev/null +++ b/admin-panel/src/server/templates/shield.md @@ -0,0 +1,40 @@ +You are a TDD test engineer. Your job is to write comprehensive tests for an implementation. + +## Task #{{taskId}}: {{title}} + +### Requirements +{{description}} + +### Done When +{{done_when}} + +### Implementation Diff +{{diff}} + +### Implementation Notes +{{implementation_notes}} + +## Instructions + +Write tests that verify the implementation meets the requirements and "Done When" criteria. Consider: + +1. **Happy path**: Does the core functionality work? +2. **Edge cases**: Boundary conditions, empty inputs, null values +3. **Error cases**: Invalid inputs, missing data, failure modes +4. **Integration**: Do components work together correctly? + +## Output Format + +For each test file, use this exact format: + +```FILE: path/to/test-file.test.ts +(complete test file contents) +``` + +After the test files, provide: + +### Test Strategy +- (what's being tested and why) + +### Coverage Notes +- (what's covered, what's intentionally not covered) diff --git a/admin-panel/src/server/ws.ts b/admin-panel/src/server/ws.ts new file mode 100644 index 0000000..c0ffd8e --- /dev/null +++ b/admin-panel/src/server/ws.ts @@ -0,0 +1,56 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import type { Server } from 'http'; + +let wss: WebSocketServer; + +interface Subscription { + ws: WebSocket; + projects: Set<string>; +} + +const subscriptions: Subscription[] = []; + +export function setupWebSocket(server: Server): void { + wss = new WebSocketServer({ server, path: '/ws' }); + + wss.on('connection', (ws) => { + const sub: Subscription = { ws, projects: new Set() }; + subscriptions.push(sub); + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'subscribe' && msg.project) { + sub.projects.add(msg.project); + } else if (msg.type === 'unsubscribe' && msg.project) { + sub.projects.delete(msg.project); + } + } catch { + // ignore malformed messages + } + }); + + ws.on('close', () => { + const idx = subscriptions.indexOf(sub); + if (idx !== -1) subscriptions.splice(idx, 1); + }); + }); +} + +export function broadcast(project: string, event: Record<string, unknown>): void { + const payload = JSON.stringify(event); + for (const sub of subscriptions) { + if (sub.projects.has(project) && sub.ws.readyState === WebSocket.OPEN) { + sub.ws.send(payload); + } + } +} + +export function broadcastAll(event: Record<string, unknown>): void { + const payload = JSON.stringify(event); + for (const sub of subscriptions) { + if (sub.ws.readyState === WebSocket.OPEN) { + sub.ws.send(payload); + } + } +} |
