Commit graph

174 commits

Author SHA1 Message Date
oskar 57a5f66d0d fix(ha/ken): alias Gniadka -> Gniazdka 2026-07-23 12:35:56 +02:00
oskar 4366cdb871 drift(ken): HA znormalizowal legacy schema (trigger->triggers, service->action) po pierwszym pelnym deployu 2026-07-22 18:48:31 +02:00
oskar adeed6b902 feat(ha): deploy.sh — api write path with drift-abort and verify 2026-07-22 18:31:22 +02:00
oskar 2dac154d78 feat(remediation): node-agent wykonuje zlecone akcje lokalnie — koniec SSH z executora
Executor nie ma klienta ssh ani klucza do floty (uid 1000 homelab, brak
~/.ssh, brak resolucji nazw wezlow) — container_restart przez subprocess ssh
failowal w 6ms na kazdej probie. Zamiast dodawac SSH do executora, kierunek
jest odwrocony: executor zapisuje zlecenie do
/opt/homelab/actions/dispatch/<node>/<action_id>.json, a node-agent na
docelowym wezle (ktory ma dzialajacy docker.sock i juz ma klucz SSH do VPS
uzywany do shippingu eventow) sam je odbiera i wykonuje lokalnie.

- executor: _dispatch_container_restart pisze zlecenie zamiast ssh;
  _reconcile_running_actions konsumuje zwrotne action_result eventy i
  timeoutuje akcje bez odpowiedzi (ACTION_TIMEOUT_SECS, domyslnie 300s).
  redeploy/disk_cleanup/alert_only bez zmian.
- node-agent: nowy krok w petli — rsync-pull wlasnej podkatalogu dispatch z
  VPS (ten sam klucz co _ship_events_to_vps, w przeciwnym kierunku; no-op na
  VPS, gdzie katalog jest lokalny), walidacja (node_name, whitelist tylko
  container_restart, odmowa restartu wlasnego kontenera), wykonanie przez
  docker SDK, raport jako event action_result (istniejacy kanal shippingu).
  Idempotencja przez znacznik w /opt/homelab/state/processed-actions/.
- 26 nowych testow (10 executor, 16 node-agent), pelny suite obu serwisow
  183/183 zielony.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:08:01 +02:00
oskar 1b48fad451 feat(ha): initial import of ken instance config
First production run of the api adapter against ken (192.168.31.7,
HAOS): 118 automations, 5 scripts, 3 scenes, 7 dashboards (default + 6
named — one registered dashboard was never configured, config_not_found,
reported not fatal), area/entity registries, and 5 input_* helper
domains present on this instance.
2026-07-22 18:07:26 +02:00
oskar 5b9111afa7 feat(ha): api adapter for import.sh
ken is HAOS (no SSH/docker exec path), so it needs a REST/WebSocket-only
adapter: automations/scripts/scenes via one GET per
/api/config/<domain>/config/<id>, dashboards/area+entity registries/
input_* helpers via the HA WebSocket API (read-only commands only).

scripts/ha/lib/ha_api.py and ha_ws.py never take a token as a value —
only a token_path, read from disk in-process — so the bearer token never
touches a subprocess argv or a log line. ha_ws.py depends on the optional
websocket-client package and raises a clear, actionable ImportError if
it's missing rather than a raw traceback; import.sh still completes the
REST-only part of the import in that case.

Automations/scripts/scenes reuse split.write_split() so both adapters
produce byte-identical config/<instance>/ layouts and the same
idempotent stale-file cleanup on re-run.

docker-exec adapter logic is untouched.
2026-07-22 18:07:26 +02:00
oskar 9a5c160b39 fix(security): control-plane UI bind na Tailscale IP — 18180 z /action/mutate byl publicznie dostepny bez auth na VPS
Co: operator-ui (18180:8080) bindowal na 0.0.0.0, wiec /action/mutate
(brak autoryzacji, moze przenosic akcje do "approved") byl osiagalny z
publicznego internetu. Fix stosuje istniejacy wzorzec repo
(TAILSCALE_BIND_IP env var, patrz fleet-prometheus/llm-gateway/gokapi) +
dual-bind jak w ollama (127.0.0.1 obok TAILSCALE_BIND_IP), bo node-agent
na VPS laczy sie z network_mode: host przez localhost:18180/summary.

Nie ruszono operator_ui.py / mutate_action — auth to osobny temat.
2026-07-22 17:29:11 +02:00
oskar a73a986bbe feat(kb-query): add search frontend (module 5 phase 4, plan §7, Krok 4)
Krok 4 of the phase-4 plan done ahead of the local-embed-fallback step
(Krok 2, deliberately deferred -- embed stays a plain SOLARIA call, per
task instruction): one FastAPI process now serves both the /search API
and the UI, no separate frontend build (plan §2 decision 4).

- GET / renders a Jinja2 shell; app/static/app.js (vanilla, no build) and
  style.css are the whole client. Query -> /search, results grouped by
  envelope_id client-side (chunks sorted by dist, <details> fragments).
- Colour thresholds per plan §7: dist<0.45 green, 0.45-0.55 yellow (still
  shown with a warning), >0.55 never rendered as an individual result; if
  a query ends up with nothing renderable, one "Brak odpowiedzi w KB"
  message replaces the list, carrying the best observed dist.
