diff --git a/CHANGELOG.md b/CHANGELOG.md index 003f3bd..6973c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.2.7] - 2025-05-21 + +### Added + +- Cron expression support for daemon scheduling — use `interval: "0 6 * * 1-5"` alongside simple intervals (`30m`, `1h`). Auto-detected, no config flag needed. +- `croniter>=2.0` added as a core dependency. + ## [0.2.6] - 2025-05-21 ### Added diff --git a/README.md b/README.md index f20fde5..e4749e9 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ oikb daemon --port 8080 ``` Features: -- **Scheduled sync** — configurable per-source intervals (`30m`, `1h`, `6h`) +- **Scheduled sync** — simple intervals (`30m`, `1h`) or cron expressions (`0 6 * * 1-5`) - **Webhooks** — instant sync on push via `/webhooks/github`, `/webhooks/gitlab`, `/webhooks/slack`, `/webhooks/confluence` - **Health checks** — `GET /health` for Docker/K8s readiness probes - **Prometheus metrics** — `GET /metrics` exports sync counters, duration histograms, and error rates @@ -59,13 +59,13 @@ sources: - name: wiki source: github:owner/repo kb-id: 8f3a2b1c-... - interval: 1h + interval: 1h # simple interval webhook: true - name: handbook source: confluence:ENG kb-id: 4e7d9a0f-... - interval: 6h + interval: "0 6 * * 1-5" # cron: weekdays at 6am ``` ```bash diff --git a/pyproject.toml b/pyproject.toml index 9912d0d..f88c206 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oikb" -version = "0.2.6" +version = "0.2.7" description = "Sync anything to Open WebUI Knowledge Bases" readme = "README.md" authors = [ @@ -10,6 +10,7 @@ requires-python = ">=3.11" license = {file = "LICENSE"} dependencies = [ "click>=8.1", + "croniter>=2.0", "httpx>=0.27", "prometheus-client>=0.20", "pyyaml>=6.0", diff --git a/src/oikb/__init__.py b/src/oikb/__init__.py index 5859d08..a1bf9a7 100644 --- a/src/oikb/__init__.py +++ b/src/oikb/__init__.py @@ -1,3 +1,3 @@ """oikb — Open WebUI Knowledge Base CLI.""" -__version__ = "0.2.6" +__version__ = "0.2.7" diff --git a/src/oikb/daemon.py b/src/oikb/daemon.py index a548526..14c4653 100644 --- a/src/oikb/daemon.py +++ b/src/oikb/daemon.py @@ -40,7 +40,7 @@ async def verify_api_key( app = FastAPI( title="oikb", description="Sync engine for Open WebUI Knowledge Bases. Trigger syncs, check status, and query history.", - version="0.2.6", + version="0.2.7", ) # Runtime state populated by start_daemon(). @@ -50,7 +50,13 @@ _entries: list[dict] = [] _shutdown_event: asyncio.Event | None = None -# ── Interval parser ────────────────────────────────────────────── +# ── Interval / cron parser ─────────────────────────────────────── + + +def _is_cron(value: str) -> bool: + """Check if a string looks like a cron expression (5-6 space-separated fields).""" + return isinstance(value, str) and len(value.strip().split()) >= 5 + def parse_interval(value: str | int) -> int: """Parse an interval string like '5m', '1h', '30s' to seconds.""" @@ -63,6 +69,18 @@ def parse_interval(value: str | int) -> int: return int(value) +def _next_cron_delay(cron_expr: str) -> float: + """Compute seconds until the next cron fire time.""" + from datetime import datetime + + from croniter import croniter + + now = datetime.now() + cron = croniter(cron_expr, now) + next_time = cron.get_next(datetime) + return max((next_time - now).total_seconds(), 1.0) + + # ── FastAPI routes ─────────────────────────────────────────────── @app.get( @@ -231,23 +249,37 @@ async def _run_entry(entry: dict) -> None: async def _schedule_entry(entry: dict) -> None: - """Run sync for one .oikb.yaml entry on a loop.""" - interval = parse_interval(entry.get("interval", "30m")) - source = entry.get("source", "unknown") + """Run sync for one .oikb.yaml entry on a loop. - log.info(f"Scheduling {source} every {interval}s") + Supports both simple intervals ('30m', '1h') and cron expressions + ('0 */6 * * *', '0 6 * * 1-5'). + """ + raw_interval = entry.get("interval", "30m") + source = entry.get("source", "unknown") + use_cron = _is_cron(str(raw_interval)) + + if use_cron: + log.info(f"Scheduling {source} with cron: {raw_interval}") + else: + interval = parse_interval(raw_interval) + log.info(f"Scheduling {source} every {interval}s") while not _shutdown_event.is_set(): await _run_entry(entry) - # Calculate next sync time. - _scheduler_state.setdefault(source, {})["next_sync_in"] = f"{interval}s" + # Compute wait until next run. + if use_cron: + delay = _next_cron_delay(str(raw_interval)) + _scheduler_state.setdefault(source, {})["next_sync_in"] = f"{int(delay)}s" + else: + delay = float(interval) + _scheduler_state.setdefault(source, {})["next_sync_in"] = f"{interval}s" try: - await asyncio.wait_for(_shutdown_event.wait(), timeout=interval) + await asyncio.wait_for(_shutdown_event.wait(), timeout=delay) break # Shutdown requested. except asyncio.TimeoutError: - pass # Interval elapsed, run again. + pass # Time elapsed, run again. async def _run_scheduler(entries: list[dict]) -> None: