From 3f780bf2b3b34361e12fb34eedd75c834a72505e Mon Sep 17 00:00:00 2001 From: abccodes Date: Thu, 18 Jun 2026 04:12:52 -0700 Subject: [PATCH] [aidan] feat/workflows: validate steps before scheduling and keep chat tool memory --- backend/apps/workflows/executor.py | 22 ++++- backend/apps/workflows/models.py | 11 +++ backend/apps/workflows/workflows.py | 87 ++++++++++++++++--- .../pages/Workflows/AddToSchedulePopover.tsx | 7 +- .../src/app/pages/Workflows/EditAgentView.tsx | 37 ++++---- .../app/pages/Workflows/ScheduleCalendar.tsx | 7 +- .../Workflows/ScheduleTestWarningDialog.tsx | 48 ++++++++++ .../src/app/pages/Workflows/WorkflowCard.tsx | 11 ++- .../pages/Workflows/WorkflowCardSubviews.tsx | 37 +++++++- .../app/pages/Workflows/WorkflowsHubCard.tsx | 7 +- .../app/pages/Workflows/runWorkflowTest.ts | 32 +++++++ .../src/app/pages/Workflows/scheduleUtils.ts | 19 +++- frontend/src/shared/state/workflowsSlice.ts | 20 ++++- frontend/src/shared/ws/WebSocketManager.ts | 4 +- 14 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx create mode 100644 frontend/src/app/pages/Workflows/runWorkflowTest.ts diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index fb8188a1..146d78cf 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -79,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 @@ -92,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 @@ -150,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, @@ -363,10 +374,13 @@ 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" diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 8b134891..b5c3212e 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -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): @@ -195,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): diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index ea50ee26..785b7f6b 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -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: @@ -199,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) @@ -869,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: @@ -951,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) @@ -1049,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") @@ -1057,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 diff --git a/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx b/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx index 8cc8a7d2..0c744bc1 100644 --- a/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx @@ -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]); diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx index 2f6a039f..ecf722a3 100644 --- a/frontend/src/app/pages/Workflows/EditAgentView.tsx +++ b/frontend/src/app/pages/Workflows/EditAgentView.tsx @@ -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; @@ -136,11 +138,6 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd } catch { /* best-effort */ } }, [testSessionId]); - const onSaveClick = useCallback((e: React.MouseEvent) => { - setSaveAnchorEl(e.currentTarget); - setSavePhase('ask-test'); - }, []); - const onSaveNow = useCallback(async () => { if (!canSave) return; setSavePhase('idle'); @@ -152,24 +149,20 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd toSaved(); }, [canSave, dispatch, workflow.id, toSaved]); + const onSaveClick = useCallback((e: React.MouseEvent) => { + 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'); + }, [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) => { diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 38851d86..dcfeb12b 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -11,7 +11,7 @@ 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, formatTime, formatHourLabel } from './scheduleUtils'; +import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils'; interface Props { view: 'Week' | 'Month' | 'List'; @@ -40,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 = () => { diff --git a/frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx b/frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx new file mode 100644 index 00000000..e4e3d6c4 --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx @@ -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 ( + + Test before scheduling? + + + 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. + + + + + Schedule anyway + + + Test first + + + + ); +} diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 2b780cea..5b03aca6 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -47,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'; @@ -393,8 +394,12 @@ const WorkflowCard: React.FC = ({ 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)); - 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 })); @@ -645,7 +650,7 @@ const WorkflowCard: React.FC = ({ 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); @@ -675,7 +680,7 @@ const WorkflowCard: React.FC = ({ 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; diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 04201390..6ebc2d03 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -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): string { if (s === 'success') return c.status.success; @@ -173,8 +176,12 @@ 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)); - 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; }, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]); @@ -347,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]); @@ -438,7 +461,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo Not now {showNudge ? : ( + setWarnOpen(false)} + onTestFirst={onTestFirst} + onScheduleAnyway={onScheduleAnyway} + /> ); } diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index dc603908..149599d7 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -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'; @@ -539,7 +539,10 @@ const WorkflowsHubCard: React.FC = ({ anchorPosition={sidebarCtxMenu ? { top: sidebarCtxMenu.y, left: sidebarCtxMenu.x } : undefined}> { if (!sidebarCtxMenu) return; - dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id)); + dispatch(runWorkflowNow({ + id: sidebarCtxMenu.workflow.id, + signature: stepsSignature(sidebarCtxMenu.workflow.steps), + })); closeSidebarCtxMenu(); }}>Run now {sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && ( diff --git a/frontend/src/app/pages/Workflows/runWorkflowTest.ts b/frontend/src/app/pages/Workflows/runWorkflowTest.ts new file mode 100644 index 00000000..28e74cc9 --- /dev/null +++ b/frontend/src/app/pages/Workflows/runWorkflowTest.ts @@ -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; + +// 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 { + 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; + } +} diff --git a/frontend/src/app/pages/Workflows/scheduleUtils.ts b/frontend/src/app/pages/Workflows/scheduleUtils.ts index 51ecea0c..53b89d56 100644 --- a/frontend/src/app/pages/Workflows/scheduleUtils.ts +++ b/frontend/src/app/pages/Workflows/scheduleUtils.ts @@ -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'; diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 360a2c76..85005fe8 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -91,10 +91,16 @@ export interface Workflow { * unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */ remembered_approvals?: Record; step_tool_usage?: Record>; + /** 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 { @@ -326,8 +332,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 { diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 78b517f6..6d41d1aa 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -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') {