mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 12:42:22 +02:00
665 lines
26 KiB
Python
665 lines
26 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}/propose-edit")
|
|
async def propose_edit(workflow_id: str, body: dict):
|
|
"""Aux-LLM-propose a single-step edit from a natural-language request.
|
|
|
|
Powers the Edit Agent chat (Image #38). Frontend hands us the user's
|
|
message, the current draft steps, and optional failure-context (when
|
|
we're inside Fix-with-Agent). We respond with a reply string PLUS,
|
|
optionally, a `step_idx` + `new_text` that the FE shows as a
|
|
proposal card. The user clicks Apply to merge into their local draft;
|
|
nothing is persisted until they click Save in the header.
|
|
"""
|
|
wf = storage.get_workflow(workflow_id)
|
|
if not wf:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
message = (body or {}).get("message", "").strip()
|
|
steps_in = (body or {}).get("steps") or []
|
|
context = (body or {}).get("context") or None
|
|
if not message or not isinstance(steps_in, list):
|
|
raise HTTPException(status_code=400, detail="Missing message or steps")
|
|
try:
|
|
from backend.apps.agents.providers.registry import resolve_aux_model, get_anthropic_client_for_model
|
|
from backend.apps.settings.settings import load_settings as _ls
|
|
except Exception:
|
|
raise HTTPException(status_code=500, detail="Aux model unavailable")
|
|
settings = _ls()
|
|
try:
|
|
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
|
client = get_anthropic_client_for_model(settings, aux_model)
|
|
except Exception:
|
|
raise HTTPException(status_code=500, detail="Aux model unavailable")
|
|
import json, re
|
|
steps_lines = "\n".join(
|
|
f"{i+1}. {(s.get('label') or '').strip() or (s.get('text') or '')[:60]}: {(s.get('text') or '')}"
|
|
for i, s in enumerate(steps_in)
|
|
)
|
|
fix_context = ""
|
|
if context and isinstance(context, dict):
|
|
fs = context.get("failed_step")
|
|
err = context.get("error")
|
|
if fs is not None and err:
|
|
fix_context = (
|
|
f"\n\nFAILURE CONTEXT: Step {int(fs) + 1} failed on the most recent run. "
|
|
f"The error was: {err}\n"
|
|
f"Your proposed edit should specifically address that failure if possible."
|
|
)
|
|
prompt = (
|
|
"You are an Edit Agent helping the user iterate on a saved automation "
|
|
"workflow. The workflow's current steps are listed below. The user has "
|
|
"asked for a modification.\n\n"
|
|
"Respond with STRICT JSON, no prose, no fence. Schema:\n"
|
|
' {"reply": string, '
|
|
'"step_idx": int | null, '
|
|
'"new_text": string | null, '
|
|
'"explanation": string | null}\n\n'
|
|
"Rules:\n"
|
|
"- `reply` is a short conversational acknowledgement (1-2 sentences).\n"
|
|
"- If the user is asking a question or for clarification, set step_idx=null and new_text=null.\n"
|
|
"- If the user is asking to change a specific step, set step_idx (0-based) and new_text to the FULL replacement prompt for that step.\n"
|
|
"- `explanation` describes the change in user-facing terms.\n"
|
|
"- Never invent new steps. Never remove steps. Only edit existing ones.\n\n"
|
|
f"Workflow steps:\n{steps_lines}{fix_context}\n\n"
|
|
f"User: {message}"
|
|
)
|
|
try:
|
|
resp = await client.messages.create(
|
|
model=aux_model,
|
|
max_tokens=400,
|
|
messages=[
|
|
{"role": "user", "content": prompt},
|
|
{"role": "assistant", "content": "{"},
|
|
],
|
|
)
|
|
out = ""
|
|
if isinstance(resp.content, list):
|
|
for block in resp.content:
|
|
if getattr(block, "type", None) == "text":
|
|
out += getattr(block, "text", "")
|
|
raw = "{" + out.strip() if not out.strip().startswith("{") else out.strip()
|
|
m = re.search(r"\{.*\}", raw, flags=re.DOTALL)
|
|
if m:
|
|
raw = m.group(0)
|
|
data = json.loads(raw)
|
|
except Exception as e:
|
|
logger.warning("propose-edit: aux LLM failed: %s", e)
|
|
raise HTTPException(status_code=400, detail="Couldn't generate a proposal")
|
|
reply = str(data.get("reply") or "").strip()[:600]
|
|
step_idx = data.get("step_idx")
|
|
new_text = data.get("new_text")
|
|
explanation = str(data.get("explanation") or "").strip()[:600]
|
|
out: dict = {"reply": reply}
|
|
if isinstance(step_idx, int) and 0 <= step_idx < len(steps_in) and isinstance(new_text, str) and new_text.strip():
|
|
out["step_idx"] = step_idx
|
|
out["new_text"] = new_text.strip()
|
|
if explanation:
|
|
out["explanation"] = explanation
|
|
return out
|
|
|
|
|
|
@workflows.router.post("/{workflow_id}/parse-schedule")
|
|
async def parse_schedule(workflow_id: str, body: dict):
|
|
"""Aux-LLM-parse natural language into a ScheduleConfig.
|
|
|
|
Frontend SchedulingView (Image #49) hits this on submit; the parsed
|
|
config rides back to the user for explicit "Schedule it" confirmation
|
|
before any persistence. Returns the parsed config under {"schedule": ...}.
|
|
"""
|
|
wf = storage.get_workflow(workflow_id)
|
|
if not wf:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
text = (body or {}).get("text", "").strip()
|
|
if not text:
|
|
raise HTTPException(status_code=400, detail="Missing text")
|
|
try:
|
|
from backend.apps.agents.providers.registry import resolve_aux_model, get_anthropic_client_for_model
|
|
from backend.apps.settings.settings import load_settings as _ls
|
|
except Exception:
|
|
raise HTTPException(status_code=500, detail="Aux model unavailable")
|
|
settings = _ls()
|
|
try:
|
|
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
|
client = get_anthropic_client_for_model(settings, aux_model)
|
|
except Exception:
|
|
raise HTTPException(status_code=500, detail="Aux model unavailable")
|
|
import json, re
|
|
prompt = (
|
|
"Parse the following natural-language schedule into STRICT JSON. "
|
|
"No prose, no fence, no comments. Schema:\n"
|
|
' {"repeat_unit": "day"|"week"|"month", '
|
|
'"repeat_every": int>=1, '
|
|
'"on_days": [int 0..6, Sunday=0], '
|
|
'"hour": int 0..23, "minute": int 0..59, '
|
|
'"timezone": IANA tz string (default to local)}\n\n'
|
|
"Rules:\n"
|
|
"- If user says weekdays, on_days=[1,2,3,4,5], repeat_unit=week.\n"
|
|
"- If user says weekends, on_days=[0,6], repeat_unit=week.\n"
|
|
"- If user names a single day (e.g. \"Mondays\"), on_days=[1], repeat_unit=week.\n"
|
|
"- If user says daily/everyday, repeat_unit=day, on_days=[].\n"
|
|
"- If no AM/PM, assume PM for 1-7 and AM for 8-12.\n"
|
|
"- timezone: assume system local if not given.\n\n"
|
|
f"Input: {text}"
|
|
)
|
|
try:
|
|
resp = await client.messages.create(
|
|
model=aux_model,
|
|
max_tokens=180,
|
|
messages=[
|
|
{"role": "user", "content": prompt},
|
|
{"role": "assistant", "content": "{"},
|
|
],
|
|
)
|
|
out = ""
|
|
if isinstance(resp.content, list):
|
|
for block in resp.content:
|
|
if getattr(block, "type", None) == "text":
|
|
out += getattr(block, "text", "")
|
|
raw = "{" + out.strip() if not out.strip().startswith("{") else out.strip()
|
|
m = re.search(r"\{[^{}]*\}", raw, flags=re.DOTALL)
|
|
if m:
|
|
raw = m.group(0)
|
|
data = json.loads(raw)
|
|
except Exception as e:
|
|
logger.warning("parse-schedule: aux LLM failed: %s", e)
|
|
raise HTTPException(status_code=400, detail="Couldn't parse schedule")
|
|
cfg = wf.schedule.model_copy(update={
|
|
"enabled": True,
|
|
"repeat_unit": str(data.get("repeat_unit") or "week"),
|
|
"repeat_every": int(data.get("repeat_every") or 1),
|
|
"on_days": [int(d) for d in (data.get("on_days") or [])],
|
|
"hour": int(data.get("hour") or 9),
|
|
"minute": int(data.get("minute") or 0),
|
|
"timezone": str(data.get("timezone") or wf.schedule.timezone or "UTC"),
|
|
})
|
|
return {"schedule": cfg.model_dump(mode="json")}
|
|
|
|
|
|
@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.post("/runs/{run_id}/stop")
|
|
async def stop_run(run_id: str):
|
|
"""Force-terminate a running workflow's underlying agent session.
|
|
|
|
Fired by RunningView's Stop button (Image #40). The run record gets
|
|
marked failure with a "stopped by user" error so it surfaces correctly
|
|
in History instead of looking like it succeeded.
|
|
"""
|
|
target_wf_id = None
|
|
target_run = None
|
|
for wf in storage.list_workflows():
|
|
for r in storage.list_runs(wf.id, limit=50):
|
|
if r.id == run_id and r.status == "running":
|
|
target_wf_id = wf.id
|
|
target_run = r
|
|
break
|
|
if target_run:
|
|
break
|
|
if not target_run or not target_wf_id:
|
|
raise HTTPException(status_code=404, detail="Run not found or not active")
|
|
if target_run.session_id:
|
|
try:
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
await agent_manager.close_session(target_run.session_id)
|
|
except Exception:
|
|
logger.exception("stop_run: close_session failed for %s", target_run.session_id)
|
|
target_run.status = "failure"
|
|
target_run.error = "Stopped by user"
|
|
target_run.finished_at = datetime.now()
|
|
storage.record_run(target_run)
|
|
wf = storage.get_workflow(target_wf_id)
|
|
if wf:
|
|
_persist_run_fields(wf, {
|
|
"last_run_status": "failure",
|
|
"last_run_at": target_run.finished_at,
|
|
"last_run_id": target_run.id,
|
|
})
|
|
try:
|
|
from backend.apps.agents.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:run", {
|
|
"workflow_id": target_wf_id,
|
|
"run": target_run.model_dump(mode="json"),
|
|
})
|
|
except Exception:
|
|
pass
|
|
return {"ok": True}
|
|
|
|
|
|
@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]}
|