Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f8a0f7fb | ||
|
|
785ccc2aa6 |
@@ -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
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
|
||||||
@@ -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"),
|
|
||||||
)
|
|
||||||
@@ -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}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"))
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
||||||
@@ -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())
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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()]}
|
|
||||||
@@ -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)}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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"})
|
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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"))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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."}
|
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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"]}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}"}
|
|
||||||
@@ -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)."}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
"""
|
|
||||||
@@ -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}"
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()))
|
|
||||||
@@ -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}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
@@ -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
@@ -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)}"}
|
|
||||||
|
|||||||
+802
-765
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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]
|
||||||
@@ -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}."}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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()}
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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"}
|
|
||||||
@@ -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
Reference in New Issue
Block a user