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

442 lines
16 KiB
Python

import asyncio
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
from fastapi import HTTPException, Header, Request
from backend.config.Apps import SubApp
from backend.apps.workflows.models import (
Workflow,
WorkflowCreate,
WorkflowUpdate,
WorkflowRun,
)
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
logger = logging.getLogger(__name__)
def _scan_cron_for_openswarm() -> list[str]:
"""Surface OS-level scheduled-task entries that reference us.
macOS + Linux: read `crontab -l`. Windows: query `schtasks` for any
task whose command/path contains 'openswarm'. Best-effort across all
three; any failure (no tool installed, permission denied, parse
error) just returns []. Surfaced to the FE so the Workflows hub can
offer a one-click migration banner to convert into native workflows.
"""
import subprocess
import platform as _platform
findings: list[str] = []
if _platform.system() == "Windows":
try:
proc = subprocess.run(
["schtasks", "/query", "/fo", "CSV", "/v"],
capture_output=True, text=True, timeout=4,
)
if proc.returncode != 0:
return []
for line in (proc.stdout or "").splitlines():
if "openswarm" in line.lower() and not line.lstrip().startswith('"#'):
findings.append(line.strip())
except Exception:
return []
return findings
# macOS + Linux
try:
proc = subprocess.run(
["crontab", "-l"],
capture_output=True, text=True, timeout=2,
)
if proc.returncode != 0:
return []
out = proc.stdout or ""
return [line.strip() for line in out.splitlines() if "openswarm" in line.lower() and not line.strip().startswith("#")]
except Exception:
return []
_cron_findings: list[str] = []
@asynccontextmanager
async def workflows_lifespan():
storage.init()
await scheduler.start()
# Cheap one-shot scan for prior cron entries that reference us. We
# don't migrate automatically; the FE shows a banner with a "Convert
# to OpenSwarm scheduled tasks" button so the user is in control.
global _cron_findings
_cron_findings = _scan_cron_for_openswarm()
try:
yield
finally:
await scheduler.stop()
workflows = SubApp("workflows", workflows_lifespan)
def _derive_icon(wf: Workflow) -> str:
"""Cheap icon hint used until proper auto-icon generation lands.
Pull the first emoji from the title, falling back to the first
letter. Keeps the Search list (image 2 annotation) populated without
waiting on the LLM-based icon generator.
"""
title = (wf.title or "").strip()
for ch in title:
if ord(ch) > 0x2700:
return ch
if title:
return title[:1].upper()
return "W"
@workflows.router.get("/list")
async def list_workflows(dashboard_id: Optional[str] = None):
items = storage.list_workflows()
if dashboard_id:
items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id]
items.sort(key=lambda w: w.updated_at or w.created_at, reverse=True)
# Enrich with cost_estimate so calendar tooltips and the WorkflowsHub
# list don't have to round-trip to GET /workflows/{id} per row. Cheap
# because fires_in_window walks at most ~30 fires per workflow.
return {"workflows": [_enriched(w) for w in items]}
@workflows.router.post("/create")
async def create_workflow(body: WorkflowCreate):
actions = body.actions
# Scheduled workflows default to freeze=on for safety. The user can
# flip "Full agent access" in the editor with an explicit confirm.
# Source-session creates inherit the chat's tool choices so we leave
# them alone there (the source session itself already vetted the
# blast radius).
if body.schedule.enabled and not actions.freeze and not body.source_session_id:
actions = actions.model_copy(update={"freeze": True})
wf = Workflow(
title=body.title,
description=body.description,
icon=body.icon,
system_prompt=body.system_prompt,
use_synced_prompt=body.use_synced_prompt,
steps=body.steps,
actions=actions,
schedule=body.schedule,
permissions=body.permissions or [],
source_session_id=body.source_session_id,
dashboard_id=body.dashboard_id,
model=body.model or "sonnet",
mode=body.mode or "agent",
provider=body.provider or "anthropic",
cost_cap_usd_monthly=body.cost_cap_usd_monthly,
)
if not wf.icon:
wf.icon = _derive_icon(wf)
if wf.schedule.enabled:
wf.next_run_at = scheduler.compute_next_fire(wf)
# Force-generate title + description from the steps in a single aux
# call. Previously we only filled missing description, leaving stale
# session names ("Inbox check") as titles. One round-trip, both
# fields, overwrites whatever shallow draft the FE sent.
try:
title, description = await _generate_title_and_description(wf)
if title:
wf.title = title
if description:
wf.description = description
except Exception:
pass
storage.save_workflow(wf)
scheduler.kick()
return _enriched(wf)
async def _generate_title_and_description(wf: Workflow) -> tuple[str, str]:
"""Single aux-model call returning (title, description).
Uses strict JSON output so both fields come back in one round-trip.
Returns ("", "") on any failure so the caller can write back
unconditionally without dropping the workflow create.
"""
if not wf.steps:
return "", ""
try:
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.agents.providers.registry import get_anthropic_client_for_model
from backend.apps.settings.settings import load_settings as _ls
except Exception:
return "", ""
settings = _ls()
try:
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, aux_model)
except Exception:
return "", ""
steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(wf.steps) if s.text)
prompt = (
"You name and describe a saved automation routine that the user "
"can re-run later. The routine is defined ONLY by the numbered "
"steps below; treat those as the user's instructions to the "
"agent.\n\n"
"Return STRICT JSON, nothing else, no code fence:\n"
" {\"title\": string, \"description\": string}\n\n"
"title rules:\n"
"- 2 to 5 words, Title Case\n"
"- Starts with a verb-noun pair when possible (e.g. \"Summarize "
"Daily Emails\")\n"
"- No emoji, no quotes, no trailing punctuation\n\n"
"description rules:\n"
"- 1 to 2 sentences, under 30 words total\n"
"- Describes the concrete WORK the routine performs for the user, "
"not metadata about itself. Examples of GOOD output:\n"
" \"Reads recent Gmail, ranks urgency, and emails you a PDF "
"digest each Sunday at 9am.\"\n"
" \"Pulls today's calendar plus inbox, writes a Notion brief, "
"and texts you the link.\"\n"
"- Examples of BAD output you MUST AVOID verbatim:\n"
" \"This is an AI-generated description...\"\n"
" \"Auto-generated description used to wrap workflows...\"\n"
" Any sentence that talks about the description itself\n"
"- Start with a verb. Do NOT start with \"This\", \"A\", \"An\", "
"\"The workflow\", \"This routine\".\n\n"
f"Steps:\n{steps_lines}"
)
import json
import re as _re
def _extract_json_object(s: str) -> Optional[dict]:
"""Find the first {...} block and json.loads it. Handles code
fences, prose preambles, and trailing chatter that some aux
models like to add."""
s = s.strip()
if s.startswith("```"):
s = _re.sub(r"^```(?:json)?\s*", "", s, flags=_re.IGNORECASE)
s = _re.sub(r"\s*```\s*$", "", s)
# Greedy brace match; falls through to direct json.loads if no
# braces are visible at all.
start = s.find("{")
end = s.rfind("}")
if start != -1 and end != -1 and end > start:
s = s[start : end + 1]
try:
return json.loads(s)
except Exception:
return None
try:
# Prefill the assistant turn with `{` so the model is steered into
# emitting JSON from the first token. The Anthropic API treats a
# trailing assistant message as a prefill; we'll glue it back on
# before parsing.
resp = await client.messages.create(
model=aux_model,
max_tokens=240,
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": "{"},
],
)
text = ""
if isinstance(resp.content, list):
for block in resp.content:
if getattr(block, "type", None) == "text":
text += getattr(block, "text", "")
raw = "{" + text.strip() if not text.strip().startswith("{") else text.strip()
data = _extract_json_object(raw)
if not data:
logger.warning("description gen: failed to parse aux model output: %s", raw[:400])
return "", ""
title = (data.get("title") or "").strip()[:80]
description = (data.get("description") or "").strip()[:500]
if not description:
logger.warning("description gen: empty description from aux model. Raw: %s", raw[:400])
return title, description
except Exception as e:
logger.warning("description gen: aux model call failed: %s", e)
return "", ""
def _last_run_cost(wid: str) -> float:
for r in storage.list_runs(wid, limit=10):
if r.status in ("success", "ran_late") and r.cost_usd:
return float(r.cost_usd)
return 0.0
def _enriched(wf: Workflow) -> dict:
"""Serialize a workflow with a cost_estimate block attached.
monthly_usd assumes future fires cost the same as the last successful
fire. Surfaces honestly as "at last run's cost" in the UI so users
understand it's a projection, not a quota.
"""
base = wf.model_dump(mode="json")
last = _last_run_cost(wf.id)
fires = scheduler.fires_in_window(wf, days=30)
base["cost_estimate"] = {
"monthly_usd": round(last * fires, 4),
"last_run_usd": round(last, 4),
"fires_per_month": fires,
}
return base
@workflows.router.get("/active")
async def list_active_runs():
"""Snapshot of currently-running workflow runs. Used by the tray and
the auto-updater veto."""
return {"active": scheduler.list_active()}
@workflows.router.post("/pause-all")
async def pause_all_schedules():
storage.set_paused(True)
scheduler.kick()
return {"paused": True}
@workflows.router.post("/resume-all")
async def resume_all_schedules():
storage.set_paused(False)
scheduler.kick()
return {"paused": False}
@workflows.router.get("/paused")
async def get_paused_state():
return {"paused": storage.get_paused()}
@workflows.router.get("/cron/findings")
async def cron_findings():
"""Cron entries we found at startup that reference OpenSwarm. The
FE renders a one-time banner inviting users to convert them; we
return the raw lines so the user can verify before migrating."""
return {"entries": list(_cron_findings)}
@workflows.router.get("/cloud/sms/status")
async def cloud_sms_status():
"""Probe used by the FE to decide whether to show the 'falls back to
in-app notify' acknowledgement on the text/call tiers. Returns
enabled=False until the cloud SMS bridge ships."""
return {"enabled": False}
@workflows.router.post("/runs/{run_id}/ack")
async def ack_run(run_id: str):
cancelled = escalation.cancel(run_id)
return {"acked": True, "had_pending_escalation": cancelled}
@workflows.router.get("/runs/{run_id}/escalation")
async def get_run_escalation(run_id: str):
state = escalation.status(run_id)
return {"state": state}
@workflows.router.get("/{workflow_id}")
async def get_workflow(workflow_id: str):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
return _enriched(wf)
@workflows.router.get("/{workflow_id}/audit")
async def get_workflow_audit(workflow_id: str, limit: int = 50):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
return {"entries": audit.read_tail(workflow_id, limit=limit)}
@workflows.router.patch("/{workflow_id}")
async def update_workflow(
workflow_id: str,
body: WorkflowUpdate,
if_match: Optional[str] = Header(default=None, alias="If-Match"),
):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
# Optimistic concurrency: if the client passed If-Match, verify it
# matches the current updated_at. Stale writes (another window or a
# mid-edit background fire) get a 409 so the FE can prompt to reload
# instead of silently clobbering the other actor's changes. Missing
# header = legacy client, allow through (back-compat with the
# frontend's pre-409 code path; FE rolls out If-Match immediately).
if if_match:
current_stamp = wf.updated_at.isoformat() if hasattr(wf.updated_at, "isoformat") else str(wf.updated_at)
# Strip quotes a well-behaved HTTP client might add per RFC 7232.
if if_match.strip().strip('"') != current_stamp:
raise HTTPException(
status_code=409,
detail={
"error": "stale_update",
"message": "This workflow changed in another window or by a recent run. Reload and try again.",
"current_updated_at": current_stamp,
},
)
before = wf.model_dump(mode="json")
data = body.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(wf, k, v)
wf.updated_at = datetime.now()
if not wf.icon:
wf.icon = _derive_icon(wf)
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
storage.save_workflow(wf)
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
scheduler.kick()
return _enriched(wf)
@workflows.router.delete("/{workflow_id}")
async def delete_workflow(workflow_id: str):
existed = storage.delete_workflow(workflow_id)
if not existed:
raise HTTPException(status_code=404, detail="Workflow not found")
scheduler.kick()
return {"ok": True}
@workflows.router.post("/{workflow_id}/run")
async def run_workflow_now(workflow_id: str):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
# executor.execute() owns the run record. Don't pre-create a stub here
# or we end up with two rows per manual fire (one orphan "running"
# row from this handler plus the real one from the executor).
pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)}
asyncio.create_task(executor.execute(wf, triggered_by="manual"))
# Poll briefly for the newly created run id. We also surface the
# run's status + error string when it lands quickly (e.g. cost-cap
# short-circuit, _running collision) so the FE can render a toast
# instead of silently switching to History.
for _ in range(25):
for r in storage.list_runs(wf.id, limit=10):
if r.id not in pre_ids and r.triggered_by == "manual":
return {
"run_id": r.id,
"status": r.status,
"error": r.error,
}
await asyncio.sleep(0.01)
return {"run_id": "", "status": None, "error": None}
@workflows.router.get("/{workflow_id}/runs")
async def list_workflow_runs(workflow_id: str, limit: int = 50):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
runs = storage.list_runs(workflow_id, limit=limit)
return {"runs": [r.model_dump(mode="json") for r in runs]}