0.2.7: cron expression support for daemon scheduling

interval key now accepts both simple intervals (30m, 1h) and
standard cron expressions (0 6 * * 1-5). Auto-detected based on
field count — no config flag needed. Adds croniter>=2.0 dep.
This commit is contained in:
Timothy Jaeryang Baek 2026-05-21 12:48:58 +04:00
parent 6efcee9af8
commit 2aa721147b
5 changed files with 55 additions and 15 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -1,3 +1,3 @@
"""oikb — Open WebUI Knowledge Base CLI."""
__version__ = "0.2.6"
__version__ = "0.2.7"

View file

@ -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: