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

808 lines
33 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 + per-step labels from the steps
# in a single aux call. Previously we only filled missing description,
# leaving stale session names ("Inbox check") as titles. Step labels
# are the 3-6 word at-a-glance headlines surfaced in StepList; without
# them the UI falls back to truncated raw prompts.
try:
title, description, labels = await _generate_workflow_metadata(wf)
if title:
wf.title = title
if description:
wf.description = description
if labels and len(labels) == len(wf.steps):
for i, lab in enumerate(labels):
if lab:
wf.steps[i].label = lab
except Exception:
pass
storage.save_workflow(wf)
scheduler.kick()
return _enriched(wf)
async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]:
"""Single aux-model call returning (title, description, step_labels).
One round-trip for all three so we don't burn 3x aux cost. Returns
("", "", []) on any failure; caller writes back unconditionally.
"""
if not wf.steps:
return "", "", []
try:
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials 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)
n_steps = len(wf.steps)
prompt = (
"You name and describe a saved automation routine that the user "
"can re-run later, AND produce a short at-a-glance label for "
"each step. 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, "step_labels": [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"
"- Start with a verb. Do NOT start with \"This\", \"A\", \"An\", "
"\"The workflow\", \"This routine\".\n\n"
f"step_labels rules:\n"
f"- EXACTLY {n_steps} entries, one per step, same order.\n"
"- Each label: 3 to 6 words, Sentence case.\n"
"- Imperative verb-led (\"Summarize emails & calendar\", \"Make "
"brief in notion\", \"Email brief link to me\").\n"
"- No trailing punctuation, no quotes, no emoji.\n"
"- Should read as the human-friendly NAME of the step, NOT a "
"restatement of the prompt.\n\n"
f"Steps:\n{steps_lines}"
)
import json
import re as _re
def _extract_json_object(s: str) -> Optional[dict]:
s = s.strip()
if s.startswith("```"):
s = _re.sub(r"^```(?:json)?\s*", "", s, flags=_re.IGNORECASE)
s = _re.sub(r"\s*```\s*$", "", s)
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:
resp = await client.messages.create(
model=aux_model,
max_tokens=400 + n_steps * 30,
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("workflow meta 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]
raw_labels = data.get("step_labels") or []
labels = [str(x or "").strip()[:60] for x in raw_labels] if isinstance(raw_labels, list) else []
return title, description, labels
except Exception as e:
logger.warning("workflow meta 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, optional failure-context (Fix-with-
Agent), AND the prior turns so the model has multi-turn memory. We
respond with a reply string PLUS, optionally, a `step_idx` + `new_text`
that the FE shows as a proposal card.
"""
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
history = (body or {}).get("history") or []
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
from backend.apps.settings.credentials import 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."
)
# Build history block so the model remembers prior turns. Each entry
# is {role, text}; we only carry assistant/user pairs (proposals get
# summarised inline so the assistant has context for follow-ups).
history_lines = []
if isinstance(history, list):
for h in history[-12:]:
if not isinstance(h, dict):
continue
role = str(h.get("role") or "").strip().lower()
text = str(h.get("text") or "").strip()
if role in ("user", "assistant") and text:
history_lines.append(f"{role.capitalize()}: {text}")
history_block = ("\n\nPrior conversation:\n" + "\n".join(history_lines)) if history_lines else ""
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"
"- Use prior conversation context to disambiguate follow-ups (e.g. \"yes do that\" should reference the last proposal).\n\n"
f"Workflow steps:\n{steps_lines}{fix_context}{history_block}\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}/edit-agent-session")
async def edit_agent_session(workflow_id: str):
"""Create (or return existing) Edit Agent session for this workflow.
The Edit Agent is a real agent session that the user chats with to
iterate on the workflow (Image #38, #48). It has the workflow context
pre-loaded in its system prompt and the full default tool surface so
tool calls render as cards in the chat (Image #48: MCP Activation,
Gmail Query, etc.).
Singleton per workflow: re-entering edit mode reattaches to the same
session so the conversation persists. Frontend stores the returned
session_id in the workflow card's openCard state.
"""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
# Track the edit-agent session id on the workflow record so the FE
# can find it after a reload. Persisted under a private namespace
# field added below; we attach it lazily so existing workflows don't
# need a migration.
existing_id = getattr(wf, "edit_agent_session_id", None) or None
if existing_id:
from backend.apps.agents.agent_manager import agent_manager
if existing_id in agent_manager.sessions:
return {"session_id": existing_id}
# In-memory miss but on disk it's still valid; fall through to
# rehydrate via launch_agent OR return id for the FE to fetch.
return {"session_id": existing_id}
from backend.apps.agents.core.models import AgentConfig
from backend.apps.agents.agent_manager import agent_manager
steps_lines = "\n".join(f"{i+1}. {(s.label or '').strip() or (s.text or '')[:60]}\n Prompt: {s.text}" for i, s in enumerate(wf.steps))
system_prompt = (
f"You are the Edit Agent for the user's saved workflow \"{wf.title}\" "
f"(id: {wf.id}). Help the user iterate on it. The workflow's purpose: "
f"{wf.description or '(unspecified)'}.\n\n"
f"Current steps:\n{steps_lines}\n\n"
"How to work:\n"
"1. When the user describes a change, briefly confirm what you'll do.\n"
"2. If you need to look at files / search / activate an MCP / etc. to "
"verify your idea, use your tools.\n"
"3. Call EditWorkflowStep(workflow_id, step_idx, new_text) to apply a "
"prompt change to a specific step. The change persists immediately. "
"Confirm with the user via AskUserQuestion FIRST if there's any "
"ambiguity about what they want.\n"
"4. Call TestWorkflow(workflow_id) to spawn a sibling Test Agent that "
"runs the latest version end-to-end. Use this after a change to verify "
"it works.\n\n"
"Be brief in your replies. Don't restate the whole workflow back; the "
"user can see it. Just confirm what changed and what you're doing."
)
config = AgentConfig(
name=f"Edit Agent: {wf.title}",
model=wf.model or "sonnet",
mode=wf.mode or "agent",
provider=wf.provider or "anthropic",
system_prompt=system_prompt,
allowed_tools=[],
dashboard_id=wf.dashboard_id,
)
session = await agent_manager.launch_agent(config)
try:
setattr(wf, "edit_agent_session_id", session.id)
storage.save_workflow(wf)
except Exception:
logger.debug("could not persist edit_agent_session_id (legacy schema)", exc_info=True)
return {"session_id": session.id}
@workflows.router.post("/{workflow_id}/test-run")
async def test_run_workflow(workflow_id: str, body: dict):
"""Spawn a Test Agent session running the (possibly-unsaved) draft.
Powers Image #39: EditAgentView's Test button. Takes an optional
draft `steps` array overriding the saved workflow's steps so the
user can validate edits before persisting. The spawned session is
a normal agent session; nothing is recorded as a WorkflowRun so
History stays clean. Returns the new session id; the FE wires it
to the workflow card via setCardSidecar(kind='testing') and the
dashboard draws the labeled arrow chip between the two cards.
"""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
draft_steps = (body or {}).get("steps")
steps_texts: list[str]
if isinstance(draft_steps, list) and draft_steps:
steps_texts = [str(s.get("text") or "") for s in draft_steps if isinstance(s, dict) and s.get("text")]
else:
steps_texts = [s.text for s in wf.steps if s.text and s.text.strip()]
if not steps_texts:
raise HTTPException(status_code=400, detail="Workflow has no steps to test")
from backend.apps.agents.core.models import AgentConfig
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.workflows import executor
config = AgentConfig(
name=f"{wf.title or 'Workflow'} (test)",
model=wf.model or "sonnet",
mode=wf.mode or "agent",
provider=wf.provider or "anthropic",
system_prompt=executor._resolve_system_prompt(wf),
allowed_tools=executor._resolve_allowed_tools(wf) or [
"Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion",
],
dashboard_id=wf.dashboard_id,
)
session = await agent_manager.launch_agent(config)
async def _drive_test() -> None:
try:
for step in steps_texts:
await agent_manager.send_message(session.id, step)
await executor._await_session_idle(session.id)
sess_state = agent_manager.sessions.get(session.id)
if sess_state is not None and getattr(sess_state, "status", None) == "error":
return
except Exception:
logger.exception("test-run drive loop failed")
asyncio.create_task(_drive_test())
return {"session_id": session.id}
@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
from backend.apps.settings.credentials import 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.core.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]}