mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 01:54:52 +02:00
1009 lines
41 KiB
Python
1009 lines
41 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,
|
|
WorkflowStep,
|
|
)
|
|
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"
|
|
|
|
|
|
def p_source_session_approvals(session_id: Optional[str]) -> dict[str, str]:
|
|
if not session_id:
|
|
return {}
|
|
try:
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
sess = agent_manager.sessions.get(session_id)
|
|
decisions = getattr(sess, "approval_decisions", None) if sess is not None else None
|
|
if decisions is None:
|
|
from backend.apps.agents.manager.session.session_store import _load_session_data
|
|
data = _load_session_data(session_id) or {}
|
|
decisions = data.get("approval_decisions") or []
|
|
except Exception:
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for entry in decisions or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("sensitive_pattern"):
|
|
continue
|
|
tool = str(entry.get("tool") or "")
|
|
behavior = entry.get("behavior")
|
|
if tool and behavior in ("allow", "deny"):
|
|
out[tool] = behavior
|
|
return out
|
|
|
|
|
|
def p_prune_step_tool_usage(wf: Workflow) -> None:
|
|
live_ids = {s.id for s in wf.steps}
|
|
wf.step_tool_usage = {
|
|
sid: dict(tools)
|
|
for sid, tools in (wf.step_tool_usage or {}).items()
|
|
if sid in live_ids and isinstance(tools, dict)
|
|
}
|
|
|
|
|
|
@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,
|
|
)
|
|
wf.remembered_approvals = p_source_session_approvals(body.source_session_id)
|
|
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 "", "", []
|
|
|
|
|
|
async def p_relabel_changed_steps(wf: Workflow, before_steps: list[dict]) -> None:
|
|
before_by_id = {s.get("id"): s for s in before_steps}
|
|
regen_idxs: list[int] = []
|
|
for i, step in enumerate(wf.steps):
|
|
old = before_by_id.get(step.id)
|
|
old_text = (old or {}).get("text") or ""
|
|
old_label = (old or {}).get("label") or ""
|
|
new_label = (step.label or "").strip()
|
|
if old is not None and old_text == step.text:
|
|
if not new_label and old_label:
|
|
step.label = old_label
|
|
continue
|
|
if not (new_label and new_label != old_label):
|
|
regen_idxs.append(i)
|
|
if not regen_idxs:
|
|
return
|
|
try:
|
|
labels = (await _generate_workflow_metadata(wf))[2]
|
|
except Exception:
|
|
return
|
|
if labels and len(labels) == len(wf.steps):
|
|
for i in regen_idxs:
|
|
if labels[i]:
|
|
wf.steps[i].label = labels[i]
|
|
|
|
|
|
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,
|
|
}
|
|
base["has_draft"] = wf.draft_steps is not None
|
|
return base
|
|
|
|
|
|
def p_render_test_transcript(messages: list, max_chars: int = 14000) -> str:
|
|
"""Flatten a Test Agent's messages into a readable role-tagged transcript.
|
|
|
|
Tail-biased cap so the end (where a run succeeds or blows up) always
|
|
survives, protecting the Edit Agent's context window.
|
|
"""
|
|
import json as json_mod
|
|
lines: list[str] = []
|
|
for m in messages:
|
|
if getattr(m, "hidden", False):
|
|
continue
|
|
role = (getattr(m, "role", "") or "?").upper()
|
|
content = getattr(m, "content", "")
|
|
if isinstance(content, str):
|
|
text = content
|
|
elif isinstance(content, list):
|
|
parts: list[str] = []
|
|
for b in content:
|
|
if not isinstance(b, dict):
|
|
parts.append(str(b))
|
|
continue
|
|
kind = b.get("type")
|
|
if kind == "text":
|
|
parts.append(str(b.get("text") or ""))
|
|
elif kind == "tool_use":
|
|
parts.append(f"[tool {b.get('name')}] {json_mod.dumps(b.get('input') or {})[:300]}")
|
|
elif kind == "tool_result":
|
|
inner = b.get("content")
|
|
parts.append(f"[result] {inner if isinstance(inner, str) else json_mod.dumps(inner)[:300]}")
|
|
else:
|
|
parts.append(str(b)[:200])
|
|
text = "\n".join(p for p in parts if p)
|
|
else:
|
|
text = ""
|
|
if text.strip():
|
|
lines.append(f"{role}: {text.strip()}")
|
|
out = "\n\n".join(lines)
|
|
if len(out) > max_chars:
|
|
out = "...(earlier turns trimmed)...\n\n" + out[-max_chars:]
|
|
return out
|
|
|
|
|
|
@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)
|
|
# While an Edit-Agent draft is in flight, ANY PATCH that touches steps
|
|
# stages those steps into the draft instead of the live workflow, so the
|
|
# commit/discard pair is the only thing that moves the live steps. The
|
|
# match is "steps present", not "steps only", so a mixed patch can never
|
|
# leak an edit onto the live steps (which commit would then clobber with
|
|
# the stale draft). The main chat agent never opens an Edit Agent, so it
|
|
# has no draft and falls through to the live path below.
|
|
if wf.draft_steps is not None and "steps" in data:
|
|
wf.draft_steps = data["steps"]
|
|
# Any non-steps fields in the same patch still apply live (rare from
|
|
# the Edit Agent, whose tools only touch steps).
|
|
for k, v in data.items():
|
|
if k != "steps":
|
|
setattr(wf, k, v)
|
|
wf.updated_at = datetime.now()
|
|
storage.save_workflow(wf)
|
|
enriched = _enriched(wf)
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:updated", {
|
|
"workflow_id": wf.id,
|
|
"workflow": enriched,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return enriched
|
|
for k, v in data.items():
|
|
setattr(wf, k, v)
|
|
if "steps" in data:
|
|
await p_relabel_changed_steps(wf, before.get("steps") or [])
|
|
p_prune_step_tool_usage(wf)
|
|
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()
|
|
# Push the change to every open dashboard so an agent-driven edit (the
|
|
# Edit Agent's add/delete/edit-step tools all PATCH here) refreshes the
|
|
# card live instead of looking stale until the next full refetch.
|
|
enriched = _enriched(wf)
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:updated", {
|
|
"workflow_id": wf.id,
|
|
"workflow": enriched,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return enriched
|
|
|
|
|
|
@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()
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:deleted", {"workflow_id": workflow_id})
|
|
except Exception:
|
|
pass
|
|
return {"ok": True}
|
|
|
|
|
|
@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")
|
|
# Reattach to an in-progress edit session (the user closed and reopened the
|
|
# card mid-edit): resume the existing draft, don't reset it. Save/Discard
|
|
# clear edit_agent_session_id, so once an edit is finished the next entry
|
|
# falls through to the fresh path below: a brand-new chat against the
|
|
# current committed workflow.
|
|
existing_id = getattr(wf, "edit_agent_session_id", None) or None
|
|
if existing_id:
|
|
if wf.draft_steps is None:
|
|
wf.draft_steps = list(wf.steps)
|
|
storage.save_workflow(wf)
|
|
return {"session_id": existing_id}
|
|
|
|
# Fresh edit session: snapshot a clean draft from the current committed
|
|
# steps so the Edit Agent's edits stage there (never the live workflow)
|
|
# until the user clicks Save, and Discard reverts to exactly this.
|
|
wf.draft_steps = list(wf.steps)
|
|
storage.save_workflow(wf)
|
|
|
|
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))
|
|
# A brand-new workflow ("+ New" in the hub) opens here with zero steps, so
|
|
# frame the agent as a builder rather than a fix-what-exists editor.
|
|
intro = (
|
|
"Help the user iterate on it."
|
|
if wf.steps
|
|
else "This workflow is brand new and has no steps yet. Help the user "
|
|
"build it from scratch: ask what it should do, then add steps with "
|
|
"AddWorkflowStep."
|
|
)
|
|
steps_block = f"Current steps:\n{steps_lines}\n\n" if wf.steps else "It has no steps yet.\n\n"
|
|
system_prompt = (
|
|
f"You are the Edit Agent for the user's saved workflow \"{wf.title}\" "
|
|
f"(id: {wf.id}). {intro} The workflow's purpose: "
|
|
f"{wf.description or '(unspecified)'}.\n\n"
|
|
f"{steps_block}"
|
|
"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. To change the workflow's steps, call the matching tool. Your edits "
|
|
"STAGE to a pending draft and are fully reversible; nothing touches the "
|
|
"live workflow until the user clicks Save. The card shows your draft as "
|
|
"you go:\n"
|
|
" - EditWorkflowStep(workflow_id, step_idx, new_text, new_label) to "
|
|
"rewrite a step. ALWAYS pass new_label (a fresh 3-5 word summary) so "
|
|
"the card reflects the change instead of the stale old label.\n"
|
|
" - AddWorkflowStep(workflow_id, text, label) to add a step.\n"
|
|
" - DeleteWorkflowStep(workflow_id, step_idx) to remove one.\n"
|
|
" Confirm via AskUserQuestion FIRST if there's any ambiguity.\n"
|
|
"4. Call TestWorkflow(workflow_id) to spawn a sibling Test Agent that "
|
|
"runs the current draft end-to-end. Use this after a change to verify "
|
|
"it works.\n"
|
|
"5. After a test finishes, call ReadTestTranscript(workflow_id) to read "
|
|
"the Test Agent's full transcript and diagnose what happened before "
|
|
"proposing further edits.\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.\n"
|
|
"Write like a normal chat: plain conversational sentences. When you "
|
|
"suggest changes, describe them in prose (e.g. \"I could add a step "
|
|
"that...\"). Never dump raw JSON, arrays, or code blocks of step "
|
|
"objects at the user; that belongs in your EditWorkflowStep tool call, "
|
|
"not the message."
|
|
)
|
|
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}
|
|
|
|
|
|
async def p_end_edit_session(wf) -> None:
|
|
"""End a workflow's Edit-Agent session (after Save or Discard) so the next
|
|
edit opens a brand-new chat against the current workflow instead of
|
|
resuming the old conversation that still references the dropped edits."""
|
|
sid = getattr(wf, "edit_agent_session_id", None)
|
|
wf.edit_agent_session_id = None
|
|
if not sid:
|
|
return
|
|
try:
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
await agent_manager.close_session(sid)
|
|
except Exception:
|
|
logger.debug("could not close edit session %s", sid, exc_info=True)
|
|
|
|
|
|
@workflows.router.post("/{workflow_id}/draft/commit")
|
|
async def commit_draft(workflow_id: str):
|
|
"""Commit the Edit-Agent draft: draft_steps become the live steps."""
|
|
wf = storage.get_workflow(workflow_id)
|
|
if not wf:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
if wf.draft_steps is None:
|
|
await p_end_edit_session(wf)
|
|
storage.save_workflow(wf)
|
|
return _enriched(wf)
|
|
before = wf.model_dump(mode="json")
|
|
wf.steps = wf.draft_steps
|
|
wf.draft_steps = None
|
|
await p_relabel_changed_steps(wf, before.get("steps") or [])
|
|
p_prune_step_tool_usage(wf)
|
|
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
|
|
await p_end_edit_session(wf)
|
|
storage.save_workflow(wf)
|
|
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
|
|
scheduler.kick()
|
|
enriched = _enriched(wf)
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:updated", {
|
|
"workflow_id": wf.id,
|
|
"workflow": enriched,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return enriched
|
|
|
|
|
|
@workflows.router.post("/{workflow_id}/draft/discard")
|
|
async def discard_draft(workflow_id: str):
|
|
"""Throw away the Edit-Agent draft; the live workflow is untouched."""
|
|
wf = storage.get_workflow(workflow_id)
|
|
if not wf:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
# Discard wipes the whole edit session: drop the draft AND end the chat, so
|
|
# reopening Edit is a fresh conversation against the current committed steps.
|
|
wf.draft_steps = None
|
|
await p_end_edit_session(wf)
|
|
p_prune_step_tool_usage(wf)
|
|
storage.save_workflow(wf)
|
|
enriched = _enriched(wf)
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:updated", {
|
|
"workflow_id": wf.id,
|
|
"workflow": enriched,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return enriched
|
|
|
|
|
|
@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")
|
|
step_entries: list[WorkflowStep]
|
|
if isinstance(draft_steps, list) and draft_steps:
|
|
step_entries = [
|
|
WorkflowStep(**s)
|
|
for s in draft_steps
|
|
if isinstance(s, dict) and str(s.get("text") or "").strip()
|
|
]
|
|
else:
|
|
# No explicit override: prefer the pending draft so a mid-edit
|
|
# TestWorkflow call (from the Edit Agent itself) tests the draft.
|
|
src = wf.draft_steps if wf.draft_steps is not None else wf.steps
|
|
step_entries = [s for s in src if s.text and s.text.strip()]
|
|
if not step_entries:
|
|
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,
|
|
clear_workflow_approval_memory,
|
|
get_workflow_step_usage,
|
|
set_workflow_approval_memory,
|
|
set_workflow_approval_step,
|
|
)
|
|
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)
|
|
session.workflow_test_state = "running"
|
|
set_workflow_approval_memory(
|
|
session.id,
|
|
decisions=dict(wf.remembered_approvals),
|
|
step_usage={sid: dict(tools) for sid, tools in wf.step_tool_usage.items()},
|
|
remember=executor.p_make_remember_approval(wf.id),
|
|
ask_timeout=600.0,
|
|
)
|
|
# Point the workflow at its latest test session so ReadTestTranscript can
|
|
# fetch the transcript on demand.
|
|
try:
|
|
wf.last_test_session_id = session.id
|
|
storage.save_workflow(wf)
|
|
except Exception:
|
|
logger.debug("could not persist last_test_session_id", exc_info=True)
|
|
|
|
async def _set_test_state(state: str) -> None:
|
|
sess = agent_manager.sessions.get(session.id)
|
|
if sess is not None:
|
|
sess.workflow_test_state = state
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("agent:test_state", {
|
|
"session_id": session.id,
|
|
"state": state,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
async def _drive_test() -> None:
|
|
final = "complete"
|
|
try:
|
|
for step in step_entries:
|
|
set_workflow_approval_step(session.id, step.id)
|
|
await agent_manager.send_message(session.id, step.text)
|
|
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":
|
|
final = "error"
|
|
return
|
|
except Exception:
|
|
logger.exception("test-run drive loop failed")
|
|
final = "error"
|
|
finally:
|
|
try:
|
|
executor.p_persist_step_tool_usage(wf.id, get_workflow_step_usage(session.id))
|
|
except Exception:
|
|
logger.exception("test-run step usage persist failed")
|
|
set_workflow_approval_step(session.id, None)
|
|
clear_workflow_approval_memory(session.id)
|
|
await _set_test_state(final)
|
|
asyncio.create_task(_drive_test())
|
|
|
|
return {"session_id": session.id}
|
|
|
|
|
|
@workflows.router.get("/{workflow_id}/test-transcript")
|
|
async def test_transcript(workflow_id: str):
|
|
"""Full transcript of the workflow's most recent Test Agent session.
|
|
|
|
Backs the Edit Agent's ReadTestTranscript tool: it needs the Test Agent's
|
|
entire chat history (not just a final output) to diagnose a run.
|
|
"""
|
|
wf = storage.get_workflow(workflow_id)
|
|
if not wf:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
if not wf.last_test_session_id:
|
|
return {"transcript": "", "status": "none"}
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
sess = agent_manager.sessions.get(wf.last_test_session_id)
|
|
if sess is None:
|
|
return {"transcript": "", "status": "unavailable"}
|
|
transcript = p_render_test_transcript(getattr(sess, "messages", []) or [])
|
|
return {"transcript": transcript, "status": getattr(sess, "status", "") or ""}
|
|
|
|
|
|
@workflows.router.post("/{workflow_id}/schedule-agent-session")
|
|
async def schedule_agent_session(workflow_id: str):
|
|
"""Create (or return existing) embedded scheduling-agent session.
|
|
|
|
The scheduling agent is a real agent session the user chats with to set
|
|
the workflow's cadence (Image #49). It interprets the user's natural
|
|
language ("every Wednesday at 1pm", "this time, this month") itself and
|
|
commits via UpdateScheduledWorkflow, which is force-gated to "ask" so the
|
|
user gives a final Approve/Deny through ApprovalBar. No deterministic
|
|
pre-parse: the cadence is a model decision.
|
|
|
|
Singleton per workflow (same reattach contract as edit-agent-session) so
|
|
re-entering the scheduling view resumes the same conversation.
|
|
"""
|
|
wf = storage.get_workflow(workflow_id)
|
|
if not wf:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
existing_id = getattr(wf, "schedule_agent_session_id", None) or None
|
|
if existing_id:
|
|
return {"session_id": existing_id}
|
|
|
|
from backend.apps.agents.core.models import AgentConfig
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
now_local = datetime.now().astimezone()
|
|
current_dt = now_local.strftime("%A %Y-%m-%d %H:%M %Z")
|
|
system_prompt = (
|
|
f"You are the Scheduling Agent for the user's saved workflow \"{wf.title}\" "
|
|
f"(id: {wf.id}). Your only job is to set when this workflow runs.\n\n"
|
|
f"The current local date and time is {current_dt}. Resolve relative "
|
|
"phrasing (\"this month\", \"next Wednesday\", \"this time\") against it.\n\n"
|
|
"When the user states a cadence, interpret it yourself and call "
|
|
"UpdateScheduledWorkflow with:\n"
|
|
f" - workflow_id: \"{wf.id}\"\n"
|
|
" - schedule_enabled: true\n"
|
|
" - hour (0-23) and minute (0-59) in the user's local time\n"
|
|
" - repeat_unit: \"minute\" | \"hour\" | \"day\" | \"week\" | \"month\"\n"
|
|
" - repeat_every: the interval count (1 unless they say e.g. \"every other\"; "
|
|
"for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n"
|
|
" - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n"
|
|
" - timezone: an IANA name only if the user names a specific zone\n\n"
|
|
"If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence "
|
|
"is genuinely ambiguous, ask ONE short clarifying question first; otherwise "
|
|
"go straight to the tool call. The user approves or rejects the change in a "
|
|
"permission prompt, so the tool call IS the confirmation: do not also ask "
|
|
"\"should I schedule this?\" in text. Do not edit the workflow's steps. Keep "
|
|
"every reply to one short sentence."
|
|
)
|
|
config = AgentConfig(
|
|
name=f"Scheduling: {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, "schedule_agent_session_id", session.id)
|
|
storage.save_workflow(wf)
|
|
except Exception:
|
|
logger.debug("could not persist schedule_agent_session_id (legacy schema)", exc_info=True)
|
|
return {"session_id": session.id}
|
|
|
|
|
|
@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]}
|