mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 20:52:23 +02:00
198 lines
6.0 KiB
Python
198 lines
6.0 KiB
Python
"""On-disk store for workflows + workflow runs.
|
|
|
|
Layout under DATA_ROOT/workflows/:
|
|
<id>.json workflow record
|
|
runs/<workflow_id>.json bounded log (latest N) of runs for that workflow
|
|
|
|
A separate runs file per workflow keeps history reads O(history size) instead
|
|
of O(total runs across all workflows). The workflow record only carries
|
|
last_run_* / next_run_at summary fields; full history lives in the runs file.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from threading import Lock
|
|
from typing import Optional
|
|
|
|
from backend.config.paths import DATA_ROOT
|
|
from backend.apps.workflows.models import Workflow, WorkflowRun
|
|
|
|
DATA_DIR = os.path.join(DATA_ROOT, "workflows")
|
|
RUNS_DIR = os.path.join(DATA_DIR, "runs")
|
|
PAUSED_FILE = os.path.join(DATA_DIR, "paused.json")
|
|
|
|
_io_lock = Lock()
|
|
_workflow_cache: dict[str, Workflow] = {}
|
|
_runs_cache: dict[str, list[WorkflowRun]] = {}
|
|
_cache_loaded = False
|
|
_paused = False
|
|
|
|
|
|
def _resolve_host_tz_name() -> str:
|
|
"""Best-effort IANA name for the host. Mirrors apps/service/client.py."""
|
|
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
|
|
if not name:
|
|
try:
|
|
from tzlocal import get_localzone_name # type: ignore
|
|
name = get_localzone_name() or ""
|
|
except Exception:
|
|
name = ""
|
|
return name or "UTC"
|
|
|
|
# Keep this much run history per workflow on disk. Older runs are pruned;
|
|
# the History tab caps at ~20 anyway, and unbounded growth turned the JSON
|
|
# read into a real cost on hot-reload of the schedule page.
|
|
RUNS_PER_WORKFLOW = 200
|
|
|
|
|
|
def _ensure_dirs() -> None:
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
os.makedirs(RUNS_DIR, exist_ok=True)
|
|
|
|
|
|
def _wf_path(wid: str) -> str:
|
|
return os.path.join(DATA_DIR, f"{wid}.json")
|
|
|
|
|
|
def _runs_path(wid: str) -> str:
|
|
return os.path.join(RUNS_DIR, f"{wid}.json")
|
|
|
|
|
|
def _load_all_from_disk() -> None:
|
|
global _cache_loaded, _paused
|
|
_ensure_dirs()
|
|
_workflow_cache.clear()
|
|
_runs_cache.clear()
|
|
host_tz = _resolve_host_tz_name()
|
|
for fname in os.listdir(DATA_DIR):
|
|
if not fname.endswith(".json") or fname == "paused.json":
|
|
continue
|
|
try:
|
|
with open(os.path.join(DATA_DIR, fname)) as f:
|
|
wf = Workflow(**json.load(f))
|
|
# Coerce legacy timezone="local" to the host IANA zone in
|
|
# memory only. We don't rewrite the file here so backup/sync
|
|
# tooling doesn't see mtime churn on every startup; the next
|
|
# user-driven save migrates the on-disk record naturally.
|
|
if wf.schedule.timezone == "local":
|
|
wf.schedule.timezone = host_tz
|
|
_workflow_cache[wf.id] = wf
|
|
except Exception:
|
|
continue
|
|
if os.path.exists(RUNS_DIR):
|
|
for fname in os.listdir(RUNS_DIR):
|
|
if not fname.endswith(".json"):
|
|
continue
|
|
wid = fname[:-5]
|
|
try:
|
|
with open(os.path.join(RUNS_DIR, fname)) as f:
|
|
arr = json.load(f)
|
|
_runs_cache[wid] = [WorkflowRun(**r) for r in arr]
|
|
except Exception:
|
|
_runs_cache[wid] = []
|
|
# Load the global pause flag if it's been set previously.
|
|
if os.path.exists(PAUSED_FILE):
|
|
try:
|
|
with open(PAUSED_FILE) as f:
|
|
_paused = bool(json.load(f).get("paused", False))
|
|
except Exception:
|
|
_paused = False
|
|
_cache_loaded = True
|
|
|
|
|
|
def init() -> None:
|
|
with _io_lock:
|
|
_load_all_from_disk()
|
|
|
|
|
|
def list_workflows() -> list[Workflow]:
|
|
if not _cache_loaded:
|
|
init()
|
|
return list(_workflow_cache.values())
|
|
|
|
|
|
def get_workflow(wid: str) -> Optional[Workflow]:
|
|
if not _cache_loaded:
|
|
init()
|
|
return _workflow_cache.get(wid)
|
|
|
|
|
|
def save_workflow(wf: Workflow) -> Workflow:
|
|
with _io_lock:
|
|
_ensure_dirs()
|
|
_workflow_cache[wf.id] = wf
|
|
with open(_wf_path(wf.id), "w") as f:
|
|
json.dump(wf.model_dump(mode="json"), f, indent=2)
|
|
return wf
|
|
|
|
|
|
def delete_workflow(wid: str) -> bool:
|
|
with _io_lock:
|
|
existed = wid in _workflow_cache
|
|
_workflow_cache.pop(wid, None)
|
|
_runs_cache.pop(wid, None)
|
|
wf_file = _wf_path(wid)
|
|
if os.path.exists(wf_file):
|
|
os.remove(wf_file)
|
|
rf = _runs_path(wid)
|
|
if os.path.exists(rf):
|
|
os.remove(rf)
|
|
return existed
|
|
|
|
|
|
def list_runs(wid: str, limit: int = 50) -> list[WorkflowRun]:
|
|
if not _cache_loaded:
|
|
init()
|
|
runs = _runs_cache.get(wid, [])
|
|
return runs[-limit:][::-1]
|
|
|
|
|
|
def record_run(run: WorkflowRun) -> WorkflowRun:
|
|
with _io_lock:
|
|
_ensure_dirs()
|
|
arr = _runs_cache.setdefault(run.workflow_id, [])
|
|
# Replace prior entry with same id if we're updating an in-flight run.
|
|
for i, prior in enumerate(arr):
|
|
if prior.id == run.id:
|
|
arr[i] = run
|
|
break
|
|
else:
|
|
arr.append(run)
|
|
# Bound the per-workflow history to keep disk + memory cheap.
|
|
if len(arr) > RUNS_PER_WORKFLOW:
|
|
del arr[: len(arr) - RUNS_PER_WORKFLOW]
|
|
with open(_runs_path(run.workflow_id), "w") as f:
|
|
json.dump([r.model_dump(mode="json") for r in arr], f, indent=2)
|
|
return run
|
|
|
|
|
|
def get_paused() -> bool:
|
|
if not _cache_loaded:
|
|
init()
|
|
return _paused
|
|
|
|
|
|
def set_paused(value: bool) -> bool:
|
|
global _paused
|
|
with _io_lock:
|
|
_ensure_dirs()
|
|
_paused = bool(value)
|
|
with open(PAUSED_FILE, "w") as f:
|
|
json.dump({"paused": _paused}, f)
|
|
return _paused
|
|
|
|
|
|
def update_run(run_id: str, **fields) -> Optional[WorkflowRun]:
|
|
if not _cache_loaded:
|
|
init()
|
|
for arr in _runs_cache.values():
|
|
for i, r in enumerate(arr):
|
|
if r.id == run_id:
|
|
updated = r.model_copy(update=fields)
|
|
arr[i] = updated
|
|
with _io_lock:
|
|
with open(_runs_path(updated.workflow_id), "w") as f:
|
|
json.dump([x.model_dump(mode="json") for x in arr], f, indent=2)
|
|
return updated
|
|
return None
|