Initial runtime multi-agent system snapshot

This commit is contained in:
Codex 2026-05-08 11:53:16 +00:00
parent 1f01b1b655
commit 8f6a63da9a
16 changed files with 2038 additions and 29 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
__pycache__/
*.py[cod]
.aider*

View file

@ -0,0 +1,9 @@
services:
monitor-agent:
build: ./monitor-agent
environment:
NODE_NAME: ${NODE_NAME:-}
ORCHESTRATOR_URL: ${ORCHESTRATOR_URL:?set ORCHESTRATOR_URL}
SERVICES_TO_CHECK: ${SERVICES_TO_CHECK:-homeassistant,lms,forgejo}
INTERVAL_SECONDS: ${INTERVAL_SECONDS:-30}
restart: unless-stopped

View file

@ -11,5 +11,31 @@ services:
- redis
environment:
REDIS_HOST: redis
HA_PROXY_URL: https://ha.okit.pl
volumes:
- /tmp/agent-events.log:/tmp/agent-events.log
stdin_open: true
tty: true
webui:
build: ./webui
container_name: agent-system-webui
environment:
REDIS_HOST: redis
ports:
- "8080:8080"
volumes:
- /tmp/agent-events.log:/tmp/agent-events.log
depends_on:
- orchestrator
- redis
monitor-agent:
build: ./monitor-agent
environment:
NODE_NAME: ubuntu-4gb-hel1-1
ORCHESTRATOR_URL: http://webui:8080/events
SERVICES_TO_CHECK: homeassistant,lms,forgejo
depends_on:
- webui
restart: unless-stopped

6
monitor-agent/Dockerfile Normal file
View file

@ -0,0 +1,6 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["python", "main.py"]

115
monitor-agent/main.py Normal file
View file

@ -0,0 +1,115 @@
import json
import os
import socket
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
NODE_NAME = os.getenv("NODE_NAME") or socket.gethostname()
ORCHESTRATOR_URL = os.getenv("ORCHESTRATOR_URL")
SERVICES_TO_CHECK = {
name.strip()
for name in os.getenv("SERVICES_TO_CHECK", "").split(",")
if name.strip()
}
INTERVAL_SECONDS = int(os.getenv("INTERVAL_SECONDS", "30"))
SERVICE_CATALOG = [
{"name": "homeassistant", "type": "http", "url": "http://homeassistant:8123"},
{"name": "lms", "type": "tcp", "host": "192.168.31.6", "port": 9000},
{"name": "forgejo", "type": "http", "url": "http://forgejo:3000"},
{"name": "nginx", "type": "http", "url": "http://nginx"},
{"name": "mosquitto", "type": "tcp", "host": "mosquitto", "port": 1883},
]
def services_to_check():
if not SERVICES_TO_CHECK:
return SERVICE_CATALOG
return [
service for service in SERVICE_CATALOG
if service["name"] in SERVICES_TO_CHECK
]
def check_http(url):
request = Request(url, headers={"User-Agent": "monitor-agent/1.0"})
try:
with urlopen(request, timeout=5) as response:
return "ok" if response.status == 200 else "error"
except (HTTPError, URLError, TimeoutError, OSError):
return "error"
def check_tcp(host, port):
try:
with socket.create_connection((host, int(port)), timeout=5):
return "ok"
except OSError:
return "error"
def check_service(service):
service_type = service.get("type")
if service_type == "http":
return check_http(service["url"])
if service_type == "tcp":
return check_tcp(service["host"], service["port"])
return "error"
def build_event(service, status):
return {
"type": "health",
"service": service["name"],
"status": status,
"timestamp": time.time(),
"run_id": None,
"node": NODE_NAME,
}
def send_event(event):
body = json.dumps(event).encode("utf-8")
request = Request(
ORCHESTRATOR_URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=5) as response:
if response.status >= 300:
raise RuntimeError(f"event endpoint returned {response.status}")
def main():
if not ORCHESTRATOR_URL:
raise SystemExit("ORCHESTRATOR_URL is required")
selected_services = services_to_check()
print(
(
f"[monitor-agent] ready node={NODE_NAME} "
f"url={ORCHESTRATOR_URL} "
f"services={[service['name'] for service in selected_services]}"
),
flush=True,
)
while True:
started = time.time()
for service in selected_services:
status = check_service(service)
event = build_event(service, status)
try:
send_event(event)
except Exception as exc:
print(f"[monitor-agent] send failed: {exc}", flush=True)
print(json.dumps(event), flush=True)
elapsed = time.time() - started
time.sleep(max(0, INTERVAL_SECONDS - elapsed))
if __name__ == "__main__":
main()

View file

14
node-agent/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

116
node-agent/main.py Normal file
View file

@ -0,0 +1,116 @@
import json
import os
import subprocess
import time
import redis
NODE_NAME = os.getenv("NODE_NAME", "node")
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
def redis_client():
return redis.Redis(host=REDIS_HOST, port=6379, decode_responses=True)
def build_result(task_id, status, result="", error=None, run_id=None):
return {
"task_id": task_id,
"run_id": run_id,
"node": NODE_NAME,
"status": status,
"result": result,
"error": error,
}
def run_command(cmd):
completed = subprocess.run(
cmd,
shell=True,
text=True,
capture_output=True,
timeout=30,
)
output = completed.stdout.strip()
error = completed.stderr.strip()
if completed.returncode != 0:
return "error", output, error or f"exit code {completed.returncode}"
return "ok", output, None
def docker_ps(params):
container = params.get("container")
cmd = "docker ps --format '{{.Names}}|{{.Status}}'"
status, output, error = run_command(cmd)
if status != "ok":
return status, output, error
containers = []
for line in output.splitlines():
name, _, state = line.partition("|")
if container and container not in name:
continue
containers.append({"name": name, "status": state})
return "ok", containers, None
def docker_logs(params):
container = params.get("container")
if not container:
return "error", "", "container parameter is required"
return run_command(f"docker logs --tail 120 {container}")
def handle_task(task):
action = task.get("action")
params = task.get("params") or {}
if action == "exec":
return run_command(params.get("cmd", ""))
if action == "docker_ps":
return docker_ps(params)
if action == "docker_logs":
return docker_logs(params)
return "error", "", f"unknown action: {action}"
def main():
client = redis_client()
print(f"[node-agent] ready node={NODE_NAME} redis={REDIS_HOST}")
while True:
try:
_, raw_task = client.brpop("tasks")
task = json.loads(raw_task)
if task.get("target") != NODE_NAME:
client.lpush("tasks", raw_task)
time.sleep(1)
continue
status, result, error = handle_task(task)
client.lpush(
"results",
json.dumps(
build_result(
task.get("task_id"),
status,
result,
error,
task.get("run_id"),
)
),
)
except Exception as exc:
print(f"[node-agent] error: {exc}")
time.sleep(2)
if __name__ == "__main__":
main()

