Compare commits

...

3 commits

Author SHA1 Message Date
oskar 75d96956a5 docs(recon): przyrostowka IMAP gmail + fastmail — Krok 7 fazy mailowej
Read-only recon (kb/audits/mail-sync-2026-08-06.md, OKF type: audit).

Stan wyjsciowy zmierzony na zywo: korpus gmail urywa sie 2026-06-19, dziura
48 dni ~ 1800 maili przy tempie ~37/dobe; zero kodu IMAP/JMAP w repo (tylko
dokumenty), brak modelu stanu synca — w bazie 3 tabele, zadnej z UID.

Ustalenia blokujace, ktore latwo przeoczyc (kazde zawodzi cicho, bez bledu):
- koperty bez entities[type=headers] daja prefiks chunka "(brak tematu) | ?"
  (build_prefix), wiec poller musi pisac headers przy INSERCIE, nie backfillem
- DEFAULT_SUMMARYLESS_SOURCES = ("gmail",) — fastmail zembeduje sie i zniknie
  z /search, bo galaz summaryless filtruje po source
- etap mailowy dopiety do kb-ingest.timer (03:30) zapali KbEmbedBacklogGrowing
  na stale: SOLARIA wtedy spi (potwierdzone: kb_ingest_embed_skipped 1)
- envelope.id = goly Message-ID globalnie, wiec mail obecny na obu kontach
  trafia do bazy raz, z source konta ktore wygralo wyscig

Architektura: fetch na PIHA co godzine (24/7, archiwum kanoniczne, bez GPU),
indeksowanie osobno bramkowane probe'em Ollamy (embed z PIHA zmierzony:
HTTP 200 w 8 ms, ~60 chunkow/dobe — rsync na SOLARIE zbedny). Spoiwem jest
kolejka "koperty bez chunkow", nie --since (ts to naglowek nadawcy).

Decyzje operatora (a)-(g) z rekomendacjami. Dwie korekty zalozen:
- POSTGRES_PASSWORD NIE lezy plaintextem w repo — service.yaml wymienia tylko
  nazwy zmiennych, env.example ma placeholdery, skan sledzonych YAML: 0 trafien.
  Rekomendacja uzywa istniejacego /opt/homelab/kb/.env (root:root 600,
  czytany przez systemd przed zrzuceniem uprawnien)
- Fastmail przez IMAP, nie JMAP — domyka otwarta od czerwca decyzje
  "unifikacja adaptera" (kb-mail-pillar.md §9); wymaga korekty §2/§7 tamtego
  dokumentu po zatwierdzeniu

Zaleznosci z reconem multiagentowym: zadnych blokujacych. Dyspozytor to
subsystem B (osobny projekt); jawna zaleznosc to wiki-kompilat
(kb-m5-faza3.md:620 — "pelna wiki po przyrostowce").

Aktualizacja kb-m5-faza-mailowa.md: Krok 7 = WYKONANE + wskaznik do reconu.
Nic nie zaimplementowano, nie zdeployowano ani nie pobrano.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:13:23 +02:00
oskar 1bab3219d6 revert(m1): zdjecie NODE_TYPE=lte_node na SOLARII i VPS po wdrozeniu R1
Warunek zdjecia M1 brzmial "R1 (prune filtrowany po restart policy / labelu
compose) wdrozony na tym nodzie". Zweryfikowane bezposrednio w kodzie dzialajacych
kontenerow, nie po datach deployu:

  SOLARIA  md5(/app/src/node_agent.py) = c9ac64e10b42b3e0ed9e4c168579bfaa,
           identyczny z origin/master.
  VPS      rozni sie od origin/master wylacznie trescia komentarzy (5 linii
           `docs/backlog.md` vs `kb/phases/backlog.md`, skutek migracji sciezek
           w 9128530) — zero roznic funkcjonalnych.

Na obu nodach `_prune_stopped_containers` jest obecny (te same numery linii:
635/700/717/725), a jedyne wystapienia `containers.prune()` to tekst docstringa
i komentarza — brak wykonywalnego niefiltrowanego prune. Sprawdzone dodatkowo,
ze scripts/monitor/health-monitor.sh (ktory nadal ma niefiltrowane
`docker container prune -f` — R1 objelo tylko node_agent.py) nie jest wpiety w
zaden crontab ani timer na SOLARII i VPS, wiec node-agent byl faktycznie jedynym
zrodlem prune i cleanup byl na obu nodach realnie wylaczony.

SOLARIA: przywrocone jawne NODE_TYPE=ai_node — stan sprzed M1 (1cd6401),
zgodnie z konwencja pozostalych hostow, ktore wszystkie ustawiaja NODE_TYPE
jawnie (piha/lustro sd_card, chelsty-infra lte_node). solaria jest w AI_NODES,
wiec default dalby to samo, ale jawny wpis nie zalezy od hostname'u.

VPS: linia usunieta w calosci wraz z komentarzem TEMPORARY — dokladny stan
sprzed 11f3f80, gdzie NODE_TYPE nie bylo ustawione wcale. Potwierdzone, ze
default daje `standard`, nie None: base compose przekazuje `NODE_TYPE=${NODE_TYPE:-}`,
czyli pusty string, ktory jest falsy, wiec _resolve_node_type() schodzi do
rozpoznania po nazwie, a `vps` nie nalezy do LTE_NODES/SD_CARD_NODES/AI_NODES.
Sprawdzone na zlozonym `docker compose config` (NODE_TYPE: "") i uruchomieniem
_resolve_node_type() -> 'standard'. Rotacja filesystemu control-plane jest
bramkowana node_name == VPS_NODE_NAME, nie node_type, wiec dziala niezaleznie.

UWAGA DO DEPLOYU: na obu nodach brak /opt/homelab/state/last-docker-cleanup,
a przy braku markera _cleanup_rate_ok() zwraca True — pierwszy cleanup pojdzie
w pierwszym cyklu po restarcie (<=60 s), nie po 24 h jak na LUSTRO.
W chwili sprawdzenia zero kontenerow `exited` na obu nodach, wiec galaz
kontenerowa nie ma czego usunac; do sprzatniecia sa 4 dangling images na SOLARII
(~553 MB) i 1 na VPS (395 MB) plus build cache. humanai-mailer i humanai-landing
maja restart=unless-stopped, wiec sa chronione pierwsza galezia filtra R1 nawet
gdyby zostaly zatrzymane.

node-agent: 70 passed.

