diff --git a/agents/k8s/ops-worker-deployment.yaml b/agents/k8s/ops-worker-deployment.yaml new file mode 100644 index 0000000..c1a15c9 --- /dev/null +++ b/agents/k8s/ops-worker-deployment.yaml @@ -0,0 +1,75 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ops-worker + namespace: ai-agents + labels: + app.kubernetes.io/name: ops-worker + agentic-os.io/agent: ops-agent +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ops-worker + template: + metadata: + labels: + app.kubernetes.io/name: ops-worker + agentic-os.io/agent: ops-agent + spec: + serviceAccountName: default + initContainers: + - name: git-clone + image: alpine/git + command: + - "sh" + - "-c" + - "git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace && cp -r /workspace/agents/ops_agent/* /app/" + volumeMounts: + - name: app-code + mountPath: /app + containers: + - name: ops-worker + image: python:3.11-slim + imagePullPolicy: Always + command: ["sh", "-c", "pip install -r /app/requirements.txt && cd /app && python -m ops_agent.temporal.worker"] + volumeMounts: + - name: app-code + mountPath: /app + env: + - name: TEMPORAL_ADDRESS + value: "temporal-frontend.ai-core.svc.cluster.local:7233" + - name: TEMPORAL_NAMESPACE + value: "default" + - name: PYTHONUNBUFFERED + value: "1" + - name: OPS_TASK_QUEUE + value: "ops" + - name: MOCK_OPS + value: "false" # Set to false to run actual checks in the cluster + - name: LOCAL_WHATSAPP_URL + value: "http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message" + - name: LITELLM_BASE_URL + value: "http://ollama.ai-core.svc.cluster.local:11434/v1" + - name: GUMBO_LLM_MODEL + value: "qwen2.5:3b" + - name: LITELLM_API_KEY + valueFrom: + secretKeyRef: + name: gumbo-litellm + key: api_key + - name: LANGGRAPH_CHECKPOINT_URI + valueFrom: + secretKeyRef: + name: gumbo-checkpoint-db + key: uri + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi + volumes: + - name: app-code + emptyDir: {} diff --git a/agents/k8s/whatsapp-gateway-deployment.yaml b/agents/k8s/whatsapp-gateway-deployment.yaml new file mode 100644 index 0000000..f748aa3 --- /dev/null +++ b/agents/k8s/whatsapp-gateway-deployment.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: whatsapp-gateway + namespace: ai-agents + labels: + app: whatsapp-gateway +spec: + replicas: 1 + selector: + matchLabels: + app: whatsapp-gateway + template: + metadata: + labels: + app: whatsapp-gateway + spec: + serviceAccountName: default + initContainers: + - name: git-clone + image: alpine/git + command: + - "sh" + - "-c" + - "git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace && cp -r /workspace/whatsapp-gateway/* /app/" + volumeMounts: + - name: app-code + mountPath: /app + containers: + - name: whatsapp-gateway + image: node:18-alpine + # Install dependencies needed by Puppeteer inside Alpine + # whatsapp-web.js requires Chromium + command: + - "sh" + - "-c" + - "apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont && cd /app && npm install && PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser node server.js" + volumeMounts: + - name: app-code + mountPath: /app + ports: + - containerPort: 5001 + resources: + requests: + cpu: 300m + memory: 512Mi + limits: + cpu: "1.5" + memory: 1.5Gi + volumes: + - name: app-code + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: whatsapp-gateway + namespace: ai-agents +spec: + ports: + - port: 5001 + targetPort: 5001 + selector: + app: whatsapp-gateway diff --git a/agents/ops_agent/Dockerfile b/agents/ops_agent/Dockerfile new file mode 100644 index 0000000..8331907 --- /dev/null +++ b/agents/ops_agent/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PYTHONPATH=/app +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt +COPY ops_agent /app/ops_agent +CMD ["python", "-m", "ops_agent.run"] diff --git a/agents/ops_agent/Dockerfile.worker b/agents/ops_agent/Dockerfile.worker new file mode 100644 index 0000000..d2b3c9b --- /dev/null +++ b/agents/ops_agent/Dockerfile.worker @@ -0,0 +1,10 @@ +FROM python:3.11-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PYTHONPATH=/app +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt +COPY ops_agent /app/ops_agent +CMD ["python", "-m", "ops_agent.temporal.worker"] diff --git a/agents/ops_agent/ops_agent/__init__.py b/agents/ops_agent/ops_agent/__init__.py new file mode 100644 index 0000000..12b4518 --- /dev/null +++ b/agents/ops_agent/ops_agent/__init__.py @@ -0,0 +1 @@ +# OpsAgent package diff --git a/agents/ops_agent/ops_agent/graph.py b/agents/ops_agent/ops_agent/graph.py new file mode 100644 index 0000000..8e41815 --- /dev/null +++ b/agents/ops_agent/ops_agent/graph.py @@ -0,0 +1,226 @@ +import os +import logging +from typing import TypedDict +from langchain_core.messages import SystemMessage, HumanMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import StateGraph, END +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + +from ops_agent.tools import ( + k8s_get_status, k8s_get_logs, k8s_restart_pod, k8s_scale_deployment, + proxmox_get_status, proxmox_get_disk_space, proxmox_reboot_container, proxmox_clean_disk +) + +logger = logging.getLogger(__name__) + +class OpsState(TypedDict): + target_type: str # 'kubernetes' or 'proxmox' + target_id: str # e.g., 'default/web-pod' or VMID '101' + status_info: str # status output + logs: str # logs output + diagnosis: str # LLM diagnosis of issues + proposed_action: str # Proposed action: 'restart_pod', 'scale_deployment', 'reboot_container', 'clean_disk', 'none' + proposed_action_details: str # E.g., pod name, deployment name, or container ID + is_approved: bool # Has user approved it? + action_result: str # Outcome of the action execution + +def get_llm(): + """Initializes LLM using environment variables, matching the Gumbo setup.""" + api_key = os.environ.get("LITELLM_API_KEY") or os.environ.get("OPENAI_API_KEY", "mock-api-key") + base_url = os.environ.get("LITELLM_BASE_URL") or os.environ.get("OPENAI_BASE_URL", "http://localhost:4000") + model = os.environ.get("OPS_LLM_MODEL") or os.environ.get("GUMBO_LLM_MODEL", "ollama-qwen") + + logger.info(f"Initializing ChatOpenAI with model={model}, base_url={base_url}") + return ChatOpenAI( + model=model, + api_key=api_key, + base_url=base_url, + temperature=0.1 + ) + +async def _fetch_status_and_logs(state: OpsState) -> dict: + target_type = state.get("target_type", "").lower() + target_id = state.get("target_id", "") + + status_info = "" + logs = "" + + if target_type == "kubernetes": + # target_id pattern: "namespace/pod-name" or just "pod-name" (assumes default namespace) + parts = target_id.split("/") + if len(parts) == 2: + ns, pod = parts[0], parts[1] + else: + ns, pod = "default", parts[0] + + status_info = k8s_get_status(ns, pod) + logs = k8s_get_logs(ns, pod) + + elif target_type == "proxmox": + # target_id is LXC VMID (e.g. "101") + status_info = proxmox_get_status(target_id) + logs = proxmox_get_disk_space(target_id) + + else: + status_info = f"Unknown target type: {target_type}" + logs = "No logs available." + + return {"status_info": status_info, "logs": logs} + +async def _diagnose_issue(state: OpsState) -> dict: + llm = get_llm() + system_msg = SystemMessage( + content=( + "You are OpsAgent, a senior DevOps engineer and Proxmox/Kubernetes site reliability assistant. " + "Analyze the provided status info and logs, identify the root cause of any problems, and return " + "a concise Markdown diagnosis." + ) + ) + user_content = ( + f"Target Type: {state['target_type']}\n" + f"Target ID: {state['target_id']}\n\n" + f"=== STATUS INFO ===\n{state['status_info']}\n\n" + f"=== LOGS / RESOURCE USAGE ===\n{state['logs']}\n" + ) + + try: + resp = await llm.ainvoke([system_msg, HumanMessage(content=user_content)]) + diagnosis = resp.content if isinstance(resp.content, str) else str(resp.content) + except Exception as e: + logger.error(f"LLM call failed in diagnosis: {e}") + diagnosis = f"LLM error: Failed to diagnose log. Raw error: {str(e)}" + + return {"diagnosis": diagnosis} + +async def _propose_remediation(state: OpsState) -> dict: + llm = get_llm() + system_msg = SystemMessage( + content=( + "You are OpsAgent. Based on the diagnosis, propose a specific remediation action from the following list:\n" + "- restart_pod (Kubernetes pods crash/hangs)\n" + "- scale_deployment (K8s capacity issues)\n" + "- reboot_container (Proxmox container stopped/unresponsive)\n" + "- clean_disk (Proxmox/LXC container running out of disk space: Use% >= 90%)\n" + "- none (No actions required, target is running fine)\n\n" + "Respond ONLY with a JSON object: " + '{"action": "restart_pod"|"scale_deployment"|"reboot_container"|"clean_disk"|"none", ' + '"details": "brief reason or extra target parameter"}' + ) + ) + user_content = f"Diagnosis:\n{state['diagnosis']}" + + action = "none" + details = "No action proposed." + + try: + resp = await llm.ainvoke([system_msg, HumanMessage(content=user_content)]) + content = resp.content if isinstance(resp.content, str) else str(resp.content) + # Parse JSON + import json + # Strip potential markdown fences + clean_content = content.replace("```json", "").replace("```", "").strip() + data = json.loads(clean_content) + action = data.get("action", "none") + details = data.get("details", "") + + # Dispatch WhatsApp Alert to phone if an anomaly/remediation is flagged + if action != "none": + try: + from ops_agent.tools import send_whatsapp_alert + alert_msg = ( + f"⚠️ *OPSAGENT THREAT WARNING* ⚠️\n\n" + f"*Target:* {state['target_type'].upper()} ({state['target_id']})\n" + f"*Diagnosis:* {details}\n" + f"*Proposed Action:* {action.replace('_', ' ').upper()}\n\n" + f"Login to the Agentic OS Dashboard to authorize or reject this mitigation." + ) + send_whatsapp_alert(alert_msg) + except Exception as ex: + logger.error(f"Could not dispatch WhatsApp alert: {ex}") + + except Exception as e: + logger.error(f"Failed to propose action via LLM: {e}") + + return {"proposed_action": action, "proposed_action_details": details} + +async def _execute_remediation(state: OpsState) -> dict: + if not state.get("is_approved", False): + return {"action_result": "Remediation action skipped: Approval was not provided."} + + action = state.get("proposed_action", "none") + target_type = state.get("target_type", "") + target_id = state.get("target_id", "") + + result = "No action executed." + + try: + if target_type == "kubernetes": + parts = target_id.split("/") + ns = parts[0] if len(parts) == 2 else "default" + pod = parts[1] if len(parts) == 2 else parts[0] + + if action == "restart_pod": + result = k8s_restart_pod(ns, pod) + elif action == "scale_deployment": + # Default scale to 2 for demo purposes + result = k8s_scale_deployment(ns, pod, 2) + else: + result = f"Action '{action}' is not supported for Kubernetes target." + + elif target_type == "proxmox": + if action == "reboot_container": + result = proxmox_reboot_container(target_id) + elif action == "clean_disk": + result = proxmox_clean_disk(target_id) + else: + result = f"Action '{action}' is not supported for Proxmox container VMID {target_id}." + else: + result = f"Unknown target type: {target_type}." + + except Exception as e: + result = f"Error during execution: {str(e)}" + + return {"action_result": result} + + +def build_graph_builder() -> StateGraph: + builder = StateGraph(OpsState) + builder.add_node("fetch_status", _fetch_status_and_logs) + builder.add_node("diagnose", _diagnose_issue) + builder.add_node("propose_action", _propose_remediation) + builder.add_node("execute_action", _execute_remediation) + + builder.set_entry_point("fetch_status") + builder.add_edge("fetch_status", "diagnose") + builder.add_edge("diagnose", "propose_action") + builder.add_edge("propose_action", "execute_action") + builder.add_edge("execute_action", END) + + return builder + +async def run_ops_graph(target_type: str, target_id: str, is_approved: bool = False, conn_string: str = None) -> OpsState: + builder = build_graph_builder() + + initial_state = { + "target_type": target_type, + "target_id": target_id, + "status_info": "", + "logs": "", + "diagnosis": "", + "proposed_action": "none", + "proposed_action_details": "", + "is_approved": is_approved, + "action_result": "" + } + + if conn_string: + async with AsyncPostgresSaver.from_conn_string(conn_string) as checkpointer: + await checkpointer.setup() + graph = builder.compile(checkpointer=checkpointer) + return await graph.ainvoke( + initial_state, + config={"configurable": {"thread_id": f"ops-{target_type}-{target_id}"}}, + ) + else: + graph = builder.compile() + return await graph.ainvoke(initial_state) diff --git a/agents/ops_agent/ops_agent/run.py b/agents/ops_agent/ops_agent/run.py new file mode 100644 index 0000000..1f99856 --- /dev/null +++ b/agents/ops_agent/ops_agent/run.py @@ -0,0 +1,63 @@ +import asyncio +import argparse +import os +import sys +import json + +async def main(): + parser = argparse.ArgumentParser(description="Run OpsAgent diagnostics CLI tool.") + parser.add_argument("--target-type", required=True, choices=["kubernetes", "proxmox"], help="Target platform to check.") + parser.add_argument("--target-id", required=True, help="Specific target identifier (e.g., namespace/pod-name or Proxmox container VMID).") + parser.add_argument("--approve", action="store_true", help="Approve and execute proposed remediation.") + parser.add_argument("--real", action="store_true", help="Run real CLI commands instead of mocks.") + + args = parser.parse_args() + + # Configure logging + import logging + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") + + # Set mock mode + if args.real: + os.environ["MOCK_OPS"] = "false" + print("Executing in REAL mode...") + else: + os.environ["MOCK_OPS"] = "true" + print("Executing in MOCK mode...") + + # We need to set mock key environment variables if they aren't present + if "LITELLM_API_KEY" not in os.environ: + os.environ["LITELLM_API_KEY"] = "mock-key" + if "LITELLM_BASE_URL" not in os.environ: + os.environ["LITELLM_BASE_URL"] = "http://localhost:4000" # fallback + + from ops_agent.graph import run_ops_graph + + print(f"Starting OpsAgent check for target type: {args.target_type}, ID: {args.target_id}...") + + try: + result = await run_ops_graph( + target_type=args.target_type, + target_id=args.target_id, + is_approved=args.approve + ) + + print("\n==================================================") + print(" RESULTS ") + print("==================================================") + print(f"Target Check: {result['target_type'].upper()} ({result['target_id']})") + print("\n--- DIAGNOSIS ---") + print(result["diagnosis"]) + print("\n--- PROPOSED ACTION ---") + print(f"Action: {result['proposed_action']}") + print(f"Details: {result['proposed_action_details']}") + print("\n--- EXECUTION RESULT ---") + print(result["action_result"]) + print("==================================================") + + except Exception as e: + print(f"\nExecution failed: {str(e)}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/agents/ops_agent/ops_agent/temporal/__init__.py b/agents/ops_agent/ops_agent/temporal/__init__.py new file mode 100644 index 0000000..2445eae --- /dev/null +++ b/agents/ops_agent/ops_agent/temporal/__init__.py @@ -0,0 +1 @@ +# Temporal package initialization for OpsAgent diff --git a/agents/ops_agent/ops_agent/temporal/activities.py b/agents/ops_agent/ops_agent/temporal/activities.py new file mode 100644 index 0000000..fa1600d --- /dev/null +++ b/agents/ops_agent/ops_agent/temporal/activities.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import os +from typing import Any +from temporalio import activity + +from ops_agent.graph import run_ops_graph + +@activity.defn +async def run_ops_diagnose_activity(params: dict[str, Any]) -> dict[str, Any]: + """Runs status check, log fetch, and LLM diagnosis.""" + target_type = params["target_type"] + target_id = params["target_id"] + + # We do NOT run remediation yet, just diagnosis and proposed action + state = await run_ops_graph( + target_type=target_type, + target_id=target_id, + is_approved=False + ) + + return { + "target_type": state["target_type"], + "target_id": state["target_id"], + "status_info": state["status_info"], + "logs": state["logs"], + "diagnosis": state["diagnosis"], + "proposed_action": state["proposed_action"], + "proposed_action_details": state["proposed_action_details"] + } + +@activity.defn +async def run_ops_remediate_activity(params: dict[str, Any]) -> dict[str, Any]: + """Runs remediation action once approved.""" + target_type = params["target_type"] + target_id = params["target_id"] + proposed_action = params["proposed_action"] + + from ops_agent.graph import build_graph_builder + builder = build_graph_builder() + graph = builder.compile() + + # Run the graph directly starting with the execute node by feeding the state + state = await graph.ainvoke({ + "target_type": target_type, + "target_id": target_id, + "status_info": params.get("status_info", ""), + "logs": params.get("logs", ""), + "diagnosis": params.get("diagnosis", ""), + "proposed_action": proposed_action, + "proposed_action_details": params.get("proposed_action_details", ""), + "is_approved": True, + "action_result": "" + }) + + return { + "action_result": state["action_result"] + } diff --git a/agents/ops_agent/ops_agent/temporal/worker.py b/agents/ops_agent/ops_agent/temporal/worker.py new file mode 100644 index 0000000..266d92f --- /dev/null +++ b/agents/ops_agent/ops_agent/temporal/worker.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import asyncio +import os +import logging +from temporalio.client import Client +from temporalio.worker import Worker + +from ops_agent.temporal.workflows import OpsRemediateWorkflow +from ops_agent.temporal.activities import ( + run_ops_diagnose_activity, + run_ops_remediate_activity +) + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +async def _main() -> None: + address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233") + namespace = os.environ.get("TEMPORAL_NAMESPACE", "default") + task_queue = os.environ.get("OPS_TASK_QUEUE", "ops") + + logger.info(f"Connecting to Temporal cluster at {address} (Namespace: {namespace})...") + try: + client = await Client.connect(address, namespace=namespace) + logger.info("Successfully connected to Temporal!") + except Exception as e: + logger.error(f"Failed to connect to Temporal: {e}") + return + + worker = Worker( + client, + task_queue=task_queue, + workflows=[OpsRemediateWorkflow], + activities=[ + run_ops_diagnose_activity, + run_ops_remediate_activity, + ], + ) + logger.info(f"Starting OpsAgent worker on queue '{task_queue}'...") + await worker.run() + +def main() -> None: + asyncio.run(_main()) + +if __name__ == "__main__": + main() diff --git a/agents/ops_agent/ops_agent/temporal/workflows.py b/agents/ops_agent/ops_agent/temporal/workflows.py new file mode 100644 index 0000000..dcbd722 --- /dev/null +++ b/agents/ops_agent/ops_agent/temporal/workflows.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import timedelta +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from ops_agent.temporal.activities import ( + run_ops_diagnose_activity, + run_ops_remediate_activity + ) + +@workflow.defn +class OpsRemediateWorkflow: + """Orchestrates OpsAgent diagnosis, waits for human operator approval signal, and executes remediation.""" + + def __init__(self) -> None: + self._approved = False + self._rejected = False + + @workflow.signal + def approve(self) -> None: + self._approved = True + + @workflow.signal + def reject(self) -> None: + self._rejected = True + + @workflow.run + async def run(self, target_type: str, target_id: str) -> dict: + # Step 1: Diagnose issues + params = {"target_type": target_type, "target_id": target_id} + diagnose_res = await workflow.execute_activity( + run_ops_diagnose_activity, + params, + start_to_close_timeout=timedelta(minutes=5), + ) + + proposed_action = diagnose_res.get("proposed_action", "none") + + # Step 2: Check if remediation is proposed. If 'none', exit. + if proposed_action == "none": + return { + "status": "completed", + "diagnosis": diagnose_res["diagnosis"], + "action_proposed": "none", + "action_executed": "none", + "result": "No problems detected. No action needed." + } + + # Step 3: Approval Gate (wait for approval signal or reject signal or timeout) + # Timeout after 24 hours (or shorter in test/production depending on requirements) + approval_timeout = timedelta(hours=2) + + workflow.logger.info( + f"Action proposed: {proposed_action}. Waiting for approval signal on workflow ID: {workflow.info().workflow_id}" + ) + + # Wait for approval condition + try: + is_condition_met = await workflow.wait_condition( + lambda: self._approved or self._rejected, + timeout=approval_timeout + ) + except Exception: + # wait_condition times out + is_condition_met = False + + if not is_condition_met: + return { + "status": "timed_out", + "diagnosis": diagnose_res["diagnosis"], + "action_proposed": proposed_action, + "action_executed": "none", + "result": "Timed out waiting for administrator approval." + } + + if self._rejected: + return { + "status": "rejected", + "diagnosis": diagnose_res["diagnosis"], + "action_proposed": proposed_action, + "action_executed": "none", + "result": "Remediation action rejected by administrator." + } + + # Step 4: Execute remediation + remed_params = {**diagnose_res, "is_approved": True} + exec_res = await workflow.execute_activity( + run_ops_remediate_activity, + remed_params, + start_to_close_timeout=timedelta(minutes=5), + ) + + return { + "status": "remediated", + "diagnosis": diagnose_res["diagnosis"], + "action_proposed": proposed_action, + "action_executed": proposed_action, + "result": exec_res["action_result"] + } diff --git a/agents/ops_agent/ops_agent/tools.py b/agents/ops_agent/ops_agent/tools.py new file mode 100644 index 0000000..a88a777 --- /dev/null +++ b/agents/ops_agent/ops_agent/tools.py @@ -0,0 +1,202 @@ +import os +import subprocess +import logging + +logger = logging.getLogger(__name__) + +def run_local_command(args): + """Executes a command on the local system.""" + try: + res = subprocess.run(args, capture_output=True, text=True, timeout=15) + if res.returncode == 0: + return res.stdout + else: + return f"Error executing command: {res.stderr}" + except Exception as e: + return f"Failed to run command locally: {str(e)}" + +def run_ssh_command(host, username, command, password=None, key_filename=None): + """Runs a command via SSH on a remote host (e.g., Proxmox node).""" + try: + import paramiko + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + # Connect using password or key + ssh.connect( + hostname=host, + username=username, + password=password, + key_filename=key_filename, + timeout=10 + ) + + stdin, stdout, stderr = ssh.exec_command(command) + out = stdout.read().decode('utf-8') + err = stderr.read().decode('utf-8') + ssh.close() + + if err and not out: + return f"SSH Error: {err}" + return out + except Exception as e: + return f"SSH connection failed: {str(e)}" + +# --- Kubernetes Tools --- + +def k8s_get_status(namespace: str, pod_name: str = None) -> str: + """Gets Kubernetes pod status in the specified namespace.""" + if os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock K8s] Fetching status...") + if pod_name: + return f"NAME: {pod_name}\nREADY: 0/1\nSTATUS: CrashLoopBackOff\nRESTARTS: 5\nAGE: 2h" + return f"NAME\tREADY\tSTATUS\tRESTARTS\tAGE\nweb-pod-xyz\t0/1\tCrashLoopBackOff\t5\t2h\ndb-pod-abc\t1/1\tRunning\t0\t5d" + + # Real execution + cmd = ["kubectl", "get", "pods", "-n", namespace] + if pod_name: + cmd.append(pod_name) + return run_local_command(cmd) + +def k8s_get_logs(namespace: str, pod_name: str) -> str: + """Gets Kubernetes pod logs.""" + if os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock K8s] Fetching logs...") + return ( + "2026-05-29 01:00:00 [INFO] Starting up server...\n" + "2026-05-29 01:00:01 [INFO] Connecting to database...\n" + "2026-05-29 01:00:03 [ERROR] Database connection failed: Connection refused by 10.96.20.10:5432\n" + "2026-05-29 01:00:03 [FATAL] Exiting server due to database unavailability." + ) + + # Real execution + cmd = ["kubectl", "logs", pod_name, "-n", namespace, "--tail=100"] + return run_local_command(cmd) + +def k8s_restart_pod(namespace: str, pod_name: str) -> str: + """Restarts a Kubernetes pod by deleting it (K8s will recreate it).""" + if os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock K8s] Restarting pod...") + return f"Mock: Pod {namespace}/{pod_name} successfully deleted (restarted)." + + cmd = ["kubectl", "delete", "pod", pod_name, "-n", namespace] + return run_local_command(cmd) + +def k8s_scale_deployment(namespace: str, deployment: str, replicas: int) -> str: + """Scales a deployment to the target number of replicas.""" + if os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock K8s] Scaling deployment...") + return f"Mock: Deployment {namespace}/{deployment} successfully scaled to {replicas} replicas." + + cmd = ["kubectl", "scale", f"deployment/{deployment}", f"--replicas={replicas}", "-n", namespace] + return run_local_command(cmd) + + +# --- Proxmox Tools --- + +def get_proxmox_ssh_creds(): + """Retrieves SSH configs for Proxmox node from environment.""" + return { + "host": os.environ.get("PROXMOX_SSH_HOST"), + "user": os.environ.get("PROXMOX_SSH_USER", "root"), + "password": os.environ.get("PROXMOX_SSH_PASSWORD"), + "key": os.environ.get("PROXMOX_SSH_KEY") + } + +def proxmox_get_status(vmid: str) -> str: + """Checks LXC container status in Proxmox.""" + creds = get_proxmox_ssh_creds() + if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock Proxmox] Fetching status for VMID %s...", vmid) + return f"status: stopped\nname: app-container-{vmid}\nmem: 2048MB\ndisk: 20GB\nrunning: false" + + cmd = f"pct status {vmid}" + return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"]) + +def proxmox_get_disk_space(vmid: str) -> str: + """Queries disk space for the LXC container.""" + creds = get_proxmox_ssh_creds() + if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock Proxmox] Fetching disk space for VMID %s...", vmid) + return ( + "Filesystem Size Used Avail Use% Mounted on\n" + "/dev/loop0 20G 19G 0.2G 99% /\n" + "none 4.0G 0 4.0G 0% /dev" + ) + + cmd = f"pct exec {vmid} -- df -h /" + return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"]) + +def proxmox_reboot_container(vmid: str) -> str: + """Reboots a Proxmox LXC container.""" + creds = get_proxmox_ssh_creds() + if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock Proxmox] Rebooting LXC container %s...", vmid) + return f"Mock: Container {vmid} successfully rebooted." + + cmd = f"pct reboot {vmid}" + return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"]) + +def proxmox_clean_disk(vmid: str) -> str: + """Performs common disk cleanup operations inside a Proxmox container.""" + creds = get_proxmox_ssh_creds() + if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true": + logger.info("[Mock Proxmox] Cleaning up disk inside container %s...", vmid) + return "Mock: Ran 'apt-get clean' and removed temporary files. Disk usage reduced to 75%." + + # Perform cleanup inside LXC + cleanup_cmd = "pct exec {} -- bash -c 'apt-get clean && rm -rf /tmp/* && rm -rf /var/tmp/*'" + cmd = cleanup_cmd.format(vmid) + return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"]) + +def send_whatsapp_alert(message_body: str) -> str: + """Dispatches a WhatsApp alert using the local gateway (if running) or falls back to Twilio.""" + admin_number = os.environ.get("ADMIN_WHATSAPP_NUMBER") or os.environ.get("PHYSICIAN_WHATSAPP_NUMBER", "whatsapp:+14085505485") + + # 1. Attempt local WhatsApp Web Gateway first (if enabled/running) + use_local = os.environ.get("USE_LOCAL_WHATSAPP", "true").lower() == "true" + gateway_url = os.environ.get("LOCAL_WHATSAPP_URL", "http://localhost:5001/send-message") + + if use_local: + try: + import urllib.request + import json + payload = json.dumps({"to": admin_number, "message": message_body}).encode('utf-8') + req = urllib.request.Request( + gateway_url, + data=payload, + headers={'Content-Type': 'application/json'}, + method='POST' + ) + # Short timeout to avoid hanging if gateway is offline + with urllib.request.urlopen(req, timeout=8) as response: + res_data = json.loads(response.read().decode('utf-8')) + if res_data.get("success"): + logger.info(f"[OpsAgent] Local WhatsApp alert dispatched successfully to: {res_data.get('recipient')}") + return f"Alert sent via local gateway (JID: {res_data.get('recipient')})" + except Exception as e: + logger.warning(f"[OpsAgent] Local WhatsApp Web Gateway failed or is offline: {str(e)}. Falling back to Twilio...") + + # 2. Twilio Fallback + account_sid = os.environ.get("TWILIO_ACCOUNT_SID", "AC601e1962dc8417ddb3ae53cd8d865020") + auth_token = os.environ.get("TWILIO_AUTH_TOKEN", "08a68571a251d6d62f60dc83fc725b5a") + twilio_from = os.environ.get("TWILIO_WHATSAPP_FROM") or os.environ.get("TWILIO_FROM_NUMBER", "whatsapp:+14155238886") + + if not (account_sid and auth_token and twilio_from and admin_number): + logger.warning("[OpsAgent] Twilio credentials or admin number not set. Skipping WhatsApp alert.") + return "Twilio credentials not configured." + + try: + from twilio.rest import Client + client = Client(account_sid, auth_token) + message = client.messages.create( + from_=twilio_from, + body=message_body, + to=admin_number + ) + logger.info(f"[OpsAgent] WhatsApp alert sent successfully! SID: {message.sid}") + return f"Alert sent (SID: {message.sid})" + except Exception as e: + logger.error(f"[OpsAgent] Failed to send WhatsApp alert: {str(e)}") + return f"Failed to send alert: {str(e)}" + diff --git a/agents/ops_agent/pyproject.toml b/agents/ops_agent/pyproject.toml new file mode 100644 index 0000000..b160de9 --- /dev/null +++ b/agents/ops_agent/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "ops-agent" +version = "0.1.0" +description = "OpsAgent — Infrastructure & DevOps monitoring agent for Proxmox and Kubernetes" +requires-python = ">=3.11" + +dependencies = [ + "langgraph>=0.2.0", + "langchain-core>=0.3.0", + "langchain-openai>=0.2.0", + "temporalio>=1.8.0", + "paramiko>=3.4.0", + "psycopg[binary]>=3.2.0", + "pydantic>=2.0.0", + "twilio>=8.0.0", +] + +[project.scripts] +ops-worker = "ops_agent.temporal.worker:main" +ops-run = "ops_agent.run:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/agents/ops_agent/requirements.txt b/agents/ops_agent/requirements.txt new file mode 100644 index 0000000..145a3a1 --- /dev/null +++ b/agents/ops_agent/requirements.txt @@ -0,0 +1,8 @@ +langgraph>=0.2.0 +langchain-core>=0.3.0 +langchain-openai>=0.2.0 +temporalio>=1.8.0 +paramiko>=3.4.0 +psycopg[binary]>=3.2.0 +pydantic>=2.0.0 +twilio>=8.0.0 diff --git a/agents/trigger_ops.py b/agents/trigger_ops.py new file mode 100644 index 0000000..adda6b4 --- /dev/null +++ b/agents/trigger_ops.py @@ -0,0 +1,97 @@ +"""Trigger and control OpsAgent workflows from local machine.""" +from __future__ import annotations + +import asyncio +import os +import sys +import argparse + +from temporalio.client import Client + +async def connect_client(): + address = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") + namespace = os.environ.get("TEMPORAL_NAMESPACE", "default") + print(f"Connecting to Temporal at {address}...") + return await Client.connect(address, namespace=namespace) + +async def start_workflow(args): + client = await connect_client() + wf_id = f"ops-{args.target_type}-{args.target_id.replace('/', '-')}" + + print(f"Starting OpsRemediateWorkflow for {args.target_type} target '{args.target_id}'...") + try: + handle = await client.start_workflow( + "OpsRemediateWorkflow", + args.target_type, + args.target_id, + id=wf_id, + task_queue="ops", + ) + print(f"Workflow started successfully! Workflow ID: {handle.id}") + print("You can now monitor it, or run 'approve' / 'reject' command to signal it.") + except Exception as e: + print(f"Failed to start workflow: {e}") + +async def send_signal(args, signal_type: str): + client = await connect_client() + wf_id = args.workflow_id + + print(f"Sending '{signal_type}' signal to workflow {wf_id}...") + try: + handle = client.get_workflow_handle(wf_id) + if signal_type == "approve": + await handle.signal("approve") + else: + await handle.signal("reject") + print(f"Signal '{signal_type}' sent successfully!") + except Exception as e: + print(f"Failed to send signal: {e}") + +async def get_result(args): + client = await connect_client() + wf_id = args.workflow_id + + print(f"Waiting for results of workflow {wf_id}...") + try: + handle = client.get_workflow_handle(wf_id) + result = await handle.result() + print("\n=== Workflow Final Result ===") + import json + print(json.dumps(result, indent=2)) + except Exception as e: + print(f"Failed to retrieve workflow result: {e}") + +def main(): + parser = argparse.ArgumentParser(description="Trigger and control OpsAgent workflows.") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Start command + parser_start = subparsers.add_parser("start", help="Start a diagnostics & remediation workflow.") + parser_start.add_argument("--target-type", required=True, choices=["kubernetes", "proxmox"]) + parser_start.add_argument("--target-id", required=True, help="e.g. default/web-pod or VMID like 101") + + # Approve command + parser_app = subparsers.add_parser("approve", help="Send approve signal to running workflow.") + parser_app.add_argument("--workflow-id", required=True) + + # Reject command + parser_rej = subparsers.add_parser("reject", help="Send reject signal to running workflow.") + parser_rej.add_argument("--workflow-id", required=True) + + # Result command + parser_res = subparsers.add_parser("result", help="Wait for and display workflow final result.") + parser_res.add_argument("--workflow-id", required=True) + + args = parser.parse_args() + + if args.command == "start": + asyncio.run(start_workflow(args)) + elif args.command == "approve": + asyncio.run(send_signal(args, "approve")) + elif args.command == "reject": + asyncio.run(send_signal(args, "reject")) + elif args.command == "result": + asyncio.run(get_result(args)) + +if __name__ == "__main__": + main() diff --git a/interface/hq-dashboard/Dockerfile b/interface/hq-dashboard/Dockerfile new file mode 100644 index 0000000..1a24769 --- /dev/null +++ b/interface/hq-dashboard/Dockerfile @@ -0,0 +1,8 @@ +FROM node:18-alpine +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm install +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/interface/hq-dashboard/app/globals.css b/interface/hq-dashboard/app/globals.css new file mode 100644 index 0000000..3c745c5 --- /dev/null +++ b/interface/hq-dashboard/app/globals.css @@ -0,0 +1,187 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --bg-primary: #080a10; + --bg-secondary: #0f131f; + --bg-card: rgba(20, 26, 43, 0.6); + --border-color: rgba(255, 255, 255, 0.08); + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --accent-violet: #8b5cf6; + --accent-violet-glow: rgba(139, 92, 246, 0.25); + --accent-cyan: #06b6d4; + --accent-cyan-glow: rgba(6, 182, 212, 0.25); + --accent-emerald: #10b981; + --accent-emerald-glow: rgba(16, 185, 129, 0.2); + --accent-rose: #f43f5e; + --accent-rose-glow: rgba(244, 63, 94, 0.2); + --accent-amber: #f59e0b; + --accent-amber-glow: rgba(245, 158, 11, 0.2); + + --font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background-color: var(--border-color); + border-radius: 3px; +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + overflow-x: hidden; + height: 100vh; +} + +/* Glassmorphism utility */ +.glass { + background: var(--bg-card); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-color); + border-radius: 12px; +} + +.glass-interactive { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} +.glass-interactive:hover { + border-color: rgba(139, 92, 246, 0.3); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 15px rgba(139, 92, 246, 0.05); + transform: translateY(-2px); +} + +/* Accent Glows */ +.glow-violet { + box-shadow: 0 0 20px var(--accent-violet-glow); +} +.glow-cyan { + box-shadow: 0 0 20px var(--accent-cyan-glow); +} + +/* Status Indicator pulsing */ +@keyframes pulse-emerald { + 0% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); + } + 70% { + box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} +.pulse-emerald { + animation: pulse-emerald 2s infinite; + background-color: var(--accent-emerald); +} + +@keyframes pulse-amber { + 0% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7); + } + 70% { + box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); + } +} +.pulse-amber { + animation: pulse-amber 2s infinite; + background-color: var(--accent-amber); +} + +/* Layout Utilities */ +.app-container { + display: grid; + grid-template-columns: 260px 1fr; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +.sidebar { + background-color: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + padding: 24px 16px; + justify-content: space-between; +} + +.main-content { + display: flex; + flex-direction: column; + height: 100vh; + overflow-y: auto; + position: relative; + background-image: radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.05), transparent 40%), + radial-gradient(circle at 10% 80%, rgba(6, 182, 212, 0.05), transparent 40%); +} + +.header { + height: 70px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; + background: rgba(8, 10, 16, 0.5); + backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 10; +} + +.content-body { + padding: 32px; + flex: 1; + display: flex; + flex-direction: column; +} + +/* Typography and utilities */ +h1, h2, h3, h4, h5 { + font-weight: 600; + letter-spacing: -0.02em; +} + +.mono { + font-family: var(--font-mono); +} + +/* Animations */ +.fade-in { + animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/interface/hq-dashboard/app/layout.jsx b/interface/hq-dashboard/app/layout.jsx index dd9486b..7a3eb1f 100644 --- a/interface/hq-dashboard/app/layout.jsx +++ b/interface/hq-dashboard/app/layout.jsx @@ -1,7 +1,14 @@ +import './globals.css'; + +export const metadata = { + title: 'Agentic OS HQ — AI Agent Command Center', + description: 'Control center and dashboard for monitoring and managing autonomous workflows (Bernard, Gumbo, OpsAgent, Curio).', +}; + export default function RootLayout({ children }) { return ( - {children} + {children} ); } diff --git a/interface/hq-dashboard/app/page.jsx b/interface/hq-dashboard/app/page.jsx index b02b1c3..b19af5e 100644 --- a/interface/hq-dashboard/app/page.jsx +++ b/interface/hq-dashboard/app/page.jsx @@ -1,8 +1,811 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { + Activity, + Terminal, + User, + Cpu, + ShieldAlert, + CheckCircle, + XCircle, + Database, + FileText, + Sparkles, + Send, + RefreshCw, + Play, + GitPullRequest, + Layers, + Settings, + AlertCircle, + Trash2, + HeartPulse, + LogOut, + Maximize2 +} from "lucide-react"; + export default function Page() { + const [activeTab, setActiveTab] = useState("dashboard"); + + // Claw Chat State + const [messages, setMessages] = useState([ + { + role: "assistant", + content: "Hello! I am Claw, your local agent. I have access to your Kubernetes namespace and Proxmox cluster nodes. How can I help you today?", + timestamp: "Just now" + } + ]); + const [inputValue, setInputValue] = useState(""); + const [isClawTyping, setIsClawTyping] = useState(false); + const [clawTerminalLogs, setClawTerminalLogs] = useState([]); + + // OpsAgent State + const [pendingApprovals, setPendingApprovals] = useState([ + { + id: "ops-req-102", + targetType: "proxmox", + targetId: "102", + action: "clean_disk", + details: "LXC container root filesystem is at 94% disk usage. Recommending apt cache clean and temporary file removal.", + timestamp: "5 mins ago", + status: "pending" + }, + { + id: "ops-req-201", + targetType: "kubernetes", + targetId: "curio-app-xyz", + action: "restart_pod", + details: "Pod curio-app-xyz is in CrashLoopBackOff state. Logs indicate: 'Database connection failed: Connection refused by 10.96.20.10:5432'. Recommending pod deletion to trigger k8s reschedule.", + timestamp: "10 mins ago", + status: "pending" + } + ]); + const [opsLogs, setOpsLogs] = useState([ + "2026-05-29 01:10:00 [OpsAgent] Initialized diagnostic run on K8s cluster...", + "2026-05-29 01:10:15 [OpsAgent] Found Pod 'curio-app-xyz' in CrashLoopBackOff. Emitting warning.", + "2026-05-29 01:12:02 [OpsAgent] Proxmox VMID 102 root usage checked: 94%. Action required." + ]); + + // Curio HMS State + const [dictationText, setDictationText] = useState( + "Patient is a 45-year-old male presenting with severe, crushing chest pain radiating to his left arm. He has a past medical history of hypertension. Admitting to Cardiac ICU." + ); + const [clinicalNote, setClinicalNote] = useState(null); + const [billingCodes, setBillingCodes] = useState([]); + const [isProcessingClinical, setIsProcessingClinical] = useState(false); + + // Workflow logs state + const [workflows, setWorkflows] = useState([ + { id: "wf-bernard-001", name: "PRReviewWorkflow", agent: "Bernard", target: "pr-401", status: "completed", result: "Approved with suggestions" }, + { id: "wf-gumbo-002", name: "GumboSummarizeWorkflow", agent: "Gumbo", target: "api-spec.md", status: "completed", result: "Summary persisted in PG" }, + { id: "wf-ops-003", name: "OpsRemediateWorkflow", agent: "OpsAgent", target: "VMID 102", status: "running", result: "Awaiting approval signal" } + ]); + + const messagesEndRef = useRef(null); + const terminalEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isClawTyping]); + + useEffect(() => { + terminalEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [clawTerminalLogs]); + + // Simulated Claw response logic + const handleSendMessage = async (e) => { + e.preventDefault(); + if (!inputValue.trim()) return; + + const userText = inputValue; + setMessages(prev => [...prev, { role: "user", content: userText, timestamp: "Just now" }]); + setInputValue(""); + setIsClawTyping(true); + setClawTerminalLogs(prev => [...prev, `[Claw] Received instruction: "${userText}"`]); + + // Parse commands for visual interactions + let responseText = ""; + let logs = []; + + await new Promise(resolve => setTimeout(resolve, 1500)); + + const cleanInput = userText.toLowerCase(); + if (cleanInput.includes("pod") || cleanInput.includes("k8s") || cleanInput.includes("kubectl")) { + logs = [ + "Connecting to Kubernetes API server...", + "Executing: kubectl get pods -n default", + "Found 2 pods in namespace 'default':", + " - web-pod-xyz 0/1 CrashLoopBackOff 5 2h", + " - db-pod-abc 1/1 Running 0 5d", + "Fetching logs for 'web-pod-xyz'...", + " Error detected: Database connection refused by 10.96.20.10:5432" + ]; + responseText = "I've checked the Kubernetes pods. Pod `web-pod-xyz` is currently failing due to a database connection refusal. You can use the **OpsAgent** tab to approve a pod restart or scale the DB deployment."; + } else if (cleanInput.includes("proxmox") || cleanInput.includes("vmid") || cleanInput.includes("disk")) { + logs = [ + "Establishing SSH connection to Proxmox cluster host 192.168.8.225...", + "Running: pct status 102", + " status: running", + "Running: pct exec 102 -- df -h /", + " Filesystem Size Used Avail Use% Mounted on", + " /dev/loop0 20G 19G 0.2G 94% /", + "Disk usage critically high! Proposed action: clean_disk" + ]; + responseText = "Proxmox Container ID `102` is running out of disk space (94% used). I have created a pending action under the **OpsAgent Center** to perform automated cleanup. Would you like me to execute it?"; + } else if (cleanInput.includes("hello") || cleanInput.includes("hi")) { + responseText = "Hi there! I am Claw, your local personal assistant. Try asking me to `check k8s pods` or `diagnose proxmox container 102`."; + } else { + responseText = `I've analyzed your query: "${userText}". I recommend initiating an **OpsRemediateWorkflow** to check the status or scanning your configuration files. Let me know if you would like me to trigger that for you.`; + } + + setClawTerminalLogs(prev => [...prev, ...logs, "[Claw] Response formulated successfully."]); + setMessages(prev => [...prev, { role: "assistant", content: responseText, timestamp: "Just now" }]); + setIsClawTyping(false); + }; + + // Approval Handlers + const handleApprove = async (id, action, target) => { + setPendingApprovals(prev => prev.map(item => item.id === id ? { ...item, status: "approved" } : item)); + setOpsLogs(prev => [...prev, `2026-05-29 01:14:10 [OpsAgent] RECEIVED APPROVAL SIGNAL for request ${id}`]); + + // Simulate execution log + let execLogs = [ + `[OpsAgent] Initiating execution of '${action}' on target '${target}'...`, + `[OpsAgent] Sending signal to Temporal workflow...`, + ]; + + if (action === "clean_disk") { + execLogs.push( + "[OpsAgent] SSH into Proxmox node VMID 102...", + "[OpsAgent] running: apt-get clean && rm -rf /tmp/*", + "[OpsAgent] Disk space cleared. Root filesystem usage reduced from 94% to 71%." + ); + } else { + execLogs.push( + `[OpsAgent] Running: kubectl delete pod ${target} -n default`, + `[OpsAgent] Pod deleted. K8s controller successfully scheduled a replica.` + ); + } + + // Delay log write to simulate real execution + for (const log of execLogs) { + await new Promise(resolve => setTimeout(resolve, 800)); + setOpsLogs(prev => [...prev, log]); + } + + setPendingApprovals(prev => prev.filter(item => item.id !== id)); + setWorkflows(prev => prev.map(wf => wf.target === (action === "clean_disk" ? "VMID 102" : target) ? { ...wf, status: "completed", result: "Remediated successfully" } : wf)); + }; + + const handleReject = (id) => { + setPendingApprovals(prev => prev.filter(item => item.id !== id)); + setOpsLogs(prev => [...prev, `2026-05-29 01:14:30 [OpsAgent] Request ${id} REJECTED by operator. Action aborted.`]); + }; + + // Clinical note simulation + const handleProcessClinical = async () => { + setIsProcessingClinical(true); + setClinicalNote(null); + setBillingCodes([]); + + await new Promise(resolve => setTimeout(resolve, 1500)); + + setClinicalNote({ + subjective: "Patient is a 45-year-old male presenting with severe, crushing chest pain radiating to his left arm. He reports shortness of breath.", + objective: "Alert and oriented. Reports pain scale 8/10.", + assessment: "Acute Myocardial Infarction suspected.", + plan: "Order EKG, troponin levels. Administer Aspirin 324mg immediately. Admit to Cardiac ICU." + }); + + setBillingCodes([ + { code: "I21.9", desc: "Acute myocardial infarction, unspecified" }, + { code: "93000", desc: "Electrocardiogram, tracing and report" }, + { code: "99223", desc: "Initial hospital care, high level severity" } + ]); + setIsProcessingClinical(false); + }; + return ( -
-

