mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[aidan] fix: schedule time in chat
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
|
||||
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
|
||||
{workflow.mode && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{workflow.mode}</Box>}
|
||||
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchedulingView({ workflow, steps }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<ScheduleConfig | null>(null);
|
||||
const [stepsOpen, setStepsOpen] = useState(true);
|
||||
const [scheduleSessionId, setScheduleSessionId] = useState<string | null>(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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, flex: '1 0 auto' }}>
|
||||
{/* 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. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<InlineSubtitle workflow={workflow} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onCancel}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.82rem', fontWeight: 500,
|
||||
color: c.text.muted, cursor: 'pointer',
|
||||
'&:hover': { color: c.status.error },
|
||||
}}>
|
||||
<DeleteOutlineRounded sx={{ fontSize: 15 }} />
|
||||
Cancel task scheduling
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Steps in a soft bubble so they read as a quoted "here's the workflow"
|
||||
block inside the chat, not loose body text. */}
|
||||
<Box sx={{ p: 1.5, borderRadius: `${c.radius.lg}px`, bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}` }}>
|
||||
<StepList steps={steps} />
|
||||
</Box>
|
||||
{/* Prompt in its own bubble, like an agent message asking for the cadence. */}
|
||||
<Box sx={{ p: 1.5, borderRadius: `${c.radius.lg}px`, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}` }}>
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.45 }}>
|
||||
When should this workflow run (e.g. every Wednesday at 1pm)
|
||||
</Typography>
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.status.error }}>{error}</Typography>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{/* 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 && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap',
|
||||
p: 1.25, mb: 1, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.bg.elevated, border: `1px solid ${c.accent.primary}55`,
|
||||
}}>
|
||||
<Typography sx={{ flex: 1, minWidth: 180, fontSize: '0.86rem', color: c.text.secondary, lineHeight: 1.45 }}>
|
||||
Set <b>{workflow.title}</b> to run <b>{describe(pending)}</b>?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
onClick={() => 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
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onConfirm}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.84rem', fontWeight: 700,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.4, py: 0.5, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
{busy ? 'Applying…' : 'Schedule it'}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{/* Collapsible "here's the workflow" strip peeks at the read-only steps
|
||||
without leaving the chat; Cancel drops back to the saved card. */}
|
||||
<Box sx={{ flexShrink: 0, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<Box
|
||||
onClick={() => 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 },
|
||||
}}>
|
||||
<KeyboardArrowDownRounded sx={{ fontSize: 16, transform: stepsOpen ? 'none' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
|
||||
Workflow ({steps.length} step{steps.length === 1 ? '' : 's'})
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onCancel}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer',
|
||||
'&:hover': { color: c.status.error },
|
||||
}}>
|
||||
<DeleteOutlineRounded sx={{ fontSize: 15 }} />
|
||||
Cancel task scheduling
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<Box sx={{ mx: -0.5 }}>
|
||||
<ChatInput
|
||||
onSend={(msg) => { void onSubmit(msg); }}
|
||||
mode={chatMode}
|
||||
onModeChange={setChatMode}
|
||||
model={chatModel}
|
||||
onModelChange={setChatModel}
|
||||
embedded
|
||||
autoFocus
|
||||
sessionId={`schedule-${workflow.id}`}
|
||||
disabled={busy}
|
||||
/>
|
||||
{stepsOpen && (
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
<StepList steps={steps} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* 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. */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', mx: -2, mb: -2 }}>
|
||||
{scheduleSessionId ? (
|
||||
<AgentChat sessionId={scheduleSessionId} embedded autoFocus />
|
||||
) : (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.muted, fontSize: '0.85rem' }}>
|
||||
Starting...
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user