From 1422cc9bc8b927174d9a67105374a276c3cbe550 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Tue, 31 Mar 2026 14:46:44 +0800 Subject: [PATCH] feat: complete Phase 1 MVP - add agent executor and WebSocket real-time updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent Executor Service: - BaseAgent class with LLM integration via LiteLLM proxy - AgentExecutor service that processes pending executions every 5 seconds - Action execution: create_subtask, update_status, send_notification, add_comment - Approval workflow: CxO level agents require user approval before execution - Telegram notifications for approvals and completions - Error handling and execution status tracking WebSocket Real-Time Updates: - WebSocket endpoint at /ws/opc for real-time task updates - ConnectionManager for broadcasting to all connected clients - Frontend WebSocket client with auto-reconnect - Live updates for task create/update/move/delete actions - Agent execution status broadcasts Integration: - Agent executor starts on dashboard startup - Task router broadcasts WebSocket updates on all mutations - Frontend OPC page subscribes to WebSocket and updates UI in real-time - Agents (PM, CTO, COO) can now execute tasks autonomously Phase 1 MVP Complete: ✅ PostgreSQL database with full schema ✅ Task CRUD API with automatic time tracking ✅ Kanban board with drag-and-drop ✅ Agent executor service with LLM integration ✅ WebSocket real-time updates ✅ 3 core agents ready to execute Next: Agent panel UI, email notifications, PDF invoicing --- dashboard/backend/main.py | 11 +- dashboard/backend/routers/opc_tasks.py | 12 + dashboard/backend/routers/opc_ws.py | 107 ++++++ dashboard/backend/services/agent_executor.py | 306 ++++++++++++++++++ .../backend/services/agents/base_agent.py | 146 +++++++++ dashboard/frontend/src/lib/opc-ws.js | 85 +++++ dashboard/frontend/src/routes/OPC.svelte | 32 +- scripts/init-opc-db.sh | 23 ++ 8 files changed, 720 insertions(+), 2 deletions(-) create mode 100644 dashboard/backend/routers/opc_ws.py create mode 100644 dashboard/backend/services/agent_executor.py create mode 100644 dashboard/backend/services/agents/base_agent.py create mode 100644 dashboard/frontend/src/lib/opc-ws.js create mode 100755 scripts/init-opc-db.sh diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index c7b6921..e75c3fc 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, opc_tasks, opc_agents +from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws import auth as auth_module import config from rbac import require_page @@ -121,6 +121,14 @@ async def startup_event(): except Exception as e: logging.getLogger(__name__).error("Failed to initialize OPC database: %s", e) + # Start agent executor service + from services import agent_executor + try: + asyncio.create_task(agent_executor.start_executor()) + logging.getLogger(__name__).info("Agent executor service started") + except Exception as e: + logging.getLogger(__name__).error("Failed to start agent executor: %s", e) + asyncio.create_task(monitor_containers()) @app.get("/api/health") @@ -194,5 +202,6 @@ app.include_router(opc_agents.router, prefix="/api/opc", # WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary) app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint) +app.add_api_websocket_route("/ws/opc", opc_ws.websocket_endpoint) app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") diff --git a/dashboard/backend/routers/opc_tasks.py b/dashboard/backend/routers/opc_tasks.py index 13339ec..5608210 100644 --- a/dashboard/backend/routers/opc_tasks.py +++ b/dashboard/backend/routers/opc_tasks.py @@ -7,6 +7,7 @@ from pydantic import BaseModel from datetime import datetime from db import opc_db from rbac import require_page, User, _inject_user +from routers import opc_ws router = APIRouter() @@ -97,6 +98,9 @@ async def create_task( actor=user.username ) + # Broadcast WebSocket update + await opc_ws.broadcast_task_update(created_task["id"], "created", created_task) + return created_task @@ -153,6 +157,10 @@ async def update_task( updates=updates, actor=user.username ) + + # Broadcast WebSocket update + await opc_ws.broadcast_task_update(task_id, "updated", updated_task) + return updated_task @@ -163,6 +171,10 @@ async def delete_task( ): """Delete a task""" await opc_db.delete_task(task_id=task_id, actor=user.username) + + # Broadcast WebSocket update + await opc_ws.broadcast_task_update(task_id, "deleted", {"id": task_id}) + return {"ok": True} diff --git a/dashboard/backend/routers/opc_ws.py b/dashboard/backend/routers/opc_ws.py new file mode 100644 index 0000000..1398eca --- /dev/null +++ b/dashboard/backend/routers/opc_ws.py @@ -0,0 +1,107 @@ +""" +OPC WebSocket Router - Real-time updates for tasks and agent executions +""" +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends +from typing import Set +import json +import asyncio +import logging +from auth import get_current_user_ws + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Active WebSocket connections +active_connections: Set[WebSocket] = set() + + +class ConnectionManager: + """Manages WebSocket connections""" + + def __init__(self): + self.active_connections: Set[WebSocket] = set() + + async def connect(self, websocket: WebSocket): + """Accept and register a new connection""" + await websocket.accept() + self.active_connections.add(websocket) + logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}") + + def disconnect(self, websocket: WebSocket): + """Remove a connection""" + self.active_connections.discard(websocket) + logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}") + + async def broadcast(self, message: dict): + """Broadcast message to all connected clients""" + disconnected = set() + for connection in self.active_connections: + try: + await connection.send_json(message) + except Exception as e: + logger.error(f"Failed to send message: {e}") + disconnected.add(connection) + + # Clean up disconnected clients + for conn in disconnected: + self.disconnect(conn) + + +manager = ConnectionManager() + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for OPC real-time updates""" + await manager.connect(websocket) + + try: + while True: + # Keep connection alive and receive any client messages + data = await websocket.receive_text() + + # Echo back for ping/pong + if data == "ping": + await websocket.send_text("pong") + + except WebSocketDisconnect: + manager.disconnect(websocket) + except Exception as e: + logger.error(f"WebSocket error: {e}") + manager.disconnect(websocket) + + +async def broadcast_task_update(task_id: int, action: str, task_data: dict): + """Broadcast task update to all connected clients""" + message = { + "type": "task_update", + "task_id": task_id, + "action": action, # created, updated, moved, deleted + "data": task_data, + "timestamp": task_data.get("updated_at") + } + await manager.broadcast(message) + + +async def broadcast_agent_execution(execution_id: int, status: str, execution_data: dict): + """Broadcast agent execution update""" + message = { + "type": "agent_execution", + "execution_id": execution_id, + "status": status, # pending, running, completed, failed, pending_approval + "data": execution_data, + "timestamp": execution_data.get("started_at") or execution_data.get("completed_at") + } + await manager.broadcast(message) + + +async def broadcast_agent_status(agent_id: str, status: str, message_text: str = ""): + """Broadcast agent status update""" + message = { + "type": "agent_status", + "agent_id": agent_id, + "status": status, # idle, working, error + "message": message_text + } + await manager.broadcast(message) diff --git a/dashboard/backend/services/agent_executor.py b/dashboard/backend/services/agent_executor.py new file mode 100644 index 0000000..353dc16 --- /dev/null +++ b/dashboard/backend/services/agent_executor.py @@ -0,0 +1,306 @@ +""" +Agent Executor Service - Executes agent tasks and manages their lifecycle +""" +import asyncio +import logging +from typing import Dict, Any, Optional +from datetime import datetime +from db import opc_db +from services.agents.base_agent import BaseAgent +import httpx +import os + +logger = logging.getLogger(__name__) + +# Telegram notification settings +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") + + +class AgentExecutor: + """Manages agent execution lifecycle""" + + def __init__(self): + self.agents: Dict[str, BaseAgent] = {} + self.running = False + + async def initialize(self): + """Load agents from database""" + agents_data = await opc_db.get_agents(enabled_only=True) + + for agent_data in agents_data: + agent = BaseAgent( + agent_id=agent_data["id"], + name=agent_data["name"], + role=agent_data["role"], + system_prompt=agent_data["system_prompt"], + config=agent_data["config"] + ) + self.agents[agent_data["id"]] = agent + logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})") + + async def start(self): + """Start the executor service""" + self.running = True + logger.info("Agent executor service started") + + while self.running: + try: + await self._process_pending_executions() + except Exception as e: + logger.error(f"Error in executor loop: {e}") + + await asyncio.sleep(5) # Check every 5 seconds + + async def stop(self): + """Stop the executor service""" + self.running = False + logger.info("Agent executor service stopped") + + async def _process_pending_executions(self): + """Process pending agent executions""" + executions = await opc_db.get_agent_executions(status="pending", limit=10) + + for execution in executions: + try: + await self._execute_agent(execution) + except Exception as e: + logger.error(f"Failed to execute agent {execution['agent_id']}: {e}") + await self._mark_execution_failed(execution["id"], str(e)) + + async def _execute_agent(self, execution: Dict[str, Any]): + """Execute a single agent task""" + agent_id = execution["agent_id"] + task_id = execution["task_id"] + + # Get agent + agent = self.agents.get(agent_id) + if not agent: + raise ValueError(f"Agent {agent_id} not found") + + # Mark as running + await self._update_execution_status(execution["id"], "running") + + # Get task and context + tasks = await opc_db.get_tasks(limit=1000) + task = next((t for t in tasks if t["id"] == task_id), None) + if not task: + raise ValueError(f"Task {task_id} not found") + + # Build context + context = await self._build_context(task) + + # Execute agent + logger.info(f"Executing agent {agent.name} on task {task['title']}") + result = await agent.execute(task, context) + + # Check if requires approval + if execution.get("requires_approval") or result.get("requires_approval"): + # Mark as pending approval + await self._mark_pending_approval(execution["id"], result) + await self._send_approval_notification(agent, task, result) + else: + # Execute actions + await self._execute_actions(task, result["actions"]) + + # Mark as completed + await self._mark_execution_completed(execution["id"], result) + + # Send completion notification + await self._send_completion_notification(agent, task, result) + + async def _build_context(self, task: Dict[str, Any]) -> Dict[str, Any]: + """Build context for agent execution""" + context = { + "task": task, + "timestamp": datetime.utcnow().isoformat() + } + + # Get related tasks from same project + if task.get("project_id"): + related_tasks = await opc_db.get_tasks(project_id=task["project_id"], limit=20) + context["related_tasks"] = related_tasks + + # Get task history + history = await opc_db.get_task_history(task["id"]) + context["history"] = history[:10] # Last 10 events + + return context + + async def _execute_actions(self, task: Dict[str, Any], actions: list): + """Execute proposed actions""" + for action in actions: + action_type = action.get("type") + + try: + if action_type == "create_subtask": + await self._action_create_subtask(task, action) + elif action_type == "update_task_status": + await self._action_update_status(task, action) + elif action_type == "send_notification": + await self._action_send_notification(action) + elif action_type == "add_comment": + await self._action_add_comment(task, action) + else: + logger.warning(f"Unknown action type: {action_type}") + except Exception as e: + logger.error(f"Failed to execute action {action_type}: {e}") + + async def _action_create_subtask(self, parent_task: Dict[str, Any], action: Dict[str, Any]): + """Create a subtask""" + await opc_db.create_task( + title=action.get("title", "Subtask"), + description=action.get("description", ""), + status="parking_lot", + priority=parent_task.get("priority", "medium"), + project_id=parent_task.get("project_id"), + tags=parent_task.get("tags", []), + actor="agent" + ) + logger.info(f"Created subtask: {action.get('title')}") + + async def _action_update_status(self, task: Dict[str, Any], action: Dict[str, Any]): + """Update task status""" + new_status = action.get("status") + if new_status: + await opc_db.update_task( + task_id=task["id"], + updates={"status": new_status}, + actor="agent" + ) + logger.info(f"Updated task {task['id']} status to {new_status}") + + async def _action_send_notification(self, action: Dict[str, Any]): + """Send notification""" + message = action.get("message", "") + if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID: + await self._send_telegram(message) + + async def _action_add_comment(self, task: Dict[str, Any], action: Dict[str, Any]): + """Add comment to task (stored in history)""" + comment = action.get("comment", "") + if comment: + # Store as task history + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + INSERT INTO task_history (task_id, action, actor, actor_type, new_value) + VALUES ($1, $2, $3, $4, $5) + """, task["id"], "comment", "agent", "agent", comment) + + async def _update_execution_status(self, execution_id: int, status: str): + """Update execution status""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + UPDATE agent_executions + SET status = $1, started_at = CURRENT_TIMESTAMP + WHERE id = $2 + """, status, execution_id) + + async def _mark_execution_completed(self, execution_id: int, result: Dict[str, Any]): + """Mark execution as completed""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + UPDATE agent_executions + SET status = $1, + output_result = $2, + actions_proposed = $3, + actions_executed = $3, + completed_at = CURRENT_TIMESTAMP + WHERE id = $4 + """, "completed", + opc_db.json.dumps(result), + opc_db.json.dumps(result.get("actions", [])), + execution_id) + + async def _mark_execution_failed(self, execution_id: int, error: str): + """Mark execution as failed""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + UPDATE agent_executions + SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP + WHERE id = $3 + """, "failed", error, execution_id) + + async def _mark_pending_approval(self, execution_id: int, result: Dict[str, Any]): + """Mark execution as pending approval""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + UPDATE agent_executions + SET status = $1, + output_result = $2, + actions_proposed = $3, + requires_approval = TRUE + WHERE id = $4 + """, "pending_approval", + opc_db.json.dumps(result), + opc_db.json.dumps(result.get("actions", [])), + execution_id) + + async def _send_telegram(self, message: str): + """Send Telegram notification""" + try: + proxy_url = os.environ.get("TELEGRAM_PROXY") + async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client: + await client.post( + f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + data={ + "chat_id": TELEGRAM_CHAT_ID, + "text": message, + "parse_mode": "Markdown" + } + ) + except Exception as e: + logger.error(f"Failed to send Telegram notification: {e}") + + async def _send_approval_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]): + """Send notification for approval request""" + message = f"""🤖 *Agent Approval Required* + +Agent: {agent.name} +Task: {task['title']} + +Reasoning: {result.get('reasoning', 'No reasoning provided')} + +Proposed Actions: +""" + for i, action in enumerate(result.get("actions", []), 1): + message += f"{i}. {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}\n" + + message += "\nPlease review and approve in the OPC dashboard." + + await self._send_telegram(message) + + async def _send_completion_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]): + """Send notification for completed execution""" + message = f"""✅ *Agent Task Completed* + +Agent: {agent.name} +Task: {task['title']} + +Actions Executed: {len(result.get('actions', []))} +""" + await self._send_telegram(message) + + +# Global executor instance +_executor: Optional[AgentExecutor] = None + + +async def get_executor() -> AgentExecutor: + """Get or create executor instance""" + global _executor + if _executor is None: + _executor = AgentExecutor() + await _executor.initialize() + return _executor + + +async def start_executor(): + """Start the executor service""" + executor = await get_executor() + await executor.start() diff --git a/dashboard/backend/services/agents/base_agent.py b/dashboard/backend/services/agents/base_agent.py new file mode 100644 index 0000000..acf3747 --- /dev/null +++ b/dashboard/backend/services/agents/base_agent.py @@ -0,0 +1,146 @@ +""" +Base Agent Class - Foundation for all OPC agents +""" +from typing import Dict, Any, List, Optional +import json +import httpx +import os +from datetime import datetime + + +class BaseAgent: + """Base class for all OPC agents""" + + def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: Dict[str, Any]): + self.agent_id = agent_id + self.name = name + self.role = role + self.system_prompt = system_prompt + self.config = config + self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005") + + async def execute(self, task: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: + """ + Execute agent on a task + Returns: { + "actions_proposed": [...], + "reasoning": "...", + "requires_approval": bool + } + """ + # Build prompt with task context + user_prompt = self._build_prompt(task, context) + + # Call LLM + response = await self._call_llm(user_prompt) + + # Parse response and extract actions + result = self._parse_response(response) + + return result + + def _build_prompt(self, task: Dict[str, Any], context: Dict[str, Any]) -> str: + """Build the prompt for the LLM""" + prompt = f"""You are {self.name}, a {self.role} agent. + +Task Details: +- Title: {task.get('title')} +- Description: {task.get('description', 'No description')} +- Status: {task.get('status')} +- Priority: {task.get('priority')} +- Tags: {', '.join(task.get('tags', []))} + +Context: +- Project: {context.get('project', 'None')} +- Related tasks: {len(context.get('related_tasks', []))} +- Company goals: {context.get('goals', 'None')} + +Your role: {self.system_prompt} + +Based on this task, propose concrete actions you would take. Format your response as JSON: +{{ + "reasoning": "Your analysis of the task", + "actions": [ + {{"type": "create_subtask", "title": "...", "description": "..."}}, + {{"type": "update_task_status", "status": "..."}}, + {{"type": "send_notification", "message": "..."}}, + {{"type": "request_approval", "question": "..."}} + ], + "requires_approval": false +}} + +Only propose actions you can actually execute. Be specific and actionable.""" + + return prompt + + async def _call_llm(self, prompt: str) -> str: + """Call LiteLLM proxy""" + model = self.config.get("model", "claude-sonnet-4-6") + temperature = self.config.get("temperature", 0.7) + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.litellm_url}/chat/completions", + json={ + "model": model, + "messages": [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": prompt} + ], + "temperature": temperature, + "max_tokens": 2000 + } + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + except Exception as e: + raise Exception(f"LLM call failed: {str(e)}") + + def _parse_response(self, response: str) -> Dict[str, Any]: + """Parse LLM response and extract actions""" + try: + # Try to extract JSON from response + if "```json" in response: + json_str = response.split("```json")[1].split("```")[0].strip() + elif "```" in response: + json_str = response.split("```")[1].split("```")[0].strip() + else: + json_str = response.strip() + + result = json.loads(json_str) + + # Validate structure + if "actions" not in result: + result["actions"] = [] + if "reasoning" not in result: + result["reasoning"] = "No reasoning provided" + if "requires_approval" not in result: + # Check if any action requires approval + result["requires_approval"] = any( + action.get("type") == "request_approval" for action in result["actions"] + ) + + return result + + except Exception as e: + # Fallback: return error action + return { + "reasoning": f"Failed to parse response: {str(e)}", + "actions": [ + { + "type": "error", + "message": f"Agent response parsing failed: {str(e)}" + } + ], + "requires_approval": False + } + + def get_capabilities(self) -> List[str]: + """Get agent capabilities""" + return self.config.get("capabilities", []) + + def requires_approval(self) -> bool: + """Check if agent requires approval for actions""" + return self.config.get("approval_level") == "cxo" diff --git a/dashboard/frontend/src/lib/opc-ws.js b/dashboard/frontend/src/lib/opc-ws.js new file mode 100644 index 0000000..8d78f3a --- /dev/null +++ b/dashboard/frontend/src/lib/opc-ws.js @@ -0,0 +1,85 @@ +/** + * OPC WebSocket Client - Real-time updates + */ + +let ws = null; +let reconnectTimer = null; +let listeners = new Set(); + +export function connect() { + if (ws && ws.readyState === WebSocket.OPEN) { + return; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/ws/opc`; + + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log("OPC WebSocket connected"); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + // Send ping every 30 seconds to keep connection alive + const pingInterval = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send("ping"); + } else { + clearInterval(pingInterval); + } + }, 30000); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + notifyListeners(message); + } catch (e) { + console.error("Failed to parse WebSocket message:", e); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + }; + + ws.onclose = () => { + console.log("OPC WebSocket disconnected"); + ws = null; + + // Reconnect after 5 seconds + reconnectTimer = setTimeout(() => { + console.log("Reconnecting WebSocket..."); + connect(); + }, 5000); + }; +} + +export function disconnect() { + if (ws) { + ws.close(); + ws = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } +} + +export function subscribe(callback) { + listeners.add(callback); + return () => listeners.delete(callback); +} + +function notifyListeners(message) { + listeners.forEach((callback) => { + try { + callback(message); + } catch (e) { + console.error("Listener error:", e); + } + }); +} diff --git a/dashboard/frontend/src/routes/OPC.svelte b/dashboard/frontend/src/routes/OPC.svelte index dc81994..34e645b 100644 --- a/dashboard/frontend/src/routes/OPC.svelte +++ b/dashboard/frontend/src/routes/OPC.svelte @@ -1,14 +1,16 @@ diff --git a/scripts/init-opc-db.sh b/scripts/init-opc-db.sh new file mode 100755 index 0000000..a3c97bc --- /dev/null +++ b/scripts/init-opc-db.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Initialize OPC database in PostgreSQL + +set -e + +echo "Initializing OPC database..." + +# Connect to PostgreSQL and create OPC database +ssh nas "/volume1/@appstore/ContainerManager/usr/bin/docker exec -i gitea-db psql -U gitea -d postgres" << 'EOF' +-- 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; +EOF + +echo "OPC database created successfully!" +echo "" +echo "Next steps:" +echo "1. The dashboard will auto-initialize tables on startup" +echo "2. Push your changes to trigger CI/CD deployment" +echo "3. Access OPC at https://nas.jimmygan.com/opc"