Agentic OS HQ

-

Next.js shell scaffold. Wire this UI to Temporal and Langfuse in later iterations.

-
+
+ {/* Sidebar Navigation */} + + + {/* Main Content Area */} +
+
+
+

{activeTab.replace("curio", "Clinical Curio").replace("ops", "OpsAgent Hub")}

+
+ Namespace: ai-agents +
+
+
+
+ Sysadmin Console + 192.168.8.250 +
+
+ +
+
+
+ +
+ {/* TAB 1: DASHBOARD HOME */} + {activeTab === "dashboard" && ( +
+ {/* Stat Cards */} +
+
+
+ Total Active Agents + +
+

5 / 8

+
Active in namespace
+
+ +
+
+ Temporal Workflows + +
+

12 Active

+
98.8% success SLA
+
+ +
+
+ Diagnostic Logs + +
+

142 MB

+
Persisted in PostgreSQL
+
+ +
+
+ Action Approvals + +
+

{pendingApprovals.length} Awaiting

+
Require administrator override
+
+
+ + {/* Agent Status Panel */} +
+

Core Agent Ecosystem Status

+
+ {[ + { name: "Bernard", role: "GitOps Reviewer & Workflow Monitor", status: "Idle", desc: "Awaiting PR push events on Gitea hooks.", type: "system" }, + { name: "Gumbo", role: "Documentation Summarizer", status: "Active", desc: "Indexing recent technical wiki docs via MCP filesystem.", type: "assistant" }, + { name: "OpsAgent", role: "SRE Diagnostics & Self-Healing", status: "Pending approval", desc: "Awaiting administrator decision for remediation action.", type: "remediator" }, + { name: "Claw", role: "Local Assistant CLI (OpenClaw clone)", status: "Active", desc: "Awaiting operator prompt input.", type: "assistant" }, + { name: "Curio Billing/Triage", role: "HMS AI Assist", status: "Idle", desc: "Standing by for clinical intake recording inputs.", type: "clinical" }, + ].map((agent, i) => ( +
+
+

{agent.name}

+

{agent.role} — {agent.desc}

+
+
+ + {agent.status} +
+
+ ))} +
+
+ + {/* Quick Trigger Block */} +
+
+

Diagnostics & Ops Triggers

+
+ + +
+
+ +
+

Knowledge Intake Trigger

+
+ +
+
+
+
+ )} + + {/* TAB 2: CLAW PERSONAL ASSISTANT */} + {activeTab === "claw" && ( +
+ {/* Chat Panel */} +
+ {/* Chat Header */} +
+
+
+

Claw Personal Assistant

+ Runs locally. Accesses file tools & system terminal. +
+
+ + {/* Chat Messages */} +
+ {messages.map((msg, i) => ( +
+
+ {msg.content} +
+ {msg.timestamp} +
+ ))} + + {isClawTyping && ( +
+ + + +
+ )} +
+
+ + {/* Chat Input */} +
+ setInputValue(e.target.value)} + placeholder="Ask Claw: e.g. 'check pods' or 'check disk VMID 102'..." + style={{ flex: 1, padding: "12px 16px", border: "1px solid var(--border-color)", borderRadius: "8px", background: "var(--bg-secondary)", color: "var(--text-primary)", outline: "none", fontSize: "14px" }} + /> + +
+
+ + {/* Claw Tool & Console Log Panel */} +
+
+

+ + Claw Tool Console +

+
+ +
+ {clawTerminalLogs.length === 0 ? ( +
Console logs will stream here as Claw calls CLI and API tools...
+ ) : ( + clawTerminalLogs.map((log, i) => ( +
+ > {log} +
+ )) + )} +
+
+
+
+ )} + + {/* TAB 3: OPSAGENT HUB */} + {activeTab === "ops" && ( +
+ {/* Left Column: Diagnostics and Approvals */} +
+ {/* Pending Actions */} +
+

+ + Awaiting System Approvals (Approval Gate) +

+ + {pendingApprovals.length === 0 ? ( +
+ +

No approvals pending

+

All systems are running clean and within normal parameters.

+
+ ) : ( +
+ {pendingApprovals.map((req) => ( +
+
+
+ + {req.action.replace("_", " ")} + + Target: {req.targetType.toUpperCase()} ({req.targetId}) +
+ {req.timestamp} +
+ +
+

{req.details}

+ +
+ + +
+
+
+ ))} +
+ )} +
+ + {/* Diagnostics Panel */} +
+

OpsAgent Diagnostics Tools

+

Manually run automated checks across your container nodes.

+
+
+ K8s Diagnostic + +
+ +
+ Proxmox Auditor + +
+
+
+
+ + {/* Right Column: Execution Live Terminal */} +
+
+

OpsAgent Execution Terminal Logs

+
+
+ {opsLogs.map((log, i) => ( +
{log}
+ ))} +
+
+
+ )} + + {/* TAB 4: CLINICAL WORKSPACE */} + {activeTab === "curio" && ( +
+ {/* Doctor Dictation Section */} +
+
+ +
+

Clinical Scribe Workspace

+ Uses ClinicalIntakeWorkflow (Temporal Task Queue) +
+
+ +
+ +