Skip to content
This repository was archived by the owner on Mar 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLUSTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ GITHUB REPO: /cluster/repos/FactoryLM-Architecture/
- Claude Code CLI installed
- SMB mount to Alpha: `mount_smbfs //alpha/cluster /cluster`

### CHARLIE (192.168.1.12) — Vector KB
### CHARLIE (192.168.1.12) — Vector KB + Telegram Ingress
- Qdrant running on :8000
- **Telegram bot** via polling (`services/troubleshoot/adapters/telegram_bot.py`)
- Uses `run_polling()` — no public endpoint or webhook needed
- Replaced VPS OpenClaw webhook gateway (March 2026)
- Claude Code CLI installed
- SMB mount to Alpha: `mount_smbfs //alpha/cluster /cluster`

Expand Down
180 changes: 171 additions & 9 deletions apps/mission-control/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Port: 8090
"""

import logging
import os
import json
import asyncio
Expand All @@ -25,9 +26,12 @@
import yaml
from fastapi import FastAPI, BackgroundTasks, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

logger = logging.getLogger("mission-control")

app = FastAPI(
title="Mission Control API",
description="FactoryLM Orchestration - 40+ autonomous capabilities",
Expand Down Expand Up @@ -81,6 +85,18 @@ class HILAction(BaseModel):
risk_level: str = "medium"


class ShellExecRequest(BaseModel):
command: str
timeout: int = 30
cwd: Optional[str] = None


class ClaudePromptRequest(BaseModel):
prompt: str
project_path: str = "~/factorylm"
timeout: int = 300


# ============ HEALTH ============

@app.get("/health")
Expand Down Expand Up @@ -139,8 +155,7 @@ async def run_workflow(workflow_id: str, request: WorkflowRunRequest, background

async def execute_workflow(workflow_id: str, params: dict, run_id: str):
"""Background task to execute workflow."""
# Implementation would use antfarm CLI or direct execution
pass
logger.warning(f"Workflow execution not implemented: {workflow_id} (run_id={run_id})")


# ============ CELERY WORKER SWARM ============
Expand Down Expand Up @@ -369,8 +384,7 @@ async def stop_agent(agent: str):

async def _run_agent(agent: str):
"""Background task to run agent."""
# Implementation depends on agent type
pass
logger.warning(f"Agent execution not implemented: {agent}")


# ============ PLC COLLECTORS ============
Expand All @@ -383,23 +397,23 @@ async def list_collectors():
"name": "s7",
"type": "Siemens S7",
"file": "workers/collectors/s7_collector_tasks.py",
"status": "active",
"status": "unknown",
"protocol": "S7comm",
"last_poll": datetime.utcnow().isoformat()
},
{
"name": "ab",
"type": "Allen-Bradley",
"file": "workers/collectors/ab_collector_tasks.py",
"status": "active",
"status": "unknown",
"protocol": "EtherNet/IP",
"last_poll": datetime.utcnow().isoformat()
},
{
"name": "modbus",
"type": "Modbus TCP",
"file": "workers/collectors/modbus_collector_tasks.py",
"status": "active",
"status": "unknown",
"protocol": "Modbus TCP",
"last_poll": datetime.utcnow().isoformat()
}
Expand Down Expand Up @@ -543,9 +557,11 @@ def _get_active_mode() -> str:
# ============ JARVIS NODES ============

JARVIS_NODES = {
"vps": {"host": "100.68.120.99", "port": 8765, "name": "VPS (Jarvis)"},
# "vps": {"host": "100.68.120.99", "port": 8765, "name": "VPS (Jarvis)"}, # Deprecated: Telegram moved to CHARLIE polling
"travel": {"host": "100.83.251.23", "port": 8765, "name": "Travel Laptop"},
"plc": {"host": "100.72.2.99", "port": 8765, "name": "PLC Laptop"},
"bravo": {"host": "100.86.236.11", "port": 8765, "name": "Mac Mini BRAVO"},
"charlie": {"host": "100.82.246.52", "port": 8765, "name": "Mac Mini CHARLIE"},
}


Expand Down Expand Up @@ -574,6 +590,132 @@ async def _check_node_health(host: str, port: int) -> str:
return "offline"


# ============ SHELL EXECUTION ============

@app.post("/api/nodes/{node_id}/shell")
async def node_shell(node_id: str, request: ShellExecRequest):
"""Execute shell command on a Jarvis node."""
if node_id not in JARVIS_NODES:
raise HTTPException(404, f"Unknown node: {node_id}")

node = JARVIS_NODES[node_id]
url = f"http://{node['host']}:{node['port']}/shell"

try:
async with httpx.AsyncClient(timeout=request.timeout + 5) as client:
resp = await client.post(url, json={
"command": request.command,
"timeout": request.timeout,
"cwd": request.cwd,
})
if resp.status_code == 200:
data = resp.json()
# Normalize field names across Jarvis node versions
if "returncode" in data and "exit_code" not in data:
data["exit_code"] = data.pop("returncode")
if "duration_ms" not in data:
data["duration_ms"] = 0
return {
"node": node_id,
"node_name": node["name"],
**data
}
else:
raise HTTPException(resp.status_code, f"Node error: {resp.text}")
except httpx.ConnectError:
raise HTTPException(503, f"Cannot connect to {node['name']} at {node['host']}:{node['port']}")
except httpx.ReadTimeout:
raise HTTPException(408, f"Command timed out on {node['name']}")


@app.get("/api/nodes/{node_id}/info")
async def node_info(node_id: str):
"""Get system info from a Jarvis node."""
if node_id not in JARVIS_NODES:
raise HTTPException(404, f"Unknown node: {node_id}")

node = JARVIS_NODES[node_id]
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"http://{node['host']}:{node['port']}/system-info")
if resp.status_code == 200:
return {"node": node_id, "node_name": node["name"], **resp.json()}
raise HTTPException(resp.status_code, "Failed to get system info")
except httpx.ConnectError:
raise HTTPException(503, f"Cannot reach {node['name']}")


@app.get("/api/quick-actions")
async def quick_actions():
"""Pre-defined quick actions for the dashboard."""
return {
"actions": [
{"id": "openclaw-status", "label": "OpenClaw Status", "node": "vps", "command": "systemctl status openclaw --no-pager -l", "risk": "low"},
{"id": "openclaw-logs", "label": "OpenClaw Logs", "node": "vps", "command": "journalctl -u openclaw -n 50 --no-pager", "risk": "low"},
{"id": "restart-openclaw", "label": "Restart OpenClaw", "node": "vps", "command": "systemctl restart openclaw", "risk": "medium"},
{"id": "git-pull-vps", "label": "Git Pull (VPS)", "node": "vps", "command": "cd /opt/openclaw && git pull", "risk": "medium"},
{"id": "disk-space-vps", "label": "Disk Space (VPS)", "node": "vps", "command": "df -h /", "risk": "low"},
{"id": "tailscale-status", "label": "Tailscale Status", "node": "vps", "command": "tailscale status", "risk": "low"},
{"id": "ollama-bravo", "label": "Ollama Models (BRAVO)", "node": "bravo", "command": "ollama list", "risk": "low"},
{"id": "ollama-charlie", "label": "Ollama Models (CHARLIE)", "node": "charlie", "command": "ollama list", "risk": "low"},
]
}


# ============ CLAUDE CODE RELAY ============

@app.post("/api/nodes/{node_id}/claude")
async def node_claude(node_id: str, request: ClaudePromptRequest):
"""Send a prompt to Claude Code CLI on a Jarvis node."""
if node_id not in JARVIS_NODES:
raise HTTPException(404, f"Unknown node: {node_id}")

node = JARVIS_NODES[node_id]
base_url = f"http://{node['host']}:{node['port']}"
prompt_id = str(uuid.uuid4())[:8]
tmp_path = f"/tmp/mc-prompt-{prompt_id}.txt"

# Step 1: Write prompt to temp file on target node
try:
async with httpx.AsyncClient(timeout=10) as client:
write_resp = await client.post(f"{base_url}/file/write", json={
"path": tmp_path,
"content": request.prompt,
})
if write_resp.status_code != 200:
raise HTTPException(502, f"Failed to write prompt file on {node['name']}")
except httpx.ConnectError:
raise HTTPException(503, f"Cannot connect to {node['name']}")

# Step 2: Run claude -p with the prompt file
command = f"cd {request.project_path} && claude -p < {tmp_path} ; rm -f {tmp_path}"
try:
async with httpx.AsyncClient(timeout=request.timeout + 10) as client:
resp = await client.post(f"{base_url}/shell", json={
"command": command,
"timeout": request.timeout,
})
if resp.status_code == 200:
data = resp.json()
if "returncode" in data and "exit_code" not in data:
data["exit_code"] = data.pop("returncode")
if "duration_ms" not in data:
data["duration_ms"] = 0
return {
"node": node_id,
"node_name": node["name"],
"prompt_preview": request.prompt[:200],
"project_path": request.project_path,
**data,
}
else:
raise HTTPException(resp.status_code, f"Claude Code error: {resp.text}")
except httpx.ReadTimeout:
raise HTTPException(408, f"Claude Code timed out after {request.timeout}s on {node['name']}")
except httpx.ConnectError:
raise HTTPException(503, f"Lost connection to {node['name']}")


# ============ HIL (Human-in-Loop) QUEUE ============

@app.get("/api/hil/pending")
Expand Down Expand Up @@ -645,6 +787,22 @@ async def event_generator():
)


# ============ SPA SERVING ============

FRONTEND_BUILD = Path(__file__).parent.parent / "frontend" / "dist"

if FRONTEND_BUILD.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_BUILD / "assets"), name="static")

@app.get("/{path:path}")
async def serve_spa(path: str):
"""Serve React SPA - all non-API routes go to index.html."""
file_path = FRONTEND_BUILD / path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
return FileResponse(FRONTEND_BUILD / "index.html")


# ============ STARTUP ============

@app.on_event("startup")
Expand All @@ -653,6 +811,10 @@ async def startup():
print(f" Flower URL: {FLOWER_URL}")
print(f" Ralph API: {RALPH_API}")
print(f" Antfarm Dir: {ANTFARM_DIR}")
if FRONTEND_BUILD.exists():
print(f" Serving React SPA from: {FRONTEND_BUILD}")
else:
print(f" No React build found at {FRONTEND_BUILD} - run 'npm run build' in frontend/")


if __name__ == "__main__":
Expand Down
6 changes: 5 additions & 1 deletion apps/mission-control/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { Routes, Route } from 'react-router-dom'
import Sidebar from './components/Sidebar'
import Chat from './pages/Chat'
import Dashboard from './pages/Dashboard'
import Workers from './pages/Workers'
import Ralph from './pages/Ralph'
import Agents from './pages/Agents'
import Tools from './pages/Tools'
import Terminal from './pages/Terminal'

export default function App() {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/" element={<Chat />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/terminal" element={<Terminal />} />
<Route path="/workers" element={<Workers />} />
<Route path="/ralph" element={<Ralph />} />
<Route path="/agents" element={<Agents />} />
Expand Down
8 changes: 6 additions & 2 deletions apps/mission-control/frontend/src/components/AgentPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useAgents, useAgentControl, Agent } from '../hooks/useAPI'
import { Bot, Play, Square, Cpu, Video, HardDrive, Cross } from 'lucide-react'
import { Bot, Play, Square, Cpu, Video, HardDrive } from 'lucide-react'
import clsx from 'clsx'

const AGENT_ICONS: Record<string, React.ReactNode> = {
Expand Down Expand Up @@ -92,7 +92,11 @@ function AgentCard({

{!isOnDemand && (
<div className="flex items-center gap-2">
{isActive ? (
{agent.status === 'unknown' ? (
<span className="text-xs px-2 py-1 bg-gray-800 text-gray-500 rounded">
Not Connected
</span>
) : isActive ? (
<button
onClick={onStop}
disabled={isLoading}
Expand Down
Loading