diff --git a/services/agent-system/docker-compose.yml b/services/agent-system/docker-compose.yml
new file mode 100644
index 0000000..c2e13e4
--- /dev/null
+++ b/services/agent-system/docker-compose.yml
@@ -0,0 +1,33 @@
+services:
+ redis:
+ image: redis:7
+ container_name: agent-system-redis
+ ports:
+ - "6379:6379"
+ restart: unless-stopped
+
+ webui:
+ build: ./webui
+ container_name: agent-system-webui
+ ports:
+ - "18180:8080"
+ volumes:
+ - /opt/homelab:/opt/homelab
+ depends_on:
+ - redis
+ restart: unless-stopped
+
+ runtime-materializer:
+ build: ./runtime-materializer
+ container_name: agent-system-runtime-materializer
+ environment:
+ REDIS_HOST: redis
+ REDIS_PORT: "6379"
+ HOMELAB_WORLD_ROOT: /opt/homelab/world
+ WORLD_DIR: /opt/homelab/world
+ MATERIALIZE_INTERVAL: "10"
+ volumes:
+ - /opt/homelab:/opt/homelab
+ depends_on:
+ - redis
+ restart: unless-stopped
diff --git a/services/agent-system/runtime-materializer/Dockerfile b/services/agent-system/runtime-materializer/Dockerfile
new file mode 100644
index 0000000..5510bc3
--- /dev/null
+++ b/services/agent-system/runtime-materializer/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Install redis python package as requested
+RUN pip install --no-cache-dir redis
+
+COPY materializer.py .
+
+# Ensure the world directory exists in the container (though it will likely be a volume)
+RUN mkdir -p /opt/homelab/world
+
+# Use unbuffered output to see logs in docker
+ENV PYTHONUNBUFFERED=1
+
+CMD ["python", "materializer.py"]
diff --git a/services/agent-system/runtime-materializer/materializer.py b/services/agent-system/runtime-materializer/materializer.py
new file mode 100644
index 0000000..7dcd42f
--- /dev/null
+++ b/services/agent-system/runtime-materializer/materializer.py
@@ -0,0 +1,148 @@
+import redis
+import json
+import os
+import time
+import argparse
+from datetime import datetime
+
+# Configuration from environment variables
+REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
+REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))
+WORLD_DIR = os.environ.get("WORLD_DIR", "/opt/homelab/world")
+
+def get_redis_client():
+ """Returns a Redis client with decoding enabled."""
+ return redis.Redis(
+ host=REDIS_HOST,
+ port=REDIS_PORT,
+ decode_responses=True,
+ socket_timeout=5
+ )
+
+def safe_json_loads(data, default=None):
+ """Safely loads JSON from a string."""
+ if not data:
+ return default
+ try:
+ if isinstance(data, (dict, list)):
+ return data
+ return json.loads(data)
+ except (json.JSONDecodeError, TypeError):
+ return data
+
+def materialize():
+ """Reads state from Redis and writes JSON files to the world directory."""
+ print(f"[{datetime.now().isoformat()}] Materializing world state...")
+ try:
+ r = get_redis_client()
+
+ # 1. Nodes
+ nodes = []
+ node_keys = r.keys("homelab:nodes:*")
+ for key in node_keys:
+ node_data = r.hgetall(key)
+ if node_data:
+ # Parse JSON fields if they exist
+ if "capabilities" in node_data:
+ node_data["capabilities"] = safe_json_loads(node_data["capabilities"], [])
+ if "checks" in node_data:
+ node_data["checks"] = safe_json_loads(node_data["checks"], {})
+ nodes.append(node_data)
+
+ # 2. Services
+ services = []
+ service_keys = r.keys("homelab:services:*")
+ for key in service_keys:
+ svc_data = r.hgetall(key)
+ if svc_data:
+ if "dependencies" in svc_data:
+ svc_data["dependencies"] = safe_json_loads(svc_data["dependencies"], [])
+ if "recommendations" in svc_data:
+ svc_data["recommendations"] = safe_json_loads(svc_data["recommendations"], [])
+ services.append(svc_data)
+
+ # 3. Events (Stream)
+ events = []
+ try:
+ # Get last 100 events from the stream
+ raw_events = r.xrevrange("homelab:events", count=100)
+ for event_id, data in raw_events:
+ event = data.copy()
+ event["id"] = event_id
+ if "details" in event:
+ event["details"] = safe_json_loads(event["details"], {})
+ events.append(event)
+ except redis.exceptions.ResponseError:
+ # homelab:events might not be a stream or doesn't exist
+ pass
+
+ # 4. Incidents (Hash)
+ incidents = []
+ incident_keys = r.keys("homelab:incidents:*")
+ for key in incident_keys:
+ incident_data = r.hgetall(key)
+ if incident_data:
+ incidents.append(incident_data)
+
+ # 5. Deployments (Hash)
+ deployments = []
+ deployment_keys = r.keys("homelab:deployments:*")
+ for key in deployment_keys:
+ dep_data = r.hgetall(key)
+ if dep_data:
+ deployments.append(dep_data)
+
+ # 6. Recommendations (Hash)
+ recommendations = []
+ recommendation_keys = r.keys("homelab:recommendations:*")
+ for key in recommendation_keys:
+ rec_data = r.hgetall(key)
+ if rec_data:
+ recommendations.append(rec_data)
+
+ # 7. Runtime Summary
+ summary = {
+ "timestamp": datetime.utcnow().isoformat() + "Z",
+ "node_count": len(nodes),
+ "service_count": len(services),
+ "unhealthy_services_count": len([s for s in services if s.get("health") != "healthy"]),
+ "incident_count": len(incidents),
+ "recent_events_count": len(events)
+ }
+
+ # Ensure directory exists
+ os.makedirs(WORLD_DIR, exist_ok=True)
+
+ def write_json(filename, data):
+ path = os.path.join(WORLD_DIR, filename)
+ with open(path, "w") as f:
+ json.dump(data, f, indent=2)
+
+ write_json("runtime-summary.json", summary)
+ write_json("nodes.json", nodes)
+ write_json("services.json", services)
+ write_json("incidents.json", incidents)
+ write_json("events.json", events)
+ write_json("deployments.json", deployments)
+ write_json("recommendations.json", recommendations)
+
+ print(f"[{datetime.now().isoformat()}] Successfully materialized to {WORLD_DIR}")
+
+ except redis.exceptions.ConnectionError as e:
+ print(f"Redis connection error: {e}")
+ except Exception as e:
+ print(f"Unexpected error during materialization: {e}")
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Homelab Runtime Materializer")
+ parser.add_argument("--once", action="store_true", help="Run once and exit")
+ parser.add_argument("--interval", type=int, default=30, help="Sleep interval between runs (seconds)")
+ args = parser.parse_args()
+
+ if args.once:
+ materialize()
+ else:
+ print(f"Starting materializer loop (interval: {args.interval}s)...")
+ while True:
+ materialize()
+ time.sleep(args.interval)
diff --git a/services/agent-system/webui/Dockerfile b/services/agent-system/webui/Dockerfile
new file mode 100644
index 0000000..771fcda
--- /dev/null
+++ b/services/agent-system/webui/Dockerfile
@@ -0,0 +1,7 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+COPY web.py index.html ./
+
+EXPOSE 8080
+CMD ["python", "web.py"]
diff --git a/services/agent-system/webui/index.html b/services/agent-system/webui/index.html
new file mode 100644
index 0000000..d20843a
--- /dev/null
+++ b/services/agent-system/webui/index.html
@@ -0,0 +1,701 @@
+
+
+
+
+
+ Operator Control Plane
+
+
+
+
+
+
+
+ RUNTIME STATE IS STALE
+
+
+
+
Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/agent-system/webui/web.py b/services/agent-system/webui/web.py
new file mode 100644
index 0000000..4332474
--- /dev/null
+++ b/services/agent-system/webui/web.py
@@ -0,0 +1,272 @@
+import json
+import os
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+
+STATE_DIR = Path(os.getenv("HOMELAB_STATE_ROOT", "/opt/homelab/state"))
+EVENTS_DIR = Path(os.getenv("HOMELAB_EVENTS_ROOT", "/opt/homelab/events"))
+WORLD_DIR = Path(os.getenv("HOMELAB_WORLD_ROOT", "/opt/homelab/world"))
+ACTIONS_DIR = Path(os.getenv("HOMELAB_ACTIONS_ROOT", "/opt/homelab/actions"))
+CONFIG_DIR = Path(os.getenv("HOMELAB_CONFIG_ROOT", "/opt/homelab/config"))
+
+STATIC_DIR = Path(__file__).parent
+
+DEFAULT_CONFIG = {
+ "operator_mode": "approval",
+ "auto_mode": True,
+ "action_thresholds": {
+ "restart_ha": 0.8,
+ "check_network": 0.9,
+ },
+ "default_threshold": 0.9,
+ "allowed_auto_actions": ["restart_ha"],
+}
+
+
+def read_json_file(path, default=None):
+ if not path.exists():
+ return default if default is not None else []
+ try:
+ return json.loads(path.read_text())
+ except Exception:
+ return default if default is not None else []
+
+
+def get_config():
+ config_path = STATE_DIR / "operator-config.json"
+ if config_path.exists():
+ return read_json_file(config_path, DEFAULT_CONFIG)
+ return DEFAULT_CONFIG
+
+
+def save_config(config):
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ (STATE_DIR / "operator-config.json").write_text(json.dumps(config, indent=2))
+
+
+def current_nodes():
+ return read_json_file(STATE_DIR / "nodes.json")
+
+
+def current_services():
+ return read_json_file(STATE_DIR / "services.json")
+
+
+def current_deployments():
+ return read_json_file(STATE_DIR / "deployments.json")
+
+
+def current_incidents():
+ return read_json_file(STATE_DIR / "incidents.json")
+
+
+def current_recommendations():
+ return read_json_file(STATE_DIR / "recommendations.json")
+
+
+def current_summary():
+ summary = read_json_file(STATE_DIR / "runtime-summary.json", default={})
+ if summary:
+ # Check for staleness
+ mtime = os.path.getmtime(STATE_DIR / "runtime-summary.json")
+ summary["last_update"] = mtime
+ summary["stale"] = (time.time() - mtime) > 60 # Stale if older than 60s
+ return summary
+
+
+def current_events():
+ events = []
+ if EVENTS_DIR.exists():
+ for f in EVENTS_DIR.glob("*.json"):
+ data = read_json_file(f)
+ if data:
+ # Add source file for traceability
+ data["_source"] = f.name
+ events.append(data)
+ return sorted(events, key=lambda x: x.get("timestamp", 0), reverse=True)
+
+
+def current_actions():
+ actions = {}
+ statuses = ["pending", "approved", "running", "completed", "failed", "rejected"]
+ for status in statuses:
+ actions[status] = []
+ status_dir = ACTIONS_DIR / status
+ if status_dir.exists():
+ for f in status_dir.glob("*.json"):
+ data = read_json_file(f)
+ if data:
+ # Injects some metadata for UI
+ data["id"] = data.get("action_id") or f.stem
+ data["status"] = status
+ actions[status].append(data)
+ return actions
+
+
+def mutate_action(action_id, target_status):
+ statuses = ["pending", "approved", "running", "completed", "failed", "rejected"]
+ if target_status not in statuses:
+ return False, f"Invalid target status: {target_status}"
+
+ # Find where the action is
+ source_path = None
+ current_status = None
+ for status in statuses:
+ p = ACTIONS_DIR / status / f"{action_id}.json"
+ if p.exists():
+ source_path = p
+ current_status = status
+ break
+
+ if not source_path:
+ return False, f"Action {action_id} not found"
+
+ target_dir = ACTIONS_DIR / target_status
+ target_dir.mkdir(parents=True, exist_ok=True)
+ target_path = target_dir / f"{action_id}.json"
+
+ try:
+ data = json.loads(source_path.read_text())
+ data["status"] = target_status
+ data["updated_at"] = time.time()
+
+ # Keep history of transitions
+ history = data.get("transition_history", [])
+ history.append({
+ "from": current_status,
+ "to": target_status,
+ "timestamp": time.time()
+ })
+ data["transition_history"] = history
+
+ target_path.write_text(json.dumps(data, indent=2))
+ if source_path != target_path:
+ source_path.unlink()
+ return True, "Success"
+ except Exception as e:
+ return False, str(e)
+
+
+def send_json(status, payload, handler):
+ body = (json.dumps(payload) + "\n").encode("utf-8")
+ handler.send_response(status)
+ handler.send_header("Content-Type", "application/json")
+ handler.send_header("Content-Length", str(len(body)))
+ handler.end_headers()
+ handler.wfile.write(body)
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/config":
+ send_json(200, get_config(), self)
+ return
+
+ if self.path == "/nodes":
+ send_json(200, current_nodes(), self)
+ return
+
+ if self.path == "/services":
+ send_json(200, current_services(), self)
+ return
+
+ if self.path == "/deployments":
+ send_json(200, current_deployments(), self)
+ return
+
+ if self.path == "/incidents":
+ send_json(200, current_incidents(), self)
+ return
+
+ if self.path == "/recommendations":
+ send_json(200, current_recommendations(), self)
+ return
+
+ if self.path == "/summary":
+ send_json(200, current_summary(), self)
+ return
+
+ if self.path == "/events":
+ send_json(200, current_events(), self)
+ return
+
+ if self.path == "/actions":
+ send_json(200, current_actions(), self)
+ return
+
+ if self.path in ("/", "/index.html"):
+ body = (STATIC_DIR / "index.html").read_bytes()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+
+ self.send_error(404)
+
+ def do_POST(self):
+ if self.path not in (
+ "/config",
+ "/action/mutate",
+ "/mode",
+ ):
+ self.send_error(404)
+ return
+
+ length = int(self.headers.get("Content-Length", "0"))
+ raw_body = self.rfile.read(length).decode("utf-8")
+ try:
+ payload = json.loads(raw_body)
+ except json.JSONDecodeError:
+ self.send_error(400, "Invalid JSON")
+ return
+
+ if self.path == "/config":
+ config = get_config()
+ config.update(payload)
+ save_config(config)
+ send_json(200, {"status": "ok"}, self)
+ return
+
+ if self.path == "/mode":
+ mode = payload.get("mode")
+ if not mode:
+ self.send_error(400, "mode is required")
+ return
+ config = get_config()
+ config["operator_mode"] = mode
+ save_config(config)
+ send_json(200, {"status": "ok"}, self)
+ return
+
+ if self.path == "/action/mutate":
+ action_id = payload.get("id")
+ target = payload.get("status")
+ if not action_id or not target:
+ self.send_error(400, "id and status are required")
+ return
+ success, msg = mutate_action(action_id, target)
+ if success:
+ send_json(200, {"status": "ok"}, self)
+ else:
+ self.send_error(500, msg)
+ return
+
+ def log_message(self, format, *args):
+ return
+
+
+if __name__ == "__main__":
+ # Ensure directories exist
+ for d in [STATE_DIR, EVENTS_DIR, WORLD_DIR, ACTIONS_DIR, CONFIG_DIR]:
+ d.mkdir(parents=True, exist_ok=True)
+ for s in ["pending", "approved", "running", "completed", "failed", "rejected"]:
+ (ACTIONS_DIR / s).mkdir(parents=True, exist_ok=True)
+
+ port = int(os.getenv("PORT", "8080"))
+ print(f"Operator Control Plane starting on 0.0.0.0:{port}")
+ server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
+ server.serve_forever()