- Paperless hits link out; gmail hits get a "kopiuj Message-ID" button
  (there's nothing to link to yet, plan §2 decision 3) plus header
  metadata. Cascade/flat toggle defaults to cascade. Footer shows
  sol_status, refreshed from /healthz on load and after each search.
- /search gained additive summary/summary_tags fields (document_summary,
  haiku track) so the UI can show a document summary as each result
  group's header -- non-breaking, existing response shape untouched.
- Tests: app/db.py + app/search.py unit tests (mocked DB/HTTP, no live
  deps) cover the new summary join; tests/test_frontend.py drives GET /
  and /static/* via TestClient without running the DB-requiring lifespan;
  tests/frontend/app.test.js (Node's built-in test runner, no framework)
  covers query-URL encoding, threshold colouring, and envelope grouping.
- Verified live: docker build + container against kb-postgres@PIHA over
  LAN and Ollama@SOLARIA over Tailscale -- GET / (HTML), /static/app.js,
  /healthz, and /search (cascade + flat) all round-tripped correctly,
  including real summary/summary_tags data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:49:36 +02:00
oskar 970b8cc023 feat(ha): archival import of ken-legacy config
Read-only import of the pre-migration homeassistant5 container (piha),
per DESIGN.md phase-0 scope: 60 automations (split one-file-per-id),
5 scenes, 1 script, plus curated .storage/* export (area/entity
registries, input_boolean/input_text, lovelace dashboards).

No token at ~/.config/ha-deploy/ken-legacy.token — fixtures step
fail-softed as designed, no repo import.sh changes needed. Verified:
gitignore-excluded paths (secrets.yaml, *.db*, logs, .storage/auth*,
core.restore_state, tts/, deps/, backups/, .cloud/) correctly stripped
from the pull; three consecutive import.sh runs produced byte-identical
output under config/ken-legacy/ and storage-export/ken-legacy/
(idempotent); automation count matches the 2026-07-22 recon estimate
(~60) exactly. README.md only touched to note the import date.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:49:09 +02:00
oskar 4bfd6c4429 fix(observer): map containers_not_running to unhealthy status + incident — dead/crash-looping containers created no incident, so supervisor never alerted
node-agent (and stability-agent) emit containers_not_running for exited/dead
and crash-looping containers, but the observer's event->status/incident map
only handled service_recovered/service_healthy/service_unhealthy/
healthcheck_failed. containers_not_running fell through: status stayed at its
last "healthy" value and no incident was opened, so the supervisor saw no drift
and generated no action — a dead container produced ZERO operator alerts
(matches the "action queue empty despite failures" symptom).

- containers_not_running now sets status=unhealthy and opens an incident whose
  trigger_type ("containers_not_running") is already in the supervisor's
  CONTAINER_RESTART_TRIGGERS, so remediation (container_restart) fires with no
  supervisor change. Recovery is unchanged: service_healthy resolves the
  incident via the existing svc_key->incident_id link.
- container_restarting / container_state_unexpected (added in 4746ebe) are kept
  intentionally observational — no incident, status not flipped to unhealthy
  (would cause a false redeploy for a transient blip) — but leave a
  last_observation trace so they don't vanish. A real crash-loop still escalates
  via node-agent re-emitting containers_not_running.

Tests: services/control-plane/tests/test_observer_container_events.py — status
+ incident + trigger_type, end-to-end reconcile -> container_restart, recovery
auto-resolve, observational no-incident/trace, idempotent (no incident
multiplication). Full control-plane suite: 117 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:28:31 +02:00
oskar 77d55caf07 feat(ha): cutover ken to 31.7 HAOS, archive legacy container instance
instances.yaml pointed "ken" at the piha container (homeassistant5), a
pre-migration instance that never actually stopped running — it kept
firing automations in parallel with the real home instance (HAOS on a
dedicated RPi4, 192.168.31.7), causing duplicate triggers on shared
buttons/scenes. Repoint ken to the real instance (api adapter),
demote the container to ken-legacy (archived, import-only, never a
deploy target), and log the incident in DESIGN.md.
2026-07-22 16:17:18 +02:00
oskar b2379e3275 feat(kb): add kb-query service skeleton (search API, no ingress yet)
Module 5 phase 4 step 1 (docs/kb/modules/05-faza4-plan.md, §4): first
user-facing HTTP entry point to the KB. FastAPI wrapping
kb_retrieval.cascade_query/flat_query — GET /search (query_text -> embed via
Ollama@SOLARIA -> cascade/flat -> envelope join -> JSON with per-source
links) and GET /healthz. Search API only, no answer synthesis (phase 5) and
no server-side dist filtering — the 0.45/0.55 colour thresholds are a
frontend concern (plan §7, a later step).

Hard startup invariant (plan §2 decision 2): refuses to start unless the
configured EMBED_MODEL is present in both document_chunk.model and
document_summary.embedding_model. Note the latter: document_summary.model is
the LLM that *wrote* the summary (claude-haiku-4-5/gemma3:12b), not the
embedder — checked live against kb-postgres@PIHA before writing this, see
app/startup.py's docstring. Verified end-to-end with a live docker run: the
invariant crash-loops on a mismatched EMBED_MODEL and passes through to a
real /search hit against the live corpus with a correct model.

Repo-only: no deploy, no npm/OIDC/DNS wiring (plan §8, later step), no local
embed fallback (plan §5, later step) — Ollama@SOLARIA is called directly and
a failure surfaces as 503, not a crash.

Also: scripts/deploy/deploy.sh's gate now builds each service via
`docker compose build` instead of a raw `docker build <svc_dir>`, so a
service whose docker-compose.yml declares a repo-root build context (needed
here to COPY packages/kb-retrieval/, the packages/ Dockerfile convention
already documented in CLAUDE.md) resolves the same way in the gate as it
does at real deploy time (deploy-node.sh's `docker compose ... up --build`).
No behavior change for existing single-context services — verified against
llm-gateway's compose file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:06:18 +02:00
oskar ee48319a86 feat(ha): skeleton for Home Assistant configs-as-code subproject
Structure only, no deploy path: DESIGN.md decision registry (phasing,
per-instance adapter, bidirectional sync with drift-abort on deploy,
reload-vs-restart gating, token handling), instances.yaml (ken/piha via
docker-exec, chelsty-ha via api), config/storage-export/fixtures dirs,
and a read-only scripts/ha/import.sh (docker-exec adapter only) with a
canonical YAML normalize+split library and an offline determinism test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:29:39 +02:00
oskar d2fe7f93c8 fix(actions): mirror pending z VPS na panel PIHA + mutate proxy do VPS — Action Queue byla slepa 2026-07-20 19:55:54 +02:00
oskar cb8b60fee6 feat(kb): faza 3 krok 5 — cykliczny ingest (systemd timer) + alerting
documents-ingest-cyclic (jobs/documents-ingest/src/documents_ingest/cyclic_ingest.py):
orkiestruje paperless_adapter -> chunk_embed -> summarize(--backend anthropic,
claude-haiku-4-5) -> summarize(--embed-summaries) bez zmian w samych jobach. Ollama@SOLARIA
(availability_target: medium) jest tolerowana offline: probe GET /api/tags przed obu
etapami embed, brak -> pominięcie, nie fail (oba embed passy idempotentne, nadrobią się na
kolejnym ticku). Czwarty etap (embed-summaries) dopisany ponad plan §7.1 (który wymieniał
tylko 3 kroki) — bez niego nowe streszczenia miałyby embedding=NULL i byłyby niewidoczne dla
cascade_query (bramka kroku 4, WHERE embedding IS NOT NULL); potwierdzone z Oskarem.
Predykaty pass/fail każdego etapu 1:1 z exit-checkiem danego joba (chunks_errors,
llm_errors, stats-balance itd.) — etapy izolowane, nie fail-fast (wcześniejszy fail nie
blokuje kolejnych, tak jak joby izolują błędy per wiersz). Metryki .prom (atomowy zapis,
last_success_timestamp trzymany z poprzedniego pliku przy failu) do
/opt/homelab/state/node-exporter/kb-ingest.prom. 36 nowych testów (202/202 pakietu).

systemd (jobs/documents-ingest/systemd/): pierwszy systemd-timer w repo — kb-ingest.timer
(OnCalendar=*-*-* 03:30, Persistent=true, plan §7.1) + kb-ingest.service (host-level, User
oskar, EnvironmentFile /opt/homelab/kb/.env) + kb-ingest-run.sh (log per-run do
/opt/homelab/logs/kb-ingest/, konwencja repo). Instalacja i sekrety udokumentowane w
README (Faza 3 krok 5) — instalacja na PIHA dopiero po merge.

fleet-prometheus (rules/kb-ingest.yml): KbIngestStale (>172800s od last_success, critical)
+ KbEmbedBacklogGrowing (backlog>0 przez 72h, warning) — dostawa istniejącym torem
brain-watchdog->Telegram, bez Alertmanagera (konwencja liveness.yml).

node_exporter: owner_node vps -> per-host (service.yaml) + wpis + override
(--collector.textfile.directory, bez nowego mountu — czyta przez istniejący /:/host:ro) +
topology.yaml dla PIHA. Domyka pozycję z docs/backlog.md "stability-agent / node_exporter
owner_node single, biegaja wielomiejscowo -> per-host" (połowę — node_exporter; stability-agent
zostaje osobnym follow-upem) w ramach paczki B inwentaryzacji monitoringu dla PIHA.

Test end-to-end na żywo na PIHA (2× --apply, po potwierdzeniu z Oskarem): pierwszy run
złapał 5 dokumentów faktycznie nowych w Paperless (nieoczekiwane, niezwiązane z tym
taskiem) -> 82 nowe chunki (2 ocr_junk), 5 nowych streszczeń, 5 embeddingów streszczeń,
0 błędów, metryki zapisane. Drugi run: pełna idempotencja, wszystko 0. ANTHROPIC_API_KEY
dodany przez Oskara ręcznie do /opt/homelab/kb/.env (nigdy nie logowany/generowany).

Co dalej: prawdziwa instalacja systemd (services.yaml już przygotowany, po merge),
zdecydowanie czy stability-agent też idzie na per-host przy okazji.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 15:54:13 +02:00
oskar 4083f18673 fix(agent-system): deploy.sh nigdy nie dolaczal override'u hosta -> panel czytal legacy Redis, nie VPS observera
Root cause rozjazdu panelu agents.okit.pl vs services.json NIE lezal w
observer.py/operator_ui.py (te sa poprawne: ghost-prune dziala in-memory
i na plik co cykl, operator_ui czyta services.json na zywo bez cache).

Prawdziwy lancuch: npm proxy_host agents.okit.pl -> 100.108.208.3:18180
(Tailscale IP PIHA, nie VPS!) -> legacy kontener agent-system-webui
(services/agent-system/, sprzed migracji do control-plane/, brak
service.yaml, nieobecny w hosts/piha/services.yaml i topology.yaml).
Jego runtime-materializer czytal z Redis (homelab:services:*), do ktorego
NIC juz nie pisze w obecnej architekturze -- stad 27 martwych/ghost
wpisow (w tym hash-prefixed) i inna liczba serwisow (110 vs 117 w
services.json z VPS).

Fix na to juz istnial w repo od 2026-05-27 (7277bdc): materializer.py ma
materialize_from_api() ktory mirroruje czysty output observera z VPS przez
CONTROL_PLANE_URL, a hosts/piha/runtime/agent-system/docker-compose.override.yml
ustawia ta zmienna. Nigdy sie jednak nie aktywowal, bo
services/agent-system/deploy.sh (jedyna sciezka deployu tego serwisu)
wolal `docker compose up` uzywajac WYLACZNIE docker-compose.yml, bez
dolaczania override'u z hosts/ -- w odroznieniu od control-plane/
deploy-local.sh i stability-agent/deploy-local.sh, ktore ten wzorzec juz
stosuja.

Fix: deploy.sh dolacza teraz hosts/piha/runtime/agent-system/docker-compose.override.yml
(ten sam wzorzec co control-plane, ktory hardkoduje vps). Po nastepnym
`services/agent-system/deploy.sh` na PIHA runtime-materializer zacznie
mirrorowac /nodes /services /summary itd. z control-plane API zamiast
Redis -- panel bedzie pokazywal to samo co services.json.

Docker socket PIHA (DOCKER_API_ERROR 2026-07-17T00:00:40Z): NIE regres
group_add/gid (node-agent ma poprawne grupy 999/123, gid docker.sock=123
sie zgadza). Wszystkie ~40 kontenerow na PIHA wystartowaly jednoczesnie o
00:00:26 UTC -- to byl automatyczny apt-get upgrade docker-ce
29.6.1->29.6.2 (apt/history.log, Start-Date 02:00:08 CEST), ktory
zrestartowal Docker Engine. stability-agent (root) trafil na gniazdo w
~1-sekundowym oknie zanim daemon w pelni wstal (docker.service
ActiveEnterTimestamp 02:00:41 CEST). Jednorazowy, samo-naprawiony,
zdarzenie sie nie powtorzylo. Brak zmiany kodu.

Testy: ast.parse (observer.py, operator_ui.py, materializer.py) OK,
bash -n deploy.sh OK, docker compose config (merge z override) poprawnie
wstrzykuje CONTROL_PLANE_URL, pytest services/control-plane/tests 114
passed, pytest services/agent-system/telegram-bot/tests 8 passed.

Co NIE zrobiono (poza zakresem/deploy nalezy do operatora): rzeczywisty
redeploy agent-system na PIHA + weryfikacja panelu na zywo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 14:14:19 +02:00
oskar 569f95005d feat(kb): faza 3 krok 2 — migracja 004 (document_summary) + pilot streszczeń A/B
Migracja 004: document_summary (envelope_id, summary, tags JSONB, model, embedding
VECTOR(1024) + HNSW cosine, embedding_model, UNIQUE(envelope_id, model) od razu — wzorzec
002/003). Zastosowana na żywej bazie kb-postgres@PIHA.

Job documents-ingest-summarize: wejście = document_chunk.text WHERE excluded_reason IS NULL
per koperta source='paperless' (duplikaty przez entities[duplicate_of] pomijane w całości),
wymuszony JSON {summary, tags} przez --backend ollama|anthropic, słownik tagów kontrolowany
(tags-vocab.yaml) z max 3 free-form, map-reduce dla dokumentów >200k znaków (grupy ~20
chunków), bilans + idempotencja + izolacja błędów per wiersz wg wzorców rodziny jobów.
Osobny --embed-summaries (bge-m3, reużywa chunk_embed.embed_chunk).

Bug znaleziony i naprawiony w trakcie pilota: brak options.num_ctx w wywołaniach Ollamy
powodował, że gemma3:12b używał domyślnego runtime kontekstu (~2048 tok), nie
zadeklarowanego 128k — dla 71/157 dokumentów (45%, >8k znaków) treść była cicho ucinana
(zweryfikowane: prompt_eval_count=2051 dla dokumentu 93k znaków). Naprawa: compute_num_ctx()
liczy num_ctx z długości promptu (~3 znaki/token, cap 131072). Cały tor lokalny przeliczony
od zera po naprawie.

Wynik pilota (186 dok. paperless, 3 duplikaty, 26 bez aktywnych chunków → 157 oczekiwanych
na tor): tor lokalny (gemma3:12b) 155/157 (2 izolowane błędy JSON po retry: paperless:24,
paperless:61), tor referencyjny (claude-haiku-4-5) 157/157, 0 błędów JSON, 0 tagów
ucinanych — słownik przestrzegany w 100% przypadków. Oba komplety zembedowane (bge-m3).
Znaleziony przy okazji: prompt do tagów wymagał dopracowania — pierwsza wersja pozwalała
modelowi zwracać tagi po angielsku spoza słownika; wzmocniona instrukcja (słownik w
pierwszej kolejności, "nigdy po angielsku") poprawiła zgodność w 2/3 przypadków testowych.

Porównanie A/B (~15 dok.) i weryfikacja końcowa (bilans, sanity SQL, retrieval po summary)
odłożone do następnej sesji.

Testy: 157 (152 nowe/summarize.py + istniejące), mocki API/Ollama/DB, bilans, idempotencja,
regresja num_ctx.

Co najmniej 3 decyzje wymagały zatrzymania i potwierdzenia z Oskarem w sesji (sposób podania
klucza API, wybór modelu lokalnego gemma3:12b, naprawa+przeliczenie całego toru lokalnego po
odkryciu buga num_ctx) — udokumentowane w transkrypcie sesji.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 13:30:47 +02:00
oskar dff76ecd30 fix(events): service_healthy tylko na transition + cleanup 358k backlog + retencja — glob zalewu paralizowal reconcile supervisora
- node-agent: service_healthy emitowany tylko przy przejsciu w stan zdrowy
  (per-service in-memory state), nie co cykl dla kazdego zdrowego serwisu.
  To samo dla control-plane HTTP probe. healthcheck_failed/containers_not_running/
  incydenty pozostaja emitowane bez zmian (realne sygnaly).
- node-agent: naprawiono _cleanup_control_plane_fs — istniejaca retencja
  eventow byla martwa od migracji observer_checkpoint.json na epoch-int
  (str(path) <= int rzucal TypeError, lapane cicho przez broad except).
  Teraz porownuje epoch-do-epoch i czysci wylacznie service_healthy/node_health
  starsze niz checkpoint + 3-dniowy bufor; healthcheck_failed/incydenty/ha_*
  zachowane bezterminowo.
- scripts/maintenance/cleanup_event_backlog.py: jednorazowy skrypt do
  bezpiecznego czyszczenia backlogu na VPS (dry-run domyslnie, --apply
  do usuniecia). Ten sam warunek: typ szumu + starsze niz checkpoint + 1h bufor.
2026-07-16 20:20:17 +02:00
oskar 8f42452f53 feat(kb): faza 3 krok 1 — porządki po pilocie retrieval (śmieć + dedup + klucz modelu)
Migracja 003 (UNIQUE+model, excluded_reason) zastosowana na żywej bazie kb-postgres@PIHA
(2683 chunki, bez DELETE). Kalibracja heurystyki ocr_junk (3 sygnały z planu §3.1) ujawniła
realną sprzeczność z planem: sygnał 1 (dowolny znak kontrolny) fałszywie łapał paperless:119
(wymagany aktywny) i legalny angielski tekst — próg doprecyzowany do >=5 wystąpień na
podstawie rozkładu na korpusie. 8 chunków oflagowanych ocr_junk po odjęciu potwierdzonych
fałszywych alarmów (kalendarz, mikro-fragmenty referencyjne).

Dedup: SQL z planu (exact content hash) znalazł 2 pary, ale nie wykrył znanego z pilota
duplikatu paperless:14≡74 (różne OCR, 99.2% chunków identycznych treściowo) — dodany fuzzy
check na potwierdzenie. Odrzucono 3 kandydatury o wysokim nakładaniu jako różne
wersje/typy dokumentów dzielące boilerplate PZU, nie duplikaty. 130 chunków oflagowanych
duplicate + entities[duplicate_of] na 3 kopertach.

chunk_embed.py: heurystyka is_ocr_junk() przed embedem (junk -> insert bez wywołania Ollamy,
embedding=NULL), ON CONFLICT rozszerzony o model, nowy licznik chunks_junk_flagged w bilansie,
testy (kody kreskowe, mojibake nie-junk, dot-leader, idempotencja, model w kluczu konfliktu).

Weryfikacja: 7 zapytań eval-setu z WHERE excluded_reason IS NULL — żadne trafienie nie
degraduje, kontrole negatywne bez zmian (>0.55), śmieć zniknął z top-5 zapytania 2,
paperless:119 pozostał aktywnym trafieniem. Bilans: 2545 aktywne / 130 duplicate / 8 ocr_junk.

Co najmniej 3 decyzje wymagały zatrzymania i potwierdzenia z Oskarem w sesji (kalibracja
progu sygnału 1, dołączenie 14≡74 mimo braku exact-hash matcha, odrzucenie 3 fałszywych
kandydatur dedup) — plan przewidywał, że heurystyka będzie się mylić; wszystkie decyzje
udokumentowane w transkrypcie sesji.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 17:24:38 +02:00
oskar 409b583ab9 fix(supervisor): odpornosc petli na zawieszenie + timeout blokujacych wywolan — petla stanela cicho po ha_websocket_dead (mozg martwy 24h, healthy ale nie tika) 2026-07-16 16:17:20 +02:00
oskar 5e920dd7c1 fix(ollama): restore GPU reservation on SOLARIA, close 07-15 cutover docs
Driver fixed 2026-07-16 (nvidia-driver-595-open from distro repo, old
graphics-drivers PPA for jammy disabled) — RTX 4070 Ti SUPER 16GB, CUDA
13.2, nvidia-container-toolkit already present from the 07-15 cutover
prerequisite install. Uncomments deploy.resources.reservations (nvidia
gpu) in services/ollama/docker-compose.yml, restoring clean formatting.

Docs close out the loose ends from the 07-15 declarative cutover:
- ollama-solaria-cutover runbook gets a "Wykonanie" section documenting
  what actually happened (mv instead of rsync for the model store, the
  missing nvidia-container-toolkit prerequisite, the driver-missing
  discovery, CPU-only cutover, then the 07-16 GPU fix) plus a note on
  the container disappearing after the 07-15 evening reboot (one-off,
  boots fine now, root cause not established).
- hosts/solaria/services.yaml: ollama role comment now reflects actual
  GPU-backed state instead of the previously-aspirational wording.
- hosts/solaria/README.md: drop stale Open WebUI mention (not in repo).
- docs/backlog.md: close the NVIDIA driver item; leave two follow-ups
  (Ollama call batching before the mail phase, UNIQUE(envelope_id,
  chunk_index) schema change for multi-model embeddings at phase 3).
- jobs/documents-ingest/README.md: timing section gets a GPU placeholder
  line next to the existing 0.79s/chunk CPU baseline, to be filled in
  after the live GPU benchmark.

Live recreate + GPU-vs-CPU embedding benchmark deliberately left for the
operator to run from the main checkout after merge, per worktree-aware
discipline — this worktree only owns the declarative fix and the docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 14:43:29 +02:00
oskar f2ba81bcc1 fix(ha-diag): node_name from env + fail-fast on unknown — evt-unknown-* files poisoned observer checkpoint (see d5139c9)
An "unknown"/empty node_name silently produced evt-unknown-* event files. One
landed in events/piha/ and, being lexically greater than every evt-piha-* name,
pinned the observer's (then lexical) checkpoint and blocked PIHA for ~34 days.

Defense in depth, two layers, both refuse rather than emit poison:
- config.py: node_name field_validator rejects ""/"unknown" (case-insensitive,
  trimmed); validate_default=True so the "unknown" default itself is rejected
  when NODE_NAME never reaches the process. main() catches ValidationError and
  exits 1 with a clear FATAL message instead of a raw traceback.
- event_emitter.py: EventEmitter.__init__ guards node_name at the exact poison
  site (node_name is embedded in the filename), so no future call path can
  reintroduce evt-unknown-*.

Precedence unchanged and correct: Settings.load() uses os.environ.setdefault, so
env NODE_NAME wins over YAML; YAML only supplies node_name when env is absent.
api.py never writes event files (only /health, /trigger), so its _node_name
global cannot produce poison — verified, left as-is.

Tests (services/ha-diag-agent/tests/): new test_config.py (env load, env>YAML
precedence, fail-fast on unknown/empty/whitespace/default/YAML-unknown) and
EventEmitter guard tests. Full unit suite: 139 passed, 0 regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:48:58 +02:00
oskar f57a01a20d fix(ollama): disable GPU reservation — no NVIDIA driver on SOLARIA host
Live cutover 2026-07-15 odkrył: nvidia-smi not found, brak libnvidia-ml
w ldcache — host nigdy nie miał sterownika, natywna Ollama mieliła na CPU.
Sekcja deploy.resources (nvidia) zakomentowana do czasu instalacji sterownika
(backlog). nvidia-container-toolkit doinstalowany na hoście (prerequisite,
brakował w runbooku). Kontener zweryfikowany: 5/5 modeli z bind-mounta,
embed bge-m3 dim=1024, reachable z PIHA przez tailnet.
2026-07-15 19:46:48 +02:00
oskar 38cb204a64 fix(ollama): declare service on SOLARIA, restrict bind, add cutover runbook
Ollama's service.yaml/docker-compose.yml declared owner_node: solaria but
was never added to hosts/solaria/services.yaml, the manifest deploy-node.sh
actually reads — so it stayed running manually/natively instead of via the
declarative pipeline. Add the missing entry.

Also switch the port publish from 0.0.0.0 to loopback + TAILSCALE_BIND_IP
(same convention as llm-gateway@PIHA), since this is a private service and
llm-gateway@PIHA is the only consumer beyond the host itself.

Live cutover (relocating the existing native model store, disabling the
systemd unit, bringing up the container, verifying bge-m3 embeddings +
GPU use) is documented in docs/infra/ollama-solaria-cutover-2026-07-15.md
but not executed here — no SSH access to SOLARIA from this worktree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:09:08 +02:00
oskar 9e7ed3e077 feat(observer): persist SHADOW_LIVENESS_MISMATCH to mounted file — survives container recreate (cutover evidence)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:30:44 +02:00
oskar 4746ebe0fb fix(node-agent): detect restarting/crash-looping containers — state fell through classification, crash-loops were invisible to monitoring
check_containers() classified only exited/dead, running+unhealthy and running.
Docker's "restarting", "paused", "removing" and any future state matched no
branch, so a crash-looping container (restart policy + continuous crash) emitted
ZERO events and world-state kept showing it "healthy" (observed live:
lustro/pi-watchtower-1). Every Docker state is now handled:

- restarting + RestartCount >= CRASH_LOOP_RESTART_THRESHOLD (default 3):
  reuse containers_not_running (high, crash_loop=true) — parity with exited/dead,
  rides the existing supervisor-wired remediation path.
- restarting below threshold: new observational container_restarting (low),
  visible but non-actionable so benign post-deploy restarts don't alarm.
- paused / unknown-or-future state: new diagnostic container_state_unexpected
  (medium) — no more silent fall-through; new Docker states become visible.
- removing: conscious documented skip (ephemeral teardown).
- created: unchanged skip (compose tracking artifact).

RestartCount (top-level inspect field) distinguishes a crash-loop from a one-off
restart. Adds services/node-agent/tests/test_check_containers.py pinning the full
state table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:11:56 +02:00
oskar d5139c99ca fix(observer): checkpoint by timestamp not lexical path — lexically-smaller-but-newer events were silently skipped forever (poisoned node)
Per-node checkpoint now stores the last-processed event TIMESTAMP (int epoch)
instead of a file path compared lexically. A file is "new" iff its timestamp
(parsed from evt-<node>-<unixts>-<type>-<svc>.json, mtime fallback) exceeds the
node's checkpoint; processing is ordered by timestamp, not path.

Root cause (PIHA dead ~34d, 2026-07-12): a stray evt-unknown-<ts>-… file landed
in events/piha/, lexically greater than every evt-piha-… name. The lexical
checkpoint pinned there, so every genuinely newer piha event sorted "before" it
and was skipped forever. Event backlog grew to 7344 files, last_seen frozen,
shadow-read logged false SHADOW_LIVENESS_MISMATCH event=dead prom=up.

- _event_ts_from_path: filename epoch, mtime fallback; NEVER returns 0 for an
  existing file (0 == "older than checkpoint" == the poison).
- _checkpoint_ts_from_value: graceful migration of pre-fix path-string
  checkpoints (and the older last_processed_file format) to int epochs;
  unparseable → 0 (reprocess all — safe, process_event is idempotent on
  last_seen/world_state; bias to reprocess, never to skip).
- Preserved: quarantine of bad events, observer-source re-ingest guard.
- Regression tests (test_incident_lifecycle.py section 9): lexically-smaller-
  but-newer processed, unparseable name falls back to mtime (not wedged),
  ts-not-path ordering, both checkpoint-format migrations, helper units.

Separate bug filed in backlog (not fixed here): ha-diag-agent emits node=
"unknown" events (config.py node_name default) into another node's dir when
NODE_NAME reaches the compose volume path but not the app env — the source of
the poison file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:55:38 +02:00
oskar cc5c7921e2 fix(node-agent): rsync --no-perms/--no-owner/--no-group — same aerbot-owned dir EPERM as dir-times (exit 23 after omit-dir-times fix) 2026-07-13 21:15:41 +02:00
oskar 8cafd9917f feat(kb-postgres): migration 002_chunks.sql — document_chunk table
Krok 1 planu docs/kb/modules/05-faza2-plan.md §3/§6 (chunk-level embeddings,
1:N do envelope). Addytywna — 001_envelope.sql nietknięta (zweryfikowane
\d envelope po migracji: identyczny schemat + FK jako "Referenced by").

Schemat wg rekomendacji recon (§2 decyzja 1+2): osobna tabela (nie kolumna
w envelope, bo N-wartościowy chunking jest obowiązkowy przy dokumentach
>8k tokenów), embedding VECTOR(1024) pod bge-m3 (dense), HNSW cosine index,
kolumna `model` do trywialnego re-indexu przy zmianie modelu (kb-00 zasada
#1: indeks odtwarzalny). Idempotentna (CREATE TABLE/INDEX IF NOT EXISTS,
zweryfikowane podwójnym uruchomieniem na kb-postgres@PIHA — drugi run same
NOTICE "already exists, skipping").

Zastosowana na żywej bazie: ssh piha docker exec kb-postgres psql, po
potwierdzeniu SQL przez Oskara. \dt + \d document_chunk + \d envelope
zweryfikowane po migracji.

Testy: dopisane sanity-testy 002 do packages/kb-mail/tests/test_migration.py
(wzorzec 001 — statyczne assercje na treści SQL, bez DB), 13/13 zielone.

Co NIE jest częścią tego kroku (§3 planu, odłożone): entity/entity_link
(graf encji) — szkic na przyszłość, nie blokuje domknięcia modułu 5.

Co dalej (plan §6, poza zakresem tego kroku): ollama pull bge-m3 na SOLARII,
token API Paperless, jobs/gmail-header-backfill/, adapter Paperless→koperta,
chunking+embed job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:13:13 +02:00
oskar f37f85f1bc fix(node-agent): rsync --omit-dir-times — dir mtime on VPS not settable (aerbot-owned), caused false "shipping failed" despite successful transfer
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 20:54:37 +02:00
oskar 196a99ffef fix(paperless-worker): celery command bypassed manage.py + missing shared scratch dir
Two config bugs found on the already-deployed split-host OCR worker
(module 3): (1) `command: celery ...` was routed through manage.py by
the image entrypoint because it didn't start with "/" — fixed with an
absolute gosu+celery path. (2) SCRATCH_DIR (/tmp/paperless) was not
shared over NFS like data/media/consume, so tasks picked up by
worker@SOLARIA instead of worker@PIHA failed with "File not found" —
fixed by adding a paperless_scratch NFS volume/bind mount on both
sides.

Verified live on PIHA + SOLARIA: test PDFs dropped into consume/ were
split across both workers, the SOLARIA-picked task completed OCR with
zero File not found errors, test documents cleaned up afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:51:04 +02:00
Oskar Kapala fbf9e7501d feat(gokapi): config publicznego file-share na VPS (share.okit.pl, E2E, Tailscale-bind za npm@VPS, disk-protection) — do deployu
Nextcloud zostaje prywatny (mesh/kapala.org); Gokapi to osobny publiczny
serwis do wysylania linkow do plikow na zewnatrz (Firefox Send alt).
owner_node=vps, storage lokalny dysk (nie S3), E2E encryption ON, port
53842 bindowany tylko na TAILSCALE_BIND_IP (hairpin NAT przez npm@VPS,
nigdy 0.0.0.0). Cutover checklist w README (DNS, wildcard *.okit.pl na
npm@VPS, setup wizard) — nie zdeployowane w tym commicie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 17:29:28 +02:00
Oskar Kapala 7e5577c58f feat(kb): configi Paperless/Nextcloud wg 9 decyzji — NC na PIHA, domeny kapala, Redis requirepass, backup SOLARIA, NC pin 34
Co zrobione:
- Nextcloud host = PIHA (always-on dla aktywnego uzycia), owner_node +
  LAN_BIND_IP/TRUSTED_PROXIES w .env, README zaktualizowane
- Redis brokera Paperlessa: requirepass, PAPERLESS_REDIS_PASSWORD w .env
  po obu stronach (PIHA + worker@SOLARIA), healthchecki z auth
- Domeny potwierdzone: paper.kapala.org, cloud.kapala.org (Cloudflare
  DNS-only -> Tailscale PIHA, wildcard cert juz pokrywa) — udokumentowane,
  nic nie utworzone
- Backup Paperlessa zatwierdzony: document_exporter + rsync/borg -> SOLARIA,
  retencja 7/4/6, offsite jako future-note
- Nextcloud pin: 34-apache (zweryfikowany aktualny stable, endoflife.date)
- Whoosh fallback-worker: zaakceptowane bez zmian
- Porty/wylaczenie local login/sizing OCR-workera: przeniesione z "decyzji"
  na "TODO przy deployu"
- DECYZJE-do-podjecia.md zaktualizowane: wszystko poza portami/loginem/
  sizingiem przeniesione do "Rozstrzygniete"

Tylko edycja configow w repo — nic nie zdeployowane, zadne kontenery nie
byly ruszane, DNS/vhosty nie utworzone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:17:27 +02:00
Oskar Kapala e5ecefe2b7 feat(observer): shadow-read Prometheus up{} liveness with mismatch logging (cutover etap 1, no switching)
Observer now optionally (PROM_SHADOW_URL) queries Prometheus up{} once per
cycle and LOGS SHADOW_LIVENESS_MISMATCH when its event-driven liveness
disagrees. Parallel-run only: compute_liveness and _emit_node_transition are
untouched; authoritative liveness stays 100% event-driven. Fail-open on any
Prometheus error (down/timeout/bad JSON -> {}). 9 new tests, incl. proof that
shadow-read does not change node_info liveness/status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:38:20 +02:00
oskar 2aa47963b3 feat(kb): configi Paperless (PIHA) + OCR-worker (SOLARIA, NFS split-host) + Nextcloud — do review, split-host NFS zweryfikowany (GH #3900), 9 decyzji w DECYZJE-do-podjecia.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:10:38 +02:00
oskar cbb910d856 feat(llm-gateway): wciagniecie shadow-serwisu z PIHA do GitOps
Kod zyl tylko na dysku PIHA w /opt/llm-gateway (bez gita). Przeniesiony do
services/llm-gateway/ pod pelny wzorzec homelaba:

- app/main.py: kod 1:1 z PIHA + OLLAMA_URL/CHAT_MODEL/CODE_MODEL
  nadpisywalne przez env (defaulty bez zmian)
- docker-compose.yml: bind TYLKO do Tailscale IP (${TAILSCALE_BIND_IP},
  wzorzec fleet-prometheus), nie 0.0.0.0 jak w starym compose
- service.yaml, env.example (bez sekretow), healthcheck.sh, README, testy
- hosts/piha/runtime/llm-gateway: mem_limit 256m (PIHA jest RAM-bound)
- rejestracja w hosts/piha/services.yaml i inventory/topology.yaml

Zepsuty /opt/llm-gateway/docker-compose.yml (zduplikowany klucz ports)
celowo NIE przeniesiony.

DoD: pytest 4 passed; docker build + smoke run OK (GET / -> gateway ok).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:08:33 +02:00
oskar b6d55949ba fix(vikunja): PUBLICURL na vikunja.kapala.org 2026-07-02 17:28:56 +02:00
oskar f454ac7448 fix(vikunja): OIDC na forgejo.kapala.org + redirect vikunja.kapala.org (cert okit.pl wygasl) 2026-07-02 17:24:51 +02:00
oskar 886bc85e0b fix(inventory): correct owner_node — forgejo→piha, mosquitto→vps (per 2026-07-02 verify)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:48:12 +02:00
Oskar Kapala 62d6fc066b feat(brain-watchdog): poll Prometheus /api/v1/alerts as second alert source (Telegram)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:51:34 +02:00
Oskar Kapala d4170004e7 feat(fleet-prometheus): add liveness alert rules for always-on nodes (vps, piha)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:42:52 +02:00
Oskar Kapala 7d4014e512 feat(fleet-prometheus): add piha/solaria/lustro node_exporter scrape targets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:41:09 +02:00
oskar 992ff7ca7c test(control-plane): isolate _make_observer_simple module-state leak — fixes flaky test_incident_lifecycle
The test_run_once_* cases were flaky/order-dependent. Root cause: observer.observer
derives OBSERVER_STATE_FILE from STATE_DIR at import time. The helper patched
STATE_DIR but never OBSERVER_STATE_FILE, so run_once()/_save_checkpoint() wrote the
checkpoint to the real /opt/homelab/state/observer_checkpoint.json. Those node_checkpoints
(tmp paths tagged with a pytest run number) leaked across tests and across pytest runs;
run_once's `file_path > checkpoint` string compare then skipped/kept events based on
run-number ordering. The helper also never restored the module globals it overwrote.

Replace both ad-hoc helpers with an autouse monkeypatch fixture that redirects every
observer path — including OBSERVER_STATE_FILE — into the per-test tmp_path and reverts
them afterward. Tests no longer touch real disk and are deterministic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:48:03 +02:00
oskar 43c47a0a55 fix(fleet-prometheus): bind listen socket to Tailscale IP, not 0.0.0.0
Previously published "9090:9090" → Docker bound to 0.0.0.0 (all host interfaces,
including the public Hetzner IP), leaving tailscale-internal enforced only by the
VPS firewall. Now bind explicitly to the VPS Tailscale interface for
defense-in-depth: the port does not exist on the public IP at all.

- ports -> "${TAILSCALE_BIND_IP}:9090:9090"
- env.example: add TAILSCALE_BIND_IP (VPS Tailscale IP, verify via `tailscale ip -4`)
- README: deploy section — .env is mandatory; a missing .env makes Compose
  silently bind 0.0.0.0 (warns, does not fail), so use --env-file and verify host_ip

Smoke: config with --env-file and with co-located .env both resolve
host_ip=100.95.58.48; with .env absent Compose warns and falls back to 0.0.0.0
(documented). .env is gitignored (global *.env rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:07:18 +02:00
oskar 4518b15f98 feat(fleet-prometheus): scaffold fleet liveness Prometheus (VPS)
Clean scaffold of a Prometheus instance dedicated to fleet liveness, deliberately
separate from the home `prom` on PIHA. Scrapes only itself + the VPS-local
node_exporter for now.

- image pinned to prom/prometheus:v3.5.0 (LTS) — no :latest anti-pattern
- TSDB retention 15d / 2GB on the tight VPS; --web.enable-lifecycle for reloads
- mem_limit 512m, oom_score_adj +200 (sacrificial OOM victim before control-plane)
- tailscale-internal exposure via plain published 9090, mirroring control-plane
- node_exporter scraped via host.docker.internal:9100 (it runs network_mode: host)
- secret-free prometheus.yml; alerting/rule_files left as commented placeholders
- in-container healthcheck via busybox wget (present in the image; curl is not)

Smoke: docker compose config OK; promtool check config SUCCESS; up -d ->
/-/healthy + /-/ready OK; both targets (prometheus, fleet-node) report up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:07:18 +02:00
oskar 2b3cb89144 refactor(kb-postgres): relokacja SOLARIA→PIHA — arm64, mem_limit 1g, tuning pod małą maszynę
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:59:58 +02:00
oskar 1666511475 feat(kb-mail): fundament — pgvector spine, koperta, archiwum, pakiet domeny
- services/kb-postgres: pgvector/pgvector:pg16 na SOLARIA (:5433), named
  volume, init/001_envelope.sql (CREATE EXTENSION vector + zamrożona tabela
  envelope: id/source/ts/geo/raw_ref/entities), service.yaml, healthcheck,
  README z poprawnym mechanizmem deploy (deploy-node.sh składa dwa -f)
- hosts/solaria/runtime/kb-postgres/docker-compose.override.yml: mem_limit 4g
- inventory/topology.yaml + hosts/solaria/services.yaml: kb-postgres wpisany
- packages/kb-mail: nowa konwencja shared lib (pip install /repo/packages/<lib>/)
  envelope.py — @dataclass Envelope, walidacja tz-aware ts
  db.py       — insert_envelope / get_envelope (asyncpg, ON CONFLICT DO NOTHING)
  archive.py  — save_eml append-only (asyncio.to_thread, FileExistsError na dup)
  tests: 15 unit pass + 5 integration (@pytest.mark.integration, wymaga KB_TEST_DSN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 20:02:25 +02:00
oskar 5f1528e4ab feat(observer): 3-state node liveness (fresh/stale/dead) + transitions + read-time net
Fixes the "dead node shown NOMINAL" silent outage: node status was set only by
events and never expired, so a node that crashed/lost connectivity stayed
"online" forever (chelsty-infra was online for 16d, piha ~6d). The only thing
that flipped status to offline was a node_offline event, which an unreachable
node can never emit.

Now node status is derived from freshness (now - last_seen), recomputed every
observer cycle (incl. cycles with no new events):
  - always-on: fresh <=180s, stale 180-600s, dead >600s (3x the 60s heartbeat)
  - remote/LTE (chelsty-*): fresh <=900s, stale 900-3600s, dead >3600s

Thresholds + tier logic live in ONE shared helper, services/control-plane/src/
liveness.py, imported by the observer and both operator UIs (bind-mounted into
the agent-system webui image). No 3x copy.

Transitions are not silent: the observer emits node_stale / node_offline /
node_online (recovery) events tagged source=observer (skipped on re-ingest so
they never reset last_seen), routed by the supervisor to alert_only actions.

Read-time safety net: both UIs recompute liveness from last_seen at request
time, so a stalled observer still surfaces dead nodes. Services inherit their
node's liveness (cascade, variant B) without mutating services.json.

Replaces the earlier binary NODE_OFFLINE_TTL_SECS flip.

Tests: liveness unit tests, observer 3-state + transitions/recovery/baseline +
self-event skip, operator_ui read-time net + cascade, supervisor node-event
routing. 89 passed. docker compose config valid for both stacks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:07:25 +02:00
oskar 3663071f5c feat(observer): mark nodes offline when last_seen exceeds TTL
Nodes that crash or lose connectivity without emitting node_offline
stay online in world state indefinitely. _prune_stale_world() now
flips any online node to offline if its last_seen is older than
NODE_OFFLINE_TTL_SECS (default 300 s = 5× the 60 s heartbeat interval).
Nodes with last_seen=None (never reported) and already-offline nodes
are left unchanged. Five new tests cover all branches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:26:01 +02:00
Oskar Kapala e5c6bfe830 fix(vikunja): align compose with known-good live instance (review fixes)
Addresses review findings — all reverting to the live config that worked:

- config.yml mounts at /app/vikunja/config.yml (live path), not /etc/vikunja.
- Remove the vikunja container healthcheck: the image ships no wget/curl, so
  an in-container HTTP check is always unhealthy. Health stays on db
  (pg_isready) + host-side healthcheck.sh (curl). Live had no app healthcheck.
- Secrets injected exclusively via env_file (.env) on BOTH services; dropped
  the ${VAR:?} parse-time interpolation that depended on a .env in cwd. db now
  also has env_file. Non-secret env stays inline.
- Rename .env.example -> env.example to match repo convention (forgejo).

Verified: `docker compose -f services/vikunja/docker-compose.yml config` passes;
services/vikunja/.env is gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:25:25 +02:00
Oskar Kapala f7e1391f30 feat(vikunja): bring PIHA Vikunja under GitOps (postgres + Forgejo OIDC)
Wraps the existing manually-run Vikunja instance on PIHA in the standard
service layout. No deploy — definition only, for review.

- services/vikunja/: docker-compose.yml (vikunja + postgres:16-alpine),
  service.yaml, README, healthcheck, config.yml (OIDC, secret-free),
  .env.example. Real .env stays gitignored.
- Pins EXISTING named volumes (vikunja_vikunja_db, vikunja_vikunja_files)
  so DB + uploaded files survive cutover.
- extra_hosts forgejo.okit.pl=192.168.31.5 (npm on PIHA) so OIDC discovery
  resolves over LAN instead of flaky public DNS.
- OIDC client secret injected via env (VIKUNJA_AUTH_OPENID_PROVIDERS_
  FORGEJO_CLIENTSECRET); config.yml keeps trailing-slash authurl/redirecturl.
- Registers vikunja in hosts/piha/services.yaml + inventory/topology.yaml,
  plus hosts/piha/runtime/vikunja override (advisory mem_limits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:02:37 +02:00
Oskar Kapala c9ee8eb06d fix(observer): quarantine malformed event files to prevent processing wedge
Recovery from bad merge of task/observer-poison-quarantine (c255a02)
which carried false deletes from a stale branch base. Re-applies only
the genuine observer changes on top of correct master state.

When an event file fails to parse (malformed JSON, truncated, corrupted),
the observer previously kept retrying on every cycle while the node's
checkpoint stayed pinned — all subsequent good events for that node lost.

Now: first parse failure -> atomic os.replace to STATE_DIR/observer_failed_events/<node>/
with collision handling. Checkpoint advances, downstream events flow.
Move failures are logged but don't crash the loop.

Complementary to the atomic_write_json fix on state files; this addresses
the same race-pattern on event files instead.

Regression test asserts: bad event quarantined to failed_events dir,
removed from hot path, subsequent good event processed (node online),
checkpoint moves to good event.
2026-06-12 13:11:15 +02:00
Oskar Kapala fa59625aa6 docs(ha-diag-agent): replace curl verify commands with docker exec
Port 8087 is no longer mapped to the host. Operator verify commands
that used curl http://localhost:8087/health now use docker exec with
Python's urllib (the image is python:3.11-slim, no curl binary).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:46:33 +02:00
Oskar Kapala d7e0d3162f fix(ha-diag-agent): remove host port mapping for 8087
Port 8087 conflicted with zigbee2mqtt on piha (8087:8080 mapping active
for 7+ days), preventing ha-diag-agent from starting.

Grep across the full repo confirms no external consumer (no nginx/npm
proxy, no Prometheus scrape, no control-plane reference) uses this port.
The Docker healthcheck runs inside the container network namespace and
does not require a host-side mapping. Internal FastAPI binding on 8087
is unchanged.

Removed: ports section from docker-compose.yml and service.yaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:46:28 +02:00
Oskar Kapala 5e9db5c106 fix(ha-diag-agent): structlog event kwarg collision + replace aioresponses
- main.py: rename event= to ha_event= in _log.warning() — structlog treats
  'event' as a reserved positional arg; the old name caused TypeError when
  any check returned unhealthy results (events were still emitted, but the
  check was logged as check_error instead of check_unhealthy)
- tests/test_ha_client.py: replace aioresponses with unittest.mock — aioresponses
  0.7.8 is incompatible with aiohttp >=3.12 (missing stream_writer kwarg)
- pyproject.toml: remove aioresponses from dev dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:10:06 +02:00
Oskar Kapala d60b28a949 feat(ha-diag-agent): add piha deploy config
- hosts/piha/runtime/ha-diag-agent/docker-compose.override.yml: mem_limit
  128m, hardcoded events volume (/opt/homelab/events/piha:/events) to avoid
  ${NODE_NAME} shell-expansion issue in deploy-node.sh
- services/ha-diag-agent/env.example: per-host HA_URL comments (piha vs
  chelsty-infra tailscale), HA_TOKEN source note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:10:06 +02:00
Oskar Kapala 58ac6edd7d fix(stability-agent): run as uid 1000 with docker group access
stability-agent had no USER instruction and no user: in compose, running
as root and writing root-owned files to /opt/homelab bind-mount.

- Dockerfile: add useradd -m -u 1000 homelab + USER homelab
- docker-compose.yml: add user: "1000:1000" and group_add: ["999"]
  (GID 999 = docker group on VPS) to retain docker.sock:ro access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:20:54 +02:00
Oskar Kapala 19fd8799d9 fix(node-agent): run as uid 1000 with docker group access
node-agent had no USER instruction and no user: in compose, running
as root and writing root-owned files to /opt/homelab bind-mount.

- Dockerfile: add useradd -m -u 1000 homelab + USER homelab
- docker-compose.yml: add user: "1000:1000" and group_add: ["999"]
  (GID 999 = docker group on VPS) to retain docker.sock access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:20:31 +02:00
Oskar Kapala 7f17b65278 fix(control-plane): run executor as uid 1000 with docker group access
Executor was the only control-plane container running as root (uid=0),
writing root-owned files to /opt/homelab via bind-mount and triggering
false sudo on every deploy.

- Dockerfile: add USER homelab after useradd (useradd already present)
- docker-compose.yml: add user: "1000:1000" and group_add: ["999"]
  (GID 999 = docker group on VPS) so executor retains docker.sock access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:19:58 +02:00
Oskar Kapala 00fc36df3a fix(deploy): skip sudo chown/chmod when /opt/homelab ownership is already correct
deploy-local.sh previously ran `sudo chown -R 1000:1000` and
`sudo chmod -R 775` unconditionally on every deploy, which blocked
non-TTY execution (CC/CI) on VPS where /opt/homelab is already 1000:1000.

Both steps are now conditional using `find ... -print -quit`:
- chown: runs only if any file/dir is NOT uid/gid 1000
- chmod: runs only if any directory is missing -775 permission bits

When everything is correct (steady state on VPS), both steps log
"already correct, skipping" and never invoke sudo.  If a new directory
was created by root (e.g. a manual mkdir, volume mount, or restart artefact),
the remediation path triggers automatically — the self-heal property is preserved.

Smoke-tested in Docker (ubuntu:22.04):
  Case 1 (1000:1000 + 775):  chown skipped, chmod skipped ✓
  Case 2 (root-owned subdir): chown triggered ✓
  Case 3 (700 dir perms):     chmod triggered ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:44:44 +02:00
Oskar Kapala f5dcefc752 fix(observer): robust incident lifecycle + orphan auto-resolve
Two root causes for stale "active" incidents on the dashboard:

1. TypeError bug in _prune_stale_world: last_occurrence / resolved_at
   can be an ISO-8601 string (stability-agent via events.py) or a Unix
   int (node-agent).  The previous session's auto-resolve did plain
   `time.time() - last_occ` which raises TypeError for strings,
   silently preventing _save_world() from being called and leaving
   incidents perpetually "active" on disk.

   Fix: add _parse_ts(ts) -> float that handles int, float, and
   ISO-8601 strings uniformly. All timestamp arithmetic now goes through
   it; returns 0.0 on None / garbage to keep comparisons safe.

2. Orphaned active incidents: _resolve_incident clears service["incident_id"]
   and marks the incident "resolved" in memory, but if incidents.json was
   truncated mid-write (pre-atomic-write era), the observer loaded it at
   next startup with status="active" and no service entry pointing to it.
   No code ever touched these orphans again.

   Fix: _prune_stale_world now runs two cleanup passes each cycle:
   - Case 1 (healthy-linked): service.status=="healthy" AND incident_id
     still set → resolve immediately (service cannot have active incident)
   - Case 2 (orphaned): active incident with no service link AND
     last_occurrence > 5 min ago → resolve (5-min guard for creation race)

   Both cases are wrapped in try/except so a bug here never crashes the
   observer loop or blocks _save_world.

   Also fixes the 7-day stale-incident prune to use _parse_ts so
   ISO-string resolved_at values are handled correctly.

3. Operator UI: current_incidents() now filters to status=="active" only.
   Resolved incidents were previously included in the /incidents endpoint,
   making the dashboard show a wall of historical records as if active.

Nocturnal job investigation: _cleanup_control_plane_fs in node-agent runs
every 60s on VPS (not midnight-specific); it reads observer_checkpoint.json
(now written atomically) and deletes old event files. No non-atomic writes
found. Midnight clustering was likely external (logrotate / OS flush);
the supervisor's resilient loader already handles such transient issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:29:12 +02:00
Oskar Kapala 98437d46b2 test(control-plane): atomic write and resilient loader coverage
11 new test cases in test_state_reliability.py covering:
- atomic_write_json: produces valid JSON, no .tmp left behind, overwrites,
  works with nested structures
- _load_actual_state: returns False on empty / truncated file, returns True
  on valid files, preserves last-known-good state across a parse failure
- reconcile: empty/truncated services.json or incidents.json generates zero
  actions (skip-cycle semantics proven end-to-end)
- healthy service with valid world state generates no spurious action

All 32 tests (11 new + 21 existing) pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:27:05 +02:00
Oskar Kapala 5e97b4e448 fix(supervisor): atomic writes + skip cycle on unreadable world state
Two independent fixes for the false-alarm storm caused by race-condition
reads of truncated world state files:

1. Atomic writes: _atomic_write_json (write→fsync→os.replace) replaces
   all bare open('w')+json.dump calls in supervisor and executor, so the
   action-file pipeline is never visible in a half-written state.

2. Resilient loader: _load_actual_state now returns False when any world
   state file fails to parse (empty or truncated mid-write). reconcile()
   skips the entire drift check on False instead of treating {} as "all
   services missing". actual_state retains its last-known-good values so
   a single bad cycle does not wipe accumulated context.

   Before: parse error → raw[key]={} → all desired services missing →
     wall of redeploy actions → drift_resolved_auto churn on next cycle.
   After:  parse error → WARNING logged → cycle skipped → no actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:26:59 +02:00
Oskar Kapala cb4ae756ab test(brain-watchdog): add pytest suite covering import and check() logic
7 cases: package importable, fresh ok, stale, unreachable, HTTP error,
missing last_update field, unparseable timestamp. pytest.ini sets pythonpath=src
so tests run without PYTHONPATH set in the environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:38:24 +02:00
Oskar Kapala cfe5e02372 fix(brain-watchdog): add PYTHONPATH=/app/src so brain_watchdog package is importable
WORKDIR is /app but the package lives under src/; without PYTHONPATH set
`python -m brain_watchdog.main` raised ModuleNotFoundError on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:31:45 +02:00
Oskar Kapala 039f9f7247 feat(piha): brain-watchdog — external watchdog for control-plane
Polls /summary on VPS over Tailscale every 60s; computes freshness
locally from last_update epoch (never trusts self-reported status).
Alerts via Telegram Bot API directly after 3 consecutive failures;
sends recovery message on heal. State (fail_count, alerted) persisted
to volume so debounce survives restarts.

- services/brain-watchdog/: Python service, no external deps (stdlib only)
- hosts/piha/runtime/brain-watchdog/: override with mem_limit 64m
- hosts/piha/services.yaml + inventory/topology.yaml: manifest entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:54:36 +02:00
Oskar Kapala 495741e7ac operator-ui: /events bez ladowania calego katalogu + daemon threads; epoch z regexa (fix chelsty-infra)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:34:52 +02:00
Oskar Kapala 43c5d45353 deploy: chmod/chown na /opt/homelab odporne na znikające pliki eventow 2026-06-01 14:35:19 +02:00
Oskar Kapala f64cec645e vps: mem_limit + oom_score_adj na serwisach in-repo; deploy-local stosuje override (stop OOM) 2026-06-01 14:23:58 +02:00
Oskar Kapala 1db9db7d03 fix(dashboard): read last_update from JSON content, not file mtime
operator_ui.py called .replace() on last_update without checking type —
an integer value (written by the materializer) raised AttributeError and
silently fell back to os.path.getmtime(), which was stuck at 5/29 after a
deploy with preserved timestamps. web.py had the same class of bug but
worse: it unconditionally replaced last_update with mtime, ignoring the
JSON field entirely. Both now branch on isinstance(str) and cast numeric
values directly to float, with mtime only as a last-resort fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:10:50 +02:00
Oskar Kapala 52607a7cdd feat(control-plane): shadow_mode for HA event auto-actions + deploy docs
- HA_DIAG_SHADOW_MODE env flag in supervisor (default true)
- shadow_mode downgrades container_restart actions to alert_only with
  [SHADOW MODE] note; same action_id and 30-min cooldown apply
- alert_only events unaffected (always routed normally)
- 3 new tests: shadow on/off for ha_websocket_dead, alert-only unaffected
- DEPLOY.md with token gen, per-host config, verification, 48h observation,
  production-mode enablement, rollback
- README.md updated with shadow mode flag summary and DEPLOY.md link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:12:33 +02:00
Oskar Kapala b9ed118b8c fix(telegram-bot): correct risk_level field + show description in alerts
- read risk_level with risk fallback (was: risk only → "unknown" for
  all actions written by supervisor which uses risk_level key)
- include description field in alert format (was: alert_only payloads'
  substance was invisible — description carried the full message)
- extract _format_pending_action() pure helper to enable unit testing
  without a live Telegram connection
- 8 tests: risk_level present, risk fallback, both absent, description
  shown/absent, truncation, full HA alert_only shape, no-description no-crash
- flagged during Phase 5 review of ha-diag-agent supervisor routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:26:49 +02:00
Oskar Kapala bf1415e4c1 feat(control-plane): route ha-diag-agent events through supervisor
- 8 HA event types mapped to existing action types
- ha_websocket_dead → container_restart (homeassistant), 30-min cooldown
- 6 events → alert_only (entity_unavailable, integration_failed,
  automation_failing, update_available, recorder_lag,
  system_health_degraded), 1-hour cooldown
- ha_websocket_recovered → cancels matching pending container_restart
- state-aware suppression: skip HA events when homeassistant has an
  active containers_not_running incident < 5 min ago (avoids alert
  storms during HA restarts/updates)
- location_tag preserved through action pipeline for per-house
  telegram alerts
- executor: alert_only acknowledged as no-op success
- 18 tests covering all 8 event types, suppression, cooldown,
  dedup, location_tag, recovery cancellation
- CLAUDE.md: supervisor event routing table added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:59:23 +02:00
Oskar Kapala 31b48d162a feat(ha-diag-agent): WebSocketMonitor for real-time HA liveness
- persistent WS connection to HA with auth + state_changed subscription
- watchdog detects silence > 5min → emits ha_websocket_dead
- immediate ha_websocket_dead on disconnect, exponential reconnect with jitter
- cooldown prevents alert spam (10min repeat window while HA stays down)
- ha_websocket_recovered emitted on reconnect after a dead alert (allows
  supervisor to clear active incidents in Phase 5)
- new monitors/ subpackage for long-running tasks (vs interval checks/)
- /health endpoint now includes ws_connected field
- 26 unit tests, 3 integration tests (real HA + container stop/restart)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:00:18 +02:00
Oskar Kapala 3499b2f280 feat(ha-diag-agent): three REST diagnostic checks + Phase 3 flag fixes
New checks:
- SystemHealthCheck (15min interval): detects newly-failing HA
  integrations via /api/system_health snapshot diff; transition-based
  dedup (ok→error fires, sustained error silent, error→ok clears alert)
- UpdatesAvailableCheck (daily cron 09:00): per-update ha_update_available
  events with 7-day dedup; release notes truncated at 2000 chars
- UpdatesDigestCheck (Sunday cron 09:00): single digest event with all
  pending updates; weekly ISO-week dedup, independent of daily dedup key
- AutomationFailuresCheck (30min interval): detects automations with
  N consecutive failures (default 3) via /api/trace/automation/<id>;
  6h cooldown per automation

Phase 3 flag fixes:
- Flag #1 (since field): UnavailableEntitiesCheck now uses
  min(state.last_changed, baseline.first_seen) as effective "since",
  giving accurate duration when agent was offline at entity's first fail
- Flag #3 (registry cache): HAClient.get_entity_registry() caches
  response in-process with configurable TTL (default 300s); avoids
  repeated API calls across concurrent check cycles; invalidate_registry_cache()
  for manual invalidation

Storage: system_health_snapshot table (component, last_status, last_seen_at,
payload) created automatically on next Storage.open() call

Config additions (all with defaults): entity_registry_cache_ttl=300,
system_health_check_interval=900, automation_check_interval=1800,
automation_failure_threshold=3, updates_check_hour=9,
updates_check_minute=0, updates_cooldown_days=7

Tests: 95 unit tests pass (49 new), 13 integration tests pass (9 new);
3 skipped (live-HA token not set in CI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:43:10 +02:00
Oskar Kapala f41ec5d0c5 docs: compress CLAUDE.md + fix zigbee2mqtt coordinator docs
- CLAUDE.md: collapsed 5-section deployment block to single annotated
  block, removed inline emit_event signatures (kept path + type list),
  flattened runtime path tree to bullets, condensed node table note to
  reference capabilities.yaml, added CHELSTY docker-compose v1
  constraint; 156 → 113 lines (~750 → ~480 tokens)
- fix: zigbee2mqtt/README.md updated to TCP coordinator (SLZB-06U at
  192.168.1.105:6638, ezsp); removed stale /dev/ttyACM0 USB reference
  and corrected owner node from piha to chelsty-infra

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:17:23 +02:00
Oskar Kapala 20f6761a67 feat(ha-diag-agent): UnavailableEntitiesCheck with root cause dedup
- shared aiohttp ClientSession in HAClient (Phase 1 Flag #2 fixed):
  make_session() factory, session injected at startup, closed on shutdown
- Check.run() → list[CheckResult]: clean multi-event interface
- first real diagnostic check: entity unavailable > 24h
  (INSERT OR IGNORE baseline preserves first-seen timestamp)
- root cause grouping: emit ha_integration_failed instead of N entity
  events when ≥50% of integration's entities are unavailable (≥3 min)
- alert deduplication via SQLite cooldown window (default 6h)
- recovery clears baseline + dedup for immediate re-alert
- configurable thresholds: duration, integration %, cooldown
- 38 unit tests + 7 integration tests (42 pass, 3 skip w/o live HA)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:41:55 +02:00
Oskar Kapala 07bd498fd6 feat(ha-diag-agent): test environment with dual HA Docker instances
- dockerized ken + chelsty HA test instances with template fixtures
- snapshot/reset/wait scripts for fixture management
- integration test infrastructure with separate marker
- location_tag promoted from metadata to event payload (Phase 1 flag #3)
- chelsty-infra target_url points to chelsty-ha via tailnet (Phase 1 flag #1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:56:13 +02:00
Oskar Kapala 90c8e77bf7 chore: gitignore *.egg-info, remove committed egg-info
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:26:57 +02:00
Oskar Kapala ab8895d28b feat(ha-diag-agent): scaffold service with HA REST client and event emitter
- new per-host service, follows node-agent pattern
- 7 new HA event types defined (routing in supervisor — Phase 5)
- HeartbeatCheck as pipeline validator (pings /api/, emits ha_websocket_dead)
- service.yaml + host configs for piha (ken) and chelsty-infra (chelsty)
- test scaffolding with aiohttp/aiosqlite mocks (15/15 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:26:34 +02:00
Oskar Kapala bd7f955e4e fix+debug(planner-agent): use base_url (not api_base) for litellm.acompletion, add print [TEMP]
litellm.acompletion() has base_url as a named param; api_base only works
via **kwargs fallback path. Switching to base_url ensures the value lands
correctly in completion_kwargs and reaches the ollama provider.

Print() added (not logger) so base_url is always visible in docker logs
regardless of log level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:07:58 +02:00
Oskar Kapala 99200e6690 debug(planner-agent): log api_base before each litellm call [TEMP]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:52:11 +02:00
Oskar Kapala dcacac6965 fix(planner-agent): rename OLLAMA_HOST → OLLAMA_API_BASE (litellm convention)
LiteLLM reads OLLAMA_API_BASE, not OLLAMA_HOST.
- llm_router.py: DEFAULT_OLLAMA_HOST → DEFAULT_OLLAMA_API_BASE, param ollama_host → ollama_api_base
- planner.py: env var os.getenv("OLLAMA_HOST") → os.getenv("OLLAMA_API_BASE"), param renamed accordingly
- /opt/homelab/config/planner-agent/.env on SOLARIA updated in-place (not in git)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:34:08 +02:00
Oskar Kapala e52b2e2259 fix(planner-agent): remove duplicate ANTHROPIC_API_KEY from environment
Key is already provided via env_file: /opt/homelab/config/planner-agent/.env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:57:08 +02:00
Oskar Kapala 5ccdfa0ca6 docs: add planner-agent docs and session summary 2026-05-27
- services/planner-agent/README.md: full service doc (what it does,
  LLM fallback chain, env vars, deploy steps, local run, redis-cli
  end-to-end test, healthcheck)
- README.md: add Agent System section with all agents and their roles
- docs/sessions/2026-05-27-planner-agent.md: session summary (built
  files, architectural decisions, problems + solutions, deployment
  status, pending work)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:35:59 +02:00
Oskar Kapala ff6fda1f04 planner-agent: use env_file, keep only ANTHROPIC_API_KEY in environment
All runtime vars (REDIS_URL, OLLAMA_HOST, OLLAMA_MODEL, NODE_NAME,
COOLDOWN_SECONDS, RUNTIME_PATH) are sourced from the host-local
/opt/homelab/config/planner-agent/.env via env_file.
Only ANTHROPIC_API_KEY stays in environment (not in env_file — secret
injected at runtime by the operator when needed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:27:44 +02:00
Oskar Kapala ca37fca5ce feat(planner-agent): main loop with LLM routing and HITL action proposals
services/planner-agent/src/planner.py:
- PlannerAgent: async Redis pub/sub on health_events + world_updates
- Pipeline: receive event → cooldown gate → LLMRouter → write pending action
  → emit remediation_started filesystem event
- CooldownTracker: 5-min suppression per svc_key (configurable via env)
- parse_event(): accepts node-agent shape A and world_updates shape B
- PROPOSAL_SCHEMA: jsonschema enforced by LLMRouter before accepting response
- SYSTEM_PROMPT: homelab topology + action rules (chelsty always requires_human,
  disk_pressure always notify, confidence<0.7 → requires_human)
- write_pending_action(): atomic tmp→rename write, executor-compatible format
- emit_event(): async wrapper around filesystem event write (no control-plane import)
- _emit_event_sync() reads NODE_NAME at call time (not import) for testability
- Benign events (service_healthy, node_online, ...) silently skipped
- LLM chain failure: no cooldown recorded so next event can retry

services/planner-agent/tests/test_planner.py (49 tests, 0 network):
- TestCooldownTracker: 7 tests (ready/not-ready/elapsed/reset/independence)
- TestHealthEvent, TestActionProposal, TestMapActionToExecutorType
- TestParseEvent: both event shapes, missing fields, timestamp formats
- TestBuildMessages: system prompt rules, payload inclusion
- TestPlannerHandleEvent: benign skip, cooldown block, ignore/restart/redeploy/
  notify proposals, remediation event emission, LLM failure isolation,
  requires_human propagation, cooldown recording, model name in proposal
- TestPlannerDispatch: valid JSON, invalid JSON, non-string data, missing node
- TestWritePendingAction, TestEmitEvent: filesystem integration with tmp_path

services/planner-agent/service.yaml:
  owner_node: solaria, dependencies: [redis, ollama]
services/planner-agent/docker-compose.yml: env + healthcheck
services/planner-agent/Dockerfile: python:3.11-slim
services/planner-agent/healthcheck.sh: heartbeat file age check (300s)
services/planner-agent/requirements.txt: litellm, redis, jsonschema, structlog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:11:39 +02:00
Oskar Kapala 1bbc511bb7 feat(planner-agent): add llm_router.py with local-first fallback chain
services/planner-agent/src/llm_router.py:
- LLMRouter: async routing via litellm; chain = Qwen/Ollama → haiku → sonnet
- Timeouts: 8s local, 30s cloud; asyncio.wait_for belt-and-suspenders
- Rejection triggers: timeout, API error, refusal patterns, JSON schema fail
- JSON fence extraction: recovers valid JSON from  blocks
- ModelMetrics: per-model success/fallback/error counters + success_rate()
- Redis publish to 'llm_router_metrics' after every call (failure-safe)
- redis_url=None disables Redis (useful in tests / edge nodes)
- context= param adds caller label to all log lines for tracing

services/planner-agent/tests/test_llm_router.py:
- 34 tests, 0 network calls (litellm + Redis fully mocked)
- Covers: primary success, JSON error fallback, refusal fallback,
  timeout fallback, API exception fallback, all-fail RuntimeError,
  schema validation, fence extraction, metrics recording, Redis publish,
  Redis failure isolation

services/planner-agent/requirements.txt:
- litellm>=1.40.0, redis>=5.0.0, jsonschema>=4.21.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:38:06 +02:00
Oskar Kapala 7277bdc27f Fix Copy for AI: materializer fetches from control-plane API instead of Redis
services/agent-system/runtime-materializer/materializer.py:
- Add materialize_from_api() that fetches all world-state endpoints
  from the control-plane HTTP API (CONTROL_PLANE_URL env var)
- When CONTROL_PLANE_URL is set, use API as source of truth instead of Redis
- Redis path preserved as fallback for backward compat

hosts/piha/runtime/agent-system/docker-compose.override.yml (new):
- Inject CONTROL_PLANE_URL=http://100.95.58.48:18180 for runtime-materializer
- piha webui /snapshot now mirrors VPS observer output (clean, ghost-free)

Root cause: materializer read from Redis which held 80 stale service entries
with hash-prefixed ghost keys (e.g. 0ccb8a88e079_control-plane-supervisor).
Redis is never updated by the current observer pipeline; the control-plane API
is the single authoritative world-state source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:07:51 +02:00
Oskar Kapala b40b832159 Fix ghost service keys from hash-prefixed Docker container names
node-agent: use com.docker.compose.service label as canonical name
- Add _canonical_container_name() method: prefers compose label,
  falls back to hash-prefix-stripped c.name
- Replace bare c.name usage in check_containers()
- Skip 'created'-state containers (Docker stale-state artifacts)

observer: prune hash-prefixed ghost keys in _prune_stale_world()
- Each reconcile cycle removes service keys matching <node>/<12hex>_<name>
- Acts as safety net for entries already in services.json + future slippage

control-plane/docker-compose.yml already has explicit container_name on
all four services — no change needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:41:13 +02:00
Oskar Kapala 46ae92b5c1 supervisor: also cancel pending actions for services removed from desired state
Previously _cancel_resolved_pending_actions() only cancelled actions where
the service became healthy. This left orphaned actions when a service was
removed from services.yaml or marked monitor:false.

Add Case 1: if the action's svc_key is no longer in desired_state (either
removed entirely or skipped due to monitor:false), cancel with reason
service_removed_from_desired_state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:19:13 +02:00
Oskar Kapala 51002d4502 Fix pending actions: node_exporter, zigbee2mqtt, chelsty-ha monitoring
node_exporter (new service):
- Add services/node_exporter/docker-compose.yml matching solaria deployment
  (network_mode: host, pid: host, /:/host:ro,rslave mount)
- Add services/node_exporter/service.yaml

zigbee2mqtt chelsty-infra override:
- Fix network_mode: host (mosquitto runs on host network, port 1883 on localhost)
- Fix volume mount: ./configuration.yaml → absolute /opt/homelab/config/zigbee2mqtt/
  (secrets stay in runtime config dir, never in Git)
- Remove MQTT_USER/MQTT_PASSWORD (mosquitto uses allow_anonymous true)
- Extend healthcheck start_period to 60s (z2m takes time on first start)

chelsty-ha/services.yaml:
- Remove node-agent entry entirely (never deployed, no plans to bootstrap now)
- Keep homeassistant with monitor: false (no node-agent = no health events)

supervisor: respect monitor: false in services.yaml
- Skip action generation for services where monitor=false
- Cleans up chelsty-ha entries from action queue without removing desired-state docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:10:48 +02:00
Oskar Kapala fb7828b52b supervisor: auto-cancel pending actions when drift is resolved
When a service becomes healthy (node-agent emits service_healthy → observer
updates services.json), any previously queued redeploy/container_restart
action is stale. Without cleanup, the queue accumulates old actions that
require manual rejection.

_cancel_resolved_pending_actions() runs after each reconcile cycle:
- Reads all pending/*.json with type=redeploy or container_restart
- If the service is now healthy in actual_state, moves action to cancelled/
  with reason=drift_resolved_auto
- Only pending actions are touched; approved/running are left to the operator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:58:55 +02:00
Oskar Kapala 2f1965733f fix(node-agent): unique event IDs per service to prevent same-second overwrites
Multiple service_healthy (or containers_not_running) events emitted in the
same second for different containers shared the same filename pattern
evt-{node}-{ts}-{type}.json — the second write silently overwrote the first,
so the observer only ever saw the last container checked per event type per cycle.

Fix: include a sanitized service name slug in the ID so every event gets a
unique file, e.g. evt-vps-1234-service_healthy-node-agent.json.

Also adds import re (required for re.sub in the slug generation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:55:22 +02:00
Oskar Kapala 4e8968f9c7 Fix service health tracking: emit service_healthy, control-plane endpoint check, cleanup checkpoint migration
- node_agent: emit service_healthy for all running managed containers so
  observer populates services.json (previously empty → supervisor flooded
  action queue with missing_service redeploys for healthy services)
- node_agent: VPS-only _check_control_plane_health() probes the HTTP
  endpoint to emit service_healthy/unhealthy for the 'control-plane' logical
  service (multi-container stack, container names don't match service name)
- node_agent: fix _cleanup_control_plane_fs() to read new node_checkpoints
  format from observer checkpoint (was reading old last_processed_file key,
  always found nothing, never cleaned up old events)
- observer: handle service_healthy event type → sets service status healthy
  without resolving incidents (unlike service_recovered which also resolves)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:49:56 +02:00
Oskar Kapala a5a3e223dc fix(node-agent): skip SSH config file in rsync to avoid UID ownership errors
When ~/.ssh is mounted from the host oskar user into a container that
runs as root, OpenSSH rejects ~/.ssh/config with 'Bad owner or
permissions' because the file UID doesn't match the running process.

Add -F /dev/null to the rsync SSH command to skip the config file
entirely.  Also add UserKnownHostsFile=/dev/null so no known_hosts
write is attempted into a potentially read-only mounted .ssh dir.
The key itself (/root/.ssh/id_rsa) is still read as an implicit
default identity and is not affected by -F.

Reproduces on chelsty-infra (has ~/.ssh/config); safe for all nodes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:12:19 +02:00
Oskar Kapala 96bf32614f fix(observer+operator-ui): fix stale world state, dict→list API, event time filter
Root cause of stale data:
- node_agent.py falls back to socket.gethostname() when NODE_NAME is unset.
  Inside a Docker container this returns the 12-char container ID (e.g.
  'be17cb6eb0f6'), not the host name.  Observer ingested those events and
  created ghost entries in world/nodes.json that never expired.

observer.py:
- _prune_stale_world(): removes node/service/incident entries for nodes absent
  from topology inventory; called on every run_once() cycle (both new-events
  and idle paths).  Resolved incidents older than 7 days are also aged out.
- _save_world(): now writes node_count and service_count to runtime-summary.json
  so the Dashboard's System Overview cards show real numbers instead of undefined.

operator_ui.py:
- current_nodes/services/deployments/incidents(): the observer stores world state
  as keyed dicts; the frontend calls .map() which requires an array.  All four
  functions now convert the dict to a properly-shaped list.  Each item has the
  fields the Nodes, Services, Topology, Deployments, and Correlation views expect
  (hostname, health, capabilities, desired_state, dependencies, etc.).
- current_incidents(): synthesises a human-readable 'message' field from node +
  service + trigger_type (observer does not store one; dashboard showed undefined).
- current_events(): adds a 24 h time filter (EVENTS_MAX_AGE_HOURS env var,
  default 24).  Without this, every event file ever written was returned,
  including events from ghost-node deploys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:51:03 +02:00
Oskar Kapala 01b7758fe6 feat(node-agent): implement health monitor and safe cleanup policy
scripts/monitor/health-monitor.sh (new):
- Standalone bash health monitor: disk/RAM/CPU checks + docker container health
- Per-node-type cleanup policy enforced:
    lte_node  (chelsty-infra, chelsty-ha): NO cleanup, no docker ops
    sd_card   (piha, saturn): dangling images + containers, rate-limited once/24h
    ai_node   (solaria): dangling + containers + build cache, NEVER -a
    standard  (vps): dangling + containers + build cache + CP filesystem rotation
- VPS filesystem rotation: completed/failed actions >7d, deploy logs >30d,
  events >3d AND past observer checkpoint
- Emits structured JSON events (node_health, disk_pressure, high_memory, high_cpu,
  containers_not_running, healthcheck_failed)

services/node-agent/ (new):
- Python daemon (node_agent.py): same policy as bash script, Docker SDK
  for container checks and cleanup, /proc for system metrics
- Optional event shipping to VPS via rsync+SSH (VPS_EVENTS_HOST env var)
- Dockerfile: python:3.11-slim + openssh-client + rsync + docker>=6.0
- docker-compose.yml: mounts docker socket, /opt/homelab, repo read-only

observer.py:
- Handle node_health: update node status + disk/mem/cpu metrics, clear disk_pressure
- Handle disk_pressure: record severity on node, clear when healthy
- Handle high_memory / high_cpu: record pressure level for correlation

supervisor.py:
- Add NO_DISK_CLEANUP_NODES = {chelsty-infra, chelsty-ha}
- reconcile() step 3: generate disk_cleanup actions for nodes with high disk pressure
- _generate_disk_cleanup_recommendation(): stable ID disk-cleanup-{node},
  checks all active states, risk=guarded (operator approval required)

executor.py:
- Handle disk_cleanup action type via _execute_disk_cleanup()
- Commands come from action payload; safety gate rejects any command touching
  /opt/homelab/data/, /opt/homelab/config/, /opt/homelab/state/, or rm -rf /

hosts/*/services.yaml:
- Rename stability-agent -> node-agent on piha, vps, solaria, chelsty-infra
- Add node-agent to chelsty-ha (previously missing)
- Add cleanup policy notes to LTE node comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:15:06 +02:00
Oskar Kapala 7742bda245 feat(control-plane): add container_restart remediation
- observer: store trigger_type on incidents for supervisor routing
- supervisor: route containers_not_running/mqtt_unreachable to container_restart instead of redeploy
- supervisor: fix node alias normalization via NODE_ALIAS_MAP
- supervisor: fix pending action dedup (scan by content not filename)
- executor: implement container_restart via SSH docker restart with retry
- control-plane override: configure NODE_ALIAS_MAP for production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 12:50:46 +02:00
oskar 9b39581b53 fix(supervisor): content-based action IDs to prevent 30s backlog accumulation
Timestamp in reconcile-{ts}-{node}-{service} meant dedup guard never fired.
Switch to reconcile-{node}-{service} and check pending/approved/running states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:47:37 +02:00