diff options
Diffstat (limited to 'admin-panel/src/server/services')
| -rw-r--r-- | admin-panel/src/server/services/claude.service.ts | 47 | ||||
| -rw-r--r-- | admin-panel/src/server/services/gerrit.service.ts | 81 | ||||
| -rw-r--r-- | admin-panel/src/server/services/git.service.ts | 142 | ||||
| -rw-r--r-- | admin-panel/src/server/services/pipeline.service.ts | 304 |
4 files changed, 574 insertions, 0 deletions
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, + }; +} |
