Compare commits
3 commits
a62c36c0b6
...
75d96956a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75d96956a5 | ||
|
|
1bab3219d6 | ||
|
|
52eca1c22a |
|
|
@ -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 — R1–R3 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
809
kb/audits/mail-sync-2026-08-06.md
Normal file
809
kb/audits/mail-sync-2026-08-06.md
Normal 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 3–4, **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 60–90 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 ~8–18 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 1–2 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 4–7 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.
|
||||||
|
|
@ -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 3–4 (`jobs/fastmail-poller`,
|
Zakotwiczone w kb-00 jako etapy 3–4 (`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 3–4 (`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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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"):
|
with caplog.at_level("WARNING"):
|
||||||
agent.pull_dispatched_actions()
|
_pull_with(agent, monkeypatch, 24, "rsync warning: some files vanished")
|
||||||
|
|
||||||
assert "Dispatch pull failed" not in caplog.text
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue