CIS490/receiver/__main__.py
max 5cebe7096a robustness: gate falls back to local git, queue sweeps stale tarballs
Two follow-ups from the post-cutover diagnosis:

1. version_gate: forgejo → local git fallback. If forgejo refresh
   returns empty AND a local repo path is configured, retry against
   `git log` from the local checkout. The receiver service runs on
   the same Pi as forgejo, so a simultaneous restart used to leave
   the gate's cache empty and reject every PUT with not-in-window.
   Auto-detects /opt/cis490/.git when the operator hasn't set
   local_repo_path explicitly — that path is always present on a
   production receiver and ProtectSystem=strict still allows reads.
   Logs `source=git-fallback` so this isn't silent.

2. shipper/queue: sweep orphaned outbox tarballs. The lifecycle
   invariant is `outbox/<id>.tar.zst exists ⇒ episodes/<id>/ exists`
   — broken historically by the now-fixed fatal-loop, by operator
   `rm` of an episode dir, or by an OS crash between rename(2) and
   the post-ship cleanup. Without sweeping, dead bytes pile up
   forever. New _sweep_outbox runs at the start of every scan,
   bounded by the file count in outbox/.

Tests cover: fallback fires when forgejo unreachable + repo_path set;
no fallback when repo_path None (opt-in); orphan tarball + partial
get swept on the next pass; live tarballs untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:49:38 -05:00

70 lines
2.1 KiB
Python

from __future__ import annotations
import argparse
import logging
import os
from pathlib import Path
import uvicorn
from .app import make_app
from .config import ReceiverConfig
from .store import EpisodeStore
from .version_gate import VersionGate
def main() -> None:
parser = argparse.ArgumentParser(prog="cis490-receiver")
parser.add_argument(
"--config",
default=os.environ.get("CIS490_RECEIVER_CONFIG", "/etc/cis490/receiver.toml"),
help="path to receiver TOML config",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
cfg = ReceiverConfig.load(args.config)
store = EpisodeStore(
store_root=cfg.store_root,
incoming_root=cfg.incoming_root,
index_path=cfg.index_path,
)
version_gate = None
if cfg.version_gate_enabled:
# Auto-detect /opt/cis490/.git as a fallback so a forgejo blip
# at startup doesn't reject every PUT with not-in-window. The
# receiver service has read access to /opt under
# ProtectSystem=strict, and that path is where the production
# install lands — so it's the natural local source of truth.
repo_path = cfg.version_gate_local_repo
if repo_path is None and Path("/opt/cis490/.git").is_dir():
repo_path = Path("/opt/cis490")
version_gate = VersionGate(
repo_path=repo_path,
window=cfg.version_gate_window,
forgejo_url=cfg.version_gate_forgejo_url,
repo_owner=cfg.version_gate_repo_owner,
repo_name=cfg.version_gate_repo_name,
branch=cfg.version_gate_branch,
auth_token=cfg.version_gate_auth_token,
)
app = make_app(
store=store,
max_episode_bytes=cfg.max_episode_bytes,
bearer_token=cfg.bearer_token,
version_gate=version_gate,
)
uvicorn.run(
app,
host=cfg.listen_host,
port=cfg.listen_port,
log_config=None,
)
if __name__ == "__main__":
main()