feat: add OPC (One Person Company) management system with Kanban board
Deploy Dashboard (Dev) / deploy-dev (push) Has been cancelled
Run Tests / Backend Tests (push) Has been cancelled
Run Tests / Frontend Tests (push) Has been cancelled
Run Tests / Test Summary (push) Has been cancelled

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
This commit is contained in:
Gan, Jimmy
2026-03-31 14:31:31 +08:00
parent 51c4cd1a4e
commit 1ca019fa48
16 changed files with 1771 additions and 3 deletions
+582
View File
@@ -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
+15 -1
View File
@@ -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)
+1 -1
View File
@@ -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": {},
+2
View File
@@ -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
+10 -1
View File
@@ -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
+116
View File
@@ -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
}
+259
View File
@@ -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)
}
+4
View File
@@ -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 @@
<CcConnect />
{:else if page === "info-engine" && hasPageAccess("dashboard")}
<InfoEngine />
{:else if page === "opc" && hasPageAccess("opc")}
<OPC />
{:else if page !== "terminal"}
<Dashboard />
{/if}
@@ -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 @@
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{:else if icon === "shield"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
{:else if icon === "kanban"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2zm8 0h-2a2 2 0 00-2 2v8a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2z" /></svg>
{:else if icon === "music"}
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg>
{:else if icon === "play"}
@@ -0,0 +1,40 @@
<script>
import KanbanColumn from "./KanbanColumn.svelte";
import { moveTask } from "../../lib/opc-api.js";
let { tasks = [], agents = [], onEdit, onDelete, onAssign, onTaskMoved } = $props();
const columns = [
{ id: "parking_lot", title: "Parking Lot", status: "parking_lot" },
{ id: "in_progress", title: "In Progress", status: "in_progress" },
{ id: "done", title: "Done", status: "done" }
];
function getTasksByStatus(status) {
return tasks.filter(t => t.status === status);
}
async function handleDrop(taskId, newStatus) {
try {
await moveTask(taskId, newStatus);
onTaskMoved?.(taskId, newStatus);
} catch (e) {
alert("Failed to move task: " + e.message);
}
}
</script>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 h-full">
{#each columns as column}
<KanbanColumn
title={column.title}
status={column.status}
tasks={getTasksByStatus(column.status)}
{agents}
{onEdit}
{onDelete}
{onAssign}
onDrop={handleDrop}
/>
{/each}
</div>
@@ -0,0 +1,85 @@
<script>
import TaskCard from "./TaskCard.svelte";
let {
title,
status,
tasks = [],
agents = [],
onEdit,
onDelete,
onAssign,
onDrop
} = $props();
const columnColors = {
parking_lot: "bg-slate-100 dark:bg-slate-800 border-slate-300",
in_progress: "bg-blue-50 dark:bg-blue-900/20 border-blue-300",
done: "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300"
};
function handleDragOver(e) {
e.preventDefault();
e.currentTarget.classList.add("ring-2", "ring-primary-400");
}
function handleDragLeave(e) {
e.currentTarget.classList.remove("ring-2", "ring-primary-400");
}
function handleDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove("ring-2", "ring-primary-400");
const taskId = parseInt(e.dataTransfer.getData("taskId"));
const fromStatus = e.dataTransfer.getData("fromStatus");
if (fromStatus !== status) {
onDrop?.(taskId, status);
}
}
function handleDragStart(e, task) {
e.dataTransfer.setData("taskId", task.id);
e.dataTransfer.setData("fromStatus", task.status);
e.dataTransfer.effectAllowed = "move";
}
</script>
<div class="flex flex-col h-full">
<!-- Column header -->
<div class="flex items-center justify-between mb-3 pb-2 border-b border-surface-300 dark:border-surface-600">
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
{title}
</h3>
<span class="text-sm text-surface-600 dark:text-surface-400 bg-surface-200 dark:bg-surface-700 px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
<!-- Drop zone -->
<div
class="flex-1 min-h-[200px] p-2 rounded-lg border-2 border-dashed transition-all {columnColors[status] || columnColors.parking_lot}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
{#if tasks.length === 0}
<div class="flex items-center justify-center h-32 text-surface-400 dark:text-surface-500 text-sm">
Drop tasks here
</div>
{:else}
{#each tasks as task (task.id)}
<div draggable="true" ondragstart={(e) => handleDragStart(e, task)}>
<TaskCard
{task}
{agents}
{onEdit}
{onDelete}
{onAssign}
/>
</div>
{/each}
{/if}
</div>
</div>
@@ -0,0 +1,169 @@
<script>
import { deleteTask, assignTask, getTaskTime } from "../../lib/opc-api.js";
let { task, agents = [], onEdit, onDelete, onAssign } = $props();
let showActions = $state(false);
let timeInfo = $state(null);
const priorityColors = {
low: "bg-slate-200 text-slate-700",
medium: "bg-blue-100 text-blue-700",
high: "bg-amber-100 text-amber-700",
urgent: "bg-rose-100 text-rose-700"
};
async function loadTimeInfo() {
if (task.status === "in_progress") {
try {
timeInfo = await getTaskTime(task.id);
} catch (e) {
console.error("Failed to load time info:", e);
}
}
}
$effect(() => {
loadTimeInfo();
});
async function handleDelete() {
if (confirm(`Delete task "${task.title}"?`)) {
try {
await deleteTask(task.id);
onDelete?.(task.id);
} catch (e) {
alert("Failed to delete task: " + e.message);
}
}
}
async function handleAssignAgent(agentId) {
try {
await assignTask(task.id, agentId, "agent");
onAssign?.(task.id, agentId, "agent");
} catch (e) {
alert("Failed to assign agent: " + e.message);
}
}
function getAgentIcon(agentId) {
const icons = {
pm: "📋",
cto: "💻",
coo: "⚙️",
ceo: "🎯",
marketing: "📢",
social_media: "📱"
};
return icons[agentId] || "🤖";
}
</script>
<div
class="bg-white dark:bg-surface-800 rounded-lg p-3 mb-2 shadow-sm border border-surface-200 dark:border-surface-700 cursor-move hover:shadow-md transition-shadow"
onmouseenter={() => showActions = true}
onmouseleave={() => showActions = false}
>
<!-- Priority badge -->
<div class="flex items-start justify-between mb-2">
<span class="text-xs px-2 py-0.5 rounded {priorityColors[task.priority] || priorityColors.medium}">
{task.priority}
</span>
{#if showActions}
<div class="flex gap-1">
<button
onclick={() => onEdit?.(task)}
class="text-xs text-surface-600 hover:text-primary-600 dark:text-surface-400"
title="Edit"
>
✏️
</button>
<button
onclick={handleDelete}
class="text-xs text-surface-600 hover:text-rose-600 dark:text-surface-400"
title="Delete"
>
🗑️
</button>
</div>
{/if}
</div>
<!-- Title -->
<h4 class="font-medium text-surface-900 dark:text-surface-100 mb-1 text-sm">
{task.title}
</h4>
<!-- Description preview -->
{#if task.description}
<p class="text-xs text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">
{task.description}
</p>
{/if}
<!-- Tags -->
{#if task.tags && task.tags.length > 0}
<div class="flex flex-wrap gap-1 mb-2">
{#each task.tags as tag}
<span class="text-xs px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300">
{tag}
</span>
{/each}
</div>
{/if}
<!-- Assignee and time -->
<div class="flex items-center justify-between text-xs text-surface-600 dark:text-surface-400">
<div class="flex items-center gap-1">
{#if task.assigned_to}
{#if task.assigned_type === "agent"}
<span title={task.assigned_to}>
{getAgentIcon(task.assigned_to)}
</span>
{:else}
<span>👤 {task.assigned_to}</span>
{/if}
{:else}
<button
onclick={() => showActions = true}
class="text-surface-500 hover:text-primary-600"
title="Assign"
>
Unassigned
</button>
{/if}
</div>
{#if timeInfo && timeInfo.total_hours > 0}
<span class="text-xs text-surface-500">
⏱️ {timeInfo.total_hours}h
</span>
{/if}
</div>
<!-- Due date -->
{#if task.due_date}
<div class="text-xs text-surface-500 mt-1">
📅 {new Date(task.due_date).toLocaleDateString()}
</div>
{/if}
<!-- Quick assign to agent (when hovering) -->
{#if showActions && !task.assigned_to}
<div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
<div class="text-xs text-surface-600 mb-1">Assign to agent:</div>
<div class="flex gap-1">
{#each agents as agent}
<button
onclick={() => handleAssignAgent(agent.id)}
class="text-lg hover:scale-110 transition-transform"
title={agent.name}
>
{getAgentIcon(agent.id)}
</button>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,250 @@
<script>
import { createTask, updateTask } from "../../lib/opc-api.js";
let { task = null, agents = [], onClose, onSave } = $props();
let isEdit = $state(!!task);
let formData = $state({
title: task?.title || "",
description: task?.description || "",
status: task?.status || "parking_lot",
priority: task?.priority || "medium",
assigned_to: task?.assigned_to || "",
assigned_type: task?.assigned_type || "human",
tags: task?.tags || [],
due_date: task?.due_date ? task.due_date.split("T")[0] : ""
});
let tagInput = $state("");
let saving = $state(false);
function addTag() {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
formData.tags = [...formData.tags, tagInput.trim()];
tagInput = "";
}
}
function removeTag(tag) {
formData.tags = formData.tags.filter(t => t !== tag);
}
async function handleSubmit(e) {
e.preventDefault();
saving = true;
try {
const payload = {
...formData,
due_date: formData.due_date ? new Date(formData.due_date).toISOString() : null,
assigned_to: formData.assigned_to || null
};
if (isEdit) {
await updateTask(task.id, payload);
} else {
await createTask(payload);
}
onSave?.();
onClose?.();
} catch (e) {
alert("Failed to save task: " + e.message);
} finally {
saving = false;
}
}
</script>
<!-- Modal backdrop -->
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onclick={(e) => e.target === e.currentTarget && onClose?.()}
>
<!-- Modal content -->
<div class="bg-white dark:bg-surface-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-surface-200 dark:border-surface-700">
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
{isEdit ? "Edit Task" : "Create Task"}
</h2>
<button
onclick={onClose}
class="text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
>
</button>
</div>
<!-- Form -->
<form onsubmit={handleSubmit} class="p-4 space-y-4">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Title *
</label>
<input
type="text"
bind:value={formData.title}
required
class="w-full 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="Task title"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Description
</label>
<textarea
bind:value={formData.description}
rows="4"
class="w-full 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="Task description"
></textarea>
</div>
<!-- Status and Priority -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Status
</label>
<select
bind:value={formData.status}
class="w-full 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"
>
<option value="parking_lot">Parking Lot</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Priority
</label>
<select
bind:value={formData.priority}
class="w-full 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"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<!-- Assignee -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Assign to
</label>
<select
bind:value={formData.assigned_type}
class="w-full 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"
>
<option value="human">Human</option>
<option value="agent">Agent</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
{formData.assigned_type === "agent" ? "Agent" : "Username"}
</label>
{#if formData.assigned_type === "agent"}
<select
bind:value={formData.assigned_to}
class="w-full 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"
>
<option value="">Unassigned</option>
{#each agents as agent}
<option value={agent.id}>{agent.name}</option>
{/each}
</select>
{:else}
<input
type="text"
bind:value={formData.assigned_to}
class="w-full 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="Username"
/>
{/if}
</div>
</div>
<!-- Due date -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Due Date
</label>
<input
type="date"
bind:value={formData.due_date}
class="w-full 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"
/>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
Tags
</label>
<div class="flex gap-2 mb-2">
<input
type="text"
bind:value={tagInput}
onkeydown={(e) => 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"
/>
<button
type="button"
onclick={addTag}
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600"
>
Add
</button>
</div>
{#if formData.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each formData.tags as tag}
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded text-sm">
{tag}
<button
type="button"
onclick={() => removeTag(tag)}
class="hover:text-primary-900 dark:hover:text-primary-100"
>
</button>
</span>
{/each}
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 pt-4 border-t border-surface-200 dark:border-surface-700">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</form>
</div>
</div>
+80
View File
@@ -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 });
}
+149
View File
@@ -0,0 +1,149 @@
<script>
import { onMount } from "svelte";
import KanbanBoard from "../components/opc/KanbanBoard.svelte";
import TaskModal from "../components/opc/TaskModal.svelte";
import { getTasks, getAgents } from "../lib/opc-api.js";
let tasks = $state([]);
let agents = $state([]);
let loading = $state(true);
let showTaskModal = $state(false);
let editingTask = $state(null);
async function loadData() {
loading = true;
try {
const [tasksRes, agentsRes] = await Promise.all([
getTasks(),
getAgents()
]);
tasks = tasksRes.items || [];
agents = agentsRes.items || [];
} catch (e) {
console.error("Failed to load data:", e);
} finally {
loading = false;
}
}
function handleCreateTask() {
editingTask = null;
showTaskModal = true;
}
function handleEditTask(task) {
editingTask = task;
showTaskModal = true;
}
function handleDeleteTask(taskId) {
tasks = tasks.filter(t => t.id !== taskId);
}
function handleAssignTask(taskId, assignedTo, assignedType) {
const task = tasks.find(t => t.id === taskId);
if (task) {
task.assigned_to = assignedTo;
task.assigned_type = assignedType;
tasks = [...tasks];
}
}
function handleTaskMoved(taskId, newStatus) {
const task = tasks.find(t => t.id === taskId);
if (task) {
task.status = newStatus;
tasks = [...tasks];
}
}
function handleModalClose() {
showTaskModal = false;
editingTask = null;
}
function handleModalSave() {
loadData();
}
onMount(() => {
loadData();
});
</script>
<div class="space-y-6 h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100">
OPC Management
</h1>
<p class="text-surface-600 dark:text-surface-400 mt-1">
Manage your tasks with AI agents
</p>
</div>
<button
onclick={handleCreateTask}
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<span></span>
<span>New Task</span>
</button>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">Total Tasks</div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100 mt-1">
{tasks.length}
</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">Parking Lot</div>
<div class="text-2xl font-bold text-slate-600 dark:text-slate-400 mt-1">
{tasks.filter(t => t.status === "parking_lot").length}
</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">In Progress</div>
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{tasks.filter(t => t.status === "in_progress").length}
</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">Done</div>
<div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
{tasks.filter(t => t.status === "done").length}
</div>
</div>
</div>
<!-- Kanban Board -->
{#if loading}
<div class="flex-1 flex items-center justify-center">
<div class="text-surface-600 dark:text-surface-400">Loading...</div>
</div>
{:else}
<div class="flex-1 overflow-hidden">
<KanbanBoard
{tasks}
{agents}
onEdit={handleEditTask}
onDelete={handleDeleteTask}
onAssign={handleAssignTask}
onTaskMoved={handleTaskMoved}
/>
</div>
{/if}
</div>
<!-- Task Modal -->
{#if showTaskModal}
<TaskModal
task={editingTask}
{agents}
onClose={handleModalClose}
onSave={handleModalSave}
/>
{/if}