From 47650ed1043af6530e5f8c30e6f598b36eb26d73 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 21 May 2026 23:15:17 -0700 Subject: [PATCH] [eric] workflows: SchedulingView + NL parse-schedule endpoint (Image #49) --- backend/apps/workflows/workflows.py | 77 ++++++ .../app/pages/Workflows/SchedulingView.tsx | 221 ++++++++++++++++++ .../src/app/pages/Workflows/WorkflowCard.tsx | 8 +- 3 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/SchedulingView.tsx diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 3dafb74c..64e7a11c 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -405,6 +405,83 @@ async def delete_workflow(workflow_id: str): return {"ok": True} +@workflows.router.post("/{workflow_id}/parse-schedule") +async def parse_schedule(workflow_id: str, body: dict): + """Aux-LLM-parse natural language into a ScheduleConfig. + + Frontend SchedulingView (Image #49) hits this on submit; the parsed + config rides back to the user for explicit "Schedule it" confirmation + before any persistence. Returns the parsed config under {"schedule": ...}. + """ + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + text = (body or {}).get("text", "").strip() + if not text: + raise HTTPException(status_code=400, detail="Missing text") + try: + from backend.apps.agents.providers.registry import resolve_aux_model, get_anthropic_client_for_model + from backend.apps.settings.settings import load_settings as _ls + except Exception: + raise HTTPException(status_code=500, detail="Aux model unavailable") + settings = _ls() + try: + aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku") + client = get_anthropic_client_for_model(settings, aux_model) + except Exception: + raise HTTPException(status_code=500, detail="Aux model unavailable") + import json, re + prompt = ( + "Parse the following natural-language schedule into STRICT JSON. " + "No prose, no fence, no comments. Schema:\n" + ' {"repeat_unit": "day"|"week"|"month", ' + '"repeat_every": int>=1, ' + '"on_days": [int 0..6, Sunday=0], ' + '"hour": int 0..23, "minute": int 0..59, ' + '"timezone": IANA tz string (default to local)}\n\n' + "Rules:\n" + "- If user says weekdays, on_days=[1,2,3,4,5], repeat_unit=week.\n" + "- If user says weekends, on_days=[0,6], repeat_unit=week.\n" + "- If user names a single day (e.g. \"Mondays\"), on_days=[1], repeat_unit=week.\n" + "- If user says daily/everyday, repeat_unit=day, on_days=[].\n" + "- If no AM/PM, assume PM for 1-7 and AM for 8-12.\n" + "- timezone: assume system local if not given.\n\n" + f"Input: {text}" + ) + try: + resp = await client.messages.create( + model=aux_model, + max_tokens=180, + messages=[ + {"role": "user", "content": prompt}, + {"role": "assistant", "content": "{"}, + ], + ) + out = "" + if isinstance(resp.content, list): + for block in resp.content: + if getattr(block, "type", None) == "text": + out += getattr(block, "text", "") + raw = "{" + out.strip() if not out.strip().startswith("{") else out.strip() + m = re.search(r"\{[^{}]*\}", raw, flags=re.DOTALL) + if m: + raw = m.group(0) + data = json.loads(raw) + except Exception as e: + logger.warning("parse-schedule: aux LLM failed: %s", e) + raise HTTPException(status_code=400, detail="Couldn't parse schedule") + cfg = wf.schedule.model_copy(update={ + "enabled": True, + "repeat_unit": str(data.get("repeat_unit") or "week"), + "repeat_every": int(data.get("repeat_every") or 1), + "on_days": [int(d) for d in (data.get("on_days") or [])], + "hour": int(data.get("hour") or 9), + "minute": int(data.get("minute") or 0), + "timezone": str(data.get("timezone") or wf.schedule.timezone or "UTC"), + }) + return {"schedule": cfg.model_dump(mode="json")} + + @workflows.router.post("/{workflow_id}/run") async def run_workflow_now(workflow_id: str): wf = storage.get_workflow(workflow_id) diff --git a/frontend/src/app/pages/Workflows/SchedulingView.tsx b/frontend/src/app/pages/Workflows/SchedulingView.tsx new file mode 100644 index 00000000..7ad009f8 --- /dev/null +++ b/frontend/src/app/pages/Workflows/SchedulingView.tsx @@ -0,0 +1,221 @@ +// Image #49: natural-language schedule composer. +// Header morphs into `[trash] Cancel task scheduling`. Body shows a soft +// frame around the read-only step list, an agent reply bubble asking for +// the cadence, and a chat-style composer at the bottom. On submit we +// hit /workflows/{id}/parse-schedule (aux LLM), surface the parsed +// ScheduleConfig in a confirmation modal (the "always ask permission" +// stand-in for the schedule_workflow tool call), and PATCH on confirm. + +import React, { useCallback, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Dialog from '@mui/material/Dialog'; +import TextareaAutosize from '@mui/material/TextareaAutosize'; +import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { updateWorkflow, updateWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice'; +import StepList from './StepList'; +import { API_BASE, getAuthToken } from '@/shared/config'; + +interface Props { + workflow: Workflow; + steps: Workflow['steps']; +} + +export default function SchedulingView({ workflow, steps }: Props) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const [draft, setDraft] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + const onCancel = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + }, [dispatch, workflow.id]); + + const onSubmit = useCallback(async () => { + if (!draft.trim() || busy) return; + setBusy(true); + setError(null); + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/parse-schedule`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, + body: JSON.stringify({ text: draft.trim() }), + }); + if (!res.ok) { + setError(`Couldn't parse that. Try "every Wednesday at 1pm" or "Mondays at 3pm".`); + return; + } + const data = await res.json(); + const cfg = data?.schedule as ScheduleConfig | undefined; + if (!cfg) { + setError(`Couldn't read a schedule out of that. Try being more specific.`); + return; + } + setPending(cfg); + } catch (e) { + setError((e as Error)?.message || 'Network error.'); + } finally { + setBusy(false); + } + }, [draft, busy, workflow.id]); + + const onConfirm = useCallback(async () => { + if (!pending) return; + setBusy(true); + try { + await dispatch(updateWorkflow({ + id: workflow.id, + patch: { schedule: { ...pending, enabled: true } as Workflow['schedule'] }, + ifMatch: workflow.updated_at || null, + })); + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + } finally { + setBusy(false); + setPending(null); + } + }, [pending, dispatch, workflow.id, workflow.updated_at]); + + return ( + + {/* Inline header replacement. The default History/Run row is hidden + for 'scheduling' view; this Cancel button sits in its place. */} + + + + Cancel task scheduling + + + + + + + + When should this workflow run (e.g. every Wednesday at 1pm) + + + {error && ( + {error} + )} + + + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void onSubmit(); + } + }} + minRows={1} + maxRows={4} + placeholder="Agent, @ for context, / for commands" + style={{ + width: '100%', resize: 'none', boxSizing: 'border-box', + fontFamily: 'inherit', fontSize: '0.92rem', color: c.text.primary, + border: 'none', outline: 'none', background: 'transparent', + padding: '6px 4px', lineHeight: 1.45, + }} + /> + + + + + + + {busy ? 'Working…' : 'Send'} + + + + + setPending(null)}> + + + Schedule this workflow? + + + The agent wants to set {workflow.title} to run {pending && describe(pending)}. You can change or cancel this anytime. + + + setPending(null)} + role="button" + sx={{ fontSize: '0.86rem', color: c.text.secondary, px: 1, py: 0.6, cursor: 'pointer', '&:hover': { color: c.text.primary } }}> + Cancel + + + {busy ? 'Applying…' : 'Schedule it'} + + + + + + ); +} + +function Pill({ label }: { label: string }) { + const c = useClaudeTokens(); + return ( + {label} + ); +} + +function describe(s: ScheduleConfig): string { + const h12 = ((s.hour + 11) % 12) + 1; + const ampm = s.hour < 12 ? 'am' : 'pm'; + const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`; + if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `every day at ${time}` : `every ${s.repeat_every} days at ${time}`; + if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `every month at ${time}` : `every ${s.repeat_every} months at ${time}`; + const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `weekdays at ${time}`; + if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `weekends at ${time}`; + if (s.on_days.length === 1) return `${labels[s.on_days[0]]}s at ${time}`; + return `weekly at ${time}`; +} diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 8e0d9aa6..fb9f7107 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -35,6 +35,7 @@ import { fetchSession } from '@/shared/state/agentsSlice'; import WorkflowEditViews from './WorkflowEditViews'; import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews'; import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews'; +import SchedulingView from './SchedulingView'; import StopRounded from '@mui/icons-material/StopRounded'; import PauseRounded from '@mui/icons-material/PauseRounded'; import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; @@ -607,10 +608,13 @@ const WorkflowCard: React.FC = ({ {card.view === 'failed' && workflow && ( )} - {(card.view === 'edit_agent' || card.view === 'fix_agent' || card.view === 'scheduling') && workflow && ( + {card.view === 'scheduling' && workflow && ( + + )} + {(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && ( dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))} onDirtyChange={setEditDirty} />