Files
nanobot-runtime/plans/remind-sqlite-redesign.md
2026-06-10 06:44:58 +02:00

7.2 KiB

Přechod /remind skillu z YAML na SQLite

Kontext

Současný /remind skill ukládá reminder data do reminder.yaml a dedup state do .reminder_state.json. YAML má známé problémy: žádné transakce, celý soubor se načítá do paměti, dedup je hrubý (hash textu), chybí audit trail pro CRUD operace. Cílem je přejít na SQLite jako primární storage s textovým logem pro audit.

Postup

1. Schéma a inicializace databáze

Cesta k DB: workspace/db/reminders.sqlite (podle pravidel v AGENTS.md).

Vytvořit skills/remind/scripts/db.py:

  • Funkce init_db(path) — spustí CREATE TABLE/INDEX z schématu níže
  • Funkce get_db(path) — vrací connection s WAL mode a foreign keys
  • Funkce log_operation(operation, reminder_id, details) — append do workspace/log/reminder.log

Schéma:

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS reminders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    text TEXT NOT NULL CHECK(text <> ''),
    enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
    timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    deleted_at TEXT
);

CREATE TABLE IF NOT EXISTS schedule_at (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    at_datetime TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS schedule_cron (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    cron_expr TEXT NOT NULL CHECK(cron_expr <> '')
);

CREATE TABLE IF NOT EXISTS schedule_random (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    times_per_day INTEGER NOT NULL CHECK(times_per_day >= 1),
    window_start INTEGER NOT NULL CHECK(window_start >= 0 AND window_start < 1440),
    window_end INTEGER NOT NULL CHECK(window_end > 0 AND window_end <= 1440),
    days_filter TEXT,
    from_date TEXT,
    until_date TEXT,
    CHECK(window_start < window_end)
);

CREATE TABLE IF NOT EXISTS reminder_fires (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
    schedule_id INTEGER NOT NULL,
    schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at', 'cron', 'random')),
    fire_time TEXT NOT NULL,
    delivered_at TEXT,
    status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')),
    error_message TEXT
);

CREATE INDEX IF NOT EXISTS idx_reminders_text ON reminders(text);
CREATE INDEX IF NOT EXISTS idx_at_datetime ON schedule_at(reminder_id, at_datetime);
CREATE INDEX IF NOT EXISTS idx_cron_expr ON schedule_cron(reminder_id, cron_expr);
CREATE INDEX IF NOT EXISTS idx_random_dates ON schedule_random(from_date, until_date);
CREATE INDEX IF NOT EXISTS idx_fire_lookup ON reminder_fires(
    reminder_id, schedule_type, schedule_id, fire_time, status
);

2. Přepsat remind_edit.py

Změnit backend z YAML na SQLite, zachovat CLI rozhraní. Všechny operace v jedné SQLite transakci.

Subcommandy:

  • list — SQL JOIN místo yaml.safe_load. Výstup: JSON pole reminderů se scheduly.
  • addINSERT INTO reminders → získat lastrowidINSERT INTO schedule_* podle parametrů. Podporuje kombinaci at, cron, random v jednom reminderu.
  • remove --keywordSELECT id FROM reminders WHERE text LIKE '%keyword%'UPDATE deleted_at = now (soft delete). Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení.
  • edit --keywordUPDATE reminders.text nebo přidání/odebrání schedulů. Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení.
  • enable --keyword / disable --keywordUPDATE reminders SET enabled = 0/1.

Log formát do workspace/log/reminder.log:

2026-06-10T06:40:00 [ADD] id=42 text="..." at=2026-06-11T09:00:00
2026-06-10T06:41:00 [REMOVE] id=42 text="..."
2026-06-10T06:42:00 [EDIT] id=42 text="..."
2026-06-10T06:43:00 [ENABLE] id=42
2026-06-10T06:44:00 [DISABLE] id=42

3. Přepsat remind_send.py

Změnit z YAML+JSON state na SQLite. Cesta k DB přes argument nebo default workspace/db/reminders.sqlite.

Algoritmus:

  1. Query pro všechny due fires (at + cron + random) v jednom SELECT s UNION ALL.
  2. Pro každý fire: zkontrolovat reminder_fires — pokud existuje řádek se stejným reminder_id + schedule_id + schedule_type + fire_time a status = 'delivered', přeskočit.
  3. Odeslat Telegram notifikaci.
  4. INSERT INTO reminder_fires (... status='delivered', delivered_at=now).
  5. Při chybě odeslání: INSERT INTO reminder_fires (... status='failed', error_message=e) — příští běh retry.
  6. Log do reminder.log: 2026-06-10T09:00:00 [DELIVER] id=42 text="..."

Cron job — příkaz zůstává stejný (uv run skills/remind/scripts/remind_send.py), skript si sám najde DB.

Query pattern (at):

SELECT r.id, r.text, 'at' AS schedule_type, sa.id AS schedule_id, sa.at_datetime AS fire_time
FROM reminders r JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
  AND sa.at_datetime > datetime('now', '-60 seconds')
  AND sa.at_datetime <= datetime('now')
  AND NOT EXISTS (
      SELECT 1 FROM reminder_fires rf
      WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id
        AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime
        AND rf.status = 'delivered'
  )

Query pattern (cron): Načíst všechny aktivní cron expr, v Pythonu přes croniter vypočítat poslední fire time, porovnat s now - 60s.

Query pattern (random): Načíst všechny aktivní random scheduly, v Pythonu přes compute_fire_times(date.today(), ...) vypočítat fire times, porovnat s now - 60s.

4. Migrační skript

Vytvořit scripts/migrate_yaml_to_sqlite.py:

  • Načte reminder.yaml
  • BEGIN TRANSACTION
  • Pro každý reminder: INSERT INTO reminders → získat lastrowid
  • Podle polí at / at_times / cron_exprs / random vložit do příslušných schedule tabulek
  • COMMIT
  • Přejmenovat reminder.yamlreminder.yaml.bak

5. Testy

  • tests/test_db.py — inicializace schématu, INSERT/SELECT/UPDATE/DELETE, foreign keys, constraints
  • tests/test_remind_edit.py — add, remove, list, edit, enable/disable s :memory: databází
  • tests/test_remind_send.py — due detection, dedup, retry failed, log output
  • Aktualizovat tests/test_random_times.py — stále platný, random_times.py se nemění

6. Aktualizovat SKILL.md a dokumentaci

  • Popsat nové DB storage místo YAML
  • Popsat reminder.log formát
  • Aktualizovat reminder.example.yaml na SQL příklady nebo odstranit

Ověření

  1. Spustit pytest skills/remind/tests/ — všechny testy procházejí
  2. Spustit migrační skript — reminder.yaml se převede, .bak vznikne
  3. Ručně přidat reminder přes remind_edit.py add — ověřit v DB přes sqlite3
  4. Spustit remind_send.py — ověřit delivery a log
  5. Ověřit dedup: spustit send dvakrát ve stejné minutě — druhý běh nesmí poslat duplikát
  6. Ověřit remind_edit.py list — zobrazí všechny aktivní reminder
  7. Ověřit remind_edit.py remove --keyword — soft delete, záznam zůstane v DB s deleted_at