565 changed files with 29006 additions and 39263 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"mcpServers": {
"mc2-memory": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
]
},
"mc2-system": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
]
},
"mc2-web": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
]
}
}
}
+27
View File
@@ -0,0 +1,27 @@
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/
frontend/node_modules/
frontend/dist/
**/__pycache__/
*.pyc
.git/
# Große / binäre / sensible Dateien
*.vrm
*.gguf
*.onnx
*.safetensors
*.wav
*.env
.env
credentials*
*.key
*.pem
# Lokale Scratch-/Recon-Skripte
box_recon*
gemma_swap*
+37 -55
View File
@@ -1,55 +1,37 @@
{ {
"version": "0.0.1", "version": "0.0.1",
"configurations": [ "configurations": [
{ {
"name": "mc2", "name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe", "runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [ "runtimeArgs": [
"-m", "-m",
"uvicorn", "uvicorn",
"app:app", "app:app",
"--app-dir", "--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend", "F:\\Coding Stuff\\mission-control-2\\backend",
"--port", "--port",
"9000" "9000"
], ],
"port": 9000 "port": 9000
}, },
{ {
"name": "frontend", "name": "frontend",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": [ "runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"run", "cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"dev", "env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"--", "autoPort": false,
"--port", "port": 5180
"5180", },
"--strictPort" {
], "name": "frontend-mock",
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend", "runtimeExecutable": "npm",
"env": { "runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
"MC_API_TARGET": "http://192.168.178.151:9001" "cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
}, "env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
"autoPort": false, "autoPort": false,
"port": 5180 "port": 5181
}, }
{ ]
"name": "frontend-mock", }
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5181",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": {
"MC_API_TARGET": "http://127.0.0.1:9000"
},
"autoPort": false,
"port": 5181
}
]
}
+100
View File
@@ -0,0 +1,100 @@
{
"permissions": {
"allow": [
"autoMode": {
"allow": [
"$defaults",
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
]
}
"Bash(git fetch *)",
"Bash(git push:*)",
"Bash(git rev-list *)",
"Bash(ssh hitonabi@192.168.178.151:*)",
"Bash(grep -E \"\\\\.py$|^d\")",
"Bash(grep -rn \"http://\\\\|https://\\\\|/srv/\\\\|/opt/\\\\|/etc/\" services/*.py routers/*.py)",
"Bash(awk '/^\\(async \\)?def /{in_func=1; func=$0; line=NR} in_func {count++} /^\\(async \\)?def / && NR!=line {if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"; count=0; func=$0; line=NR} END{if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"}' routers/*.py services/*.py)",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(\"\\(/api|/v1\\)[^\"`]*' frontend/src/)",
"Bash(sed -E 's/api\\(<[^>]+>\\)?\\\\\\(\"//')",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(`[^`]*`' frontend/src/)",
"Bash(python -c ' *)",
"Bash(echo \"tsc exit: $?\")",
"WebSearch",
"WebFetch(domain:lobehub.com)",
"WebFetch(domain:docs.litellm.ai)",
"WebFetch(domain:github.com)",
"WebFetch(domain:runaihome.com)",
"WebFetch(domain:docs.getbifrost.ai)",
"WebFetch(domain:thefrontierlab.ai)",
"WebFetch(domain:www.glukhov.org)",
"WebFetch(domain:cognio.so)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 'curl -s http://127.0.0.1:8080/running; echo; echo \"--- after a quick hermes ping, whats running ---\"; curl -s http://127.0.0.1:8080/running')",
"Bash(git checkout *)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 \"sed -n '46,75p' /etc/llama-swap/config.yaml\")",
"Bash(ssh hitonabi@192.168.178.151 \"node --version 2>/dev/null || echo 'no-node'; docker --version 2>/dev/null || echo 'no-docker'; python3 --version; systemctl --user list-units --type=service --state=running 2>/dev/null | head -10\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user cat hermes-gateway.service 2>/dev/null; echo '---'; ls ~/hermes-gateway/ 2>/dev/null || ls ~/mission-control-v2/deploy/ | grep gateway\")",
"Bash(ssh hitonabi@192.168.178.151 \"ls ~/.hermes/ && echo '---' && ls ~/.hermes/hermes-agent/ 2>/dev/null && echo '---' && cat ~/.hermes/config/config.yaml 2>/dev/null || cat ~/.hermes/config.yaml 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"journalctl --user -u hermes-gateway.service --no-pager -n 20\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/config.yaml | grep -A5 'api_server\\\\|api_key\\\\|gateway_api\\\\|secret' | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'gateway_api_key\\\\|api_server\\\\|HERMES_API_KEY\\\\|8642' ~/.hermes/config.yaml ~/.config/environment.d/ 2>/dev/null | head -20; echo '---'; cat ~/.config/environment.d/*.conf 2>/dev/null | grep -i hermes | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'hermes.*gateway\\\\|gateway.*url\\\\|8642\\\\|GATEWAY' ~/mission-control-v2/backend/ 2>/dev/null | grep -v '.pyc' | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -A20 'def get_agent_status\\\\|def check\\\\|HERMES_API' ~/mission-control-v2/backend/services/agent.py | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json 2>/dev/null | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(d.get\\(\\\\\"api_key\\\\\"\\) or d.get\\(\\\\\"key\\\\\"\\) or \\\\\"no key field\\\\\"\\)' 2>/dev/null; echo '---'; ls ~/.hermes/auth* ~/.hermes/pairing/ 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway status 2>&1 | head -20; echo '---'; curl -s http://127.0.0.1:8642/health 2>/dev/null || curl -s http://127.0.0.1:8642/ 2>/dev/null | head -5\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\(\\\\\"platforms\\\\\", {}\\), indent=2\\)\\)'\")",
"Bash(ssh hitonabi@192.168.178.151 \"bash ~/mission-control-v2/deploy/deploy.sh\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway connect --help 2>&1 | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway enroll --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway setup --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main --help 2>&1 | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/.env 2>/dev/null; echo '---'; ~/ .hermes/hermes-agent/venv/bin/python -m hermes_cli.main config --help 2>&1 | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_BOT_TOKEN=|TELEGRAM_BOT_TOKEN=8908266336:AAElPDmdEJXZ5cs0gUzbAnm4a9glRY18Zac|' ~/.hermes/.env && grep TELEGRAM_BOT_TOKEN ~/.hermes/.env\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user restart hermes-gateway && sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 15\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 4 && journalctl --user -u hermes-gateway --no-pager -n 20 --since '1 minute ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 25 --since '2 minutes ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_ALLOWED_USERS=.*|TELEGRAM_ALLOWED_USERS=6150562984|' ~/.hermes/.env && grep TELEGRAM_ALLOWED ~/.hermes/.env\")",
"Bash(mkdir -p \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-stack-state.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-hermes-setup.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-mc2-architecture.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-pending-tasks.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== ENGINE VERSION ===\"; /usr/local/bin/llama-server --version 2>&1 | head -3; echo \"=== LLAMA-SWAP VERSION ===\"; \\(llama-swap --version 2>&1 || /usr/local/bin/llama-swap --version 2>&1 || echo \"no --version\"\\) | head -3; echo \"=== RUNNING MODELS ===\"; curl -s http://127.0.0.1:8080/running 2>&1 | head -c 2000; echo; echo \"=== FREE MEM ===\"; free -g | head -3')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG ===\"; cat ~/.hermes/config.yaml 2>&1 | head -120; echo \"=== HERMES DIR ===\"; ls -la ~/.hermes/ 2>&1 | head -40')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG \\(rest\\) ===\"; sed -n \"120,400p\" ~/.hermes/config.yaml 2>&1; echo \"=== TOOLS COUNT \\(api/all\\) ===\"; cd ~/.hermes 2>/dev/null; \\(hermes tools list 2>&1 | tail -40\\) || echo \"hermes CLI not on PATH\"')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 ' *)",
"Bash(xargs cat)",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== llama-server version ===\"; /usr/local/bin/llama-server --version 2>&1 | head -5; echo \"=== running models ===\"; curl -s http://127.0.0.1:8080/running 2>/dev/null | head -c 2000; echo; echo \"=== free ===\"; free -g')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== config.yaml ===\"; cat /etc/llama-swap/config.yaml; echo; echo \"=== models on disk ===\"; du -sh /srv/models/* 2>/dev/null | sort -h')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== Qwen3.6 MTP dir ===\"; ls -la /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/ 2>/dev/null; echo \"=== drafts dir ===\"; ls -la /srv/models/drafts/ 2>/dev/null; echo \"=== disk free ===\"; df -h /srv 2>/dev/null; echo \"=== gemma remnant ===\"; ls -la /srv/models/gemma-4-26B-A4B-it-GGUF/ 2>/dev/null')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"Bash(ssh -o ConnectTimeout=12 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"WebFetch(domain:unsloth.ai)",
"WebFetch(domain:huggingface.co)",
"Bash(xargs ls -la)",
"Bash(xargs wc -l)",
"PowerShell(ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== engine ==='; /opt/llamacpp-vulkan/llama-server --version 2>&1 | head -3; echo; echo '=== llama-swap version ==='; /opt/llama-swap/llama-swap --version 2>/dev/null || llama-swap --version 2>/dev/null || ls -la /opt/llama-swap 2>/dev/null | head -5; echo; echo '=== running models ==='; curl -s http://127.0.0.1:8080/running; echo; echo '=== services ==='; for s in llama-swap mc2-backend hermes-api hermes-webui mem0-service voice-service; do printf '%s: ' $s; systemctl is-active $s 2>/dev/null; done; echo '=== uname/mem ==='; uname -r; free -g | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== ports ==='; ss -tlnp 2>/dev/null | grep -E ':\\(9001|8642|8650|8765|8787|7681|8080|8130\\)'; echo; echo '=== wer serviert 9001? ==='; systemctl list-units --all --type=service --no-pager --no-legend | grep -iE 'gateway|backend|control|api'; echo; echo '=== mem0 venv ==='; systemctl cat mem0-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'; echo; echo '=== voice venv ==='; systemctl cat voice-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; cat /proc/11257/cmdline 2>/dev/null | tr '\\\\0' ' '; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo; echo '=== mc2 backend unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend'; echo; echo '=== mem0 pip ==='; MEMPY=/proc/7277/exe; readlink /proc/7277/cwd; readlink /proc/7277/exe; V=\\(dirname \\(readlink /proc/7277/exe\\)\\); echo; /srv/mc2/mem0-venv/bin/pip show mem0ai 2>/dev/null | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; tr '\\\\0' ' ' < /proc/11257/cmdline; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo '=== mc2 unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend|llama|hermes|mem0|voice'; echo '=== mem0 exe ==='; readlink /proc/7277/exe; readlink /proc/7277/cwd\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== mission-control.service ==='; systemctl is-enabled mission-control 2>&1; systemctl is-active mission-control 2>&1; grep -E 'ExecStart|WorkingDirectory' /etc/systemd/system/mission-control.service; echo; echo '=== mem0ai version ==='; ls /home/hitonabi/mission-control-v2/mem0_service/ | head; for p in /home/hitonabi/mission-control-v2/mem0_service/.venv/bin/pip /home/hitonabi/mission-control-v2/mem0_service/venv/bin/pip; do [ -x \\\\\"\\\\$p\\\\\" ] && \\\\\"\\\\$p\\\\\" show mem0ai chromadb 2>/dev/null | grep -E 'Name|Version'; done\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"tr '\\\\0' '\\\\n' < /proc/7277/environ | grep -E 'VIRTUAL_ENV|PATH=' | head -3; tr '\\\\0' ' ' < /proc/7277/cmdline; echo; /home/hitonabi/.local/share/uv/python/cpython-3.12.13-linux-x86_64-gnu/bin/python3.12 -c 'import mem0; print\\(mem0.__version__\\)' 2>&1 | tail -1\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/pip show mem0ai 2>/dev/null | grep -E 'Name|Version'; /home/hitonabi/.mem0/venv/bin/pip show chromadb 2>/dev/null | grep Version\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"ls /home/hitonabi/.mem0/venv/bin/ | head -8; /home/hitonabi/.mem0/venv/bin/python -m pip show mem0ai chromadb 2>&1 | grep -E 'Name:|Version:'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/python -c 'import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)' 2>&1\")",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 '/home/hitonabi/.mem0/venv/bin/python -c \"import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)\"')",
"WebFetch(domain:docs.mem0.ai)",
"PowerShell(Write-Output '=== Lucy-Prozesse lokal ==='; Get-Process | Where-Object {$_.ProcessName -match 'electron|python|node'} | Select-Object ProcessName, Id, WorkingSet64 | Format-Table; Write-Output '=== Port 8130 \\(Pocket-TTS\\) ==='; Get-NetTCPConnection -LocalPort 8130 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, OwningProcess; Write-Output '=== GPU ==='; Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion | Format-Table)",
"PowerShell($p = 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts\\\\ptts-venv\\\\Scripts\\\\python.exe'; if \\(Test-Path $p\\) { & $p -c \"import importlib.metadata as m; [print\\(n, m.version\\(n\\)\\) for n in ['pocket-tts','torch','onnxruntime','fastapi','numpy'] if True]\" 2>&1 } else { Write-Output 'ptts-venv nicht gefunden'; Get-ChildItem 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts' -Directory | Select-Object Name })",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== /v1/models ===\"; curl -s http://127.0.0.1:9001/v1/models | head -c 600; echo; echo \"=== voice metrics ===\"; curl -s http://127.0.0.1:9001/api/voice/metrics | head -c 1200')",
"Bash(ssh -o ConnectTimeout=15 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -o /dev/null -w \"TTFB_chat_lane: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=30 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 500')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== routing policy ===\"; curl -s http://127.0.0.1:9001/api/routing | head -c 800; echo; echo \"=== aliases in llama-swap config ===\"; grep -B1 -A3 \"aliases:\" /etc/llama-swap/config.yaml | head -40; echo \"=== direkt hermes ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 400; echo; echo \"=== direkt an llama-swap :8080 ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:8080/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"WebFetch(domain:www.hermes-ai.net)",
"WebFetch(domain:releases.electronjs.org)",
"WebFetch(domain:dev.to)",
"WebFetch(domain:kyuz0.github.io)",
"WebFetch(domain:kmarble.dev)"
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
]
}
}
+76
View File
@@ -0,0 +1,76 @@
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
#
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
#
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
name: Ampel
on: [push, pull_request]
jobs:
ampel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
shell: bash
run: |
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
set -u +e
rot=0
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
ruff check . || rot=1
echo "== Python: Import/Syntax (compileall) =="
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
if [ -f frontend/package.json ]; then
if [ ! -f frontend/package-lock.json ]; then
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
rot=1
else
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
if [ $rot -eq 0 ]; then
echo "== Frontend: Push nach release-dist =="
git config --global user.name "Gitea Actions"
git config --global user.email "actions@gitea.local"
CURRENT_COMMIT=$(git rev-parse HEAD)
# Erzeuge (oder hole) den orphan branch 'release-dist'
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
git rm -rf . >/dev/null 2>&1 || true
# Checkout nur frontend/dist vom aktuellen Stand
git add -f frontend/dist
if ! git diff-index --quiet HEAD; then
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
git push origin HEAD:release-dist -f
else
echo "Keine Änderungen am Frontend-Build."
fi
# Zurück zum originalen Branch für den Rest des Skripts
git checkout $CURRENT_COMMIT
fi
fi
fi
if [ $rot -ne 0 ]; then
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
else
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
fi
exit $rot
+24 -34
View File
@@ -1,34 +1,24 @@
# Python # Python
backend/.venv/ backend/.venv/
__pycache__/ __pycache__/
*.pyc *.pyc
# Node / Vite # Node / Vite
frontend/node_modules/ frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/ # frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Env / local # Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
*.env # Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
.DS_Store frontend/public/avatar.vrm
frontend/dist/avatar.vrm
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon* # Env / local
gemma_swap* *.env
.DS_Store
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo # Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
# Lokale Claude-Code-Einstellungen (enthielten bis 24.09.2026 ein Token) und Worktree-Ordner gemma_swap*
.claude/settings.local.json
.claude/worktrees/ # TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
# Caches und lokale Umgebungen, egal in welchem Ordner
.ruff_cache/
.pytest_cache/
.venv/
node_modules/
# Sicherungskopien von Hand
*.bak
*.bak-*
*.orig
+60 -74
View File
@@ -1,74 +1,60 @@
# AGENTS.md — Homelab Orchestrator (vormals Mission Control 2.0) # AGENTS.md — Mission Control 2.0
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei). Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`. Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
## Was das ist ## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten: Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) · Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind. **Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):** **Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
- **System:** Bosgame M5 (AMD Strix Halo APU) - **System:** Bosgame M5 (AMD Strix Halo APU)
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar). - **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs. - **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht. **Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy"). Mem0 läuft als dedizierter semantischer Langzeit-Gedächtnis-Sidecar (`mem0-service` auf `:8765`) und wird von Hermes via MCP (`mcp_memory.py`) eingebunden. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
## Sprache (nicht verhandelbar) ## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen, - **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare. Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht). - **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts. Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle) ## Build & Deploy (die häufigste Falle)
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die - **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand. **das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
- **Deploy** (`bash ~/mission-control-v2/deploy/deploy.sh` auf der Box) holt `origin/main` per (Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
fast-forward — **`main` muss vorher auf Gitea liegen**, im Box-Checkout nie von Hand ändern. - **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
Zweistufig seit 24.09.2026: Stufe 1 holt den Stand und startet die NEUE Fassung des Skripts, dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
Stufe 2 prüft (pruefen.sh), spielt Units/Cron-Skripte/Skills aus, startet neu und prüft nach. - **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
Scheitert etwas: automatisch zurück auf den alten Stand + dringende Meldung. Laufende Update-Jobs
verschieben den Deploy. Die llama-swap-Config wird nur überschrieben, wenn sich der Repo-Abzug im ## Agentic IDE & Vibe Coding
selben Deploy geändert hat (sonst gewinnt die lebende Datei, die MC2 selbst schreibt). - **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy. - Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
## Agentic IDE & Vibe Coding - **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in **OpenChamber** (mit eigener OpenCode-CLI).
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr. ## Zeit & Umgebung
- Der Agent in OpenChamber nutzt die Modelle der Box über `http://192.168.178.151:9001/v1` (Provider `aibox`, nur Rollen-Aliase; Vorlage der PC-Config: `deploy/opencode-config/`). - **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
- **Prüftor:** `bash deploy/pruefen.sh` (Shell-/Python-Syntax, ruff, Importe, pytest) muss vor jedem Push grün sein. Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
Der Deploy auf der Box führt es vor dem Umschalten noch einmal aus; rot = automatisch zurück auf den alten Stand. (siehe `backend/services/reminders.py`).
## Zeit & Umgebung ## Backend-Konventionen
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows. - Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen (Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
(siehe `backend/services/reminders.py`). `services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
keine zweite Allowlist in einem Router duplizieren.
## Backend-Konventionen - **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/` - Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`); - Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
keine zweite Allowlist in einem Router duplizieren.
- **Gate vor Commit:** `bash deploy/pruefen.sh` (enthält `py_compile`, `ruff check .` und die Tests). ## Grenzen (Verdikt, eingehalten)
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap = - **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen. - **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht - Alles reversibel halten: Branch + Backup + Pin.
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also, ## Gitea
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst. PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile
einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still.
(Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.)
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- Alles reversibel halten: Branch + Backup + Pin.
## Gitea
Remote = `ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git` (SSH, Push aus
Git-Bash geht). Bei Aussetzern **Push mit Retry**. Neue Repos per API anlegen. Kein GitHub.
Ungemergtes, das weg soll, erst als Tag `archiv/<name>` sichern, dann löschen.
+127 -72
View File
@@ -1,90 +1,145 @@
# Homelab Orchestrator # Mission Control 2.0
Ein Steuerpult fürs Heimnetz, das sich selbst wartet. Zwei Bereiche, eine Oberfläche: Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
- **Box-Wart** betreut die KI-Box (Bosgame M5, AMD Ryzen AI MAX+ 395, 128 GB gemeinsamer Speicher, llama.cpp über > **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
Vulkan): hält sie aktuell, passt auf sie auf und sucht bessere Modelle. Live seit 23.09.2026.
- **Homelab** soll den Proxmox-PC mit seinen Containern und Arcane (Docker) betreuen: offene Updates zeigen, per
Knopf einspielen, danach die Erreichbarkeit prüfen. In Arbeit ([Fahrplan](docs/wissen/OFFENE-FAEDEN.md)).
Dieselbe Codebasis läuft als zwei Instanzen: auf der KI-Box (Rolle `box`) und später als Container auf dem ## Die drei Bahnen
Proxmox-PC (Rolle `homelab`); beide prüfen sich gegenseitig. Repo, Dienste und Dateien tragen noch den alten Namen
„Mission Control 2" (`mission-control-v2`, `mission-control-2`, `mc2-*`). Alles läuft lokal, kein Cloud-Modell im
Datenpfad.
## Oberfläche | Bahn | Was sie tut | Wo |
Im Heimnetz `http://192.168.178.151:9001`, am PC oder am Handy.
| Seite | Inhalt |
|---|---|
| **Start** | Warnlampen, Instrumente (Speicher, Temperatur, Platte, Laufzeit), Checkliste mit den Hinweisen des Wächters und ihren Knöpfen, Flugplan (was lief, was kommt), Radar-Kasten |
| **Updates** | Bausteine Betriebssystem, Motor (llama.cpp), llama-swap und Hermes; Verlauf der Update-Läufe; Sicherungen |
| **Modelle** | Speicher, Rollen Hirn und Coder, wer die Modelle nutzt, Modell-Radar, Aufräumen, Modelle selbst suchen |
| oben rechts bzw. „Mehr" | Hermes-Dashboard, Dienste und Protokolle, Einstellungen |
Anleitung für den Alltag: [docs/BEDIENUNG.md](docs/BEDIENUNG.md).
## Was von allein läuft
- **Wächter** (jede Minute): Dienste, Timer, Hermes-Jobs, Kern, Platte, festgehaltene Updates und, sobald
eingerichtet, die Partner-Instanz. Abgestürzte Dienste startet er selbst neu (höchstens 2× je Stunde), Rotes meldet
er per Telegram.
- **Updates** sonntags 04:30: llama-swap → Motor → Hermes, danach Prüfung; rot → zurück und festhalten. Die Box
startet nur sonntags zwischen 04:00 und 06:59 neu.
- **Modell-Radar** nachts 00:30–02:30: sucht Kandidaten für Hirn und Coder und testet höchstens einen pro Woche;
übernommen wird nur per Knopf.
- **Sicherung** täglich gegen 03:30, 14 Stück, Kopie auf dem Proxmox-Host.
- **Meldungen** gehen an Telegram und in Lucys Briefkasten; nachts gesammelt, um 07:00 als eine Morgenmeldung,
Dringendes sofort.
Alle Zeiten: [docs/wissen/STACK.md](docs/wissen/STACK.md), Abschnitt „Automatik".
## Dienste und Ports (KI-Box)
| Port | Unit | Aufgabe |
|---|---|---| |---|---|---|
| `9001` | `mission-control-2` | Oberfläche, `/api`, `/v1` für Lucy und OpenChamber, Hermes-Dashboard unter `/hermes-ui/` | | **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
| `9010` (nur lokal) | `mc2-gateway` | `/v1`-Datenpfad: `model: auto`, Bild-Weiche | | **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
| – | `mc2-steward` | Wächter und Re-Warm | | **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
| `8080` | `llama-swap` (System-Dienst) | Modell-Router, startet je Modell einen `llama-server` |
| `8642` | `hermes-gateway` | Hermes Agent „Lucy", Telegram |
| `9119` | `hermes-builtin-ui` | Hermes-Dashboard |
| `8021` (nur lokal) | `lucy-stimme` | Lucys Stimme |
| `8650` (nur lokal) | `voice-service` | Spracherkennung (schläft seit 24.09.) |
| `7682` (nur lokal) | `box-console` | Web-Konsole (schläft seit 24.09.) |
Dazu die Timer `mc2-radar`, `mc2-backup`, `mc2-morgenmeldung`, `mc2-autoupdate` und `projekte-sync`. Die Unit-Dateien Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
liegen in [`deploy/`](deploy/).
## Ports & Dienste
| Port | Dienst | Erreichbar |
|---|---|---|
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
| `8080` | **llama-swap** — Modell-Router | LAN |
| `9010` | `mc2-gateway` — `/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
## Die Bau-Pipeline
```
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
└── autonomes Vibe Coding via OpenCode Desktop + MCP
```
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
Wächtern geboren**:
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
Keine `VERIFY`-Datei = Prüf-Tor aus.
## Modelle & Rollen
Gemessen auf der Box (Stand: August 2026, Vulkan b10502):
| Rolle | Modell | Tempo | Aufgabe |
|---|---|---|---|
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft (131k Kontext) |
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6–90 t/s** | Lucys Hirn **und** Sucher-Subagent. Immer warm |
| `debugger` / `doctor` | Muse-Glimmer-30B | **40–50 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (Multimodal) |
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen, 16k Deckel) |
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
| `scout` | GLM-4.6V-Flash | auf Abruf | Schneller Vision- und Tool-Allrounder |
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
| `dense-planer` | Qwen3.8-27B | **12,7 t/s** (On-Demand) | Dichtes 27B-Modell für tiefes Reasoning & 262k Kontext |
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
## Qualitäts-Tore
1. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
Kontext-Komprimierung löscht still die Regeln mit.
## Selbsterhaltung
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
## API (Auswahl)
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
memory/* · agent/* · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
## Entwickeln ## Entwickeln
```bash ```bash
# Backend lokal auf :9000 (Windows) # Backend
cd backend cd backend
python -m venv .venv python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
.venv/Scripts/python -m pip install -r requirements.txt -r requirements-dev.txt
.venv/Scripts/python -m uvicorn app:app --port 9000 .venv/Scripts/python -m uvicorn app:app --port 9000
# Frontend (Vite leitet /api an :9000 weiter; MC_API_TARGET=http://192.168.178.151:9001 zeigt auf die Box) # Frontend (Dev, proxyt /api → :9000)
cd frontend && npm ci && npm run dev cd frontend && npm install && npm run dev # http://localhost:5173
# Frontend (Build → wird vom Backend ausgeliefert)
cd frontend && npm run build # → frontend/dist
``` ```
Vor jedem Push: `bash deploy/pruefen.sh` (Shell- und Python-Syntax, `ruff check .`, Importe, `pytest backend/tests`); `frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
bei Frontend-Änderungen zusätzlich `npm run lint`, `npm test` und `npm run build` in `frontend/`, und das neue Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
`frontend/dist` mitcommitten, denn die Box baut kein Frontend. Gearbeitet wird auf einem Branch; `main` liegt auf
Gitea (`ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git`), danach auf der Box
`bash ~/mission-control-v2/deploy/deploy.sh`. Regeln für Agenten: [AGENTS.md](AGENTS.md).
## Doku ## Env-Vars (Auswahl)
| Dokument | Für | Inhalt | | Variable | Default | Zweck |
|---|---|---| |---|---|---|
| [docs/README.md](docs/README.md) | alle | Index, Lesereihenfolge, Pflegeregeln | | `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
| [docs/BEDIENUNG.md](docs/BEDIENUNG.md) | User | Oberfläche und Telegram-Nachrichten | | `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Agenten, Technik | Prozesse, Rollen, Datenwege, wer was schreibt | | `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
| [docs/BETRIEB.md](docs/BETRIEB.md) | Agenten, Technik | Handgriffe, Meldungen, Wächter, Deploy, Sicherung, Notfall | | `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
| [docs/UPDATES.md](docs/UPDATES.md) | Agenten, Technik | Sonntags-Update, Festhalten, Freigeben |
| [docs/RADAR.md](docs/RADAR.md) | Agenten, Technik | Modell-Radar, Stack-Radar, Prüfstand | | `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
| [docs/WIEDERAUFBAU.md](docs/WIEDERAUFBAU.md) | Technik | die KI-Box von null aufbauen | | `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
| [docs/wissen/](docs/wissen/) | Agenten | Stand, Verdikte, Fallen, Arbeitsweise, offene Fäden | | `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
## Weiterlesen
| Dokument | Inhalt |
|---|---|
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
+26
View File
@@ -0,0 +1,26 @@
# Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-08-19. Zustands-Snapshot nach Stack-Vollupdate (Hermes v0.20.4, llama.cpp b10502, llama-swap v250, Qwen 3.8 DFlash-2 Vorbereitung & Doku-Bereinigung)._
## Was das ist (in einem Satz)
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
Agenten-Status, Updates & Wartung — und dient als Steuerpult für die autonome KI („Lucy").
## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets direkt aus).
- **MC2-Gateway** `:9010` (`/v1`-Datenpfad, model:auto Routing + native Bildweiche).
- **Modell-Router** `:8080` (llama-swap, Vulkan/RADV Engine b10502).
- **Hermes Agent** `:8642` (`hermes-gateway`, v0.20.4 mit nativem Bot-Mode).
- **Sidecars:** `mem0_service/` (:8765, semantischer Chroma-Gedächtnis-Sidecar), `voice_service/` (:8650, faster-whisper STT + Piper TTS), `mcp/` (MCP-Server), `client/hermes-pc` (PC-Executor :7777).
## Aktueller Zustand (19.08.2026)
- **Stack-Vollupdate abgeschlossen:** llama-swap v250, llama.cpp b10502 (Vulkan), Hermes Agent v0.20.4 (Bot-Mode Support).
- **Selbsttests:** `self-smoke.sh` zu 100 % grün.
- **Codebase-Bereinigung:** Verwaiste Governor-Schnittstellen im Frontend entfernt, Regex-Versionsparsing in `maintenance.py` gefixt, Doku-Wahrheit (Mem0-Sidecar, Modelltabellen, Ports) wiederhergestellt.
- **Modelle live:** hermes=Qwen3.6-35B-A3B (immer warm, ~70–90 t/s) · coder=Qwen3-Coder-Next (51,5 t/s) · debugger=Muse-Glimmer-30B · kritiker=Devstral-Small-2-24B (16k Deckel) · dense-planer=Qwen3.8-27B · heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed/reranker=Qwen3 0.6B.
## Letzter Sicherungspunkt
- Stand: 19.08.2026 nach Stack-Audit & Cleanup.
+69 -104
View File
@@ -1,16 +1,9 @@
""" """
Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg. Mission Control 2.0 — dünner FastAPI-Einstieg.
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen. Server (proxyt /api hierher), daher CORS für localhost offen.
Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen,
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge)
homelab — der Proxmox-PC: der Homelab-Teil
Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter
/api/partner/… durch.
""" """
import asyncio import asyncio
@@ -22,17 +15,34 @@ from typing import Any
import httpx import httpx
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from kern.einstellungen import einstellungen from routers import (
from routers import health, partner agent,
from services.herkunft import erlaubt auftragsbuch,
chronik,
connect,
console,
events,
gateway_proxy,
health,
hermes_ui,
ideen,
maintenance,
models,
routing,
skills,
system,
voice,
wissen,
zeitmaschine,
)
from routers import reminders as reminders_router
from services import metrics_history, reminders, sentry, warmer
from starlette.requests import Request from starlette.requests import Request
ROLLE = einstellungen().rolle
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)). # für alle Module (logging.getLogger(__name__)).
logging.basicConfig( logging.basicConfig(
@@ -42,11 +52,10 @@ logging.basicConfig(
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _box_hintergrund() -> list[asyncio.Task[Any]]: @asynccontextmanager
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden.""" """Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
from services import reminders, warmer + Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
tasks: list[asyncio.Task[Any]] = [] tasks: list[asyncio.Task[Any]] = []
if warmer.ENABLED: if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop())) tasks.append(asyncio.create_task(warmer.rewarm_loop()))
@@ -54,27 +63,11 @@ def _box_hintergrund() -> list[asyncio.Task[Any]]:
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
warmer.INTERVAL, warmer.INTERVAL,
) )
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen if sentry.ENABLED:
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern. tasks.append(asyncio.create_task(sentry.sentry_loop()))
if os.environ.get("MC_PROBELAUF", "") != "1": tasks.append(asyncio.create_task(reminders.reminders_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop())) # 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
else: tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
return tasks
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden."""
# Aufträge (Updates, Downloads) laufen als eigene Einheiten weiter, während MC2 neu startet —
# hier werden sie wieder beobachtet. Ein Probelauf daneben fasst sie nicht an.
if os.environ.get("MC_PROBELAUF", "") != "1":
from services import jobengine
await asyncio.to_thread(jobengine.wiederaufnehmen)
tasks = _box_hintergrund() if ROLLE == "box" else []
if ROLLE == "homelab":
from services.homelab import updates
updates.unterbrochene_abschliessen() # Läufe des vorigen Prozesses gelten als unterbrochen
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient( app.state.gw_client = httpx.AsyncClient(
@@ -89,7 +82,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await app.state.gw_client.aclose() await app.state.gw_client.aclose()
app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan) app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf. # Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware( app.add_middleware(
@@ -100,15 +93,6 @@ app.add_middleware(
) )
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
@app.middleware("http")
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403)
return await call_next(request)
@app.middleware("http") @app.middleware("http")
async def no_cache( async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
@@ -119,57 +103,42 @@ async def no_cache(
return resp return resp
def _box_router(app: FastAPI) -> None:
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
from routers import (
boxwart,
einstellungen,
events,
gateway_proxy,
hermes_ui,
maintenance,
models,
radar,
routing,
system,
voice,
zeitmaschine,
)
from routers import reminders as reminders_router
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
if V1_UPSTREAM:
from routers import gateway_forward
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
else:
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router)
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
# Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
app.include_router(hermes_ui.router)
app.include_router(health.router) app.include_router(health.router)
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche app.include_router(models.router)
if ROLLE == "box": app.include_router(routing.router)
_box_router(app) app.include_router(system.router)
app.include_router(connect.router)
app.include_router(agent.router)
app.include_router(
voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
app.include_router(
reminders_router.router
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
if V1_UPSTREAM:
from routers import gateway_forward
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
else: else:
from routers import einstellungen, homelab app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4) app.include_router(maintenance.router)
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(einstellungen.homelab_router) # Telegram-Test des Homelab-Teils app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
app.include_router(skills.router) # Skills & Jobs Dashboard
app.include_router(
console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
app.include_router(
hermes_ui.router
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
@@ -180,10 +149,6 @@ if FRONTEND_DIST.exists():
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
def spa(full_path: str): def spa(full_path: str):
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
if full_path == "api" or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus. # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
try: try:
+136 -125
View File
@@ -1,125 +1,136 @@
""" """
Zentrale Konfiguration für Mission Control 2.0. Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen. Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
""" """
import os import os
from pathlib import Path from pathlib import Path
# --- Engine (llama-swap) ----------------------------------------------------- from ruamel.yaml import YAML
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")) # --- Engine (llama-swap) -----------------------------------------------------
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models")) LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace). CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster. MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json"))) # Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h # Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB, DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot. DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten). # Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen. # Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server — # liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse). # Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg # weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026). MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
_DEFAULT_CMD_TEMPLATE = ( # Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} " # Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
"-c {ctx} -ngl 999 -fa on --load-mode none" # llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
) # macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE) _DEFAULT_CMD_TEMPLATE = (
if "{model}" not in CMD_TEMPLATE: "llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE "-c {ctx} -ngl 999 -fa on --no-mmap"
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300")) )
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt if "{model}" not in CMD_TEMPLATE:
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern). CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts"))) DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur # Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt, # fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.) # (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "") DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich # Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# zu --spec-draft-model, sonst ist Spec inaktiv). # verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple") # weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit # Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT # zu --spec-draft-model, sonst ist Spec inaktiv).
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust. SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4")) # MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha). # MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"} # `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
# --- Routing-Gateway (builtin in MC2, model: auto) --------------------------- SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer # Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr. HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B. # --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad # MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes # LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback. GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/") # Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
# --- Hermes Agent (eigener Dienst auf der Box) ------------------------------- # und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`). # Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/") V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent # --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP. # Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
def _read_hermes_env(key: str) -> str: HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env). # API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht.""" # /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
try: # (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env" def _read_hermes_env(key: str) -> str:
for line in env_path.read_text(encoding="utf-8").splitlines(): """Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
line = line.strip() Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
if line.startswith(f"{key}="): try:
return line.split("=", 1)[1].strip().strip('"').strip("'") env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
except OSError: for line in env_path.read_text(encoding="utf-8").splitlines():
pass line = line.strip()
return "" if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
HERMES_API_KEY = ( pass
os.environ.get("HERMES_API_KEY") return ""
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
) HERMES_API_KEY = (
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn, os.environ.get("HERMES_API_KEY")
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft. or os.environ.get("API_SERVER_KEY")
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes") or _read_hermes_env("API_SERVER_KEY")
)
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) --------------- # Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv # das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
# passen. MC2 proxyt nach außen. HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) ----------- # --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten # Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy. # Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/") # (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit # rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback: # KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein # Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall # Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
# anfasst, öffnet damit auch diese Oberfläche. MC2 erreicht sie über Loopback und reicht sie BOX_CONSOLE_PATH = "/console/"
# same-origin unter # Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle # Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix # (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig. # /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/") # SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
HERMES_BUILTIN_UI_PATH = "/hermes-ui/" # liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# --- Server ------------------------------------------------------------------ # GitHub-Repo für Update-Checks.
PORT = int(os.environ.get("MC_PORT", "9000")) HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus; HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher. # PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist"))) PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
# Version (Phase 0 — Greenfield-Skeleton). # --- Server ------------------------------------------------------------------
VERSION = "2.0.0-w8" HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
-3
View File
@@ -1,3 +0,0 @@
"""Kern des Homelab Orchestrators (Phase 2, ab 24.09.2026): was beide Instanzen teilen —
Einstellungen, Zeitzone, Partner-Instanz. Neue Module lesen ihre Einstellungen hier; ältere
Module ziehen nach und nach um."""
-40
View File
@@ -1,40 +0,0 @@
"""Zentrale Einstellungen einer Instanz des Homelab Orchestrators.
Zwei Rollen, derselbe Code (User-Entscheid 24.09.2026, „zwei Instanzen, eine Oberfläche“):
box — auf der KI-Box: Box-Wart (Updates, Wächter, Modelle, Radar, Gateway für Lucy)
homelab — auf dem Proxmox-PC: Homelab-Teil (Proxmox-Host, Container, Arcane)
Beide kennen die jeweils andere Instanz als Partner: die Oberfläche zeigt beide Bereiche, und
jede Instanz prüft, ob die andere noch antwortet.
"""
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
ROLLEN = ("box", "homelab")
@dataclass(frozen=True)
class Einstellungen:
rolle: str # "box" | "homelab"
instanz: str # Anzeigename dieser Instanz
daten_dir: Path # Zustandsdateien dieser Instanz
partner_url: str # Basis-URL der anderen Instanz, leer = keine
partner_name: str # Anzeigename der anderen Instanz
@lru_cache(maxsize=1)
def einstellungen() -> Einstellungen:
rolle = os.environ.get("MC_ROLLE", "box").strip().lower() or "box"
if rolle not in ROLLEN:
raise ValueError(f"MC_ROLLE muss eine von {ROLLEN} sein, nicht {rolle!r}.")
box = rolle == "box"
standard_daten = os.environ.get("MC_MODELS_DIR", "/srv/models") if box else "/var/lib/mc2"
return Einstellungen(
rolle=rolle,
instanz=os.environ.get("MC_INSTANZ", "Box-Wart" if box else "Homelab"),
daten_dir=Path(os.environ.get("MC_DATEN_DIR", standard_daten)),
partner_url=os.environ.get("MC_PARTNER_URL", "").strip().rstrip("/"),
partner_name=os.environ.get("MC_PARTNER_NAME", "Homelab" if box else "Box-Wart"),
)
-45
View File
@@ -1,45 +0,0 @@
"""GitHub-Abfragen mit Zwischenspeicher — für beide Rollen (Box: Motor und llama-swap, Homelab: die Apps).
GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte früher
neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
"""
import os
import re
import threading
import time
import httpx
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
def github_json(pfad: str, timeout: float = 8) -> object:
"""GET https://api.github.com/<pfad>, 15 Minuten gemerkt. Wirft bei HTTP-Fehlern."""
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
return eintrag[1]
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
r.raise_for_status()
daten = r.json()
with _lock:
_cache[pfad] = (jetzt, daten)
return daten
_VERSION = re.compile(r"^v?(\d+(?:\.\d+)+|\d{4}-\d{2}-\d{2}(?:-r\d+)?)", re.IGNORECASE)
def neueste_version(repo: str) -> dict | None:
"""Neueste Veröffentlichung eines Repos: {"tag", "version", "datum"} oder None. Manche Projekte
veröffentlichen „untagged“ und tragen die Version nur im Namen (PVE Scripts Local, 09/2026)."""
daten = github_json(f"repos/{repo}/releases/latest")
if not isinstance(daten, dict) or not daten.get("tag_name"):
return None
tag, name = str(daten["tag_name"]), str(daten.get("name") or "")
roh = tag if _VERSION.match(tag) or not _VERSION.match(name) else name
return {"tag": roh, "tag_roh": tag, "version": roh.lstrip("vV"),
"datum": str(daten.get("published_at") or "")[:10] or None}
-53
View File
@@ -1,53 +0,0 @@
"""Die Partner-Instanz (User-Entscheid 24.09.2026: zwei Instanzen, eine Oberfläche).
Box-Wart läuft auf der KI-Box, der Homelab-Teil in einem Container auf dem Proxmox-PC. Jede
Instanz kennt die andere über MC_PARTNER_URL. Sie fragt deren Lebenszeichen ab (für den Wächter
und die Kopfzeile der Oberfläche) und reicht Anfragen der Oberfläche unter /api/partner/… durch,
damit eine Seite beide Bereiche zeigt — egal, auf welcher Instanz man sie öffnet.
"""
import threading
import time
import httpx
from kern.einstellungen import einstellungen
ZEITLIMIT_S = 5.0
CACHE_S = 15.0
_lock = threading.Lock()
_cache: dict = {"ts": 0.0, "daten": None}
def _kurz(exc: Exception) -> str:
if isinstance(exc, httpx.TimeoutException):
return "antwortet nicht rechtzeitig"
if isinstance(exc, httpx.ConnectError):
return "nicht erreichbar"
if isinstance(exc, httpx.HTTPStatusError):
return f"antwortet mit HTTP {exc.response.status_code}"
return f"{exc.__class__.__name__}: {exc}"[:160]
def status(frisch: bool = False) -> dict:
"""Lebenszeichen der Partner-Instanz. Ohne MC_PARTNER_URL: {"eingerichtet": False}."""
e = einstellungen()
if not e.partner_url:
return {"eingerichtet": False}
with _lock:
if not frisch and _cache["daten"] and time.time() - _cache["ts"] < CACHE_S:
return dict(_cache["daten"])
daten: dict = {"eingerichtet": True, "name": e.partner_name, "url": e.partner_url,
"erreichbar": False, "rolle": None, "instanz": None, "version": None, "fehler": None}
try:
r = httpx.get(f"{e.partner_url}/api/health", timeout=ZEITLIMIT_S)
r.raise_for_status()
h = r.json()
daten.update(erreichbar=True, rolle=h.get("rolle"), instanz=h.get("instanz"), version=h.get("version"))
except Exception as exc:
daten["fehler"] = _kurz(exc)
daten["geprueft"] = time.time()
with _lock:
_cache.update(ts=time.time(), daten=daten)
return dict(daten)
-7
View File
@@ -1,7 +0,0 @@
"""Die eine Zeitzone für alles, was Zeiten anzeigt oder naive Zeiten deutet (Projektregel:
nie datetime.now() ohne Zone). Vorher stand dieselbe Zeile in sieben Modulen."""
import os
from zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
-62
View File
@@ -1,62 +0,0 @@
"""Ziele und Bausteine (Phase 2, 24.09.2026): ein Modell für alles, was Updates bekommt.
Ein Ziel ist ein Gerät: die KI-Box, der Proxmox-Host, ein Container, die Arcane-VM. Es besteht aus
Bausteinen (Betriebssystem, Motor, die App im Container, Docker-Images …). Jeder Baustein sagt, wie
sein Stand ist — neu, aktuell, unbekannt (mit Grund) oder festgehalten — und wie man ihn einspielt.
Die KI-Box meldet ihre Bausteine über den Box-Adapter (services/box_updates.py), der Homelab-Teil
ab Phase 3 über Proxmox- und Arcane-Adapter. So wird aus beiden Instanzen EINE Liste offener Updates
(User-Entscheid 24.09.: eine Anlaufstelle für Updates im ganzen Homelab).
"""
from dataclasses import asdict, dataclass, field
from typing import Literal
Zustand = Literal["neu", "aktuell", "unbekannt", "festgehalten", "wird-geprueft"]
# Reihenfolge für die Anzeige: was Aufmerksamkeit braucht, zuerst.
_RANG = {"unbekannt": 0, "festgehalten": 1, "neu": 2, "wird-geprueft": 3, "aktuell": 4}
@dataclass
class Aktion:
"""Wie ein Update angestoßen wird: eine Schnittstelle der Instanz, die das Ziel pflegt."""
methode: str # "POST"
pfad: str # "/api/maintenance/engine-update"
frage: str # Rückfrage vor dem Klick
label: str = "Jetzt updaten"
@dataclass
class BausteinStand:
id: str # "engine"
name: str # "Motor (llama.cpp)"
zustand: Zustand
installiert: str | None = None
verfuegbar: str | None = None
kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen"
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
@dataclass
class ZielStand:
id: str # "ki-box"
name: str # "KI-Box"
art: str # "ki-box" | "proxmox-host" | "container" | "vm"
instanz: str # welche Instanz es pflegt: "box" | "homelab"
erreichbar: bool | None = None # None = nicht geprüft
bausteine: list[BausteinStand] = field(default_factory=list)
geprueft: float | None = None # Unix-Sekunden der letzten Prüfung
rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup"
rueckweg_art: str | None = None # Kurzform für die Oberfläche: „snapshot“, „sicherung“ oder „keiner“
platte: dict | None = None # {"belegt", "gesamt"} in Bytes, wo bekannt (laufende Container)
def als_dict(self) -> dict:
daten = asdict(self)
daten["bausteine"].sort(key=lambda b: _RANG.get(b["zustand"], 9))
zaehlung: dict[str, int] = {}
for b in self.bausteine:
zaehlung[b.zustand] = zaehlung.get(b.zustand, 0) + 1
daten["zaehlung"] = zaehlung
daten["offen"] = zaehlung.get("neu", 0)
return daten
+57
View File
@@ -0,0 +1,57 @@
import sys
from pathlib import Path
# Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent))
from config import CONFIG_PATH
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
def migrate():
print(f"Reading config from {CONFIG_PATH}...")
if not CONFIG_PATH.exists():
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
return
cfg = read_config()
models = cfg.get("models", {})
for name, spec in models.items():
if not isinstance(spec, dict):
continue
cmd = spec.get("cmd", "")
if not cmd:
continue
print(f"Migrating model: {name}")
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
# automatisch pro Slot.
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
# 2. Extract aliases/role
aliases = spec.get("aliases", [])
role = aliases[0] if aliases else None
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
if role in ("fast", "coder"):
if "--parallel" not in cmd:
cmd = cmd.strip() + " --parallel 2"
if "--spec-draft-model" not in cmd:
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
cmd = cmd.strip() + spec_draft_flags(target)
# Update cmd
from ruamel.yaml.scalarstring import LiteralScalarString
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
print(f"Writing updated config back to {CONFIG_PATH}...")
write_config(cfg)
print("Migration completed successfully!")
if __name__ == "__main__":
migrate()
-139
View File
@@ -1,139 +0,0 @@
"""
Nächtlicher Lauf des Modell-Radars (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
Warum ein eigener Einstieg: Der Test lädt ein bis zu ~85 GB großes Modell neben Lucys Hirn. Das
darf nur nachts passieren und muss um 02:30 sicher vorbei sein, weil um 03:00 der NerdQuiz-
Nachtlauf das Hirn braucht. Ablauf:
1. Suchen — höchstens einmal am Tag (Merkliste + Entdeckung).
2. Offene Urteile nachholen („getestet“: damals fehlte die Vergleichsmessung).
3. Testen — nur im Fenster 00:30–02:30 und höchstens ein neuer Kandidat pro Woche.
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die
Notbremse: Kandidaten-Server und Download werden gestoppt, der Prozess endet hart. systemd
(RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
Mit --nur-suche (seit 24.09.2026) nur Schritt 1, jederzeit: So startet der Knopf „Jetzt suchen“ die Suche als
Auftrag (services/radar.suche_starten). Getestet wird dabei nichts.
"""
import logging
import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from services import radar
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger("radar_lauf")
NOTBREMSE_S = int(os.environ.get("MC_RADAR_NOTBREMSE_S", "300"))
def _beim_beenden(signum, _frame) -> None:
"""SIGTERM (systemd stop, RuntimeMaxSec) → normal auslaufen, damit der Kandidaten-Server gestoppt wird."""
raise SystemExit(f"Signal {signum}")
def _notbremse(frist: float) -> threading.Timer:
def ziehen() -> None:
log.error("Radar: Notbremse – %s s nach der Frist läuft noch etwas, der Lauf wird hart beendet.", NOTBREMSE_S)
try:
radar.notstopp()
finally:
os._exit(3)
bremse = threading.Timer(max(frist - time.time(), 0) + NOTBREMSE_S, ziehen)
bremse.daemon = True
bremse.start()
return bremse
NOTIFY = Path(__file__).resolve().parents[1] / "deploy" / "notify.sh"
def melde_bestanden(test: dict) -> None:
"""Ein bestandener Kandidat wartet auf eine Entscheidung — einmal Bescheid geben, sonst liegt er
unbemerkt herum (Lehre der Pins vom September). notify.sh sammelt Nachtmeldungen für den Morgen."""
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
rolle = "das Hirn" if test.get("rolle") == "hirn" else "den Coder"
text = (f"{k.get('name') or test.get('kandidat')} hat den Nachttest für {rolle} bestanden. "
f"{str(test.get('zusammenfassung') or '')[:300]} "
"In MC2 unter Modelle kannst du ihn übernehmen oder verwerfen.")
try:
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
log.warning("Radar: Meldung über den bestandenen Kandidaten ging nicht raus.", exc_info=True)
def main() -> int:
beginn = radar.jetzt()
if radar.suche_faellig(beginn):
try:
ergebnis = radar.suche()
log.info("Radar: Suche – %s neu, %s warten", len(ergebnis.get("neu") or []), ergebnis.get("wartend"))
except Exception:
log.warning("Radar: Suche fehlgeschlagen", exc_info=True)
else:
log.info("Radar: heute schon gesucht.")
if not radar.im_fenster(radar.jetzt()):
log.info("Radar: außerhalb des Test-Fensters %s–%s, nichts zu testen.", radar.FENSTER_START,
radar.FENSTER_ENDE)
return 0
frist = radar.fenster_ende(radar.jetzt()).timestamp()
signal.signal(signal.SIGTERM, _beim_beenden)
bremse = _notbremse(frist)
try:
if (nachgeholt := radar.nachurteilen(frist)):
log.info("Radar: %s offene Urteile nachgeholt.", nachgeholt)
plan = radar.plane_test(radar.lade_stand(), radar.jetzt())
if not plan.get("kandidat"):
log.info("Radar: nichts fällig (%s).", plan.get("grund"))
return 0
log.info("Radar: teste %s bis spätestens %s.", plan.get("grund"), radar.FENSTER_ENDE)
test = radar.teste(plan["kandidat"], frist)
if test:
log.info("Radar: %s – %s", test.get("ergebnis"), test.get("zusammenfassung"))
if test.get("ergebnis") == "bestanden":
melde_bestanden(test)
return 0
finally:
bremse.cancel()
def nur_suche() -> int:
"""„Jetzt suchen“ aus der Oberfläche: nur die Suche, kein Test. Die letzte Zeile sagt das Ergebnis in Worten —
die Auftragskarte zeigt sie. Code 1, wenn die Suche scheitert oder keine Quelle erreichbar war."""
# Das Protokoll des Auftrags zeigt die Oberfläche: ohne eine Zeile je Hugging-Face-Abruf (samt signierter
# Download-Adressen), nur die Schritte des Radars und das Ergebnis.
logging.getLogger("httpx").setLevel(logging.WARNING)
try:
ergebnis = radar.suche()
except Exception as exc:
log.exception("Radar: Suche fehlgeschlagen")
print(f"Die Suche ist gescheitert: {exc.__class__.__name__}: {exc}", flush=True)
return 1
neu = ergebnis.get("neu") or []
quellen = ergebnis.get("quellen") or {}
teile = [f"{len(neu)} neu ({', '.join(neu)})" if neu else "nichts Neues",
f"{ergebnis.get('wartend', 0)} warten auf den Nachttest"]
if not quellen.get("discover", True):
teile.append("Hugging Face war nicht erreichbar")
if not quellen.get("watchlist", True):
teile.append("die Merkliste war nicht lesbar")
print(f"Suche fertig: {', '.join(teile)}.", flush=True)
return 0 if any(quellen.values()) else 1
if __name__ == "__main__":
sys.exit(nur_suche() if "--nur-suche" in sys.argv[1:] else main())
-3
View File
@@ -1,3 +0,0 @@
# Nur fürs Prüftor (deploy/pruefen.sh) — die Dienste selbst brauchen das nicht.
# Gepinnt am 24.09.2026 auf den Stand im venv der Box (der Container installiert diese Datei nicht).
pytest==9.1.1
+9 -16
View File
@@ -1,16 +1,9 @@
# Gepinnt am 24.09.2026 auf das, was nachweislich läuft: `pip freeze` im venv der KI-Box (Python 3.14) und im fastapi>=0.115
# Homelab-Container 107 (/opt/mc2/backend/.venv, Python 3.13). Gleiche Version = „==“, sonst ein Bereich, der python-multipart>=0.0.9
# beide Stände abdeckt — so installiert weder deploy.sh (Box) noch einrichten.sh (Container) etwas Neues. uvicorn[standard]>=0.30
# Wer eine Grenze anhebt: vorher deploy/probelauf-box.sh, und die neue Version in beiden Umgebungen prüfen. httpx>=0.27
fastapi>=0.139.0,<=0.141.1 ruamel.yaml>=0.18
python-multipart==0.0.32 psutil>=5.9
uvicorn[standard]>=0.51.0,<=0.53.0 huggingface_hub>=0.27
httpx==0.28.1 mcp>=1.2.0
ruamel.yaml==0.19.1 tzdata>=2024.1
psutil==7.2.2
huggingface_hub>=1.23.0,<=2.0.0
# mcp braucht nur die MCP-Server in mcp/ (Hermes startet sie mit diesem venv, auf der Box). mcp 2.x hat FastMCP
# entfernt — `from mcp.server.fastmcp import FastMCP` scheitert dort. Der Container hat 2.2.0, nutzt es aber nicht;
# deshalb hier der Stand der Box (einrichten.sh zieht den Container beim nächsten Ausrollen auf 1.28.1).
mcp==1.28.1
tzdata>=2026.3,<=2026.4
+41
View File
@@ -0,0 +1,41 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api")
class BrainReq(BaseModel):
model: str
class SetBrainReq(BaseModel):
model_id: str
@router.get("/agent/status")
def status() -> dict:
return agent_status()
@router.get("/agent/brain")
def brain_info() -> dict:
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
return hermes_brain_info()
@router.post("/agent/brain/set")
def set_brain(body: SetBrainReq) -> dict:
"""Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config)."""
res = set_agent_brain(body.model_id)
if not res.get("ok"):
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
return res
@router.post("/agent/brain")
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
return {"ok": ok}
+63
View File
@@ -0,0 +1,63 @@
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import auftragsbuch
router = APIRouter(prefix="/api")
class BranchIn(BaseModel):
branch: str
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
class KandidatIn(BaseModel):
file: str
@router.get("/auftragsbuch")
def list_proposals() -> dict:
return auftragsbuch.list_proposals()
@router.get("/auftragsbuch/diff")
def diff(branch: str, repo: str = "mc2") -> dict:
res = auftragsbuch.diff_of(branch, repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res
@router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch, body.repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/annehmen")
def skill_accept(body: KandidatIn) -> dict:
res = auftragsbuch.skill_accept(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/ablehnen")
def skill_reject(body: KandidatIn) -> dict:
res = auftragsbuch.skill_reject(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
return res
-224
View File
@@ -1,224 +0,0 @@
"""
Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht.
GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan
GET /api/hinweise Hinweise + Verlauf des Wächters
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2)
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
GET /api/aufraeumen/altreste Altreste neben dem Betrieb (feste Liste, nur was da ist)
POST /api/aufraeumen/altreste/loeschen Einen Altrest löschen — per Kennung, nie per Pfad
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
"""
import time
from datetime import datetime
import psutil
from config import MODELS_DIR
from fastapi import APIRouter, HTTPException
from kern.zeit import LOCAL_TZ
from pydantic import BaseModel
from services import (
aufraeumen,
backup,
box_updates,
llamaswap,
modell_nutzung,
system,
update_verlauf,
waechter,
zeitplan,
)
router = APIRouter(prefix="/api", tags=["boxwart"])
def _zeit_kurz(ts: float | None) -> str:
if not ts:
return "–"
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
heute = datetime.now(LOCAL_TZ).date()
if dt.date() == heute:
return f"heute {dt:%H:%M}"
if (heute - dt.date()).days == 1:
return f"gestern {dt:%H:%M}"
return f"{dt:%d.%m. %H:%M}"
def _lampen(stand: dict, u: dict) -> list[dict]:
ids = {h.get("id", "") for h in stand["hinweise"]}
versionen = system.check_versions_cached()
def lampe(lid: str, label: str, zustand: str, wert: str) -> dict:
return {"id": lid, "label": label, "zustand": zustand, "wert": wert}
engine_ok = llamaswap.engine_reachable()
build = (versionen.get("engine") or {}).get("version_text", "")
lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")]
hirn = llamaswap.brain_status() if engine_ok else {}
lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler",
"geladen" if hirn.get("ready") else "lädt nicht"))
laufend = set(llamaswap.get_running_models()) if engine_ok else set()
coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None)
coder_an = bool(coder and coder.get("name") in laufend)
lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf"))
hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway"))
lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok",
"antwortet nicht" if hermes_weg else "läuft"))
job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))]
rot = any(h.get("stufe") == "rot" for h in job_hinweise)
lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"),
f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}"
if job_hinweise else "alles gelaufen"))
try:
sicherungen = backup.list_backups()
except Exception:
sicherungen = []
letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0)
zu_alt = not letzte or time.time() - letzte > 36 * 3600
lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte)))
try:
platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent
except Exception:
platte = None
zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else (
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
n = len(box_updates.bausteine(u))
fest = len(update_verlauf.festgehalten())
unklar = len(box_updates.unklar(u))
if fest:
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
elif not box_updates.gelesen():
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
elif n:
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
elif unklar:
lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar"))
else:
lampen.append(lampe("updates", "Updates", "ok", "aktuell"))
return lampen
def _box() -> dict:
p = system.metrik_punkt()
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
platte = {"used": du.used, "total": du.total, "percent": du.percent}
except Exception:
platte = None
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
"temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"),
"platte": platte, "uptime_s": p.get("uptime_s")}
@router.get("/start")
def start() -> dict:
stand = waechter.lese_stand()
u = box_updates.gecacht()
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
anzahl = len(stand["hinweise"])
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
return {
"zustand": {
"stufe": "rot" if rot else ("gelb" if anzahl else "ok"),
"anzahl": anzahl,
"waechter_wach": wach,
"stand": stand["stand"],
"update_laeuft": stand["update_laeuft"],
},
"hinweise": stand["hinweise"],
"verlauf": stand["verlauf"][:20],
"lampen": _lampen(stand, u),
"box": _box(),
"updates": {"bausteine": box_updates.bausteine(u), "unklar": box_updates.unklar(u),
"festgehalten": update_verlauf.festgehalten(),
"gelesen": box_updates.gelesen()},
"flugplan": zeitplan.flugplan(),
}
@router.get("/hinweise")
def hinweise() -> dict:
return waechter.lese_stand()
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id)
if ergebnis.get("detail") and not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis["detail"])
return ergebnis
@router.get("/modelle/nutzung")
def nutzung() -> dict:
return modell_nutzung.nutzung()
@router.get("/updates/verlauf")
def updates_verlauf(anzahl: int = 8) -> dict:
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
@router.post("/updates/festgehalten/{baustein}/freigeben")
def festgehalten_freigeben(baustein: str) -> dict:
if not update_verlauf.freigeben(baustein):
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
class LoeschAuftrag(BaseModel):
art: str # "eintrag" | "ordner"
name: str
@router.get("/modelle/aufraeumen")
def aufraeumen_liste() -> dict:
return {"kandidaten": aufraeumen.kandidaten()}
@router.post("/modelle/aufraeumen/loeschen")
def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name)
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
return ergebnis
class AltrestAuftrag(BaseModel):
id: str # Kennung aus der festen Liste (services/aufraeumen.ALTRESTE), nie ein Pfad
@router.get("/aufraeumen/altreste")
def altreste_liste() -> dict:
"""Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …), nur was da und nicht in Gebrauch ist."""
return {"altreste": aufraeumen.altreste()}
@router.post("/aufraeumen/altreste/loeschen")
def altrest_loeschen(auftrag: AltrestAuftrag) -> dict:
ergebnis = aufraeumen.altrest_loeschen(auftrag.id)
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
return ergebnis
@router.get("/ziele")
def ziele() -> dict:
"""Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab
Phase 3 unter /api/homelab/ziele; die Oberfläche legt beide zu einer Update-Liste zusammen."""
return {"ziele": [box_updates.ki_box_ziel().als_dict()]}
+28
View File
@@ -0,0 +1,28 @@
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from fastapi import APIRouter
from pydantic import BaseModel
from services import announce
router = APIRouter(prefix="/api")
class ChronikItem(BaseModel):
id: int
ts: float
subject: str
text: str
source: str
priority: str
class ChronikResponse(BaseModel):
items: list[ChronikItem]
@router.get("/chronik", response_model=ChronikResponse)
def chronik(limit: int = 150) -> dict:
return {"items": announce.list_recent(limit)}
+20
View File
@@ -0,0 +1,20 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets, check_health
router = APIRouter(prefix="/api")
@router.get("/connect")
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
kwargs = {}
if mcp_path:
kwargs["mcp_script_path"] = mcp_path
return build_snippets(host=host, **kwargs)
@router.get("/connect/health")
def connect_health() -> dict:
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab."""
return check_health()
+87
View File
@@ -0,0 +1,87 @@
"""
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
"""
import asyncio
import logging
import httpx
import websockets
from config import BOX_CONSOLE_UPSTREAM
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
log = logging.getLogger(__name__)
router = APIRouter()
def _register(base: str, upstream: str) -> None:
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
@router.websocket(f"/{base}/ws")
async def _proxy_ws(ws: WebSocket) -> None:
await ws.accept(subprotocol="tty")
try:
async with websockets.connect(
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
async def _proxy_http(request: Request, path: str = "") -> Response:
url = f"{upstream}/{base}/{path}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(request.method, url, content=await request.body())
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type"))
except httpx.HTTPError as exc:
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
media_type="text/plain")
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
name=f"{base}_path")
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
_register("console", BOX_CONSOLE_UPSTREAM)
-62
View File
@@ -1,62 +0,0 @@
"""Einstellungen der Oberfläche (seit 24.09.2026).
KI-Box (Rolle box):
GET /api/einstellungen Update-Zeitfenster und Radar-Schalter samt Zustand laut systemd
POST /api/einstellungen/update-fenster {tag: 1–7, uhrzeit: "HH:MM"}
POST /api/einstellungen/radar {an: true|false}
POST /api/einstellungen/telegram-test Testmeldung über notify.sh, Ergebnis aus dem Melde-Log
Homelab-Teil (Rolle homelab):
POST /api/homelab/einstellungen/telegram-test dasselbe für den Meldeweg des Homelab-Teils
Die Oberfläche schickt alles unter /api/homelab/… an den Homelab-Teil und den Rest an die KI-Box (lib/instanz.ts) —
so erreicht jeder Test-Knopf die richtige Instanz, egal welche die Seite ausliefert. Dünn: die Logik liegt in
services/einstellungen.py.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import einstellungen
router = APIRouter(prefix="/api/einstellungen", tags=["einstellungen"])
homelab_router = APIRouter(prefix="/api/homelab/einstellungen", tags=["einstellungen"])
class Zeitfenster(BaseModel):
tag: int
uhrzeit: str
class Schalter(BaseModel):
an: bool
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das ging gerade nicht.")
return ergebnis
@router.get("")
def stand() -> dict:
return einstellungen.stand()
@router.post("/update-fenster")
def update_fenster(fenster: Zeitfenster) -> dict:
return _antwort(einstellungen.update_fenster_setzen(fenster.tag, fenster.uhrzeit))
@router.post("/radar")
def radar(schalter: Schalter) -> dict:
return _antwort(einstellungen.radar_schalten(schalter.an))
@router.post("/telegram-test")
def telegram_test() -> dict:
"""Gesendet oder nicht, beides ist eine Antwort (200) — der Grund steht im Ergebnis."""
return einstellungen.telegram_test()
@homelab_router.post("/telegram-test")
def telegram_test_homelab() -> dict:
return einstellungen.telegram_test()
+52 -80
View File
@@ -1,26 +1,25 @@
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken. """SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten: GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln. billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler). `invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen. Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
invalidate-Event nur Lärm).
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob `event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`. Sekunden), Modell-Quelle aus der Karte übernommen.
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes' Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst. selbst und bis dahin pollt die UI wie bisher).
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
Frontend-Gegenstück: frontend/src/lib/events.ts
""" """
import asyncio import asyncio
@@ -28,6 +27,7 @@ import json
import logging import logging
from pathlib import Path from pathlib import Path
from config import MODELS_DIR
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -35,8 +35,7 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
METRIK_S = 1.0 # Takt der Messpunkte TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
@@ -51,9 +50,15 @@ def _fingerprints() -> dict[str, object]:
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle """Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen).""" dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
fp: dict[str, object] = {} fp: dict[str, object] = {}
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
from services import waechter from services import announce
fp["start"] = _mtime(waechter.STORE_PATH) fp["chronik"] = announce.list_after(None)["latest"]
except Exception:
pass
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
from services import ideen
d = ideen.list_queue()
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
except Exception: except Exception:
pass pass
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
@@ -61,68 +66,35 @@ def _fingerprints() -> dict[str, object]:
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models())) fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception: except Exception:
pass pass
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade # Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
from services import jobengine fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
fp["jobs"] = json.dumps( _mtime(MODELS_DIR / "mc2-announce-branches.json"))
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs(mit_log=False)] fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
)
except Exception:
pass
return fp return fp
async def _strom(request: Request, mit_metrik: bool): @router.get("/events")
"""Gemeinsamer Kern beider Endpunkte. async def events(request: Request) -> StreamingResponse:
async def strom():
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) — # Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
ein globaler Snapshot würde bei mehreren Clients Events verschlucken. # ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
""" alt = _fingerprints()
# Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere yield ": verbunden\n\n"
# Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread. seit_ping = 0.0
alt = await asyncio.to_thread(_fingerprints) while True:
yield ": verbunden\n\n" if await request.is_disconnected():
return
seit_abdruck = 0.0 await asyncio.sleep(TICK_S)
seit_ping = 0.0 seit_ping += TICK_S
takt = METRIK_S if mit_metrik else ABDRUCK_S neu = _fingerprints()
while True:
if await request.is_disconnected():
return
await asyncio.sleep(takt)
seit_abdruck += takt
seit_ping += takt
if mit_metrik:
try:
from services.system import metrik_punkt
punkt = await asyncio.to_thread(metrik_punkt)
yield f"event: metrik\ndata: {json.dumps(punkt)}\n\n"
seit_ping = 0.0
except Exception:
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
if seit_abdruck >= ABDRUCK_S:
seit_abdruck = 0.0
neu = await asyncio.to_thread(_fingerprints)
keys = [k for k, v in neu.items() if k in alt and v != alt[k]] keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu) alt.update(neu)
if keys: if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n" yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
seit_ping = 0.0 seit_ping = 0.0
elif seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
if seit_ping >= KEEPALIVE_S: return StreamingResponse(strom(), media_type="text/event-stream",
yield ": ping\n\n" headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
seit_ping = 0.0
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
"""Anstöße UND Messpunkte."""
return StreamingResponse(_strom(request, mit_metrik=True),
media_type="text/event-stream", headers=_KOPF)
+71 -141
View File
@@ -1,15 +1,12 @@
import hashlib
import json
import logging import logging
import os import os
from collections import OrderedDict
import httpx import httpx
from config import LLAMA_SWAP_URL from config import LLAMA_SWAP_URL
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.router_logic import VISION_CAPABLE, bild_ziel, bilder_aufteilen, choose_for_lane, has_image from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
from services.routing_policy import load_policy from services.routing_policy import load_policy
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -25,15 +22,8 @@ _LANG_DIRECTIVE = os.environ.get(
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl: # Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen # Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
# Request. Sind mehr als _BILD_MAX Bilder NEU zu beschreiben, bleiben alle Bilder drin und die
# Anfrage geht an den Bild-Zwilling (ehrlich langsam statt scheinbar hängend).
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
# Beschreibung älterer Bilder: Prompt bewusst auf wörtliche Wiedergabe von Code/Fehlermeldungen
# getrimmt — das schnelle Modell arbeitet in den Folgeschritten nur noch mit diesem Text.
_BILD_BESCHREIB_PROMPT = os.environ.get( _BILD_BESCHREIB_PROMPT = os.environ.get(
"MC_CODER_IMAGE_PROMPT", "MC_CODER_IMAGE_PROMPT",
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, " "Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
@@ -73,74 +63,57 @@ def _apply_no_think_marker(body: dict) -> None:
body["chat_template_kwargs"] = {"enable_thinking": False} body["chat_template_kwargs"] = {"enable_thinking": False}
# Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500 def _ist_coder_alias(alias: str) -> bool:
# „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen """Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
# Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild). stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
# • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe. return "coder" in (alias or "").lower()
# • Bild aus früheren Schritten → einmal von vision beschrieben (je Bild gemerkt) und als Text mitgeschickt,
# damit der Rest der Aufgabe wieder beim schnellen Modell mit Draft läuft.
_BESCHREIBUNGEN: OrderedDict[str, str] = OrderedDict()
_BESCHREIBUNGEN_MAX = 64
def _bild_schluessel(part: dict) -> str: async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest() """Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
async def _beschreibe(part: dict, vision_alias: str) -> str: (gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
"""Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
neu, darum wird jedes Bild nur einmal beschrieben.""" ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
schluessel = _bild_schluessel(part) Vision-Umleitung als Fallback.
if schluessel in _BESCHREIBUNGEN: """
_BESCHREIBUNGEN.move_to_end(schluessel) fundstellen: list[tuple[dict, int, dict]] = []
return _BESCHREIBUNGEN[schluessel] for m in body.get("messages") or []:
frage = { if not isinstance(m, dict) or not isinstance(m.get("content"), list):
"model": vision_alias, continue
"stream": False, for i, part in enumerate(m["content"]):
"max_tokens": 700, if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
# Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer. fundstellen.append((m, i, part))
"chat_template_kwargs": {"enable_thinking": False}, for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
"messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}], frage = {
} "model": vision_alias,
text = "" "stream": False,
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz "max_tokens": 700,
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry. "messages": [{"role": "user",
for versuch in (1, 2): "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
try: }
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c: # Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage) # Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
if r.status_code == 200: # Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or "" text = ""
if text.strip(): for versuch in (1, 2):
break try:
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s — %.200s", async with httpx.AsyncClient(timeout=240.0) as c:
versuch, r.status_code, r.text) r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
except Exception: if r.status_code == 200:
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True) text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
text = text.strip() if text.strip():
if text: break
_BESCHREIBUNGEN[schluessel] = text log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX: versuch, r.status_code, r.text)
_BESCHREIBUNGEN.popitem(last=False) except Exception:
return text log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
if not text.strip():
async def _alte_bilder_beschreiben(alt: list[tuple[dict, int]], vision_alias: str) -> bool:
"""Bilder aus früheren Schritten durch ihre Beschreibung ersetzen. False = ging nicht (zu viele neue
oder eine Beschreibung scheiterte) — dann bleiben alle Bilder drin."""
neu = {_bild_schluessel(msg["content"][idx]) for msg, idx in alt} - set(_BESCHREIBUNGEN)
if len(neu) > _BILD_MAX:
log.warning("Bild-Weiche: %s ältere Bilder neu zu beschreiben (Deckel %s) — Anfrage geht mit allen "
"Bildern an den Bild-Zwilling", len(neu), _BILD_MAX)
return False
texte = []
for msg, idx in alt:
text = await _beschreibe(msg["content"][idx], vision_alias)
if not text:
return False return False
texte.append((msg, idx, text)) msg["content"][idx] = {"type": "text", "text": (
for msg, idx, text in texte: f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
msg["content"][idx] = {"type": "text", "text": f"[Bild aus einem früheren Schritt — so sah es aus:\n{text}]"} f"{text.strip()}]")}
return True return True
@@ -161,29 +134,11 @@ def _inject_language(body: dict, alias: str) -> None:
elif isinstance(first_sys.get("content"), list): elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse:
"""Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy
können damit umgehen und zeigen den Grund an."""
return JSONResponse({"error": {"message": text, "type": "upstream_error"}},
status_code=status, headers=headers)
@router.get("/models") @router.get("/models")
async def models(request: Request): async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
try: r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) data = r.json()
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}")
try:
data = r.json()
except ValueError:
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
# statt eines 500ers aus dem Parser.
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
return JSONResponse(
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
"type": "upstream_error"}}, status_code=502)
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell" # Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand # angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste. # mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
@@ -232,20 +187,7 @@ async def models(request: Request):
async def _proxy(path: str, request: Request): async def _proxy(path: str, request: Request):
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft body = await request.json()
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
try:
body = await request.json()
except Exception:
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
"type": "invalid_request_error"}}, status_code=400)
if not isinstance(body, dict):
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
f"'{type(body).__name__}' bekommen.",
"type": "invalid_request_error"}}, status_code=400)
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus _apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
requested = str(body.get("model") or "auto") requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"): if requested.lower() in ("auto", "chat", "coding"):
@@ -259,36 +201,31 @@ async def _proxy(path: str, request: Request):
pol = load_policy() pol = load_policy()
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
angefragt = alias # Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
# Bild-Weiche v3 (siehe oben): Bild im aktuellen Schritt → Bild-Zwilling der Rolle; ältere Bilder → # sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
# Beschreibung als Text, die Anfrage bleibt beim schnellen Modell. # so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
# Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
# Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
vision_alias = pol.get("vision") vision_alias = pol.get("vision")
if vision_alias and alias not in VISION_CAPABLE and has_image(body): if vision_alias and alias not in VISION_CAPABLE and has_image(body):
frisch, alt = bilder_aufteilen(body) if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
beschrieben = bool(alt) and await _alte_bilder_beschreiben(alt, vision_alias) routed = {"x-mc-routed-to": alias,
lane = routed.get("x-mc-lane", "-") "x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
if frisch or not beschrieben: "x-mc-lane": routed.get("x-mc-lane", "-")}
alias = bild_ziel(alias, vision_alias, pol.get("coder_vision") or None) else:
alias = vision_alias
body["model"] = alias body["model"] = alias
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.). # HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild im aktuellen Schritt -> Bild-Zwilling", routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
"x-mc-lane": lane} "x-mc-lane": routed.get("x-mc-lane", "-")}
else: # fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
routed = {"x-mc-routed-to": alias, if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
"x-mc-route-reason": "aeltere Bilder beschrieben -> bleibt beim schnellen Modell",
"x-mc-lane": lane}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt) — auch wenn die
# Anfrage wegen eines Bildes beim Hirn-Zwilling landet (gleiches Hirn, gleiche Spur).
if pol["fast_no_think"] and angefragt == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False} body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias) _inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}" url = f"{LLAMA_SWAP_URL}{path}"
if body.get("stream"): if body.get("stream"):
req = client.build_request("POST", url, json=body, timeout=None) req = client.build_request("POST", url, json=body, timeout=None)
try: r = await client.send(req, stream=True)
r = await client.send(req, stream=True)
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
if r.status_code != 200: if r.status_code != 200:
await r.aread() await r.aread()
try: try:
@@ -306,15 +243,8 @@ async def _proxy(path: str, request: Request):
await r.aclose() await r.aclose()
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
try: r = await client.post(url, json=body, timeout=600.0)
r = await client.post(url, json=body, timeout=600.0) resp_json = r.json()
except httpx.HTTPError as exc:
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
try:
resp_json = r.json()
except ValueError:
log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text)
return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed)
if isinstance(resp_json, dict): if isinstance(resp_json, dict):
record_usage(resp_json.get("usage"), alias) record_usage(resp_json.get("usage"), alias)
if any(isinstance(c, dict) and c.get("finish_reason") == "length" if any(isinstance(c, dict) and c.get("finish_reason") == "length"
+15 -24
View File
@@ -1,12 +1,9 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz. """Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
Seit 24.09.2026 nennt er auch Rolle und Namen der Instanz: Die Partner-Instanz liest daran ab,
wer ihr antwortet. Engine, Gateway und Hirn gibt es nur auf der KI-Box."""
from config import VERSION from config import VERSION
from fastapi import APIRouter from fastapi import APIRouter
from kern.einstellungen import einstellungen
from pydantic import BaseModel from pydantic import BaseModel
from services import gateway, llamaswap
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -14,25 +11,19 @@ router = APIRouter(prefix="/api")
class HealthResponse(BaseModel): class HealthResponse(BaseModel):
status: str status: str
version: str version: str
rolle: str engine_reachable: bool
instanz: str gateway_reachable: bool
engine_reachable: bool | None = None brain: dict
gateway_reachable: bool | None = None
brain: dict | None = None
@router.get("/health", response_model=HealthResponse, response_model_exclude_none=True) @router.get("/health", response_model=HealthResponse)
def health() -> dict: def health() -> dict:
e = einstellungen() return {
antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz} "status": "ok",
if e.rolle == "box": "version": VERSION,
from services import gateway, llamaswap "engine_reachable": llamaswap.engine_reachable(),
"gateway_reachable": gateway.gateway_reachable(),
antwort.update( # Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
engine_reachable=llamaswap.engine_reachable(), # (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
gateway_reachable=gateway.gateway_reachable(), "brain": llamaswap.brain_status(),
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn aber tot }
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail.
brain=llamaswap.brain_status(),
)
return antwort
+9 -38
View File
@@ -6,19 +6,14 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für (/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets). alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
durchgereicht → keine eigene Firewall-Freigabe, kein CORS. Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`, Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann `/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
`:9119/...` weiter. `:9119/...` weiter.
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
""" """
import asyncio import asyncio
@@ -96,24 +91,6 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"') _BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"' _BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>" _HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
def praefixiere_ort(ort: str) -> str:
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
return f"/{_BASE}{ort}"
return ort
def praefixiere_html(body: bytes) -> bytes:
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
if _BASE_PATH_RE.search(body):
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
elif b"<head>" in body:
body = body.replace(b"<head>", _HEAD_INJECT, 1)
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
async def _proxy_http(request: Request, path: str = "") -> Response: async def _proxy_http(request: Request, path: str = "") -> Response:
@@ -136,18 +113,12 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
) )
body = r.content body = r.content
if "text/html" in r.headers.get("content-type", "").lower(): if "text/html" in r.headers.get("content-type", "").lower():
body = praefixiere_html(body) if _BASE_PATH_RE.search(body):
resp = Response(content=body, status_code=r.status_code) body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen elif b"<head>" in body:
# dürfen nicht zu einer zusammenfallen. body = body.replace(b"<head>", _HEAD_INJECT, 1)
for k, v in r.headers.multi_items(): resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
kl = k.lower() return Response(content=body, status_code=r.status_code, headers=resp_headers)
if kl in _DROP:
continue
if kl == "location":
v = praefixiere_ort(v)
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
return resp
@router.get(f"/{_BASE}") @router.get(f"/{_BASE}")
-157
View File
@@ -1,157 +0,0 @@
"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt)
Dünn: die Logik liegt in services/homelab/.
"""
import asyncio
import json
import time
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from kern.einstellungen import einstellungen
from pydantic import BaseModel
from services.homelab import inventar, kanal, updates
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
strom_router = APIRouter(prefix="/api", tags=["homelab"])
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
if not kanal.token_gueltig(x_mc2_ausfuehrer):
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
kanal.kontakt()
@router.get("/ziele")
def ziele() -> dict:
return inventar.ziele()
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
return ergebnis
@router.post("/ziele/{ziel_id}/update")
def update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
@router.post("/ziele/{ziel_id}/os-update")
def os_update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "os"))
@router.post("/ziele/{ziel_id}/docker")
def docker(ziel_id: str) -> dict:
"""Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt)."""
return _antwort(updates.starten(ziel_id, "docker"))
@router.post("/ziele/pve/neustart")
def host_neustart() -> dict:
return _antwort(updates.starten("pve", "neustart"))
@router.post("/ziele/{ziel_id}/suchen")
def suchen(ziel_id: str) -> dict:
gast = inventar.gast(ziel_id)
if not gast or not gast.get("erlaubt"):
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
@router.get("/hinweise")
def hinweise() -> dict:
"""Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …)."""
from services import waechter
return waechter.lese_stand()
@router.get("/laeufe")
def laeufe() -> dict:
return {"laeufe": updates.laeufe()}
@router.get("/ausfuehrer")
def ausfuehrer() -> dict:
zuletzt = kanal.zuletzt()
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"auftraege": kanal.liste()[:20]}
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
def auftrag_abholen() -> dict:
return kanal.naechster() or {}
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
def bericht(daten: dict) -> dict:
kanal.bericht_speichern(daten)
return {"ok": True}
class Ergebnis(BaseModel):
code: int
text: str = ""
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
# --- Live-Strom der Homelab-Instanz ---------------------------------------------------------------
# Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine
# Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern,
# und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot).
_DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"),
("homelab-hinweise", "mc2-waechter.json"))
def _abdruecke() -> dict[str, float]:
ordner = einstellungen().daten_dir
abdruck = {}
for schluessel, name in _DATEIEN:
try:
abdruck[schluessel] = (ordner / name).stat().st_mtime
except OSError:
abdruck[schluessel] = 0.0
return abdruck
async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5):
alt = _abdruecke()
yield ": verbunden\n\n"
takte = 0
while not await request.is_disconnected():
await asyncio.sleep(takt_s)
takte += 1
neu = _abdruecke()
schluessel = [k for k, v in neu.items() if v != alt.get(k)]
alt = neu
if schluessel or takte % lebenszeichen_takte == 0:
yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n"
@strom_router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
return StreamingResponse(_strom(request), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+154
View File
@@ -0,0 +1,154 @@
"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban).
LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import ideen
router = APIRouter(prefix="/api")
class IdeeIn(BaseModel):
titel: str
notiz: str = ""
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
class TaskIn(BaseModel):
id: str
class AntwortIn(BaseModel):
id: str
antwort: str
class ArchivAlleIn(BaseModel):
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
projekt: str = ""
class EinordnenIn(BaseModel):
text: str
@router.get("/ideen")
def list_queue() -> dict:
return ideen.list_queue()
@router.post("/ideen")
def add_idea(body: IdeeIn) -> dict:
res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe,
ziel=body.ziel, art=body.art)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
return res
@router.post("/ideen/antwort")
def answer(body: AntwortIn) -> dict:
res = ideen.answer_task(body.id, body.antwort)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
return res
class PrioIn(BaseModel):
id: str
richtung: str # hoch | runter
@router.post("/ideen/stopp")
def stop(body: TaskIn) -> dict:
res = ideen.stop_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
return res
@router.post("/ideen/retry")
def retry(body: TaskIn) -> dict:
res = ideen.retry_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
return res
@router.post("/ideen/prio")
def prio(body: PrioIn) -> dict:
res = ideen.prio_task(body.id, body.richtung)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
return res
class WeiterIn(BaseModel):
id: str
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
@router.post("/ideen/weiterfuehren")
def weiterfuehren(body: WeiterIn) -> dict:
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
return res
class NachschaerfenIn(BaseModel):
id: str
hinweis: str
@router.post("/ideen/nachschaerfen")
def nachschaerfen(body: NachschaerfenIn) -> dict:
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden."))
return res
@router.post("/ideen/archivieren")
def archive(body: TaskIn) -> dict:
res = ideen.archive_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
return res
@router.post("/ideen/einordnen")
def einordnen(body: EinordnenIn) -> dict:
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
und die Oberfläche behält ihre Voreinstellung."""
return ideen.einordnen(body.text)
@router.post("/ideen/archivieren-alle")
def archive_alle(body: ArchivAlleIn) -> dict:
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
res = ideen.archive_done(body.projekt)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
return res
@router.get("/ideen/{id}/log")
def get_log(id: str) -> dict:
return ideen.log_of(id)
@router.get("/ideen/{id}/ergebnis")
def get_ergebnis(id: str) -> dict:
return ideen.ergebnis_of(id)
@router.get("/ideen/{id}/konzept")
def get_konzept(id: str) -> dict:
return ideen.konzept_of(id)
+28 -47
View File
@@ -1,28 +1,19 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs. """Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box from fastapi import APIRouter, Header, HTTPException
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
mit einem im Browser geparkten Geheimnis übertüncht.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import geheimnisse, maintenance from services import maintenance
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
class SudoReq(BaseModel):
sudo_password: str | None = None
class RestartReq(BaseModel): class RestartReq(BaseModel):
service: str service: str
sudo_password: str | None = None
class GeheimnisReq(BaseModel):
schluessel: str
wert: str | None = None
@router.get("/maintenance/updates") @router.get("/maintenance/updates")
@@ -37,67 +28,57 @@ def update_details(kind: str) -> dict:
return maintenance.update_details(kind) return maintenance.update_details(kind)
@router.post("/maintenance/check-updates") @router.post("/maintenance/check-updates")
def check_updates() -> dict: def check_updates(body: SudoReq) -> dict:
res = maintenance.check_updates_job() res = maintenance.check_updates_job(body.sudo_password)
if isinstance(res, dict) and not res.get("ok", True): if isinstance(res, dict) and not res.get("ok", True):
return res return res
return res return res
@router.post("/maintenance/os-update") @router.post("/maintenance/os-update")
def os_update() -> dict: def os_update(body: SudoReq) -> dict:
res = maintenance.os_update_job() res = maintenance.os_update_job(body.sudo_password)
if isinstance(res, dict) and not res.get("ok", True): if isinstance(res, dict) and not res.get("ok", True):
return res return res
return res return res
@router.post("/maintenance/engine-update") @router.post("/maintenance/engine-update")
def engine_update() -> dict: def engine_update(body: SudoReq) -> dict:
res = maintenance.engine_update_job() res = maintenance.engine_update_job(body.sudo_password)
if not res: if not res:
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).") raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
return res return res
@router.post("/maintenance/swap-update") @router.post("/maintenance/swap-update")
def swap_update() -> dict: def swap_update(body: SudoReq) -> dict:
res = maintenance.swap_update_job() res = maintenance.swap_update_job(body.sudo_password)
if not res: if not res:
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).") raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
return res return res
@router.post("/maintenance/hermes-update") @router.post("/maintenance/hermes-update")
def hermes_update(verlauf: bool = True) -> dict: def hermes_update() -> dict:
"""verlauf=false: Der Sonntags-Lauf trägt das Ergebnis selbst in seinen Update-Verlauf ein.""" return maintenance.hermes_update_job()
return maintenance.hermes_update_job(verlauf=verlauf)
@router.post("/maintenance/update-all") @router.post("/maintenance/update-all")
def update_all() -> dict: def update_all(body: SudoReq) -> dict:
return maintenance.update_all_job() return maintenance.update_all_job(body.sudo_password)
@router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password)
@router.post("/maintenance/restart") @router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict: def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service) return maintenance.restart_service(body.service, body.sudo_password)
@router.get("/maintenance/logs") @router.get("/maintenance/logs")
def logs(service: str, lines: int = 200) -> dict: def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
return maintenance.logs(service, lines) return maintenance.logs(service, lines, x_sudo_password)
@router.get("/maintenance/geheimnisse")
def geheimnisse_status() -> dict:
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
return geheimnisse.status()
@router.post("/maintenance/geheimnisse")
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
if not geheimnisse.setzen(body.schluessel, body.wert):
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
return {"ok": True, **geheimnisse.status()}
+133 -35
View File
@@ -1,12 +1,11 @@
"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen, """Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen
(ohne Nutzer seit dem Box-Wart-Umbau)."""
import psutil import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap from services import budget, discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -30,6 +29,27 @@ def discover_models(force: bool = False) -> dict:
return {**data, "sys_ram_gb": round(ram, 1)} return {**data, "sys_ram_gb": round(ram, 1)}
@router.get("/fit")
def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192,
name: str = "", role: str = "") -> dict:
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben
würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM.
So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx."""
ram = _ram_gb()
pb = params_b if params_b > 0 else budget.params_b_for(name)
saw = budget.setup_aware_ctx(pb, quant, role=role or None)
return {
"params_b": round(pb, 1),
"fit": evaluate_fit(pb, quant, ctx, ram, name=name),
"optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein)
"assigned_ctx": saw["ctx"], # setup-bewusst vergeben
"budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"],
"budget_gb": saw["budget_gb"], "mode": saw["mode"]},
"sys_ram_gb": round(ram, 1),
}
class RegisterReq(BaseModel): class RegisterReq(BaseModel):
model_path: str model_path: str
role: str | None = None role: str | None = None
@@ -41,10 +61,6 @@ class RegisterReq(BaseModel):
@router.post("/models/register") @router.post("/models/register")
def register(req: RegisterReq) -> dict: def register(req: RegisterReq) -> dict:
# Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine.
for pfad in (req.model_path, req.mmproj_path):
if pfad and not llamaswap.im_modellordner(pfad):
raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}")
try: try:
model_id = llamaswap.register_model( model_id = llamaswap.register_model(
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl, req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
@@ -55,29 +71,13 @@ def register(req: RegisterReq) -> dict:
return {"ok": True, "model_id": model_id} return {"ok": True, "model_id": model_id}
@jobengine.nacharbeit("modell:rolle")
def _rolle_uebernehmen(model_id: str, role: str) -> None:
"""Nach dem Download die gewünschte Rolle setzen. hermes geht dabei durch den warm-bewussten
Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) — der rohe Alias-Move hatte
diesen Flow bisher umgangen."""
if role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
else:
llamaswap.set_role(model_id, role)
class InstallReq(BaseModel): class InstallReq(BaseModel):
repo: str repo: str
role: str | None = None role: str | None = None
quant: str = "Q4_K_M" quant: str = "Q4_K_M"
ctx: int | None = None ctx: int | None = None
jinja: bool = False jinja: bool = False
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage hf_token: str | None = None
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
@router.get("/hf/search") @router.get("/hf/search")
@@ -123,9 +123,20 @@ def install(req: InstallReq) -> dict:
except PermissionError as exc: except PermissionError as exc:
raise HTTPException(500, str(exc)) raise HTTPException(500, str(exc))
# Rolle erst NACH erfolgreichem Download übernehmen (_rolle_uebernehmen). # Rolle erst NACH erfolgreichem Download übernehmen. hermes geht dabei durch den
# warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) —
# der rohe Alias-Move hatte diesen Flow bisher umgangen.
role = (req.role or "").strip().lower() role = (req.role or "").strip().lower()
def _apply_role() -> None:
if role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
else:
llamaswap.set_role(model_id, role)
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen. # Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", repo] args = [hf.hf_bin(), "download", repo]
args.extend(info["files"]) args.extend(info["files"])
@@ -133,13 +144,10 @@ def install(req: InstallReq) -> dict:
args.append(info["mmproj"]) args.append(info["mmproj"])
args += ["--local-dir", str(target)] args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV) env = dict(HF_DOWNLOAD_ENV)
if token := geheimnisse.hf_token(): if req.hf_token:
env["HF_TOKEN"] = token env["HF_TOKEN"] = req.hf_token
# Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf. job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
# Der Download läuft als eigener Auftrag weiter, auch wenn MC2 zwischendurch neu startet. on_done=_apply_role if role else None)
job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download", zeitlimit_s=6 * 3600,
nacharbeit="modell:rolle" if role else None,
nacharbeit_daten={"model_id": model_id, "role": role} if role else None)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path, return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])} "total_bytes": info["total_bytes"], "files": len(info["files"])}
@@ -159,6 +167,14 @@ class RoleReq(BaseModel):
role: str | None = None role: str | None = None
@router.get("/roles/{role}/recommend")
def recommend_role(role: str) -> dict:
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
from services import roles
return roles.recommend_for_role(role)
@router.post("/models/{model_id}/role") @router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict: def set_model_role(model_id: str, body: RoleReq) -> dict:
new_role = (body.role or "").strip().lower() new_role = (body.role or "").strip().lower()
@@ -183,6 +199,54 @@ def set_model_role(model_id: str, body: RoleReq) -> dict:
return {"ok": True} return {"ok": True}
class CtxReq(BaseModel):
ctx: int
@router.get("/models/{model_id}/ctx/auto")
def auto_ctx(model_id: str) -> dict:
"""Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant +
aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte."""
m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None)
if not m:
raise HTTPException(404, "Modell nicht gefunden")
saw = budget.setup_aware_ctx_for_model(m)
return {"model_id": model_id, "current_ctx": m.get("ctx"),
"params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"),
"role": m.get("role"), **saw}
@router.post("/models/{model_id}/ctx")
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
if not llamaswap.set_ctx(model_id, body.ctx):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.get("/models/drafts")
def list_drafts(target: str = "") -> dict:
"""Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell
(target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI."""
return llamaswap.drafts_for(target)
class DraftReq(BaseModel):
draft_path: str | None = None
@router.post("/models/{model_id}/draft")
def set_model_draft(model_id: str, body: DraftReq) -> dict:
"""Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible
(oder nicht prüfbare) Drafts werden serverseitig abgelehnt."""
try:
res = llamaswap.set_spec_draft(model_id, body.draft_path)
except PermissionError as exc:
raise HTTPException(500, str(exc))
if not res["ok"]:
raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"])
return res
@router.post("/models/unload") @router.post("/models/unload")
def unload_all_models() -> dict: def unload_all_models() -> dict:
import httpx import httpx
@@ -209,8 +273,21 @@ def unload_model(model_id: str) -> dict:
@router.post("/models/{model_id}/load") @router.post("/models/{model_id}/load")
def load_model(model_id: str) -> dict: def load_model(model_id: str) -> dict:
"""Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap).""" import httpx
return llamaswap.lade_modell(model_id) from config import LLAMA_SWAP_URL
try:
# Trigger load by sending a lightweight completion request.
body = {
"model": model_id,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
# High timeout because model loading might take time
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.delete("/models/{model_id}") @router.delete("/models/{model_id}")
@@ -218,3 +295,24 @@ def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id): if not llamaswap.delete_model(model_id):
raise HTTPException(404, "Modell nicht gefunden") raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True} return {"ok": True}
@router.get("/groups")
def groups() -> dict:
return {"groups": llamaswap.list_groups()}
class GroupReq(BaseModel):
group: str
members: list[str]
swap: bool = False
persist: bool = False
@router.put("/groups")
def set_group(req: GroupReq) -> dict:
try:
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True}
-55
View File
@@ -1,55 +0,0 @@
"""Partner-Instanz: Lebenszeichen und Durchreiche (zwei Instanzen, eine Oberfläche, 24.09.2026).
Die Oberfläche fragt den Bereich der anderen Instanz über /api/partner/<pfad> ab; hier wird
daraus <partner>/api/<pfad>. Die Herkunftsprüfung (services/herkunft.py) greift wie bei jeder
anderen schreibenden Anfrage schon hier, bevor etwas weitergereicht wird.
"""
import httpx
from fastapi import APIRouter, HTTPException, Request, Response
from kern import partner
from kern.einstellungen import einstellungen
from services import software_stand
router = APIRouter(prefix="/api", tags=["partner"])
# Nicht durchreichen: den Partner-Weg des Partners (sonst reichen sich zwei Instanzen eine Anfrage
# endlos zu) und den Live-Strom (SSE hält die Verbindung offen; der Partner-Bereich fragt ab).
_GESPERRT = ("partner", "stream")
_ZEITLIMIT = httpx.Timeout(30.0, connect=5.0)
@router.get("/instanz")
def instanz() -> dict:
"""Wer liefert die Seite aus? Ohne Netzabfragen, damit die Oberfläche sofort weiß, welche Anfragen
sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen. Seit 24.09.2026 mit dem
Software-Stand (welcher Commit seit wann live ist); den der anderen Instanz liefert /api/partner/instanz."""
e = einstellungen()
return {"rolle": e.rolle, "instanz": e.instanz,
"partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name},
"stand": software_stand.stand()}
@router.get("/partner")
def partner_status() -> dict:
return partner.status()
@router.api_route("/partner/{pfad:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
async def weiterreichen(pfad: str, request: Request) -> Response:
e = einstellungen()
if not e.partner_url:
raise HTTPException(status_code=404, detail="Es ist keine zweite Instanz eingerichtet.")
if pfad.split("/", 1)[0] in _GESPERRT:
raise HTTPException(status_code=404, detail="Diese Schnittstelle wird nicht weitergereicht.")
kopf = {"X-MC-Von": e.instanz}
if request.headers.get("content-type"):
kopf["Content-Type"] = request.headers["content-type"]
try:
async with httpx.AsyncClient(timeout=_ZEITLIMIT) as c:
r = await c.request(request.method, f"{e.partner_url}/api/{pfad}",
params=request.query_params, content=await request.body(), headers=kopf)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail=f"{e.partner_name} ist gerade nicht erreichbar.") from None
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type", "application/json"))
-43
View File
@@ -1,43 +0,0 @@
"""
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) — als Auftrag, antwortet sofort
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py).
"""
from fastapi import APIRouter, HTTPException
from services import radar
router = APIRouter(prefix="/api", tags=["radar"])
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=int(ergebnis.get("status") or 409),
detail=ergebnis.get("detail") or "Das ging nicht.")
return ergebnis
@router.get("/radar")
def radar_stand() -> dict:
return radar.uebersicht()
@router.post("/radar/suche")
def radar_suche() -> dict:
"""Startet die Suche als Auftrag (sie kann Minuten dauern) und antwortet sofort mit der Auftrags-ID."""
return radar.suche_starten()
@router.post("/radar/{kandidat_id}/uebernehmen")
def radar_uebernehmen(kandidat_id: str) -> dict:
return _antwort(radar.uebernehmen(kandidat_id))
@router.post("/radar/{kandidat_id}/verwerfen")
def radar_verwerfen(kandidat_id: str) -> dict:
return _antwort(radar.verwerfen(kandidat_id))
+33 -3
View File
@@ -1,8 +1,9 @@
"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau """Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import gateway from services import gateway
from services.routing_policy import policy_meta, save_policy
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -10,3 +11,32 @@ router = APIRouter(prefix="/api")
@router.get("/routing") @router.get("/routing")
def routing() -> dict: def routing() -> dict:
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()} return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
@router.get("/routing/policy")
def get_policy() -> dict:
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
return policy_meta()
class PolicyPatch(BaseModel):
fast: str | None = None
heavy: str | None = None
coder: str | None = None
coder_lite: str | None = None
heavy_chars: int | None = None
coding_escalate_chars: int | None = None
fast_no_think: bool | None = None
@router.put("/routing/policy")
def put_policy(patch: PolicyPatch) -> dict:
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
try:
new_policy = save_policy(fields)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
return {"policy": new_policy}
+83
View File
@@ -0,0 +1,83 @@
import os
import subprocess
import psutil
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/api")
class RunSkillRequest(BaseModel):
skill_name: str
@router.get("/skills")
def list_skills():
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
current_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(os.path.dirname(current_dir))
skills_dir = os.path.join(repo_root, "deploy", "skills")
skills = []
# Check currently running hermes skill processes
running_skills = set()
for p in psutil.process_iter(['name', 'cmdline']):
try:
cmdline = p.info.get('cmdline')
if cmdline and any("hermes" in arg for arg in cmdline):
for arg in cmdline:
if "Führe den " in arg and " Skill aus" in arg:
# Extract skill name from "Führe den 'skill_name' Skill aus"
import re
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
if match:
running_skills.add(match.group(1))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
for entry in os.scandir(skills_dir):
if entry.is_dir():
# Suche nach SKILL.md für Metadaten
skill_md_path = os.path.join(entry.path, "SKILL.md")
description = ""
if os.path.exists(skill_md_path):
try:
with open(skill_md_path, "r", encoding="utf-8") as f:
# Lese erste Zeilen für Description (YAML Frontmatter)
lines = f.readlines()[:10]
for line in lines:
if "description:" in line.lower():
description = line.split(":", 1)[1].strip().strip('"\'')
break
elif "author:" in line.lower() and not description:
description = line.strip()
except Exception:
pass
skills.append({
"name": entry.name,
"description": description or "Keine Beschreibung verfügbar.",
"running": entry.name in running_skills
})
return {"skills": skills}
@router.post("/skills/run")
def run_skill(req: RunSkillRequest):
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
if not os.path.exists(hermes_bin):
# Fallback falls lokal (auf Windows) entwickelt wird
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat
cmd = [hermes_bin, "-z", f"Führe den '{req.skill_name}' Skill aus", "chat"]
try:
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{req.skill_name.replace('/', '_')}.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a") as f:
f.write(f"\n--- Starting Skill: {req.skill_name} ---\n")
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
except Exception as e:
return {"ok": False, "err": str(e)}
+46 -15
View File
@@ -1,4 +1,4 @@
"""System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch. """System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern). Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
@@ -10,11 +10,11 @@ import os
import subprocess import subprocess
import httpx import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
from services import backup as backup_svc from services import backup as backup_svc
from services import maintenance, waechter from services import maintenance
from services.agent import agent_status from services.agent import agent_status
from services.gateway import gateway_reachable from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models from services.llamaswap import engine_reachable, list_models
@@ -26,11 +26,29 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status") @router.get("/system/status")
def status() -> dict: def status() -> dict:
return system_status() return system_status()
@router.get("/system/history")
def history(minutes: int = 60) -> dict:
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
from services import metrics_history
return metrics_history.history(minutes)
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
def _voice_reachable() -> bool: def _voice_reachable() -> bool:
try: try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
@@ -69,7 +87,7 @@ def services() -> dict:
"ok": gateway_reachable(), "scope": "user"}) "ok": gateway_reachable(), "scope": "user"})
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich; # MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel. # die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
rows.append({"name": "Box-Wart (MC2)", "unit": "mission-control-2", "url": gw_url, rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
"ok": True, "scope": "user"}) "ok": True, "scope": "user"})
else: else:
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
@@ -79,20 +97,15 @@ def services() -> dict:
"ok": _user_unit_active("mc2-steward"), "scope": "user"}, "ok": _user_unit_active("mc2-steward"), "scope": "user"},
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL, {"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
"ok": a["gateway_reachable"], "scope": "user"}, "ok": a["gateway_reachable"], "scope": "user"},
{"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], {"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
"ok": a["hermes_ui_reachable"], "scope": "user"}, "ok": a["hermes_ui_reachable"], "scope": "user"},
{"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL, {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
"ok": _voice_reachable(), "scope": "user"}, "ok": _voice_reachable(), "scope": "user"},
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte # ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus. # das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "", {"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
"ok": _user_unit_active("lucy-stimme"), "scope": "user"}, "ok": bool(a.get("box_console_reachable")), "scope": "user"},
] ]
# Bewusst abgeschaltete Dienste (24.09.2026: Spracherkennung bis zur Android-App) sind kein
# Fehler — die Oberfläche zeigt sie als „schläft“ mit Knopf zum Wecken statt rot.
for row in rows:
row["schlaeft"] = (not row["ok"] and row["scope"] == "user"
and waechter.schlaeft(waechter.dienst_zustand(row["unit"])))
return { return {
"services": rows, "services": rows,
"links": { "links": {
@@ -113,6 +126,15 @@ def backups() -> dict:
return {"backups": backup_svc.list_backups()} return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc:
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel): class RestartReq(BaseModel):
service: str service: str
@@ -127,6 +149,15 @@ def restart(req: RestartReq) -> dict:
return maintenance.restart_service(req.service) return maintenance.restart_service(req.service)
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
@router.get("/system/token-stats") @router.get("/system/token-stats")
def token_stats() -> dict: def token_stats() -> dict:
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT).""" """Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
+357 -289
View File
@@ -1,289 +1,357 @@
""" """
Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter). Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) — Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
`X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste, """
Klon-Referenz und das alte Piper/Chatterbox-TTS.
""" import logging
import os
import json
import logging # Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import os import sys as _sys
import time
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
import httpx from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL from fastapi.responses import Response, StreamingResponse
from fastapi import APIRouter, File, Form, HTTPException, UploadFile from pydantic import BaseModel
from fastapi.responses import Response, StreamingResponse from services import announce
from pydantic import BaseModel from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
from services import announce Timer,
TurnTrace,
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") get_metrics,
if _GUARD_DIR not in _sys.path: get_trace,
_sys.path.insert(0, _GUARD_DIR) park,
try: record_stage,
from guard import wrap_untrusted )
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
def wrap_untrusted(text: str, label: str = "") -> str: _GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
return text if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR)
log = logging.getLogger(__name__) try:
router = APIRouter(prefix="/api") from guard import wrap_untrusted
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an def wrap_untrusted(text: str, label: str = "") -> str:
# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar. return text
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes. log = logging.getLogger(__name__)
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) router = APIRouter(prefix="/api")
_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
def _sse_fehler(text: str) -> bytes: # (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client).""" VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode() # Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
async def _describe_images(image_urls: list[str], hint: str) -> str:
"""Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). async def _describe_images(image_urls: list[str], hint: str) -> str:
Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler.""" """Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
multi = len(image_urls) > 1 Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " multi = len(image_urls) > 1
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). " intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
"Keine Einleitung, keine Wiederholung der Frage. " "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
if multi else "Keine Einleitung, keine Wiederholung der Frage. "
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot " if multi else
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ") "Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}] "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
for u in image_urls: content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
content.append({"type": "image_url", "image_url": {"url": u}}) for u in image_urls:
try: content.append({"type": "image_url", "image_url": {"url": u}})
# 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne try:
# Bildschirm-Kontext antworten als ewig hängen. # 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client: # kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"messages": [{"role": "user", "content": content}], "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
}) "messages": [{"role": "user", "content": content}],
r.raise_for_status() })
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() r.raise_for_status()
except Exception as exc: return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) except Exception as exc:
return "" log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
return ""
class ChatIn(BaseModel): _TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
text: str # die neue User-Äußerung (STT-Ergebnis)
session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key) class TTSIn(BaseModel):
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") text: str
model: str = "" engine: str = "piper"
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") voice: str = ""
language: str = ""
ref_path: str = ""
class AnnounceIn(BaseModel):
text: str # die Meldung (wird von Lucy gesprochen)
subject: str = "" # kurze Betreffzeile (z.B. "[Update]") class ChatIn(BaseModel):
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel text: str # die neue User-Äußerung (STT-Ergebnis)
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen session_id: str # stabiler Voice-Faden → server-seitiger Transcript
session_key: str = "" # optional: Langzeit-Memory-Scope
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
class AlarmIn(BaseModel): model: str = ""
text: str # die Alarm-Meldung images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
class AnnounceIn(BaseModel):
text: str # die Meldung (wird von Lucy gesprochen)
@router.post("/alarm") subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
def alarm(body: AlarmIn) -> dict: source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht
auch nachts sofort raus (notify.sh).""" class AlarmIn(BaseModel):
text = (body.text or "").strip() text: str # die Alarm-Meldung
if not text: subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
raise HTTPException(400, "Leere Meldung.") source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
subject = (body.subject or "[Alarm]").strip()
try:
item = announce.add(text, subject, body.source or "alarm", "normal") @router.post("/alarm")
except ValueError as exc: def alarm(body: AlarmIn) -> dict:
raise HTTPException(400, str(exc)) """Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op) Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
return {"ok": True, "item": item} der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
wie alle MC2-Endpoints."""
@router.post("/voice/announce") text = (body.text or "").strip()
def voice_announce(body: AnnounceIn) -> dict: if not text:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh raise HTTPException(400, "Leere Meldung.")
(Updates/Radar/Telegram-Spiegel), Hermes-Cron.""" subject = (body.subject or "[Alarm]").strip()
try: try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} item = announce.add(text, subject, body.source or "alarm", "normal")
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
return {"ok": True, "item": item}
@router.get("/voice/announcements")
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den @router.post("/voice/announce")
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach.""" def voice_announce(body: AnnounceIn) -> dict:
return announce.list_after(after, limit) """Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
try:
@router.post("/voice/stt") return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: except ValueError as exc:
"""Mikro-Audio → Text (Proxy auf Sidecar /stt).""" raise HTTPException(400, str(exc))
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.") @router.get("/voice/announcements")
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
try: """Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client: aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) return announce.list_after(after, limit)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc: @router.get("/voice/health")
raise HTTPException(502, f"STT fehlgeschlagen: {exc}") def voice_health() -> dict:
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
@router.post("/voice/turn") try:
async def voice_turn(audio: UploadFile = File(...)) -> dict: r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar.""" out["sidecar"] = r.status_code == 200
data = await audio.read() out["detail"] = r.json() if r.status_code == 200 else None
if not data: except Exception as exc:
raise HTTPException(400, "Leeres Audio.") out["error"] = str(exc)
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")} return out
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files) @router.get("/voice/metrics")
r.raise_for_status() def voice_metrics() -> dict:
return r.json() """Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
except httpx.HTTPError as exc: return get_metrics()
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc)
return {"complete": True, "probability": 1.0, "engine": "fallback"} @router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
@router.post("/voice/chat") Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
async def voice_chat(body: ChatIn) -> StreamingResponse: damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. return {"turns": get_trace(limit)}
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401.""" @router.get("/voice/voices")
if not HERMES_API_KEY: def voice_voices() -> dict:
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") try:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
headers = { r.raise_for_status()
"Authorization": f"Bearer {HERMES_API_KEY}", return r.json()
"X-Hermes-Session-Id": body.session_id, except Exception as exc:
} raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
if body.session_key:
headers["X-Hermes-Session-Key"] = body.session_key
@router.post("/voice/stt")
async def gen(): async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
# Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client """Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
# bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der data = await audio.read()
# Request hängt, während das Bild-Modell beschreibt. if not data:
user_text = body.text raise HTTPException(400, "Leeres Audio.")
imgs = [u for u in (body.images or []) if u] files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
if imgs: try:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
desc = await _describe_images(imgs, body.text) _t0 = time.perf_counter()
if desc: r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
safe_desc = wrap_untrusted(desc, "BILDSCHIRM") _ms = (time.perf_counter() - _t0) * 1000.0
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}" record_stage("stt", _ms)
messages = [] park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
if body.system: r.raise_for_status()
messages.append({"role": "system", "content": body.system}) return r.json()
messages.append({"role": "user", "content": user_text}) except httpx.HTTPError as exc:
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True} raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
# Lucys Hirn ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst
# generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s).
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"): @router.post("/voice/turn")
payload["chat_template_kwargs"] = {"enable_thinking": False} async def voice_turn(audio: UploadFile = File(...)) -> dict:
try: """Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: data = await audio.read()
async with client.stream( if not data:
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers, raise HTTPException(400, "Leeres Audio.")
) as r: files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
if r.status_code != 200: try:
detail = (await r.aread()).decode("utf-8", "replace")[:500] async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
yield _sse_fehler(f"Hermes {r.status_code}: {detail}") with Timer("turn"):
return r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
async for chunk in r.aiter_raw(): r.raise_for_status()
yield chunk return r.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}") # Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc)
return StreamingResponse(gen(), media_type="text/event-stream") return {"complete": True, "probability": 1.0, "engine": "fallback"}
# --------------------------------------------------------------------------------------------- @router.post("/voice/reference")
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, data = await audio.read()
# /tts/stream -> PCM16 + X-Sample-Rate). if not data:
raise HTTPException(400, "Leeres Audio.")
class LucyTtsIn(BaseModel): files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
text: str try:
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
r.raise_for_status()
@router.get("/lucy/stimme/health") return r.json()
def lucy_stimme_health() -> dict: except httpx.HTTPError as exc:
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading).""" raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
try:
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
r.raise_for_status() @router.get("/voice/reference")
return r.json() def voice_get_reference() -> dict:
except Exception as exc: try:
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}") r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
@router.post("/lucy/stimme/tts") except Exception as exc:
async def lucy_stimme_tts(body: LucyTtsIn) -> Response: return {"active": False, "error": str(exc)}
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client: @router.delete("/voice/reference")
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True)) def voice_clear_reference() -> dict:
r.raise_for_status() try:
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"), r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")}) r.raise_for_status()
except httpx.HTTPStatusError as exc: return r.json()
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}") except httpx.HTTPError as exc:
except httpx.HTTPError as exc: raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
@router.post("/voice/tts")
@router.post("/lucy/stimme/tts/stream") async def voice_tts(body: TTSIn) -> Response:
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse: """Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate). try:
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream.""" with Timer("tts"):
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
try: r.raise_for_status()
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True)) return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
upstream = await client.send(req, stream=True) except httpx.HTTPError as exc:
except httpx.HTTPError as exc: raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
await client.aclose()
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
if upstream.status_code != 200: @router.post("/voice/chat")
detail = (await upstream.aread()).decode("utf-8", "replace")[:200] async def voice_chat(body: ChatIn) -> StreamingResponse:
await upstream.aclose(); await client.aclose() """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
async def gen(): Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
try: if not HERMES_API_KEY:
async for chunk in upstream.aiter_raw(): raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
yield chunk
finally: headers = {
await upstream.aclose() "Authorization": f"Bearer {HERMES_API_KEY}",
await client.aclose() "X-Hermes-Session-Id": body.session_id,
}
return StreamingResponse(gen(), media_type="application/octet-stream", if body.session_key:
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")}) headers["X-Hermes-Session-Key"] = body.session_key
async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True
first_content = True
committed = False
def _commit() -> None:
nonlocal committed
if not committed:
committed = True
trace.commit()
try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text
imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs)
if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
_tv = time.perf_counter()
desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream(
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
) as r:
if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}"
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return
async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb()
first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk:
trace.note_first_content()
first_content = False
yield chunk
except httpx.HTTPError as exc:
trace.error = "verbindung"
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream")
+151
View File
@@ -0,0 +1,151 @@
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
import os
import re
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api")
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
def _available() -> bool:
return VAULT.is_dir()
def _title_of(p: Path) -> str:
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
try:
with p.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if s:
return s.lstrip("# ").strip()[:160]
except OSError:
pass
return p.stem
class VaultFile(BaseModel):
path: str
name: str
dir: str
title: str
mtime: float
neu: bool
class WissenListResponse(BaseModel):
available: bool
files: list[VaultFile]
class WissenFileResponse(BaseModel):
path: str
content: str
mtime: float
@router.get("/wissen", response_model=WissenListResponse)
def list_vault() -> dict:
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
if not _available():
return {"available": False, "files": []}
files = []
now = time.time()
for p in sorted(VAULT.rglob("*.md")):
if ".git" in p.parts or "traeume" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
st = p.stat()
files.append({
"path": rel,
"name": p.stem,
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
"title": _title_of(p),
"mtime": st.st_mtime,
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
})
files.sort(key=lambda f: f["mtime"], reverse=True)
return {"available": True, "files": files}
@router.get("/wissen/datei", response_model=WissenFileResponse)
def read_file(pfad: str) -> dict:
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
if not _available():
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
try:
target = (VAULT / pfad).resolve()
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
raise HTTPException(404, "Notiz nicht gefunden.")
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
"mtime": target.stat().st_mtime}
except HTTPException:
raise
except (ValueError, OSError):
raise HTTPException(404, "Notiz nicht lesbar.")
@router.get("/wissen/graph")
def graph_vault() -> dict:
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
if not _available():
return {"nodes": [], "edges": []}
nodes = []
edges = []
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
name_to_id = {}
# 1. Alle Nodes sammeln
for p in VAULT.rglob("*.md"):
if ".git" in p.parts or "traeume" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
name_to_id[p.stem.lower()] = rel
# Kategorie aus dem Ordner ableiten
cat = "knowledge"
parent = p.parent.name if p.parent != VAULT else ""
if parent == "traeume":
cat = "events"
elif parent == "muster":
cat = "rules"
elif parent == "skill-kandidaten":
cat = "identity"
nodes.append({
"id": rel,
"content": p.stem, # stem ist oft kürzer/prägnanter als der Titel
"category": cat,
"source": "wiki"
})
# 2. Edges extrahieren (Wiki-Links [[Name]])
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
for n in nodes:
try:
target = VAULT / n["id"]
content = target.read_text(encoding="utf-8", errors="replace")
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)}
for link in found_links:
target_id = name_to_id.get(link)
if target_id and target_id != n["id"]:
edges.append({
"source": n["id"],
"target": target_id,
"weight": 1.0
})
except OSError:
pass
return {"nodes": nodes, "edges": edges}
+11
View File
@@ -31,6 +31,17 @@ def list_snapshots() -> dict:
return {"available": os.name == "posix", "backups": backup_svc.list_backups()} return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
@router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file
if not p.is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
return {"file": file, "components": backup_svc.snapshot_components(p)}
@router.post("/zeitmaschine/restore") @router.post("/zeitmaschine/restore")
def restore(body: RestoreIn) -> dict: def restore(body: RestoreIn) -> dict:
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein """Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Parse systemd timeout errors for mission-control-2.service from journalctl.
Captures lines containing "State 'stop-sigterm' timed out" and appends them
to ~/logs/mc2-timeout.log with ISO timestamps.
Permission errors are handled gracefully.
Uses user journal (systemctl --user) to query logs.
"""
import subprocess
from datetime import datetime, timezone
from pathlib import Path
LOG_DIR = Path.home() / "logs"
LOG_FILE = LOG_DIR / "mc2-timeout.log"
SERVICE_NAME = "mission-control-2.service"
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
JOURNALCTL_LIMIT = 1000
def ensure_log_dir() -> None:
"""Create log directory if it doesn't exist."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
def get_timeout_entries() -> list[str]:
"""Run journalctl --user and return lines matching the timeout pattern."""
cmd = [
"journalctl",
"--user",
"-u", SERVICE_NAME,
"--no-pager",
"-n", str(JOURNALCTL_LIMIT),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
print("journalctl timed out")
return []
except PermissionError:
print("Permission denied: journalctl requires access to user logs")
return []
if result.returncode != 0:
if "Permission denied" in result.stderr:
print("Permission denied: journalctl requires access to user logs")
else:
print(f"journalctl failed: {result.stderr.strip()}")
return []
return [
line
for line in result.stdout.splitlines()
if SEARCH_PATTERN in line
]
def write_to_log(entries: list[str]) -> int:
"""Append entries to log file with ISO timestamps. Returns count written."""
ensure_log_dir()
timestamp = datetime.now(timezone.utc).isoformat()
written = 0
with LOG_FILE.open("a", encoding="utf-8") as f:
for entry in entries:
f.write(f"[{timestamp}] {entry}\n")
written += 1
return written
def main() -> None:
entries = get_timeout_entries()
if not entries:
print("No timeout entries found.")
return
count = write_to_log(entries)
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
if __name__ == "__main__":
main()
+118 -71
View File
@@ -1,22 +1,36 @@
""" """
Hermes-Agent: Status und Hirn-Umstellung. MC betreibt Hermes NICHT — Werkzeuge und MCP werden in Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
Hermes' eigener Config verdrahtet (siehe docs/ARCHITEKTUR.md, „Wer schreibt was"). Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
""" """
import logging import logging
import os import os
import re
import httpx import httpx
from config import ( from config import (
BOX_CONSOLE_PATH,
BOX_CONSOLE_UPSTREAM,
HERMES_API_URL, HERMES_API_URL,
HERMES_BUILTIN_UI_PATH, HERMES_BUILTIN_UI_PATH,
HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_UPSTREAM,
HERMES_HOME, HERMES_HOME,
PC_EXECUTOR_URL,
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower()
if "hermes" not in low:
return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None
def _active_brain_name() -> str: def _active_brain_name() -> str:
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model).""" """Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
try: try:
@@ -92,29 +106,76 @@ def _reach(url: str, path: str = "") -> bool:
return False return False
def agent_status() -> dict: def _count_enabled_mcp_servers() -> int:
"""Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste. config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return 0
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(mcp_servers, dict):
return 0
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
except Exception:
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
return 0
def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
home = HERMES_HOME
brain_model = "auto"
config_path = home / "config.yaml"
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict):
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
m = cfg.get("model", {}) or {}
brain_model = m.get("default") or m.get("model") or "auto"
except Exception:
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
Bis 24.09.2026 fragte die Funktion bei jedem Aufruf auch die abgebaute Konsole und den
PC-Ausführer ab (3 s Zeitlimit) und las die Hermes-Config, obwohl nur diese Felder gebraucht
werden. Seit der PC-Ausführer schläft, hätte das jede Dienste-Abfrage um 3 s verzögert."""
return { return {
"gateway_url": HERMES_API_URL, "gateway_url": HERMES_API_URL,
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/). # Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
"hermes_ui_url": HERMES_BUILTIN_UI_PATH, "hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"), "gateway_reachable": _reach(HERMES_API_URL, "/health"),
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"), "hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
"pc_executor_url": PC_EXECUTOR_URL,
} }
def set_agent_brain(model_id: str, hermes_umstellen: bool = True) -> dict: def set_agent_brain(model_id: str) -> dict:
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst: """Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot), 1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben), 2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart. 3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
So bleibt das neue Hirn warm und der Agent nutzt es sofort. So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
hermes_umstellen=False: Schritt 3 macht der Aufrufer selbst — etwa das Radar, das erst nach dem
einen gesammelten Schreibvorgang der llama-swap-Config Hermes neu startet."""
from services import llamaswap from services import llamaswap
models = {m["name"]: m for m in llamaswap.list_models()} models = {m["name"]: m for m in llamaswap.list_models()}
if model_id not in models: if model_id not in models:
@@ -124,30 +185,26 @@ def set_agent_brain(model_id: str, hermes_umstellen: bool = True) -> dict:
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen. # Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
try: try:
from services.llamaswap import set_ttl from services.llamaswap import set_ttl
with llamaswap.sammeln(): brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] if model_id not in brains:
if model_id not in brains: llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True) set_ttl(model_id, 0)
set_ttl(model_id, 0)
except PermissionError as exc: except PermissionError as exc:
return {"ok": False, "reason": str(exc)} return {"ok": False, "reason": str(exc)}
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"} return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
try: try:
llamaswap.set_role(model_id, "hermes") # 1) Alias
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import DEFAULT_TTL, set_ttl from services.llamaswap import DEFAULT_TTL, set_ttl
# Alias, Gruppe und ttl als EIN Schreibvorgang (24.09.2026) — jeder einzelne entlud alle Modelle. set_ttl(model_id, 0)
with llamaswap.sammeln(): if old:
llamaswap.set_role(model_id, "hermes") # 1) Alias set_ttl(old, DEFAULT_TTL)
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
set_ttl(model_id, 0)
if old:
set_ttl(old, DEFAULT_TTL)
except PermissionError as exc: except PermissionError as exc:
return {"ok": False, "reason": str(exc)} return {"ok": False, "reason": str(exc)}
if hermes_umstellen: update_brain_model("hermes") # 3) Config + Restart
update_brain_model("hermes") # 3) Config + Restart
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT? # Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
warning = None warning = None
try: try:
@@ -163,59 +220,49 @@ def set_agent_brain(model_id: str, hermes_umstellen: bool = True) -> dict:
def update_brain_model(new_model: str) -> bool: def update_brain_model(new_model: str) -> bool:
"""Setzt Lucys Hirn in Hermes' config.yaml (model.default + model.model) und startet den
Hermes-Gateway neu.
Seit 24.09.2026 vorsichtig: Ist die Datei nicht lesbar (halb geschrieben, Syntaxfehler), wird
NICHTS geschrieben — früher entstand dann eine neue Datei nur mit dem model-Block, und der Rest
von Hermes' Konfiguration wäre weg gewesen. Geschrieben wird atomar (tmp + os.replace), vorher
liegt eine Sicherung config.yaml.bak-hirn-<Zeitstempel> daneben."""
import shutil
import time as _time
from config import HERMES_HOME from config import HERMES_HOME
from ruamel.yaml import YAML home = HERMES_HOME
config_path = home / "config.yaml"
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists(): # Ensure home directory exists
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path) home.mkdir(parents=True, exist_ok=True)
return False
r_yaml = YAML() cfg = {}
r_yaml.preserve_quotes = True if config_path.exists():
r_yaml.width = 100 try:
r_yaml.indent(mapping=2, sequence=4, offset=2) from ruamel.yaml import YAML
try: r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f: with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) cfg = r_yaml.load(f) or {}
except Exception: except Exception:
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True) log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
return False cfg = {}
if not isinstance(cfg, dict): if not isinstance(cfg, dict):
log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben") cfg = {}
return False
if "model" not in cfg or not isinstance(cfg["model"], dict): if "model" not in cfg or not isinstance(cfg["model"], dict):
cfg["model"] = {} cfg["model"] = {}
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param. # Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
# Beide setzen, sonst greift die Umschaltung nicht. # Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
cfg["model"]["default"] = new_model cfg["model"]["default"] = new_model
cfg["model"]["model"] = new_model cfg["model"]["model"] = new_model
tmp = config_path.with_suffix(".yaml.neu")
try: try:
shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}")) from ruamel.yaml import YAML
with tmp.open("w", encoding="utf-8") as f: r_yaml = YAML()
with config_path.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f) r_yaml.dump(cfg, f)
YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein
os.replace(tmp, config_path) # Restart the user-space service to apply changes
try:
from services import maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True
except Exception: except Exception:
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True) log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
tmp.unlink(missing_ok=True)
return False return False
try:
from services import maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
return True
+10 -2
View File
@@ -20,13 +20,13 @@ import time
from pathlib import Path from pathlib import Path
import httpx import httpx
from kern.einstellungen import einstellungen from config import MODELS_DIR
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh") NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(einstellungen().daten_dir / "mc2-announce.json"))) STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200")) MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses # Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
@@ -112,6 +112,14 @@ def notify_telegram(subject: str, text: str) -> None:
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True) log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
def list_recent(limit: int = 150) -> list[dict]:
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
with _lock:
state = _load()
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
def list_after(after: int | None, limit: int = 20) -> dict: def list_after(after: int | None, limit: int = 20) -> dict:
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen """Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern.""" Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
-379
View File
@@ -1,379 +0,0 @@
"""
Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden?
Zwei Arten von Kandidaten:
• Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre
Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen.
• Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale,
Reserve-Modelle, Drafts aus früheren Versuchen).
Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
Seit 24.09.2026 zusätzlich: Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees, Alt-Units) aus
einer festen Liste — siehe ALTRESTE unten.
"""
import os
import shutil
import stat
import subprocess
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from config import MODELS_DIR
from services import llamaswap
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"}
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
HINWEISE = {
"Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn "
"einmal Probleme macht.",
"Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.",
"Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, "
"nie in Betrieb).",
"DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
"drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
"gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.",
"Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.",
"Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).",
}
def _ordner_von(pfad: str) -> str | None:
"""Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt)."""
if not os.path.isabs(pfad):
return None
try:
rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve())
except (ValueError, OSError):
return None
return rel.parts[0] if len(rel.parts) > 1 else None
def _genutzte_ordner(models: dict) -> dict[str, set[str]]:
"""Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft)."""
genutzt: dict[str, set[str]] = {}
for name, spec in models.items():
if not isinstance(spec, dict):
continue
for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt
if (ordner := _ordner_von(wort.strip("'\""))):
genutzt.setdefault(ordner, set()).add(name)
return genutzt
def _groesse(pfad: Path) -> int:
summe = 0
for wurzel, _, dateien in os.walk(pfad):
for datei in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, datei))
except OSError:
pass
return summe
def _immer_warm(cfg: dict) -> set[str]:
return {m for g in (cfg.get("groups") or {}).values()
if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []}
def kandidaten() -> list[dict]:
"""Alles, was sich löschen ließe — größte zuerst. Löscht nichts."""
cfg = llamaswap.read_config()
models = cfg.get("models") or {}
warm = _immer_warm(cfg)
genutzt = _genutzte_ordner(models)
liste: list[dict] = []
for name, spec in models.items():
aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []}
if aliase & AKTIVE_ROLLEN or name in warm:
continue
eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt
groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene)
rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle."
liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1),
"hinweis": HINWEISE.get(name, rolle)})
try:
ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir())
except OSError:
ordner = []
for pfad in ordner:
if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."):
continue
liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1),
"hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")})
return sorted(liste, key=lambda k: -k["groesse_gb"])
def _ordner_loeschen(name: str) -> int:
"""Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück."""
wurzel = Path(MODELS_DIR).resolve()
pfad = (wurzel / name).resolve()
if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."):
raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.")
groesse = _groesse(pfad)
shutil.rmtree(pfad)
return groesse
def loeschen(art: str, name: str) -> dict:
"""Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück."""
if not any(k["art"] == art and k["name"] == name for k in kandidaten()):
return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."}
if not AUF_DER_BOX:
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
frei = 0
try:
if art == "ordner":
frei = _ordner_loeschen(name)
else:
with llamaswap.config_sperre():
cfg = llamaswap.read_config()
models = cfg.get("models") or {}
vorher = _genutzte_ordner(models)
del models[name]
for gruppe in (cfg.get("groups") or {}).values():
if isinstance(gruppe, dict) and name in (gruppe.get("members") or []):
gruppe["members"] = [m for m in gruppe["members"] if m != name]
llamaswap.write_config(cfg)
nachher = _genutzte_ordner(models)
for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt
frei += _ordner_loeschen(ordner)
except (OSError, ValueError) as e:
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
# --- Altreste neben dem Betrieb (seit 24.09.2026) ---------------------------------------------------------------
# Feste Liste aus dem Prüfbericht vom 24.09.2026, am selben Abend auf der Box nachgemessen (du) und gegengeprüft:
# Kein laufender Dienst, keine eingetragene Unit, kein Cron- oder Hermes-Job, kein aktiver Skill und keine
# Hermes-Einstellung nennt einen dieser Pfade. Angezeigt wird nur, was noch da ist; gelöscht wird nur auf Knopfdruck
# mit Rückfrage und nur ein Eintrag dieser Liste — die Anfrage nennt eine Kennung, nie einen Pfad.
#
# Bewusst NICHT in der Liste, weil nicht klar tot: ~/.voice (die Spracherkennung schläft nur), ~/.cache (Modelle
# von Lucys Stimme), ~/.opencode (steht in ~/.bashrc), ~/.hermes/kanban.db (Hermes öffnet sie noch), Archive
# (profiles-archive, archiv-aufraeumen, wissens-vault), ~/.hermes/state-snapshots, der PBS-Weg, die Rückweg-Kopien
# des Updaters (/opt/llamacpp-vulkan.bak, llama-swap.bak) und Skripte, die ein aktiver Skill noch nennt
# (ampel-waechter.sh, night-cron-wrapper.sh, pc_path_lookup.*).
@dataclass(frozen=True)
class Altrest:
id: str
name: str
pfade: tuple[str, ...] # "~/…" (Home des Dienst-Nutzers) oder absolut
hinweis: str
art: str = "ordner" # "ordner" | "worktree" (danach git worktree prune) | "unit" (danach daemon-reload)
sperre: tuple[str, ...] = () # User-Units, die weder laufen noch für den Autostart eingetragen sein dürfen
sudo: bool = False # Elternordner gehört root (/opt): den geleerten Ordner mit sudo -n entfernen
_UNITS = "~/.config/systemd/user/"
_STEMPEL = "~/.local/share/systemd/timers/stamp-"
_SKRIPTE = "~/.hermes/scripts/"
ALTRESTE: tuple[Altrest, ...] = (
Altrest("zonos2-test", "TTS-Test Zonos2", ("~/zonos2-test",),
"Test einer anderen Sprachausgabe vom 04.09. Lucys Stimme ist pocket-tts (~/.lucy-stimme); "
"nichts ruft diesen Ordner auf."),
Altrest("rocm-build", "Alter ROCm-Build von llama.cpp", ("/opt/llamacpp",),
"Motor aus der ROCm-Zeit (Juni). Seit dem Vulkan-Verdikt läuft er aus /opt/llamacpp-vulkan, und "
"llama-swap verweist nicht mehr hierher. Zurück zu ROCm ginge danach nur mit einem neuen Download.",
sudo=True),
Altrest("mem0", "mem0-Umgebung", ("~/.mem0",),
"Gedächtnis-Sidecar, am 27.08. abgelöst: Hermes führt das Gedächtnis selbst. Nur die abgeschaltete "
"Unit mem0-service verweist noch darauf.", sperre=("mem0-service.service",)),
Altrest("kanban", "Kanban-Arbeitsordner", ("~/.hermes/kanban",),
"Arbeitsordner, Anhänge und Protokolle der Kanban-Aufgaben aus Juli und August. Kanban ist in Hermes "
"abgeschaltet; die kleine Datenbank kanban.db bleibt liegen, weil Hermes sie noch öffnet."),
Altrest("alte-venvs", "Alte Python-Umgebungen", ("~/.venv-audit", "~/.venv-lint", "~/.litellm-venv"),
"Umgebungen für den früheren venv-Audit, Lint-Versuche und LiteLLM (der Gateway ist seit Juli "
"eingebaut). Kein Dienst und kein Job nutzt sie."),
Altrest("audiocpp-test", "audio.cpp-Test", ("~/audiocpp-test",),
"Test von audio.cpp mit Vulkan vom 04.09., nie in Betrieb gegangen."),
Altrest("avatar", "Avatar in der Oberfläche", ("~/mission-control-v2/frontend/dist/avatar.vrm",),
"VRM-Avatar vom 25.07., nie im Repo. Die Oberfläche lädt ihn nicht (kein Abruf in 30 Tagen), "
"Lucy-Desktop bringt einen eigenen mit."),
Altrest("worktrees", "Alte Git-Arbeitskopien",
("~/.hermes/worktrees/orch-graph-verknuepfer", "~/.hermes/worktrees/orch-graph-verknuepfer-work",
"~/.hermes/worktrees/wartung-remove-openrouter", "~/projekte/mc2-referenz",
"~/mission-control-v2/.worktrees/t_b7b426dc"),
"Arbeitskopien alter Aufgaben aus Juli und August, weit hinter main. Ihre Zweige bleiben im Box-Repo "
"erhalten; verloren gingen nur Änderungen, die dort nie committet wurden.", art="worktree"),
Altrest("test-reste", "Test-Reste im Home-Ordner",
("~/v2test", "~/bench_ab", "~/stimm_varianten", "~/stimm_moods", "~/emotion_test", "~/english_probe",
"~/zed-web-search-mcp", "~/cron_digest.py", "~/mc2-träum-duplikatscheck.patch"),
"Hörproben der Stimm-Tests vom 09. bis 12.07., ein MCP-Server der abgelösten Zed-Bahn, ein Skript des "
"alten Morgen-Digests und ein Patch aus der mem0-Zeit. Lucys Stimme nutzt ihre Referenz in "
"~/.lucy-stimme."),
Altrest("hermes-fremd", "Fremde Dateien im Hermes-Quellbaum",
("~/.hermes/hermes-agent/hermes_cli/mc2_kanban_reaper.py",
"~/.hermes/hermes-agent/hermes_cli/web_dist.bak-rootbuild"),
"Ein alter Kanban-Aufräumer und eine Sicherung der Web-Oberfläche, beide nicht von Hermes. Nichts lädt "
"sie; Hermes selbst bleibt unverändert."),
Altrest("hermes-skripte", "Alte Skripte in ~/.hermes/scripts",
tuple(_SKRIPTE + s for s in (
"blogwatcher-logger.py", "briefing-date.sh", "fremdblick.sh", "gitea-repo-create.sh",
"hermes-release-radar-feed.sh", "news-melden.sh.bak-20260923", "radar-feed.sh", "restore-probe.sh",
"selbstkritik-feed.sh", "venv-audit.sh", "worker.sh")),
"Skripte früherer Cron-Jobs (Werkstatt, Ampel, alte Radare). Kein Job, kein Cron und kein aktiver "
"Skill ruft sie auf; deploy.sh legt nur die heutigen Skripte dorthin."),
Altrest("mc2-memory", "Plugin mc2-memory", ("~/.hermes/plugins/mc2-memory",),
"Gedächtnis-Plugin aus der mem0-Zeit, in Hermes nicht aktiviert und nicht mehr im Repo."),
Altrest("pinned-version", "Veraltete PINNED_VERSION", ("~/.hermes/PINNED_VERSION",),
"Sagt noch „v0.17.0“ und führt in die Irre: Festgehaltene Versionen stehen in /srv/models/mc2-pins.json."),
Altrest("alt-units", "Abgeschaltete Alt-Units",
tuple(_UNITS + u for u in (
"mem0-service.service", "mem0-service.service.bak-174750", "mc2-morgen-digest.service",
"mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer",
"hermes-dashboard.service", "lucy-stimme.service.bak-20260904"))
+ (_STEMPEL + "mc2-morgen-digest.timer", _STEMPEL + "mc2-selfsmoke.timer"),
"Units früherer Dienste (mem0, Morgen-Digest, Selbsttest, altes Hermes-Dashboard) samt zwei Sicherungen "
"davon, alle abgeschaltet; ihre Skripte gibt es nicht mehr. Danach liest systemd die Units neu ein.",
art="unit",
sperre=("mem0-service.service", "mc2-morgen-digest.service", "mc2-morgen-digest.timer",
"mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service")),
)
REPO = Path(__file__).resolve().parents[2]
ALTRESTE_CACHE_S = 300 # Größen messen dauert (os.walk über ~20 000 Dateien); sie ändern sich selten
_altreste_lock = threading.Lock()
_altreste_cache: dict = {"ts": 0.0, "liste": None}
def _home() -> Path:
return Path(os.environ.get("MC_ALTRESTE_HOME", str(Path.home())))
def _pfad(roh: str) -> Path:
return _home() / roh[2:] if roh.startswith("~/") else Path(roh)
def _belegt(pfad: Path) -> int:
"""Bytes einer Datei oder eines Ordners, ohne Verknüpfungen zu folgen (ein venv zeigt sonst aufs System-Python)."""
try:
st = pfad.lstat()
except OSError:
return 0
if not stat.S_ISDIR(st.st_mode):
return st.st_size
summe = 0
for wurzel, _, dateien in os.walk(pfad):
for datei in dateien:
try:
summe += os.lstat(os.path.join(wurzel, datei)).st_size
except OSError:
pass
return summe
def _menge(n: int) -> str:
"""Größe in Worten, dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“."""
if n >= 1e9:
return f"{n / 1e9:.1f} GB".replace(".", ",")
if n >= 1e6:
return f"{n / 1e6:.0f} MB"
if n >= 1e3:
return f"{n / 1e3:.0f} KB"
return "unter 1 KB"
def _befehl(args: list[str]) -> tuple[bool, str]:
"""Befehle (systemctl, git, sudo) über eine Schicht, die Tests ersetzen. Ohne das Programm: (False, Grund)."""
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=60)
except (OSError, subprocess.SubprocessError) as exc:
return False, f"{exc.__class__.__name__}: {exc}"
return r.returncode == 0, ((r.stdout if r.returncode == 0 else (r.stderr or r.stdout)) or "").strip()[:300]
def _vorhanden(a: Altrest) -> list[tuple[str, Path]]:
return [(roh, p) for roh in a.pfade if (p := _pfad(roh)).exists() or p.is_symlink()]
def _sperrgrund(a: Altrest) -> str | None:
"""Läuft eine der Units noch oder steht sie im Autostart, wird der Rest gebraucht — dann nicht anbieten."""
for unit in a.sperre:
ok, ausgabe = _befehl(["systemctl", "--user", "show", unit, "-p", "ActiveState,UnitFileState"])
z = dict(zeile.split("=", 1) for zeile in ausgabe.splitlines() if "=" in zeile) if ok else {}
if z.get("ActiveState") in ("active", "activating", "reloading"):
return f"{unit} läuft gerade, {a.name} wird also noch gebraucht."
if z.get("UnitFileState") in ("enabled", "enabled-runtime", "linked", "linked-runtime"):
return f"{unit} steht im Autostart, {a.name} wird also noch gebraucht."
return None
def altreste(frisch: bool = False) -> list[dict]:
"""Die Altreste der festen Liste, die noch da und nicht in Gebrauch sind — größte zuerst. Löscht nichts."""
with _altreste_lock:
if not frisch and _altreste_cache["liste"] is not None \
and time.time() - _altreste_cache["ts"] < ALTRESTE_CACHE_S:
return [dict(e) for e in _altreste_cache["liste"]]
liste = []
for a in ALTRESTE:
da = _vorhanden(a)
if not da or _sperrgrund(a):
continue
groesse = sum(_belegt(p) for _, p in da)
liste.append({"id": a.id, "name": a.name, "pfade": [roh for roh, _ in da], "groesse_bytes": groesse,
"groesse": _menge(groesse), "hinweis": a.hinweis})
liste.sort(key=lambda e: -e["groesse_bytes"])
with _altreste_lock:
_altreste_cache.update(ts=time.time(), liste=liste)
return [dict(e) for e in liste]
def _entfernen(pfad: Path, a: Altrest) -> None:
if pfad.is_symlink() or not pfad.is_dir():
pfad.unlink() # eine Verknüpfung selbst, nie ihr Ziel
return
try:
shutil.rmtree(pfad)
except PermissionError:
if not a.sudo:
raise
# /opt gehört root: Den Inhalt darf der Dienst-Nutzer löschen, den geleerten Ordner selbst nur mit sudo.
ok, grund = _befehl(["sudo", "-n", "rm", "-rf", "--", str(pfad)])
if not ok:
raise PermissionError(f"sudo -n rm ging nicht ({grund})") from None
def altrest_loeschen(kennung: str) -> dict:
"""Knopf „Löschen“ eines Altrests: nur Einträge der festen Liste, nur was da ist, nur wenn nichts es braucht."""
a = next((x for x in ALTRESTE if x.id == kennung), None)
if a is None:
return {"ok": False, "detail": "Diesen Altrest kennt die Liste nicht."}
if not AUF_DER_BOX:
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
da = _vorhanden(a)
if not da:
return {"ok": False, "detail": f"{a.name}: schon weg."}
if (grund := _sperrgrund(a)):
return {"ok": False, "detail": grund}
frei, fehler = 0, []
for roh, pfad in da:
groesse = _belegt(pfad)
try:
_entfernen(pfad, a)
frei += groesse
except OSError as exc:
fehler.append(f"{roh} ({exc.strerror or exc})")
if a.art == "worktree": # Git vergisst die gelöschten Arbeitskopien, die Zweige bleiben im Repo
_befehl(["git", "-C", str(REPO), "worktree", "prune"])
elif a.art == "unit":
_befehl(["systemctl", "--user", "daemon-reload"])
with _altreste_lock:
_altreste_cache.update(ts=0.0, liste=None)
if fehler:
return {"ok": False, "detail": f"{a.name} nur zum Teil gelöscht ({_menge(frei)} frei). Geblieben: "
+ "; ".join(fehler)}
return {"ok": True, "text": f"{a.name} gelöscht, {_menge(frei)} frei."}
+525
View File
@@ -0,0 +1,525 @@
"""
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import time
import urllib.request
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
REPOS: dict[str, dict] = {
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
}
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
# Muster wie mc2-announce.json unter MODELS_DIR.
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
def _gitea_creds() -> tuple | None:
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
try:
import subprocess
p = subprocess.run(
["git", "credential", "fill"],
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
capture_output=True,
check=True
)
out = p.stdout.decode(errors="replace")
u_match = re.search(r"username=(.+)", out)
p_match = re.search(r"password=(.+)", out)
if u_match and p_match:
return u_match.group(1).strip(), p_match.group(1).strip()
except Exception:
pass
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
try:
cred = Path("~/.git-credentials").expanduser()
for line in cred.read_text(encoding="utf-8").splitlines():
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
if m: return m.group(1), m.group(2)
except Exception:
pass
return None
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
# Nur auf der Box (wo creds liegen) sinnvoll
creds = _gitea_creds()
if not creds: return None
_user, token = creds
cache_key = (repo, branch, head_sha)
cached = _CI_CACHE.get(cache_key)
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
return cached["status"]
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
try:
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=5) as r:
data = json.load(r)
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
if runs:
run = runs[0]
if run.get("head_sha", "").startswith(head_sha[:7]):
st = run.get("status")
if st:
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
return st
except Exception as e:
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
return None
def _available() -> bool:
return os.name == "posix" and (REPO / ".git").exists()
def _repo_ok(repo: str) -> bool:
r = REPOS.get(repo)
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
capture_output=True, text=True, timeout=timeout)
def _status_key(repo: str, branch: str) -> str:
return f"{REPOS[repo]['key']}{branch}"
def _fetch_throttled(repo: str) -> None:
now = time.time()
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
return
_fetch_cache[repo] = now
try:
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception:
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
def _load_announce_branches() -> set:
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
try:
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
return set(data) if isinstance(data, list) else set()
except Exception:
return set()
def _save_announce_branches(branches: set) -> None:
"""Speichert die Menge gemelder Branch-Namen."""
try:
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
tmp.replace(ANNOUNCE_BRANCHES_PATH)
except OSError:
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)
def _statuses() -> dict:
try:
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
except Exception:
return {}
def _set_status(key: str, state: str, detail: str) -> None:
try:
try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
tmp = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH)
except OSError:
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _gutachten() -> dict:
"""Alle Karten-Stempel ("<repo>:<branch>" → {empfehlung, satz, richter, commit_ts, ts})."""
try:
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _remote_branches(repo: str) -> list[str]:
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = []
for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/")
if name and name != "HEAD" and name.startswith(PREFIXES):
out.append(name)
return out
def _valid_branch(branch: str) -> bool:
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
items = []
for branch in _remote_branches(repo):
ref = f"origin/{branch}"
status = statuses.get(_status_key(repo, branch))
try:
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
# garantierter Fehllauf („refusing to merge unrelated histories").
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
ts_val = int(cts) if cts.strip().isdigit() else None
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
stempel = gutachten.get(f"{repo}:{branch}")
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
stempel = None
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else None
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
for line in files_raw[:60]:
parts = line.split("\t")
if len(parts) >= 2:
files.append({"status": parts[0][:2], "path": parts[-1]})
paths = [f["path"] for f in files]
frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({
"repo": repo,
"branch": branch,
"kind": branch.split("/", 1)[0],
"subject": subject.strip(),
"body": body.strip()[:2000],
"author": author.strip(),
"ts": ts_val,
"empfehlung": stempel,
"ahead": ahead,
"behind": behind,
"verwaist": verwaist,
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
"leer": not verwaist and ahead > 0 and not files_raw,
"shortstat": stat,
"files": files,
"files_truncated": len(files_raw) > 60,
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
"ci_status": ci_status,
})
except Exception:
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
return items
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
statuses = _statuses()
gutachten = _gutachten()
items = []
for repo in REPOS:
if not _repo_ok(repo):
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
_fetch_throttled(repo)
items.extend(_repo_items(repo, statuses, gutachten))
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
aktuelle_branches = set()
for repo in REPOS:
if _repo_ok(repo):
aktuelle_branches.update(_remote_branches(repo))
# Bereits gemeldete Branches laden
gemeldet = _load_announce_branches()
# Nur wirklich NEUE Branches pingen
neue = aktuelle_branches - gemeldet
for branch in neue:
# NotifyTelegram best-effort, niemals crashen
try:
from services import announce
subject = "[Auftragsbuch]"
text = f"Neue Karte wartet auf deinen Klick: {branch}"
announce.notify_telegram(subject, text)
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
announce.add(text, subject, "auftragsbuch", "normal")
except Exception:
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
if aktuelle_branches != gemeldet:
_save_announce_branches(aktuelle_branches)
kandidaten = list_skill_kandidaten()
open_count = sum(1 for i in items
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str, repo: str = "mc2") -> dict:
if not _repo_ok(repo):
return {"ok": False, "error": "Nur auf der Box verfügbar."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
text = r.stdout or ""
truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str, repo: str = "mc2") -> dict:
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
if not _repo_ok(repo):
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
key = _status_key(repo, branch)
state = (_statuses().get(key) or {}).get("state")
if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
src = REPO / "deploy" / REPOS[repo]["runner"]
if not src.is_file():
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try:
shutil.copyfile(src, runner)
except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(runner), branch],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
return {"ok": True, "unit": unit}
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
if not _repo_ok(repo):
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
grund = (grund or "").strip()[:400]
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
detail = "Vom Commander abgelehnt — Branch gelöscht"
if grund:
detail += f" (Grund: {grund})"
_set_status(_status_key(repo, branch), "abgelehnt", detail)
try:
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
except OSError:
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
try:
from services import announce
wo = "im Lucy-Repo " if repo == "lucy" else ""
warum = f" Grund: {grund}" if grund else ""
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True}
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
def _kandidaten_dir() -> Path:
return VAULT / "skill-kandidaten"
def list_skill_kandidaten() -> list[dict]:
d = _kandidaten_dir()
if os.name != "posix" or not d.is_dir():
return []
out = []
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
text = p.read_text(encoding="utf-8", errors="replace")
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
out.append({"file": p.name, "title": first[:160],
"preview": text[:3000], "mtime": p.stat().st_mtime})
except OSError:
continue
return out
def _vault_git(args: list[str]) -> None:
try:
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
except Exception:
pass
def _kandidat_path(fname: str) -> Path | None:
if not _KANDIDAT_RX.match(fname):
return None
p = (_kandidaten_dir() / fname).resolve()
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
return None
return p
def skill_accept(fname: str) -> dict:
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
if not _available():
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
beauftragt = _kandidaten_dir() / "beauftragt"
beauftragt.mkdir(parents=True, exist_ok=True)
target = beauftragt / p.name
try:
content = p.read_text(encoding="utf-8", errors="replace")
p.rename(target)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
+ content)
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
try:
order_file.write_text(order, encoding="utf-8")
except OSError as exc:
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
unit = f"mc2-skill-auftrag-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
try:
from services import announce
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True, "unit": unit}
def skill_reject(fname: str) -> dict:
if not _available():
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
verworfen = _kandidaten_dir() / "verworfen"
verworfen.mkdir(parents=True, exist_ok=True)
try:
p.rename(verworfen / p.name)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
return {"ok": True}
+8 -5
View File
@@ -1,7 +1,7 @@
""" """
Voll-Zustands-Backup (Hermes-Configs/Secrets + llama-swap config). Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config).
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer); Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BETRIEB.md (Sicherung). Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
""" """
import subprocess import subprocess
@@ -27,6 +27,11 @@ def _latest() -> Path | None:
return snaps[0] if snaps else None return snaps[0] if snaps else None
def snapshot_components(tarball: Path) -> list[str]:
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
return _components(tarball)
def _components(tarball: Path) -> list[str]: def _components(tarball: Path) -> list[str]:
"""Top-Level-Einträge im Tarball (zur Anzeige im UI).""" """Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
try: try:
@@ -65,11 +70,9 @@ def list_backups() -> list[dict]:
return [] return []
out = [] out = []
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True): for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
st = p.stat()
out.append({ out.append({
"snapshot": _ts(p), "snapshot": _ts(p),
"file": p.name, "file": p.name,
"size_mb": round(st.st_size / 1_000_000, 2), "size_mb": round(p.stat().st_size / 1_000_000, 2),
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
}) })
return out return out
-156
View File
@@ -1,156 +0,0 @@
"""Updates der KI-Box: Zwischenspeicher für den Start-Bildschirm und der Box-Adapter (Phase 2).
maintenance.updates() fragt GitHub, apt und git und braucht nach einem Neustart gern zehn Sekunden
und mehr. Der Start-Bildschirm wartet darauf nie: Er bekommt den letzten Stand (oder zunächst
nichts), frisch geholt wird im Hintergrund. Seit 24.09.2026 liegt das hier statt im Router, weil
auch der Box-Adapter (ki_box_ziel) davon liest.
ki_box_ziel() beschreibt die KI-Box im gemeinsamen Modell aus kern/ziele.py: vier Bausteine mit
Stand, Versionen und dem Knopf, der das jeweilige Update anstößt.
"""
import platform
import threading
import time
from kern.ziele import Aktion, BausteinStand, ZielStand
from services import maintenance, system, update_verlauf
_updates_lock = threading.Lock()
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
UPDATES_CACHE_S = 600
NAMEN = {"os": "Betriebssystem", "engine": "Motor (llama.cpp)", "swap": "llama-swap", "hermes": "Hermes"}
NAMEN_KURZ = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"}
AKTIONEN = {
"os": Aktion("POST", "/api/maintenance/os-update",
"Betriebssystem-Updates jetzt einspielen? Danach wird der Stack geprüft."),
"engine": Aktion("POST", "/api/maintenance/engine-update",
"Den Motor jetzt aktualisieren? Die Modelle sind dabei kurz weg; scheitert die Prüfung, "
"geht es zurück."),
"swap": Aktion("POST", "/api/maintenance/swap-update",
"llama-swap jetzt aktualisieren? Laufende Antworten brechen ab; scheitert die Prüfung, "
"geht es zurück."),
"hermes": Aktion("POST", "/api/maintenance/hermes-update",
"Hermes jetzt aktualisieren? Lucy ist dabei ein paar Minuten weg."),
}
def _holen() -> None:
stand = maintenance.update_stand
try:
daten = maintenance.updates()
except Exception:
daten = None
with _updates_lock:
if daten is not None or _updates_cache["daten"] is None:
_updates_cache["daten"] = daten or {}
_updates_cache["ts"] = time.time()
_updates_cache["stand"] = stand
_updates_cache["laeuft"] = False
def gecacht() -> dict:
"""Letzter Stand der Update-Prüfung; ist er veraltet, wird im Hintergrund neu geholt."""
with _updates_lock:
# Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen,
# statt bis zu 10 Minuten „Aktualisieren" für schon eingespielte Updates zu zeigen (24.09.2026).
veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
or _updates_cache["stand"] != maintenance.update_stand)
if veraltet and not _updates_cache["laeuft"]:
_updates_cache["laeuft"] = True
threading.Thread(target=_holen, name="updates-holen", daemon=True).start()
return _updates_cache["daten"] or {}
def gelesen() -> bool:
"""Liegt schon ein Ergebnis vor (nach einem Neustart dauert die erste Prüfung)?"""
return _updates_cache["daten"] is not None
def bausteine(u: dict) -> list[dict]:
"""Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft."""
liste = []
if u.get("os"):
liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"})
if u.get("engine"):
liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"})
if u.get("swap"):
liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"})
for c in u.get("components") or []:
if c.get("key") == "hermes_agent" and c.get("update"):
liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"})
return liste
def unklar(u: dict) -> list[dict]:
"""Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt" statt „aktuell"."""
return [{"id": k, "name": NAMEN_KURZ.get(k, k), "grund": grund}
for k, grund in (u.get("pruef_fehler") or {}).items() if grund]
# --- Box-Adapter ------------------------------------------------------------------------
def _ubuntu() -> str | None:
try:
info = platform.freedesktop_os_release()
except OSError:
return None
return " ".join(x for x in (info.get("NAME"), info.get("VERSION_ID")) if x) or None
def _versionen(u: dict) -> dict[str, tuple[str | None, str | None]]:
"""(installiert, verfügbar) je Baustein. „Verfügbar" nur, wenn es Neues gibt — die Abfrage
dafür ist bei GitHub zwischengespeichert (maintenance._github_json, 15 Minuten)."""
engine = maintenance._installed_engine_build()
swap = maintenance._installed_swap_version()
werte: dict[str, tuple[str | None, str | None]] = {
"os": (_ubuntu(), f"{u['os']} Pakete" if u.get("os") else None),
"engine": (f"b{engine}" if engine else None, None),
"swap": (f"v{swap}" if swap else None, None),
}
try:
if u.get("engine") and (rel := maintenance._latest_engine_asset_release()):
werte["engine"] = (werte["engine"][0], str(rel.get("tag_name") or "") or None)
if u.get("swap"):
rel = maintenance._github_json(f"repos/{maintenance.SWAP_REPO}/releases/latest")
tag = str(rel.get("tag_name") or "") if isinstance(rel, dict) else ""
werte["swap"] = (werte["swap"][0], tag or None)
except Exception:
pass
hermes = next((c for c in u.get("components") or [] if c.get("key") == "hermes_agent"), {})
git = system.find_hermes_agent_git() or {}
version = maintenance._hermes_version(git["path"]) if git.get("path") else None
werte["hermes"] = (version or hermes.get("current"),
f"+{hermes.get('behind')} Änderungen" if hermes.get("update") else None)
return werte
def ki_box_ziel() -> ZielStand:
"""Die KI-Box als Ziel mit ihren vier Bausteinen."""
u = gecacht()
ziel = ZielStand(id="ki-box", name="KI-Box", art="ki-box", instanz="box", erreichbar=True,
geprueft=_updates_cache["ts"] or None,
rueckweg="Sicherung vor jedem Sonntags-Lauf; Motor, llama-swap und Hermes rollen "
"bei rotem Check selbst zurück, das Betriebssystem nicht.")
if not gelesen():
ziel.bausteine = [BausteinStand(id=k, name=n, zustand="wird-geprueft") for k, n in NAMEN.items()]
return ziel
offen = {b["id"]: b["neu"] for b in bausteine(u)}
fehler = {b["id"]: b["grund"] for b in unklar(u)}
fest = {p["baustein"]: p for p in update_verlauf.festgehalten()}
versionen = _versionen(u)
for k, name in NAMEN.items():
installiert, verfuegbar = versionen.get(k, (None, None))
stand = BausteinStand(id=k, name=name, zustand="aktuell", installiert=installiert, verfuegbar=verfuegbar)
if k in fest:
p = fest[k]
stand.zustand, stand.grund = "festgehalten", f"Seit {p['seit']} auf {p['version']}: {p['grund']}"
elif k in fehler:
stand.zustand, stand.grund = "unbekannt", fehler[k]
elif k in offen:
stand.zustand, stand.kurz, stand.aktion = "neu", offen[k], AKTIONEN[k]
ziel.bausteine.append(stand)
return ziel
+34
View File
@@ -169,3 +169,37 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
} }
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
from services import gguf_meta
quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
+165
View File
@@ -0,0 +1,165 @@
"""
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
"""
import json
import httpx
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
DEFAULT_HOST = "192.168.178.151"
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
PRIMARY_MODEL = "coder"
def _caps(tools: bool = True, images: bool = False) -> dict:
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
return {"tools": tools, "images": images, "parallel_tool_calls": False,
"prompt_cache_key": False, "chat_completions": True,
"interleaved_reasoning": False, "max_tokens_parameter": False}
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
IDE_MODELS = [
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
]
def _gw(host: str) -> str:
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
return f"http://{host}:{PORT}/v1"
def build_snippets(host: str = DEFAULT_HOST,
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
mcp_python: str = "python") -> dict:
gw = _gw(host)
mc_url = f"http://{host}:{PORT}"
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
kilo = json.dumps({
"apiProvider": "openai",
"openAiBaseUrl": gw,
"openAiApiKey": "local",
"openAiModelId": PRIMARY_MODEL,
}, indent=2)
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
hermes_desktop = (
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
f"#\n"\
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
f"# (MC2-Proxy auf dem Gateway)\n"\
f"#\n"\
f"# 2. Session-Token: auf der Box liegen in\n"\
f"# ~/.hermes/desktop-gateway-token\n"\
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
f"#\n"\
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
f"#\n"\
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
)
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
claude_code = (
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
f"# • claude-code-router (leichtgewichtig, npm)\n"
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
f"\n"
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
f'export ANTHROPIC_MODEL="coder"'
)
memory_mcp = json.dumps({
"mcpServers": {
"mission-control-memory": {
"command": mcp_python,
"args": [mcp_script_path],
"env": {"MC_URL": mc_url},
}
}
}, indent=2)
return {
"host": host,
"gateway_url": gw,
"mc_url": mc_url,
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
# Claude Code (starke Sessions).
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
"tools": {
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
},
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
}
def check_health() -> dict:
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
sonst llama-swap direkt), Leitung 2 = Natives Hermes-Gedächtnis (hermes-gateway),
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
if r.status_code == 200:
n = len(r.json().get("data", []))
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
else:
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
except Exception:
pass
memory = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
# Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit
r = c.get("http://127.0.0.1:8642/health")
if r.status_code in (200, 404, 401):
memory = {"ok": True, "detail": "Hermes-Nativ bereit"}
else:
memory = {"ok": False, "detail": f"HTTP {r.status_code}"}
except Exception:
pass
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception:
pass
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
+2
View File
@@ -25,6 +25,8 @@ from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str: def _categorize(repo_id: str) -> str:
low = repo_id.lower() low = repo_id.lower()
-415
View File
@@ -1,415 +0,0 @@
"""
Einstellungen der Oberfläche (seit 24.09.2026): was sich unter „Einstellungen“ wirklich einstellen lässt.
• Update-Zeitfenster Wochentag und Uhrzeit des wöchentlichen Update-Laufs (mc2-autoupdate.timer, Standard
So 04:30). Umgesetzt als Drop-in ~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf,
das deploy.sh nicht anfasst. Neu gestartet wird die Box nur in den drei Stunden ab der vollen
Stunde des Beginns; deploy/wartungsfenster.sh liest das Fenster aus der Einstellungsdatei.
• Modell-Radar mc2-radar.timer (täglich 00:30) an oder aus. deploy.sh respektiert den Schalter.
• Telegram-Test eine Testmeldung über den echten Meldeweg (deploy/notify.sh -d, also auch nachts sofort);
das Ergebnis samt Grund kommt aus dem Melde-Log. Geht auf beiden Instanzen.
Was eingestellt ist, steht in einer Datei im Datenordner (mc2-einstellungen.json; nur MC2 schreibt sie, deploy.sh
und autoupdate.sh lesen sie). Was gilt, sagt systemd. Die Oberfläche zeigt beides: den Wunsch aus der Datei und den
nächsten Lauf laut systemd, und ob beides zusammenpasst.
‼ Nachhol-Falle (24.09.2026): Beide Timer haben Persistent=true. Startet ein Timer mit neuem Plan, holt er einen
Termin, der seit seinem letzten Lauf „verpasst“ wurde, sofort nach — so startete die Box an einem Donnerstagnachmittag
neu. Darum wird der Update-Timer VOR dem daemon-reload angehalten (ein laufender Timer rechnet beim Reload mit seinem
letzten Lauf und dem neuen Plan), und vor jedem Start steht der Stempel ~/.local/share/systemd/timers/stamp-<timer>
auf jetzt: systemd nimmt dessen Zeit als letzten Lauf und plant den nächsten Termin von dort aus.
"""
import json
import logging
import os
import re
import secrets
import shutil
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen as instanz
from kern.zeit import LOCAL_TZ
# Bewusst kein Import aus services.waechter: Der Telegram-Test läuft auch im Homelab-Teil, und der lädt nichts
# von der KI-Box (waechter zieht llamaswap und maintenance nach, siehe test_partner).
log = logging.getLogger(__name__)
UPDATE_TIMER = "mc2-autoupdate.timer"
UPDATE_DIENST = "mc2-autoupdate.service"
RADAR_TIMER = "mc2-radar.timer"
TAGE = ("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
SYSTEMD_TAGE = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
STANDARD_FENSTER = {"tag": 7, "uhrzeit": "04:30"}
NEUSTART_DAUER_MIN = 180 # wie WARTUNG_DAUER_MIN in deploy/wartungsfenster.sh
TEST_ABSTAND_S = 20 # höchstens ein Telegram-Test alle 20 s
AUF_DER_BOX = os.name == "posix" # am Windows-PC (Entwicklung) wird nichts geschaltet und nichts gesendet
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
_sperre = threading.Lock()
_test_sperre = threading.Lock()
_letzter_test = {"ts": 0.0}
# --- Orte (zur Laufzeit gelesen, damit Tests sie umlenken können) ------------------------------------
def datei() -> Path:
return Path(os.environ.get("MC_EINSTELLUNGEN", str(instanz().daten_dir / "mc2-einstellungen.json")))
def _user_units() -> Path:
return Path(os.environ.get("MC_SYSTEMD_USER_DIR", str(Path.home() / ".config" / "systemd" / "user")))
def _stempel_ordner() -> Path:
return Path(os.environ.get("MC_TIMER_STEMPEL_DIR", str(Path.home() / ".local" / "share" / "systemd" / "timers")))
def dropin() -> Path:
return _user_units() / f"{UPDATE_TIMER}.d" / "zeitfenster.conf"
def melde_log() -> Path:
return Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
# --- Die Datei -------------------------------------------------------------------------------------
def fenster_fehler(tag: object, uhrzeit: object) -> str | None:
"""Warum ein Zeitfenster ungültig ist — oder None."""
if not isinstance(tag, int) or isinstance(tag, bool) or not 1 <= tag <= 7:
return "Der Wochentag muss zwischen 1 (Montag) und 7 (Sonntag) liegen."
if not isinstance(uhrzeit, str) or not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", uhrzeit):
return "Die Uhrzeit braucht die Form HH:MM, etwa 04:30."
return None
def lesen() -> dict:
"""Was eingestellt ist. Fehlt die Datei oder ist ein Wert unsinnig, gilt der Standard (So 04:30, Radar an)."""
try:
roh = json.loads(datei().read_text(encoding="utf-8"))
except (OSError, ValueError):
roh = {}
roh = roh if isinstance(roh, dict) else {}
fenster = roh.get("update_fenster") if isinstance(roh.get("update_fenster"), dict) else {}
tag, uhrzeit = fenster.get("tag"), fenster.get("uhrzeit")
if fenster_fehler(tag, uhrzeit):
tag, uhrzeit = STANDARD_FENSTER["tag"], STANDARD_FENSTER["uhrzeit"]
radar = roh.get("radar") if isinstance(roh.get("radar"), dict) else {}
an = radar.get("an") if isinstance(radar.get("an"), bool) else True
return {"update_fenster": {"tag": tag, "uhrzeit": uhrzeit}, "radar": {"an": an}}
def _speichern(daten: dict) -> None:
"""Atomar über eine Nachbardatei — deploy.sh und autoupdate.sh lesen die Datei jederzeit."""
ziel = datei()
ziel.parent.mkdir(parents=True, exist_ok=True)
tmp = ziel.with_suffix(".json.tmp")
tmp.write_text(json.dumps({**daten, "geaendert": datetime.now(LOCAL_TZ).isoformat(timespec="seconds")},
ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, ziel)
# --- Zeitfenster -----------------------------------------------------------------------------------
def neustart_fenster(tag: int, uhrzeit: str) -> str:
"""Wann die Box nach einem Update neu starten darf, in Worten (gleiche Regel wie deploy/wartungsfenster.sh):
drei Stunden ab der vollen Stunde des Beginns. So 04:30 → „Sonntag 04:00–06:59“."""
stunde = int(uhrzeit[:2])
ende = stunde * 60 + NEUSTART_DAUER_MIN - 1
tag_ende = tag
if ende >= 1440:
ende -= 1440
tag_ende = tag % 7 + 1
von, bis = f"{stunde:02d}:00", f"{ende // 60:02d}:{ende % 60:02d}"
if tag_ende == tag:
return f"{TAGE[tag - 1]} {von}–{bis}"
return f"{TAGE[tag - 1]} {von} – {TAGE[tag_ende - 1]} {bis}"
def dropin_text(tag: int, uhrzeit: str) -> str:
"""Das leere OnCalendar= löscht den Plan der Repo-Unit, statt einen zweiten danebenzustellen."""
return ("# Update-Zeitfenster aus den Einstellungen der Oberfläche (backend/services/einstellungen.py).\n"
"# Nicht von Hand ändern: Die Oberfläche schreibt diese Datei beim Speichern neu; deploy.sh lässt sie stehen.\n"
"[Timer]\n"
"OnCalendar=\n"
f"OnCalendar={SYSTEMD_TAGE[tag - 1]} *-*-* {uhrzeit}:00\n")
def _dropin_lesen() -> str | None:
try:
return dropin().read_text(encoding="utf-8")
except OSError:
return None
def _dropin_schreiben(inhalt: str | None) -> None:
"""None heißt: kein Drop-in (zurück auf den Plan der Repo-Unit)."""
pfad = dropin()
if inhalt is None:
pfad.unlink(missing_ok=True)
return
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".conf.tmp")
tmp.write_text(inhalt, encoding="utf-8")
os.replace(tmp, pfad)
# --- systemd ---------------------------------------------------------------------------------------
def _systemctl(*args: str) -> tuple[bool, str]:
"""systemctl --user … — die eine Schicht zu systemd, die Tests ersetzen. Gibt (ok, Ausgabe bzw. Grund)."""
try:
r = subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True, timeout=60)
except (OSError, subprocess.SubprocessError) as exc:
return False, f"systemctl ging nicht ({exc.__class__.__name__}: {exc})"
if r.returncode == 0:
return True, (r.stdout or "").strip()
return False, ((r.stderr or r.stdout or "").strip() or f"systemctl endete mit Code {r.returncode}")[:300]
def _zustand(unit: str) -> dict[str, str]:
ok, ausgabe = _systemctl("show", unit, "-p", "LoadState,ActiveState,UnitFileState")
if not ok:
return {}
return dict(z.split("=", 1) for z in ausgabe.splitlines() if "=" in z)
def _naechster_lauf(timer: str) -> str | None:
ok, ausgabe = _systemctl("list-timers", "--all", "--output=json", timer)
if not ok:
return None
try:
timer_liste = json.loads(ausgabe or "[]")
except ValueError:
return None
for t in timer_liste if isinstance(timer_liste, list) else []:
if isinstance(t, dict) and t.get("unit") == timer and t.get("next"):
try:
return datetime.fromtimestamp(int(t["next"]) / 1_000_000, LOCAL_TZ).isoformat(timespec="seconds")
except (TypeError, ValueError, OverflowError, OSError):
return None
return None
def _stempel_setzen(timer: str) -> None:
"""Letzter Lauf = jetzt: So holt der Timer beim nächsten Start keinen „verpassten“ Termin nach (Persistent=true).
Scheitert das, startet der Timer trotzdem — ein nachgeholter Lauf ist besser als gar keiner mehr."""
try:
pfad = _stempel_ordner() / f"stamp-{timer}"
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.touch(exist_ok=True)
except OSError:
log.warning("einstellungen: Stempel für %s nicht setzbar — der Timer könnte nachholen", timer, exc_info=True)
def _fenster_anwenden(inhalt: str | None) -> tuple[bool, str]:
"""Drop-in schreiben (None: entfernen) und den Update-Timer ohne Nachholen neu planen. Lief der Timer nicht,
bleibt er aus — er übernimmt das Fenster beim nächsten Start."""
lief = _zustand(UPDATE_TIMER).get("ActiveState") == "active"
if lief:
ok, grund = _systemctl("stop", UPDATE_TIMER)
if not ok:
return False, grund
try:
_dropin_schreiben(inhalt)
ok, grund = _systemctl("daemon-reload")
except OSError as exc:
ok, grund = False, f"Drop-in {dropin()} nicht schreibbar: {exc}"
if lief: # auch nach einem Fehler wieder starten: ohne Timer gäbe es gar keine Updates mehr
_stempel_setzen(UPDATE_TIMER)
gestartet, grund_start = _systemctl("start", UPDATE_TIMER)
if ok and not gestartet:
ok, grund = False, grund_start
return ok, grund
# --- Für die Oberfläche ----------------------------------------------------------------------------
def schlaeft(z: dict[str, str]) -> bool:
"""Dieselbe Regel wie waechter.schlaeft (ein Test hält beide gleich): abgeschaltet und nicht mehr für den
Autostart eingetragen = bewusst schlafen gelegt."""
return z.get("ActiveState") == "inactive" and z.get("UnitFileState") == "disabled"
def _radar_zustand(z: dict[str, str]) -> str:
if not z or z.get("LoadState") in ("not-found", ""):
return "unbekannt"
if z.get("ActiveState") == "active":
return "an"
return "schlaeft" if schlaeft(z) else "aus"
def stand() -> dict:
"""Einstellungen samt dem, was systemd daraus gemacht hat."""
daten = lesen()
f = daten["update_fenster"]
update = _zustand(UPDATE_TIMER)
radar = _zustand(RADAR_TIMER)
ist = _dropin_lesen()
fenster_wirksam = (ist == dropin_text(f["tag"], f["uhrzeit"])) if ist is not None else (f == STANDARD_FENSTER)
radar_zustand = _radar_zustand(radar)
radar_an = daten["radar"]["an"]
return {
"schaltbar": AUF_DER_BOX and bool(update or radar),
"update_fenster": {
**f,
"tag_name": TAGE[f["tag"] - 1],
"neustart_fenster": neustart_fenster(f["tag"], f["uhrzeit"]),
"timer_aktiv": update.get("ActiveState") == "active",
"naechster_lauf": _naechster_lauf(UPDATE_TIMER) if update else None,
"wirksam": fenster_wirksam,
},
"radar": {
"an": radar_an,
"zustand": radar_zustand,
"naechster_lauf": _naechster_lauf(RADAR_TIMER) if radar_zustand == "an" else None,
# „aus“ (gestoppt, aber noch im Autostart) passt nicht zu „aus“: nach einem Neustart liefe es wieder.
"wirksam": radar_zustand == "unbekannt" or radar_zustand == ("an" if radar_an else "schlaeft"),
},
}
def update_fenster_setzen(tag: int, uhrzeit: str) -> dict:
"""Knopf „Speichern“ beim Update-Zeitfenster."""
if (fehler := fenster_fehler(tag, uhrzeit)):
return {"ok": False, "detail": fehler}
if not AUF_DER_BOX:
return {"ok": False, "detail": "Das Update-Zeitfenster lässt sich nur auf der Box einstellen."}
with _sperre:
if _zustand(UPDATE_DIENST).get("ActiveState") in ("active", "activating", "deactivating"):
return {"ok": False, "detail": "Gerade läuft das Update. Das Zeitfenster lässt sich danach ändern."}
vorher = _dropin_lesen()
ok, grund = _fenster_anwenden(dropin_text(tag, uhrzeit))
if not ok:
zurueck, _ = _fenster_anwenden(vorher)
return {"ok": False, "detail": f"systemd hat das neue Zeitfenster nicht übernommen: {grund}"
+ ("" if zurueck else " Der Timer lässt sich auch nicht zurückstellen.")}
daten = lesen()
daten["update_fenster"] = {"tag": tag, "uhrzeit": uhrzeit}
try:
_speichern(daten)
except OSError as exc:
return {"ok": False, "detail": f"Der Timer ist umgestellt, die Einstellung ließ sich aber nicht speichern: {exc}"}
return {"ok": True, "text": f"Updates laufen ab jetzt {TAGE[tag - 1]} um {uhrzeit}. Neu gestartet wird die Box "
f"nur {neustart_fenster(tag, uhrzeit)}. Ein verpasster Termin wird nicht nachgeholt.",
"einstellungen": stand()}
def radar_schalten(an: bool) -> dict:
"""Knopf „Einschalten“/„Ausschalten“ beim Modell-Radar."""
if not AUF_DER_BOX:
return {"ok": False, "detail": "Das Radar lässt sich nur auf der Box schalten."}
with _sperre:
if an:
ok, grund = _systemctl("enable", RADAR_TIMER)
if ok:
_stempel_setzen(RADAR_TIMER)
ok, grund = _systemctl("start", RADAR_TIMER)
else: # ein Lauf, der gerade läuft (Nachttest), endet noch von selbst
ok, grund = _systemctl("disable", "--now", RADAR_TIMER)
if not ok:
return {"ok": False, "detail": f"systemd hat das Radar nicht {'eingeschaltet' if an else 'ausgeschaltet'}: "
f"{grund}"}
daten = lesen()
daten["radar"] = {"an": an}
try:
_speichern(daten)
except OSError as exc:
return {"ok": False, "detail": f"Das Radar ist geschaltet, die Einstellung ließ sich aber nicht speichern: "
f"{exc}. Der nächste Deploy könnte es zurückschalten."}
text = ("Das Radar ist an und sucht wieder jede Nacht ab 00:30. Ein verpasster Lauf wird nicht nachgeholt." if an
else "Das Radar ist aus und schläft. Es sucht und testet keine Modelle mehr, bis du es wieder einschaltest.")
return {"ok": True, "text": text, "einstellungen": stand()}
# --- Telegram-Test ---------------------------------------------------------------------------------
_KOPF = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ")
_FALLBACK = "FALLBACK (telegram fehlgeschlagen:"
def log_eintrag(pfad: Path, marke: str, max_bytes: int = 262_144) -> str | None:
"""Der jüngste Eintrag des Melde-Logs, der `marke` enthält — samt Folgezeilen, falls die Ausgabe von
`hermes send` mehrzeilig war (dann beginnt nur die erste Zeile mit dem Zeitstempel)."""
try:
with pfad.open("rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - max_bytes))
zeilen = f.read().decode("utf-8", "replace").splitlines()
except OSError:
return None
for i in range(len(zeilen) - 1, -1, -1):
if marke in zeilen[i]:
anfang = i
while anfang > 0 and not _KOPF.match(zeilen[anfang]):
anfang -= 1
return "\n".join(zeilen[anfang:i + 1])
return None
def deute_eintrag(eintrag: str | None, text: str) -> dict:
"""Was notify.sh mit der Meldung gemacht hat. Wortlaut der Zeilen: deploy/notify.sh."""
if eintrag is None:
return {"gesendet": False, "weg": None, "grund": None}
rest = _KOPF.sub("", eintrag, count=1)
if rest.startswith("OK telegram direkt:"):
return {"gesendet": True, "weg": "direkt", "grund": None}
if rest.startswith("OK telegram:"):
return {"gesendet": True, "weg": "hermes", "grund": None}
if rest.startswith("QUEUED"):
return {"gesendet": False, "weg": None,
"grund": "notify.sh hat sie für die Morgenmeldung zurückgelegt statt sie zu senden."}
if rest.startswith(_FALLBACK):
innen = rest[len(_FALLBACK):]
ende = innen.rfind("): " + text[:40])
grund = " ".join((innen[:ende] if ende >= 0 else innen).split())
return {"gesendet": False, "weg": None,
"grund": grund[:400] or "Telegram hat sie nicht angenommen, ohne Angabe eines Grundes."}
return {"gesendet": False, "weg": None, "grund": " ".join(rest.split())[:300]}
def _notify(bash: str, args: list[str]) -> tuple[int | None, str]:
"""deploy/notify.sh aufrufen — der echte Meldeweg. Lucys Briefkasten und wall bleiben beim Test außen vor."""
env = {**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1", "MC_NOTIFY_OHNE_WALL": "1"}
try:
r = subprocess.run([bash, NOTIFY_SH.as_posix(), *args], env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=90, stdin=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError) as exc:
return None, f"{exc.__class__.__name__}: {exc}"
return r.returncode, (r.stderr or r.stdout or "").strip()
def telegram_test() -> dict:
"""Knopf „Testmeldung senden“: eine kurze Meldung, dringend (-d), damit sie auch nachts sofort rausgeht."""
e = instanz()
jetzt = datetime.now(LOCAL_TZ)
basis = {"instanz": e.instanz, "zeit": jetzt.isoformat(timespec="seconds")}
with _test_sperre:
if time.time() - _letzter_test["ts"] < TEST_ABSTAND_S:
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
"detail": "Der letzte Test ist erst ein paar Sekunden her. Bitte kurz warten."}
_letzter_test["ts"] = time.time()
bash = shutil.which("bash") # voller Pfad: unter Windows fände „bash“ sonst womöglich die WSL-Hülle
if not AUF_DER_BOX or not bash:
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
"detail": "Der Telegram-Test geht nur auf der Box und im Homelab-Teil."}
kennung = secrets.token_hex(3)
text = (f"Testmeldung aus den Einstellungen ({e.instanz}, {jetzt:%d.%m. %H:%M} Uhr, Kennung {kennung}). "
"Kommt sie an, funktioniert der Meldeweg.")
code, ausgabe = _notify(bash, ["-d", "-s", "[Test]", text])
gedeutet = deute_eintrag(log_eintrag(melde_log(), f"Kennung {kennung}"), text)
basis["kennung"] = kennung
if gedeutet["gesendet"]:
weg = "über Hermes" if gedeutet["weg"] == "hermes" else "direkt an die Telegram-Bot-API"
return {**basis, "ok": True, **gedeutet,
"text": f"Die Testmeldung ist raus ({weg}). In Telegram steht sie mit der Kennung {kennung}."}
grund = gedeutet["grund"] or (f"Im Melde-Log {melde_log()} steht nichts zu dieser Testmeldung"
+ (f"; notify.sh endete mit Code {code}" if code is not None else "")
+ (f": {ausgabe[:200]}" if ausgabe else "."))
return {**basis, "ok": False, **gedeutet, "grund": grund, "detail": f"Die Testmeldung ging nicht raus: {grund}"}
+5
View File
@@ -99,3 +99,8 @@ def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8) return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8)
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
k = ctx // 1024
return {"ctx": ctx, "k": k,
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
+3 -9
View File
@@ -7,7 +7,6 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
import json import json
import logging import logging
import threading
import time import time
from services.token_stats import increment_tokens from services.token_stats import increment_tokens
@@ -38,17 +37,12 @@ def warn_truncation(model: str, max_tokens: int | None = None) -> None:
return return
_trunc_last[model] = now _trunc_last[model] = now
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model) log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
# Im eigenen Thread abliefern: announce.add spricht im Gateway-Prozess per HTTP mit MC2 (bis 5 s).
# Synchron hätte das den Event-Loop des Gateways und damit jeden LLM-Strom der Box angehalten.
threading.Thread(target=_melde_abriss, args=(model,), daemon=True).start()
def _melde_abriss(model: str) -> None:
try: try:
from services import announce from services import announce
announce.add( announce.add(
f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen " f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.", f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
"[Kontext-Limit]", "gateway", "silent") "[Kontext-Limit]", "gateway", "silent")
except Exception: except Exception:
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True) log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
-100
View File
@@ -1,100 +0,0 @@
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
gleichbedeutend mit Root auf der Box gewesen.
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
erreicht — nicht, dass die Datei unlesbar wäre.
"""
import json
import logging
import os
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
ERLAUBT = frozenset({"hf_token"})
def _lesen() -> dict[str, str]:
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
try:
daten = json.loads(PFAD.read_text(encoding="utf-8"))
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
except (OSError, ValueError):
return {}
def _schreiben(daten: dict[str, str]) -> bool:
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
tmp = PFAD.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
tmp.replace(PFAD)
return True
except OSError as exc:
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
return False
def hf_token() -> str | None:
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
def setzen(schluessel: str, wert: str | None) -> bool:
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
if schluessel not in ERLAUBT:
return False
daten = _lesen()
if wert:
daten[schluessel] = wert
else:
daten.pop(schluessel, None)
return _schreiben(daten)
def status() -> dict[str, bool]:
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
daten = _lesen()
return {
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
"schreibbar": _schreibbar(),
}
def _schreibbar() -> bool:
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
return os.access(PFAD.parent, os.W_OK)
except OSError:
return False
-21
View File
@@ -1,21 +0,0 @@
"""Herkunftsprüfung für Knöpfe (24.09.2026, User-Entscheid: keine Anmeldung).
Knöpfe schicken POST/PUT/DELETE an /api. Ein Browser setzt dabei den Origin-Header — kommt er von
einer fremden Webseite, wird die Anfrage abgelehnt. Das verhindert, dass eine fremde Seite im
Browser eines Heimnetz-Geräts heimlich Updates, Neustarts oder Löschen auslöst.
Unverändert erlaubt: Anfragen ohne Origin (Skripte, curl, Lucy-Watchdog), Origin „null“/„file://“
(Desktop-Lucy aus Electron) und alles unter /v1 (OpenChamber, Hermes).
"""
ENTWICKLUNG = {"localhost:5173", "127.0.0.1:5173", "localhost:5180", "localhost:5181"}
SCHREIBEND = {"POST", "PUT", "PATCH", "DELETE"}
def erlaubt(methode: str, pfad: str, origin: str | None, host: str | None) -> bool:
if methode.upper() not in SCHREIBEND or not pfad.startswith("/api/"):
return True
if not origin or origin in ("null", "file://"):
return True
wirt = origin.split("://", 1)[-1].rstrip("/")
return wirt == (host or "") or wirt in ENTWICKLUNG
+15 -25
View File
@@ -17,12 +17,10 @@ def normalize_repo(s: str) -> str:
return s.strip("/") return s.strip("/")
def list_quants(repo: str) -> list[dict]: def list_quants(repo: str) -> list[str]:
"""Verfügbare Quant-Stufen eines Repos mit Downloadgröße (alle Teile + mmproj), ohne mmproj """Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
als eigene Stufe. Ein Aufruf der Hugging-Face-API für alle Stufen."""
baum = _tree(repo)
quants: set[str] = set() quants: set[str] = set()
for e in baum: for e in _tree(repo):
p = str(e.get("path", "")) p = str(e.get("path", ""))
if p.lower().endswith(".gguf") and "mmproj" not in p.lower(): if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE) m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
@@ -30,18 +28,18 @@ def list_quants(repo: str) -> list[dict]:
quants.add(m.group(1).upper()) quants.add(m.group(1).upper())
# gängige Reihenfolge zuerst # gängige Reihenfolge zuerst
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6} order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
return [{"quant": q, "total_bytes": auswahl(baum, q)["total_bytes"]} return sorted(quants, key=lambda q: (order.get(q, 99), q))
for q in sorted(quants, key=lambda q: (order.get(q, 99), q))]
def search(q: str = "", limit: int = 24) -> list[dict]: def search(q: str = "", limit: int = 24) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern).""" """Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit} url = (f"https://huggingface.co/api/models?filter=gguf"
f"&sort=downloads&direction=-1&limit={limit}")
if q and q.strip(): if q and q.strip():
params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt) url += f"&search={q.strip()}"
try: try:
with httpx.Client(timeout=12.0) as c: with httpx.Client(timeout=12.0) as c:
data = c.get("https://huggingface.co/api/models", params=params).json() data = c.get(url).json()
except Exception: except Exception:
return [] return []
out = [] out = []
@@ -71,34 +69,26 @@ def _size(entry: dict) -> int:
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict: def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
"""Beste GGUF-Auswahl eines Repos für einen Quant (siehe auswahl).""" """Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs
return auswahl(_tree(repo), quant)
def auswahl(tree: list[dict], quant: str = "Q4_K_M") -> dict:
"""GGUF-Auswahl aus dem Dateibaum eines Repos für einen Quant. Behandelt Split-GGUFs
(-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download. (-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download.
Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool} Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool}
""" """
tree = _tree(repo)
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")] ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
q = quant.lower() q = quant.lower()
# mmproj separat (Vision-Projektor) # mmproj separat (Vision-Projektor)
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None) mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
model = [e for e in ggufs if "mmproj" not in e["path"].lower()] model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
# Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück — # bevorzugt den gewünschten Quant
# bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB). pref = [e for e in model if q in e["path"].lower()]
chosen = [e for e in model if q in e["path"].lower()] chosen = pref or model
if not chosen: if not chosen:
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False} return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix). # Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
split = any("-of-" in e["path"].lower() for e in chosen) split = any("-of-" in e["path"].lower() for e in chosen)
if split: if split:
def _gruppe(pfad: str) -> str: parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
return re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", pfad, flags=re.IGNORECASE)
erste = min(e["path"] for e in chosen if "-of-" in e["path"].lower())
parts = sorted([e for e in chosen if "-of-" in e["path"].lower() and _gruppe(e["path"]) == _gruppe(erste)],
key=lambda e: e["path"])
files = [e["path"] for e in parts] files = [e["path"] for e in parts]
first = files[0] first = files[0]
total = sum(_size(e) for e in parts) total = sum(_size(e) for e in parts)
-9
View File
@@ -1,9 +0,0 @@
"""Der Homelab-Teil (Rolle homelab, Phase 3/4): Proxmox-Host, Container und Arcane.
apps.py was in welchem Container steckt, wo seine Oberfläche liegt, woher die neueste Version kommt
kanal.py Aufträge und Berichte des Ausführers auf dem Proxmox-Host (gemeinsames Geheimnis)
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
karenz.py Wartezeit nach einer Änderung am Update-Skript (community-scripts, ungepinnt von GitHub)
updates.py „Jetzt updaten“: Snapshot bzw. Sicherung → Update → Prüfung → bei Rot zurück, mit Meldung
pflege.py wöchentliches Suchen (Paketlisten der Gäste), im Wächter-Takt des Stewards
"""
-56
View File
@@ -1,56 +0,0 @@
"""Was in welchem Container steckt (Community-Scripts-Kennung aus /usr/bin/update im Gast).
Stand der Bestandsaufnahme vom 24.09.2026: sechs Container, alle mit dem Etikett community-script.
Wie eine App aktualisiert wird, ist je Community-Script verschieden (nachgelesen am 24.09. in ct/<app>.sh):
skript `update` im Gast spielt die App wirklich neu ein (Gitea: neues Programm von GitHub,
NetBird: apt aus dem NetBird-Repository, NPMplus: neues Docker-Image)
eigene-oberflaeche das Skript verweist auf den eingebauten Updater der App (AdGuard, PVE Scripts Local)
pakete die App kommt als Paket; das Update der Pakete im Gast ist ihr Update (PBS)
Nur beim Weg „skript“ bietet die Übersicht „Jetzt updaten“ für die App an.
Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class App:
kennung: str
name: str
quelle: str | None = None # GitHub owner/repo für die neueste Version; None = über die Pakete des Gasts
vergleich: str = "version" # „version“ oder „datum“ (Docker-Images ohne Versionsnummer)
port: int | None = None # Weboberfläche; None = keine (z. B. NetBird-Client)
https: bool = False
pfad: str = "/"
weg: str = "skript" # „skript“, „eigene-oberflaeche“ oder „pakete“ (siehe oben)
KATALOG: dict[str, App] = {a.kennung: a for a in (
App("adguard", "AdGuard Home", "AdguardTeam/AdGuardHome", port=8080, weg="eigene-oberflaeche"),
App("npmplus", "NPMplus", "ZoeyVid/NPMplus", vergleich="datum", port=81, https=True),
App("netbird", "NetBird", "netbirdio/netbird"),
App("pve-scripts-local", "PVE Scripts Local", "community-scripts/ProxmoxVE-Local", port=3000,
weg="eigene-oberflaeche"),
App("gitea", "Gitea", "go-gitea/gitea", port=3000),
App("proxmox-backup-server", "Proxmox Backup Server", port=8007, https=True, weg="pakete"),
)}
# Gäste, die keine Community-Scripts sind, erkennt man am Namen.
NACH_NAME: dict[str, App] = {
"arcane": App("arcane", "Arcane (Docker)", port=3552, weg="eigene-oberflaeche"),
}
def app_fuer(kennung: str | None, name: str | None) -> App | None:
if kennung and kennung in KATALOG:
return KATALOG[kennung]
return NACH_NAME.get(str(name or "").lower())
def adresse(app: App | None, ip: str | None) -> str | None:
"""Adresse der Weboberfläche, falls es eine gibt und die IP bekannt ist."""
if not app or not app.port or not ip:
return None
return f"{'https' if app.https else 'http'}://{ip}:{app.port}{app.pfad}"
-99
View File
@@ -1,99 +0,0 @@
"""Arcane und Docker auf der Arcane-VM (Phase 3/4, 24.09.2026).
Arcane (getarcaneapp/arcane) verwaltet die Docker-Container der VM (NerdQuiz, Rippy …) und hat eine
eigene Schnittstelle (OpenAPI unter /api/openapi.json). Öffentlich ist nur die eigene Version
(/api/app-version mit currentVersion, newestVersion, updateAvailable). Alles über Images braucht einen
API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt — MC_ARCANE_KEY in /etc/mc2/homelab.env.
Docker-Updates laufen über Arcanes eigenen Updater (POST /environments/{id}/updater/run). User-Entscheid
24.09.: zuerst nur als Probelauf (dryRun); echte Updates erst mit MC_ARCANE_ECHT=1.
"""
import os
import threading
import time
import httpx
ZEITLIMIT = httpx.Timeout(20.0, connect=5.0)
CACHE_S = 120
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
def _gemerkt(schluessel: str, holen):
"""Arcane merkt sich den Update-Stand selbst; die Übersicht fragt ihn höchstens alle zwei Minuten ab."""
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(schluessel)) and jetzt - eintrag[0] < CACHE_S:
return eintrag[1]
wert = holen()
with _lock:
_cache[schluessel] = (jetzt, wert)
return wert
def schluessel() -> str:
return os.environ.get("MC_ARCANE_KEY", "").strip()
def echt() -> bool:
return os.environ.get("MC_ARCANE_ECHT", "") == "1"
def app_version(url: str) -> dict | None:
"""Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None."""
return _gemerkt(f"version:{url}", lambda: _app_version(url))
def _app_version(url: str) -> dict | None:
try:
d = httpx.get(f"{url}/api/app-version", timeout=ZEITLIMIT).json()
except (httpx.HTTPError, ValueError):
return None
if not isinstance(d, dict) or not d.get("currentVersion"):
return None
return {"installiert": str(d["currentVersion"]).lstrip("v"), "neu": str(d.get("newestVersion") or "").lstrip("v") or None,
"update": bool(d.get("updateAvailable"))}
def _get(url: str, pfad: str) -> object:
r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": schluessel()}, timeout=ZEITLIMIT)
r.raise_for_status()
return r.json()
def umgebungen(url: str) -> list[dict]:
daten = _get(url, "/environments?limit=50")
return [e for e in (daten or {}).get("data") or [] if isinstance(e, dict) and e.get("id") is not None]
def images_mit_update(url: str) -> list[dict]:
"""Images mit einem neueren Stand in der Registry, über alle Umgebungen: {"name", "aktuell", "neu", "benutzt"}."""
return _gemerkt(f"images:{url}", lambda: _images_mit_update(url))
def _images_mit_update(url: str) -> list[dict]:
ergebnis = []
for umgebung in umgebungen(url):
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500")
for image in (daten or {}).get("data") or []:
info = image.get("updateInfo") or {}
if not info.get("hasUpdate"):
continue
name = f"{image.get('repo') or '?'}:{image.get('tag') or 'latest'}"
ergebnis.append({"name": name, "aktuell": info.get("currentVersion") or None,
"neu": info.get("latestVersion") or None, "umgebung": umgebung["id"],
"benutzt": [str(u.get("name") if isinstance(u, dict) else u) for u in image.get("usedBy") or []]})
return ergebnis
def updater(url: str, probelauf: bool) -> dict:
"""Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler)."""
ergebnisse = {}
for umgebung in umgebungen(url):
r = httpx.post(f"{url}/api/environments/{umgebung['id']}/updater/run", headers={"X-API-Key": schluessel()},
json={"dryRun": probelauf}, timeout=httpx.Timeout(600.0, connect=5.0))
r.raise_for_status()
ergebnisse[str(umgebung.get("name") or umgebung["id"])] = (r.json() or {}).get("data") or {}
return ergebnisse
-303
View File
@@ -1,303 +0,0 @@
"""Vom Bericht des Ausführers zu Zielen im gemeinsamen Modell (kern/ziele.py) — Phase 3, 24.09.2026.
Je Gerät ein Ziel: der Proxmox-Host (Pakete, Neustart) und jeder freigegebene Gast (Etikett
community-script oder watcher, nicht watcher-aus) mit seinen Bausteinen „App“ (Community-Script,
Vergleich mit der neuesten Veröffentlichung auf GitHub) und „Pakete“ (Betriebssystem im Gast).
Dazu prüft dieser Teil selbst, ob die Weboberfläche jedes Gasts antwortet.
Wie ehrlich der Stand ist, steht dabei: Sind die Paketlisten im Gast älter als zwei Wochen, heißt es
„unbekannt“ (mit Knopf zum Suchen) statt „aktuell“; ist der Bericht älter als eine halbe Stunde, sagt
die Übersicht, dass der Ausführer schweigt. Ist das Update-Skript einer App frisch geändert, bleibt sie
„neu“, aber ohne Knopf (karenz.py). Rückweg und Rückfrage sagen, was vor dem Update geschieht: Snapshot,
wo keiner geht eine Sicherung, sonst keiner.
"""
import re
import threading
import time
from datetime import datetime
import httpx
from kern.github import neueste_version
from kern.zeit import LOCAL_TZ
from kern.ziele import Aktion, BausteinStand, ZielStand
from services.homelab import apps, arcane, kanal, karenz
BERICHT_ALT_S = 30 * 60
LISTEN_ALT_S = 14 * 24 * 3600
EIGENES_ETIKETT = "mc2"
ERREICHBAR_CACHE_S = 60
_erreichbar_cache: dict[str, tuple[float, bool]] = {}
_erreichbar_lock = threading.Lock()
def erreichbar(url: str) -> bool:
"""Antwortet die Weboberfläche? Alles unter 500 zählt (eine Anmeldeseite ist eine Antwort)."""
jetzt = time.time()
with _erreichbar_lock:
if (eintrag := _erreichbar_cache.get(url)) and jetzt - eintrag[0] < ERREICHBAR_CACHE_S:
return eintrag[1]
try:
# Selbstsignierte Zertifikate sind im Heimnetz üblich (PBS, NPMplus); geprüft wird nur, ob jemand antwortet.
ok = httpx.get(url, timeout=5.0, verify=False, follow_redirects=False).status_code < 500
except httpx.HTTPError:
ok = False
with _erreichbar_lock:
_erreichbar_cache[url] = (jetzt, ok)
return ok
def erreichbar_frisch(url: str) -> bool:
"""Wie erreichbar(), aber ohne den Zwischenspeicher — für die Prüfung direkt nach einem Update."""
with _erreichbar_lock:
_erreichbar_cache.pop(url, None)
return erreichbar(url)
def _teile(version: str) -> tuple[int, ...]:
return tuple(int(x) for x in re.findall(r"\d+", version.split("-")[0])[:4])
def neuer(verfuegbar: str, installiert: str) -> bool | None:
"""Ist verfuegbar neuer als installiert? None, wenn sich das nicht sagen lässt."""
a, b = _teile(verfuegbar), _teile(installiert)
if not a or not b:
return None
return a > b
def _datum(ts: float | None) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.%Y") if ts else "?"
def _rueckweg(gast: dict) -> str:
"""Die Zeile „Rückweg“ der Karte: was vor jedem Update geschieht."""
if gast.get("snapshot_moeglich"):
return "Snapshot vor jedem Update"
grund = gast.get("snapshot_grund") or "kein Snapshot möglich"
if gast.get("sicherung_moeglich"):
return f"Sicherung auf „{gast.get('sicherung_speicher') or 'dem Host'}“ vor jedem Update ({grund})"
if gast.get("sicherung_grund"):
return f"Kein automatischer Rückweg: {grund}. Auch keine Sicherung: {gast['sicherung_grund']}"
return grund
def _rueckweg_art(gast: dict) -> str:
"""Dasselbe in einem Wort — die Oberfläche fasst den Rückweg in der Update-Liste kurz."""
if gast.get("snapshot_moeglich"):
return "snapshot"
return "sicherung" if gast.get("sicherung_moeglich") else "keiner"
def _rueckweg_frage(gast: dict) -> str:
"""Der Satz zum Rückweg in der Rückfrage vor „Jetzt updaten“."""
if gast.get("snapshot_moeglich"):
return "Vorher wird ein Snapshot angelegt; ist die Prüfung danach rot, geht es von selbst zurück."
if gast.get("sicherung_moeglich"):
return "Vorher wird eine Sicherung angelegt; ist die Prüfung danach rot, wird sie zurückgespielt."
satz = "Ein Snapshot ist hier nicht möglich — scheitert das Update, gibt es keinen automatischen Rückweg."
if gast.get("sicherung_grund"):
satz += f" Eine Sicherung geht auch nicht: {gast['sicherung_grund']}"
return satz
def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
installiert = (gast.get("app") or {}).get("version")
stand = BausteinStand(id="app", name=app.name, zustand="aktuell", installiert=installiert)
if not app.quelle:
return stand
try:
neu = neueste_version(app.quelle)
except Exception:
neu = None
if not neu:
stand.zustand, stand.grund = "unbekannt", "Die neueste Version ist gerade nicht abrufbar (GitHub)."
return stand
stand.verfuegbar = neu["version"]
if installiert and installiert.startswith("untagged-"):
# Manche Projekte veröffentlichen ohne Versions-Tag; die App kennt dann nur dieses Tag (PVE Scripts Local).
if installiert == neu.get("tag_roh"):
stand.installiert = neu["version"]
return stand
stand.zustand, stand.grund = "unbekannt", "Die installierte Fassung lässt sich keiner Version zuordnen."
return stand
if app.vergleich == "datum":
# Docker-Image ohne Versionsnummer: neuer, wenn die Veröffentlichung jünger ist als das Image.
bild = re.search(r"\d{4}-\d{2}-\d{2}", installiert or "")
ist_neuer = (neu["datum"] or "") > bild.group(0) if bild else None
else:
ist_neuer = neuer(neu["version"], installiert) if installiert else None
if ist_neuer is None:
stand.zustand, stand.grund = "unbekannt", "Die installierte Version ließ sich nicht lesen."
elif ist_neuer and app.weg != "skript":
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
stand.grund = f"{app.name} aktualisiert sich über die eigene Oberfläche; das Community-Script tut es nicht."
elif ist_neuer:
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
# Frisch geänderte Update-Skripte erst nach der Wartezeit (karenz.py): „neu“, aber ohne Knopf.
wartezeit = karenz.pruefen(app.kennung)
if wartezeit["gesperrt"]:
stand.grund = wartezeit["gesperrt"]
return stand
frage = f"{app.name} jetzt aktualisieren? {_rueckweg_frage(gast)}"
if not wartezeit["geprueft"]:
frage += f" {karenz.NICHT_GEPRUEFT}"
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/update", frage)
return stand
def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
os_stand = gast.get("os_updates") or {}
stand = BausteinStand(id="os", name="Pakete", zustand="aktuell")
anzahl, listen = os_stand.get("anzahl"), os_stand.get("listen_stand")
suchen = Aktion("POST", f"/api/homelab/ziele/{zid}/suchen", "Die Paketlisten im Gast jetzt erneuern?",
label="Nach Updates suchen")
if anzahl is None:
stand.zustand, stand.grund = "unbekannt", "Die Pakete ließen sich nicht lesen."
elif listen and jetzt - listen > LISTEN_ALT_S:
stand.zustand, stand.aktion = "unbekannt", suchen
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
elif anzahl > 0:
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
f"{anzahl} Pakete im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
return stand
def _arcane_bausteine(basis: str | None, zid: str) -> list[BausteinStand]:
"""Arcane selbst (öffentliche Version) und die Docker-Images (mit API-Schlüssel)."""
app = arcane.app_version(basis) if basis else None
selbst = BausteinStand(id="arcane", name="Arcane", zustand="unbekannt",
installiert=app["installiert"] if app else None, verfuegbar=app["neu"] if app else None)
if not app:
selbst.grund = "Arcane antwortet nicht."
elif app["update"]:
selbst.zustand, selbst.kurz = "neu", f"{app['installiert']} → {app['neu']}"
selbst.grund = "Arcane spielt sein eigenes Update über die eigene Oberfläche ein (Einstellungen)."
else:
selbst.zustand = "aktuell"
docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt")
if not arcane.schluessel():
docker.grund = ("Es fehlt ein Arcane-API-Schlüssel (MC_ARCANE_KEY): ohne ihn sieht der Orchestrator "
"die Images nicht.")
elif basis:
try:
images = arcane.images_mit_update(basis)
except Exception:
docker.grund = "Arcane verweigert die Auskunft oder antwortet nicht (Schlüssel prüfen)."
else:
if images:
docker.zustand, docker.kurz = "neu", f"{len(images)} Images"
docker.grund = ", ".join(i["name"] for i in images[:6]) + (" …" if len(images) > 6 else "")
probe = not arcane.echt()
docker.aktion = Aktion(
"POST", f"/api/homelab/ziele/{zid}/docker",
("Arcane prüft im Probelauf, welche Container es aktualisieren würde — geändert wird noch nichts."
if probe else "Arcane aktualisiert jetzt die Container mit neuen Images; Daten-Volumes bleiben."),
label="Probelauf" if probe else "Jetzt updaten")
else:
docker.zustand = "aktuell"
return [selbst, docker]
def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
art = "container" if gast["art"] == "lxc" else "vm"
zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}"
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
url = apps.adresse(app, gast.get("ip"))
laeuft = gast.get("status") == "running"
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
rueckweg=_rueckweg(gast), rueckweg_art=_rueckweg_art(gast), platte=gast.get("platte"))
if not laeuft:
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
return ziel
if app and app.kennung == "arcane":
# Arcane läuft über seine eigene Schnittstelle, nicht über den Ausführer: kein Etikett nötig.
ziel.bausteine += _arcane_bausteine(url.rstrip("/") if url else None, zid)
return ziel
if not gast.get("erlaubt"):
ziel.bausteine.append(BausteinStand(
id="freigabe", name="Freigabe", zustand="unbekannt",
grund="Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt)."))
return ziel
if app and app.kennung in apps.KATALOG:
ziel.bausteine.append(_app_baustein(gast, app, zid))
if gast["art"] == "lxc":
ziel.bausteine.append(_os_baustein(gast, zid, jetzt))
return ziel
def _host_ziel(host: dict) -> ZielStand:
ziel = ZielStand(id="pve", name="Proxmox-Host", art="proxmox-host", instanz="homelab", erreichbar=True,
rueckweg="Kein Snapshot möglich; der Host bleibt beim Update in Betrieb, neu gestartet wird getrennt.",
rueckweg_art="keiner")
updates = host.get("updates")
pakete = BausteinStand(id="pakete", name="Proxmox VE und Debian", zustand="aktuell",
installiert=host.get("version"))
if updates is None:
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
elif updates:
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
ziel.bausteine.append(pakete)
if host.get("neustart_noetig"):
ziel.bausteine.append(BausteinStand(
id="neustart", name="Neustart", zustand="neu", kurz="steht an",
aktion=Aktion("POST", "/api/homelab/ziele/pve/neustart",
"Den Proxmox-Host jetzt neu starten? ALLE Container und die Arcane-VM sind dann ein paar "
"Minuten weg. Die KI-Box prüft danach von außen, ob alles wiederkommt.",
label="Jetzt neu starten")))
return ziel
def ziele() -> dict:
"""Alle Ziele des Homelabs aus dem letzten Bericht, dazu wie frisch er ist."""
eingang = kanal.bericht()
jetzt = time.time()
zuletzt = kanal.zuletzt()
ausfuehrer = {"verbunden": bool(zuletzt and jetzt - zuletzt < BERICHT_ALT_S), "zuletzt": zuletzt}
if not eingang:
return {"ziele": [], "bericht": None, "ausfuehrer": ausfuehrer}
b = eingang["bericht"]
liste = [_host_ziel(b.get("host") or {})]
# Der Container des Orchestrators selbst (Etikett „mc2“) ist kein Gerät, das er pflegt.
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or [] if EIGENES_ETIKETT not in (g.get("etiketten") or [])]
for z in liste:
z.geprueft = eingang["empfangen"]
return {"ziele": [z.als_dict() for z in liste],
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
"ausfuehrer": ausfuehrer}
def erreichbarkeit() -> list[dict]:
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen. Gäste mitten in
einem Update-Lauf bleiben außen vor: Sicherung, Update und Zurückspielen legen sie kurz still, und was dabei
herauskommt, meldet der Lauf selbst (sonst käme ein zweiter Alarm samt „wieder ok“)."""
from services.homelab import updates # updates importiert dieses Modul
eingang = kanal.bericht()
im_update = updates.laufende_ziele()
ergebnis = []
for g in (eingang or {}).get("bericht", {}).get("gaeste") or []:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
url = apps.adresse(app, g.get("ip"))
zid = f"{'ct' if g['art'] == 'lxc' else 'vm'}-{g['vmid']}"
if url and g.get("erlaubt") and zid not in im_update:
ergebnis.append({"id": zid, "name": app.name if app else g.get("name"), "url": url,
"laeuft": g.get("status") == "running",
"ok": g.get("status") == "running" and erreichbar(url)})
return ergebnis
def gast(zid: str) -> dict | None:
"""Der Gast zu einer Ziel-ID aus dem letzten Bericht (ct-104 → vmid 104)."""
m = re.fullmatch(r"(ct|vm)-(\d+)", zid)
eingang = kanal.bericht()
if not m or not eingang:
return None
return next((g for g in eingang["bericht"].get("gaeste") or [] if g.get("vmid") == int(m.group(2))), None)
-203
View File
@@ -1,203 +0,0 @@
"""Kanal zum Ausführer auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
Der Ausführer (deploy/homelab/ausfuehrer.py) holt sich Aufträge hier ab, liefert die Ergebnisse und
alle zehn Minuten einen Bericht. Er weist sich mit einem gemeinsamen Geheimnis aus (Kopfzeile
X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/ausfuehrer.token (nur
für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc/mc2-ausfuehrer.json.
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
Zwei Prozesse schreiben hinein: die Oberfläche (Knöpfe, Abholen durch den Ausführer) und seit 24.09.2026 der
Steward (wöchentliches Suchen, pflege.py). Lesen, ändern, schreiben geschieht deshalb unter einer Dateisperre.
"""
import hmac
import json
import os
import secrets
import threading
import time
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from kern.einstellungen import einstellungen
try:
import fcntl # Linux: Sperre über Prozessgrenzen
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
fcntl = None
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 60 # so viele erledigte Aufträge bleiben sichtbar
_lock = threading.Lock()
_kontakt: dict = {"zuletzt": None}
def _dir() -> Path:
return einstellungen().daten_dir
@contextmanager
def _sperre() -> Iterator[None]:
"""Für Lesen-ändern-schreiben der Auftragsliste: Thread-Sperre, unter Linux zusätzlich flock. Ohne sie
überschrieben sich Oberfläche und Steward gegenseitig (ein abgeholter Auftrag stünde wieder auf „wartet“)."""
with _lock:
if fcntl is None:
yield
return
pfad = _dir() / "ausfuehrer-auftraege.lock"
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "a", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def _json_schreiben(pfad: Path, daten: object) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
tmp.replace(pfad)
def _json_lesen(pfad: Path, leer: object) -> object:
try:
return json.loads(pfad.read_text(encoding="utf-8"))
except (OSError, ValueError):
return leer
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
def token() -> str:
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
return wert
pfad = _dir() / "ausfuehrer.token"
try:
return pfad.read_text(encoding="utf-8").strip()
except OSError:
pass
wert = secrets.token_urlsafe(32)
pfad.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
return pfad.read_text(encoding="utf-8").strip()
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(wert + "\n")
return wert
def token_gueltig(wert: str | None) -> bool:
return bool(wert) and hmac.compare_digest(str(wert), token())
def kontakt() -> None:
_kontakt["zuletzt"] = time.time()
def zuletzt() -> float | None:
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
# --- Bericht ------------------------------------------------------------------------------
def bericht_speichern(daten: dict) -> None:
with _lock:
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten})
def bericht() -> dict | None:
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
with _lock:
daten = _json_lesen(_dir() / "pve-bericht.json", None)
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
# --- Aufträge -------------------------------------------------------------------------------
def _pfad() -> Path:
return _dir() / "ausfuehrer-auftraege.json"
def _alle() -> list[dict]:
daten = _json_lesen(_pfad(), [])
return daten if isinstance(daten, list) else []
def _merken(auftraege: list[dict]) -> None:
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
def anlegen(aktion: str, parameter: dict | None = None) -> str:
if aktion not in AKTIONEN:
raise ValueError(f"Unbekannte Aktion: {aktion}")
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
"code": None, "text": None}
with _sperre():
auftraege = _alle()
auftraege.append(auftrag)
_merken(auftraege)
return auftrag["id"]
def naechster() -> dict | None:
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
vorher als verloren abgeschlossen."""
jetzt = time.time()
with _sperre():
auftraege = _alle()
for a in auftraege:
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
if auftrag:
auftrag.update(status="laeuft", abgeholt=jetzt)
_merken(auftraege)
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
with _sperre():
auftraege = _alle()
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
if auftrag is None or auftrag["status"] != "laeuft":
return False
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
text=str(text)[-20000:])
_merken(auftraege)
return True
def auftrag(auftrag_id: str) -> dict | None:
# Lesen unter derselben Sperre wie das Schreiben: Unter Windows scheitert das atomare Ersetzen,
# solange ein anderer Thread die Datei offen hat.
with _lock:
return next((a for a in _alle() if a["id"] == auftrag_id), None)
def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None:
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
ende = time.time() + zeitlimit_s
while time.time() < ende:
a = auftrag(auftrag_id)
if a and a["status"] not in ("wartet", "laeuft"):
return a
time.sleep(takt_s)
return None
def liste() -> list[dict]:
with _lock:
return list(reversed(_alle()))
-61
View File
@@ -1,61 +0,0 @@
"""Wartezeit nach einer Änderung am Update-Skript (24.09.2026).
„Jetzt updaten“ lässt im Gast `update` laufen. Das lädt ct/<kennung>.sh ungepinnt von GitHub
(community-scripts/ProxmoxVE, Zweig main) und führt es als root aus. Eine Änderung, die erst ein paar Stunden
alt ist, hat noch kaum jemand im Betrieb gesehen. Deshalb gilt für Apps mit dem Weg „skript“: Ist die letzte
Änderung an ct/<kennung>.sh jünger als MC_HOMELAB_KARENZ_H Stunden (Standard 48), bleibt der Baustein „neu“,
aber ohne Knopf, und updates.starten() lehnt ab — beide mit demselben Satz.
Wann das Skript zuletzt geändert wurde, sagt die GitHub-API (kern/github.py, 15 Minuten gemerkt). Lässt sich das
nicht abfragen, wird nicht blockiert; die Rückfrage vor dem Update sagt es dann (NICHT_GEPRUEFT).
"""
import os
import re
import time
from datetime import datetime
from kern.github import github_json
from kern.zeit import LOCAL_TZ
REPO = "community-scripts/ProxmoxVE"
STANDARD_H = 48.0
NICHT_GEPRUEFT = "Ob das Skript kürzlich geändert wurde, ließ sich nicht prüfen."
_KENNUNG = re.compile(r"^[a-z0-9-]+$")
def karenz_s() -> float:
"""Die Wartezeit in Sekunden (MC_HOMELAB_KARENZ_H; 0 schaltet sie ab)."""
try:
stunden = float(os.environ.get("MC_HOMELAB_KARENZ_H", "") or STANDARD_H)
except ValueError:
stunden = STANDARD_H
return max(0.0, stunden) * 3600
def skript_geaendert(kennung: str) -> float:
"""Wann ct/<kennung>.sh auf main zuletzt geändert wurde (Unix-Sekunden). Wirft, wenn es sich nicht sagen lässt."""
if not _KENNUNG.match(kennung or ""):
raise ValueError(f"ungültige Kennung {kennung!r}")
daten = github_json(f"repos/{REPO}/commits?path=ct/{kennung}.sh&sha=main&per_page=1")
# Das Datum, an dem die Änderung auf main landete (committer, nicht author: ein Pull-Request kann älter sein).
datum = str(daten[0]["commit"]["committer"]["date"])
return datetime.fromisoformat(datum.replace("Z", "+00:00")).timestamp()
def sperrtext(geaendert: float, frei_ab: float) -> str:
a, b = datetime.fromtimestamp(geaendert, LOCAL_TZ), datetime.fromtimestamp(frei_ab, LOCAL_TZ)
return f"Das Update-Skript wurde am {a:%d.%m.} geändert; zur Sicherheit erst ab {b:%d.%m. %H:%M}."
def pruefen(kennung: str, jetzt: float | None = None) -> dict:
"""{"gesperrt": Satz oder None, "geprueft": bool}. Gesperrt, solange die letzte Änderung am Skript jünger ist
als die Wartezeit. Scheitert die Abfrage: nicht gesperrt, aber geprueft=False."""
try:
geaendert = skript_geaendert(kennung)
except Exception:
return {"gesperrt": None, "geprueft": False}
frei_ab = geaendert + karenz_s()
if (time.time() if jetzt is None else jetzt) >= frei_ab:
return {"gesperrt": None, "geprueft": True}
return {"gesperrt": sperrtext(geaendert, frei_ab), "geprueft": True}
-180
View File
@@ -1,180 +0,0 @@
"""Pflege im Hintergrund: wöchentlich nach Updates suchen (24.09.2026).
Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die erneuert dort niemand von selbst
(AdGuard stand im September 2026 auf Listen vom Oktober 2025). Deshalb stößt der Homelab-Teil den Auftrag
„suchen“ (apt-get update bzw. apk update im Gast, ändert keine Pakete) selbst an, sobald die Listen eines Gasts
älter als sieben Tage sind:
• nur freigegebene, laufende Container — der Ausführer prüft das Etikett ohnehin selbst noch einmal
• höchstens ein Auftrag je Gast und Tag; nie während eines Update-Laufs für diesen Gast und nie, solange für
ihn ein anderer Auftrag offen ist; nur, wenn der Ausführer gerade berichtet
• bevorzugt nachts 02:00–05:00 (Berlin); war die Instanz oder der Ausführer in der letzten Nacht nicht da,
eben gleich
• keine Meldungen. Scheitert die Suche für einen Gast FEHLSCHLAEGE_HINWEIS-mal hintereinander, wird daraus
ein gelber Hinweis des Wächters (gelb geht nicht an Telegram).
Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py,
PRUEFUNGEN). Zustand in <Datenordner>/homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst
landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt).
"""
import json
import logging
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services.homelab import apps, inventar, kanal, updates
log = logging.getLogger(__name__)
LISTEN_ALT_S = 7 * 24 * 3600
NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin
FEHLSCHLAEGE_HINWEIS = 2
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-pflege.json"
def zustand() -> dict:
"""{"nacht": Datum der letzten Nacht, in der gesucht werden konnte, "gaeste": {vmid: {…}}}"""
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
if not isinstance(daten, dict):
daten = {}
if not isinstance(daten.get("gaeste"), dict):
daten["gaeste"] = {}
return daten
def _schreiben(daten: dict) -> None:
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(_pfad())
def faellig(gaeste: list[dict], jetzt: float) -> list[dict]:
"""Die Container, deren Paketlisten eine neue Suche brauchen: freigegeben, laufend, Listen älter als 7 Tage."""
ergebnis = []
for g in gaeste:
listen = (g.get("os_updates") or {}).get("listen_stand")
if (g.get("art") == "lxc" and g.get("status") == "running" and g.get("erlaubt")
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])
and listen and jetzt - listen > LISTEN_ALT_S):
ergebnis.append(g)
return ergebnis
def _fehlerzeile(text: str | None) -> str:
"""Die aussagekräftigste Zeile einer gescheiterten Suche (apt meldet Fehler mit „E:“)."""
zeilen = [z.strip() for z in str(text or "").splitlines() if z.strip()]
wichtig = [z for z in zeilen if z.startswith(("E:", "ERROR", "Abgelehnt", "Zeitlimit", "Fehler"))]
return (wichtig or zeilen or ["ohne Angabe"])[-1][:200]
def _einsammeln(daten: dict) -> bool:
"""Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr."""
geaendert = False
for eintrag in daten["gaeste"].values():
aid = eintrag.get("auftrag")
if not aid:
continue
a = kanal.auftrag(aid)
if a is not None and a.get("status") in ("wartet", "laeuft"):
continue
if a is not None and a.get("status") == "fertig":
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
elif a is not None: # fehler, verloren
eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1
eintrag["fehler"] = _fehlerzeile(a.get("text"))
eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen
geaendert = True
return geaendert
def _offene_gaeste() -> set:
"""Gäste mit einem offenen Auftrag gleich welcher Art (auch von Hand oder aus einem Update-Lauf)."""
return {(a.get("parameter") or {}).get("vmid") for a in kanal.liste() if a.get("status") in ("wartet", "laeuft")}
def _befunde(daten: dict, faellige: list[dict]) -> list:
"""Gelbe Hinweise für Gäste, deren Listen weiter alt sind und deren Suche wiederholt scheiterte."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
befunde = []
for g in faellige:
eintrag = daten["gaeste"].get(str(g["vmid"])) or {}
anzahl = int(eintrag.get("fehlschlaege") or 0)
if anzahl < FEHLSCHLAEGE_HINWEIS:
continue
name = eintrag.get("name") or str(g.get("name") or g["vmid"])
befunde.append(Befund(
id=f"pflege:ct-{g['vmid']}", stufe="gelb", titel=f"{name}: Die Suche nach Updates scheitert",
text=(f"Die Paketlisten ließen sich {anzahl}-mal hintereinander nicht erneuern (wöchentliche Suche). "
f"Zuletzt: {eintrag.get('fehler') or 'ohne Angabe'}"),
quelle=f"ct-{g['vmid']}", sofort=True))
return befunde
def pruefe_paketlisten(jetzt: float | None = None) -> list:
"""Im Wächter-Takt: Ergebnisse einsammeln, fällige Suchen anstoßen. Rückgabe: Befunde für den Wächter —
nur, wenn die Suche für einen Gast wiederholt scheitert (gelb)."""
from services import waechter
jetzt = time.time() if jetzt is None else jetzt
lokal = datetime.fromtimestamp(jetzt, LOCAL_TZ)
heute = lokal.date().isoformat()
nachts = NACHT_VON <= lokal.hour < NACHT_BIS
# Die zuletzt begonnene Nacht: vor 02:00 die von gestern.
letzte_nacht = (lokal.date() - timedelta(days=0 if lokal.hour >= NACHT_VON else 1)).isoformat()
eingang = kanal.bericht()
bericht = (eingang or {}).get("bericht") or {}
gaeste = bericht.get("gaeste") or []
zuletzt = kanal.zuletzt()
# Aufträge nur, wenn der Ausführer gerade berichtet und nicht im Nur-Lesen-Modus läuft (dann lehnte er ab).
verbunden = bool(eingang and zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S
and not (bericht.get("host") or {}).get("nur_lesen"))
faellige = faellig(gaeste, jetzt)
with _lock:
daten = zustand()
geaendert = _einsammeln(daten)
faellig_ids = {str(g["vmid"]) for g in faellige}
for vmid, eintrag in daten["gaeste"].items():
# Listen wieder frisch (auch von Hand gesucht) oder Gast nicht mehr dabei: Zähler vergessen.
if gaeste and eintrag.get("fehlschlaege") and vmid not in faellig_ids:
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
geaendert = True
if verbunden and nachts and daten.get("nacht") != heute:
daten["nacht"] = heute
geaendert = True
# Nachts sowieso; tagsüber nur, wenn die letzte Nacht verpasst wurde (Instanz oder Ausführer war weg).
if verbunden and (nachts or daten.get("nacht") != letzte_nacht):
offen = _offene_gaeste()
im_update = updates.laufende_ziele()
for g in faellige:
vmid = g["vmid"]
eintrag = daten["gaeste"].setdefault(str(vmid), {})
if eintrag.get("tag") == heute or eintrag.get("auftrag") or vmid in offen \
or f"ct-{vmid}" in im_update:
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
eintrag["name"] = app.name if app else str(g.get("name") or vmid)
if waechter.TROCKEN:
log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"])
continue
eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt)
geaendert = True
log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"])
if geaendert:
_schreiben(daten)
return _befunde(daten, faellige)
-309
View File
@@ -1,309 +0,0 @@
"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026).
Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück
und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf.
Ablauf für einen Gast:
1. Rückweg anlegen: ein Snapshot; wo keiner geht (PBS: Bind-Mount), eine Sicherung (vzdump auf einen lokalen
Speicher des Hosts, nie auf den PBS); geht beides nicht, ohne Rückweg — die Rückfrage sagt es. Scheitert
dieser Schritt, beginnt das Update gar nicht.
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts. Ein frisch geändertes
Update-Skript wartet erst MC_HOMELAB_KARENZ_H Stunden (karenz.py).
3. 20 s warten, frischen Bericht holen
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung.
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
Jeder Lauf steht in <Datenordner>/homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel
läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen.
"""
import json
import logging
import re
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, update_verlauf
from services.homelab import apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__)
WARTEN_NACH_UPDATE_S = 20
ZEITLIMIT_UPDATE_S = 45 * 60
ZEITLIMIT_KURZ_S = 15 * 60
ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf)
BETREFF = "[Homelab-Update]"
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-laeufe.json"
def _laeufe() -> list[dict]:
with _lock:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
return daten if isinstance(daten, list) else []
def _merken(lauf: dict) -> None:
with _lock:
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8")
tmp.replace(_pfad())
def laeufe(anzahl: int = 20) -> list[dict]:
return list(reversed(_laeufe()))[:anzahl]
def laufende_ziele() -> set[str]:
"""Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege)."""
return {str(x.get("ziel")) for x in _laeufe() if x.get("status") == "laeuft"}
def laeuft(ziel_id: str) -> bool:
return ziel_id in laufende_ziele()
def unterbrochene_abschliessen() -> None:
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess."""
for lauf in _laeufe():
if lauf.get("status") == "laeuft":
lauf.update(status="unterbrochen", ende=time.time())
lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.")
_merken(lauf)
def _melden(text: str, dringend: bool = False) -> None:
try:
announce.notify_telegram("[Alarm] Homelab-Update" if dringend else BETREFF, text)
except Exception:
log.warning("homelab: Meldung ging nicht raus", exc_info=True)
def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict:
"""Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf."""
aid = kanal.anlegen(aktion, parameter)
lauf["schritte"].append(f"{aktion} …")
_merken(lauf)
a = kanal.warten(aid, zeitlimit_s)
if a is None:
raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten")
if a["status"] != "fertig":
raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}")
lauf["schritte"][-1] = f"{aktion}: ok"
return a
def _frischer_gast(lauf: dict, vmid: int) -> dict | None:
try:
a = _auftrag(lauf, "bericht", {}, 5 * 60)
bericht = json.loads(a.get("text") or "{}")
kanal.bericht_speichern(bericht)
except (RuntimeError, ValueError):
return None
return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None)
def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]:
"""Was nach dem Update nicht stimmt (leer = grün)."""
if nachher is None:
return ["Der Gast fehlt im neuen Bericht."]
fehler = []
if nachher.get("status") != "running":
fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).")
app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name"))
url = apps.adresse(app, nachher.get("ip"))
if url and not inventar.erreichbar_frisch(url):
fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.")
if baustein == "app":
alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version")
if alt and neu and alt == neu:
fehler.append(f"Die App-Version ist unverändert ({alt}).")
return fehler
def _rueckweg_anlegen(lauf: dict, gast: dict) -> tuple[str, str] | None:
"""Vor dem Update: ("snapshot", Name), wo keiner geht ("sicherung", Archiv), sonst None. Wirft RuntimeError,
wenn der Schritt scheitert (etwa zu wenig Platz) — dann beginnt das Update gar nicht."""
vmid = gast["vmid"]
if gast.get("snapshot_moeglich"):
a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S)
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
return ("snapshot", m.group(1)) if m else None
if gast.get("sicherung_moeglich"):
a = _auftrag(lauf, "sichern", {"vmid": vmid}, ZEITLIMIT_SICHERUNG_S)
m = re.search(r"^sicherung=(\S+)$", a.get("text") or "", re.MULTILINE)
if not m:
raise RuntimeError("sichern: Der Ausführer nannte keine Sicherung")
return ("sicherung", m.group(1))
return None
def _zurueck(lauf: dict, vmid: int, rueckweg: tuple[str, str]) -> str:
"""Den Rückweg gehen; Rückgabe: was geschah (für die Meldung). Wirft RuntimeError, wenn er scheitert."""
art, name = rueckweg
if art == "snapshot":
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
return f"automatisch zurück auf den Snapshot {name}"
_auftrag(lauf, "sicherung_zurueck", {"vmid": vmid, "sicherung": name}, ZEITLIMIT_SICHERUNG_S)
return f"automatisch die Sicherung von vorher zurückgespielt ({name.rsplit('/', 1)[-1]})"
def _gast_lauf(lauf: dict, gast: dict) -> None:
vmid, baustein = gast["vmid"], lauf["baustein"]
try:
rueckweg = _rueckweg_anlegen(lauf, gast)
except RuntimeError as exc:
_ende(lauf, "fehler", f"{lauf['name']}: Update nicht begonnen — {str(exc).rstrip('.')}. "
"Am Gerät wurde nichts geändert.")
return
try:
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S)
time.sleep(WARTEN_NACH_UPDATE_S)
fehler = pruefen(gast, _frischer_gast(lauf, vmid), baustein)
except RuntimeError as exc:
fehler = [str(exc)]
if not fehler:
_aufraeumen(lauf, vmid, rueckweg)
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
return
if rueckweg:
try:
wie = _zurueck(lauf, vmid, rueckweg)
_ende(lauf, "zurueckgerollt",
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — {wie}. Läuft wieder wie vorher.",
dringend=True)
return
except RuntimeError as exc:
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
if rueckweg[0] == "sicherung":
fehler.append(f"Die Sicherung {rueckweg[1]} liegt weiter auf dem Proxmox-Host.")
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
+ ("" if rueckweg else " Es gab weder Snapshot noch Sicherung, also keinen automatischen "
"Rückweg."),
dringend=True)
def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None:
"""Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen;
der Rückweg dieses Laufs (der neueste) bleibt."""
art, behalten = rueckweg or (None, None)
gast = inventar.gast(lauf["ziel"]) or {}
for name in gast.get("snapshots") or []:
if name.startswith("mc2-") and name != behalten:
try:
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
if art != "sicherung":
return
for volid in gast.get("sicherungen") or []:
if volid != behalten:
try:
_auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alte Sicherung {volid} blieb stehen.")
def _docker_lauf(lauf: dict, basis: str) -> None:
"""Docker über Arcanes Updater — zuerst nur als Probelauf (User-Entscheid 24.09.2026)."""
probe = not arcane.echt()
try:
ergebnisse = arcane.updater(basis, probelauf=probe)
except Exception as exc:
_ende(lauf, "fehler", f"Arcane: Updater gescheitert — {exc}", dringend=not probe)
return
gesamt = {k: sum(int((e or {}).get(k) or 0) for e in ergebnisse.values()) for k in ("checked", "updated", "failed")}
namen = [str(i.get("resourceName")) for e in ergebnisse.values() for i in (e or {}).get("items") or []
if isinstance(i, dict) and i.get("updateAvailable")]
if probe:
_ende(lauf, "offen", f"Arcane-Probelauf: {len(namen)} von {gesamt['checked']} Containern würden aktualisiert"
+ (f" ({', '.join(namen[:8])})" if namen else "") + ". Geändert wurde nichts.")
elif gesamt["failed"]:
_ende(lauf, "fehler", f"Arcane: {gesamt['failed']} Container ließen sich nicht aktualisieren, "
f"{gesamt['updated']} schon.", dringend=True)
else:
ok = inventar.erreichbar_frisch(basis + "/")
_ende(lauf, "eingespielt" if ok else "fehler",
f"Arcane: {gesamt['updated']} Container aktualisiert" + ("." if ok else ", aber Arcane antwortet danach nicht."),
dringend=not ok)
def _host_lauf(lauf: dict) -> None:
try:
if lauf["baustein"] == "neustart":
_auftrag(lauf, "host_neustart", {}, 60)
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
return
_auftrag(lauf, "host_update", {}, 60 * 60)
_auftrag(lauf, "bericht", {}, 5 * 60)
_ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.")
except RuntimeError as exc:
_ende(lauf, "fehler", f"Proxmox-Host: Update gescheitert — {exc}", dringend=True)
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time())
_merken(lauf)
try:
start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds")
update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text)
except Exception:
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
_melden(text, dringend=dringend)
def _neuer_lauf(ziel_id: str, baustein: str, name: str) -> dict:
lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft",
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []}
_merken(lauf)
return lauf
def starten(ziel_id: str, baustein: str) -> dict:
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
if baustein not in ("app", "os", "pakete", "neustart", "docker"):
return {"ok": False, "detail": "Unbekannter Baustein."}
if laeuft(ziel_id):
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
if ziel_id == "pve":
name, gast = "Proxmox-Host", None
else:
gast = inventar.gast(ziel_id)
if not gast:
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
if baustein == "docker":
app = apps.app_fuer(None, gast.get("name"))
basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/")
if not (app and app.kennung == "arcane" and basis and arcane.schluessel()):
return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."}
lauf = _neuer_lauf(ziel_id, baustein, app.name)
threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}", daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
if not gast.get("erlaubt"):
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
name = app.name if app else str(gast.get("name"))
if baustein == "app" and (not app or app.weg != "skript"):
return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script "
"(eigene Oberfläche oder Pakete)."}
if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]):
return {"ok": False, "detail": sperre}
lauf = _neuer_lauf(ziel_id, baustein, name)
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
File diff suppressed because it is too large Load Diff
+116 -499
View File
@@ -1,388 +1,118 @@
""" """
Auftrags-System: Hintergrund-Aufträge (Updates, Modell-Downloads) mit Protokoll und Fortschritt. Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
Ursprünglich aus Mission Control v1 portiert. Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
Seit Phase 2 (24.09.2026) überleben Aufträge einen Neustart von MC2:
- Jeder Auftrag läuft als eigene systemd-Einheit „mc2-job-<id>“ (systemd-run), nicht mehr als
Kindprozess von MC2. Ein Deploy oder Absturz von MC2 würgt ihn nicht mehr ab.
- Akte (<id>.json), Protokoll (<id>.log) und Exit-Code (<id>.exit) liegen unter JOBS_DIR.
MC2 liest die Akten beim Start wieder ein (wiederaufnehmen()) und beobachtet weiter.
- Nacharbeiten („Rolle setzen, wenn der Download fertig ist“) sind benannt (@nacharbeit) und
stehen mit ihren Daten in der Akte — sie laufen auch, wenn MC2 zwischendurch neu gestartet hat.
- Geheimnisse (z. B. HF_TOKEN) gehen über eine Umgebungsdatei (nur für den Nutzer lesbar), die
der Auftrag beim Start liest und löscht — nicht über die Befehlszeile oder die Einheit.
- Ohne systemd (Entwicklungsrechner, Tests: MC_JOBS_ART=prozess) läuft der Auftrag als
Kindprozess; dann überlebt er einen Neustart nicht.
Weiterhin: „Abbrechen“ beendet den ganzen Auftrag samt Kindern, jede Auftragsart hat ein
Zeitlimit, start_job_exklusiv prüft und trägt unter EINER Sperre ein, beendete Aufträge
verschwinden nach einem Tag bzw. ab 40 Stück.
Nur MC2 selbst beobachtet Aufträge (wiederaufnehmen() im Lebenszyklus der App); andere Prozesse
starten keine.
""" """
import glob import glob
import json
import logging
import os import os
import re
import shlex import shlex
import shutil
import signal
import subprocess import subprocess
import threading import threading
import time import time
import uuid import uuid
from collections.abc import Callable
from pathlib import Path
from kern.einstellungen import einstellungen
log = logging.getLogger(__name__)
JOBS: dict[str, dict] = {} JOBS: dict[str, dict] = {}
_PROCS: dict[str, subprocess.Popen] = {} _PROCS: dict[str, subprocess.Popen] = {}
_LOCK = threading.Lock()
_LOG_CAP = 400 _LOG_CAP = 400
_LOG_LESEN_MAX = 256_000 # so viel vom Protokollende wird für die Anzeige gelesen
BEHALTEN_MAX = 40 # so viele beendete Aufträge bleiben sichtbar
BEHALTEN_S = 24 * 3600 # beendete Aufträge verschwinden nach einem Tag
STANDARD_ZEITLIMIT_S = 3 * 3600
_ENDE = ("done", "failed", "canceled")
_TAKT_S = 1.0 # wie oft ein Beobachter nach dem Exit-Code schaut
_EINHEIT_PRUEFEN_S = 5.0 # wie oft er zusätzlich fragt, ob die Einheit noch lebt
# Nur im Speicher (nicht in der Akte): Fortschritt eines Downloads.
_LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"}
# Nicht an die Oberfläche: Verwaltung des Auftrags.
_INTERN = {"art", "nacharbeit", "nacharbeit_daten", "nacharbeit_erledigt", "abschluss", "abschluss_daten",
"abschluss_erledigt", "fortschritt", "zeitlimit_s"}
# Nicht in die Umgebung des Auftrags: gehört zur Einheit von MC2 oder ist in bash schreibgeschützt.
_UMGEBUNG_OHNE = {"INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID", "MANAGERPID", "NOTIFY_SOCKET",
"LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "WATCHDOG_PID", "WATCHDOG_USEC", "MAINPID",
"_", "SHLVL", "PWD", "OLDPWD", "SHELLOPTS", "BASHOPTS", "BASH_VERSINFO", "EUID", "UID",
"PPID", "GROUPS"}
_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Die Hülle um den eigentlichen Befehl: Umgebung lesen (und die Datei löschen), Ausgabe ins
# Protokoll, Exit-Code atomar ablegen. $1 = Pfadpräfix der Akte, danach der Befehl.
_HUELLE = ('akte="$1"; shift; '
'if [ -f "$akte.env" ]; then . "$akte.env"; rm -f "$akte.env"; fi; '
'exec >>"$akte.log" 2>&1 </dev/null; '
'"$@"; code=$?; echo "$code" > "$akte.exit.tmp" && mv -f "$akte.exit.tmp" "$akte.exit"')
_NACHARBEITEN: dict[str, Callable[..., None]] = {}
_ABSCHLUESSE: dict[str, Callable[[dict], None]] = {}
_geladen = False
def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]: def _append_log(job: dict, line: str) -> None:
"""Eine Nacharbeit unter festem Namen anmelden. Sie bekommt die nacharbeit_daten des Auftrags job["log"].append(line)
als Schlüsselwort-Argumente und läuft nur, wenn der Auftrag erfolgreich war.""" if len(job["log"]) > _LOG_CAP:
def anmelden(fn: Callable[..., None]) -> Callable[..., None]: del job["log"][0]
_NACHARBEITEN[name] = fn
return fn
return anmelden
def abschluss(name: str) -> Callable[[Callable[[dict], None]], Callable[[dict], None]]: def _pump_output(job: dict, stream) -> None:
"""Einen Abschluss unter festem Namen anmelden. Anders als die Nacharbeit läuft er bei JEDEM Ende """Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
(fertig, gescheitert, abgebrochen) und bekommt eine Kopie der Akte — etwa um das Ergebnis in den buf = b""
Update-Verlauf zu schreiben.""" overwrite = False
def anmelden(fn: Callable[[dict], None]) -> Callable[[dict], None]: pending_cr = False
_ABSCHLUESSE[name] = fn
return fn
return anmelden
def commit():
# --- Ablage ------------------------------------------------------------------------ line = buf.decode("utf-8", "replace")
if overwrite and job["log"]:
def _jobs_dir() -> Path: job["log"][-1] = line
return Path(os.environ.get("MC_JOBS_DIR", str(einstellungen().daten_dir / "mc2-jobs")))
def _pfad(job_id: str, endung: str) -> Path:
return _jobs_dir() / f"{job_id}{endung}"
def _speichern(job: dict) -> None:
"""Akte ohne Laufzeitfelder atomar schreiben."""
daten = {k: v for k, v in job.items() if k not in _LAUFZEIT and not k.startswith("_")}
try:
_jobs_dir().mkdir(parents=True, exist_ok=True)
tmp = _pfad(job["id"], ".json.tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
tmp.replace(_pfad(job["id"], ".json"))
except OSError:
log.warning("jobengine: Akte %s nicht schreibbar", job.get("id"), exc_info=True)
def _protokoll(job_id: str, zeile: str) -> None:
"""Eine Zeile von MC2 ins Protokoll des Auftrags schreiben."""
try:
_jobs_dir().mkdir(parents=True, exist_ok=True)
with _pfad(job_id, ".log").open("a", encoding="utf-8", newline="\n") as f:
f.write(zeile.rstrip("\n") + "\n")
except OSError:
pass
def zeilen_aus(text: str) -> list[str]:
"""Protokolltext in Anzeigezeilen: `\\r` (Fortschrittsbalken von hf/tqdm/apt) überschreibt die Zeile."""
zeilen = []
for roh in text.split("\n"):
if "\r" in roh:
teile = [t for t in roh.split("\r") if t]
roh = teile[-1] if teile else ""
zeilen.append(roh)
if zeilen and zeilen[-1] == "":
zeilen.pop()
return zeilen[-_LOG_CAP:]
def _protokoll_lesen(job_id: str) -> list[str]:
try:
with _pfad(job_id, ".log").open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - _LOG_LESEN_MAX))
roh = f.read()
except OSError:
return []
text = roh.decode("utf-8", "replace")
if groesse > _LOG_LESEN_MAX:
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
return zeilen_aus(text)
def protokoll(job_id: str) -> list[str]:
"""Das Protokoll eines Auftrags (Ende, Fortschrittszeilen zusammengefasst)."""
return _protokoll_lesen(job_id)
def _exit_code(job_id: str) -> int | None:
try:
return int(_pfad(job_id, ".exit").read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None
def _exit_ablegen(job_id: str, code: int) -> None:
try:
tmp = _pfad(job_id, ".exit.tmp")
tmp.write_text(str(code), encoding="utf-8")
tmp.replace(_pfad(job_id, ".exit"))
except OSError:
pass
def _umgebungsdatei(job_id: str, env: dict | None) -> None:
"""Umgebung des Auftrags (MC2-Umgebung + env) als nur für den Nutzer lesbare Datei."""
werte = {**os.environ, **(env or {})}
zeilen = [f"export {k}={shlex.quote(str(v))}" for k, v in werte.items()
if _NAME.match(k) and k not in _UMGEBUNG_OHNE]
fd = os.open(_pfad(job_id, ".env"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(zeilen) + "\n")
# --- Ausführung ----------------------------------------------------------------------
def _art() -> str:
"""„systemd“, wenn Aufträge als eigene Einheiten laufen können, sonst „prozess“."""
gewuenscht = os.environ.get("MC_JOBS_ART", "").strip().lower()
if gewuenscht in ("systemd", "prozess"):
return gewuenscht
if os.name != "posix" or not shutil.which("systemd-run"):
return "prozess"
if os.geteuid() != 0 and not os.environ.get("XDG_RUNTIME_DIR"):
return "prozess"
return "systemd"
def _systemd(programm: str, *args: str) -> list[str]:
"""systemctl/systemd-run für den richtigen Manager: als root der System-, sonst der Nutzer-Manager."""
als_root = os.name == "posix" and os.geteuid() == 0
return [programm, *(() if als_root else ("--user",)), *args]
def _einheit(job_id: str) -> str:
return f"mc2-job-{job_id}"
def _einheit_lebt(job_id: str) -> bool:
try:
r = subprocess.run(_systemd("systemctl", "is-active", _einheit(job_id)),
capture_output=True, text=True, timeout=10)
except Exception:
return True # Im Zweifel weiter beobachten statt einen laufenden Auftrag totzusagen.
return r.stdout.strip() in ("active", "activating", "deactivating", "reloading")
def _starte_systemd(job: dict, args: list[str], env: dict | None) -> None:
job_id = job["id"]
_umgebungsdatei(job_id, env)
befehl = _systemd("systemd-run", "--unit", _einheit(job_id), "--collect", "--quiet",
"--working-directory", os.getcwd(),
"--property", f"RuntimeMaxSec={int(job['zeitlimit_s'])}",
"--", "/bin/bash", "-c", _HUELLE, "mc2-job", str(_jobs_dir() / job_id), *args)
r = subprocess.run(befehl, capture_output=True, text=True, timeout=30)
if r.returncode != 0:
_pfad(job_id, ".env").unlink(missing_ok=True)
raise RuntimeError((r.stderr or r.stdout or "systemd-run scheiterte").strip()[:300])
threading.Thread(target=_beobachten, args=(job_id,), daemon=True).start()
def _starte_prozess(job: dict, args: list[str], env: dict | None) -> None:
"""Rückfall ohne systemd: Kindprozess, Ausgabe ins Protokoll; ein Warte-Thread schließt ab."""
job_id = job["id"]
with _pfad(job_id, ".log").open("ab") as protokoll:
proc = subprocess.Popen(list(args), stdout=protokoll, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
env={**os.environ, **(env or {})}, start_new_session=(os.name == "posix"))
_PROCS[job_id] = proc
grenze = int(job["zeitlimit_s"])
def zu_lang() -> None:
job["zeitlimit"] = True
_protokoll(job_id, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Auftrag wird beendet.")
_beende_gruppe(proc)
wecker = threading.Timer(grenze, zu_lang)
wecker.daemon = True
wecker.start()
def warten() -> None:
code = proc.wait()
wecker.cancel()
_exit_ablegen(job_id, code)
_abschliessen(job, code)
threading.Thread(target=warten, daemon=True).start()
def _beende_gruppe(proc: subprocess.Popen) -> None:
"""Den Auftragsprozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
try:
if os.name == "posix":
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
else: else:
proc.terminate() _append_log(job, line)
except (ProcessLookupError, PermissionError, OSError):
pass
def _abschliessen(job: dict, code: int | None) -> None:
"""Endzustand festhalten (genau einmal). Bei Erfolg läuft die Nacharbeit VOR dem Endzustand:
Wer „done“ sieht, sieht auch schon ihre Wirkung (gesetzte Rolle, geleerte Zwischenspeicher)."""
with _LOCK:
if job["state"] in _ENDE or job.get("_schliesst"):
return
job["_schliesst"] = True
felder: dict = {"returncode": code}
if job.get("canceled"):
felder["state"] = "canceled"
elif job.get("zeitlimit") or (code is None and time.time() - job["started_at"] >= job["zeitlimit_s"] - 5):
felder.update(state="failed", zeitlimit=True, error="Zeitlimit überschritten")
elif code is None:
felder.update(state="failed", error=job.get("error") or "Der Auftrag endete ohne Rückmeldung.")
else:
felder["state"] = "done" if code == 0 else "failed"
if felder["state"] == "done":
_nacharbeit_ausfuehren(job)
with _LOCK:
job.update(felder, finished_at=time.time())
job.pop("_schliesst", None)
_PROCS.pop(job["id"], None)
_speichern(job)
_abschluss_ausfuehren(job)
def _nacharbeit_ausfuehren(job: dict) -> None:
name = job.get("nacharbeit")
if not name or job.get("nacharbeit_erledigt"):
return
job["nacharbeit_erledigt"] = True
_speichern(job)
try:
fn = _NACHARBEITEN.get(name)
if fn is None:
raise RuntimeError(f"Nacharbeit „{name}“ ist in dieser Instanz unbekannt")
fn(**(job.get("nacharbeit_daten") or {}))
except Exception as exc:
_protokoll(job["id"], f"[mc] Nachbearbeitung-Fehler: {exc}")
log.warning("jobengine: Nacharbeit %s von %s gescheitert", name, job["id"], exc_info=True)
def _abschluss_ausfuehren(job: dict) -> None:
name = job.get("abschluss")
if not name or job.get("abschluss_erledigt"):
return
job["abschluss_erledigt"] = True
_speichern(job)
try:
fn = _ABSCHLUESSE.get(name)
if fn is None:
raise RuntimeError(f"Abschluss „{name}“ ist in dieser Instanz unbekannt")
fn({k: v for k, v in job.items() if not k.startswith("_")})
except Exception as exc:
_protokoll(job["id"], f"[mc] Abschluss-Fehler: {exc}")
log.warning("jobengine: Abschluss %s von %s gescheitert", name, job["id"], exc_info=True)
def _beobachten(job_id: str) -> None:
"""Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit,
Absturz), schließt er den Auftrag trotzdem ab."""
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
while True: while True:
job = JOBS.get(job_id) ch = stream.read(1)
if job is None or job["state"] in _ENDE: if not ch:
return break
if (code := _exit_code(job_id)) is not None: if pending_cr:
_abschliessen(job, code) pending_cr = False
return if ch == b"\n":
if time.time() >= naechste_pruefung: commit(); overwrite = False; buf = b""
if not _einheit_lebt(job_id): continue
time.sleep(0.5) # der Exit-Code kann gerade erst geschrieben werden commit(); overwrite = True; buf = b""
_abschliessen(job, _exit_code(job_id)) if ch == b"\r":
return pending_cr = True
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S elif ch == b"\n":
time.sleep(_TAKT_S) commit(); overwrite = False; buf = b""
def _starten(job: dict, args: list[str], env: dict | None) -> None:
job["state"] = "running"
_speichern(job)
try:
if job["art"] == "systemd":
_starte_systemd(job, args, env)
else: else:
_starte_prozess(job, args, env) buf += ch
if pending_cr:
commit(); overwrite = True; buf = b""
if buf:
commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
job = JOBS[job_id]
job["state"] = "running"
try:
actual_args = list(args)
if sudo_password is not None:
for i, arg in enumerate(actual_args):
if isinstance(arg, str):
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
proc = subprocess.Popen(
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if sudo_password is not None else None,
bufsize=0,
env={**os.environ, **(env or {})},
)
_PROCS[job_id] = proc
if sudo_password is not None and proc.stdin:
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
proc.stdin.flush()
proc.stdin.close()
_pump_output(job, proc.stdout)
proc.wait()
job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
# Check if failed due to sudo authorization failure
if proc.returncode != 0 and job["log"]:
log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True
except Exception as exc: except Exception as exc:
_protokoll(job["id"], f"[mc] Fehler: {exc}") _append_log(job, f"[mc] Fehler: {exc}")
job["error"] = str(exc) job["state"] = "failed"
_abschliessen(job, -1) job["returncode"] = -1
finally:
_PROCS.pop(job_id, None)
job["finished_at"] = time.time()
cb = job.pop("_on_done", None)
if cb and job["state"] == "done":
try:
cb()
except Exception as exc:
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
# --- Fortschritt (Downloads) ------------------------------------------------------------
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None: def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie).""" """Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
if not total_bytes or total_bytes <= 0:
return
job = JOBS.get(job_id) job = JOBS.get(job_id)
if job is None or not total_bytes or total_bytes <= 0: if job is not None:
return job["progress"] = 0
job["fortschritt"] = {"ordner": local_dir, "gesamt": total_bytes} job["total_bytes"] = total_bytes
job["total_bytes"] = total_bytes
job["progress"] = 0
_speichern(job)
_fortschritt_beobachten(job_id)
def _fortschritt_beobachten(job_id: str) -> None:
ziel = (JOBS.get(job_id) or {}).get("fortschritt") or {}
local_dir, total_bytes = ziel.get("ordner"), ziel.get("gesamt")
if not local_dir or not total_bytes:
return
def _watch(): def _watch():
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete") pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
@@ -390,7 +120,7 @@ def _fortschritt_beobachten(job_id: str) -> None:
rate = 0.0 rate = 0.0
while True: while True:
j = JOBS.get(job_id) j = JOBS.get(job_id)
if not j or j["state"] in _ENDE: if not j or j["state"] in ("done", "failed", "canceled"):
break break
try: try:
inc = glob.glob(pat, recursive=True) inc = glob.glob(pat, recursive=True)
@@ -417,162 +147,49 @@ def _fortschritt_beobachten(job_id: str) -> None:
threading.Thread(target=_watch, daemon=True).start() threading.Thread(target=_watch, daemon=True).start()
# --- Verwaltung ------------------------------------------------------------------- def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
sudo_password: str | None = None, group: str | None = None) -> str:
def _laden() -> None:
"""Akten von der Platte einlesen (einmal je Prozess; unter _LOCK aufrufen)."""
global _geladen
if _geladen:
return
_geladen = True
for datei in sorted(_jobs_dir().glob("*.json")):
try:
akte = json.loads(datei.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if isinstance(akte, dict) and akte.get("id") and akte["id"] not in JOBS:
JOBS[akte["id"]] = akte
def _aufraeumen() -> None:
"""Alte beendete Aufträge samt Dateien entfernen (unter _LOCK aufrufen)."""
jetzt = time.time()
fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE),
key=lambda j: j.get("finished_at") or 0, reverse=True)
for i, j in enumerate(fertig):
if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S:
JOBS.pop(j["id"], None)
for endung in (".json", ".log", ".exit", ".env", ".exit.tmp", ".json.tmp"):
_pfad(j["id"], endung).unlink(missing_ok=True)
def _eintragen(args: list[str], label: str, group: str | None, zeitlimit_s: int | None,
nacharbeit_name: str | None, nacharbeit_daten: dict | None,
abschluss_name: str | None = None, abschluss_daten: dict | None = None) -> dict:
"""Neue Akte anlegen (unter _LOCK aufrufen)."""
if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN:
raise ValueError(f"Unbekannte Nacharbeit: {nacharbeit_name}")
if abschluss_name and abschluss_name not in _ABSCHLUESSE:
raise ValueError(f"Unbekannter Abschluss: {abschluss_name}")
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
job = { # Mask password in log if present in args
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(), log_args = list(args)
JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued", "group": group,
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
"returncode": None, "started_at": time.time(), "finished_at": None, "returncode": None, "started_at": time.time(), "finished_at": None,
"zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S),
"nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {},
"abschluss": abschluss_name, "abschluss_daten": abschluss_daten or {},
} }
JOBS[job_id] = job if on_done:
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args)) JOBS[job_id]["_on_done"] = on_done
_speichern(job) threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
return job return job_id
def _aktiv_in(group: str) -> dict | None: def active_in_group(group: str) -> dict | None:
for j in list(JOBS.values()): """Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
for j in JOBS.values():
if j.get("group") == group and j.get("state") in ("running", "queued"): if j.get("group") == group and j.get("state") in ("running", "queued"):
return j return j
return None return None
def start_job(args: list[str], label: str, env: dict | None = None, group: str | None = None,
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
nacharbeit_daten: dict | None = None, abschluss: str | None = None,
abschluss_daten: dict | None = None) -> str:
with _LOCK:
_laden()
_aufraeumen()
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten,
abschluss, abschluss_daten)
_starten(job, list(args), env)
return job["id"]
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None,
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
nacharbeit_daten: dict | None = None, abschluss: str | None = None,
abschluss_daten: dict | None = None) -> tuple[str | None, dict | None]:
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
Sperre. Rückgabe: (neue Auftrags-ID, None) oder (None, der schon laufende Auftrag)."""
with _LOCK:
_laden()
if (laeuft := _aktiv_in(group)) is not None:
return None, laeuft
_aufraeumen()
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten,
abschluss, abschluss_daten)
_starten(job, list(args), env)
return job["id"], None
def active_in_group(group: str) -> dict | None:
"""Erster laufender/wartender Auftrag einer Gruppe (z. B. 'maintenance'), sonst None.
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
with _LOCK:
_laden()
return _aktiv_in(group)
def wiederaufnehmen() -> int:
"""Beim Start von MC2: Akten einlesen und laufende Aufträge weiter beobachten.
Rückgabe: Zahl der wieder aufgenommenen Aufträge."""
with _LOCK:
_laden()
offen = [j for j in JOBS.values() if j["state"] in ("queued", "running")]
nacharbeit_offen = [j for j in JOBS.values() if j["state"] == "done" and j.get("nacharbeit")
and not j.get("nacharbeit_erledigt")]
abschluss_offen = [j for j in JOBS.values() if j["state"] in _ENDE and j.get("abschluss")
and not j.get("abschluss_erledigt")]
for job in offen:
if job.get("art") == "systemd":
threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start()
_fortschritt_beobachten(job["id"])
elif (code := _exit_code(job["id"])) is not None:
_abschliessen(job, code)
else:
# Kindprozess des vorigen MC2: sein Warte-Thread ist mit ihm gegangen.
_protokoll(job["id"], "[mc] MC2 wurde neu gestartet; der Auftrag lief als Kindprozess mit.")
job["error"] = "MC2 wurde während des Auftrags neu gestartet."
_abschliessen(job, None)
for job in nacharbeit_offen:
_nacharbeit_ausfuehren(job)
for job in abschluss_offen:
_abschluss_ausfuehren(job)
if offen:
log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen))
return len(offen)
def cancel_job(job_id: str) -> bool: def cancel_job(job_id: str) -> bool:
with _LOCK: job = JOBS.get(job_id)
_laden() if not job or job["state"] in ("done", "failed", "canceled"):
job = JOBS.get(job_id) return False
if not job or job["state"] in _ENDE: job["canceled"] = True
return False _append_log(job, "[mc] Abbruch angefordert…")
job["canceled"] = True proc = _PROCS.get(job_id)
_speichern(job) if proc is not None:
_protokoll(job_id, "[mc] Abbruch angefordert…")
if job["state"] == "queued":
_abschliessen(job, None)
elif job.get("art") == "systemd":
try: try:
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)), proc.terminate()
capture_output=True, timeout=15)
except Exception: except Exception:
log.warning("jobengine: Abbruch von %s gescheitert", job_id, exc_info=True) pass
elif (proc := _PROCS.get(job_id)) is not None:
_beende_gruppe(proc)
else: else:
_abschliessen(job, None) job["state"] = "canceled"
job["finished_at"] = time.time()
return True return True
def public_jobs(mit_log: bool = True) -> list[dict]: def public_jobs() -> list[dict]:
"""Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende).""" """Jobs ohne interne Felder (_on_done) für die API."""
with _LOCK: return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
_laden()
_aufraeumen()
jobs = [{k: v for k, v in j.items() if k not in _INTERN and not k.startswith("_")} for j in JOBS.values()]
for j in jobs:
j["log"] = _protokoll_lesen(j["id"]) if mit_log else []
return sorted(jobs, key=lambda j: j.get("started_at") or 0)
+79 -229
View File
@@ -6,13 +6,9 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz. `swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
""" """
import functools
import logging import logging
import os import os
import re import re
import threading
from contextlib import contextmanager
from pathlib import Path
import httpx import httpx
from config import ( from config import (
@@ -21,18 +17,12 @@ from config import (
DEFAULT_TTL, DEFAULT_TTL,
DRAFTS_DIR, DRAFTS_DIR,
LLAMA_SWAP_URL, LLAMA_SWAP_URL,
MODELS_DIR,
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_MODEL_PATH,
SPEC_DRAFT_N_MAX, SPEC_DRAFT_N_MAX,
SPEC_TYPE, SPEC_TYPE,
) )
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
try:
import fcntl
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
fcntl = None
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS, # Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
@@ -63,77 +53,8 @@ def _gguf_total_size(path: str) -> int | None:
return None return None
# --- Sperre ------------------------------------------------------------------
# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die
# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben
# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im
# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config.
_SPERRE = threading.RLock()
_SPERR_TIEFE = threading.local()
@contextmanager
def config_sperre():
with _SPERRE:
tiefe = getattr(_SPERR_TIEFE, "n", 0)
_SPERR_TIEFE.n = tiefe + 1
datei = None
try:
if tiefe == 0 and fcntl is not None:
try:
datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+")
fcntl.flock(datei, fcntl.LOCK_EX)
except OSError:
datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre
yield
finally:
_SPERR_TIEFE.n = tiefe
if datei is not None:
fcntl.flock(datei, fcntl.LOCK_UN)
datei.close()
# Sammel-Schreiben (24.09.2026): Jeder Schreibvorgang lässt llama-swap neu laden und ALLE Modelle
# entladen. Ein Modelltausch bestand aus bis zu sieben Schreibvorgängen (Eintragen, Befehl, Zwilling,
# Alias, Gruppe, ttl …). Innerhalb von sammeln() lesen alle Änderungen dieselbe Config aus dem
# Speicher, und geschrieben wird einmal am Ende — oder gar nicht, wenn etwas scheitert.
_SAMMEL = threading.local()
@contextmanager
def sammeln():
with config_sperre():
if getattr(_SAMMEL, "aktiv", False): # verschachtelt: der äußere schreibt
yield
return
_SAMMEL.aktiv, _SAMMEL.cfg = True, None
try:
yield
cfg = _SAMMEL.cfg
finally:
_SAMMEL.aktiv, _SAMMEL.cfg = False, None
if cfg is not None:
_schreiben(cfg)
def _mit_sperre(fn):
@functools.wraps(fn)
def huelle(*args, **kwargs):
with config_sperre():
return fn(*args, **kwargs)
return huelle
# --- Lesen ------------------------------------------------------------------- # --- Lesen -------------------------------------------------------------------
def read_config() -> dict: def read_config() -> dict:
if getattr(_SAMMEL, "aktiv", False):
if _SAMMEL.cfg is None:
_SAMMEL.cfg = _lesen()
return _SAMMEL.cfg
return _lesen()
def _lesen() -> dict:
if not CONFIG_PATH.exists(): if not CONFIG_PATH.exists():
return {"models": {}} return {"models": {}}
from ruamel.yaml import YAML from ruamel.yaml import YAML
@@ -291,15 +212,7 @@ def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
def write_config(cfg: dict) -> None: def write_config(cfg: dict) -> None:
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie """Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung. Innerhalb von sammeln() eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
wird nur vorgemerkt; geschrieben wird am Ende einmal."""
if getattr(_SAMMEL, "aktiv", False):
_SAMMEL.cfg = cfg
return
_schreiben(cfg)
def _schreiben(cfg: dict) -> None:
try: try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp") tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
@@ -323,7 +236,7 @@ def _schreiben(cfg: dict) -> None:
) from exc ) from exc
@_mit_sperre
def register_model(model_path: str, role: str | None = None, ctx: int = 8192, def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None, ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False, set_alias: bool = True) -> str: jinja: bool = False, set_alias: bool = True) -> str:
@@ -348,7 +261,7 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
if "--cache-reuse" not in cmd and "--mmproj" not in cmd: if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
cmd += " --cache-reuse 256 -cram 16384" cmd += " --cache-reuse 256 -cram 16384"
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage # IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/archiv/2026-06-30-optimierungsplan.md §9.4 V6). # (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
if role_lower == "coder" and "--parallel" not in cmd: if role_lower == "coder" and "--parallel" not in cmd:
cmd += " --parallel 2" cmd += " --parallel 2"
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben # Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
@@ -455,8 +368,70 @@ def spec_draft_flags(target_path: str) -> str:
return _spec_flags_for_draft(d) if d else "" return _spec_flags_for_draft(d) if d else ""
def drafts_for(target_path: str) -> dict:
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
from services import gguf_meta
exists = bool(target_path and os.path.exists(target_path))
drafts = list_drafts()
seen = {d["path"] for d in drafts}
for p in _sibling_mtp_drafters(target_path):
if p not in seen:
drafts.append({"path": p, "filename": os.path.basename(p),
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
"vocab": gguf_meta.fingerprint(p)})
for d in drafts:
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
d["mtp"] = _is_mtp_draft(d["path"])
return {
"target_path": target_path,
"target_exists": exists,
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
"drafts": drafts,
}
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
abgelehnt (idiotensicher). Returns {ok, reason}."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not isinstance(spec, dict):
return {"ok": False, "reason": "Modell nicht gefunden"}
cmd = str(spec.get("cmd", ""))
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
if draft_path:
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
if not os.path.isabs(draft_path) and "/" not in draft_path:
draft_path = str(DRAFTS_DIR / draft_path)
if not os.path.exists(draft_path):
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
from services import gguf_meta
target = ""
if (mt := _PATH_RE.search(cmd)):
target = mt.group(1).replace("'", "").replace('"', "")
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
if comp is not True:
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
"würde beim Laden scheitern."
if comp is False else
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
return {"ok": False, "reason": reason}
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
write_config(cfg)
return {"ok": True, "reason": ""}
# --- Groups (Ko-Residenz) ---------------------------------------------------- # --- Groups (Ko-Residenz) ----------------------------------------------------
@_mit_sperre
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None: def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen """llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True → GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
@@ -469,10 +444,6 @@ def set_group(group: str, members: list[str], swap: bool = False, persist: bool
cfg = read_config() cfg = read_config()
groups = cfg.setdefault("groups", {}) groups = cfg.setdefault("groups", {})
groups[group] = {"swap": swap, "persist": persist, "persistent": persist, groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
# Eine immer-warme Gruppe darf nichts verdrängen. llama-swap macht Gruppen sonst
# `exclusive`, und JEDE Anfrage ans Hirn entlud den Coder samt Prompt-Cache
# (live gefunden 24.09.2026: Lucy-Anfrage → OpenChamber-Coder weg).
**({"exclusive": False} if persist else {}),
"members": list(members)} "members": list(members)}
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied. # Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen # `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
@@ -492,7 +463,6 @@ def list_groups() -> dict:
return read_config().get("groups") or {} return read_config().get("groups") or {}
@_mit_sperre
def set_role(model_id: str, role: str | None) -> bool: def set_role(model_id: str, role: str | None) -> bool:
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man """Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert).""" z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
@@ -504,23 +474,22 @@ def set_role(model_id: str, role: str | None) -> bool:
return True return True
@_mit_sperre def set_ctx(model_id: str, ctx: int) -> bool:
def add_role(model_id: str, role: str) -> bool: """Kontextlänge (-c) eines bestehenden Modells ändern."""
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
cfg = read_config() cfg = read_config()
models = cfg.get("models") or {} spec = (cfg.get("models") or {}).get(model_id)
rolle = (role or "").strip().lower() if not spec:
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
return False return False
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle] cmd = str(spec.get("cmd", ""))
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg if _CTX_RE.search(cmd):
models[model_id]["aliases"] = bisher + [rolle] cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
else:
cmd = cmd.rstrip() + f" -c {ctx}"
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
write_config(cfg) write_config(cfg)
return True return True
@_mit_sperre
def set_ttl(model_id: str, ttl: int) -> bool: def set_ttl(model_id: str, ttl: int) -> bool:
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch """Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss).""" entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
@@ -533,15 +502,6 @@ def set_ttl(model_id: str, ttl: int) -> bool:
return True return True
@_mit_sperre
def im_modellordner(pfad: str) -> bool:
"""Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026)."""
try:
return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve())
except (OSError, ValueError):
return False
def delete_model(model_id: str) -> bool: def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen """Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner. GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
@@ -553,17 +513,8 @@ def delete_model(model_id: str) -> bool:
model_spec = models[model_id] or {} model_spec = models[model_id] or {}
cmd = str(model_spec.get("cmd", "")).strip() cmd = str(model_spec.get("cmd", "")).strip()
# Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder. if (m := _PATH_RE.search(cmd)):
# Dateien, die ein anderer Eintrag noch nennt, bleiben liegen — sonst risse das Löschen eines Zwillings
# den Coder mit. Dann wird nur der Eintrag entfernt.
andere = " ".join(str((s or {}).get("cmd", "")) for mid, s in models.items() if mid != model_id)
if (m := _PATH_RE.search(cmd)) and m.group(1).replace("'", "").replace('"', "") in andere:
m = None
if m:
path = m.group(1).replace("'", "").replace('"', "") path = m.group(1).replace("'", "").replace('"', "")
if path and not im_modellordner(path):
log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR)
path = ""
if path: if path:
# 1. Haupt-GGUF-Datei löschen # 1. Haupt-GGUF-Datei löschen
if os.path.exists(path): if os.path.exists(path):
@@ -591,7 +542,7 @@ def delete_model(model_id: str) -> bool:
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd) mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
if mmproj_match: if mmproj_match:
m_path = mmproj_match.group(1) m_path = mmproj_match.group(1)
if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path): if os.path.exists(m_path):
try: try:
os.remove(m_path) os.remove(m_path)
except Exception: except Exception:
@@ -599,7 +550,7 @@ def delete_model(model_id: str) -> bool:
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist # 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
try: try:
if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve(): if not os.listdir(dirname) and os.path.basename(dirname) != "models":
os.rmdir(dirname) os.rmdir(dirname)
except Exception: except Exception:
pass pass
@@ -659,104 +610,3 @@ def get_running_models() -> list[str]:
except Exception: except Exception:
log.warning("get_running_models fehlgeschlagen", exc_info=True) log.warning("get_running_models fehlgeschlagen", exc_info=True)
return [] return []
def modell_zustaende() -> dict[str, str] | None:
"""Zustand je Modell aus /running: Name → "ready" | "starting" | "stopping". Wer fehlt, ist nicht geladen.
llama-swap v257 listet dort jedes Modell, das weder „stopped“ noch „shutdown“ ist (internal/router/base.go,
RunningModels) — also auch eines, das gerade lädt. Ältere Fassungen lieferten nur Namen; die gelten als bereit.
None = /running nicht lesbar."""
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/running")
if r.status_code != 200:
return None
eintraege = r.json().get("running") or []
except Exception:
log.warning("modell_zustaende: /running nicht lesbar", exc_info=True)
return None
zustaende: dict[str, str] = {}
for x in eintraege:
if isinstance(x, dict):
if x.get("model"):
zustaende[str(x["model"])] = str(x.get("state") or "ready")
elif x:
zustaende[str(x)] = "ready"
return zustaende
def hirn_zustand() -> dict:
"""Wie steht Lucys Hirn (Modell mit dem Alias/der Rolle „hermes“) in llama-swap? Gezielt dieses eine Modell —
bis 24.09.2026 galt das Hirn als bereit, sobald IRGENDEIN Modell lief (ein abgestürztes Hirn neben einem
geladenen Coder blieb unbemerkt).
Rückgabe {"modell": Name oder None, "zustand": "bereit" | "laedt" | "fehlt" | "unbekannt"};
„unbekannt“ heißt: /running antwortet nicht. Trägt kein Modell die Rolle, ist modell None und zustand "fehlt"."""
name = brain_model_name()
zustaende = modell_zustaende()
if zustaende is None:
return {"modell": name, "zustand": "unbekannt"}
zustand = zustaende.get(name or "")
if zustand == "ready":
return {"modell": name, "zustand": "bereit"}
if zustand == "starting":
return {"modell": name, "zustand": "laedt"}
return {"modell": name, "zustand": "fehlt"}
# Wie lange „Jetzt laden“ auf die Engine wartet. Große Modelle brauchen eine Weile (Lesen + Hochladen in den Speicher);
# was danach noch lädt, meldet lade_modell als „lädt noch“ statt als Fehler.
LADEN_WARTE_S = float(os.environ.get("MC_LADEN_WARTE_S", "90"))
def _engine_grund(r: httpx.Response) -> str:
"""Die Fehlermeldung der Engine aus ihrer Antwort (OpenAI-Format {"error": {"message"}}, {"error": "…"},
{"detail": "…"} oder reiner Text), auf eine Zeile gekürzt."""
text = ""
try:
daten = r.json()
except ValueError:
daten = None
if isinstance(daten, dict):
fehler = daten.get("error")
if isinstance(fehler, dict):
text = str(fehler.get("message") or "")
elif fehler:
text = str(fehler)
elif daten.get("detail"):
text = str(daten["detail"])
text = " ".join((text or r.text or "").split())
return text[:200] or "ohne Begründung"
def lade_modell(model_id: str, warte_s: float | None = None) -> dict:
"""Ein Modell laden und das ECHTE Ergebnis melden (seit 24.09.2026 — vorher hieß jede Antwort der Engine „ok“).
llama-swap lädt ein Modell mit der ersten Anfrage; dafür reicht eine Mini-Anfrage mit einem Token. Ob das Modell
danach wirklich geladen ist, entscheidet /running — so zählen auch Modelle, die gar nicht chatten (embed,
reranker), und ein Fehler der Anfrage selbst macht ein geladenes Modell nicht zum Fehlschlag.
Rückgabe: {"ok": True, "text": …} (bei "laedt": True lädt es nach der Wartezeit noch) oder
{"ok": False, "detail": deutscher Grund, mit der Meldung der Engine}."""
warte = LADEN_WARTE_S if warte_s is None else warte_s
anfrage = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
try:
with httpx.Client(timeout=warte) as c:
r = c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=anfrage)
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine (llama-swap) ist nicht "
f"erreichbar ({exc.__class__.__name__})."}
except httpx.TimeoutException:
if (modell_zustaende() or {}).get(model_id) == "starting":
return {"ok": True, "laedt": True,
"text": f"{model_id} lädt noch. Große Modelle brauchen etwas; der Stand erscheint gleich unter Modelle."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine hat nach {warte:.0f} Sekunden "
"nicht geantwortet, und das Modell lädt auch nicht."}
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: {exc.__class__.__name__}: {exc}"[:300]}
if r.status_code == 200:
return {"ok": True, "text": f"{model_id} ist geladen."}
zustand = (modell_zustaende() or {}).get(model_id)
if zustand == "ready":
return {"ok": True, "text": f"{model_id} ist geladen."}
if zustand == "starting":
return {"ok": True, "laedt": True, "text": f"{model_id} lädt noch."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden. Die Engine meldet (HTTP {r.status_code}): "
f"{_engine_grund(r)}"}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
Laden fliegt alles raus, was älter als 24 h ist.
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
Lücke pro Deploy sind egal.
"""
import asyncio
import json
import logging
import os
import time
from collections import deque
from config import MODELS_DIR
log = logging.getLogger(__name__)
SAMPLE_S = 10 # Abtast-Takt
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
FLUSH_S = 300 # höchstens alle 5 min auf Platte
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
_points: deque = deque(maxlen=MAXLEN)
_loaded = False
_last_flush = 0.0
def _load() -> None:
global _loaded
if _loaded:
return
_loaded = True
try:
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
cutoff = time.time() - RETENTION_S
for p in raw if isinstance(raw, list) else []:
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
_points.append(p)
if _points:
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
except FileNotFoundError:
pass
except Exception:
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
def _flush() -> None:
global _last_flush
_last_flush = time.time()
try:
tmp = HISTORY_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
tmp.replace(HISTORY_PATH)
except OSError:
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
def _sample() -> None:
import psutil
from services.system import _gpu_sysfs
from services.token_stats import get_stats
vm = psutil.virtual_memory()
disk = None
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
pass
gpu = None
try:
g = _gpu_sysfs()
gpu = g.get("busy_percent") if g else None
except Exception:
pass
tp = tc = None
try:
ts = get_stats()
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
except Exception:
pass
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
async def sampler_loop() -> None:
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
_load()
while True:
try:
await asyncio.to_thread(_sample)
except Exception:
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
if time.time() - _last_flush >= FLUSH_S:
try:
await asyncio.to_thread(_flush)
except Exception:
pass
await asyncio.sleep(SAMPLE_S)
def history(minutes: int = 60) -> dict:
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
_load()
minutes = max(1, min(int(minutes), RETENTION_S // 60))
cutoff = time.time() - minutes * 60
pts = [p for p in _points if p[0] >= cutoff]
stride = max(1, len(pts) // MAX_RETURN_POINTS)
pts = pts[::stride]
return {
"sample_s": SAMPLE_S * stride,
"points": [
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
for p in pts
],
}
-109
View File
@@ -1,109 +0,0 @@
"""
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026)
llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je
Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell
zuletzt geladen wurde ("Health check passed").
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
bevor jemand nachsieht (gemessen am 23.09.2026).
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst).
"""
import os
import re
import subprocess
import threading
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from kern.zeit import LOCAL_TZ
CACHE_S = 600
TAGE = 7
_STANDARD_NAMEN = {
"127.0.0.1": "Lucy und OpenChamber über MC2",
"::1": "Lucy und OpenChamber über MC2",
"192.168.178.28": "NerdQuiz auf Arcane",
"192.168.178.22": "NerdQuiz-Tests vom PC",
}
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)')
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
_lock = threading.Lock()
_cache: dict = {"ts": 0.0, "daten": None}
def _namen() -> dict[str, str]:
namen = dict(_STANDARD_NAMEN)
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
if "=" in paar:
ip, name = paar.split("=", 1)
namen[ip.strip()] = name.strip()
return namen
def _journal(seit: str) -> list[str]:
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
am Tag. Ohne systemd (Windows-Dev) leer."""
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
" | grep -E 'POST /v1/|Health check passed'")
try:
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
except Exception:
return []
return out.stdout.splitlines()
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell."""
je_absender: Counter = Counter()
je_tag: dict[str, Counter] = defaultdict(Counter)
stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h)
geladen: dict[str, str] = {}
grenze_24h = jetzt - timedelta(hours=24)
for z in zeilen:
try:
ts = datetime.fromisoformat(z.split(" ", 1)[0])
except ValueError:
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=LOCAL_TZ)
if (m := _GELADEN.search(z)):
geladen[m.group(1)] = ts.isoformat()
continue
m = _ANFRAGE.search(z)
if not m or not m.group(2).startswith("/v1/chat/completions"):
continue
wer = namen.get(m.group(1), m.group(1))
je_absender[wer] += 1
je_tag[ts.date().isoformat()][wer] += 1
if ts >= grenze_24h:
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
return {
"tage": TAGE,
"absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()],
"je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())},
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))}
for h in range(24)],
"zuletzt_geladen": geladen,
}
def nutzung() -> dict:
"""Gecachte Auswertung der letzten TAGE Tage (10 min)."""
with _lock:
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
return _cache["daten"]
jetzt = datetime.now(LOCAL_TZ)
seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S")
daten = werte_aus(_journal(seit), jetzt, _namen())
with _lock:
_cache.update(ts=time.time(), daten=daten)
return daten
-415
View File
@@ -1,415 +0,0 @@
"""
Monatliche Probe-Wiederherstellung (seit 24.09.2026): Eine Sicherung ist erst eine, wenn sie sich zurückspielen lässt.
Packt die jüngste Sicherung (deploy/backup.sh → /srv/models/mc2-backups) probeweise in einen Tmp-Ordner aus, prüft die
Pflichtinhalte und räumt den Ordner wieder weg. Lebende Dateien fasst sie nie an; geschrieben werden nur der Tmp-Ordner
und die Zustandsdatei ZUSTAND_PATH. Die liest der Wächter (Rolle box): gelb, wenn die letzte Probe rot war oder älter
als WARN_TAGE ist. Bei Rot geht zusätzlich eine Meldung über deploy/notify.sh raus, in normaler Dringlichkeit — nachts
sammelt notify.sh sie für die Morgenmeldung um 07:00.
Pflichtinhalte (was restore.sh zurückspielt oder was ohne Sicherung verloren wäre):
• Lucys Gedächtnis (hermes/memories) — dazu ein Abgleich mit dem lebenden Gedächtnis: Was schon vor der Sicherung so
dastand, muss darin unverändert stehen. Ebenso SOUL.md.
• Skills (SKILL.md) und Cron-Skripte (hermes/scripts), die Hermes-Cron-Jobs (cron/jobs.json)
• Hermes-Config (config.yaml lesbar; .env vorhanden) und die llama-swap-Config (Modelle eingetragen)
• Box-Wart-Zustand (box-wart/mc2-*.json lesbar) und die systemd-Units (samt llama-swap-Drop-ins)
Geheimnisse (.env, Token-Dateien) und Verknüpfungen werden nicht ausgepackt, nur ihr Vorhandensein geprüft.
Aufruf: mc2-probe-wiederherstellung.service (Timer: erster Montag im Monat, 05:15) oder von Hand auf der Box:
cd ~/mission-control-v2/backend && .venv/bin/python -m services.probe_wiederherstellung
Exit 0 = grün, 1 = rot.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import zlib
from datetime import datetime
from pathlib import Path
from config import HERMES_HOME
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import backup as sicherung
log = logging.getLogger(__name__)
EINHEIT = "mc2-probe-wiederherstellung"
ZUSTAND_PATH = Path(os.environ.get("MC_PROBE_ZUSTAND",
str(einstellungen().daten_dir / "mc2-probe-wiederherstellung.json")))
SICHERUNGEN_DIR = sicherung.BACKUP_DIR
# Die Sicherung läuft täglich um 03:30. Ist die jüngste älter, steht sie — das ist ein roter Befund.
MAX_ALTER_H = float(os.environ.get("MC_PROBE_MAX_ALTER_H", "48"))
# Der Wächter zeigt gelb, wenn die letzte Probe älter ist (monatlicher Takt plus Spielraum).
WARN_TAGE = 40
# Lebender Stand, gegen den verglichen wird (Tests setzen eigene Pfade).
HERMES_LIVE = HERMES_HOME
DATEN_LIVE = einstellungen().daten_dir
LLAMA_SWAP_DROPINS = Path("/etc/systemd/system/llama-swap.service.d")
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
# Nicht auspacken: Geheimnisse. Geprüft wird nur, dass sie in der Sicherung stehen.
_GEHEIM = {".env", "desktop-gateway-token", "session-token.conf"}
_PRAEFIX = "mc2-probe-"
_NAME_ZEIT = re.compile(r"mc2-state-(\d{8}-\d{6})\.tar\.gz$")
# Eine Datei, die bis so kurz vor dem Start der Sicherung geändert wurde, zählt als „danach geändert“.
_SPIELRAUM_S = 5.0
# --- Hilfen ------------------------------------------------------------------------------------
def _tmp_basis() -> str | None:
"""Wohin ausgepackt wird: MC_PROBE_TMP, sonst /var/tmp (Platte; /tmp ist auf der Box ein RAM-Laufwerk)."""
if (basis := os.environ.get("MC_PROBE_TMP", "").strip()):
return basis
return "/var/tmp" if os.path.isdir("/var/tmp") else None
def _alte_tmp_ordner_entfernen() -> None:
"""Reste einer abgebrochenen Probe (nur eigene Ordner mit unserem Präfix, älter als ein Tag)."""
basis = Path(_tmp_basis() or tempfile.gettempdir())
try:
kandidaten = list(basis.glob(f"{_PRAEFIX}*"))
except OSError:
return
for ordner in kandidaten:
try:
if ordner.is_dir() and not ordner.is_symlink() and time.time() - ordner.stat().st_mtime > 86400:
_entfernen(ordner)
except OSError:
pass
def _entfernen(ordner: Path) -> bool:
"""Tmp-Ordner löschen, auch wenn die Sicherung schreibgeschützte Ordner mitbrachte. True = er ist weg."""
def schreibbar_machen(funktion, pfad, _fehler) -> None:
try:
os.chmod(os.path.dirname(pfad), 0o700)
os.chmod(pfad, 0o700)
funktion(pfad)
except OSError:
pass
shutil.rmtree(ordner, onexc=schreibbar_machen)
return not ordner.exists()
def _rel(name: str) -> str:
"""Name im Archiv ohne führendes „./“ („./hermes/SOUL.md“ → „hermes/SOUL.md“)."""
while name.startswith("./"):
name = name[2:]
return name.rstrip("/")
def _dateien(ordner: Path) -> list[Path]:
if not ordner.is_dir():
return []
return sorted(p for p in ordner.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
def _geaendert(pfad: Path) -> float:
"""Letzte Änderung einer lebenden Datei. ctime zählt mit: Wer eine Datei samt alter mtime auspackt (Skill-Paket,
Restore), erzeugt sie trotzdem neu — sie kann dann gar nicht in einer älteren Sicherung stehen."""
st = pfad.stat()
return max(st.st_mtime, st.st_ctime)
def sicherungs_zeit(tarball: Path) -> float:
"""Wann die Sicherung begann: Zeitstempel im Namen (Ortszeit der Box), sonst die Dateizeit."""
if (m := _NAME_ZEIT.search(tarball.name)):
try:
return datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(tzinfo=LOCAL_TZ).timestamp()
except ValueError:
pass
return tarball.stat().st_mtime
def juengste_sicherung(ordner: Path | None = None) -> Path | None:
ordner = SICHERUNGEN_DIR if ordner is None else ordner
try:
liste = sorted(ordner.glob("mc2-state-*.tar.gz"), key=lambda p: p.name, reverse=True)
except OSError:
return None
return liste[0] if liste else None
def _yaml(pfad: Path):
from ruamel.yaml import YAML
return YAML(typ="safe").load(pfad.read_text(encoding="utf-8"))
# --- Die Prüfung ---------------------------------------------------------------------------------
def _sammelzeile(bereich: str, namen: list[str], einzeln: str, mehrere: str) -> str:
"""Eine Fehlerzeile je Art und Bereich, auch wenn hunderte Dateien betroffen sind."""
if len(namen) == 1:
return f"{bereich}: {namen[0]} {einzeln}."
beispiele = ", ".join(namen[:3]) + (" …" if len(namen) > 3 else "")
return f"{bereich}: {len(namen)} Dateien {mehrere} ({beispiele})."
def _pruefe_abgleich(bereich: str, kopie: Path, live: Path | None, beginn: float, fehler: list[str],
inhalt_gleich: bool = False) -> None:
"""Jede lebende Datei, die schon vor Beginn der Sicherung so dastand, muss in der Sicherung stehen
(bei inhalt_gleich auch mit demselben Inhalt). Leere Dateien und Sperrdateien (*.lock) zählen nicht."""
if live is None or not live.exists():
return
fehlend: list[str] = []
anders: list[str] = []
unlesbar: list[str] = []
for datei in [live] if live.is_file() else _dateien(live):
name = datei.name if live.is_file() else datei.relative_to(live).as_posix()
try:
# Verknüpfungen packt die Probe nicht aus (backup.sh sichert sie als Verknüpfung) — nicht vergleichen.
if (datei.is_symlink() or datei.name.endswith(".lock") or datei.stat().st_size == 0
or _geaendert(datei) >= beginn - _SPIELRAUM_S):
continue
gegenstueck = kopie if live.is_file() else kopie / datei.relative_to(live)
if not gegenstueck.is_file():
fehlend.append(name)
elif inhalt_gleich and gegenstueck.read_bytes() != datei.read_bytes():
anders.append(name)
except OSError:
unlesbar.append(name)
if fehlend:
fehler.append(_sammelzeile(bereich, fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
if anders:
fehler.append(_sammelzeile(bereich, anders, "steht in der Sicherung anders als auf der Box",
"stehen in der Sicherung anders als auf der Box"))
if unlesbar:
fehler.append(_sammelzeile(bereich, unlesbar, "ließ sich nicht vergleichen", "ließen sich nicht vergleichen"))
def _pruefe_inhalt(wurzel: Path, mitglieder: dict[str, tarfile.TarInfo], beginn: float,
geprueft: list[str], fehler: list[str]) -> None:
"""Die Pflichtinhalte im ausgepackten Ordner (Geheimnisse nur über die Liste der Mitglieder)."""
hermes = wurzel / "hermes"
# Lucys Gedächtnis: das Wertvollste, deshalb auch Inhalt gegen den lebenden Stand.
gedaechtnis = [p for p in _dateien(hermes / "memories") if p.stat().st_size > 0 and not p.name.endswith(".lock")]
if gedaechtnis:
geprueft.append(f"Gedächtnis: {len(gedaechtnis)} Dateien ({', '.join(p.name for p in gedaechtnis[:4])})")
else:
fehler.append("Lucys Gedächtnis (hermes/memories) fehlt in der Sicherung oder ist leer.")
_pruefe_abgleich("Gedächtnis", hermes / "memories", HERMES_LIVE / "memories", beginn, fehler, inhalt_gleich=True)
seele = hermes / "SOUL.md"
if seele.is_file() and seele.stat().st_size > 0:
geprueft.append("SOUL.md")
else:
fehler.append("SOUL.md fehlt in der Sicherung oder ist leer.")
_pruefe_abgleich("SOUL.md", seele, HERMES_LIVE / "SOUL.md", beginn, fehler, inhalt_gleich=True)
skills = [p for p in _dateien(hermes / "skills") if p.name == "SKILL.md"]
if skills:
geprueft.append(f"Skills: {len(skills)}")
else:
fehler.append("Keine Skills (hermes/skills/…/SKILL.md) in der Sicherung.")
_pruefe_abgleich("Skills", hermes / "skills", HERMES_LIVE / "skills", beginn, fehler)
skripte = _dateien(hermes / "scripts")
if skripte:
geprueft.append(f"Cron-Skripte: {len(skripte)}")
else:
fehler.append("Keine Cron-Skripte (hermes/scripts) in der Sicherung.")
_pruefe_abgleich("Cron-Skripte", hermes / "scripts", HERMES_LIVE / "scripts", beginn, fehler)
try:
jobs = json.loads((hermes / "cron" / "jobs.json").read_text(encoding="utf-8"))
liste = jobs.get("jobs", jobs) if isinstance(jobs, dict) else jobs
geprueft.append(f"Hermes-Cron-Jobs: {len(liste)}")
except (OSError, ValueError, TypeError) as exc:
fehler.append(f"Die Hermes-Cron-Jobs (cron/jobs.json) fehlen oder sind unlesbar ({exc.__class__.__name__}).")
try:
cfg = _yaml(hermes / "config.yaml")
if not isinstance(cfg, dict) or not cfg:
raise ValueError("leer")
geprueft.append("Hermes-Config lesbar")
except Exception as exc:
fehler.append(f"Die Hermes-Config (config.yaml) fehlt oder ist unlesbar ({exc.__class__.__name__}).")
env = mitglieder.get("hermes/.env")
if env is not None and env.isfile() and env.size > 0:
geprueft.append("Zugangsdaten (.env) enthalten (nicht ausgepackt)")
else:
fehler.append("Die Zugangsdaten (hermes/.env) fehlen in der Sicherung.")
try:
modelle = (_yaml(wurzel / "llama-swap" / "config.yaml") or {}).get("models")
if not isinstance(modelle, dict) or not modelle:
raise ValueError("ohne Modelle")
geprueft.append(f"llama-swap-Config: {len(modelle)} Modelle")
except Exception as exc:
fehler.append(f"Die llama-swap-Config fehlt oder ist unbrauchbar ({exc.__class__.__name__}: {exc})"[:200])
zustand = sorted((wurzel / "box-wart").glob("mc2-*.json")) if (wurzel / "box-wart").is_dir() else []
kaputt = []
for datei in zustand:
try:
json.loads(datei.read_text(encoding="utf-8"))
except (OSError, ValueError):
kaputt.append(datei.name)
if not zustand:
fehler.append("Der Box-Wart-Zustand (box-wart/mc2-*.json) fehlt in der Sicherung.")
elif kaputt:
fehler.append(f"Box-Wart-Zustand unlesbar: {', '.join(kaputt)}")
else:
geprueft.append(f"Box-Wart-Zustand: {len(zustand)} Dateien")
fehlend = []
for datei in sorted(DATEN_LIVE.glob("mc2-*.json")) if DATEN_LIVE.is_dir() else []:
try:
vorher = _geaendert(datei) < beginn - _SPIELRAUM_S and datei.stat().st_size > 0
except OSError:
continue
if vorher and not (wurzel / "box-wart" / datei.name).is_file():
fehlend.append(datei.name)
if fehlend:
fehler.append(_sammelzeile("Box-Wart-Zustand", fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
units = sorted(p.name for p in (wurzel / "systemd" / "user").glob("*.service")) \
if (wurzel / "systemd" / "user").is_dir() else []
if "mission-control-2.service" in units:
geprueft.append(f"systemd-Units: {len(units)} Dienste")
else:
fehler.append("Die systemd-Units (systemd/user, mindestens mission-control-2.service) fehlen in der Sicherung.")
if LLAMA_SWAP_DROPINS.is_dir() and not (wurzel / "systemd" / LLAMA_SWAP_DROPINS.name).is_dir():
fehler.append(f"Die llama-swap-Drop-ins ({LLAMA_SWAP_DROPINS.name}) fehlen in der Sicherung.")
def pruefe_sicherung(tarball: Path) -> tuple[list[str], list[str]]:
"""Eine Sicherung probeweise auspacken und prüfen. Rückgabe (Geprüftes, Fehler); der Tmp-Ordner ist danach weg."""
geprueft: list[str] = []
fehler: list[str] = []
beginn = sicherungs_zeit(tarball)
try:
with tarfile.open(tarball, "r:gz") as tar:
alle = tar.getmembers() # liest das ganze Archiv: ein abgeschnittenes fällt hier auf
mitglieder = {_rel(m.name): m for m in alle}
auspacken = [m for m in alle if _rel(m.name) and (m.isfile() or m.isdir())
and Path(_rel(m.name)).name not in _GEHEIM]
tmp = Path(tempfile.mkdtemp(prefix=_PRAEFIX, dir=_tmp_basis()))
try:
tar.extractall(tmp, members=auspacken, filter="data")
geprueft.append(f"ausgepackt: {sum(1 for m in auspacken if m.isfile())} Dateien")
_pruefe_inhalt(tmp, mitglieder, beginn, geprueft, fehler)
finally:
if not _entfernen(tmp):
fehler.append(f"Der Tmp-Ordner {tmp} ließ sich nicht entfernen.")
except (tarfile.TarError, EOFError, zlib.error, OSError) as exc:
fehler.append(f"Die Sicherung lässt sich nicht auspacken: {exc.__class__.__name__}: {exc}"[:240])
return geprueft, fehler
def probe() -> dict:
"""Die ganze Probe: jüngste Sicherung finden, Alter prüfen, auspacken und prüfen, Ergebnis ablegen, bei Rot melden."""
start = time.time()
geprueft: list[str] = []
fehler: list[str] = []
tarball = None
try:
_alte_tmp_ordner_entfernen()
tarball = juengste_sicherung()
if tarball is None:
fehler.append(f"Keine Sicherung gefunden ({SICHERUNGEN_DIR}).")
else:
alter_h = (time.time() - tarball.stat().st_mtime) / 3600
if alter_h > MAX_ALTER_H:
fehler.append(f"Die jüngste Sicherung ist {alter_h / 24:.0f} Tage alt: Die tägliche Sicherung "
"(mc2-backup.timer) läuft nicht.")
else:
geprueft.append(f"jüngste Sicherung {alter_h:.0f} Stunden alt")
g, f = pruefe_sicherung(tarball)
geprueft += g
fehler += f
except Exception as exc: # die Probe selbst darf nie still scheitern
log.exception("Probe-Wiederherstellung abgestürzt")
fehler.append(f"Die Probe ist abgestürzt: {exc.__class__.__name__}: {exc}"[:240])
stand = {
"version": 1,
"zeit": start,
"datum": datetime.fromtimestamp(start, LOCAL_TZ).isoformat(timespec="seconds"),
"ergebnis": "rot" if fehler else "gruen",
"sicherung": tarball.name if tarball else None,
"dauer_s": round(time.time() - start, 1),
"geprueft": geprueft,
"fehler": fehler,
}
speichere(stand)
if fehler:
melden(stand)
return stand
# --- Ablage und Meldung ------------------------------------------------------------------------------
def speichere(stand: dict) -> None:
try:
ZUSTAND_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = ZUSTAND_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(stand, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, ZUSTAND_PATH)
except OSError:
log.warning("Probe-Wiederherstellung: %s nicht schreibbar", ZUSTAND_PATH, exc_info=True)
def lese_stand() -> dict | None:
"""Letztes Ergebnis für den Wächter. None = noch nie gelaufen (oder Datei unlesbar)."""
try:
daten = json.loads(ZUSTAND_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(daten, dict) or not isinstance(daten.get("zeit"), (int, float)):
return None
return daten
def meldetext(stand: dict) -> str:
fehler = stand.get("fehler") or []
text = (f"Die Probe-Wiederherstellung ist rot ({stand.get('sicherung') or 'keine Sicherung'}): "
+ " ".join(fehler[:3]))
if len(fehler) > 3:
text += f" (und {len(fehler) - 3} weitere)"
return text + " Der Wächter zeigt es auch im Cockpit."
def _bash() -> str | None:
"""bash für notify.sh; auf Windows (Entwicklung) gibt es keinen Meldeweg."""
return shutil.which("bash") if os.name == "posix" else None
def melden(stand: dict) -> None:
"""Rot → Meldung über notify.sh in normaler Dringlichkeit: kein -d, kein „Alarm“ im Betreff — nachts sammelt
notify.sh sie für die Morgenmeldung um 07:00."""
if not (bash := _bash()):
log.warning("Probe-Wiederherstellung rot, aber hier gibt es keinen Meldeweg: %s", meldetext(stand))
return
try:
subprocess.run([bash, str(NOTIFY_SH), "-s", "[Sicherung]", meldetext(stand)], timeout=120, check=False,
stdin=subprocess.DEVNULL, capture_output=True)
except (OSError, subprocess.SubprocessError):
log.warning("Probe-Wiederherstellung: Meldung ging nicht raus", exc_info=True)
def main() -> int:
logging.basicConfig(level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
stand = probe()
print(f"Probe-Wiederherstellung von {stand['sicherung'] or '–'}: "
f"{'grün' if stand['ergebnis'] == 'gruen' else 'ROT'} ({stand['dauer_s']} s)")
for zeile in stand["geprueft"]:
print(f" ok {zeile}")
for zeile in stand["fehler"]:
print(f" ! {zeile}")
return 0 if stand["ergebnis"] == "gruen" else 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -21,15 +21,16 @@ import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from config import MODELS_DIR from config import MODELS_DIR
from kern.zeit import LOCAL_TZ
from services import announce from services import announce
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json"))) STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden) INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100")) MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
REPEATS = ("", "daily", "weekdays", "weekly") REPEATS = ("", "daily", "weekdays", "weekly")
+143
View File
@@ -0,0 +1,143 @@
"""
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
"""
import psutil
from services import budget, catalog, llamaswap
from services.fit import evaluate_fit
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _capability_suit(role: str, caps: dict, name: str) -> float:
"""0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle.
Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht."""
role = (role or "").lower()
low = (name or "").lower()
vision = bool(caps.get("vision"))
coder = bool(caps.get("coder"))
tools = caps.get("tools") != "no"
if role == "vision":
if not vision:
return 0.0 # harte Anforderung
return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni
if role == "coder":
return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung
if role == "hermes":
# Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht.
if "hermes" in low:
return 1.0
return 0.6 if tools else 0.1
if role == "fast":
# Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref.
return 1.0 if tools else 0.6
if role == "heavy":
return 1.0
if role == "scout":
return 0.9 if vision else 0.7
return 0.5
def _pref(role: str, params: float, tps: float) -> float:
"""0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit,
'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben)."""
role = (role or "").lower()
if role == "fast":
speed = min(tps / 25.0, 1.0)
size_ok = 1.0 if params <= 50 else 50.0 / params
return speed * size_ok
if role == "heavy":
return min(params / 120.0, 1.0)
if role == "hermes":
return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 7–24B ideal als Hirn
if role == "vision":
return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt
if role == "coder":
return 0.5 + 0.5 * min(params / 80.0, 1.0)
if role == "scout":
return 1.0 if params <= 40 else 0.5
return 0.5
def _catalog_role_match(role: str, name: str) -> bool:
"""Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet?
Dann ist es der prinzipien-konforme Pick → starker Bonus."""
meta = catalog.meta_for_name(name)
return bool(meta and (meta.get("role") or "").lower() == (role or "").lower())
def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool,
incomplete: bool, cat_match: bool) -> str:
if incomplete:
return "Download unvollständig"
if role == "vision" and not caps.get("vision"):
return "keine Vision-Fähigkeit"
if role == "coder" and not caps.get("coder"):
return "kein Coder-Modell"
if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no":
return "kein natives Tool-Calling"
if not fits:
return "passt nicht ins Budget (OOM)"
bits = []
if cat_match:
bits.append("Katalog-Pick ✓")
if role == "vision":
bits.append("Vision ✓")
if role == "coder" and caps.get("coder"):
bits.append("Coder ✓")
if role == "hermes":
bits.append("Hermes" if "hermes" in (name or "").lower()
else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools"))
if caps.get("moe"):
bits.append("MoE")
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
return " · ".join(bits)
def recommend_for_role(role: str) -> dict:
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
role = (role or "").strip().lower()
ram = _ram_gb()
out = []
for m in llamaswap.list_models():
caps = m.get("capabilities") or {}
params = budget.params_of_model(m)
quant = m.get("quant") or "Q4_K_M"
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
incomplete = bool(m.get("incomplete"))
fits = (fit["level"] != "too_tight") and not incomplete
tps = fit["tps"] or 0
suit = _capability_suit(role, caps, m["name"])
cat_match = _catalog_role_match(role, m["name"])
suitable = suit >= 0.5 and fits
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
# Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit.
score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term
if not fits:
score -= 5.0
out.append({
"name": m["name"], "current_role": m.get("role"),
"params_b": round(params, 1), "quant": quant,
"fit": fit, "suitable": suitable, "incomplete": incomplete,
"score": round(score, 3),
"reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match),
})
out.sort(key=lambda x: -x["score"])
rec = next((o["name"] for o in out if o["suitable"]), None)
for o in out:
o["recommended"] = (o["name"] == rec)
return {"role": role, "recommended": rec, "models": out}
+19 -35
View File
@@ -3,7 +3,7 @@ Lane-Routing für den eingebauten MC2-Gateway (:9001/v1).
Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell: Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell:
- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`. - **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`.
- **coding**: Code-Arbeit → `coder`; riesiger/architektonischer Kontext → `heavy`; - **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
triviale Kurzfrage ohne Code → `fast` (Tempo). triviale Kurzfrage ohne Code → `fast` (Tempo).
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
@@ -19,6 +19,8 @@ from services.routing_policy import load_policy
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars # (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code. # sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
LANES = ["coding", "chat"]
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
_HEAVY_KW = re.compile( _HEAVY_KW = re.compile(
@@ -34,46 +36,25 @@ _CODING_HEAVY_KW = re.compile(
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b", r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
re.IGNORECASE, re.IGNORECASE,
) )
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
_CODE_HINT = re.compile(
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
r"npm|pip|git|docker|terminal|shell|command)\b",
re.IGNORECASE | re.MULTILINE,
)
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet. # Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte VISION_CAPABLE = {"vision", "scout"}
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
IMAGE_PART_TYPES = {"image_url", "input_image", "image"} IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
def ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele: coder, heavy (derselbe Coder) und rohe Coder-IDs."""
a = (alias or "").lower()
return "coder" in a or a == "heavy"
def bilder_aufteilen(body: dict) -> tuple[list[tuple[dict, int]], list[tuple[dict, int]]]:
"""Bild-Parts in (frisch, alt) teilen. Frisch = nach der letzten Antwort des Modells (der Schritt,
um den es gerade geht: neue Nutzer-Nachricht oder Werkzeug-Ergebnis). Alt = davor — das Modell hat
sie schon gesehen und seine Schlüsse im Verlauf. Jeder Eintrag ist (Nachricht, Index im content)."""
msgs = [m for m in body.get("messages") or [] if isinstance(m, dict)]
letzte_antwort = max((i for i, m in enumerate(msgs) if m.get("role") == "assistant"), default=-1)
frisch: list[tuple[dict, int]] = []
alt: list[tuple[dict, int]] = []
for i, m in enumerate(msgs):
if not isinstance(m.get("content"), list):
continue
for j, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
(frisch if i > letzte_antwort else alt).append((m, j))
return frisch, alt
def bild_ziel(alias: str, vision_alias: str, coder_vision_alias: str | None) -> str:
"""Bild-Zwilling für eine Anfrage mit frischem Bild: Coder-Ziele an den Coder-Zwilling (falls es ihn
gibt), alles andere an den Hirn-Zwilling (vision)."""
if coder_vision_alias and ist_coder_alias(alias):
return coder_vision_alias
return vision_alias
def has_image(body: dict) -> bool: def has_image(body: dict) -> bool:
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler """True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil).""" content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
@@ -136,3 +117,6 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
return _route_chat(text, n) # chat + alles Unbekannte return _route_chat(text, n) # chat + alles Unbekannte
def choose_model(body: dict) -> tuple[str, str]:
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
return choose_for_lane("chat", body)
+131 -117
View File
@@ -1,117 +1,131 @@
""" """
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat). UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json — Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten. einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
bleiben in router_logic.py im Code. bleiben in router_logic.py im Code.
""" """
import json import json
import os import os
import threading import threading
from pathlib import Path from pathlib import Path
from config import MODELS_DIR from config import MODELS_DIR
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json"))) POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
def _env_bool(name: str, default: str) -> bool: def _env_bool(name: str, default: str) -> bool:
return os.environ.get(name, default) not in ("0", "false", "") return os.environ.get(name, default) not in ("0", "false", "")
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert. # Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
DEFAULTS: dict = { DEFAULTS: dict = {
"fast": os.environ.get("MC_ROUTE_FAST", "fast"), "fast": os.environ.get("MC_ROUTE_FAST", "fast"),
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"), "heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
"coder": os.environ.get("MC_ROUTE_CODER", "coder"), "coder": os.environ.get("MC_ROUTE_CODER", "coder"),
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""), "coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet # Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough). # (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
"vision": os.environ.get("MC_ROUTE_VISION", "vision"), "vision": os.environ.get("MC_ROUTE_VISION", "vision"),
# Seit 24.09.2026: Bild-Zwilling des Coders — Coder-Anfragen mit neuem Bild gehen hierhin statt an vision. "heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
# "" = auch Coder-Bilder gehen an vision. "coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"coder_vision": os.environ.get("MC_ROUTE_CODER_VISION", "coder-bild"), "fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")), }
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"), # Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
} FIELDS: list[dict] = [
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung. {"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
FIELDS: list[dict] = [ {"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"}, {"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"}, {"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"}, {"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"}, {"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"}, {"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
{"key": "coder_vision", "label": "Bild-Zwilling des Coders (leer = Coder-Bilder an vision)", "type": "str"}, ]
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000}, _LOCK = threading.Lock()
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"}, _CACHE: dict = {"mtime": None, "policy": None}
]
_LOCK = threading.Lock() def _read_file() -> dict:
_CACHE: dict = {"mtime": None, "policy": None} try:
with open(POLICY_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
def _read_file() -> dict: return data if isinstance(data, dict) else {}
try: except (FileNotFoundError, json.JSONDecodeError, OSError):
with open(POLICY_PATH, "r", encoding="utf-8") as f: return {}
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError): def _coerce(patch: dict) -> dict:
return {} """Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
spec = {f["key"]: f for f in FIELDS}
out: dict = {}
def _coerce(patch: dict) -> dict: for k, v in (patch or {}).items():
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten.""" f = spec.get(k)
spec = {f["key"]: f for f in FIELDS} if not f:
out: dict = {} continue # unbekannte Keys still verwerfen
for k, v in (patch or {}).items(): if f["type"] == "int":
f = spec.get(k) iv = int(v)
if not f: lo, hi = f.get("min", 1), f.get("max", 10**9)
continue # unbekannte Keys still verwerfen if not (lo <= iv <= hi):
if f["type"] == "int": raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
iv = int(v) out[k] = iv
lo, hi = f.get("min", 1), f.get("max", 10**9) elif f["type"] == "bool":
if not (lo <= iv <= hi): out[k] = bool(v)
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]") else: # str
out[k] = iv sv = str(v).strip()
elif f["type"] == "bool": if k not in ("coder_lite", "vision") and not sv:
out[k] = bool(v) raise ValueError(f"{k} darf nicht leer sein")
else: # str out[k] = sv
sv = str(v).strip() return out
if k not in ("coder_lite", "vision", "coder_vision") and not sv:
raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv def _coerce_safe(patch: dict) -> dict:
return out """Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
try:
return _coerce(patch)
def _coerce_safe(patch: dict) -> dict: except (ValueError, TypeError):
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen.""" return {}
try:
return _coerce(patch)
except (ValueError, TypeError): def load_policy() -> dict:
return {} """Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
try:
mtime = POLICY_PATH.stat().st_mtime
def load_policy() -> dict: except OSError:
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig.""" mtime = None
try: with _LOCK:
mtime = POLICY_PATH.stat().st_mtime if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
except OSError: merged = {**DEFAULTS}
mtime = None if mtime is not None:
with _LOCK: merged.update(_coerce_safe(_read_file()))
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime: _CACHE["mtime"] = mtime
merged = {**DEFAULTS} _CACHE["policy"] = merged
if mtime is not None: return dict(_CACHE["policy"])
merged.update(_coerce_safe(_read_file()))
_CACHE["mtime"] = mtime
_CACHE["policy"] = merged def save_policy(patch: dict) -> dict:
return dict(_CACHE["policy"]) """Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
clean = _coerce(patch) # wirft bei ungültigem Input
with _LOCK:
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = POLICY_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False, indent=2)
os.replace(tmp, POLICY_PATH)
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
_CACHE["policy"] = None
return current
def policy_meta() -> dict:
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
+152
View File
@@ -0,0 +1,152 @@
"""
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
"""
import asyncio
import logging
import os
import time
import httpx
import psutil
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
def _reach(url: str, path: str = "/health") -> bool:
try:
with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}").status_code < 500
except Exception:
return False
def _check_engine() -> bool:
return llamaswap.engine_reachable()
def _check_brain() -> bool:
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
st = llamaswap.brain_status()
if st.get("ready"):
return True
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
def _check_disk() -> bool:
try:
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
except Exception:
return True # kein Messwert ≠ Alarm
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
CHECKS: dict[str, tuple] = {
"engine": (_check_engine,
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
"Die Modell-Engine ist wieder da."),
"brain": (_check_brain,
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models"),
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"Der Agent-Dienst läuft wieder."),
"voice": (lambda: _reach(VOICE_SERVICE_URL),
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
"Der Hör-Dienst läuft wieder."),
"disk": (_check_disk,
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
"Die Platte hat wieder genug Luft."),
}
# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward),
# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei
# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit).
if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.",
"Das Steuerpult ist wieder da.")
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
"Der Modell-Gateway läuft wieder.")
class _Watch:
__slots__ = ("alert_ts", "alerted", "fails")
def __init__(self) -> None:
self.fails = 0 # Fehl-Ticks in Folge
self.alerted = False # Alarm ist raus, Entwarnung steht aus
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
def _notify_telegram(subject: str, text: str) -> None:
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
legt der Wächter selbst ab."""
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
def _tick() -> None:
now = time.time()
for name, (check, fail_msg, ok_msg) in CHECKS.items():
w = _watches[name]
try:
ok = bool(check())
except Exception:
ok = False
if ok:
w.fails = 0
if w.alerted:
w.alerted = False
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
_notify_telegram("[Box wieder ok]", ok_msg)
continue
w.fails += 1
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
if due:
prefix = "" if not w.alerted else "Immer noch: "
w.alerted = True
w.alert_ts = now
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
_notify_telegram("[Box-Problem]", prefix + fail_msg)
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
async def sentry_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
await asyncio.sleep(START_DELAY)
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
-77
View File
@@ -1,77 +0,0 @@
"""
Software-Stand einer Instanz (seit 24.09.2026): welcher Commit seit wann live ist.
Nach einem erfolgreichen Umschalten schreibt deploy/deploy.sh (KI-Box) bzw. deploy/homelab/ausrollen.sh
(Container des Homelab-Teils) die Stand-Datei mc2-stand.json in den Datenordner (deploy/stand-schreiben.py):
Commit kurz, Betreff, Commit-Datum und den Zeitpunkt des Deploys. Die Oberfläche zeigt sie unter
Einstellungen → Software-Stand, für diese Instanz über /api/instanz und für die andere über /api/partner/instanz.
Ohne Stand-Datei (Entwicklung am PC, Box vor dem ersten Deploy mit dieser Datei) kommt der Stand aus git, sonst
heißt er „unbekannt“. Nur lesend.
"""
import json
import os
import subprocess
import threading
import time
from pathlib import Path
from kern.einstellungen import einstellungen
REPO = Path(__file__).resolve().parents[2]
GIT_CACHE_S = 60.0
_lock = threading.Lock()
_git_cache: dict = {"ts": 0.0, "stand": None}
def stand_datei() -> Path:
return Path(os.environ.get("MC_STAND_DATEI", str(einstellungen().daten_dir / "mc2-stand.json")))
def _text(wert: object) -> str | None:
return wert.strip() if isinstance(wert, str) and wert.strip() else None
def _aus_datei() -> dict | None:
try:
daten = json.loads(stand_datei().read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(daten, dict) or not _text(daten.get("commit")):
return None
return {"quelle": "deploy", **{k: _text(daten.get(k)) for k in ("commit", "betreff", "commit_datum",
"deploy_zeit")}}
def _git_log() -> str | None:
"""Kopf des Checkouts, in dem dieser Code liegt. Die Schicht, die Tests ersetzen."""
try:
r = subprocess.run(["git", "-C", str(REPO), "log", "-1", "--format=%h%n%cI%n%s"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5)
except (OSError, subprocess.SubprocessError):
return None
return r.stdout if r.returncode == 0 else None
def _aus_git() -> dict | None:
with _lock:
if time.time() - _git_cache["ts"] < GIT_CACHE_S:
return _git_cache["stand"]
ausgabe = _git_log()
stand = None
if ausgabe:
commit, datum, betreff = ([z.strip() for z in ausgabe.splitlines()] + ["", "", ""])[:3]
if commit:
stand = {"quelle": "git", "commit": commit, "betreff": betreff or None,
"commit_datum": datum or None, "deploy_zeit": None}
with _lock:
_git_cache.update(ts=time.time(), stand=stand)
return stand
def stand() -> dict:
"""{"quelle": "deploy"|"git"|"unbekannt", "commit", "betreff", "commit_datum", "deploy_zeit"}."""
return _aus_datei() or _aus_git() or {"quelle": "unbekannt", "commit": None, "betreff": None,
"commit_datum": None, "deploy_zeit": None}
+27 -75
View File
@@ -8,10 +8,8 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
import glob import glob
import os import os
import re
import subprocess import subprocess
import threading import threading
import time
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
@@ -128,25 +126,33 @@ def find_hermes_agent_git() -> dict | None:
def get_engine_version() -> dict: def get_engine_version() -> dict:
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan). engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
git_info = get_git_info(engine_path)
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit if git_info:
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die return {**git_info, "type": "git"}
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan") candidates = [
binary = os.path.join(engine_path, "llama-server") os.path.join(engine_path, "llama-server"),
if not os.path.exists(binary): os.path.join(engine_path, "bin", "llama-server"),
return {"type": "unknown"} "/usr/local/bin/llama-server",
try: "/usr/bin/llama-server",
env = dict(os.environ, LD_LIBRARY_PATH=engine_path) "llama-server"
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env) ]
text = (res.stdout or "") + (res.stderr or "")
except Exception: for binary in candidates:
return {"type": "unknown"} if binary != "llama-server" and not os.path.exists(binary):
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)): continue
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"} try:
zeile = next((z for z in text.splitlines() if z.startswith("version")), "") res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
return {"type": "binary", "version_text": zeile or "unbekannt"} output = (res.stdout or "").strip() or (res.stderr or "").strip()
if output:
lines = output.splitlines()
ver = lines[0] if lines else "unknown"
return {"version_text": ver, "type": "binary"}
except Exception:
pass
return {"type": "unknown"}
_VERSION_CACHE = {"ts": 0.0, "data": {}} _VERSION_CACHE = {"ts": 0.0, "data": {}}
@@ -191,59 +197,5 @@ def system_status() -> dict:
"gpu": _gpu_sysfs(), "gpu": _gpu_sysfs(),
"temp": _temps(), "temp": _temps(),
"disk": disk, "disk": disk,
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
"uptime_s": _uptime_s(),
"versions": check_versions_cached(), "versions": check_versions_cached(),
} }
def metrik_punkt() -> dict:
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
verfaelscht nichts."""
vm = psutil.virtual_memory()
temp = _temps() or {}
gpu = _gpu_sysfs() or {}
try:
from services.token_stats import get_stats
tok = get_stats()
except Exception:
tok = {}
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
disk = None
return {
"cpu": psutil.cpu_percent(interval=None),
"ram": vm.percent,
"ram_used": vm.used,
"ram_total": vm.total,
"gpu": gpu.get("busy_percent"),
"disk": disk,
"temp_cpu": temp.get("cpu"),
"temp_gpu": temp.get("gpu"),
"uptime_s": _uptime_s(),
"tok_p": tok.get("prompt_tokens", 0),
"tok_c": tok.get("completion_tokens", 0),
}
def _uptime_s() -> int | None:
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
try:
return int(time.time() - psutil.boot_time())
except Exception:
return None
-323
View File
@@ -1,323 +0,0 @@
"""
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede
Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend.
Seit 24.09.2026 gibt es dazu einen strukturierten Verlauf (mc2-update-verlauf.jsonl im Datenordner):
autoupdate.sh und die Update-Knöpfe schreiben je Baustein eine JSON-Zeile mit Lauf, Anlass, Ergebnis
und Text. Ab dem ersten solchen Eintrag gilt nur noch er; ältere Läufe kommen weiter aus dem
Meldeprotokoll. So bricht keine Umformulierung einer Telegram-Meldung mehr den Verlauf.
Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check
nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn.
Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
"""
import json
import os
import re
import threading
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
VERLAUF_FILE = Path(os.environ.get("MC_UPDATE_VERLAUF",
str(einstellungen().daten_dir / "mc2-update-verlauf.jsonl")))
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
_BAUSTEIN_IM_TEXT = (
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
)
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
_EINTRAG = re.compile(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(?:OK telegram(?: direkt)?|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
_UPDATE_MELDUNG = re.compile(
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
r"|^Auto-Update abgebrochen|^Sonntags-Update")
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
# Die Morgenmeldung (07:00) wiederholt die Nachtmeldungen gesammelt — sie ist kein eigener Lauf.
_MORGENMELDUNG = re.compile(r"^Guten Morgen, Commander\. Heute Nacht")
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
_EINORDNUNG = (
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
re.IGNORECASE)),
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
)
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
# Bausteine im strukturierten Verlauf → Name im Cockpit.
BAUSTEIN_NAME = {"swap": "llama-swap", "engine": "Motor", "hermes": "Hermes", "os": "Betriebssystem",
"neustart": "Neustart", "lauf": "Update-Lauf"}
_cache_lock = threading.Lock()
_cache: dict = {"schluessel": None, "laeufe": []}
_struktur_cache: dict = {"schluessel": None, "laeufe": []}
_schreib_lock = threading.Lock()
# --- Protokoll lesen ----------------------------------------------------------------
def eintraege(text: str) -> list[tuple[datetime, str]]:
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
ergebnis: list[tuple[datetime, str]] = []
for zeile in text.splitlines():
if m := _EINTRAG.match(zeile):
try:
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
except ValueError:
continue
ergebnis.append((zeit, m.group(2)))
elif ergebnis:
zeit, bisher = ergebnis[-1]
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
return ergebnis
def _einordnen(text: str) -> str:
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
def _baustein_aus(text: str) -> str:
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
def _lesbar(text: str) -> str:
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
text = re.sub(r"\b1 Commits\b", "1 Änderung", text)
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
# Nur der erste Satz: der Rest der Meldungen ist Begleittext für Telegram.
return re.split(r"(?<=[.!])\s", text.strip(), maxsplit=1)[0][:240]
def _zeile(baustein: str, text: str) -> dict:
ergebnis = _einordnen(text)
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
zeilen: list[dict] = []
if zusammenfassung:
for z in zusammenfassung.splitlines()[1:]:
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
for _, text in gruppe:
erste = text.splitlines()[0] if text else ""
if _NEUSTART.search(erste):
zeilen.append(_zeile("Neustart", erste))
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
zeilen.append(_zeile("Update-Lauf", erste))
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
start, ende = gruppe[0][0], gruppe[-1][0]
sonntag = start.weekday() == 6 and 4 <= start.hour < 6
return {"start": start.isoformat(), "ende": ende.isoformat(),
"anlass": "Updates am Sonntag" if sonntag else "Update von Hand",
**_bewertung(zeilen), "zeilen": zeilen}
def _bewertung(zeilen: list[dict]) -> dict:
"""Stufe und Titel eines Laufs aus seinen Zeilen: das Schlimmste zählt."""
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
arten = {z["ergebnis"] for z in zeilen}
if stufe == "rot":
titel = "Fehler"
elif stufe == "gelb":
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
elif stufe == "gruen":
titel = "Eingespielt"
elif stufe == "info":
titel = "Neu gestartet"
else:
titel = "Alles aktuell"
return {"stufe": stufe, "titel": titel}
# --- Strukturierter Verlauf (seit 24.09.2026) ----------------------------------------
def eintragen(lauf: str, anlass: str, baustein: str, ergebnis: str, text: str) -> None:
"""Eine Zeile in den strukturierten Verlauf (dasselbe Format schreibt autoupdate.sh)."""
if ergebnis not in STUFE_DER_ZEILE:
raise ValueError(f"Unbekanntes Ergebnis: {ergebnis}")
zeile = json.dumps({"lauf": lauf, "anlass": anlass, "ts": datetime.now(LOCAL_TZ).isoformat(timespec="seconds"),
"baustein": baustein, "ergebnis": ergebnis, "text": text}, ensure_ascii=False)
with _schreib_lock:
VERLAUF_FILE.parent.mkdir(parents=True, exist_ok=True)
with VERLAUF_FILE.open("a", encoding="utf-8", newline="\n") as f:
f.write(zeile + "\n")
def _zeitpunkt(wert: str) -> datetime | None:
try:
zeit = datetime.fromisoformat(wert)
except (TypeError, ValueError):
return None
return zeit if zeit.tzinfo else zeit.replace(tzinfo=LOCAL_TZ)
def strukturierte_laeufe(text: str) -> list[dict]:
"""Läufe aus dem strukturierten Verlauf (eine JSON-Zeile je Baustein), neueste zuerst."""
gruppen: dict[str, list[dict]] = {}
for zeile in text.splitlines():
try:
eintrag = json.loads(zeile)
except ValueError:
continue
if isinstance(eintrag, dict) and _zeitpunkt(str(eintrag.get("lauf"))):
gruppen.setdefault(str(eintrag["lauf"]), []).append(eintrag)
laeufe = []
for lauf, eintraege in gruppen.items():
zeilen = [{"baustein": BAUSTEIN_NAME.get(str(e.get("baustein")), str(e.get("baustein") or "Update-Lauf")),
"ergebnis": e["ergebnis"], "stufe": STUFE_DER_ZEILE[e["ergebnis"]],
"text": _lesbar(str(e.get("text") or ""))}
for e in eintraege if e.get("ergebnis") in STUFE_DER_ZEILE]
start = _zeitpunkt(lauf)
zeiten = [t for e in eintraege if (t := _zeitpunkt(str(e.get("ts"))))]
ende = max([start, *zeiten])
laeufe.append({"start": start.isoformat(), "ende": ende.isoformat(),
"anlass": str(eintraege[0].get("anlass") or "Update von Hand"),
**_bewertung(zeilen), "zeilen": zeilen})
return sorted(laeufe, key=lambda lauf: _zeitpunkt(lauf["start"]), reverse=True)
def zusammenfuehren(struktur: list[dict], alt: list[dict]) -> list[dict]:
"""Ab dem ersten strukturierten Lauf gilt nur noch der strukturierte Verlauf; davor das
Meldeprotokoll (dort stünde derselbe Sonntag sonst doppelt)."""
if struktur:
grenze = min(_zeitpunkt(lauf["start"]) for lauf in struktur)
alt = [lauf for lauf in alt if _zeitpunkt(lauf["start"]) < grenze]
return sorted(struktur + alt, key=lambda lauf: _zeitpunkt(lauf["start"]), reverse=True)
def laeufe_aus(text: str) -> list[dict]:
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
relevant = [(z, t) for z, t in eintraege(text)
if not _MORGENMELDUNG.match(t)
and (_ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
or _NEUSTART.search(t.splitlines()[0] if t else ""))]
gruppen: list[list[tuple[datetime, str]]] = []
for eintrag in relevant:
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
gruppen[-1].append(eintrag)
else:
gruppen.append([eintrag])
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
return list(reversed(laeufe))
def _protokoll_ende() -> str:
try:
with NOTIFY_LOG.open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - LESE_MAX))
roh = f.read()
except OSError:
return ""
text = roh.decode("utf-8", errors="replace")
if groesse > LESE_MAX:
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
return text
def _schluessel(pfad: Path) -> tuple[int, int] | None:
try:
st = pfad.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
def laeufe(anzahl: int = 8) -> list[dict]:
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn eine Quelle wuchs."""
with _cache_lock:
schluessel = _schluessel(NOTIFY_LOG)
if _cache["schluessel"] != schluessel:
_cache["laeufe"] = laeufe_aus(_protokoll_ende()) if schluessel else []
_cache["schluessel"] = schluessel
schluessel = _schluessel(VERLAUF_FILE)
if _struktur_cache["schluessel"] != schluessel:
try:
text = VERLAUF_FILE.read_text(encoding="utf-8") if schluessel else ""
except OSError:
text = ""
_struktur_cache["laeufe"] = strukturierte_laeufe(text)
_struktur_cache["schluessel"] = schluessel
return zusammenfuehren(_struktur_cache["laeufe"], _cache["laeufe"])[:anzahl]
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
def _pins_lesen() -> dict:
try:
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def festgehalten() -> list[dict]:
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
ergebnis = []
for baustein, eintrag in _pins_lesen().items():
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
continue
datum = str(eintrag.get("datum") or "")
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
"version": str(eintrag.get("version") or "?"), "seit": seit,
"grund": _lesbar(str(eintrag.get("grund") or ""))})
return ergebnis
def freigeben(baustein: str) -> bool:
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
daten = _pins_lesen()
if baustein not in daten:
return False
daten.pop(baustein)
tmp = PINS_FILE.with_suffix(".tmp")
try:
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, PINS_FILE)
except OSError:
return False
return True
+187
View File
@@ -0,0 +1,187 @@
"""
Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
Zwei Sichten auf dieselben Messungen:
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die
letzten N Messungen. Gut für Trends („Wie schnell ist STT im Schnitt?").
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) — jeder einzelne Chat-Turn als eigener Datensatz mit
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket.
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig.
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
"""
import threading
import time
import uuid
from collections import deque
_LOCK = threading.Lock()
_MAX = 200
_STAGES: dict[str, deque] = {}
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
def record_stage(stage: str, ms: float) -> None:
"""Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten."""
if ms is None or ms < 0:
return
with _LOCK:
dq = _STAGES.get(stage)
if dq is None:
dq = _STAGES[stage] = deque(maxlen=_MAX)
dq.append(float(ms))
def park(kind: str, ms: float) -> None:
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
if ms is None or ms < 0 or kind not in _PARKED:
return
with _LOCK:
_PARKED[kind] = (float(ms), time.perf_counter())
def _take_stt(max_age: float = 20.0) -> float | None:
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
with _LOCK:
v = _PARKED.get("stt")
if v and (time.perf_counter() - v[1]) <= max_age:
_PARKED["stt"] = None
return round(v[0], 1)
return None
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
Turns) und frisch ist."""
with _LOCK:
v = _PARKED.get("retrieve")
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
_PARKED["retrieve"] = None
return round(v[0], 1)
return None
class Timer:
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
def __init__(self, stage: str) -> None:
self.stage = stage
self._t0 = 0.0
def __enter__(self) -> "Timer":
self._t0 = time.perf_counter()
return self
def __exit__(self, *exc) -> None:
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
class TurnTrace:
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
def __init__(self, session_id: str = "", kind: str = "voice") -> None:
self.id = uuid.uuid4().hex[:8]
self.ts = time.time()
self.perf0 = time.perf_counter()
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
self.session_id = (session_id or "")[:24]
self.kind = kind
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT)
self.had_images = False
self.error: str | None = None
def note_vision(self, ms: float) -> None:
self.vision_ms = round(ms, 1)
record_stage("vision", ms)
def mark_brain_start(self) -> None:
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
self._brain0 = time.perf_counter()
def note_ttfb(self) -> None:
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
def note_first_content(self) -> None:
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
ms = (time.perf_counter() - self._brain0) * 1000.0
self.hirn_ms = round(ms, 1)
record_stage("chat_first_content", ms)
def commit(self) -> dict:
total = (time.perf_counter() - self.perf0) * 1000.0
stt = _take_stt() # rollend bereits in /voice/stt erfasst
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
rec = {
"id": self.id,
"ts": round(self.ts, 3),
"session": self.session_id,
"kind": self.kind,
"had_images": self.had_images,
"stt_ms": stt,
"vision_ms": self.vision_ms,
"hirn_ms": self.hirn_ms,
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
"mem0_ms": mem0,
"total_ms": round(total, 1),
"error": self.error,
}
with _LOCK:
_TURNS.append(rec)
return rec
def _summary(vals: list[float]) -> dict:
if not vals:
return {"count": 0}
s = sorted(vals)
n = len(s)
return {
"count": n,
"avg_ms": round(sum(s) / n, 1),
"p50_ms": round(s[n // 2], 1),
"p95_ms": round(s[min(n - 1, int(n * 0.95))], 1),
"last_ms": round(vals[-1], 1),
}
def get_metrics() -> dict:
"""Rollende Zusammenfassung je Stufe."""
with _LOCK:
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
def get_trace(limit: int = 20) -> list[dict]:
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
with _LOCK:
return list(_TURNS)[-limit:][::-1]
-799
View File
@@ -1,799 +0,0 @@
"""
Wächter der Box (Box-Wart, Umbau 09/2026) — löst den alten Health-Wächter (sentry.py) ab.
Der alte Wächter kannte nur sieben Dienste per HTTP. Dass projekte-sync seit dem 07.09.
stündlich scheiterte und der Nachrichten-Job jeden Morgen Werkzeugfehler warf, hat er nie
gesehen. Dieser Wächter schaut deshalb breiter hin:
• Dienste systemd-Zustand der Kern-Dienste (System + User)
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
• Platte Füllstand des Modell-Laufwerks
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
• Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb)
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein
bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
zusätzlich an Telegram und in Lucys Briefkasten.
Läuft im mc2-steward (eigener Prozess, übersteht MC2-Neustarts) und ist dort der EINZIGE
Schreiber von STORE_PATH. MC2 liest den Stand nur (lese_stand()) und führt Knopf-Aktionen
selbst aus (fuehre_aktion_aus()); der nächste Takt sieht das Ergebnis.
Während eines Updates (autoupdate.sh, update-engine.sh, update-swap.sh, hermes update)
hält er still: keine neuen Dienst-Hinweise, keine Selbstreparatur — Neustarts gehören dort
zum Ablauf.
"""
import asyncio
import json
import logging
import os
import re
import sqlite3
import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
import httpx
import psutil
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL
from kern import partner
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, llamaswap, maintenance, probe_wiederherstellung, update_verlauf
log = logging.getLogger(__name__)
ROLLE = einstellungen().rolle
DATEN_DIR = einstellungen().daten_dir
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
INTERVAL = int(os.environ.get("MC_WAECHTER_INTERVAL", "60")) # Sekunden zwischen Takten
START_DELAY = int(os.environ.get("MC_WAECHTER_START_DELAY", "60")) # Dienste nach Boot setzen lassen
FAIL_AFTER = int(os.environ.get("MC_WAECHTER_FAIL_AFTER", "2")) # Takte bis zum Hinweis
REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram-Erinnerung: 6 h
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(DATEN_DIR / "mc2-waechter.json")))
# „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist.
# Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos.
QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(DATEN_DIR / "mc2-quittiert.json")))
# Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update
# auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird.
PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5"))
VERLAUF_MAX = 200
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
GATEWAY_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
# Dienst → (System-Dienst?, wichtig?, Anzeigename). Wichtig = rot, sonst gelb.
DIENSTE: dict[str, tuple[bool, bool, str]] = {
"llama-swap": (True, True, "Motor (llama-swap)"),
"hermes-gateway": (False, True, "Hermes"),
"mc2-gateway": (False, True, "Modell-Gateway"),
"mission-control-2": (False, True, "MC2"),
"voice-service": (False, False, "Spracherkennung"),
"lucy-stimme": (False, False, "Lucys Stimme"),
"hermes-builtin-ui": (False, False, "Hermes-Dashboard"),
}
# Einmal-Dienste hinter Timern: Name → (Anzeigename, wichtig?)
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
"projekte-sync": ("Projekte-Abgleich", False),
"mc2-backup": ("Sicherung", True),
"mc2-radar": ("Modell-Radar", False),
# Rot: scheitert sie, erfährst du nichts von dem, was nachts passiert ist.
"mc2-morgenmeldung": ("Morgenmeldung", True),
# Seit 24.09.2026 ein eigener Timer statt Hermes-Cron (Hermes startet sich beim Update selbst neu).
"mc2-autoupdate": ("Updates am Sonntag", True),
# Seit 24.09.2026 im Repo (vom 21.08. an aus, weil sie rot lief und niemand es sah). Gelb: Die tägliche
# Sicherung (mc2-backup) bleibt die erste Schicht, der PBS ist die Kopie auf dem QNAP. Ausgeschaltet kein Befund.
"pbs-backup": ("Sicherung auf den PBS", False),
}
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
HERMES_EXEC_DB = HERMES_HOME / "cron" / "executions.db"
HERMES_ERRORS_LOG = HERMES_HOME / "logs" / "errors.log"
# Prozesse, an denen ein laufendes Update zu erkennen ist.
_UPDATE_PROZESSE = re.compile(r"autoupdate\.sh|update-engine\.sh|update-swap\.sh|hermes(\s+\S+)*\s+update\b")
@dataclass
class Befund:
"""Ein aktuell festgestelltes Problem. Wird nach FAIL_AFTER Takten zum Hinweis."""
id: str
stufe: str # "rot" | "gelb"
titel: str
text: str
quelle: str
aktionen: list[dict] = field(default_factory=list)
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
nach_takten: int = 0 # eigene Wartezeit statt FAIL_AFTER (0 = Standard)
def _aktion(aid: str, label: str, **extra: str) -> dict:
return {"id": aid, "label": label, **extra}
# --- Rohdaten -------------------------------------------------------------------
def _systemctl_show(name: str, system: bool) -> dict[str, str]:
"""Zustand einer Unit. Auf Windows (Dev) oder ohne systemd: leeres Dict (harmlos)."""
cmd = ["systemctl"] if system else ["systemctl", "--user"]
cmd += ["show", name, "-p",
"LoadState,ActiveState,SubState,Result,ExecMainStatus,InactiveExitTimestamp,UnitFileState"]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
except Exception:
return {}
return dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
def _journal_fehlerzeile(name: str, system: bool = False) -> str:
"""Die aussagekräftigste Fehlerzeile aus den letzten Journal-Zeilen einer Unit."""
cmd = ["journalctl"] + ([] if system else ["--user"]) + ["-u", name, "-n", "40", "-o", "cat", "--no-pager"]
try:
zeilen = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
except Exception:
return ""
return waehle_fehlerzeile(zeilen)
def waehle_fehlerzeile(zeilen: list[str]) -> str:
"""Die Zeile mit der eigentlichen Ursache. Zuerst die Meldungen des Skripts selbst
(projekte-sync markiert Fehler mit „!“), erst dann allgemeine Fehlerwörter — und nie die
systemd-Rahmenzeilen („Failed to start …“, „Main process exited …“): Die sagen nur,
DASS es scheiterte, nicht warum (so stand es im ersten Probelauf am 23.09.)."""
rahmen = re.compile(r"Failed to start|Main process exited|Failed with result|Consumed .* CPU time")
stufen = (
re.compile(r"(^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+)?\s*!|fehlgeschlagen|ABBRUCH)", re.IGNORECASE),
re.compile(r"(error|failed|fatal|traceback)", re.IGNORECASE),
)
for muster in stufen:
for zeile in reversed(zeilen):
if muster.search(zeile) and not rahmen.search(zeile):
# Zeitstempel "2026-09-23 21:02:46 " des Skript-Logs abschneiden
return re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+", "", zeile).strip()[:300]
return ""
def _reach(url: str, path: str, headers: dict[str, str] | None = None) -> bool:
"""Antwortet der Dienst? < 500 genügt — ein 401 beweist, dass jemand zuhört."""
try:
with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
except Exception:
return False
def _update_laeuft() -> bool:
"""Läuft gerade ein Update? Dann gehören Neustarts zum Ablauf und sind kein Befund."""
try:
for p in psutil.process_iter(["cmdline"]):
cmd = " ".join(p.info.get("cmdline") or [])
if cmd and _UPDATE_PROZESSE.search(cmd):
return True
except Exception:
return False
return False
# --- Prüfungen ------------------------------------------------------------------
def dienst_zustand(name: str, system: bool = False) -> dict[str, str]:
"""systemd-Zustand einer Unit für andere Module (Dienste-Liste der Oberfläche)."""
return _systemctl_show(name, system)
def schlaeft(zustand: dict[str, str]) -> bool:
"""Abgeschaltet und nicht mehr für den Autostart eingetragen = bewusst schlafen gelegt
(24.09.2026: Konsole, Spracherkennung). Das ist kein Fehler; geweckt wird per Knopf."""
return zustand.get("ActiveState") == "inactive" and zustand.get("UnitFileState") == "disabled"
def pruefe_dienste() -> list[Befund]:
befunde: list[Befund] = []
for name, (system, wichtig, anzeige) in DIENSTE.items():
z = _systemctl_show(name, system)
if not z or z.get("LoadState") in ("not-found", ""):
continue # nicht installiert oder kein systemd (Dev)
zustand = z.get("ActiveState", "")
if zustand in ("active", "activating", "reloading", "deactivating"):
continue
if schlaeft(z):
continue # bewusst schlafen gelegt (disable --now), z. B. die Spracherkennung bis zur App
stufe = "rot" if wichtig else "gelb"
aktionen = [_aktion("neustart", "Neu starten", dienst=name), _aktion("protokoll", "Protokoll", dienst=name)]
if zustand == "failed":
befunde.append(Befund(
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist abgestürzt",
text=_journal_fehlerzeile(name, system) or f"Die Unit {name} steht auf „failed“.",
quelle=name, aktionen=aktionen, auto="neustart"))
else: # inactive: vermutlich bewusst gestoppt → nur melden, nicht eigenmächtig starten
befunde.append(Befund(
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist gestoppt",
text=f"Die Unit {name} läuft nicht. Sie wurde vermutlich angehalten.",
quelle=name, aktionen=[_aktion("neustart", "Starten", dienst=name), aktionen[1]]))
return befunde
def pruefe_timer_dienste() -> list[Befund]:
befunde: list[Befund] = []
for name, (anzeige, wichtig) in TIMER_DIENSTE.items():
z = _systemctl_show(name, False)
if not z or z.get("LoadState") in ("not-found", ""):
continue
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
continue
# Der Dienst hinter einem Timer ist „static“ und schläft nie selbst — schlafen kann nur sein Timer
# (Einstellungen → Radar aus, seit 24.09.2026). Dann ist ein alter Fehlschlag kein Befund mehr.
if schlaeft(_systemctl_show(f"{name}.timer", False)):
continue
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
befunde.append(Befund(
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
titel=f"{anzeige} scheitert beim letzten Lauf", text=grund, quelle=name,
aktionen=[_aktion("protokoll", "Protokoll", dienst=name),
_aktion("neustart", "Jetzt erneut laufen lassen", dienst=name)],
sofort=True))
return befunde
def _hermes_jobs() -> list[dict]:
try:
daten = json.loads(HERMES_JOBS_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
jobs = daten.get("jobs", daten) if isinstance(daten, dict) else daten
if isinstance(jobs, dict):
jobs = list(jobs.values())
return [j for j in jobs if isinstance(j, dict)]
def _letzter_lauf(job_id: str) -> dict | None:
"""Letzter Lauf eines Jobs aus executions.db (nur lesend geöffnet)."""
if not HERMES_EXEC_DB.exists():
return None
try:
con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2)
try:
row = con.execute(
"select status, started_at, finished_at, error from executions "
"where job_id=? order by started_at desc limit 1", (job_id,)).fetchone()
finally:
con.close()
except sqlite3.Error:
return None
if not row:
return None
return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]}
def _gelesene_seite(zeilen: list[str], i: int) -> bool:
"""Hermes hängt an jedes web_extract-Ergebnis ein leeres "error"-Feld und hält ein Ergebnis für
gescheitert, sobald '"error"' in seinen ersten 500 Zeichen steht — bei kurzen Seiten also auch
Erfolge (agent/display.py, 24.09. gesehen). Ein mehrzeiliges Ergebnis mit "results" ist eine
gelesene Seite, kein Werkzeugfehler; echte Fehler kommen als {"success": false, "error": …}."""
return (zeilen[i].rstrip().endswith("{") and i + 1 < len(zeilen)
and zeilen[i + 1].lstrip().startswith('"results"'))
def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]:
"""Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede
Zeile mit [cron_<job>_<JJJJMMTT>_<HHMMSS>]; der Tag des Laufstarts reicht zur Zuordnung."""
try:
tag = datetime.fromisoformat(start_iso).strftime("%Y%m%d")
except (TypeError, ValueError):
return 0, ""
marke = f"[cron_{job_id}_{tag}_"
treffer = [z for i, z in enumerate(zeilen)
if marke in z and "returned error" in z and not _gelesene_seite(zeilen, i)]
if not treffer:
return 0, ""
m = re.search(r"Tool (\S+) returned error[^:]*:\s*(.*)$", treffer[0])
beispiel = ""
if m:
beispiel = f"{m.group(1)}: {m.group(2)}"
if (fm := re.search(r'"error":\s*"([^"]+)', m.group(2))):
beispiel = f"{m.group(1)}: {fm.group(1)}"
return len(treffer), beispiel[:220]
def _errors_log_ende(max_bytes: int = 400_000) -> list[str]:
try:
with HERMES_ERRORS_LOG.open("rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - max_bytes))
return f.read().decode("utf-8", "replace").splitlines()
except OSError:
return []
def _quittiert() -> dict[str, str]:
try:
daten = json.loads(QUITTIERT_PATH.read_text(encoding="utf-8"))
return daten if isinstance(daten, dict) else {}
except (OSError, ValueError):
return {}
def quittieren(hinweis_id: str, lauf: str) -> None:
"""Hinweis bis zum nächsten Lauf ausblenden (atomar schreiben, alte Einträge begrenzen)."""
daten = _quittiert()
daten[hinweis_id] = lauf
daten = dict(list(daten.items())[-50:])
tmp = QUITTIERT_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, QUITTIERT_PATH)
def pruefe_hermes_jobs() -> list[Befund]:
befunde: list[Befund] = []
jobs = _hermes_jobs()
if not jobs:
return befunde
log_zeilen = _errors_log_ende()
jetzt = time.time()
for job in jobs:
if not job.get("enabled", True):
continue
jid, name = str(job.get("id", "")), str(job.get("name", "Job"))
wichtig = "update" in name.lower()
status = job.get("last_status")
if status not in (None, "ok", "success") or int(job.get("failure_streak") or 0) > 0:
befunde.append(Befund(
id=f"job:{jid}", stufe="rot" if wichtig else "gelb",
titel=f"Job „{name}“ ist fehlgeschlagen",
text=str(job.get("last_error") or "Der letzte Lauf endete mit einem Fehler.")[:300],
quelle=f"hermes-cron:{jid}",
aktionen=[_aktion("job-wiederholen", "Erneut ausführen", job=jid),
_aktion("protokoll", "Protokoll", job=jid)],
sofort=True))
continue
if job.get("no_agent"):
continue # Skript-Jobs haben keine Werkzeuge
lauf = _letzter_lauf(jid)
if not lauf or not lauf.get("started_at"):
continue
try:
alter = jetzt - datetime.fromisoformat(lauf["started_at"]).timestamp()
except ValueError:
continue
if alter > 26 * 3600:
continue # nur der aktuelle Lauf zählt
anzahl, beispiel = werkzeugfehler_im_lauf(log_zeilen, jid, lauf["started_at"])
befund_id = f"job:{jid}:werkzeug"
if anzahl and _quittiert().get(befund_id) != lauf["started_at"]:
befunde.append(Befund(
id=befund_id, stufe="gelb",
titel=f"Job „{name}“: {anzahl} Werkzeugfehler im letzten Lauf",
text=beispiel or "Einzelne Werkzeuge meldeten Fehler, der Lauf selbst kam durch.",
quelle=f"hermes-cron:{jid}",
aktionen=[_aktion("protokoll", "Protokoll", job=jid),
_aktion("ausblenden", "Ausblenden bis zum nächsten Lauf", lauf=lauf["started_at"])],
sofort=True))
return befunde
def _hermes_kopf() -> dict[str, str]:
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
# Lucys Hirn gezielt (seit 24.09.2026): Ein Ladevorgang ist kein Ausfall — aber nur bis zu dieser Frist. llama-swap
# bricht einen Start nach healthCheckTimeout (300 s) selbst ab; wer danach immer noch „lädt“, hängt oder startet
# immer wieder neu (dann wechseln sich „lädt“ und „fehlt“ ab, und ohne Frist käme nie ein Hinweis zustande).
HIRN_LADEN_MAX_S = int(os.environ.get("MC_WAECHTER_HIRN_LADEN_S", "600"))
_hirn: dict[str, float | None] = {"fehlt_seit": None, "geprueft": None}
def _hirn_befund(jetzt: float | None = None) -> Befund | None:
"""Ist Lucys Hirn (Rolle hermes) geladen? Bis 24.09.2026 galt es als bereit, sobald irgendein Modell lief —
ein abgestürztes Hirn neben einem geladenen Coder blieb so unbemerkt. Lädt es gerade, ist das kein Befund;
alles andere zählt wie jeder Befund erst nach FAIL_AFTER Takten."""
jetzt = time.monotonic() if jetzt is None else jetzt
zuletzt, _hirn["geprueft"] = _hirn["geprueft"], jetzt
if zuletzt is None or jetzt - zuletzt > 5 * 60:
_hirn["fehlt_seit"] = None # lange nicht geprüft (Update, Motor weg): die Frist beginnt neu
st = llamaswap.hirn_zustand()
name, zustand = st.get("modell"), st.get("zustand")
if zustand == "bereit":
_hirn["fehlt_seit"] = None
return None
if _hirn["fehlt_seit"] is None:
_hirn["fehlt_seit"] = jetzt
dauer = jetzt - _hirn["fehlt_seit"]
if zustand == "laedt" and dauer < HIRN_LADEN_MAX_S:
return None
if not name:
text = "Kein Modell trägt die Rolle „hermes“. Ohne sie hat Lucy kein Hirn; die Rolle vergibt die Modelle-Seite."
elif zustand == "laedt":
text = (f"{name} lädt seit über {int(dauer // 60)} Minuten und wird nicht fertig. Vermutlich startet es immer "
"wieder neu; das Protokoll des Motors sagt, warum.")
elif zustand == "unbekannt":
text = "Der Motor sagt nicht, welche Modelle laufen (llama-swap /running antwortet nicht)."
else:
text = f"{name} läuft nicht. Der Re-Warm-Wächter lädt es nach; das Protokoll des Motors sagt, warum es fehlt."
return Befund(id="kern:hirn", stufe="rot",
titel="Lucys Hirn lädt nicht fertig" if zustand == "laedt" else "Lucys Hirn ist nicht geladen",
text=text, quelle="hirn", aktionen=[_aktion("protokoll", "Protokoll des Motors", dienst="llama-swap")])
def pruefe_kern() -> list[Befund]:
"""HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)"""
proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok)
engine_ok = llamaswap.engine_reachable()
proben.append(("kern:engine", "Der Motor antwortet nicht",
"llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok))
hirn = _hirn_befund() if engine_ok else None
proben.append(("kern:hermes", "Hermes antwortet nicht",
"Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.",
_reach(HERMES_API_URL, "/v1/models", _hermes_kopf())))
# Die Spracherkennung schläft seit 24.09.2026 (bis zur Android-App) — dann ist Schweigen kein Fehler.
if not schlaeft(_systemctl_show("voice-service", False)):
proben.append(("kern:hoeren", "Die Spracherkennung antwortet nicht",
"Spracheingabe über Lucy-Desktop kann hängen.", _reach(VOICE_SERVICE_URL, "/health")))
if WATCH_MC2:
proben.append(("kern:mc2", "MC2 antwortet nicht",
"Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health")))
proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht",
"Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health")))
befunde = [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
for (i, t, x, ok) in proben if not ok]
return befunde + ([hirn] if hirn else [])
def pruefe_platte() -> list[Befund]:
try:
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent
except Exception:
return []
if pct >= DISK_ROT_PCT:
stufe = "rot"
elif pct >= DISK_GELB_PCT:
stufe = "gelb"
else:
return []
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
quelle="platte", sofort=True)]
def pruefe_probe_wiederherstellung() -> list[Befund]:
"""Monatliche Probe-Wiederherstellung (services/probe_wiederherstellung.py, seit 24.09.2026): gelb, wenn die
letzte Probe rot war oder älter als WARN_TAGE ist. Gab es noch keine, erst, wenn ihr Timer eingerichtet ist."""
einheit = probe_wiederherstellung.EINHEIT
aktionen = [_aktion("neustart", "Jetzt prüfen", dienst=einheit), _aktion("protokoll", "Protokoll", dienst=einheit)]
stand = probe_wiederherstellung.lese_stand()
if stand is None:
z = _systemctl_show(f"{einheit}.timer", False)
if not z or z.get("LoadState") in ("not-found", ""):
return []
return [Befund(id="probe:wiederherstellung", stufe="gelb",
titel="Die Sicherung wurde noch nie probeweise ausgepackt",
text=("Die Probe läuft am ersten Montag im Monat um 05:15. „Jetzt prüfen“ startet sie sofort; "
"sie braucht Sekunden und fasst nichts Lebendes an."),
quelle=einheit, aktionen=aktionen, sofort=True)]
datum = datetime.fromtimestamp(float(stand["zeit"]), LOCAL_TZ).strftime("%d.%m.%Y")
if stand.get("ergebnis") != "gruen":
fehler = [str(f) for f in stand.get("fehler") or []] or ["ohne Begründung"]
weitere = f" (und {len(fehler) - 1} weitere)" if len(fehler) > 1 else ""
return [Befund(id="probe:wiederherstellung", stufe="gelb",
titel="Die letzte Probe-Wiederherstellung war rot",
text=f"Probe vom {datum} ({stand.get('sicherung') or 'keine Sicherung'}): {fehler[0]}{weitere}"[:400],
quelle=einheit, aktionen=aktionen, sofort=True)]
tage = (time.time() - float(stand["zeit"])) / 86400
if tage > probe_wiederherstellung.WARN_TAGE:
return [Befund(id="probe:wiederherstellung", stufe="gelb",
titel=f"Die Sicherung wurde seit {int(tage)} Tagen nicht mehr probeweise ausgepackt",
text=(f"Die letzte Probe am {datum} war grün. Sie soll am ersten Montag im Monat laufen — "
f"ist {einheit}.timer eingeschaltet?"),
quelle=einheit, aktionen=aktionen, sofort=True)]
return []
def pruefe_festgehalten() -> list[Befund]:
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt
die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates."""
return [Befund(id=f"pin:{p['baustein']}", stufe="gelb",
titel=f"{p['name']} bekommt keine Updates mehr",
text=(f"Seit {p['seit']} auf {p['version']} festgehalten: {p['grund']}. "
"Der Sonntags-Lauf überspringt ihn, bis du ihn freigibst."),
quelle="updates", sofort=True,
aktionen=[_aktion("freigeben", "Freigeben", baustein=p["baustein"])])
for p in update_verlauf.festgehalten()]
def pruefe_partner() -> list[Befund]:
"""Die andere Instanz (User-Entscheid 24.09.2026): Box und Homelab prüfen sich gegenseitig —
fällt eine aus, meldet die andere es."""
stand = partner.status(frisch=True)
if not stand.get("eingerichtet") or stand.get("erreichbar"):
return []
grund = stand.get("fehler") or "nicht erreichbar"
if ROLLE == "homelab":
text = f"Die KI-Box ({stand['url']}) ist {grund}. Lucy, die Modelle und der Box-Wart sind so lange weg."
else:
text = (f"Der Homelab-Teil auf dem Proxmox-PC ({stand['url']}) ist {grund}. "
"Updates im Homelab lassen sich so lange nicht anstoßen.")
return [Befund(id="partner", stufe="rot", titel=f"{stand['name']} antwortet nicht", text=text,
quelle="partner", nach_takten=PARTNER_TAKTE)]
def pruefe_ausfuehrer() -> list[Befund]:
"""Homelab: Der Ausführer auf dem Proxmox-Host meldet sich alle zehn Minuten. Schweigt er, zeigt die
Übersicht nichts Neues mehr, und „Jetzt updaten“ bliebe liegen. Vor seinem ersten Bericht: kein Alarm."""
from services.homelab import inventar, kanal
if kanal.bericht() is None:
return []
zuletzt = kanal.zuletzt()
if zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S:
return []
return [Befund(id="ausfuehrer", stufe="rot", titel="Der Ausführer auf dem Proxmox-Host schweigt",
text="Seit über 30 Minuten kein Bericht. Läuft mc2-ausfuehrer.service auf dem Proxmox-Host?",
quelle="ausfuehrer")]
def pruefe_gaeste() -> list[Befund]:
"""Homelab: Läuft jeder freigegebene Gast, und antwortet seine Weboberfläche?"""
from services.homelab import inventar
return [Befund(id=f"gast:{e['id']}", stufe="rot", titel=f"{e['name']} antwortet nicht",
text=(f"Die Weboberfläche ({e['url']}) antwortet nicht." if e["laeuft"]
else "Der Gast läuft nicht."), quelle=e["id"])
for e in inventar.erreichbarkeit() if not e["ok"]]
GAST_PLATTE_GELB = 0.80
GAST_PLATTE_ROT = 0.90 # ext4 hält 5 % für root zurück: bei 94 % war AdGuard innen schon voll
def _gb_komma(n: float) -> str:
return f"{n / 1e9:.1f}".replace(".", ",")
def pruefe_gast_platten() -> list[Befund]:
"""Homelab: Läuft die Platte eines Containers voll? Am 24.09.2026 stand AdGuard bei 100 %: Das Abfrageprotokoll
schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es."""
from services.homelab import apps, kanal
daten = kanal.bericht()
befunde = []
for g in (daten or {}).get("bericht", {}).get("gaeste") or []:
platte = g.get("platte") or {}
gesamt, belegt = platte.get("gesamt"), platte.get("belegt") or 0
if not gesamt or belegt / gesamt < GAST_PLATTE_GELB:
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
name = app.name if app else str(g.get("name") or g["vmid"])
anteil = belegt / gesamt
befunde.append(Befund(
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb",
titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll",
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt (Container {g['vmid']}). Platz schaffen "
"oder in Proxmox die Platte vergrößern: Container → Ressourcen → Laufwerk → Größe ändern."),
quelle=f"ct-{g['vmid']}"))
return befunde
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
# den Ausführer auf dem Proxmox-Host und seine Gäste.
PRUEFUNGEN = {
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung),
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste, pruefe_gast_platten),
}[ROLLE]
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
_lock = threading.Lock()
_stand: dict = {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}
def _lade() -> None:
global _stand
try:
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
if isinstance(daten.get("hinweise"), dict):
_stand = {**_stand, **daten}
except (OSError, ValueError):
pass
def _speichere() -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(_stand, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
def _verlauf(art: str, hinweis_id: str, text: str) -> None:
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text})
del _stand["verlauf"][:-VERLAUF_MAX]
def _telegram(betreff: str, text: str) -> None:
if TROCKEN:
log.info("waechter (trocken): würde melden %s %s", betreff, text)
return
announce.add(text, subject=betreff, source="waechter")
announce.notify_telegram(betreff, text + " (Diese Meldung kam auch an Lucy.)")
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
versuche = [t for t in _stand["auto"].get(schluessel, []) if jetzt - t < 3600]
_stand["auto"][schluessel] = versuche
return len(versuche) < AUTO_MAX_PRO_STUNDE
def _selbst_beheben(b: Befund, jetzt: float) -> None:
if b.auto != "neustart" or not _auto_erlaubt(b.id, jetzt):
return
if TROCKEN:
_verlauf("auto", b.id, f"{b.titel}: würde automatisch neu starten (Trockenlauf)")
_stand["auto"][b.id].append(jetzt)
return
ergebnis = maintenance.restart_service(b.quelle)
_stand["auto"][b.id].append(jetzt)
ok = bool(ergebnis.get("ok", ergebnis.get("returncode", 1) == 0))
_verlauf("auto", b.id, f"{b.titel}: automatisch neu gestartet" + ("" if ok else " (ohne Erfolg)"))
log.warning("waechter: %s → Neustart von %s (%s)", b.id, b.quelle, "ok" if ok else "fehlgeschlagen")
def takt() -> None:
"""Ein Prüfdurchlauf: Befunde sammeln, Hinweise führen, Einfaches selbst beheben."""
jetzt = time.time()
update = _update_laeuft()
befunde: list[Befund] = []
for pruefung in PRUEFUNGEN:
if update and pruefung in (pruefe_dienste, pruefe_kern):
continue # während eines Updates sind Neustarts normal
try:
befunde += pruefung()
except Exception as exc:
# Bis 24.09.2026 stand das nur im Debug-Log: Die Prüfung war dann still aus, und das
# Cockpit blieb grün (z. B. wenn Hermes das Format seiner Job-Liste ändert).
log.warning("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
befunde.append(Befund(
id=f"pruefung:{pruefung.__name__}", stufe="gelb",
titel="Eine Prüfung des Wächters lief nicht",
text=f"{pruefung.__name__}: {exc.__class__.__name__}: {exc}"[:240],
quelle="mc2-steward"))
with _lock:
hinweise: dict = _stand["hinweise"]
kandidaten: dict = _stand["kandidaten"]
aktuell = {b.id for b in befunde}
for b in befunde:
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
k["takte"] += 1
if not update and b.auto:
_selbst_beheben(b, jetzt)
if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER)):
continue
h = hinweise.get(b.id)
neu = h is None
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0}
h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
hinweise[b.id] = h
if neu:
_verlauf("neu", b.id, b.titel)
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
vorsatz = "" if neu else "Immer noch: "
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
h["gemeldet"] = jetzt
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
# Kern-Hinweise stehen (sie wurden in diesem Takt gar nicht geprüft).
for hid in list(hinweise):
if hid in aktuell:
continue
if update and hid.startswith(("dienst:", "kern:")):
continue
h = hinweise.pop(hid)
_verlauf("erledigt", hid, h.get("titel", hid))
if h.get("stufe") == "rot" and h.get("gemeldet"):
_telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.")
for kid in list(kandidaten):
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
kandidaten.pop(kid)
_stand["stand"] = jetzt
_stand["update_laeuft"] = update
_speichere()
async def waechter_loop() -> None:
"""Endlos-Schleife im mc2-steward."""
_lade()
await asyncio.sleep(START_DELAY)
log.info("waechter: aktiv (Takt %ss, Hinweis nach %s Takten, Selbstreparatur max. %s/h)",
INTERVAL, FAIL_AFTER, AUTO_MAX_PRO_STUNDE)
while True:
try:
await asyncio.to_thread(takt)
except Exception:
log.warning("waechter: Takt fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
def lese_stand() -> dict:
"""Stand für die Oberfläche: Hinweise (rot zuerst, dann nach Beginn) + Verlauf."""
try:
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
hinweise = sorted((daten.get("hinweise") or {}).values(),
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0)))
for h in hinweise:
h.pop("gemeldet", None)
return {
"hinweise": hinweise,
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])),
"stand": daten.get("stand"),
"update_laeuft": bool(daten.get("update_laeuft")),
"aktiv": bool(daten),
}
def _job_protokoll(job_id: str) -> dict:
marke = f"[cron_{job_id}_"
zeilen = [z for z in _errors_log_ende() if marke in z][-60:]
return {"ok": True, "text": "\n".join(zeilen) or "Keine Fehlerzeilen zu diesem Job gefunden."}
def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
"""Knopf eines Hinweises ausführen. Nur Aktionen, die der Hinweis selbst anbietet."""
stand = lese_stand()
hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
if hinweis is None:
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."}
aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
if aktion is None:
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
if aktion_id == "neustart":
return maintenance.restart_service(aktion["dienst"])
if aktion_id == "protokoll":
if aktion.get("job"):
return _job_protokoll(aktion["job"])
return maintenance.logs(aktion["dienst"], lines=120)
if aktion_id == "freigeben":
if not update_verlauf.freigeben(aktion["baustein"]):
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
if aktion_id == "ausblenden":
quittieren(hinweis_id, str(aktion.get("lauf", "")))
return {"ok": True, "text": "Ausgeblendet. Der Hinweis verschwindet in spätestens einer Minute "
"und kommt nur wieder, wenn der nächste Lauf erneut Fehler hat."}
if aktion_id == "job-wiederholen":
try:
r = subprocess.run(["hermes", "cron", "run", aktion["job"]],
capture_output=True, text=True, timeout=30)
return {"ok": r.returncode == 0,
"text": (r.stdout or r.stderr).strip()[:500] or "Job läuft beim nächsten Takt."}
except Exception as e:
return {"ok": False, "detail": f"hermes cron run ging nicht: {e}"}
return {"ok": False, "detail": f"Unbekannte Aktion {aktion_id}."}
-99
View File
@@ -1,99 +0,0 @@
"""
Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes.
Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd-
Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen
Anfragen). Alles nur lesend.
"""
import json
import subprocess
from datetime import datetime, timedelta
from kern.zeit import LOCAL_TZ
from services import modell_nutzung, waechter
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar",
"mc2-morgenmeldung.timer": "Morgenmeldung", "mc2-autoupdate.timer": "Updates am Sonntag"}
def _parse_iso(wert) -> datetime | None:
"""ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten)."""
if not wert:
return None
try:
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
except ValueError:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)
def _timer() -> list[dict]:
"""systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer."""
try:
out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"],
capture_output=True, text=True, timeout=10).stdout
daten = json.loads(out or "[]")
except Exception:
return []
ergebnis = []
for t in daten if isinstance(daten, list) else []:
name = TIMER.get(t.get("unit", ""))
if not name:
continue
def _us(v):
try:
return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None
except (TypeError, ValueError):
return None
ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))})
return ergebnis
def _nerdquiz_nacht(nutzung: dict) -> dict | None:
"""Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h."""
stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"]
anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n)
if not anfragen:
return None
erste = next((s["stunde"] for s in stunden
if any("NerdQuiz" in n for n in s["je_absender"])), "03")
heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0)
return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn",
"status": "erledigt"}
def flugplan() -> dict:
"""Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert."""
jetzt = datetime.now(LOCAL_TZ)
heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)
gelaufen: list[dict] = []
geplant: list[dict] = []
for job in waechter._hermes_jobs():
if not job.get("enabled", True):
continue
name = str(job.get("name", "Job"))
letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at"))
if letzter and letzter >= heute_start:
ok = job.get("last_status") in (None, "ok", "success")
gelaufen.append({"zeit": letzter, "titel": name, "text": "",
"status": "erledigt" if ok else "fehler"})
if naechster and naechster > jetzt:
geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"})
for t in _timer():
if t["letzter"] and t["letzter"] >= heute_start:
gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"})
if t["naechster"] and t["naechster"] > jetzt:
geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"})
if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())):
gelaufen.append(nq)
def _aus(eintraege: list[dict]) -> list[dict]:
return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])]
grenze = jetzt + timedelta(days=8)
return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])}
+15 -15
View File
@@ -1,27 +1,24 @@
""" """
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2). MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
Bisher hingen Re-Warm-Wächter und Health-Wächter am Bisher hingen Re-Warm-Wächter, Health-Wächter (sentry) und Mem0-Auto-Dedupe am
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
Mini-Dienst (mc2-steward.service, Restart=always): Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den • warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
In-Process-Nudge aus llamaswap.write_config) In-Process-Nudge aus llamaswap.write_config)
• waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste, • sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
Timer, Hermes-Jobs, Kern, Platte → Hinweise + Selbstreparatur;
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
• memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar)
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md).
BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem
/api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im /api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im
Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser
Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP). Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP).
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED) Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED)
stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein
Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback. Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback.
""" """
@@ -31,8 +28,8 @@ import logging
import os import os
from config import CONFIG_PATH from config import CONFIG_PATH
from kern.einstellungen import einstellungen from services import memory as memory_svc
from services import waechter, warmer from services import sentry, warmer
logging.basicConfig( logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
@@ -62,14 +59,17 @@ async def config_watch() -> None:
async def main() -> None: async def main() -> None:
tasks: list[asyncio.Task] = [] tasks: list[asyncio.Task] = []
# Warm-Set und llama-swap gibt es nur auf der KI-Box; der Homelab-Teil hat nur den Wächter. if warmer.ENABLED:
if einstellungen().rolle == "box" and warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop())) tasks.append(asyncio.create_task(warmer.rewarm_loop()))
tasks.append(asyncio.create_task(config_watch())) tasks.append(asyncio.create_task(config_watch()))
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)", log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
warmer.INTERVAL, CONFIG_WATCH_S) warmer.INTERVAL, CONFIG_WATCH_S)
if waechter.ENABLED: if sentry.ENABLED:
tasks.append(asyncio.create_task(waechter.waechter_loop())) tasks.append(asyncio.create_task(sentry.sentry_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD)
if not tasks: if not tasks:
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.") log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
return return
-7
View File
@@ -1,7 +0,0 @@
"""Backend-Module importieren sich gegenseitig ohne Paketpräfix (from config import …) —
für die Tests liegt deshalb backend/ auf dem Suchpfad, genau wie beim Start der Dienste."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-215
View File
@@ -1,215 +0,0 @@
{
"zeit": "2026-09-24T16:26:04+00:00",
"host": {
"node": "pve",
"neustart_noetig": false,
"version": "9.2.11",
"updates": [
{
"paket": "libvirt-common",
"alt": "11.3.0-3+deb13u2",
"neu": "11.3.0-3+deb13u3",
"herkunft": "Debian"
},
{
"paket": "librados2",
"alt": "19.2.3-pve1",
"neu": "19.2.6-pve4",
"herkunft": "Proxmox"
},
{
"paket": "libperl5.40",
"alt": "5.40.1-6",
"neu": "5.40.1-6+deb13u1",
"herkunft": "Debian"
},
{
"paket": "pve-docs",
"alt": "9.2.4",
"neu": "9.2.12",
"herkunft": "Proxmox"
},
{
"paket": "pve-edk2-firmware-ovmf",
"alt": "4.2025.05-3",
"neu": "4.2026.08-1",
"herkunft": "Proxmox"
}
],
"kernel": "7.0.14-12-pve"
},
"gaeste": [
{
"vmid": 100,
"art": "lxc",
"name": "adguard",
"status": "running",
"etiketten": [
"adblock",
"community-script"
],
"erlaubt": true,
"uptime": 3366322,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.100",
"app": {
"kennung": "adguard",
"version": "0.107.79"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1761157469
}
},
{
"vmid": 101,
"art": "lxc",
"name": "npmplus",
"status": "running",
"etiketten": [
"community-script",
"nginx",
"proxy"
],
"erlaubt": true,
"uptime": 2868368,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "alpine",
"ip": "192.168.178.110",
"app": {
"kennung": "npmplus",
"version": "Image vom 2026-08-27"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1787398997
}
},
{
"vmid": 102,
"art": "lxc",
"name": "netbird",
"status": "running",
"etiketten": [
"community-script",
"network",
"vpn"
],
"erlaubt": true,
"uptime": 3366322,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.148",
"app": {
"kennung": "netbird",
"version": "0.77.1"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1787399037
}
},
{
"vmid": 103,
"art": "lxc",
"name": "pve-scripts-local",
"status": "running",
"etiketten": [
"community-script",
"pve-scripts-local"
],
"erlaubt": true,
"uptime": 1713361,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.149",
"app": {
"kennung": "pve-scripts-local",
"version": "untagged-80028680f0b29a379726"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1777589263
}
},
{
"vmid": 104,
"art": "lxc",
"name": "gitea",
"status": "running",
"etiketten": [
"community-script",
"git"
],
"erlaubt": true,
"uptime": 3366321,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.153",
"app": {
"kennung": "gitea",
"version": "1.27.2"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1781978337
}
},
{
"vmid": 105,
"art": "lxc",
"name": "proxmox-backup-server",
"status": "running",
"etiketten": [
"backup",
"community-script"
],
"erlaubt": true,
"uptime": 3366321,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": false,
"snapshot_grund": "Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich",
"ostype": "debian",
"ip": "192.168.178.156",
"app": {
"kennung": "proxmox-backup-server",
"version": "4.2.5-1"
},
"os_updates": {
"anzahl": 58,
"listen_stand": 1790207866
}
},
{
"vmid": 106,
"art": "qemu",
"name": "arcane",
"status": "running",
"etiketten": [],
"erlaubt": false,
"uptime": 1713735,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ip": "192.168.178.28"
}
]
}
-197
View File
@@ -1,197 +0,0 @@
"""Altreste neben dem Betrieb (24.09.2026): nur aus der festen Liste, nur was da ist, nur auf Knopfdruck — und die
Löschfunktion nimmt eine Kennung, nie einen Pfad. Befehle (systemctl, git, sudo) laufen über eine Schicht, die hier
ersetzt ist; kein Test fasst die Box an."""
import os
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers import boxwart
from services import aufraeumen
from services.aufraeumen import Altrest
class Befehle:
"""Ersatz für aufraeumen._befehl: schreibt mit, und Units laufen nur, wenn der Test es sagt."""
def __init__(self):
self.aufrufe: list[list[str]] = []
self.laufend: set[str] = set()
self.eingetragen: set[str] = set()
self.sudo_ok = True
def __call__(self, args: list[str]) -> tuple[bool, str]:
self.aufrufe.append(args)
if args[:3] == ["systemctl", "--user", "show"]:
unit = args[3]
return True, (f"ActiveState={'active' if unit in self.laufend else 'inactive'}\n"
f"UnitFileState={'enabled' if unit in self.eingetragen else 'disabled'}")
if args[0] == "sudo":
return self.sudo_ok, "" if self.sudo_ok else "sudo: a password is required"
return True, ""
@pytest.fixture
def heim(tmp_path, monkeypatch):
"""Ein Home-Ordner mit Resten, eine Liste darauf und daneben etwas, das bleiben muss."""
home = tmp_path / "home"
for pfad, groesse in (("zonos/modell.bin", 3000), ("zonos/sub/x.txt", 500), (".venv-a/lib/a.py", 1000),
("skript.sh", 50), ("wt/datei", 10), ("bleibt/wichtig.txt", 5)):
datei = home / pfad
datei.parent.mkdir(parents=True, exist_ok=True)
datei.write_bytes(b"x" * groesse)
liste = (
Altrest("zonos", "TTS-Test", ("~/zonos",), "Test."),
Altrest("venvs", "Alte venvs", ("~/.venv-a", "~/.venv-gibt-es-nicht"), "Alt."),
Altrest("skript", "Altes Skript", ("~/skript.sh",), "Tot."),
Altrest("fehlt", "Längst weg", ("~/gibt-es-nicht",), "Weg."),
Altrest("wt", "Arbeitskopie", ("~/wt",), "Git.", art="worktree"),
Altrest("mem", "mem0", ("~/.venv-a",), "Sidecar.", sperre=("mem0-service.service",)),
)
befehle = Befehle()
monkeypatch.setenv("MC_ALTRESTE_HOME", str(home))
monkeypatch.setattr(aufraeumen, "ALTRESTE", liste)
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", True)
monkeypatch.setattr(aufraeumen, "_befehl", befehle)
monkeypatch.setitem(aufraeumen._altreste_cache, "liste", None)
return home, befehle
def test_liste_zeigt_nur_was_da_ist(heim):
liste = aufraeumen.altreste(frisch=True)
assert [a["id"] for a in liste] == ["zonos", "venvs", "mem", "skript", "wt"] # größte zuerst
zonos = liste[0]
assert zonos["groesse_bytes"] == 3500 and zonos["groesse"] == "4 KB" and zonos["pfade"] == ["~/zonos"]
venvs = next(a for a in liste if a["id"] == "venvs")
assert venvs["pfade"] == ["~/.venv-a"] # nur, was da ist
assert next(a for a in liste if a["id"] == "skript")["groesse"] == "unter 1 KB"
def test_was_ein_dienst_braucht_wird_nicht_angeboten(heim):
_, befehle = heim
befehle.laufend.add("mem0-service.service")
assert "mem" not in {a["id"] for a in aufraeumen.altreste(frisch=True)}
ergebnis = aufraeumen.altrest_loeschen("mem")
assert not ergebnis["ok"] and "läuft gerade" in ergebnis["detail"]
befehle.laufend.clear()
befehle.eingetragen.add("mem0-service.service")
assert "Autostart" in aufraeumen.altrest_loeschen("mem")["detail"]
assert (heim[0] / ".venv-a").exists()
def test_loeschen_nur_ueber_die_kennung(heim):
home, _ = heim
for kennung in ("../../etc", "~/bleibt", str(home / "bleibt"), ""):
ergebnis = aufraeumen.altrest_loeschen(kennung)
assert not ergebnis["ok"] and "kennt die Liste nicht" in ergebnis["detail"]
ergebnis = aufraeumen.altrest_loeschen("zonos")
assert ergebnis == {"ok": True, "text": "TTS-Test gelöscht, 4 KB frei."}
assert not (home / "zonos").exists() and (home / "bleibt" / "wichtig.txt").exists()
assert "zonos" not in {a["id"] for a in aufraeumen.altreste()} # die Liste ist danach frisch
assert "schon weg" in aufraeumen.altrest_loeschen("zonos")["detail"]
def test_nacharbeit_je_art(heim):
home, befehle = heim
assert aufraeumen.altrest_loeschen("wt")["ok"] and not (home / "wt").exists()
assert ["git", "-C", str(aufraeumen.REPO), "worktree", "prune"] in befehle.aufrufe
assert aufraeumen.altrest_loeschen("skript")["ok"]
assert ["systemctl", "--user", "daemon-reload"] not in befehle.aufrufe # nur bei Units
def test_units_werden_neu_eingelesen(heim, monkeypatch):
home, befehle = heim
unit = home / ".config" / "systemd" / "user" / "alt.timer"
unit.parent.mkdir(parents=True)
unit.write_text("[Timer]\n", encoding="utf-8")
monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("units", "Alt-Units", ("~/.config/systemd/user/alt.timer",),
"Aus.", art="unit", sperre=("alt.timer",)),))
befehle.eingetragen.add("alt.timer")
assert not aufraeumen.altrest_loeschen("units")["ok"] and unit.exists()
befehle.eingetragen.clear()
assert aufraeumen.altrest_loeschen("units")["ok"] and not unit.exists()
assert befehle.aufrufe[-1] == ["systemctl", "--user", "daemon-reload"]
def test_verknuepfung_nimmt_nie_ihr_ziel_mit(heim, monkeypatch):
home, _ = heim
try:
os.symlink(home / "bleibt", home / "link", target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("Verknüpfungen gehen hier nicht (Windows ohne Rechte)")
monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("link", "Verknüpfung", ("~/link",), "Test."),))
assert aufraeumen.altrest_loeschen("link")["ok"]
assert not (home / "link").is_symlink() and (home / "bleibt" / "wichtig.txt").exists()
def test_root_ordner_mit_sudo_nur_wo_erlaubt(heim, monkeypatch):
home, befehle = heim
def verweigert(pfad, *args, **kwargs):
raise PermissionError(13, "Keine Berechtigung", str(pfad))
monkeypatch.setattr(aufraeumen.shutil, "rmtree", verweigert)
monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("rocm", "ROCm", ("~/zonos",), "Alt.", sudo=True),
Altrest("ohne", "Ohne sudo", ("~/.venv-a",), "Alt.")))
assert aufraeumen.altrest_loeschen("rocm")["ok"]
assert ["sudo", "-n", "rm", "-rf", "--", str(home / "zonos")] in befehle.aufrufe
ergebnis = aufraeumen.altrest_loeschen("ohne")
assert not ergebnis["ok"] and "Keine Berechtigung" in ergebnis["detail"]
assert sum(1 for a in befehle.aufrufe if a[0] == "sudo") == 1
befehle.sudo_ok = False
assert "sudo -n rm ging nicht" in aufraeumen.altrest_loeschen("rocm")["detail"]
def test_am_pc_wird_nichts_geloescht(heim, monkeypatch):
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", False)
assert not aufraeumen.altrest_loeschen("zonos")["ok"] and (heim[0] / "zonos").exists()
def test_schnittstelle_nimmt_nur_eine_kennung(heim):
app = FastAPI()
app.include_router(boxwart.router)
c = TestClient(app)
assert c.get("/api/aufraeumen/altreste").json()["altreste"][0]["id"] == "zonos"
assert c.post("/api/aufraeumen/altreste/loeschen", json={"pfad": "/"}).status_code == 422
r = c.post("/api/aufraeumen/altreste/loeschen", json={"id": "../bleibt"})
assert r.status_code == 409 and "kennt die Liste nicht" in r.json()["detail"]
assert c.post("/api/aufraeumen/altreste/loeschen", json={"id": "skript"}).json()["ok"] is True
# --- Die echte Liste ----------------------------------------------------------------------------------
GESCHUETZT = ("~", "~/.hermes", "~/.hermes/hermes-agent", "~/.hermes/hermes-agent/hermes_cli/web_dist",
"~/.hermes/config.yaml", "~/.hermes/.env", "~/.hermes/memories", "~/.hermes/skills",
"~/.hermes/kanban.db", "~/.hermes/scripts/news-melden.sh", "~/.hermes/scripts/sonntags-update.sh",
"~/.hermes/scripts/stack-radar.sh", "~/.hermes/plugins/mc2-web-lesen", "~/mission-control-v2",
"~/mission-control-v2/frontend/dist/index.html", "~/.config/systemd/user",
"~/.config/systemd/user/mc2-radar.timer", "~/.config/systemd/user/lucy-stimme.service",
"~/.lucy-stimme", "~/.voice", "~/.cache", "~/.opencode", "~/projekte", "~/.local/bin",
"/srv/models", "/opt", "/opt/llamacpp-vulkan", "/opt/llamacpp-vulkan.bak")
def test_feste_liste_ist_eng():
kennungen = [a.id for a in aufraeumen.ALTRESTE]
assert len(kennungen) == len(set(kennungen))
for a in aufraeumen.ALTRESTE:
assert a.art in ("ordner", "worktree", "unit") and a.hinweis and a.name
for pfad in a.pfade:
assert pfad.startswith("~/") or pfad == "/opt/llamacpp", pfad
assert not any(z in pfad for z in ("*", "?", "..", "//")) and not pfad.endswith("/"), pfad
for heilig in GESCHUETZT:
assert pfad != heilig and not heilig.startswith(pfad + "/"), (pfad, heilig)
assert a.sudo == (a.pfade == ("/opt/llamacpp",)) # sudo nur für den ROCm-Ordner unter /opt
def test_feste_liste_enthaelt_die_bekannten_reste():
alle = {p for a in aufraeumen.ALTRESTE for p in a.pfade}
for pfad in ("~/zonos2-test", "/opt/llamacpp", "~/.mem0", "~/.hermes/kanban", "~/.venv-audit",
"~/.hermes/worktrees/orch-graph-verknuepfer", "~/projekte/mc2-referenz",
"~/.hermes/plugins/mc2-memory", "~/.hermes/PINNED_VERSION",
"~/mission-control-v2/frontend/dist/avatar.vrm"):
assert pfad in alle, pfad
# Was noch jemand nutzt, steht nicht darin (Prüfung vom 24.09.2026).
assert not alle & {"~/.hermes/kanban.db", "~/.voice", "~/.cache", "~/.opencode",
"~/.hermes/scripts/ampel-waechter.sh", "~/.hermes/scripts/pc_path_lookup.sh"}
assert "~/.hermes/scripts/curator-idle-watchdog.sh" not in alle # steht in der crontab
-67
View File
@@ -1,67 +0,0 @@
"""Aufräumen der Modell-Platte: nur Ungenutztes, nur auf Knopfdruck, geteilte Dateien bleiben."""
from pathlib import Path
import pytest
from services import aufraeumen, llamaswap
@pytest.fixture
def platte(tmp_path, monkeypatch):
"""Nachbau der Box: Hirn + Bild-Zwilling teilen Gewichte, der Projektor liegt im Original-Ordner."""
for ordner, datei, groesse in (("Hirn-GGUF", "hirn.gguf", 3000), ("Original-GGUF", "mmproj.gguf", 500),
("Coder-GGUF", "coder.gguf", 2000), ("Alt-GGUF", "alt.gguf", 4000),
("Muse-GGUF", "muse.gguf", 1500), ("Reserve-GGUF", "reserve.gguf", 2500),
("radar", "log.txt", 10), (".cache", "x", 10)):
(tmp_path / ordner).mkdir()
(tmp_path / ordner / datei).write_bytes(b"x" * groesse)
m = str(tmp_path)
cfg = {"models": {
"hirn": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf", "aliases": ["hermes", "fast"]},
"hirn-bild": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf --mmproj {m}/Original-GGUF/mmproj.gguf",
"aliases": ["vision"]},
"coder": {"cmd": f"llama-server -m {m}/Coder-GGUF/coder.gguf", "aliases": ["coder"]},
"alt": {"cmd": f"llama-server -m {m}/Alt-GGUF/alt.gguf"},
"muse": {"cmd": f"llama-server -m {m}/Muse-GGUF/muse.gguf", "aliases": ["debugger"]},
}, "groups": {"brains": {"persistent": True, "members": ["hirn"]}}}
monkeypatch.setattr(aufraeumen, "MODELS_DIR", tmp_path)
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", True)
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
return tmp_path, cfg
def test_kandidaten_sind_rollenlose_eintraege_und_verwaiste_ordner(platte):
namen = {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}
assert namen == {("eintrag", "alt"), ("eintrag", "muse"), ("ordner", "Reserve-GGUF")}
# Der Original-Ordner ist KEIN Kandidat: der Bild-Zwilling nutzt seinen Projektor.
muse = next(k for k in aufraeumen.kandidaten() if k["name"] == "muse")
assert "debugger" in muse["hinweis"]
def test_eintrag_loeschen_nimmt_ihn_aus_der_config_und_seinen_ordner_mit(platte):
wurzel, cfg = platte
ergebnis = aufraeumen.loeschen("eintrag", "alt")
assert ergebnis["ok"] and "alt gelöscht" in ergebnis["text"]
assert "alt" not in cfg["models"] and not (wurzel / "Alt-GGUF").exists()
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists() and (wurzel / "Coder-GGUF").exists()
def test_nur_aktuelle_kandidaten_und_nie_systemordner(platte):
wurzel, _ = platte
assert not aufraeumen.loeschen("ordner", "Hirn-GGUF")["ok"] # wird genutzt
assert not aufraeumen.loeschen("ordner", "radar")["ok"] # Systemordner
assert not aufraeumen.loeschen("ordner", "../etwas")["ok"]
assert (wurzel / "Hirn-GGUF").exists() and (wurzel / "radar").exists()
assert aufraeumen.loeschen("ordner", "Reserve-GGUF")["ok"] and not (wurzel / "Reserve-GGUF").exists()
def test_delete_model_laesst_geteilte_gewichte_liegen(platte):
"""Das Löschen des Bild-Zwillings darf die Gewichte des Hirns nicht mitnehmen (24.09.)."""
wurzel, cfg = platte
assert llamaswap.delete_model("hirn-bild") is True
assert "hirn-bild" not in cfg["models"]
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists()
# Im Zweifel liegen lassen: der Ordner taucht danach als verwaist im Aufräumen auf.
assert Path(wurzel / "Original-GGUF" / "mmproj.gguf").exists()
assert ("ordner", "Original-GGUF") in {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}
-100
View File
@@ -1,100 +0,0 @@
"""Bild-Weiche v3 (24.09.2026): frische Bilder an den Bild-Zwilling, ältere als Beschreibung."""
import asyncio
import copy
from typing import ClassVar
from routers import gateway_proxy
from services.router_logic import bild_ziel, bilder_aufteilen, ist_coder_alias
BILD_A = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
BILD_B = {"type": "image_url", "image_url": {"url": "data:image/png;base64,BBBB"}}
def verlauf() -> dict:
"""Agenten-Ablauf: Bild A im ersten Schritt, danach Werkzeug-Aufruf, jetzt Bild B als Ergebnis."""
return {"model": "coder", "messages": [
{"role": "system", "content": "Du bist ein Coding-Agent."},
{"role": "user", "content": [BILD_A, {"type": "text", "text": "Was zeigt der Fehler?"}]},
{"role": "assistant", "content": "", "tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "screenshot", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "Bildschirmfoto folgt."},
{"role": "user", "content": [BILD_B, {"type": "text", "text": "Bildschirmfoto aus screenshot."}]},
]}
def test_frisch_ist_was_nach_der_letzten_antwort_kommt():
frisch, alt = bilder_aufteilen(verlauf())
assert [m["content"][i] for m, i in frisch] == [BILD_B]
assert [m["content"][i] for m, i in alt] == [BILD_A]
def test_ohne_antwort_sind_alle_bilder_frisch():
frisch, alt = bilder_aufteilen({"messages": [{"role": "user", "content": [BILD_A, BILD_B]}]})
assert len(frisch) == 2 and alt == []
def test_coder_ziele_gehen_an_den_coder_zwilling_alles_andere_an_vision():
assert ist_coder_alias("coder") and ist_coder_alias("heavy") and not ist_coder_alias("fast")
assert bild_ziel("coder", "vision", "coder-bild") == "coder-bild"
assert bild_ziel("heavy", "vision", "coder-bild") == "coder-bild"
assert bild_ziel("fast", "vision", "coder-bild") == "vision"
assert bild_ziel("coder", "vision", None) == "vision" # ohne Coder-Zwilling: Hirn-Zwilling
class _FalscherClient:
"""Ersetzt httpx.AsyncClient: zählt Beschreibungs-Anfragen und antwortet mit fester Beschreibung."""
anfragen: ClassVar[list] = []
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json):
_FalscherClient.anfragen.append(json)
class Antwort:
status_code = 200
text = ""
@staticmethod
def json():
return {"choices": [{"message": {"content": "Fehlermeldung: PORT 4711 belegt"}}]}
return Antwort()
def test_aeltere_bilder_werden_einmal_beschrieben_und_gemerkt(monkeypatch):
monkeypatch.setattr(gateway_proxy.httpx, "AsyncClient", _FalscherClient)
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
_FalscherClient.anfragen = []
body = verlauf()
_, alt = bilder_aufteilen(body)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
ersetzt = body["messages"][1]["content"][0]
assert ersetzt["type"] == "text" and "PORT 4711" in ersetzt["text"]
assert body["messages"][4]["content"][0] == BILD_B # das frische Bild bleibt ein Bild
anfrage = _FalscherClient.anfragen[0]
assert anfrage["model"] == "vision" and anfrage["chat_template_kwargs"] == {"enable_thinking": False}
# Nächster Schritt schickt denselben Verlauf erneut: aus dem Gedächtnis, keine zweite Anfrage.
nochmal = verlauf()
_, alt = bilder_aufteilen(nochmal)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
assert len(_FalscherClient.anfragen) == 1
def test_zu_viele_neue_bilder_bleiben_drin(monkeypatch):
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
monkeypatch.setattr(gateway_proxy, "_BILD_MAX", 1)
body = {"messages": [{"role": "user", "content": [BILD_A, BILD_B]}, {"role": "assistant", "content": "ok"},
{"role": "user", "content": "und jetzt?"}]}
vorher = copy.deepcopy(body)
_, alt = bilder_aufteilen(body)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is False
assert body == vorher
-120
View File
@@ -1,120 +0,0 @@
"""Units, die deploy.sh ausspielt (24.09.2026): Probe-Wiederherstellung und PBS-Sicherung.
Prüft die Listen in deploy.sh gegen die Dateien in deploy/, den Takt der Probe (nie sonntags) und das PBS-Skript mit
einer Attrappe statt proxmox-backup-client — ohne Box, ohne PBS."""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
DEPLOY_SH = (DEPLOY / "deploy.sh").read_text(encoding="utf-8")
BASH = shutil.which("bash")
def _liste(name: str) -> list[str]:
m = re.search(rf'^{name}="([^"]*)"', DEPLOY_SH, re.MULTILINE)
assert m, f"{name} fehlt in deploy.sh"
return m.group(1).split()
def test_jede_unit_aus_deploy_sh_liegt_im_repo():
units, aktiv = _liste("UNITS"), _liste("AKTIV")
assert [u for u in units if not (DEPLOY / u).is_file()] == []
assert set(aktiv) <= set(units)
def test_probe_und_pbs_timer_laufen_mit():
units, aktiv = _liste("UNITS"), _liste("AKTIV")
assert {"mc2-probe-wiederherstellung.service", "mc2-probe-wiederherstellung.timer",
"pbs-backup.service", "pbs-backup.timer"} <= set(units)
assert "mc2-probe-wiederherstellung.timer" in aktiv
assert "pbs-backup.timer" in aktiv # seit 24.09. wieder an (User-Ja), der Deploy hält ihn an
# Der Radar-Timer startet getrennt (nur mit Stempel, siehe Einstellungen); die Probe mit den übrigen Timern.
start = re.search(r"systemctl --user start (mc2-backup\.timer[^\n]*)", DEPLOY_SH)
assert start and "mc2-probe-wiederherstellung.timer" in start.group(1)
assert "pbs-backup.timer" in DEPLOY_SH[start.start():start.end() + 40]
def _timer(name: str) -> dict[str, str]:
werte = {}
for zeile in (DEPLOY / name).read_text(encoding="utf-8").splitlines():
if "=" in zeile and not zeile.startswith("#"):
k, v = zeile.split("=", 1)
werte[k.strip()] = v.strip()
return werte
def test_probe_monatlich_nie_sonntags_und_nicht_um_die_sicherung():
t = _timer("mc2-probe-wiederherstellung.timer")
assert t["OnCalendar"] == "Mon *-*-01..07 05:15:00" # erster Montag im Monat
assert t["Persistent"] == "true"
assert _timer("mc2-backup.timer")["OnCalendar"].endswith("03:30:00")
assert _timer("mc2-autoupdate.timer")["OnCalendar"].startswith("Sun ")
def test_units_und_skripte_haben_lf():
dateien = [*DEPLOY.glob("*.service"), *DEPLOY.glob("*.timer"), *DEPLOY.glob("*.sh")]
assert [p.name for p in dateien if b"\r" in p.read_bytes()] == []
# --- pbs-backup.sh mit Attrappe --------------------------------------------------------------------------
# Unter Windows kann „bash“ die WSL-Hülle sein (System32 oder WindowsApps), die Windows-Pfade nicht versteht.
pytestmark_bash = pytest.mark.skipif(
BASH is None or (sys.platform == "win32" and any(s in BASH.lower() for s in ("system32", "windowsapps"))),
reason="braucht eine bash (Git-Bash oder Linux)",
)
@pytest.fixture
def pbs(tmp_path):
(tmp_path / "hermes").mkdir()
(tmp_path / "lswap").mkdir()
env = tmp_path / "pbs.env"
env.write_text("PBS_REPOSITORY=aibox@pbs@192.0.2.1:8007:qnap\nPBS_PASSWORD=nur-ein-test\n", encoding="utf-8")
client = tmp_path / "client"
client.write_text('#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$STUB_ARGS"\n'
'printf "%s\\n" "${PBS_REPOSITORY:-}" > "$STUB_ENV"\n', encoding="utf-8", newline="\n")
client.chmod(0o755)
umgebung = {**os.environ, "MC_PBS_ENV": env.as_posix(), "MC_PBS_CLIENT": client.as_posix(),
"HERMES_HOME": (tmp_path / "hermes").as_posix(), "MC_PBS_LLAMASWAP_DIR": (tmp_path / "lswap").as_posix(),
"MC_PBS_VAULT_DIR": (tmp_path / "vault").as_posix(),
"STUB_ARGS": (tmp_path / "args.txt").as_posix(), "STUB_ENV": (tmp_path / "env.txt").as_posix()}
return tmp_path, umgebung
def _pbs(umgebung: dict) -> subprocess.CompletedProcess:
return subprocess.run([BASH, (DEPLOY / "pbs-backup.sh").as_posix()], env=umgebung,
capture_output=True, text=True, encoding="utf-8", timeout=60)
@pytestmark_bash
def test_pbs_sichert_ohne_mem0_und_ueberspringt_fehlende_sammlung(pbs):
tmp, umgebung = pbs
r = _pbs(umgebung)
assert r.returncode == 0, r.stdout + r.stderr
args = (tmp / "args.txt").read_text(encoding="utf-8").splitlines()
assert args[0] == "backup" and args[-2:] == ["--backup-id", "aibox"]
assert [a.split(":", 1)[0] for a in args[1:-2]] == ["hermes.pxar", "llamaswap.pxar"] # kein mem0.pxar mehr
assert "wird übersprungen" in r.stdout
assert (tmp / "env.txt").read_text(encoding="utf-8").strip() == "aibox@pbs@192.0.2.1:8007:qnap"
(tmp / "vault").mkdir()
assert _pbs(umgebung).returncode == 0
assert "vault.pxar" in (tmp / "args.txt").read_text(encoding="utf-8")
@pytestmark_bash
def test_pbs_bricht_ohne_zugang_oder_pflichtordner_ab(pbs):
tmp, umgebung = pbs
r = _pbs({**umgebung, "MC_PBS_ENV": (tmp / "fehlt.env").as_posix()})
assert r.returncode == 1 and "! Zugangsdatei fehlt" in r.stdout
r = _pbs({**umgebung, "HERMES_HOME": (tmp / "weg").as_posix()})
assert r.returncode == 1 and "! Ordner fehlt" in r.stdout
assert not (tmp / "args.txt").exists()
-494
View File
@@ -1,494 +0,0 @@
"""Einstellungen der Oberfläche (24.09.2026): Update-Zeitfenster, Radar-Schalter, Telegram-Test.
Kein Test setzt echte systemctl-Befehle ab: services/einstellungen spricht systemd nur über _systemctl, und das
ersetzt hier ein kleines Abbild von systemd — samt der Nachhol-Regel von Persistent=true, die die Box am 24.09.
mitten am Tag neu startete. Der Telegram-Test fährt das echte notify.sh, aber mit einem Ersatz für `hermes send`,
einer Tmp-Datei als Telegram-Zugang und einer lokalen Bot-API: Es geht nie eine echte Nachricht raus."""
import json
import os
import re
import shutil
import subprocess
import sys
import threading
from datetime import datetime, timedelta
from datetime import time as uhr
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern.zeit import LOCAL_TZ
from routers import einstellungen as einstellungen_router
from services import einstellungen, waechter
UPDATE, RADAR = "mc2-autoupdate.timer", "mc2-radar.timer"
class FakeSystemd:
"""Genug systemd für die Einstellungen: Zustände, Aufrufe — und die Nachhol-Regel eines Timers mit
Persistent=true. Beim Start gilt der Stempel als letzter Lauf; liegt ein Termin des geladenen Plans zwischen ihm
und jetzt, läuft der Dienst sofort. Beim daemon-reload rechnet ein laufender Timer mit seinem letzten Lauf im
Speicher und dem NEUEN Plan (so verhält sich systemd beim Deserialisieren) — hat sich sein Plan nicht geändert,
ist nichts nachzuholen, sonst wäre er schon gelaufen."""
def __init__(self, stempel: Path, dropin: Path, letzter_lauf: datetime):
self.stempel, self.dropin = stempel, dropin
self.aufrufe: list[tuple[str, ...]] = []
self.nachgeholt: list[str] = []
self.scheitert: set[str] = set() # Befehle, die beim nächsten Aufruf einmal scheitern
self.aktiv = {UPDATE: True, RADAR: True, "mc2-autoupdate.service": False}
self.eingetragen = {UPDATE: True, RADAR: True}
self.im_speicher = {UPDATE: letzter_lauf, RADAR: letzter_lauf}
self.geladen = {UPDATE: self._plan_aus_datei(UPDATE), RADAR: self._plan_aus_datei(RADAR)}
stempel.mkdir(parents=True, exist_ok=True)
for timer in (UPDATE, RADAR):
datei = stempel / f"stamp-{timer}"
datei.touch()
os.utime(datei, (letzter_lauf.timestamp(), letzter_lauf.timestamp()))
def _plan_aus_datei(self, timer: str) -> tuple[int | None, int, int]:
if timer == RADAR:
return None, 0, 30 # täglich 00:30
text = self.dropin.read_text(encoding="utf-8") if self.dropin.exists() else ""
m = re.search(r"^OnCalendar=(\w{3}) \*-\*-\* (\d\d):(\d\d):00$", text, re.MULTILINE)
if not m:
return 7, 4, 30 # Plan der Repo-Unit: So 04:30
return einstellungen.SYSTEMD_TAGE.index(m.group(1)) + 1, int(m.group(2)), int(m.group(3))
@staticmethod
def termin_dazwischen(plan: tuple[int | None, int, int], von: datetime, bis: datetime) -> bool:
wochentag, stunde, minute = plan
tag = von.date()
while tag <= bis.date():
termin = datetime.combine(tag, uhr(stunde, minute), LOCAL_TZ)
if (wochentag is None or termin.isoweekday() == wochentag) and von < termin <= bis:
return True
tag += timedelta(days=1)
return False
def __call__(self, *args: str) -> tuple[bool, str]:
self.aufrufe.append(args)
befehl, jetzt = args[0], datetime.now(LOCAL_TZ)
if befehl in self.scheitert:
self.scheitert.discard(befehl)
return False, f"{befehl} ging schief"
if befehl == "show":
unit = args[1]
eingetragen = self.eingetragen.get(unit)
datei = "static" if eingetragen is None else ("enabled" if eingetragen else "disabled")
return True, (f"LoadState=loaded\nActiveState={'active' if self.aktiv.get(unit) else 'inactive'}\n"
f"UnitFileState={datei}")
if befehl == "list-timers":
unit = args[-1]
naechster = int((jetzt + timedelta(days=1)).timestamp() * 1_000_000) if self.aktiv.get(unit) else None
return True, json.dumps([{"unit": unit, "next": naechster, "last": None}])
if befehl == "stop":
self.aktiv[args[1]] = False
elif befehl == "daemon-reload":
for timer in (UPDATE, RADAR):
neu = self._plan_aus_datei(timer)
if self.aktiv[timer] and neu != self.geladen[timer] \
and self.termin_dazwischen(neu, self.im_speicher[timer], jetzt):
self.nachgeholt.append(timer)
self.geladen[timer] = neu
elif befehl == "start":
timer = args[1]
if not self.aktiv[timer]:
datei = self.stempel / f"stamp-{timer}"
letzter = datetime.fromtimestamp(datei.stat().st_mtime, LOCAL_TZ) if datei.exists() else None
if letzter is not None and letzter < jetzt:
self.im_speicher[timer] = letzter
if self.termin_dazwischen(self.geladen[timer], letzter, jetzt):
self.nachgeholt.append(timer)
self.aktiv[timer] = True
elif befehl == "enable":
self.eingetragen[args[1]] = True
elif befehl == "disable":
self.eingetragen[args[-1]] = False
if "--now" in args:
self.aktiv[args[-1]] = False
return True, ""
def befehle(self, *arten: str) -> list[str]:
return [a[0] for a in self.aufrufe if a[0] in arten]
@pytest.fixture
def box(tmp_path, monkeypatch):
"""Einstellungsdatei, Units und Stempel in tmp; systemd ist das Abbild oben (letzter Lauf vor vier Tagen)."""
monkeypatch.setenv("MC_EINSTELLUNGEN", str(tmp_path / "mc2-einstellungen.json"))
monkeypatch.setenv("MC_SYSTEMD_USER_DIR", str(tmp_path / "units"))
monkeypatch.setenv("MC_TIMER_STEMPEL_DIR", str(tmp_path / "timers"))
monkeypatch.setattr(einstellungen, "AUF_DER_BOX", True)
fake = FakeSystemd(tmp_path / "timers", einstellungen.dropin(), datetime.now(LOCAL_TZ) - timedelta(days=4))
monkeypatch.setattr(einstellungen, "_systemctl", fake)
return fake
def _vorgestern() -> tuple[int, str]:
"""Ein Plan, dessen Termin vorgestern war — zwischen dem letzten Lauf (vor vier Tagen) und jetzt."""
d = datetime.now(LOCAL_TZ) - timedelta(days=2)
if (d.isoweekday(), d.strftime("%H:%M")) == (7, "04:30"): # wäre der alte Plan — dann nichts „Neues“
d -= timedelta(minutes=1)
return d.isoweekday(), d.strftime("%H:%M")
# --- Datei und Regeln ------------------------------------------------------------------------------
def test_ohne_datei_gilt_der_standard(box):
assert einstellungen.lesen() == {"update_fenster": {"tag": 7, "uhrzeit": "04:30"}, "radar": {"an": True}}
@pytest.mark.parametrize("inhalt", [
"kein json", "[]", '{"update_fenster": {"tag": 8, "uhrzeit": "04:30"}, "radar": {"an": "nein"}}',
'{"update_fenster": {"tag": true, "uhrzeit": "4:30"}}', '{"update_fenster": {"tag": 3, "uhrzeit": "24:00"}}',
])
def test_unsinn_in_der_datei_faellt_auf_den_standard(box, inhalt):
einstellungen.datei().write_text(inhalt, encoding="utf-8")
assert einstellungen.lesen() == {"update_fenster": {"tag": 7, "uhrzeit": "04:30"}, "radar": {"an": True}}
@pytest.mark.parametrize("tag, uhrzeit, text", [
(7, "04:30", "Sonntag 04:00–06:59"),
(6, "05:00", "Samstag 05:00–07:59"),
(6, "23:30", "Samstag 23:00 – Sonntag 01:59"),
(7, "22:00", "Sonntag 22:00 – Montag 00:59"),
(1, "00:00", "Montag 00:00–02:59"),
])
def test_neustart_fenster(tag, uhrzeit, text):
assert einstellungen.neustart_fenster(tag, uhrzeit) == text
def test_dropin_ersetzt_den_plan_statt_einen_zweiten_daneben():
text = einstellungen.dropin_text(6, "05:00")
assert "[Timer]\nOnCalendar=\nOnCalendar=Sat *-*-* 05:00:00\n" in text
@pytest.mark.parametrize("zustand", [
{"ActiveState": "inactive", "UnitFileState": "disabled"},
{"ActiveState": "inactive", "UnitFileState": "enabled"},
{"ActiveState": "active", "UnitFileState": "disabled"},
{"ActiveState": "failed", "UnitFileState": "disabled"},
{},
])
def test_schlaeft_wie_der_waechter(zustand):
assert einstellungen.schlaeft(zustand) == waechter.schlaeft(zustand)
# --- Update-Zeitfenster ------------------------------------------------------------------------------
def test_zeitfenster_holt_nicht_nach(box):
"""Die Falle vom 24.09.: Ein Plan, dessen Termin seit dem letzten Lauf schon vorbei ist, darf nicht sofort laufen."""
tag, uhrzeit = _vorgestern()
ergebnis = einstellungen.update_fenster_setzen(tag, uhrzeit)
assert ergebnis["ok"], ergebnis
assert box.nachgeholt == []
# Erst anhalten, dann neu einlesen, dann (mit Stempel) starten — sonst rechnet systemd mit dem alten Lauf.
assert box.befehle("stop", "daemon-reload", "start") == ["stop", "daemon-reload", "start"]
assert einstellungen.dropin().read_text(encoding="utf-8") == einstellungen.dropin_text(tag, uhrzeit)
assert einstellungen.lesen()["update_fenster"] == {"tag": tag, "uhrzeit": uhrzeit}
assert einstellungen.neustart_fenster(tag, uhrzeit) in ergebnis["text"]
def test_gegenprobe_das_abbild_erkennt_die_falle(box):
"""Ohne die Vorsicht holt das Abbild nach — sonst bewiese der Test oben nichts."""
tag, uhrzeit = _vorgestern()
einstellungen._dropin_schreiben(einstellungen.dropin_text(tag, uhrzeit))
box("daemon-reload") # Reload bei laufendem Timer
assert box.nachgeholt == [UPDATE]
def test_ohne_stempel_wuerde_der_timer_nachholen(box, monkeypatch):
monkeypatch.setattr(einstellungen, "_stempel_setzen", lambda timer: None)
tag, uhrzeit = _vorgestern()
assert einstellungen.update_fenster_setzen(tag, uhrzeit)["ok"]
assert box.nachgeholt == [UPDATE]
def test_zeitfenster_nicht_waehrend_eines_updates(box):
box.aktiv["mc2-autoupdate.service"] = True
ergebnis = einstellungen.update_fenster_setzen(6, "05:00")
assert not ergebnis["ok"] and "läuft das Update" in ergebnis["detail"]
assert box.befehle("stop", "daemon-reload", "start") == []
assert not einstellungen.dropin().exists()
@pytest.mark.parametrize("tag, uhrzeit", [(0, "04:30"), (8, "04:30"), (7, "4:30"), (7, "24:00"), (7, "04:60")])
def test_zeitfenster_prueft_die_eingabe(box, tag, uhrzeit):
ergebnis = einstellungen.update_fenster_setzen(tag, uhrzeit)
assert not ergebnis["ok"] and ergebnis["detail"]
assert box.aufrufe == []
def test_zeitfenster_rollt_bei_fehler_zurueck(box):
einstellungen._dropin_schreiben(einstellungen.dropin_text(6, "05:00"))
vorher = einstellungen.dropin().read_text(encoding="utf-8")
box.scheitert.add("daemon-reload")
ergebnis = einstellungen.update_fenster_setzen(3, "04:00")
assert not ergebnis["ok"] and "nicht übernommen" in ergebnis["detail"]
assert einstellungen.dropin().read_text(encoding="utf-8") == vorher
assert box.aktiv[UPDATE] is True # ohne Timer gäbe es keine Updates mehr
assert not einstellungen.datei().exists()
assert box.nachgeholt == []
def test_gestoppter_timer_bleibt_aus(box):
box.aktiv[UPDATE] = False
assert einstellungen.update_fenster_setzen(6, "05:00")["ok"]
assert box.befehle("stop", "start") == [] and box.aktiv[UPDATE] is False
assert einstellungen.stand()["update_fenster"]["timer_aktiv"] is False
def test_stand_zeigt_ob_der_timer_zum_wunsch_passt(box):
stand = einstellungen.stand()
assert stand["schaltbar"] is True
assert stand["update_fenster"]["wirksam"] is True and stand["update_fenster"]["tag_name"] == "Sonntag"
assert stand["update_fenster"]["naechster_lauf"]
# Wunsch in der Datei, aber kein Drop-in (etwa nach einem Neuaufbau): nicht wirksam.
einstellungen.datei().write_text('{"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}', encoding="utf-8")
assert einstellungen.stand()["update_fenster"]["wirksam"] is False
assert einstellungen.update_fenster_setzen(6, "05:00")["ok"]
assert einstellungen.stand()["update_fenster"]["wirksam"] is True
def test_am_pc_wird_nichts_geschaltet(box, monkeypatch):
monkeypatch.setattr(einstellungen, "AUF_DER_BOX", False)
assert not einstellungen.update_fenster_setzen(6, "05:00")["ok"]
assert not einstellungen.radar_schalten(False)["ok"]
assert box.befehle("stop", "start", "enable", "disable", "daemon-reload") == []
# --- Radar-Schalter -----------------------------------------------------------------------------------
def test_radar_aus_und_wieder_an_ohne_nachholen(box):
ergebnis = einstellungen.radar_schalten(False)
assert ergebnis["ok"] and "schläft" in ergebnis["text"]
assert ("disable", "--now", RADAR) in box.aufrufe
assert einstellungen.lesen()["radar"] == {"an": False}
radar = einstellungen.stand()["radar"]
assert radar["zustand"] == "schlaeft" and radar["wirksam"] is True and radar["naechster_lauf"] is None
ergebnis = einstellungen.radar_schalten(True)
assert ergebnis["ok"]
assert box.befehle("enable", "start") == ["enable", "start"]
assert box.nachgeholt == [] # der letzte Lauf war vor vier Tagen, 00:30 lag dazwischen
assert einstellungen.lesen()["radar"] == {"an": True}
assert einstellungen.stand()["radar"]["zustand"] == "an"
def test_radar_nur_gestoppt_ist_nicht_aus(box):
"""Gestoppt, aber noch im Autostart: nach einem Neustart der Box liefe das Radar wieder."""
einstellungen.datei().write_text('{"radar": {"an": false}}', encoding="utf-8")
box.aktiv[RADAR] = False
radar = einstellungen.stand()["radar"]
assert radar["zustand"] == "aus" and radar["wirksam"] is False
box.eingetragen[RADAR] = False
assert einstellungen.stand()["radar"]["wirksam"] is True
def test_radar_ohne_stempel_wuerde_nachholen(box, monkeypatch):
monkeypatch.setattr(einstellungen, "_stempel_setzen", lambda timer: None)
einstellungen.radar_schalten(False)
einstellungen.radar_schalten(True)
assert box.nachgeholt == [RADAR]
def test_radar_fehler_wird_nicht_gespeichert(box):
box.scheitert.add("disable")
ergebnis = einstellungen.radar_schalten(False)
assert not ergebnis["ok"] and "nicht ausgeschaltet" in ergebnis["detail"]
assert not einstellungen.datei().exists()
def test_waechter_laesst_den_schlafenden_radar_in_ruhe(monkeypatch):
"""Ein alter Fehlschlag des Radars ist kein Hinweis mehr, sobald sein Timer schläft (Radar aus)."""
zustaende = {
"mc2-radar": {"LoadState": "loaded", "ActiveState": "failed", "Result": "exit-code"},
"mc2-radar.timer": {"LoadState": "loaded", "ActiveState": "inactive", "UnitFileState": "disabled"},
}
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: zustaende.get(
name, {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success"}))
monkeypatch.setattr(waechter, "_journal_fehlerzeile", lambda name, system=False: "")
assert "timer:mc2-radar" not in {b.id for b in waechter.pruefe_timer_dienste()}
zustaende["mc2-radar.timer"] = {"LoadState": "loaded", "ActiveState": "active", "UnitFileState": "enabled"}
assert "timer:mc2-radar" in {b.id for b in waechter.pruefe_timer_dienste()}
# --- Schnittstellen -----------------------------------------------------------------------------------
def _client() -> TestClient:
app = FastAPI()
app.include_router(einstellungen_router.router)
app.include_router(einstellungen_router.homelab_router)
return TestClient(app)
def test_schnittstellen(box):
c = _client()
assert c.get("/api/einstellungen").json()["radar"]["an"] is True
r = c.post("/api/einstellungen/update-fenster", json={"tag": 9, "uhrzeit": "04:30"})
assert r.status_code == 409 and "Wochentag" in r.json()["detail"]
r = c.post("/api/einstellungen/update-fenster", json={"tag": 6, "uhrzeit": "05:00"})
assert r.status_code == 200 and r.json()["einstellungen"]["update_fenster"]["tag"] == 6
r = c.post("/api/einstellungen/radar", json={"an": False})
assert r.status_code == 200 and r.json()["einstellungen"]["radar"]["zustand"] == "schlaeft"
BACKEND = Path(__file__).resolve().parent.parent
@pytest.mark.parametrize("rolle, da, nicht_da", [
("box", "/api/einstellungen/telegram-test", "/api/homelab/einstellungen/telegram-test"),
("homelab", "/api/homelab/einstellungen/telegram-test", "/api/einstellungen"),
])
def test_jede_rolle_hat_ihren_test_knopf(tmp_path, rolle, da, nicht_da):
"""Der Homelab-Teil bekommt nur den Telegram-Test (und lädt dafür nichts von der KI-Box, siehe test_partner)."""
env = {**os.environ, "MC_ROLLE": rolle, "MC_MODELS_DIR": str(tmp_path), "MC_DATEN_DIR": str(tmp_path),
"MC_PARTNER_URL": ""}
lauf = subprocess.run([sys.executable, "-c", "import json, app; print(json.dumps(sorted(app.app.openapi()['paths'])))"],
cwd=BACKEND, env=env, capture_output=True, text=True, timeout=120)
assert lauf.returncode == 0, lauf.stderr[-2000:]
pfade = json.loads(lauf.stdout.strip().splitlines()[-1])
assert da in pfade and nicht_da not in pfade
# --- Telegram-Test ------------------------------------------------------------------------------------
BASH = shutil.which("bash")
braucht_bash = pytest.mark.skipif(
BASH is None or (sys.platform == "win32" and "system32" in BASH.lower()),
reason="braucht eine bash (Git-Bash oder Linux)",
)
@pytest.fixture
def melden(tmp_path, monkeypatch):
"""notify.sh mit einem Ersatz für `hermes send`, der nur mitschreibt, und ohne echten Telegram-Zugang."""
protokoll = tmp_path / "gesendet.txt"
stub = tmp_path / "hermes-stub"
stub.write_text(
"#!/usr/bin/env bash\n"
'printf "%s\\n" "$*" >> "$STUB_PROTOKOLL"\n'
'[ -n "${STUB_AUSGABE:-}" ] && echo "$STUB_AUSGABE"\n'
'exit "${STUB_EXIT:-0}"\n',
encoding="utf-8", newline="\n")
stub.chmod(0o755)
for name, wert in {"MC_NOTIFY_LOG": tmp_path / "notify.log", "MC_NIGHT_QUEUE": tmp_path / "night-queue.txt",
"MC_NOTIFY_HERMES": stub, "MC_TELEGRAM_ENV": tmp_path / "keine.env",
"STUB_PROTOKOLL": protokoll}.items():
monkeypatch.setenv(name, Path(wert).as_posix())
monkeypatch.setenv("MC_TELEGRAM_API", "http://127.0.0.1:9")
monkeypatch.setenv("MC_NOTIFY_STUNDE", "03") # mitten in der Nacht: -d muss trotzdem sofort senden
monkeypatch.delenv("STUB_EXIT", raising=False)
monkeypatch.delenv("STUB_AUSGABE", raising=False)
monkeypatch.setattr(einstellungen, "AUF_DER_BOX", True)
monkeypatch.setitem(einstellungen._letzter_test, "ts", 0.0)
return tmp_path, protokoll
@pytest.fixture
def bot_api():
"""Ein Ersatz für api.telegram.org, der mitschreibt, was ankommt."""
empfangen: list[dict] = []
antwort = {"code": 200}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
laenge = int(self.headers.get("Content-Length", 0))
felder = parse_qs(self.rfile.read(laenge).decode("utf-8"))
empfangen.append({"pfad": self.path, **{k: v[0] for k, v in felder.items()}})
self.send_response(antwort["code"])
self.end_headers()
self.wfile.write(b'{"ok": true}')
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", empfangen, antwort
server.shutdown()
@braucht_bash
def test_telegram_test_ueber_hermes_auch_nachts(melden):
tmp, protokoll = melden
ergebnis = einstellungen.telegram_test()
assert ergebnis["ok"] and ergebnis["gesendet"] and ergebnis["weg"] == "hermes", ergebnis
gesendet = protokoll.read_text(encoding="utf-8")
assert "send --to telegram --subject [Test]" in gesendet and f"Kennung {ergebnis['kennung']}" in gesendet
assert not (tmp / "night-queue.txt").exists()
assert "OK telegram: Testmeldung aus den Einstellungen" in (tmp / "notify.log").read_text(encoding="utf-8")
@braucht_bash
def test_telegram_test_ueber_den_zweitweg(melden, bot_api, monkeypatch):
tmp, _ = melden
url, empfangen, _ = bot_api
env = tmp / "telegram.env"
env.write_text("TELEGRAM_BOT_TOKEN=1:test\nTELEGRAM_HOME_CHANNEL=42\n", encoding="utf-8", newline="\n")
monkeypatch.setenv("MC_TELEGRAM_ENV", env.as_posix())
monkeypatch.setenv("MC_TELEGRAM_API", url)
monkeypatch.setenv("STUB_EXIT", "1") # wie im Homelab-Teil: kein Hermes
ergebnis = einstellungen.telegram_test()
assert ergebnis["gesendet"] and ergebnis["weg"] == "direkt", ergebnis
assert len(empfangen) == 1 and empfangen[0]["text"].startswith("[Test]\nTestmeldung aus den Einstellungen")
@braucht_bash
def test_telegram_test_nennt_den_grund(melden, bot_api, monkeypatch):
tmp, _ = melden
monkeypatch.setenv("STUB_EXIT", "1")
monkeypatch.setenv("STUB_AUSGABE", "Fehler: Telegram-Plattform nicht verbunden")
ergebnis = einstellungen.telegram_test()
assert not ergebnis["ok"] and not ergebnis["gesendet"]
assert ergebnis["grund"].startswith("Fehler: Telegram-Plattform nicht verbunden; Zweitweg: Zugangsdatei")
assert "fehlt oder ist nicht lesbar" in ergebnis["grund"] and ergebnis["detail"].startswith("Die Testmeldung ging")
# Telegram lehnt ab (etwa ein falsches Token): der Grund kommt aus curls Fehlercode.
url, _, antwort = bot_api
antwort["code"] = 401
env = tmp / "telegram.env"
env.write_text("TELEGRAM_BOT_TOKEN=1:falsch\nTELEGRAM_HOME_CHANNEL=42\n", encoding="utf-8", newline="\n")
monkeypatch.setenv("MC_TELEGRAM_ENV", env.as_posix())
monkeypatch.setenv("MC_TELEGRAM_API", url)
monkeypatch.setitem(einstellungen._letzter_test, "ts", 0.0)
assert "Zweitweg: Telegram lehnt ab" in einstellungen.telegram_test()["grund"]
@braucht_bash
def test_telegram_test_nicht_im_sekundentakt(melden):
assert einstellungen.telegram_test()["gesendet"]
zweiter = einstellungen.telegram_test()
assert not zweiter["ok"] and "kurz warten" in zweiter["detail"]
def test_telegram_test_am_pc(monkeypatch):
monkeypatch.setattr(einstellungen, "AUF_DER_BOX", False)
monkeypatch.setitem(einstellungen._letzter_test, "ts", 0.0)
ergebnis = einstellungen.telegram_test()
assert not ergebnis["ok"] and "nur auf der Box" in ergebnis["detail"]
def test_melde_log_lesen(tmp_path):
"""Eine mehrzeilige Ausgabe von `hermes send` landet mitten im FALLBACK-Eintrag (notify.sh)."""
text = "Testmeldung aus den Einstellungen (Box-Wart, 24.09. 21:00 Uhr, Kennung abc123). Kommt sie an …"
log = tmp_path / "notify.log"
log.write_text(
"2026-09-24 20:00:00 OK telegram: Frühere Meldung\n"
"2026-09-24 21:00:00 FALLBACK (telegram fehlgeschlagen: Traceback (most recent call last):\n"
" ConnectionError: gateway down; Zweitweg: Telegram antwortet nicht rechtzeitig): " + text + "\n"
"2026-09-24 21:00:05 OK telegram: Spätere Meldung\n", encoding="utf-8")
eintrag = einstellungen.log_eintrag(log, "Kennung abc123")
assert eintrag.startswith("2026-09-24 21:00:00 FALLBACK") and eintrag.endswith(text)
gedeutet = einstellungen.deute_eintrag(eintrag, text)
assert gedeutet == {"gesendet": False, "weg": None, "grund": "Traceback (most recent call last): ConnectionError: "
"gateway down; Zweitweg: Telegram antwortet nicht rechtzeitig"}
assert einstellungen.log_eintrag(log, "Kennung fehlt") is None
assert einstellungen.log_eintrag(tmp_path / "gibt-es-nicht.log", "x") is None
assert einstellungen.deute_eintrag("2026-09-24 21:00:00 OK telegram direkt: x", "x")["weg"] == "direkt"
assert einstellungen.deute_eintrag("2026-09-24 03:00:00 QUEUED für Morgen-Digest: x", "x")["gesendet"] is False
-23
View File
@@ -1,23 +0,0 @@
"""Herkunftsprüfung für Knöpfe (24.09.2026)."""
from services.herkunft import erlaubt
BOX = "192.168.178.151:9001"
def test_eigene_seite_und_skripte_duerfen():
assert erlaubt("POST", "/api/maintenance/os-update", "http://192.168.178.151:9001", BOX)
assert erlaubt("POST", "/api/alarm", None, BOX) # Lucy-Watchdog, curl
assert erlaubt("POST", "/api/voice/chat", "null", BOX) # Desktop-Lucy (Electron)
assert erlaubt("POST", "/api/voice/stt", "file://", BOX)
assert erlaubt("POST", "/api/system/restart", "http://localhost:5173", BOX) # Vite-Entwicklung
def test_fremde_webseite_darf_keine_knoepfe_druecken():
assert not erlaubt("POST", "/api/maintenance/update-all", "https://boese.example", BOX)
assert not erlaubt("DELETE", "/api/models/x", "http://192.168.178.99:8080", BOX)
def test_lesen_und_v1_bleiben_offen():
assert erlaubt("GET", "/api/start", "https://boese.example", BOX)
assert erlaubt("POST", "/v1/chat/completions", "http://localhost:3000", BOX)
@@ -1,67 +0,0 @@
"""Hermes-Plugin mc2-web-lesen (deploy/hermes-plugins): liest Seiten lokal für web_extract.
Das Plugin läuft in Hermes' eigener Umgebung. Hier wird die Hermes-Schnittstelle durch eine
Attrappe ersetzt und das Netz durch httpx.MockTransport — ohne Hermes, ohne Internet.
"""
import abc
import asyncio
import importlib.util
import sys
import types
from pathlib import Path
import httpx
import pytest
PLUGIN = Path(__file__).resolve().parents[2] / "deploy" / "hermes-plugins" / "mc2-web-lesen" / "__init__.py"
@pytest.fixture
def plugin(monkeypatch):
class WebSearchProvider(abc.ABC): # Attrappe der Hermes-Schnittstelle
@property
@abc.abstractmethod
def name(self) -> str: ...
agent = types.ModuleType("agent")
schnittstelle = types.ModuleType("agent.web_search_provider")
schnittstelle.WebSearchProvider = WebSearchProvider
monkeypatch.setitem(sys.modules, "agent", agent)
monkeypatch.setitem(sys.modules, "agent.web_search_provider", schnittstelle)
spec = importlib.util.spec_from_file_location("mc2_web_lesen", PLUGIN)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
SEITE = ("<html><head><title>Box-News</title><script>var x = 1;</script></head>"
"<body><article><h1>Neues Modell</h1><p>Die Box testet nachts selbst.</p></article></body></html>")
def _lies(plugin, antwort: httpx.Response, url: str = "https://example.org/a") -> dict:
async def lauf():
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: antwort)) as client:
return await plugin.lies(client, url)
return asyncio.run(lauf())
def test_liest_titel_und_haupttext(plugin):
ergebnis = _lies(plugin, httpx.Response(200, headers={"content-type": "text/html; charset=utf-8"}, text=SEITE))
assert ergebnis["url"] == "https://example.org/a" and ergebnis["title"] == "Box-News"
assert "testet nachts selbst" in ergebnis["content"]
assert "var x" not in ergebnis["content"] and "error" not in ergebnis
def test_fehler_werden_je_seite_gemeldet_statt_geworfen(plugin):
assert _lies(plugin, httpx.Response(404))["error"] == "HTTP 404"
pdf = _lies(plugin, httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"%PDF"))
assert "Kein Text-Inhalt (application/pdf)" in pdf["error"]
def test_anbieter_liest_nur_und_meldet_sich_an(plugin):
angemeldet = []
plugin.register(types.SimpleNamespace(register_web_search_provider=angemeldet.append))
anbieter = angemeldet[0]
assert anbieter.name == "mc2-lesen" and anbieter.is_available()
assert anbieter.supports_extract() and not anbieter.supports_search()
-33
View File
@@ -1,33 +0,0 @@
"""Tests für die Pfad-Umschreibung des Hermes-Dashboard-Proxys (Anmeldung seit Hermes v0.21)."""
from routers import hermes_ui
def test_umleitung_auf_login_bekommt_praefix():
assert hermes_ui.praefixiere_ort("/login?next=%2F") == "/hermes-ui/login?next=%2F"
assert hermes_ui.praefixiere_ort("/") == "/hermes-ui/"
def test_fremde_oder_schon_praefixierte_ziele_bleiben():
assert hermes_ui.praefixiere_ort("/hermes-ui/sessions") == "/hermes-ui/sessions"
assert hermes_ui.praefixiere_ort("https://example.org/x") == "https://example.org/x"
assert hermes_ui.praefixiere_ort("//evil.example/x") == "//evil.example/x"
def test_anmeldeseite_schickt_an_praefixierten_pfad():
html = (b'<html><head><title>Login</title></head><body>'
b'<form action="/auth/password-login" method="post"></form>'
b'<script>fetch("/auth/password-login",{method:"POST"});location.href="/login?x=1"</script>'
b'</body></html>')
neu = hermes_ui.praefixiere_html(html)
assert b'action="/hermes-ui/auth/password-login"' in neu
assert b'fetch("/hermes-ui/auth/password-login"' in neu
assert b'"/hermes-ui/login?x=1"' in neu
assert b'window.__HERMES_BASE_PATH__="/hermes-ui"' in neu
def test_andere_absolute_pfade_bleiben_unberuehrt():
html = b'<head></head><a href="/assets/x.js">x</a><a href="/authors">y</a>'
neu = hermes_ui.praefixiere_html(html)
assert b'href="/assets/x.js"' in neu
assert b'href="/authors"' in neu
-156
View File
@@ -1,156 +0,0 @@
"""Lucys Hirn gezielt prüfen (Wächter, 24.09.2026) und „Jetzt laden“ mit echtem Ergebnis.
Bis 24.09. galt das Hirn als bereit, sobald irgendein Modell lief, und POST /api/models/{id}/load meldete „ok“,
egal was die Engine sagte. llama-swap wird hier durch httpx.MockTransport ersetzt (Format von /running wie in
llama-swap v257: jedes Modell, das nicht „stopped“ ist, mit seinem Zustand)."""
import httpx
import pytest
from services import llamaswap, waechter
_ECHTER_CLIENT = httpx.Client
def _engine(monkeypatch, handler) -> None:
"""llama-swap durch eine Attrappe ersetzen (alle httpx.Client in services.llamaswap)."""
monkeypatch.setattr(llamaswap.httpx, "Client", lambda *a, **kw: _ECHTER_CLIENT(
transport=httpx.MockTransport(handler), timeout=kw.get("timeout")))
def _running(*eintraege) -> httpx.Response:
return httpx.Response(200, json={"running": list(eintraege)})
# --- Zustände aus /running ------------------------------------------------------------------------
def test_modell_zustaende_liest_objekte_und_alte_namenslisten(monkeypatch):
_engine(monkeypatch, lambda req: _running({"model": "Hirn", "state": "starting"},
{"model": "Coder", "state": "ready"}, "Alt"))
assert llamaswap.modell_zustaende() == {"Hirn": "starting", "Coder": "ready", "Alt": "ready"}
_engine(monkeypatch, lambda req: httpx.Response(500))
assert llamaswap.modell_zustaende() is None
@pytest.mark.parametrize("running, zustand", [
([{"model": "Hirn", "state": "ready"}], "bereit"),
([{"model": "Hirn", "state": "starting"}], "laedt"),
([{"model": "Hirn", "state": "stopping"}], "fehlt"),
([{"model": "Coder", "state": "ready"}], "fehlt"), # der alte Fehler: Coder läuft, Hirn ist weg
(None, "unbekannt"),
])
def test_hirn_zustand_prueft_gezielt_das_hirn(monkeypatch, running, zustand):
monkeypatch.setattr(llamaswap, "brain_model_name", lambda: "Hirn")
monkeypatch.setattr(llamaswap, "modell_zustaende",
lambda: None if running is None else {e["model"]: e["state"] for e in running})
assert llamaswap.hirn_zustand() == {"modell": "Hirn", "zustand": zustand}
# --- Wächter ------------------------------------------------------------------------------------------
@pytest.fixture
def hirn(monkeypatch):
"""Der Wächter sieht den Zustand, den der Test vorgibt; seine Frist beginnt frisch."""
zustand = {"modell": "Qwen3.6-35B-A3B", "zustand": "bereit"}
monkeypatch.setattr(waechter, "_hirn", {"fehlt_seit": None, "geprueft": None})
monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: dict(zustand))
return zustand
def test_abgestuerztes_hirn_neben_geladenem_coder_ist_ein_befund(monkeypatch, hirn):
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
monkeypatch.setattr(waechter.llamaswap, "get_running_models", lambda: ["Qwen3.8-27B"]) # Coder läuft
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: True)
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"ActiveState": "active"})
hirn["zustand"] = "fehlt"
befunde = {b.id: b for b in waechter.pruefe_kern()}
assert befunde["kern:hirn"].titel == "Lucys Hirn ist nicht geladen"
assert "Qwen3.6-35B-A3B läuft nicht" in befunde["kern:hirn"].text
assert befunde["kern:hirn"].aktionen == [{"id": "protokoll", "label": "Protokoll des Motors", "dienst": "llama-swap"}]
assert not befunde["kern:hirn"].sofort # zählt erst nach den üblichen Takten
def test_laden_ist_kein_ausfall_bis_zur_frist(monkeypatch, hirn):
monkeypatch.setattr(waechter, "HIRN_LADEN_MAX_S", 600)
hirn["zustand"] = "laedt"
assert waechter._hirn_befund(jetzt=1000.0) is None
assert waechter._hirn_befund(jetzt=1060.0) is None
# Startet es immer wieder neu, wechseln sich „fehlt“ und „lädt“ ab: „fehlt“ ist ein Befund, und nach der Frist
# ist es auch „lädt“ — sonst käme nie ein Hinweis zustande.
hirn["zustand"] = "fehlt"
assert waechter._hirn_befund(jetzt=1120.0).titel == "Lucys Hirn ist nicht geladen"
hirn["zustand"] = "laedt"
for jetzt in range(1180, 1600, 60): # Takt wie im Betrieb: jede Minute
assert waechter._hirn_befund(jetzt=float(jetzt)) is None
b = waechter._hirn_befund(jetzt=1600.0)
assert b.titel == "Lucys Hirn lädt nicht fertig" and "seit über 10 Minuten" in b.text
hirn["zustand"] = "bereit"
assert waechter._hirn_befund(jetzt=1660.0) is None
hirn["zustand"] = "laedt" # nach „bereit“ beginnt die Frist neu
assert waechter._hirn_befund(jetzt=1720.0) is None
def test_lange_pause_setzt_die_frist_zurueck(hirn):
"""Während eines Updates prüft der Wächter den Kern nicht. Danach darf das Hirn wieder in Ruhe laden."""
hirn["zustand"] = "laedt"
assert waechter._hirn_befund(jetzt=0.0) is None
assert waechter._hirn_befund(jetzt=5000.0) is None
def test_ohne_hirn_rolle_ist_es_ein_befund(hirn):
hirn.update(modell=None, zustand="fehlt")
assert "Kein Modell trägt die Rolle „hermes“" in waechter._hirn_befund(jetzt=0.0).text
# --- Jetzt laden -----------------------------------------------------------------------------------------
def _laden(monkeypatch, antwort, running=()) -> dict:
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/running":
return _running(*running)
if isinstance(antwort, Exception):
raise antwort
return antwort
_engine(monkeypatch, handler)
return llamaswap.lade_modell("Qwen3.8-27B", warte_s=5)
def test_laden_meldet_den_fehler_der_engine(monkeypatch):
ergebnis = _laden(monkeypatch, httpx.Response(502, text="upstream command exited prematurely but successfully"))
assert ergebnis["ok"] is False
assert ergebnis["detail"] == ("Qwen3.8-27B ließ sich nicht laden. Die Engine meldet (HTTP 502): "
"upstream command exited prematurely but successfully")
ergebnis = _laden(monkeypatch, httpx.Response(404, json={"error": {"message": "model not found"}}))
assert ergebnis["ok"] is False and ergebnis["detail"].endswith("(HTTP 404): model not found")
def test_laden_gelingt(monkeypatch):
assert _laden(monkeypatch, httpx.Response(200, json={"choices": []})) == {"ok": True, "text": "Qwen3.8-27B ist geladen."}
# Ein Modell, das nicht chattet (embed), antwortet mit Fehler — geladen ist es trotzdem.
ergebnis = _laden(monkeypatch, httpx.Response(501, text="no chat"), running=[{"model": "Qwen3.8-27B", "state": "ready"}])
assert ergebnis["ok"] is True
def test_laden_nach_der_wartezeit(monkeypatch):
zeitueber = httpx.ReadTimeout("zu langsam")
ergebnis = _laden(monkeypatch, zeitueber, running=[{"model": "Qwen3.8-27B", "state": "starting"}])
assert ergebnis["ok"] is True and ergebnis["laedt"] is True
ergebnis = _laden(monkeypatch, zeitueber)
assert ergebnis["ok"] is False and "nach 5 Sekunden nicht geantwortet" in ergebnis["detail"]
ergebnis = _laden(monkeypatch, httpx.ConnectError("refused"))
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectError)" in ergebnis["detail"]
# Unter Windows scheitert ein Verbindungsaufbau zu einem toten Port als Zeitüberschreitung — das ist
# „nicht erreichbar“, nicht „lädt noch“.
ergebnis = _laden(monkeypatch, httpx.ConnectTimeout("timed out"))
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectTimeout)" in ergebnis["detail"]
def test_route_reicht_das_ergebnis_durch(monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers import models
monkeypatch.setattr(models.llamaswap, "lade_modell", lambda mid: {"ok": False, "detail": f"{mid}: kaputt"})
app = FastAPI()
app.include_router(models.router)
antwort = TestClient(app).post("/api/models/Qwen3.8-27B/load")
assert antwort.status_code == 200 and antwort.json() == {"ok": False, "detail": "Qwen3.8-27B: kaputt"}
-385
View File
@@ -1,385 +0,0 @@
"""Homelab-Teil (Phase 3/4, 24.09.2026): Kanal zum Ausführer, Ziele aus dem echten Bericht des
Proxmox-Hosts (tests/fixtures/pve-bericht.json, gelesen am 24.09.), „Jetzt updaten“ mit Rückweg."""
import importlib.util
import json
import os
import threading
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import inventar, kanal, updates
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.delenv("MC_AUSFUEHRER_TOKEN", raising=False)
einstellungen_mod.einstellungen.cache_clear()
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
# Keine Netzabfragen: neueste Versionen und Weboberflächen sind fest.
neueste = {"AdguardTeam/AdGuardHome": "0.107.79", "go-gitea/gitea": "1.27.3", "netbirdio/netbird": "0.79.0",
"community-scripts/ProxmoxVE-Local": "1.2.1"}
roh = {"community-scripts/ProxmoxVE-Local": "untagged-80028680f0b29a379726"}
monkeypatch.setattr(inventar, "neueste_version", lambda repo: {
"tag": neueste.get(repo, "2026-07-24-r1"), "tag_roh": roh.get(repo, neueste.get(repo, "2026-07-24-r1")),
"version": neueste.get(repo, "2026-07-24-r1"), "datum": "2026-07-24"})
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
monkeypatch.setattr(inventar.karenz, "skript_geaendert", lambda kennung: 0.0) # Update-Skripte alt, kein Netz
monkeypatch.setattr(inventar.arcane, "app_version",
lambda url: {"installiert": "2.12.0", "neu": "2.13.1", "update": True})
monkeypatch.delenv("MC_ARCANE_KEY", raising=False)
monkeypatch.delenv("MC_ARCANE_ECHT", raising=False)
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
# --- Kanal ---------------------------------------------------------------------------------
def test_token_wird_einmal_erzeugt_und_geprueft(daten):
wert = kanal.token()
assert len(wert) > 30 and kanal.token() == wert
assert kanal.token_gueltig(wert) and not kanal.token_gueltig("falsch") and not kanal.token_gueltig(None)
if os.name == "posix":
assert (daten / "ausfuehrer.token").stat().st_mode & 0o077 == 0
def test_auftraege_der_reihe_nach_und_verloren(daten, monkeypatch):
erster = kanal.anlegen("bericht")
zweiter = kanal.anlegen("suchen", {"vmid": 104})
with pytest.raises(ValueError):
kanal.anlegen("rm -rf")
assert kanal.naechster()["id"] == erster
assert kanal.ergebnis(erster, 0, "ok") and not kanal.ergebnis(erster, 0, "doppelt")
assert kanal.naechster() == {"id": zweiter, "aktion": "suchen", "parameter": {"vmid": 104}}
monkeypatch.setattr(kanal, "VERLOREN_S", -1)
assert kanal.naechster() is None
assert kanal.auftrag(zweiter)["status"] == "verloren"
def _client() -> TestClient:
from routers import homelab
app = FastAPI()
app.include_router(homelab.router)
return TestClient(app)
def test_nur_der_ausfuehrer_darf_in_den_kanal(daten):
c = _client()
assert c.get("/api/homelab/ausfuehrer/auftrag").status_code == 403
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT,
headers={"X-MC2-Ausfuehrer": "falsch"}).status_code == 403
kopf = {"X-MC2-Ausfuehrer": kanal.token()}
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT, headers=kopf).json() == {"ok": True}
assert c.get("/api/homelab/ausfuehrer/auftrag", headers=kopf).json() == {}
assert c.get("/api/homelab/ausfuehrer").json()["verbunden"] is True
# --- Ziele aus dem echten Bericht ---------------------------------------------------------------
def _ziele(daten) -> dict[str, dict]:
kanal.bericht_speichern(BERICHT)
return {z["id"]: z for z in inventar.ziele()["ziele"]}
def test_ziele_aus_dem_echten_bericht(daten):
z = _ziele(daten)
assert list(z) == ["pve", "ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"]
host = {b["id"]: b for b in z["pve"]["bausteine"]}
assert host["pakete"]["zustand"] == "neu" and host["pakete"]["aktion"]["pfad"] == "/api/homelab/ziele/pve/update"
gitea = {b["id"]: b for b in z["ct-104"]["bausteine"]}
assert (gitea["app"]["zustand"], gitea["app"]["kurz"]) == ("neu", "1.27.2 → 1.27.3")
assert z["ct-104"]["name"] == "Gitea" and z["ct-104"]["rueckweg"] == "Snapshot vor jedem Update"
assert (z["ct-104"]["rueckweg_art"], z["pve"]["rueckweg_art"]) == ("snapshot", "keiner")
adguard = {b["id"]: b for b in z["ct-100"]["bausteine"]}
assert adguard["app"]["zustand"] == "aktuell"
# Paketlisten vom Oktober 2025: ehrlich „unbekannt“ mit Knopf zum Suchen statt „aktuell“.
assert adguard["os"]["zustand"] == "unbekannt" and adguard["os"]["aktion"]["label"] == "Nach Updates suchen"
pbs = {b["id"]: b for b in z["ct-105"]["bausteine"]}
assert (pbs["os"]["zustand"], pbs["os"]["kurz"]) == ("neu", "58 Pakete")
assert "Bind-Mount" in z["ct-105"]["rueckweg"]
# PVE Scripts Local steht auf der neuesten (untagged) Veröffentlichung: aktuell, angezeigt als 1.2.1.
pvesl = {b["id"]: b for b in z["ct-103"]["bausteine"]}["app"]
assert (pvesl["zustand"], pvesl["installiert"], pvesl["aktion"]) == ("aktuell", "1.2.1", None)
assert {b["id"]: b for b in z["ct-101"]["bausteine"]}["app"]["zustand"] == "aktuell" # Image jünger
# NetBird hat keine Weboberfläche: erreichbar heißt hier „läuft“.
assert z["ct-102"]["erreichbar"] is True
arcane = {b["id"]: b for b in z["vm-106"]["bausteine"]}
assert z["vm-106"]["name"] == "Arcane (Docker)"
# Arcane läuft über seine eigene Schnittstelle: ohne Schlüssel sagt die Übersicht das ehrlich.
assert (arcane["arcane"]["zustand"], arcane["arcane"]["kurz"]) == ("neu", "2.12.0 → 2.13.1")
assert arcane["docker"]["zustand"] == "unbekannt" and "MC_ARCANE_KEY" in arcane["docker"]["grund"]
def test_ohne_bericht_leer_und_ehrlich(daten):
stand = inventar.ziele()
assert stand["ziele"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False
def test_wachter_sieht_stumme_gaeste(daten, monkeypatch):
from services import waechter
kanal.bericht_speichern(BERICHT)
monkeypatch.setattr(inventar, "erreichbar", lambda url: "153" not in url)
befunde = waechter.pruefe_gaeste()
assert [b.id for b in befunde] == ["gast:ct-104"] and befunde[0].titel == "Gitea antwortet nicht"
assert waechter.pruefe_ausfuehrer() == [] # Bericht ist frisch
# --- „Jetzt updaten“ mit einem nachgespielten Ausführer ----------------------------------------
def _ausfuehrer_spielen(stopp: threading.Event, neue_version: str, protokoll: list[str]) -> None:
while not stopp.is_set():
auftrag = kanal.naechster()
if not auftrag:
time.sleep(0.02)
continue
protokoll.append(auftrag["aktion"])
text = ""
if auftrag["aktion"] == "snapshot":
text = "snapshot=mc2-20260924-190000"
elif auftrag["aktion"] == "bericht":
neu = json.loads(json.dumps(BERICHT))
next(g for g in neu["gaeste"] if g["vmid"] == 104)["app"]["version"] = neue_version
text = json.dumps(neu)
kanal.ergebnis(auftrag["id"], 0, text)
@pytest.fixture
def ausfuehrer(daten, monkeypatch):
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
monkeypatch.setattr(kanal, "warten", _schnell_warten)
meldungen: list[tuple[str, bool]] = []
monkeypatch.setattr(updates, "_melden", lambda text, dringend=False: meldungen.append((text, dringend)))
verlauf: list[tuple] = []
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
kanal.bericht_speichern(BERICHT)
stopp, protokoll = threading.Event(), []
def starten(neue_version: str) -> None:
threading.Thread(target=_ausfuehrer_spielen, args=(stopp, neue_version, protokoll), daemon=True).start()
yield starten, protokoll, meldungen, verlauf
stopp.set()
_echtes_warten = kanal.warten
def _schnell_warten(auftrag_id, zeitlimit_s, takt_s=2.0):
return _echtes_warten(auftrag_id, min(zeitlimit_s, 10), 0.02)
def _auf_ende(lauf_id: str) -> dict:
ende = time.time() + 20
while time.time() < ende:
lauf = next((x for x in updates.laeufe() if x["id"] == lauf_id), {})
if lauf.get("status") == "fertig":
return lauf
time.sleep(0.05)
raise AssertionError(f"Lauf {lauf_id} wurde nicht fertig")
def test_update_gruen(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, verlauf = ausfuehrer
starten("1.27.3")
antwort = updates.starten("ct-104", "app")
assert antwort["ok"]
assert updates.starten("ct-104", "app")["detail"] == "Für dieses Gerät läuft schon ein Update."
lauf = _auf_ende(antwort["lauf"])
assert lauf["ergebnis"] == "eingespielt" and protokoll == ["snapshot", "update", "bericht"]
assert meldungen == [("Gitea: eingespielt, Prüfung grün.", False)]
assert verlauf[0][2:4] == ("ct-104", "eingespielt")
def test_update_rot_geht_zurueck_und_meldet_dringend(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, _ = ausfuehrer
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
starten("1.27.3")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt"
assert protokoll == ["snapshot", "update", "bericht", "zurueck"]
text, dringend = meldungen[0]
assert dringend and "zurück auf den Snapshot mc2-20260924-190000" in text and "antwortet nicht" in text
def test_update_ohne_neue_version_ist_rot(ausfuehrer):
starten, _, _, _ = ausfuehrer
starten("1.27.2")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt" and "unverändert" in lauf["text"]
def test_nicht_freigegeben_und_unbekannt(ausfuehrer):
assert updates.starten("vm-106", "app")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
assert updates.starten("ct-999", "app")["ok"] is False
assert updates.starten("ct-104", "boese")["ok"] is False
# --- Der Ausführer selbst (läuft auf dem Proxmox-Host) -------------------------------------------
def _ausfuehrer_modul():
spec = importlib.util.spec_from_file_location("ausfuehrer", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_ausfuehrer_entscheidet_selbst_ueber_etiketten():
a = _ausfuehrer_modul()
assert a.erlaubt(["community-script", "git"]) and a.erlaubt(["watcher"])
assert not a.erlaubt(["community-script", "watcher-aus"]) and not a.erlaubt([])
def test_ausfuehrer_snapshot_faehigkeit():
a = _ausfuehrer_modul()
arten = {"local-lvm": "lvmthin", "local": "dir"}
assert a.snapshot_moeglich({"rootfs": "local-lvm:vm-104-disk-0,size=8G"}, arten) == (True, None)
ok, grund = a.snapshot_moeglich({"rootfs": "local-lvm:vm-105-disk-0", "mp0": "/mnt/qnap,mp=/mnt/ds"}, arten)
assert not ok and "Bind-Mount" in grund
assert a.snapshot_moeglich({"rootfs": "local:105/vm-105-disk-0.raw"}, arten)[0] is False
def test_ausfuehrer_lehnt_fremdes_ab(monkeypatch):
a = _ausfuehrer_modul()
monkeypatch.setattr(a, "_pvesh", lambda pfad, *p: [
{"vmid": 104, "type": "lxc", "tags": "community-script;git"},
{"vmid": 106, "type": "qemu", "tags": ""}])
ausgefuehrt: list[list[str]] = []
monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60: ausgefuehrt.append(befehl) or (0, "ok"))
assert a.ausfuehren({"aktion": "rm", "parameter": {}})["code"] == 2
assert "kein erlaubtes Etikett" in a.ausfuehren({"aktion": "update", "parameter": {"vmid": 106}})["text"]
assert a.ausfuehren({"aktion": "zurueck", "parameter": {"vmid": 104, "snapshot": "vorher"}})["code"] == 2
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": "104; reboot"}})["code"] == 2
assert ausgefuehrt == []
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": 104}})["code"] == 0
assert ausgefuehrt[-1][:4] == ["pct", "exec", "104", "--"] and "PHS_SILENT=1" in ausgefuehrt[-1][-1]
def test_docker_ueber_arcane_erst_als_probelauf(ausfuehrer, monkeypatch):
_, _, meldungen, _ = ausfuehrer
monkeypatch.setenv("MC_ARCANE_KEY", "schluessel")
monkeypatch.setattr(inventar.arcane, "images_mit_update", lambda url: [
{"name": "ghcr.io/tobi/nerdquiz:latest", "aktuell": None, "neu": None, "umgebung": 0, "benutzt": ["nerdquiz"]}])
docker = {b["id"]: b for b in _ziele(None)["vm-106"]["bausteine"]}["docker"]
assert (docker["zustand"], docker["kurz"], docker["aktion"]["label"]) == ("neu", "1 Images", "Probelauf")
aufrufe: list[bool] = []
monkeypatch.setattr(updates.arcane, "updater", lambda url, probelauf: aufrufe.append(probelauf) or {
"local": {"checked": 7, "updated": 0, "failed": 0,
"items": [{"resourceName": "nerdquiz", "updateAvailable": True}]}})
lauf = _auf_ende(updates.starten("vm-106", "docker")["lauf"])
assert aufrufe == [True] and lauf["ergebnis"] == "offen"
assert meldungen[-1][0] == "Arcane-Probelauf: 1 von 7 Containern würden aktualisiert (nerdquiz). Geändert wurde nichts."
monkeypatch.delenv("MC_ARCANE_KEY")
assert updates.starten("vm-106", "docker")["ok"] is False
def test_ausfuehrer_nur_lesen(monkeypatch):
a = _ausfuehrer_modul()
monkeypatch.setattr(a, "NUR_LESEN", True)
monkeypatch.setattr(a, "_pvesh", lambda pfad, *p: [{"vmid": 104, "type": "lxc", "tags": "community-script"}])
ausgefuehrt: list[list[str]] = []
monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60: ausgefuehrt.append(befehl) or (0, "ok"))
antwort = a.ausfuehren({"aktion": "update", "parameter": {"vmid": 104}})
assert antwort["code"] == 2 and "Nur-Lesen" in antwort["text"] and ausgefuehrt == []
monkeypatch.setattr(a, "bericht", lambda: {"gaeste": []})
assert a.ausfuehren({"aktion": "bericht", "parameter": {}})["code"] == 0
def test_eigener_container_erscheint_nicht(daten):
bericht = json.loads(json.dumps(BERICHT))
bericht["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "status": "running",
"etiketten": ["mc2"], "erlaubt": False, "ip": "192.168.178.31"})
kanal.bericht_speichern(bericht)
assert "ct-107" not in {z["id"] for z in inventar.ziele()["ziele"]}
def test_strom_stoesst_bei_aenderung_an_und_meldet_sich(daten):
import asyncio
from routers import homelab as homelab_router
class Anfrage:
def __init__(self):
self.runden = 0
async def is_disconnected(self):
self.runden += 1
return self.runden > 4
async def lesen() -> list[str]:
teile = []
async for teil in homelab_router._strom(Anfrage(), takt_s=0.01, lebenszeichen_takte=2):
teile.append(teil)
if len(teile) == 1:
kanal.bericht_speichern(BERICHT) # ein neuer Bericht → Anstoß „homelab“
return teile
teile = asyncio.run(lesen())
assert teile[0] == ": verbunden\n\n"
assert teile[1] == 'event: invalidate\ndata: {"keys": ["homelab"]}\n\n'
assert 'event: invalidate\ndata: {"keys": []}\n\n' in teile[2:] # Lebenszeichen
def test_hinweise_des_homelab_waechters(daten, monkeypatch):
from services import waechter
monkeypatch.setattr(waechter, "STORE_PATH", daten / "mc2-waechter.json")
(daten / "mc2-waechter.json").write_text(json.dumps({"hinweise": {"gast:ct-104": {
"id": "gast:ct-104", "stufe": "rot", "titel": "Gitea antwortet nicht", "text": "…", "seit": 1}}}),
encoding="utf-8")
antwort = _client().get("/api/homelab/hinweise").json()
assert [h["titel"] for h in antwort["hinweise"]] == ["Gitea antwortet nicht"]
def test_apps_mit_eigenem_updater_bekommen_keinen_knopf(daten, monkeypatch):
"""AdGuard und PVE Scripts Local aktualisiert das Community-Script nicht (ct/<app>.sh, 24.09.2026)."""
bericht = json.loads(json.dumps(BERICHT))
next(g for g in bericht["gaeste"] if g["vmid"] == 100)["app"]["version"] = "0.107.70"
kanal.bericht_speichern(bericht)
adguard = {b["id"]: b for b in {z["id"]: z for z in inventar.ziele()["ziele"]}["ct-100"]["bausteine"]}["app"]
assert adguard["zustand"] == "neu" and adguard["aktion"] is None and "eigene Oberfläche" in adguard["grund"]
antwort = updates.starten("ct-103", "app")
assert antwort["ok"] is False and "Community-Script" in antwort["detail"]
def test_ausfuehrer_erkennt_beide_formate_von_update():
"""Nach dem ersten echten Update (Gitea, 24.09.2026) schrieb das Community-Script /usr/bin/update neu."""
a = _ausfuehrer_modul()
alt = 'bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/adguard.sh)"'
neu = ('#!/usr/bin/env bash\nexport SCRIPT_SLUG="gitea"\nexport UPDATE_SCRIPT_NAME="gitea"\n'
'bash -c "$(curl -fsSL "${COMMUNITY_SCRIPTS_URL}/ct/${UPDATE_SCRIPT_NAME}.sh")"\n')
assert a.app_kennung_aus(alt) == "adguard"
assert a.app_kennung_aus(neu) == "gitea"
assert a.app_kennung_aus("") is None
def test_waechter_und_uebersicht_sehen_volle_gastplatten(daten):
"""24.09.2026: AdGuard stand bei 100 % seiner 2 GB — jetzt sagen es Wächter und Geräteliste."""
from services import waechter
bericht = json.loads(json.dumps(BERICHT))
for g in bericht["gaeste"]:
if g["vmid"] == 100:
g["platte"] = {"belegt": 2_000_000_000, "gesamt": 2_040_000_000}
if g["vmid"] == 104:
g["platte"] = {"belegt": 1_700_000_000, "gesamt": 2_000_000_000}
if g["vmid"] == 101:
g["platte"] = {"belegt": 500_000_000, "gesamt": 2_000_000_000}
kanal.bericht_speichern(bericht)
befunde = {b.id: b for b in waechter.pruefe_gast_platten()}
assert set(befunde) == {"gast-platte:100", "gast-platte:104"}
assert (befunde["gast-platte:100"].stufe, befunde["gast-platte:104"].stufe) == ("rot", "gelb")
assert befunde["gast-platte:100"].titel == "Platte von AdGuard Home zu 98 % voll"
assert "2,0 von 2,0 GB" in befunde["gast-platte:100"].text
z = {z["id"]: z for z in inventar.ziele()["ziele"]}
assert z["ct-100"]["platte"] == {"belegt": 2_000_000_000, "gesamt": 2_040_000_000} and z["pve"]["platte"] is None

Some files were not shown because too many files have changed in this diff Show More