[aidan] feat/workflow-runs: add pause, resume, and stop controls for live runs

This commit is contained in:
abccodes
2026-06-18 03:53:27 -07:00
parent 5648db5b77
commit 506bcb7cbf
8 changed files with 448 additions and 159 deletions
+88 -19
View File
@@ -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
@@ -222,12 +246,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 +280,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 +308,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 +328,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)
@@ -327,6 +372,7 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
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 +380,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 +425,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)
+4
View File
@@ -169,6 +169,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):
+101 -39
View File
@@ -891,9 +891,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:
@@ -1024,54 +1023,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)
+86
View File
@@ -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():
@@ -319,6 +328,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():
+25 -3
View File
@@ -269,6 +269,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,6 +335,9 @@ 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);
@@ -471,7 +493,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
didDispatchQueued = true;
} else {
if (curr === 'stopped') {
setShowResumeBubble(true);
setShowResumeBubble(!isWorkflowRunSidecar);
}
}
@@ -489,7 +511,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
@@ -1872,7 +1894,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}
@@ -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,
@@ -984,56 +986,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>
@@ -172,22 +172,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');
@@ -231,11 +215,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>
);
}
+112 -43
View File
@@ -113,8 +113,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;
@@ -162,9 +167,62 @@ interface State {
cloudSmsEnabled: boolean;
allRuns: WorkflowRun[];
allRunsLoading: boolean;
runControlPending: Record<string, WorkflowRunControlAction>;
}
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false };
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, 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];
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',
@@ -254,6 +312,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) => {
@@ -328,48 +400,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';
}
}
mergeRunIntoState(state, action.payload);
},
toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) {
const card = state.openCards[action.payload.workflowId];
@@ -420,8 +451,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) => {