Refs docs/incidents/2026-07-30-ollama-solaria-vanish.md (§7, M1),
docs/sessions/2026-08-06.md (follow-up #5)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:47:09 +02:00
oskar 52eca1c22a fix(dispatch): inbox 0o775 + rsync rc=23 przestaje byc cichy
Wyciek plikow dispatch potwierdzony 2026-08-06 (session log, follow-up #1):
LUSTRO re-pullowalo te same dwie akcje co 60 s przez wiele dni, odbijajac sie
od bramki idempotencji, i nie zostawilo po sobie ani jednej linii w logach.

Przyczyna zlozona z dwoch niezaleznych defektow:

1. Executor tworzyl actions/dispatch/<node>/ z 0o755 (aerbot:aerbot). Rsync-pull
   z noda uwierzytelnia sie jako inny uzytkownik, bedacy tylko *czlonkiem* tej
   grupy. --remove-source-files musi zrobic unlink pliku, a unlink wymaga prawa
   zapisu w katalogu nadrzednym, nie na samym pliku. Zrodlo przezywalo pobranie.
   (dispatch/piha mialo historycznie 775 i dlatego dzialalo.)

2. node-agent traktowal rc=23 jako benign obok 0 i 24, wiec rsync zglaszal
   porazke, a agent ja polykal.

Executor: _ensure_inbox_dir() = mkdir + bezwarunkowy os.chmod(0o775). chmod jest
bezwarunkowy z dwoch powodow: mkdir(mode=) jest maskowany przez umask procesu
(przy 0o022 daje dokladnie feralne 0o755), a inboxy zalozone przez wczesniejszy
build juz istnieja na flocie z 0o755. Naprawa w miejscu zapisu, a nie skanem przy
starcie: jedno idempotentne wywolanie na tej samej sciezce kodu, ktora pisze plik
dispatch, wiec nie da sie rozjechac z pisarzami. Blad chmod nie jest fatalny —
akcja i tak sie wykonuje, a nieskasowane zrodlo widac teraz po stronie noda.

Objete tez actions/deploy/<node>/ (deploy-runner): ten sam wzorzec drenowania
tym samym rsync-pullem, ten sam defekt, jedno wywolanie obok.

node-agent: klasyfikacja kodow wyjscia zamiast wspolnej listy benign.
Weryfikacja empiryczna rsync 3.4.1 pokazala, ze rc=23 pokrywa dwa rozne
przypadki, a rozroznia je dopiero stderr:
  * `change_dir ... No such file or directory` — executor zaklada inbox dopiero
    przy pierwszym dispatchu, wiec kazdy nod, do ktorego nic nie poszlo, dostaje
    rc=23 co cykl. DEBUG — inaczej byloby po linii na minute z wiekszosci floty
    i realny sygnal utonalby w szumie.
  * `sender failed to remove <plik>: Permission denied` — wlasnie ten wyciek.
    WARNING z pelnym stderr.
Pusty (ale istniejacy) inbox to rc=0, nie 23 — dotychczasowy komentarz w kodzie
mowil inaczej. rc=24 zostaje benign (wyscig z executorem piszacym inbox),
pozostale kody to teraz ERROR, nie WARNING. Zachowanie funkcjonalne bez zmian:
retry i idempotencja dzialaja jak dotad, zmienia sie wylacznie widocznosc.

Testy: 4 nowe w test_executor_dispatch.py (oba inboxy 0o775 pod umask 0o022,
naprawa istniejacego 0o755 in place, dispatch przezywa nieudany chmod), 5 w
test_action_dispatch.py na klasyfikacje rc. Zastapiony
test_pull_treats_empty_source_returncodes_as_non_error — kodyfikowal wlasnie to
zalozenie, ktore okazalo sie bugiem. Oba zestawy sprawdzone mutacja: bez chmod
padaja 3 testy executora, przy starej liscie benign pada test rc=23.

node-agent 70 passed, control-plane 173 passed.

Refs docs/sessions/2026-08-06.md (follow-up #1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:42:47 +02:00
8 changed files with 1068 additions and 41 deletions

View file

@ -1,16 +1,3 @@
# MITYGACJA TYMCZASOWA (M1) — założona 2026-08-04.
# NODE_TYPE=lte_node wyłącza run_safe_cleanup() (niefiltrowany
# `docker container prune`) na czas backfillu embed (faza mailowa KB).
# Incydent: kb/incidents/2026-07-30-ollama-solaria-vanish.md (§7, M1).
# Bez tego każdy zatrzymany kontener na SOLARII znika w ≤60 s — również taki
# z `restart: unless-stopped`, zatrzymany świadomie przez operatora.
# Warunek zdjęcia: R1 (filtrowanie prune po restart policy / labelu compose)
# wdrożony na tym nodzie — R1R3 są w toku po stronie subsystemu A.
# Po zdjęciu przywrócić: NODE_TYPE=ai_node.
# Zakres wyłączenia: `lte_node` pomija CAŁY cleanup, więc na czas mitygacji
# nie są też sprzątane dangling images ani build cache — pilnować miejsca
# na dysku. Monitoring, eventy i dispatch akcji działają bez zmian
# (self.node_type jest czytane wyłącznie w run_safe_cleanup i dwóch liniach logu).
services: services:
node-agent: node-agent:
# Docker GID on SOLARIA is 996 (not the Debian default 999 the base compose # Docker GID on SOLARIA is 996 (not the Debian default 999 the base compose
@ -23,7 +10,11 @@ services:
- "996" # host docker gid, verified 2026-07-30 (getent group docker → 996) - "996" # host docker gid, verified 2026-07-30 (getent group docker → 996)
environment: environment:
- NODE_NAME=solaria - NODE_NAME=solaria
- NODE_TYPE=lte_node # M1 (2026-08-04) — było: ai_node; przywrócić po R1 # ai_node = dangling images + kontenery + build cache, ale NIGDY
# `image prune -a` (skasowałoby obrazy runtime Ollamy). Ustawione jawnie,
# zgodnie z konwencją pozostałych hostów, mimo że solaria jest w AI_NODES
# w node_agent.py i default dałby to samo.
- NODE_TYPE=ai_node
- VPS_EVENTS_HOST=100.95.58.48 - VPS_EVENTS_HOST=100.95.58.48
- VPS_EVENTS_USER=oskar - VPS_EVENTS_USER=oskar
- VPS_EVENTS_PATH=/opt/homelab/events - VPS_EVENTS_PATH=/opt/homelab/events

View file

@ -3,18 +3,11 @@ services:
environment: environment:
- NODE_NAME=vps - NODE_NAME=vps
- CHECK_INTERVAL=60 - CHECK_INTERVAL=60
# TEMPORARY mitigation (M1) for the unfiltered-prune incident # No NODE_TYPE here on purpose: `vps` is in none of node_agent.py's
# (kb/incidents/2026-07-30-ollama-solaria-vanish.md §7). node-agent runs # LTE_NODES / SD_CARD_NODES / AI_NODES sets, so _resolve_node_type() falls
# `docker container prune()` with NO filters every CHECK_INTERVAL, and the # through to "standard" — dangling images + stopped containers + build
# Docker API removes EVERY non-running container regardless of restart # cache, plus the control-plane filesystem rotation (that one is gated on
# policy or compose labels — this already destroyed ollama@solaria. On VPS # node_name == VPS_NODE_NAME, not on node_type). This is the pre-M1 state.
# the loss is worse: humanai-mailer and humanai-landing have no compose
# definition in this repo, so a pruned container cannot be recreated.
# node_type is read ONLY by run_safe_cleanup() (plus two log lines), so
# lte_node disables cleanup and nothing else — monitoring, event shipping
# and action dispatch keep working.
# REMOVE once R1 (explicit-enumeration prune) is deployed to VPS.
- NODE_TYPE=lte_node
# host network mode: node-agent on VPS shares the host's network namespace # host network mode: node-agent on VPS shares the host's network namespace
# so that localhost:18180 resolves to the control-plane's exposed port. # so that localhost:18180 resolves to the control-plane's exposed port.
# Without this, localhost inside the container is the container's own loopback # Without this, localhost inside the container is the container's own loopback

View file

@ -0,0 +1,809 @@
---
okf: "0.1"
type: audit
visibility: private
status: active
updated: 2026-08-06
as_of: 2026-08-06
links: []
---
# Recon — przyrostówka IMAP (gmail + fastmail), 2026-08-06
Read-only recon. Realizuje **Krok 7** fazy mailowej
(`kb/phases/kb-m5-faza-mailowa.md` §10, §12 poz. 7 — „OTWARTE — NEXT",
odblokowane zamknięciem Etapu B). Zakres: co trzeba dobudować, żeby korpus
mailowy przestał być fotografią z Takeoutu i sam się dosypywał z dwóch żywych
skrzynek.
Źródło kodu: `task/mail-sync-recon` @ `b1b6692` (worktree cięty z mastera).
Dowody runtime zebrane 2026-08-06 ~13:50 UTC+2: SOLARIA = host tego reconu
(odczyty lokalne), PIHA po ssh (`docker exec kb-postgres psql`, `systemctl`,
`curl`). **Nic nie zostało zaimplementowane, zdeployowane ani pobrane — wynik
to wyłącznie ten dokument.** Twierdzenia o zewnętrznych API (limity Gmaila,
układ folderów Fastmaila) są oznaczone jako **[do weryfikacji na żywo]** —
nie sprawdzałem ich, bo wymagałoby to logowania na konta.
---
## 1. Jak powstał obecny korpus
### 1.1 Tor, którym przyszły dane — trzy joby, jeden kierunek
| # | Job | Co robi | Kiedy |
|---|---|---|---|
| 1 | `jobs/gmail-bulk-import` | Takeout `.mbox` → archiwum `.eml` + wiersze `envelope` (tylko manifest załączników) | jednorazowo, 2026-06/07 |
| 2 | `jobs/gmail-header-backfill` | UPDATE: dopisuje `entities[type=headers]` do istniejących kopert | jednorazowo, 2026-07 |
| 3 | `jobs/mail-body-ingest` | drugi pełny przebieg po archiwum: body → quote-strip → chunk → embed → `document_chunk` + `entities[type=threading]` | Etap A 2026-07-23, Etap B 2026-08-06 |
Wszystkie trzy są **one-shotami uruchamianymi ręcznie**. Żaden nie ma jednostki
systemd, żaden nie wie o istnieniu skrzynki — punktem wejścia jest plik na dysku
(`.mbox`, potem `.eml`). To jest dokładnie ta luka, którą przyrostówka wypełnia:
**nie ma niczego, co rozmawia z serwerem pocztowym.**
### 1.2 Archiwum `.eml` — struktura i format
Kanoniczne archiwum na PIHA, lustro (dla backfillu GPU) na SOLARII:
`/home/oskar/kb/mail/archive/`.
Układ katalogów wyznacza `kb_mail.archive.save_eml`
(`packages/kb-mail/src/kb_mail/archive.py:29`):
```
{archive_root}/{source}/{YYYY}/{MM}/{sanitized_message_id}.eml
```
- `sanitized_message_id` = `envelope.id` przepuszczony przez
`str.maketrans({"/": "_", "\\": "_", ":": "_", "<": "", ">": ""})`.
- `YYYY/MM` pochodzą z `ts` (nagłówek `Date`), **nie** z daty pobrania.
- **Append-only twardo**: `save_eml` robi `dest.exists()``FileExistsError`.
Nie ma trybu nadpisania. Wołający decyduje, czy to błąd, czy „już mam"
(bulk-import liczy to jako `skipped`).
- Format pliku: surowe bajty wiadomości, zapisane przez
`msg.as_bytes(policy=email.policy.compat32)` — czyli **bez** normalizacji,
z oryginalnymi 8-bitowymi bajtami w nagłówkach włącznie.
Stan na dziś (SOLARIA, odczyt lokalny): jedyny podkatalog to `gmail/`, roczniki
`1970` (epoch fallback) oraz `2002``2026`. **Katalogu `fastmail/` nie ma —
źródło `fastmail` jest greenfieldem, zero plików i zero wierszy w DB.**
### 1.3 Tabela `envelope` — schemat i klucze dedup
`services/kb-postgres/init/001_envelope.sql`:
```sql
CREATE TABLE envelope (
id TEXT PRIMARY KEY, -- goły Message-ID (bez < >), lub sha256-<32hex>
source TEXT NOT NULL, -- fastmail | gmail | paperless
ts TIMESTAMPTZ NOT NULL, -- data wiadomości, UTC
geo JSONB, -- null dla maili
raw_ref TEXT NOT NULL, -- ścieżka względna do .eml
entities JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX envelope_source_idx ON envelope (source);
CREATE INDEX envelope_ts_idx ON envelope (ts);
```
Kontrakt zamrożony, addytywny (`packages/kb-mail/src/kb_mail/envelope.py`,
`kb/subsystems/kb-mail-pillar.md` §3). Kolumn UID/folder/etykieta **nie ma**
i nie było w planie.
**Klucz dedup to `id`, czyli goły Message-ID.** Wyprowadza go
`gmail_bulk_import._message_id` (`importer.py:63`):
```python
mid = _sanitize(str(msg.get("Message-ID", ""))).strip().strip("<>")
return mid or ("sha256-" + hashlib.sha256(msg.as_bytes()).hexdigest()[:32])
```
Cała idempotencja stoi na `INSERT ... ON CONFLICT (id) DO NOTHING`
(`kb_mail.db.insert_envelope`, `importer._insert_batch`). Na żywej bazie:
**9 kopert** ma id z prefiksem `sha256-` (maile bez Message-ID), reszta to
prawdziwe Message-ID; średnia długość id = 60 znaków.
`entities` to lista otwartych obiektów tagowanych `type`. Dziś w użyciu trzy:
| `type` | Kto pisze | Zawartość |
|---|---|---|
| `attachment` | gmail-bulk-import | `filename`, `content_type`, `size`, `sha256` |
| `headers` | gmail-header-backfill | `from`, `to`, `cc`, `delivered_to`, `subject`, `date_raw` |
| `threading` | mail-body-ingest | `in_reply_to`, `references[]` |
Oba appendy (`headers`, `threading`) są idempotentne przez
`WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements(entities) e WHERE e->>'type'='…')`.
### 1.4 Stan żywej bazy i archiwum (zmierzone 2026-08-06)
| Miara | Wartość | Skąd |
|---|---|---|
| `envelope` source='gmail' | 225 030 | `SELECT source, count(*) … GROUP BY source` |
| `envelope` source='paperless' | 191 | j.w. |
| `envelope` source='fastmail' | **0** | j.w. |
| `max(ts)` dla gmail | **2026-06-19 19:13:39+02** | j.w. |
| `document_chunk` (model bge-m3) | 389 012, w tym 201 849 bez wektora (newsletter) | `GROUP BY model` |
| Rozmiar `document_chunk` | 2 971 MB | `pg_total_relation_size` |
| Rozmiar `envelope` | 264 MB | j.w. |
| Rozmiar bazy `kb` | **3 248 MB** | `pg_database_size` |
| PIHA `/home` wolne | 136 GB (66% zajęte) | `df -h` |
| PIHA RAM | 8 GB, `available` 2,4 GB | `free -m` |
| Archiwum gmail 2024 / 2025 / 2026 | 13 207 / 13 575 / 6 213 plików | `find … -name '*.eml' \| wc -l` |
| Ostatni rocznik archiwum | `2026/06` (861 plików) | `ls` |
**Kluczowa liczba tego reconu: korpus urywa się 2026-06-19, dziś jest 2026-08-06
— dziura ma 48 dni.** Przy zmierzonym tempie ~37 maili/dobę (13 575 w 2025;
6 213 przez ~170 dni 2026) to **~1 800 kopert**, których w KB nie ma i nigdy nie
będzie, dopóki ktoś nie zrobi drugiego Takeoutu albo nie postawi przyrostówki.
Dziura rośnie o kolejny dzień każdego dnia.
### 1.5 Skąd `source='gmail'` i czym różni się ścieżka fastmail
`source` jest **wpisany na sztywno w kod importera**`Envelope(..., source="gmail", ...)`
(`importer.py:255`) i `save_eml(archive_root, envelope_id, "gmail", ts, raw)`
(`importer.py:248`). Nie ma parametru CLI, nie ma detekcji. Tak samo
`mail_body_ingest.fetch_envelopes` (`ingest.py:372`) ma zaszyte
`WHERE source = 'gmail'`.
Ścieżka fastmail **nie istnieje w żadnej postaci** — ani kodu, ani wierszy, ani
katalogu w archiwum. Jedyne, co jest, to zarezerwowane miejsce w kontrakcie
(komentarz `-- fastmail | gmail | ...` w DDL i w dataclassie). Różnice, które
realnie wystąpią, gdy się ją zbuduje:
1. **Brak Takeoutu** — nie ma bulk-importu do wykonania; fastmail zaczyna od
pustego zbioru i albo bierzemy tylko nowe maile, albo robimy jednorazowy
pełny zaciąg IMAP-em (Decyzja **(e)** w §4).
2. **Inny układ folderów** — Gmail ma jeden worek `All Mail`; Fastmail ma
klasyczne IMAP-owe foldery i trzeba je wyliczyć (§2.3).
3. **Ta sama przestrzeń id** — i to jest pułapka, patrz §2.4.
---
## 2. Czego brakuje do przyrostówki
### 2.1 Klient IMAP — nie ma go nigdzie
Grep po całym repo (`imaplib|aioimaplib|imapclient|IMAP|JMAP`) daje wyłącznie
**dokumenty**: `kb/subsystems/kb-mail-pillar.md`, `kb/subsystems/kb-overview.md`,
§10 planu fazy mailowej, kilka session-logów. **Zero linii kodu.**
`packages/kb-mail` ma dokładnie pięć modułów — `archive`, `chunking`, `db`,
`envelope`, `text` — i dwie zależności (`asyncpg`, `structlog`). Nie ma warstwy
transportu i nigdy nie było.
Katalogi `jobs/fastmail-poller/` i `jobs/gmail-imap-poller/`, zapowiadane w
`kb-overview.md` jako etapy 34, **nie istnieją**.
Co jest gotowe do reużycia i jest tego sporo:
| Element | Gdzie | Uwaga |
|---|---|---|
| `save_eml` | `kb_mail/archive.py` | append-only, `FileExistsError` = „już mam" |
| `insert_envelope` | `kb_mail/db.py` | `ON CONFLICT (id) DO NOTHING` |
| `parse_headers` + `parse_headers_fallback` | `gmail_header_backfill/backfill.py:67,131` | typed parse + compat32, przetestowane na 8-bitowych nagłówkach |
| `_message_id`, `_parse_date`, `_parse_attachments` | `gmail_bulk_import/importer.py:63,78,97` | wyprowadzenie id/ts/manifestu |
| `sanitize_surrogates`, `strip_nul` | `kb_mail/text.py` | oba obowiązkowe przed zapisem do jsonb/text |
| Cały pipeline body | `mail_body_ingest/ingest.py` | wymaga jednej zmiany, §3.2 |
| `embed_batch_resilient`, `check_ollama_health` | `kb_retrieval/embed.py:168,256` | retry + bisekcja + probe backendu |
`imaplib` jest w stdlib Pythona 3.11 (PIHA ma 3.11.2) — przyrostówka **nie musi
dokładać żadnej zależności**, jeśli zaakceptujemy synchroniczny klient
(uzasadnienie przy Decyzji (a)).
### 2.2 Model stanu synca — nie ma go, i to jest główna nowa rzecz do zaprojektowania
W bazie są **trzy tabele**: `envelope`, `document_chunk`, `document_summary`
(sprawdzone `\dt`). Nic nie przechowuje ani UIDVALIDITY, ani UIDNEXT, ani
last-seen-UID, ani daty ostatniego pollu.
Trzy warianty, uporządkowane:
**(A) Nowa tabela `mail_sync_state` w kb-postgres** — migracja `005`:
```sql
CREATE TABLE mail_sync_state (
account TEXT NOT NULL, -- 'gmail' | 'fastmail' (== envelope.source)
folder TEXT NOT NULL, -- nazwa IMAP-owa, jak zwrócona przez LIST
uidvalidity BIGINT NOT NULL,
last_uid BIGINT NOT NULL, -- najwyższy UID przetworzony do końca
last_sync_ts TIMESTAMPTZ NOT NULL,
PRIMARY KEY (account, folder)
);
```
**(B) Plik JSON w `/opt/homelab/state/`** — zgodne z konwencją runtime-state
z CLAUDE.md, ale rozjeżdża stan z danymi: przy odtworzeniu bazy z backupu plik
zostaje w przyszłości i przyrostówka cicho przeskakuje maile.
**(C) Wyprowadzanie z `max(envelope.ts)`** — bez stanu. Kuszące i **złe**:
`ts` to nagłówek `Date` nadawcy, nie moment dostarczenia. Mail z przekręconym
zegarem albo opóźniony w dostarczeniu wpadnie poniżej znacznika i zniknie na
zawsze. Znamy to z własnego korpusu — 2 559 kopert ma `ts` = epoch 1970.
**Rekomendacja: (A).** Stan synca to dane, nie konfiguracja — należy do tej
samej jednostki backupu co koperty, którym odpowiada. Kosztuje jedną migrację
(`services/kb-postgres/init/005_mail_sync_state.sql`), a płaci za to
najważniejszą właściwością: **stan i dane odtwarzają się razem albo wcale.**
Semantyka pętli (standardowa i jedyna poprawna dla IMAP):
1. `SELECT` folderu → serwer zwraca `UIDVALIDITY` i `UIDNEXT`.
2. Jeśli `UIDVALIDITY` ≠ zapamiętany → **cała numeracja UID jest unieważniona**;
zapamiętane `last_uid` nic nie znaczy. Reakcja: pełne przemiecenie folderu
(`UID SEARCH ALL`) i oparcie się wyłącznie na dedupie po Message-ID, po czym
zapis nowego `uidvalidity`. To zdarza się rzadko, ale gdy się zdarzy i nie
obsłużymy — tracimy maile bez śladu.
3. W przeciwnym razie `UID FETCH {last_uid+1}:*`.
4. Zapis `last_uid` **dopiero po** tym, jak koperta i plik `.eml` są trwale
zapisane — nigdy przed. Przerwanie w środku = powtórka partii przy następnym
ticku, którą dedup wyciszy.
Uwaga na wyścig: `UID FETCH n:*` zawsze zwraca co najmniej jedną wiadomość
(serwer zwraca ostatnią, gdy przedział jest pusty) — trzeba odfiltrować UID-y
`<= last_uid` po stronie klienta, inaczej licznik „nowych maili" nigdy nie
spadnie do zera i obserwowalność z §3.4 kłamie.
### 2.3 Mapowanie folderów
**Gmail.** Model etykietowy: jedna wiadomość, wiele etykiet, każda etykieta
widoczna jako osobny folder IMAP. Synchronizowanie kilku folderów oznacza
pobranie tej samej wiadomości wielokrotnie. Właściwy wybór to **jeden folder
`\All`** (`[Gmail]/All Mail`), który zawiera wszystko poza Spamem i Koszem —
i to jest dokładnie ta sama populacja co Takeout „All Mail", na której stoi
obecny korpus (plan §Decyzja 4: „Takeout »All Mail« nie zawiera folderu Spam").
Przyrostówka na `\All` jest więc **ciągła merytorycznie** z tym, co już jest
w bazie.
Nazwy folderów Gmaila są lokalizowane (przy polskim UI `[Gmail]/Wszystkie`), więc
**nie wolno ich zaszywać** — trzeba wybrać folder po atrybucie SPECIAL-USE
`\All` z odpowiedzi `LIST`. To samo dotyczy `\Sent`/`\Trash`.
**Fastmail.** Model klasyczny: `INBOX`, `Archive`, `Sent`, `Drafts`, `Trash`,
`Spam` + foldery użytkownika. Nie ma jednego worka odpowiadającego Gmailowemu
`\All` **[do weryfikacji na żywo — czy konto wystawia wirtualny folder
obejmujący całość]**. Zakres trzeba więc podać jawnie jako listę; rozsądny
domyślny zestaw to `INBOX` + `Archive` + `Sent` (Decyzja **(e)**).
Konsekwencja projektowa: konfiguracja folderów musi być **per konto listą**, a
nie pojedynczą nazwą, bo Gmail chce jednego wpisu, a Fastmail trzech. Tabela
z §2.2 jest już kluczowana `(account, folder)`, więc to obsługuje.
Czego **nie** zapisujemy: etykiet Gmaila. `entities` przyjęłoby
`{"type":"labels", …}` bez migracji, ale nic w retrievalu ich dziś nie czyta,
a `X-GM-LABELS` wymaga rozszerzenia `X-GM-EXT-1`, które przywiązuje kod do
Google — wprost wbrew zasadzie „protokół, nie provider"
(`kb-overview.md` §61). Odkładam.
### 2.4 Dedup nowych vs istniejące — działa, z jednym ostrym rogiem
Trzy warstwy, wszystkie już w kodzie:
1. **Archiwum**: `save_eml``FileExistsError` = ten `.eml` już leży.
2. **Koperty**: `ON CONFLICT (id) DO NOTHING` po Message-ID.
3. **Chunki**: `ON CONFLICT (envelope_id, chunk_index, model) DO NOTHING`
+ pre-fetch kluczy.
Dla ponownego pobrania tej samej wiadomości z tego samego konta to jest
komplet i nic nie trzeba dokładać. Ale:
> **Znalezisko — kolizja Message-ID między kontami.** `envelope.id` to goły
> Message-ID, **globalnie unikalny w całej tabeli**, bez prefiksu źródła
> (inaczej niż paperless, który używa `paperless:N`). Wiadomość obecna i w
> Gmailu, i w Fastmailu (ta sama lista dyskusyjna, przekierowanie, CC na oba
> adresy) ma **ten sam Message-ID**. Kto pierwszy wstawi, ten ustala `source`;
> drugi jest cicho pominięty przez `ON CONFLICT DO NOTHING`. Jednocześnie
> `save_eml` zapisze **dwa** pliki `.eml` (w `gmail/…` i `fastmail/…`, bo ścieżka
> zawiera `source`), a `raw_ref` będzie wskazywał tylko na jeden z nich.
To nie jest korupcja danych i nie blokuje niczego — treść trafia do indeksu raz,
co jest zachowaniem pożądanym. Ale ma dwa mierzalne skutki, o których trzeba
wiedzieć **zanim** ktoś zacznie się dziwić liczbom:
- statystyki „nowe maile fastmail" będą systematycznie zaniżone o część wspólną,
- filtrowanie po `source` w retrievalu (dziś: `DEFAULT_SUMMARYLESS_SOURCES`)
przypisze taki mail do konta, które akurat wygrało wyścig.
**Rekomendacja: zostawić zachowanie, dodać licznik.** Job niech raportuje
`envelopes_conflict_other_source` (konflikt na id, którego istniejący wiersz ma
inny `source`) — jedno zapytanie przy pominiętym wstawieniu, zero zmiany
semantyki, a zjawisko przestaje być niewidzialne. Zmiana klucza na
`(source, message_id)` jest odrzucona: łamie zamrożony kontrakt koperty,
wymagałaby przepisania 225 030 istniejących id i wszystkich `raw_ref`, a kupuje
duplikaty w indeksie — czyli dokładnie to, przed czym dedup chroni.
### 2.5 Czego jeszcze brakuje, a nie widać na pierwszy rzut oka
**(i) Nowe koperty muszą mieć `headers` od razu.** `mail_body_ingest.build_prefix`
(`ingest.py:344`) buduje prefiks chunka `Temat: … | Od: … | Data: …`
z `entities[type=headers]`, a przy ich braku podstawia `"(brak tematu)"` i `"?"`
**cicho, bez błędu**. Historyczne koperty mają headers tylko dlatego, że
przejechał po nich osobny backfill. Gdyby poller wstawiał koperty tak jak
bulk-import (sam manifest załączników), każdy nowy mail dostałby bezużyteczny
prefiks i nikt by tego nie zauważył poza spadkiem jakości retrievalu.
**Poller musi wstawiać `headers` i `attachment` w jednym `entities` przy
INSERCIE**, wołając `gmail_header_backfill.parse_headers` z jego fallbackiem.
**(ii) `mail_body_ingest` nie umie o źródle innym niż gmail.**
`fetch_envelopes` ma zaszyte `WHERE source = 'gmail'`. Potrzebny parametr
`--source` (lub `--sources` przyjmujące listę) — zmiana jednolinijkowa
w zapytaniu plus flaga CLI.
**(iii) `fastmail` jest niewidoczny w domyślnym trybie wyszukiwania.**
`packages/kb-retrieval/src/kb_retrieval/retrieval.py:43`:
```python
DEFAULT_SUMMARYLESS_SOURCES = ("gmail",)
```
`kb-query` nie nadpisuje tej wartości (`services/kb-query/app/main.py:128`
nie przekazuje `summaryless_sources`). Ponieważ maile nie mają streszczeń, do
wyniku wchodzą **wyłącznie** przez gałąź „summaryless" trybu hybrid — a ta
filtruje `WHERE e.source = ANY($2)`. **Koperty fastmail wpadłyby do bazy,
zembedowały się i nie pojawiły w żadnym wyniku `/search`.** Poprawka to jedno
słowo (`("gmail", "fastmail")`), ale bez niej cała gałąź fastmail jest niema.
To jest najbardziej „cicha" pułapka w całym przedsięwzięciu.
**(iv) Pre-fetch kluczy chunków nie skaluje się do trybu cyklicznego.**
`fetch_existing_chunk_keys` (`ingest.py:387`) robi
`SELECT envelope_id, chunk_index FROM document_chunk WHERE model = $1`
**bez ograniczenia** — dziś 389 012 wierszy. Zmierzone na żywo: ~1,0 s po
stronie serwera, ~26 MB na drucie (średnia długość id = 60 B), plus set 389 tys.
krotek w Pythonie (rzędu 6090 MB RSS) — na PIHA, gdzie `free` pokazuje 2,4 GB
`available`. Dla jednorazowego plastra 50k to nic; dla joba odpalanego co godzinę
to marnotrawstwo rosnące razem z korpusem. Poprawka: zawęzić pre-fetch do kopert
z bieżącego zbioru roboczego (`WHERE model = $1 AND envelope_id = ANY($2)`).
Nieblokujące — `ON CONFLICT` i tak stanowi drugą linię obrony — ale należy
zrobić to razem z (ii), bo dotyka tej samej funkcji.
---
## 3. Proponowana architektura
### 3.1 Gdzie biegnie fetcher: **PIHA**, i to nie jest bliska decyzja
| Kryterium | PIHA | SOLARIA |
|---|---|---|
| Dostępność | 24/7 | **wyłączana ~16 h/dobę z założenia** (`kb/decisions/architektura-2026-07-28.md`) |
| Archiwum kanoniczne | tak | lustro do backfillu, może dryfować |
| kb-postgres | lokalnie | przez Tailscale |
| GPU / Ollama | nie | tak |
| Precedens (`kb-ingest.timer`) | tak | — |
Przyrostówka jest zadaniem **sieciowo-, nie obliczeniowo-zależnym**: pobrać
~37 maili, zapisać ~4,6 MB, wstawić 37 wierszy. Umieszczenie jej na hoście
wyłączanym na noc oznaczałoby, że stan skrzynki jest odczytywany tylko wtedy,
gdy ktoś akurat włączył desktop — i że archiwum kanoniczne żyje po drugiej
stronie sieci od procesu, który je zapisuje. Decyzja architektoniczna repo mówi
o SOLARII wprost: „nic wymagającego 24/7 nie może tu mieszkać".
**Embedding zostaje na SOLARII, ale tylko jako wywołanie HTTP.** Zweryfikowane
na żywo z PIHA (2026-08-06 13:56): `getent hosts solaria`
`100.100.231.104`, `GET http://solaria:11434/api/tags`**HTTP 200 w 8,2 ms**,
RTT ICMP 1,26 ms. Przy dobowym przyroście rzędu 60 chunków (37 maili × 1,73
chunka/mail, zmierzone: 389 012 / 225 030) i ~818 ms/chunk to **poniżej sekundy
pracy GPU na dobę**. Argument z planu §Decyzja 8 przeciwko embedowaniu z PIHA
dotyczył 271 tys. chunków backfillu i **nie przenosi się** na tę skalę.
**Wniosek: rsync archiwum PIHA→SOLARIA nie jest przyrostówce do niczego
potrzebny.** Lustro na SOLARII pozostaje artefaktem backfillu; niech dryfuje
albo zniknie.
### 3.2 Jak nowe koperty wchodzą w istniejący tor — dwa etapy, nie jeden
**Odrzucam „tryb `--since`" jako mechanizm spinający.** `--since` filtruje po
`envelope.ts`, czyli po dacie z nagłówka nadawcy. Mail dostarczony dziś z datą
sprzed tygodnia wypadnie poza okno i nie zostanie nigdy zchunkowany — ta sama
wada, co wariant (C) w §2.2, tylko przesunięta o jeden krok dalej w potoku.
`--since` zostaje tym, czym był: narzędziem do etapowania ręcznych runów.
Właściwym spoiwem jest **kolejka wynikająca z danych**: koperta bez chunków
*jest* elementem kolejki. Zapytanie:
```sql
SELECT e.id FROM envelope e
WHERE e.source = ANY($1)
AND NOT EXISTS (SELECT 1 FROM document_chunk c WHERE c.envelope_id = e.id)
```
Jest samonaprawiające się (przerwany run, koperta pominięta przy padniętej
Ollamie, mail wstawiony z datą wsteczną — wszystko wraca do kolejki samo)
i nie wymaga żadnego dodatkowego stanu. To wprost wzorzec, którym `kb-ingest`
liczy `kb_ingest_embed_backlog`.
Stąd **dwa niezależne kroki**, celowo rozdzielone po linii „potrzebuje GPU":
```
kb-mail-sync.timer (PIHA, co godzinę, bez GPU)
└─ jobs/mail-imap-sync
IMAP LOGIN → SELECT folder → UID FETCH last_uid+1:*
→ save_eml() (append-only, FileExistsError = skip)
→ insert_envelope() (entities = [headers…, attachment…])
→ UPDATE mail_sync_state.last_uid
→ .prom
kb-ingest.timer (PIHA, istniejący, bramkowany probe'em Ollamy)
└─ + nowy etap: mail_body_ingest.run(sources=[...], only_unchunked=True)
→ chunk → embed (http://solaria:11434) → document_chunk + threading
```
Zaletą tego podziału jest to, że **pobieranie nigdy nie czeka na SOLARIĘ**.
Maile lądują w archiwum i w kopertach co godzinę niezależnie od tego, czy
desktop jest włączony; indeksowanie dogania, gdy GPU jest dostępne — dokładnie
tak, jak dziś działa paperless. Nowy kod ogranicza się do klienta IMAP; reszta
to wpięcie istniejących funkcji.
### 3.3 Harmonogram — i pułapka odziedziczona po `kb-ingest.timer`
Wzorzec jednostek: `jobs/documents-ingest/systemd/``.service` (`Type=oneshot`,
`User=oskar`, `EnvironmentFile=/opt/homelab/kb/.env`) + `.timer`
(`Persistent=true`) + cienki `*-run.sh`, którego jedynym zadaniem jest
przekierowanie do `/opt/homelab/logs/<job>/run-YYYYMMDD.log` (nigdy sam
journal — lekcja z runu, który stracił 4999 wierszy do zamkniętego tmuxa).
Proponowany takt: **`OnCalendar=hourly`** dla `kb-mail-sync`. Uzasadnienie:
przy ~37 mailach na dobę godzinny tick pobiera 12 wiadomości, czyli jest
praktycznie darmowy, a jednocześnie utrzymuje świeżość KB w granicach godziny —
co ma znaczenie dla przyszłego dyspozytora (§5). `Persistent=true` nadgania po
reboocie PIHA.
> **Znalezisko — `kb-ingest.timer` chodzi o 03:30, kiedy SOLARIA prawie na pewno
> śpi.** Dowód z żywego systemu (PIHA, 2026-08-06):
> `NEXT Fri 2026-08-07 03:30, LAST Thu 2026-08-06 03:30:03` oraz zawartość
> `/opt/homelab/state/node-exporter/kb-ingest.prom`:
> `kb_ingest_embed_skipped 1`, `kb_ingest_embed_backlog 0`.
> Dzisiejszy tick **pominął oba etapy embedujące**, bo probe Ollamy nie
> odpowiedział. Dziś to nieszkodliwe: paperless nie generuje nowych chunków, więc
> backlog stoi na zerze i `KbEmbedBacklogGrowing` (próg: `> 0` przez `for: 72h`)
> nigdy nie ma czego mierzyć.
>
> **Dopięcie etapu mailowego do tego samego ticku zmienia to jakościowo.**
> Wpłynie ~60 nowych chunków na dobę, embed będzie pomijany każdej nocy,
> backlog zacznie rosnąć monotonicznie i po 72 h alert zapali się **na stałe**
> nie sygnalizując żadnej awarii, tylko rozjazd harmonogramu z dobowym cyklem
> SOLARII. Alert, który świeci zawsze, przestaje być alertem.
Do rozstrzygnięcia razem z Decyzją **(d)**. Najtańsza poprawka: przesunąć etap
mailowy (albo cały `kb-ingest`) na godzinę, o której SOLARIA realnie pracuje —
w chwili tego reconu jest włączona od ~10:40, `uptime` 3 h 12 min. Alternatywa
bez zgadywania: uruchamiać etap indeksujący częściej (np. co 2 h) i pozwolić,
by probe Ollamy sam wybrał okno, w którym GPU odpowiada. Ta druga opcja jest
odporna na zmianę nawyków operatora i nie wymaga zgadywania, o której SOLARIA
wstaje — **rekomenduję ją.**
### 3.4 Obserwowalność — istniejący tor, bez nowych bytów
Tor jest gotowy i sprawdzony: job pisze plik `.prom` atomowo (tmp + rename) do
`/opt/homelab/state/node-exporter/`, node_exporter@PIHA go zbiera przez
textfile collector (mount `/:/host:ro` już to pokrywa), reguły idą do
`services/fleet-prometheus/rules/`, a dostawę do Telegrama robi brain-watchdog
odpytujący `/api/v1/alerts`. **Bez Alertmanagera** — tak jak `kb-ingest.yml`
i `liveness.yml`.
Proponowane metryki w `/opt/homelab/state/node-exporter/kb-mail-sync.prom`:
| Metryka | Typ | Znaczenie |
|---|---|---|
| `kb_mail_sync_last_run_timestamp` | gauge | ostatni tick (sukces lub nie) |
| `kb_mail_sync_last_success_timestamp` | gauge | ostatni tick bez twardego błędu |
| `kb_mail_sync_last_exit_code` | gauge | kod wyjścia |
| `kb_mail_sync_envelopes_inserted{account}` | gauge | nowe koperty w tym ticku |
| `kb_mail_sync_envelopes_skipped_dup{account}` | gauge | dedup po Message-ID |
| `kb_mail_sync_conflict_other_source{account}` | gauge | kolizja międzykontowa z §2.4 |
| `kb_mail_sync_last_message_ts{account}` | gauge | `max(envelope.ts)` dla konta |
| `kb_mail_sync_uidvalidity_resets{account}` | counter | ile razy serwer unieważnił numerację |
Reguły alertowe — i tu jest realna subtelność:
- `KbMailSyncStale`: `time() - kb_mail_sync_last_success_timestamp > 21600`
(6 h = 6 nieudanych godzinnych ticków), severity critical. **To jest właściwy
alert.**
- **„Zero nowych maili przez X dni" odradzam jako alert.** Zero nowych maili jest
legalnym stanem skrzynki — urlop, weekend, przeniesienie ruchu na drugie konto.
Alert na ciszę zapali się przy zdrowym systemie i zostanie wyciszony, po czym
przestanie działać wtedy, gdy będzie potrzebny. Ten sam skutek daje sygnał,
który **nie ma fałszywych trafień**: `kb_mail_sync_last_success_timestamp`
odpowiada na pytanie „czy poller w ogóle działa", i to niezależnie od tego,
czy cokolwiek przyszło.
- Jeśli mimo to operator chce ostrzeżenie o „martwej skrzynce", właściwą metryką
jest `kb_mail_sync_last_message_ts` (data najnowszego maila, nie licznik ticku)
z progiem **per konto** i severity `warning` — gmail jest kontem śmieciowym,
więc dla niego kilkudniowa cisza to anomalia, a dla fastmaila niekoniecznie.
Sugerowany próg gmail: 7 dni. Do rozstrzygnięcia po miesiącu obserwacji, nie
z góry.
---
## 4. Decyzje operatora
### (a) Gmail: IMAP + hasło aplikacji **czy** Gmail API / OAuth2
**Rekomendacja: IMAP + hasło aplikacji.**
- Zasada z `kb-overview.md` §61 mówi wprost: *„Protokół, nie provider. Ingest
pisany przeciw standardom (JMAP, IMAP, WebDAV), nie przeciw firmie →
przenośność"*. `kb-mail-pillar.md` §2 powtarza to dla Gmaila: *„adapter IMAP
(protokół, nie Gmail API → przenośność)*". Ta decyzja jest w repo już podjęta;
recon jej nie zmienia, tylko potwierdza, że nic nowego nie każe jej rewidować.
- Jeden adapter obsłuży oba konta. Gmail API dałby drugi, nieprzenośny tor kodu
dla konta, które sam operator opisuje jako **śmieciowe** (loginy, 2FA,
newslettery) — najgorszy możliwy stosunek nakładu do wartości.
- OAuth2 wymaga projektu w Google Cloud, ekranu zgody, cyklu odświeżania tokenu
i obsługi jego wygaśnięcia w jobie bezobsługowym. Hasło aplikacji to jeden
ciąg znaków w pliku `.env`, który już istnieje i już trzyma inne sekrety.
- **[do weryfikacji na żywo]** Hasła aplikacji wymagają włączonego 2FA na koncie
i bywają wyłączane politykami Workspace; przy koncie prywatnym powinny być
dostępne. Warto sprawdzić przy okazji, czy Google nie ogłosiło wygaszenia tej
metody — mój stan wiedzy sięga maja 2026 i **nie jest wiarygodnym źródłem dla
polityki dostawcy z sierpnia 2026**. Gdyby okazało się, że hasła aplikacji
odpadły, OAuth2 staje się przymusem, nie wyborem — reszta architektury się nie
zmienia, wymianie podlega wyłącznie sposób uwierzytelnienia w kliencie.
- **[do weryfikacji na żywo]** Gmail limituje dobowy transfer IMAP (rzędu
kilku GB). Przy ~1 800 zaległych maili (~225 MB) i ~4,6 MB/dobę bieżąco nie
ma to znaczenia, ale gdyby ktoś kiedyś chciał zaciągnąć IMAP-em pełną
historię — miałby.
### (b) Fastmail: IMAP + hasło aplikacji
**Rekomendacja: tak, IMAP + hasło aplikacji — i tym samym zamykamy otwartą
decyzję „unifikacja adaptera".**
To nie jest formalność, bo **stoi w sprzeczności z tym, co repo dziś zapisuje**:
`kb-mail-pillar.md` §2 i §7 oraz §10 planu fazy mailowej przewidują dla
Fastmaila **JMAP** (`jobs/fastmail-poller`), a `kb-mail-pillar.md` §9 trzyma to
jako decyzję otwartą: *„jeden wspólny IMAP dla Fastmail + Gmail (mniej kodu) vs
JMAP dla Fastmail + IMAP dla Gmail (JMAP bogatszy)?"*. Zlecenie tego reconu
mówi „IMAP (gmail + fastmail)", czyli rozstrzyga tę decyzję na rzecz unifikacji.
Recon się z tym zgadza:
- JMAP daje synchronizację po `state` — elegancką i **niepotrzebną przy dwóch
ticku na godzinę i 37 mailach na dobę**. UIDVALIDITY/UIDNEXT rozwiązuje ten
sam problem, a jest jedyną rzeczą, którą trzeba i tak zaimplementować dla
Gmaila.
- Jeden adapter = jeden zestaw testów, jedna klasa błędów, jedna ścieżka
hardeningu 8-bitowych nagłówków. Dwa protokoły to dwa razy tyle powierzchni
przy identycznym wyniku w bazie.
- Fastmail wystawia pełny IMAP i hasła aplikacji z ograniczonym zakresem
(można wydać poświadczenie tylko-IMAP, bez dostępu do panelu) — **[do
weryfikacji na żywo]**, ale jeśli tak, jest to *ściślejsze* uprawnienie niż
token JMAP.
- JMAP nie jest zamknięty na zawsze: koperta i archiwum są protokołowo obojętne,
więc wymiana transportu w przyszłości nie dotyka danych.
**Konsekwencja dokumentacyjna:** po zatwierdzeniu trzeba poprawić
`kb-mail-pillar.md` §2/§7/§9 i §10 planu fazy mailowej, żeby nie zostawiać
w KB dwóch sprzecznych zapisów. Nazwy jobów z tamtych dokumentów
(`jobs/fastmail-poller` + `jobs/gmail-imap-poller`) tracą sens przy jednym
adapterze — proponuję **jeden `jobs/mail-imap-sync`** sparametryzowany kontem.
### (c) Sekrety — gdzie trzymać hasła
> **Sprostowanie założenia.** Zlecenie mówi, że `POSTGRES_PASSWORD` leży dziś
> plaintextem w `services/*/service.yaml`. **Tak nie jest.** `service.yaml`
> wymienia wyłącznie *nazwy* zmiennych (`runtime.env_vars: [POSTGRES_PASSWORD]`
> — `services/kb-postgres/service.yaml:22`), pliki `env.example` zawierają
> placeholdery (`POSTGRES_PASSWORD=change-me-strong-password`), a `.gitignore`
> blokuje `.env` i `*.env`. Skan wszystkich śledzonych plików YAML pod kątem
> wartości wyglądających na sekrety (≥12 znaków, po odsianiu placeholderów)
> zwrócił **zero trafień**. Wzorzec repo jest poprawny i to jego należy użyć,
> a nie zastępować.
**Rekomendacja: rozszerzyć istniejący `/opt/homelab/kb/.env` na PIHA.**
Zweryfikowane na żywo: plik istnieje, ma uprawnienia **`-rw------- root root`**,
262 bajty, i trzyma już `KB_DSN`, `PAPERLESS_API_TOKEN` oraz
`ANTHROPIC_API_KEY`. `kb-ingest.service` czyta go przez
`EnvironmentFile=/opt/homelab/kb/.env`, mimo że sam biegnie jako `User=oskar`
systemd wczytuje `EnvironmentFile` **jako root, przed zrzuceniem uprawnień**.
Efekt: sekret jest wstrzykiwany do procesu, ale **nie jest czytelny dla
użytkownika `oskar` w spoczynku**. To jest lepsza własność, niż dałby plik
`600 oskar:oskar`, i warto ją zachować świadomie, a nie przypadkiem.
Dokładamy cztery klucze:
```
MAIL_GMAIL_USER=…
MAIL_GMAIL_APP_PASSWORD=…
MAIL_FASTMAIL_USER=…
MAIL_FASTMAIL_APP_PASSWORD=…
```
Repo dostaje **wyłącznie** `jobs/mail-imap-sync/env.example` z placeholderami —
dokładnie jak `services/*/env.example`.
Zasady wykonawcze, wszystkie wyprowadzone z tego, co już się w tym repo
wydarzyło:
1. **Nigdy w argv.** `--dsn <hasło>` trafia do `ps` i do historii powłoki.
Istniejące joby dopuszczają `--dsn`, ale honorują też `KB_DSN` z env —
nowy job niech przyjmuje poświadczenia **tylko** ze środowiska, bez
odpowiednika `--password`.
2. **Nigdy do transkryptu.** W repo są **dwa** udokumentowane przypadki rotacji
po wycieku do transkryptu sesji: token Paperless (`docs/sessions/2026-07-15.md`
§Krok 3) i klucz Anthropic (`docs/sessions/2026-07-21.md`, incydent 3).
To nie jest hipotetyczne ryzyko — to jedyny sposób, w jaki sekrety w tym
homelabie dotąd wyciekały. Instalacja hasła musi się odbyć bez wypisywania go
na ekran.
3. **Hasła aplikacji, nie hasła kont** — odwoływalne pojedynczo, bez dostępu do
ustawień konta i bez omijania 2FA.
4. **Bez własnego szyfrowania.** SOPS/age/vault to nowy byt operacyjny (klucze,
dystrybucja, odtwarzanie po awarii) dla jednego pliku na jednym hoście.
Nieproporcjonalne, i wprost sprzeczne z „użyj istniejących mechanizmów".
Odrzucone: sekrety w `hosts/<node>/runtime/…` (śledzone w Gicie);
`docker secret` (wymaga Swarma, którego repo świadomie nie używa —
patrz konwencja `mem_limit` w CLAUDE.md); osobny plik per job (rozdrabnia to,
co już jest scentralizowane i poprawnie uprawnione).
### (d) Host schedulera
**Rekomendacja: PIHA** — pełne uzasadnienie w §3.1 (24/7, archiwum kanoniczne,
lokalny kb-postgres, istniejący precedens `kb-ingest.timer`, decyzja
architektoniczna zakazująca stawiania na SOLARII czegokolwiek, co wymaga 24/7).
**Do rozstrzygnięcia razem z tym: takt indeksowania wobec dobowego cyklu
SOLARII** (§3.3). Rekomendacja: pobieranie co godzinę (`kb-mail-sync`, bez GPU),
indeksowanie częściej niż raz na dobę i bramkowane probe'em Ollamy, żeby
któryś tick trafił w okno pracy desktopu. Bez tego backlog embedów rośnie
każdej nocy i `KbEmbedBacklogGrowing` zapala się na stałe.
### (e) Zakres folderów per konto
**Rekomendacja:**
| Konto | Foldery | Uzasadnienie |
|---|---|---|
| gmail | **wyłącznie `\All`** (SPECIAL-USE, nie nazwa) | jedna wiadomość = jeden fetch mimo wielu etykiet; ta sama populacja co Takeout, na którym stoi 225 030 istniejących kopert → ciągłość korpusu |
| fastmail | `INBOX` + `Archive` + `Sent` | brak odpowiednika `\All`; te trzy pokrywają korespondencję prowadzoną i zarchiwizowaną |
Wyłączone z obu kont: `Spam`, `Trash`, `Drafts`. Spam i Trash to zdefiniowany
szum, a ich włączenie zmieniłoby populację względem tego, co już jest w bazie
(plan §Decyzja 4 odnotowuje, że Takeout „All Mail" nie zawiera Spamu). Drafty
nie są korespondencją — nie mają stabilnego Message-ID i mutują.
Zakres **musi być konfigurowalny listą per konto** (`MAIL_<ACCOUNT>_FOLDERS`),
bo tabela stanu jest kluczowana `(account, folder)`. Rozszerzenie o kolejny
folder jest wtedy zmianą konfiguracji, nie kodu, a dedup po Message-ID
gwarantuje, że dołożenie folderu z częściowo pokrywającą się zawartością niczego
nie zduplikuje.
**Osobne pytanie, którego nie rozstrzygam za operatora: historia Fastmaila.**
Konto ma zawartość sprzed dziś, a przyrostówka domyślnie zaczyna od
„od teraz" (`last_uid` = `UIDNEXT-1` przy pierwszym `SELECT`). Dwie opcje:
- **(e1) Tylko nowe.** Pierwszy tick zapisuje `last_uid` i nie pobiera nic
wstecz. Najprostsze, natychmiastowe, historia zostaje poza KB.
- **(e2) Pełny zaciąg.** Pierwszy przebieg z `UID SEARCH ALL` ściąga całą
zawartość wybranych folderów. Koszt zależy od rozmiaru skrzynki, którego
**nie znam** — to jest dokładnie ta „decyzja otwarta: sizing", którą
`kb-mail-pillar.md` §9 trzyma niezamkniętą od czerwca.
**Rekomendacja: (e2), ale dopiero po pomiarze.** Historia Fastmaila to
prawdopodobnie ta „sensowna poczta", o którą operatorowi chodziło bardziej niż
o gmailowe newslettery — a mechanizm jest ten sam co dla przyrostu, więc nie
kosztuje osobnego kodu. Warunek: najpierw jedno bezpieczne zapytanie
(`SELECT` folderu + `STATUS (MESSAGES)`), które poda liczbę wiadomości, i
dopiero na tej liczbie decyzja. Wykonalne w minutę **po** postawieniu klienta —
nie ma sensu zgadywać teraz.
### (f) Nowa decyzja, której zlecenie nie wymieniało: model stanu synca
Wypływa z §2.2 i wymaga zgody, bo dokłada **migrację `005`** do bazy, o której
faza mailowa deklarowała „zero migracji".
**Rekomendacja: tabela `mail_sync_state` w kb-postgres** (wariant A), nie plik
w `/opt/homelab/state/`. Powód rozstrzygający: stan synca i dane, którym
odpowiada, muszą się odtwarzać razem. Plik stanu przeżywający restore bazy
sprawia, że przyrostówka **cicho przeskakuje** wszystko między odtworzonym
stanem a bieżącym `last_uid` — awaria bez objawów, wykrywalna dopiero przy
zauważeniu brakujących maili miesiące później.
### (g) Nowa decyzja: `fastmail` w domyślnym trybie wyszukiwania
Z §2.5 (iii). `DEFAULT_SUMMARYLESS_SOURCES = ("gmail",)` sprawia, że koperty
fastmail byłyby niewidoczne w `/search` mimo poprawnego zembedowania.
**Rekomendacja: rozszerzyć do `("gmail", "fastmail")` w tym samym commicie,
który wprowadza źródło `fastmail`** — nie później. Rozdzielenie tych zmian tworzy
okno, w którym system wygląda na działający, a wyszukiwarka po cichu gubi całe
źródło. Zmiana jest jednowierszowa i nie ma wpływu na paperless (idzie kaskadą)
ani na inwariant startowy `kb-query` (sprawdza model embeddera, nie źródła).
---
## 5. Zależności z reconem multiagentowym i planem dyspozytora
Sprawdzony dokument: `docs/architecture/RECON-multiagent-2026-07-27.md`
**już nie istnieje pod tą ścieżką** — migracja OKF przeniosła go commitem
`00de810` do `kb/subsystems/recon-multiagent.md` (`type: subsystem`).
Analogicznie recony-audyty poszły commitem `9f77a72` do `kb/audits/`
(`type: audit`, wymagane pole `as_of`) — dlatego ten dokument leży
w `kb/audits/`, nie w `docs/architecture/`.
**Wprost: planu dyspozytora w tym reconie nie ma.** Dokument dotyczy w całości
subsystemu A (control-plane, node-agenty, MQTT, event pipeline, topologia).
`kb-query` pojawia się w nim wyłącznie jako wiersz inwentarza („kb-query | piha",
linia 129) i jako rozjazd topologii (linia 463: `topology.yaml` pomija
kb-query, choć `hosts/piha` go ma). Fraza „dyspozytor" nie występuje.
Dyspozytor jest zdefiniowany gdzie indziej —
`kb/decisions/architektura-2026-07-28.md`: *„B — do-the-work: dyspozytor zadań
(agent) + nogi KB / Home Assistant / homelab-ops. **Osobny wysiłek, osobny
projekt**"*, z PIHA jako *„przyszłym domem dyspozytora subsystemu B"*.
**Zależności twardych (blokujących) nie ma w żadną stronę.** Przyrostówka pisze
do `envelope`/`document_chunk`; kb-query czyta. Kontrakt między nimi to schemat
bazy, który się nie zmienia. Są natomiast **cztery realne styki**, które warto
mieć zapisane:
1. **Wybór hosta jest zgodny z docelową architekturą.** Dyspozytor ma zamieszkać
na PIHA; przyrostówka też tam trafia. Świeżość korpusu i jego konsument będą
w tym samym miejscu, bez nowych przeskoków sieciowych.
2. **Świeżość jest warunkiem sensowności dyspozytora.** Agent odpowiadający na
„co pisał X w zeszłym tygodniu" na korpusie urwanym 2026-06-19 zwróci
pewną siebie i nieprawdziwą odpowiedź. Przyrostówka jest tym, co zamienia
KB z archiwum w źródło bieżące — i to jest jej główny związek z subsystemem B.
3. **Wiki-kompilat jest zablokowany wprost.** `kb/phases/kb-m5-faza3.md:620`:
*„Pełna wiki po fazie mailowej (przyrostówka) — wcześniej kompilat byłby
fotografią przeszłości"*. To jedyna znaleziona **jawna** zależność „X czeka na
przyrostówkę" w całym KB.
4. **Nowy timer powiększa „shadow set" z otwartego pytania nr 5 tamtego reconu.**
Recon multiagentowy wymienia `kb-ingest` wśród bytów instalowanych **poza**
GitOps-owym wykrywaniem dryfu (*„shadow-deploy family: stability-agent,
agent-system, frigate, kb-ingest, HA config push"*) i stawia decyzję: co jest
w zakresie detekcji dryfu. `kb-mail-sync.timer` dołoży się do tej samej listy.
Nie blokuje to niczego i nie jest powodem do zmiany planu — ale przy
rozstrzyganiu pytania 5 lista będzie o jedną pozycję dłuższa i lepiej, żeby
trafiła tam świadomie niż przez przeoczenie.
Dla `kb-query` „API bez UI" przyrostówka nie zmienia **nic** po stronie
kontraktu HTTP: `/search` przyjmuje `q` i `mode`, a źródła są wewnętrznym
szczegółem retrievalu. Jedyny punkt styku to Decyzja **(g)** — bez niej nowe
źródło nie pojawi się w odpowiedziach ani przez UI, ani przez API.
---
## 6. Podsumowanie i zakres pracy
**Stan wyjściowy.** Korpus mailowy jest kompletny i zaindeksowany — do
**2026-06-19**. Od 48 dni nie przyrasta, i nie przyrośnie, bo w repo nie ma ani
jednej linii kodu rozmawiającej z serwerem pocztowym. Przy zmierzonym tempie
~37 maili/dobę poza KB jest już ~1 800 wiadomości, a licznik bije dalej.
**Co trzeba zbudować** (szacunek: 2 sesje, w tym testy):
| # | Element | Rodzaj |
|---|---|---|
| 1 | `jobs/mail-imap-sync` — klient IMAP (stdlib `imaplib`), pętla UIDVALIDITY/UIDNEXT, wybór folderu po SPECIAL-USE | nowy kod |
| 2 | Migracja `005_mail_sync_state.sql` | nowy plik |
| 3 | Wstawianie kopert z `entities = [headers, attachment]` w jednym kroku (reuse `parse_headers` + `parse_headers_fallback`) | reuse |
| 4 | `--source` / `--sources` w `mail_body_ingest.fetch_envelopes` + tryb „koperty bez chunków" | 2 małe zmiany |
| 5 | Zawężenie `fetch_existing_chunk_keys` do zbioru roboczego | 1 mała zmiana |
| 6 | `DEFAULT_SUMMARYLESS_SOURCES` += `"fastmail"` | 1 wiersz |
| 7 | Licznik `envelopes_conflict_other_source` | 1 mała zmiana |
| 8 | `systemd/kb-mail-sync.{service,timer}` + `kb-mail-sync-run.sh` | wzorzec 1:1 z `documents-ingest/systemd/` |
| 9 | Metryki `.prom` + reguła `KbMailSyncStale` w `services/fleet-prometheus/rules/` | wzorzec 1:1 z `kb-ingest.yml` |
| 10 | `env.example` + rozszerzenie `/opt/homelab/kb/.env` na PIHA | placeholdery w repo, wartości poza |
Punkty 47 to cztery drobne zmiany w istniejącym kodzie; **cały właściwy nowy
kod to pozycja 1**. Reszta pipeline'u — archiwum, koperta, parse z hardeningiem
8-bitowym, quote-strip, chunker, batch embed z breakerem i bisekcją, tor
Prometheus — jest zbudowana, przetestowana i przećwiczona na 225 tysiącach
wiadomości.
**Trzy rzeczy, które łatwo przeoczyć, a każda kosztuje cicho:**
1. Koperty bez `entities[type=headers]` dostają prefiks
`Temat: (brak tematu) | Od: ?`**bez błędu**, tylko z gorszym retrievalem
(§2.5 i).
2. `fastmail` nieujęty w `DEFAULT_SUMMARYLESS_SOURCES` znika z wyników mimo
poprawnego zembedowania — **bez błędu** (§2.5 iii).
3. Etap mailowy dopięty do ticku 03:30 zapali `KbEmbedBacklogGrowing` na stałe,
bo o tej porze SOLARIA śpi — potwierdzone dziś odczytem
`kb_ingest_embed_skipped 1` (§3.3).
**Do decyzji operatora przed startem:** (a) Gmail IMAP+app password,
(b) Fastmail IMAP+app password — co domyka otwartą od czerwca decyzję
„unifikacja adaptera" i **wymaga korekty** zapisów o JMAP w
`kb-mail-pillar.md` i §10 planu, (c) sekrety w istniejącym
`/opt/homelab/kb/.env` (**założenie zlecenia o plaintextowych hasłach w repo
jest nieprawdziwe** — obecny wzorzec jest poprawny), (d) PIHA + korekta taktu
indeksowania, (e) `\All` dla Gmaila i `INBOX`+`Archive`+`Sent` dla Fastmaila,
plus osobno historia Fastmaila po pomiarze rozmiaru skrzynki, (f) stan synca
jako tabela w bazie, (g) `fastmail` w domyślnym trybie wyszukiwania.

View file

@ -13,7 +13,10 @@ links: []
> retrieval, Etap A apply na żywej bazie, bramka jakościowa **PASS** — patrz §8). > retrieval, Etap A apply na żywej bazie, bramka jakościowa **PASS** — patrz §8).
> **Etap B (Krok 6) ZAMKNIĘTY 2026-08-06**: pełny korpus gmail jest zchunkowany > **Etap B (Krok 6) ZAMKNIĘTY 2026-08-06**: pełny korpus gmail jest zchunkowany
> i zembedowany (389 012 chunków, zero nie-excluded bez wektora) — patrz §9 > i zembedowany (389 012 chunków, zero nie-excluded bez wektora) — patrz §9
> „Wynik Etapu B". Następny: Krok 7 (recon przyrostówki IMAP/JMAP). > „Wynik Etapu B". **Krok 7 (recon przyrostówki) WYKONANY 2026-08-06**:
> `kb/audits/mail-sync-2026-08-06.md` — czeka na decyzje operatora (a)-(g),
> w tym rozstrzygnięcie IMAP vs JMAP dla Fastmaila (§10 niżej mówi JMAP,
> recon rekomenduje wspólny IMAP).
> >
> Kontynuacja `05-faza4-plan.md` (faza 4: `packages/kb-retrieval` wydzielone, > Kontynuacja `05-faza4-plan.md` (faza 4: `packages/kb-retrieval` wydzielone,
> serwis `kb-query` z UI działa na PIHA — „KB po raz pierwszy odpowiada przez > serwis `kb-query` z UI działa na PIHA — „KB po raz pierwszy odpowiada przez
@ -695,6 +698,15 @@ oznacza co innego: backend embed padł, trzeba wznowić plaster po naprawie Olla
## 10. Krok 7 — IMAP/JMAP przyrostówka (zarys; szczegóły = osobny recon) ## 10. Krok 7 — IMAP/JMAP przyrostówka (zarys; szczegóły = osobny recon)
> **Recon wykonany 2026-08-06: `kb/audits/mail-sync-2026-08-06.md`.** Zarys
> poniżej pochodzi z 2026-07-22 i zachowuję go jako zapis intencji. Recon
> rozstrzyga inaczej dwa jego punkty: (1) **Fastmail przez IMAP, nie JMAP**
> (unifikacja adaptera — jeden `jobs/mail-imap-sync` zamiast
> `fastmail-poller` + `gmail-imap-poller`), (2) spoiwem z torem body nie jest
> `--since`, tylko kolejka „koperty bez chunków". Reszta zarysu (poll zamiast
> IDLE, reuse `save_eml`/`insert_envelope`, sekrety w `/opt/homelab/config/`)
> się potwierdziła.
Zakotwiczone w kb-00 jako etapy 34 (`jobs/fastmail-poller`, Zakotwiczone w kb-00 jako etapy 34 (`jobs/fastmail-poller`,
`jobs/gmail-imap-poller`). Zarys decyzji do tamtego reconu: `jobs/gmail-imap-poller`). Zarys decyzji do tamtego reconu:
@ -734,7 +746,7 @@ Zakotwiczone w kb-00 jako etapy 34 (`jobs/fastmail-poller`,
| 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | **WYKONANE** | §7 „Wynik Etapu A" (run na żywo 2026-07-23); potwierdzone na żywej bazie 2026-08-04: `document_chunk` gmail = 33 871 (6 398 z embeddingiem + 27 473 `newsletter`) — zgodne co do sztuki z tabelą §7 | | 4 | rsync + Etap A (12 mies.) + kalibracja | 2 | 1 sesja | **WYKONANE** | §7 „Wynik Etapu A" (run na żywo 2026-07-23); potwierdzone na żywej bazie 2026-08-04: `document_chunk` gmail = 33 871 (6 398 z embeddingiem + 27 473 `newsletter`) — zgodne co do sztuki z tabelą §7 |
| 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | **WYKONANE** (PASS) | §8 „Wynik bramki"; `56f64e9` (eval + queries.yaml dla hybrid), `bce635c` (`mail_hit@3`, próg N2, werdykt PASS), `71eb264` (`--transport http`) | | 5 | Bramka jakościowa (eval mailowy + regresja) | 3, 4 + zapytania od operatora | 1 sesja | **WYKONANE** (PASS) | §8 „Wynik bramki"; `56f64e9` (eval + queries.yaml dla hybrid), `bce635c` (`mail_hit@3`, próg N2, werdykt PASS), `71eb264` (`--transport http`) |
| 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | **WYKONANE** (2026-08-06) | §9 „Wynik Etapu B"; żywa baza: 389 012 chunków, 0 nie-excluded bez embeddingu. Weryfikacja plastrami 0-4 (wszystkie EXIT 0) + fix NUL `4ec0b78`; `docs/sessions/2026-08-06-kb-etapb-backfill.md` | | 6 | Etap B (pełne archiwum) + regresja + obserwacja PIHA | 5 = PASS | 1 sesja | **WYKONANE** (2026-08-06) | §9 „Wynik Etapu B"; żywa baza: 389 012 chunków, 0 nie-excluded bez embeddingu. Weryfikacja plastrami 0-4 (wszystkie EXIT 0) + fix NUL `4ec0b78`; `docs/sessions/2026-08-06-kb-etapb-backfill.md` |
| 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | **OTWARTE — NEXT** | Odblokowane przez zamknięcie Etapu B. Brak `jobs/fastmail-poller` / `jobs/gmail-imap-poller`, brak dokumentu reconu; IMAP/JMAP występuje wyłącznie jako zarys w §10 i w `kb-00-overview.md` | | 7 | Recon przyrostówki IMAP/JMAP | — (po 6) | 1 sesja (poza DoD fazy) | **WYKONANE** (2026-08-06) | `kb/audits/mail-sync-2026-08-06.md`. Ustalenia: korpus urywa się 2026-06-19 (dziura 48 dni ≈ 1 800 maili), zero kodu IMAP w repo, brak modelu stanu synca; cały nowy kod to jeden `jobs/mail-imap-sync` + 4 drobne zmiany w istniejącym torze. Do decyzji operatora: (a)-(g) w §4 tamtego dokumentu |
**Kryterium ukończenia fazy mailowej:** (a) pełny korpus gmail zchunkowany **Kryterium ukończenia fazy mailowej:** (a) pełny korpus gmail zchunkowany
(bilans domknięty, `parse_errors` na poziomie pojedynczych sztuk jak (bilans domknięty, `parse_errors` na poziomie pojedynczych sztuk jak

View file

@ -16,6 +16,7 @@ def _atomic_write_json(path: Path, data) -> None:
os.fsync(f.fileno()) os.fsync(f.fileno())
os.replace(tmp, path) os.replace(tmp, path)
# Constants and Paths # Constants and Paths
RUNTIME_PATH = os.getenv("RUNTIME_PATH", "/opt/homelab") RUNTIME_PATH = os.getenv("RUNTIME_PATH", "/opt/homelab")
ACTIONS_DIR = Path(RUNTIME_PATH) / "actions" ACTIONS_DIR = Path(RUNTIME_PATH) / "actions"
@ -27,6 +28,17 @@ DISPATCH_DIR = ACTIONS_DIR / "dispatch"
# failure-reports every file it finds in its inbox, so a redeploy landing there # failure-reports every file it finds in its inbox, so a redeploy landing there
# would be killed before the runner ever saw it. # would be killed before the runner ever saw it.
DEPLOY_DISPATCH_DIR = ACTIONS_DIR / "deploy" DEPLOY_DISPATCH_DIR = ACTIONS_DIR / "deploy"
# Mode for the per-node inboxes under DISPATCH_DIR / DEPLOY_DISPATCH_DIR.
# These dirs are written by the executor (as the control-plane user on VPS) but
# drained by the target node, whose rsync-pull authenticates as a *different*
# user that is only a member of the owning group. --remove-source-files must
# unlink the fetched file, and unlink needs write permission on the containing
# directory — at 0o755 the group has none, so the source survives, rsync exits
# 23, and the node re-pulls the same action every cycle forever, bouncing off
# the idempotency gate. Observed on dispatch/lustro 2026-08-06 (session log,
# follow-up #1); the older dispatch/piha happened to be 775 and worked.
INBOX_DIR_MODE = 0o775
# The executor no longer reads the repo at all (the old redeploy path ran a # The executor no longer reads the repo at all (the old redeploy path ran a
# script out of it). Kept only so an operator can still see which checkout the # script out of it). Kept only so an operator can still see which checkout the
# container is wired to; nothing in this module resolves paths against it. # container is wired to; nothing in this module resolves paths against it.
@ -68,6 +80,27 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(
logger = logging.getLogger("executor") logger = logging.getLogger("executor")
def _ensure_inbox_dir(path: Path) -> None:
"""Create (or repair) a per-node inbox so the target node can drain it.
chmod runs unconditionally rather than only on creation, for two reasons:
mkdir(mode=...) is masked by the process umask and so cannot be relied on to
produce INBOX_DIR_MODE, and inboxes created by an earlier executor build
already exist at 0o755 across the fleet. Repairing here on the same code
path that writes the dispatch file keeps the fix to one idempotent call
and needs no startup scan that could drift out of sync with the writers.
"""
path.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path, INBOX_DIR_MODE)
except OSError as e:
# Deliberately not fatal: the dispatch file still gets written and the
# node still executes the action. Only the post-fetch source delete
# stays broken — and that now surfaces as a WARNING on the node side
# (node_agent.pull_dispatched_actions) instead of being swallowed.
logger.warning(f"Could not set mode {oct(INBOX_DIR_MODE)} on {path}: {e}")
class Executor: class Executor:
def __init__(self): def __init__(self):
self._ensure_dirs() self._ensure_dirs()
@ -217,7 +250,7 @@ class Executor:
logger.error(f"Action {action_id}: container_restart with no node set") logger.error(f"Action {action_id}: container_restart with no node set")
return return
inbox = DISPATCH_DIR / node inbox = DISPATCH_DIR / node
inbox.mkdir(parents=True, exist_ok=True) _ensure_inbox_dir(inbox)
payload = { payload = {
"action_id": action_id, "action_id": action_id,
"type": "container_restart", "type": "container_restart",
@ -249,7 +282,7 @@ class Executor:
Does not resolve the action itself; _reconcile_running_actions() does. Does not resolve the action itself; _reconcile_running_actions() does.
""" """
inbox = DEPLOY_DISPATCH_DIR / node inbox = DEPLOY_DISPATCH_DIR / node
inbox.mkdir(parents=True, exist_ok=True) _ensure_inbox_dir(inbox)
payload = { payload = {
"action_id": action_id, "action_id": action_id,
"type": "redeploy", "type": "redeploy",

View file

@ -9,9 +9,11 @@ forever.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock
import pytest import pytest
@ -239,3 +241,78 @@ def test_alert_only_action_resolves_synchronously_not_via_dispatch(tmp_path, mon
assert _exists(tmp_path, "completed", "alert-1") assert _exists(tmp_path, "completed", "alert-1")
assert not (tmp_path / "actions" / "dispatch").exists() or \ assert not (tmp_path / "actions" / "dispatch").exists() or \
not list((tmp_path / "actions" / "dispatch").glob("**/*.json")) not list((tmp_path / "actions" / "dispatch").glob("**/*.json"))
# ---------------------------------------------------------------------------
# Inbox permissions (dispatch leak, 2026-08-06)
#
# The per-node inbox is written here but drained by the target node over rsync
# --remove-source-files, authenticating as a different user that is only a
# group member. Unlinking the fetched file needs write permission on the
# containing dir; at 0o755 it silently fails and the node re-pulls the same
# action forever.
# ---------------------------------------------------------------------------
@pytest.fixture
def restrictive_umask():
"""0o022 masks the group-write bit out of mkdir(mode=0o775) — the reason
the fix cannot rely on the mode argument alone."""
old = os.umask(0o022)
yield
os.umask(old)
def _mode(path: Path) -> int:
return path.stat().st_mode & 0o777
def test_dispatch_inbox_is_group_writable(tmp_path, monkeypatch, restrictive_umask):
ex = _setup_executor(tmp_path, monkeypatch)
ex._execute_action(_write_approved(tmp_path, "cr-perm", node="piha"))
inbox = tmp_path / "actions" / "dispatch" / "piha"
assert (inbox / "cr-perm.json").exists()
assert _mode(inbox) == 0o775
def test_deploy_inbox_is_group_writable(tmp_path, monkeypatch, restrictive_umask):
"""Same defect, same rsync-pull drain — the deploy runner's inbox needs it too."""
ex = _setup_executor(tmp_path, monkeypatch)
ex._execute_action(
_write_approved(tmp_path, "rd-perm", node="piha", action_type="redeploy")
)
inbox = tmp_path / "actions" / "deploy" / "piha"
assert (inbox / "rd-perm.json").exists()
assert _mode(inbox) == 0o775
def test_existing_inbox_is_repaired_in_place(tmp_path, monkeypatch, restrictive_umask):
"""Inboxes already on disk fleet-wide were created at 0o755 by an earlier
build; dispatching to one must fix it rather than inherit it."""
ex = _setup_executor(tmp_path, monkeypatch)
inbox = tmp_path / "actions" / "dispatch" / "piha"
inbox.mkdir(parents=True)
os.chmod(inbox, 0o755)
ex._execute_action(_write_approved(tmp_path, "cr-repair", node="piha"))
assert _mode(inbox) == 0o775
def test_dispatch_survives_unsettable_mode(tmp_path, monkeypatch, restrictive_umask):
"""A chmod failure (inbox owned by another user) must not cost us the
dispatch the action still executes on the node; only the source delete
stays broken, and the node warns about that."""
ex = _setup_executor(tmp_path, monkeypatch)
monkeypatch.setattr(
executor_module.os, "chmod",
MagicMock(side_effect=PermissionError("Operation not permitted")),
)
ex._execute_action(_write_approved(tmp_path, "cr-chmod-fail", node="piha"))
assert (tmp_path / "actions" / "dispatch" / "piha" / "cr-chmod-fail.json").exists()
assert _exists(tmp_path, "running", "cr-chmod-fail")

View file

@ -169,6 +169,23 @@ def _utc_iso() -> str:
_EVENT_TS_RE = re.compile(r"-(\d{9,11})-") _EVENT_TS_RE = re.compile(r"-(\d{9,11})-")
_EVENT_TYPE_RE = re.compile(r"^evt-.+?-\d{9,11}-(.+)$") _EVENT_TYPE_RE = re.compile(r"^evt-.+?-\d{9,11}-(.+)$")
# rsync exits 23 for two very different situations and the dispatch pull hits
# both routinely, so the stderr has to be read to tell them apart:
#
# * The remote inbox does not exist yet. The executor creates
# actions/dispatch/<node>/ only on its first dispatch to that node, so until
# then every pull reports `change_dir "..." failed: No such file or
# directory`. Expected, and warning about it would mean one line a minute on
# every node that has never been sent an action.
# * The files WERE fetched but rsync could not unlink the source
# (`sender failed to remove <file>: Permission denied`) — the dispatch leak
# of 2026-08-06. That one has to be loud: it means the same actions come
# back on every single cycle until someone fixes the directory mode on VPS.
#
# (An empty-but-existing remote inbox — by far the most common case — is a plain
# rc=0 and never reaches here.)
_RSYNC_MISSING_SRC_RE = re.compile(r"change_dir .* failed: No such file or directory")
def _event_ts_from_filename(name: str): def _event_ts_from_filename(name: str):
"""Return the embedded <unixts> from an event filename, or None if absent.""" """Return the embedded <unixts> from an event filename, or None if absent."""
@ -951,14 +968,45 @@ class NodeAgent:
] ]
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
# rsync returns 23/24 ("partial transfer"/"vanished source files") self._log_dispatch_pull_result(result.returncode, result.stderr or "")
# when the remote dispatch dir is simply empty — the common case,
# not an error worth logging every cycle.
if result.returncode not in (0, 23, 24):
logger.warning(f"Dispatch pull failed: {result.stderr.strip()}")
except Exception as exc: except Exception as exc:
logger.warning(f"Dispatch pull error: {exc}") logger.warning(f"Dispatch pull error: {exc}")
@staticmethod
def _log_dispatch_pull_result(returncode: int, stderr: str) -> None:
"""Classify an rsync exit code from the dispatch pull.
Visibility only the caller retries on the next cycle either way, and
an action that was fetched is executed regardless of what the source
side did. Codes:
0 clean, including "remote inbox exists and is empty".
24 a source file vanished between the file list and the transfer.
A benign race with the executor writing the inbox concurrently.
23 "some files were not transferred", which covers two situations
that must NOT be logged alike (see _RSYNC_MISSING_SRC_RE).
* real transport failure (ssh down, auth, timeout).
Before 2026-08-06 every one of these was benign-listed, which is how the
dispatch leak stayed invisible: the node re-pulled the same actions
every 60 s for days, silently bouncing them off the idempotency gate.
"""
stderr = stderr.strip()
if returncode in (0, 24):
return
if returncode == 23:
if _RSYNC_MISSING_SRC_RE.search(stderr) and "failed to remove" not in stderr:
logger.debug(f"Dispatch inbox not present on VPS yet: {stderr}")
return
logger.warning(
"Dispatch pull incomplete (rsync rc=23): action files were "
"fetched but their source copy on VPS was NOT removed, so they "
"will be re-pulled every cycle. Check the mode of "
f"actions/dispatch/<node>/ on VPS (needs group write). {stderr}"
)
return
logger.error(f"Dispatch pull failed (rsync rc={returncode}): {stderr}")
def process_dispatched_actions(self): def process_dispatched_actions(self):
"""Execute every action currently sitting in this node's dispatch inbox.""" """Execute every action currently sitting in this node's dispatch inbox."""
inbox = self._dispatch_inbox_dir() inbox = self._dispatch_inbox_dir()

View file

@ -238,13 +238,77 @@ def test_pull_invokes_rsync_pull_direction(agent, monkeypatch):
assert cmd[-1] == str(agent._dispatch_inbox_dir()) + "/" assert cmd[-1] == str(agent._dispatch_inbox_dir()) + "/"
def test_pull_treats_empty_source_returncodes_as_non_error(agent, monkeypatch, caplog): # ----------------------------------------------------------------------
def fake_run(cmd, **kwargs): # rsync exit-code classification (dispatch leak, 2026-08-06)
return MagicMock(returncode=23, stderr="rsync: some vanished-source message") #
# rc=23 used to be benign-listed together with 0 and 24, which is why the
# leak — files fetched but never removed from VPS, so re-pulled every 60 s —
# produced no log line at all for days. These pin the four outcomes.
# ----------------------------------------------------------------------
monkeypatch.setattr(node_agent.subprocess, "run", fake_run) # Verbatim rsync 3.4.1 stderr for the two distinct rc=23 causes.
_STDERR_UNDELETABLE_SOURCE = (
"rsync: [sender] sender failed to remove act-123.json: Permission denied (13)\n"
"rsync error: some files/attrs were not transferred "
"(see previous errors) (code 23) at main.c(1356) [sender=3.4.1]"
)
_STDERR_MISSING_INBOX = (
'rsync: [sender] change_dir "/opt/homelab/actions/dispatch/test-node" '
"failed: No such file or directory (2)\n"
"rsync error: some files/attrs were not transferred "
"(see previous errors) (code 23) at main.c(1356) [sender=3.4.1]"
)
with caplog.at_level("WARNING"):
def _pull_with(agent, monkeypatch, returncode, stderr=""):
monkeypatch.setattr(
node_agent.subprocess, "run",
lambda cmd, **kwargs: MagicMock(returncode=returncode, stderr=stderr),
)
agent.pull_dispatched_actions() agent.pull_dispatched_actions()
assert "Dispatch pull failed" not in caplog.text
def test_pull_rc0_logs_nothing(agent, monkeypatch, caplog):
with caplog.at_level("DEBUG"):
_pull_with(agent, monkeypatch, 0)
assert "Dispatch pull" not in caplog.text
def test_pull_rc24_vanished_source_stays_benign(agent, monkeypatch, caplog):
"""A file removed between file-list and transfer is a race with the
executor writing the inbox, not a fault."""
with caplog.at_level("WARNING"):
_pull_with(agent, monkeypatch, 24, "rsync warning: some files vanished")
assert caplog.text == ""
def test_pull_rc23_undeletable_source_warns_with_stderr(agent, monkeypatch, caplog):
"""The leak itself: loud, and carrying the rsync stderr that names it."""
with caplog.at_level("WARNING"):
_pull_with(agent, monkeypatch, 23, _STDERR_UNDELETABLE_SOURCE)
assert "WARNING" in caplog.text
assert "rc=23" in caplog.text
# Full stderr forwarded, so the operator sees which file and why.
assert "sender failed to remove act-123.json: Permission denied" in caplog.text
def test_pull_rc23_missing_remote_inbox_is_quiet(agent, monkeypatch, caplog):
"""A node that has never been dispatched to gets rc=23 on every cycle
because the executor has not created its inbox yet. Warning here would be
a line a minute on most of the fleet and would bury the case above."""
with caplog.at_level("WARNING"):
_pull_with(agent, monkeypatch, 23, _STDERR_MISSING_INBOX)
assert caplog.text == ""
def test_pull_other_returncode_logs_error(agent, monkeypatch, caplog):
with caplog.at_level("WARNING"):
_pull_with(agent, monkeypatch, 255, "ssh: connect to host vps port 22: No route to host")
assert "ERROR" in caplog.text
assert "rc=255" in caplog.text
assert "No route to host" in caplog.text