diff options
Diffstat (limited to 'admin-panel/src')
43 files changed, 4150 insertions, 0 deletions
diff --git a/admin-panel/src/client/components/agent-log.ts b/admin-panel/src/client/components/agent-log.ts new file mode 100644 index 0000000..5938426 --- /dev/null +++ b/admin-panel/src/client/components/agent-log.ts @@ -0,0 +1,2 @@ +// Agent log is inlined in task-modal.ts — this file exists for Phase 3 expansion +export {}; diff --git a/admin-panel/src/client/components/board.ts b/admin-panel/src/client/components/board.ts new file mode 100644 index 0000000..b611133 --- /dev/null +++ b/admin-panel/src/client/components/board.ts @@ -0,0 +1,123 @@ +import { api, type Task } from '../lib/api.js'; +import { renderTaskCard } from './task-card.js'; +import { showTaskModal } from './task-modal.js'; + +const COLUMNS = [ + { id: 'todo', label: 'Req' }, + { id: 'plan', label: 'Plan' }, + { id: 'plan_review', label: 'Plan Review' }, + { id: 'impl', label: 'Impl' }, + { id: 'impl_review', label: 'Impl Review' }, + { id: 'test', label: 'Test' }, + { id: 'done', label: 'Done' }, +]; + +export function renderBoard(container: HTMLElement, projectName: string, tasks: Task[]): void { + const tasksByStatus = new Map<string, Task[]>(); + for (const col of COLUMNS) { + tasksByStatus.set(col.id, []); + } + for (const task of tasks) { + const list = tasksByStatus.get(task.status); + if (list) list.push(task); + else tasksByStatus.get('todo')!.push(task); // fallback + } + + container.innerHTML = `<div class="board-container">${COLUMNS.map(col => { + const colTasks = tasksByStatus.get(col.id)!; + return ` + <div class="board-column" data-status="${col.id}"> + <div class="column-header"> + <h3>${col.label}</h3> + <span class="column-count">${colTasks.length}</span> + </div> + <div class="column-body" data-status="${col.id}"> + ${colTasks.map(task => renderTaskCard(task)).join('')} + </div> + </div> + `; + }).join('')}</div>`; + + // Setup drag-drop + setupDragDrop(container, projectName); + + // Setup card click + container.querySelectorAll('.task-card').forEach(card => { + card.addEventListener('click', (e) => { + // Don't open modal if dragging + if ((card as HTMLElement).classList.contains('dragging')) return; + const taskId = parseInt((card as HTMLElement).dataset.taskId!); + const task = tasks.find(t => t.id === taskId); + if (task) showTaskModal(projectName, task); + }); + }); +} + +function setupDragDrop(container: HTMLElement, projectName: string): void { + const cards = container.querySelectorAll('.task-card'); + const columnBodies = container.querySelectorAll('.column-body'); + + let draggedCard: HTMLElement | null = null; + + cards.forEach(card => { + const el = card as HTMLElement; + el.draggable = true; + + el.addEventListener('dragstart', (e) => { + draggedCard = el; + el.classList.add('dragging'); + (e as DragEvent).dataTransfer!.effectAllowed = 'move'; + (e as DragEvent).dataTransfer!.setData('text/plain', el.dataset.taskId!); + }); + + el.addEventListener('dragend', () => { + el.classList.remove('dragging'); + draggedCard = null; + columnBodies.forEach(cb => cb.classList.remove('drag-over')); + }); + }); + + columnBodies.forEach(body => { + body.addEventListener('dragover', (e) => { + e.preventDefault(); + (e as DragEvent).dataTransfer!.dropEffect = 'move'; + body.classList.add('drag-over'); + }); + + body.addEventListener('dragleave', () => { + body.classList.remove('drag-over'); + }); + + body.addEventListener('drop', async (e) => { + e.preventDefault(); + body.classList.remove('drag-over'); + + if (!draggedCard) return; + + const taskId = parseInt(draggedCard.dataset.taskId!); + const newStatus = (body as HTMLElement).dataset.status!; + + // Calculate rank based on drop position + const existingCards = Array.from(body.querySelectorAll('.task-card:not(.dragging)')); + let rank: number; + + if (existingCards.length === 0) { + rank = 1; + } else { + // Drop at end by default + const lastCard = existingCards[existingCards.length - 1] as HTMLElement; + const lastRank = parseFloat(lastCard.dataset.rank || '0'); + rank = lastRank + 1; + } + + // Optimistically move card + body.appendChild(draggedCard); + + try { + await api.reorderTask(projectName, taskId, rank, newStatus); + } catch (err) { + console.error('Reorder failed:', err); + } + }); + }); +} diff --git a/admin-panel/src/client/components/diff-viewer.ts b/admin-panel/src/client/components/diff-viewer.ts new file mode 100644 index 0000000..b746b75 --- /dev/null +++ b/admin-panel/src/client/components/diff-viewer.ts @@ -0,0 +1,2 @@ +// Diff viewer is inlined in task-modal.ts — this file exists for Phase 4 expansion +export {}; diff --git a/admin-panel/src/client/components/pipeline-controls.ts b/admin-panel/src/client/components/pipeline-controls.ts new file mode 100644 index 0000000..2b3e9a5 --- /dev/null +++ b/admin-panel/src/client/components/pipeline-controls.ts @@ -0,0 +1,2 @@ +// Pipeline controls are inlined in task-modal.ts — this file exists for Phase 3 expansion +export {}; diff --git a/admin-panel/src/client/components/project-form.ts b/admin-panel/src/client/components/project-form.ts new file mode 100644 index 0000000..d69f194 --- /dev/null +++ b/admin-panel/src/client/components/project-form.ts @@ -0,0 +1,2 @@ +// Project form is inlined in dashboard.ts — this file exists for future extraction if needed +export {}; diff --git a/admin-panel/src/client/components/task-card.ts b/admin-panel/src/client/components/task-card.ts new file mode 100644 index 0000000..a63e507 --- /dev/null +++ b/admin-panel/src/client/components/task-card.ts @@ -0,0 +1,40 @@ +import type { Task } from '../lib/api.js'; + +export function renderTaskCard(task: Task): string { + const tags = parseTags(task.tags); + const priorityClass = `badge-${task.priority}`; + const isActive = task.current_agent !== null; + + return ` + <div class="task-card ${isActive ? 'pipeline-active' : ''}" + data-task-id="${task.id}" + data-rank="${task.rank}" + draggable="true"> + <div class="task-card-header"> + <span class="task-id">#${task.id}</span> + ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''} + </div> + <div class="task-title">${escapeHtml(task.title)}</div> + <div class="task-meta"> + <span class="badge ${priorityClass}">${task.priority}</span> + <span class="badge badge-level">L${task.level}</span> + ${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')} + </div> + </div> + `; +} + +function parseTags(tagsStr: string): string[] { + try { + const parsed = JSON.parse(tagsStr); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/admin-panel/src/client/components/task-modal.ts b/admin-panel/src/client/components/task-modal.ts new file mode 100644 index 0000000..725d921 --- /dev/null +++ b/admin-panel/src/client/components/task-modal.ts @@ -0,0 +1,271 @@ +import { api, type Task } from '../lib/api.js'; +import { renderMarkdown } from '../lib/markdown.js'; + +const STATUS_ORDER = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done']; +const STATUS_LABELS: Record<string, string> = { + todo: 'Req', plan: 'Plan', plan_review: 'Plan Review', + impl: 'Impl', impl_review: 'Impl Review', test: 'Test', done: 'Done' +}; + +const TABS = ['Requirements', 'Plan', 'Implementation', 'Reviews', 'Tests', 'Agent Log']; + +export function showTaskModal(projectName: string, task: Task): void { + document.querySelector('.modal-overlay')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + <div class="modal task-modal"> + <div class="modal-header"> + <div class="top-row"> + <span class="task-id" style="font-size:14px">#${task.id}</span> + <button class="btn-icon close-btn" style="font-size:20px">×</button> + </div> + <h2 style="width:100%">${escapeHtml(task.title)}</h2> + <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> + <span class="badge badge-${task.priority}">${task.priority}</span> + <span class="badge badge-level">L${task.level}</span> + <span class="badge" style="background:var(--border);color:var(--text-secondary)"> + ${STATUS_LABELS[task.status] || task.status} + </span> + ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''} + ${parseTags(task.tags).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')} + </div> + </div> + + ${renderLifecycleBar(task)} + + <div class="tabs" id="task-tabs"> + ${TABS.map((tab, i) => ` + <button class="tab ${i === 0 ? 'active' : ''}" data-tab="${i}">${tab}</button> + `).join('')} + </div> + + <div class="tab-content" id="tab-content"> + ${renderTabContent(0, task)} + </div> + + <div class="modal-footer"> + <div class="pipeline-controls"> + <button class="btn btn-primary btn-sm" id="run-pipeline-btn" title="Run full pipeline">Run Pipeline</button> + <button class="btn btn-secondary btn-sm" id="step-pipeline-btn" title="Run next step">Step</button> + <button class="btn btn-danger btn-sm" id="stop-pipeline-btn" title="Stop pipeline">Stop</button> + </div> + <div style="flex:1"></div> + <button class="btn btn-danger btn-sm" id="delete-task-btn">Delete</button> + </div> + </div> + `; + + document.body.appendChild(overlay); + + // Tab switching + overlay.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + overlay.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const tabIdx = parseInt((tab as HTMLElement).dataset.tab!); + overlay.querySelector('#tab-content')!.innerHTML = renderTabContent(tabIdx, task); + }); + }); + + // Close + overlay.querySelectorAll('.close-btn').forEach(btn => { + btn.addEventListener('click', () => overlay.remove()); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + + // Pipeline controls + overlay.querySelector('#run-pipeline-btn')?.addEventListener('click', async () => { + try { + await api.runPipeline(projectName, task.id); + } catch (err) { + alert((err as Error).message); + } + }); + + overlay.querySelector('#step-pipeline-btn')?.addEventListener('click', async () => { + try { + await api.stepPipeline(projectName, task.id); + } catch (err) { + alert((err as Error).message); + } + }); + + overlay.querySelector('#stop-pipeline-btn')?.addEventListener('click', async () => { + try { + await api.stopPipeline(projectName, task.id); + } catch (err) { + alert((err as Error).message); + } + }); + + // Delete + overlay.querySelector('#delete-task-btn')?.addEventListener('click', async () => { + if (!confirm(`Delete task #${task.id}?`)) return; + try { + await api.deleteTask(projectName, task.id); + overlay.remove(); + } catch (err) { + alert((err as Error).message); + } + }); +} + +function renderLifecycleBar(task: Task): string { + const currentIdx = STATUS_ORDER.indexOf(task.status); + return ` + <div class="lifecycle-bar"> + ${STATUS_ORDER.map((s, i) => { + let cls = 'lifecycle-step'; + if (i < currentIdx) cls += ' completed'; + else if (i === currentIdx) cls += ' current'; + return `<div class="${cls}" title="${STATUS_LABELS[s]}"></div>`; + }).join('')} + </div> + `; +} + +function renderTabContent(tabIdx: number, task: Task): string { + switch (tabIdx) { + case 0: // Requirements + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${task.description + ? `<div>${renderMarkdown(task.description)}</div>` + : '<p>No description provided.</p>' + } + ${task.done_when ? ` + <h4 style="margin-top:16px;color:var(--text-primary)">Done When</h4> + <div>${renderMarkdown(task.done_when)}</div> + ` : ''} + </div> + `; + + case 1: // Plan + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${task.plan + ? `<div>${renderMarkdown(task.plan)}</div>` + : '<p>No plan generated yet. Run the pipeline to create one.</p>' + } + ${task.decision_log ? ` + <h4 style="margin-top:16px;color:var(--text-primary)">Decision Log</h4> + <div>${renderMarkdown(task.decision_log)}</div> + ` : ''} + </div> + `; + + case 2: // Implementation + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${task.implementation_notes + ? `<div>${renderMarkdown(task.implementation_notes)}</div>` + : '<p>No implementation notes yet.</p>' + } + ${task.branch_name ? `<p style="margin-top:12px">Branch: <code>${escapeHtml(task.branch_name)}</code></p>` : ''} + ${task.diff ? ` + <h4 style="margin-top:16px;color:var(--text-primary)">Diff</h4> + <div class="diff-viewer">${renderDiff(task.diff)}</div> + ` : ''} + </div> + `; + + case 3: // Reviews + return ` + <div style="color:var(--text-secondary);font-size:13px"> + <h4 style="color:var(--text-primary)">Plan Reviews (${task.plan_review_count})</h4> + ${renderJsonArray(task.plan_review_comments, 'No plan reviews yet.')} + + <h4 style="margin-top:16px;color:var(--text-primary)">Code Reviews (${task.impl_review_count})</h4> + ${renderJsonArray(task.review_comments, 'No code reviews yet.')} + + ${task.gerrit_change_number ? ` + <p style="margin-top:16px">Gerrit Change: <a href="/gerrit/${task.gerrit_change_number}" style="color:var(--accent-glow)">#${task.gerrit_change_number}</a></p> + ` : ''} + </div> + `; + + case 4: // Tests + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${renderJsonArray(task.test_results, 'No test results yet. Run the pipeline to generate tests.')} + </div> + `; + + case 5: // Agent Log + return ` + <div class="agent-log"> + ${renderAgentLog(task.agent_log)} + </div> + `; + + default: + return ''; + } +} + +function renderDiff(diff: string): string { + return diff.split('\n').map(line => { + let cls = 'diff-line'; + if (line.startsWith('+')) cls += ' diff-add'; + else if (line.startsWith('-')) cls += ' diff-del'; + else if (line.startsWith('@@')) cls += ' diff-hunk'; + return `<div class="${cls}">${escapeHtml(line)}</div>`; + }).join(''); +} + +function renderJsonArray(jsonStr: string, emptyMsg: string): string { + try { + const items = JSON.parse(jsonStr); + if (!Array.isArray(items) || items.length === 0) return `<p>${emptyMsg}</p>`; + return items.map((item: unknown) => { + if (typeof item === 'string') return `<div class="log-entry"><div class="log-message">${escapeHtml(item)}</div></div>`; + if (typeof item === 'object' && item !== null) { + const obj = item as Record<string, unknown>; + return `<div class="log-entry"> + ${obj.agent ? `<div class="log-agent">${escapeHtml(String(obj.agent))}</div>` : ''} + <div class="log-message">${escapeHtml(String(obj.message || obj.comment || JSON.stringify(obj)))}</div> + </div>`; + } + return ''; + }).join(''); + } catch { + return `<p>${emptyMsg}</p>`; + } +} + +function renderAgentLog(logStr: string): string { + try { + const entries = JSON.parse(logStr); + if (!Array.isArray(entries) || entries.length === 0) { + return '<div class="empty-state"><p>No agent activity yet.</p></div>'; + } + return entries.map((entry: Record<string, unknown>) => ` + <div class="log-entry"> + <span class="log-time">${entry.timestamp ? new Date(String(entry.timestamp)).toLocaleTimeString() : ''}</span> + <span class="log-agent">${escapeHtml(String(entry.agent || ''))}</span> + <span class="log-message">${escapeHtml(String(entry.message || ''))}</span> + </div> + `).join(''); + } catch { + return '<div class="empty-state"><p>No agent activity yet.</p></div>'; + } +} + +function parseTags(tagsStr: string): string[] { + try { + const parsed = JSON.parse(tagsStr); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/admin-panel/src/client/index.html b/admin-panel/src/client/index.html new file mode 100644 index 0000000..58af369 --- /dev/null +++ b/admin-panel/src/client/index.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Bastion Admin</title> + <link rel="stylesheet" href="./styles/main.css" /> +</head> +<body> + <div id="app"> + <nav id="navbar"> + <a href="/" class="nav-brand">BASTION</a> + <div class="nav-links"> + <a href="/" data-route="/">Dashboard</a> + <a href="/settings" data-route="/settings">Settings</a> + </div> + </nav> + <main id="main-content"></main> + </div> + <script type="module" src="./main.ts"></script> +</body> +</html> diff --git a/admin-panel/src/client/lib/api.ts b/admin-panel/src/client/lib/api.ts new file mode 100644 index 0000000..d82693f --- /dev/null +++ b/admin-panel/src/client/lib/api.ts @@ -0,0 +1,155 @@ +const BASE = '/api'; + +async function request<T>(path: string, options?: RequestInit): Promise<T> { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + + if (res.status === 204) return undefined as T; + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || `Request failed: ${res.status}`); + } + return data as T; +} + +export const api = { + // Projects + getProjects: () => request<Project[]>('/projects'), + createProject: (data: CreateProject) => + request<Project>('/projects', { method: 'POST', body: JSON.stringify(data) }), + getProject: (name: string) => request<Project>(`/projects/${name}`), + updateProject: (name: string, data: Partial<Project>) => + request<Project>(`/projects/${name}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteProject: (name: string, deleteRepo = false) => + request<void>(`/projects/${name}?delete_repo=${deleteRepo}`, { method: 'DELETE' }), + + // Tasks + getTasks: (project: string) => request<Task[]>(`/projects/${project}/tasks`), + createTask: (project: string, data: CreateTask) => + request<Task>(`/projects/${project}/tasks`, { method: 'POST', body: JSON.stringify(data) }), + getTask: (project: string, id: number) => request<Task>(`/projects/${project}/tasks/${id}`), + updateTask: (project: string, id: number, data: Partial<Task>) => + request<Task>(`/projects/${project}/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteTask: (project: string, id: number) => + request<void>(`/projects/${project}/tasks/${id}`, { method: 'DELETE' }), + reorderTask: (project: string, id: number, rank: number, status?: string) => + request<Task>(`/projects/${project}/tasks/${id}/reorder`, { + method: 'PATCH', + body: JSON.stringify({ rank, status }), + }), + + // Pipeline + runPipeline: (project: string, taskId: number) => + request<void>(`/projects/${project}/pipeline/run/${taskId}`, { method: 'POST' }), + stepPipeline: (project: string, taskId: number) => + request<void>(`/projects/${project}/pipeline/step/${taskId}`, { method: 'POST' }), + stopPipeline: (project: string, taskId: number) => + request<void>(`/projects/${project}/pipeline/stop/${taskId}`, { method: 'POST' }), + getPipelineStatus: (project: string, taskId: number) => + request<PipelineStatus>(`/projects/${project}/pipeline/status/${taskId}`), + + // Dashboard + getDashboardStats: () => request<DashboardStats>('/dashboard/stats'), + getDashboardActivity: () => request<ActivityEntry[]>('/dashboard/activity'), + + // Settings + getSettings: () => request<Record<string, string>>('/settings'), + updateSettings: (data: Record<string, string>) => + request<void>('/settings', { method: 'PATCH', body: JSON.stringify(data) }), +}; + +// Types +export 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; +} + +export interface CreateProject { + name: string; + display_name: string; + description?: string; +} + +export 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; +} + +export interface CreateTask { + title: string; + description?: string; + priority?: string; + level?: number; + tags?: string[]; +} + +export interface PipelineStatus { + running: boolean; + currentAgent: string | null; + jobs: PipelineJob[]; +} + +export interface PipelineJob { + id: number; + task_id: number; + agent: string; + status: string; + model: string; + started_at: string | null; + completed_at: string | null; + error: string | null; + tokens_used: number; +} + +export interface DashboardStats { + totalProjects: number; + totalTasks: number; + tasksByStatus: Record<string, number>; + completedToday: number; +} + +export interface ActivityEntry { + project: string; + taskId: number; + taskTitle: string; + agent: string; + action: string; + timestamp: string; +} diff --git a/admin-panel/src/client/lib/markdown.ts b/admin-panel/src/client/lib/markdown.ts new file mode 100644 index 0000000..c89fa77 --- /dev/null +++ b/admin-panel/src/client/lib/markdown.ts @@ -0,0 +1,39 @@ +// Minimal markdown renderer — handles the basics without pulling in a library +export function renderMarkdown(text: string): string { + if (!text) return ''; + + let html = escapeHtml(text); + + // Code blocks + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>'); + + // Inline code + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); + + // Headers + html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>'); + html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>'); + html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>'); + + // Bold and italic + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); + + // Lists + html = html.replace(/^- (.+)$/gm, '<li>$1</li>'); + html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>'); + + // Line breaks + html = html.replace(/\n\n/g, '</p><p>'); + html = `<p>${html}</p>`; + html = html.replace(/<p><\/p>/g, ''); + + return html; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +} diff --git a/admin-panel/src/client/lib/ws.ts b/admin-panel/src/client/lib/ws.ts new file mode 100644 index 0000000..14e0cec --- /dev/null +++ b/admin-panel/src/client/lib/ws.ts @@ -0,0 +1,79 @@ +type EventHandler = (data: unknown) => void; + +class WsClient { + private ws: WebSocket | null = null; + private handlers = new Map<string, Set<EventHandler>>(); + private reconnectTimer: ReturnType<typeof setTimeout> | null = null; + private subscribedProjects = new Set<string>(); + + connect(): void { + if (this.ws?.readyState === WebSocket.OPEN) return; + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + this.ws = new WebSocket(`${protocol}//${location.host}/ws`); + + this.ws.onopen = () => { + // Re-subscribe to projects + for (const project of this.subscribedProjects) { + this.send({ type: 'subscribe', project }); + } + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const handlers = this.handlers.get(data.type); + if (handlers) { + for (const handler of handlers) { + handler(data); + } + } + // Also fire wildcard handlers + const wildcardHandlers = this.handlers.get('*'); + if (wildcardHandlers) { + for (const handler of wildcardHandlers) { + handler(data); + } + } + } catch { + // ignore + } + }; + + this.ws.onclose = () => { + this.reconnectTimer = setTimeout(() => this.connect(), 3000); + }; + } + + disconnect(): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.ws = null; + } + + subscribe(project: string): void { + this.subscribedProjects.add(project); + this.send({ type: 'subscribe', project }); + } + + unsubscribe(project: string): void { + this.subscribedProjects.delete(project); + this.send({ type: 'unsubscribe', project }); + } + + on(event: string, handler: EventHandler): () => void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)!.add(handler); + return () => this.handlers.get(event)?.delete(handler); + } + + private send(data: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } +} + +export const wsClient = new WsClient(); diff --git a/admin-panel/src/client/main.ts b/admin-panel/src/client/main.ts new file mode 100644 index 0000000..87f25db --- /dev/null +++ b/admin-panel/src/client/main.ts @@ -0,0 +1,63 @@ +import { renderDashboard } from './pages/dashboard.js'; +import { renderProjectPage } from './pages/project.js'; +import { renderSettings } from './pages/settings.js'; +import { wsClient } from './lib/ws.js'; + +const mainContent = document.getElementById('main-content')!; + +// Simple client-side router +type Route = { + pattern: RegExp; + handler: (container: HTMLElement, params: string[]) => Promise<void>; +}; + +const routes: Route[] = [ + { pattern: /^\/$/, handler: (c) => renderDashboard(c) }, + { pattern: /^\/project\/([a-z0-9-]+)$/, handler: (c, p) => renderProjectPage(c, p[1]) }, + { pattern: /^\/settings$/, handler: (c) => renderSettings(c) }, +]; + +function navigate(path: string): void { + for (const route of routes) { + const match = path.match(route.pattern); + if (match) { + updateNavActive(path); + route.handler(mainContent, Array.from(match)); + return; + } + } + // 404 + mainContent.innerHTML = ` + <div class="empty-state"> + <h3>Page not found</h3> + <p><a href="/" data-route="/" style="color:var(--accent-glow)">Back to Dashboard</a></p> + </div> + `; +} + +function updateNavActive(path: string): void { + document.querySelectorAll('.nav-links a').forEach(a => { + const route = (a as HTMLElement).dataset.route; + a.classList.toggle('active', route === path || (route === '/' && path.startsWith('/project/'))); + }); +} + +// Intercept link clicks for SPA navigation +document.addEventListener('click', (e) => { + const link = (e.target as HTMLElement).closest('[data-route]'); + if (link) { + e.preventDefault(); + const path = (link as HTMLElement).dataset.route!; + history.pushState(null, '', path); + navigate(path); + } +}); + +// Handle browser back/forward +window.addEventListener('popstate', () => { + navigate(location.pathname); +}); + +// Init +wsClient.connect(); +navigate(location.pathname); diff --git a/admin-panel/src/client/pages/dashboard.ts b/admin-panel/src/client/pages/dashboard.ts new file mode 100644 index 0000000..ded6d3b --- /dev/null +++ b/admin-panel/src/client/pages/dashboard.ts @@ -0,0 +1,150 @@ +import { api, type Project } from '../lib/api.js'; + +export async function renderDashboard(container: HTMLElement): Promise<void> { + container.innerHTML = ` + <div class="dashboard-header"> + <h1>Projects</h1> + <button class="btn btn-primary" id="new-project-btn">+ New Project</button> + </div> + <div class="project-grid" id="project-grid"> + <div class="empty-state"> + <h3>Loading...</h3> + </div> + </div> + `; + + const grid = container.querySelector('#project-grid')!; + const newBtn = container.querySelector('#new-project-btn')!; + + // Load projects + try { + const projects = await api.getProjects(); + renderProjects(grid, projects); + } catch (err) { + grid.innerHTML = `<div class="empty-state"><h3>Failed to load projects</h3><p>${(err as Error).message}</p></div>`; + } + + newBtn.addEventListener('click', () => showCreateDialog(grid)); +} + +function renderProjects(grid: Element, projects: Project[]): void { + if (projects.length === 0) { + grid.innerHTML = ` + <div class="empty-state"> + <h3>No projects yet</h3> + <p>Create your first project to get started.</p> + </div> + `; + return; + } + + grid.innerHTML = projects.map(p => ` + <a href="/project/${p.name}" class="project-card" data-route="/project/${p.name}"> + <h3>${escapeHtml(p.display_name)}</h3> + <div class="project-name">${escapeHtml(p.name)}</div> + ${p.description ? `<div class="project-desc">${escapeHtml(p.description)}</div>` : ''} + <div class="project-stats"> + <span class="stat"><span class="stat-dot todo"></span> ${escapeHtml(p.repo_name)}</span> + <span class="stat">${timeAgo(p.created_at)}</span> + </div> + </a> + `).join(''); +} + +function showCreateDialog(grid: Element): void { + // Remove existing modal if present + document.querySelector('.modal-overlay')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + <div class="modal"> + <div class="modal-header"> + <h2>New Project</h2> + <button class="btn-icon close-btn">×</button> + </div> + <form id="create-project-form"> + <div class="form-group"> + <label for="proj-name">Project Name</label> + <input type="text" id="proj-name" placeholder="my-project" required pattern="[a-z0-9-]+" /> + </div> + <div class="form-group"> + <label for="proj-display">Display Name</label> + <input type="text" id="proj-display" placeholder="My Project" required /> + </div> + <div class="form-group"> + <label for="proj-desc">Description</label> + <textarea id="proj-desc" placeholder="What is this project about?"></textarea> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary close-btn">Cancel</button> + <button type="submit" class="btn btn-primary">Create Project</button> + </div> + </form> + </div> + `; + + document.body.appendChild(overlay); + + // Auto-generate name from display name + const displayInput = overlay.querySelector('#proj-display') as HTMLInputElement; + const nameInput = overlay.querySelector('#proj-name') as HTMLInputElement; + displayInput.addEventListener('input', () => { + nameInput.value = displayInput.value + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-'); + }); + + // Close handlers + overlay.querySelectorAll('.close-btn').forEach(btn => { + btn.addEventListener('click', () => overlay.remove()); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + + // Submit + const form = overlay.querySelector('#create-project-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement; + submitBtn.disabled = true; + submitBtn.textContent = 'Creating...'; + + try { + await api.createProject({ + name: nameInput.value, + display_name: displayInput.value, + description: (overlay.querySelector('#proj-desc') as HTMLTextAreaElement).value || undefined, + }); + overlay.remove(); + // Reload projects + const projects = await api.getProjects(); + renderProjects(grid, projects); + } catch (err) { + submitBtn.disabled = false; + submitBtn.textContent = 'Create Project'; + alert((err as Error).message); + } + }); + + displayInput.focus(); +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function timeAgo(dateStr: string): string { + const date = new Date(dateStr + 'Z'); + const now = new Date(); + const diff = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} diff --git a/admin-panel/src/client/pages/project.ts b/admin-panel/src/client/pages/project.ts new file mode 100644 index 0000000..5e01d64 --- /dev/null +++ b/admin-panel/src/client/pages/project.ts @@ -0,0 +1,167 @@ +import { api, type Task, type Project } from '../lib/api.js'; +import { wsClient } from '../lib/ws.js'; +import { renderBoard } from '../components/board.js'; + +let currentProject: string | null = null; +let cleanupWs: (() => void) | null = null; + +export async function renderProjectPage(container: HTMLElement, projectName: string): Promise<void> { + // Cleanup previous subscriptions + if (currentProject && currentProject !== projectName) { + wsClient.unsubscribe(currentProject); + } + if (cleanupWs) cleanupWs(); + + currentProject = projectName; + + container.innerHTML = ` + <div class="board-header"> + <div style="display:flex;align-items:center;gap:12px"> + <a href="/" data-route="/" class="btn btn-secondary btn-sm">← Back</a> + <h1 id="project-title">Loading...</h1> + </div> + <button class="btn btn-primary" id="new-task-btn">+ New Task</button> + </div> + <div id="board-root"></div> + `; + + const boardRoot = container.querySelector('#board-root') as HTMLElement; + const titleEl = container.querySelector('#project-title')!; + const newTaskBtn = container.querySelector('#new-task-btn')!; + + try { + const [project, tasks] = await Promise.all([ + api.getProject(projectName), + api.getTasks(projectName), + ]); + + titleEl.textContent = project.display_name; + renderBoard(boardRoot, projectName, tasks); + + // WebSocket subscription + wsClient.subscribe(projectName); + const unsub1 = wsClient.on('task:created', (data: unknown) => { + const event = data as { project: string; task: Task }; + if (event.project === projectName) { + refreshBoard(boardRoot, projectName); + } + }); + const unsub2 = wsClient.on('task:updated', (data: unknown) => { + const event = data as { project: string; task: Task }; + if (event.project === projectName) { + refreshBoard(boardRoot, projectName); + } + }); + const unsub3 = wsClient.on('task:deleted', (data: unknown) => { + const event = data as { project: string }; + if (event.project === projectName) { + refreshBoard(boardRoot, projectName); + } + }); + + cleanupWs = () => { unsub1(); unsub2(); unsub3(); }; + + } catch (err) { + boardRoot.innerHTML = `<div class="empty-state"><h3>Failed to load project</h3><p>${(err as Error).message}</p></div>`; + } + + newTaskBtn.addEventListener('click', () => showCreateTaskDialog(projectName, boardRoot)); +} + +async function refreshBoard(boardRoot: HTMLElement, projectName: string): Promise<void> { + try { + const tasks = await api.getTasks(projectName); + renderBoard(boardRoot, projectName, tasks); + } catch { + // silently fail on refresh + } +} + +function showCreateTaskDialog(projectName: string, boardRoot: HTMLElement): void { + document.querySelector('.modal-overlay')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + <div class="modal"> + <div class="modal-header"> + <h2>New Task</h2> + <button class="btn-icon close-btn">×</button> + </div> + <form id="create-task-form"> + <div class="form-group"> + <label for="task-title">Title</label> + <input type="text" id="task-title" placeholder="What needs to be done?" required /> + </div> + <div class="form-group"> + <label for="task-desc">Description</label> + <textarea id="task-desc" rows="4" placeholder="Detailed requirements..."></textarea> + </div> + <div style="display:flex;gap:12px"> + <div class="form-group" style="flex:1"> + <label for="task-priority">Priority</label> + <select id="task-priority"> + <option value="low">Low</option> + <option value="medium" selected>Medium</option> + <option value="high">High</option> + </select> + </div> + <div class="form-group" style="flex:1"> + <label for="task-level">Level</label> + <select id="task-level"> + <option value="1">L1 — Quick</option> + <option value="2">L2 — Standard</option> + <option value="3" selected>L3 — Full</option> + </select> + </div> + </div> + <div class="form-group"> + <label for="task-tags">Tags (comma-separated)</label> + <input type="text" id="task-tags" placeholder="frontend, bug-fix" /> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary close-btn">Cancel</button> + <button type="submit" class="btn btn-primary">Create Task</button> + </div> + </form> + </div> + `; + + document.body.appendChild(overlay); + + overlay.querySelectorAll('.close-btn').forEach(btn => { + btn.addEventListener('click', () => overlay.remove()); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + + const form = overlay.querySelector('#create-task-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement; + submitBtn.disabled = true; + submitBtn.textContent = 'Creating...'; + + const tagsStr = (overlay.querySelector('#task-tags') as HTMLInputElement).value; + const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []; + + try { + await api.createTask(projectName, { + title: (overlay.querySelector('#task-title') as HTMLInputElement).value, + description: (overlay.querySelector('#task-desc') as HTMLTextAreaElement).value || undefined, + priority: (overlay.querySelector('#task-priority') as HTMLSelectElement).value, + level: parseInt((overlay.querySelector('#task-level') as HTMLSelectElement).value), + tags, + }); + overlay.remove(); + await refreshBoard(boardRoot, projectName); + } catch (err) { + submitBtn.disabled = false; + submitBtn.textContent = 'Create Task'; + alert((err as Error).message); + } + }); + + (overlay.querySelector('#task-title') as HTMLInputElement).focus(); +} diff --git a/admin-panel/src/client/pages/settings.ts b/admin-panel/src/client/pages/settings.ts new file mode 100644 index 0000000..e78c69f --- /dev/null +++ b/admin-panel/src/client/pages/settings.ts @@ -0,0 +1,59 @@ +export async function renderSettings(container: HTMLElement): Promise<void> { + container.innerHTML = ` + <div class="settings-container"> + <h1 style="margin-bottom:24px">Settings</h1> + + <div class="settings-section"> + <h2>API Configuration</h2> + <div class="form-group"> + <label for="api-key">Anthropic API Key</label> + <input type="password" id="api-key" placeholder="sk-ant-..." style="width:100%" /> + <small style="color:var(--text-secondary);margin-top:4px;display:block"> + Configured via ANTHROPIC_API_KEY environment variable. + The API key is set in the container's environment, not stored in the database. + </small> + </div> + </div> + + <div class="settings-section"> + <h2>Agent Models</h2> + <p style="color:var(--text-secondary);margin-bottom:16px;font-size:13px"> + Configure which Claude model each agent uses. + </p> + <div class="form-group"> + <label>Planner (Plan generation)</label> + <select disabled><option>Claude Opus</option></select> + </div> + <div class="form-group"> + <label>Critic (Plan review)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <div class="form-group"> + <label>Builder (Implementation)</label> + <select disabled><option>Claude Opus</option></select> + </div> + <div class="form-group"> + <label>Shield (Test writing)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <div class="form-group"> + <label>Inspector (Code review)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <div class="form-group"> + <label>Ranger (Test runner)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <small style="color:var(--text-secondary)">Model configuration will be available when the AI pipeline is enabled.</small> + </div> + + <div class="settings-section"> + <h2>Git / SSH</h2> + <p style="color:var(--text-secondary);font-size:13px"> + SSH key for git-server and Gerrit access is mounted at <code>/app/.ssh/</code> in the container. + Ensure the public key is added to <code>/var/git_ssh_keys/</code> on the host. + </p> + </div> + </div> + `; +} diff --git a/admin-panel/src/client/styles/main.css b/admin-panel/src/client/styles/main.css new file mode 100644 index 0000000..d2fe35b --- /dev/null +++ b/admin-panel/src/client/styles/main.css @@ -0,0 +1,724 @@ +/* Cyberpunk Dark Theme */ +:root { + --bg-deep: #0A0D1F; + --bg-card: #1A1A2E; + --accent: #7B2FBE; + --accent-glow: #A855F7; + --alert: #FF2D78; + --text-secondary: #8B9BB4; + --text-primary: #E8ECF4; + --bg-input: #12152a; + --border: #2a2a4a; + --success: #22c55e; + --warning: #eab308; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + background: var(--bg-deep); + color: var(--text-primary); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Navbar */ +#navbar { + display: flex; + align-items: center; + padding: 0 24px; + height: 52px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.nav-brand { + font-size: 18px; + font-weight: 700; + letter-spacing: 3px; + color: var(--accent-glow); + text-decoration: none; + margin-right: 32px; +} + +.nav-links { + display: flex; + gap: 4px; +} + +.nav-links a { + color: var(--text-secondary); + text-decoration: none; + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + transition: all 0.15s; +} + +.nav-links a:hover, +.nav-links a.active { + color: var(--text-primary); + background: rgba(123, 47, 190, 0.15); +} + +.nav-links a.active { + color: var(--accent-glow); +} + +/* Main content */ +#main-content { + flex: 1; + overflow: auto; + padding: 24px; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-deep); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-glow); + box-shadow: 0 0 20px rgba(168, 85, 247, 0.3); +} + +.btn-secondary { + background: var(--border); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--text-secondary); +} + +.btn-danger { + background: transparent; + color: var(--alert); + border: 1px solid var(--alert); +} + +.btn-danger:hover { + background: var(--alert); + color: white; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-icon { + padding: 6px; + background: transparent; + color: var(--text-secondary); + border: none; + border-radius: 4px; + cursor: pointer; +} + +.btn-icon:hover { + color: var(--text-primary); + background: var(--border); +} + +/* Form elements */ +input, textarea, select { + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +input:focus, textarea:focus, select:focus { + border-color: var(--accent); +} + +textarea { + resize: vertical; + min-height: 80px; +} + +label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.15s; +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + width: 90%; + max-width: 540px; + max-height: 90vh; + overflow-y: auto; + padding: 24px; + animation: slideUp 0.2s; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* Dashboard */ +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.dashboard-header h1 { + font-size: 24px; + font-weight: 600; +} + +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} + +.project-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + cursor: pointer; + transition: all 0.15s; + text-decoration: none; + color: inherit; + display: block; +} + +.project-card:hover { + border-color: var(--accent); + box-shadow: 0 0 20px rgba(123, 47, 190, 0.15); + transform: translateY(-1px); +} + +.project-card h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +} + +.project-card .project-name { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 8px; + font-family: monospace; +} + +.project-card .project-desc { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 12px; +} + +.project-card .project-stats { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-secondary); +} + +.project-card .stat { + display: flex; + align-items: center; + gap: 4px; +} + +.stat-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.stat-dot.todo { background: var(--text-secondary); } +.stat-dot.active { background: var(--accent-glow); } +.stat-dot.done { background: var(--success); } + +/* Kanban Board */ +.board-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.board-header h1 { + font-size: 20px; + font-weight: 600; +} + +.board-container { + display: flex; + gap: 12px; + overflow-x: auto; + height: calc(100vh - 140px); + padding-bottom: 16px; +} + +.board-column { + flex: 0 0 260px; + display: flex; + flex-direction: column; + background: rgba(26, 26, 46, 0.3); + border-radius: 8px; + overflow: hidden; +} + +.column-header { + padding: 12px 14px; + background: var(--bg-card); + border-bottom: 2px solid var(--accent-glow); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.column-header h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +.column-count { + font-size: 11px; + color: var(--text-secondary); + background: var(--border); + padding: 1px 8px; + border-radius: 10px; +} + +.column-body { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.column-body.drag-over { + background: rgba(123, 47, 190, 0.08); +} + +/* Task Cards */ +.task-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 6px; + padding: 12px; + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.task-card:hover { + border-color: var(--accent); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); +} + +.task-card.dragging { + opacity: 0.5; + transform: rotate(2deg); +} + +.task-card.pipeline-active { + animation: pulseGlow 2s infinite; +} + +@keyframes pulseGlow { + 0%, 100% { box-shadow: 0 0 5px rgba(168, 85, 247, 0.2); } + 50% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.5); } +} + +.task-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 6px; +} + +.task-id { + font-size: 11px; + color: var(--text-secondary); + font-family: monospace; +} + +.task-title { + font-size: 13px; + font-weight: 500; + line-height: 1.4; + margin-bottom: 8px; +} + +.task-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.badge { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-high { background: var(--alert); color: white; } +.badge-medium { background: var(--accent); color: white; } +.badge-low { background: var(--border); color: var(--text-secondary); } + +.badge-level { + background: var(--border); + color: var(--text-secondary); +} + +.badge-agent { + background: rgba(168, 85, 247, 0.2); + color: var(--accent-glow); +} + +.tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: var(--border); + color: var(--text-secondary); +} + +/* Task Detail Modal */ +.task-modal { + max-width: 800px; +} + +.task-modal .modal-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.task-modal .modal-header .top-row { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; +} + +.task-modal .tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.task-modal .tab { + padding: 8px 16px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; + background: none; + border-top: none; + border-left: none; + border-right: none; +} + +.task-modal .tab:hover { + color: var(--text-primary); +} + +.task-modal .tab.active { + color: var(--accent-glow); + border-bottom-color: var(--accent-glow); +} + +.tab-content { + min-height: 200px; +} + +.lifecycle-bar { + display: flex; + gap: 4px; + margin-bottom: 16px; +} + +.lifecycle-step { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--border); +} + +.lifecycle-step.completed { + background: var(--accent-glow); +} + +.lifecycle-step.current { + background: var(--accent-glow); + animation: pulseGlow 2s infinite; +} + +/* Agent Log */ +.agent-log { + display: flex; + flex-direction: column; + gap: 12px; +} + +.log-entry { + display: flex; + gap: 12px; + padding: 12px; + background: var(--bg-deep); + border-radius: 6px; + font-size: 13px; +} + +.log-entry .log-time { + color: var(--text-secondary); + font-family: monospace; + font-size: 11px; + white-space: nowrap; +} + +.log-entry .log-agent { + color: var(--accent-glow); + font-weight: 500; + white-space: nowrap; +} + +.log-entry .log-message { + color: var(--text-secondary); +} + +/* Diff Viewer */ +.diff-viewer { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + overflow: auto; + max-height: 500px; +} + +.diff-line { + padding: 1px 12px; + white-space: pre; +} + +.diff-add { + background: rgba(34, 197, 94, 0.1); + color: #4ade80; +} + +.diff-del { + background: rgba(255, 45, 120, 0.1); + color: #ff6b9d; +} + +.diff-hunk { + color: var(--accent-glow); + background: rgba(168, 85, 247, 0.05); +} + +/* Pipeline Controls */ +.pipeline-controls { + display: flex; + gap: 8px; + align-items: center; +} + +/* Settings Page */ +.settings-container { + max-width: 600px; +} + +.settings-section { + margin-bottom: 32px; +} + +.settings-section h2 { + font-size: 16px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 48px 24px; + color: var(--text-secondary); +} + +.empty-state h3 { + font-size: 16px; + margin-bottom: 8px; + color: var(--text-primary); +} + +/* Toast notifications */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 2000; +} + +.toast { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 16px; + font-size: 13px; + animation: slideUp 0.2s; + max-width: 360px; +} + +.toast.error { + border-color: var(--alert); +} + +.toast.success { + border-color: var(--success); +} + +/* Responsive */ +@media (max-width: 768px) { + .board-container { + gap: 8px; + } + + .board-column { + flex: 0 0 220px; + } + + .project-grid { + grid-template-columns: 1fr; + } +} 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); + } + } +} |
