Files
openswarm/backend/apps/workflows/escalation.py
T

89 lines
3.2 KiB
Python

"""Server-side escalation timer.
The permission chain in the UI (notify -> text -> call) used to time out
client-side, which dies the moment the window closes. We move the timer
here so a run that finishes at 9am can escalate to a real text at 9:05am
whether or not the user has the app open. The text/call wire-up itself
still routes through notifier (cloud SMS bridge is wired separately); we
just own the *when*.
State lives in module-scoped dicts, not on disk. If the backend restarts
mid-escalation the chain is lost on purpose: the user is already in front
of an open app at that point (otherwise the backend wouldn't have started)
and they can ack manually. Persisting escalation state would mean
re-firing on a stale schedule after a multi-day downtime, which is worse.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from backend.apps.workflows.models import PermissionTier, Workflow, WorkflowRun
logger = logging.getLogger(__name__)
_tasks: dict[str, asyncio.Task] = {} # run_id -> escalation task
_state: dict[str, dict] = {} # run_id -> {tier_idx, next_at, kind}
def _tier_delay_seconds(tier: PermissionTier) -> int:
"""Tier minutes/hours convention matches the FE: text uses minutes,
call uses hours (the UI label flips with tier.kind). We translate at
the boundary so the backend math is always in seconds."""
if tier.kind == "call":
return max(0, tier.after_minutes) * 3600
return max(0, tier.after_minutes) * 60
def schedule(wf: Workflow, run: WorkflowRun) -> None:
"""Kick off escalation for a finished run. No-op if the workflow has
only the default notify tier (i.e. nothing to escalate to)."""
tiers = wf.permissions or []
if len(tiers) <= 1:
return
# Cancel any prior task for this run (defense against a re-fire).
cancel(run.id)
task = asyncio.create_task(_runner(wf, run, tiers))
_tasks[run.id] = task
def cancel(run_id: str) -> bool:
task = _tasks.pop(run_id, None)
_state.pop(run_id, None)
if task is None:
return False
task.cancel()
return True
def status(run_id: str) -> Optional[dict]:
return _state.get(run_id)
async def _runner(wf: Workflow, run: WorkflowRun, tiers: list[PermissionTier]) -> None:
from backend.apps.workflows.notifier import send_tier
try:
# Tier 0 is the initial notify; we don't re-fire it here. Walk 1..N, sleeping the tier's delay before sending. If the user acks via /workflows/runs/{run_id}/ack, the task is cancelled.
for idx in range(1, len(tiers)):
tier = tiers[idx]
delay = _tier_delay_seconds(tier)
fire_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
_state[run.id] = {
"tier_idx": idx,
"tier_kind": tier.kind,
"next_at": fire_at.isoformat(),
}
await asyncio.sleep(delay)
try:
await send_tier(wf, run, tier)
except Exception:
logger.exception("escalation send_tier failed run=%s tier=%s", run.id, tier.kind)
except asyncio.CancelledError:
pass
finally:
_state.pop(run.id, None)
_tasks.pop(run.id, None)