View file

@ -0,0 +1 @@
redis

80
orchestrator/diagnosis.py Normal file
View file

@ -0,0 +1,80 @@
class DiagnosisEngine:
def __init__(self):
self.signals = {}
def add(self, key, value):
self.signals[key] = value
def evaluate(self):
results = []
lms = self.signals.get("lms")
ha_logs_client_error = self.signals.get("ha_logs_client_connector_error")
ha_container = self.signals.get("ha_container")
ha_dependency_failure = (
lms == "000"
and ha_logs_client_error
and ha_container == "running"
)
# LMS
if lms == "000" and not ha_dependency_failure:
results.append("[info] LMS is not reachable")
elif lms == "200":
results.append("[info] LMS: OK")
# Dependency failures
if ha_dependency_failure:
results.append("[warning] HA dependency failure: LMS unreachable")
# HA local
ha_local = self.signals.get("ha_local")
if ha_local == "000":
options = [
{
"label": "Restart HA",
"command": "restart_ha",
"confidence": 0.85,
},
{
"label": "Check network",
"command": "check_network",
"confidence": 0.6,
}
]
ignore_option = {
"label": "Ignore",
"command": "ignore",
"is_ignore": True,
}
results.append("[error] HA is not working locally")
results.append(
{
"type": "proposal",
"message": "Home Assistant is not working",
"confidence": 0.85,
"options": sorted(
options,
key=lambda option: option["confidence"],
reverse=True,
) + [ignore_option],
}
)
elif ha_local == "200":
results.append("[info] HA local: OK")
# HA proxy
ha_proxy = self.signals.get("ha_proxy")
if ha_proxy == "dns_error":
results.append("[error] HA proxy: DNS failure")
results.append("[error] external access issue: DNS or routing failure")
elif ha_proxy == "000":
results.append("[error] HA proxy: not reachable")
elif ha_proxy == "200":
results.append("[info] HA proxy: OK")
diagnosis_count = sum(1 for result in results if isinstance(result, str))
if diagnosis_count > 1:
results.append("[info] multiple issues detected")
return results

35
orchestrator/events.py Normal file
View file

@ -0,0 +1,35 @@
import json
import time
EVENT_LOG = "/tmp/agent-events.log"
def emit_event(event):
event.setdefault("run_id", None)
event.setdefault("node", None)
event.setdefault("timestamp", time.time())
line = json.dumps(event)
print(line)
print("EVENT WRITTEN")
try:
with open(EVENT_LOG, "a", encoding="utf-8") as event_log:
event_log.write(line + "\n")
event_log.flush()
except OSError as exc:
print(f"[event:error] failed to write event log: {exc}")
def emit_run_progress(run_id, run):
emit_event(
{
"type": "run_progress",
"run_id": run_id,
"node": None,
"received": run.get("received", 0),
"expected": run.get("expected", 0),
"message": (
f"run progress: {run.get('received', 0)}/"
f"{run.get('expected', 0)}"
),
}
)

View file

