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 @@
+ {task.description} +
+ {/if} + + + {#if task.tags && task.tags.length > 0} ++ Manage your tasks with AI agents +
+