mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] merge/scheduled-tasks: reconcile toast, keep our running-now popup, drop duplicate ScheduledRunToast
This commit is contained in:
@@ -9,9 +9,15 @@ from fastapi.responses import JSONResponse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Soft per-session throttle so the integration suggestion can fire on any turn
|
||||
# without nagging every message; only stamped when a suggestion actually emits.
|
||||
MCP_SUGGEST_COOLDOWN_S = 300.0
|
||||
p_mcp_suggest_cooldown: dict[str, float] = {}
|
||||
|
||||
# Dedup concurrent generate-group-meta calls; collapses the 429 thundering herd by sharing one upstream Future per (session, group).
|
||||
_group_meta_inflight: dict[tuple[str, str], asyncio.Future] = {}
|
||||
|
||||
@@ -74,24 +80,26 @@ async def send_message(session_id: str, body: dict):
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
|
||||
# Run MCP-suggestion classifier in parallel with the agent launch; fails open.
|
||||
# Fires on any turn, but a per-session cooldown keeps it from nagging every message.
|
||||
try:
|
||||
from backend.apps.agents.core.mcp_preflight import run_preflight
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as _ws
|
||||
last_suggested = p_mcp_suggest_cooldown.get(session_id, 0.0)
|
||||
if time.monotonic() - last_suggested >= MCP_SUGGEST_COOLDOWN_S:
|
||||
from backend.apps.agents.core.mcp_preflight import run_preflight
|
||||
|
||||
async def _emit_preflight():
|
||||
try:
|
||||
result = await run_preflight(prompt, task_id=session_id)
|
||||
if result.get("suggestions") or result.get("is_vague"):
|
||||
await _ws.send_to_session(session_id, "agent:mcp_suggestions", {
|
||||
"session_id": session_id,
|
||||
"suggestions": result.get("suggestions", []),
|
||||
"is_vague": bool(result.get("is_vague")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
async def _emit_preflight():
|
||||
try:
|
||||
result = await run_preflight(prompt, task_id=session_id)
|
||||
if result.get("suggestions"):
|
||||
p_mcp_suggest_cooldown[session_id] = time.monotonic()
|
||||
await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", {
|
||||
"session_id": session_id,
|
||||
"suggestions": result.get("suggestions", []),
|
||||
"is_vague": bool(result.get("is_vague")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import asyncio as _asyncio
|
||||
_asyncio.create_task(_emit_preflight())
|
||||
asyncio.create_task(_emit_preflight())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
@@ -23,6 +24,20 @@ PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
|
||||
|
||||
|
||||
def _local_timezone_name() -> str:
|
||||
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:
|
||||
return (getattr(ZoneInfo(name), "key", None) or "UTC") if name else "UTC"
|
||||
except ZoneInfoNotFoundError:
|
||||
return "UTC"
|
||||
|
||||
|
||||
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]},
|
||||
@@ -68,7 +83,7 @@ TOOLS = [
|
||||
"items": {"type": "integer"},
|
||||
"description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.",
|
||||
},
|
||||
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's local zone."},
|
||||
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's current local zone at scheduling time."},
|
||||
"source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."},
|
||||
},
|
||||
"required": ["title", "steps", "preset"],
|
||||
@@ -253,7 +268,8 @@ def _call(method: str, path: str, body=None) -> dict:
|
||||
|
||||
|
||||
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}
|
||||
local_tz = _local_timezone_name()
|
||||
base = {"timezone": args.get("timezone") or local_tz, "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
|
||||
if preset == "custom":
|
||||
return {
|
||||
**base,
|
||||
@@ -263,7 +279,6 @@ def _build_schedule_from_preset(preset: str, args: dict) -> dict:
|
||||
"hour": int(args.get("hour", 9)),
|
||||
"minute": int(args.get("minute", 0)),
|
||||
"on_days": list(args.get("on_days") or []),
|
||||
"timezone": args.get("timezone") or "local",
|
||||
}
|
||||
preset_def = PRESETS.get(preset)
|
||||
if not preset_def:
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import tempfile
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException, Query, UploadFile, File
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -292,6 +293,21 @@ async def put_app_theme_override(body: AppThemeOverridePayload):
|
||||
return {"ok": True, "mode": current.app_template_theme_override}
|
||||
|
||||
|
||||
class DismissMcpSuggestionPayload(BaseModel):
|
||||
ids: list[str]
|
||||
|
||||
|
||||
@settings.router.put("/dismiss-mcp-suggestion")
|
||||
async def put_dismiss_mcp_suggestion(body: DismissMcpSuggestionPayload):
|
||||
"""MERGE dismissed integration suggestions; the general PUT replaces the whole object and would blank secrets."""
|
||||
current = load_settings()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for tool_id in body.ids:
|
||||
current.dismissed_mcp_suggestions[tool_id] = now
|
||||
await save_settings_async(current)
|
||||
return {"ok": True, "settings": current.model_dump()}
|
||||
|
||||
|
||||
@settings.router.get("/default-system-prompt")
|
||||
async def get_default_system_prompt():
|
||||
return {"default_system_prompt": DEFAULT_SYSTEM_PROMPT}
|
||||
|
||||
@@ -25,6 +25,30 @@ _running: dict[str, str] = {}
|
||||
_running_lock = asyncio.Lock()
|
||||
|
||||
|
||||
# run_id -> "stop". Set by the stop endpoint so the executor loop, not the
|
||||
# HTTP handler, owns the run's terminal write. Without this the still-running
|
||||
# executor task could overwrite a "Stopped by user" failure with success.
|
||||
# Pause is NOT in here: it rides the agent session's own "stopped" status,
|
||||
# which the step loop waits out (see _await_session_idle).
|
||||
_run_control: dict[str, str] = {}
|
||||
_run_pause_override: dict[str, tuple[bool, float]] = {}
|
||||
|
||||
|
||||
def request_stop(run_id: str) -> None:
|
||||
_run_control[run_id] = "stop"
|
||||
|
||||
|
||||
def set_pause_override(run_id: str, paused: bool, ttl_s: float = 5.0) -> None:
|
||||
"""Keep an explicit pause/resume control state authoritative briefly.
|
||||
|
||||
The tool watcher normally derives paused from the agent session status,
|
||||
but pause/resume endpoints now return before the slower agent_manager call
|
||||
finishes. This prevents the watcher from broadcasting the pre-control
|
||||
status during that handoff window.
|
||||
"""
|
||||
_run_pause_override[run_id] = (paused, asyncio.get_event_loop().time() + ttl_s)
|
||||
|
||||
|
||||
def _resolve_system_prompt(wf: Workflow) -> Optional[str]:
|
||||
if wf.use_synced_prompt:
|
||||
return None
|
||||
@@ -55,7 +79,11 @@ def p_make_remember_approval(workflow_id: str):
|
||||
return p_remember_approval
|
||||
|
||||
|
||||
def p_persist_step_tool_usage(workflow_id: str, step_usage: dict[str, dict[str, bool]]) -> None:
|
||||
def p_persist_step_tool_usage(
|
||||
workflow_id: str,
|
||||
step_usage: dict[str, dict[str, bool]],
|
||||
tested_signature: Optional[str] = None,
|
||||
) -> None:
|
||||
fresh = storage.get_workflow(workflow_id)
|
||||
if fresh is None:
|
||||
return
|
||||
@@ -68,6 +96,8 @@ def p_persist_step_tool_usage(workflow_id: str, step_usage: dict[str, dict[str,
|
||||
for sid, tools in (step_usage or {}).items()
|
||||
if sid in live_ids and isinstance(tools, dict)
|
||||
}
|
||||
if tested_signature is not None:
|
||||
fresh.tested_signature = tested_signature
|
||||
storage.save_workflow(fresh)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
@@ -126,7 +156,12 @@ def _monthly_spend_so_far(wf: Workflow) -> float:
|
||||
return total
|
||||
|
||||
|
||||
async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun:
|
||||
async def execute(
|
||||
wf: Workflow,
|
||||
triggered_by: str = "schedule",
|
||||
scheduled_for: Optional[datetime] = None,
|
||||
tested_signature: Optional[str] = None,
|
||||
) -> WorkflowRun:
|
||||
from backend.apps.agents.agent_manager import (
|
||||
agent_manager,
|
||||
clear_workflow_approval_memory,
|
||||
@@ -222,12 +257,21 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
# finally block alongside _running cleanup.
|
||||
async def _watch_tool_calls() -> None:
|
||||
last_seen = ""
|
||||
last_paused = False
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(1.5)
|
||||
sess = agent_manager.sessions.get(session.id)
|
||||
if not sess:
|
||||
return
|
||||
now = asyncio.get_event_loop().time()
|
||||
override = _run_pause_override.get(run.id)
|
||||
if override and override[1] >= now:
|
||||
paused_now = override[0]
|
||||
else:
|
||||
if override:
|
||||
_run_pause_override.pop(run.id, None)
|
||||
paused_now = getattr(sess, "status", None) == "stopped"
|
||||
msgs = getattr(sess, "messages", []) or []
|
||||
label = ""
|
||||
for m in reversed(msgs):
|
||||
@@ -247,9 +291,13 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
label = content[:60]
|
||||
if label:
|
||||
break
|
||||
if label and label != last_seen:
|
||||
last_seen = label
|
||||
run.last_tool_label = label
|
||||
label_changed = bool(label) and label != last_seen
|
||||
if label_changed or paused_now != last_paused:
|
||||
if label_changed:
|
||||
last_seen = label
|
||||
run.last_tool_label = label
|
||||
last_paused = paused_now
|
||||
run.paused = paused_now
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
await ws_manager.broadcast_global("workflow:run", {
|
||||
@@ -271,10 +319,16 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
# safe regardless of how long each turn takes.
|
||||
step_error: Optional[str] = None
|
||||
for idx, step in enumerate(steps):
|
||||
if _run_control.get(run.id) == "stop":
|
||||
step_error = "Stopped by user"
|
||||
break
|
||||
# Broadcast the step bump before sending so RunningView flips
|
||||
# the disc immediately, not after the agent finishes the step.
|
||||
# Advancing means we're not paused; keep the broadcast authoritative
|
||||
# so it never races a stale paused=True from the watcher.
|
||||
run.active_step_idx = idx
|
||||
run.last_tool_label = None
|
||||
run.paused = False
|
||||
set_workflow_approval_step(session.id, step.id)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as _wsm
|
||||
@@ -285,15 +339,17 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
except Exception:
|
||||
pass
|
||||
await agent_manager.send_message(session.id, step.text)
|
||||
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":
|
||||
disp = await _await_session_idle(session.id, run.id)
|
||||
if disp == "stopped":
|
||||
step_error = "Stopped by user"
|
||||
# Pin active step so FailedView renders the X on the right row.
|
||||
break
|
||||
if disp == "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()
|
||||
run.paused = False
|
||||
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)
|
||||
@@ -318,15 +374,19 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
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, {
|
||||
run_fields = {
|
||||
"last_run_at": run.finished_at,
|
||||
"last_run_status": wf.last_run_status,
|
||||
}, schedule_runs_count_delta=runs_delta)
|
||||
}
|
||||
if triggered_by == "manual" and run.status in ("success", "ran_late") and isinstance(tested_signature, str):
|
||||
run_fields["tested_signature"] = tested_signature
|
||||
_persist_run_fields(wf, run_fields, 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()
|
||||
run.paused = False
|
||||
storage.record_run(run)
|
||||
wf.last_run_status = "failure"
|
||||
_persist_run_fields(wf, {
|
||||
@@ -334,6 +394,8 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
"last_run_at": run.finished_at,
|
||||
})
|
||||
finally:
|
||||
_run_control.pop(run.id, None)
|
||||
_run_pause_override.pop(run.id, None)
|
||||
# Cancel the tool-call watcher before we tear the session down so
|
||||
# the next poll doesn't race close_session.
|
||||
try:
|
||||
@@ -377,26 +439,47 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
|
||||
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.
|
||||
async def _await_session_idle(session_id: str, run_id: Optional[str] = None, timeout_s: float = 600.0) -> str:
|
||||
"""Wait out the current step's agent turn. Returns a disposition:
|
||||
'idle' turn finished, advance to the next step
|
||||
'error' the agent session errored
|
||||
'stopped' the run was manually stopped (full stop)
|
||||
|
||||
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.
|
||||
For a real run (run_id given) a user PAUSE shows up as the session going
|
||||
'stopped' WITHOUT a stop signal; that is not terminal, so we hold here
|
||||
until Resume or Stop, keeping the step deadline fresh so a long pause
|
||||
doesn't fail the step. The attended test-run driver passes no run_id and
|
||||
treats 'stopped' as terminal (no pause/resume there).
|
||||
|
||||
Polls cheaply since agent_manager doesn't expose a per-session completion
|
||||
future. Bounded by timeout_s so a stuck step can't hang the runner forever.
|
||||
"""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
|
||||
hold_on_pause = run_id is not None
|
||||
deadline = asyncio.get_event_loop().time() + timeout_s
|
||||
while True:
|
||||
if run_id is not None and _run_control.get(run_id) == "stop":
|
||||
return "stopped"
|
||||
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
|
||||
return "idle"
|
||||
status = getattr(sess, "status", None)
|
||||
if status in ("completed", "error", "stopped"):
|
||||
return
|
||||
if status == "stopped":
|
||||
if not hold_on_pause:
|
||||
return "stopped"
|
||||
# Paused. Hold, and reset the deadline so paused wall-time
|
||||
# doesn't count against the step timeout.
|
||||
deadline = asyncio.get_event_loop().time() + timeout_s
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
if status == "error":
|
||||
return "error"
|
||||
if status == "completed":
|
||||
return "idle"
|
||||
task = agent_manager.tasks.get(session_id)
|
||||
if task is not None and task.done() and status not in ("running", "waiting_approval"):
|
||||
return "idle"
|
||||
if asyncio.get_event_loop().time() > deadline:
|
||||
raise TimeoutError(f"Step exceeded {timeout_s}s on session {session_id}")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
@@ -106,6 +106,11 @@ class Workflow(BaseModel):
|
||||
default_factory=lambda: [PermissionTier(kind="notify")]
|
||||
)
|
||||
source_session_id: Optional[str] = None
|
||||
# Tool names observed in the source chat when this workflow was generated.
|
||||
# This preserves conversion context without pretending those calls map to
|
||||
# generated workflow step ids. Explicit approval decisions still live in
|
||||
# remembered_approvals and are the only values reused as permissions.
|
||||
source_tools: list[str] = Field(default_factory=list)
|
||||
dashboard_id: Optional[str] = None
|
||||
model: str = "sonnet"
|
||||
mode: str = "agent"
|
||||
@@ -148,6 +153,11 @@ class Workflow(BaseModel):
|
||||
# the scheduled/unscheduled lists until the first commit clears the flag,
|
||||
# so an in-progress build doesn't litter the sidebar.
|
||||
unsaved: bool = False
|
||||
# Stable signature of the steps last validated by a test run (or seeded at
|
||||
# chat conversion). The FE compares it against the current steps before
|
||||
# scheduling: a mismatch means "edited since you last approved tools" and
|
||||
# triggers the test-first warning. Computed FE-side so there's one algorithm.
|
||||
tested_signature: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowRun(BaseModel):
|
||||
@@ -169,6 +179,10 @@ class WorkflowRun(BaseModel):
|
||||
# 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
|
||||
# True while the user has paused the in-flight agent turn (same mechanic
|
||||
# as the chat's stop/resume). Rides the workflow:run broadcast so the
|
||||
# card shows the paused state even when the live chat isn't open.
|
||||
paused: bool = False
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
@@ -191,6 +205,7 @@ class WorkflowCreate(BaseModel):
|
||||
mode: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
cost_cap_usd_monthly: Optional[float] = None
|
||||
tested_signature: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
|
||||
@@ -58,6 +58,11 @@ def _host_tz() -> ZoneInfo:
|
||||
return _host_tz_cache
|
||||
|
||||
|
||||
def host_timezone_name() -> str:
|
||||
"""Concrete IANA-ish zone name for schedules created on this host."""
|
||||
return getattr(_host_tz(), "key", None) or "UTC"
|
||||
|
||||
|
||||
def _resolve_tz(tz: str) -> ZoneInfo:
|
||||
if not tz or tz == "local":
|
||||
return _host_tz()
|
||||
@@ -194,6 +199,53 @@ def fires_in_window(wf: Workflow, days: int = 30) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def occurrences_between(
|
||||
wf: Workflow,
|
||||
from_utc: datetime,
|
||||
to_utc: datetime,
|
||||
cap: int = 5000,
|
||||
) -> list[datetime]:
|
||||
"""Return scheduled fire instants in [from_utc, to_utc).
|
||||
|
||||
Calendar previews must use the same timezone-aware recurrence engine as
|
||||
the scheduler. Inputs and outputs are UTC-aware datetimes; callers can
|
||||
render those absolute instants in any local timezone.
|
||||
"""
|
||||
sched = wf.schedule
|
||||
if not sched.enabled or not is_schedule_configured(sched):
|
||||
return []
|
||||
if sched.max_runs is not None and sched.runs_count >= sched.max_runs:
|
||||
return []
|
||||
start_utc = _as_utc(from_utc)
|
||||
end_utc = _as_utc(to_utc)
|
||||
if start_utc is None or end_utc is None or end_utc <= start_utc:
|
||||
return []
|
||||
|
||||
created_at = _as_utc(getattr(wf, "created_at", None))
|
||||
cursor_utc = start_utc - timedelta(microseconds=1)
|
||||
if created_at is not None and created_at > cursor_utc:
|
||||
cursor_utc = created_at
|
||||
|
||||
ends_at = _as_utc(sched.ends_at)
|
||||
if ends_at is not None:
|
||||
if ends_at <= start_utc:
|
||||
return []
|
||||
if ends_at < end_utc:
|
||||
end_utc = ends_at
|
||||
|
||||
remaining = sched.max_runs - sched.runs_count if sched.max_runs is not None else cap
|
||||
limit = max(0, min(cap, remaining))
|
||||
out: list[datetime] = []
|
||||
while len(out) < limit:
|
||||
nxt = _next_fire_after(sched, cursor_utc)
|
||||
if nxt is None or nxt >= end_utc:
|
||||
break
|
||||
if nxt >= start_utc:
|
||||
out.append(nxt.astimezone(timezone.utc))
|
||||
cursor_utc = nxt
|
||||
return out
|
||||
|
||||
|
||||
def kick() -> None:
|
||||
_wake.set()
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Header, Request
|
||||
from fastapi import HTTPException, Header, Query, Request
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.workflows.models import (
|
||||
@@ -96,30 +96,79 @@ def _derive_icon(wf: Workflow) -> str:
|
||||
return "W"
|
||||
|
||||
|
||||
def p_source_session_approvals(session_id: Optional[str]) -> dict[str, str]:
|
||||
def _source_tool_name(value) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
name = value.strip()
|
||||
return name if name else ""
|
||||
|
||||
|
||||
def _collect_tool_names_from_content(content, out: set[str]) -> None:
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
_collect_tool_names_from_content(item, out)
|
||||
return
|
||||
if not isinstance(content, dict):
|
||||
return
|
||||
block_type = content.get("type")
|
||||
if block_type == "tool_use":
|
||||
name = _source_tool_name(content.get("name") or content.get("tool"))
|
||||
if name:
|
||||
out.add(name)
|
||||
for key in ("tool_name", "tool"):
|
||||
name = _source_tool_name(content.get(key))
|
||||
if name:
|
||||
out.add(name)
|
||||
nested = content.get("content")
|
||||
if nested is not content:
|
||||
_collect_tool_names_from_content(nested, out)
|
||||
|
||||
|
||||
def p_source_session_memory(session_id: Optional[str]) -> tuple[dict[str, str], list[str]]:
|
||||
if not session_id:
|
||||
return {}
|
||||
return {}, []
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
sess = agent_manager.sessions.get(session_id)
|
||||
decisions = getattr(sess, "approval_decisions", None) if sess is not None else None
|
||||
messages = getattr(sess, "messages", None) if sess is not None else None
|
||||
tool_latencies = getattr(sess, "tool_latencies", None) if sess is not None else None
|
||||
if decisions is None:
|
||||
from backend.apps.agents.manager.session.session_store import _load_session_data
|
||||
data = _load_session_data(session_id) or {}
|
||||
decisions = data.get("approval_decisions") or []
|
||||
messages = data.get("messages") or []
|
||||
tool_latencies = data.get("tool_latencies") or {}
|
||||
except Exception:
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
return {}, []
|
||||
approvals: dict[str, str] = {}
|
||||
tools: set[str] = set()
|
||||
for entry in decisions or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tool = _source_tool_name(entry.get("tool"))
|
||||
if tool:
|
||||
tools.add(tool)
|
||||
if entry.get("sensitive_pattern"):
|
||||
continue
|
||||
tool = str(entry.get("tool") or "")
|
||||
behavior = entry.get("behavior")
|
||||
if tool and behavior in ("allow", "deny"):
|
||||
out[tool] = behavior
|
||||
return out
|
||||
approvals[tool] = behavior
|
||||
if isinstance(tool_latencies, dict):
|
||||
for tool in tool_latencies.keys():
|
||||
name = _source_tool_name(tool)
|
||||
if name:
|
||||
tools.add(name)
|
||||
for msg in messages or []:
|
||||
role = getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
|
||||
content = getattr(msg, "content", None) if not isinstance(msg, dict) else msg.get("content")
|
||||
if role == "tool_call":
|
||||
tool_name = getattr(msg, "tool_name", None) if not isinstance(msg, dict) else msg.get("tool_name")
|
||||
name = _source_tool_name(tool_name)
|
||||
if name:
|
||||
tools.add(name)
|
||||
_collect_tool_names_from_content(content, tools)
|
||||
return approvals, sorted(tools)
|
||||
|
||||
|
||||
def p_prune_step_tool_usage(wf: Workflow) -> None:
|
||||
@@ -144,13 +193,34 @@ async def list_workflows(dashboard_id: Optional[str] = None):
|
||||
|
||||
|
||||
def _normalize_schedule_state(wf: Workflow) -> None:
|
||||
if wf.schedule.timezone == "local" and wf.schedule.enabled:
|
||||
wf.schedule.timezone = scheduler.host_timezone_name()
|
||||
if wf.schedule.enabled and not scheduler.is_schedule_configured(wf.schedule):
|
||||
wf.schedule.enabled = False
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
|
||||
|
||||
|
||||
def _has_nonempty_steps(steps: list[WorkflowStep] | None) -> bool:
|
||||
return any(bool((s.text or "").strip()) for s in (steps or []))
|
||||
|
||||
|
||||
def _parse_calendar_bound(value: str, label: str) -> datetime:
|
||||
raw = (value or "").strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {label} timestamp")
|
||||
if dt.tzinfo is None:
|
||||
raise HTTPException(status_code=400, detail=f"{label} timestamp must include a timezone")
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@workflows.router.post("/create")
|
||||
async def create_workflow(body: WorkflowCreate):
|
||||
if not body.unsaved and not _has_nonempty_steps(body.steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
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.
|
||||
@@ -178,7 +248,13 @@ async def create_workflow(body: WorkflowCreate):
|
||||
auto_named=body.auto_named,
|
||||
unsaved=body.unsaved,
|
||||
)
|
||||
wf.remembered_approvals = p_source_session_approvals(body.source_session_id)
|
||||
source_approvals, source_tools = p_source_session_memory(body.source_session_id)
|
||||
wf.remembered_approvals = source_approvals
|
||||
wf.source_tools = source_tools
|
||||
# Convert-from-chat passes the steps signature so the workflow counts as
|
||||
# already validated (the chat already prompted for permissions); a blank
|
||||
# "New" create leaves it None so the first schedule warns to test first.
|
||||
wf.tested_signature = body.tested_signature
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
_normalize_schedule_state(wf)
|
||||
@@ -507,6 +583,30 @@ async def list_all_runs(limit: int = 200):
|
||||
return {"runs": [r.model_dump(mode="json") for r in runs]}
|
||||
|
||||
|
||||
@workflows.router.get("/calendar")
|
||||
async def list_calendar_events(
|
||||
from_: str = Query(..., alias="from"),
|
||||
to: str = Query(...),
|
||||
dashboard_id: Optional[str] = None,
|
||||
):
|
||||
start_utc = _parse_calendar_bound(from_, "from")
|
||||
end_utc = _parse_calendar_bound(to, "to")
|
||||
if end_utc <= start_utc:
|
||||
raise HTTPException(status_code=400, detail="to must be after from")
|
||||
items = storage.list_workflows()
|
||||
if dashboard_id:
|
||||
items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id]
|
||||
events: list[dict] = []
|
||||
for wf in items:
|
||||
for fire_at in scheduler.occurrences_between(wf, start_utc, end_utc):
|
||||
events.append({
|
||||
"workflow_id": wf.id,
|
||||
"fire_at": fire_at.astimezone(timezone.utc).isoformat(),
|
||||
})
|
||||
events.sort(key=lambda e: (e["fire_at"], e["workflow_id"]))
|
||||
return {"events": events}
|
||||
|
||||
|
||||
@workflows.router.get("/{workflow_id}")
|
||||
async def get_workflow(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
@@ -746,15 +846,21 @@ async def commit_draft(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# Clicking Save is the user committing to this workflow, so reveal it in
|
||||
# the hub (clears the "+ New" build-in-progress flag) even if there's no
|
||||
# pending draft to flush.
|
||||
wf.unsaved = False
|
||||
if wf.draft_steps is None:
|
||||
if not _has_nonempty_steps(wf.steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
# Clicking Save is the user committing to this workflow, so reveal it
|
||||
# in the hub (clears the "+ New" build-in-progress flag).
|
||||
wf.unsaved = False
|
||||
await p_end_edit_session(wf)
|
||||
storage.save_workflow(wf)
|
||||
return _enriched(wf)
|
||||
before = wf.model_dump(mode="json")
|
||||
if not _has_nonempty_steps(wf.draft_steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
# Clicking Save is the user committing to this workflow, so reveal it in
|
||||
# the hub (clears the "+ New" build-in-progress flag).
|
||||
wf.unsaved = False
|
||||
wf.steps = wf.draft_steps
|
||||
wf.draft_steps = None
|
||||
await p_relabel_changed_steps(wf, before.get("steps") or [])
|
||||
@@ -818,6 +924,7 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
tested_signature = body.get("signature") if isinstance(body, dict) else None
|
||||
draft_steps = (body or {}).get("steps")
|
||||
step_entries: list[WorkflowStep]
|
||||
if isinstance(draft_steps, list) and draft_steps:
|
||||
@@ -891,9 +998,8 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
for step in step_entries:
|
||||
set_workflow_approval_step(session.id, step.id)
|
||||
await agent_manager.send_message(session.id, step.text)
|
||||
await executor._await_session_idle(session.id)
|
||||
sess_state = agent_manager.sessions.get(session.id)
|
||||
if sess_state is not None and getattr(sess_state, "status", None) == "error":
|
||||
disp = await executor._await_session_idle(session.id)
|
||||
if disp == "error":
|
||||
final = "error"
|
||||
return
|
||||
except Exception:
|
||||
@@ -901,7 +1007,11 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
final = "error"
|
||||
finally:
|
||||
try:
|
||||
executor.p_persist_step_tool_usage(wf.id, get_workflow_step_usage(session.id))
|
||||
executor.p_persist_step_tool_usage(
|
||||
wf.id,
|
||||
get_workflow_step_usage(session.id),
|
||||
tested_signature=tested_signature if isinstance(tested_signature, str) else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("test-run step usage persist failed")
|
||||
set_workflow_approval_step(session.id, None)
|
||||
@@ -956,11 +1066,12 @@ async def schedule_agent_session(workflow_id: str):
|
||||
from backend.apps.agents.core.models import AgentConfig
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
now_local = datetime.now().astimezone()
|
||||
local_tz = scheduler.host_timezone_name()
|
||||
current_dt = now_local.strftime("%A %Y-%m-%d %H:%M %Z")
|
||||
system_prompt = (
|
||||
f"You are the Scheduling Agent for the user's saved workflow \"{wf.title}\" "
|
||||
f"(id: {wf.id}). Your only job is to set when this workflow runs.\n\n"
|
||||
f"The current local date and time is {current_dt}. Resolve relative "
|
||||
f"The current local date and time is {current_dt} in {local_tz}. Resolve relative "
|
||||
"phrasing (\"this month\", \"next Wednesday\", \"this time\") against it.\n\n"
|
||||
"When the user states a cadence, interpret it yourself and call "
|
||||
"UpdateScheduledWorkflow with:\n"
|
||||
@@ -971,7 +1082,7 @@ async def schedule_agent_session(workflow_id: str):
|
||||
" - repeat_every: the interval count (1 unless they say e.g. \"every other\"; "
|
||||
"for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n"
|
||||
" - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n"
|
||||
" - timezone: an IANA name only if the user names a specific zone\n\n"
|
||||
f" - timezone: \"{local_tz}\" unless the user names a different specific zone\n\n"
|
||||
"If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence "
|
||||
"is genuinely ambiguous, ask ONE short clarifying question first; otherwise "
|
||||
"go straight to the tool call. The user approves or rejects the change in a "
|
||||
@@ -998,7 +1109,7 @@ async def schedule_agent_session(workflow_id: str):
|
||||
|
||||
|
||||
@workflows.router.post("/{workflow_id}/run")
|
||||
async def run_workflow_now(workflow_id: str):
|
||||
async def run_workflow_now(workflow_id: str, body: Optional[dict] = None):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
@@ -1006,7 +1117,12 @@ async def run_workflow_now(workflow_id: str):
|
||||
# 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"))
|
||||
tested_signature = body.get("signature") if isinstance(body, dict) else None
|
||||
asyncio.create_task(executor.execute(
|
||||
wf,
|
||||
triggered_by="manual",
|
||||
tested_signature=tested_signature if isinstance(tested_signature, str) else None,
|
||||
))
|
||||
|
||||
# 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
|
||||
@@ -1024,54 +1140,117 @@ async def run_workflow_now(workflow_id: str):
|
||||
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
|
||||
def _find_active_run(run_id: str):
|
||||
"""Locate a currently-running run by id, returning (workflow_id, run)."""
|
||||
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,
|
||||
})
|
||||
return wf.id, r
|
||||
return None, None
|
||||
|
||||
|
||||
async def _broadcast_run(workflow_id: str, run) -> None:
|
||||
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"),
|
||||
"workflow_id": workflow_id,
|
||||
"run": run.model_dump(mode="json"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@workflows.router.post("/runs/{run_id}/stop")
|
||||
async def stop_run(run_id: str):
|
||||
"""Fully stop a running workflow, failing it with a manual-stop reason.
|
||||
|
||||
Fired by the running card's Stop button. We signal the executor (which
|
||||
owns the run's terminal write) and halt the in-flight agent turn now; the
|
||||
executor marks the run failure "Stopped by user" and closes the session in
|
||||
its finally block. Signalling instead of writing the row here avoids the
|
||||
old race where the still-looping executor overwrote the failure.
|
||||
"""
|
||||
target_wf_id, target_run = _find_active_run(run_id)
|
||||
if not target_run or not target_wf_id:
|
||||
raise HTTPException(status_code=404, detail="Run not found or not active")
|
||||
executor.request_stop(run_id)
|
||||
if target_run.session_id:
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
await agent_manager.stop_agent(target_run.session_id)
|
||||
except Exception:
|
||||
logger.exception("stop_run: stop_agent failed for %s", target_run.session_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@workflows.router.post("/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: str):
|
||||
"""Pause the in-flight agent turn, same mechanic as the chat's Stop.
|
||||
|
||||
The executor holds on the current step (see _await_session_idle) until the
|
||||
matching resume. We flag the run paused so the card reflects it even when
|
||||
the live chat isn't open.
|
||||
"""
|
||||
target_wf_id, target_run = _find_active_run(run_id)
|
||||
if not target_run or not target_wf_id:
|
||||
raise HTTPException(status_code=404, detail="Run not found or not active")
|
||||
target_run.paused = True
|
||||
executor.set_pause_override(run_id, True)
|
||||
storage.record_run(target_run)
|
||||
await _broadcast_run(target_wf_id, target_run)
|
||||
|
||||
async def _stop_agent_for_pause() -> None:
|
||||
if not target_run.session_id:
|
||||
return
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
await agent_manager.stop_agent(target_run.session_id)
|
||||
except Exception:
|
||||
logger.exception("pause_run: stop_agent failed for %s", target_run.session_id)
|
||||
target_run.paused = False
|
||||
executor.set_pause_override(run_id, False, ttl_s=0.1)
|
||||
storage.record_run(target_run)
|
||||
await _broadcast_run(target_wf_id, target_run)
|
||||
|
||||
asyncio.create_task(_stop_agent_for_pause())
|
||||
return {"ok": True, "run": target_run.model_dump(mode="json")}
|
||||
|
||||
|
||||
@workflows.router.post("/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: str):
|
||||
"""Resume a paused run, same mechanic as the chat's Resume Agent Response:
|
||||
a hidden "continue where you left off" message restarts the current step's
|
||||
turn. The executor advances once that turn completes.
|
||||
"""
|
||||
target_wf_id, target_run = _find_active_run(run_id)
|
||||
if not target_run or not target_wf_id:
|
||||
raise HTTPException(status_code=404, detail="Run not found or not active")
|
||||
target_run.paused = False
|
||||
executor.set_pause_override(run_id, False)
|
||||
storage.record_run(target_run)
|
||||
await _broadcast_run(target_wf_id, target_run)
|
||||
|
||||
async def _send_resume_message() -> None:
|
||||
if not target_run.session_id:
|
||||
return
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
await agent_manager.send_message(
|
||||
target_run.session_id,
|
||||
"Continue where you left off. Start your response EXACTLY with 'Sorry, let me pick up where I left off'",
|
||||
hidden=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("resume_run: send_message failed for %s", target_run.session_id)
|
||||
target_run.paused = True
|
||||
executor.set_pause_override(run_id, True, ttl_s=0.1)
|
||||
storage.record_run(target_run)
|
||||
await _broadcast_run(target_wf_id, target_run)
|
||||
|
||||
asyncio.create_task(_send_resume_message())
|
||||
return {"ok": True, "run": target_run.model_dump(mode="json")}
|
||||
|
||||
|
||||
@workflows.router.get("/{workflow_id}/runs")
|
||||
async def list_workflow_runs(workflow_id: str, limit: int = 50):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
|
||||
@@ -23,6 +23,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -37,6 +38,7 @@ def isolated_data_dir(monkeypatch, tmp_path):
|
||||
starts with empty caches."""
|
||||
from backend.apps.workflows import storage as _storage
|
||||
from backend.apps.workflows import escalation as _escalation
|
||||
from backend.apps.workflows import executor as _executor
|
||||
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"))
|
||||
@@ -47,6 +49,8 @@ def isolated_data_dir(monkeypatch, tmp_path):
|
||||
# Reset escalation registry between tests.
|
||||
_escalation._tasks.clear()
|
||||
_escalation._state.clear()
|
||||
_executor._run_control.clear()
|
||||
_executor._run_pause_override.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
|
||||
@@ -65,6 +69,11 @@ def _make_wf(**overrides):
|
||||
return Workflow(**base)
|
||||
|
||||
|
||||
class _NoopDebug:
|
||||
def __call__(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
# --- DST tests ---------------------------------------------------------------
|
||||
|
||||
def test_dst_spring_forward_weekly():
|
||||
@@ -180,6 +189,122 @@ def test_month_repeat_no_longer_clamps_to_28():
|
||||
assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date()
|
||||
|
||||
|
||||
def test_calendar_occurrences_use_schedule_timezone_not_viewer_timezone():
|
||||
"""A 9am New York schedule returns UTC instants. The frontend can then
|
||||
render those instants in the viewer's current timezone."""
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/New_York",
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
start = datetime(2026, 6, 18, 0, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 20, 0, 0, tzinfo=timezone.utc)
|
||||
fires = scheduler.occurrences_between(wf, start, end)
|
||||
assert len(fires) == 2
|
||||
assert fires[0].astimezone(ZoneInfo("America/New_York")).hour == 9
|
||||
assert fires[0].astimezone(ZoneInfo("America/Los_Angeles")).hour == 6
|
||||
|
||||
|
||||
def test_calendar_occurrences_stay_wall_clock_across_dst():
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/New_York",
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
fires = scheduler.occurrences_between(
|
||||
wf,
|
||||
datetime(2025, 3, 8, 0, 0, tzinfo=timezone.utc),
|
||||
datetime(2025, 3, 11, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
ny = ZoneInfo("America/New_York")
|
||||
locals_ = [f.astimezone(ny) for f in fires]
|
||||
assert [d.date() for d in locals_] == [
|
||||
datetime(2025, 3, 8).date(),
|
||||
datetime(2025, 3, 9).date(),
|
||||
datetime(2025, 3, 10).date(),
|
||||
]
|
||||
assert all((d.hour, d.minute) == (9, 0) for d in locals_)
|
||||
assert [f.hour for f in fires] == [14, 13, 13]
|
||||
|
||||
|
||||
def test_calendar_occurrences_honor_end_conditions():
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
start = datetime(2026, 6, 18, 0, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 22, 0, 0, tzinfo=timezone.utc)
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="UTC",
|
||||
max_runs=3,
|
||||
runs_count=1,
|
||||
ends_at=datetime(2026, 6, 21, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
fires = scheduler.occurrences_between(wf, start, end)
|
||||
assert [f.date() for f in fires] == [
|
||||
datetime(2026, 6, 18).date(),
|
||||
datetime(2026, 6, 19).date(),
|
||||
]
|
||||
|
||||
wf.schedule.enabled = False
|
||||
assert scheduler.occurrences_between(wf, start, end) == []
|
||||
|
||||
wf.schedule.enabled = True
|
||||
wf.schedule.repeat_unit = "week"
|
||||
wf.schedule.on_days = []
|
||||
assert scheduler.occurrences_between(wf, start, end) == []
|
||||
|
||||
|
||||
def test_calendar_endpoint_returns_sorted_utc_events():
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.workflows import list_calendar_events
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/New_York",
|
||||
)
|
||||
)
|
||||
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
storage.save_workflow(wf)
|
||||
|
||||
async def runner():
|
||||
return await list_calendar_events(
|
||||
from_="2026-06-18T00:00:00+00:00",
|
||||
to="2026-06-20T00:00:00+00:00",
|
||||
)
|
||||
|
||||
res = asyncio.new_event_loop().run_until_complete(runner())
|
||||
assert [e["workflow_id"] for e in res["events"]] == [wf.id, wf.id]
|
||||
assert res["events"][0]["fire_at"].startswith("2026-06-18T13:00:00")
|
||||
|
||||
|
||||
# --- Cost cap ----------------------------------------------------------------
|
||||
|
||||
def test_cost_cap_skips_with_clear_error(monkeypatch):
|
||||
@@ -234,6 +359,59 @@ def test_freeze_not_forced_when_source_session_present():
|
||||
assert result["actions"]["freeze"] is False
|
||||
|
||||
|
||||
def test_create_enabled_schedule_normalizes_local_timezone(monkeypatch):
|
||||
from backend.apps.workflows import scheduler
|
||||
from backend.apps.workflows.workflows import create_workflow
|
||||
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig
|
||||
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Chicago")
|
||||
monkeypatch.setattr(scheduler, "_host_tz_cache", None)
|
||||
body = WorkflowCreate(
|
||||
title="local-tz-create",
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="day",
|
||||
repeat_every=1,
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="local",
|
||||
),
|
||||
)
|
||||
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
|
||||
assert result["schedule"]["timezone"] == "America/Chicago"
|
||||
|
||||
|
||||
def test_enable_schedule_normalizes_local_timezone_and_preserves_concrete_timezone(monkeypatch):
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
from backend.apps.workflows.workflows import update_workflow
|
||||
from backend.apps.workflows.models import WorkflowUpdate, ScheduleConfig
|
||||
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Denver")
|
||||
monkeypatch.setattr(scheduler, "_host_tz_cache", None)
|
||||
|
||||
wf = _make_wf()
|
||||
wf.schedule.enabled = False
|
||||
wf.schedule.timezone = "local"
|
||||
storage.save_workflow(wf)
|
||||
|
||||
async def enable_runner():
|
||||
sched = ScheduleConfig(**wf.schedule.model_dump(mode="json"))
|
||||
sched.enabled = True
|
||||
return await update_workflow(wf.id, WorkflowUpdate(schedule=sched), if_match=None)
|
||||
|
||||
enabled = asyncio.new_event_loop().run_until_complete(enable_runner())
|
||||
assert enabled["schedule"]["timezone"] == "America/Denver"
|
||||
|
||||
stored = storage.get_workflow(wf.id)
|
||||
sched = ScheduleConfig(**stored.schedule.model_dump(mode="json"))
|
||||
sched.hour = 10
|
||||
|
||||
async def edit_runner():
|
||||
return await update_workflow(wf.id, WorkflowUpdate(schedule=sched), if_match=None)
|
||||
|
||||
edited = asyncio.new_event_loop().run_until_complete(edit_runner())
|
||||
assert edited["schedule"]["timezone"] == "America/Denver"
|
||||
assert edited["schedule"]["hour"] == 10
|
||||
|
||||
|
||||
# --- Audit log ---------------------------------------------------------------
|
||||
|
||||
def test_audit_log_records_title_change():
|
||||
@@ -319,6 +497,83 @@ def test_paused_flag_persists_and_blocks_tick():
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_pause_run_returns_confirmed_state_before_agent_stop_finishes(monkeypatch):
|
||||
async def scenario():
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.models import WorkflowRun
|
||||
monkeypatch.setitem(sys.modules, "debug", _NoopDebug())
|
||||
from backend.apps.workflows import workflows as routes
|
||||
from backend.apps.agents import agent_manager as agent_manager_module
|
||||
|
||||
wf = _make_wf()
|
||||
storage.save_workflow(wf)
|
||||
run = WorkflowRun(workflow_id=wf.id, status="running", session_id="s1", triggered_by="manual")
|
||||
storage.record_run(run)
|
||||
broadcasts: list[bool] = []
|
||||
|
||||
async def fake_broadcast(_workflow_id, updated_run):
|
||||
broadcasts.append(updated_run.paused)
|
||||
|
||||
stop_started = asyncio.Event()
|
||||
stop_release = asyncio.Event()
|
||||
|
||||
async def fake_stop_agent(_session_id):
|
||||
stop_started.set()
|
||||
await stop_release.wait()
|
||||
|
||||
monkeypatch.setattr(routes, "_broadcast_run", fake_broadcast)
|
||||
monkeypatch.setattr(agent_manager_module.agent_manager, "stop_agent", fake_stop_agent)
|
||||
|
||||
result = await asyncio.wait_for(routes.pause_run(run.id), timeout=0.05)
|
||||
assert result["run"]["paused"] is True
|
||||
assert storage.list_runs(wf.id)[0].paused is True
|
||||
assert broadcasts[-1] is True
|
||||
await asyncio.wait_for(stop_started.wait(), timeout=0.05)
|
||||
stop_release.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_resume_run_returns_confirmed_state_before_resume_message_finishes(monkeypatch):
|
||||
async def scenario():
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.models import WorkflowRun
|
||||
monkeypatch.setitem(sys.modules, "debug", _NoopDebug())
|
||||
from backend.apps.workflows import workflows as routes
|
||||
from backend.apps.agents import agent_manager as agent_manager_module
|
||||
|
||||
wf = _make_wf()
|
||||
storage.save_workflow(wf)
|
||||
run = WorkflowRun(workflow_id=wf.id, status="running", session_id="s1", triggered_by="manual", paused=True)
|
||||
storage.record_run(run)
|
||||
broadcasts: list[bool] = []
|
||||
|
||||
async def fake_broadcast(_workflow_id, updated_run):
|
||||
broadcasts.append(updated_run.paused)
|
||||
|
||||
send_started = asyncio.Event()
|
||||
send_release = asyncio.Event()
|
||||
|
||||
async def fake_send_message(_session_id, _prompt, hidden=False):
|
||||
assert hidden is True
|
||||
send_started.set()
|
||||
await send_release.wait()
|
||||
|
||||
monkeypatch.setattr(routes, "_broadcast_run", fake_broadcast)
|
||||
monkeypatch.setattr(agent_manager_module.agent_manager, "send_message", fake_send_message)
|
||||
|
||||
result = await asyncio.wait_for(routes.resume_run(run.id), timeout=0.05)
|
||||
assert result["run"]["paused"] is False
|
||||
assert storage.list_runs(wf.id)[0].paused is False
|
||||
assert broadcasts[-1] is False
|
||||
await asyncio.wait_for(send_started.wait(), timeout=0.05)
|
||||
send_release.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
# --- Escalation --------------------------------------------------------------
|
||||
|
||||
def test_escalation_schedules_and_ack_cancels():
|
||||
|
||||
@@ -6,6 +6,7 @@ import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
@@ -17,7 +18,7 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { friendlyStatusLabel } from '@/shared/statusLabel';
|
||||
import { openSettingsModal } from '@/shared/state/settingsSlice';
|
||||
import { openSettingsModal, dismissMcpSuggestion } from '@/shared/state/settingsSlice';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import {
|
||||
sendMessage as sendMessageThunk,
|
||||
@@ -269,6 +270,25 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
return found?.workflowId ?? null;
|
||||
});
|
||||
const isStoppableSidecar = !!linkedWorkflowId;
|
||||
// A live workflow run being watched owns pause/resume from its workflow
|
||||
// card, so the chat's own "Resume Agent Response" bubble is redundant and
|
||||
// would go stale against the card's Resume. Suppress it for any workflow-run
|
||||
// sidecar, not just the fragile exact "watching" value. Test-run sidecars
|
||||
// keep their chat-level resume behavior.
|
||||
const isWorkflowRunSidecar = useAppSelector((s) => {
|
||||
if (!id) return false;
|
||||
for (const cd of Object.values(s.workflows.openCards)) {
|
||||
if (cd.sidecarSessionId !== id || cd.sidecarKind === 'testing') continue;
|
||||
if (cd.runId) {
|
||||
const run = (s.workflows.runs[cd.workflowId] || []).find((r) => r.id === cd.runId);
|
||||
if (!run || run.session_id === id) return true;
|
||||
}
|
||||
if (cd.sidecarKind === 'watching' || cd.sidecarKind === 'viewing-completed' || cd.sidecarKind === 'viewing-error') return true;
|
||||
}
|
||||
return Object.values(s.workflows.runs).some((runs) =>
|
||||
runs.some((r) => r.session_id === id && r.status === 'running'),
|
||||
);
|
||||
});
|
||||
const testState = useAppSelector((s) => (id ? s.agents.sessions[id]?.workflow_test_state : null) ?? null);
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -316,10 +336,16 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const [heightVersion, setHeightVersion] = useState(0);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [showResumeBubble, setShowResumeBubble] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isWorkflowRunSidecar) setShowResumeBubble(false);
|
||||
}, [isWorkflowRunSidecar]);
|
||||
const [awaitingResponse, setAwaitingResponse] = useState(false);
|
||||
const [preSendActivityLabel, setPreSendActivityLabel] = useState<string | null>(null);
|
||||
const [activatingMcp, setActivatingMcp] = useState<string | null>(null);
|
||||
const [activateError, setActivateError] = useState<string | null>(null);
|
||||
// Holds the last non-empty suggestions so the docked banner's exit fade renders
|
||||
// them instead of going blank the instant the array is cleared.
|
||||
const mcpSnapshotRef = useRef<Array<{ id: string; title: string; description: string; reason?: string }>>([]);
|
||||
const [mode, setMode] = useState('agent');
|
||||
const [model, setModel] = useState('sonnet');
|
||||
|
||||
@@ -471,7 +497,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
didDispatchQueued = true;
|
||||
} else {
|
||||
if (curr === 'stopped') {
|
||||
setShowResumeBubble(true);
|
||||
setShowResumeBubble(!isWorkflowRunSidecar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +515,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
if (curr !== 'draft' && !didDispatchQueued) {
|
||||
setAwaitingResponse(false);
|
||||
}
|
||||
}, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]);
|
||||
}, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage, isWorkflowRunSidecar]);
|
||||
|
||||
// Idle reconcile: if the session has been 'running' for 5s with no
|
||||
// WebSocket activity (no new messages, no streaming updates), do a
|
||||
@@ -915,9 +941,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
if (id) dispatch(removeCard(id));
|
||||
}, [linkedWorkflowId, id, dispatch]);
|
||||
|
||||
const onTestSaveWorkflow = useCallback(() => {
|
||||
const onTestSaveWorkflow = useCallback(async () => {
|
||||
if (linkedWorkflowId) {
|
||||
dispatch(commitDraft(linkedWorkflowId));
|
||||
try {
|
||||
await dispatch(commitDraft(linkedWorkflowId)).unwrap();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
dispatch(updateWorkflowCard({ workflowId: linkedWorkflowId, patch: { view: 'saved' } }));
|
||||
dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null }));
|
||||
}
|
||||
@@ -1562,123 +1592,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{(session.mcp_suggestions && session.mcp_suggestions.length > 0) && (
|
||||
<Box sx={{
|
||||
mt: 1,
|
||||
mb: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Dismiss integration suggestion"
|
||||
onClick={() => id && dispatch(clearMcpSuggestions({ sessionId: id }))}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 8,
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1,
|
||||
color: c.text.muted,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 0.75,
|
||||
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5, pr: 3 }}>
|
||||
Looks like this might need an integration
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1 }}>
|
||||
Activating one of these will let the agent answer in a single round-trip.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{session.mcp_suggestions.map((s) => (
|
||||
<Box key={s.id} sx={{ flexBasis: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.primary, fontWeight: 500 }}>
|
||||
{s.title}
|
||||
</Typography>
|
||||
{s.reason && (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: c.text.tertiary }}>
|
||||
{s.reason}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
component="button"
|
||||
variant="caption"
|
||||
disabled={activatingMcp === s.id}
|
||||
onClick={async () => {
|
||||
if (activatingMcp) return;
|
||||
setActivateError(null);
|
||||
setActivatingMcp(s.id);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
if (tok) headers['Authorization'] = `Bearer ${tok}`;
|
||||
const r = await fetch(`${API_BASE}/mcp-meta/activate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
server_name: s.id.toLowerCase().replace(/\s+/g, '-'),
|
||||
reason: s.reason || 'preflight suggestion',
|
||||
parent_session_id: session.id,
|
||||
}),
|
||||
});
|
||||
const body = await r.json().catch(() => ({} as any));
|
||||
if (!r.ok) {
|
||||
setActivateError(`Activation failed (${r.status})`);
|
||||
} else if (body?.status === 'unknown_server') {
|
||||
// Not yet connected; jump straight to Actions
|
||||
// so the user can finish OAuth. Nothing here
|
||||
// can do it on their behalf.
|
||||
navigate('/actions');
|
||||
} else if (id) {
|
||||
// Activation succeeded; clear the banner so the user
|
||||
// gets visual confirmation the click did something.
|
||||
dispatch(clearMcpSuggestions({ sessionId: id }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setActivateError(e?.message || 'Activation failed');
|
||||
} finally {
|
||||
setActivatingMcp(null);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
cursor: activatingMcp === s.id ? 'wait' : 'pointer',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
bgcolor: 'transparent',
|
||||
color: c.text.primary,
|
||||
opacity: activatingMcp === s.id ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated },
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{activatingMcp === s.id ? 'Activating…' : 'Activate'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{activateError && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: c.status.error }}>
|
||||
{activateError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{session.context_overflow && (() => {
|
||||
const reason = session.context_overflow.reason;
|
||||
const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error';
|
||||
@@ -1872,7 +1785,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{showResumeBubble && session.status === 'stopped' && (
|
||||
{showResumeBubble && session.status === 'stopped' && !isWorkflowRunSidecar && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
<Box
|
||||
onClick={handleResume}
|
||||
@@ -2180,6 +2093,134 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const list = session.mcp_suggestions ?? [];
|
||||
if (list.length) mcpSnapshotRef.current = list;
|
||||
const display = mcpSnapshotRef.current;
|
||||
return (
|
||||
<Fade in={list.length > 0} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box sx={{
|
||||
mx: 2,
|
||||
mb: 1,
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Dismiss integration suggestion"
|
||||
onClick={() => {
|
||||
if (!id) return;
|
||||
dispatch(clearMcpSuggestions({ sessionId: id }));
|
||||
dispatch(dismissMcpSuggestion(display.map((s) => s.id)));
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 8,
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1,
|
||||
color: c.text.muted,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 0.75,
|
||||
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5, pr: 3 }}>
|
||||
Looks like this might need an integration
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1 }}>
|
||||
Activating one of these will let the agent answer in a single round-trip.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{display.map((s) => (
|
||||
<Box key={s.id} sx={{ flexBasis: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.primary, fontWeight: 500 }}>
|
||||
{s.title}
|
||||
</Typography>
|
||||
{s.reason && (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: c.text.tertiary }}>
|
||||
{s.reason}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
component="button"
|
||||
variant="caption"
|
||||
disabled={activatingMcp === s.id}
|
||||
onClick={async () => {
|
||||
if (activatingMcp) return;
|
||||
setActivateError(null);
|
||||
setActivatingMcp(s.id);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
if (tok) headers['Authorization'] = `Bearer ${tok}`;
|
||||
const r = await fetch(`${API_BASE}/mcp-meta/activate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
server_name: s.id.toLowerCase().replace(/\s+/g, '-'),
|
||||
reason: s.reason || 'preflight suggestion',
|
||||
parent_session_id: session.id,
|
||||
}),
|
||||
});
|
||||
const body = await r.json().catch(() => ({} as any));
|
||||
if (!r.ok) {
|
||||
setActivateError(`Activation failed (${r.status})`);
|
||||
} else if (body?.status === 'unknown_server') {
|
||||
// Not yet connected; jump straight to Actions
|
||||
// so the user can finish OAuth. Nothing here
|
||||
// can do it on their behalf.
|
||||
navigate('/actions');
|
||||
} else if (id) {
|
||||
// Activation succeeded; clear the banner so the user
|
||||
// gets visual confirmation the click did something.
|
||||
dispatch(clearMcpSuggestions({ sessionId: id }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setActivateError(e?.message || 'Activation failed');
|
||||
} finally {
|
||||
setActivatingMcp(null);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
cursor: activatingMcp === s.id ? 'wait' : 'pointer',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
bgcolor: 'transparent',
|
||||
color: c.text.primary,
|
||||
opacity: activatingMcp === s.id ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated },
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{activatingMcp === s.id ? 'Activating…' : 'Activate'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{activateError && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: c.status.error }}>
|
||||
{activateError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
})()}
|
||||
{isStoppableSidecar ? (
|
||||
<ForceStopAgentBar onStop={handleStop} onSaveWorkflow={onTestSaveWorkflow} onContinueEditing={onTestContinueEditing} testState={testState} />
|
||||
) : (
|
||||
|
||||
@@ -55,6 +55,7 @@ export function elbowPath(x1: number, y1: number, x2: number, y2: number): strin
|
||||
}
|
||||
|
||||
type Anchor = { x: number; y: number; side: 'left' | 'right' | 'top' | 'bottom' };
|
||||
type CanvasRect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
// Where the ray from a rect's center toward (tx,ty) crosses the rect border.
|
||||
// Pins a tether endpoint to the card edge facing the other card, so it can
|
||||
@@ -69,6 +70,39 @@ function borderPoint(x: number, y: number, w: number, h: number, tx: number, ty:
|
||||
return { x: cx + dx * scale, y: cy + dy * scale };
|
||||
}
|
||||
|
||||
function rectCenter(r: CanvasRect): { x: number; y: number } {
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
}
|
||||
|
||||
function selectCardElement(content: HTMLElement, type: 'agent-card' | 'workflow-card', id: string): HTMLElement | null {
|
||||
const candidates = content.querySelectorAll<HTMLElement>(`[data-select-type="${type}"]`);
|
||||
for (const el of Array.from(candidates)) {
|
||||
if (el.dataset.selectId === id) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function measuredCanvasRect(
|
||||
contentRef: RefObject<HTMLElement>,
|
||||
zoom: number,
|
||||
type: 'agent-card' | 'workflow-card',
|
||||
id: string,
|
||||
): CanvasRect | null {
|
||||
const content = contentRef.current;
|
||||
if (!content) return null;
|
||||
const el = selectCardElement(content, type, id);
|
||||
if (!el) return null;
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const z = zoom || 1;
|
||||
return {
|
||||
x: (elRect.left - contentRect.left) / z,
|
||||
y: (elRect.top - contentRect.top) / z,
|
||||
width: elRect.width / z,
|
||||
height: elRect.height / z,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseTethersArgs {
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
glowingBrowserCards: Record<string, GlowingBrowserCard>;
|
||||
@@ -82,6 +116,8 @@ interface UseTethersArgs {
|
||||
liveDragInfo: LiveDragInfo | null;
|
||||
measuredHeightsRef: RefObject<Record<string, number>>;
|
||||
measuredHeightsTick: number;
|
||||
contentRef: RefObject<HTMLElement>;
|
||||
zoom: number;
|
||||
sessionList: AgentSession[];
|
||||
}
|
||||
|
||||
@@ -98,6 +134,8 @@ export function useTethers({
|
||||
liveDragInfo,
|
||||
measuredHeightsRef,
|
||||
measuredHeightsTick,
|
||||
contentRef,
|
||||
zoom,
|
||||
sessionList,
|
||||
}: UseTethersArgs): Tether[] {
|
||||
return useMemo(() => {
|
||||
@@ -357,10 +395,14 @@ export function useTethers({
|
||||
? Math.max(EXPANDED_CARD_MIN_H, sidecar.height)
|
||||
: sidecar.height);
|
||||
const wcH = wfHeight(wc);
|
||||
const srcCx = srcX + wc.width / 2, srcCy = srcY + wcH / 2;
|
||||
const dstCx = dstX + sidecar.width / 2, dstCy = dstY + dstH / 2;
|
||||
const a = borderPoint(srcX, srcY, wc.width, wcH, dstCx, dstCy);
|
||||
const b = borderPoint(dstX, dstY, sidecar.width, dstH, srcCx, srcCy);
|
||||
const measuredWorkflow = measuredCanvasRect(contentRef, zoom, 'workflow-card', wc.workflow_id);
|
||||
const measuredSidecar = measuredCanvasRect(contentRef, zoom, 'agent-card', sidecarId);
|
||||
const workflowRect = measuredWorkflow ?? { x: srcX, y: srcY, width: wc.width, height: wcH };
|
||||
const sidecarRect = measuredSidecar ?? { x: dstX, y: dstY, width: sidecar.width, height: dstH };
|
||||
const srcCenter = rectCenter(workflowRect);
|
||||
const dstCenter = rectCenter(sidecarRect);
|
||||
const a = borderPoint(workflowRect.x, workflowRect.y, workflowRect.width, workflowRect.height, dstCenter.x, dstCenter.y);
|
||||
const b = borderPoint(sidecarRect.x, sidecarRect.y, sidecarRect.width, sidecarRect.height, srcCenter.x, srcCenter.y);
|
||||
const x1 = a.x, y1 = a.y;
|
||||
const x2 = b.x, y2 = b.y;
|
||||
const pathD = elbowPath(x1, y1, x2, y2);
|
||||
@@ -429,5 +471,5 @@ export function useTethers({
|
||||
// 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, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
|
||||
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, contentRef, zoom, sessionList]);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
liveDragInfo,
|
||||
measuredHeightsRef,
|
||||
measuredHeightsTick,
|
||||
contentRef: canvas.contentRef,
|
||||
zoom: canvas.zoom,
|
||||
sessionList,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { needsScheduleTestWarning } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
@@ -23,7 +24,11 @@ export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Pr
|
||||
const makeSchedule = useCallback(() => {
|
||||
if (!workflow) return;
|
||||
dispatch(addWorkflowCard({ workflowId: workflow.id }));
|
||||
dispatch(openWorkflowCard({ workflowId: workflow.id, view: 'scheduling' }));
|
||||
// Untested steps: land on the saved card so its Schedule button can warn and
|
||||
// offer a test run (which needs the card's sidecar context). Otherwise go
|
||||
// straight to scheduling.
|
||||
const view = needsScheduleTestWarning(workflow) ? 'saved' : 'scheduling';
|
||||
dispatch(openWorkflowCard({ workflowId: workflow.id, view }));
|
||||
onClose();
|
||||
}, [dispatch, workflow, onClose]);
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import StepList from './StepList';
|
||||
import AgentChat from '@/app/pages/AgentChat/AgentChat';
|
||||
import { useOpenSidecar } from './WorkflowCardLiveViews';
|
||||
import EditAgentSavePopovers, { type SavePhase } from './EditAgentSavePopovers';
|
||||
import { runWorkflowTest } from './runWorkflowTest';
|
||||
import { needsScheduleTestWarning } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
@@ -112,6 +114,8 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
const [saveAnchorEl, setSaveAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [testSessionId, setTestSessionId] = useState<string | null>(null);
|
||||
const draftSteps = workflow.draft_steps ?? steps;
|
||||
const canSave = draftSteps.some((s) => (s.text || '').trim().length > 0);
|
||||
const allowDiscard = !workflow.unsaved;
|
||||
// A draft always exists in edit mode (we snapshot on entry), so only flag
|
||||
// "unsaved" once the draft actually diverges from the committed steps.
|
||||
const hasChanges = workflow.draft_steps != null && JSON.stringify(workflow.draft_steps) !== JSON.stringify(workflow.steps);
|
||||
@@ -134,35 +138,31 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
} catch { /* best-effort */ }
|
||||
}, [testSessionId]);
|
||||
|
||||
const onSaveNow = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setSavePhase('idle');
|
||||
try {
|
||||
await dispatch(commitDraft(workflow.id)).unwrap();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
toSaved();
|
||||
}, [canSave, dispatch, workflow.id, toSaved]);
|
||||
|
||||
const onSaveClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!canSave) return;
|
||||
// Already validated this exact version? Skip the "test first?" nudge.
|
||||
if (!needsScheduleTestWarning(workflow)) { void onSaveNow(); return; }
|
||||
setSaveAnchorEl(e.currentTarget);
|
||||
setSavePhase('ask-test');
|
||||
}, []);
|
||||
|
||||
const onSaveNow = useCallback(async () => {
|
||||
setSavePhase('idle');
|
||||
await dispatch(commitDraft(workflow.id));
|
||||
toSaved();
|
||||
}, [dispatch, workflow.id, toSaved]);
|
||||
}, [canSave, workflow, onSaveNow]);
|
||||
|
||||
const onRunTest = useCallback(async () => {
|
||||
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: draftSteps }),
|
||||
});
|
||||
if (!res.ok) { setSavePhase('idle'); return; }
|
||||
const data = await res.json();
|
||||
const sid = data?.session_id as string | undefined;
|
||||
if (!sid) { setSavePhase('idle'); return; }
|
||||
setTestSessionId(sid);
|
||||
// The Test Agent card now owns the post-test decision (Continue editing /
|
||||
// Save workflow) in its own footer, so just close this popover.
|
||||
setSavePhase('idle');
|
||||
await openSidecar(sid, 'testing');
|
||||
} catch { setSavePhase('idle'); }
|
||||
setSavePhase('idle');
|
||||
// The Test Agent card now owns the post-test decision (Continue editing /
|
||||
// Save workflow) in its own footer, so just close this popover.
|
||||
const sid = await runWorkflowTest(workflow.id, draftSteps, openSidecar);
|
||||
if (sid) setTestSessionId(sid);
|
||||
}, [workflow.id, draftSteps, openSidecar]);
|
||||
|
||||
const onDiscardClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
@@ -199,18 +199,22 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>· unsaved</Typography>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{allowDiscard && (
|
||||
<Box
|
||||
onClick={onDiscardClick}
|
||||
role="button"
|
||||
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer', mr: 1, '&:hover': { color: c.status.error } }}>
|
||||
Discard
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
onClick={onDiscardClick}
|
||||
role="button"
|
||||
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer', mr: 1, '&:hover': { color: c.status.error } }}>
|
||||
Discard
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onSaveClick}
|
||||
onClick={canSave ? onSaveClick : undefined}
|
||||
role="button"
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{
|
||||
fontSize: '0.8rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.2, py: 0.35, borderRadius: 999, cursor: 'pointer',
|
||||
px: 1.2, py: 0.35, borderRadius: 999, cursor: canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: canSave ? 1 : 0.45,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
Save
|
||||
@@ -218,8 +222,12 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
</Box>
|
||||
{stepsOpen && (
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
{isFixMode && fixSeed && <FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />}
|
||||
<StepList steps={draftSteps} />
|
||||
{isFixMode && fixSeed && (
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
<FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
@@ -7,10 +7,11 @@ 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 { API_BASE } from '@/shared/config';
|
||||
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, isScheduleActive } from './scheduleUtils';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
@@ -24,6 +25,11 @@ interface Props {
|
||||
// starting hour. The scroll container caps the visible window.
|
||||
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
interface CalendarEvent {
|
||||
workflow_id: string;
|
||||
fire_at: string;
|
||||
}
|
||||
|
||||
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -34,7 +40,10 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
const closeMenu = () => setCtxMenu(null);
|
||||
const onRunNow = () => {
|
||||
if (!ctxMenu) return;
|
||||
dispatch(runWorkflowNow(ctxMenu.workflow.id));
|
||||
dispatch(runWorkflowNow({
|
||||
id: ctxMenu.workflow.id,
|
||||
signature: stepsSignature(ctxMenu.workflow.steps),
|
||||
}));
|
||||
closeMenu();
|
||||
};
|
||||
const onPauseToggle = () => {
|
||||
@@ -74,32 +83,74 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
// 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.
|
||||
// refDate is recreated on every render unless the caller memoizes it.
|
||||
// Pin the calendar to a day-precision key so occurrence fetches only
|
||||
// change when the visible day, view, or schedule set changes.
|
||||
const today = refDate || new Date();
|
||||
const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||
const compact = density === 'compact';
|
||||
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
|
||||
const rangeStart = useMemo(
|
||||
() => view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[view, dayKey],
|
||||
);
|
||||
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [calendarFetchKey, setCalendarFetchKey] = useState('');
|
||||
const workflowScheduleKey = workflows
|
||||
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
const fromIso = rangeStart.toISOString();
|
||||
const toIso = rangeEndExclusive.toISOString();
|
||||
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ctrl = new AbortController();
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents((data.events || []) as CalendarEvent[]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents([]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctrl.abort();
|
||||
};
|
||||
}, [fromIso, toIso, calendarRequestKey]);
|
||||
|
||||
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<string, { workflow: Workflow; date: Date }[]>();
|
||||
for (const wf of workflows) {
|
||||
if (!isScheduleActive(wf.schedule)) 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);
|
||||
}
|
||||
if (calendarFetchKey !== calendarRequestKey) {
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}
|
||||
return { map, start, end };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflows, view, dayKey]);
|
||||
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
|
||||
for (const event of calendarEvents) {
|
||||
const wf = workflowById.get(event.workflow_id);
|
||||
if (!wf) continue;
|
||||
const d = new Date(event.fire_at);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push({ workflow: wf, date: d });
|
||||
map.set(key, arr);
|
||||
}
|
||||
for (const arr of map.values()) {
|
||||
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
|
||||
|
||||
const SLOT_H = compact ? 32 : 44;
|
||||
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onTestFirst: () => void;
|
||||
onScheduleAnyway: () => void;
|
||||
}
|
||||
|
||||
// Shown before scheduling a workflow whose current steps haven't been validated
|
||||
// by a test run. Scheduled fires can't pause to ask for tool permission, so an
|
||||
// untested workflow that needs approval would silently fail on its first run.
|
||||
export default function ScheduleTestWarningDialog({ open, onClose, onTestFirst, onScheduleAnyway }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>Test before scheduling?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ fontSize: '0.86rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
Scheduled runs can't pause to ask for permission. If this workflow uses tools that
|
||||
need your approval, it could fail when it runs on its own. A quick test run lets you
|
||||
approve those tools now.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onScheduleAnyway}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.secondary, cursor: 'pointer', px: 1, py: 0.5, '&:hover': { color: c.text.primary } }}>
|
||||
Schedule anyway
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onTestFirst}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: 'pointer', px: 1.5, py: 0.6, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Test first
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,30 @@ const PRESETS: Preset[] = [
|
||||
{ label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) },
|
||||
];
|
||||
|
||||
function extractStepsFromSession(session: { messages?: Array<{ role: string; content: unknown; hidden?: boolean }> } | null | undefined): 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;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
@@ -61,6 +85,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
const sessionDashboardId = useAppSelector(
|
||||
(s) => sessionId ? s.agents.sessions[sessionId]?.dashboard_id : null,
|
||||
);
|
||||
const sourceSession = useAppSelector((s) => sessionId ? s.agents.sessions[sessionId] : null);
|
||||
|
||||
// Dup-detect: a chat session can only sanely have one schedule attached.
|
||||
// If we find one already, offer "Open existing" instead of silently
|
||||
@@ -82,6 +107,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
source_session_id: sessionId,
|
||||
steps: extractStepsFromSession(sourceSession),
|
||||
schedule,
|
||||
} as Partial<Workflow>));
|
||||
if (createWorkflow.fulfilled.match(result)) {
|
||||
@@ -98,7 +124,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
|
||||
}, [busy, dispatch, sessionId, sourceSession, title, onClose, onCreated]);
|
||||
|
||||
const openCustom = useCallback(() => {
|
||||
// Open a local draft. NO backend create yet — the workflow only
|
||||
|
||||
@@ -5,6 +5,7 @@ import { motion } from 'framer-motion';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
@@ -14,6 +15,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeWorkflowCard,
|
||||
controlWorkflowRun,
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
fetchRuns,
|
||||
@@ -45,6 +47,7 @@ import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import StopRounded from '@mui/icons-material/StopRounded';
|
||||
import PauseRounded from '@mui/icons-material/PauseRounded';
|
||||
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun, isRealTitle } from './workflowVisuals';
|
||||
import { stepsSignature } from './scheduleUtils';
|
||||
import { store } from '@/shared/state/store';
|
||||
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
|
||||
|
||||
@@ -379,18 +382,24 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
const persistDraft = useCallback(async (): Promise<Workflow | null> => {
|
||||
const d = card?.draft;
|
||||
if (!d || persistingRef.current) return null;
|
||||
const draftSteps = d.steps || [];
|
||||
if (!draftSteps.some((s) => (s.text || '').trim().length > 0)) return null;
|
||||
persistingRef.current = true;
|
||||
try {
|
||||
const result = await dispatch(createWorkflow({
|
||||
title: (d.title as string) || 'New workflow',
|
||||
description: (d.description as string) || '',
|
||||
steps: (d.steps || []).map((s) => ({ id: s.id, text: s.text })),
|
||||
steps: draftSteps.map((s) => ({ id: s.id, text: s.text })),
|
||||
source_session_id: (d.source_session_id as string | undefined) || card?.sourceSessionId || null,
|
||||
use_synced_prompt: true,
|
||||
model: defaultModel || (d.model as string),
|
||||
mode: defaultMode || (d.mode as string),
|
||||
tested_signature: ((d.source_session_id as string | undefined) || card?.sourceSessionId)
|
||||
? stepsSignature(draftSteps)
|
||||
: undefined,
|
||||
} as Partial<Workflow>));
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (!createWorkflow.fulfilled.match(result)) return null;
|
||||
const wf = result.payload as Workflow;
|
||||
if (!wf?.id) return null;
|
||||
dispatch(rekeyOpenCard({ oldId: workflowId, newId: wf.id }));
|
||||
dispatch(rekeyWorkflowCard({ oldId: workflowId, newId: wf.id }));
|
||||
@@ -641,7 +650,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
const wf = await persistDraft();
|
||||
if (!wf) return;
|
||||
dispatch(openWorkflowCardAction({ workflowId: wf.id, sourceSessionId: card?.sourceSessionId || null, view: 'saved', draft: null }));
|
||||
await dispatch(runWorkflowNow(wf.id));
|
||||
await dispatch(runWorkflowNow({ id: wf.id, signature: stepsSignature(wf.steps) }));
|
||||
await dispatch(fetchRuns(wf.id));
|
||||
} finally {
|
||||
setTimeout(() => setRunStarting(false), 600);
|
||||
@@ -671,7 +680,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
if (runStarting) return;
|
||||
setRunStarting(true);
|
||||
try {
|
||||
const result = await dispatch(runWorkflowNow(workflow.id));
|
||||
const result = await dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) }));
|
||||
await dispatch(fetchRuns(workflow.id));
|
||||
if (runWorkflowNow.fulfilled.match(result)) {
|
||||
const payload = result.payload;
|
||||
@@ -984,56 +993,51 @@ function RunningHeader({ workflowId }: { workflowId: string }) {
|
||||
const runs = useAppSelector((s) => s.workflows.runs[workflowId]);
|
||||
const runId = card?.runId || null;
|
||||
const run = (runs || []).find((r) => r.id === runId);
|
||||
const pendingAction = useAppSelector((s) => runId ? s.workflows.runControlPending[runId] : undefined);
|
||||
// Hit a run-control endpoint by run id. Keyed off runId (not the run object,
|
||||
// which can lag right after Run starts) so Stop/Pause never silently no-op.
|
||||
const postRunAction = React.useCallback(async (action: 'stop' | 'pause' | 'resume') => {
|
||||
if (!runId || pendingAction) return;
|
||||
await dispatch(controlWorkflowRun({ runId, action }));
|
||||
}, [dispatch, runId, pendingAction]);
|
||||
const onStop = React.useCallback(async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(run.id)}/stop`, {
|
||||
method: 'POST',
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved', runId: null } }));
|
||||
}, [dispatch, workflowId, run]);
|
||||
// Pause = "let this run finish, but stop firing future schedules."
|
||||
// Can't actually pause a streaming agent turn mid-call, so we flip
|
||||
// schedule.enabled so the scheduler stops queuing the next fire. The
|
||||
// button label flips to "Resume" while paused; user can re-enable
|
||||
// without leaving the running view.
|
||||
const isPaused = !!workflow && !workflow.schedule.enabled && workflow.schedule.runs_count > 0;
|
||||
const onPauseToggle = React.useCallback(async () => {
|
||||
if (!workflow) return;
|
||||
const next = { ...workflow.schedule, enabled: isPaused };
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: next as Workflow['schedule'] },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
}, [dispatch, workflow, isPaused]);
|
||||
await postRunAction('stop');
|
||||
}, [postRunAction]);
|
||||
// Pause/Resume mirror the chat's stop-agent / resume-agent-response on the
|
||||
// run's own session, so the paused state shows in both the chat and here.
|
||||
const isPaused = !!run?.paused;
|
||||
const onPauseToggle = React.useCallback(() => {
|
||||
void postRunAction(isPaused ? 'resume' : 'pause');
|
||||
}, [postRunAction, isPaused]);
|
||||
const stopPending = pendingAction === 'stop';
|
||||
const pausePending = pendingAction === 'pause' || pendingAction === 'resume';
|
||||
const controlsDisabled = !!pendingAction;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<SubtitleRow workflow={workflow || null} runs={runs || null} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onStop}
|
||||
onClick={controlsDisabled ? undefined : onStop}
|
||||
role="button"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', borderRadius: 999, '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated } }}>
|
||||
<StopRounded sx={{ fontSize: 15 }} />
|
||||
aria-disabled={controlsDisabled}
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: controlsDisabled ? 'default' : 'pointer', borderRadius: 999, opacity: controlsDisabled && !stopPending ? 0.55 : 1, '&:hover': controlsDisabled ? {} : { color: c.text.primary, bgcolor: c.bg.elevated } }}>
|
||||
{stopPending ? <CircularProgress size={14} thickness={5} sx={{ color: c.text.secondary }} /> : <StopRounded sx={{ fontSize: 15 }} />}
|
||||
Stop
|
||||
</Box>
|
||||
<Tooltip title={isPaused ? 'Schedule is paused. Click to resume future fires.' : 'Pause future scheduled fires. This run finishes normally.'}>
|
||||
<Tooltip title={isPaused ? 'Resume the agent and continue this run.' : 'Pause the agent. This run holds until you resume.'}>
|
||||
<Box
|
||||
onClick={onPauseToggle}
|
||||
onClick={controlsDisabled ? undefined : onPauseToggle}
|
||||
role="button"
|
||||
aria-disabled={controlsDisabled}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: controlsDisabled ? 'default' : 'pointer',
|
||||
opacity: controlsDisabled && !pausePending ? 0.55 : 1,
|
||||
'&:hover': controlsDisabled ? {} : { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PauseRounded sx={{ fontSize: 15 }} />
|
||||
{pausePending ? <CircularProgress size={14} thickness={5} sx={{ color: '#fff' }} /> : isPaused ? <PlayArrowIcon sx={{ fontSize: 15 }} /> : <PauseRounded sx={{ fontSize: 15 }} />}
|
||||
{isPaused ? 'Resume' : 'Pause'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
@@ -171,22 +171,6 @@ export function RunningView({ workflow, steps, runs, mode = 'card' }: {
|
||||
|
||||
const isLinked = mode === 'sidecar-linked' && (card?.sidecarKind === 'watching' || card?.sidecarKind === 'testing');
|
||||
|
||||
const onStop = useCallback(async () => {
|
||||
if (!runId) return;
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(runId)}/stop`, {
|
||||
method: 'POST',
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
}, [runId]);
|
||||
const onPause = useCallback(() => {
|
||||
// Pause flips the global paused state; the in-flight run continues but
|
||||
// future fires queue up behind it. Maps to the existing /pause-all path.
|
||||
void undefined;
|
||||
}, []);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const onWatchLive = useCallback(() => {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'watching');
|
||||
@@ -230,11 +214,7 @@ export function RunningView({ workflow, steps, runs, mode = 'card' }: {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{/* Stop / Pause live in the header row, rendered by WorkflowCard.
|
||||
See header-button overrides in WorkflowCard.tsx for the
|
||||
per-view replacement of History/Run. */}
|
||||
<Box sx={{ display: 'none' }} aria-hidden onClick={onStop} />
|
||||
<Box sx={{ display: 'none' }} aria-hidden onClick={onPause} />
|
||||
{/* Stop / Pause live in the header row, rendered by WorkflowCard. */}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ import { placeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSli
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
|
||||
import StepList from './StepList';
|
||||
import { isScheduleConfigured } from './scheduleUtils';
|
||||
import { isScheduleConfigured, needsScheduleTestWarning, stepsSignature } from './scheduleUtils';
|
||||
import ScheduleTestWarningDialog from './ScheduleTestWarningDialog';
|
||||
import { runWorkflowTest } from './runWorkflowTest';
|
||||
import { useOpenSidecar } from './WorkflowCardLiveViews';
|
||||
|
||||
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
|
||||
if (s === 'success') return c.status.success;
|
||||
@@ -123,6 +126,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial<Workflow>;
|
||||
const title = (liveDraft.title as string) || 'New workflow';
|
||||
const description = (liveDraft.description as string) || '';
|
||||
const canSave = steps.some((s) => (s.text || '').trim().length > 0);
|
||||
// The new workflow runs with the user's configured default model/mode (their
|
||||
// subscription, etc.), falling back to whatever the source chat used. Without
|
||||
// this the backend picks its own default, which surprised users who'd set a
|
||||
@@ -161,6 +165,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
}, [closeRequestNonce]);
|
||||
|
||||
const saveWorkflow = useCallback(async (): Promise<Workflow | null> => {
|
||||
if (!canSave) return null;
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
description,
|
||||
@@ -171,11 +176,15 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
// happened to run on, so a converted workflow behaves like a fresh chat.
|
||||
model: defaultModel || (liveDraft.model as string),
|
||||
mode: defaultMode || (liveDraft.mode as string),
|
||||
// Converting a chat carries its prior approvals, so count it as already
|
||||
// validated for these steps: scheduling won't nag to test first.
|
||||
tested_signature: sourceSessionId ? stepsSignature(steps) : undefined,
|
||||
} as Partial<Workflow>));
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (!createWorkflow.fulfilled.match(result)) return null;
|
||||
const wf = result.payload as Workflow;
|
||||
if (wf?.id) return wf;
|
||||
return null;
|
||||
}, [dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
|
||||
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
|
||||
|
||||
const onIgnore = useCallback(async () => {
|
||||
if (busy) return;
|
||||
@@ -183,7 +192,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
}, [busy]);
|
||||
|
||||
const onSaveThenSchedule = useCallback(async () => {
|
||||
if (busy) return;
|
||||
if (busy || !canSave) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
@@ -191,10 +200,10 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, saveWorkflow, onSaved]);
|
||||
}, [busy, canSave, saveWorkflow, onSaved]);
|
||||
|
||||
const onSaveDraft = useCallback(async () => {
|
||||
if (busy) return;
|
||||
if (busy || !canSave) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
@@ -203,7 +212,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
setBusy(false);
|
||||
setSavePromptOpen(false);
|
||||
}
|
||||
}, [busy, saveWorkflow, onSaved]);
|
||||
}, [busy, canSave, saveWorkflow, onSaved]);
|
||||
|
||||
const onDontSave = useCallback(() => {
|
||||
setSavePromptOpen(false);
|
||||
@@ -252,15 +261,16 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
Not now
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onSaveThenSchedule}
|
||||
onClick={canSave ? onSaveThenSchedule : undefined}
|
||||
role="button"
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: 999,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: busy || !canSave ? 0.6 : 1,
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
|
||||
}}>
|
||||
Schedule Workflow
|
||||
@@ -288,8 +298,9 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onSaveDraft}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : 'pointer', px: 1.5, py: 0.6, opacity: busy ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
onClick={canSave ? onSaveDraft : undefined}
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed', px: 1.5, py: 0.6, opacity: busy || !canSave ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Save
|
||||
</Box>
|
||||
</DialogActions>
|
||||
@@ -343,9 +354,25 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
const openEditAgent = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const [warnOpen, setWarnOpen] = useState(false);
|
||||
const openScheduling = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling', showScheduleNudge: false } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
// Gate the schedule action: warn first if the current steps haven't been
|
||||
// validated by a test run (so an unattended fire won't silently deny a tool).
|
||||
const requestSchedule = useCallback(() => {
|
||||
if (needsScheduleTestWarning(workflow)) { setWarnOpen(true); return; }
|
||||
openScheduling();
|
||||
}, [workflow, openScheduling]);
|
||||
const onTestFirst = useCallback(() => {
|
||||
setWarnOpen(false);
|
||||
void runWorkflowTest(workflow.id, workflow.draft_steps ?? workflow.steps, openSidecar);
|
||||
}, [workflow.id, workflow.draft_steps, workflow.steps, openSidecar]);
|
||||
const onScheduleAnyway = useCallback(() => {
|
||||
setWarnOpen(false);
|
||||
openScheduling();
|
||||
}, [openScheduling]);
|
||||
const onToggleStep = useCallback((stepId: string) => {
|
||||
dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId }));
|
||||
}, [dispatch, workflow.id]);
|
||||
@@ -434,7 +461,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
Not now
|
||||
</Box>
|
||||
<Box
|
||||
onClick={openScheduling}
|
||||
onClick={requestSchedule}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
@@ -453,7 +480,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
{showNudge ? <Box /> : (
|
||||
<Box
|
||||
onClick={scheduleClickable ? openScheduling : undefined}
|
||||
onClick={scheduleClickable ? requestSchedule : undefined}
|
||||
role={scheduleClickable ? 'button' : undefined}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.6,
|
||||
@@ -483,6 +510,12 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
Edit
|
||||
</Box>
|
||||
</Box>
|
||||
<ScheduleTestWarningDialog
|
||||
open={warnOpen}
|
||||
onClose={() => setWarnOpen(false)}
|
||||
onTestFirst={onTestFirst}
|
||||
onScheduleAnyway={onScheduleAnyway}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import Tooltip from '@mui/material/Tooltip';
|
||||
import { useEffect } from 'react';
|
||||
import ScheduleCalendar from './ScheduleCalendar';
|
||||
import AddToSchedulePopover from './AddToSchedulePopover';
|
||||
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable } from './scheduleUtils';
|
||||
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable, stepsSignature } from './scheduleUtils';
|
||||
import { isRealTitle } from './workflowVisuals';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
|
||||
@@ -144,6 +144,23 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
const [view, setView] = useState<CalendarView>('List');
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [refDate, setRefDate] = useState(new Date());
|
||||
|
||||
// The hub card lives on the canvas for days at a time, so a refDate frozen
|
||||
// at mount leaves the calendar stuck on the day it was opened (e.g. still
|
||||
// showing "yesterday" past midnight). Roll it forward when the day flips,
|
||||
// but only if the user was parked on today, so manual navigation is left be.
|
||||
const refDateRef = useRef(refDate);
|
||||
refDateRef.current = refDate;
|
||||
useEffect(() => {
|
||||
let lastToday = new Date();
|
||||
const id = window.setInterval(() => {
|
||||
const now = new Date();
|
||||
if (sameDay(now, lastToday)) return;
|
||||
if (sameDay(refDateRef.current, lastToday)) setRefDate(now);
|
||||
lastToday = now;
|
||||
}, 60000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
// Right-click on a sidebar row opens this menu pinned to the cursor.
|
||||
@@ -522,7 +539,10 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
anchorPosition={sidebarCtxMenu ? { top: sidebarCtxMenu.y, left: sidebarCtxMenu.x } : undefined}>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
|
||||
dispatch(runWorkflowNow({
|
||||
id: sidebarCtxMenu.workflow.id,
|
||||
signature: stepsSignature(sidebarCtxMenu.workflow.steps),
|
||||
}));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Run now</MenuItem>
|
||||
{sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import type { WorkflowStep } from '@/shared/state/workflowsSlice';
|
||||
import { stepsSignature } from './scheduleUtils';
|
||||
|
||||
type OpenSidecar = (sessionId: string, kind: 'testing') => Promise<void>;
|
||||
|
||||
// Kick off a Test Agent run for the given (possibly-draft) steps and wire its
|
||||
// session into the workflow card's sidecar. The signature rides along so a
|
||||
// completed test run stamps the workflow as validated (see scheduleUtils +
|
||||
// the test-run endpoint). Returns the session id, or null if it didn't start.
|
||||
export async function runWorkflowTest(
|
||||
workflowId: string,
|
||||
steps: WorkflowStep[],
|
||||
openSidecar: OpenSidecar,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/test-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ steps, signature: stepsSignature(steps) }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const sid = data?.session_id as string | undefined;
|
||||
if (!sid) return null;
|
||||
await openSidecar(sid, 'testing');
|
||||
return sid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow, ScheduleConfig, WorkflowStep } from '@/shared/state/workflowsSlice';
|
||||
|
||||
export const WEEKDAY_LABEL = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
export const WEEKDAY_LABEL_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
||||
@@ -39,6 +39,23 @@ export function isWorkflowSchedulable(workflow: Workflow): boolean {
|
||||
return isScheduleConfigured(workflow.schedule);
|
||||
}
|
||||
|
||||
// Stable fingerprint of the steps that actually drive behavior (order + id +
|
||||
// text). label is just the at-a-glance headline, so it's left out. Computed
|
||||
// only here so the backend stores exactly what the FE compares: no cross-
|
||||
// language hashing drift.
|
||||
export function stepsSignature(steps: WorkflowStep[] | null | undefined): string {
|
||||
return JSON.stringify((steps || []).map((s) => [s.id, s.text]));
|
||||
}
|
||||
|
||||
// True when the current steps haven't been validated by a test run (or seeded
|
||||
// at chat conversion) since they were last edited. Drives the test-first
|
||||
// warning before scheduling.
|
||||
export function needsScheduleTestWarning(workflow: Workflow): boolean {
|
||||
const steps = workflow.draft_steps ?? workflow.steps;
|
||||
if (!steps || steps.length === 0) return false;
|
||||
return stepsSignature(steps) !== (workflow.tested_signature ?? '');
|
||||
}
|
||||
|
||||
export function formatTime(hour: number, minute: number): string {
|
||||
const h12 = ((hour + 11) % 12) + 1;
|
||||
const suffix = hour < 12 ? 'am' : 'pm';
|
||||
|
||||
@@ -168,6 +168,19 @@ export const resetSystemPrompt = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const dismissMcpSuggestion = createAsyncThunk(
|
||||
'settings/dismissMcpSuggestion',
|
||||
async (ids: string[]) => {
|
||||
const res = await fetch(`${SETTINGS_API}/dismiss-mcp-suggestion`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.settings as AppSettings;
|
||||
}
|
||||
);
|
||||
|
||||
export const browseDirectories = createAsyncThunk(
|
||||
'settings/browseDirectories',
|
||||
async (path: string) => {
|
||||
@@ -298,6 +311,10 @@ const settingsSlice = createSlice({
|
||||
state.data = action.payload;
|
||||
state.draft = null;
|
||||
state.draftTab = null;
|
||||
})
|
||||
.addCase(dismissMcpSuggestion.fulfilled, (state, action) => {
|
||||
state.latestWriteId = action.meta.requestId;
|
||||
state.data = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,10 +91,16 @@ export interface Workflow {
|
||||
* unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */
|
||||
remembered_approvals?: Record<string, 'allow' | 'deny'>;
|
||||
step_tool_usage?: Record<string, Record<string, boolean>>;
|
||||
/** Tool names observed in the source chat when this workflow was generated. */
|
||||
source_tools?: string[];
|
||||
/** False once the user explicitly renames the workflow; backend may auto-rename while true. */
|
||||
auto_named?: boolean;
|
||||
/** True while a brand-new "+ New" workflow is still being built and hasn't been saved; hub hides these. */
|
||||
unsaved?: boolean;
|
||||
/** Signature of the steps last validated by a test run (or seeded at chat
|
||||
* conversion). Compared against the current steps to decide whether to warn
|
||||
* before scheduling. See scheduleUtils.needsScheduleTestWarning. */
|
||||
tested_signature?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
@@ -113,8 +119,13 @@ export interface WorkflowRun {
|
||||
/** Currently-executing 0-based step index while status is 'running';
|
||||
* freezes on the failed step when status flips to 'failure'. */
|
||||
active_step_idx?: number | null;
|
||||
/** True while the user has paused the in-flight agent turn (chat-style
|
||||
* stop/resume). Drives the running card's Pause/Resume button. */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export type WorkflowRunControlAction = 'pause' | 'resume' | 'stop';
|
||||
|
||||
/** Transient view-only state per card; position lives in dashboardLayoutSlice.workflowCards. */
|
||||
export interface OpenCard {
|
||||
workflowId: string;
|
||||
@@ -169,9 +180,73 @@ interface State {
|
||||
allRuns: WorkflowRun[];
|
||||
allRunsLoading: boolean;
|
||||
runningToast: RunningToast | null;
|
||||
runControlPending: Record<string, WorkflowRunControlAction>;
|
||||
}
|
||||
|
||||
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null };
|
||||
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, runControlPending: {} };
|
||||
|
||||
function mergeRunIntoState(state: State, r: WorkflowRun) {
|
||||
const arr = state.runs[r.workflow_id] || [];
|
||||
const idx = arr.findIndex((x) => x.id === r.id);
|
||||
const prev = idx >= 0 ? arr[idx] : null;
|
||||
if (idx >= 0) arr[idx] = r; else arr.unshift(r);
|
||||
state.runs[r.workflow_id] = arr.slice(0, 100);
|
||||
// Keep the cross-workflow log (Scheduled tasks history tab) live without a refetch.
|
||||
const aIdx = state.allRuns.findIndex((x) => x.id === r.id);
|
||||
if (aIdx >= 0) state.allRuns[aIdx] = r; else state.allRuns.unshift(r);
|
||||
state.allRuns.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
||||
state.allRuns = state.allRuns.slice(0, 200);
|
||||
const pending = state.runControlPending[r.id];
|
||||
if (
|
||||
(pending === 'pause' && r.paused) ||
|
||||
(pending === 'resume' && !r.paused) ||
|
||||
(pending === 'stop' && r.status !== 'running')
|
||||
) {
|
||||
delete state.runControlPending[r.id];
|
||||
}
|
||||
const wf = state.items[r.workflow_id];
|
||||
if (wf) {
|
||||
wf.last_run_at = r.finished_at || r.started_at;
|
||||
wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']);
|
||||
wf.last_run_id = r.id;
|
||||
}
|
||||
// Auto-flip the card view on run state transitions so the user sees
|
||||
// Running while it streams, Completed on success, Failed on failure.
|
||||
// Only nudge from views that the user hasn't actively navigated away
|
||||
// from (saved / running). Edit, history, scheduling etc. stay put.
|
||||
const card = state.openCards[r.workflow_id];
|
||||
// A scheduled run flipping into 'running' fired unattended, so nudge the
|
||||
// user with a clickable toast. Only on the into-running edge (not every
|
||||
// tool-label/step bump), and only for schedule (manual runs they kicked
|
||||
// off themselves don't need a "surprise, it's running" popup).
|
||||
if (r.status === 'running' && r.triggered_by === 'schedule' && (!prev || prev.status !== 'running')) {
|
||||
state.runningToast = {
|
||||
workflowId: r.workflow_id,
|
||||
runId: r.id,
|
||||
workflowTitle: state.items[r.workflow_id]?.title || 'Workflow',
|
||||
};
|
||||
}
|
||||
if (card) {
|
||||
const fromRunnable = card.view === 'saved' || card.view === 'running';
|
||||
if (r.status === 'running' && fromRunnable) {
|
||||
card.view = 'running';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'success' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'completed';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'failure' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'failed';
|
||||
card.runId = r.id;
|
||||
}
|
||||
// A run that finishes while the user is watching it live becomes a
|
||||
// "viewing" link so the sibling chat stays open with Stop Viewing,
|
||||
// not a stale "watching" arrow pointing at a finished run.
|
||||
if (card.sidecarSessionId && card.sidecarKind === 'watching' && prev && prev.status === 'running') {
|
||||
if (r.status === 'failure') card.sidecarKind = 'viewing-error';
|
||||
else if (r.status === 'success' || r.status === 'ran_late') card.sidecarKind = 'viewing-completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchWorkflows = createAsyncThunk(
|
||||
'workflows/fetch',
|
||||
@@ -249,8 +324,18 @@ export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: st
|
||||
return id;
|
||||
});
|
||||
|
||||
export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: string) => {
|
||||
const res = await fetch(`${API}/${id}/run`, { method: 'POST' });
|
||||
type RunWorkflowNowArg = string | { id: string; signature?: string | null };
|
||||
|
||||
export const runWorkflowNow = createAsyncThunk('workflows/run', async (arg: RunWorkflowNowArg) => {
|
||||
const id = typeof arg === 'string' ? arg : arg.id;
|
||||
const signature = typeof arg === 'string' ? null : arg.signature;
|
||||
const res = await fetch(`${API}/${id}/run`, {
|
||||
method: 'POST',
|
||||
...(signature ? {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ signature }),
|
||||
} : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`run failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
@@ -261,6 +346,20 @@ export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: strin
|
||||
};
|
||||
});
|
||||
|
||||
export const controlWorkflowRun = createAsyncThunk(
|
||||
'workflows/controlRun',
|
||||
async ({ runId, action }: { runId: string; action: WorkflowRunControlAction }) => {
|
||||
const res = await fetch(`${API}/runs/${encodeURIComponent(runId)}/${action}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`${action} failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
runId,
|
||||
action,
|
||||
run: (data.run || null) as WorkflowRun | null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchRuns = createAsyncThunk(
|
||||
'workflows/runs',
|
||||
async (id: string) => {
|
||||
@@ -335,62 +434,7 @@ const slice = createSlice({
|
||||
state.openCards[action.payload.newId] = { ...entry, workflowId: action.payload.newId };
|
||||
},
|
||||
upsertRun(state, action: { payload: WorkflowRun }) {
|
||||
const r = action.payload;
|
||||
const arr = state.runs[r.workflow_id] || [];
|
||||
const idx = arr.findIndex((x) => x.id === r.id);
|
||||
const prev = idx >= 0 ? arr[idx] : null;
|
||||
if (idx >= 0) arr[idx] = r; else arr.unshift(r);
|
||||
state.runs[r.workflow_id] = arr.slice(0, 100);
|
||||
// Keep the cross-workflow log (Scheduled tasks history tab) live without a refetch.
|
||||
const aIdx = state.allRuns.findIndex((x) => x.id === r.id);
|
||||
if (aIdx >= 0) state.allRuns[aIdx] = r; else state.allRuns.unshift(r);
|
||||
state.allRuns.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
||||
state.allRuns = state.allRuns.slice(0, 200);
|
||||
const wf = state.items[r.workflow_id];
|
||||
if (wf) {
|
||||
wf.last_run_at = r.finished_at || r.started_at;
|
||||
wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']);
|
||||
wf.last_run_id = r.id;
|
||||
}
|
||||
// Auto-flip the card view on run state transitions so the user sees
|
||||
// Running while it streams, Completed on success, Failed on failure.
|
||||
// Only nudge from views that the user hasn't actively navigated away
|
||||
// from (saved / running). Edit, history, scheduling etc. stay put.
|
||||
const card = state.openCards[r.workflow_id];
|
||||
if (card) {
|
||||
const fromRunnable = card.view === 'saved' || card.view === 'running';
|
||||
if (r.status === 'running' && fromRunnable) {
|
||||
card.view = 'running';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'success' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'completed';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'failure' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'failed';
|
||||
card.runId = r.id;
|
||||
}
|
||||
// A run that finishes while the user is watching it live becomes a
|
||||
// "viewing" link so the sibling chat stays open with Stop Viewing,
|
||||
// not a stale "watching" arrow pointing at a finished run.
|
||||
if (card.sidecarSessionId && card.sidecarKind === 'watching' && prev && prev.status === 'running') {
|
||||
if (r.status === 'failure') card.sidecarKind = 'viewing-error';
|
||||
else if (r.status === 'success' || r.status === 'ran_late') card.sidecarKind = 'viewing-completed';
|
||||
}
|
||||
}
|
||||
// A scheduled run flipping into 'running' fired unattended, so nudge the
|
||||
// user with a clickable toast. Only on the into-running edge (not every
|
||||
// tool-label/step bump), and only for schedule (manual runs they kicked
|
||||
// off themselves don't need a "surprise, it's running" popup).
|
||||
if (r.status === 'running' && r.triggered_by === 'schedule' && (!prev || prev.status !== 'running')) {
|
||||
state.runningToast = {
|
||||
workflowId: r.workflow_id,
|
||||
runId: r.id,
|
||||
workflowTitle: state.items[r.workflow_id]?.title || 'Workflow',
|
||||
};
|
||||
}
|
||||
},
|
||||
dismissRunningToast(state) {
|
||||
state.runningToast = null;
|
||||
mergeRunIntoState(state, action.payload);
|
||||
},
|
||||
toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) {
|
||||
const card = state.openCards[action.payload.workflowId];
|
||||
@@ -421,6 +465,9 @@ const slice = createSlice({
|
||||
delete state.runs[action.payload];
|
||||
state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload);
|
||||
},
|
||||
dismissRunningToast(state) {
|
||||
state.runningToast = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
@@ -441,8 +488,46 @@ const slice = createSlice({
|
||||
delete state.runs[action.payload];
|
||||
state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload);
|
||||
})
|
||||
.addCase(runWorkflowNow.fulfilled, (state, action) => {
|
||||
// Enter the running view the moment the run kicks off, off the run_id
|
||||
// the REST call returns. Don't wait for the workflow:run WS event:
|
||||
// if it's missed or races the view, the Stop/Pause header never shows.
|
||||
const { id, run_id, status } = action.payload;
|
||||
const card = state.openCards[id];
|
||||
if (!card || !run_id || status !== 'running') return;
|
||||
if (['saved', 'running', 'completed', 'failed', 'history', 'history_detail'].includes(card.view)) {
|
||||
card.view = 'running';
|
||||
card.runId = run_id;
|
||||
}
|
||||
})
|
||||
.addCase(controlWorkflowRun.pending, (state, action) => {
|
||||
state.runControlPending[action.meta.arg.runId] = action.meta.arg.action;
|
||||
})
|
||||
.addCase(controlWorkflowRun.fulfilled, (state, action) => {
|
||||
if (action.payload.run) {
|
||||
mergeRunIntoState(state, action.payload.run);
|
||||
}
|
||||
if (action.payload.action !== 'stop') {
|
||||
delete state.runControlPending[action.payload.runId];
|
||||
} else if (action.payload.run && action.payload.run.status !== 'running') {
|
||||
delete state.runControlPending[action.payload.runId];
|
||||
}
|
||||
})
|
||||
.addCase(controlWorkflowRun.rejected, (state, action) => {
|
||||
delete state.runControlPending[action.meta.arg.runId];
|
||||
})
|
||||
.addCase(fetchRuns.fulfilled, (state, action) => {
|
||||
state.runs[action.payload.id] = action.payload.runs;
|
||||
for (const r of action.payload.runs) {
|
||||
const pending = state.runControlPending[r.id];
|
||||
if (
|
||||
(pending === 'pause' && r.paused) ||
|
||||
(pending === 'resume' && !r.paused) ||
|
||||
(pending === 'stop' && r.status !== 'running')
|
||||
) {
|
||||
delete state.runControlPending[r.id];
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(fetchAllRuns.pending, (state) => { state.allRunsLoading = true; })
|
||||
.addCase(fetchAllRuns.fulfilled, (state, action) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPositio
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { displaySessionName } from '../state/sessionDisplay';
|
||||
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
|
||||
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { getAuthToken } from '../config';
|
||||
import { notifyAgentCompletion } from '../notifications';
|
||||
|
||||
@@ -946,7 +947,8 @@ import { WS_BASE } from '@/shared/config';
|
||||
return;
|
||||
}
|
||||
if (outcome === 'rerun') {
|
||||
store.dispatch(runWorkflowNow(workflowId));
|
||||
const wf = store.getState().workflows.items[workflowId];
|
||||
store.dispatch(wf ? runWorkflowNow({ id: workflowId, signature: stepsSignature(wf.steps) }) : runWorkflowNow(workflowId));
|
||||
return;
|
||||
}
|
||||
if (outcome === 'edit' || outcome === 'open') {
|
||||
|
||||
Reference in New Issue
Block a user