@ -1,22 +1,183 @@
import json
import os
import sys
import threading
import time
from uuid import uuid4
from diagnosis import DiagnosisEngine
from events import emit_event, emit_run_progress
from redis_client import get_redis_client
from result_listener import listen_for_results
from task_builder import build_task
def send_task(redis_client, task):
def send_task(redis_client, task, runs=None, run_id=None):
if run_id:
task["run_id"] = run_id
if runs is not None and run_id in runs:
runs[run_id]["expected"] += 1
emit_run_progress(run_id, runs[run_id])
redis_client.lpush("tasks", json.dumps(task))
print(f"sent {task['action']} to {task['target']} ({task['task_id']})")
emit_event(
{
"type": "task",
"message": f"sent {task['action']} to {task['target']}",
"run_id": task.get("run_id"),
"node": task.get("target"),
}
)
def dispatch(redis_client, command, ha_task_ids):
def stop_run(runs, run_id):
run = runs.get(run_id)
if not run or not run.get("active"):
return False
run["active"] = False
run["status"] = "stopped"
emit_event(
{
"type": "run_status",
"message": "run status: stopped",
"run_id": run_id,
"node": None,
"status": "stopped",
}
)
runs.pop(run_id, None)
return True
def apply_run_action(redis_client, run_actions, run_id, command):
context = run_actions.get(run_id, {})
if command == "ignore":
emit_event(
{
"type": "action",
"message": "proposal ignored",
"run_id": run_id,
"node": None,
}
)
return True
if command == "check_network":
emit_event(
{
"type": "action",
"message": "confirmed action: check network connectivity",
"run_id": run_id,
"node": "vps",
}
)
send_task(
redis_client,
build_task("vps", "exec", {"cmd": "ping -c 1 1.1.1.1"}),
run_id=run_id,
)
return True
if command == "restart_ha":
container_name = context.get("ha_container_name") or "homeassistant"
emit_event(
{
"type": "action",
"message": f"confirmed action: restart {container_name}",
"run_id": run_id,
"node": "piha",
}
)
send_task(
redis_client,
build_task("piha", "exec", {"cmd": f"docker restart {container_name}"}),
run_id=run_id,
)
return True
emit_event(
{
"type": "action",
"message": f"unknown proposal command: {command}",
"run_id": run_id,
"node": None,
}
)
return False
def emit_auto_config(auto_config, message=None):
emit_event(
{
"type": "auto_config",
"message": message or "auto config updated",
"run_id": None,
"node": None,
"auto_mode": auto_config.get("auto_mode"),
"action_thresholds": auto_config.get("action_thresholds", {}),
"default_threshold": auto_config.get("default_threshold"),
"allowed_auto_actions": auto_config.get("allowed_auto_actions", []),
"max_retries_per_action": auto_config.get("max_retries_per_action", {}),
"retry_window_seconds": auto_config.get("retry_window_seconds"),
}
)
def set_auto_config(auto_config, config):
if "auto_mode" in config:
auto_config["auto_mode"] = bool(config["auto_mode"])
if isinstance(config.get("action_thresholds"), dict):
auto_config["action_thresholds"] = {
str(command): float(threshold)
for command, threshold in config["action_thresholds"].items()
}
if "default_threshold" in config:
auto_config["default_threshold"] = float(config["default_threshold"])
if isinstance(config.get("allowed_auto_actions"), list):
auto_config["allowed_auto_actions"] = [
str(command)
for command in config["allowed_auto_actions"]
]
emit_auto_config(auto_config)
def set_auto_mode(auto_config, enabled):
set_auto_config(auto_config, {"auto_mode": enabled})
def dispatch(redis_client, command, ha_task_ids, runs):
if command == "ha":
run_id = str(uuid4())
runs[run_id] = {
"expected": 0,
"received": 0,
"engine": DiagnosisEngine(),
"active": True,
"status": "running",
}
emit_event(
{
"type": "run_status",
"message": "run status: running",
"run_id": run_id,
"node": None,
"status": "running",
}
)
emit_run_progress(run_id, runs[run_id])
task = build_task("piha", "docker_ps", {})
ha_task_ids.add(task["task_id"])
send_task(redis_client, task)
ha_task_ids[task["task_id"]] = run_id
send_task(redis_client, task, runs, run_id)
elif command == "stop":
for run_id, run in list(runs.items()):
if not run.get("active"):
continue
stop_run(runs, run_id)
elif command == "all":
send_task(redis_client, build_task("piha", "docker_ps", {}))
send_task(redis_client, build_task("vps", "docker_ps", {}))
@ -24,33 +185,223 @@ def dispatch(redis_client, command, ha_task_ids):
send_task(redis_client, build_task("piha", "docker_ps", {}))
def process_input(command, redis_client, ha_task_ids, runs):
if not command:
return
print(f"[input] {command}")
emit_event(
{
"type": "log",
"message": f"user input: {command}",
"run_id": None,
"node": None,
}
)
dispatch(redis_client, command, ha_task_ids, runs)
def listen_for_input_tasks(redis_client, ha_task_ids, runs, run_actions, auto_config):
while True:
_, raw_task = redis_client.brpop("tasks")
try:
task = json.loads(raw_task)
except json.JSONDecodeError:
continue
if task.get("target") != "orchestrator":
redis_client.lpush("tasks", raw_task)
time.sleep(1)
continue
action = task.get("action")
if action == "stop_run":
params = task.get("params") or {}
stop_run(runs, str(params.get("run_id", "")).strip())
continue
if action == "run_action":
params = task.get("params") or {}
apply_run_action(
redis_client,
run_actions,
str(params.get("run_id", "")).strip(),
str(params.get("command", "")).strip(),
)
continue
if action == "set_auto_mode":
params = task.get("params") or {}
set_auto_mode(auto_config, bool(params.get("auto_mode")))
continue
if action == "set_auto_config":
params = task.get("params") or {}
set_auto_config(auto_config, params.get("config") or {})
continue
if action != "input":
continue
params = task.get("params") or {}
process_input(str(params.get("command", "")).strip(), redis_client, ha_task_ids, runs)
def services_list(services):
return [
{
"name": name,
"status": state["status"],
"last_check": state["last_check"],
"node": state.get("node"),
"history": state["history"],
}
for name, state in sorted(services.items())
]
def update_service_health(services, event):
service_name = event.get("service")
status = event.get("status")
node = event.get("node")
timestamp = event.get("timestamp", time.time())
if not service_name or status not in ("ok", "error"):
return
state = services.setdefault(
service_name,
{
"status": status,
"last_check": timestamp,
"node": node,
"history": [],
},
)
state["status"] = status
state["last_check"] = timestamp
state["node"] = node
state["history"].append({"status": status, "timestamp": timestamp, "node": node})
state["history"] = state["history"][-20:]
emit_event(
{
"type": "services_state",
"message": "services health updated",
"run_id": None,
"node": None,
"services": services_list(services),
}
)
def listen_for_external_events(redis_client, services):
while True:
try:
_, raw_event = redis_client.brpop("events")
event = json.loads(raw_event)
except json.JSONDecodeError:
emit_event(
{
"type": "log",
"message": f"invalid external event: {raw_event}",
"run_id": None,
"node": None,
}
)
continue
emit_event(event)
if event.get("type") == "health":
update_service_health(services, event)
def main():
if not os.path.exists("/tmp/agent-events.log"):
open("/tmp/agent-events.log", "w").close()
redis_client = get_redis_client()
ha_task_ids = set()
ha_task_ids = {}
runs = {}
last_actions = {}
run_actions = {}
services = {}
auto_config = {
"auto_mode": True,
"action_thresholds": {
"restart_ha": 0.8,
"check_network": 0.9,
},
"default_threshold": 0.9,
"allowed_auto_actions": ["restart_ha"],
"max_retries_per_action": {
"restart_ha": 3,
},
"retry_window_seconds": 300,
"action_history": {},
}
listener = threading.Thread(
target=listen_for_results,
args=(redis_client, ha_task_ids),
args=(redis_client, ha_task_ids, runs, last_actions, run_actions, auto_config),
daemon=True,
)
listener.start()
input_listener = threading.Thread(
target=listen_for_input_tasks,
args=(redis_client, ha_task_ids, runs, run_actions, auto_config),
daemon=True,
)
input_listener.start()
event_listener = threading.Thread(
target=listen_for_external_events,
args=(redis_client, services),
daemon=True,
)
event_listener.start()
print("[orchestrator] ready")
emit_event(
{
"type": "log",
"message": "orchestrator ready",
"run_id": None,
"node": None,
}
)
emit_auto_config(auto_config, "auto mode: on")
while True:
try:
command = input("> ").strip()
except EOFError:
if not sys.stdin.isatty():
print("[orchestrator] stdin closed, exiting")
emit_event(
{
"type": "log",
"message": "stdin closed, exiting",
"run_id": None,
"node": None,
}
)
return
print("[orchestrator] stdin closed, waiting...")
emit_event(
{
"type": "log",
"message": "stdin closed, waiting",
"run_id": None,
"node": None,
}
)
time.sleep(2)
continue
except KeyboardInterrupt:
print()
continue
if not command:
continue
dispatch(redis_client, command, ha_task_ids)
process_input(command, redis_client, ha_task_ids, runs)
if __name__ == "__main__":

