From 5a5046d5a9d8fcf3fedd3b13db1c9c2dd6a62122 Mon Sep 17 00:00:00 2001 From: abccodes Date: Tue, 16 Jun 2026 00:51:56 -0700 Subject: [PATCH] [aidan] fix: schedule time in chat --- backend/apps/agents/agent_manager.py | 16 + backend/apps/agents/schedule_mcp_server.py | 15 +- backend/apps/workflows/models.py | 3 + backend/apps/workflows/workflows.py | 124 +++---- .../app/pages/Workflows/SchedulingView.tsx | 325 +++++++----------- frontend/src/shared/state/workflowsSlice.ts | 2 + 6 files changed, 202 insertions(+), 283 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 1adee0f5..ee3f8999 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -548,6 +548,16 @@ class AgentManager: _PATH_GATED_TOOLS = ("Write", "Edit", "NotebookEdit") + # Native-scheduler tools that commit or mutate a recurring schedule. + # Always-on MCP servers fall through to the always_allow default, so + # these would otherwise fire silently; force them through ApprovalBar. + _SCHEDULE_GATED = { + "mcp__openswarm-schedule__ScheduleWorkflow", + "mcp__openswarm-schedule__UpdateScheduledWorkflow", + "mcp__openswarm-schedule__DeleteScheduledWorkflow", + "mcp__openswarm-schedule__PauseAllWorkflows", + } + # OS-level scheduling across macOS/Linux/Windows. Agent must # not install cron entries, launchd plists, Windows scheduled # tasks, or PowerShell ScheduledTask cmdlets behind the user's @@ -667,6 +677,12 @@ class AgentManager: """ if tool_name == "Bash" and _looks_like_os_scheduling(tool_input): return "ask", 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 + # always_allow default that always-on MCP servers fall through to. + if tool_name in _SCHEDULE_GATED: + return "ask", None if tool_name == "Bash" and isinstance(tool_input, dict): bash_match = _match_bash_catastrophic_pattern(str(tool_input.get("command") or "")) if bash_match: diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index 8997b23d..e900333e 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -62,11 +62,13 @@ TOOLS = [ "hour": {"type": "integer", "description": "Hour 0-23 in the user's local time. Required when preset='custom'."}, "minute": {"type": "integer", "description": "Minute 0/15/30/45. Required when preset='custom'."}, "repeat_unit": {"type": "string", "enum": ["day", "week", "month"], "description": "Required when preset='custom'."}, + "repeat_every": {"type": "integer", "description": "Interval count for repeat_unit when preset='custom' (e.g. repeat_unit='week' + repeat_every=2 means every other week). Defaults to 1."}, "on_days": { "type": "array", "items": {"type": "integer"}, "description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.", }, + "timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's local zone."}, "source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."}, }, "required": ["title", "steps", "preset"], @@ -87,10 +89,12 @@ TOOLS = [ "title": {"type": "string"}, "steps": {"type": "array", "items": {"type": "string"}}, "schedule_enabled": {"type": "boolean", "description": "Quick on/off without changing other schedule fields."}, - "hour": {"type": "integer"}, - "minute": {"type": "integer"}, + "hour": {"type": "integer", "description": "Hour 0-23 in the schedule's timezone."}, + "minute": {"type": "integer", "description": "Minute 0-59."}, "repeat_unit": {"type": "string", "enum": ["day", "week", "month"]}, - "on_days": {"type": "array", "items": {"type": "integer"}}, + "repeat_every": {"type": "integer", "description": "Interval count for repeat_unit (e.g. 2 with repeat_unit='week' means every other week)."}, + "on_days": {"type": "array", "items": {"type": "integer"}, "description": "Weekdays (Sun=0..Sat=6) when repeat_unit='week'."}, + "timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles')."}, }, "required": ["workflow_id"], }, @@ -237,10 +241,11 @@ def _build_schedule_from_preset(preset: str, args: dict) -> dict: **base, "enabled": True, "repeat_unit": args.get("repeat_unit", "day"), - "repeat_every": 1, + "repeat_every": int(args.get("repeat_every", 1) or 1), "hour": int(args.get("hour", 9)), "minute": int(args.get("minute", 0)), "on_days": list(args.get("on_days") or []), + "timezone": args.get("timezone") or "local", } preset_def = PRESETS.get(preset) if not preset_def: @@ -306,7 +311,7 @@ def handle_update(args: dict) -> dict: if "schedule_enabled" in args: sched_patch["enabled"] = bool(args["schedule_enabled"]) sched_dirty = True - for k in ("hour", "minute", "repeat_unit", "on_days"): + for k in ("hour", "minute", "repeat_unit", "on_days", "repeat_every", "timezone"): if k in args: sched_patch[k] = args[k] sched_dirty = True diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 9819b17f..db8b25fa 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -107,6 +107,9 @@ class Workflow(BaseModel): # (Image #38, #48). Optional so older workflows don't fail validation # on rehydrate. edit_agent_session_id: Optional[str] = None + # Sticky session id for the embedded scheduling agent (the chat that + # turns "every Wednesday at 1pm" into a permission-gated tool call). + schedule_agent_session_id: Optional[str] = None class WorkflowRun(BaseModel): diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 610e9ca4..b87eef12 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -699,82 +699,68 @@ async def test_run_workflow(workflow_id: str, body: dict): return {"session_id": session.id} -@workflows.router.post("/{workflow_id}/parse-schedule") -async def parse_schedule(workflow_id: str, body: dict): - """Aux-LLM-parse natural language into a ScheduleConfig. +@workflows.router.post("/{workflow_id}/schedule-agent-session") +async def schedule_agent_session(workflow_id: str): + """Create (or return existing) embedded scheduling-agent session. - 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": ...}. + The scheduling agent is a real agent session the user chats with to set + the workflow's cadence (Image #49). It interprets the user's natural + language ("every Wednesday at 1pm", "this time, this month") itself and + commits via UpdateScheduledWorkflow, which is force-gated to "ask" so the + user gives a final Approve/Deny through ApprovalBar. No deterministic + pre-parse: the cadence is a model decision. + + Singleton per workflow (same reattach contract as edit-agent-session) so + re-entering the scheduling view resumes the same conversation. """ 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 - from backend.apps.settings.credentials import 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}" + existing_id = getattr(wf, "schedule_agent_session_id", None) or None + if existing_id: + return {"session_id": existing_id} + + from backend.apps.agents.core.models import AgentConfig + from backend.apps.agents.agent_manager import agent_manager + now_local = datetime.now().astimezone() + current_dt = now_local.strftime("%A %Y-%m-%d %H:%M %Z") + system_prompt = ( + f"You are the Scheduling Agent for the user's saved workflow \"{wf.title}\" " + f"(id: {wf.id}). Your only job is to set when this workflow runs.\n\n" + f"The current local date and time is {current_dt}. Resolve relative " + "phrasing (\"this month\", \"next Wednesday\", \"this time\") against it.\n\n" + "When the user states a cadence, interpret it yourself and call " + "UpdateScheduledWorkflow with:\n" + f" - workflow_id: \"{wf.id}\"\n" + " - schedule_enabled: true\n" + " - hour (0-23) and minute (0-59) in the user's local time\n" + " - repeat_unit: \"day\" | \"week\" | \"month\"\n" + " - repeat_every: the interval count (1 unless they say e.g. \"every other\")\n" + " - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n" + " - timezone: an IANA name only if the user names a specific zone\n\n" + "If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence " + "is genuinely ambiguous, ask ONE short clarifying question first; otherwise " + "go straight to the tool call. The user approves or rejects the change in a " + "permission prompt, so the tool call IS the confirmation: do not also ask " + "\"should I schedule this?\" in text. Do not edit the workflow's steps. Keep " + "every reply to one short sentence." ) + config = AgentConfig( + name=f"Scheduling: {wf.title}", + model=wf.model or "sonnet", + mode=wf.mode or "agent", + provider=wf.provider or "anthropic", + system_prompt=system_prompt, + allowed_tools=[], + dashboard_id=wf.dashboard_id, + ) + session = await agent_manager.launch_agent(config) 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")} + setattr(wf, "schedule_agent_session_id", session.id) + storage.save_workflow(wf) + except Exception: + logger.debug("could not persist schedule_agent_session_id (legacy schema)", exc_info=True) + return {"session_id": session.id} @workflows.router.post("/{workflow_id}/run") diff --git a/frontend/src/app/pages/Workflows/SchedulingView.tsx b/frontend/src/app/pages/Workflows/SchedulingView.tsx index 92ea27c7..8a5cccd3 100644 --- a/frontend/src/app/pages/Workflows/SchedulingView.tsx +++ b/frontend/src/app/pages/Workflows/SchedulingView.tsx @@ -1,239 +1,146 @@ -// 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. +// Image #49: the scheduling chat embedded in the workflow card. +// Mirrors EditAgentView: a sticky-per-workflow agent session (via +// /workflows/{id}/schedule-agent-session) interprets the user's cadence +// ("every Wednesday at 1pm") itself and commits via UpdateScheduledWorkflow, +// which is force-gated to "ask" so the commit shows up as a real ApprovalBar +// tool card in the chat. No deterministic pre-parse: the cadence is a model +// decision. Once the schedule turns enabled, we drop back to the saved view. import React, { useCallback, useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded'; +import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded'; 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 { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; +import { fetchSession } from '@/shared/state/agentsSlice'; import { API_BASE, getAuthToken } from '@/shared/config'; -import { useAppSelector as _useAppSelector } from '@/shared/hooks'; -import ChatInput from '@/app/pages/AgentChat/ChatInput'; +import StepList from './StepList'; +import AgentChat from '@/app/pages/AgentChat/AgentChat'; interface Props { workflow: Workflow; steps: Workflow['steps']; } -function InlineSubtitle({ workflow }: { workflow: Workflow }) { - const c = useClaudeTokens(); - const modelsByProvider = _useAppSelector((s) => s.models.byProvider); - const runs = _useAppSelector((s) => s.workflows.runs[workflow.id]); - const modelLabel = React.useMemo(() => { - if (!workflow?.model) return ''; - for (const list of Object.values(modelsByProvider || {})) { - for (const m of (list as Array<{ value: string; label?: string }>) || []) { - if (m.value === workflow.model) return m.label || workflow.model; - } - } - return workflow.model; - }, [workflow?.model, modelsByProvider]); - const duration = React.useMemo(() => { - if (!runs || runs.length === 0) return ''; - const last = runs.find((r) => r.finished_at); - if (!last || !last.finished_at) return ''; - const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime(); - if (ms <= 0) return ''; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${Math.round(ms / 1000)}s`; - return `${Math.floor(ms / 60_000)}m`; - }, [runs]); - return ( - - {modelLabel && {modelLabel}} - {workflow.mode && {workflow.mode}} - {duration && {duration}} - - ); -} - export default function SchedulingView({ workflow, steps }: Props) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [pending, setPending] = useState(null); + const [stepsOpen, setStepsOpen] = useState(true); + const [scheduleSessionId, setScheduleSessionId] = useState(workflow.schedule_agent_session_id || null); + const [seedSent, setSeedSent] = useState(false); - // The composer behaves like any normal chat: it defaults to the user's - // configured default model/mode (e.g. their subscription model), not the - // workflow's stored run model, and its pickers actually work. - const defaultModel = _useAppSelector((s) => s.settings.data.default_model); - const defaultMode = _useAppSelector((s) => s.settings.data.default_mode); - const settingsLoaded = _useAppSelector((s) => s.settings.loaded); - const [chatModel, setChatModel] = useState(defaultModel || 'sonnet'); - const [chatMode, setChatMode] = useState(defaultMode || 'agent'); - const settingsApplied = useRef(false); + // Spawn (or reattach to) the sticky scheduling-agent session on mount. useEffect(() => { - if (settingsLoaded && !settingsApplied.current) { - setChatModel(defaultModel || 'sonnet'); - setChatMode(defaultMode || 'agent'); - settingsApplied.current = true; + if (scheduleSessionId) return; + let alive = true; + (async () => { + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/schedule-agent-session`, { + method: 'POST', + headers: tok ? { Authorization: `Bearer ${tok}` } : {}, + }); + if (!res.ok) return; + const data = await res.json(); + const sid = data?.session_id as string | undefined; + if (!sid || !alive) return; + try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may not be hydrated yet */ } + if (alive) setScheduleSessionId(sid); + } catch { /* best-effort */ } + })(); + return () => { alive = false; }; + }, [scheduleSessionId, workflow.id, dispatch]); + + // First-turn seed: hidden opener so the agent's first reply is the + // figma's "When should this workflow run..." question. + const scheduleSession = useAppSelector((s) => scheduleSessionId ? s.agents.sessions[scheduleSessionId] : undefined); + useEffect(() => { + if (!scheduleSessionId || !scheduleSession || seedSent) return; + const msgs = scheduleSession.messages || []; + if (msgs.length > 0) { + setSeedSent(true); + return; } - }, [settingsLoaded, defaultModel, defaultMode]); + setSeedSent(true); + (async () => { + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + 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)?"', + hidden: true, + }), + }); + } catch { /* best-effort */ } + })(); + }, [scheduleSessionId, scheduleSession, seedSent]); + + // Drop back to the saved view once a commit lands. The scheduling agent + // only PATCHes through the approved tool call, so a changed updated_at + // with the schedule now enabled means the user approved it. Comparing + // against the mount-time value avoids bouncing out on entry (e.g. when + // rescheduling a workflow that was already enabled). + const initialUpdatedAt = useRef(workflow.updated_at); + useEffect(() => { + if (workflow.schedule?.enabled && workflow.updated_at !== initialUpdatedAt.current) { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + } + }, [workflow.schedule?.enabled, workflow.updated_at, workflow.id, dispatch]); const onCancel = useCallback(() => { dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); }, [dispatch, workflow.id]); - const onSubmit = useCallback(async (text: string) => { - const cleaned = (text || '').trim(); - if (!cleaned || busy) return undefined; - 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: cleaned }), - }); - if (!res.ok) { - setError(`Couldn't parse that. Try "every Wednesday at 1pm" or "Mondays at 3pm".`); - return undefined; - } - 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 undefined; - } - setPending(cfg); - return cfg; - } catch (e) { - setError((e as Error)?.message || 'Network error.'); - return undefined; - } finally { - setBusy(false); - } - }, [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. Image #49: subtitle on LEFT, Cancel - on RIGHT. Cancel matches the subtitle's weight/size/color so the - row reads as peers, not a heavy CTA; it just reddens on hover. */} - - - - - - Cancel task scheduling - - - {/* Steps in a soft bubble so they read as a quoted "here's the workflow" - block inside the chat, not loose body text. */} - - - - {/* Prompt in its own bubble, like an agent message asking for the cadence. */} - - - When should this workflow run (e.g. every Wednesday at 1pm) - - - {error && ( - {error} - )} - {/* Spacer docks the composer (and the inline confirm) to the bottom of - the card so it reads like a chat: prompt up top, input below. */} - - {/* Inline confirm (the HITL "always ask before scheduling" gate). Lives - in the card right above the composer instead of a screen-dimming - modal, so it feels native to the chat. */} - {pending && ( - - - Set {workflow.title} to run {describe(pending)}? - - - setPending(null)} - role="button" - sx={{ fontSize: '0.84rem', color: c.text.secondary, px: 1, py: 0.5, cursor: 'pointer', '&:hover': { color: c.text.primary } }}> - Cancel - - - {busy ? 'Applying…' : 'Schedule it'} - + + {/* Collapsible "here's the workflow" strip peeks at the read-only steps + without leaving the chat; Cancel drops back to the saved card. */} + + + setStepsOpen((x) => !x)} + role="button" + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.25, cursor: 'pointer', + fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary, + '&:hover': { color: c.text.primary }, + }}> + + Workflow ({steps.length} step{steps.length === 1 ? '' : 's'}) + + + + + Cancel task scheduling - )} - {/* Real ChatInput (same one the toolbar / agent chat use) so the - composer matches Image #54 / #64 exactly: live model picker, - mode picker, thinking level, paperclip + mic, the works. We - ignore everything except the message text on send and route it - through /parse-schedule. sessionId is a stable per-workflow id - so ChatInput's draft persistence survives view re-mounts. */} - - { void onSubmit(msg); }} - mode={chatMode} - onModeChange={setChatMode} - model={chatModel} - onModelChange={setChatModel} - embedded - autoFocus - sessionId={`schedule-${workflow.id}`} - disabled={busy} - /> + {stepsOpen && ( + + + + )} + + {/* The card IS the chat. Negative margins cancel the card body's p:2 so + the thread (and the ApprovalBar tool card) runs edge-to-edge. */} + + {scheduleSessionId ? ( + + ) : ( + + Starting... + + )} ); } - -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/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index e1ad0346..a5da4c5f 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -79,6 +79,8 @@ export interface Workflow { cost_estimate?: CostEstimate; /** Sticky session id for the Edit Agent embedded in the workflow card. */ edit_agent_session_id?: string | null; + /** Sticky session id for the embedded scheduling agent (cadence -> gated tool call). */ + schedule_agent_session_id?: string | null; } export interface WorkflowRun {