diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 9d91456b..b9248375 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -612,6 +612,12 @@ class AgentManager: "mcp__openswarm-schedule__DeleteScheduledWorkflow", "mcp__openswarm-schedule__PauseAllWorkflows", } + _CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete") + + def _is_claude_schedule_skill(tool_name: str, tool_input) -> bool: + if tool_name != "Skill" or not isinstance(tool_input, dict): + return False + return str(tool_input.get("skill") or "").strip().lower() == "schedule" # OS-level scheduling across macOS/Linux/Windows. Agent must # not install cron entries, launchd plists, Windows scheduled @@ -732,6 +738,8 @@ class AgentManager: """ if tool_name == "Bash" and _looks_like_os_scheduling(tool_input): return "ask", None + if tool_name in _CLAUDE_INTERNAL_SCHEDULER_TOOLS: + return "deny", None # Committing or mutating a native recurring schedule is the in-app # twin of the crontab gate above: real, user-visible, hard-to-undo, # so it goes through ApprovalBar every time regardless of the @@ -907,6 +915,14 @@ class AgentManager: return decision async def can_use_tool(tool_name, input_data, context): + if _is_claude_schedule_skill(tool_name, input_data): + p_note_tool_used(tool_name, False) + return PermissionResultDeny( + message=( + "Use the openswarm-schedule MCP tools instead of " + "Claude's internal schedule skill." + ) + ) sensitive_pattern: str | None = None if tool_name != "AskUserQuestion": policy, sensitive_pattern = _maybe_override_policy( @@ -936,6 +952,18 @@ class AgentManager: if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) + if _is_claude_schedule_skill(tool_name, tool_input): + p_note_tool_used(tool_name, False) + return { + "hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "deny", + "permissionDecisionReason": ( + "Use the openswarm-schedule MCP tools instead " + "of Claude's internal schedule skill." + ), + } + } policy, sensitive_pattern = _maybe_override_policy( _get_effective_policy(tool_name), tool_name, tool_input ) @@ -1225,17 +1253,35 @@ class AgentManager: mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps) global_settings = load_settings() + # Nudge the agent to surface workflow conversion proactively when + # the user's ask looks recurring. The actual conversion is owned + # by the UI prompt; chat should not turn this into a cadence Q&A. schedule_ctx = ( "\n" - "After completing a substantive task, if the work looks " - "repeatable (the user said 'every', 'each', 'daily', " - "'weekly', 'morning', 'before standup', or you just did " - "the same sequence twice in this session), offer to " - "schedule it. Use AskUserQuestion to confirm cadence, " - "then ScheduleWorkflow to create it. Never reach for " - "crontab, launchctl, or schtasks; always use the native " - "scheduler so the user can see, pause, and edit it. " - "Don't ask after trivial one-off requests.\n" + "When a normal chat produces work that would naturally be " + "useful again later, prefer the openswarm-schedule MCP " + "workflow path over Claude's internal scheduler. If the user " + "explicitly says 'daily', 'weekly', 'every', 'each', " + "'morning', 'before standup', 'monitor', 'alert me', or " + "'keep this updated', treat that as a strong recurring-work " + "signal. After completing that work, call " + "SuggestConvertToWorkflow with a short reason and cadence " + "hint so the UI can open its workflow-conversion prompt. You " + "may say one short sentence before the tool call, such as " + "'This is a good fit for built-in Workflows.' Do not repeat " + "that advice after the tool call, do not send a follow-up " + "assistant message like 'Done', do not say you are 'nudging' " + "the UI, and do not mention the tool. If the user " + "then says 'ok', 'yes', or 'do it', do not ask what time and " + "do not schedule from chat; the UI prompt/Convert button owns " + "the conversion flow. Only call ScheduleWorkflow when the user " + "explicitly asks you to create a live schedule and has already " + "given an exact cadence and time (for example, 'every weekday " + "at 8am'). If cadence or time is missing, use " + "SuggestConvertToWorkflow instead of asking a follow-up. Never " + "use Skill('schedule'), CronCreate, CronList, CronDelete, " + "crontab, launchctl, or schtasks for user-facing recurring " + "work.\n" "" ) composed_prompt = self._compose_system_prompt( @@ -1551,6 +1597,15 @@ class AgentManager: else: effective_allowed.append(f"mcp__{name}__*") + if "openswarm-schedule" in mcp_servers: + effective_allowed = [ + t for t in effective_allowed + if t not in _CLAUDE_INTERNAL_SCHEDULER_TOOLS + ] + for _bt in _CLAUDE_INTERNAL_SCHEDULER_TOOLS: + if _bt not in effective_disallowed: + effective_disallowed.append(_bt) + # If the openswarm-web MCP was registered, the CLI's built-in # WebSearch/WebFetch are guaranteed to fail (no Anthropic # backend). Suppress them so the model picks our MCP variants diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index a4d20c8f..a9f2228f 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -4,9 +4,9 @@ Why this exists: the agent should be able to schedule recurring work on the user's behalf, but ALWAYS through the native scheduler (visible, auditable, cost-capped) rather than `crontab`. Each tool is a thin -wrapper around /api/workflows/*. The descriptions are written to nudge -the agent toward AskUserQuestion-first behavior (confirm cadence with -the user before calling ScheduleWorkflow). +wrapper around /api/workflows/*. The descriptions are written to prefer +UI-owned workflow conversion for vague recurring asks, and to reserve +ScheduleWorkflow for exact, user-specified live schedules. """ import json @@ -52,8 +52,13 @@ TOOLS = [ "name": "ScheduleWorkflow", "description": ( "Create a recurring scheduled workflow for the user. Use this " - "ONLY after confirming cadence with the user via AskUserQuestion " - "(do not assume — the user must pick or accept the time). " + "ONLY when the user explicitly asks you to create a live schedule " + "and has already supplied an exact cadence and time. Do not use " + "this after a generic convert-to-workflow suggestion, and do not " + "ask follow-up questions like 'what time should it run' from a " + "normal chat. If cadence or time is missing, call " + "SuggestConvertToWorkflow instead so the UI can open the workflow " + "conversion prompt. " "The workflow runs the listed steps on the schedule and is " "visible in the user's Workflows hub. Never use crontab, " "launchctl, or schtasks to schedule recurring work; always use " @@ -237,6 +242,44 @@ TOOLS = [ "required": ["workflow_id"], }, }, + { + "name": "SuggestConvertToWorkflow", + "description": ( + "Call this at the end of a response when the completed task is a clear " + "candidate for repeatable scheduled work (e.g. a daily report, weekly " + "digest, recurring data check, monitoring ping, inbox triage, or status " + "briefing). Prefer this native workflow nudge over Claude's internal " + "schedule skill or CronCreate/CronList/CronDelete tools. Use it whenever " + "the user explicitly mentions daily, weekly, every, each, mornings, " + "standup, monitoring, alerts, or keeping something updated, and when you " + "have just done a sequence that would naturally be useful again later. Do " + "NOT call it for one-off tasks, debugging sessions, creative work, or " + "anything where 'repeat it tomorrow' would be odd. It is OK to call this " + "more than once per session for distinct workflow candidates, but avoid " + "repeated nudges for the same task. This nudges the frontend to highlight " + "the 'Convert to Workflow' button and open the workflow-conversion " + "prompt. In user-facing text, say at most one short sentence, such " + "as: 'This is a good fit for built-in Workflows.' Do not repeat the " + "advice after this tool returns, do not say you are nudging the UI, " + "and do not ask what time it should run. After this tool returns, " + "do not send another assistant message like 'Done'; the UI prompt " + "will handle the next step." + ), + "inputSchema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "A brief, user-friendly explanation of why this task is a good candidate for a recurring workflow (e.g. 'This is a daily report that stays the same'). Shown in the tool bubble.", + }, + "suggested_cadence": { + "type": "string", + "description": "Optional freeform cadence hint (e.g. 'every weekday morning at 9am' or 'weekly on Monday'). Leave blank if uncertain. The frontend will parse it to prefill the schedule.", + }, + }, + "required": ["reason"], + }, + }, ] @@ -298,6 +341,7 @@ def handle_schedule_workflow(args: dict) -> dict: "steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s], "schedule": schedule, "source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None, + "dashboard_id": DASHBOARD_ID or None, } r = _call("POST", "/create", body) if "_error" in r: @@ -507,6 +551,15 @@ def handle_read_test_transcript(args: dict) -> dict: return _ok(f"Test Agent transcript (status: {status}):\n\n{transcript}") +def handle_suggest_convert_to_workflow(args: dict) -> dict: + reason = (args.get("reason") or "").strip() + if not reason: + return _err("reason is required.") + cadence = (args.get("suggested_cadence") or "").strip() + result = json.dumps({"reason": reason, "cadence": cadence}) + return {"content": [{"type": "text", "text": result}]} + + HANDLERS = { "ScheduleWorkflow": handle_schedule_workflow, "ListScheduledWorkflows": handle_list, @@ -520,6 +573,7 @@ HANDLERS = { "DeleteWorkflowStep": handle_delete_step, "TestWorkflow": handle_test_workflow, "ReadTestTranscript": handle_read_test_transcript, + "SuggestConvertToWorkflow": handle_suggest_convert_to_workflow, } diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 64cf7d3d..420cde9a 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -288,7 +288,16 @@ async def create_workflow(body: WorkflowCreate): pass storage.save_workflow(wf) scheduler.kick() - return _enriched(wf) + enriched = _enriched(wf) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:updated", { + "workflow_id": wf.id, + "workflow": enriched, + }) + except Exception: + pass + return enriched @workflows.router.post("/generate-metadata") diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 61b370bb..38bfc8d6 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -30,7 +30,7 @@ import ChatInput from '@/app/pages/AgentChat/ChatInput'; import type { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import SchedulePopover from '@/app/pages/Workflows/SchedulePopover'; import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice'; -import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice'; +import { addWorkflowCard, openWorkflowsHub, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -191,6 +191,7 @@ const DashboardToolbar = React.forwardRef( const allRuns = useAppSelector((s) => s.workflows.allRuns); const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading); const workflowItems = useAppSelector((s) => s.workflows.items); + const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub)); const outputList = useMemo(() => Object.values(outputs), [outputs]); const filteredOutputs = useMemo(() => { @@ -814,15 +815,16 @@ const DashboardToolbar = React.forwardRef( enterDelay={200} title={ - History ⌘O + Workflows + Schedule and calendar } > dispatch(workflowsHubOpen ? closeWorkflowsHub() : openWorkflowsHub({ expandedSessionIds: [] }))} sx={{ display: 'flex', alignItems: 'center', @@ -830,14 +832,15 @@ const DashboardToolbar = React.forwardRef( width: BTN, height: BTN, borderRadius: `${c.radius.md}px`, - color: c.text.tertiary, + color: workflowsHubOpen ? c.accent.primary : c.text.tertiary, + bgcolor: workflowsHubOpen ? c.bg.secondary : 'transparent', cursor: 'pointer', transition: 'opacity 0.15s, background-color 0.15s', '&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary }, ...popIn(3), }} > - + @@ -876,6 +879,40 @@ const DashboardToolbar = React.forwardRef( + + History ⌘O + + } + > + + + + + {placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => ( = ({ toolbarPrefill, toolbarPrefillMode, }) => { + const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard); return ( <> {/* Floating bottom toolbar */} @@ -131,6 +133,7 @@ const DashboardOverlays: React.FC = ({ browserCards, workflowCards, workflowsHub, + missedRunsCard, }} onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })} /> diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index f025c5f6..0392ea6c 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -7,6 +7,7 @@ import Button from '@mui/material/Button'; import Tooltip from '@mui/material/Tooltip'; import Fade from '@mui/material/Fade'; import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import CheckIcon from '@mui/icons-material/Check'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; @@ -69,6 +70,93 @@ function extractStepsFromSession(session: { messages: Array<{ role: string; cont return out; } +function isWorkflowSuggestionTool(toolName: unknown, mcpServer?: unknown): boolean { + const normalizedTool = String(toolName || '').toLowerCase(); + const normalizedServer = String(mcpServer || '').toLowerCase(); + if (!normalizedTool) return false; + if (normalizedTool === 'suggestconverttoworkflow') return true; + if (normalizedTool.endsWith('__suggestconverttoworkflow')) return true; + return normalizedTool.includes('suggestconverttoworkflow') && ( + normalizedTool.includes('openswarm-schedule') || + normalizedServer.includes('openswarm-schedule') + ); +} + +function isScheduleWorkflowTool(toolName: unknown): boolean { + const normalizedTool = String(toolName || '').toLowerCase(); + if (!normalizedTool) return false; + return normalizedTool === 'scheduleworkflow' || normalizedTool.endsWith('__scheduleworkflow'); +} + +function parseWorkflowSuggestion(text: unknown): { reason: string; cadence: string } | null { + if (typeof text !== 'string' || !text.trim()) return null; + try { + const parsed = JSON.parse(text); + if (!parsed?.reason || typeof parsed.reason !== 'string') return null; + return { + reason: parsed.reason, + cadence: typeof parsed.cadence === 'string' + ? parsed.cadence + : (typeof parsed.suggested_cadence === 'string' ? parsed.suggested_cadence : ''), + }; + } catch { + return null; + } +} + +function parseWorkflowSuggestionFromContent(content: any): { reason: string; cadence: string } | null { + return parseWorkflowSuggestion( + content?.text ?? + content?.content?.[0]?.text ?? + content?.result ?? + content?.output, + ); +} + +/** Detect if the session has a completed SuggestConvertToWorkflow tool call. */ +function findWorkflowSuggestion(session: AgentSession): { reason: string; cadence: string } | null { + let found: { reason: string; cadence: string } | null = null; + for (const msg of session.messages || []) { + const msgAny = msg as any; + const directContent = msgAny.content; + if (msgAny.role === 'tool_result') { + const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name; + if (isWorkflowSuggestionTool(toolName, directContent?.mcpServer ?? msgAny.mcpServer)) { + found = parseWorkflowSuggestionFromContent(directContent) || found; + } + } + + const blocks = Array.isArray(directContent) ? directContent : []; + for (const block of blocks) { + if (block?.type !== 'tool_result') continue; + const toolName = block?.tool_name ?? block?.tool ?? block?.name; + if (isWorkflowSuggestionTool(toolName, block?.mcpServer)) { + found = parseWorkflowSuggestionFromContent(block) || found; + } + } + } + return found; +} + +/** Count completed ScheduleWorkflow tool calls so a new one (vs the mount baseline) can pop the workflow open. */ +function countScheduleWorkflowCalls(session: AgentSession): number { + let count = 0; + for (const msg of session.messages || []) { + const msgAny = msg as any; + const directContent = msgAny.content; + if (msgAny.role === 'tool_result') { + const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name; + if (isScheduleWorkflowTool(toolName)) count += 1; + } + const blocks = Array.isArray(directContent) ? directContent : []; + for (const block of blocks) { + if (block?.type !== 'tool_result') continue; + if (isScheduleWorkflowTool(block?.tool_name ?? block?.tool ?? block?.name)) count += 1; + } + } + return count; +} + const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => { if (service === 'gmail') { return ( @@ -260,7 +348,10 @@ const AgentCard: React.FC = ({ const defaultModel = useAppSelector((s) => s.settings.data.default_model); const defaultMode = useAppSelector((s) => s.settings.data.default_mode); const [converting, setConverting] = useState(false); - const [convertToast, setConvertToast] = useState(null); + const [suggestGlowCycle, setSuggestGlowCycle] = useState(0); + const [workflowToast, setWorkflowToast] = useState(''); + const [dismissedWorkflowPromptKey, setDismissedWorkflowPromptKey] = useState(''); + const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]); // Hide the "Convert to workflow" button when this chat is already // entangled with a workflow (Image #44 note). Two cases: // (a) The session is one of a workflow's runner sessions, OR @@ -304,24 +395,35 @@ const AgentCard: React.FC = ({ !sourceWorkflow.schedule?.enabled && (session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2; - // The convert button stays visible whenever this is a real, convertible - // chat; it's only greyed out (not hidden) while a turn is still running so - // the affordance doesn't flicker in and out mid-response. - const turnSettled = session.status === 'completed' || session.status === 'stopped'; - const showConvert = session.messages.length >= 2 && !isWorkflowRunnerSession; - const handleConvert = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); + const hasUserPrompt = useMemo( + () => (session.messages || []).some((m) => m.role === 'user' && !m.hidden), + [session.messages], + ); + const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped'; + const showConvertToWorkflow = + !session.is_welcome_draft && + !isWorkflowRunnerSession && + hasUserPrompt && + (session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion); + const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn && !converting; + const workflowSuggestionKey = workflowSuggestion + ? `${session.id}:${workflowSuggestion.reason}:${workflowSuggestion.cadence}` + : ''; + const showWorkflowSuggestionPrompt = Boolean( + workflowSuggestion && + canConvertToWorkflow && + workflowSuggestionKey && + dismissedWorkflowPromptKey !== workflowSuggestionKey, + ); + const convertChatToWorkflow = useCallback(() => { if (converting) return; - if (session.status !== 'completed' && session.status !== 'stopped') { - setConvertToast("Can't convert to workflow mid-turn. Wait for the agent to finish."); + const steps = extractStepsFromSession(session); + if (steps.length === 0) { + setWorkflowToast('Add a prompt before converting this chat to a workflow.'); return; } - const steps = extractStepsFromSession(session); - if (steps.length === 0) return; setConverting(true); const draftId = `draft-${session.id}-${Date.now()}`; - // The chat card becomes a temporary workflow draft in the same slot. - // Nothing is persisted until the user chooses Save Draft or Schedule. dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds })); dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY })); dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight })); @@ -341,6 +443,7 @@ const AgentCard: React.FC = ({ use_synced_prompt: true, model: defaultModel || session.model, mode: defaultMode || session.mode, + suggested_cadence: workflowSuggestion?.cadence || undefined, } as Partial, })); const genModel = defaultModel || session.model; @@ -352,7 +455,28 @@ const AgentCard: React.FC = ({ dispatch(updateWorkflowCard({ workflowId: draftId, patch: { metaLoading: false } })); } }); - }, [converting, session, dispatch, expandedSessionIds, cardX, cardY, cardWidth, cardHeight, defaultModel, defaultMode]); + }, [ + cardHeight, + cardWidth, + cardX, + cardY, + converting, + defaultMode, + defaultModel, + dispatch, + expandedSessionIds, + isConvertBlockedByTurn, + session, + workflowSuggestion?.cadence, + ]); + const handleConvertToWorkflow = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (isConvertBlockedByTurn) { + setWorkflowToast('Cannot convert to a workflow while the agent is responding.'); + return; + } + convertChatToWorkflow(); + }, [convertChatToWorkflow, isConvertBlockedByTurn]); // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { const value = session.model; @@ -369,6 +493,43 @@ const AgentCard: React.FC = ({ }, [session.model, modelsByProvider]); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); + const suggestionPulseRef = useRef(''); + const readyPulseRef = useRef(''); + useEffect(() => { + if (!workflowSuggestion) return; + const key = `${workflowSuggestion.reason}|${workflowSuggestion.cadence}`; + if (canConvertToWorkflow) { + if (readyPulseRef.current === key) return; + readyPulseRef.current = key; + } else { + if (suggestionPulseRef.current === key) return; + suggestionPulseRef.current = key; + } + setSuggestGlowCycle((n) => n + 1); + dispatch(fadeGlowingAgentCard(session.id, 3200)); + }, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]); + + // When the agent schedules a workflow from this chat, pop its card open + // next to the chat. Baseline the count once on mount so historical + // schedules (e.g. after an app reload) don't re-open on their own. + const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]); + const baselineScheduleCountRef = useRef(null); + const autoOpenedWorkflowIdsRef = useRef>(new Set()); + useEffect(() => { + if (baselineScheduleCountRef.current === null) { + baselineScheduleCountRef.current = scheduleWorkflowCount; + return; + } + if (scheduleWorkflowCount <= baselineScheduleCountRef.current) return; + for (const wf of Object.values(workflowItems || {})) { + if (wf.source_session_id !== session.id) continue; + if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue; + autoOpenedWorkflowIdsRef.current.add(wf.id); + dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: session.id, expandedSessionIds })); + dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' })); + } + }, [scheduleWorkflowCount, workflowItems, session.id, dispatch, expandedSessionIds]); + const cardBoxRef = useRef(null); // Ref so ResizeObserver sees latest value without re-attaching when active flips. const isDashboardActiveRef = useRef(isDashboardActive); @@ -975,62 +1136,82 @@ const AgentCard: React.FC = ({ - - - {friendlyModelLabel} - - - - - {session.cost_usd > 0 && hasApiKey && ( - - ${session.cost_usd.toFixed(4)} + + + + {friendlyModelLabel} - )} - {showConvert && ( - <> - - - e.stopPropagation()} - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.5, - color: '#fff', - bgcolor: c.accent.primary, - border: `1px solid ${c.accent.primary}`, - fontSize: '0.78rem', fontWeight: 700, - px: 1.1, py: 0.5, - borderRadius: `${c.radius.md}px`, - cursor: converting ? 'wait' : turnSettled ? 'pointer' : 'not-allowed', - opacity: converting ? 0.7 : turnSettled ? 1 : 0.45, - '&:hover': turnSettled && !converting ? { filter: 'brightness(1.05)' } : {}, - }} - > - - {converting ? 'Converting…' : 'Convert to workflow'} - - - + + + + {session.cost_usd > 0 && hasApiKey && ( + + ${session.cost_usd.toFixed(4)} + + )} + + {showConvertToWorkflow && ( + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + animate={suggestGlowCycle > 0 ? { + scale: [1, 1.06, 1, 1.045, 1], + filter: ['brightness(1)', 'brightness(1.18)', 'brightness(1)', 'brightness(1.12)', 'brightness(1)'], + boxShadow: [ + `0 0 0 0 ${c.accent.primary}00`, + `0 0 0 4px ${c.accent.primary}99, 0 0 20px 6px ${c.accent.primary}66`, + `0 0 0 8px ${c.accent.primary}00`, + `0 0 0 3px ${c.accent.primary}88, 0 0 16px 4px ${c.accent.primary}55`, + canConvertToWorkflow ? c.shadow.sm : 'none', + ], + } : undefined} + transition={{ duration: 2.4, ease: 'easeInOut' }} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.35, + color: canConvertToWorkflow ? '#fff' : c.text.tertiary, + bgcolor: canConvertToWorkflow ? c.accent.primary : c.bg.secondary, + border: `1px solid ${canConvertToWorkflow ? c.accent.primary : c.border.medium}`, + fontSize: '0.68rem', + lineHeight: 1, + fontWeight: 700, + px: 0.8, + py: 0.35, + minHeight: 22, + borderRadius: `${c.radius.sm}px`, + cursor: canConvertToWorkflow ? (converting ? 'wait' : 'pointer') : 'not-allowed', + opacity: converting ? 0.7 : 1, + whiteSpace: 'nowrap', + flexShrink: 0, + boxShadow: canConvertToWorkflow ? c.shadow.sm : 'none', + '&:hover': canConvertToWorkflow ? { filter: 'brightness(1.05)' } : { bgcolor: c.bg.secondary }, + }} + > + + {converting ? 'Converting...' : 'Convert to workflow'} + + )} - setConvertToast(null)} - message={convertToast || ''} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> - {expanded && ( e.stopPropagation()} @@ -1239,6 +1420,95 @@ const AgentCard: React.FC = ({ ) : null} )} + + { + e.stopPropagation(); + setDismissedWorkflowPromptKey(workflowSuggestionKey); + }} + sx={{ + position: 'absolute', + inset: 0, + zIndex: 30, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + p: 2, + bgcolor: 'rgba(0,0,0,0.28)', + backdropFilter: 'blur(1.5px)', + }} + > + e.stopPropagation()} + sx={{ + width: '100%', + maxWidth: 340, + bgcolor: c.bg.surface, + color: c.text.primary, + border: `1px solid ${c.border.medium}`, + borderRadius: `${c.radius.lg}px`, + boxShadow: c.shadow.lg, + p: 2.25, + }} + > + + Would you like to make this a workflow? + + + I can open a workflow draft from this chat. You can review the steps and choose the schedule there. + + {workflowSuggestion?.cadence && ( + + Suggested cadence: {workflowSuggestion.cadence} + + )} + + + + + + + + setWorkflowToast('')} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setWorkflowToast('')} + sx={{ fontSize: '0.78rem' }} + > + {workflowToast} + + ); diff --git a/frontend/src/app/pages/Dashboard/controls/Minimap.tsx b/frontend/src/app/pages/Dashboard/controls/Minimap.tsx index 4bdee3b1..3b2b64eb 100644 --- a/frontend/src/app/pages/Dashboard/controls/Minimap.tsx +++ b/frontend/src/app/pages/Dashboard/controls/Minimap.tsx @@ -1,6 +1,6 @@ import React, { useRef, useCallback, useMemo } from 'react'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition, MissedRunsCardPosition } from '@/shared/state/dashboardLayoutSlice'; const MINIMAP_W = 200; const MINIMAP_H = 140; @@ -16,6 +16,7 @@ export interface MinimapProps { browserCards: Record; workflowCards: Record; workflowsHub: WorkflowsHubPosition | null; + missedRunsCard: MissedRunsCardPosition | null; onPan: (panX: number, panY: number) => void; } @@ -24,12 +25,12 @@ interface CardRect { y: number; width: number; height: number; - type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub'; + type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub' | 'missed-runs'; } const Minimap: React.FC = ({ panX, panY, zoom, viewportRef, - cards, viewCards, browserCards, workflowCards, workflowsHub, + cards, viewCards, browserCards, workflowCards, workflowsHub, missedRunsCard, onPan, }) => { const c = useClaudeTokens(); @@ -59,8 +60,17 @@ const Minimap: React.FC = ({ type: 'workflows-hub', }); } + if (missedRunsCard) { + result.push({ + x: missedRunsCard.x, + y: missedRunsCard.y, + width: missedRunsCard.width, + height: missedRunsCard.height, + type: 'missed-runs', + }); + } return result; - }, [cards, viewCards, browserCards, workflowCards, workflowsHub]); + }, [cards, viewCards, browserCards, workflowCards, workflowsHub, missedRunsCard]); const layout = useMemo(() => { const vp = viewportRef.current; @@ -149,6 +159,7 @@ const Minimap: React.FC = ({ case 'browser': return c.status.success; case 'workflow': return c.status.warning; case 'workflows-hub': return c.status.warning; + case 'missed-runs': return c.status.warning; } }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts index ed5f0291..8e8e04fc 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts @@ -2,7 +2,7 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react'; import { report } from '@/shared/serviceClient'; import { useAppDispatch } from '@/shared/hooks'; import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice'; -import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice'; import { closeWorkflowCard } from '@/shared/state/workflowsSlice'; import type { useDashboardSelection } from '../state/useDashboardSelection'; @@ -83,6 +83,8 @@ export function useDashboardShortcuts({ } else if (type === 'workflow') { dispatch(removeWorkflowCard(id)); dispatch(closeWorkflowCard(id)); + } else if (type === 'workflows-hub') { + dispatch(closeWorkflowsHub()); } } selection.deselectAll(); diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 2acbfe7c..7d5f75be 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -255,25 +255,25 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD return ( {/* Day headers: muted weekday caps; today's date gets the filled circle */} - + {!compact && ( {TZ_LABEL} )} {days.map((d) => { - const isToday = sameDay(d, today); + const isToday = sameDay(d, now); return ( {WEEKDAY_LABEL_SHORT[d.getDay()]} - {d.getDate()} + {d.getDate()} ); })} - + {HOURS.map((hour, hourIdx) => ( {/* Hour label sits inside its row (top-aligned) rather than @@ -348,43 +348,55 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i)); const accent = c.accent.primary; return ( - + {/* Sticky weekday header so it stays visible even when the calendar body scrolls. Slightly bigger + tinted bg so it reads cleanly in both light and dark themes. */} - + {WEEKDAY_LABEL_SHORT.map((l, i) => ( {l} ))} - + {cells.map((d) => { const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; const evs = eventsByDay.map.get(key) || []; - const isToday = sameDay(d, today); + const isToday = sameDay(d, now); const inMonth = d.getMonth() === today.getMonth(); return ( - + {/* Out-of-month dates still need to be legible (Apple Calendar shows them in a muted shade, not invisible). Color tweak instead of opacity so dark themes stay readable. */} - {d.getDate()} + {d.getDate()} - {evs.slice(0, compact ? 3 : 4).map((e, idx) => ( + {evs.slice(0, compact ? 3 : 4).map((e, idx) => { + // Past fires read as a hollow ring, upcoming ones stay filled, + // so a glance down a day tells you what already ran. + const passed = e.date.getTime() < now.getTime(); + return ( onSelectWorkflow?.(e.workflow.id)} onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }} sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}> - + {formatTime(e.date.getHours(), e.date.getMinutes())} {e.workflow.title} - ))} + ); + })} {evs.length > (compact ? 3 : 4) && ( - +{evs.length - (compact ? 3 : 4)} more + )} ); @@ -571,6 +583,57 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext ); } +// "+N more" on a packed month cell opens a scrollable popover listing every +// run that day, so a heavy day isn't a dead end. Past fires keep the hollow +// ring the cell rows use, for a consistent at-a-glance "already ran" read. +function MonthDayOverflow({ date, count, events, now, fontSize, onSelectWorkflow }: { + date: Date; + count: number; + events: { workflow: Workflow; date: Date }[]; + now: Date; + fontSize: string; + onSelectWorkflow?: (id: string) => void; +}) { + const c = useClaudeTokens(); + const [anchor, setAnchor] = useState(null); + const accent = c.accent.primary; + return ( + <> + { e.stopPropagation(); setAnchor(e.currentTarget); }} + role="button" + sx={{ fontSize, color: c.text.muted, mt: 0.3, pl: 1.4, cursor: 'pointer', '&:hover': { color: accent } }}> + +{count} more + + setAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }}> + + + {`${events.length} scheduled · ${date.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' })}`} + + {events.map((e, idx) => { + const passed = e.date.getTime() < now.getTime(); + return ( + { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }} + sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> + + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} + + ); + })} + + + + ); +} + function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) { const wf = event.workflow; const status = wf.last_run_status; diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx index 2183a931..3ca80972 100644 --- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -226,8 +226,8 @@ export default function SchedulePopover({ {periodLabel} - - + + )} diff --git a/frontend/src/app/pages/Workflows/SchedulingView.tsx b/frontend/src/app/pages/Workflows/SchedulingView.tsx index 8a5cccd3..ae84d56e 100644 --- a/frontend/src/app/pages/Workflows/SchedulingView.tsx +++ b/frontend/src/app/pages/Workflows/SchedulingView.tsx @@ -66,11 +66,13 @@ export default function SchedulingView({ workflow, steps }: Props) { (async () => { try { const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const cadenceHint = workflow.suggested_cadence ? ` I think it should run ${workflow.suggested_cadence}.` : ''; + const prompt = `Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"${cadenceHint}`; await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(scheduleSessionId)}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, body: JSON.stringify({ - prompt: 'Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"', + prompt, hidden: true, }), }); diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index d69ec5bd..e0684204 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -678,9 +678,9 @@ function groupKey(iso: string): string { export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleFor }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void; showWorkflow?: boolean; workflowTitleFor?: (workflowId: string) => string }) { const c = useClaudeTokens(); const [expandedId, setExpandedId] = useState(null); - // Filter chips: all / failures / late. Power-users debugging a flaky - // workflow shouldn't have to scroll past successes. - const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all'); + // Filter chips: all / success / failures / skipped. Power-users debugging a + // flaky workflow shouldn't have to scroll past the runs they don't care about. + const [filter, setFilter] = useState<'all' | 'success' | 'failure' | 'skipped'>('all'); const filtered = useMemo(() => { if (filter === 'all') return runs; return (runs || []).filter((r) => r.status === filter); @@ -707,7 +707,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF )} - {(['all', 'failure', 'ran_late'] as const).map((k) => ( + {(['all', 'success', 'failure', 'skipped'] as const).map((k) => ( setFilter(k)} role="button" sx={{ fontSize: '0.72rem', fontWeight: 600, color: filter === k ? c.accent.primary : c.text.muted, @@ -716,7 +716,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF px: 0.75, py: 0.3, borderRadius: c.radius.full, cursor: 'pointer', '&:hover': { color: c.accent.primary }, }}> - {k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'} + {k === 'all' ? 'All' : k === 'success' ? 'Success' : k === 'failure' ? 'Failures' : 'Skipped'} ))} @@ -754,19 +754,17 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF {expanded && ( - + + {r.error ? ( {r.error} - ) : ( - - {r.session_id ? 'Click below to see the full conversation.' : 'No session was recorded for this run. Click below to see the full conversation.'} - - )} - - { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}> - See full conversation → + ) : r.session_id ? ( + { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}> + Click to see the full conversation → - + ) : ( + No session was recorded for this run. + )} )} diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index 1eed4b13..a4bdc15c 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -342,6 +342,7 @@ const WorkflowsHubCard: React.FC = ({ border, borderRadius: 3, boxShadow: shadow, + overflow: 'hidden', display: 'flex', flexDirection: 'column', zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, @@ -356,7 +357,8 @@ const WorkflowsHubCard: React.FC = ({ onPointerUp={onHeaderPointerUp} sx={{ display: 'flex', alignItems: 'center', gap: 0.6, - px: 1.5, py: 0.6, + px: 1.5, py: 0.5, + bgcolor: c.bg.elevated, borderBottom: `1px solid ${c.border.subtle}`, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none', @@ -381,7 +383,7 @@ const WorkflowsHubCard: React.FC = ({ {/* ===== Toolbar row (matches Figma image #8 header) ===== */} - + setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}> @@ -393,9 +395,9 @@ const WorkflowsHubCard: React.FC = ({ data-no-drag sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, - fontSize: '0.82rem', fontWeight: 600, color: c.text.primary, - bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`, - px: 1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer', + fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, + bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, + px: 1, py: 0.4, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { borderColor: c.accent.primary, color: c.accent.primary }, }} > @@ -436,9 +438,6 @@ const WorkflowsHubCard: React.FC = ({ {monthLabel} - - - setViewOpen((v) => !v)} @@ -474,8 +473,8 @@ const WorkflowsHubCard: React.FC = ({ {/* Sidebar */} {sidebarOpen && ( - - + + setSearch(e.target.value)} @@ -485,7 +484,7 @@ const WorkflowsHubCard: React.FC = ({ /> - + match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} /> match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} onSchedule={(wf, el) => setSchedulePopover({ anchorEl: el, workflow: wf })} /> @@ -493,7 +492,7 @@ const WorkflowsHubCard: React.FC = ({ )} {/* Main calendar area */} - + @@ -571,7 +570,7 @@ function MiniMonth({ refDate, onPick }: { refDate: Date; onPick: (d: Date) => vo const today = new Date(); const label = refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); return ( - + {label} onPick(addMonths(refDate, -1))} sx={{ p: 0.15 }}> diff --git a/frontend/src/app/pages/Workflows/workflowVisuals.tsx b/frontend/src/app/pages/Workflows/workflowVisuals.tsx index 27871ae9..eb7af1e1 100644 --- a/frontend/src/app/pages/Workflows/workflowVisuals.tsx +++ b/frontend/src/app/pages/Workflows/workflowVisuals.tsx @@ -386,15 +386,14 @@ function relTime(ms: number): string { // ---------- Run history sparkline ---------- // 10-dot horizontal strip of last N runs colored by status. Easy "lately -// healthy?" check without opening the History tab. Tooltip names the -// pattern out loud so a non-dev knows the dots aren't decorative. +// healthy?" check without opening the History tab. export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) { const c = useClaudeTokens(); if (!runs || runs.length === 0) return null; const slice = runs.slice(0, max).reverse(); const successes = slice.filter((r) => r.status === 'success').length; const failures = slice.filter((r) => r.status === 'failure').length; - const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`; + const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} successful, ${failures} failed`; return ( diff --git a/frontend/src/shared/mcpToolMeta.ts b/frontend/src/shared/mcpToolMeta.ts index 89b6f122..4e5379f3 100644 --- a/frontend/src/shared/mcpToolMeta.ts +++ b/frontend/src/shared/mcpToolMeta.ts @@ -121,6 +121,7 @@ export function getWorkflowToolLabel(action: string): string | null { if (lower === 'testworkflow') return 'Test workflow'; if (lower === 'readtesttranscript') return 'Read test results'; if (lower === 'listworkflows') return 'List workflows'; + if (lower === 'suggestconverttoworkflow') return 'Suggest workflow'; return null; } diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 307ec496..4358de95 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -100,6 +100,9 @@ export interface Workflow { * conversion). Compared against the current steps to decide whether to warn * before scheduling. See scheduleUtils.needsScheduleTestWarning. */ tested_signature?: string | null; + /** Suggested cadence from a SuggestConvertToWorkflow tool call (e.g. "every weekday at 9am"). + * Used to seed the scheduling agent's prompt. Transient draft field only. */ + suggested_cadence?: string; } export interface WorkflowRun {