From f0c2715713dfb6b7fc370b98256b706e161baa4e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 27 May 2026 11:53:30 -0700 Subject: [PATCH] [eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69) --- backend/apps/agents/agent_manager.py | 53 +- backend/apps/agents/schedule_mcp_server.py | 412 ++++++++ backend/apps/dashboards/models.py | 9 +- backend/apps/workflows/__init__.py | 0 backend/apps/workflows/audit.py | 95 ++ backend/apps/workflows/escalation.py | 90 ++ backend/apps/workflows/executor.py | 334 +++++++ backend/apps/workflows/models.py | 164 +++ backend/apps/workflows/notifier.py | 55 ++ backend/apps/workflows/scheduler.py | 352 +++++++ backend/apps/workflows/storage.py | 197 ++++ backend/apps/workflows/workflows.py | 807 +++++++++++++++ backend/main.py | 3 +- backend/tests/test_schedule_e2e.py | 277 ++++++ backend/tests/test_workflows_semantics.py | 430 ++++++++ electron/main.js | 18 +- electron/workflowsLifecycle.js | 208 ++++ .../app/pages/Dashboard/DashboardToolbar.tsx | 85 +- .../Dashboard/canvas/DashboardCanvas.tsx | 14 +- .../Dashboard/canvas/DashboardCardLayer.tsx | 54 + .../app/pages/Dashboard/cards/AgentCard.tsx | 104 ++ .../pages/Dashboard/geometry/contentBounds.ts | 6 + .../Dashboard/geometry/dashboardTethers.ts | 207 +++- .../pages/Dashboard/geometry/getCardRect.ts | 4 + .../hooks/interaction/useArrowNav.ts | 9 +- .../interaction/useDashboardShortcuts.ts | 6 +- .../hooks/lifecycle/useDashboardLifecycle.ts | 45 + .../hooks/state/useDashboardController.ts | 16 +- .../hooks/state/useDashboardSelection.ts | 21 +- .../hooks/state/useDashboardSelectors.ts | 14 + .../Dashboard/hooks/state/useLayoutSave.ts | 13 +- .../src/app/pages/Workflows/ActionsFacet.tsx | 80 ++ .../pages/Workflows/ConfigurePanelCard.tsx | 158 +++ .../src/app/pages/Workflows/EditAgentView.tsx | 323 ++++++ .../src/app/pages/Workflows/GeneralFacet.tsx | 132 +++ .../app/pages/Workflows/ScheduleCalendar.tsx | 431 ++++++++ .../src/app/pages/Workflows/ScheduleFacet.tsx | 476 +++++++++ .../app/pages/Workflows/SchedulePopover.tsx | 241 +++++ .../pages/Workflows/ScheduleThisPopover.tsx | 240 +++++ .../app/pages/Workflows/SchedulingView.tsx | 230 +++++ frontend/src/app/pages/Workflows/StepList.tsx | 291 ++++++ .../src/app/pages/Workflows/WorkflowCard.tsx | 932 ++++++++++++++++++ .../pages/Workflows/WorkflowCardLiveViews.tsx | 554 +++++++++++ .../pages/Workflows/WorkflowCardSubviews.tsx | 598 +++++++++++ .../app/pages/Workflows/WorkflowEditViews.tsx | 162 +++ .../app/pages/Workflows/WorkflowsHubCard.tsx | 631 ++++++++++++ .../app/pages/Workflows/permissionsUtils.ts | 31 + .../src/app/pages/Workflows/scheduleDetect.ts | 103 ++ .../src/app/pages/Workflows/scheduleUtils.ts | 171 ++++ .../pages/Workflows/workflowEditCommon.tsx | 52 + .../app/pages/Workflows/workflowVisuals.tsx | 503 ++++++++++ .../src/shared/state/dashboardLayoutSlice.ts | 303 +++++- frontend/src/shared/state/store.ts | 2 + frontend/src/shared/state/workflowsSlice.ts | 374 +++++++ frontend/src/shared/ws/WebSocketManager.ts | 87 +- 55 files changed, 11119 insertions(+), 88 deletions(-) create mode 100644 backend/apps/agents/schedule_mcp_server.py create mode 100644 backend/apps/workflows/__init__.py create mode 100644 backend/apps/workflows/audit.py create mode 100644 backend/apps/workflows/escalation.py create mode 100644 backend/apps/workflows/executor.py create mode 100644 backend/apps/workflows/models.py create mode 100644 backend/apps/workflows/notifier.py create mode 100644 backend/apps/workflows/scheduler.py create mode 100644 backend/apps/workflows/storage.py create mode 100644 backend/apps/workflows/workflows.py create mode 100644 backend/tests/test_schedule_e2e.py create mode 100644 backend/tests/test_workflows_semantics.py create mode 100644 electron/workflowsLifecycle.js create mode 100644 frontend/src/app/pages/Workflows/ActionsFacet.tsx create mode 100644 frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx create mode 100644 frontend/src/app/pages/Workflows/EditAgentView.tsx create mode 100644 frontend/src/app/pages/Workflows/GeneralFacet.tsx create mode 100644 frontend/src/app/pages/Workflows/ScheduleCalendar.tsx create mode 100644 frontend/src/app/pages/Workflows/ScheduleFacet.tsx create mode 100644 frontend/src/app/pages/Workflows/SchedulePopover.tsx create mode 100644 frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx create mode 100644 frontend/src/app/pages/Workflows/SchedulingView.tsx create mode 100644 frontend/src/app/pages/Workflows/StepList.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowCard.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowEditViews.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx create mode 100644 frontend/src/app/pages/Workflows/permissionsUtils.ts create mode 100644 frontend/src/app/pages/Workflows/scheduleDetect.ts create mode 100644 frontend/src/app/pages/Workflows/scheduleUtils.ts create mode 100644 frontend/src/app/pages/Workflows/workflowEditCommon.tsx create mode 100644 frontend/src/app/pages/Workflows/workflowVisuals.tsx create mode 100644 frontend/src/shared/state/workflowsSlice.ts diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 15d8155b..431e73d7 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1061,6 +1061,23 @@ class AgentManager: mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps) global_settings = load_settings() + # Nudge the agent to surface ScheduleWorkflow proactively when + # the user's ask looks recurring. The per-tool description + # carries the full protocol; this is just the "when to think + # about it" signal so the agent doesn't ignore the surface. + schedule_ctx = ( + "\n" + "After completing a substantive task, if the work looks " + "repeatable (the user said 'every', 'each', 'daily', " + "'weekly', 'morning', 'before standup', or you just did " + "the same sequence twice in this session), offer to " + "schedule it. Use AskUserQuestion to confirm cadence, " + "then ScheduleWorkflow to create it. Never reach for " + "crontab, launchctl, or schtasks; always use the native " + "scheduler so the user can see, pause, and edit it. " + "Don't ask after trivial one-off requests.\n" + "" + ) composed_prompt = self._compose_system_prompt( global_settings.default_system_prompt, mode_sys_prompt, @@ -1069,20 +1086,15 @@ class AgentManager: browser_ctx, mcp_registry_ctx, ) + composed_prompt = (composed_prompt + "\n\n" + schedule_ctx) if composed_prompt else schedule_ctx # Pin the agent's notion of "now" to the host wall clock + zone - # so it can answer day-of-week questions without hallucinating. + # so it can answer day-of-week questions and pick sensible + # cadences ("every Friday afternoon") without hallucinating. try: from zoneinfo import ZoneInfo - # Best-effort IANA name for the host. Mirrors apps/service/client.py. - tz_name = os.environ.get("OPENSWARM_TIMEZONE", "").strip() - if not tz_name: - try: - from tzlocal import get_localzone_name # type: ignore - tz_name = get_localzone_name() or "" - except Exception: - tz_name = "" - tz_name = tz_name or "UTC" + from backend.apps.workflows.storage import _resolve_host_tz_name + tz_name = _resolve_host_tz_name() now_local = datetime.now(ZoneInfo(tz_name)) tz_abbr = now_local.strftime("%Z") or tz_name time_ctx = ( @@ -1180,6 +1192,27 @@ class AgentManager: "type": "stdio", } + # Always-on schedule server. Exposes ScheduleWorkflow + CRUD + # tools so the agent can offer to schedule recurring work via + # the native scheduler (visible, auditable) rather than reach + # for cron/launchctl. Tool descriptions tell the agent to + # AskUserQuestion FIRST to confirm cadence with the user. + schedule_server_path = os.path.join( + os.path.dirname(__file__), "schedule_mcp_server.py" + ) + from backend.auth import get_auth_token as _get_auth_token_sched + mcp_servers["openswarm-schedule"] = { + "command": sys.executable, + "args": [schedule_server_path], + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": _get_auth_token_sched(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + }, + "type": "stdio", + } + # Always-on meta-MCP server. Exposes MCPList / MCPSearch / # MCPActivate so the model can discover and activate user MCPs at # runtime. The activation gate (active_mcps filter in diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py new file mode 100644 index 00000000..5a75a603 --- /dev/null +++ b/backend/apps/agents/schedule_mcp_server.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +"""Stdio MCP server exposing scheduled-workflow tools to the agent. + +Why this exists: the agent should be able to schedule recurring work on +the user's behalf, but ALWAYS through the native scheduler (visible, +auditable, cost-capped) rather than `crontab`. Each tool is a thin +wrapper around /api/workflows/*. The descriptions are written to nudge +the agent toward AskUserQuestion-first behavior (confirm cadence with +the user before calling ScheduleWorkflow). +""" + +import json +import sys +import os +import urllib.request +import urllib.error + +BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") +BACKEND_BASE = f"http://127.0.0.1:{BACKEND_PORT}/api/workflows" +PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") +DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "") + + +PRESETS = { + "daily_morning": {"enabled": True, "repeat_unit": "day", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []}, + "weekdays_morning": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1, 2, 3, 4, 5]}, + "weekly_monday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1]}, + "weekly_friday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 17, "minute": 0, "on_days": [5]}, + "monthly_first": {"enabled": True, "repeat_unit": "month", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []}, +} + + +TOOLS = [ + { + "name": "ScheduleWorkflow", + "description": ( + "Create a recurring scheduled workflow for the user. Use this " + "ONLY after confirming cadence with the user via AskUserQuestion " + "(do not assume — the user must pick or accept the time). " + "The workflow runs the listed steps on the schedule and is " + "visible in the user's Workflows hub. Never use crontab, " + "launchctl, or schtasks to schedule recurring work; always use " + "this tool so the user can see, pause, edit, or delete it. " + "After creating, briefly confirm to the user what was scheduled." + ), + "inputSchema": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Short workflow name shown in the hub and on the dashboard card."}, + "steps": { + "type": "array", + "items": {"type": "string"}, + "description": "Ordered list of instructions for the agent to execute on each fire. Each string is one step.", + }, + "preset": { + "type": "string", + "enum": ["daily_morning", "weekdays_morning", "weekly_monday", "weekly_friday", "monthly_first", "custom"], + "description": "Cadence preset. Use 'custom' to specify your own hour/minute/days.", + }, + "hour": {"type": "integer", "description": "Hour 0-23 in the user's local time. Required when preset='custom'."}, + "minute": {"type": "integer", "description": "Minute 0/15/30/45. Required when preset='custom'."}, + "repeat_unit": {"type": "string", "enum": ["day", "week", "month"], "description": "Required when preset='custom'."}, + "on_days": { + "type": "array", + "items": {"type": "integer"}, + "description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.", + }, + "source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."}, + }, + "required": ["title", "steps", "preset"], + }, + }, + { + "name": "ListScheduledWorkflows", + "description": "List the user's scheduled workflows. Use this to find a workflow the user is referring to before editing or deleting it.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "UpdateScheduledWorkflow", + "description": "Modify an existing scheduled workflow. Only pass the fields you want to change. Always confirm with the user via AskUserQuestion before making changes that meaningfully alter behavior (cadence, steps, permissions).", + "inputSchema": { + "type": "object", + "properties": { + "workflow_id": {"type": "string"}, + "title": {"type": "string"}, + "steps": {"type": "array", "items": {"type": "string"}}, + "schedule_enabled": {"type": "boolean", "description": "Quick on/off without changing other schedule fields."}, + "hour": {"type": "integer"}, + "minute": {"type": "integer"}, + "repeat_unit": {"type": "string", "enum": ["day", "week", "month"]}, + "on_days": {"type": "array", "items": {"type": "integer"}}, + }, + "required": ["workflow_id"], + }, + }, + { + "name": "DeleteScheduledWorkflow", + "description": "Permanently delete a scheduled workflow. Cannot be undone. ALWAYS confirm via AskUserQuestion before calling this — the user should pick from a list, not have you guess.", + "inputSchema": { + "type": "object", + "properties": {"workflow_id": {"type": "string"}}, + "required": ["workflow_id"], + }, + }, + { + "name": "PauseAllWorkflows", + "description": "Globally pause every scheduled workflow. In-flight runs finish; future runs are blocked until resumed. Use when the user wants a temporary stop (vacation, debugging) without deleting workflows.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "ResumeAllWorkflows", + "description": "Resume scheduled workflows after a previous PauseAllWorkflows.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "RunWorkflowNow", + "description": "Trigger an immediate one-off run of a scheduled workflow. The schedule continues to fire on its normal cadence in addition.", + "inputSchema": { + "type": "object", + "properties": {"workflow_id": {"type": "string"}}, + "required": ["workflow_id"], + }, + }, + { + "name": "EditWorkflowStep", + "description": ( + "Edit a single step's prompt text on an existing workflow. Use " + "when the user has accepted a proposed change during an Edit " + "Agent conversation; the new prompt replaces the existing one " + "and persists immediately. The next scheduled run uses the new " + "version. Always confirm the change with the user before " + "calling this; AskUserQuestion FIRST if there is any ambiguity." + ), + "inputSchema": { + "type": "object", + "properties": { + "workflow_id": {"type": "string", "description": "The workflow to edit."}, + "step_idx": {"type": "integer", "description": "0-based index of the step to modify."}, + "new_text": {"type": "string", "description": "Full replacement prompt text for the step."}, + }, + "required": ["workflow_id", "step_idx", "new_text"], + }, + }, + { + "name": "TestWorkflow", + "description": ( + "Spawn a sibling Test Agent that runs the workflow end-to-end " + "(with the latest persisted steps) so the user can watch it " + "work. Use after editing a step to verify the change. The Test " + "Agent renders as a sibling card on the dashboard with a " + "'Testing' arrow chip linking back to this workflow." + ), + "inputSchema": { + "type": "object", + "properties": { + "workflow_id": {"type": "string", "description": "The workflow to test."}, + }, + "required": ["workflow_id"], + }, + }, +] + + +def send_response(id_, result=None, error=None): + msg = {"jsonrpc": "2.0", "id": id_} + if error is not None: + msg["error"] = error + else: + msg["result"] = result + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def _call(method: str, path: str, body=None) -> dict: + url = BACKEND_BASE + path + data = json.dumps(body).encode() if body is not None else None + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode() or "null") or {} + except urllib.error.HTTPError as e: + body_err = e.read().decode() if e.fp else str(e) + return {"_error": f"HTTP {e.code}: {body_err}"} + except Exception as e: + return {"_error": str(e)} + + +def _build_schedule_from_preset(preset: str, args: dict) -> dict: + base = {"timezone": "local", "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0} + if preset == "custom": + return { + **base, + "enabled": True, + "repeat_unit": args.get("repeat_unit", "day"), + "repeat_every": 1, + "hour": int(args.get("hour", 9)), + "minute": int(args.get("minute", 0)), + "on_days": list(args.get("on_days") or []), + } + preset_def = PRESETS.get(preset) + if not preset_def: + return {} + return {**base, **preset_def, "repeat_every": 1} + + +def handle_schedule_workflow(args: dict) -> dict: + title = args.get("title") or "Scheduled workflow" + steps_in = args.get("steps") or [] + preset = args.get("preset") or "daily_morning" + schedule = _build_schedule_from_preset(preset, args) + if not schedule: + return _err(f"Unknown preset: {preset}. Use one of: {list(PRESETS.keys()) + ['custom']}.") + body = { + "title": title, + "steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s], + "schedule": schedule, + "source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None, + } + r = _call("POST", "/create", body) + if "_error" in r: + return _err(r["_error"]) + wid = r.get("id", "") + nxt = r.get("next_run_at") or "soon" + return _ok(f"Scheduled \"{title}\" ({preset}). Workflow id: {wid}. Next run: {nxt}. The user can view, pause, or edit it in the Workflows hub.") + + +def handle_list(_args: dict) -> dict: + r = _call("GET", "/list") + if "_error" in r: + return _err(r["_error"]) + ws = r.get("workflows", []) + if not ws: + return _ok("No scheduled workflows yet.") + lines = ["Scheduled workflows:"] + for w in ws: + s = w.get("schedule") or {} + enabled = s.get("enabled") + unit = s.get("repeat_unit", "?") + hour = s.get("hour") + title = w.get("title", "(untitled)") + wid = w.get("id", "") + state = "ON" if enabled else "off" + lines.append(f" - {title} [{state}] {unit} at {hour:02d}:00 (id: {wid})") + return _ok("\n".join(lines)) + + +def handle_update(args: dict) -> dict: + wid = args.get("workflow_id") or "" + if not wid: + return _err("workflow_id is required.") + cur = _call("GET", f"/{wid}") + if "_error" in cur: + return _err(cur["_error"]) + sched = cur.get("schedule") or {} + patch: dict = {} + if "title" in args: patch["title"] = args["title"] + if "steps" in args: + patch["steps"] = [{"id": f"s{i+1}", "text": s} for i, s in enumerate(args["steps"] or []) if s] + sched_patch = dict(sched) + sched_dirty = False + if "schedule_enabled" in args: + sched_patch["enabled"] = bool(args["schedule_enabled"]) + sched_dirty = True + for k in ("hour", "minute", "repeat_unit", "on_days"): + if k in args: + sched_patch[k] = args[k] + sched_dirty = True + if sched_dirty: + patch["schedule"] = sched_patch + if not patch: + return _ok(f"No changes requested for workflow {wid}.") + r = _call("PATCH", f"/{wid}", patch) + if "_error" in r: + return _err(r["_error"]) + return _ok(f"Updated \"{r.get('title', wid)}\". Next run: {r.get('next_run_at') or 'paused/unscheduled'}.") + + +def handle_delete(args: dict) -> dict: + wid = args.get("workflow_id") or "" + if not wid: + return _err("workflow_id is required.") + r = _call("DELETE", f"/{wid}") + if "_error" in r: + return _err(r["_error"]) + return _ok(f"Deleted workflow {wid}.") + + +def handle_pause_all(_args: dict) -> dict: + r = _call("POST", "/pause-all") + if "_error" in r: + return _err(r["_error"]) + return _ok("All scheduled workflows are paused. In-flight runs will finish; future fires are blocked. Resume with ResumeAllWorkflows.") + + +def handle_resume_all(_args: dict) -> dict: + r = _call("POST", "/resume-all") + if "_error" in r: + return _err(r["_error"]) + return _ok("Scheduled workflows resumed.") + + +def handle_run_now(args: dict) -> dict: + wid = args.get("workflow_id") or "" + if not wid: + return _err("workflow_id is required.") + r = _call("POST", f"/{wid}/run") + if "_error" in r: + return _err(r["_error"]) + if r.get("status") == "skipped": + return _ok(f"Run was skipped: {r.get('error', 'unknown reason')}.") + return _ok(f"Run started (run id: {r.get('run_id', '')}). Output will appear in the workflow's History.") + + +def _ok(text: str) -> dict: + return {"content": [{"type": "text", "text": text}]} + + +def _err(text: str) -> dict: + return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True} + + +def handle_edit_step(args: dict) -> dict: + wid = args.get("workflow_id") or "" + if not wid: + return _err("workflow_id is required.") + try: + idx = int(args.get("step_idx")) + except (TypeError, ValueError): + return _err("step_idx must be an integer.") + new_text = (args.get("new_text") or "").strip() + if not new_text: + return _err("new_text is required.") + cur = _call("GET", f"/{wid}") + if "_error" in cur: + return _err(cur["_error"]) + steps = cur.get("steps") or [] + if idx < 0 or idx >= len(steps): + return _err(f"step_idx {idx} out of range (workflow has {len(steps)} steps).") + new_steps = list(steps) + new_steps[idx] = {**new_steps[idx], "text": new_text} + r = _call("PATCH", f"/{wid}", {"steps": new_steps}) + if "_error" in r: + return _err(r["_error"]) + return _ok(f"Step {idx + 1} updated. The next run uses the new prompt.") + + +def handle_test_workflow(args: dict) -> dict: + wid = args.get("workflow_id") or "" + if not wid: + return _err("workflow_id is required.") + r = _call("POST", f"/{wid}/test-run", {}) + if "_error" in r: + return _err(r["_error"]) + sid = r.get("session_id", "") + return _ok(f"Test Agent spawned (session {sid[:8]}...). It runs the latest workflow on the dashboard with a Testing arrow chip.") + + +HANDLERS = { + "ScheduleWorkflow": handle_schedule_workflow, + "ListScheduledWorkflows": handle_list, + "UpdateScheduledWorkflow": handle_update, + "DeleteScheduledWorkflow": handle_delete, + "PauseAllWorkflows": handle_pause_all, + "ResumeAllWorkflows": handle_resume_all, + "RunWorkflowNow": handle_run_now, + "EditWorkflowStep": handle_edit_step, + "TestWorkflow": handle_test_workflow, +} + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + method = msg.get("method") + id_ = msg.get("id") + params = msg.get("params", {}) + if method == "initialize": + send_response(id_, { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "openswarm-schedule", "version": "1.0.0"}, + }) + elif method == "notifications/initialized": + pass + elif method == "tools/list": + send_response(id_, {"tools": TOOLS}) + elif method == "tools/call": + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + handler = HANDLERS.get(tool_name) + if handler is None: + send_response(id_, _err(f"Unknown tool: {tool_name}")) + else: + send_response(id_, handler(arguments)) + elif method == "ping": + send_response(id_, {}) + elif id_ is not None: + send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"}) + + +if __name__ == "__main__": + main() diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 52be0543..b62c608f 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -53,12 +53,17 @@ class NotePosition(BaseModel): class DashboardLayout(BaseModel): - # extra="allow" so any keys the FE sends (or legacy on-disk layouts - # carry) round-trip without Pydantic stripping them. + # Accept whatever the FE serialises (workflow_cards, configure_panels, + # workflows_hub etc). Pydantic was silently stripping these because + # they weren't declared, which made the dashboard re-render WITHOUT + # the workflow card the user just placed. model_config = ConfigDict(extra="allow") cards: dict[str, CardPosition] = Field(default_factory=dict) view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict) browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict) + workflow_cards: dict = Field(default_factory=dict) + configure_panels: dict = Field(default_factory=dict) + workflows_hub: Optional[dict] = None notes: dict[str, NotePosition] = Field(default_factory=dict) expanded_session_ids: list[str] = Field(default_factory=list) diff --git a/backend/apps/workflows/__init__.py b/backend/apps/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/workflows/audit.py b/backend/apps/workflows/audit.py new file mode 100644 index 00000000..4ea43a33 --- /dev/null +++ b/backend/apps/workflows/audit.py @@ -0,0 +1,95 @@ +"""Append-only audit log for workflow edits. + +One JSONL file per workflow at /workflows/audit/.jsonl. We +diff before/after rather than snapshotting the full record so the file +stays small even after dozens of edits. Read path tails the file; we don't +keep this in memory because audits are inspected rarely. +""" + +import json +import logging +import os +from datetime import datetime, timezone +from threading import Lock +from typing import Any + +from backend.apps.workflows.storage import DATA_DIR + +logger = logging.getLogger(__name__) + +AUDIT_DIR = os.path.join(DATA_DIR, "audit") +_io_lock = Lock() +# Soft cap on bytes per audit file. When exceeded we truncate to the last +# CAP/2 bytes on next write so attackers (or a runaway PATCH loop) can't +# fill the disk. 256 KiB is ~2000 edits; we never expect to hit it. +SOFT_CAP_BYTES = 256 * 1024 + + +def _audit_path(wid: str) -> str: + return os.path.join(AUDIT_DIR, f"{wid}.jsonl") + + +def _diff(before: dict, after: dict) -> dict[str, dict[str, Any]]: + """Return only the keys whose value changed. Nested dicts are diffed + shallowly; the schedule/actions/permissions blocks are small so we just + record the whole sub-dict when any sub-key changes. + """ + changed: dict[str, dict[str, Any]] = {} + keys = set(before) | set(after) + for k in keys: + b = before.get(k) + a = after.get(k) + if b != a: + changed[k] = {"before": b, "after": a} + return changed + + +def log_change(wid: str, who: str, before: dict, after: dict) -> None: + diff = _diff(before, after) + if not diff: + return + entry = { + "ts": datetime.now(timezone.utc).isoformat(), + "who": who, + "diff": diff, + } + try: + with _io_lock: + os.makedirs(AUDIT_DIR, exist_ok=True) + path = _audit_path(wid) + if os.path.exists(path) and os.path.getsize(path) > SOFT_CAP_BYTES: + # Keep the tail half. Cheap, lossy, prevents pathological + # disk growth without crashing on a corrupt file. + with open(path, "rb") as f: + f.seek(-(SOFT_CAP_BYTES // 2), os.SEEK_END) + tail = f.read() + first_nl = tail.find(b"\n") + tail = tail[first_nl + 1:] if first_nl >= 0 else b"" + with open(path, "wb") as f: + f.write(tail) + with open(path, "a") as f: + f.write(json.dumps(entry) + "\n") + except Exception: + logger.debug("audit log_change failed", exc_info=True) + + +def read_tail(wid: str, limit: int = 50) -> list[dict]: + path = _audit_path(wid) + if not os.path.exists(path): + return [] + try: + with open(path) as f: + lines = f.readlines() + except Exception: + return [] + out: list[dict] = [] + for line in lines[-limit:]: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except Exception: + continue + out.reverse() + return out diff --git a/backend/apps/workflows/escalation.py b/backend/apps/workflows/escalation.py new file mode 100644 index 00000000..a0ab7536 --- /dev/null +++ b/backend/apps/workflows/escalation.py @@ -0,0 +1,90 @@ +"""Server-side escalation timer. + +The permission chain in the UI (notify -> text -> call) used to time out +client-side, which dies the moment the window closes. We move the timer +here so a run that finishes at 9am can escalate to a real text at 9:05am +whether or not the user has the app open. The text/call wire-up itself +still routes through notifier (cloud SMS bridge is wired separately); we +just own the *when*. + +State lives in module-scoped dicts, not on disk. If the backend restarts +mid-escalation the chain is lost on purpose: the user is already in front +of an open app at that point (otherwise the backend wouldn't have started) +and they can ack manually. Persisting escalation state would mean +re-firing on a stale schedule after a multi-day downtime, which is worse. +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional + +from backend.apps.workflows.models import PermissionTier, Workflow, WorkflowRun + +logger = logging.getLogger(__name__) + + +_tasks: dict[str, asyncio.Task] = {} # run_id -> escalation task +_state: dict[str, dict] = {} # run_id -> {tier_idx, next_at, kind} + + +def _tier_delay_seconds(tier: PermissionTier) -> int: + """Tier minutes/hours convention matches the FE: text uses minutes, + call uses hours (the UI label flips with tier.kind). We translate at + the boundary so the backend math is always in seconds.""" + if tier.kind == "call": + return max(0, tier.after_minutes) * 3600 + return max(0, tier.after_minutes) * 60 + + +def schedule(wf: Workflow, run: WorkflowRun) -> None: + """Kick off escalation for a finished run. No-op if the workflow has + only the default notify tier (i.e. nothing to escalate to).""" + tiers = wf.permissions or [] + if len(tiers) <= 1: + return + # Cancel any prior task for this run (defense against a re-fire). + cancel(run.id) + task = asyncio.create_task(_runner(wf, run, tiers)) + _tasks[run.id] = task + + +def cancel(run_id: str) -> bool: + task = _tasks.pop(run_id, None) + _state.pop(run_id, None) + if task is None: + return False + task.cancel() + return True + + +def status(run_id: str) -> Optional[dict]: + return _state.get(run_id) + + +async def _runner(wf: Workflow, run: WorkflowRun, tiers: list[PermissionTier]) -> None: + from backend.apps.workflows.notifier import send_tier + + try: + # Tier 0 is the initial notify; we don't re-fire it here. Walk + # 1..N, sleeping the tier's delay before sending. If the user acks + # via /workflows/runs/{run_id}/ack, the task is cancelled. + for idx in range(1, len(tiers)): + tier = tiers[idx] + delay = _tier_delay_seconds(tier) + fire_at = datetime.now(timezone.utc) + timedelta(seconds=delay) + _state[run.id] = { + "tier_idx": idx, + "tier_kind": tier.kind, + "next_at": fire_at.isoformat(), + } + await asyncio.sleep(delay) + try: + await send_tier(wf, run, tier) + except Exception: + logger.exception("escalation send_tier failed run=%s tier=%s", run.id, tier.kind) + except asyncio.CancelledError: + pass + finally: + _state.pop(run.id, None) + _tasks.pop(run.id, None) diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py new file mode 100644 index 00000000..28073db5 --- /dev/null +++ b/backend/apps/workflows/executor.py @@ -0,0 +1,334 @@ +"""Run a workflow by launching an agent session and feeding it the steps. + +The executor is intentionally thin: it leans entirely on agent_manager's +existing launch + send_message path so a scheduled run looks identical to +a manual chat. That keeps the MCP gate, action filtering, provider +routing, retries, and history all aligned with the rest of the app. +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional + +from backend.apps.agents.core.models import AgentConfig +from backend.apps.workflows.models import Workflow, WorkflowRun +from backend.apps.workflows import storage + +logger = logging.getLogger(__name__) + + +# In-process map: workflow_id -> currently running run id. Prevents two +# overlapping fires for the same workflow (e.g. cron tick races a manual +# Run button) without serializing across the whole executor. +_running: dict[str, str] = {} +_running_lock = asyncio.Lock() + + +def _resolve_system_prompt(wf: Workflow) -> Optional[str]: + if wf.use_synced_prompt: + return None + return wf.system_prompt or None + + +def _resolve_allowed_tools(wf: Workflow) -> list[str]: + if not wf.actions.freeze: + return [] + return list(wf.actions.configured_sets) + + +def _persist_run_fields(wf: Workflow, run_fields: dict, schedule_runs_count_delta: int = 0) -> None: + """Merge run-side fields into the current on-disk workflow. + + The executor holds the `wf` it was launched with; meanwhile the user + may have PATCHed unrelated fields (title, schedule, permissions...). + Saving our captured `wf` would clobber those edits. Re-read the + authoritative record from storage and only mutate the run-side fields + we own. If the workflow has been deleted while we ran, silently skip + the save so we don't resurrect a deleted record. + + schedule_runs_count_delta is a small int (0 or 1) that we add to the + on-disk schedule.runs_count to avoid the same race overwriting an + in-flight bump on the user's PATCH path. + """ + fresh = storage.get_workflow(wf.id) + if fresh is None: + # Deleted while we ran. Don't resurrect. + return + for k, v in run_fields.items(): + setattr(fresh, k, v) + if schedule_runs_count_delta: + fresh.schedule.runs_count = fresh.schedule.runs_count + schedule_runs_count_delta + storage.save_workflow(fresh) + + +def _monthly_spend_so_far(wf: Workflow) -> float: + """Sum cost_usd across runs of `wf` started in the last 30 days. + + Reads the bounded run log (200 rows max per workflow), so this is + O(history) and runs once per fire. Naive datetimes (legacy rows) are + treated as host-local then normalized to UTC by Python's astimezone. + """ + cutoff = datetime.now(timezone.utc) - timedelta(days=30) + total = 0.0 + for r in storage.list_runs(wf.id, limit=200): + started = r.started_at + if started is None: + continue + if started.tzinfo is None: + started = started.astimezone(timezone.utc) + else: + started = started.astimezone(timezone.utc) + if started >= cutoff: + total += float(r.cost_usd or 0.0) + return total + + +async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun: + from backend.apps.agents.agent_manager import agent_manager + + run = WorkflowRun( + workflow_id=wf.id, + status="running", + scheduled_for=scheduled_for, + started_at=datetime.now(), + triggered_by=triggered_by, + ) + + # Cost cap pre-check happens before claiming `_running` so a capped + # workflow doesn't block its own next fire. We still record the run so + # the user sees it in History with a clear reason. + if wf.cost_cap_usd_monthly is not None: + spent = _monthly_spend_so_far(wf) + if spent >= wf.cost_cap_usd_monthly: + run.status = "skipped" + run.error = f"Monthly cost cap reached (${spent:.2f} / ${wf.cost_cap_usd_monthly:.2f})" + run.finished_at = datetime.now() + storage.record_run(run) + _persist_run_fields(wf, { + "last_run_at": run.finished_at, + "last_run_status": "skipped", + "last_run_id": run.id, + }) + return run + + storage.record_run(run) + + async with _running_lock: + if wf.id in _running: + run.status = "skipped" + run.error = "Previous run still active" + run.finished_at = datetime.now() + storage.record_run(run) + return run + _running[wf.id] = run.id + + wf.last_run_at = run.started_at + wf.last_run_status = "running" + wf.last_run_id = run.id + _persist_run_fields(wf, { + "last_run_at": run.started_at, + "last_run_status": "running", + "last_run_id": run.id, + }) + + session = None + try: + steps = [s.text for s in wf.steps if s.text and s.text.strip()] + if not steps: + raise ValueError("Workflow has no steps") + + config = AgentConfig( + name=wf.title or "Workflow", + model=wf.model or "sonnet", + mode=wf.mode or "agent", + provider=wf.provider or "anthropic", + system_prompt=_resolve_system_prompt(wf), + allowed_tools=_resolve_allowed_tools(wf) or [ + "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", + ], + dashboard_id=wf.dashboard_id, + ) + + session = await agent_manager.launch_agent(config) + run.session_id = session.id + storage.record_run(run) + + # Background poller: surface the latest tool-call name as a + # live "what's the agent doing" subtitle on the workflow:run + # ws event. Cheap enough to run at 1.5s cadence; nothing else + # is watching session.messages from here. Cancelled in the + # finally block alongside _running cleanup. + async def _watch_tool_calls() -> None: + last_seen = "" + while True: + try: + await asyncio.sleep(1.5) + sess = agent_manager.sessions.get(session.id) + if not sess: + return + msgs = getattr(sess, "messages", []) or [] + label = "" + for m in reversed(msgs): + if getattr(m, "role", None) != "tool_call": + continue + content = getattr(m, "content", None) + # Content can be a string, a dict with "name", or + # a list of blocks. Pick the first tool_use name. + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "tool_use": + label = str(b.get("name") or "") + break + elif isinstance(content, dict): + label = str(content.get("name") or "") + elif isinstance(content, str): + label = content[:60] + if label: + break + if label and label != last_seen: + last_seen = label + run.last_tool_label = label + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:run", { + "workflow_id": wf.id, + "run": run.model_dump(mode="json"), + }) + except Exception: + pass + except asyncio.CancelledError: + return + except Exception: + return + + watcher_task = asyncio.create_task(_watch_tool_calls()) + + # Send each step sequentially. agent_manager.send_message is a no-op + # while a prior turn is still streaming, so we await until the + # session is idle before posting the next step. Keeps the runner + # safe regardless of how long each turn takes. + step_error: Optional[str] = None + for idx, step in enumerate(steps): + # Broadcast the step bump before sending so RunningView flips + # the disc immediately, not after the agent finishes the step. + run.active_step_idx = idx + run.last_tool_label = None + try: + from backend.apps.agents.core.ws_manager import ws_manager as _wsm + await _wsm.broadcast_global("workflow:run", { + "workflow_id": wf.id, + "run": run.model_dump(mode="json"), + }) + except Exception: + pass + await agent_manager.send_message(session.id, step) + await _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": + step_error = "Agent session entered error state" + # Pin active step so FailedView can render the X on the + # right row. error_step_idx == active_step_idx at fail time. + break + + run.finished_at = datetime.now() + sess_state = agent_manager.sessions.get(session.id) + if sess_state is not None: + run.cost_usd = float(getattr(sess_state, "cost_usd", 0.0) or 0.0) + + if step_error is not None: + run.status = "failure" + run.error = step_error + wf.last_run_status = "failure" + elif scheduled_for is not None and (run.finished_at.replace(tzinfo=None) - scheduled_for.replace(tzinfo=None)).total_seconds() > 300: + # Started more than 5 minutes after its slot (app was closed, + # event loop backed up, etc.). Surface in History as ran_late + # so the user can tell apart "fired on time" from "caught up". + # Strip tz before the subtraction so a UTC-aware scheduled_for + # (new code path) and a naive finished_at don't raise. + run.status = "ran_late" + wf.last_run_status = "ran_late" + else: + run.status = "success" + wf.last_run_status = "success" + # Bump runs_count for scheduled fires that reached a terminal state + # other than "skipped". Manual runs don't count against max_runs. + runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0 + storage.record_run(run) + wf.last_run_at = run.finished_at + _persist_run_fields(wf, { + "last_run_at": run.finished_at, + "last_run_status": wf.last_run_status, + }, schedule_runs_count_delta=runs_delta) + except Exception as e: + logger.exception("Workflow run failed: %s", e) + run.status = "failure" + run.error = str(e)[:500] + run.finished_at = datetime.now() + storage.record_run(run) + wf.last_run_status = "failure" + _persist_run_fields(wf, { + "last_run_status": "failure", + "last_run_at": run.finished_at, + }) + finally: + # Cancel the tool-call watcher before we tear the session down so + # the next poll doesn't race close_session. + try: + watcher_task.cancel() # type: ignore[name-defined] + except Exception: + pass + # Close the workflow's agent session so closed_at is set and the + # run shows up in chat history (get_history sorts by closed_at; + # sessions with closed_at=None sort to the bottom and fall off + # the first page). close_session also drops in-memory state and + # persists the final snapshot to disk. + if session is not None: + try: + await agent_manager.close_session(session.id) + except Exception: + logger.exception("close_session failed for workflow run %s", run.id) + async with _running_lock: + _running.pop(wf.id, None) + + try: + from backend.apps.workflows.notifier import notify_run_complete + await notify_run_complete(wf, run) + except Exception: + logger.debug("notifier failed", exc_info=True) + + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:run", { + "workflow_id": wf.id, + "run": run.model_dump(mode="json"), + }) + except Exception: + pass + + return run + + +async def _await_session_idle(session_id: str, timeout_s: float = 600.0) -> None: + """Block until the agent session reaches a non-running terminal state. + + Polls cheaply (50ms) since the agent_manager doesn't expose a per-session + completion future. Bounded by timeout_s so a stuck step doesn't hang the + runner forever. + """ + from backend.apps.agents.agent_manager import agent_manager + + deadline = asyncio.get_event_loop().time() + timeout_s + while True: + sess = agent_manager.sessions.get(session_id) + if not sess: + return + task = agent_manager.tasks.get(session_id) + if task is not None and task.done(): + return + status = getattr(sess, "status", None) + if status in ("completed", "error", "stopped"): + return + if asyncio.get_event_loop().time() > deadline: + raise TimeoutError(f"Step exceeded {timeout_s}s on session {session_id}") + await asyncio.sleep(0.05) diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py new file mode 100644 index 00000000..9819b17f --- /dev/null +++ b/backend/apps/workflows/models.py @@ -0,0 +1,164 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Optional, Literal, Any +from datetime import datetime +from uuid import uuid4 + + +# Each "tier" in the permission chain: notify in app, fall through to text +# after N minutes if no response, then to call after a further N minutes/hours. +# Matches images 17 to 19 (Schedule edit). Order in the list = escalation order. +class PermissionTier(BaseModel): + kind: Literal["notify", "text", "call"] = "notify" + after_minutes: int = 0 + phone: Optional[str] = None + + +class ScheduleConfig(BaseModel): + enabled: bool = False + # Bounds keep the scheduler from blowing up on malformed input. The + # FE clamps these too, but defense-in-depth: a misbehaving agent + # tool, an old JSON file, or a curl-wielding power user shouldn't + # be able to crash _next_fire_after by passing hour=99. + repeat_every: int = Field(default=1, ge=1, le=365) + repeat_unit: Literal["day", "week", "month"] = "week" + on_days: list[int] = Field(default_factory=list) + hour: int = Field(default=9, ge=0, le=23) + minute: int = Field(default=0, ge=0, le=59) + # IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy + # records that predate explicit tz. storage._load_all_from_disk coerces + # "local" to the host zone in memory; we leave it on disk until the + # user's next save so backup/sync tools don't see spurious churn. + timezone: str = "local" + on_missed: Literal["skip", "run_once", "run_all"] = "skip" + # Optional end conditions. None = forever / unbounded. Schedule auto- + # disables once either is satisfied; scheduler._tick zeroes out + # next_run_at and flips enabled=False so the UI reflects reality. + ends_at: Optional[datetime] = None + max_runs: Optional[int] = Field(default=None, ge=1) + runs_count: int = Field(default=0, ge=0) + + @field_validator("on_days") + @classmethod + def _clean_on_days(cls, v: list[int]) -> list[int]: + # Backend uses JS-style weekday (Sun=0..Sat=6). Drop entries + # outside that range so a malformed PATCH can't trip the + # scheduler later, and dedupe while preserving order. + seen: set[int] = set() + out: list[int] = [] + for d in v or []: + if isinstance(d, int) and 0 <= d <= 6 and d not in seen: + seen.add(d) + out.append(d) + return out + + +class ActionsConfig(BaseModel): + prevent_unused: bool = False + freeze: bool = False + configured_sets: list[str] = Field(default_factory=list) + + +class WorkflowStep(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex) + text: str = "" + # 3 to 6 word LLM-generated headline shown in the collapsed step row. + # The full prompt lives in `text`; this is the "at-a-glance" label. + label: Optional[str] = None + + +def _empty_str_default() -> str: + return "" + + +class Workflow(BaseModel): + # validate_assignment is load-bearing for the PATCH /workflows/{id} path + # (workflows.py:update_workflow setattr's raw dicts from body.model_dump + # straight onto the cached Workflow). Without coercion the nested + # schedule/steps/actions/permissions fields become plain dicts in + # memory, and every downstream call; scheduler tick, executor.execute, + # subsequent PATCHes; crashes on `.enabled` / `.text`. + model_config = ConfigDict(validate_assignment=True) + + id: str = Field(default_factory=lambda: uuid4().hex) + title: str = "Untitled workflow" + description: str = "" + icon: str = "" + system_prompt: Optional[str] = None + use_synced_prompt: bool = True + steps: list[WorkflowStep] = Field(default_factory=list) + actions: ActionsConfig = Field(default_factory=ActionsConfig) + schedule: ScheduleConfig = Field(default_factory=ScheduleConfig) + permissions: list[PermissionTier] = Field( + default_factory=lambda: [PermissionTier(kind="notify")] + ) + source_session_id: Optional[str] = None + dashboard_id: Optional[str] = None + model: str = "sonnet" + mode: str = "agent" + provider: str = "anthropic" + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + last_run_at: Optional[datetime] = None + last_run_status: Optional[Literal["success", "failure", "ran_late", "running", "skipped"]] = None + last_run_id: Optional[str] = None + next_run_at: Optional[datetime] = None + cost_cap_usd_monthly: Optional[float] = None + # Sticky session id for the Edit Agent embedded in the workflow card + # (Image #38, #48). Optional so older workflows don't fail validation + # on rehydrate. + edit_agent_session_id: Optional[str] = None + + +class WorkflowRun(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex) + workflow_id: str + status: Literal["running", "success", "failure", "ran_late", "skipped"] = "running" + scheduled_for: Optional[datetime] = None + started_at: datetime = Field(default_factory=datetime.now) + finished_at: Optional[datetime] = None + session_id: Optional[str] = None + error: Optional[str] = None + cost_usd: float = 0.0 + triggered_by: Literal["schedule", "manual", "retry"] = "schedule" + # Last tool-call label observed on the underlying agent session while + # the workflow is running. Surfaced under the active step in RunningView + # (Image #40) so the user can tell the run is still making progress. + last_tool_label: Optional[str] = None + # Currently-executing step index (0-based). Executor bumps this each + # time it dispatches a step prompt and broadcasts the run. RunningView + # uses this for the disc statuses; estimate fallback only when null. + active_step_idx: Optional[int] = None + + +class WorkflowCreate(BaseModel): + title: str = "Untitled workflow" + description: str = "" + icon: str = "" + system_prompt: Optional[str] = None + use_synced_prompt: bool = True + steps: list[WorkflowStep] = Field(default_factory=list) + actions: ActionsConfig = Field(default_factory=ActionsConfig) + schedule: ScheduleConfig = Field(default_factory=ScheduleConfig) + permissions: Optional[list[PermissionTier]] = None + source_session_id: Optional[str] = None + dashboard_id: Optional[str] = None + model: Optional[str] = None + mode: Optional[str] = None + provider: Optional[str] = None + cost_cap_usd_monthly: Optional[float] = None + + +class WorkflowUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + system_prompt: Optional[str] = None + use_synced_prompt: Optional[bool] = None + steps: Optional[list[WorkflowStep]] = None + actions: Optional[ActionsConfig] = None + schedule: Optional[ScheduleConfig] = None + permissions: Optional[list[PermissionTier]] = None + model: Optional[str] = None + mode: Optional[str] = None + provider: Optional[str] = None + cost_cap_usd_monthly: Optional[float] = None diff --git a/backend/apps/workflows/notifier.py b/backend/apps/workflows/notifier.py new file mode 100644 index 00000000..20034872 --- /dev/null +++ b/backend/apps/workflows/notifier.py @@ -0,0 +1,55 @@ +"""Permission/escalation chain notifier. + +The notify tier broadcasts a ws event the renderer picks up. The text/call +tiers route through the cloud SMS bridge once enabled; until it's enabled +we fall back to an extra ws notify with a `fallback: true` marker so the +renderer can label it honestly ("Text-me fallback: cloud SMS not wired"). +The *when* of escalation is owned by apps/workflows/escalation.py. +""" + +import logging +from datetime import datetime + +from backend.apps.workflows.models import PermissionTier, Workflow, WorkflowRun + +logger = logging.getLogger(__name__) + + +def _base_payload(wf: Workflow, run: WorkflowRun) -> dict: + return { + "workflow_id": wf.id, + "workflow_title": wf.title, + "run_id": run.id, + "status": run.status, + "session_id": run.session_id, + "started_at": run.started_at.isoformat() if isinstance(run.started_at, datetime) else run.started_at, + "finished_at": run.finished_at.isoformat() if isinstance(run.finished_at, datetime) else run.finished_at, + } + + +async def notify_run_complete(wf: Workflow, run: WorkflowRun) -> None: + from backend.apps.agents.core.ws_manager import ws_manager + from backend.apps.workflows import escalation + + payload = _base_payload(wf, run) + await ws_manager.broadcast_global("workflow:notify", payload) + + # Kick off server-side escalation only if there are additional tiers + # beyond the default notify. The escalation runner will sleep + call + # send_tier per tier. + escalation.schedule(wf, run) + + +async def send_tier(wf: Workflow, run: WorkflowRun, tier: PermissionTier) -> None: + """Send a single escalation tier. Today the text/call paths fall back + to an in-app notify with `fallback: true` and the tier kind set so the + renderer can show "Text-me fallback (cloud SMS not wired)." + """ + from backend.apps.agents.core.ws_manager import ws_manager + + payload = _base_payload(wf, run) + payload["tier_kind"] = tier.kind + payload["tier_phone"] = (tier.phone or "")[-4:] if tier.phone else None + payload["fallback"] = True # flip to False once the cloud SMS bridge is wired + await ws_manager.broadcast_global("workflow:notify", payload) + logger.info("workflow tier=%s fallback fired wf=%s run=%s", tier.kind, wf.id, run.id) diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py new file mode 100644 index 00000000..d05a5be3 --- /dev/null +++ b/backend/apps/workflows/scheduler.py @@ -0,0 +1,352 @@ +"""In-process cron-style scheduler. + +One long-lived asyncio task wakes on the next-due workflow boundary, fires +matching workflows, then re-computes. We deliberately avoid one-task-per- +workflow (turns rescheduling into a thundering re-spawn problem). On +startup we walk persisted workflows once, decide what to do about missed +fires via on_missed, and queue each. + +Schedule semantics: + unit=day: fires every repeat_every days at hour:minute + unit=week: fires on the listed weekday(s) every repeat_every weeks + unit=month: fires on the original day-of-month every repeat_every months + +Wall-clock math runs in the workflow's IANA timezone, then we convert to +UTC at the boundary. This is the only safe way to honor DST (a "9am +Monday" schedule must remain 9am local across spring-forward / fall-back). +Legacy records with timezone="local" are coerced to the host zone in +memory by storage._load_all_from_disk; the on-disk file is not rewritten +until the user's next save. +""" + +import asyncio +import calendar +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from backend.apps.workflows.models import Workflow, ScheduleConfig +from backend.apps.workflows import storage, executor + +logger = logging.getLogger(__name__) + + +_loop_task: Optional[asyncio.Task] = None +_wake = asyncio.Event() +_host_tz_cache: Optional[ZoneInfo] = None + + +def _host_tz() -> ZoneInfo: + global _host_tz_cache + if _host_tz_cache is not None: + return _host_tz_cache + name = os.environ.get("OPENSWARM_TIMEZONE", "").strip() + if not name: + try: + from tzlocal import get_localzone_name # type: ignore + name = get_localzone_name() or "" + except Exception: + name = "" + try: + _host_tz_cache = ZoneInfo(name) if name else ZoneInfo("UTC") + except ZoneInfoNotFoundError: + _host_tz_cache = ZoneInfo("UTC") + return _host_tz_cache + + +def _resolve_tz(tz: str) -> ZoneInfo: + if not tz or tz == "local": + return _host_tz() + try: + return ZoneInfo(tz) + except ZoneInfoNotFoundError: + return _host_tz() + + +def _as_utc(dt: Optional[datetime]) -> Optional[datetime]: + """Normalize an arbitrary stored datetime to aware-UTC. + + Pydantic deserializes naive ISO strings as naive datetimes. Treat such + values as host-local (matches the pre-tz codepath that wrote them) so + comparisons against datetime.now(timezone.utc) don't raise. + """ + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=_host_tz()).astimezone(timezone.utc) + return dt.astimezone(timezone.utc) + + +def _add_months(dt: datetime, months: int) -> datetime: + """Add months preserving day-of-month, clamping only if the target month + is shorter (e.g. Jan 31 + 1mo → Feb 28/29). Wall-clock arithmetic; the + caller is responsible for tz attachment. + """ + total = dt.month - 1 + months + year = dt.year + total // 12 + month = total % 12 + 1 + day = min(dt.day, calendar.monthrange(year, month)[1]) + return dt.replace(year=year, month=month, day=day) + + +def _js_weekday(d: datetime) -> int: + """Frontend uses JS getDay() convention (Sun=0..Sat=6). Python's + datetime.weekday() is Mon=0..Sun=6. Wire format stays JS-style so the + on_days array round-trips between FE and BE without translation in two + places.""" + return (d.weekday() + 1) % 7 + + +def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datetime]: + if not sched.enabled: + return None + tz = _resolve_tz(sched.timezone) + ref_local = ref_utc.astimezone(tz) + base = ref_local.replace(second=0, microsecond=0) + candidate = base.replace(hour=sched.hour, minute=sched.minute) + if candidate <= ref_local: + candidate = candidate + timedelta(days=1) + + if sched.repeat_unit == "day": + step = max(1, sched.repeat_every) + # Walk forward in step-day increments until we find a slot strictly + # after `ref_local`. Cheap because step is small. + while candidate <= ref_local: + candidate = candidate + timedelta(days=step) + return candidate.astimezone(timezone.utc) + + if sched.repeat_unit == "week": + allowed = sched.on_days or [_js_weekday(ref_local)] + for _ in range(0, 14): + if _js_weekday(candidate) in allowed and candidate > ref_local: + return candidate.astimezone(timezone.utc) + candidate = candidate + timedelta(days=1) + return candidate.astimezone(timezone.utc) + + if sched.repeat_unit == "month": + target_day = ref_local.day + step = max(1, sched.repeat_every) + c = candidate.replace(day=min(target_day, calendar.monthrange(candidate.year, candidate.month)[1])) + while c <= ref_local: + c = _add_months(c, step) + return c.astimezone(timezone.utc) + + return None + + +def compute_next_fire(wf: Workflow, ref: Optional[datetime] = None) -> Optional[datetime]: + ref_utc = _as_utc(ref) if ref is not None else datetime.now(timezone.utc) + return _next_fire_after(wf.schedule, ref_utc) + + +def fires_in_window(wf: Workflow, days: int = 30) -> int: + """Count fires from now through `days` days from now. Used by the + cost-estimate response. Honors end conditions so the projection doesn't + over-count after ends_at or max_runs. Caps the walk at 1000 fires to + guard pathological sub-day schedules (none today, but cheap insurance). + """ + sched = wf.schedule + if not sched.enabled: + return 0 + if sched.max_runs is not None and sched.runs_count >= sched.max_runs: + return 0 + cursor_utc = datetime.now(timezone.utc) + end_utc = cursor_utc + timedelta(days=days) + ends_at_utc = _as_utc(sched.ends_at) + if ends_at_utc is not None and ends_at_utc < end_utc: + end_utc = ends_at_utc + remaining_budget = ( + sched.max_runs - sched.runs_count if sched.max_runs is not None else 1000 + ) + count = 0 + while count < min(1000, remaining_budget): + nxt = _next_fire_after(sched, cursor_utc) + if nxt is None or nxt > end_utc: + break + count += 1 + cursor_utc = nxt + return count + + +def kick() -> None: + _wake.set() + + +def _end_condition_hit(wf: Workflow, now_utc: datetime) -> bool: + s = wf.schedule + ends_at = _as_utc(s.ends_at) + if ends_at is not None and now_utc >= ends_at: + return True + if s.max_runs is not None and s.runs_count >= s.max_runs: + return True + return False + + +def _disable_schedule(wf: Workflow) -> None: + wf.schedule.enabled = False + wf.next_run_at = None + storage.save_workflow(wf) + + +async def _tick() -> None: + now_utc = datetime.now(timezone.utc) + if storage.get_paused(): + return + due: list[Workflow] = [] + for wf in storage.list_workflows(): + if not wf.schedule.enabled: + continue + if _end_condition_hit(wf, now_utc): + _disable_schedule(wf) + continue + nra = _as_utc(wf.next_run_at) + if nra and nra <= now_utc: + due.append(wf) + + for wf in due: + scheduled_for = _as_utc(wf.next_run_at) + nxt = _next_fire_after(wf.schedule, now_utc) + wf.next_run_at = nxt + storage.save_workflow(wf) + asyncio.create_task(_fire(wf, scheduled_for=scheduled_for)) + + +async def _fire(wf: Workflow, scheduled_for: Optional[datetime]) -> None: + try: + await executor.execute(wf, triggered_by="schedule", scheduled_for=scheduled_for) + except Exception: + logger.exception("scheduler fire failed for workflow=%s", wf.id) + + +def _seconds_until_next() -> float: + now_utc = datetime.now(timezone.utc) + soonest: Optional[datetime] = None + for wf in storage.list_workflows(): + if not wf.schedule.enabled: + continue + nra = _as_utc(wf.next_run_at) + if nra is None: + continue + if soonest is None or nra < soonest: + soonest = nra + if soonest is None: + return 60.0 + delta = (soonest - now_utc).total_seconds() + return max(1.0, min(delta, 60.0)) + + +async def _loop() -> None: + logger.info("workflow scheduler loop started") + while True: + try: + await _tick() + except Exception: + logger.exception("scheduler tick error") + try: + await asyncio.wait_for(_wake.wait(), timeout=_seconds_until_next()) + except asyncio.TimeoutError: + pass + _wake.clear() + + +def _mark_stuck_runs_failed() -> None: + """Any run marked 'running' that survives a backend restart is dead. + + The owning event loop is gone, so there's no way to resume. Mark it + failed once at startup instead of letting the History tab show a + forever-spinning row that misleads the user. + """ + now = datetime.now() + for wf in storage.list_workflows(): + for r in storage.list_runs(wf.id, limit=200): + if r.status == "running": + storage.update_run( + r.id, + status="failure", + error="OpenSwarm closed before this run finished.", + finished_at=now, + ) + + +def reconcile_on_startup() -> None: + """Walk persisted workflows once and resolve missed fires per policy. + + Missed-run policies: + skip -> roll forward to next future fire, ignore missed + run_once -> if any fires were missed, schedule a single catch-up at now + run_all -> not actually run_all in v1 (would burn tokens); same as run_once + but we mark the run.status as ran_late so the UI surfaces it + """ + now_utc = datetime.now(timezone.utc) + for wf in storage.list_workflows(): + if not wf.schedule.enabled: + wf.next_run_at = None + storage.save_workflow(wf) + continue + + if _end_condition_hit(wf, now_utc): + _disable_schedule(wf) + continue + + nra = _as_utc(wf.next_run_at) + missed = bool(nra and nra <= now_utc) + if missed and wf.schedule.on_missed in ("run_once", "run_all"): + # Keep next_run_at <= now_utc so the very next tick fires it. + # Normalize to a UTC-aware value so future comparisons don't + # trip on naive legacy datetimes. + wf.next_run_at = nra + storage.save_workflow(wf) + else: + wf.next_run_at = _next_fire_after(wf.schedule, now_utc) + storage.save_workflow(wf) + + +async def start() -> None: + global _loop_task + if _loop_task is not None: + return + _mark_stuck_runs_failed() + reconcile_on_startup() + _loop_task = asyncio.create_task(_loop()) + + +async def stop() -> None: + global _loop_task + if _loop_task is None: + return + _loop_task.cancel() + try: + await _loop_task + except (asyncio.CancelledError, Exception): + pass + _loop_task = None + + +def list_active() -> list[dict]: + """Snapshot of currently-running workflow runs. + + Reads executor._running (workflow_id -> run_id) and joins against the + workflow cache for titles. Used by GET /workflows/active so the tray + and the auto-updater veto can both ask "are any runs in flight?" + without holding the executor lock. + """ + out: list[dict] = [] + snapshot = dict(executor._running) + for wid, run_id in snapshot.items(): + wf = storage.get_workflow(wid) + title = wf.title if wf else "" + started_at = None + if wf: + for r in storage.list_runs(wid, limit=10): + if r.id == run_id: + started_at = r.started_at.isoformat() if isinstance(r.started_at, datetime) else r.started_at + break + out.append({ + "workflow_id": wid, + "run_id": run_id, + "title": title, + "started_at": started_at, + }) + return out diff --git a/backend/apps/workflows/storage.py b/backend/apps/workflows/storage.py new file mode 100644 index 00000000..86d80352 --- /dev/null +++ b/backend/apps/workflows/storage.py @@ -0,0 +1,197 @@ +"""On-disk store for workflows + workflow runs. + +Layout under DATA_ROOT/workflows/: + .json workflow record + runs/.json bounded log (latest N) of runs for that workflow + +A separate runs file per workflow keeps history reads O(history size) instead +of O(total runs across all workflows). The workflow record only carries +last_run_* / next_run_at summary fields; full history lives in the runs file. +""" + +import json +import os +from threading import Lock +from typing import Optional + +from backend.config.paths import DATA_ROOT +from backend.apps.workflows.models import Workflow, WorkflowRun + +DATA_DIR = os.path.join(DATA_ROOT, "workflows") +RUNS_DIR = os.path.join(DATA_DIR, "runs") +PAUSED_FILE = os.path.join(DATA_DIR, "paused.json") + +_io_lock = Lock() +_workflow_cache: dict[str, Workflow] = {} +_runs_cache: dict[str, list[WorkflowRun]] = {} +_cache_loaded = False +_paused = False + + +def _resolve_host_tz_name() -> str: + """Best-effort IANA name for the host. Mirrors apps/service/client.py.""" + name = os.environ.get("OPENSWARM_TIMEZONE", "").strip() + if not name: + try: + from tzlocal import get_localzone_name # type: ignore + name = get_localzone_name() or "" + except Exception: + name = "" + return name or "UTC" + +# Keep this much run history per workflow on disk. Older runs are pruned; +# the History tab caps at ~20 anyway, and unbounded growth turned the JSON +# read into a real cost on hot-reload of the schedule page. +RUNS_PER_WORKFLOW = 200 + + +def _ensure_dirs() -> None: + os.makedirs(DATA_DIR, exist_ok=True) + os.makedirs(RUNS_DIR, exist_ok=True) + + +def _wf_path(wid: str) -> str: + return os.path.join(DATA_DIR, f"{wid}.json") + + +def _runs_path(wid: str) -> str: + return os.path.join(RUNS_DIR, f"{wid}.json") + + +def _load_all_from_disk() -> None: + global _cache_loaded, _paused + _ensure_dirs() + _workflow_cache.clear() + _runs_cache.clear() + host_tz = _resolve_host_tz_name() + for fname in os.listdir(DATA_DIR): + if not fname.endswith(".json") or fname == "paused.json": + continue + try: + with open(os.path.join(DATA_DIR, fname)) as f: + wf = Workflow(**json.load(f)) + # Coerce legacy timezone="local" to the host IANA zone in + # memory only. We don't rewrite the file here so backup/sync + # tooling doesn't see mtime churn on every startup; the next + # user-driven save migrates the on-disk record naturally. + if wf.schedule.timezone == "local": + wf.schedule.timezone = host_tz + _workflow_cache[wf.id] = wf + except Exception: + continue + if os.path.exists(RUNS_DIR): + for fname in os.listdir(RUNS_DIR): + if not fname.endswith(".json"): + continue + wid = fname[:-5] + try: + with open(os.path.join(RUNS_DIR, fname)) as f: + arr = json.load(f) + _runs_cache[wid] = [WorkflowRun(**r) for r in arr] + except Exception: + _runs_cache[wid] = [] + # Load the global pause flag if it's been set previously. + if os.path.exists(PAUSED_FILE): + try: + with open(PAUSED_FILE) as f: + _paused = bool(json.load(f).get("paused", False)) + except Exception: + _paused = False + _cache_loaded = True + + +def init() -> None: + with _io_lock: + _load_all_from_disk() + + +def list_workflows() -> list[Workflow]: + if not _cache_loaded: + init() + return list(_workflow_cache.values()) + + +def get_workflow(wid: str) -> Optional[Workflow]: + if not _cache_loaded: + init() + return _workflow_cache.get(wid) + + +def save_workflow(wf: Workflow) -> Workflow: + with _io_lock: + _ensure_dirs() + _workflow_cache[wf.id] = wf + with open(_wf_path(wf.id), "w") as f: + json.dump(wf.model_dump(mode="json"), f, indent=2) + return wf + + +def delete_workflow(wid: str) -> bool: + with _io_lock: + existed = wid in _workflow_cache + _workflow_cache.pop(wid, None) + _runs_cache.pop(wid, None) + wf_file = _wf_path(wid) + if os.path.exists(wf_file): + os.remove(wf_file) + rf = _runs_path(wid) + if os.path.exists(rf): + os.remove(rf) + return existed + + +def list_runs(wid: str, limit: int = 50) -> list[WorkflowRun]: + if not _cache_loaded: + init() + runs = _runs_cache.get(wid, []) + return runs[-limit:][::-1] + + +def record_run(run: WorkflowRun) -> WorkflowRun: + with _io_lock: + _ensure_dirs() + arr = _runs_cache.setdefault(run.workflow_id, []) + # Replace prior entry with same id if we're updating an in-flight run. + for i, prior in enumerate(arr): + if prior.id == run.id: + arr[i] = run + break + else: + arr.append(run) + # Bound the per-workflow history to keep disk + memory cheap. + if len(arr) > RUNS_PER_WORKFLOW: + del arr[: len(arr) - RUNS_PER_WORKFLOW] + with open(_runs_path(run.workflow_id), "w") as f: + json.dump([r.model_dump(mode="json") for r in arr], f, indent=2) + return run + + +def get_paused() -> bool: + if not _cache_loaded: + init() + return _paused + + +def set_paused(value: bool) -> bool: + global _paused + with _io_lock: + _ensure_dirs() + _paused = bool(value) + with open(PAUSED_FILE, "w") as f: + json.dump({"paused": _paused}, f) + return _paused + + +def update_run(run_id: str, **fields) -> Optional[WorkflowRun]: + if not _cache_loaded: + init() + for arr in _runs_cache.values(): + for i, r in enumerate(arr): + if r.id == run_id: + updated = r.model_copy(update=fields) + arr[i] = updated + with _io_lock: + with open(_runs_path(updated.workflow_id), "w") as f: + json.dump([x.model_dump(mode="json") for x in arr], f, indent=2) + return updated + return None diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py new file mode 100644 index 00000000..cb1b4634 --- /dev/null +++ b/backend/apps/workflows/workflows.py @@ -0,0 +1,807 @@ +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]} diff --git a/backend/main.py b/backend/main.py index f4c76cf4..7e17a007 100644 --- a/backend/main.py +++ b/backend/main.py @@ -32,11 +32,12 @@ from backend.apps.subscription.router import subscription from backend.apps.auth.router import auth from backend.apps.web.web import web from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy +from backend.apps.workflows.workflows import workflows from fastapi.middleware.cors import CORSMiddleware from fastapi import WebSocket, WebSocketDisconnect import json -main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy]) +main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy, workflows]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the diff --git a/backend/tests/test_schedule_e2e.py b/backend/tests/test_schedule_e2e.py new file mode 100644 index 00000000..3723041a --- /dev/null +++ b/backend/tests/test_schedule_e2e.py @@ -0,0 +1,277 @@ +"""End-to-end smoke: does a scheduled workflow actually fire when its +time hits, with the full scheduler loop running? + +Runs the real scheduler.start() loop with the executor mocked so we +don't need a live agent_manager. Then arms a workflow whose +next_run_at is one second in the future, waits, and asserts the +mocked executor was called. + +Run: + cd backend && .venv/bin/python -m pytest tests/test_schedule_e2e.py -v +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock +from zoneinfo import ZoneInfo + +import pytest + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(autouse=True) +def isolated_data_dir(monkeypatch, tmp_path): + from backend.apps.workflows import storage as _storage + from backend.apps.workflows import escalation as _escalation + from backend.apps.workflows import audit as _audit + from backend.apps.workflows import scheduler as _scheduler + monkeypatch.setattr(_storage, "DATA_DIR", str(tmp_path / "workflows")) + monkeypatch.setattr(_storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs")) + monkeypatch.setattr(_storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json")) + monkeypatch.setattr(_storage, "_workflow_cache", {}) + monkeypatch.setattr(_storage, "_runs_cache", {}) + monkeypatch.setattr(_storage, "_cache_loaded", False) + monkeypatch.setattr(_storage, "_paused", False) + monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit")) + # Module-level scheduler state survives across tests; reset it so + # each test gets a fresh _wake Event bound to its own event loop. + _scheduler._loop_task = None + _scheduler._wake = asyncio.Event() + _escalation._tasks.clear() + _escalation._state.clear() + yield + + +def _make_wf(**overrides): + from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep + base = dict( + title="smoke", + steps=[WorkflowStep(text="hi")], + schedule=ScheduleConfig( + enabled=True, repeat_unit="day", repeat_every=1, + hour=9, minute=0, timezone="America/Los_Angeles", + ), + ) + base.update(overrides) + return Workflow(**base) + + +async def test_loop_fires_due_workflow(monkeypatch): + """Arm a workflow to fire ~now and assert the executor was actually + invoked by the scheduler loop within the test window. Note the save + happens AFTER scheduler.start() so reconcile_on_startup doesn't + clobber next_run_at.""" + from backend.apps.workflows import storage, scheduler, executor + + fired = asyncio.Event() + captured: dict = {} + + async def fake_execute(wf, triggered_by="schedule", scheduled_for=None): + captured["wf_id"] = wf.id + captured["triggered_by"] = triggered_by + captured["scheduled_for"] = scheduled_for + from backend.apps.workflows.models import WorkflowRun + run = WorkflowRun( + workflow_id=wf.id, + status="success", + scheduled_for=scheduled_for, + started_at=datetime.now(timezone.utc), + finished_at=datetime.now(timezone.utc), + triggered_by=triggered_by, + ) + storage.record_run(run) + fired.set() + return run + + monkeypatch.setattr(executor, "execute", fake_execute) + + await scheduler.start() + try: + wf = _make_wf() + wf.next_run_at = datetime.now(timezone.utc) + timedelta(seconds=1) + storage.save_workflow(wf) + scheduler.kick() # force immediate tick + # Wait up to 5s for the fire to land. + await asyncio.wait_for(fired.wait(), timeout=5.0) + finally: + await scheduler.stop() + + assert captured.get("wf_id") == wf.id + assert captured.get("triggered_by") == "schedule" + runs = storage.list_runs(wf.id, limit=10) + assert len(runs) == 1 + assert runs[0].status == "success" + # Scheduler should have rolled next_run_at forward to a future slot. + after = storage.get_workflow(wf.id) + assert after.next_run_at is not None + assert after.next_run_at > datetime.now(timezone.utc) + + +async def test_disabled_workflow_does_not_fire(monkeypatch): + """Master switch off => loop never invokes the executor even if + next_run_at is in the past.""" + from backend.apps.workflows import storage, scheduler, executor + + fake = AsyncMock() + monkeypatch.setattr(executor, "execute", fake) + + wf = _make_wf() + wf.schedule.enabled = False + wf.next_run_at = datetime.now(timezone.utc) - timedelta(seconds=10) + storage.save_workflow(wf) + + await scheduler.start() + try: + scheduler.kick() + await asyncio.sleep(2.0) + finally: + await scheduler.stop() + fake.assert_not_called() + + +async def test_paused_state_blocks_all_fires(monkeypatch): + """Global pause flag wins over per-workflow enabled state.""" + from backend.apps.workflows import storage, scheduler, executor + + fake = AsyncMock() + monkeypatch.setattr(executor, "execute", fake) + + wf = _make_wf() + wf.next_run_at = datetime.now(timezone.utc) - timedelta(seconds=1) + storage.save_workflow(wf) + storage.set_paused(True) + + await scheduler.start() + try: + scheduler.kick() + await asyncio.sleep(2.0) + finally: + await scheduler.stop() + fake.assert_not_called() + storage.set_paused(False) + + +async def test_reconcile_skip_rolls_past_missed(monkeypatch): + """on_missed='skip' + a missed next_run_at => startup rolls forward + to the next future fire without queuing a catch-up.""" + from backend.apps.workflows import storage, scheduler + wf = _make_wf() + wf.schedule.on_missed = "skip" + # Stash a missed fire 6 hours ago. + wf.next_run_at = datetime.now(timezone.utc) - timedelta(hours=6) + storage.save_workflow(wf) + scheduler.reconcile_on_startup() + after = storage.get_workflow(wf.id) + assert after.next_run_at is not None + assert after.next_run_at > datetime.now(timezone.utc) + + +async def test_reconcile_run_once_keeps_missed(monkeypatch): + """on_missed='run_once' => startup leaves next_run_at in the past so + the first tick fires a catch-up.""" + from backend.apps.workflows import storage, scheduler + wf = _make_wf() + wf.schedule.on_missed = "run_once" + missed = datetime.now(timezone.utc) - timedelta(hours=6) + wf.next_run_at = missed + storage.save_workflow(wf) + scheduler.reconcile_on_startup() + after = storage.get_workflow(wf.id) + assert after.next_run_at <= datetime.now(timezone.utc) + + +async def test_create_workflow_schedules_next_fire(): + """POST-like create path: enabled schedule => next_run_at populated + by compute_next_fire.""" + from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep + from backend.apps.workflows import scheduler + wf = Workflow( + title="t", + steps=[WorkflowStep(text="hi")], + schedule=ScheduleConfig( + enabled=True, repeat_unit="week", repeat_every=1, on_days=[0], + hour=9, minute=0, timezone="America/Los_Angeles", + ), + ) + nxt = scheduler.compute_next_fire(wf) + assert nxt is not None + assert nxt > datetime.now(timezone.utc) + tz = ZoneInfo("America/Los_Angeles") + local = nxt.astimezone(tz) + assert local.weekday() == 6 # Python: Sunday + assert (local.hour, local.minute) == (9, 0) + + +async def test_next_run_at_advances_after_fire(monkeypatch): + """After a fire the loop should re-compute next_run_at into the + future and persist it, so the same fire can't repeat in the same + minute.""" + from backend.apps.workflows import storage, scheduler, executor + + fired = asyncio.Event() + + async def fake_execute(wf, triggered_by="schedule", scheduled_for=None): + from backend.apps.workflows.models import WorkflowRun + run = WorkflowRun( + workflow_id=wf.id, status="success", scheduled_for=scheduled_for, + started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc), + triggered_by=triggered_by, + ) + storage.record_run(run) + fired.set() + return run + + monkeypatch.setattr(executor, "execute", fake_execute) + + await scheduler.start() + try: + wf = _make_wf() + armed_at = datetime.now(timezone.utc) + timedelta(seconds=1) + wf.next_run_at = armed_at + storage.save_workflow(wf) + scheduler.kick() + await asyncio.wait_for(fired.wait(), timeout=5.0) + # Give the loop one extra tick to persist next_run_at. + await asyncio.sleep(0.2) + finally: + await scheduler.stop() + + after = storage.get_workflow(wf.id) + assert after.next_run_at is not None + assert after.next_run_at > armed_at, "scheduler did not advance next_run_at past the slot it just fired" + + +async def test_kick_wakes_loop_before_timeout(monkeypatch): + """kick() should wake the loop early so manual schedule edits don't + have to wait a full minute for the next tick boundary.""" + from backend.apps.workflows import storage, scheduler, executor + + fired = asyncio.Event() + + async def fake_execute(wf, triggered_by="schedule", scheduled_for=None): + from backend.apps.workflows.models import WorkflowRun + run = WorkflowRun( + workflow_id=wf.id, status="success", + started_at=datetime.now(timezone.utc), + finished_at=datetime.now(timezone.utc), triggered_by=triggered_by, + ) + storage.record_run(run) + fired.set() + return run + + monkeypatch.setattr(executor, "execute", fake_execute) + + await scheduler.start() + try: + wf = _make_wf() + wf.next_run_at = datetime.now(timezone.utc) - timedelta(seconds=1) + storage.save_workflow(wf) + scheduler.kick() + # Without kick(), the loop would sleep up to 60s before checking + # the freshly-saved workflow. With kick, it should fire fast. + await asyncio.wait_for(fired.wait(), timeout=3.0) + finally: + await scheduler.stop() diff --git a/backend/tests/test_workflows_semantics.py b/backend/tests/test_workflows_semantics.py new file mode 100644 index 00000000..79a887e7 --- /dev/null +++ b/backend/tests/test_workflows_semantics.py @@ -0,0 +1,430 @@ +"""Backend semantics tests for the scheduled-tasks fix. + +Covers: + - DST-safe wall-clock math (spring forward + fall back) via zoneinfo + - End conditions (ends_at + max_runs) auto-disable the schedule + - Cost cap skips fires with a clear error + - Freeze-default on for new scheduled non-source-session creates + - Audit log captures field diffs + - /workflows/active surfaces in-process running runs + - Legacy timezone="local" coerced in memory at load + - Storage paused flag round-trips + - Month math no longer clamps to day 28 + - Server-side escalation kicks tasks (and ack cancels them) + +Run: + pip install -r backend/requirements.txt -r backend/requirements-dev.txt + cd backend && python -m pytest tests/test_workflows_semantics.py -v +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import tempfile +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_data_dir(monkeypatch, tmp_path): + """Point storage at a fresh tmpdir per test so we never touch a real + install's workflows data. Reloads in-process module state so each test + starts with empty caches.""" + from backend.apps.workflows import storage as _storage + from backend.apps.workflows import escalation as _escalation + monkeypatch.setattr(_storage, "DATA_DIR", str(tmp_path / "workflows")) + monkeypatch.setattr(_storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs")) + monkeypatch.setattr(_storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json")) + monkeypatch.setattr(_storage, "_workflow_cache", {}) + monkeypatch.setattr(_storage, "_runs_cache", {}) + monkeypatch.setattr(_storage, "_cache_loaded", False) + monkeypatch.setattr(_storage, "_paused", False) + # Reset escalation registry between tests. + _escalation._tasks.clear() + _escalation._state.clear() + # Also clear audit dir reference; audit.py reads DATA_DIR at import via + # module-level expression, so reach in and override the AUDIT_DIR too. + from backend.apps.workflows import audit as _audit + monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit")) + yield + + +def _make_wf(**overrides): + from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep + base = dict( + title="t", + steps=[WorkflowStep(text="hi")], + schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles"), + ) + base.update(overrides) + return Workflow(**base) + + +# --- DST tests --------------------------------------------------------------- + +def test_dst_spring_forward_weekly(): + """A 2:30am LA weekly Sunday schedule lands on 3:30am LA on the spring- + forward Sunday (2025-03-09) because the wall clock skips 02:30.""" + from backend.apps.workflows.scheduler import _next_fire_after + from backend.apps.workflows.models import ScheduleConfig + tz = ZoneInfo("America/Los_Angeles") + sched = ScheduleConfig(enabled=True, repeat_unit="week", repeat_every=1, on_days=[0], hour=2, minute=30, timezone="America/Los_Angeles") + # Saturday 2025-03-08 23:00 LA, asking "what's the next Sunday 2:30?" + ref_local = datetime(2025, 3, 8, 23, 0, tzinfo=tz) + nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc)) + assert nxt is not None + nxt_local = nxt.astimezone(tz) + # 02:30 wall-clock on the spring-forward day doesn't exist; zoneinfo + # resolves it forward to 03:30. The point is the *date* lands on the + # 9th, not the 8th and not the 16th. + assert nxt_local.date() == datetime(2025, 3, 9).date() + assert nxt_local.hour in (2, 3) + + +def test_dst_fall_back_no_double_fire(): + """A 9am LA daily schedule should fire exactly once on the fall-back day + (2025-11-02) and the next fire is the 3rd, not the 2nd again.""" + from backend.apps.workflows.scheduler import _next_fire_after + from backend.apps.workflows.models import ScheduleConfig + tz = ZoneInfo("America/Los_Angeles") + sched = ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles") + ref_local = datetime(2025, 11, 1, 23, 0, tzinfo=tz) + nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc)) + assert nxt.astimezone(tz).date() == datetime(2025, 11, 2).date() + # After firing on the 2nd, the next fire should be the 3rd, not a + # second 2nd from the duplicated hour. + after = _next_fire_after(sched, nxt) + assert after.astimezone(tz).date() == datetime(2025, 11, 3).date() + + +# --- End condition tests ----------------------------------------------------- + +def test_max_runs_disables_schedule(): + from backend.apps.workflows import storage, scheduler + wf = _make_wf() + wf.schedule.max_runs = 2 + wf.schedule.runs_count = 2 + wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1) + storage.save_workflow(wf) + asyncio.new_event_loop().run_until_complete(scheduler._tick()) + after = storage.get_workflow(wf.id) + assert after.schedule.enabled is False + assert after.next_run_at is None + + +def test_ends_at_disables_schedule(): + from backend.apps.workflows import storage, scheduler + wf = _make_wf() + wf.schedule.ends_at = datetime.now(timezone.utc) - timedelta(days=1) + wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1) + storage.save_workflow(wf) + asyncio.new_event_loop().run_until_complete(scheduler._tick()) + after = storage.get_workflow(wf.id) + assert after.schedule.enabled is False + + +# --- Month-day-31 (formerly clamped to 28) ----------------------------------- + +def test_month_repeat_no_longer_clamps_to_28(): + """An every-month schedule starting on March 31 should next fire on + April 30 (last day of April), then May 31, then June 30.""" + from backend.apps.workflows.scheduler import _next_fire_after + from backend.apps.workflows.models import ScheduleConfig + tz = ZoneInfo("America/Los_Angeles") + sched = ScheduleConfig(enabled=True, repeat_unit="month", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles") + ref_local = datetime(2025, 3, 31, 10, 0, tzinfo=tz) # past 9am on the 31st + nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc)) + assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date() + + +# --- Cost cap ---------------------------------------------------------------- + +def test_cost_cap_skips_with_clear_error(monkeypatch): + from backend.apps.workflows import storage, executor + from backend.apps.workflows.models import WorkflowRun + wf = _make_wf() + wf.cost_cap_usd_monthly = 1.0 + storage.save_workflow(wf) + storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=0.6, started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc))) + storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=0.6, started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc))) + + async def fake_launch(*a, **k): + raise AssertionError("agent_manager should not be reached when cost-capped") + + # Patch agent_manager.launch_agent so we'd fail loudly if the cap + # didn't short-circuit before launch. + from backend.apps.agents import agent_manager + monkeypatch.setattr(agent_manager.agent_manager, "launch_agent", fake_launch) + + run = asyncio.new_event_loop().run_until_complete(executor.execute(wf, triggered_by="schedule")) + assert run.status == "skipped" + assert "Monthly cost cap reached" in (run.error or "") + + +# --- Freeze-default for scheduled non-source-session creates ---------------- + +def test_freeze_defaults_on_for_scheduled_create(): + """POST /workflows/create with schedule.enabled=true and no source + session should flip actions.freeze=True to keep blast radius small.""" + from backend.apps.workflows.workflows import create_workflow + from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig + body = WorkflowCreate( + title="scheduled", + schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0), + actions=ActionsConfig(freeze=False, configured_sets=[]), + ) + result = asyncio.new_event_loop().run_until_complete(create_workflow(body)) + assert result["actions"]["freeze"] is True + + +def test_freeze_not_forced_when_source_session_present(): + """Source-session creates inherit the chat's choices; we don't override.""" + from backend.apps.workflows.workflows import create_workflow + from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig + body = WorkflowCreate( + title="from chat", + source_session_id="sess-1", + schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0), + actions=ActionsConfig(freeze=False, configured_sets=[]), + ) + result = asyncio.new_event_loop().run_until_complete(create_workflow(body)) + assert result["actions"]["freeze"] is False + + +# --- Audit log --------------------------------------------------------------- + +def test_audit_log_records_title_change(): + from backend.apps.workflows import audit + audit.log_change("wf-1", "user", {"title": "old"}, {"title": "new"}) + entries = audit.read_tail("wf-1", limit=10) + assert len(entries) == 1 + diff = entries[0]["diff"] + assert diff["title"]["before"] == "old" + assert diff["title"]["after"] == "new" + + +def test_audit_log_no_op_when_unchanged(): + from backend.apps.workflows import audit + audit.log_change("wf-2", "user", {"title": "same"}, {"title": "same"}) + assert audit.read_tail("wf-2") == [] + + +# --- /workflows/active ------------------------------------------------------- + +def test_list_active_reflects_running_map(): + from backend.apps.workflows import storage, executor, scheduler + wf = _make_wf(title="active-test") + storage.save_workflow(wf) + from backend.apps.workflows.models import WorkflowRun + run = WorkflowRun(workflow_id=wf.id, status="running") + storage.record_run(run) + executor._running[wf.id] = run.id + try: + active = scheduler.list_active() + assert len(active) == 1 + assert active[0]["workflow_id"] == wf.id + assert active[0]["title"] == "active-test" + finally: + executor._running.pop(wf.id, None) + + +# --- Legacy tz coercion ------------------------------------------------------ + +def test_legacy_timezone_coerced_on_load(monkeypatch): + from backend.apps.workflows import storage + storage._ensure_dirs() + wf_id = "legacy-wf" + legacy_blob = { + "id": wf_id, + "title": "legacy", + "schedule": { + "enabled": False, "repeat_every": 1, "repeat_unit": "week", + "on_days": [], "hour": 9, "minute": 0, "timezone": "local", + "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0, + }, + } + with open(os.path.join(storage.DATA_DIR, f"{wf_id}.json"), "w") as f: + json.dump(legacy_blob, f) + monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Los_Angeles") + monkeypatch.setattr(storage, "_cache_loaded", False) + loaded = storage.get_workflow(wf_id) + assert loaded is not None + # In-memory should be the host zone, not "local". + assert loaded.schedule.timezone == "America/Los_Angeles" + # On-disk file should be unchanged (still "local") so we don't churn + # mtime on every restart. + with open(os.path.join(storage.DATA_DIR, f"{wf_id}.json")) as f: + on_disk = json.load(f) + assert on_disk["schedule"]["timezone"] == "local" + + +# --- Paused flag ------------------------------------------------------------- + +def test_paused_flag_persists_and_blocks_tick(): + from backend.apps.workflows import storage, scheduler + wf = _make_wf() + wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1) + storage.save_workflow(wf) + storage.set_paused(True) + # Reload simulates a backend restart. + storage._cache_loaded = False + assert storage.get_paused() is True + # Tick must not advance next_run_at when paused. + before = storage.get_workflow(wf.id).next_run_at + asyncio.new_event_loop().run_until_complete(scheduler._tick()) + after = storage.get_workflow(wf.id).next_run_at + assert before == after + + +# --- Escalation -------------------------------------------------------------- + +def test_escalation_schedules_and_ack_cancels(): + from backend.apps.workflows import escalation + from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun, ScheduleConfig + + async def runner(): + wf = Workflow(title="t", permissions=[ + PermissionTier(kind="notify"), + PermissionTier(kind="text", after_minutes=60, phone="+15551234567"), + ]) + run = WorkflowRun(workflow_id=wf.id, status="success") + escalation.schedule(wf, run) + # State should be present immediately. + await asyncio.sleep(0.01) + assert escalation.status(run.id) is not None + # Ack cancels. + assert escalation.cancel(run.id) is True + await asyncio.sleep(0.01) + assert escalation.status(run.id) is None + + asyncio.new_event_loop().run_until_complete(runner()) + + +def test_executor_merge_does_not_clobber_concurrent_patch(): + """Executor's final save must NOT overwrite unrelated fields that + were PATCHed while the run was in flight. We simulate this by + capturing a wf, mutating storage's record directly (acting as the + PATCH that landed mid-run), then asking the executor's persist + helper to flush its run-side bookkeeping. The patched fields must + survive. + """ + from backend.apps.workflows import storage, executor + from datetime import datetime + wf = _make_wf(title="t-orig") + storage.save_workflow(wf) + # Simulate a user PATCH mid-run. + storage._workflow_cache[wf.id].title = "t-patched" + storage._workflow_cache[wf.id].description = "patched while running" + storage.save_workflow(storage._workflow_cache[wf.id]) + # Executor uses the stale `wf` it captured before the patch. With + # the merge helper, the patched fields must remain. + executor._persist_run_fields(wf, { + "last_run_at": datetime.now(), + "last_run_status": "success", + }) + after = storage.get_workflow(wf.id) + assert after.title == "t-patched", "title clobbered by executor" + assert after.description == "patched while running", "description clobbered" + assert after.last_run_status == "success" + + +def test_executor_delete_during_run_does_not_resurrect(): + """If the workflow was deleted mid-run, executor's persist must + silently no-op so the deleted record isn't re-written.""" + from backend.apps.workflows import storage, executor + from datetime import datetime + wf = _make_wf(title="doomed") + storage.save_workflow(wf) + storage.delete_workflow(wf.id) + executor._persist_run_fields(wf, { + "last_run_at": datetime.now(), + "last_run_status": "success", + }, schedule_runs_count_delta=1) + assert storage.get_workflow(wf.id) is None + + +def test_patch_if_match_rejects_stale_write(): + """A PATCH with a stale If-Match must return 409. Without If-Match, + the request still succeeds (legacy clients keep working until they + roll out the header).""" + from backend.apps.workflows.workflows import update_workflow + from backend.apps.workflows.models import WorkflowUpdate + from backend.apps.workflows import storage + from fastapi import HTTPException + + wf = _make_wf(title="optimistic-test") + storage.save_workflow(wf) + stale = "1999-01-01T00:00:00" + + async def runner(): + # Stale If-Match → 409. + try: + await update_workflow(wf.id, WorkflowUpdate(title="x"), if_match=stale) + return "no exception" + except HTTPException as he: + return he.status_code + code = asyncio.new_event_loop().run_until_complete(runner()) + assert code == 409, f"stale If-Match should 409, got {code}" + + # Fresh If-Match → 200. + fresh = storage.get_workflow(wf.id) + fresh_stamp = fresh.updated_at.isoformat() + async def runner_ok(): + return await update_workflow(wf.id, WorkflowUpdate(title="y"), if_match=fresh_stamp) + result = asyncio.new_event_loop().run_until_complete(runner_ok()) + assert result["title"] == "y" + + # Missing If-Match → legacy path still works. + async def runner_legacy(): + return await update_workflow(wf.id, WorkflowUpdate(title="z"), if_match=None) + result = asyncio.new_event_loop().run_until_complete(runner_legacy()) + assert result["title"] == "z" + + +def test_killed_by_restart_message_is_friendly(): + """stuck-run reaper writes a user-facing string, not internal jargon.""" + from backend.apps.workflows import storage, scheduler + from backend.apps.workflows.models import WorkflowRun + wf = _make_wf() + storage.save_workflow(wf) + storage.record_run(WorkflowRun(workflow_id=wf.id, status="running")) + scheduler._mark_stuck_runs_failed() + runs = storage.list_runs(wf.id, limit=10) + assert any(r.status == "failure" and "OpenSwarm closed" in (r.error or "") for r in runs) + assert not any("Killed by restart" in (r.error or "") for r in runs) + + +def test_run_endpoint_surfaces_skipped_status(): + """POST /workflows/{id}/run returns the skipped status + error when + a cost-cap or in-flight collision short-circuits the run.""" + from backend.apps.workflows.workflows import run_workflow_now + from backend.apps.workflows import storage + from backend.apps.workflows.models import WorkflowRun + from datetime import datetime, timezone + wf = _make_wf(title="cap-immediate") + wf.cost_cap_usd_monthly = 0.01 + storage.save_workflow(wf) + # Burn the cap with a single $5 historical run. + storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=5.0, + started_at=datetime.now(timezone.utc), + finished_at=datetime.now(timezone.utc))) + + async def runner(): + return await run_workflow_now(wf.id) + res = asyncio.new_event_loop().run_until_complete(runner()) + assert res.get("status") == "skipped" + assert "cost cap" in (res.get("error") or "").lower() + + +def test_escalation_noop_for_single_tier(): + from backend.apps.workflows import escalation + from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun + wf = Workflow(title="t", permissions=[PermissionTier(kind="notify")]) + run = WorkflowRun(workflow_id=wf.id, status="success") + escalation.schedule(wf, run) + assert escalation.status(run.id) is None diff --git a/electron/main.js b/electron/main.js index 1fb765ef..7c8679e9 100644 --- a/electron/main.js +++ b/electron/main.js @@ -34,6 +34,7 @@ const fs = require('fs'); const getPort = require('get-port'); const http = require('http'); const affiliateTracking = require('./affiliateTracking'); +const workflowsLifecycle = require('./workflowsLifecycle'); // Defender warmup: NSIS runs us with --prewarm right after install so Windows scans the bundled binaries while the user is already watching the installer instead of staring at a slow first launch. if (process.argv.includes('--prewarm') && process.platform === 'win32') { @@ -729,6 +730,10 @@ function markBackendReady() { if (backendReady) return; backendReady = true; _backendReadyResolve(); + try { + workflowsLifecycle.setBackend({ port: backendPort, token: authToken }); + workflowsLifecycle.startPolling(); + } catch (_) {} } function getAuthTokenFilePath() { @@ -1229,8 +1234,8 @@ app.whenReady().then(async () => { backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10); console.log(`Dev mode: using existing backend on port ${backendPort}`); emitSplashStatus('Connecting to dev backend…'); - // Load the token before marking ready, same as prod, so renderer - // fetches get a real token instead of '' (else they 401). + // Load the token before marking ready, same as prod, so the workflow + // poller's setBackend() gets a real token instead of '' (else it 401s). await loadAuthToken(); markBackendReady(); } else { @@ -1626,6 +1631,10 @@ app.on('before-quit', async (event) => { try { await postShutdownAllApps(10000); } catch (_) {} + // Give in-flight workflow runs up to 30s to land so we don't destroy paid LLM work. + try { + await workflowsLifecycle.drainOnQuit(30); + } catch (_) {} app.quit(); }); @@ -1752,6 +1761,11 @@ ipcMain.handle('set-allow-prerelease', async (_e, value) => { ipcMain.handle('install-update', async () => { if (!autoUpdater) return; + // Veto while a workflow is in flight; lifecycle poller fires the deferred install once active drains. + try { + const vetoed = await workflowsLifecycle.maybeVetoInstall(); + if (vetoed) return { vetoed: true }; + } catch (_) {} autoUpdater.quitAndInstall(false, true); }); diff --git a/electron/workflowsLifecycle.js b/electron/workflowsLifecycle.js new file mode 100644 index 00000000..7b79b6ee --- /dev/null +++ b/electron/workflowsLifecycle.js @@ -0,0 +1,208 @@ +// Lifecycle helpers that keep scheduled workflows surviving real-world +// app states (machine sleep, window closed, auto-update). All exports are +// safe to call before the backend is up; failed fetches return null and +// callers degrade to "no active runs known." + +const { app, powerSaveBlocker, Notification, shell } = require('electron'); +const http = require('http'); + +let backendPortRef = null; +let authTokenRef = null; +let blockerId = null; +let updaterVetoPending = false; +let pollTimer = null; +let lastActiveCount = 0; +let onActiveChange = () => {}; + +function setBackend({ port, token }) { + backendPortRef = port; + authTokenRef = token; +} + +function setActiveChangeListener(cb) { + onActiveChange = cb || (() => {}); +} + +// Cheap GET to the localhost backend. Resolves null on any error. +function fetchJson(pathStr) { + return new Promise((resolve) => { + if (!backendPortRef) return resolve(null); + const req = http.request({ + hostname: '127.0.0.1', + port: backendPortRef, + path: pathStr, + method: 'GET', + headers: authTokenRef ? { Authorization: `Bearer ${authTokenRef}` } : {}, + timeout: 1500, + }, (res) => { + let data = ''; + res.on('data', (c) => { data += c; }); + res.on('end', () => { + try { resolve(JSON.parse(data)); } catch { resolve(null); } + }); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + req.end(); + }); +} + +async function getActive() { + // Must hit the /api prefix; the bare path 401s and would leave the + // powerSaveBlocker + updater-veto blind to in-flight runs. + const res = await fetchJson('/api/workflows/active'); + if (!res || !Array.isArray(res.active)) return []; + return res.active; +} + +// powerSaveBlocker holds the system awake while at least one workflow is +// active. Released as soon as the active list goes empty so we don't pin +// the user's laptop on idle. +function ensureBlocker(active) { + if (active && blockerId == null) { + try { blockerId = powerSaveBlocker.start('prevent-app-suspension'); } catch (_) {} + } else if (!active && blockerId != null) { + try { powerSaveBlocker.stop(blockerId); } catch (_) {} + blockerId = null; + } +} + +function startPolling() { + if (pollTimer) return; + // 5s cadence is the sweet spot: fast enough to release the + // powerSaveBlocker promptly after a fire, slow enough that the localhost + // request is invisible in CPU traces. + pollTimer = setInterval(async () => { + const active = await getActive(); + const count = active.length; + ensureBlocker(count > 0); + if (count !== lastActiveCount) { + lastActiveCount = count; + try { onActiveChange(active); } catch (_) {} + } + // If the updater queued an install while a run was in flight, fire it + // the moment the active list drains. + if (updaterVetoPending && count === 0) { + updaterVetoPending = false; + try { + const { autoUpdater } = require('electron-updater'); + autoUpdater.quitAndInstall(false, true); + } catch (_) {} + } + }, 5000); +} + +function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +// Updater veto: if a workflow is running and the user clicks "Install +// update," queue it instead of quitAndInstall'ing on top of an active +// run. Returns true if vetoed (caller should display a "queued" banner), +// false otherwise. +async function maybeVetoInstall() { + const active = await getActive(); + if (active.length === 0) return false; + updaterVetoPending = true; + return true; +} + +// Drain on quit: give in-flight runs up to QUIT_DRAIN_S to finish before +// killing the backend. The user-facing tradeoff is a slow quit when busy +// vs. losing the run; we lean toward "wait" because the run already +// committed real cost. +function drainOnQuit(maxSeconds = 30) { + return new Promise((resolve) => { + const deadline = Date.now() + maxSeconds * 1000; + const tick = async () => { + const active = await getActive(); + if (active.length === 0 || Date.now() > deadline) return resolve(); + setTimeout(tick, 500); + }; + tick(); + }); +} + +// Native OS notification. Falls back silently when Notification isn't +// supported (some Linux setups, headless test envs). When `actions` is +// provided AND we're on macOS, attaches button actions so the user can +// ack/re-run/open without the app taking focus. Routes the chosen +// outcome back to the renderer via an IPC channel that the renderer's +// WebSocketManager already listens for. +function showNativeNotification({ title, body, deepLink, runId, workflowId, actions }) { + if (!Notification || !Notification.isSupported()) return null; + try { + const opts = { title: title || 'OpenSwarm', body: body || '', silent: false }; + const platformActions = Array.isArray(actions) && process.platform === 'darwin' + ? actions.map((a) => ({ type: 'button', text: a.text })) + : undefined; + if (platformActions && platformActions.length) opts.actions = platformActions; + const n = new Notification(opts); + const route = (outcome) => { + try { + const { BrowserWindow } = require('electron'); + const wins = BrowserWindow.getAllWindows(); + const wc = wins[0]?.webContents; + if (wc) wc.send('workflow:notification-action', { outcome, runId, workflowId, deepLink }); + } catch (_) {} + }; + n.on('action', (_event, idx) => { + const a = (actions || [])[idx]; + if (a) route(a.outcome); + }); + n.on('click', () => { + if (deepLink) { + try { shell.openExternal(deepLink); } catch (_) {} + } + route('open'); + }); + n.show(); + return n; + } catch (_) { + return null; + } +} + +// Launch-at-login wrappers. macOS + Windows both honor this; Linux is a +// no-op in Electron's API. +function getLoginItem() { + try { + const { openAtLogin } = app.getLoginItemSettings(); + return Boolean(openAtLogin); + } catch (_) { return false; } +} + +function setLoginItem(value) { + try { + // openAsHidden is macOS-only; on Windows the equivalent is passing + // a --hidden arg and having main.js suppress the initial window + // when the arg is present. Linux uses a .desktop file in + // ~/.config/autostart/ which Electron writes for us via this same + // call (no extra plumbing needed). + const opts = { + openAtLogin: Boolean(value), + openAsHidden: true, + }; + if (process.platform === 'win32') { + opts.args = ['--hidden']; + } + app.setLoginItemSettings(opts); + return Boolean(value); + } catch (_) { return false; } +} + +module.exports = { + setBackend, + setActiveChangeListener, + startPolling, + stopPolling, + getActive, + maybeVetoInstall, + drainOnQuit, + showNativeNotification, + getLoginItem, + setLoginItem, +}; diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 7ca528ed..666873b7 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -34,6 +34,9 @@ import SearchIcon from '@mui/icons-material/Search'; import { motion } from 'framer-motion'; import ChatInput from '@/app/pages/AgentChat/ChatInput'; import type { ContextPath } from '@/app/components/editor/DirectoryBrowser'; +import SchedulePopover from '@/app/pages/Workflows/SchedulePopover'; +import { openWorkflowCard } from '@/shared/state/workflowsSlice'; +import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -173,6 +176,7 @@ const DashboardToolbar = React.forwardRef( const [viewSearch, setViewSearch] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const [historyQuery, setHistoryQuery] = useState(''); + const [popoverMode, setPopoverMode] = useState<'search' | 'schedule'>('search'); const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut); const outputs = useAppSelector((s) => s.outputs.items); const historySearch = useAppSelector((s) => s.agents.historySearch); @@ -420,7 +424,7 @@ const DashboardToolbar = React.forwardRef( padding: isExpanded ? '6px' : '5px', userSelect: 'none' as const, overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden', - // historyOpen: width owned by the inline history list; leave undefined so framer-motion measures intrinsic size. + // historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size. width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined, }} > @@ -444,57 +448,34 @@ const DashboardToolbar = React.forwardRef( /> ) : historyOpen ? ( - // Past-chat search list. Fixed-size bordered surface (matches the - // toolbar popover footprint) with a search input + scrollable - // results; clicking a row resumes that chat. - - - - - setHistoryQuery(e.target.value)} - placeholder="Search past chats..." - sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, fontFamily: c.font.sans, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }} - /> - - - {historySearch.results.length === 0 && !historySearch.loading && ( - - {historyQuery ? 'No matching chats' : 'No chat history yet'} - - )} - {historySearch.results.map((entry) => ( - handleHistorySelect(entry.id)} - sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }} - > - - {entry.name} - - - {formatRelativeTime(entry.closed_at)} - - - ))} - - - +
+ ({ id: e.id, name: e.name, closed_at: e.closed_at }))} + historyLoading={historySearch.loading} + historyQuery={historyQuery} + onHistoryQueryChange={setHistoryQuery} + onHistorySelect={handleHistorySelect} + onNewChat={() => { handleCloseHistory(); onNewAgent(); }} + onWorkflowSelect={(wid) => { + dispatch(addWorkflowCard({ workflowId: wid })); + dispatch(openWorkflowCard({ + workflowId: wid, + view: 'saved', + })); + handleCloseHistory(); + }} + onExpand={() => { + // Singleton per dashboard, second Expand brings the existing card forward. + dispatch(openWorkflowsHub({ expandedSessionIds: [] })); + handleCloseHistory(); + }} + historyScrollRef={historyListRef as React.RefObject} + onHistoryScroll={handleHistoryScroll} + /> +
) : viewPickerOpen ? (
diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 00406c3b..9ec0b28d 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -12,6 +12,9 @@ import type { ViewCardPosition, BrowserCardPosition, NotePosition, + WorkflowCardPosition, + WorkflowsHubPosition, + ConfigurePanelPosition, } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection'; @@ -37,6 +40,9 @@ interface DashboardCanvasProps { viewCards: Record; browserCards: Record; notes: Record; + workflowCards: Record; + workflowsHub: WorkflowsHubPosition | null; + configurePanels: Record; outputs: Record; glowingAgentCards: Record; expandedSessionIds: string[]; @@ -94,6 +100,9 @@ const DashboardCanvas: React.FC = ({ viewCards, browserCards, notes, + workflowCards, + workflowsHub, + configurePanels, outputs, glowingAgentCards, expandedSessionIds, @@ -211,7 +220,7 @@ const DashboardCanvas: React.FC = ({ }} /> - {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? ( + {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? ( ) : (
= ({ viewCards={viewCards} browserCards={browserCards} notes={notes} + workflowCards={workflowCards} + workflowsHub={workflowsHub} + configurePanels={configurePanels} outputs={outputs} glowingAgentCards={glowingAgentCards} expandedSessionIds={expandedSessionIds} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 2bad2019..75af0b0d 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -4,6 +4,9 @@ import AgentCard from '../cards/AgentCard'; import DashboardViewCard from '../cards/DashboardViewCard'; import BrowserCard from '../cards/BrowserCard'; import NoteCard from '../cards/NoteCard'; +import WorkflowCard from '@/app/pages/Workflows/WorkflowCard'; +import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard'; +import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard'; import { EXPANDED_CARD_MIN_H, DEFAULT_CARD_W, @@ -12,6 +15,9 @@ import { type ViewCardPosition, type BrowserCardPosition, type NotePosition, + type WorkflowCardPosition, + type WorkflowsHubPosition, + type ConfigurePanelPosition, } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection'; @@ -26,6 +32,9 @@ interface DashboardCardLayerProps { viewCards: Record; browserCards: Record; notes: Record; + workflowCards: Record; + workflowsHub: WorkflowsHubPosition | null; + configurePanels: Record; outputs: Record; glowingAgentCards: Record; expandedSessionIds: string[]; @@ -59,6 +68,9 @@ const DashboardCardLayer: React.FC = ({ viewCards, browserCards, notes, + workflowCards, + workflowsHub, + configurePanels, outputs, glowingAgentCards, expandedSessionIds, @@ -251,6 +263,48 @@ const DashboardCardLayer: React.FC = ({ onBringToFront={onBringToFront} /> ))} + {workflowsHub && ( + + )} + {Object.values(workflowCards).map((wc) => ( + + ))} + {Object.values(configurePanels).map((p) => ( + + ))} {/* Marquee selection rectangle */} {selection.marquee && (
}): Array<{ id: string; text: string }> { + const out: Array<{ id: string; text: string }> = []; + for (const msg of session.messages || []) { + if (msg.role !== 'user' || msg.hidden) continue; + const text = typeof msg.content === 'string' ? msg.content : (Array.isArray(msg.content) ? msg.content.map((b: any) => (typeof b === 'string' ? b : b?.text || '')).join(' ') : ''); + const trimmed = text.trim(); + if (trimmed.length < 6) continue; + out.push({ id: `step-${out.length + 1}-${Date.now().toString(36)}`, text: trimmed.slice(0, 400) }); + if (out.length === 3) break; + } + if (out.length === 0 && session.messages?.length) { + const fallback = session.messages.find((m) => m.role === 'user'); + if (fallback) { + const text = typeof fallback.content === 'string' ? fallback.content : ''; + out.push({ id: `step-1-${Date.now().toString(36)}`, text: text.slice(0, 400) || 'Run the original task' }); + } + } + return out; +} + const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => { if (service === 'gmail') { return ( @@ -221,6 +245,25 @@ const AgentCard: React.FC = ({ const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); const modelsByProvider = useAppSelector((s) => s.models.byProvider); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + // Hide the "Convert to workflow" button when this chat is already + // entangled with a workflow (Image #44 note). Two cases: + // (a) The session is one of a workflow's runner sessions, OR + // (b) The session is the source the workflow was originally derived + // from. Either way a fresh convert would just clone the workflow, + // which is confusing identity collapse. + const workflowRunsMap = useAppSelector((s) => s.workflows.runs); + const workflowItems = useAppSelector((s) => s.workflows.items); + const isWorkflowRunnerSession = useMemo(() => { + for (const arr of Object.values(workflowRunsMap || {})) { + for (const r of arr || []) { + if (r.session_id === session.id) return true; + } + } + for (const wf of Object.values(workflowItems || {})) { + if (wf.source_session_id === session.id) return true; + } + return false; + }, [workflowRunsMap, workflowItems, session.id]); // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { const value = session.model; @@ -855,6 +898,67 @@ const AgentCard: React.FC = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} > + {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && ( + + { + e.stopPropagation(); + const steps = extractStepsFromSession(session); + if (steps.length === 0) return; + const draft: Partial = { + title: session.name || 'New workflow', + description: '', + steps, + source_session_id: session.id, + dashboard_id: session.dashboard_id || null, + model: session.model, + mode: session.mode, + provider: session.provider, + }; + const tempId = `draft-${session.id}`; + // The OG chat card BECOMES the workflow card: capture + // its position + size, remove the chat card, and drop + // the workflow card in the same physical slot. The + // chat session itself stays accessible via History. + // Per Image #61 / #62: no tether arrow, no second + // card alongside. + // Capture this card's current position/size, drop the + // workflow card in the same slot, then remove the + // source chat card. + dispatch(addWorkflowCard({ + workflowId: tempId, + sourceSessionId: null, + expandedSessionIds, + })); + dispatch(setWorkflowCardPosition({ workflowId: tempId, x: cardX, y: cardY })); + dispatch(setWorkflowCardSize({ workflowId: tempId, width: cardWidth, height: cardHeight })); + dispatch(removeCard(session.id)); + dispatch(openWorkflowCard({ + workflowId: tempId, + sourceSessionId: null, + view: 'preview', + draft, + })); + }} + onMouseDown={(e) => e.stopPropagation()} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + color: '#fff', + bgcolor: c.accent.primary, + border: `1px solid ${c.accent.primary}`, + fontSize: '0.78rem', fontWeight: 700, + px: 1.1, py: 0.5, + borderRadius: `${c.radius.md}px`, + cursor: 'pointer', + '&:hover': { filter: 'brightness(1.05)' }, + }} + > + + Convert to workflow + + + )} , viewCards: Record, browserCards: Record, + workflowCards: Record = {}, + workflowsHub: WorkflowsHubPosition | null = null, ): ContentBounds | undefined { const allRects = [ ...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), ...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), ...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...Object.values(workflowCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...(workflowsHub ? [{ x: workflowsHub.x, y: workflowsHub.y, w: workflowsHub.width, h: workflowsHub.height }] : []), ]; if (allRects.length === 0) return undefined; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 80440641..9fc02e24 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -1,6 +1,7 @@ import { useMemo, type RefObject } from 'react'; -import type { CardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; -import { EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, BrowserCardPosition, WorkflowCardPosition, ConfigurePanelPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { Workflow, OpenCard } from '@/shared/state/workflowsSlice'; +import { EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice'; import type { AgentSession } from '@/shared/state/agentsSlice'; const ELBOW_RADIUS = 16; @@ -60,6 +61,10 @@ interface UseTethersArgs { glowingBrowserCards: Record; cards: Record; browserCards: Record; + workflowCards: Record; + workflowItems: Record; + workflowOpenCards: Record; + configurePanels: Record; expandedSessionIds: string[]; liveDragInfo: LiveDragInfo | null; measuredHeightsRef: RefObject>; @@ -72,6 +77,10 @@ export function useTethers({ glowingBrowserCards, cards, browserCards, + workflowCards, + workflowItems, + workflowOpenCards, + configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsRef, @@ -226,9 +235,199 @@ export function useTethers({ const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[]; - return [...agentTethers, ...browserTethers]; + // Workflow tethers reuse the browser-tether anchor/elbow math; skip deleted workflows to avoid dangling arrows. + const workflowTethers: Tether[] = []; + for (const wc of Object.values(workflowCards)) { + const sourceId = wc.source_session_id; + if (!sourceId) continue; + const src = cards[sourceId]; + if (!src) continue; + // Layout entry can outlive its workflow when deleted from the hub. + const hasReal = wc.workflow_id in workflowItems; + const hasDraft = wc.workflow_id in workflowOpenCards; + if (!hasReal && !hasDraft) continue; + // "Make workflow" is a draft-time affordance; once saved (openCard leaves 'preview') the link retires. + const openCard = workflowOpenCards[wc.workflow_id]; + if (openCard && openCard.view !== 'preview') continue; + + let srcX = src.x, srcY = src.y; + let dstX = wc.x, dstY = wc.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + if (liveDragInfo.cardId === wc.workflow_id) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + } + + const srcMeasured = measuredHeightsRef.current![sourceId]; + const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId) + ? Math.max(EXPANDED_CARD_MIN_H, src.height) + : src.height); + + const srcCx = srcX + src.width / 2; + const dstCx = dstX + wc.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' }, + { x: srcX, y: srcY + srcH * 0.54, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + srcH, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + wc.height * 0.54, side: 'left' }, + { x: dstX + wc.width, y: dstY + wc.height * 0.54, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + wc.height, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom') + && (bestDst.side === 'top' || bestDst.side === 'bottom'); + let pathD: string; + if (isVertical) { + const dx = x2 - x1; + const dy = y2 - y1; + const midY = y1 + dy / 2; + const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2) + ? 0 + : Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4); + const sx = dx >= 0 ? 1 : -1; + const sy = dy >= 0 ? 1 : -1; + pathD = [ + `M ${x1},${y1}`, + `V ${midY - sy * r}`, + `Q ${x1},${midY} ${x1 + sx * r},${midY}`, + `H ${x2 - sx * r}`, + `Q ${x2},${midY} ${x2},${midY + sy * r}`, + `V ${y2}`, + ].join(' '); + } else { + pathD = elbowPath(x1, y1, x2, y2); + } + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15; + const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; + workflowTethers.push({ + key: `workflow-${wc.workflow_id}`, + path: pathD, + labelX, + labelY, + label: 'Make workflow', + fading: false, + }); + } + + // Sidecar tethers: workflow card to its sibling agent session (View Agent / Watch Live / Test Agent). + for (const wc of Object.values(workflowCards)) { + const openCard = workflowOpenCards[wc.workflow_id]; + if (!openCard?.sidecarSessionId || !openCard.sidecarKind) continue; + const sidecarId = openCard.sidecarSessionId; + const sidecar = cards[sidecarId]; + if (!sidecar) continue; + let srcX = wc.x, srcY = wc.y; + let dstX = sidecar.x, dstY = sidecar.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === wc.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + if (liveDragInfo.cardId === sidecarId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + } + const dstMeasured = measuredHeightsRef.current![sidecarId]; + const dstH = dstMeasured ?? (expandedSessionIds.includes(sidecarId) + ? Math.max(EXPANDED_CARD_MIN_H, sidecar.height) + : sidecar.height); + const srcCx = srcX + wc.width / 2; + const dstCx = dstX + sidecar.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + wc.width, y: srcY + wc.height * 0.54, side: 'right' }, + { x: srcX, y: srcY + wc.height * 0.54, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + wc.height, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + dstH * 0.54, side: 'left' }, + { x: dstX + sidecar.width, y: dstY + dstH * 0.54, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + dstH, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const pathD = elbowPath(x1, y1, x2, y2); + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + const sidecarLabel = openCard.sidecarKind === 'testing' ? 'Testing' : 'Watching'; + workflowTethers.push({ + key: `sidecar-${wc.workflow_id}`, + path: pathD, + labelX: midX, + labelY: midY, + label: sidecarLabel, + fading: false, + }); + } + + // Configure-panel tethers: anchor each open configure panel to its workflow card. + const configureTethers: Tether[] = []; + for (const p of Object.values(configurePanels)) { + const wc = workflowCards[p.workflow_id]; + if (!wc) continue; + let srcX = wc.x, srcY = wc.y; + let dstX = p.x, dstY = p.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === p.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + } + const srcCx = srcX + wc.width / 2; + const dstCx = dstX + p.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + wc.width, y: srcY + wc.height * 0.5, side: 'right' }, + { x: srcX, y: srcY + wc.height * 0.5, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + wc.height, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + p.height * 0.5, side: 'left' }, + { x: dstX + p.width, y: dstY + p.height * 0.5, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + p.height, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const pathD = elbowPath(x1, y1, x2, y2); + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + configureTethers.push({ + key: `configure-${p.workflow_id}`, + path: pathD, + labelX: midX, + labelY: midY, + label: 'Configure', + fading: false, + }); + } + + return [...agentTethers, ...browserTethers, ...workflowTethers, ...configureTethers]; // measuredHeightsTick re-runs the memo once ResizeObserver reports a new // height after a collapse (the ref read is invisible to the dep checker). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); } diff --git a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts index 1417754d..1d80881f 100644 --- a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts +++ b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts @@ -23,6 +23,10 @@ export function getCardRect(id: string, type: CardType): const n = layoutState.notes[id]; if (!n) return undefined; return { x: n.x, y: n.y, width: n.width, height: n.height }; + } else if (type === 'workflow') { + const wc = layoutState.workflowCards[id]; + if (!wc) return undefined; + return { x: wc.x, y: wc.y, width: wc.width, height: wc.height }; } return undefined; } diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts index a13397dc..242c2be4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts @@ -3,7 +3,7 @@ import { report } from '@/shared/serviceClient'; import { useAppDispatch } from '@/shared/hooks'; import { expandSession } from '@/shared/state/agentsSlice'; import { bringToFront } from '@/shared/state/dashboardLayoutSlice'; -import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '../state/useDashboardSelection'; import type { CanvasActions } from './useCanvasControls'; @@ -13,6 +13,7 @@ interface UseArrowNavArgs { cards: Record; viewCards: Record; browserCards: Record; + workflowCards: Record; zoom: number; isActive: boolean; focusedCardId: string | null; @@ -25,6 +26,7 @@ export function useArrowNav({ cards, viewCards, browserCards, + workflowCards, zoom, isActive, focusedCardId, @@ -48,6 +50,9 @@ export function useArrowNav({ for (const bc of Object.values(browserCards)) { allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 }); } + for (const wc of Object.values(workflowCards)) { + allCardEntries.push({ id: wc.workflow_id, type: 'workflow', cx: wc.x + wc.width / 2, cy: wc.y + wc.height / 2 }); + } const current = allCardEntries.find((c) => c.id === currentId); if (!current) return null; @@ -80,7 +85,7 @@ export function useArrowNav({ } return best ? { id: best.id, type: best.type } : null; - }, [cards, viewCards, browserCards]); + }, [cards, viewCards, browserCards, workflowCards]); // Compute which directions have neighbors from the focused card const neighborDirections = useMemo(() => { diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts index 401c8b35..ed5f0291 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts @@ -2,7 +2,8 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react'; import { report } from '@/shared/serviceClient'; import { useAppDispatch } from '@/shared/hooks'; import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice'; -import { removeViewCard, removeBrowserCard, removeNote } from '@/shared/state/dashboardLayoutSlice'; +import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; import type { useDashboardSelection } from '../state/useDashboardSelection'; type Selection = ReturnType; @@ -79,6 +80,9 @@ export function useDashboardShortcuts({ dispatch(removeBrowserCard(id)); } else if (type === 'note') { dispatch(removeNote(id)); + } else if (type === 'workflow') { + dispatch(removeWorkflowCard(id)); + dispatch(closeWorkflowCard(id)); } } selection.deselectAll(); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 759bbbd6..bbfb8c41 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -15,9 +15,12 @@ import { resetLayout, removeViewCard, clearPendingFocusBrowserId, + clearPendingFocusWorkflowId, + clearPendingFocusWorkflowsHub, type ViewCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; +import { fetchWorkflows } from '@/shared/state/workflowsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; @@ -59,6 +62,8 @@ export function useDashboardLifecycle({ const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl); const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId); + const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); + const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub); // Track dashboard engagement time useEffect(() => { @@ -93,11 +98,13 @@ export function useDashboardLifecycle({ ? (window as any).requestIdleCallback(() => { dispatch(fetchHistory({ dashboardId })); dispatch(fetchOutputs()); + dispatch(fetchWorkflows(dashboardId)); dashboardWs.connect(); }, { timeout: 2000 }) : window.setTimeout(() => { dispatch(fetchHistory({ dashboardId })); dispatch(fetchOutputs()); + dispatch(fetchWorkflows(dashboardId)); dashboardWs.connect(); }, 200); @@ -214,6 +221,44 @@ export function useDashboardLifecycle({ }, 200); }, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]); + // Same pan/highlight choreography for newly-spawned workflow cards. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusWorkflowId || !layoutInitialized) return; + const workflowId = pendingFocusWorkflowId; + dispatch(clearPendingFocusWorkflowId()); + setTimeout(() => { + const card = store.getState().dashboardLayout.workflowCards[workflowId]; + if (card) { + canvasActions.fitToCards( + [{ x: card.x, y: card.y, width: card.width, height: card.height }], + 1.15, + true, + ); + handleHighlightCard(workflowId); + } + }, 200); + }, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]); + + // Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusWorkflowsHub || !layoutInitialized) return; + dispatch(clearPendingFocusWorkflowsHub()); + const fit = () => { + const hub = store.getState().dashboardLayout.workflowsHub; + if (!hub) return; + canvasActions.fitToCards( + [{ x: hub.x, y: hub.y, width: hub.width, height: hub.height }], + 1.1, + true, + ); + }; + requestAnimationFrame(() => requestAnimationFrame(fit)); + const fallback = setTimeout(fit, 300); + return () => clearTimeout(fallback); + }, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvasActions]); + useEffect(() => { if (!layoutInitialized || restoredExpandedRef.current) return; restoredExpandedRef.current = true; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 8e59c42b..154cb765 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -30,6 +30,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { const isElementSelectMode = elementSelectionCtx?.selectMode ?? false; const { dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards, + workflowCards, workflowItems, workflowOpenCards, configurePanels, workflowsHub, + pendingFocusWorkflowId, pendingFocusWorkflowsHub, notes, pendingFocusNoteId, layoutInitialized, persistedExpandedSessionIds, zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats, autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards, @@ -40,8 +42,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { const sessionList = useMemo(() => Object.values(sessions), [sessions]); const contentBounds = useMemo( - () => computeContentBounds(cards, viewCards, browserCards), - [cards, viewCards, browserCards], + () => computeContentBounds(cards, viewCards, browserCards, workflowCards, workflowsHub), + [cards, viewCards, browserCards, workflowCards, workflowsHub], ); const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive); @@ -51,6 +53,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { viewCards, browserCards, notes, + workflowCards, ); const { toolbarRef, toolbarOpen, setToolbarOpen, searchPaletteOpen, setSearchPaletteOpen, @@ -138,6 +141,9 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { cards, viewCards, browserCards, + workflowCards, + configurePanels, + workflowsHub, notes, expandedSessionIds, captureNow, @@ -168,6 +174,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { cards, viewCards, browserCards, + workflowCards, zoom: canvas.zoom, isActive, focusedCardId, @@ -231,6 +238,10 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { glowingBrowserCards, cards, browserCards, + workflowCards, + workflowItems, + workflowOpenCards, + configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsRef, @@ -241,6 +252,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { return { c, dashboardId, dashboardName, canvas, selection, sessions, sessionList, cards, viewCards, browserCards, notes, outputs, glowingAgentCards, + workflowCards, workflowsHub, configurePanels, expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId, focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection, neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index 2fbe8ec3..b5ad9d64 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useRef, useEffect, RefObject } from 'react'; -import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice'; export type { CardType } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; @@ -43,6 +43,7 @@ export function useDashboardSelection( viewCards: Record, browserCards: Record = {}, notes: Record = {}, + workflowCards: Record = {}, ) { const [selectedIds, setSelectedIds] = useState>(new Map()); const [marquee, setMarquee] = useState(null); @@ -77,8 +78,9 @@ export function useDashboardSelection( for (const vc of Object.values(viewCards)) next.set(vc.output_id, 'view'); for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser'); for (const n of Object.values(notes)) next.set(n.note_id, 'note'); + for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow'); setSelectedIds(next); - }, [cards, viewCards, browserCards, notes]); + }, [cards, viewCards, browserCards, notes, workflowCards]); const selectCard = useCallback( (id: string, type: CardType, shiftKey: boolean) => { @@ -161,6 +163,19 @@ export function useDashboardSelection( } } + for (const wc of Object.values(workflowCards)) { + if ( + rectsIntersect(rect, { + x: wc.x, + y: wc.y, + width: wc.width, + height: wc.height, + }) + ) { + intersecting.set(wc.workflow_id, 'workflow'); + } + } + if (shiftKey) { const base = selectionBeforeMarqueeRef.current; const next = new Map(base); @@ -176,7 +191,7 @@ export function useDashboardSelection( return intersecting; }, - [cards, viewCards, browserCards, notes], + [cards, viewCards, browserCards, notes, workflowCards], ); const handleCanvasMouseDown = useCallback( diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts index f0644826..129693db 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -11,6 +11,13 @@ export function useDashboardSelectors(dashboardId: string) { const cards = useAppSelector((state) => state.dashboardLayout.cards); const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); + const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards); + const configurePanels = useAppSelector((state) => state.dashboardLayout.configurePanels); + const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub); + const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); + const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub); + const workflowItems = useAppSelector((state) => state.workflows.items); + const workflowOpenCards = useAppSelector((state) => state.workflows.openCards); const notes = useAppSelector((state) => state.dashboardLayout.notes); const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId); const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); @@ -32,6 +39,13 @@ export function useDashboardSelectors(dashboardId: string) { cards, viewCards, browserCards, + workflowCards, + workflowItems, + workflowOpenCards, + configurePanels, + workflowsHub, + pendingFocusWorkflowId, + pendingFocusWorkflowsHub, notes, pendingFocusNoteId, layoutInitialized, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts b/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts index 9dd0be40..f06c37d1 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts @@ -6,6 +6,9 @@ import { type ViewCardPosition, type BrowserCardPosition, type NotePosition, + type WorkflowCardPosition, + type ConfigurePanelPosition, + type WorkflowsHubPosition, } from '@/shared/state/dashboardLayoutSlice'; interface UseLayoutSaveArgs { @@ -15,6 +18,9 @@ interface UseLayoutSaveArgs { cards: Record; viewCards: Record; browserCards: Record; + workflowCards: Record; + configurePanels: Record; + workflowsHub: WorkflowsHubPosition | null; notes: Record; expandedSessionIds: string[]; captureNow: () => void; @@ -31,6 +37,9 @@ export function useLayoutSave({ cards, viewCards, browserCards, + workflowCards, + configurePanels, + workflowsHub, notes, expandedSessionIds, captureNow, @@ -47,7 +56,7 @@ export function useLayoutSave({ skipInitialSave.current = false; return; } - const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds }; + const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds }; pendingSaveRef.current = payload; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { @@ -56,7 +65,7 @@ export function useLayoutSave({ saveTimerRef.current = null; captureNow(); }, 500); - }, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); + }, [isActive, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); useEffect(() => { return () => { diff --git a/frontend/src/app/pages/Workflows/ActionsFacet.tsx b/frontend/src/app/pages/Workflows/ActionsFacet.tsx new file mode 100644 index 00000000..63cc626f --- /dev/null +++ b/frontend/src/app/pages/Workflows/ActionsFacet.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { openConfigurePanel, closeConfigurePanel } from '@/shared/state/dashboardLayoutSlice'; +import type { Workflow } from '@/shared/state/workflowsSlice'; +import { BODY_FS, LABEL_FS } from './workflowEditCommon'; + +export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + // Configure pops the Action Library out as a separate dashboard card + // tethered to this workflow (image #120). Lives in + // dashboardLayout.configurePanels keyed by workflow id; user can drag, + // resize, and X-close from there. + const configuring = useAppSelector((s) => Boolean(s.dashboardLayout.configurePanels[draft.id])); + const toggleConfigure = () => { + if (configuring) dispatch(closeConfigurePanel(draft.id)); + else dispatch(openConfigurePanel({ workflowId: draft.id })); + }; + // If the user flips Freeze off while the popout is open, close it so + // the orphaned card doesn't keep listening to a workflow that no + // longer wants a frozen action set. + React.useEffect(() => { + if (!draft.actions.freeze && configuring) { + dispatch(closeConfigurePanel(draft.id)); + } + }, [draft.actions.freeze, draft.id, configuring, dispatch]); + + return ( + + + Do you want to prevent the agent from taking actions that weren't used in the original workflow? + + + + + + + Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings? + + + + + + {/* Configure only makes sense when actions are frozen: the user + is explicitly picking a curated subset. With "Don't freeze", + the agent inherits global settings, so there's nothing to + configure here. Auto-close the panel on un-freeze so a stale + popout doesn't outlive the toggle. */} + {draft.actions.freeze && ( + + + {configuring ? '⚙ Configuring…' : '⚙ Configure'} + + + )} + + ); +} diff --git a/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx b/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx new file mode 100644 index 00000000..2db4947f --- /dev/null +++ b/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { + closeConfigurePanel, + setConfigurePanelPosition, + setConfigurePanelSize, + type ConfigurePanelPosition, +} from '@/shared/state/dashboardLayoutSlice'; +import Tools from '@/app/pages/Tools/Tools'; + +const MIN_W = 420; +const MIN_H = 320; +const EDGE = 6; + +export default function ConfigurePanelCard({ panel, zOrder }: { panel: ConfigurePanelPosition; zOrder: number }) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null); + const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null); + const [localSize, setLocalSize] = useState<{ w: number; h: number } | null>(null); + + const onDragStart = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + dragRef.current = { startX: e.clientX, startY: e.clientY, origX: panel.x, origY: panel.y }; + setLocalPos({ x: panel.x, y: panel.y }); + }, [panel.x, panel.y]); + + const onDragMove = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + const dx = e.clientX - dragRef.current.startX; + const dy = e.clientY - dragRef.current.startY; + const nx = dragRef.current.origX + dx; + const ny = dragRef.current.origY + dy; + setLocalPos({ x: nx, y: ny }); + // Push the live position into Redux so the dashboard tether stays + // glued to the panel during the drag instead of lagging until pointer + // up. setLocalPos is kept for sub-frame smoothness, but Redux is the + // tether's source of truth. + dispatch(setConfigurePanelPosition({ workflowId: panel.workflow_id, x: nx, y: ny })); + }, [dispatch, panel.workflow_id]); + + const onDragEnd = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + dragRef.current = null; + setLocalPos(null); + }, []); + + const onResizeStart = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: panel.width, origH: panel.height }; + setLocalSize({ w: panel.width, h: panel.height }); + }, [panel.width, panel.height]); + + const onResizeMove = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + const dw = e.clientX - resizeRef.current.startX; + const dh = e.clientY - resizeRef.current.startY; + setLocalSize({ + w: Math.max(MIN_W, resizeRef.current.origW + dw), + h: Math.max(MIN_H, resizeRef.current.origH + dh), + }); + }, []); + + const onResizeEnd = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + if (localSize) { + dispatch(setConfigurePanelSize({ workflowId: panel.workflow_id, width: localSize.w, height: localSize.h })); + } + resizeRef.current = null; + setLocalSize(null); + }, [dispatch, localSize, panel.workflow_id]); + + const displayX = localPos?.x ?? panel.x; + const displayY = localPos?.y ?? panel.y; + const displayW = localSize?.w ?? panel.width; + const displayH = localSize?.h ?? panel.height; + + return ( + + {/* Drag handle + close X strip across the top. Stays slim so the + full Action Library underneath gets the vertical space. */} + + + Action Library + dispatch(closeConfigurePanel(panel.workflow_id))} + onPointerDown={(e) => e.stopPropagation()} + sx={{ p: 0.25, color: c.text.muted, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}> + + + + {/* Body: the real Action Library, exact same component as /actions. */} + + + + {/* SE resize handle. */} + + + ); +} diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx new file mode 100644 index 00000000..4989d5ef --- /dev/null +++ b/frontend/src/app/pages/Workflows/EditAgentView.tsx @@ -0,0 +1,323 @@ +// Image #38, #48: Edit Agent embedded in the workflow card. +// Creates a real, sticky-per-workflow agent session via /workflows/{id}/ +// edit-agent-session and embeds AgentChat so tool calls render as their +// normal cards (MCP Activation, Gmail Query, etc.). Header keeps the +// subtitle on the left and Settings + Discard + Save on the right. In +// fix mode (Image #48) the very first message in the session is a +// failure-context prompt, and a red prefix card renders above the chat +// so the user sees Why we're here at a glance. + +import React, { useCallback, useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Dialog from '@mui/material/Dialog'; +import Tooltip from '@mui/material/Tooltip'; +import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded'; +import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; +import BuildRounded from '@mui/icons-material/BuildRounded'; +import TuneRounded from '@mui/icons-material/TuneRounded'; +import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded'; +import ScienceOutlined from '@mui/icons-material/ScienceOutlined'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { clearFixSeed, setCardSidecar, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; +import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import { fetchSession } from '@/shared/state/agentsSlice'; +import StepList from './StepList'; +import { API_BASE, getAuthToken } from '@/shared/config'; +import AgentChat from '@/app/pages/AgentChat/AgentChat'; + +interface Props { + workflow: Workflow; + steps: Workflow['steps']; + isFixMode?: boolean; +} + +function InlineSubtitle({ workflow }: { workflow: Workflow }) { + const c = useClaudeTokens(); + const modelsByProvider = useAppSelector((s) => s.models.byProvider); + const runs = useAppSelector((s) => s.workflows.runs[workflow.id]); + const modelLabel = React.useMemo(() => { + if (!workflow?.model) return ''; + for (const list of Object.values(modelsByProvider || {})) { + for (const m of (list as Array<{ value: string; label?: string }>) || []) { + if (m.value === workflow.model) return m.label || workflow.model; + } + } + return workflow.model; + }, [workflow?.model, modelsByProvider]); + const duration = React.useMemo(() => { + if (!runs || runs.length === 0) return ''; + const last = runs.find((r) => r.finished_at); + if (!last || !last.finished_at) return ''; + const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime(); + if (ms <= 0) return ''; + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + return `${Math.floor(ms / 60_000)}m`; + }, [runs]); + return ( + + {modelLabel && {modelLabel}} + {workflow.mode && {workflow.mode}} + {duration && {duration}} + + ); +} + +export default function EditAgentView({ workflow, steps, isFixMode = false }: Props) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); + const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]); + const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + const fixSeed = card?.fixSeed || null; + const [busy, setBusy] = useState(false); + const [showSaveBeforeTest, setShowSaveBeforeTest] = useState(false); + const [fixPrefixExpanded, setFixPrefixExpanded] = useState(false); + const [editSessionId, setEditSessionId] = useState(workflow.edit_agent_session_id || null); + const [seedSent, setSeedSent] = useState(false); + // Clear the fix seed after the view unmounts so re-entering edit_agent + // (without going through Fix-with-Agent) doesn't re-show the prefix. + useEffect(() => () => { dispatch(clearFixSeed(workflow.id)); }, [dispatch, workflow.id]); + + // Spawn (or reattach to) the sticky Edit Agent session on mount. + useEffect(() => { + if (editSessionId) return; + let alive = true; + (async () => { + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/edit-agent-session`, { + method: 'POST', + headers: tok ? { Authorization: `Bearer ${tok}` } : {}, + }); + if (!res.ok) return; + const data = await res.json(); + const sid = data?.session_id as string | undefined; + if (!sid || !alive) return; + try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may not be hydrated yet */ } + if (alive) setEditSessionId(sid); + } catch { /* best-effort */ } + })(); + return () => { alive = false; }; + }, [editSessionId, workflow.id, dispatch]); + + // First-turn seed: post the hidden opener so the agent's first reply + // is the friendly "How would you like to modify the workflow..." prompt + // (or, in fix mode, an analysis of the failure context). + const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined); + useEffect(() => { + if (!editSessionId || !editSession || seedSent) return; + const msgs = editSession.messages || []; + if (msgs.length > 0) { + setSeedSent(true); + return; + } + const seed = isFixMode && fixSeed + ? `The most recent run failed on Step ${fixSeed.stepIdx + 1} (${fixSeed.stepLabel}). Error: ${fixSeed.error}\n\nWalk me through what likely went wrong and propose a concrete prompt change for that step.` + : 'Greet me briefly, then ask: "How would you like to modify the workflow (e.g. filter out spam emails before summarizing)?"'; + setSeedSent(true); + (async () => { + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(editSessionId)}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, + body: JSON.stringify({ prompt: seed, hidden: true }), + }); + } catch { /* best-effort */ } + })(); + }, [editSessionId, editSession, seedSent, isFixMode, fixSeed]); + + const onClose = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + }, [dispatch, workflow.id]); + + const onTest = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/test-run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, + body: JSON.stringify({ steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label || null })) }), + }); + if (!res.ok) return; + const data = await res.json(); + const sessionId = data?.session_id as string | undefined; + if (!sessionId) return; + try { + const { store } = await import('@/shared/state/store'); + if (!store.getState().agents.sessions[sessionId]) { + try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ } + } + if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) { + dispatch(placeCard({ + sessionId, + x: wfCardPos.x + wfCardPos.width + 60, + y: wfCardPos.y, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + expandedSessionIds, + })); + } + dispatch(setPendingFocusAgentId(sessionId)); + } catch { /* best-effort */ } + dispatch(setCardSidecar({ workflowId: workflow.id, sessionId, kind: 'testing' })); + } finally { + setBusy(false); + } + }, [busy, workflow.id, steps, dispatch, wfCardPos, expandedSessionIds]); + + const onTestClick = useCallback(() => { + // No local draft to warn about anymore (the Edit Agent's tool will + // mutate workflow.steps directly when wired). Skip the modal for now. + void onTest(); + }, [onTest]); + void showSaveBeforeTest; void setShowSaveBeforeTest; + + return ( + + + + + + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit', editFacet: 'Actions' } }))} + role="button" + sx={{ + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + width: 28, height: 28, borderRadius: 999, + color: c.text.secondary, cursor: 'pointer', + '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated }, + }}> + + + + + + + Test + + + } + onClick={onClose} + tone="muted" + /> + } + onClick={onClose} + tone="filled" + /> + + + {isFixMode && fixSeed && setFixPrefixExpanded((x) => !x)} />} + {/* Embedded real Edit Agent chat. AgentChat owns the composer + + message list + tool-call card rendering, matching Image #48 + (MCP Activation, Gmail Query, etc.). embedded=true tells it to + skip its own dashboard chrome since we own the surrounding card. */} + + {editSessionId ? ( + + ) : ( + + Starting the Edit Agent... + + )} + + + {}} maxWidth="sm" fullWidth> + + + + ); +} + +function FixPrefixCard({ seed, expanded, onToggle }: { seed: { stepIdx: number; stepLabel: string; error: string }; expanded: boolean; onToggle: () => void }) { + const c = useClaudeTokens(); + const PREVIEW_MAX = 110; + const needsExpand = (seed.error || '').length > PREVIEW_MAX; + const shown = !needsExpand || expanded + ? seed.error + : (seed.error || '').slice(0, PREVIEW_MAX).trimEnd() + '...'; + return ( + + + + + + + + Fixing Step {seed.stepIdx + 1}: {seed.stepLabel} + + {needsExpand && ( + + )} + + + {shown} + + + + ); +} + +function HeaderBtn({ label, icon, onClick, tone, disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone: 'muted' | 'filled'; disabled?: boolean }) { + const c = useClaudeTokens(); + const filled = tone === 'filled'; + return ( + + {icon} + {label} + + ); +} diff --git a/frontend/src/app/pages/Workflows/GeneralFacet.tsx b/frontend/src/app/pages/Workflows/GeneralFacet.tsx new file mode 100644 index 00000000..72a4e038 --- /dev/null +++ b/frontend/src/app/pages/Workflows/GeneralFacet.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import InputBase from '@mui/material/InputBase'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { fetchSession, resumeSession } from '@/shared/state/agentsSlice'; +import { + DEFAULT_CARD_H, + DEFAULT_CARD_W, + placeCard, +} from '@/shared/state/dashboardLayoutSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import { store } from '@/shared/state/store'; +import type { Workflow } from '@/shared/state/workflowsSlice'; +import { FieldRow, BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon'; + +export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const sourceSessionId = draft.source_session_id || null; + // Open the source chat: fetch if missing, fall through to resume if + // it was closed, place a card if there isn't one. That's it. No pan + // animation, no focus pin, no dashboard_id patching, no auto-clear + // timers. Match the way any other chat opens on the canvas; let the + // user scroll to it. + const openSourceChat = React.useCallback(async () => { + if (!sourceSessionId) return; + const sid = sourceSessionId; + if (!store.getState().agents.sessions[sid]) { + try { + await dispatch(fetchSession(sid)).unwrap(); + } catch { + try { + await dispatch(resumeSession({ sessionId: sid })).unwrap(); + } catch { + return; + } + } + } + if (!store.getState().dashboardLayout.cards[sid]) { + dispatch(placeCard({ + sessionId: sid, + x: 400, y: 200, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + })); + } + // Pan the canvas to the chat card so the user can see it. Safe to + // do here because the active element is the Edit button, not a + // textarea: handleCardSelect's input-aware blur guard prevents the + // focus animation from killing typing focus in a separate flow. + dispatch(setPendingFocusAgentId(sid)); + }, [sourceSessionId, dispatch]); + return ( + + + setDraft({ ...draft, title: e.target.value })} + sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }} + /> + + + setDraft({ ...draft, description: e.target.value })} + sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.secondary, lineHeight: 1.5, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }} + /> + + + + + {!draft.use_synced_prompt && ( + setDraft({ ...draft, system_prompt: e.target.value })} + sx={{ fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, p: 1, lineHeight: 1.5 }} + /> + )} + + Workflow + {sourceSessionId && ( + + + Edit + + )} + + + {draft.steps.map((s, idx) => ( + + {idx + 1} + { + const next = [...draft.steps]; + next[idx] = { ...s, text: e.target.value }; + setDraft({ ...draft, steps: next }); + }} + sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.6, lineHeight: 1.4 }} + /> + + ))} + + + ); +} diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx new file mode 100644 index 00000000..348b8b1f --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -0,0 +1,431 @@ +import React, { useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import Popover from '@mui/material/Popover'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import type { Workflow } from '@/shared/state/workflowsSlice'; +import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice'; +import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel } from './scheduleUtils'; + +interface Props { + view: 'Week' | 'Month' | 'List'; + density: 'compact' | 'roomy'; + onSelectWorkflow?: (id: string) => void; + refDate?: Date; +} + +// Both compact (popover) and roomy (hub) show the full 24 hours scrollable — +// the user explicitly wants midnight visible at the top, not "9am" as the +// starting hour. The scroll container caps the visible window. +const HOURS_24 = Array.from({ length: 24 }, (_, i) => i); + +export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const workflows = useAppSelector((s) => Object.values(s.workflows.items)); + // Right-click menu: pinned position + the workflow whose pill was + // clicked. Same anchor pattern as MUI's menu examples. + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null); + const closeMenu = () => setCtxMenu(null); + const onRunNow = () => { + if (!ctxMenu) return; + dispatch(runWorkflowNow(ctxMenu.workflow.id)); + closeMenu(); + }; + const onPauseToggle = () => { + if (!ctxMenu) return; + const wf = ctxMenu.workflow; + dispatch(updateWorkflow({ + id: wf.id, + patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any }, + ifMatch: wf.updated_at || null, + })); + closeMenu(); + }; + const onEdit = () => { + if (!ctxMenu) return; + dispatch(addWorkflowCard({ workflowId: ctxMenu.workflow.id })); + // Right-click "Edit" on a calendar entry opens the new Edit Agent + // chat view, matching the post-revamp design (Image #38). + dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit_agent' })); + closeMenu(); + }; + const onDelete = () => { + if (!ctxMenu) return; + const ok = window.confirm(`Delete "${ctxMenu.workflow.title}"? Scheduled runs will stop.`); + if (!ok) { closeMenu(); return; } + dispatch(deleteWorkflow(ctxMenu.workflow.id)); + closeMenu(); + }; + const ctxMenuEl = ( + + Run now + {ctxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'} + Edit… + Delete + + ); + // refDate is recreated on every render unless the caller memoizes it, + // which then trips the eventsByDay memo every paint. Pin the calendar + // to a day-precision key so the heavy fireTimesWithin loop only re-runs + // when the day or workflow set actually changed. + const today = refDate || new Date(); + const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + const compact = density === 'compact'; + + const eventsByDay = useMemo(() => { + const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14; + const start = view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : today; + const end = addDays(start, range - 1); + const map = new Map(); + for (const wf of workflows) { + if (!wf.schedule.enabled) continue; + const fires = fireTimesWithin(wf, start, end, 60); + for (const d of fires) { + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const arr = map.get(key) || []; + arr.push({ workflow: wf, date: d }); + map.set(key, arr); + } + } + return { map, start, end }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflows, view, dayKey]); + + const SLOT_H = compact ? 32 : 44; + const ROW_LABEL = compact ? '0.7rem' : '0.74rem'; + const DAY_NUM = compact ? '0.95rem' : '1.15rem'; + const DAY_LABEL = compact ? '0.66rem' : '0.72rem'; + const EVENT_FS = compact ? '0.7rem' : '0.78rem'; + + if (view === 'Week') { + const start = startOfWeek(today); + const days = Array.from({ length: 7 }, (_, i) => addDays(start, i)); + const HOURS = HOURS_24; + // Prefer the short zone name ("PDT", "EST", "JST") so the label + // reads in plain English instead of "GMT-7". formatToParts is wide- + // supported; if it ever fails we degrade silently rather than show + // a confusing fallback. + const TZ_LABEL = (() => { + try { + const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date()); + return parts.find((p) => p.type === 'timeZoneName')?.value || ''; + } catch { return ''; } + })(); + return ( + + {/* Day headers: muted weekday caps; today's date gets the filled circle */} + + + {!compact && ( + {TZ_LABEL} + )} + + {days.map((d) => { + const isToday = sameDay(d, today); + return ( + + + {WEEKDAY_LABEL_SHORT[d.getDay()]} + + {d.getDate()} + + ); + })} + + + {HOURS.map((hour, hourIdx) => ( + + {/* Hour label sits inside its row (top-aligned) rather than + straddling the line above it; that way the first row + doesn't clip "12 AM" and the labels never drift when the + body scrolls. Apple Calendar does the same. */} + + {formatHourLabel(hour)} + + {days.map((d) => { + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour); + const targetWeekday = d.getDay(); + return ( + { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} + onDrop={(e) => { + e.preventDefault(); + const wid = e.dataTransfer.getData('application/x-workflow-id'); + if (!wid) return; + const wf = workflows.find((w) => w.id === wid); + if (!wf) return; + // Build the patched schedule: new hour, and for + // weekly schedules swap on_days to just the target + // weekday. Daily/monthly only get the new hour. + const sched = { ...wf.schedule, hour } as typeof wf.schedule; + if (sched.repeat_unit === 'week') sched.on_days = [targetWeekday]; + dispatch(updateWorkflow({ + id: wf.id, + patch: { schedule: sched as any }, + ifMatch: wf.updated_at || null, + })); + }} + sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative' }}> + { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: wf }); }} + /> + + ); + })} + + ))} + + {ctxMenuEl} + + ); + } + + if (view === 'Month') { + const start = startOfMonthGrid(today); + const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i)); + const accent = c.accent.primary; + return ( + + {/* Sticky weekday header so it stays visible even when the + calendar body scrolls. Slightly bigger + tinted bg so it + reads cleanly in both light and dark themes. */} + + {WEEKDAY_LABEL_SHORT.map((l, i) => ( + {l} + ))} + + + {cells.map((d) => { + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const evs = eventsByDay.map.get(key) || []; + const isToday = sameDay(d, today); + const inMonth = d.getMonth() === today.getMonth(); + return ( + + + {/* Out-of-month dates still need to be legible (Apple + Calendar shows them in a muted shade, not invisible). + Color tweak instead of opacity so dark themes stay + readable. */} + {d.getDate()} + + {evs.slice(0, compact ? 3 : 4).map((e, idx) => ( + onSelectWorkflow?.(e.workflow.id)} + onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }} + sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}> + + {formatTime(e.date.getHours(), e.date.getMinutes())} + {e.workflow.title} + + ))} + {evs.length > (compact ? 3 : 4) && ( + +{evs.length - (compact ? 3 : 4)} more + )} + + ); + })} + + {ctxMenuEl} + + ); + } + + // Apple-Calendar-style list: big day number + weekday on the left, a + // vertical colored bar separating it from events on the right. Today + // renders even with no events (shows a "No events today" placeholder) + // so the list doesn't feel empty for new users. + const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = []; + for (let i = 0; i < 14; i += 1) { + const day = addDays(today, i); + const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`; + const arr = eventsByDay.map.get(key) || []; + const isToday = sameDay(day, today); + if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday }); + } + const accent = c.accent.primary; + return ( + + {upcoming.length === 0 && ( + No scheduled workflows + )} + {upcoming.map(({ date, events, isToday }, rowIdx) => ( + + + + {date.getDate()} + + + + {date.toLocaleString('en', { month: 'short' })} + + {WEEKDAY_FULL[date.getDay()]} + + + + {events.length === 0 && ( + No events today + )} + {events.map((e, idx) => ( + } placement="right" arrow> + onSelectWorkflow?.(e.workflow.id)} + onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }} + sx={{ + display: 'flex', alignItems: 'center', gap: 1.25, + py: 0.4, + fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer', + '&:hover .ev-title': { color: accent }, + }}> + + + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} + + + + ))} + + + ))} + {ctxMenuEl} + + ); +} + +// Apple Calendar style event chip: 3px colored left-bar + faintly-tinted +// background + readable text. One chip per cell with a "+N" badge for +// overflow; clicking it opens a popover listing all events that hour. +function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow }: { + events: { workflow: Workflow; date: Date }[]; + onSelectWorkflow?: (id: string) => void; + eventFontSize: string; + onContextWorkflow?: (workflow: Workflow, e: React.MouseEvent) => void; +}) { + const c = useClaudeTokens(); + const [anchor, setAnchor] = useState(null); + if (events.length === 0) return null; + const first = events[0]; + const rest = events.slice(1); + const accent = c.accent.primary; + + // Time string is part of the chip so a glance tells you both what and + // when, matching Apple's "Title, 1pm" pattern. Chip is slim (height ~22) + // not slot-stretching, since OpenSwarm events fire at a single instant. + const timeLabel = formatTime(first.date.getHours(), first.date.getMinutes()); + return ( + <> + } placement="top" arrow> + { + e.dataTransfer.setData('application/x-workflow-id', first.workflow.id); + e.dataTransfer.effectAllowed = 'move'; + }} + onClick={() => onSelectWorkflow?.(first.workflow.id)} + onContextMenu={(e) => onContextWorkflow?.(first.workflow, e)} + sx={{ + position: 'absolute', + left: 2, right: rest.length > 0 ? 24 : 2, top: 2, + height: 22, + bgcolor: accent + '14', + color: c.text.primary, + borderLeft: `3px solid ${accent}`, + borderRadius: '4px', + px: 0.65, py: 0, + fontSize: eventFontSize, fontWeight: 500, + overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', + cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 0.5, + '&:hover': { bgcolor: accent + '22' }, + }}> + {first.workflow.title} + {timeLabel} + + + {rest.length > 0 && ( + setAnchor(e.currentTarget)} + role="button" + sx={{ + position: 'absolute', + right: 2, top: 2, + height: 22, + minWidth: 20, px: 0.4, + bgcolor: accent + '22', + color: accent, + borderRadius: '4px', + fontSize: eventFontSize, fontWeight: 700, + cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', + '&:hover': { bgcolor: accent + '33' }, + }}> + +{rest.length} + + )} + setAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }}> + + + {events.length} runs at this hour + + {events.map((e, idx) => ( + { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }} + sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> + + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} + + ))} + + + + ); +} + +function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) { + const wf = event.workflow; + const status = wf.last_run_status; + const cost = wf.cost_estimate?.last_run_usd; + const monthly = wf.cost_estimate?.monthly_usd; + return ( + +
{wf.title}
+
{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}
+ {status &&
{`Last run: ${status}`}
} + {typeof cost === 'number' && cost > 0 &&
{`Last run cost: $${cost.toFixed(4)}`}
} + {typeof monthly === 'number' && monthly > 0 &&
{`Est. monthly: $${monthly.toFixed(2)}`}
} +
+ ); +} diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx new file mode 100644 index 00000000..2bce61ff --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -0,0 +1,476 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import InputBase from '@mui/material/InputBase'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Switch from '@mui/material/Switch'; +import Tooltip from '@mui/material/Tooltip'; +import RepeatIcon from '@mui/icons-material/RepeatRounded'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import BedtimeIcon from '@mui/icons-material/BedtimeOutlined'; +import NotificationsIcon from '@mui/icons-material/NotificationsNoneRounded'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice'; +import { WEEKDAY_LABEL, formatTime } from './scheduleUtils'; +import { nextTierAfter } from './permissionsUtils'; +import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon'; + +function jsWeekday(d: Date): number { return d.getDay(); } + +// Turn an IANA zone string into something a non-dev can parse. "local" +// (legacy) or the host's own zone collapse to "your time"; otherwise +// show "Pacific Time" / "Eastern Time" / etc. when we can resolve a +// short name via Intl, falling back to the raw IANA name if not. +function friendlyTzLabel(tz: string): string { + if (!tz || tz === 'local') return 'your time'; + try { + const host = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (tz === host) { + const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date()); + const name = parts.find((p) => p.type === 'timeZoneName')?.value || ''; + return name ? `your time (${name.replace(' Standard Time', '').replace(' Daylight Time', '')})` : 'your time'; + } + const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date()); + const name = parts.find((p) => p.type === 'timeZoneName')?.value || ''; + return name || tz; + } catch { + return tz; + } +} + +function lastDayOfMonthFE(year: number, monthZeroBased: number): number { + return new Date(year, monthZeroBased + 1, 0).getDate(); +} + +// Compute the next fire time from a ScheduleConfig. Mirrors the backend +// math in scheduler.py:_next_fire_after using browser-local time so the +// preview lines up with what the user will actually see on their system +// clock. Honors ends_at + max_runs so the "Next run" line doesn't lie +// after the schedule has expired. +function previewNextRun(sched: ScheduleConfig): Date | null { + if (!sched.enabled) return null; + const now = new Date(); + if (sched.ends_at) { + const ends = new Date(sched.ends_at); + if (!Number.isNaN(ends.getTime()) && ends.getTime() <= now.getTime()) return null; + } + if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return null; + let candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), sched.hour, sched.minute, 0, 0); + if (candidate <= now) candidate = new Date(candidate.getTime() + 86400000); + if (sched.repeat_unit === 'day') { + const step = Math.max(1, sched.repeat_every); + while (candidate <= now) candidate = new Date(candidate.getTime() + step * 86400000); + return candidate; + } + if (sched.repeat_unit === 'week') { + const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)]; + for (let i = 0; i < 14; i += 1) { + if (allowed.includes(jsWeekday(candidate)) && candidate > now) return candidate; + candidate = new Date(candidate.getTime() + 86400000); + } + return candidate; + } + if (sched.repeat_unit === 'month') { + const step = Math.max(1, sched.repeat_every); + const startDay = now.getDate(); + let year = now.getFullYear(); + let month = now.getMonth(); + let guard = 0; + while (guard < 60) { + const day = Math.min(startDay, lastDayOfMonthFE(year, month)); + const c = new Date(year, month, day, sched.hour, sched.minute, 0, 0); + if (c > now) return c; + month += step; + year += Math.floor(month / 12); + month = ((month % 12) + 12) % 12; + guard += 1; + } + return null; + } + return null; +} + +function formatNextRun(d: Date): string { + const wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()]; + const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()]; + return `${wd} ${mo} ${d.getDate()} at ${formatTime(d.getHours(), d.getMinutes())}`; +} + +type EndKind = 'forever' | 'on_date' | 'after_n'; + +function endKindFromSched(s: ScheduleConfig): EndKind { + if (s.ends_at) return 'on_date'; + if (s.max_runs != null) return 'after_n'; + return 'forever'; +} + +interface AppOpenInfo { + alwaysOn: boolean; // tray + login both configured + loginAtLaunch: boolean; + trayEnabled: boolean; +} + +function useAppOpenInfo(): { info: AppOpenInfo; fix: () => Promise } { + const [info, setInfo] = useState({ alwaysOn: false, loginAtLaunch: false, trayEnabled: false }); + useEffect(() => { + let alive = true; + const w: any = (window as any).openswarm; + if (!w?.getAppOpenInfo) return; + w.getAppOpenInfo().then((res: AppOpenInfo) => { if (alive) setInfo(res); }).catch(() => {}); + return () => { alive = false; }; + }, []); + const fix = useCallback(async () => { + const w: any = (window as any).openswarm; + if (!w?.setLoginItem || !w?.enableTray) return; + await w.setLoginItem(true); + await w.enableTray(true); + if (w.getAppOpenInfo) { + const next = await w.getAppOpenInfo(); + setInfo(next); + } + }, []); + return { info, fix }; +} + +export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const s = draft.schedule; + const cloudSms = useAppSelector((st) => (st as any).workflows?.cloudSmsEnabled); + + useEffect(() => { dispatch(fetchCloudSmsStatus()); }, [dispatch]); + + // No silent enable-on-edit. The master Switch is now the single source + // of truth for whether this schedule is armed. + const setSched = useCallback((patch: Partial) => { + setDraft({ ...draft, schedule: { ...s, ...patch } }); + }, [draft, s, setDraft]); + + const addBackup = useCallback(() => { + const tiers = [...(draft.permissions || [])]; + const next = nextTierAfter(tiers); + if (!next) return; + tiers.push(next); + setDraft({ ...draft, permissions: tiers }); + }, [draft, setDraft]); + + const removeTier = useCallback((idx: number) => { + // Drop the removed tier AND all following tiers so the chain stays + // contiguous (no "call" without "text" before it). + const tiers = (draft.permissions || []).slice(0, idx); + setDraft({ ...draft, permissions: tiers }); + }, [draft, setDraft]); + + const setTier = useCallback((idx: number, patch: Partial) => { + const tiers = [...(draft.permissions || [])]; + tiers[idx] = { ...tiers[idx], ...patch }; + setDraft({ ...draft, permissions: tiers }); + }, [draft, setDraft]); + + const canAddBackup = ((draft.permissions || [])[ (draft.permissions || []).length - 1 ]?.kind || 'notify') !== 'call'; + const endKind = endKindFromSched(s); + const nextPreview = useMemo(() => previewNextRun(s), [s]); + const { info: appOpen, fix: fixAppOpen } = useAppOpenInfo(); + + const setEndKind = (k: EndKind) => { + if (k === 'forever') setSched({ ends_at: null, max_runs: null }); + else if (k === 'on_date') setSched({ ends_at: new Date(Date.now() + 7 * 86400000).toISOString(), max_runs: null }); + else setSched({ ends_at: null, max_runs: 10 }); + }; + + return ( + + {/* Master on/off. */} + + setSched({ enabled: e.target.checked })} /> + + {s.enabled ? 'Schedule is on' : 'Schedule is off'} + + + + {s.enabled && ( + + )} + + {/* Section: When should this workflow run? */} + + + When should this workflow run? + + + Repeat every + setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })} + sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + + + {s.repeat_unit === 'week' && ( + + ↳ on + {WEEKDAY_LABEL.map((label, idx) => { + const active = s.on_days.includes(idx); + return ( + setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })} + role="button" + sx={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label} + ); + })} + + )} + + At + + : + + + {friendlyTzLabel(s.timezone)} + + {nextPreview && s.enabled && ( + + Next run: {formatNextRun(nextPreview)} + + )} + + Runs + + {endKind === 'on_date' && ( + { + const v = e.target.value; + setSched({ ends_at: v ? new Date(v + 'T23:59:59').toISOString() : null }); + }} + sx={{ fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + )} + {endKind === 'after_n' && ( + + setSched({ max_runs: Math.max(1, Number(e.target.value) || 1) })} + sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + runs ({s.runs_count} so far) + + )} + + {(() => { + if (endKind === 'on_date' && s.ends_at) { + const ends = new Date(s.ends_at).getTime(); + if (!Number.isNaN(ends) && ends <= Date.now()) { + return ( + + This date is in the past. The schedule will turn itself off. + + ); + } + } + if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) { + return ( + + This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm. + + ); + } + return null; + })()} + + If missed + + + + + {/* Section: What can the agent do? */} + + + What can the agent do? + + + + + {/* Section: How should the agent ask for your permission? */} + + + How should the agent ask for your permission? + + {(draft.permissions || []).map((tier, idx) => ( + setTier(idx, patch)} + onRemove={idx === 0 ? undefined : () => removeTier(idx)} + /> + ))} + {canAddBackup && ( + + Escalate if I don't respond + )} + + + ); +} + +function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; hour: number; minute: number; onFix: () => void }) { + const c = useClaudeTokens(); + const good = info.alwaysOn; + const fmt = formatTime(hour, minute); + return ( + + + + {good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`} + + {!good && ( + + Always-on + + )} + + ); +} + +function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: { + idx: number; + tier: PermissionTier; + cloudSmsEnabled: boolean; + onChange: (p: Partial) => void; + onRemove?: () => void; +}) { + const c = useClaudeTokens(); + if (idx === 0) { + return ( + + ); + } + const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes'; + return ( + + + after + onChange({ after_minutes: Math.max(0, Number(e.target.value) || 0) })} + sx={{ width: 44, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + {unitLabel} + + + + at + onChange({ phone: e.target.value })} + sx={{ flex: 1, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4, color: c.text.primary }} + /> + {onRemove && ( + × + )} + + {!cloudSmsEnabled && ( + + Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge. + + )} + + ); +} diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx new file mode 100644 index 00000000..8f9bad29 --- /dev/null +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import InputBase from '@mui/material/InputBase'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import BookmarkIcon from '@mui/icons-material/BookmarkBorderRounded'; +import SearchIcon from '@mui/icons-material/Search'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded'; +import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import AddIcon from '@mui/icons-material/Add'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppSelector } from '@/shared/hooks'; +import ScheduleCalendar from './ScheduleCalendar'; +import { addDays, startOfWeek } from './scheduleUtils'; + +type Mode = 'search' | 'schedule'; + +interface Props { + mode: Mode; + onModeChange: (m: Mode) => void; + historyResults: { id: string; name: string; closed_at: string | null }[]; + historyLoading: boolean; + historyQuery: string; + onHistoryQueryChange: (q: string) => void; + onHistorySelect: (id: string) => void; + onNewChat: () => void; + onWorkflowSelect: (id: string) => void; + onExpand: () => void; + historyScrollRef?: React.RefObject; + onHistoryScroll?: () => void; + /** When true, hides the internal Search/Schedule chips + redundant "+ New" + * pill. The new DashboardToolbar pills above the popover replace them. */ + hideTopChrome?: boolean; +} + +export default function SchedulePopover({ + mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange, + onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll, + hideTopChrome = false, +}: Props) { + const c = useClaudeTokens(); + const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week'); + const [refDate, setRefDate] = useState(() => new Date()); + const workflows = useAppSelector((s) => s.workflows.items); + + const periodLabel = useMemo(() => { + if (calendarView === 'Month') { + return refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); + } + if (calendarView === 'Week') { + const start = startOfWeek(refDate); + const end = addDays(start, 6); + const sameMonth = start.getMonth() === end.getMonth(); + const startStr = start.toLocaleString('en', { month: 'short', day: 'numeric' }); + const endStr = sameMonth + ? String(end.getDate()) + : end.toLocaleString('en', { month: 'short', day: 'numeric' }); + return `${startStr} – ${endStr}, ${end.getFullYear()}`; + } + return refDate.toLocaleString('en', { month: 'long', day: 'numeric', year: 'numeric' }); + }, [refDate, calendarView]); + + const onPrev = useCallback(() => { + setRefDate((d) => addDays(d, calendarView === 'Month' ? -28 : calendarView === 'Week' ? -7 : -1)); + }, [calendarView]); + const onNext = useCallback(() => { + setRefDate((d) => addDays(d, calendarView === 'Month' ? 28 : calendarView === 'Week' ? 7 : 1)); + }, [calendarView]); + + const workflowIconMap = useMemo(() => { + const m: Record = {}; + for (const wf of Object.values(workflows)) { + if (wf.source_session_id) m[wf.source_session_id] = wf.icon || wf.title.slice(0, 1).toUpperCase(); + } + return m; + }, [workflows]); + + // Both Search and Schedule modes render at the same fixed dimensions so + // toggling chips doesn't resize the popover. Schedule sets the floor: + // its 7-day calendar needs ~620w x ~420h, search inherits the same. + const POPOVER_W = 620; + const CONTENT_H = 420; + + return ( + + {/* Floating mode chips. Hidden when the parent toolbar supplies its + own pill row (Image #32 / #54); kept around so the legacy callers + that surface Schedule mode still have a way in. */} + {!hideTopChrome && ( + + } active={mode === 'search'} onClick={() => onModeChange('search')} /> + } active={mode === 'schedule'} onClick={() => onModeChange('schedule')} /> + + )} + + {/* Content card — separately bordered/rounded, like image #30. + Inner content crossfades on tab switch so search↔schedule isn't + a jarring jump. Outer card stays fixed-size (W×H) so the toolbar + doesn't reflow. */} + + + + {mode === 'search' && ( + + + + onHistoryQueryChange(e.target.value)} + placeholder="Search past chats..." + sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }} + /> + {!hideTopChrome && ( + + + New + + )} + + + {historyResults.length === 0 && !historyLoading && ( + {historyQuery ? 'No matching chats' : 'No chat history yet'} + )} + {historyResults.map((entry) => { + const hasWorkflow = Boolean(workflowIconMap[entry.id]); + return ( + onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> + {entry.name} + {/* Only annotate chats that became saved workflows. + A small workflow glyph reads as a tag, where the + old single-letter chip read as a random initial. */} + {hasWorkflow && ( + + + + + + )} + {relTime(entry.closed_at)} + + ); + })} + + + )} + + {mode === 'schedule' && ( + + + {(['Week', 'Month', 'List'] as const).map((v) => ( + setCalendarView(v)} role="button" sx={{ fontSize: '0.85rem', fontWeight: calendarView === v ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: calendarView === v ? c.text.primary : c.text.muted, borderBottom: `2px solid ${calendarView === v ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{v} + ))} + + + + Expand + + + {/* Period nav: Today pill, prev/next chevrons, range label. + Apple Calendar pattern. Keeps the popover usable without + forcing a full Expand for date browsing. */} + + setRefDate(new Date())} + role="button" + sx={{ + fontSize: '0.78rem', fontWeight: 600, color: c.text.secondary, + border: `1px solid ${c.border.subtle}`, px: 0.95, py: 0.3, + borderRadius: `${c.radius.md}px`, cursor: 'pointer', + '&:hover': { color: c.text.primary, borderColor: c.border.medium }, + }}>Today + + + {periodLabel} + + + + + + )} + + + + + ); +} + +// Floating chip rendered ABOVE the popover card (image #30). Active gets a +// subtle filled-elevated bg + 1px border; inactive is borderless ghost. +function ModeChip({ label, icon, active, onClick }: { label: string; icon: React.ReactNode; active: boolean; onClick: () => void }) { + const c = useClaudeTokens(); + return ( + + {icon} + {label} + + ); +} + +function relTime(iso: string | null): string { + if (!iso) return ''; + const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (sec < 60) return 'just now'; + const m = Math.floor(sec / 60); if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} diff --git a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx new file mode 100644 index 00000000..d02fedde --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx @@ -0,0 +1,240 @@ +// Minimum-steps-to-value entry point: from any open chat, hit "Schedule" +// in the header, pick one of four presets, and we materialize a workflow +// seeded with source_session_id (so it inherits the chat's tool surface +// + steps via the existing /workflows/create path). "Custom..." opens a +// LOCAL draft card instead of immediately POSTing /workflows/create, so +// users who change their mind don't leave behind an orphan workflow. + +import React, { useCallback, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Popover from '@mui/material/Popover'; +import InputBase from '@mui/material/InputBase'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { createWorkflow, openWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice'; +import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { defaultSchedule } from './scheduleUtils'; + +type Preset = { + label: string; + hint: string; + build: () => Partial; +}; + +const PRESETS: Preset[] = [ + { label: 'Every day at 9am', hint: 'Daily standup, morning report', build: () => ({ enabled: true, repeat_unit: 'day', repeat_every: 1, hour: 9, minute: 0 }) }, + { label: 'Weekdays at 9am', hint: 'Mon to Fri', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour: 9, minute: 0 }) }, + { label: 'Every Monday at 9am', hint: 'Weekly check-in', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1], hour: 9, minute: 0 }) }, + { label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) }, +]; + +interface Props { + anchorEl: HTMLElement | null; + onClose: () => void; + sessionId: string; + sessionName: string; + // Hook so the caller can show "Workflow created" feedback inline. + onCreated?: (workflowId: string) => void; + // Auto-suggest path: when the caller detected time-words and wants to + // pre-fill the popover with that exact schedule, the first preset + // shown becomes "Use suggestion: