summaryrefslogtreecommitdiff
path: root/admin-panel/src/server/services
diff options
context:
space:
mode:
Diffstat (limited to 'admin-panel/src/server/services')
-rw-r--r--admin-panel/src/server/services/claude.service.ts47
-rw-r--r--admin-panel/src/server/services/gerrit.service.ts81
-rw-r--r--admin-panel/src/server/services/git.service.ts142
-rw-r--r--admin-panel/src/server/services/pipeline.service.ts304
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,
+ };
+}