[aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98)

* [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow

Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task
when they've completed something worth repeating (daily report, weekly check,
recurring data pull). Frontend detects the tool call and glows the "Convert to
workflow" button 3 times to draw the eye. When user clicks it, the suggested
cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into
the scheduling agent's first prompt, so the agent can act on the suggestion
rather than asking the user again.

Tool is never auto-called — agents decide when a task is genuinely repeatable
(not debugging, creative work, one-off lookup). Tool description emphasizes
sparse, high-confidence use only (once per session max).

Files changed:
- backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool
- frontend/src/shared/mcpToolMeta.ts: add label for new tool
- frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in
  session messages, show+glow "Convert to workflow" button, pass cadence to draft
- frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to
  Workflow interface
- frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent
  prompt with suggested cadence hint

* [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill

* [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card
This commit is contained in:
Aidan
2026-06-19 03:47:35 -07:00
committed by GitHub
parent 111b527979
commit 6404d98b3a
10 changed files with 510 additions and 85 deletions
@@ -244,6 +244,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
{/* Tether lines between branched cards */}
<TetherLayer tethers={tethers} c={c} />
<DashboardCardLayer
dashboardId={dashboardId}
cards={cards}
viewCards={viewCards}
browserCards={browserCards}
@@ -28,6 +28,7 @@ type GlowingAgentCard = { sourceId: string; fading: boolean; sourceYRatio?: numb
type Direction = 'left' | 'right' | 'up' | 'down';
interface DashboardCardLayerProps {
dashboardId: string;
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
@@ -64,6 +65,7 @@ interface DashboardCardLayerProps {
}
const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
dashboardId,
cards,
viewCards,
browserCards,
@@ -265,6 +267,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
))}
{workflowsHub && (
<WorkflowsHubCard
dashboardId={dashboardId}
cardX={workflowsHub.x}
cardY={workflowsHub.y}
cardWidth={workflowsHub.width}
@@ -6,6 +6,8 @@ import IconButton from '@mui/material/IconButton';
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';
@@ -68,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 (
@@ -259,6 +348,10 @@ const AgentCard: React.FC<Props> = ({
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
const [converting, setConverting] = useState(false);
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
@@ -302,6 +395,76 @@ const AgentCard: React.FC<Props> = ({
!sourceWorkflow.schedule?.enabled &&
(session.status === 'completed' || session.status === 'stopped') &&
session.messages.length >= 2;
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;
const steps = extractStepsFromSession(session);
if (steps.length === 0) {
setWorkflowToast('Add a prompt before converting this chat to a workflow.');
return;
}
setConverting(true);
const draftId = `draft-${session.id}-${Date.now()}`;
dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds }));
dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY }));
dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight }));
dispatch(removeCard(session.id));
dispatch(openWorkflowCard({
workflowId: draftId,
sourceSessionId: session.id,
view: 'preview',
draft: {
title: session.name || 'New workflow',
description: '',
steps,
source_session_id: session.id,
use_synced_prompt: true,
model: defaultModel || session.model,
mode: defaultMode || session.mode,
suggested_cadence: workflowSuggestion?.cadence || undefined,
} as Partial<Workflow>,
}));
}, [
cardHeight,
cardWidth,
cardX,
cardY,
converting,
defaultMode,
defaultModel,
dispatch,
expandedSessionIds,
isConvertBlockedByTurn,
session,
workflowSuggestion?.cadence,
]);
const handleConvertToWorkflow = useCallback((e: React.MouseEvent<HTMLElement>) => {
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;
@@ -318,6 +481,43 @@ const AgentCard: React.FC<Props> = ({
}, [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<number | null>(null);
const autoOpenedWorkflowIdsRef = useRef<Set<string>>(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<HTMLDivElement>(null);
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
const isDashboardActiveRef = useRef(isDashboardActive);
@@ -907,58 +1107,6 @@ const AgentCard: React.FC<Props> = ({
</Box>
</Tooltip>
)}
{(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && (
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
<Box
role="button"
onClick={(e) => {
e.stopPropagation();
if (converting) 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 Workflow from the draft card.
dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds }));
dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY }));
dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight }));
dispatch(removeCard(session.id));
dispatch(openWorkflowCard({
workflowId: draftId,
sourceSessionId: session.id,
view: 'preview',
draft: {
title: session.name || 'New workflow',
description: '',
steps,
source_session_id: session.id,
use_synced_prompt: true,
model: defaultModel || session.model,
mode: defaultMode || session.mode,
} as Partial<Workflow>,
}));
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
color: '#fff',
bgcolor: c.accent.primary,
border: `1px solid ${c.accent.primary}`,
fontSize: '0.78rem', fontWeight: 700,
px: 1.1, py: 0.5,
borderRadius: `${c.radius.md}px`,
cursor: converting ? 'wait' : 'pointer',
opacity: converting ? 0.7 : 1,
'&:hover': { filter: 'brightness(1.05)' },
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
{converting ? 'Converting…' : 'Convert to workflow'}
</Box>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
@@ -976,22 +1124,77 @@ const AgentCard: React.FC<Props> = ({
</Box>
</Box>
<Box sx={{
display: isDraft && !expanded ? 'none' : 'flex',
gap: 1.5,
flexShrink: 0,
...(isDraft && { visibility: 'hidden' }),
}}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{friendlyModelLabel}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
<ElapsedTimer messages={session.messages} status={session.status} />
</Typography>
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
<Box
sx={{
display: isDraft && !expanded ? 'none' : 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
flexShrink: 0,
minWidth: 0,
...(isDraft && { visibility: 'hidden' }),
}}
>
<Box sx={{ display: 'flex', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
{friendlyModelLabel}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
<ElapsedTimer messages={session.messages} status={session.status} />
</Typography>
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary, whiteSpace: 'nowrap' }}>
${session.cost_usd.toFixed(4)}
</Typography>
)}
</Box>
{showConvertToWorkflow && (
<Tooltip title={canConvertToWorkflow ? 'Turn this chat into a reusable workflow' : 'Wait for the current response to finish before converting'}>
<Box
key={`convert-workflow-${suggestGlowCycle}-${canConvertToWorkflow ? 'ready' : 'blocked'}`}
component={motion.div}
role="button"
aria-disabled={!canConvertToWorkflow}
onClick={handleConvertToWorkflow}
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 },
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 13 }} />
{converting ? 'Converting...' : 'Convert to workflow'}
</Box>
</Tooltip>
)}
</Box>
</Box>
@@ -1204,6 +1407,95 @@ const AgentCard: React.FC<Props> = ({
) : null}
</>
)}
<Fade in={showWorkflowSuggestionPrompt} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
onClick={(e) => {
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)',
}}
>
<Box
onClick={(e) => 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,
}}
>
<Typography sx={{ fontSize: '0.98rem', fontWeight: 700, mb: 0.75 }}>
Would you like to make this a workflow?
</Typography>
<Typography sx={{ fontSize: '0.85rem', lineHeight: 1.5, color: c.text.secondary }}>
I can open a workflow draft from this chat. You can review the steps and choose the schedule there.
</Typography>
{workflowSuggestion?.cadence && (
<Typography sx={{ mt: 1, fontSize: '0.78rem', color: c.text.tertiary }}>
Suggested cadence: {workflowSuggestion.cadence}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
<Button
variant="text"
size="small"
onClick={() => setDismissedWorkflowPromptKey(workflowSuggestionKey)}
sx={{ textTransform: 'none', color: c.text.tertiary, fontWeight: 700 }}
>
No, keep chatting
</Button>
<Button
variant="contained"
size="small"
startIcon={<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />}
onClick={() => {
setDismissedWorkflowPromptKey(workflowSuggestionKey);
convertChatToWorkflow();
}}
sx={{
textTransform: 'none',
bgcolor: c.accent.primary,
color: '#fff',
fontWeight: 700,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.accent.hover },
}}
>
Yes, open workflow
</Button>
</Box>
</Box>
</Box>
</Fade>
<Snackbar
open={!!workflowToast}
autoHideDuration={3200}
onClose={() => setWorkflowToast('')}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="info"
variant="filled"
onClose={() => setWorkflowToast('')}
sx={{ fontSize: '0.78rem' }}
>
{workflowToast}
</Alert>
</Snackbar>
</Box>
</motion.div>
);
@@ -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,
}),
});
@@ -19,7 +19,7 @@ import {
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowCard, createWorkflow, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
import { openWorkflowCard, createWorkflow, fetchPausedState, fetchWorkflows, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
@@ -56,6 +56,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
];
interface Props {
dashboardId?: string;
cardX: number;
cardY: number;
cardWidth: number;
@@ -125,6 +126,7 @@ function TimeSavedBadge() {
}
const WorkflowsHubCard: React.FC<Props> = ({
dashboardId,
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta = null,
@@ -136,7 +138,10 @@ const WorkflowsHubCard: React.FC<Props> = ({
const paused = useAppSelector((s) => s.workflows.paused);
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
useEffect(() => {
dispatch(fetchPausedState());
dispatch(fetchWorkflows(dashboardId));
}, [dispatch, dashboardId]);
const togglePaused = useCallback(() => {
dispatch(setPausedAll(!paused));
+1
View File
@@ -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;
}
@@ -101,6 +101,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 {