Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed36e9764 | ||
|
|
bcf878c242 | ||
|
|
d2068da97f | ||
|
|
1b9eb38dc3 | ||
|
|
954a5e74bb | ||
|
|
b615835a73 | ||
|
|
9341b6cb37 | ||
|
|
43c058d7cc | ||
|
|
3f05263b63 | ||
|
|
2e0ad70a85 | ||
|
|
93f6613146 | ||
|
|
d835d41610 | ||
|
|
bff8478203 | ||
|
|
3d8df1c6fd | ||
|
|
d2f699ac69 | ||
|
|
37179b8864 | ||
|
|
6afec70640 | ||
|
|
8e3fecbe42 | ||
|
|
f2733f44fe | ||
|
|
a32f55645c | ||
|
|
40d2840b16 | ||
|
|
37133fd5ed | ||
|
|
4a2b1723f4 | ||
|
|
528198de60 | ||
|
|
56df849455 | ||
|
|
160381d8b3 | ||
|
|
83c33584e4 | ||
|
|
39320b446d | ||
|
|
b452b5e672 | ||
|
|
209549f0f3 | ||
|
|
5e9227c4a3 | ||
|
|
a47489fa85 | ||
|
|
589c14d5e9 | ||
|
|
22bb8c672b | ||
|
|
ee2bb9d03c | ||
|
|
0e961f112b | ||
|
|
d1ddafe721 | ||
|
|
97ba6420e2 | ||
|
|
8eb2fec2ea | ||
|
|
bc1106ddc8 | ||
|
|
1389b46846 | ||
|
|
45048c60b7 | ||
|
|
b1bc9395cf | ||
|
|
e1d7d499f8 | ||
|
|
424aaada27 | ||
|
|
f4bbdad311 | ||
|
|
83941847ab | ||
|
|
29ec6f2ccf | ||
|
|
75611be9a9 | ||
|
|
bd12667231 | ||
|
|
24e6dd81ba | ||
|
|
a091ccb3b8 | ||
|
|
634ad27302 | ||
|
|
59631601b9 | ||
|
|
99ba6a78ab | ||
|
|
739f27a7e5 | ||
|
|
3c1d8bf88f | ||
|
|
b0c9549a29 | ||
|
|
4cd856bd34 | ||
|
|
5ddc952ecd | ||
|
|
e9f488b56c | ||
|
|
c8718fcde0 | ||
|
|
45b5bf275c | ||
|
|
648be33d21 | ||
|
|
4a05bc2a05 | ||
|
|
42363229d4 | ||
|
|
133a491aca | ||
|
|
91aa16eee1 | ||
|
|
2cc1e1cc47 | ||
|
|
292a9d2b6b | ||
|
|
8d46adfd86 | ||
|
|
177c9a311c | ||
|
|
4b226d5d22 | ||
|
|
ed6948429a | ||
|
|
7135042752 | ||
|
|
790de19f1a | ||
|
|
f3af2adec3 | ||
|
|
bfb07a92c3 | ||
|
|
907289d7dc | ||
|
|
3d2c9549fb | ||
|
|
12dadfe6ef | ||
|
|
3669a6e7dc | ||
|
|
cd54fe4a0f | ||
|
|
1e7a6d974f | ||
|
|
61f226e86d | ||
|
|
6809d357c0 | ||
|
|
502a6010f1 | ||
|
|
f9f2e116e0 | ||
|
|
479fecd2c6 | ||
|
|
df8d088fac | ||
|
|
b570e9410d | ||
|
|
e9c2599542 | ||
|
|
a8a6ce3b29 | ||
|
|
0944b1d93e | ||
|
|
1c8f285151 | ||
|
|
041ede7f8d | ||
|
|
33032f8fb0 | ||
|
|
3249c8e942 | ||
|
|
24a0694b80 | ||
|
|
a494d320d7 | ||
|
|
7957cda438 | ||
|
|
dcee0096fe | ||
|
|
b74e98ffbb | ||
|
|
5aa5a484a7 | ||
|
|
464b0d20b8 | ||
|
|
ae69c5ce31 | ||
|
|
8aa22e6591 | ||
|
|
3d1881f4bc | ||
|
|
2935752613 | ||
|
|
a969898a16 | ||
|
|
57492d6759 | ||
|
|
ba9ea7743f | ||
|
|
84dc34c0a3 | ||
|
|
34a9862591 | ||
|
|
f3139d2b5d | ||
|
|
6e573d5551 | ||
|
|
2210bf5c40 | ||
|
|
dc78114102 | ||
|
|
259e1b2ca8 | ||
|
|
148a039545 | ||
|
|
25dbfb2b61 | ||
|
|
fa69edc1aa | ||
|
|
9cf04c427e | ||
|
|
f446da8ae6 | ||
|
|
c2a54bd25d | ||
|
|
56668ee43d | ||
|
|
c8e5a7162b | ||
|
|
00b64b9fdc | ||
|
|
7c23ebf382 | ||
|
|
e04ace242c | ||
|
|
d880a76b6a | ||
|
|
ff319ff291 | ||
|
|
6e3440379b | ||
|
|
5f35f0d580 | ||
|
|
43fa747bc3 | ||
|
|
83c6ecc613 | ||
|
|
b316ad40ba | ||
|
|
8c371b946b | ||
|
|
1a2051e988 |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -1,27 +0,0 @@
|
|||||||
# .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*
|
|
||||||
+55
-37
@@ -1,37 +1,55 @@
|
|||||||
{
|
{
|
||||||
"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": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
"runtimeArgs": [
|
||||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
"run",
|
||||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
"dev",
|
||||||
"autoPort": false,
|
"--",
|
||||||
"port": 5180
|
"--port",
|
||||||
},
|
"5180",
|
||||||
{
|
"--strictPort"
|
||||||
"name": "frontend-mock",
|
],
|
||||||
"runtimeExecutable": "npm",
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
"env": {
|
||||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||||
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
},
|
||||||
"autoPort": false,
|
"autoPort": false,
|
||||||
"port": 5181
|
"port": 5180
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
{
|
|
||||||
"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:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# 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
|
|
||||||
+34
-24
@@ -1,24 +1,34 @@
|
|||||||
# 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/
|
||||||
|
|
||||||
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
# Env / local
|
||||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
*.env
|
||||||
frontend/public/avatar.vrm
|
.DS_Store
|
||||||
frontend/dist/avatar.vrm
|
|
||||||
|
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
||||||
# Env / local
|
box_recon*
|
||||||
*.env
|
gemma_swap*
|
||||||
.DS_Store
|
|
||||||
|
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||||
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
frontend/tsconfig.tsbuildinfo
|
||||||
box_recon*
|
|
||||||
gemma_swap*
|
# Lokale Claude-Code-Einstellungen (enthielten bis 24.09.2026 ein Token) und Worktree-Ordner
|
||||||
|
.claude/settings.local.json
|
||||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
.claude/worktrees/
|
||||||
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,60 +1,74 @@
|
|||||||
# AGENTS.md — Mission Control 2.0
|
# AGENTS.md — Homelab Orchestrator (vormals 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"). 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.
|
**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.
|
||||||
|
|
||||||
## 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.
|
||||||
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
|
- **Deploy** (`bash ~/mission-control-v2/deploy/deploy.sh` auf der Box) holt `origin/main` per
|
||||||
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
fast-forward — **`main` muss vorher auf Gitea liegen**, im Box-Checkout nie von Hand ändern.
|
||||||
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
Zweistufig seit 24.09.2026: Stufe 1 holt den Stand und startet die NEUE Fassung des Skripts,
|
||||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
Stufe 2 prüft (pruefen.sh), spielt Units/Cron-Skripte/Skills aus, startet neu und prüft nach.
|
||||||
|
Scheitert etwas: automatisch zurück auf den alten Stand + dringende Meldung. Laufende Update-Jobs
|
||||||
## Agentic IDE & Vibe Coding
|
verschieben den Deploy. Die llama-swap-Config wird nur überschrieben, wenn sich der Repo-Abzug im
|
||||||
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
|
selben Deploy geändert hat (sonst gewinnt die lebende Datei, die MC2 selbst schreibt).
|
||||||
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||||
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
|
|
||||||
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
|
## Agentic IDE & Vibe Coding
|
||||||
|
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in **OpenChamber** (mit eigener OpenCode-CLI).
|
||||||
## Zeit & Umgebung
|
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||||
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
- 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/`).
|
||||||
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
- **Prüftor:** `bash deploy/pruefen.sh` (Shell-/Python-Syntax, ruff, Importe, pytest) muss vor jedem Push grün sein.
|
||||||
(siehe `backend/services/reminders.py`).
|
Der Deploy auf der Box führt es vor dem Umschalten noch einmal aus; rot = automatisch zurück auf den alten Stand.
|
||||||
|
|
||||||
## Backend-Konventionen
|
## Zeit & Umgebung
|
||||||
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
||||||
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
||||||
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
(siehe `backend/services/reminders.py`).
|
||||||
keine zweite Allowlist in einem Router duplizieren.
|
|
||||||
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
|
## Backend-Konventionen
|
||||||
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
||||||
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
||||||
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
||||||
|
keine zweite Allowlist in einem Router duplizieren.
|
||||||
## Grenzen (Verdikt, eingehalten)
|
- **Gate vor Commit:** `bash deploy/pruefen.sh` (enthält `py_compile`, `ruff check .` und die Tests).
|
||||||
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
|
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
||||||
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
|
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
||||||
- Alles reversibel halten: Branch + Backup + Pin.
|
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht
|
||||||
|
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
|
||||||
## Gitea
|
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also,
|
||||||
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
|
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die
|
||||||
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
|
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst.
|
||||||
|
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,145 +1,90 @@
|
|||||||
# Mission Control 2.0
|
# Homelab Orchestrator
|
||||||
|
|
||||||
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
|
Ein Steuerpult fürs Heimnetz, das sich selbst wartet. Zwei Bereiche, eine Oberfläche:
|
||||||
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.
|
|
||||||
|
|
||||||
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
- **Box-Wart** betreut die KI-Box (Bosgame M5, AMD Ryzen AI MAX+ 395, 128 GB gemeinsamer Speicher, llama.cpp über
|
||||||
|
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)).
|
||||||
|
|
||||||
## Die drei Bahnen
|
Dieselbe Codebasis läuft als zwei Instanzen: auf der KI-Box (Rolle `box`) und später als Container auf dem
|
||||||
|
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.
|
||||||
|
|
||||||
| Bahn | Was sie tut | Wo |
|
## Oberfläche
|
||||||
|
|
||||||
|
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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
| `9001` | `mission-control-2` | Oberfläche, `/api`, `/v1` für Lucy und OpenChamber, Hermes-Dashboard unter `/hermes-ui/` |
|
||||||
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
|
| `9010` (nur lokal) | `mc2-gateway` | `/v1`-Datenpfad: `model: auto`, Bild-Weiche |
|
||||||
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
| – | `mc2-steward` | Wächter und Re-Warm |
|
||||||
|
| `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.) |
|
||||||
|
|
||||||
Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
|
Dazu die Timer `mc2-radar`, `mc2-backup`, `mc2-morgenmeldung`, `mc2-autoupdate` und `projekte-sync`. Die Unit-Dateien
|
||||||
|
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
|
# Backend lokal auf :9000 (Windows)
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
|
python -m venv .venv
|
||||||
|
.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 (Dev, proxyt /api → :9000)
|
# Frontend (Vite leitet /api an :9000 weiter; MC_API_TARGET=http://192.168.178.151:9001 zeigt auf die Box)
|
||||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
cd frontend && npm ci && npm run dev
|
||||||
|
|
||||||
# Frontend (Build → wird vom Backend ausgeliefert)
|
|
||||||
cd frontend && npm run build # → frontend/dist
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
|
Vor jedem Push: `bash deploy/pruefen.sh` (Shell- und Python-Syntax, `ruff check .`, Importe, `pytest backend/tests`);
|
||||||
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
|
bei Frontend-Änderungen zusätzlich `npm run lint`, `npm test` und `npm run build` in `frontend/`, und das neue
|
||||||
|
`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).
|
||||||
|
|
||||||
## Env-Vars (Auswahl)
|
## Doku
|
||||||
|
|
||||||
| Variable | Default | Zweck |
|
| Dokument | Für | Inhalt |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
|
| [docs/README.md](docs/README.md) | alle | Index, Lesereihenfolge, Pflegeregeln |
|
||||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
|
| [docs/BEDIENUNG.md](docs/BEDIENUNG.md) | User | Oberfläche und Telegram-Nachrichten |
|
||||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
|
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Agenten, Technik | Prozesse, Rollen, Datenwege, wer was schreibt |
|
||||||
| `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
|
| [docs/BETRIEB.md](docs/BETRIEB.md) | Agenten, Technik | Handgriffe, Meldungen, Wächter, Deploy, Sicherung, Notfall |
|
||||||
|
| [docs/UPDATES.md](docs/UPDATES.md) | Agenten, Technik | Sonntags-Update, Festhalten, Freigeben |
|
||||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
|
| [docs/RADAR.md](docs/RADAR.md) | Agenten, Technik | Modell-Radar, Stack-Radar, Prüfstand |
|
||||||
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
|
| [docs/WIEDERAUFBAU.md](docs/WIEDERAUFBAU.md) | Technik | die KI-Box von null aufbauen |
|
||||||
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
|
| [docs/wissen/](docs/wissen/) | Agenten | Stand, Verdikte, Fallen, Arbeitsweise, offene Fäden |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# 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.
|
|
||||||
+104
-69
@@ -1,9 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Mission Control 2.0 — dünner FastAPI-Einstieg.
|
Homelab Orchestrator (vormals 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
|
||||||
@@ -15,34 +22,17 @@ 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
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from routers import (
|
from kern.einstellungen import einstellungen
|
||||||
agent,
|
from routers import health, partner
|
||||||
auftragsbuch,
|
from services.herkunft import erlaubt
|
||||||
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(
|
||||||
@@ -52,10 +42,11 @@ logging.basicConfig(
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
def _box_hintergrund() -> list[asyncio.Task[Any]]:
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
|
||||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
|
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
|
||||||
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
from services import reminders, warmer
|
||||||
|
|
||||||
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()))
|
||||||
@@ -63,11 +54,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
"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,
|
||||||
)
|
)
|
||||||
if sentry.ENABLED:
|
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
else:
|
||||||
|
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(
|
||||||
@@ -82,7 +89,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
await app.state.gw_client.aclose()
|
await app.state.gw_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
app = FastAPI(title="Homelab Orchestrator", 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(
|
||||||
@@ -93,6 +100,15 @@ 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]]
|
||||||
@@ -103,42 +119,57 @@ async def no_cache(
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
def _box_router(app: FastAPI) -> None:
|
||||||
app.include_router(models.router)
|
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
|
||||||
app.include_router(routing.router)
|
from routers import (
|
||||||
app.include_router(system.router)
|
boxwart,
|
||||||
|
einstellungen,
|
||||||
|
events,
|
||||||
|
gateway_proxy,
|
||||||
|
hermes_ui,
|
||||||
|
maintenance,
|
||||||
|
models,
|
||||||
|
radar,
|
||||||
|
routing,
|
||||||
|
system,
|
||||||
|
voice,
|
||||||
|
zeitmaschine,
|
||||||
|
)
|
||||||
|
from routers import reminders as reminders_router
|
||||||
|
|
||||||
app.include_router(connect.router)
|
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||||
app.include_router(agent.router)
|
app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test
|
||||||
app.include_router(
|
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||||
voice.router
|
app.include_router(models.router)
|
||||||
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
app.include_router(routing.router)
|
||||||
app.include_router(
|
app.include_router(system.router)
|
||||||
reminders_router.router
|
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
|
||||||
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
|
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
|
# /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
|
# 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
|
# 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.
|
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
||||||
if V1_UPSTREAM:
|
if V1_UPSTREAM:
|
||||||
from routers import gateway_forward
|
from routers import gateway_forward
|
||||||
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
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(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
|
||||||
|
if ROLLE == "box":
|
||||||
|
_box_router(app)
|
||||||
else:
|
else:
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
from routers import einstellungen, homelab
|
||||||
app.include_router(maintenance.router)
|
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
||||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
|
||||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
app.include_router(einstellungen.homelab_router) # Telegram-Test des Homelab-Teils
|
||||||
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.
|
||||||
@@ -149,6 +180,10 @@ 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:
|
||||||
|
|||||||
+125
-136
@@ -1,136 +1,125 @@
|
|||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
# --- Engine (llama-swap) -----------------------------------------------------
|
||||||
|
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||||
# --- Engine (llama-swap) -----------------------------------------------------
|
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||||
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
|
||||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
|
||||||
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
|
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
|
||||||
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
|
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||||
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
|
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
|
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||||
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
|
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||||
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
|
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
|
||||||
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
|
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
|
||||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
_DEFAULT_CMD_TEMPLATE = (
|
||||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
)
|
||||||
_DEFAULT_CMD_TEMPLATE = (
|
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
if "{model}" not in CMD_TEMPLATE:
|
||||||
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||||
)
|
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
||||||
if "{model}" not in CMD_TEMPLATE:
|
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
||||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
||||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
||||||
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
||||||
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
||||||
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
||||||
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
||||||
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
||||||
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
||||||
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
||||||
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
||||||
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
||||||
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
||||||
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
||||||
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
||||||
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||||
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||||
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
|
||||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
||||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
||||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
||||||
|
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
||||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
||||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
||||||
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||||
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
|
||||||
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
||||||
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||||
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||||
|
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
|
||||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
def _read_hermes_env(key: str) -> str:
|
||||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||||
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
try:
|
||||||
def _read_hermes_env(key: str) -> str:
|
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
||||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
line = line.strip()
|
||||||
try:
|
if line.startswith(f"{key}="):
|
||||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
except OSError:
|
||||||
line = line.strip()
|
pass
|
||||||
if line.startswith(f"{key}="):
|
return ""
|
||||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
||||||
except OSError:
|
|
||||||
pass
|
HERMES_API_KEY = (
|
||||||
return ""
|
os.environ.get("HERMES_API_KEY")
|
||||||
|
or os.environ.get("API_SERVER_KEY")
|
||||||
|
or _read_hermes_env("API_SERVER_KEY")
|
||||||
HERMES_API_KEY = (
|
)
|
||||||
os.environ.get("HERMES_API_KEY")
|
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
||||||
or os.environ.get("API_SERVER_KEY")
|
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
||||||
or _read_hermes_env("API_SERVER_KEY")
|
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||||
)
|
|
||||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
# passen. MC2 proxyt nach außen.
|
||||||
|
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||||
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
|
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
|
||||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
|
||||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht
|
||||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall
|
||||||
BOX_CONSOLE_PATH = "/console/"
|
# anfasst, öffnet damit auch diese Oberfläche. MC2 erreicht sie über Loopback und reicht sie
|
||||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
# same-origin unter
|
||||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
|
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||||
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
|
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
||||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
|
||||||
# GitHub-Repo für Update-Checks.
|
# --- Server ------------------------------------------------------------------
|
||||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||||
|
|
||||||
# --- Server ------------------------------------------------------------------
|
# Version (Phase 0 — Greenfield-Skeleton).
|
||||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
VERSION = "2.0.0-w8"
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""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."""
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""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"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""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}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""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"))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""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“
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
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())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 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,9 +1,16 @@
|
|||||||
fastapi>=0.115
|
# Gepinnt am 24.09.2026 auf das, was nachweislich läuft: `pip freeze` im venv der KI-Box (Python 3.14) und im
|
||||||
python-multipart>=0.0.9
|
# Homelab-Container 107 (/opt/mc2/backend/.venv, Python 3.13). Gleiche Version = „==“, sonst ein Bereich, der
|
||||||
uvicorn[standard]>=0.30
|
# beide Stände abdeckt — so installiert weder deploy.sh (Box) noch einrichten.sh (Container) etwas Neues.
|
||||||
httpx>=0.27
|
# Wer eine Grenze anhebt: vorher deploy/probelauf-box.sh, und die neue Version in beiden Umgebungen prüfen.
|
||||||
ruamel.yaml>=0.18
|
fastapi>=0.139.0,<=0.141.1
|
||||||
psutil>=5.9
|
python-multipart==0.0.32
|
||||||
huggingface_hub>=0.27
|
uvicorn[standard]>=0.51.0,<=0.53.0
|
||||||
mcp>=1.2.0
|
httpx==0.28.1
|
||||||
tzdata>=2024.1
|
ruamel.yaml==0.19.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
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"""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}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
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()]}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""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)}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""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()
|
||||||
+80
-52
@@ -1,25 +1,26 @@
|
|||||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||||
|
|
||||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten:
|
||||||
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
|
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
||||||
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
|
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
||||||
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
|
|
||||||
|
|
||||||
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
|
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
|
||||||
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).
|
|
||||||
|
|
||||||
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
|
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
||||||
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
|
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
||||||
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
|
sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
|
||||||
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
|
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
|
||||||
Sekunden), Modell-Quelle aus der Karte übernommen.
|
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
|
||||||
|
|
||||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
|
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
|
||||||
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
|
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
|
||||||
selbst und bis dahin pollt die UI wie bisher).
|
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
|
||||||
|
|
||||||
|
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
|
||||||
@@ -27,7 +28,6 @@ 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,7 +35,8 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
METRIK_S = 1.0 # Takt der Messpunkte
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -50,15 +51,9 @@ 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: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden
|
||||||
from services import announce
|
from services import waechter
|
||||||
fp["chronik"] = announce.list_after(None)["latest"]
|
fp["start"] = _mtime(waechter.STORE_PATH)
|
||||||
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
|
||||||
@@ -66,35 +61,68 @@ 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
|
||||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
|
||||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
from services import jobengine
|
||||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
fp["jobs"] = json.dumps(
|
||||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs(mit_log=False)]
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return fp
|
return fp
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events")
|
async def _strom(request: Request, mit_metrik: bool):
|
||||||
async def events(request: Request) -> StreamingResponse:
|
"""Gemeinsamer Kern beider Endpunkte.
|
||||||
async def strom():
|
|
||||||
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
Die Basislinie entsteht 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()
|
"""
|
||||||
yield ": verbunden\n\n"
|
# Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere
|
||||||
seit_ping = 0.0
|
# Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread.
|
||||||
while True:
|
alt = await asyncio.to_thread(_fingerprints)
|
||||||
if await request.is_disconnected():
|
yield ": verbunden\n\n"
|
||||||
return
|
|
||||||
await asyncio.sleep(TICK_S)
|
seit_abdruck = 0.0
|
||||||
seit_ping += TICK_S
|
seit_ping = 0.0
|
||||||
neu = _fingerprints()
|
takt = METRIK_S if mit_metrik else ABDRUCK_S
|
||||||
|
|
||||||
|
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
|
|
||||||
|
|
||||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
if seit_ping >= KEEPALIVE_S:
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
yield ": ping\n\n"
|
||||||
|
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,12 +1,15 @@
|
|||||||
|
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 IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
from services.router_logic import VISION_CAPABLE, bild_ziel, bilder_aufteilen, 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__)
|
||||||
@@ -22,8 +25,15 @@ _LANG_DIRECTIVE = os.environ.get(
|
|||||||
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
||||||
|
|
||||||
|
|
||||||
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
|
# Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
|
||||||
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
|
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
|
||||||
|
# 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, "
|
||||||
@@ -63,57 +73,74 @@ def _apply_no_think_marker(body: dict) -> None:
|
|||||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
|
|
||||||
|
|
||||||
def _ist_coder_alias(alias: str) -> bool:
|
# Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500
|
||||||
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
|
# „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen
|
||||||
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
|
# Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild).
|
||||||
return "coder" in (alias or "").lower()
|
# • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe.
|
||||||
|
# • 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
|
||||||
|
|
||||||
|
|
||||||
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
|
def _bild_schluessel(part: dict) -> str:
|
||||||
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
|
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
|
|
||||||
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
|
async def _beschreibe(part: dict, vision_alias: str) -> str:
|
||||||
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
|
"""Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt
|
||||||
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
|
neu, darum wird jedes Bild nur einmal beschrieben."""
|
||||||
Vision-Umleitung als Fallback.
|
schluessel = _bild_schluessel(part)
|
||||||
"""
|
if schluessel in _BESCHREIBUNGEN:
|
||||||
fundstellen: list[tuple[dict, int, dict]] = []
|
_BESCHREIBUNGEN.move_to_end(schluessel)
|
||||||
for m in body.get("messages") or []:
|
return _BESCHREIBUNGEN[schluessel]
|
||||||
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
|
frage = {
|
||||||
continue
|
"model": vision_alias,
|
||||||
for i, part in enumerate(m["content"]):
|
"stream": False,
|
||||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
"max_tokens": 700,
|
||||||
fundstellen.append((m, i, part))
|
# Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer.
|
||||||
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
|
"chat_template_kwargs": {"enable_thinking": False},
|
||||||
frage = {
|
"messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
||||||
"model": vision_alias,
|
}
|
||||||
"stream": False,
|
text = ""
|
||||||
"max_tokens": 700,
|
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz
|
||||||
"messages": [{"role": "user",
|
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry.
|
||||||
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
for versuch in (1, 2):
|
||||||
}
|
try:
|
||||||
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
|
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
|
||||||
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
|
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
||||||
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
|
if r.status_code == 200:
|
||||||
text = ""
|
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||||
for versuch in (1, 2):
|
if text.strip():
|
||||||
try:
|
break
|
||||||
async with httpx.AsyncClient(timeout=240.0) as c:
|
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
||||||
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
versuch, r.status_code, r.text)
|
||||||
if r.status_code == 200:
|
except Exception:
|
||||||
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True)
|
||||||
if text.strip():
|
text = text.strip()
|
||||||
break
|
if text:
|
||||||
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
_BESCHREIBUNGEN[schluessel] = text
|
||||||
versuch, r.status_code, r.text)
|
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX:
|
||||||
except Exception:
|
_BESCHREIBUNGEN.popitem(last=False)
|
||||||
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
|
return text
|
||||||
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
|
||||||
msg["content"][idx] = {"type": "text", "text": (
|
texte.append((msg, idx, text))
|
||||||
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
|
for msg, idx, text in texte:
|
||||||
f"{text.strip()}]")}
|
msg["content"][idx] = {"type": "text", "text": f"[Bild aus einem früheren Schritt — so sah es aus:\n{text}]"}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -134,11 +161,29 @@ 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)
|
||||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
try:
|
||||||
data = r.json()
|
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||||
|
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.
|
||||||
@@ -187,7 +232,20 @@ async def models(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
async def _proxy(path: str, request: Request):
|
async def _proxy(path: str, request: Request):
|
||||||
body = await request.json()
|
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
|
||||||
|
# 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"):
|
||||||
@@ -201,31 +259,36 @@ 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)
|
||||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
|
angefragt = alias
|
||||||
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
|
# Bild-Weiche v3 (siehe oben): Bild im aktuellen Schritt → Bild-Zwilling der Rolle; ältere Bilder →
|
||||||
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
|
# Beschreibung als Text, die Anfrage bleibt beim schnellen Modell.
|
||||||
# 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):
|
||||||
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
|
frisch, alt = bilder_aufteilen(body)
|
||||||
routed = {"x-mc-routed-to": alias,
|
beschrieben = bool(alt) and await _alte_bilder_beschreiben(alt, vision_alias)
|
||||||
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
|
lane = routed.get("x-mc-lane", "-")
|
||||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
if frisch or not beschrieben:
|
||||||
else:
|
alias = bild_ziel(alias, vision_alias, pol.get("coder_vision") or None)
|
||||||
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 erkannt -> Vision-Modell",
|
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild im aktuellen Schritt -> Bild-Zwilling",
|
||||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
"x-mc-lane": lane}
|
||||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
else:
|
||||||
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
|
routed = {"x-mc-routed-to": alias,
|
||||||
|
"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)
|
||||||
r = await client.send(req, stream=True)
|
try:
|
||||||
|
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:
|
||||||
@@ -243,8 +306,15 @@ 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)
|
||||||
|
|
||||||
r = await client.post(url, json=body, timeout=600.0)
|
try:
|
||||||
resp_json = r.json()
|
r = await client.post(url, json=body, timeout=600.0)
|
||||||
|
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"
|
||||||
|
|||||||
+24
-15
@@ -1,9 +1,12 @@
|
|||||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz.
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
@@ -11,19 +14,25 @@ router = APIRouter(prefix="/api")
|
|||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str
|
version: str
|
||||||
engine_reachable: bool
|
rolle: str
|
||||||
gateway_reachable: bool
|
instanz: str
|
||||||
brain: dict
|
engine_reachable: bool | None = None
|
||||||
|
gateway_reachable: bool | None = None
|
||||||
|
brain: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
@router.get("/health", response_model=HealthResponse, response_model_exclude_none=True)
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {
|
e = einstellungen()
|
||||||
"status": "ok",
|
antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz}
|
||||||
"version": VERSION,
|
if e.rolle == "box":
|
||||||
"engine_reachable": llamaswap.engine_reachable(),
|
from services import gateway, llamaswap
|
||||||
"gateway_reachable": gateway.gateway_reachable(),
|
|
||||||
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
|
antwort.update(
|
||||||
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
|
engine_reachable=llamaswap.engine_reachable(),
|
||||||
"brain": llamaswap.brain_status(),
|
gateway_reachable=gateway.gateway_reachable(),
|
||||||
}
|
# 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,14 +6,19 @@ 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 läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
|
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
|
||||||
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
|
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||||
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
|
||||||
@@ -91,6 +96,24 @@ 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:
|
||||||
@@ -113,12 +136,18 @@ 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():
|
||||||
if _BASE_PATH_RE.search(body):
|
body = praefixiere_html(body)
|
||||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
resp = Response(content=body, status_code=r.status_code)
|
||||||
elif b"<head>" in body:
|
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
||||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
# dürfen nicht zu einer zusammenfallen.
|
||||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
|
for k, v in r.headers.multi_items():
|
||||||
return Response(content=body, status_code=r.status_code, headers=resp_headers)
|
kl = k.lower()
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""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"})
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"""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,19 +1,28 @@
|
|||||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException
|
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
|
||||||
|
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 maintenance
|
from services import geheimnisse, 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")
|
||||||
@@ -28,57 +37,67 @@ 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(body: SudoReq) -> dict:
|
def check_updates() -> dict:
|
||||||
res = maintenance.check_updates_job(body.sudo_password)
|
res = maintenance.check_updates_job()
|
||||||
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(body: SudoReq) -> dict:
|
def os_update() -> dict:
|
||||||
res = maintenance.os_update_job(body.sudo_password)
|
res = maintenance.os_update_job()
|
||||||
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(body: SudoReq) -> dict:
|
def engine_update() -> dict:
|
||||||
res = maintenance.engine_update_job(body.sudo_password)
|
res = maintenance.engine_update_job()
|
||||||
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(body: SudoReq) -> dict:
|
def swap_update() -> dict:
|
||||||
res = maintenance.swap_update_job(body.sudo_password)
|
res = maintenance.swap_update_job()
|
||||||
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() -> dict:
|
def hermes_update(verlauf: bool = True) -> dict:
|
||||||
return maintenance.hermes_update_job()
|
"""verlauf=false: Der Sonntags-Lauf trägt das Ergebnis selbst in seinen Update-Verlauf ein."""
|
||||||
|
return maintenance.hermes_update_job(verlauf=verlauf)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/update-all")
|
@router.post("/maintenance/update-all")
|
||||||
def update_all(body: SudoReq) -> dict:
|
def update_all() -> dict:
|
||||||
return maintenance.update_all_job(body.sudo_password)
|
return maintenance.update_all_job()
|
||||||
|
|
||||||
|
|
||||||
@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, body.sudo_password)
|
return maintenance.restart_service(body.service)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/logs")
|
@router.get("/maintenance/logs")
|
||||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
return maintenance.logs(service, lines, x_sudo_password)
|
return maintenance.logs(service, lines)
|
||||||
|
|
||||||
|
|
||||||
|
@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()}
|
||||||
|
|||||||
+35
-133
@@ -1,11 +1,12 @@
|
|||||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen,
|
||||||
|
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, hf, jobengine, llamaswap
|
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||||
from services.fit import evaluate_fit, max_ctx_for
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -29,27 +30,6 @@ 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
|
||||||
@@ -61,6 +41,10 @@ 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,
|
||||||
@@ -71,13 +55,29 @@ 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
|
||||||
hf_token: str | None = None
|
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
|
||||||
|
# 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,20 +123,9 @@ 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. hermes geht dabei durch den
|
# Rolle erst NACH erfolgreichem Download übernehmen (_rolle_uebernehmen).
|
||||||
# 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"])
|
||||||
@@ -144,10 +133,13 @@ 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 req.hf_token:
|
if token := geheimnisse.hf_token():
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = token
|
||||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
# Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf.
|
||||||
on_done=_apply_role if role else None)
|
# Der Download läuft als eigener Auftrag weiter, auch wenn MC2 zwischendurch neu startet.
|
||||||
|
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"])}
|
||||||
@@ -167,14 +159,6 @@ 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()
|
||||||
@@ -199,54 +183,6 @@ 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
|
||||||
@@ -273,21 +209,8 @@ 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:
|
||||||
import httpx
|
"""Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap)."""
|
||||||
from config import LLAMA_SWAP_URL
|
return llamaswap.lade_modell(model_id)
|
||||||
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}")
|
||||||
@@ -295,24 +218,3 @@ 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}
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""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"))
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
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,9 +1,8 @@
|
|||||||
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
|
"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau
|
||||||
|
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter
|
||||||
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")
|
||||||
|
|
||||||
@@ -11,32 +10,3 @@ 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}
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
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)}
|
|
||||||
+15
-46
@@ -1,4 +1,4 @@
|
|||||||
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
"""System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch.
|
||||||
|
|
||||||
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, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_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
|
from services import maintenance, waechter
|
||||||
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,29 +26,11 @@ 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
|
||||||
@@ -87,7 +69,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": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
|
rows.append({"name": "Box-Wart (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,
|
||||||
@@ -97,15 +79,20 @@ 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-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
{"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
{"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||||
"ok": _voice_reachable(), "scope": "user"},
|
"ok": _voice_reachable(), "scope": "user"},
|
||||||
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||||
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||||
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "",
|
||||||
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
"ok": _user_unit_active("lucy-stimme"), "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": {
|
||||||
@@ -126,15 +113,6 @@ 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
|
||||||
|
|
||||||
@@ -149,15 +127,6 @@ 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)."""
|
||||||
|
|||||||
+289
-357
@@ -1,357 +1,289 @@
|
|||||||
"""
|
"""
|
||||||
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
|
Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter).
|
||||||
|
|
||||||
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
|
Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit
|
||||||
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) —
|
||||||
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem
|
||||||
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
`X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue
|
||||||
|
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
|
||||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
import logging
|
||||||
import sys as _sys
|
import os
|
||||||
import time
|
|
||||||
|
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||||
import httpx
|
import sys as _sys
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
import httpx
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||||
from pydantic import BaseModel
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
from services import announce
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
from pydantic import BaseModel
|
||||||
Timer,
|
from services import announce
|
||||||
TurnTrace,
|
|
||||||
get_metrics,
|
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||||
get_trace,
|
if _GUARD_DIR not in _sys.path:
|
||||||
park,
|
_sys.path.insert(0, _GUARD_DIR)
|
||||||
record_stage,
|
try:
|
||||||
)
|
from guard import wrap_untrusted
|
||||||
|
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
def wrap_untrusted(text: str, label: str = "") -> str:
|
||||||
if _GUARD_DIR not in _sys.path:
|
return text
|
||||||
_sys.path.insert(0, _GUARD_DIR)
|
|
||||||
try:
|
log = logging.getLogger(__name__)
|
||||||
from guard import wrap_untrusted
|
router = APIRouter(prefix="/api")
|
||||||
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
|
||||||
def wrap_untrusted(text: str, label: str = "") -> str:
|
# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an
|
||||||
return text
|
# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar.
|
||||||
|
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||||
log = logging.getLogger(__name__)
|
# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes.
|
||||||
router = APIRouter(prefix="/api")
|
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||||
|
_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
|
|
||||||
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
|
def _sse_fehler(text: str) -> bytes:
|
||||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client)."""
|
||||||
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
|
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode()
|
||||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
|
||||||
|
|
||||||
|
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
"""Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||||
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler."""
|
||||||
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
|
multi = len(image_urls) > 1
|
||||||
multi = len(image_urls) > 1
|
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||||
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
||||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
"Keine Einleitung, keine Wiederholung der Frage. "
|
||||||
"Keine Einleitung, keine Wiederholung der Frage. "
|
if multi else
|
||||||
if multi else
|
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
||||||
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
||||||
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
||||||
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
for u in image_urls:
|
||||||
for u in image_urls:
|
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
try:
|
||||||
try:
|
# 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne
|
||||||
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
|
# Bildschirm-Kontext antworten als ewig hängen.
|
||||||
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
|
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
"messages": [{"role": "user", "content": content}],
|
||||||
"messages": [{"role": "user", "content": content}],
|
})
|
||||||
})
|
r.raise_for_status()
|
||||||
r.raise_for_status()
|
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
||||||
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
except Exception as exc:
|
||||||
except Exception as exc:
|
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
return ""
|
||||||
return ""
|
|
||||||
|
|
||||||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
|
class ChatIn(BaseModel):
|
||||||
|
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||||
|
session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
|
||||||
class TTSIn(BaseModel):
|
session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key)
|
||||||
text: str
|
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||||
engine: str = "piper"
|
model: str = ""
|
||||||
voice: str = ""
|
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||||
language: str = ""
|
|
||||||
ref_path: str = ""
|
|
||||||
|
class AnnounceIn(BaseModel):
|
||||||
|
text: str # die Meldung (wird von Lucy gesprochen)
|
||||||
class ChatIn(BaseModel):
|
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
||||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
||||||
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
|
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
||||||
session_key: str = "" # optional: Langzeit-Memory-Scope
|
|
||||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
|
||||||
model: str = ""
|
class AlarmIn(BaseModel):
|
||||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
text: str # die Alarm-Meldung
|
||||||
|
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)
|
|
||||||
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
@router.post("/alarm")
|
||||||
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
def alarm(body: AlarmIn) -> dict:
|
||||||
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
||||||
|
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
|
||||||
class AlarmIn(BaseModel):
|
auch nachts sofort raus (notify.sh)."""
|
||||||
text: str # die Alarm-Meldung
|
text = (body.text or "").strip()
|
||||||
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
if not text:
|
||||||
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
raise HTTPException(400, "Leere Meldung.")
|
||||||
|
subject = (body.subject or "[Alarm]").strip()
|
||||||
|
try:
|
||||||
@router.post("/alarm")
|
item = announce.add(text, subject, body.source or "alarm", "normal")
|
||||||
def alarm(body: AlarmIn) -> dict:
|
except ValueError as exc:
|
||||||
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
raise HTTPException(400, str(exc))
|
||||||
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
||||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
|
return {"ok": True, "item": item}
|
||||||
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
|
|
||||||
wie alle MC2-Endpoints."""
|
|
||||||
text = (body.text or "").strip()
|
@router.post("/voice/announce")
|
||||||
if not text:
|
def voice_announce(body: AnnounceIn) -> dict:
|
||||||
raise HTTPException(400, "Leere Meldung.")
|
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh
|
||||||
subject = (body.subject or "[Alarm]").strip()
|
(Updates/Radar/Telegram-Spiegel), Hermes-Cron."""
|
||||||
try:
|
try:
|
||||||
item = announce.add(text, subject, body.source or "alarm", "normal")
|
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||||
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:
|
||||||
@router.post("/voice/announce")
|
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
||||||
def voice_announce(body: AnnounceIn) -> dict:
|
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
||||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
|
return announce.list_after(after, limit)
|
||||||
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
|
|
||||||
try:
|
|
||||||
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
@router.post("/voice/stt")
|
||||||
except ValueError as exc:
|
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||||
raise HTTPException(400, str(exc))
|
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||||
|
data = await audio.read()
|
||||||
|
if not data:
|
||||||
@router.get("/voice/announcements")
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||||
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
try:
|
||||||
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||||
return announce.list_after(after, limit)
|
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
@router.get("/voice/health")
|
except httpx.HTTPError as exc:
|
||||||
def voice_health() -> dict:
|
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||||
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
|
|
||||||
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
|
|
||||||
try:
|
@router.post("/voice/turn")
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||||
out["sidecar"] = r.status_code == 200
|
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||||
out["detail"] = r.json() if r.status_code == 200 else None
|
data = await audio.read()
|
||||||
except Exception as exc:
|
if not data:
|
||||||
out["error"] = str(exc)
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
return out
|
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||||
@router.get("/voice/metrics")
|
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||||
def voice_metrics() -> dict:
|
r.raise_for_status()
|
||||||
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
|
return r.json()
|
||||||
return get_metrics()
|
except httpx.HTTPError as exc:
|
||||||
|
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||||
|
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||||
@router.get("/voice/trace")
|
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||||
def voice_trace(limit: int = 20) -> dict:
|
|
||||||
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
|
|
||||||
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
|
@router.post("/voice/chat")
|
||||||
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
|
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||||
return {"turns": get_trace(limit)}
|
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||||
|
|
||||||
|
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
||||||
@router.get("/voice/voices")
|
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
||||||
def voice_voices() -> dict:
|
if not HERMES_API_KEY:
|
||||||
try:
|
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
|
||||||
r.raise_for_status()
|
headers = {
|
||||||
return r.json()
|
"Authorization": f"Bearer {HERMES_API_KEY}",
|
||||||
except Exception as exc:
|
"X-Hermes-Session-Id": body.session_id,
|
||||||
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 voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
async def gen():
|
||||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
# Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client
|
||||||
data = await audio.read()
|
# bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der
|
||||||
if not data:
|
# Request hängt, während das Bild-Modell beschreibt.
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
user_text = body.text
|
||||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
imgs = [u for u in (body.images or []) if u]
|
||||||
try:
|
if imgs:
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||||
_t0 = time.perf_counter()
|
desc = await _describe_images(imgs, body.text)
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
if desc:
|
||||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||||
record_stage("stt", _ms)
|
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||||
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
|
messages = []
|
||||||
r.raise_for_status()
|
if body.system:
|
||||||
return r.json()
|
messages.append({"role": "system", "content": body.system})
|
||||||
except httpx.HTTPError as exc:
|
messages.append({"role": "user", "content": user_text})
|
||||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||||
|
# 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).
|
||||||
@router.post("/voice/turn")
|
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||||
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
try:
|
||||||
data = await audio.read()
|
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||||
if not data:
|
async with client.stream(
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||||
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
) as r:
|
||||||
try:
|
if r.status_code != 200:
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||||
with Timer("turn"):
|
yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
return
|
||||||
r.raise_for_status()
|
async for chunk in r.aiter_raw():
|
||||||
return r.json()
|
yield chunk
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}")
|
||||||
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
|
||||||
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/voice/reference")
|
# ---------------------------------------------------------------------------------------------
|
||||||
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||||
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie
|
||||||
data = await audio.read()
|
# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV,
|
||||||
if not data:
|
# /tts/stream -> PCM16 + X-Sample-Rate).
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
|
||||||
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
|
class LucyTtsIn(BaseModel):
|
||||||
try:
|
text: str
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
@router.get("/lucy/stimme/health")
|
||||||
except httpx.HTTPError as exc:
|
def lucy_stimme_health() -> dict:
|
||||||
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
|
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||||
@router.get("/voice/reference")
|
r.raise_for_status()
|
||||||
def voice_get_reference() -> dict:
|
return r.json()
|
||||||
try:
|
except Exception as exc:
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except Exception as exc:
|
@router.post("/lucy/stimme/tts")
|
||||||
return {"active": False, "error": str(exc)}
|
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||||
|
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||||
|
try:
|
||||||
@router.delete("/voice/reference")
|
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||||
def voice_clear_reference() -> dict:
|
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||||
try:
|
r.raise_for_status()
|
||||||
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||||
r.raise_for_status()
|
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||||
return r.json()
|
except httpx.HTTPStatusError as exc:
|
||||||
except httpx.HTTPError as exc:
|
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||||
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
|
|
||||||
@router.post("/voice/tts")
|
|
||||||
async def voice_tts(body: TTSIn) -> Response:
|
@router.post("/lucy/stimme/tts/stream")
|
||||||
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
|
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||||
try:
|
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||||
with Timer("tts"):
|
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
|
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||||
r.raise_for_status()
|
try:
|
||||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
|
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||||
except httpx.HTTPError as exc:
|
upstream = await client.send(req, stream=True)
|
||||||
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
|
except httpx.HTTPError as exc:
|
||||||
|
await client.aclose()
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
@router.post("/voice/chat")
|
if upstream.status_code != 200:
|
||||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
await upstream.aclose(); await client.aclose()
|
||||||
|
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.
|
|
||||||
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
async def gen():
|
||||||
if not HERMES_API_KEY:
|
try:
|
||||||
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
async for chunk in upstream.aiter_raw():
|
||||||
|
yield chunk
|
||||||
headers = {
|
finally:
|
||||||
"Authorization": f"Bearer {HERMES_API_KEY}",
|
await upstream.aclose()
|
||||||
"X-Hermes-Session-Id": body.session_id,
|
await client.aclose()
|
||||||
}
|
|
||||||
if body.session_key:
|
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||||
headers["X-Hermes-Session-Key"] = body.session_key
|
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||||
|
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
"""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,17 +31,6 @@ 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
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
+70
-117
@@ -1,36 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
|
Hermes-Agent: Status und Hirn-Umstellung. MC betreibt Hermes NICHT — Werkzeuge und MCP werden in
|
||||||
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
|
Hermes' eigener Config verdrahtet (siehe docs/ARCHITEKTUR.md, „Wer schreibt was").
|
||||||
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:
|
||||||
@@ -106,76 +92,29 @@ def _reach(url: str, path: str = "") -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _count_enabled_mcp_servers() -> int:
|
|
||||||
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:
|
def agent_status() -> dict:
|
||||||
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
|
"""Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste.
|
||||||
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) -> dict:
|
def set_agent_brain(model_id: str, hermes_umstellen: bool = True) -> 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:
|
||||||
@@ -185,26 +124,30 @@ def set_agent_brain(model_id: str) -> 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
|
||||||
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
with llamaswap.sammeln():
|
||||||
if model_id not in brains:
|
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
||||||
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
if model_id not in brains:
|
||||||
set_ttl(model_id, 0)
|
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
||||||
|
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
|
||||||
set_ttl(model_id, 0)
|
# Alias, Gruppe und ttl als EIN Schreibvorgang (24.09.2026) — jeder einzelne entlud alle Modelle.
|
||||||
if old:
|
with llamaswap.sammeln():
|
||||||
set_ttl(old, DEFAULT_TTL)
|
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.
|
||||||
|
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)}
|
||||||
update_brain_model("hermes") # 3) Config + Restart
|
if hermes_umstellen:
|
||||||
|
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:
|
||||||
@@ -220,49 +163,59 @@ def set_agent_brain(model_id: str) -> 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
|
||||||
home = HERMES_HOME
|
from ruamel.yaml import YAML
|
||||||
config_path = home / "config.yaml"
|
|
||||||
|
config_path = HERMES_HOME / "config.yaml"
|
||||||
# Ensure home directory exists
|
if not config_path.exists():
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path)
|
||||||
|
return False
|
||||||
cfg = {}
|
r_yaml = YAML()
|
||||||
if config_path.exists():
|
r_yaml.preserve_quotes = True
|
||||||
try:
|
r_yaml.width = 100
|
||||||
from ruamel.yaml import YAML
|
r_yaml.indent(mapping=2, sequence=4, offset=2)
|
||||||
r_yaml = YAML()
|
try:
|
||||||
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) or {}
|
cfg = r_yaml.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True)
|
||||||
cfg = {}
|
return False
|
||||||
|
|
||||||
if not isinstance(cfg, dict):
|
if not isinstance(cfg, dict):
|
||||||
cfg = {}
|
log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben")
|
||||||
|
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 (latenter Bug: nur model.model gesetzt).
|
# Beide setzen, sonst greift die Umschaltung nicht.
|
||||||
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:
|
||||||
from ruamel.yaml import YAML
|
shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}"))
|
||||||
r_yaml = YAML()
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
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
|
||||||
# Restart the user-space service to apply changes
|
os.replace(tmp, config_path)
|
||||||
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 config import MODELS_DIR
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
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(MODELS_DIR / "mc2-announce.json")))
|
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(einstellungen().daten_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,14 +112,6 @@ 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."""
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""
|
||||||
|
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."}
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
"""
|
|
||||||
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 (mem0 + Hermes-Configs/Secrets + llama-swap config).
|
Voll-Zustands-Backup (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/BACKUP.md.
|
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BETRIEB.md (Sicherung).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -27,11 +27,6 @@ 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:
|
||||||
@@ -70,9 +65,11 @@ 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(p.stat().st_size / 1_000_000, 2),
|
"size_mb": round(st.st_size / 1_000_000, 2),
|
||||||
|
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""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,37 +169,3 @@ 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"]}
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
"""
|
|
||||||
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,8 +25,6 @@ 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()
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""
|
||||||
|
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,8 +99,3 @@ 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,6 +7,7 @@ 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
|
||||||
@@ -37,12 +38,17 @@ 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-/Kontext-Limit abgerissen "
|
f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen "
|
||||||
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
|
f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.",
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""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
|
||||||
+25
-15
@@ -17,10 +17,12 @@ def normalize_repo(s: str) -> str:
|
|||||||
return s.strip("/")
|
return s.strip("/")
|
||||||
|
|
||||||
|
|
||||||
def list_quants(repo: str) -> list[str]:
|
def list_quants(repo: str) -> list[dict]:
|
||||||
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
|
"""Verfügbare Quant-Stufen eines Repos mit Downloadgröße (alle Teile + mmproj), 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 _tree(repo):
|
for e in baum:
|
||||||
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)
|
||||||
@@ -28,18 +30,18 @@ def list_quants(repo: str) -> list[str]:
|
|||||||
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 sorted(quants, key=lambda q: (order.get(q, 99), q))
|
return [{"quant": q, "total_bytes": auswahl(baum, q)["total_bytes"]}
|
||||||
|
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)."""
|
||||||
url = (f"https://huggingface.co/api/models?filter=gguf"
|
params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit}
|
||||||
f"&sort=downloads&direction=-1&limit={limit}")
|
|
||||||
if q and q.strip():
|
if q and q.strip():
|
||||||
url += f"&search={q.strip()}"
|
params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt)
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=12.0) as c:
|
with httpx.Client(timeout=12.0) as c:
|
||||||
data = c.get(url).json()
|
data = c.get("https://huggingface.co/api/models", params=params).json()
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
@@ -69,26 +71,34 @@ 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. Behandelt Split-GGUFs
|
"""Beste GGUF-Auswahl eines Repos für einen Quant (siehe auswahl)."""
|
||||||
|
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()]
|
||||||
# bevorzugt den gewünschten Quant
|
# Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück —
|
||||||
pref = [e for e in model if q in e["path"].lower()]
|
# bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB).
|
||||||
chosen = pref or model
|
chosen = [e for e in model if q in e["path"].lower()]
|
||||||
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 dieser Gruppe.
|
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix).
|
||||||
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:
|
||||||
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
|
def _gruppe(pfad: str) -> str:
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""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
|
||||||
|
"""
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""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}"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"""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))
|
||||||
|
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)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""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()))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""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}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""„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
+498
-115
@@ -1,118 +1,388 @@
|
|||||||
"""
|
"""
|
||||||
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
|
Auftrags-System: Hintergrund-Aufträge (Updates, Modell-Downloads) mit Protokoll und Fortschritt.
|
||||||
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
|
Ursprünglich aus Mission Control v1 portiert.
|
||||||
|
|
||||||
|
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 _append_log(job: dict, line: str) -> None:
|
def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]:
|
||||||
job["log"].append(line)
|
"""Eine Nacharbeit unter festem Namen anmelden. Sie bekommt die nacharbeit_daten des Auftrags
|
||||||
if len(job["log"]) > _LOG_CAP:
|
als Schlüsselwort-Argumente und läuft nur, wenn der Auftrag erfolgreich war."""
|
||||||
del job["log"][0]
|
def anmelden(fn: Callable[..., None]) -> Callable[..., None]:
|
||||||
|
_NACHARBEITEN[name] = fn
|
||||||
|
return fn
|
||||||
|
return anmelden
|
||||||
|
|
||||||
|
|
||||||
def _pump_output(job: dict, stream) -> None:
|
def abschluss(name: str) -> Callable[[Callable[[dict], None]], Callable[[dict], None]]:
|
||||||
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
|
"""Einen Abschluss unter festem Namen anmelden. Anders als die Nacharbeit läuft er bei JEDEM Ende
|
||||||
buf = b""
|
(fertig, gescheitert, abgebrochen) und bekommt eine Kopie der Akte — etwa um das Ergebnis in den
|
||||||
overwrite = False
|
Update-Verlauf zu schreiben."""
|
||||||
pending_cr = False
|
def anmelden(fn: Callable[[dict], None]) -> Callable[[dict], None]:
|
||||||
|
_ABSCHLUESSE[name] = fn
|
||||||
def commit():
|
return fn
|
||||||
line = buf.decode("utf-8", "replace")
|
return anmelden
|
||||||
if overwrite and job["log"]:
|
|
||||||
job["log"][-1] = line
|
|
||||||
else:
|
|
||||||
_append_log(job, line)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
ch = stream.read(1)
|
|
||||||
if not ch:
|
|
||||||
break
|
|
||||||
if pending_cr:
|
|
||||||
pending_cr = False
|
|
||||||
if ch == b"\n":
|
|
||||||
commit(); overwrite = False; buf = b""
|
|
||||||
continue
|
|
||||||
commit(); overwrite = True; buf = b""
|
|
||||||
if ch == b"\r":
|
|
||||||
pending_cr = True
|
|
||||||
elif ch == b"\n":
|
|
||||||
commit(); overwrite = False; buf = b""
|
|
||||||
else:
|
|
||||||
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):
|
# --- Ablage ------------------------------------------------------------------------
|
||||||
job = JOBS[job_id]
|
|
||||||
job["state"] = "running"
|
def _jobs_dir() -> Path:
|
||||||
|
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:
|
try:
|
||||||
actual_args = list(args)
|
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||||
if sudo_password is not None:
|
tmp = _pfad(job["id"], ".json.tmp")
|
||||||
for i, arg in enumerate(actual_args):
|
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||||
if isinstance(arg, str):
|
tmp.replace(_pfad(job["id"], ".json"))
|
||||||
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
|
except OSError:
|
||||||
|
log.warning("jobengine: Akte %s nicht schreibbar", job.get("id"), exc_info=True)
|
||||||
|
|
||||||
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:
|
def _protokoll(job_id: str, zeile: str) -> None:
|
||||||
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
|
"""Eine Zeile von MC2 ins Protokoll des Auftrags schreiben."""
|
||||||
proc.stdin.flush()
|
try:
|
||||||
proc.stdin.close()
|
_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
|
||||||
|
|
||||||
_pump_output(job, proc.stdout)
|
|
||||||
proc.wait()
|
def zeilen_aus(text: str) -> list[str]:
|
||||||
job["returncode"] = proc.returncode
|
"""Protokolltext in Anzeigezeilen: `\\r` (Fortschrittsbalken von hf/tqdm/apt) überschreibt die Zeile."""
|
||||||
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
zeilen = []
|
||||||
|
for roh in text.split("\n"):
|
||||||
# Check if failed due to sudo authorization failure
|
if "\r" in roh:
|
||||||
if proc.returncode != 0 and job["log"]:
|
teile = [t for t in roh.split("\r") if t]
|
||||||
log_str = "\n".join(job["log"])
|
roh = teile[-1] if teile else ""
|
||||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
zeilen.append(roh)
|
||||||
job["sudo_failed"] = True
|
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:
|
||||||
|
proc.terminate()
|
||||||
|
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:
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Fehler: {exc}")
|
_protokoll(job["id"], f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||||
job["state"] = "failed"
|
log.warning("jobengine: Nacharbeit %s von %s gescheitert", name, job["id"], exc_info=True)
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
job = JOBS.get(job_id)
|
||||||
|
if job is None or job["state"] in _ENDE:
|
||||||
|
return
|
||||||
|
if (code := _exit_code(job_id)) is not None:
|
||||||
|
_abschliessen(job, code)
|
||||||
|
return
|
||||||
|
if time.time() >= naechste_pruefung:
|
||||||
|
if not _einheit_lebt(job_id):
|
||||||
|
time.sleep(0.5) # der Exit-Code kann gerade erst geschrieben werden
|
||||||
|
_abschliessen(job, _exit_code(job_id))
|
||||||
|
return
|
||||||
|
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
|
||||||
|
time.sleep(_TAKT_S)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
_starte_prozess(job, args, env)
|
||||||
|
except Exception as exc:
|
||||||
|
_protokoll(job["id"], f"[mc] Fehler: {exc}")
|
||||||
|
job["error"] = str(exc)
|
||||||
|
_abschliessen(job, -1)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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 not None:
|
if job is None or not total_bytes or total_bytes <= 0:
|
||||||
job["progress"] = 0
|
return
|
||||||
job["total_bytes"] = total_bytes
|
job["fortschritt"] = {"ordner": local_dir, "gesamt": 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")
|
||||||
@@ -120,7 +390,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
|||||||
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 ("done", "failed", "canceled"):
|
if not j or j["state"] in _ENDE:
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
inc = glob.glob(pat, recursive=True)
|
inc = glob.glob(pat, recursive=True)
|
||||||
@@ -147,49 +417,162 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
|||||||
threading.Thread(target=_watch, daemon=True).start()
|
threading.Thread(target=_watch, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
# --- Verwaltung -------------------------------------------------------------------
|
||||||
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]
|
||||||
# Mask password in log if present in args
|
job = {
|
||||||
log_args = list(args)
|
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
|
||||||
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 {},
|
||||||
}
|
}
|
||||||
if on_done:
|
JOBS[job_id] = job
|
||||||
JOBS[job_id]["_on_done"] = on_done
|
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args))
|
||||||
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
|
_speichern(job)
|
||||||
return job_id
|
return job
|
||||||
|
|
||||||
|
|
||||||
def active_in_group(group: str) -> dict | None:
|
def _aktiv_in(group: str) -> dict | None:
|
||||||
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
|
for j in list(JOBS.values()):
|
||||||
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:
|
||||||
job = JOBS.get(job_id)
|
with _LOCK:
|
||||||
if not job or job["state"] in ("done", "failed", "canceled"):
|
_laden()
|
||||||
return False
|
job = JOBS.get(job_id)
|
||||||
job["canceled"] = True
|
if not job or job["state"] in _ENDE:
|
||||||
_append_log(job, "[mc] Abbruch angefordert…")
|
return False
|
||||||
proc = _PROCS.get(job_id)
|
job["canceled"] = True
|
||||||
if proc is not None:
|
_speichern(job)
|
||||||
|
_protokoll(job_id, "[mc] Abbruch angefordert…")
|
||||||
|
if job["state"] == "queued":
|
||||||
|
_abschliessen(job, None)
|
||||||
|
elif job.get("art") == "systemd":
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)),
|
||||||
|
capture_output=True, timeout=15)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.warning("jobengine: Abbruch von %s gescheitert", job_id, exc_info=True)
|
||||||
|
elif (proc := _PROCS.get(job_id)) is not None:
|
||||||
|
_beende_gruppe(proc)
|
||||||
else:
|
else:
|
||||||
job["state"] = "canceled"
|
_abschliessen(job, None)
|
||||||
job["finished_at"] = time.time()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def public_jobs() -> list[dict]:
|
def public_jobs(mit_log: bool = True) -> list[dict]:
|
||||||
"""Jobs ohne interne Felder (_on_done) für die API."""
|
"""Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende)."""
|
||||||
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|
with _LOCK:
|
||||||
|
_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)
|
||||||
|
|||||||
+229
-79
@@ -6,9 +6,13 @@ 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 (
|
||||||
@@ -17,12 +21,18 @@ 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,
|
||||||
@@ -53,8 +63,77 @@ 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
|
||||||
@@ -212,7 +291,15 @@ 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."""
|
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung. Innerhalb von sammeln()
|
||||||
|
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")
|
||||||
@@ -236,7 +323,7 @@ def write_config(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:
|
||||||
@@ -261,7 +348,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/OPTIMIZATION_PLAN.md §9.4 V6).
|
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/archiv/2026-06-30-optimierungsplan.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
|
||||||
@@ -368,70 +455,8 @@ 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 →
|
||||||
@@ -444,6 +469,10 @@ 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
|
||||||
@@ -463,6 +492,7 @@ 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)."""
|
||||||
@@ -474,22 +504,23 @@ def set_role(model_id: str, role: str | None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
@_mit_sperre
|
||||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
def add_role(model_id: str, role: str) -> bool:
|
||||||
|
"""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()
|
||||||
spec = (cfg.get("models") or {}).get(model_id)
|
models = cfg.get("models") or {}
|
||||||
if not spec:
|
rolle = (role or "").strip().lower()
|
||||||
|
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||||
return False
|
return False
|
||||||
cmd = str(spec.get("cmd", ""))
|
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||||
if _CTX_RE.search(cmd):
|
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||||
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
|
models[model_id]["aliases"] = bisher + [rolle]
|
||||||
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)."""
|
||||||
@@ -502,6 +533,15 @@ 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.
|
||||||
@@ -513,8 +553,17 @@ 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()
|
||||||
if (m := _PATH_RE.search(cmd)):
|
# Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder.
|
||||||
|
# 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):
|
||||||
@@ -542,7 +591,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):
|
if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path):
|
||||||
try:
|
try:
|
||||||
os.remove(m_path)
|
os.remove(m_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -550,7 +599,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 os.path.basename(dirname) != "models":
|
if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve():
|
||||||
os.rmdir(dirname)
|
os.rmdir(dirname)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -610,3 +659,104 @@ 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)}"}
|
||||||
|
|||||||
+765
-802
File diff suppressed because it is too large
Load Diff
@@ -1,128 +0,0 @@
|
|||||||
"""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
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""
|
||||||
|
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,16 +21,15 @@ 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")
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
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` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
|
- **coding**: Code-Arbeit → `coder`; 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,8 +19,6 @@ 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(
|
||||||
@@ -36,25 +34,46 @@ _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.
|
||||||
VISION_CAPABLE = {"vision", "scout"}
|
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
|
||||||
|
# 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)."""
|
||||||
@@ -117,6 +136,3 @@ 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)
|
|
||||||
|
|||||||
+117
-131
@@ -1,131 +1,117 @@
|
|||||||
"""
|
"""
|
||||||
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"),
|
||||||
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
|
# Seit 24.09.2026: Bild-Zwilling des Coders — Coder-Anfragen mit neuem Bild gehen hierhin statt an vision.
|
||||||
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
|
# "" = auch Coder-Bilder gehen an vision.
|
||||||
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
"coder_vision": os.environ.get("MC_ROUTE_CODER_VISION", "coder-bild"),
|
||||||
}
|
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
|
||||||
|
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
|
||||||
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
|
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
||||||
FIELDS: list[dict] = [
|
}
|
||||||
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
|
|
||||||
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
|
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
|
||||||
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
|
FIELDS: list[dict] = [
|
||||||
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
|
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
|
||||||
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
|
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
|
||||||
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
|
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
|
||||||
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
|
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
|
||||||
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
|
||||||
]
|
{"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},
|
||||||
_LOCK = threading.Lock()
|
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
|
||||||
_CACHE: dict = {"mtime": None, "policy": None}
|
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
||||||
|
]
|
||||||
|
|
||||||
def _read_file() -> dict:
|
_LOCK = threading.Lock()
|
||||||
try:
|
_CACHE: dict = {"mtime": None, "policy": None}
|
||||||
with open(POLICY_PATH, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data if isinstance(data, dict) else {}
|
def _read_file() -> dict:
|
||||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
try:
|
||||||
return {}
|
with open(POLICY_PATH, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
def _coerce(patch: dict) -> dict:
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
|
return {}
|
||||||
spec = {f["key"]: f for f in FIELDS}
|
|
||||||
out: dict = {}
|
|
||||||
for k, v in (patch or {}).items():
|
def _coerce(patch: dict) -> dict:
|
||||||
f = spec.get(k)
|
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
|
||||||
if not f:
|
spec = {f["key"]: f for f in FIELDS}
|
||||||
continue # unbekannte Keys still verwerfen
|
out: dict = {}
|
||||||
if f["type"] == "int":
|
for k, v in (patch or {}).items():
|
||||||
iv = int(v)
|
f = spec.get(k)
|
||||||
lo, hi = f.get("min", 1), f.get("max", 10**9)
|
if not f:
|
||||||
if not (lo <= iv <= hi):
|
continue # unbekannte Keys still verwerfen
|
||||||
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
|
if f["type"] == "int":
|
||||||
out[k] = iv
|
iv = int(v)
|
||||||
elif f["type"] == "bool":
|
lo, hi = f.get("min", 1), f.get("max", 10**9)
|
||||||
out[k] = bool(v)
|
if not (lo <= iv <= hi):
|
||||||
else: # str
|
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
|
||||||
sv = str(v).strip()
|
out[k] = iv
|
||||||
if k not in ("coder_lite", "vision") and not sv:
|
elif f["type"] == "bool":
|
||||||
raise ValueError(f"{k} darf nicht leer sein")
|
out[k] = bool(v)
|
||||||
out[k] = sv
|
else: # str
|
||||||
return out
|
sv = str(v).strip()
|
||||||
|
if k not in ("coder_lite", "vision", "coder_vision") and not sv:
|
||||||
|
raise ValueError(f"{k} darf nicht leer sein")
|
||||||
def _coerce_safe(patch: dict) -> dict:
|
out[k] = sv
|
||||||
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
|
return out
|
||||||
try:
|
|
||||||
return _coerce(patch)
|
|
||||||
except (ValueError, TypeError):
|
def _coerce_safe(patch: dict) -> dict:
|
||||||
return {}
|
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
|
||||||
|
try:
|
||||||
|
return _coerce(patch)
|
||||||
def load_policy() -> dict:
|
except (ValueError, TypeError):
|
||||||
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
|
return {}
|
||||||
try:
|
|
||||||
mtime = POLICY_PATH.stat().st_mtime
|
|
||||||
except OSError:
|
def load_policy() -> dict:
|
||||||
mtime = None
|
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
|
||||||
with _LOCK:
|
try:
|
||||||
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
|
mtime = POLICY_PATH.stat().st_mtime
|
||||||
merged = {**DEFAULTS}
|
except OSError:
|
||||||
if mtime is not None:
|
mtime = None
|
||||||
merged.update(_coerce_safe(_read_file()))
|
with _LOCK:
|
||||||
_CACHE["mtime"] = mtime
|
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
|
||||||
_CACHE["policy"] = merged
|
merged = {**DEFAULTS}
|
||||||
return dict(_CACHE["policy"])
|
if mtime is not None:
|
||||||
|
merged.update(_coerce_safe(_read_file()))
|
||||||
|
_CACHE["mtime"] = mtime
|
||||||
def save_policy(patch: dict) -> dict:
|
_CACHE["policy"] = merged
|
||||||
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
|
return dict(_CACHE["policy"])
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
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}
|
||||||
+75
-27
@@ -8,8 +8,10 @@ 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
|
||||||
@@ -126,33 +128,25 @@ def find_hermes_agent_git() -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def get_engine_version() -> dict:
|
def get_engine_version() -> dict:
|
||||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
|
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan).
|
||||||
git_info = get_git_info(engine_path)
|
|
||||||
if git_info:
|
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit
|
||||||
return {**git_info, "type": "git"}
|
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die
|
||||||
|
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
|
||||||
candidates = [
|
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
|
||||||
os.path.join(engine_path, "llama-server"),
|
binary = os.path.join(engine_path, "llama-server")
|
||||||
os.path.join(engine_path, "bin", "llama-server"),
|
if not os.path.exists(binary):
|
||||||
"/usr/local/bin/llama-server",
|
return {"type": "unknown"}
|
||||||
"/usr/bin/llama-server",
|
try:
|
||||||
"llama-server"
|
env = dict(os.environ, LD_LIBRARY_PATH=engine_path)
|
||||||
]
|
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env)
|
||||||
|
text = (res.stdout or "") + (res.stderr or "")
|
||||||
for binary in candidates:
|
except Exception:
|
||||||
if binary != "llama-server" and not os.path.exists(binary):
|
return {"type": "unknown"}
|
||||||
continue
|
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)):
|
||||||
try:
|
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"}
|
||||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
|
zeile = next((z for z in text.splitlines() if z.startswith("version")), "")
|
||||||
output = (res.stdout or "").strip() or (res.stderr or "").strip()
|
return {"type": "binary", "version_text": zeile or "unbekannt"}
|
||||||
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": {}}
|
||||||
@@ -197,5 +191,59 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"""
|
|
||||||
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]
|
|
||||||
@@ -0,0 +1,768 @@
|
|||||||
|
"""
|
||||||
|
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"]]
|
||||||
|
|
||||||
|
|
||||||
|
# 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),
|
||||||
|
}[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}."}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
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,24 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
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, Health-Wächter (sentry) und Mem0-Auto-Dedupe am
|
Bisher hingen Re-Warm-Wächter und Health-Wächter 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 DIESELBEN Loops (unveränderte
|
sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener
|
||||||
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
|
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)
|
||||||
• sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
|
• waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste,
|
||||||
|
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 / MC_MEM_DEDUPE_ENABLED)
|
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_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.
|
||||||
"""
|
"""
|
||||||
@@ -28,8 +31,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from config import CONFIG_PATH
|
from config import CONFIG_PATH
|
||||||
from services import memory as memory_svc
|
from kern.einstellungen import einstellungen
|
||||||
from services import sentry, warmer
|
from services import waechter, warmer
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||||
@@ -59,17 +62,14 @@ async def config_watch() -> None:
|
|||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
tasks: list[asyncio.Task] = []
|
tasks: list[asyncio.Task] = []
|
||||||
if warmer.ENABLED:
|
# Warm-Set und llama-swap gibt es nur auf der KI-Box; der Homelab-Teil hat nur den Wächter.
|
||||||
|
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 sentry.ENABLED:
|
if waechter.ENABLED:
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
tasks.append(asyncio.create_task(waechter.waechter_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
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""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
@@ -0,0 +1,215 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""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()}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""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_timer_laeuft_mit_pbs_nur_von_hand():
|
||||||
|
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" not in aktiv # Einschalten ist ein Schritt des Leads (docs/BETRIEB.md)
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""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"}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user