Wdrożenie: synchronizacja kalendarzy Radicale → Basecamp
Data: 2026-09-03 | Wariant A1 (decyzja Kuby) | Status: wdrożone, testy zakończone
Co powstało
One-way sync Radicale (CalDAV) → Basecamp Schedule + (decyzja Kuby z 2026-09-03) reverse sync BC → Radicale dla wpisów zarządzanych przez sync.
- Kode:
/opt/radicale2basecamp/—radicale2basecamp.py(forward),reverse_sync.py(BC→Radicale),cron_wrapper.sh,backup_state.py - Config z sekretami:
/etc/radicale2basecamp/config.toml(0600, poza repo) - Stan:
/var/lib/radicale2basecamp/state.db(SQLite, 0600): tabeleevents,calendars,log,cycles - Cron: co 15 min (
*/15), wrapper ztimeout600 s (kill bezpieczny — stan per operacja); backup bazy codziennie 3:17 (retencja 14 d) - Logi:
/var/log/radicale2basecamp/sync.log(INFO bez tytułów), heartbeat/var/lib/radicale2basecamp/last_success
Parametry
| Parametr | Wartość |
|---|---|
| Projekt BC „Rodzinne" | bucket 48748049 |
| Schedule „Calendar" | 10266228493 |
| Kalendarze | N, Rodzina, K-Praca, K, K-Sport (666166646 pominięty — 0 eventów) |
| Prefiksy tytułów | [N], [Rodzina], [K-Praca], [K], [K-Sport] |
| Okno rolling | [-30 d; +180 d] |
| Do BC | tylko tytuł + daty (bez DESCRIPTION/VALARM/uczestników; LOCATION nie) |
| Throttle | ≤5 req/s, Retry-After, backoff 5xx (max 5) |
| Timeout cyklu | 10 min (20 przy --full); kill bezpieczny |
| Kolejność cyklu | trashes → creates → updates (mutacje = trash+create, patrz niżej) |
Wyniki liczbowe (stan po wdrożeniu)
| Kalendarz | Wpisów w BC | Uwagi |
|---|---|---|
| N | 16 | |
| Rodzina | 179 | w tym 120 wystąpień recurring |
| K-Praca | 4 | w tym 4 recurring (rocznice/urodziny) |
| K | 0 | 6 eventów w źródle, wszystkie poza oknem (2025-04…2026-05) — zweryfikowane pełnym listingiem |
| K-Sport | 0 | 74 eventy w źródle (plan 6-tyg. 2025-10…11), wszystkie poza oknem |
| Razem | 199 | zero duplikatów |
Wyniki weryfikacji endpointów BC (PoC, realne wywołania)
schedule create— ZWERYFIKOWANY (działa)recordings trash— ZWERYFIKOWANY (statustrashedpotwierdzony)schedule update— NIEZWERYFIKOWANY w tej sesji (approval gate blokuje mutacjeupdate); sync używa trash+create zamiast update — zgodne ze spec (idempotentny rebuild, kolejność trashes→creates). Uwaga praktyczna: CLI wymaga podania--starts-at/--ends-atrazem ze--summary(samo--summary→ 422).
Sync dwukierunkowy (decyzja Kuby 2026-09-03, nadpisuje one-way ze spec)
Forward (Radicale→BC) działa jak wyżej. Dodatkowo reverse (BC→Radicale):
- Dotyczy wyłącznie wpisów z prefiksem i obecnych w state (własność syncu).
- Zmiana tytułu/daty wpisu
[X] …w BC → update VEVENT w Radicale (SUMMARY bez prefiksu, DTSTART/DTEND); RRULE/EXDATE istniejącego VEVENT są zawsze zachowywane (reverse nie może zniszczyć rekurencji). - Usunięcie wpisu w BC → DELETE zasobu z Radicale.
- Wpis bez prefiksu = nietknięty; usunięcie prefiksu z tytułu = „odłączenie" wpisu (flaga
detached, sync przestaje go obsługiwac w obie strony). - Konflikt (obie strony zmienione) → wygrywa Radicale, log WARN.
- Anty-pętla: stabilny hash (sha256 tytuł+daty) zapisany w state przy każdej własnej operacji; cykl działa tylko na rzeczywiste różnice ≠ własna ostatnia operacja.
Gwarancja przetrwania („pewnego dnia zostanie tylko Basecamp")
- Health-check Radicale na starcie cyklu. Radicale down ⇒ cykl NO-OP: zero trashów, zero updateów BC, tylko licznik porażek + WARN.
- Trashedowanie BC wyłącznie przy poprawnej odpowiedzi Radicale (200/207) i faktycznym braku eventu w listingu.
- Po 48 h kolejnych porażek (192 cykle) cron self-disable (komentarz
#PAUSEDw crontab) + wpis w logu; wpisy BC pozostają nietknięte.
Testy weryfikacyjne (wszystkie wykonane realnie)
| Test | Wynik |
|---|---|
(a) zmiana tytułu wpisu [N] w BC → po cyklu zmiana SUMMARY w Radicale | ✅ |
(b) usunięcie wpisu [N] w BC → po cyklu DELETE z Radicale (HTTP 404 po) | ✅ |
| (c) wpis bez prefiksu w BC → zero reakcji syncu (unclaimed=1) | ✅ |
| (d) Radicale down (martwy port w configu) → cykl NO-OP, licznik porażek, stan BC bez zmian | ✅ |
| (e) przywrócenie Radicale → sync wznawia, zero duplikatów | ✅ |
| DST: 19:15 CEST → 17:15 UTC w BC ([Rodzina] Jozef stomatolog) | ✅ |
| DST: testowy event 19:15 CET (2026-12-10) → 18:15 UTC w BC | ✅ |
| Edycja w Radicale widoczna w BC ≤ 20 min (cron 15 min) | ✅ strukturalnie + test tytułu |
| Usunięcie → trash ≤ 20 min | ✅ |
| Zero duplikatów po 3+ restartach, w tym kill -9 w trakcie cyklu (Rodzina) | ✅ (199 wpisów, duplikaty: 0) |
| RRULE → osobne wpisy per wystąpienie | ✅ (26+25+25+25+13+… wystąpień) |
| RECURRENCE-ID nadpisania → osobne wpisy | ✅ (2 szt.) |
Sekrety poza repo/logami (grep /opt, /root/convertere, logi sync) | ✅ |
| Put/Delete do Radicale (reverse) | ✅ (201/200) |
Naprawione w trakcie wdrożenia
- EXDATE parsing —
vDDDListsnie jest iterowalny pojedynczo; obsługa listy właściwości. - All-day w BC — API odrzuca
YYYY-MM-DD; formatT00:00:00Z/T23:59:59Z(RFC3339). - Stabilny hash między procesami — wbudowany
hash()jest solony per proces (powodowałby rebuild wszystkiego co cykl); zastąpiony sha256. - Reverse nie niszczy rekurencji — PUT z Radicale zachowuje RRULE/EXDATE istniejącego VEVENT (wykryty i naprawiony przypadek utraty RRULE na all-day rocznicy — odtworzone ręcznie, stan spójny).
- Reverse delete bug indeksowania — kolumna
detachedczytana jawnym SELECT-em.
Ograniczenia ( jawne )
schedule updateBC: nieskategoryzowane w tej sesji (approval gate) — mutacje przez trash+create; nowe ID wpisu przy każdej edycji.- K i K-Sport mają 0 wpisów, bo ich eventy są poza oknem [-30;+180] — gdy wpiszesz nowe eventy, sync je pobierze automatycznie.
- Wpisy BC utworzone ręcznie przez człowieka (bez prefiksu) są nietknięte; adopcja tylko przez świadomą decyzję (
--claim, ręcznie). - Gwarancja przetrwania zależy od crontab — jeśli ktoś skasuje
#PAUSEDmarker i odświeży cron, sync wróci (to zamierzone).
Logowanie do czatu AI log (wymaganie Kuby 2026-09-03, dodane po wdrożeniu)
Każda akcja syncu idzie na czat Basecamp „AI log" (room 10271281621, bucket 48748049) przez moduł /opt/radicale2basecamp/bc_log.py. Kanał dodatkowy — sync.log działa bez zmian.
- Room w configu:
/etc/radicale2basecamp/config.toml→[basecamp] log_chat_room = 10271281621. - Co jest logowane: START cyklu (tryb, okno), każdy CREATE/UPDATE (trash+create)/TRASH — sukces ✅ i błąd ❌, reverse PUT/DELETE BC→Radicale, GUARD masowego usunięcia 🚨, Radicale down / self-disable 🚨, END cyklu 📊 (podsumowanie
+X ~Y -Z (N unchanged, Ds)), przerwanie cyklu timeoutem ⚠️. - Format:
[R2BC] ✅ CREATE [N] Tytuł (2026-09-08 11:00-12:00 UTC)— emoji ✅/❌/📊/⚠️/🚨 dla skanowalności. - Agregacja masowych operacji: serii recurring ≥10 wystąpień tej samej pozycji postują się z throttle-key (1 wiadomość +
(xN)przy flush), unchanged jako agregat w END, reverse jako podsumowanie per cykl. Normalny cykl inkrementalny = pojedyncze wpisy. - Rate limit: ≤4 post/s (limit API 5/s z zapasem), honorowanie
Retry-After, backoff przy 5xx/network, 3 próby. - Fail-safe: awaria czatu (BC down, 404, rate limit, timeout) nigdy nie przerywa cyklu — tylko
WARNING BC CHAT LOG FAILEDw sync.log. Przetestowane E2E: cykl z błędnym roomem kończy się OK (created/updated/trashed zapisane, warning w logu). - Kill-switch: env
R2BC_CHAT_LOG=0wyłącza posty (do debugowania). - Tytuły w TRASH: tabela
titlesw state.db (klucz → ostatni tytuł) — TRASH pokazuje czytelny tytuł nawet gdy event już zniknął z Radicale.
Testy czatu (realne, 2026-09-03)
| Test | Wynik |
|---|---|
| (a) ręczny cykl → START, CREATE, TRASH, END widoczne na czacie | ✅ |
(b) wymuszony create (testowy event PUT do Radicale) → ✅ CREATE [N] … na czacie; usunięcie → ✅ TRASH [N] … | ✅ |
| (c) sync.log nadal pełny (INFO, struktura bez zmian) | ✅ |
| (d) idempotencja: 2 cykle bez zmian → tylko START+END („bez zmian (199 unchanged)") | ✅ |
| fail-safe: cykl z błędnym roomem → sync OK, 2× WARNING w sync.log | ✅ |
| cron_wrapper.sh ręcznie po modyfikacji → END na czacie | ✅ |
| py_compile wszystkich modułów | ✅ |
Runbook operacyjny
- Logi:
tail -f /var/log/radicale2basecamp/sync.log(INFO), cron.log, heartbeat/var/lib/radicale2basecamp/last_success. - Ręczny cykl:
python3 /opt/radicale2basecamp/radicale2basecamp.py(opcje:--full20 min timeout,--dry-run,--calendar NAME,--reconcile). - Re-auth BC (token ~70 h):
basecamp auth login --device-code— wymaga kroku człowieka; sync wtedy nie wykona nic (401/403), heartbeat zastygnie — sygnał do re-auth. - Radicale down >48 h: cron sam się pause'uje (marker
#PAUSEDw crontab); aby wznowić: usuń#PAUSEDz linii (crontab -e) po przywróceniu Radicale. - Uszkodzenie state.db: przywróć z
/var/backups/radicale2basecamp/state-*.db(ostatni), następnie jeden cykl normalny; przy niespójności--reconcile(raport tylko — adopt ręcznie). - Pauza/odpalenie ręczne:
crontab -e— zakomentuj/wykomentuj linieradicale2basecamp. - Dodanie kalendarza: sekcja
[[calendars]]w configu (0600) — zero zmian w kodzie. - Guard masowego usunięcia: >20% wpisów kalendarza do trasha w jednym cyklu (i >1 master-UID) → pause tego kalendarza + log
GUARD; decyzja człowieka.