Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/matebot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,12 @@ def events(self):

messenger = create_messenger(config)

def schedule_sync():
def schedule_sync(*, quiet: bool = False):
if config.sync_enabled and config.data_repo:
asyncio.create_task(
sync_soon(
client, config.data_repo, messenger.send,
site_title=config.site_title,
site_title=config.site_title, state=state, quiet=quiet,
)
)

Expand Down Expand Up @@ -171,9 +171,20 @@ async def pump_events():

async def pump_shots():
import re as _re
import time as _time

last_frame_at = 0.0

async def tee(source):
nonlocal last_frame_at
async for frame in source:
now = _time.monotonic()
gap = now - last_frame_at if last_frame_at else None
last_frame_at = now
if state.get("sync_pending") and (gap is None or gap > 60):
log.info("machine is back; retrying pending journal sync")
state.set("sync_pending", False) # avoid re-trigger storms
schedule_sync(quiet=True)
cache_frame(frame)
await router.on_frame(frame)
yield frame
Expand Down
29 changes: 24 additions & 5 deletions src/matebot/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@

from __future__ import annotations

import asyncio
import fcntl
import json
import logging
import re
import subprocess
from pathlib import Path

import aiohttp

from .machine import GaggiMateClient, MachineError
from .sitegen import generate

Expand Down Expand Up @@ -130,15 +131,33 @@ async def sync(


async def sync_soon(
client: GaggiMateClient, repo: str | Path, notify, *, site_title: str = "Shot Journal"
client: GaggiMateClient, repo: str | Path, notify, *,
site_title: str = "Shot Journal", state=None, quiet: bool = False,
) -> None:
"""Post-shot sync wrapper: run, report conflicts to the user, never raise."""
"""Post-shot sync wrapper: run, report problems, never raise.

A failed sync is remembered in *state* (``sync_pending``) so the caller
can retry when the machine comes back online.
"""
try:
await sync(client, repo, site_title=site_title)
except SyncConflict as exc:
await notify(f"⚠️ Shot journal sync hit a git conflict — fix it manually:\n{exc}")
except (TimeoutError, aiohttp.ClientError, OSError) as exc:
log.warning("sync failed, machine unreachable: %r", exc)
if state is not None:
state.set("sync_pending", True)
if not quiet:
await notify(
"📡 The machine went offline before the journal could sync — "
"I'll catch up as soon as it's back on."
)
except Exception as exc: # noqa: BLE001
log.exception("sync failed")
await notify(f"⚠️ Shot journal sync failed: {exc}")
if state is not None:
state.set("sync_pending", True)
if not quiet:
await notify(f"⚠️ Shot journal sync failed: {exc!r}")
else:
await asyncio.sleep(0)
if state is not None:
state.set("sync_pending", False)
43 changes: 43 additions & 0 deletions tests/test_sync_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import aiohttp
import pytest

from matebot import sync as sync_mod
from matebot.state import State
from matebot.sync import sync_soon


class Notify:
def __init__(self):
self.sent = []

async def __call__(self, text):
self.sent.append(text)


@pytest.mark.asyncio
async def test_machine_offline_sets_pending_flag(tmp_path, monkeypatch):
async def boom(client, repo, site_title="x"):
raise aiohttp.ClientConnectionError("machine off")

monkeypatch.setattr(sync_mod, "sync", boom)
state = State(tmp_path / "state.json")
notify = Notify()
await sync_soon(None, tmp_path, notify, state=state)
assert state.get("sync_pending") is True
assert "offline" in notify.sent[0]

# quiet retry path: no message even on repeated failure
await sync_soon(None, tmp_path, notify, state=state, quiet=True)
assert len(notify.sent) == 1


@pytest.mark.asyncio
async def test_success_clears_pending_flag(tmp_path, monkeypatch):
async def ok(client, repo, site_title="x"):
return True

monkeypatch.setattr(sync_mod, "sync", ok)
state = State(tmp_path / "state.json")
state.set("sync_pending", True)
await sync_soon(None, tmp_path, Notify(), state=state)
assert state.get("sync_pending") is False
Loading