From 1ca019fa487e0690ead2a7832f2197e9a67a629e Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Tue, 31 Mar 2026 14:31:31 +0800 Subject: [PATCH] feat: add OPC (One Person Company) management system with Kanban board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 MVP implementation: - PostgreSQL database schema for tasks, agents, executions, time tracking - Backend API: task CRUD, agent management, automatic time tracking - Frontend: Kanban board with drag-and-drop (Parking Lot → In Progress → Done) - Task modal for creating/editing tasks with tags, priority, assignee - 3 core agents seeded: PM, CTO, COO - Agent approval workflow: CxO level requires approval, others auto-execute - Automatic time tracking: starts on in_progress, stops on done - RBAC integration: OPC page accessible to admin/member roles Features: - Drag-and-drop tasks between columns - Assign tasks to humans or AI agents - Priority badges (low, medium, high, urgent) - Tags and due dates - Task history tracking - Time entry tracking with automatic timer - Agent execution records with approval workflow Next steps: - Initialize OPC database on NAS - Implement agent executor service - Add WebSocket for real-time updates - Add email notifications - Add PDF invoice generation --- dashboard/backend/db/opc_db.py | 582 ++++++++++++++++++ dashboard/backend/main.py | 16 +- dashboard/backend/rbac.py | 2 +- dashboard/backend/requirements.txt | 2 + dashboard/backend/routers/cc_connect.py | 11 +- dashboard/backend/routers/opc_agents.py | 116 ++++ dashboard/backend/routers/opc_tasks.py | 259 ++++++++ dashboard/frontend/src/App.svelte | 4 + .../frontend/src/components/Sidebar.svelte | 3 + .../src/components/opc/KanbanBoard.svelte | 40 ++ .../src/components/opc/KanbanColumn.svelte | 85 +++ .../src/components/opc/TaskCard.svelte | 169 +++++ .../src/components/opc/TaskModal.svelte | 250 ++++++++ dashboard/frontend/src/lib/opc-api.js | 80 +++ dashboard/frontend/src/routes/OPC.svelte | 149 +++++ gitea/init-opc-db.sql | 6 + 16 files changed, 1771 insertions(+), 3 deletions(-) create mode 100644 dashboard/backend/db/opc_db.py create mode 100644 dashboard/backend/routers/opc_agents.py create mode 100644 dashboard/backend/routers/opc_tasks.py create mode 100644 dashboard/frontend/src/components/opc/KanbanBoard.svelte create mode 100644 dashboard/frontend/src/components/opc/KanbanColumn.svelte create mode 100644 dashboard/frontend/src/components/opc/TaskCard.svelte create mode 100644 dashboard/frontend/src/components/opc/TaskModal.svelte create mode 100644 dashboard/frontend/src/lib/opc-api.js create mode 100644 dashboard/frontend/src/routes/OPC.svelte create mode 100644 gitea/init-opc-db.sql diff --git a/dashboard/backend/db/opc_db.py b/dashboard/backend/db/opc_db.py new file mode 100644 index 0000000..8b0dc00 --- /dev/null +++ b/dashboard/backend/db/opc_db.py @@ -0,0 +1,582 @@ +""" +OPC Database Module - PostgreSQL connection and schema management +""" +import asyncpg +import json +from typing import Optional, List, Dict, Any +from datetime import datetime +import os + +# Database connection settings +DB_HOST = os.getenv("OPC_DB_HOST", "gitea-db") +DB_PORT = int(os.getenv("OPC_DB_PORT", "5432")) +DB_NAME = os.getenv("OPC_DB_NAME", "opc") +DB_USER = os.getenv("OPC_DB_USER", "gitea") +DB_PASSWORD = os.getenv("GITEA_DB_PASSWORD", "") + +# Connection pool +_pool: Optional[asyncpg.Pool] = None + + +async def get_pool() -> asyncpg.Pool: + """Get or create database connection pool""" + global _pool + if _pool is None: + _pool = await asyncpg.create_pool( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + min_size=2, + max_size=10, + ) + return _pool + + +async def close_pool(): + """Close database connection pool""" + global _pool + if _pool: + await _pool.close() + _pool = None + + +async def init_db(): + """Initialize database schema""" + pool = await get_pool() + async with pool.acquire() as conn: + # Create tables + await conn.execute(""" + CREATE TABLE IF NOT EXISTS projects ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'active', + client_id INTEGER, + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'parking_lot', + priority TEXT DEFAULT 'medium', + project_id INTEGER REFERENCES projects(id), + assigned_to TEXT, + assigned_type TEXT DEFAULT 'human', + tags JSONB, + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + due_date TIMESTAMP + ) + """) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS task_history ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + action TEXT NOT NULL, + actor TEXT NOT NULL, + actor_type TEXT DEFAULT 'human', + old_value JSONB, + new_value JSONB, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL, + description TEXT, + capabilities JSONB, + system_prompt TEXT, + config JSONB, + enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS agent_executions ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id), + status TEXT DEFAULT 'pending', + input_context JSONB, + output_result JSONB, + actions_proposed JSONB, + actions_executed JSONB, + error_message TEXT, + started_at TIMESTAMP, + completed_at TIMESTAMP, + requires_approval BOOLEAN DEFAULT FALSE, + approved_by TEXT, + approved_at TIMESTAMP + ) + """) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS time_entries ( + id SERIAL PRIMARY KEY, + task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id), + description TEXT, + duration_minutes INTEGER NOT NULL, + billable BOOLEAN DEFAULT TRUE, + hourly_rate NUMERIC(10, 2), + started_at TIMESTAMP, + ended_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS clients ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + company TEXT, + notes TEXT, + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create indexes + await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to, assigned_type)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_task_history_task ON task_history(task_id)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_executions_task ON agent_executions(task_id)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_executions_agent ON agent_executions(agent_id)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_time_entries_task ON time_entries(task_id)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_time_entries_project ON time_entries(project_id)") + + # Insert default agents + await seed_agents(conn) + + +async def seed_agents(conn): + """Seed default agents""" + agents = [ + { + "id": "pm", + "name": "Project Manager", + "role": "Project Manager", + "description": "Breaks down tasks, plans timelines, monitors progress", + "capabilities": ["task_breakdown", "timeline_planning", "risk_assessment", "status_reporting"], + "system_prompt": """You are a Project Manager agent. Your role is to: +- Break down large tasks into manageable subtasks +- Plan realistic timelines and identify dependencies +- Monitor project progress and identify blockers +- Provide status reports and risk assessments + +When assigned a task, analyze it and propose concrete actions.""", + "config": {"model": "claude-sonnet-4-6", "temperature": 0.7, "approval_level": "auto"} + }, + { + "id": "cto", + "name": "CTO", + "role": "Chief Technology Officer", + "description": "Reviews code, designs architecture, manages technical debt", + "capabilities": ["code_review", "architecture_design", "tech_debt_management", "security_audit"], + "system_prompt": """You are a CTO agent. Your role is to: +- Review code quality and architecture decisions +- Design scalable and maintainable systems +- Identify and prioritize technical debt +- Ensure security best practices + +When assigned a task, provide technical leadership and propose improvements.""", + "config": {"model": "claude-sonnet-4-6", "temperature": 0.5, "approval_level": "cxo"} + }, + { + "id": "coo", + "name": "COO", + "role": "Chief Operating Officer", + "description": "Optimizes processes, automates workflows, improves efficiency", + "capabilities": ["process_improvement", "workflow_automation", "efficiency_analysis"], + "system_prompt": """You are a COO agent. Your role is to: +- Identify opportunities for process improvement +- Automate repetitive workflows using n8n +- Analyze operational efficiency +- Optimize resource allocation + +When assigned a task, look for automation and optimization opportunities.""", + "config": {"model": "claude-sonnet-4-6", "temperature": 0.6, "approval_level": "cxo"} + } + ] + + for agent in agents: + await conn.execute(""" + INSERT INTO agents (id, name, role, description, capabilities, system_prompt, config, enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + role = EXCLUDED.role, + description = EXCLUDED.description, + capabilities = EXCLUDED.capabilities, + system_prompt = EXCLUDED.system_prompt, + config = EXCLUDED.config + """, agent["id"], agent["name"], agent["role"], agent["description"], + json.dumps(agent["capabilities"]), agent["system_prompt"], + json.dumps(agent["config"]), True) + + +# Task operations +async def create_task( + title: str, + description: Optional[str] = None, + status: str = "parking_lot", + priority: str = "medium", + project_id: Optional[int] = None, + assigned_to: Optional[str] = None, + assigned_type: str = "human", + tags: Optional[List[str]] = None, + due_date: Optional[datetime] = None, + actor: str = "system" +) -> Dict[str, Any]: + """Create a new task""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + INSERT INTO tasks (title, description, status, priority, project_id, assigned_to, assigned_type, tags, due_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, title, description, status, priority, project_id, assigned_to, assigned_type, tags, + created_at, updated_at, completed_at, due_date + """, title, description, status, priority, project_id, assigned_to, assigned_type, + json.dumps(tags or []), due_date) + + task = dict(row) + task["tags"] = json.loads(task["tags"]) if task["tags"] else [] + + # Log history + await conn.execute(""" + INSERT INTO task_history (task_id, action, actor, actor_type, new_value) + VALUES ($1, $2, $3, $4, $5) + """, task["id"], "created", actor, "human", json.dumps(task)) + + return task + + +async def get_tasks( + status: Optional[str] = None, + assigned_to: Optional[str] = None, + project_id: Optional[int] = None, + priority: Optional[str] = None, + tags: Optional[List[str]] = None, + limit: int = 50, + offset: int = 0 +) -> List[Dict[str, Any]]: + """Get tasks with filters""" + pool = await get_pool() + async with pool.acquire() as conn: + query = "SELECT * FROM tasks WHERE 1=1" + params = [] + param_count = 0 + + if status: + param_count += 1 + query += f" AND status = ${param_count}" + params.append(status) + + if assigned_to: + param_count += 1 + query += f" AND assigned_to = ${param_count}" + params.append(assigned_to) + + if project_id: + param_count += 1 + query += f" AND project_id = ${param_count}" + params.append(project_id) + + if priority: + param_count += 1 + query += f" AND priority = ${param_count}" + params.append(priority) + + query += " ORDER BY created_at DESC" + param_count += 1 + query += f" LIMIT ${param_count}" + params.append(limit) + param_count += 1 + query += f" OFFSET ${param_count}" + params.append(offset) + + rows = await conn.fetch(query, *params) + tasks = [dict(row) for row in rows] + + for task in tasks: + task["tags"] = json.loads(task["tags"]) if task["tags"] else [] + + return tasks + + +async def update_task( + task_id: int, + updates: Dict[str, Any], + actor: str = "system" +) -> Dict[str, Any]: + """Update a task""" + pool = await get_pool() + async with pool.acquire() as conn: + # Get old values + old_task = await conn.fetchrow("SELECT * FROM tasks WHERE id = $1", task_id) + if not old_task: + raise ValueError(f"Task {task_id} not found") + + old_values = dict(old_task) + + # Build update query + set_clauses = ["updated_at = CURRENT_TIMESTAMP"] + params = [] + param_count = 0 + + for key, value in updates.items(): + if key in ["title", "description", "status", "priority", "project_id", "assigned_to", "assigned_type", "due_date", "completed_at"]: + param_count += 1 + set_clauses.append(f"{key} = ${param_count}") + params.append(value) + elif key == "tags": + param_count += 1 + set_clauses.append(f"tags = ${param_count}") + params.append(json.dumps(value)) + + param_count += 1 + params.append(task_id) + + query = f"UPDATE tasks SET {', '.join(set_clauses)} WHERE id = ${param_count} RETURNING *" + row = await conn.fetchrow(query, *params) + + task = dict(row) + task["tags"] = json.loads(task["tags"]) if task["tags"] else [] + + # Log history + await conn.execute(""" + INSERT INTO task_history (task_id, action, actor, actor_type, old_value, new_value) + VALUES ($1, $2, $3, $4, $5, $6) + """, task_id, "updated", actor, "human", json.dumps(old_values), json.dumps(updates)) + + return task + + +async def delete_task(task_id: int, actor: str = "system"): + """Delete a task""" + pool = await get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + INSERT INTO task_history (task_id, action, actor, actor_type) + VALUES ($1, $2, $3, $4) + """, task_id, "deleted", actor, "human") + + await conn.execute("DELETE FROM tasks WHERE id = $1", task_id) + + +# Time tracking operations +async def start_time_entry( + task_id: int, + project_id: Optional[int] = None, + actor: str = "system" +) -> Dict[str, Any]: + """Start automatic time tracking for a task""" + pool = await get_pool() + async with pool.acquire() as conn: + # Check if there's already an active entry + active = await conn.fetchrow(""" + SELECT * FROM time_entries + WHERE task_id = $1 AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1 + """, task_id) + + if active: + return dict(active) + + # Create new time entry + row = await conn.fetchrow(""" + INSERT INTO time_entries (task_id, project_id, started_at, duration_minutes) + VALUES ($1, $2, CURRENT_TIMESTAMP, 0) + RETURNING * + """, task_id, project_id) + + return dict(row) + + +async def stop_time_entry(task_id: int) -> Optional[Dict[str, Any]]: + """Stop automatic time tracking for a task""" + pool = await get_pool() + async with pool.acquire() as conn: + # Find active entry + active = await conn.fetchrow(""" + SELECT * FROM time_entries + WHERE task_id = $1 AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1 + """, task_id) + + if not active: + return None + + # Calculate duration and update + row = await conn.fetchrow(""" + UPDATE time_entries + SET ended_at = CURRENT_TIMESTAMP, + duration_minutes = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) / 60 + WHERE id = $1 + RETURNING * + """, active["id"]) + + return dict(row) + + +async def get_time_entries( + task_id: Optional[int] = None, + project_id: Optional[int] = None +) -> List[Dict[str, Any]]: + """Get time entries""" + pool = await get_pool() + async with pool.acquire() as conn: + query = "SELECT * FROM time_entries WHERE 1=1" + params = [] + + if task_id: + params.append(task_id) + query += f" AND task_id = ${len(params)}" + + if project_id: + params.append(project_id) + query += f" AND project_id = ${len(params)}" + + query += " ORDER BY started_at DESC" + + rows = await conn.fetch(query, *params) + return [dict(row) for row in rows] + + +async def get_task_history(task_id: int) -> List[Dict[str, Any]]: + """Get task history""" + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT * FROM task_history + WHERE task_id = $1 + ORDER BY timestamp DESC + """, task_id) + return [dict(row) for row in rows] + + +# Agent operations +async def get_agents(enabled_only: bool = True) -> List[Dict[str, Any]]: + """Get all agents""" + pool = await get_pool() + async with pool.acquire() as conn: + query = "SELECT * FROM agents" + if enabled_only: + query += " WHERE enabled = TRUE" + query += " ORDER BY id" + + rows = await conn.fetch(query) + agents = [] + for row in rows: + agent = dict(row) + agent["capabilities"] = json.loads(agent["capabilities"]) if agent["capabilities"] else [] + agent["config"] = json.loads(agent["config"]) if agent["config"] else {} + agents.append(agent) + return agents + + +async def get_agent(agent_id: str) -> Optional[Dict[str, Any]]: + """Get agent by ID""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM agents WHERE id = $1", agent_id) + if not row: + return None + + agent = dict(row) + agent["capabilities"] = json.loads(agent["capabilities"]) if agent["capabilities"] else [] + agent["config"] = json.loads(agent["config"]) if agent["config"] else {} + return agent + + +async def create_agent_execution( + task_id: int, + agent_id: str, + actor: str = "system", + requires_approval: bool = False +) -> Dict[str, Any]: + """Create agent execution record""" + pool = await get_pool() + async with pool.acquire() as conn: + # Get task and agent context + task = await conn.fetchrow("SELECT * FROM tasks WHERE id = $1", task_id) + agent = await conn.fetchrow("SELECT * FROM agents WHERE id = $1", agent_id) + + if not task or not agent: + raise ValueError("Task or agent not found") + + # Build input context + input_context = { + "task": dict(task), + "agent": dict(agent), + "timestamp": datetime.utcnow().isoformat() + } + + # Check if agent requires approval (CxO level) + agent_config = json.loads(agent["config"]) if agent["config"] else {} + requires_approval = agent_config.get("approval_level") == "cxo" + + row = await conn.fetchrow(""" + INSERT INTO agent_executions (task_id, agent_id, status, input_context, requires_approval, started_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + RETURNING * + """, task_id, agent_id, "pending", json.dumps(input_context), requires_approval) + + execution = dict(row) + execution["input_context"] = json.loads(execution["input_context"]) if execution["input_context"] else {} + return execution + + +async def get_agent_executions( + task_id: Optional[int] = None, + agent_id: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50 +) -> List[Dict[str, Any]]: + """Get agent executions""" + pool = await get_pool() + async with pool.acquire() as conn: + query = "SELECT * FROM agent_executions WHERE 1=1" + params = [] + + if task_id: + params.append(task_id) + query += f" AND task_id = ${len(params)}" + + if agent_id: + params.append(agent_id) + query += f" AND agent_id = ${len(params)}" + + if status: + params.append(status) + query += f" AND status = ${len(params)}" + + query += " ORDER BY started_at DESC" + params.append(limit) + query += f" LIMIT ${len(params)}" + + rows = await conn.fetch(query, *params) + executions = [] + for row in rows: + execution = dict(row) + for json_field in ["input_context", "output_result", "actions_proposed", "actions_executed"]: + if execution[json_field]: + execution[json_field] = json.loads(execution[json_field]) + executions.append(execution) + return executions diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 604e311..c7b6921 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded -from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine +from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents import auth as auth_module import config from rbac import require_page @@ -113,6 +113,14 @@ async def monitor_containers(): @app.on_event("startup") async def startup_event(): + # Initialize OPC database + from db import opc_db + try: + await opc_db.init_db() + logging.getLogger(__name__).info("OPC database initialized") + except Exception as e: + logging.getLogger(__name__).error("Failed to initialize OPC database: %s", e) + asyncio.create_task(monitor_containers()) @app.get("/api/health") @@ -178,6 +186,12 @@ app.include_router(info_engine.router, prefix="/api/info-engine", app.include_router(security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))]) +# OPC endpoints +app.include_router(opc_tasks.router, prefix="/api/opc", + dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) +app.include_router(opc_agents.router, prefix="/api/opc", + dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) + # WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary) app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint) diff --git a/dashboard/backend/rbac.py b/dashboard/backend/rbac.py index 4c12fa3..37ce289 100644 --- a/dashboard/backend/rbac.py +++ b/dashboard/backend/rbac.py @@ -20,7 +20,7 @@ ROLE_GROUP_MAP = { DEFAULT_RBAC = { "role_defaults": { "admin": {"pages": "*", "sidebar_links": "*"}, - "member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest"], "sidebar_links": "*"}, + "member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest", "opc"], "sidebar_links": "*"}, "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}, }, "user_overrides": {}, diff --git a/dashboard/backend/requirements.txt b/dashboard/backend/requirements.txt index 707d126..03d88c2 100644 --- a/dashboard/backend/requirements.txt +++ b/dashboard/backend/requirements.txt @@ -12,3 +12,5 @@ slowapi==0.1.9 webauthn==2.7.1 cryptography>=44.0.2 aiosqlite +asyncpg==0.29.0 +reportlab==4.0.7 diff --git a/dashboard/backend/routers/cc_connect.py b/dashboard/backend/routers/cc_connect.py index bc8361d..c3d82be 100644 --- a/dashboard/backend/routers/cc_connect.py +++ b/dashboard/backend/routers/cc_connect.py @@ -7,10 +7,19 @@ from fastapi import APIRouter from config import CC_CONNECT_URL, DOCKER_HOST router = APIRouter() -client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5) + +_client = None + +def get_docker_client(): + """Get or create Docker client (lazy initialization).""" + global _client + if _client is None: + _client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5) + return _client def _get_cc_connect_container(): + client = get_docker_client() matches = client.containers.list(all=True, filters={"name": "cc-connect"}) return matches[0] if matches else None diff --git a/dashboard/backend/routers/opc_agents.py b/dashboard/backend/routers/opc_agents.py new file mode 100644 index 0000000..e32847b --- /dev/null +++ b/dashboard/backend/routers/opc_agents.py @@ -0,0 +1,116 @@ +""" +OPC Agents Router +""" +from fastapi import APIRouter, Depends, HTTPException +from typing import Optional, List +from pydantic import BaseModel +from db import opc_db +from rbac import require_page, User, _inject_user, require_admin + +router = APIRouter() + + +class AgentUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + system_prompt: Optional[str] = None + enabled: Optional[bool] = None + + +class ExecutionApproval(BaseModel): + approved: bool + + +@router.get("/agents") +async def list_agents( + enabled_only: bool = True, + user: User = Depends(_inject_user) +): + """List all agents""" + agents = await opc_db.get_agents(enabled_only=enabled_only) + return {"items": agents} + + +@router.get("/agents/{agent_id}") +async def get_agent( + agent_id: str, + user: User = Depends(_inject_user) +): + """Get agent details""" + agent = await opc_db.get_agent(agent_id) + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + return agent + + +@router.put("/agents/{agent_id}", dependencies=[Depends(require_admin())]) +async def update_agent( + agent_id: str, + updates: AgentUpdate, + user: User = Depends(_inject_user) +): + """Update agent configuration (admin only)""" + # This would need an update_agent function in opc_db + raise HTTPException(status_code=501, detail="Not implemented yet") + + +@router.post("/agents/{agent_id}/execute") +async def trigger_agent( + agent_id: str, + task_id: int, + user: User = Depends(_inject_user) +): + """Manually trigger agent execution""" + execution = await opc_db.create_agent_execution( + task_id=task_id, + agent_id=agent_id, + actor=user.username + ) + return execution + + +@router.get("/executions") +async def list_executions( + task_id: Optional[int] = None, + agent_id: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + user: User = Depends(_inject_user) +): + """List agent executions""" + executions = await opc_db.get_agent_executions( + task_id=task_id, + agent_id=agent_id, + status=status, + limit=limit + ) + return {"items": executions} + + +@router.get("/executions/{execution_id}") +async def get_execution( + execution_id: int, + user: User = Depends(_inject_user) +): + """Get execution details""" + executions = await opc_db.get_agent_executions(limit=1000) + execution = next((e for e in executions if e["id"] == execution_id), None) + if not execution: + raise HTTPException(status_code=404, detail="Execution not found") + return execution + + +@router.post("/executions/{execution_id}/approve") +async def approve_execution( + execution_id: int, + approval: ExecutionApproval, + user: User = Depends(_inject_user) +): + """Approve or reject agent execution (for CxO level actions)""" + # This would need an approve_execution function in opc_db + # For now, return placeholder + return { + "execution_id": execution_id, + "approved": approval.approved, + "approved_by": user.username + } diff --git a/dashboard/backend/routers/opc_tasks.py b/dashboard/backend/routers/opc_tasks.py new file mode 100644 index 0000000..13339ec --- /dev/null +++ b/dashboard/backend/routers/opc_tasks.py @@ -0,0 +1,259 @@ +""" +OPC Task Management Router +""" +from fastapi import APIRouter, Depends, Query, HTTPException +from typing import Optional, List +from pydantic import BaseModel +from datetime import datetime +from db import opc_db +from rbac import require_page, User, _inject_user + +router = APIRouter() + + +class TaskCreate(BaseModel): + title: str + description: Optional[str] = None + status: str = "parking_lot" + priority: str = "medium" + project_id: Optional[int] = None + assigned_to: Optional[str] = None + assigned_type: str = "human" + tags: Optional[List[str]] = None + due_date: Optional[datetime] = None + + +class TaskUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + priority: Optional[str] = None + project_id: Optional[int] = None + assigned_to: Optional[str] = None + assigned_type: Optional[str] = None + tags: Optional[List[str]] = None + due_date: Optional[datetime] = None + completed_at: Optional[datetime] = None + + +class TaskMove(BaseModel): + status: str + + +class TaskAssign(BaseModel): + assigned_to: str + assigned_type: str = "human" + + +@router.get("/tasks") +async def list_tasks( + status: Optional[str] = Query(None), + assigned_to: Optional[str] = Query(None), + project_id: Optional[int] = Query(None), + priority: Optional[str] = Query(None), + tags: Optional[str] = Query(None), + limit: int = Query(50, le=100), + offset: int = Query(0, ge=0), + user: User = Depends(_inject_user) +): + """List tasks with filters""" + tag_list = tags.split(",") if tags else None + tasks = await opc_db.get_tasks( + status=status, + assigned_to=assigned_to, + project_id=project_id, + priority=priority, + tags=tag_list, + limit=limit, + offset=offset + ) + return {"items": tasks, "total": len(tasks)} + + +@router.post("/tasks") +async def create_task( + task: TaskCreate, + user: User = Depends(_inject_user) +): + """Create a new task""" + created_task = await opc_db.create_task( + title=task.title, + description=task.description, + status=task.status, + priority=task.priority, + project_id=task.project_id, + assigned_to=task.assigned_to, + assigned_type=task.assigned_type, + tags=task.tags, + due_date=task.due_date, + actor=user.username + ) + + # Start automatic timer if status is in_progress + if task.status == "in_progress": + await opc_db.start_time_entry( + task_id=created_task["id"], + project_id=task.project_id, + actor=user.username + ) + + return created_task + + +@router.get("/tasks/{task_id}") +async def get_task( + task_id: int, + user: User = Depends(_inject_user) +): + """Get task details""" + tasks = await opc_db.get_tasks(limit=1, offset=0) + task = next((t for t in tasks if t["id"] == task_id), None) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +@router.put("/tasks/{task_id}") +async def update_task( + task_id: int, + task: TaskUpdate, + user: User = Depends(_inject_user) +): + """Update a task""" + updates = task.dict(exclude_unset=True) + + # Get current task status + tasks = await opc_db.get_tasks(limit=1000, offset=0) + current_task = next((t for t in tasks if t["id"] == task_id), None) + if not current_task: + raise HTTPException(status_code=404, detail="Task not found") + + # Handle automatic time tracking + old_status = current_task["status"] + new_status = updates.get("status", old_status) + + # Start timer when moving to in_progress + if old_status != "in_progress" and new_status == "in_progress": + await opc_db.start_time_entry( + task_id=task_id, + project_id=current_task.get("project_id"), + actor=user.username + ) + + # Stop timer when moving away from in_progress + if old_status == "in_progress" and new_status != "in_progress": + await opc_db.stop_time_entry(task_id=task_id) + + # Set completed_at when moving to done + if new_status == "done" and old_status != "done": + updates["completed_at"] = datetime.utcnow() + + updated_task = await opc_db.update_task( + task_id=task_id, + updates=updates, + actor=user.username + ) + return updated_task + + +@router.delete("/tasks/{task_id}") +async def delete_task( + task_id: int, + user: User = Depends(_inject_user) +): + """Delete a task""" + await opc_db.delete_task(task_id=task_id, actor=user.username) + return {"ok": True} + + +@router.put("/tasks/{task_id}/move") +async def move_task( + task_id: int, + move: TaskMove, + user: User = Depends(_inject_user) +): + """Move task to a different column""" + updates = {"status": move.status} + + # Get current task + tasks = await opc_db.get_tasks(limit=1000, offset=0) + current_task = next((t for t in tasks if t["id"] == task_id), None) + if not current_task: + raise HTTPException(status_code=404, detail="Task not found") + + old_status = current_task["status"] + + # Handle automatic time tracking + if old_status != "in_progress" and move.status == "in_progress": + await opc_db.start_time_entry( + task_id=task_id, + project_id=current_task.get("project_id"), + actor=user.username + ) + + if old_status == "in_progress" and move.status != "in_progress": + await opc_db.stop_time_entry(task_id=task_id) + + if move.status == "done": + updates["completed_at"] = datetime.utcnow() + + updated_task = await opc_db.update_task( + task_id=task_id, + updates=updates, + actor=user.username + ) + return updated_task + + +@router.post("/tasks/{task_id}/assign") +async def assign_task( + task_id: int, + assign: TaskAssign, + user: User = Depends(_inject_user) +): + """Assign task to human or agent""" + updates = { + "assigned_to": assign.assigned_to, + "assigned_type": assign.assigned_type + } + + updated_task = await opc_db.update_task( + task_id=task_id, + updates=updates, + actor=user.username + ) + + # If assigning to agent, create execution record + if assign.assigned_type == "agent": + await opc_db.create_agent_execution( + task_id=task_id, + agent_id=assign.assigned_to, + actor=user.username + ) + + return updated_task + + +@router.get("/tasks/{task_id}/history") +async def get_task_history( + task_id: int, + user: User = Depends(_inject_user) +): + """Get task history""" + history = await opc_db.get_task_history(task_id=task_id) + return {"items": history} + + +@router.get("/tasks/{task_id}/time") +async def get_task_time( + task_id: int, + user: User = Depends(_inject_user) +): + """Get time entries for a task""" + entries = await opc_db.get_time_entries(task_id=task_id) + total_minutes = sum(e["duration_minutes"] for e in entries) + return { + "entries": entries, + "total_minutes": total_minutes, + "total_hours": round(total_minutes / 60, 2) + } diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 95e2b20..2c71443 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -12,6 +12,7 @@ import LiteLLM from "./routes/LiteLLM.svelte"; import CcConnect from "./routes/CcConnect.svelte"; import InfoEngine from "./routes/InfoEngine.svelte"; + import OPC from "./routes/OPC.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js"; @@ -29,6 +30,7 @@ "litellm", "cc-connect", "info-engine", + "opc", ]); let page = $state("dashboard"); @@ -207,6 +209,8 @@ {:else if page === "info-engine" && hasPageAccess("dashboard")} + {:else if page === "opc" && hasPageAccess("opc")} + {:else if page !== "terminal"} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index e9fd1a5..c96e333 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -20,6 +20,7 @@ { id: "dashboard", label: "Overview", icon: "grid" }, { id: "info-engine", label: "Info Engine", icon: "sparkles" }, { id: "litellm", label: "LiteLLM", icon: "bolt" }, + { id: "opc", label: "OPC", icon: "kanban" }, { id: "docker", label: "Docker", icon: "box" }, { id: "files", label: "Files", icon: "folder" }, { id: "terminal", label: "Terminal", icon: "terminal" }, @@ -243,6 +244,8 @@ {:else if icon === "shield"} + {:else if icon === "kanban"} + {:else if icon === "music"} {:else if icon === "play"} diff --git a/dashboard/frontend/src/components/opc/KanbanBoard.svelte b/dashboard/frontend/src/components/opc/KanbanBoard.svelte new file mode 100644 index 0000000..cc6663e --- /dev/null +++ b/dashboard/frontend/src/components/opc/KanbanBoard.svelte @@ -0,0 +1,40 @@ + + +
+ {#each columns as column} + + {/each} +
diff --git a/dashboard/frontend/src/components/opc/KanbanColumn.svelte b/dashboard/frontend/src/components/opc/KanbanColumn.svelte new file mode 100644 index 0000000..ca60a0d --- /dev/null +++ b/dashboard/frontend/src/components/opc/KanbanColumn.svelte @@ -0,0 +1,85 @@ + + +
+ +
+

+ {title} +

+ + {tasks.length} + +
+ + +
+ {#if tasks.length === 0} +
+ Drop tasks here +
+ {:else} + {#each tasks as task (task.id)} +
handleDragStart(e, task)}> + +
+ {/each} + {/if} +
+
diff --git a/dashboard/frontend/src/components/opc/TaskCard.svelte b/dashboard/frontend/src/components/opc/TaskCard.svelte new file mode 100644 index 0000000..8d139ba --- /dev/null +++ b/dashboard/frontend/src/components/opc/TaskCard.svelte @@ -0,0 +1,169 @@ + + +
showActions = true} + onmouseleave={() => showActions = false} +> + +
+ + {task.priority} + + {#if showActions} +
+ + +
+ {/if} +
+ + +

+ {task.title} +

+ + + {#if task.description} +

+ {task.description} +

+ {/if} + + + {#if task.tags && task.tags.length > 0} +
+ {#each task.tags as tag} + + {tag} + + {/each} +
+ {/if} + + +
+
+ {#if task.assigned_to} + {#if task.assigned_type === "agent"} + + {getAgentIcon(task.assigned_to)} + + {:else} + 👤 {task.assigned_to} + {/if} + {:else} + + {/if} +
+ + {#if timeInfo && timeInfo.total_hours > 0} + + ⏱️ {timeInfo.total_hours}h + + {/if} +
+ + + {#if task.due_date} +
+ 📅 {new Date(task.due_date).toLocaleDateString()} +
+ {/if} + + + {#if showActions && !task.assigned_to} +
+
Assign to agent:
+
+ {#each agents as agent} + + {/each} +
+
+ {/if} +
diff --git a/dashboard/frontend/src/components/opc/TaskModal.svelte b/dashboard/frontend/src/components/opc/TaskModal.svelte new file mode 100644 index 0000000..859b8e7 --- /dev/null +++ b/dashboard/frontend/src/components/opc/TaskModal.svelte @@ -0,0 +1,250 @@ + + + +
e.target === e.currentTarget && onClose?.()} +> + +
+ +
+

+ {isEdit ? "Edit Task" : "Create Task"} +

+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + {#if formData.assigned_type === "agent"} + + {:else} + + {/if} +
+
+ + +
+ + +
+ + +
+ +
+ e.key === "Enter" && (e.preventDefault(), addTag())} + class="flex-1 px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100" + placeholder="Add tag and press Enter" + /> + +
+ {#if formData.tags.length > 0} +
+ {#each formData.tags as tag} + + {tag} + + + {/each} +
+ {/if} +
+ + +
+ + +
+
+
+
diff --git a/dashboard/frontend/src/lib/opc-api.js b/dashboard/frontend/src/lib/opc-api.js new file mode 100644 index 0000000..e193037 --- /dev/null +++ b/dashboard/frontend/src/lib/opc-api.js @@ -0,0 +1,80 @@ +/** + * OPC API Client + */ +import { get, post, put, del } from "./api.js"; + +// Tasks +export async function getTasks(filters = {}) { + const params = new URLSearchParams(); + if (filters.status) params.append("status", filters.status); + if (filters.assigned_to) params.append("assigned_to", filters.assigned_to); + if (filters.project_id) params.append("project_id", filters.project_id); + if (filters.priority) params.append("priority", filters.priority); + if (filters.tags) params.append("tags", filters.tags.join(",")); + if (filters.limit) params.append("limit", filters.limit); + if (filters.offset) params.append("offset", filters.offset); + + const query = params.toString(); + return get(`/opc/tasks${query ? "?" + query : ""}`); +} + +export async function createTask(task) { + return post("/opc/tasks", task); +} + +export async function updateTask(taskId, updates) { + return put(`/opc/tasks/${taskId}`, updates); +} + +export async function deleteTask(taskId) { + return del(`/opc/tasks/${taskId}`); +} + +export async function moveTask(taskId, status) { + return put(`/opc/tasks/${taskId}/move`, { status }); +} + +export async function assignTask(taskId, assigned_to, assigned_type = "human") { + return post(`/opc/tasks/${taskId}/assign`, { assigned_to, assigned_type }); +} + +export async function getTaskHistory(taskId) { + return get(`/opc/tasks/${taskId}/history`); +} + +export async function getTaskTime(taskId) { + return get(`/opc/tasks/${taskId}/time`); +} + +// Agents +export async function getAgents(enabledOnly = true) { + return get(`/opc/agents?enabled_only=${enabledOnly}`); +} + +export async function getAgent(agentId) { + return get(`/opc/agents/${agentId}`); +} + +export async function triggerAgent(agentId, taskId) { + return post(`/opc/agents/${agentId}/execute?task_id=${taskId}`); +} + +// Executions +export async function getExecutions(filters = {}) { + const params = new URLSearchParams(); + if (filters.task_id) params.append("task_id", filters.task_id); + if (filters.agent_id) params.append("agent_id", filters.agent_id); + if (filters.status) params.append("status", filters.status); + if (filters.limit) params.append("limit", filters.limit); + + const query = params.toString(); + return get(`/opc/executions${query ? "?" + query : ""}`); +} + +export async function getExecution(executionId) { + return get(`/opc/executions/${executionId}`); +} + +export async function approveExecution(executionId, approved) { + return post(`/opc/executions/${executionId}/approve`, { approved }); +} diff --git a/dashboard/frontend/src/routes/OPC.svelte b/dashboard/frontend/src/routes/OPC.svelte new file mode 100644 index 0000000..dc81994 --- /dev/null +++ b/dashboard/frontend/src/routes/OPC.svelte @@ -0,0 +1,149 @@ + + +
+ +
+
+

+ OPC Management +

+

+ Manage your tasks with AI agents +

+
+ +
+ + +
+
+
Total Tasks
+
+ {tasks.length} +
+
+
+
Parking Lot
+
+ {tasks.filter(t => t.status === "parking_lot").length} +
+
+
+
In Progress
+
+ {tasks.filter(t => t.status === "in_progress").length} +
+
+
+
Done
+
+ {tasks.filter(t => t.status === "done").length} +
+
+
+ + + {#if loading} +
+
Loading...
+
+ {:else} +
+ +
+ {/if} +
+ + +{#if showTaskModal} + +{/if} diff --git a/gitea/init-opc-db.sql b/gitea/init-opc-db.sql new file mode 100644 index 0000000..178e8b3 --- /dev/null +++ b/gitea/init-opc-db.sql @@ -0,0 +1,6 @@ +-- Create OPC database if it doesn't exist +SELECT 'CREATE DATABASE opc' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'opc')\gexec + +-- Grant permissions to gitea user +GRANT ALL PRIVILEGES ON DATABASE opc TO gitea;