mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 06:22:22 +02:00
154 lines
5.1 KiB
Python
154 lines
5.1 KiB
Python
"""On-disk state for the event engine under DATA_ROOT/events/:
|
|
cursors/<trigger_id>.json adapter-defined position (mtime map, page hash, ...)
|
|
pending/<trigger_id>.json events buffered but not yet fired (survives restart)
|
|
logs/<workflow_id>.json bounded activity log ("saw X, skipped because Y")
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
from typing import Dict, List
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.events.models import Event, EventLogEntry
|
|
from backend.config.json_store import atomic_write_json, read_json_or_none
|
|
from backend.config.paths import DATA_ROOT
|
|
|
|
EVENTS_DIR = os.path.join(DATA_ROOT, "events")
|
|
CURSORS_DIR = os.path.join(EVENTS_DIR, "cursors")
|
|
PENDING_DIR = os.path.join(EVENTS_DIR, "pending")
|
|
LOGS_DIR = os.path.join(EVENTS_DIR, "logs")
|
|
FIRES_DIR = os.path.join(EVENTS_DIR, "fires")
|
|
HEALTH_DIR = os.path.join(EVENTS_DIR, "health")
|
|
|
|
LOG_ENTRIES_MAX = 200
|
|
FIRES_MAX = 100
|
|
|
|
|
|
@typechecked
|
|
def load_cursor(trigger_id: str) -> Dict:
|
|
return read_json_or_none(os.path.join(CURSORS_DIR, f"{trigger_id}.json")) or {}
|
|
|
|
|
|
@typechecked
|
|
def save_cursor(trigger_id: str, cursor: Dict) -> None:
|
|
atomic_write_json(os.path.join(CURSORS_DIR, f"{trigger_id}.json"), cursor)
|
|
|
|
|
|
@typechecked
|
|
def load_pending(trigger_id: str) -> List[Event]:
|
|
raw = read_json_or_none(os.path.join(PENDING_DIR, f"{trigger_id}.json"))
|
|
if not isinstance(raw, list):
|
|
return []
|
|
out: List[Event] = []
|
|
for item in raw:
|
|
try:
|
|
out.append(Event(**item))
|
|
except Exception:
|
|
continue
|
|
return out
|
|
|
|
|
|
@typechecked
|
|
def save_pending(trigger_id: str, events: List[Event]) -> None:
|
|
path = os.path.join(PENDING_DIR, f"{trigger_id}.json")
|
|
if not events:
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
return
|
|
atomic_write_json(path, [e.model_dump(mode="json") for e in events])
|
|
|
|
|
|
@typechecked
|
|
def record_fire(trigger_id: str, when_epoch: float) -> None:
|
|
"""Persisted so the rate cap survives restarts (and any process confusion)."""
|
|
path = os.path.join(FIRES_DIR, f"{trigger_id}.json")
|
|
raw = read_json_or_none(path)
|
|
arr = [float(x) for x in raw] if isinstance(raw, list) else []
|
|
arr.append(float(when_epoch))
|
|
atomic_write_json(path, arr[-FIRES_MAX:])
|
|
|
|
|
|
@typechecked
|
|
def recent_fire_count(trigger_id: str, now_epoch: float, window_seconds: float = 3600.0) -> int:
|
|
raw = read_json_or_none(os.path.join(FIRES_DIR, f"{trigger_id}.json"))
|
|
if not isinstance(raw, list):
|
|
return 0
|
|
cutoff = now_epoch - window_seconds
|
|
return sum(1 for x in raw if isinstance(x, (int, float)) and float(x) >= cutoff)
|
|
|
|
|
|
@typechecked
|
|
def record_poll_failure(trigger_id: str, error: str = "") -> int:
|
|
"""Returns the new consecutive-failure count."""
|
|
path = os.path.join(HEALTH_DIR, f"{trigger_id}.json")
|
|
raw = read_json_or_none(path) or {}
|
|
count = int(raw.get("consecutive_failures") or 0) + 1
|
|
atomic_write_json(path, {"consecutive_failures": count, "last_error": error[:300], "last_failure_epoch": time.time()})
|
|
return count
|
|
|
|
|
|
@typechecked
|
|
def set_poll_needs(trigger_id: str, needs: str) -> None:
|
|
path = os.path.join(HEALTH_DIR, f"{trigger_id}.json")
|
|
raw = read_json_or_none(path) or {}
|
|
raw["needs"] = needs[:200]
|
|
atomic_write_json(path, raw)
|
|
|
|
|
|
@typechecked
|
|
def read_poll_health(trigger_id: str) -> Dict:
|
|
return read_json_or_none(os.path.join(HEALTH_DIR, f"{trigger_id}.json")) or {}
|
|
|
|
|
|
@typechecked
|
|
def clear_poll_failures(trigger_id: str) -> None:
|
|
path = os.path.join(HEALTH_DIR, f"{trigger_id}.json")
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
@typechecked
|
|
def append_log(workflow_id: str, entry: EventLogEntry) -> None:
|
|
path = os.path.join(LOGS_DIR, f"{workflow_id}.json")
|
|
raw = read_json_or_none(path)
|
|
arr = raw if isinstance(raw, list) else []
|
|
arr.append(entry.model_dump(mode="json"))
|
|
if len(arr) > LOG_ENTRIES_MAX:
|
|
del arr[: len(arr) - LOG_ENTRIES_MAX]
|
|
atomic_write_json(path, arr)
|
|
|
|
|
|
@typechecked
|
|
def read_log(workflow_id: str) -> List[EventLogEntry]:
|
|
raw = read_json_or_none(os.path.join(LOGS_DIR, f"{workflow_id}.json"))
|
|
if not isinstance(raw, list):
|
|
return []
|
|
out: List[EventLogEntry] = []
|
|
for item in raw:
|
|
try:
|
|
out.append(EventLogEntry(**item))
|
|
except Exception:
|
|
continue
|
|
return out
|
|
|
|
|
|
@typechecked
|
|
def sweep_stale_state(live_trigger_ids: List[str], live_workflow_ids: List[str]) -> None:
|
|
"""Drop cursor/pending/log files whose owner no longer exists (trigger edited
|
|
away, workflow purged). Runs once at engine start; keeps the data dir from
|
|
accumulating orphans forever."""
|
|
keep_triggers = set(live_trigger_ids)
|
|
keep_workflows = set(live_workflow_ids)
|
|
for directory, keep in ((CURSORS_DIR, keep_triggers), (PENDING_DIR, keep_triggers), (FIRES_DIR, keep_triggers), (HEALTH_DIR, keep_triggers), (LOGS_DIR, keep_workflows)):
|
|
if not os.path.isdir(directory):
|
|
continue
|
|
for fname in os.listdir(directory):
|
|
if not fname.endswith(".json"):
|
|
continue
|
|
if fname[:-5] not in keep:
|
|
try:
|
|
os.remove(os.path.join(directory, fname))
|
|
except OSError:
|
|
pass
|