View file

@ -1,13 +1,32 @@
import json
import re
import threading
import time
from uuid import uuid4
from diagnosis import DiagnosisEngine
from events import emit_event, emit_run_progress
from task_builder import build_task
def send_task(redis_client, target, action, params):
def send_task(redis_client, target, action, params, runs=None, run_id=None):
task = build_task(target, action, params)
if run_id:
task["run_id"] = run_id
if runs is not None and run_id in runs:
runs[run_id]["expected"] += 1
emit_run_progress(run_id, runs[run_id])
redis_client.lpush("tasks", json.dumps(task))
print(f"sent {task['action']} to {task['target']} ({task['task_id']})")
emit_event(
{
"type": "task",
"message": f"sent {task['action']} to {task['target']}",
"run_id": task.get("run_id"),
"node": task.get("target"),
}
)
return task
@ -49,18 +68,358 @@ def parse_host_port(url):
return host, port
def interpret_lms_connectivity(result):
def normalize_http_code(result):
code = str(result).strip()
if code == "200":
return "service OK"
if code == "000":
return "no connection"
return "service reachable but error"
if code.isdigit():
return code
return None
def listen_for_results(redis_client, ha_task_ids):
ha_log_task_ids = set()
lms_connectivity_task_ids = set()
def add_http_signal(engine, key, result):
code = normalize_http_code(result)
if code is not None:
engine.add(key, code)
def add_proxy_signal(engine, result):
error = str(result.get("error") or "")
if "exit code 6" in error:
engine.add("ha_proxy", "dns_error")
return
add_http_signal(engine, "ha_proxy", result.get("result"))
def action_in_cooldown(last_actions, node, action_type, run_id=None):
key = f"{node}:{action_type}"
now = time.time()
if key in last_actions and now - last_actions[key] < 60:
print("[action] skipped (cooldown)")
emit_event(
{
"type": "action",
"message": "[action] skipped (cooldown)",
"run_id": run_id,
"node": node,
}
)
return True
last_actions[key] = now
return False
def sorted_proposal_options(result):
options = result.get("options", [])
action_options = [
option for option in options
if not option.get("is_ignore")
]
ignore_options = [
option for option in options
if option.get("is_ignore")
]
return sorted(
action_options,
key=lambda option: option.get("confidence", 0),
reverse=True,
) + ignore_options
def emit_proposal_event(run_id, result):
emit_event(
{
"type": "proposal",
"run_id": run_id,
"node": None,
"message": result.get("message"),
"confidence": result.get("confidence"),
"options": sorted_proposal_options(result),
}
)
def retry_limit_reached(command, run_id, auto_config):
action_history = auto_config.setdefault("action_history", {})
history = action_history.setdefault(command, [])
now = time.time()
retry_window = auto_config.get("retry_window_seconds", 300)
cutoff = now - retry_window
history[:] = [timestamp for timestamp in history if timestamp >= cutoff]
retry_limit = auto_config.get("max_retries_per_action", {}).get(command)
if retry_limit is None:
return False
if len(history) >= retry_limit:
emit_event(
{
"type": "auto_action",
"run_id": run_id,
"node": None,
"message": f"[auto] blocked: {command} retry limit reached",
}
)
return True
history.append(now)
return False
def emit_learning(message, run_id=None):
emit_event(
{
"type": "learning",
"message": message,
"run_id": run_id,
"node": None,
}
)
def start_ha_check_run(redis_client, runs, ha_task_ids, learning_action=None):
run_id = str(uuid4())
runs[run_id] = {
"expected": 0,
"received": 0,
"engine": DiagnosisEngine(),
"active": True,
"status": "running",
"learning_action": learning_action,
}
emit_event(
{
"type": "run_status",
"message": "run status: running",
"run_id": run_id,
"node": None,
"status": "running",
}
)
emit_run_progress(run_id, runs[run_id])
task = send_task(redis_client, "piha", "docker_ps", {}, runs, run_id)
ha_task_ids[task["task_id"]] = run_id
return run_id
def schedule_action_outcome_check(redis_client, runs, ha_task_ids, action):
emit_learning(f"[learning] checking outcome for {action}")
def delayed_check():
time.sleep(30)
start_ha_check_run(
redis_client,
runs,
ha_task_ids,
learning_action=action,
)
thread = threading.Thread(target=delayed_check, daemon=True)
thread.start()
def record_action_outcome(run, run_id, action_stats):
action = run.get("learning_action")
if not action:
return
stats = action_stats.setdefault(action, {"success": 0, "failure": 0})
if run["engine"].signals.get("ha_logs_client_connector_error"):
stats["failure"] += 1
emit_learning(f"[learning] {action} failed", run_id)
else:
stats["success"] += 1
emit_learning(f"[learning] {action} success", run_id)
total = stats["success"] + stats["failure"]
success_rate = stats["success"] / total if total else 0
emit_learning(f"[learning] {action} success_rate: {success_rate:.0%}", run_id)
def auto_execute_action(
redis_client,
engine,
option,
run_id,
last_actions,
auto_config,
runs,
ha_task_ids,
):
command = option.get("command")
confidence = option.get("confidence")
if command == "restart_ha":
if action_in_cooldown(last_actions, "piha", "restart_ha", run_id):
return False
if retry_limit_reached(command, run_id, auto_config):
return False
container_name = engine.signals.get("ha_container_name") or "homeassistant"
emit_event(
{
"type": "auto_action",
"run_id": run_id,
"node": "piha",
"message": f"Auto-executed: {command}",
"confidence": confidence,
}
)
send_task(
redis_client,
"piha",
"exec",
{"cmd": f"docker restart {container_name}"},
run_id=run_id,
)
schedule_action_outcome_check(redis_client, runs, ha_task_ids, command)
return True
return False
def maybe_auto_execute_proposal(
redis_client,
run,
run_id,
result,
last_actions,
auto_config,
runs,
ha_task_ids,
):
if run.get("learning_action"):
return
if not auto_config.get("auto_mode"):
return
options = [
option for option in sorted_proposal_options(result)
if not option.get("is_ignore") and isinstance(option.get("confidence"), (int, float))
]
if not options:
return
best = options[0]
allowed_actions = set(auto_config.get("allowed_auto_actions", []))
command = best.get("command")
if command not in allowed_actions:
return
threshold = auto_config.get("action_thresholds", {}).get(
command,
auto_config.get("default_threshold", 0.9),
)
if best.get("confidence", 0) < threshold:
return
auto_execute_action(
redis_client,
run["engine"],
best,
run_id,
last_actions,
auto_config,
runs,
ha_task_ids,
)
def emit_evaluation_event(run_id, result):
if isinstance(result, dict) and result.get("type") == "proposal":
emit_proposal_event(run_id, result)
return
print(result)
emit_event(
{
"type": "diagnosis",
"message": result,
"run_id": run_id,
"node": None,
}
)
def has_error(results):
return any(
isinstance(result, str) and result.startswith("[error]")
for result in results
)
def store_action_context(run_actions, run_id, engine):
run_actions[run_id] = {
"ha_container_name": engine.signals.get("ha_container_name") or "homeassistant",
}
def evaluate_run_if_complete(
redis_client,
runs,
run_id,
last_actions,
run_actions,
auto_config,
ha_task_ids,
action_stats,
):
run = runs.get(run_id)
if not run or run["received"] != run["expected"]:
return
results = run["engine"].evaluate()
for result in results:
emit_evaluation_event(run_id, result)
if isinstance(result, dict) and result.get("type") == "proposal":
maybe_auto_execute_proposal(
redis_client,
run,
run_id,
result,
last_actions,
auto_config,
runs,
ha_task_ids,
)
status = "error" if has_error(results) else "done"
run["active"] = False
run["status"] = status
store_action_context(run_actions, run_id, run["engine"])
emit_event(
{
"type": "run_status",
"message": f"run status: {status}",
"run_id": run_id,
"node": None,
"status": status,
}
)
record_action_outcome(run, run_id, action_stats)
runs.pop(run_id, None)
def tracked_run_id_for_task(task_id, ha_task_ids, ha_log_task_ids, ha_check_tasks):
if task_id in ha_task_ids:
return ha_task_ids[task_id]
if task_id in ha_log_task_ids:
return ha_log_task_ids[task_id]
if task_id in ha_check_tasks:
return ha_check_tasks[task_id][0]
return None
def listen_for_results(redis_client, ha_task_ids, runs, last_actions, run_actions, auto_config):
ha_log_task_ids = {}
ha_check_tasks = {}
ha_check_results = {}
action_stats = {
"restart_ha": {
"success": 0,
"failure": 0,
}
}
while True:
_, raw_result = redis_client.brpop("results")
@ -68,6 +427,14 @@ def listen_for_results(redis_client, ha_task_ids):
result = json.loads(raw_result)
except json.JSONDecodeError:
print(f"\n[result:error] invalid json: {raw_result}")
emit_event(
{
"type": "result",
"message": f"invalid json: {raw_result}",
"run_id": None,
"node": None,
}
)
continue
print("\n--- result ---")
@ -79,24 +446,63 @@ def listen_for_results(redis_client, ha_task_ids):
print("--------------")
task_id = result.get("task_id")
run_id = result.get("run_id") or tracked_run_id_for_task(
task_id,
ha_task_ids,
ha_log_task_ids,
ha_check_tasks,
)
emit_event(
{
"type": "result",
"message": f"result received: {task_id} status={result.get('status')}",
"run_id": run_id,
"node": result.get("node"),
}
)
run = runs.get(run_id) if run_id else None
if not run:
continue
if task_id in lms_connectivity_task_ids:
lms_connectivity_task_ids.remove(task_id)
diagnosis = interpret_lms_connectivity(result.get("result"))
print(f"[diagnosis] LMS connectivity: {diagnosis}")
run["received"] += 1
emit_run_progress(run_id, run)
if task_id in ha_check_tasks:
tracked_run_id, label = ha_check_tasks.pop(task_id)
if tracked_run_id != run_id:
continue
checks = ha_check_results.setdefault(run_id, {})
checks[label] = normalize_http_code(result.get("result"))
if label == "ha_local_check":
add_http_signal(run["engine"], "ha_local", result.get("result"))
add_http_signal(run["engine"], "lms", result.get("result"))
elif label == "ha_proxy_check":
add_proxy_signal(run["engine"], result)
if "ha_local_check" in checks and "ha_proxy_check" in checks:
ha_check_results.pop(run_id, None)
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
if task_id in ha_log_task_ids:
ha_log_task_ids.remove(task_id)
if result.get("status") != "ok":
tracked_run_id = ha_log_task_ids.pop(task_id)
if tracked_run_id != run_id:
continue
if result.get("status") != "ok":
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
if "ClientConnectorError" in str(result.get("result")):
run["engine"].add("ha_logs_client_connector_error", True)
url = extract_first_http_url(result.get("result"))
if not url:
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
host, port = parse_host_port(url)
if not host or not port:
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
check_task = send_task(
@ -104,24 +510,58 @@ def listen_for_results(redis_client, ha_task_ids):
"piha",
"exec",
{"cmd": f"curl -s -o /dev/null -w '%{{http_code}}' {url}"},
runs,
run_id,
)
lms_connectivity_task_ids.add(check_task["task_id"])
ha_check_tasks[check_task["task_id"]] = (run_id, "ha_local_check")
proxy_task = send_task(
redis_client,
"vps",
"exec",
{"cmd": f"curl -s -o /dev/null -w '%{{http_code}}' {url}"},
runs,
run_id,
)
ha_check_tasks[proxy_task["task_id"]] = (run_id, "ha_proxy_check")
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
if task_id in ha_task_ids:
ha_task_ids.remove(task_id)
tracked_run_id = ha_task_ids.pop(task_id)
if tracked_run_id != run_id:
continue
if result.get("status") != "ok":
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
container_name = detect_homeassistant(result.get("result"))
if not container_name:
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
print(f"[orchestrator] detected HA container: {container_name}")
emit_event(
{
"type": "log",
"message": f"detected HA container: {container_name}",
"run_id": run_id,
"node": result.get("node"),
}
)
run["engine"].add("ha_container", "running")
run["engine"].add("ha_container_name", container_name)
logs_task = send_task(
redis_client,
"piha",
"docker_logs",
{"container": container_name},
runs,
run_id,
)
ha_log_task_ids.add(logs_task["task_id"])
ha_log_task_ids[logs_task["task_id"]] = run_id
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)
continue
evaluate_run_if_complete(redis_client, runs, run_id, last_actions, run_actions, auto_config, ha_task_ids, action_stats)

7
webui/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
COPY web.py index.html ./
EXPOSE 8080
CMD ["python", "web.py"]

560
webui/index.html Normal file
View file

@ -0,0 +1,560 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent System</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #101418;
color: #e7edf3;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
h1 {
margin: 0 0 16px;
font-size: 24px;
font-weight: 650;
}
#logs {
height: 70vh;
overflow-y: auto;
border: 1px solid #2a3540;
background: #0b0f13;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
}
#services {
border: 1px solid #2a3540;
background: #0b0f13;
padding: 12px;
margin-bottom: 12px;
font-size: 13px;
}
.panel-title {
font-weight: 700;
margin-bottom: 10px;
}
.service {
border-top: 1px solid #202a33;
padding: 10px 0;
}
.service:first-of-type {
border-top: 0;
padding-top: 0;
}
.service-header {
display: flex;
gap: 8px;
align-items: baseline;
justify-content: space-between;
margin-bottom: 6px;
}
.service-name {
font-weight: 700;
}
.service-history {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.history-entry {
border: 1px solid #2a3540;
padding: 3px 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.run {
border: 1px solid #333;
margin: 10px;
padding: 10px;
}
.run.stopped {
opacity: 0.45;
}
.header {
display: flex;
align-items: center;
gap: 8px;
color: #ffffff;
font-weight: 700;
margin-bottom: 8px;
}
.run-title {
flex: 1;
min-width: 0;
}
.proposal {
border: 1px solid #4c5b2b;
background: #171d12;
color: #edf5d4;
margin: 8px 0;
padding: 10px;
}
.proposal-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.proposal-divider {
border-top: 1px solid #3c4930;
margin: 10px 0;
}
.row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
margin-top: 12px;
}
.config-panel {
border: 1px solid #2a3540;
background: #111820;
margin-top: 12px;
padding: 12px;
}
.config-title {
font-weight: 700;
margin-bottom: 10px;
}
.config-grid {
display: grid;
grid-template-columns: auto minmax(96px, 140px) auto;
align-items: center;
gap: 8px;
}
.toggle {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid #354453;
background: #151c23;
color: #e7edf3;
padding: 10px 12px;
white-space: nowrap;
}
.toggle input {
padding: 0;
}
input, button {
font: inherit;
border: 1px solid #354453;
background: #151c23;
color: #e7edf3;
padding: 10px 12px;
}
button {
cursor: pointer;
min-width: 88px;
}
button:hover {
background: #1d2730;
}
.error { color: #ff6b6b; }
.warning { color: #f4a261; }
.info { color: #5dade2; }
.action { color: #58d68d; }
.auto_action { color: #a7f3d0; }
.learning { color: #c4b5fd; }
.log, .result { color: #ccd6df; }
.status.running { color: yellow; }
.status.done { color: green; }
.status.error { color: red; }
.status.stopped { color: gray; }
.health-ok { color: #58d68d; }
.health-warning { color: #f4d03f; }
.health-error { color: #ff6b6b; }
.health-unknown { color: #95a5a6; }
</style>
</head>
<body>
<main>
<h1>Agent System</h1>
<section id="services">
<div class="panel-title">Services</div>
<div id="services-list"></div>
</section>
<div id="logs"></div>
<div class="row">
<input id="cmd" autocomplete="off" placeholder="Command">
<button onclick="send()">Send</button>
<button id="stop">STOP</button>
</div>
<section class="config-panel">
<div class="config-title">Auto Mode Config</div>
<div class="config-grid">
<label class="toggle">
<input id="config-auto-mode" type="checkbox">
auto_mode
</label>
<span></span>
<button id="save-config">Save</button>
<label for="restart-threshold">restart_ha threshold</label>
<input id="restart-threshold" type="number" min="0" max="1" step="0.01">
<span></span>
<label for="network-threshold">check_network threshold</label>
<input id="network-threshold" type="number" min="0" max="1" step="0.01">
<span></span>
</div>
</section>
</main>
<script>
const logs = document.getElementById("logs");
const servicesList = document.getElementById("services-list");
let polling = true;
let currentConfig = {
auto_mode: true,
action_thresholds: {
restart_ha: 0.8,
check_network: 0.9
},
default_threshold: 0.9,
allowed_auto_actions: ["restart_ha"]
};
function eventClass(event) {
const message = event.message || "";
if (event.type === "action") return "action";
if (message.startsWith("[error]")) return "error";
if (message.startsWith("[warning]")) return "warning";
if (message.startsWith("[info]")) return "info";
return event.type || "log";
}
function confidenceLabel(confidence) {
if (typeof confidence !== "number") return "";
return `${Math.round(confidence * 100)}%`;
}
function healthClass(status) {
const normalized = String(status || "unknown").toLowerCase();
if (["ok", "healthy", "up"].includes(normalized)) return "health-ok";
if (["warning", "degraded"].includes(normalized)) return "health-warning";
if (["error", "down", "failed"].includes(normalized)) return "health-error";
return "health-unknown";
}
function formatTime(timestamp) {
if (!timestamp) return "unknown";
return new Date(timestamp * 1000).toLocaleString();
}
async function fetchServices() {
const response = await fetch("/services", {cache: "no-store"});
const services = await response.json();
servicesList.innerHTML = "";
if (!Array.isArray(services) || services.length === 0) {
servicesList.textContent = "No service health data yet.";
return;
}
services.forEach((service) => {
const item = document.createElement("div");
item.className = "service";
const header = document.createElement("div");
header.className = "service-header";
const name = document.createElement("span");
name.className = "service-name";
name.textContent = service.name || "unknown";
header.appendChild(name);
const status = document.createElement("span");
status.className = healthClass(service.status);
status.textContent = String(service.status || "unknown").toUpperCase();
header.appendChild(status);
item.appendChild(header);
const lastCheck = document.createElement("div");
lastCheck.textContent = `Last check: ${formatTime(service.last_check)}`;
item.appendChild(lastCheck);
const history = document.createElement("div");
history.className = "service-history";
(service.history || []).slice(-5).forEach((entry) => {
const node = document.createElement("span");
node.className = `history-entry ${healthClass(entry.status)}`;
node.textContent = `${entry.status || "unknown"} ${formatTime(entry.timestamp)}`;
history.appendChild(node);
});
item.appendChild(history);
servicesList.appendChild(item);
});
}
async function fetchLogs() {
if (!polling) return;
const response = await fetch("/logs", {cache: "no-store"});
const text = await response.text();
logs.innerHTML = "";
const runs = {};
text.trim().split("\n").filter(Boolean).forEach((line) => {
let event;
try {
event = JSON.parse(line);
} catch {
event = {
type: "log",
message: line,
run_id: "legacy",
timestamp: Date.now() / 1000
};
}
const runId = event.run_id || "no-run";
if (!runs[runId]) {
runs[runId] = {
events: [],
status: "running",
received: 0,
expected: 0
};
}
const run = runs[runId];
run.events.push(event);
if (event.type === "run_status") {
run.status = event.status || run.status;
} else if (event.type === "run_progress") {
run.received = Number(event.received) || 0;
run.expected = Number(event.expected) || 0;
}
});
Object.entries(runs).forEach(([runId, runState]) => {
const run = document.createElement("div");
run.className = "run";
const status = runState.status;
if (status === "stopped") {
run.classList.add("stopped");
}
const header = document.createElement("div");
header.className = "header";
const title = document.createElement("div");
title.className = "run-title";
title.appendChild(document.createTextNode(`RUN ${runId} [`));
const statusNode = document.createElement("span");
statusNode.className = `status ${status}`;
statusNode.textContent = status.toUpperCase();
title.appendChild(statusNode);
title.appendChild(
document.createTextNode(`] (${runState.received}/${runState.expected})`)
);
header.appendChild(title);
if (runId !== "no-run" && status === "running") {
const stopButton = document.createElement("button");
stopButton.textContent = "STOP";
stopButton.setAttribute("onclick", `stop(${JSON.stringify(runId)})`);
header.appendChild(stopButton);
}
run.appendChild(header);
const eventList = document.createElement("div");
eventList.className = "events";
runState.events.forEach((event) => {
if (event.type === "proposal") {
const proposal = document.createElement("div");
proposal.className = "proposal";
const message = document.createElement("div");
message.textContent = event.message || "";
proposal.appendChild(message);
const confidence = document.createElement("div");
confidence.textContent = `Confidence: ${confidenceLabel(event.confidence)}`;
proposal.appendChild(confidence);
const actions = document.createElement("div");
actions.className = "proposal-actions";
const allOptions = event.options || event.actions || [];
const options = allOptions.filter((option) => !option.is_ignore).sort(
(a, b) => (b.confidence || 0) - (a.confidence || 0)
);
const ignoreOptions = allOptions.filter((option) => option.is_ignore);
options.forEach((option) => {
const button = document.createElement("button");
const optionConfidence = confidenceLabel(option.confidence);
button.textContent = optionConfidence
? `${option.label || option.command} (${optionConfidence})`
: option.label || option.command;
button.setAttribute(
"onclick",
`apply(${JSON.stringify(runId)}, ${JSON.stringify(option.command)})`
);
actions.appendChild(button);
});
proposal.appendChild(actions);
if (ignoreOptions.length) {
const divider = document.createElement("div");
divider.className = "proposal-divider";
proposal.appendChild(divider);
const ignoreActions = document.createElement("div");
ignoreActions.className = "proposal-actions";
ignoreOptions.forEach((option) => {
const button = document.createElement("button");
button.textContent = option.label || option.command;
button.setAttribute(
"onclick",
`apply(${JSON.stringify(runId)}, ${JSON.stringify(option.command)})`
);
ignoreActions.appendChild(button);
});
proposal.appendChild(ignoreActions);
}
eventList.appendChild(proposal);
return;
}
const div = document.createElement("div");
div.className = eventClass(event);
div.textContent = `${new Date(event.timestamp * 1000).toLocaleTimeString()} ${event.message}`;
eventList.appendChild(div);
});
run.appendChild(eventList);
logs.appendChild(run);
});
logs.scrollTop = logs.scrollHeight;
}
async function send() {
const cmd = document.getElementById("cmd");
const value = cmd.value.trim();
if (!value) return;
await fetch("/command", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({command: value})
});
cmd.value = "";
}
async function stop(run_id) {
await fetch("/stop", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({run_id})
});
}
async function apply(run_id, command) {
await fetch("/action", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({run_id, command})
});
}
function populateConfig(config) {
currentConfig = config;
document.getElementById("config-auto-mode").checked = Boolean(config.auto_mode);
document.getElementById("restart-threshold").value =
config.action_thresholds?.restart_ha ?? "";
document.getElementById("network-threshold").value =
config.action_thresholds?.check_network ?? "";
}
async function loadConfig() {
const response = await fetch("/config", {cache: "no-store"});
populateConfig(await response.json());
}
async function saveConfig() {
const restartThreshold = Number(document.getElementById("restart-threshold").value);
const networkThreshold = Number(document.getElementById("network-threshold").value);
if (!Number.isFinite(restartThreshold) || !Number.isFinite(networkThreshold)) {
return;
}
const config = {
auto_mode: document.getElementById("config-auto-mode").checked,
action_thresholds: {
...(currentConfig.action_thresholds || {}),
restart_ha: restartThreshold,
check_network: networkThreshold
},
default_threshold: currentConfig.default_threshold ?? 0.9,
allowed_auto_actions: currentConfig.allowed_auto_actions || ["restart_ha"]
};
await fetch("/config", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(config)
});
populateConfig(config);
}
document.getElementById("stop").addEventListener("click", async () => {
await fetch("/command", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({command: "stop"})
});
});
document.getElementById("save-config").addEventListener("click", saveConfig);
document.getElementById("cmd").addEventListener("keydown", (event) => {
if (event.key === "Enter") send();
});
loadConfig();
fetchServices();
fetchLogs();
setInterval(fetchLogs, 1000);
setInterval(fetchServices, 10000);
</script>
</body>
</html>

248
webui/web.py Normal file
View file

@ -0,0 +1,248 @@
import json
import os
import socket
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
EVENT_LOG = Path("/tmp/agent-events.log")
STATIC_DIR = Path(__file__).parent
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
DEFAULT_CONFIG = {
"auto_mode": True,
"action_thresholds": {
"restart_ha": 0.8,
"check_network": 0.9,
},
"default_threshold": 0.9,
"allowed_auto_actions": ["restart_ha"],
}
def tail_lines(path, limit):
if not path.exists():
path.touch()
with path.open("r", encoding="utf-8", errors="replace") as handle:
lines = handle.readlines()
print(f"Read {len(lines)} lines", flush=True)
return [line.rstrip("\n") for line in lines[-limit:]]
def redis_command(*parts):
payload = f"*{len(parts)}\r\n".encode("utf-8")
for part in parts:
data = str(part).encode("utf-8")
payload += f"${len(data)}\r\n".encode("utf-8") + data + b"\r\n"
with socket.create_connection((REDIS_HOST, REDIS_PORT), timeout=3) as client:
client.sendall(payload)
return client.recv(4096)
def send_command(command):
task = {
"target": "orchestrator",
"action": "input",
"params": {
"command": command,
},
}
redis_command("LPUSH", "tasks", json.dumps(task))
def send_stop_run(run_id):
task = {
"target": "orchestrator",
"action": "stop_run",
"params": {
"run_id": run_id,
},
}
redis_command("LPUSH", "tasks", json.dumps(task))
def send_run_action(run_id, command):
task = {
"target": "orchestrator",
"action": "run_action",
"params": {
"run_id": run_id,
"command": command,
},
}
redis_command("LPUSH", "tasks", json.dumps(task))
def send_auto_mode(auto_mode):
task = {
"target": "orchestrator",
"action": "set_auto_mode",
"params": {
"auto_mode": auto_mode,
},
}
redis_command("LPUSH", "tasks", json.dumps(task))
def send_auto_config(config):
task = {
"target": "orchestrator",
"action": "set_auto_config",
"params": {
"config": config,
},
}
redis_command("LPUSH", "tasks", json.dumps(task))
def send_event(event):
redis_command("LPUSH", "events", json.dumps(event))
def current_config():
config = json.loads(json.dumps(DEFAULT_CONFIG))
for line in tail_lines(EVENT_LOG, 500):
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") != "auto_config":
continue
for key in config:
if key in event:
config[key] = event[key]
return config
def current_services():
services = []
for line in tail_lines(EVENT_LOG, 1000):
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "services_state":
services = event.get("services", [])
return services
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, current_config(), self)
return
if self.path == "/services":
send_json(200, current_services(), self)
return
if self.path == "/logs":
print("LOGS endpoint called", flush=True)
body = ("\n".join(tail_lines(EVENT_LOG, 200)) + "\n").encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
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 (
"/command",
"/stop",
"/action",
"/auto-mode",
"/config",
"/events",
):
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)
command = str(payload.get("command", "")).strip()
except json.JSONDecodeError:
payload = {}
command = raw_body.strip()
if self.path == "/events":
if not isinstance(payload, dict):
self.send_error(400, "event object is required")
return
send_event(payload)
send_json(200, {"status": "sent"}, self)
return
if self.path == "/stop":
run_id = str(payload.get("run_id", "")).strip()
if not run_id:
self.send_error(400, "run_id is required")
return
send_stop_run(run_id)
send_json(200, {"status": "sent"}, self)
return
if self.path == "/action":
run_id = str(payload.get("run_id", "")).strip()
action_command = str(payload.get("command", "")).strip()
if not run_id:
self.send_error(400, "run_id is required")
return
if not action_command:
self.send_error(400, "command is required")
return
send_run_action(run_id, action_command)
send_json(200, {"status": "sent"}, self)
return
if self.path == "/auto-mode":
send_auto_mode(bool(payload.get("auto_mode")))
send_json(200, {"status": "sent"}, self)
return
if self.path == "/config":
send_auto_config(payload)
send_json(200, {"status": "sent"}, self)
return
if not command:
self.send_error(400, "command is required")
return
send_command(command)
send_json(200, {"status": "sent"}, self)
def log_message(self, format, *args):
return
if __name__ == "__main__":
server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
server.serve_forever()